回顧一下負責渲染的 renderers.py 要負責些什麼:
renderers.py 的實作吧!我們把這些功能一一打包在 CLIRenderer 類別裡做呼叫:
# src/meowgent/cli/renderers.py
from rich.console import Console, Group
from rich.markdown import Markdown
from rich.text import Text
from rich.rule import Rule
from rich.padding import Padding
from rich.cells import cell_len
class CLIRenderer:
def __init__(self):
self.console = Console()
要做成一個類別最主要的原因就是這個 Console(),在建立 CLIRenderer 物件時直接建立 Console 物件,這樣在 main.py 使用時就不需另外在每個渲染函數調用時分別做傳入。
在每一則對話,都用分隔線做出區分,提升可讀性。
# src/meowgent/cli/renderers.py
...
class CLIRenderer:
...
def get_rule(self) -> Rule:
return Rule(style="dim")
開啟程式後,呼叫此函數達成以下效果:
Meowgent CLI 1.0 -> 藍色
matthew -> 灰色
─────────────────────────────────
# src/meowgent/cli/renderers.py
...
class CLIRenderer:
...
def initialization(self):
version = 1.0
user_name = "matthew"
self.console.print(
f"[blue] Meowgent CLI {version}[/blue]\n[dim] {user_name}[/dim]\n"
)
self.console.print(self.get_rule())
這裡因為我們區分了兩種不同的顏色,不能像剛才
get_rule()用style()的方法做「全域」的修改,所以採用[顏色] [/顏色]這樣的標籤。
這裡說的單行流式是用在「推理文字」及「工具調用參數」上的。
而之所以要這麼麻煩的轉換到單行的方式,而不是直接做輸出,是因為要解決滾動終端頁面時會發生的衝突。
先理解一下這裡我們 Live 運作的原理,它並不是只在後面加字,而是每生成一個字,就把游標移回開頭、擦掉整篇,再重新印一份更新後的全文,
但是這個游標只能在終端顯示的範圍中運作,
當回答變得很長、超出螢幕高度時,最上面的文字會被向上推擠捲出螢幕,這時程式想把整篇擦掉重寫,但游標撞到螢幕天花板卡住了,擦不到已經捲上去的舊文字,Live 就又在當前螢幕印了一次完整內容,
也就導致了「每吐一個字,螢幕就往下推一點,上面就多留下一份擦不掉的舊文字」。
有注意到嗎,剛才提到了轉換,沒錯,這裡我們還需要寫「把換行換成空格」的邏輯。
接下來還要做文字的裁切,每次只保留最新的「終端的格數減 2 格」,讓字不會出現在下一行,
對 Console 物件 .width 可以獲得格數,而減二的部分是因為要對齊的關係。
這裡為什麼**用「格」而不是「字」**呢?
這是因為,不同語言在終端佔用的格數都是不同的,例如中文一個字元會佔用兩格。
# src/meowgent/cli/renderers.py
...
class CLIRenderer:
...
def render_single_line_streamer(self, content: str) -> Group:
content = content.replace("\n", " ") # 換行替換為空格
console_width = self.console.width - 2 # 扣除 2 的縮排
這邊來做裁切的部分:
先判斷傳入的內容是否已經超出可渲染格數了,這裡要用 Rich 的 cell_len() 而不是原生的 len() 計算的才是格數而不是字元。
而接下來,我們從字串最後面一個字一個字的拿出來(用 reversed()),放入 output 串列中,直到 occupy_width 超過了可渲染格數為止,
塞滿後把 output 裡順序再用 reversed() 轉回來,才能用 join() 拼起。
最後我們回傳用 Group() 打包輸出以及分隔線,輸出的部分先是用 Markdown() 來正確渲染 Markdown 語法,且做調色,然後是用 Padding() 包起來做縮排
Padding(..., (0, 0, 0, 2))就是縮排的設定,四個數字分別對應到 - 上、右、下、左。
# src/meowgent/cli/renderers.py
...
class CLIRenderer:
...
def render_single_line_streamer(...) -> ...:
...
if cell_len(content) > console_width:
# 塞滿了做切片,因為中英佔的格數不同,不能直接切
output = []
occupy_width = 0
for char in reversed(content): # 從最後面開始取
char_width = cell_len(char)
if char_width + occupy_width > console_width:
break
output.append(char)
occupy_width += char_width
content = "".join(reversed(output)) # 反轉回來拼成字串
return Group(
Padding(Markdown(content, style="dim"), (0, 0, 0, 2)),
self.get_rule()
)
傳入秒數,回傳出如以下效果的推理總結:
已思考 ... 秒 -> 灰色
# src/meowgent/cli/renderers.py
...
class CLIRenderer:
...
def render_thinking_summary(self, think_time: float) -> Padding: # 推理總結渲染
return Padding(
Text.from_markup(f"[dim]已思考 {think_time} 秒[/dim]"),
(0, 0, 0, 2)
)
這邊有一點可以提一下,剛剛推理跟工具輸出的部分不是要加分隔線嗎?這邊怎麼不加了?
這是因為,推理總結非常快速的就被輸出了,不同於推理跟工具輸出需要一個個 chunk 慢慢輸出,也就是說在推理總結的部分很快就可以結束,我們分隔線直接交給下一個部分輸出(例如工具參數輸出或模型的回答),省了一個畫出分隔線後馬上就擦掉的麻煩。而下面的「工具調用結果」也同樣這麼做!
根據傳入的 approval 來判斷要渲染以下哪種:
... 已被調用
▲ ▲
| └─────── 綠色
└──────────── 灰色
... 未被調用
▲ ▲
| └─────── 紅色
└──────────── 灰色
# src/meowgent/cli/renderers.py
...
class CLIRenderer:
...
def render_tool_approval_result(
self,
tool_name: str,
approval: bool
) -> Padding: # 工具調用結果渲染
text_chunk = "[green]已被調用[/green]" if approval else "[red]未被調用[/red]"
return Padding(Text.from_markup(f"[dim]{tool_name}[/dim] {text_chunk}"), (0, 0, 0, 2))
目前,我們已經把除了比較複雜的「模型回答」以外的渲染都完成了,而接下來下一篇,要來做的正是它!