From ccff57b2a1f6923341bf79ec0e452ca0a0e7b7be Mon Sep 17 00:00:00 2001 From: kyugao Date: Mon, 3 Aug 2026 23:11:29 +0800 Subject: [PATCH] feat(m2): ship isolated task dashboard surface --- tauri/src/main.ts | 87 ++++++- tauri/src/protocol.ts | 16 +- tauri/src/runtime/interaction-kernel.test.ts | 24 +- .../src/runtime/isolated-surface-host.test.ts | 20 ++ tauri/src/runtime/isolated-surface-host.ts | 124 +++++++++ .../runtime/surface-instance-manager.test.ts | 68 +++++ tauri/src/runtime/surface-instance-manager.ts | 171 +++++++++++++ tauri/src/runtime/surface-registry.test.ts | 86 +++++++ tauri/src/runtime/surface-registry.ts | 242 ++++++++++++++++++ 9 files changed, 829 insertions(+), 9 deletions(-) create mode 100644 tauri/src/runtime/isolated-surface-host.test.ts create mode 100644 tauri/src/runtime/isolated-surface-host.ts create mode 100644 tauri/src/runtime/surface-instance-manager.test.ts create mode 100644 tauri/src/runtime/surface-instance-manager.ts create mode 100644 tauri/src/runtime/surface-registry.test.ts create mode 100644 tauri/src/runtime/surface-registry.ts diff --git a/tauri/src/main.ts b/tauri/src/main.ts index ae5f329..50b557d 100644 --- a/tauri/src/main.ts +++ b/tauri/src/main.ts @@ -13,6 +13,9 @@ import { InteractionKernel } from "./runtime/interaction-kernel"; import { RendererRegistry } from "./runtime/renderer-registry"; import { HttpTransportAdapter, type TransportAdapter } from "./runtime/transport-adapter"; import { TrustedDOMRendererContext } from "./runtime/trusted-dom-renderers"; +import { SurfaceRegistry } from "./runtime/surface-registry"; +import { SurfaceInstanceManager } from "./runtime/surface-instance-manager"; +import { IsolatedSurfaceHost } from "./runtime/isolated-surface-host"; type Session = { api: string; @@ -32,6 +35,10 @@ let flushingOutbox = false; const interactionKernel = new InteractionKernel(); const conversationStore = new ConversationStore(localStorage); const executionProgress = new ExecutionProgressState(); +const surfaceRegistry = new SurfaceRegistry(); +const surfaceInstances = new SurfaceInstanceManager(surfaceRegistry); +const SURFACE_REGISTRY_STORAGE_KEY = "lineup.surface-registry.v1"; +try { surfaceRegistry.restore(JSON.parse(localStorage.getItem(SURFACE_REGISTRY_STORAGE_KEY) ?? "null")); } catch { surfaceRegistry.restore(null); } const app = document.querySelector("#app"); if (!app) throw new Error("LineUp host root is missing"); @@ -49,7 +56,7 @@ app.innerHTML = ` @@ -64,6 +71,10 @@ const messages = $("#messages"); const loginView = $("#login-view"); const chatView = $("#chat-view"); const presence = $("#presence"); +const surfaceHost = new IsolatedSurfaceHost(messages, { + ready(instanceID) { surfaceInstances.ready(instanceID, new Date().toISOString()); }, + event(instanceID) { queueSurfaceCancel(instanceID); }, +}); $("#api").value = session.api; function newLocalID(prefix: string): string { @@ -91,11 +102,12 @@ function normalizeAPIAddress(value: string): string { return parsed.toString().replace(/\/$/, ""); } -function loginFailureMessage(reason: unknown): string { - if (reason instanceof Error && reason.message === "LINEUP_LOGIN_TIMEOUT") return "连接 AppServer 超时(10 秒)。请确认 Tailscale 已连接,并核对上方显示的服务地址。"; - if (reason instanceof DOMException && reason.name === "AbortError") return "连接 AppServer 超时(10 秒)。请确认 Tailscale 已连接,且服务地址和端口可访问。"; +function loginFailureMessage(reason: unknown, endpoint?: string): string { + const destination = endpoint ? ` ${endpoint}` : "上方填写的服务地址"; + if (reason instanceof Error && reason.message === "LINEUP_LOGIN_TIMEOUT") return `连接 AppServer 超时(10 秒)。请确认当前网络可以访问${destination}。`; + if (reason instanceof DOMException && reason.name === "AbortError") return `连接 AppServer 超时(10 秒)。请确认当前网络可以访问${destination}。`; const message = reason instanceof Error ? reason.message : ""; - if (message === "Load failed" || message === "Failed to fetch") return "无法连接 AppServer。请确认 Tailscale 已连接,并检查地址是否为 http://100.121.118.116:8090。"; + if (message === "Load failed" || message === "Failed to fetch") return `无法连接 AppServer。请确认当前网络可以访问${destination}。`; return message || "登录失败,请稍后重试。"; } @@ -150,9 +162,57 @@ rendererRegistry.register("app-call", (item, context) => context.renderAppCall(i rendererRegistry.register("fallback", (item, context) => context.renderFallback(item)); function renderIncoming(item: ConversationItem): void { + if (item.kind === "surface") { + const now = new Date().toISOString(); + if (item.envelope.type === "lineup.v1.ui.open") { + const result = surfaceInstances.open(item.envelope.payload, item.envelope.conversation_id, now); + if (result.instance && (result.disposition === "accepted" || result.disposition === "duplicate")) surfaceHost.mount(result.instance); + if (result.disposition === "accepted") conversationStore.replaceSurfaces(surfaceInstances.snapshot()); + return; + } + if (item.envelope.type === "lineup.v1.ui.patch") { + const instanceID = item.envelope.payload.instance_id; + const result = typeof instanceID === "string" ? surfaceInstances.patch(instanceID, item.envelope.payload.state, now) : { disposition: "invalid_request" as const }; + if (result.instance && result.disposition === "accepted") surfaceHost.update(result.instance); + if (result.disposition === "accepted") conversationStore.replaceSurfaces(surfaceInstances.snapshot()); + return; + } + if (item.envelope.type === "lineup.v1.ui.close") { + const instanceID = item.envelope.payload.instance_id; + if (typeof instanceID === "string") surfaceInstances.close(instanceID); + if (typeof instanceID === "string") surfaceHost.unmount(instanceID); + conversationStore.replaceSurfaces(surfaceInstances.snapshot()); + return; + } + } if (!rendererRegistry.render(item, rendererContext)) rendererContext.renderSystemMessage("收到当前客户端没有可信 Renderer 的内容。"); } +/** + * A Surface can legitimately arrive before its locally packaged app has been + * enabled. Keep the envelope in the conversation log, but do not silently + * lose that lifecycle transition forever: once the user enables this exact + * local app, replay only the durable Surface transcript for this conversation. + * + * This remains a local trust boundary. Replaying does not enable an app, + * download a bundle, or loosen validation; every transition still flows + * through SurfaceInstanceManager and SurfaceRegistry. + */ +function replayStoredSurfaceLifecycle(): void { + const snapshot = conversationStore.open(currentConversationKey()); + for (const stored of snapshot.items) { + if (stored.item.kind === "surface") renderIncoming(stored.item); + } +} + +$("#surface-demo").addEventListener("click", () => { + const now = new Date().toISOString(); + surfaceRegistry.enable("lineup.task-dashboard", "0.1.0", now); + localStorage.setItem(SURFACE_REGISTRY_STORAGE_KEY, JSON.stringify(surfaceRegistry.snapshot())); + replayStoredSurfaceLifecycle(); + conversationStore.replaceSurfaces(surfaceInstances.snapshot()); +}); + /** * Status only begins/ends a turn. Public operation labels can only come from * the separately validated adapter trace, never from Agent status/detail. @@ -209,6 +269,17 @@ function queueToolAction(kind: "tool-result" | "tool-cancel", callID: string, pa void flushOutbox(); } +function queueSurfaceCancel(instanceID: string): void { + if (!session.uid) return; + const createdAt = new Date().toISOString(); + const envelope = makeProtocolEvent("lineup.v1.ui.event", { instance_id: instanceID, event: "cancel", data: {} }, currentConversationKey(), { kind: "human", id: session.uid }, { kind: "agent", id: session.agentUID }); + try { + conversationStore.enqueueToolAction(newLocalID("surface"), "surface-event", JSON.stringify(envelope), createdAt); + rendererContext.renderSystemMessage("已请求取消任务。"); + void flushOutbox(); + } catch (reason) { rendererContext.renderSystemMessage(storageFailureMessage(reason)); } +} + async function flushOutbox(): Promise { if (flushingOutbox || !session.active || !transport || !session.uid) return; flushingOutbox = true; @@ -281,7 +352,7 @@ async function syncLoop(): Promise { const payload = decodeBase64(message.payload); if (message.from_uid === session.uid) { const ownEnvelope = parseEnvelopeJSON(payload); - if (ownEnvelope.ok && (ownEnvelope.envelope.type === "lineup.v1.tool.result" || ownEnvelope.envelope.type === "lineup.v1.tool.cancel")) { + if (ownEnvelope.ok && (ownEnvelope.envelope.type === "lineup.v1.tool.result" || ownEnvelope.envelope.type === "lineup.v1.tool.cancel" || ownEnvelope.envelope.type === "lineup.v1.ui.event")) { // Tool action echoes advance the inclusive cursor but are never // rendered as raw JSON user messages or replayed as a new action. continue; @@ -350,6 +421,7 @@ $("#login-form").addEventListener("submit", async event => { transport = candidateTransport; 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 }; const snapshot = conversationStore.open(currentConversationKey()); + surfaceInstances.restore(snapshot.surfaces); interactionKernel.restoreToolCalls(snapshot.tool_calls, new Date().toISOString()); interactionKernel.restoreTasks(snapshot.tasks); executionProgress.restore(snapshot.execution_summaries); @@ -364,9 +436,10 @@ $("#login-form").addEventListener("submit", async event => { for (const stored of snapshot.items) { if (stored.item.kind !== "agent-status") renderIncoming(stored.item); } + for (const instance of surfaceInstances.snapshot().instances) surfaceHost.mount(instance); rendererContext.restoreExecutionSummaries(snapshot.execution_summaries); rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void flushOutbox(); void syncLoop(); - } catch (reason) { error.textContent = loginFailureMessage(reason); } + } catch (reason) { error.textContent = loginFailureMessage(reason, endpoint); } finally { submit.disabled = false; submit.textContent = "进入 LineUp"; } }); diff --git a/tauri/src/protocol.ts b/tauri/src/protocol.ts index 4d49200..5294c31 100644 --- a/tauri/src/protocol.ts +++ b/tauri/src/protocol.ts @@ -217,6 +217,8 @@ const SUPPORTED_TYPES = new Set([ ]); const ID_MAX_LENGTH = 256; const INSTANCE_ID = /^[A-Za-z0-9._:-]{1,128}$/; +const SURFACE_APP_ID = /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/; +const SURFACE_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; const MESSAGE_TYPE = /^lineup\.v1\.[a-z][a-z0-9.]*[a-z0-9]$/; function object(value: unknown): JsonObject | null { @@ -313,13 +315,25 @@ function validSurfacePayload(type: string, payload: JsonObject): boolean { const instanceID = text(payload.instance_id); if (!INSTANCE_ID.test(instanceID)) return false; if (type === "lineup.v1.ui.open") { - if (!object(payload.app)) return false; + // Surface code is strictly host-local in M2. A wire message may identify + // an app/version and state, but can never inject HTML/CSS/JS or a bundle + // location. Exact enablement is checked by the M2 Instance Manager. + if (hasSurfaceBundleOverride(payload)) return false; + const app = object(payload.app); + if (!app || Object.keys(app).some(field => field !== "app_id" && field !== "version")) return false; + const appID = text(app.app_id); + const version = text(app.version); + if (!SURFACE_APP_ID.test(appID) || appID.length > 96 || !SURFACE_VERSION.test(version) || version.length > 64) return false; return payload.state === undefined || object(payload.state) !== null; } if (type === "lineup.v1.ui.patch") return object(payload.state) !== null; return type === "lineup.v1.ui.close"; } +function hasSurfaceBundleOverride(payload: JsonObject): boolean { + return ["bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"].some(field => field in payload); +} + function validAppCallPayload(payload: JsonObject): boolean { return Boolean(identifier(payload.call_id) && identifier(payload.capability) && object(payload.arguments)); } diff --git a/tauri/src/runtime/interaction-kernel.test.ts b/tauri/src/runtime/interaction-kernel.test.ts index 110e973..0d1fbdc 100644 --- a/tauri/src/runtime/interaction-kernel.test.ts +++ b/tauri/src/runtime/interaction-kernel.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { parseEnvelopeJSON } from "../protocol"; +import { decodeConversationItem, parseEnvelopeJSON } from "../protocol"; import { InteractionKernel } from "./interaction-kernel"; function envelope(overrides: Record = {}): string { @@ -195,4 +195,26 @@ describe("InteractionKernel", () => { expect(kernel.requestTaskCancel("task_kernel_001", "2026-08-03T09:05:00.000Z")).toMatchObject({ disposition: "accepted" }); expect(kernel.snapshot().tasks).toEqual([expect.objectContaining({ cancel_requested_at: "2026-08-03T09:05:00.000Z" })]); }); + + it("admits only app identity and state for a Surface open, never an Agent-provided bundle", () => { + const safe = decodeConversationItem(envelope({ + id: "evt_surface_safe", + type: "lineup.v1.ui.open", + payload: { instance_id: "task:001", app: { app_id: "lineup.task-dashboard", version: "0.1.0" }, state: { title: "Build" } }, + })); + const injectedBundle = decodeConversationItem(envelope({ + id: "evt_surface_bundle", + type: "lineup.v1.ui.open", + payload: { instance_id: "task:002", app: { app_id: "lineup.task-dashboard", version: "0.1.0" }, bundle_url: "https://example.invalid/app.js" }, + })); + const extraAppField = decodeConversationItem(envelope({ + id: "evt_surface_extra_app", + type: "lineup.v1.ui.open", + payload: { instance_id: "task:003", app: { app_id: "lineup.task-dashboard", version: "0.1.0", source: "injected" } }, + })); + + expect(safe).toEqual(expect.objectContaining({ kind: "surface", id: "evt_surface_safe" })); + expect(injectedBundle).toEqual(expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })); + expect(extraAppField).toEqual(expect.objectContaining({ kind: "fallback", reason: "invalid_payload" })); + }); }); diff --git a/tauri/src/runtime/isolated-surface-host.test.ts b/tauri/src/runtime/isolated-surface-host.test.ts new file mode 100644 index 0000000..6f08853 --- /dev/null +++ b/tauri/src/runtime/isolated-surface-host.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { parseReadyMessage, surfaceDocument } from "./isolated-surface-host"; + +describe("isolated Surface bridge contract", () => { + it("accepts only the exact minimal ready message", () => { + expect(parseReadyMessage({ v: 1, type: "lineup.surface.v1.ready", instance_id: "task:001" })).toEqual({ v: 1, type: "lineup.surface.v1.ready", instance_id: "task:001" }); + expect(parseReadyMessage({ v: 1, type: "lineup.surface.v1.ready", instance_id: "task:001", state: {} })).toBeUndefined(); + expect(parseReadyMessage({ v: 1, type: "lineup.surface.v1.event", instance_id: "task:001" })).toBeUndefined(); + expect(parseReadyMessage({ v: 1, type: "lineup.surface.v1.ready", instance_id: "`; +} + +function isPlainObject(value: unknown): value is Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} diff --git a/tauri/src/runtime/surface-instance-manager.test.ts b/tauri/src/runtime/surface-instance-manager.test.ts new file mode 100644 index 0000000..fbb7dbb --- /dev/null +++ b/tauri/src/runtime/surface-instance-manager.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { SurfaceInstanceManager } from "./surface-instance-manager"; +import { SurfaceRegistry } from "./surface-registry"; + +const APP = "lineup.task-dashboard"; +const VERSION = "0.1.0"; +const CONVERSATION = "conversation_surface_001"; +const OPENED = "2026-08-03T10:30:00.000Z"; +const UPDATED = "2026-08-03T10:31:00.000Z"; + +function createManager(): { registry: SurfaceRegistry; manager: SurfaceInstanceManager } { + const registry = new SurfaceRegistry(); + registry.enable(APP, VERSION, OPENED); + return { registry, manager: new SurfaceInstanceManager(registry) }; +} + +function openPayload(state: object = { title: "Build", percent: 10 }): object { + return { instance_id: "task:001", app: { app_id: APP, version: VERSION }, state }; +} + +describe("SurfaceInstanceManager", () => { + it("opens only an explicitly enabled local app and makes same-instance open idempotent", () => { + const { manager } = createManager(); + expect(manager.open(openPayload(), CONVERSATION, OPENED)).toMatchObject({ disposition: "accepted", instance: { phase: "opening", state: { title: "Build", percent: 10 } } }); + expect(manager.open(openPayload({ title: "Ignored patch" }), CONVERSATION, UPDATED)).toMatchObject({ disposition: "duplicate", instance: { state: { title: "Build", percent: 10 } } }); + }); + + it("rejects an unenabled app, an app-version mismatch, and injected bundle fields", () => { + const registry = new SurfaceRegistry(); + const manager = new SurfaceInstanceManager(registry); + expect(manager.open(openPayload(), CONVERSATION, OPENED).disposition).toBe("disabled"); + registry.enable(APP, VERSION, OPENED); + expect(manager.open({ ...openPayload(), app: { app_id: APP, version: "1.0.0" } }, CONVERSATION, OPENED).disposition).toBe("version_mismatch"); + expect(manager.open({ ...openPayload(), bundle: "