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 会话宿主