From 883767c72eb0aa302a4a1cfb97a994c323e2a4de Mon Sep 17 00:00:00 2001 From: kyugao Date: Mon, 3 Aug 2026 13:00:52 +0800 Subject: [PATCH] feat: route incoming messages through interaction kernel --- tauri/README.md | 6 ++ tauri/src/main.ts | 33 ++++++++- tauri/src/runtime/interaction-kernel.ts | 95 +++++++++++++++++++++++++ tauri/src/runtime/renderer-registry.ts | 36 ++++++++++ 4 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 tauri/src/runtime/interaction-kernel.ts create mode 100644 tauri/src/runtime/renderer-registry.ts diff --git a/tauri/README.md b/tauri/README.md index cef0b87..1a4a9fa 100644 --- a/tauri/README.md +++ b/tauri/README.md @@ -59,6 +59,12 @@ macOS 的 `src-tauri/Info.plist` 为当前 Tailscale / 私网 HTTP AppServer 配 该文件不依赖 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 diff --git a/tauri/src/main.ts b/tauri/src/main.ts index 118acd0..160b4a1 100644 --- a/tauri/src/main.ts +++ b/tauri/src/main.ts @@ -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("#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(); +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 { 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 @@ $("#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 @@ $("#message-form").addEventListener("submit", async event => { finally { send.disabled = false; input.focus(); } }); -$("#logout").addEventListener("click", () => { session.active = false; messages.replaceChildren(); chatView.hidden = true; loginView.hidden = false; }); +$("#logout").addEventListener("click", () => { session.active = false; interactionKernel.reset(); messages.replaceChildren(); chatView.hidden = true; loginView.hidden = false; }); export function makeProtocolEvent( type: string, diff --git a/tauri/src/runtime/interaction-kernel.ts b/tauri/src/runtime/interaction-kernel.ts new file mode 100644 index 0000000..0c1b5e2 --- /dev/null +++ b/tauri/src/runtime/interaction-kernel.ts @@ -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(); + private readonly seenOrder: string[] = []; + private readonly presenceByActor = new Map(); + + 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, + }); + } +} diff --git a/tauri/src/runtime/renderer-registry.ts b/tauri/src/runtime/renderer-registry.ts new file mode 100644 index 0000000..61dc5e0 --- /dev/null +++ b/tauri/src/runtime/renderer-registry.ts @@ -0,0 +1,36 @@ +import type { ConversationItem } from "../protocol"; + +export type ConversationItemKind = ConversationItem["kind"]; +export type ItemOfKind = Extract; +export type Renderer = (item: ItemOfKind, 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 { + // 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(); + + public register(kind: K, renderer: Renderer): () => 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); + } +}