Skip to content

AgentInterface: host de sesión y dispatch de eventos

源码版本v0.73.1

AgentInterface es la capa intermedia de pi-web-ui, entre el Agent de pi-agent-core y los componentes de render de mensajes concretos, como un customElement de Lit. Mantiene una instancia de Agent como propiedad session, se ocupa de ensamblar streamFn y getApiKey, suscribirse a los eventos del Agent, convertir el texto que el usuario teclea en MessageEditor en llamadas prompt(), y repartir los eventos entrantes a MessageList y StreamingMessageContainer. No toca el LLM ni guarda historial; es sólo un router de eventos con estado.

Responsabilidades

  1. Mantener session: la propiedad @property session apunta a una instancia de Agent; al cambiar, se vuelve a suscribir a los eventos. Ver packages/web-ui/src/components/AgentInterface.ts:20-47.
  2. Ensamlar streamFn y getApiKey: en connectedCallback, si session.streamFn sigue siendo el streamSimple por defecto, lo reemplaza por createStreamFn con soporte de proxy; al mismo tiempo inyecta un getApiKey por defecto que lee la key desde AppStorage. Ver packages/web-ui/src/components/AgentInterface.ts:130-152.
  3. Suscripción a eventos: session.subscribe registra un callback que mapea message_start/message_update/message_end/agent_end etc. a StreamingMessageContainer.setMessage y requestUpdate. Ver packages/web-ui/src/components/AgentInterface.ts:153-187.
  4. Enviar mensaje: sendMessage(input, attachments) valida modelo y API key; si falta key, llama al callback onApiKeyRequired, y luego invoca session.prompt(input) o prompt(message) con attachments. Ver packages/web-ui/src/components/AgentInterface.ts:215-262.
  5. Auto-scroll: un ResizeObserver observa los cambios de altura del contenido, combinado con un evento scroll para decidir si el usuario ha hecho scroll arriba y si hay que quedarse pegado abajo. Ver packages/web-ui/src/components/AgentInterface.ts:89-105.

Motivación de diseño

¿Por qué no dejar que MessageEditor llame directamente a Agent.prompt? Porque en el lado del navegador faltan varias tareas: la URL del proxy hay que decidirla dinámicamente según provider/key; la API key se lee de forma async desde IndexedDB; si falta key hay que mostrar un diálogo; durante el streaming hay que gestionar la desduplicación entre el contenedor de streaming y la lista estable. Meter todo eso en cada host app lo duplicaría; ponerlo en los componentes de mensaje acoplaría la capa de render con la de storage. AgentInterface se abstrae como única entrada: ChatPanel sólo se ocupa del layout, MessageEditor sólo de la entrada, MessageList sólo del render; los tres se conectan al Agent a través de él.

Otra motivación es que session es reemplazable: al cambiar de sesión el usuario no recrea todo el componente, sólo cambia la propiedad session; willUpdate detecta el cambio y llama setupSessionSubscription() para re-suscribir. Ver packages/web-ui/src/components/AgentInterface.ts:68-75.

Archivos clave

setupSessionSubscription es el punto central de ensamblaje; aquí se reemplazan las implementaciones por defecto de streamFn y getApiKey:

typescript
// packages/web-ui/src/components/AgentInterface.ts:138-151
if (this.session.streamFn === streamSimple) {
    this.session.streamFn = createStreamFn(async () => {
        const enabled = await getAppStorage().settings.get<boolean>("proxy.enabled");
        return enabled ? (await getAppStorage().settings.get<string>("proxy.url")) || undefined : undefined;
    });
}

if (!this.session.getApiKey) {
    this.session.getApiKey = async (provider: string) => {
        const key = await getAppStorage().providerKeys.get(provider);
        return key ?? undefined;
    };
}

sendMessage encadena validación de key con el callback onApiKeyRequired, dejando al host decidir cómo mostrar el diálogo cuando falta key:

typescript
// packages/web-ui/src/components/AgentInterface.ts:222-238
const provider = session.state.model.provider;
const apiKey = await getAppStorage().providerKeys.get(provider);

if (!apiKey) {
    if (!this.onApiKeyRequired) {
        console.error("No API key configured and no onApiKeyRequired handler set");
        return;
    }
    const success = await this.onApiKeyRequired(provider);
    if (!success) {
        return;
    }
}

Flujo de datos

El usuario pulsa Enter en MessageEditor, pasa por validación de key y ensamblaje de proxy, y finalmente dispara el bucle del Agent y los eventos que llegan al render:

Límites y fallos

  • Session sin setear: render muestra un placeholder "No session set"; sendMessage lanza No session set on AgentInterface. Ver packages/web-ui/src/components/AgentInterface.ts:216-219.
  • Model sin setear: igual, sendMessage lanza No model set, avisando al host que no inicializó el modelo.
  • Mensaje mientras hace streaming: si isStreaming es true, sendMessage retorna sin encolar ni interrumpir. Ver packages/web-ui/src/components/AgentInterface.ts:216.
  • Suscripción duplicada: setupSessionSubscription primero hace _unsubscribeSession() de la suscripción vieja antes de colgar la nueva; al cambiar de session no se filtran listeners.
  • Deduplicación de mensajes: en message_end, StreamingMessageContainer.setMessage(null, true) limpia, evitando que la lista estable que ya tiene el mensaje lo vuelva a renderizar el contenedor de streaming. Ver packages/web-ui/src/components/AgentInterface.ts:161-168.

Resumen

AgentInterface es el "host de sesión" del lado del navegador: ensambla streamFn y getApiKey, se suscribe a eventos y los reparte a los componentes de render. Arriba lo monta ChatPanel en su layout; abajo dirige al Agent de pi-agent-core. Los detalles de decisión de proxy en proxy CORS y createStreamFn; los componentes de render en componentes de render de mensajes.