iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0

回顧一下負責渲染的 renderers.py 要負責些什麼:

  1. 分隔線渲染。
  2. 介面初始化渲染。
  3. 單行流式渲染(推理和工具參數句)。
  4. 推理總結渲染。
  5. 工具調用結果渲染。
  6. 模型回覆渲染。
    而主要依靠的是 Console 以及 Live 兩種輸出方式,前者用在固定不動的輸出;後者則是動態的內容(如流式的文字)。
    事不宜遲,馬上開始 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))   

目前,我們已經把除了比較複雜的「模型回答」以外的渲染都完成了,而接下來下一篇,要來做的正是它!


上一篇
Day 10 - 漂亮的 CLI 介面 - 輸入
下一篇
Day 12 - 漂亮的 CLI 介面 - 渲染 - 下
系列文
手刻 AI Agent!大一新生的 Python 實戰筆記12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言