feat: route incoming messages through interaction kernel
This commit is contained in:
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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