feat: route incoming messages through interaction kernel
This commit is contained in:
+30
-3
@@ -2,12 +2,13 @@ import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
import "./style.css";
|
||||
import {
|
||||
decodeConversationItem,
|
||||
type Actor,
|
||||
type ConversationItem,
|
||||
type Envelope,
|
||||
type JsonObject,
|
||||
} from "./protocol";
|
||||
import { InteractionKernel } from "./runtime/interaction-kernel";
|
||||
import { RendererRegistry } from "./runtime/renderer-registry";
|
||||
|
||||
type Session = {
|
||||
api: string;
|
||||
@@ -32,6 +33,7 @@ const DEFAULT_API = "http://127.0.0.1:8090";
|
||||
let session: Session = { api: localStorage.getItem("lineup.api") || DEFAULT_API, uid: "", agentUID: "agent_hermes_main", channelID: "agent_default_channel", channelType: 2, lastSeq: 0, active: false };
|
||||
let polling = false;
|
||||
let thinking: HTMLElement | undefined;
|
||||
const interactionKernel = new InteractionKernel();
|
||||
|
||||
const app = document.querySelector<HTMLDivElement>("#app");
|
||||
if (!app) throw new Error("LineUp host root is missing");
|
||||
@@ -132,6 +134,7 @@ function hideThinking(): void { thinking?.remove(); thinking = undefined; }
|
||||
|
||||
function render(item: ConversationItem): void {
|
||||
switch (item.kind) {
|
||||
case "user-message": appendBubble("human", item.text); break;
|
||||
case "markdown": hideThinking(); appendBubble("agent", item.markdown, true); break;
|
||||
case "agent-status": item.status === "thinking" ? showThinking(item.detail || "正在思考…") : (hideThinking(), setPresence(item.detail || item.status || "在线")); break;
|
||||
case "progress": { const percent = Math.max(0, Math.min(100, item.percent)); addSystem(`${item.title || "Agent 任务"}:${percent}%${item.status ? ` · ${item.status}` : ""}`); break; }
|
||||
@@ -142,6 +145,22 @@ function render(item: ConversationItem): void {
|
||||
}
|
||||
}
|
||||
|
||||
// M0-03 establishes the dispatch boundary. M0-06 will move these individual
|
||||
// DOM renderers out of App Shell; until then the registry keeps the same UI
|
||||
// behavior while ensuring all incoming content traverses the Kernel first.
|
||||
const rendererRegistry = new RendererRegistry<void>();
|
||||
rendererRegistry.register("markdown", render);
|
||||
rendererRegistry.register("agent-status", render);
|
||||
rendererRegistry.register("progress", render);
|
||||
rendererRegistry.register("error", render);
|
||||
rendererRegistry.register("surface", render);
|
||||
rendererRegistry.register("app-call", render);
|
||||
rendererRegistry.register("fallback", render);
|
||||
|
||||
function renderIncoming(item: ConversationItem): void {
|
||||
if (!rendererRegistry.render(item, undefined)) addSystem("收到当前客户端没有可信 Renderer 的内容。");
|
||||
}
|
||||
|
||||
function decodeBase64(value: string): string {
|
||||
try { return decodeURIComponent([...atob(value)].map(c => `%${c.charCodeAt(0).toString(16).padStart(2, "0")}`).join("")); } catch { return value; }
|
||||
}
|
||||
@@ -179,7 +198,14 @@ async function syncLoop(): Promise<void> {
|
||||
const previousSeq = session.lastSeq;
|
||||
session.lastSeq = Math.max(session.lastSeq, message.message_seq);
|
||||
advanced ||= session.lastSeq > previousSeq;
|
||||
if (message.from_uid !== session.uid) render(decodeConversationItem(decodeBase64(message.payload)));
|
||||
if (message.from_uid !== session.uid) {
|
||||
const result = interactionKernel.ingest({
|
||||
raw: decodeBase64(message.payload),
|
||||
source: "sync",
|
||||
transport_id: String(message.message_seq),
|
||||
});
|
||||
for (const item of result.items) renderIncoming(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid a hot loop if an upstream response repeats a sequence that
|
||||
@@ -219,6 +245,7 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
|
||||
const response = await fetchLogin(session.api, phone, code);
|
||||
const body = await response.json().catch(() => ({})) as LoginResponse & { error?: string };
|
||||
if (!response.ok) throw new Error(body.error || "登录失败");
|
||||
interactionKernel.reset();
|
||||
session = { ...session, uid: body.uid, agentUID: body.agent_uid || session.agentUID, channelID: body.channel_id || session.channelID, channelType: body.channel_type || session.channelType, lastSeq: 0, active: true };
|
||||
loginView.hidden = true; chatView.hidden = false; addSystem("已进入与 AI Agent 的对话"); setPresence("正在同步消息…"); void syncLoop();
|
||||
} catch (reason) { error.textContent = loginFailureMessage(reason); }
|
||||
@@ -236,7 +263,7 @@ $<HTMLFormElement>("#message-form").addEventListener("submit", async event => {
|
||||
finally { send.disabled = false; input.focus(); }
|
||||
});
|
||||
|
||||
$<HTMLButtonElement>("#logout").addEventListener("click", () => { session.active = false; messages.replaceChildren(); chatView.hidden = true; loginView.hidden = false; });
|
||||
$<HTMLButtonElement>("#logout").addEventListener("click", () => { session.active = false; interactionKernel.reset(); messages.replaceChildren(); chatView.hidden = true; loginView.hidden = false; });
|
||||
|
||||
export function makeProtocolEvent(
|
||||
type: string,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
decodeConversationItem,
|
||||
type Actor,
|
||||
type ConversationItem,
|
||||
} from "../protocol";
|
||||
|
||||
/** Transport metadata is intentionally opaque to the protocol and renderer. */
|
||||
export type IncomingMessage = {
|
||||
raw: string;
|
||||
source: "sync" | "live";
|
||||
transport_id?: string;
|
||||
received_at?: string;
|
||||
};
|
||||
|
||||
export type AgentPresence = {
|
||||
actor: Actor;
|
||||
status: string;
|
||||
detail: string;
|
||||
envelope_id: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type KernelSnapshot = {
|
||||
seen_envelope_ids: number;
|
||||
agent_presence: readonly AgentPresence[];
|
||||
};
|
||||
|
||||
export type KernelResult =
|
||||
| { disposition: "accepted"; items: readonly ConversationItem[]; snapshot: KernelSnapshot }
|
||||
| { disposition: "duplicate"; items: readonly []; snapshot: KernelSnapshot };
|
||||
|
||||
/**
|
||||
* The Interaction Kernel is the sole path from untrusted incoming payloads to
|
||||
* trusted conversation items. It has no DOM, fetch, storage, or platform
|
||||
* dependency; M0-04 will persist its in-memory state through Conversation
|
||||
* Store and M0-05 will feed it from multiple Transport Adapter sources.
|
||||
*/
|
||||
export class InteractionKernel {
|
||||
private readonly seenIDs = new Set<string>();
|
||||
private readonly seenOrder: string[] = [];
|
||||
private readonly presenceByActor = new Map<string, AgentPresence>();
|
||||
|
||||
public constructor(private readonly maxRememberedEnvelopeIDs = 2_000) {
|
||||
if (!Number.isSafeInteger(maxRememberedEnvelopeIDs) || maxRememberedEnvelopeIDs < 1) {
|
||||
throw new Error("maxRememberedEnvelopeIDs must be a positive integer.");
|
||||
}
|
||||
}
|
||||
|
||||
public ingest(message: IncomingMessage): KernelResult {
|
||||
const item = decodeConversationItem(message.raw);
|
||||
if (item.id && this.seenIDs.has(item.id)) {
|
||||
return { disposition: "duplicate", items: [], snapshot: this.snapshot() };
|
||||
}
|
||||
|
||||
if (item.id) this.remember(item.id);
|
||||
this.project(item, message.received_at ?? new Date().toISOString());
|
||||
return { disposition: "accepted", items: [item], snapshot: this.snapshot() };
|
||||
}
|
||||
|
||||
public snapshot(): KernelSnapshot {
|
||||
return {
|
||||
seen_envelope_ids: this.seenIDs.size,
|
||||
agent_presence: [...this.presenceByActor.values()].map(presence => ({ ...presence })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Clear only the in-memory projection when the active conversation changes. */
|
||||
public reset(): void {
|
||||
this.seenIDs.clear();
|
||||
this.seenOrder.length = 0;
|
||||
this.presenceByActor.clear();
|
||||
}
|
||||
|
||||
private remember(id: string): void {
|
||||
this.seenIDs.add(id);
|
||||
this.seenOrder.push(id);
|
||||
while (this.seenOrder.length > this.maxRememberedEnvelopeIDs) {
|
||||
const expired = this.seenOrder.shift();
|
||||
if (expired) this.seenIDs.delete(expired);
|
||||
}
|
||||
}
|
||||
|
||||
private project(item: ConversationItem, updatedAt: string): void {
|
||||
if (item.kind !== "agent-status" || !item.envelope) return;
|
||||
const actor = item.envelope.sender;
|
||||
const actorKey = `${actor.kind}:${actor.id}`;
|
||||
this.presenceByActor.set(actorKey, {
|
||||
actor,
|
||||
status: item.status,
|
||||
detail: item.detail,
|
||||
envelope_id: item.envelope.id,
|
||||
updated_at: updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { ConversationItem } from "../protocol";
|
||||
|
||||
export type ConversationItemKind = ConversationItem["kind"];
|
||||
export type ItemOfKind<K extends ConversationItemKind> = Extract<ConversationItem, { kind: K }>;
|
||||
export type Renderer<Context, K extends ConversationItemKind> = (item: ItemOfKind<K>, context: Context) => void;
|
||||
|
||||
/**
|
||||
* Trusted renderer dispatch only. Renderers receive a typed display item and
|
||||
* view context; they receive no Transport Adapter, capability handler, or
|
||||
* mutable protocol payload, so rendering cannot be the route for a network
|
||||
* request or privileged action.
|
||||
*/
|
||||
export class RendererRegistry<Context> {
|
||||
// The generic type is checked at registration; erasure here lets one map
|
||||
// store handlers whose input is intentionally narrowed by `kind`.
|
||||
private readonly renderers = new Map<ConversationItemKind, unknown>();
|
||||
|
||||
public register<K extends ConversationItemKind>(kind: K, renderer: Renderer<Context, K>): () => void {
|
||||
this.renderers.set(kind, renderer);
|
||||
return () => {
|
||||
if (this.renderers.get(kind) === renderer) this.renderers.delete(kind);
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns false when no trusted renderer has been registered for this item. */
|
||||
public render(item: ConversationItem, context: Context): boolean {
|
||||
const renderer = this.renderers.get(item.kind) as ((value: ConversationItem, rendererContext: Context) => void) | undefined;
|
||||
if (!renderer) return false;
|
||||
renderer(item, context);
|
||||
return true;
|
||||
}
|
||||
|
||||
public has(kind: ConversationItemKind): boolean {
|
||||
return this.renderers.has(kind);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user