Skip to content

Componentes de render de mensajes

源码版本v0.73.1

MessageList, StreamingMessageContainer y Messages.ts son los tres componentes que entre todos renderizan Agent.state.messages como una conversación visible. MessageList es la lista estable (mensajes finalizados); StreamingMessageContainer es el contenedor de streaming (el mensaje generándose ahora mismo); la división evita que la lista entera se rerenderice durante el streaming. Messages.ts define los sub-elementos user-message, assistant-message, tool-message, aborted-message y procesa el render concreto de los bloques de contenido.

Responsabilidades

  1. Lista estable: MessageList.buildRenderItems itera messages, salta el rol artifact, primero intenta renderMessage custom, y si no hace fallback a user-message/assistant-message, usando la directive repeat para reutilizar DOM por key. Ver packages/web-ui/src/components/MessageList.ts:27-81.
  2. Contenedor de streaming: StreamingMessageContainer.setMessage usa requestAnimationFrame para fusionar updates en lote, evitando un rerender por cada token durante el streaming. Ver packages/web-ui/src/components/StreamingMessageContainer.ts:28-61.
  3. Clonado profundo para evitar comparación por referencia: JSON.parse(JSON.stringify(this._pendingMessage)) clona el mensaje pendiente, para que Lit detecte cambios en propiedades anidadas (como toolCall.arguments). Ver packages/web-ui/src/components/StreamingMessageContainer.ts:50-54.
  4. Render por bloques del assistant: AssistantMessage.render recorre message.content en orden y renderiza los tres tipos de bloque text/thinking/toolCall. Ver packages/web-ui/src/components/Messages.ts:104-167.
  5. Pareo de tool calls: MessageList primero construye un Map por toolCallId de los roles toolResult, y AssistantMessage lo consulta vía prop toolResultsById para renderizar inline el <tool-message>. Ver packages/web-ui/src/components/Messages.ts:115-136.
  6. Conversión de mensajes con attachments: defaultConvertToLlm convierte user-with-attachments a un user message estándar, imágenes a ImageContent y documentos a TextContent; los mensajes artifact se filtran y no se envían al LLM. Ver packages/web-ui/src/components/Messages.ts:348-383.

Motivación de diseño

¿Por qué separar la lista estable y el contenedor de streaming? Porque durante el streaming message.content va acumulando tokens; si toda la MessageList hace requestUpdate con cada cambio, en cada update se ejecuta buildRenderItems sobre toda la historia, y cuánto más larga la charla más se cuelga. Tras la separación, los mensajes finalizados entran en MessageList y no cambian; sólo el mensaje generándose entra en StreamingMessageContainer, que fusiona updates con requestAnimationFrame y renderiza como mucho una vez por frame.

¿Por qué usar JSON.parse(JSON.stringify(...)) si es lento? Porque la detección de cambios de propiedades en Lit es shallow; y el Agent durante el streaming muta in place el array content del mismo objeto AssistantMessage (y toolCall.arguments también in place). Si no se clona, Lit no ve cambio de referencia y la UI no se actualiza. El clonado profundo es lento pero sólo procesa un mensaje por frame, coste asumible.

¿Por qué el tool result no se renderiza como mensaje independiente? Porque el mensaje assistant devuelto por el LLM tiene toolCall, y el toolResult que sigue es su pareja. Renderizarlos por separado rompería el contexto; AssistantMessage con el Map toolResultsById hace el result inline junto al toolCall, visualmente como un grupo. Que MessageList skip explícitamente el rol toolResult standalone es por esto mismo. Ver packages/web-ui/src/components/MessageList.ts:75-79.

Archivos clave

La fusión por lote de setMessage es clave de rendimiento del render 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 recorre el array content en orden; los toolCall buscan su result emparejado vía 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>`,
        );
    }
}

Flujo de datos

AgentInterface se suscribe a eventos y reparte a los dos componentes:

Límites y fallos

Resumen

MessageList y StreamingMessageContainer se reparten: la lista estable no cambia; el contenedor de streaming fusiona con requestAnimationFrame; AssistantMessage renderiza por bloques según content y parea toolCall con toolResultsById inline. Los detalles de render de herramientas en registro de renderers de herramientas; la fuente de eventos en host de sesión AgentInterface.