Skip to content

Composants de rendu des messages

源码版本v0.73.1

MessageList, StreamingMessageContainer et Messages.ts sont trois composants qui, ensemble, rendent Agent.state.messages en une conversation visible. MessageList est la liste stable (messages terminés), StreamingMessageContainer est le conteneur de streaming (message en cours de génération) ; leur séparation évite que toute la liste ne se re-render pendant le streaming. Messages.ts définit les sous-éléments user-message, assistant-message, tool-message, aborted-message, et gère le rendu détaillé des blocs de contenu.

Responsabilités

  1. Liste stable : MessageList.buildRenderItems parcourt messages, saute le rôle artifact, tente d'abord un renderMessage personnalisé, puis retombe sur user-message/assistant-message, en utilisant la directive repeat pour réutiliser le DOM par clé, voir packages/web-ui/src/components/MessageList.ts:27-81.
  2. Conteneur de streaming : StreamingMessageContainer.setMessage utilise requestAnimationFrame pour fusionner les mises à jour par lot ; pendant le streaming, on ne re-render pas à chaque token, voir packages/web-ui/src/components/StreamingMessageContainer.ts:28-61.
  3. Clone profond pour éviter la comparaison par référence : JSON.parse(JSON.stringify(this._pendingMessage)) clone profondément le message à rendre, afin que Lit détecte les changements des propriétés imbriquées (par ex. toolCall.arguments), voir packages/web-ui/src/components/StreamingMessageContainer.ts:50-54.
  4. Rendu par blocs du message assistant : AssistantMessage.render parcourt le tableau message.content dans l'ordre et rend les blocs text/thinking/toolCall, voir packages/web-ui/src/components/Messages.ts:104-167.
  5. Appariement des tool calls : MessageList construit d'abord une Map à partir des rôles toolResult indexée par toolCallId ; AssistantMessage retrouve le résultat via la prop toolResultsById et rend <tool-message> en ligne, voir packages/web-ui/src/components/Messages.ts:115-136.
  6. Conversion des messages avec pièces jointes : defaultConvertToLlm transforme user-with-attachments en user message standard — les images deviennent ImageContent, les documents TextContent, les messages d'artifact sont filtrés et ne sont pas envoyés au LLM, voir packages/web-ui/src/components/Messages.ts:348-383.

Motivations de design

Pourquoi séparer en liste stable et conteneur de streaming ? Parce que pendant le streaming, le tableau message.content accumule des tokens en continu ; si toute la MessageList fait requestUpdate à chaque fois, chaque passe relance buildRenderItems et reparcourt tout l'historique — plus la conversation s'allonge, plus ça rame. Une fois séparés, les messages terminés entrent dans MessageList qui ne change plus, et seul le message en cours de génération entre dans StreamingMessageContainer, qui fusionne les updates via requestAnimationFrame : au plus un rendu par frame.

Pourquoi utiliser JSON.parse(JSON.stringify(...)), une opération lente ? Parce que la détection de changement de Lit est une comparaison de référence superficielle, alors que Agent mute directement le même objet AssistantMessage pendant le streaming (le tableau content, et pareil pour toolCall.arguments sur place). Sans clonage, Lit ne voit pas de changement de référence et l'UI ne se met pas à jour. Le clone profond est coûteux mais ne porte que sur un seul message par frame — un compromis acceptable.

Pourquoi les tool results ne sont-ils pas rendus comme des messages indépendants ? Parce que le message assistant renvoyé par le LLM contient un toolCall, immédiatement suivi du toolResult correspondant. Les rendre séparés couperait le contexte ; AssistantMessage utilise une Map toolResultsById pour placer le result juste à côté du toolCall, visuellement comme un groupe. C'est pour ça que MessageList saute explicitement les toolResult standalone, voir packages/web-ui/src/components/MessageList.ts:75-79.

Fichiers clés

La fusion par lot dans setMessage est le point clé de la performance du rendu 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 parcourt le tableau content dans l'ordre ; le toolCall trouve son résultat apparié via 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>`,
        );
    }
}

Flux de données

Une fois AgentInterface abonnée aux événements, elle les dispatche vers les deux composants :

Limites et cas d'échec

Pour résumer

MessageList et StreamingMessageContainer se répartissent le travail : la liste stable ne change pas, le conteneur de streaming fusionne les mises à jour via requestAnimationFrame ; AssistantMessage rend par blocs selon content et apparie les toolCall via toolResultsById pour les afficher en ligne. Pour les détails du rendu des appels d'outils, voir Registre des renderers d'outils ; pour la source des événements, voir AgentInterface, hôte de session.