從 Day 2~4 加入工具,Day 5~7 獨立 agent 和 provider,Day 8~9 system prompt 部分,不知道各位有沒有發現一件很尷尬的事,我們把各個功能都模塊化的放在各個地方,但主程序在哪裡呢?從 Day 1 之後,程序就無法正常的運行起來了。
而接下來五篇,我們要來逐步解決這個問題,前三篇先搞定好負責渲染的 CLI 介面,第四篇搞定 ollama 進程管理,第五篇正式把 if __name__ == "__main__": 接入!
Command-Line Interface,俗稱命令行介面,是一種透過純文字輸入指令來和終端畫面互動的介面,相較於對使用者較友善的 GUI(圖形化介面)來說,更易於製作,所以本專案使用的是 CLI 的方案。
而在這裡,先來一次完整說明整個專案的 CLI 介面的編排邏輯,方便各位後續閱讀程式碼以及解釋。
目前分成了主程序、輸入捕捉、渲染以及進程管理四個部分:
src/meowgent/
└── cli/
├── __init__.py
├── main.py # 進入點
├── input_prompt.py # 專門負責 prompt_toolkit 的輸入框
├── renderers.py # 專門負責 rich 的畫面渲染
└── ollama_manager.py # 管理 ollama 的進程開關
先講一下每個檔案要做的事情:main.py:
1. 初始化 Agent 物件。
2. 呼叫 renderers.py 做畫面初始化。
3. 啟動 ollama 進程。
4. 呼叫 input_prompt.py 獲取輸入。
5. 開啟迴圈(讓使用者不斷問問題)。
6. 呼叫 renderers.py 渲染對話。
input_prompt.py:
1. 取得用戶輸入(包含用戶輸入的渲染)。
2. 紀錄輸入歷史(按方向鍵上可追溯紀錄)。
3. 工具調用的審核和渲染。
這裡雖然也包括了渲染選項的部分,但由於渲染同時可以獲得輸入,所以歸類在「輸入」。
renderers.py:
1. 分隔線渲染。
2. 介面初始化渲染。
3. 單行流式渲染(推理和工具參數句)。
4. 推理總結渲染。
5. 工具調用結果渲染。
6. 模型回覆渲染。
ollama_manager.py:進程的啟動和關閉
__init__.py我們在這裡先把未來用到的都先做匯入(未來可能還會加入新東西,然後我可能會忘了提到要補充,所以各位自己再檢查一下啦,哈哈):# src/meowgent/cli/__init__.py from .renderers import CLIRenderer from .input_prompt import get_input, get_tool_approval, select_directory from .ollama_manager import start_ollama, clean_ollama
事不宜遲,我們先來完成輸入的部分吧!也就是 input_prompt.py。
在外部建立 _prompt_session 是因為整個程序必須全程使用同一個 PromptSession 物件,才能保存歷史輸入,
函數內部 PromptSession 物件只在外部無 _prompt_session 時才建立。style 的部分做的是色彩樣式的管理,語法是這樣的:
style = Style.from_dict({
"選擇器 Key": "樣式屬性 Value"
})
這裡,我們用的是空字串,表示為「全域」,也就是整個 PromptSession 物件相關的都會受影響。
接著還需要用 .prompt() 才能讓用戶輸入內容
# src/meowgent/cli/input_prompt.py
from prompt_toolkit import PromptSession
from prompt_toolkit.styles import Style
from typing import Optional
_prompt_session: Optional[PromptSession] = None
def get_input() -> str:
global _prompt_session
_prompt_session = PromptSession(
style=Style.from_dict({
"": "ansiblue" # 藍色
})
) if _prompt_session is None else _prompt_session # 歷史輸入管理,如果已經建立過,不再建立
return _prompt_session.prompt("> ")
未來,我們在 main.py 呼叫 get_input() 時就能獲得以下結果了:
> 幫我在下載資料夾內找一下所有的 PDF 檔案
▲ ▲
│ └──── 使用者打字:【ANSI 藍色字體】
└──────────────────── 提示箭頭:【ANSI 藍色字體】
且按下方向鍵上可以查看剛剛輸入的內容。
我們現在來建立「工具審核的選項」,如以下兩種樣式,使用者只要按下鍵盤的 y 或 n(不分大小寫)就可進行審核:
? [工具調用]: list_file
• path: /Users/matthew/Downloads
• pattern: *.xlsx
是否允許執行? (Y/n)
? [工具調用]: write_file
• file_path: /Users/matthew/hello.py
• content:
import os
import sys
def main():
print("Hello Meowgent!")
是否允許執行? (Y/n)
如果只有一行參數就不進行換行直接顯示 • 參數名: 參數,
而如果是像程式碼,或者是撰寫文章,就需要換到下一行,並且做縮排的處理。
這裡,我們對齊輸入時的樣式(
>)從 2 個字元後開始輸出文字。
主要是要對參數的部分做處理,看到上面的範例中,其實模型輸出的參數是長 import os \n import sys ... 這樣的,要對每個換行做縮排 4 格的處理,才能對齊格式。
用 for 取出參數字典的「參數名」跟「參數」,先檢查是否有換行(沒有就不需進行縮排的處理),用 splitlines() 把每行切成串列形式,再用 .join() 把換行放回去並加上縮排拼接回去,注意,第一行處要另外加入換行跟縮排在 append() 到 lines 時。
做完了每一個參數的處理,把 lines 拼起來,中間加上換行,統合成 args。
# src/meowgent/cli/input_prompt.py
from ...
import questionary
...
def get_tool_approval(tool_name: str, tool_args: dict) -> bool:
lines = []
for k, v in tool_args.items():
v_str = str(v) # 防止模型如果傳入數字等非字串
if "\n" in v_str:
# 多行內容:每一行皆整齊縮排 4 格
indented_v = "\n ".join(v_str.splitlines())
lines.append(f" • {k}:\n {indented_v}")
else:
lines.append(f" • {k}: {v_str}")
args = "\n".join(lines) if lines else " (無參數)"
最後,用 questionary.confirm 渲染出選單:
要記得 questionary 的選擇後面都要加上
.ask()。
# src/meowgent/cli/input_prompt.py
...
def get_tool_approval(...) -> ...:
...
return questionary.confirm(
f"[工具調用]: {tool_name}\n{args}\n\n 是否允許執行?",
default=True,
style=Style([
("question", "dim"),
("instruction", "dim")
])
).ask()
這邊又出現了
Style()的色彩設定,沒錯,它跟剛才PromptSession用的是同一個Style()。
從匯入的地方from prompt_toolkit.styles import Style就可以明顯地看出 questionary 使用了 prompt_toolkit 的 Style 物件。
"question"是? [工具調用]: list_file最前面的那個問號;"instruction"則是(Y/n)提示。
而"dim"會把字體變暗(降低亮度),通常會呈現出「灰色」的效果。
如此一來,input_prompt.py 的部分就完成啦!下一篇,來處理主要的渲染部分 - renderers.py。