Skip to content

Nachrichten-Render-Komponenten

源码版本v0.73.1

Drei Komponenten — MessageList, StreamingMessageContainer und Messages.ts — sind gemeinsam dafür zuständig, Agent.state.messages als sichtbaren Chat zu rendern. MessageList ist die stabile Liste (abgeschlossene Nachrichten), StreamingMessageContainer der Streaming-Container (die aktuell erzeugte Nachricht). Die Trennung verhindert, dass während des Streamings die gesamte Liste neu rendert. Messages.ts definiert die Sub-Elemente user-message, assistant-message, tool-message, aborted-message und übernimmt das Rendering der konkreten Inhaltsblöcke.

Zuständigkeiten

  1. Stabile Liste: MessageList.buildRenderItems iteriert über messages, überspringt die Rolle artifact, versucht zuerst renderMessage als Custom-Rendering und fällt auf user-message/assistant-message zurück; die repeat-Directive recycled DOM nach key. Siehe packages/web-ui/src/components/MessageList.ts:27-81.
  2. Streaming-Container: StreamingMessageContainer.setMessage fasst Updates mit requestAnimationFrame zusammen, damit während des Streamings nicht pro Token neu gerendert wird. Siehe packages/web-ui/src/components/StreamingMessageContainer.ts:28-61.
  3. Tiefe Kopie gegen Referenzvergleich: JSON.parse(JSON.stringify(this._pendingMessage)) kopiert die zu rendernde Nachricht tief, damit Lit Änderungen an verschachtelten Properties (etwa toolCall.arguments) erkennt. Siehe packages/web-ui/src/components/StreamingMessageContainer.ts:50-54.
  4. Assistant-Block-Rendering: AssistantMessage.render geht das Array message.content in Reihenfolge durch und rendert text/thinking/toolCall-Blöcke. Siehe packages/web-ui/src/components/Messages.ts:104-167.
  5. Tool-Aufrufe paaren: MessageList baut zuerst eine Map der toolResult-Rolle nach toolCallId; AssistantMessage holt über das Prop toolResultsById das Resultat und rendert <tool-message> inline. Siehe packages/web-ui/src/components/Messages.ts:115-136.
  6. Anhang-Nachrichten wandeln: defaultConvertToLlm wandelt user-with-attachments in eine Standard-user-Nachricht um; Bilder werden zu ImageContent, Dokumente zu TextContent; artifact-Nachrichten werden herausgefiltert und nicht an den LLM geschickt. Siehe packages/web-ui/src/components/Messages.ts:348-383.

Designmotivation

Warum trennt man in stabile Liste und Streaming-Container? Weil während des Streamings laufend neue Token an message.content angehängt werden. Würde die gesamte MessageList mit requestUpdate mitlaufen, müsste bei jedem Update buildRenderItems die gesamte Historie neu durchlaufen — je länger der Chat, desto ruckeliger. Nach der Trennung kommen fertiggestellte Nachrichten in MessageList und ändern sich nicht mehr; nur die gerade erzeugte liegt in StreamingMessageContainer, der Updates mit requestAnimationFrame bündelt und pro Frame maximal einmal rendert.

Warum die langsame Operation JSON.parse(JSON.stringify(...))? Weil Lit Änderungen an Properties nur per Referenzvergleich erkennt und Agent während des Streamings dasselbe AssistantMessage-Objekt direkt mutiert (die content-Array und toolCall.arguments werden in-place geändert). Ohne Kopie sieht Lit keine Referenzänderung und die UI bleibt stehen. Die tiefe Kopie ist zwar langsam, pro Frame fällt aber nur eine Nachricht an — der Preis ist akzeptabel.

Warum wird ein tool result nicht als eigene Nachricht gerendert? Weil die assistant-Nachricht des LLM einen toolCall enthält, auf den direkt der passende toolResult folgt. Würde man sie getrennt rendern, risse der Kontext auseinander; AssistantMessage nutzt die toolResultsById-Map und legt das Resultat direkt neben den toolCall, visuell sind sie eine Gruppe. Genau deswegen überspringt MessageList standalone toolResult-Rollen. Siehe packages/web-ui/src/components/MessageList.ts:75-79.

Wichtige Dateien

Das Bündeln in setMessage ist der Performance-Schlüssel beim Streaming:

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 geht das content-Array in Reihenfolge durch; toolCall holt über toolResultsById das passende Resultat:

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>`,
        );
    }
}

Datenfluss

Nachdem AgentInterface Events abonniert hat, verteilt es die Nachrichten auf die beiden Komponenten:

Randbedingungen und Fehler

Zusammenfassung

MessageList und StreamingMessageContainer teilen sich die Arbeit: Die stabile Liste bleibt unverändert, der Streaming-Container bündelt Updates mit requestAnimationFrame; AssistantMessage rendert das content-Array blockweise in Reihenfolge und paart toolCall über toolResultsById inline. Die Render-Details für Tools stehen in Tool-Renderer-Registry, die Herkunft der Events in AgentInterface Session-Host.