Skip to content

AgentInterface: Session Host and Event Dispatcher

源码版本v0.73.1

AgentInterface is a Lit customElement in pi-web-ui that sits between the Agent from pi-agent-core and the concrete message rendering components. It holds an Agent instance as the session property, wires up streamFn and getApiKey, subscribes to Agent events, turns the text typed in MessageEditor into prompt() calls, and dispatches the events that come back to MessageList and StreamingMessageContainer. It does not touch the LLM itself, nor does it store history — it is just a stateful event router.

Responsibilities

  1. Hold session: the @property session points at an Agent instance; when session changes the event subscription is refreshed, see packages/web-ui/src/components/AgentInterface.ts:20-47.
  2. Wire streamFn and getApiKey: in connectedCallback, if session.streamFn is still the default streamSimple, swap it for createStreamFn with proxy support, and inject a default getApiKey that reads the key from AppStorage, see packages/web-ui/src/components/AgentInterface.ts:130-152.
  3. Subscribe to events: session.subscribe registers callbacks that map message_start/message_update/message_end/agent_end and similar events to StreamingMessageContainer.setMessage and requestUpdate, see packages/web-ui/src/components/AgentInterface.ts:153-187.
  4. Send messages: sendMessage(input, attachments) validates model and API key; when a key is missing it calls back to onApiKeyRequired, then invokes session.prompt(input) or prompt(message) with attachments, see packages/web-ui/src/components/AgentInterface.ts:215-262.
  5. Auto-scroll: a ResizeObserver watches content height, and together with the scroll event it detects whether the user has manually scrolled up, deciding whether to stick to the bottom, see packages/web-ui/src/components/AgentInterface.ts:89-105.

Design motivation

Why not let MessageEditor call Agent.prompt directly? Because the browser side still has a handful of chores: the proxy URL has to be decided dynamically based on provider/key, the API key has to be fetched asynchronously from IndexedDB, a dialog has to pop when the key is missing, and during streaming the streaming container has to stay in sync with the stable list and avoid duplicate renders. Putting this logic into every host app would duplicate it, and pushing it into the message components would couple the render layer to the storage layer. AgentInterface is extracted as the single entry point: ChatPanel only handles layout, MessageEditor only handles input, MessageList only handles rendering, and all three reach Agent through it.

The other motivation is that session is swappable: when the user switches sessions the whole component is not rebuilt, only the session property changes, and willUpdate detects the change and calls setupSessionSubscription() to resubscribe, see packages/web-ui/src/components/AgentInterface.ts:68-75.

Key files

setupSessionSubscription is the core wiring point — streamFn and getApiKey both get their default implementations replaced here:

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 chains key validation and the onApiKeyRequired callback — when a key is missing the host decides how to prompt for it:

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;
    }
}

Data flow

When the user hits enter in MessageEditor, after key validation and proxy wiring, the Agent loop is triggered and events flow back to rendering:

Edges and failures

  • Session unset: render shows a "No session set" placeholder; sendMessage throws No session set on AgentInterface, see packages/web-ui/src/components/AgentInterface.ts:216-219.
  • Model unset: sendMessage also throws No model set, signaling that the host did not initialize the model.
  • Sending while streaming: when isStreaming is true, sendMessage returns immediately — no queuing, no interrupting, see packages/web-ui/src/components/AgentInterface.ts:216.
  • Duplicate subscriptions: setupSessionSubscription calls _unsubscribeSession() on the old subscription before attaching a new one, so session switches do not leak listeners.
  • Message dedup: on message_end, StreamingMessageContainer.setMessage(null, true) clears the streaming container, so the stable list (which already has this message) does not get a duplicate render, see packages/web-ui/src/components/AgentInterface.ts:161-168.

Summary

AgentInterface is the browser-side "session host": it wires up streamFn and getApiKey, subscribes to events, and dispatches them to the rendering components. Upward it is mounted into layout by ChatPanel; downward it drives the Agent from pi-agent-core. For proxy decision details see CORS proxy and createStreamFn; for the rendering layer components see Message rendering components.