Skip to content

メッセージ描画コンポーネント

源码版本v0.73.1

MessageListStreamingMessageContainerMessages.ts の 3 コンポーネントが協同して Agent.state.messages を目に見える会話に描画する。MessageList は安定リスト(完了済みメッセージ)、StreamingMessageContainer はストリーミングコンテナ(現在生成中のメッセージ)。この二つを分ける理由は、ストリーミング中にリスト全体が再描画されるのを避けるためだ。Messages.tsuser-messageassistant-messagetool-messageaborted-message などの子要素を定義し、具体的な内容ブロックの描画を処理する。

責務

  1. 安定リスト:MessageList.buildRenderItemsmessages を走査し、artifact role を飛ばす。まず renderMessage でカスタム描画を試し、駄目なら user-message/assistant-message にフォールバックする。repeat directive で key ごとに DOM を再利用する。packages/web-ui/src/components/MessageList.ts:27-81 参照。
  2. ストリーミングコンテナ:StreamingMessageContainer.setMessagerequestAnimationFrame で更新をバッチ化し、トークンごとに再描画が走らないようにする。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 の 3 種のブロックを描画する。packages/web-ui/src/components/Messages.ts:104-167 参照。
  5. ツール呼び出しのペアリング:MessageList は先に toolResult role を toolCallId で Map にまとめる。AssistantMessagetoolResultsById 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 配列にトークンが逐次追加され、MessageList 全体が requestUpdate で追随すると、更新のたびに buildRenderItems が全履歴を走査し直して、会話が長くなるほど重くなる。分ければ、完了済みメッセージは MessageList に入れて動かず、現在生成中のメッセージだけが StreamingMessageContainer に入る。後者は requestAnimationFrame で更新をバッチ化し、1 フレームにつき最大 1 回しか描画しない。

なぜ JSON.parse(JSON.stringify(...)) のような遅い操作を使うのか。Lit のプロパティ変更検出は参照の浅比較で、Agent はストリーミング中に同じ AssistantMessage オブジェクトの content 配列を直接 mutate する(toolCall.arguments もその場で書き換える)。コピーしないと Lit は参照変化を見逃し、UI が更新されない。深コピーは遅いが、1 フレームにつき 1 メッセージだけ処理するので許容範囲に収まる。

なぜ tool result を単独メッセージとして描画しないのか。LLM が返す assistant メッセージに toolCall があり、直後の toolResult はそのペアだからだ。分けて描画すると文脈が切れる。AssistantMessagetoolResultsById Map で result を toolCall のすぐ横にインライン化し、視覚的に一組に見せる。MessageList が standalone な toolResult role を明示的に skip するのはこのためだ。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 がイベントをサブスクライブし、2 つのコンポーネントに振り分ける:

境界と失敗

まとめ

MessageListStreamingMessageContainer で役割を分ける。安定リストは動かず、ストリーミングコンテナは requestAnimationFrame でバッチ化する。AssistantMessage は content の順にブロック描画し、toolCall は toolResultsById でペアを見てインライン化する。ツール呼び出しの描画詳細は ツールレンダラーレジストリ、イベントの源は AgentInterface セッションホスト を参照。