Composants de rendu des messages
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
- Liste stable :
MessageList.buildRenderItemsparcourtmessages, saute le rôleartifact, tente d'abord unrenderMessagepersonnalisé, puis retombe suruser-message/assistant-message, en utilisant la directiverepeatpour réutiliser le DOM par clé, voirpackages/web-ui/src/components/MessageList.ts:27-81. - Conteneur de streaming :
StreamingMessageContainer.setMessageutiliserequestAnimationFramepour fusionner les mises à jour par lot ; pendant le streaming, on ne re-render pas à chaque token, voirpackages/web-ui/src/components/StreamingMessageContainer.ts:28-61. - 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), voirpackages/web-ui/src/components/StreamingMessageContainer.ts:50-54. - Rendu par blocs du message assistant :
AssistantMessage.renderparcourt le tableaumessage.contentdans l'ordre et rend les blocstext/thinking/toolCall, voirpackages/web-ui/src/components/Messages.ts:104-167. - Appariement des tool calls :
MessageListconstruit d'abord une Map à partir des rôlestoolResultindexée partoolCallId;AssistantMessageretrouve le résultat via la proptoolResultsByIdet rend<tool-message>en ligne, voirpackages/web-ui/src/components/Messages.ts:115-136. - Conversion des messages avec pièces jointes :
defaultConvertToLlmtransformeuser-with-attachmentsen user message standard — les images deviennentImageContent, les documentsTextContent, les messages d'artifact sont filtrés et ne sont pas envoyés au LLM, voirpackages/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
packages/web-ui/src/components/MessageList.ts:11-26— déclaration declass MessageListet props.packages/web-ui/src/components/MessageList.ts:27-81—buildRenderItems: saute les artifacts, appellerenderMessage, assembleuser-message/assistant-message.packages/web-ui/src/components/MessageList.ts:83-92—render: utilisation derepeatpour la réutilisation par clé.packages/web-ui/src/components/StreamingMessageContainer.ts:6-26— déclaration declass StreamingMessageContainer.packages/web-ui/src/components/StreamingMessageContainer.ts:28-61—setMessage: envoi immédiat pour le mode immediate, sinon fusion par lot viarequestAnimationFrame.packages/web-ui/src/components/Messages.ts:42-82— composantUserMessage, rend le texte et les tuiles de pièces jointes.packages/web-ui/src/components/Messages.ts:84-168— composantAssistantMessage, rendu par blocs selon le tableau content.packages/web-ui/src/components/Messages.ts:226-277— composantToolMessage, appellerenderToolpour choisir entre rendu personnalisé et carte par défaut.packages/web-ui/src/components/Messages.ts:348-383—defaultConvertToLlm: conversion et filtrage deuser-with-attachmentsetartifact.
La fusion par lot dans setMessage est le point clé de la performance du rendu streaming :
// 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 :
// 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
- Appariement toolCall incomplet pendant le streaming :
toolResultsByIdpeut ne pas avoir de result pour l'ID concerné ; dans ce casresultvautundefinedetToolMessageest marqué pending, voirpackages/web-ui/src/components/Messages.ts:118-122. - Message interrompu (aborted) : quand
stopReason === "aborted"et qu'il n'y a pas de result,ToolMessagesynthétise un result isError en placeholder, pour éviter qu'un toolCall ne reste en suspens, voirpackages/web-ui/src/components/Messages.ts:248-258. - Masquer les pendings pour éviter le double : quand
hidePendingToolCallsest true, la liste stable saute les toolCalls sans résultat et les laisse àStreamingMessageContainer, voirpackages/web-ui/src/components/Messages.ts:122-124. - Les messages d'artifact ne sont pas affichés :
MessageListsaute explicitement le rôleartifact; ces messages servent uniquement à reconstruire la session, voirpackages/web-ui/src/components/MessageList.ts:39-42. - Message vide dans
StreamingMessageContainerpendant le streaming : on affiche quand même une barre de chargement clignotante, pour éviter un clignotement blanc, voirpackages/web-ui/src/components/StreamingMessageContainer.ts:64-70.
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.