feat: route incoming messages through interaction kernel

This commit is contained in:
2026-08-03 13:00:52 +08:00
parent d2646f534e
commit 883767c72e
4 changed files with 167 additions and 3 deletions
+6
View File
@@ -59,6 +59,12 @@ macOS 的 `src-tauri/Info.plist` 为当前 Tailscale / 私网 HTTP AppServer 配
该文件不依赖 DOM、`fetch`、Tauri 或本地存储。Surface 与 Capability 在本阶段只校验其基础合约并降级显示;不加载 bundle,也不调用能力。下一项 M0-03 才会将此模型接入 Interaction Kernel 和 Renderer Registry。 该文件不依赖 DOM、`fetch`、Tauri 或本地存储。Surface 与 Capability 在本阶段只校验其基础合约并降级显示;不加载 bundle,也不调用能力。下一项 M0-03 才会将此模型接入 Interaction Kernel 和 Renderer Registry。
### M0-03 Interaction Kernel 与 Renderer Registry(已完成)
`src/runtime/interaction-kernel.ts` 是 Transport 与表现层之间的唯一入口:它将原始同步/实时 payload 解码为 `ConversationItem`,对有有效 LineUp `id` 的消息实施有界去重,并将 Agent status 投影为可读取的 presence 快照。它不执行 `ui.*``app.*` 或用户动作。
`src/runtime/renderer-registry.ts` 只根据已验证的 `ConversationItem.kind` 分派已注册的可信 renderer;renderer 的输入只有表现项与 View context,不会获得 Transport 或 Capability 对象。当前聊天页已通过该 Registry 渲染同步进入的标准项。为了避免在 M0-03 跨阶段重写 UI,具体 DOM renderer 仍在 `main.ts`;M0-06 会把它们迁移为独立可信 renderer 模块。
## 本地命令 ## 本地命令
```bash ```bash
+30 -3
View File
@@ -2,12 +2,13 @@ import DOMPurify from "dompurify";
import { marked } from "marked"; import { marked } from "marked";
import "./style.css"; import "./style.css";
import { import {
decodeConversationItem,
type Actor, type Actor,
type ConversationItem, type ConversationItem,
type Envelope, type Envelope,
type JsonObject, type JsonObject,
} from "./protocol"; } from "./protocol";
import { InteractionKernel } from "./runtime/interaction-kernel";
import { RendererRegistry } from "./runtime/renderer-registry";
type Session = { type Session = {
api: string; 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 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 polling = false;
let thinking: HTMLElement | undefined; let thinking: HTMLElement | undefined;
const interactionKernel = new InteractionKernel();
const app = document.querySelector<HTMLDivElement>("#app"); const app = document.querySelector<HTMLDivElement>("#app");
if (!app) throw new Error("LineUp host root is missing"); 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 { function render(item: ConversationItem): void {
switch (item.kind) { switch (item.kind) {
case "user-message": appendBubble("human", item.text); break;
case "markdown": hideThinking(); appendBubble("agent", item.markdown, true); 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 "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; } 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 { function decodeBase64(value: string): string {
try { return decodeURIComponent([...atob(value)].map(c => `%${c.charCodeAt(0).toString(16).padStart(2, "0")}`).join("")); } catch { return value; } 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; const previousSeq = session.lastSeq;
session.lastSeq = Math.max(session.lastSeq, message.message_seq); session.lastSeq = Math.max(session.lastSeq, message.message_seq);
advanced ||= session.lastSeq > previousSeq; 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 // 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 response = await fetchLogin(session.api, phone, code);
const body = await response.json().catch(() => ({})) as LoginResponse & { error?: string }; const body = await response.json().catch(() => ({})) as LoginResponse & { error?: string };
if (!response.ok) throw new Error(body.error || "登录失败"); 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 }; 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(); loginView.hidden = true; chatView.hidden = false; addSystem("已进入与 AI Agent 的对话"); setPresence("正在同步消息…"); void syncLoop();
} catch (reason) { error.textContent = loginFailureMessage(reason); } } catch (reason) { error.textContent = loginFailureMessage(reason); }
@@ -236,7 +263,7 @@ $<HTMLFormElement>("#message-form").addEventListener("submit", async event => {
finally { send.disabled = false; input.focus(); } 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( export function makeProtocolEvent(
type: string, type: string,
+95
View File
@@ -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,
});
}
}
+36
View File
@@ -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);
}
}