Skip to content

訊息渲染元件

源码版本v0.73.1

MessageListStreamingMessageContainerMessages.ts 三個元件合起來負責把 Agent.state.messages 渲染成可見的對話。MessageList 是穩定列表(已完成的訊息),StreamingMessageContainer 是串流容器(當前正在產生的訊息),兩者分工是為了避免串流期間整個列表重渲染。Messages.ts 定義 user-messageassistant-messagetool-messageaborted-message 等子元素,處理具體的內容塊渲染。

職責

  1. 穩定列表:MessageList.buildRenderItems 遍歷 messages,跳過 artifact 角色,先嘗試 renderMessage 自訂渲染,再回退到 user-message/assistant-message,用 repeat directive 按 key 複用 DOM,見 packages/web-ui/src/components/MessageList.ts:27-81
  2. 串流容器:StreamingMessageContainer.setMessagerequestAnimationFrame 批次合併更新,串流期間不會每 token 都重渲染,見 packages/web-ui/src/components/StreamingMessageContainer.ts:28-61
  3. 深層複製避免引用比較:JSON.parse(JSON.stringify(this._pendingMessage)) 把待渲染的訊息深拷貝,讓 Lit 偵測到巢狀屬性(如 toolCall.arguments)的變更,見 packages/web-ui/src/components/StreamingMessageContainer.ts:50-54
  4. assistant 訊息分塊渲染:AssistantMessage.rendermessage.content 陣列順序渲染 text/thinking/toolCall 三類塊,見 packages/web-ui/src/components/Messages.ts:104-167
  5. 工具呼叫配對:MessageList 先把 toolResult 角色按 toolCallId 建 Map,AssistantMessage 透過 toolResultsById prop 查到結果,內聯渲染 <tool-message>,見 packages/web-ui/src/components/Messages.ts:115-136
  6. 附件訊息轉換:defaultConvertToLlmuser-with-attachments 轉成標準 user message,圖片變 ImageContent、文件變 TextContent,artifact 訊息被過濾掉不發給 LLM,見 packages/web-ui/src/components/Messages.ts:348-383

設計動機

為什麼分穩定列表和串流容器兩個元件?因為串流期間 message.content 陣列不斷追加新 token,如果整個 MessageList 都跟著 requestUpdate,每次更新都要重新 buildRenderItems 遍歷全部歷史訊息,聊天越長越卡。拆開後,已完成訊息進 MessageList 不再變,只有當前正在產生的訊息進 StreamingMessageContainer,後者用 requestAnimationFrame 批次合併 update,每幀最多渲染一次。

為什麼用 JSON.parse(JSON.stringify(...)) 這種慢操作?因為 Lit 的屬性變更偵測是淺比較引用,而 Agent 在串流時是直接 mutate 同一個 AssistantMessage 物件的 content 陣列(toolCall.arguments 也是原地改)。如果不複製,Lit 看不到引用變化,UI 不更新。深拷貝雖慢但每幀只處理一條訊息,代價可接受。

為什麼 tool result 不直接渲染成獨立訊息?因為 LLM 回傳的 assistant 訊息裡有 toolCall,緊接著的 toolResult 是配對的。如果分開渲染會斷開上下文,AssistantMessagetoolResultsById Map 把 result 內聯到 toolCall 旁邊,視覺上是一組。MessageList 顯式 skip standalone toolResult 角色就是這個原因,見 packages/web-ui/src/components/MessageList.ts:75-79

關鍵檔案

setMessage 的批次合併是串流渲染的效能關鍵:

typescript
// packages/web-ui/src/components/StreamingMessageContainer.ts:44-60
if (!this._updateScheduled) {
    this._updateScheduled = true;

    requestAnimationFrame(async () => {
        if (!this._immediateUpdate && this._pendingMessage !== null) {
            this._message = JSON.parse(JSON.stringify(this._pendingMessage));
            this.requestUpdate();
        }
        this._pendingMessage = null;
        this._updateScheduled = false;
        this._immediateUpdate = false;
    });
}

AssistantMessage.render 按 content 陣列順序渲染,toolCall 透過 toolResultsById 找配對結果:

typescript
// packages/web-ui/src/components/Messages.ts:115-136
} else if (chunk.type === "toolCall") {
    if (!this.hideToolCalls) {
        const tool = this.tools?.find((t) => t.name === chunk.name);
        const pending = this.pendingToolCalls?.has(chunk.id) ?? false;
        const result = this.toolResultsById?.get(chunk.id);
        if (this.hidePendingToolCalls && pending && !result) {
            continue;
        }
        const aborted = this.message.stopReason === "aborted" && !result;
        orderedParts.push(
            html`<tool-message
                .tool=${tool}
                .toolCall=${chunk}
                .result=${result}
                .pending=${pending}
                .aborted=${aborted}
                .isStreaming=${this.isStreaming}
            ></tool-message>`,
        );
    }
}

資料流

AgentInterface 訂閱事件後把訊息分流到兩個元件:

邊界與失敗

小結

MessageListStreamingMessageContainer 分工:穩定列表不變,串流容器用 requestAnimationFrame 批次合併;AssistantMessage 按 content 順序分塊渲染,toolCall 用 toolResultsById 配對內聯。工具呼叫的具體渲染細節看 工具渲染器註冊表,事件來源看 AgentInterface 會話宿主