/** * Tauri/Desktop 与 Web Reference Host 的组合入口。 * * 本文件负责创建 Host 侧适配、LineUpRuntime 和默认 Chat Core App,并连接可信的 * Renderer、Surface、Capability 集成。Transport、Store、同步循环、outbox 与原始 * Agent 消息解析均由 Runtime 独占,不能重新回流到此入口。 */ // 协议层只提供经过 Runtime 校验后的表现模型类型;本入口不直接解析原始网络数据。 import { type ConversationItem, type JsonObject, } from "@/runtime/protocol/lineup-v1"; // 下面这些模块分别对应 Host 侧的扩展界面、系统能力、Artifact 和执行状态适配。 import { ExecutionProgressState } from "@/runtime/coordination/execution-progress-state"; import { SurfaceRegistry } from "@/runtime/surfaces/surface-registry"; import { SurfaceInstanceManager } from "@/runtime/surfaces/surface-instance-manager"; import { CachedVerifiedSurfaceDocumentResolver, IsolatedSurfaceHost } from "@/runtime/surfaces/isolated-surface-host"; import { ProductionSurfaceManifestRegistry } from "@/runtime/surfaces/production-surface-manifest"; import { ProductionSurfacePolicy } from "@/runtime/surfaces/production-surface-policy"; import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, WebCryptoEd25519SurfaceManifestSignatureVerifier } from "@/runtime/surfaces/surface-bundle-cache"; import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry"; import { ClientInventoryPublisher } from "@/runtime/inventory/client-inventory"; import { ArtifactState } from "@/runtime/artifacts/artifact-state"; import { ArtifactContentCache } from "@/runtime/artifacts/artifact-content-cache"; import { CapabilityCallStateMachine, type CapabilityCallRecord, parseCapabilityCall } from "@/runtime/capabilities/capability-call-state"; import { CapabilityAuditLog } from "@/runtime/capabilities/capability-audit"; import { CapabilityExecutor, type PickedFile } from "@/runtime/capabilities/capability-executor"; import { LineUpRuntime } from "@/runtime/coordination/lineup-runtime"; import { RuntimeAppHost } from "@/runtime/app-management/runtime-app-host"; // Vite 的开发标记和 M4 测试控制器需要扩展浏览器的全局类型。 declare global { interface ImportMeta { readonly env: Readonly<{ DEV: boolean }>; } interface Window { /** Development-only M4 signed-bundle browser acceptance controller. */ __lineupM4Fixture?: Readonly<{ patch: () => void; close: () => void }>; } } // 这是登录表单首次显示时使用的本地默认地址;用户提交后的地址会保存到 localStorage。 // `0.0.0.0` is a server bind address, never a browser destination. // 打包后的 Tauri(Android + macOS)都通过 Tailscale 访问远程 AppServer, // 不能用 loopback(手机/沙盒里 127.0.0.1 指向自己),所以走 100.121.118.116。 // 仅浏览器本地 dev 模式(`http://127.0.0.1:1420` / `http://localhost:1420`)才回退到 loopback。 const DEFAULT_API = "__TAURI_INTERNALS__" in window ? "http://100.121.118.116:8090" : location.hostname === "127.0.0.1" || location.hostname === "localhost" ? "http://127.0.0.1:8090" : "http://100.121.118.116:8090"; // 这些状态对象属于当前可信 Host 的装配层:Runtime 负责会话、消息和可靠性, // 它们负责将 Surface、Capability、Artifact 和执行进度投影到当前页面。 const executionProgress = new ExecutionProgressState(); const surfaceRegistry = new SurfaceRegistry(); const surfaceInstances = new SurfaceInstanceManager(surfaceRegistry); const capabilityRegistry = new CapabilityRegistry(); const inventoryPublisher = new ClientInventoryPublisher(); const artifactState = new ArtifactState(); const artifactContentCache = new ArtifactContentCache(); const capabilityCalls = new CapabilityCallStateMachine(); const capabilityAudit = new CapabilityAuditLog(); // CapabilityExecutor 只在 Capability 状态机已经批准调用后才会触发这些 Host handler。 // 它不会因为 Agent 发来一个 payload 就直接调用浏览器或桌面能力。 const capabilityExecutor = new CapabilityExecutor({ async openURL(url) { // With `noopener`, Chromium intentionally returns null even when it did // create the tab. Treat a synchronous exception as failure, but not this // security-preserving return value; otherwise a successful safe open is // incorrectly reported as `handler_failed` to the Agent. window.open(url, "_blank", "noopener,noreferrer"); }, writeClipboard: writeClipboardFromTrustedHost, pickFile: pickFileFromTrustedHost, async saveArtifact(artifactID) { saveCachedArtifact(artifactID); }, }, artifactState); // LineUpRuntime 是本文件中最重要的底座对象。它独占 Transport、Store、同步循环、 // outbox、App Inbox 和作用域校验;main.ts 只通过 Runtime/SDK 与它协作。 const runtime = new LineUpRuntime({ storage: localStorage, prepareIncoming(item) { return item.kind === "artifact-offer" ? prepareArtifactOffer(item) : item; }, }); let lastInventoryRevision = ""; 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); } // RuntimeAppHost 会读取 Runtime 的 Core App Registry,选择并挂载默认 Core App。 // main.ts 不在这里直接创建 Chat/IM 页面。 const app = document.querySelector("#app"); if (!app) throw new Error("LineUp host root is missing"); // Host 页面是可信的静态 Shell。缺少必要节点属于装配错误,应尽早失败,而不是运行到 // 登录或消息处理时才出现难以定位的空引用。 const $ = (selector: string): T => { const found = document.querySelector(selector); if (!found) throw new Error(`Missing element: ${selector}`); return found; }; // 当前默认 Core App 的 SDK。变量在挂载应用后赋值;前面传入的回调只会在用户操作后执行, // 因此它们可以安全地引用这个稍后完成初始化的 SDK。 let chatSDK: ReturnType; // 将 Chat 所需的交互回调注入 App Host。回调本身仍然只能通过 SDK 访问 Runtime, // Chat Renderer 不会获得 Transport、Store 或 Tauri 特权对象。 const mountedApp = new RuntimeAppHost(runtime, app, { toolInteractions: { submit(callID, submission) { return chatSDK.runtime.interactions.submit(callID, submission); }, cancel(callID, reason) { return chatSDK.runtime.interactions.cancel(callID, reason); }, expire(callID) { return chatSDK.runtime.interactions.expire(callID); }, read(callID) { return chatSDK.runtime.interactions.read(callID); }, loadDraft(callID) { return chatSDK.runtime.interactions.loadDraft(callID); }, saveDraft(callID, values) { chatSDK.runtime.interactions.saveDraft(callID, values); }, clearDraft(callID) { chatSDK.runtime.interactions.clearDraft(callID); }, }, taskInteractions: { requestCancel(operationID) { return chatSDK.runtime.tasks.requestCancel(operationID); }, read(operationID) { return chatSDK.runtime.tasks.read(operationID); }, }, capabilityInteractions: { async approve(callID) { return approveCapabilityCall(callID); }, reject(callID) { return rejectCapabilityCall(callID); }, expire(callID) { return expireCapabilityCall(callID); }, read(callID) { return capabilityCalls.get(callID); }, }, }).mountDefaultApp(); // 从通用挂载结果中取得当前应用的 SDK 和页面投影对象。 chatSDK = mountedApp.sdk; const { messages, appSessions, loginView, chatView, presence, rendererContext, rendererRegistry } = mountedApp; // Surface Host 只负责承载已经通过 Runtime/Surface 策略检查的隔离界面。 // Surface 的 ready/event 回调仍回到状态管理和 SDK Action,不直接执行 Agent 指令。 const surfaceHost = new IsolatedSurfaceHost(messages, { ready(instanceID) { surfaceInstances.ready(instanceID, new Date().toISOString()); }, event(instanceID) { queueSurfaceCancel(instanceID); }, async request(instanceID, method, payload) { return runtime.handleMiniAppBridgeRequest(instanceID, method, payload); }, }); // These are the two shipped reference MiniApps. They are mounted through the // same restricted SDK as any future bundled MiniApp; this adapter only wires // lifecycle to the SDK object and never hands them Runtime internals. const bundledMiniApps = new Set(); function reconcileBundledMiniApps(): void { const active = new Set(); for (const instance of runtime.lifecycle.instances.list(runtime.currentConversationID())) { if (instance.app_scope !== "task-dashboard" && instance.app_scope !== "whiteboard") continue; if (!["starting", "foreground", "background", "suspended"].includes(instance.state)) continue; active.add(instance.instance_id); if (bundledMiniApps.has(instance.instance_id)) continue; const receipt = runtime.openBundledSurface(instance.app_scope, instance.instance_id); if (receipt.disposition === "accepted") bundledMiniApps.add(instance.instance_id); } for (const instanceID of bundledMiniApps) { if (active.has(instanceID)) continue; bundledMiniApps.delete(instanceID); } } runtime.onEvent(event => { if (event.type === "app-lifecycle") reconcileBundledMiniApps(); }); /** * Bundled MiniApps request local Surfaces through Runtime. The Host adapter * is the only place that turns the already-scoped request into a DOM iframe; * no request is sent back through the Agent protocol. */ runtime.onEvent(event => { if (event.type !== "surface-request") return; const now = new Date().toISOString(); const appID = event.app_scope === "task-dashboard" ? "lineup.task-dashboard" : event.app_scope === "whiteboard" ? "lineup.whiteboard" : undefined; if (!appID) return; const version = "0.1.0"; if (event.event === "open") { const result = surfaceInstances.open({ instance_id: event.instance_id, app: { app_id: appID, version }, state: event.data.state ?? event.data }, currentConversationKey(), now); if (result.instance && (result.disposition === "accepted" || result.disposition === "duplicate")) surfaceHost.mount(result.instance); if (result.disposition === "accepted") runtime.replaceSurfaces(surfaceInstances.snapshot()); return; } if (event.event === "patch") { const result = surfaceInstances.patch(event.instance_id, event.data.state ?? event.data, now); if (result.instance && result.disposition === "accepted") surfaceHost.update(result.instance); if (result.disposition === "accepted") runtime.replaceSurfaces(surfaceInstances.snapshot()); return; } surfaceInstances.close(event.instance_id); surfaceHost.unmount(event.instance_id); runtime.replaceSurfaces(surfaceInstances.snapshot()); }); // These two Surfaces are shipped with the Host for SDK v1. Enabling them here // does not create a market or download path; it only admits the exact local // manifests used by the bundled reference MiniApps. for (const [appID, version] of [["lineup.task-dashboard", "0.1.0"], ["lineup.whiteboard", "0.1.0"]] as const) { surfaceRegistry.enable(appID, version, new Date().toISOString()); } $("#api").value = localStorage.getItem("lineup.api") || DEFAULT_API; /** * A pre-signed public fixture used only by the headed M4 acceptance route. * It contains no private key, is excluded from production builds, and forces * the actual Ed25519 → size → SHA-256 → cache → exact resolver path. */ async function mountM4SignedFixture(): Promise { // 这是开发环境专用的签名 Surface 验收入口。正常启动和生产构建不会执行这里。 if (!import.meta.env.DEV || !new URLSearchParams(location.search).has("m4-signed-surface-e2e")) return; // Bundle 使用公开测试数据,下面的流程会真实执行“验签 → 大小校验 → SHA-256 校验 // → 缓存 → 按精确版本解析”,用于验证生产 Surface 的准入链路。 const encodedBundle = "PG1haW4-PGgyIGlkPSJtNC1zaWduZWQiPk00IOW3suetvuWQjSBTdXJmYWNlPC9oMj48cCBpZD0ic3RhdGUiPuetieW-heeKtuaAgTwvcD48c2NyaXB0PndpbmRvdy5hZGRFdmVudExpc3RlbmVyKCJtZXNzYWdlIixlPT57Y29uc3QgZD1lLmRhdGE7aWYoZCYmZC50eXBlPT09ImxpbmV1cC5zdXJmYWNlLnYxLnN0YXRlIilkb2N1bWVudC5xdWVyeVNlbGVjdG9yKCIjc3RhdGUiKS50ZXh0Q29udGVudD1TdHJpbmcoZC5zdGF0ZT8uc3RhdHVzfHwi6L-Q6KGM5LitIil9KTt3aW5kb3cucGFyZW50LnBvc3RNZXNzYWdlKHt2OjEsdHlwZToibGluZXVwLnN1cmZhY2UudjEucmVhZHkiLGluc3RhbmNlX2lkOndpbmRvdy5fX0xJTkVVUF9TVVJGQUNFX0lOU1RBTkNFX199LCIqIik8L3NjcmlwdD48L21haW4-"; const standardBase64 = encodedBundle.replace(/-/g, "+").replace(/_/g, "/"); const bundle = Uint8Array.from(atob(standardBase64 + "=".repeat((4 - standardBase64.length % 4) % 4)), value => value.charCodeAt(0)); const manifest = { v: 1, app_id: "lineup.m4-signed-fixture", version: "1.0.0", artifact: { artifact_id: "m4-signed-fixture", sha256: "98cbcf29d38cf08ae6385539b3ac7359710309a1b752a14b60c8cfc8a39f1a37", size_bytes: 396 }, min_host_version: "0.1.0", permissions: ["surface.event.cancel"], key_id: "m4-e2e-key", signature: "dCORfPL6LTxR8CB5mVss5fEeGozTdfN3L7Td27BYgxJ518yNWAmI4FEhfrjEoc_Wovwn8u8HI5BLBjWSUxZ6Dg", } as const; const cache = new VerifiedSurfaceBundleCache(new WebCryptoEd25519SurfaceManifestSignatureVerifier({ "m4-e2e-key": "AKx_a1NVmsJKbqY9Jw55hLp8r01TcCadjZNp1u2POLI" })); const policy = new ProductionSurfacePolicy(new ProductionSurfaceManifestRegistry({ host_version: "0.1.0", trusted_key_ids: ["m4-e2e-key"] }), cache); const downloader = new TrustedSurfaceBundleDownloader("https://m4-e2e.lineup.invalid/release", async url => ({ ok: true, url: url.toString(), async bytes() { return bundle; } })); if ((await policy.downloadAndEnable(manifest, downloader)).disposition !== "installed") throw new Error("M4 signed fixture verification failed."); const instances = new SurfaceInstanceManager(policy); const host = new IsolatedSurfaceHost(messages, { ready(instanceID) { instances.ready(instanceID, new Date().toISOString()); }, event() { /* fixture grants no outbound capabilities or transport events */ }, }, new CachedVerifiedSurfaceDocumentResolver(cache)); const opened = instances.open({ instance_id: "m4:signed-fixture", app: { app_id: manifest.app_id, version: manifest.version }, state: { status: "签名验证后已挂载" } }, currentConversationKey(), new Date().toISOString()); if (opened.disposition !== "accepted" || !opened.instance) throw new Error(`M4 signed fixture open rejected: ${opened.disposition}`); host.mount(opened.instance); window.__lineupM4Fixture = { patch() { const result = instances.patch("m4:signed-fixture", { status: "已通过可信 patch 更新" }, new Date().toISOString()); if (result.disposition === "accepted" && result.instance) host.update(result.instance); }, close() { host.unmount("m4:signed-fixture"); instances.close("m4:signed-fixture"); }, }; } /** 生成只在本地 Store 中使用的临时 ID,不把它当作服务器身份或消息协议 ID。 */ function newLocalID(prefix: string): string { // `crypto.randomUUID` is absent from older Safari / WKWebView versions. // A local item id needs uniqueness within this browser store, not a // transport identity, so the timestamp-and-random fallback is sufficient // and must not prevent the form submit handler from reaching HTTP. const uuid = globalThis.crypto?.randomUUID?.(); return `${prefix}_${uuid ?? `${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`}`; } /** 将 Store/SDK 异常转换成用户可以理解的提示,同时保留发送尝试的语义。 */ function storageFailureMessage(reason: unknown): string { const detail = reason instanceof Error && reason.message ? `(${reason.message})` : ""; return `本地对话历史暂时无法保存${detail};消息仍会尝试发送,刷新后将从服务端重新同步。`; } /** 校验并规范化 AppServer 地址,只允许 HTTP/HTTPS,避免把任意协议交给 Transport。 */ function normalizeAPIAddress(value: string): string { const candidate = value.trim(); if (!candidate) throw new Error("请输入 AppServer 地址。"); const withProtocol = /^https?:\/\//i.test(candidate) ? candidate : `http://${candidate}`; let parsed: URL; try { parsed = new URL(withProtocol); } catch { throw new Error("AppServer 地址格式不正确,例如 http://100.121.118.116:8090。"); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("AppServer 地址仅支持 http:// 或 https://。"); if (!parsed.hostname) throw new Error("AppServer 地址缺少主机名或 IP 地址。"); return parsed.toString().replace(/\/$/, ""); } /** 把网络、超时和输入错误转换成登录页可显示的中文原因。 */ 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。请确认当前网络可以访问${destination}。`; return message || "登录失败,请稍后重试。"; } // Chat 是 Runtime 上的 Core App。它不接收 Transport 原始 payload;Runtime 会先完成 // 协议解析、作用域筛选、持久化和 App Inbox 投递,再通过 SDK 交给这里。 chatSDK.ui.subscribeAgentMessages(message => { // 收到 Agent 消息后,先把执行状态投影出来,再进行具体内容渲染,最后 ACK。 // ACK 表示 Chat 已经接管这条消息,Runtime 才可以从可靠 Inbox 中移除它。 projectExecutionProgress(message.item, message.received_at); renderIncoming(message.item); chatSDK.ui.acknowledgeAgentMessage(message.message_id); }); chatSDK.ui.subscribe(view => renderAppSessions(view.app_sessions)); function renderAppSessions(sessions: readonly { app_session_id: string; app_scope: string; instance_id: string; state: string; interaction_count: number; pending_interaction_count: number; history: readonly { id: string; role: "agent" | "user"; text: string }[]; continued_from_app_session_id?: string; }[]): void { appSessions.replaceChildren(); for (const session of sessions.filter(candidate => candidate.app_scope !== "chat")) { const group = document.createElement("details"); group.className = "app-session-group"; const summary = document.createElement("summary"); const name = session.app_scope === "task-dashboard" ? "Task Dashboard" : session.app_scope === "whiteboard" ? "Whiteboard" : session.app_scope; const pending = session.pending_interaction_count > 0 ? ` · 等待回答 ${session.pending_interaction_count}` : ""; summary.textContent = `${name} · ${session.interaction_count} 条交互${pending}`; const detail = document.createElement("small"); detail.textContent = session.state === "stopped" || session.state === "failed" ? "已结束,只读历史" : session.state === "background" ? "后台运行" : "当前应用会话"; group.append(summary, detail); const history = document.createElement("div"); history.className = "app-session-history"; for (const entry of session.history) { const line = document.createElement("p"); line.className = `app-session-history-${entry.role}`; line.textContent = `${entry.role === "agent" ? "Agent" : "你"}:${entry.text}`; history.append(line); } group.append(history); if (session.state === "stopped" || session.state === "failed") { const continueButton = document.createElement("button"); continueButton.type = "button"; continueButton.textContent = "继续处理"; continueButton.addEventListener("click", () => { void chatSDK.runtime.dispatch({ type: "chat.continue_app_session", conversation_id: chatSDK.ui.snapshot().conversation_id ?? "", app_session_id: session.app_session_id }); }); group.append(continueButton); } appSessions.append(group); } } chatSDK.subscribeEvents(event => { // 这些是 Runtime 给 Chat 的安全事件投影,不包含原始 Agent delivery。 if (event.type === "local-item") { renderIncoming(event.item); return; } if (event.type === "delivery" && event.item?.kind === "user-message") { rendererContext.updateUserDelivery(event.local_id, event.item.delivery); return; } if (event.type === "sync-status") { rendererContext.setPresence(event.status === "syncing" ? "正在同步消息…" : event.status === "idle" ? "已同步,等待消息" : "同步中断,正在重试…"); return; } if (event.type === "scope-rejected") { rendererContext.renderSystemMessage("收到作用域不匹配的 Agent 消息,已安全忽略。"); } }); function renderIncoming(item: ConversationItem): void { // 这是 Chat 的统一表现入口。只有 Runtime 已经验证并转换成 ConversationItem 的内容 // 才能到达这里;高风险类型仍需经过各自状态机或策略再次检查。 if (item.kind === "app-call") { // App/Capability 请求不能直接变成按钮点击。先解析能力声明,再交给状态机记录 // pending 状态;Renderer 只显示确认卡片,真正执行发生在用户批准之后。 const now = new Date().toISOString(); const parsed = parseCapabilityCall(item.envelope.payload, capabilityRegistry, now); if (parsed.disposition === "unsupported") { rendererContext.renderSystemMessage("此 App 能力当前未在本机启用,未执行任何操作。"); return; } if (parsed.disposition !== "accepted") { rendererContext.renderSystemMessage("收到无法安全确认的 App 能力请求。"); return; } const transition = capabilityCalls.receive(parsed.request, item.envelope.conversation_id, now); if (transition.disposition === "accepted" && transition.record) persistCapabilityCall(transition.record, now); // Both an accepted call and an exact protocol duplicate render from the // state machine. A duplicate cannot create a second actionable card. rendererContext.renderAppCall(item); return; } if (item.kind === "artifact-offer") { // Artifact 先验证元数据和可选内容,再由 Host 负责显示。持久化只保存元数据, // 不把二进制内容写入对话 Store。 const artifact = artifactState.offer(item.envelope.payload); if (!artifact) { rendererContext.renderSystemMessage("收到无法安全验证的 Artifact。 "); return; } rendererContext.renderArtifact(artifact); reconcileArtifactSaveAvailability(); return; } if (item.kind === "surface") { // Surface 生命周期消息分别对应打开、更新和关闭。每一步都由 // SurfaceInstanceManager 做幂等、作用域和状态跃迁检查。 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") runtime.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") runtime.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); runtime.replaceSurfaces(surfaceInstances.snapshot()); return; } } // 普通文本、状态、Tool、错误和 fallback 等内容交给受信 Renderer Registry; // 未注册的类型只能安全降级为提示,不能执行未知脚本。 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 { // 某个 Surface 可能在对应本地应用启用前就已到达。Runtime 保留了对话记录, // 用户启用应用后这里只重放持久化的 Surface 过渡,不会自动下载、启用或放宽校验。 const snapshot = runtime.snapshot(); if (!snapshot) return; for (const stored of snapshot.items) { if (stored.item.kind === "surface") renderIncoming(stored.item); } } /** * Inventory is a declarative host-to-Agent update, never an action request. * Failed delivery is retried by syncLoop; a revision changes only when public * availability changes, never because of a retry. */ async function publishInventory(): Promise { // Inventory 是“当前客户端有哪些公开能力”的声明,不是 Agent 发来的执行命令。 // revision 没变化时不重复发送;发送失败交由 Runtime outbox/sync loop 重试。 const session = runtime.getSession(); if (!session.active || !session.uid) return; const update = inventoryPublisher.sync(surfaceRegistry.snapshot(), capabilityRegistry.inventory(), runtime.apps.list()); if (update.inventory.revision === lastInventoryRevision) return; await runtime.sendProtocolEnvelope("lineup.v1.client.inventory", update.inventory as unknown as JsonObject); lastInventoryRevision = update.inventory.revision; } $("#surface-demo").addEventListener("click", () => { // 这是当前 MVP 的本地演示开关,用于启用一个可信 Surface 并刷新 Agent 可见 Inventory。 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(); runtime.replaceSurfaces(surfaceInstances.snapshot()); void publishInventory(); }); /** * Status only begins/ends a turn. Public operation labels can only come from * the separately validated adapter trace, never from Agent status/detail. */ function projectExecutionProgress(item: ConversationItem, now: string): void { // 将 Agent 的 thinking、validated execution trace 和终态错误/idle 状态聚合成一张 // 可恢复的执行摘要卡片。原始 status/detail 不被当作 Agent 的“思维内容”展示。 if (item.kind === "agent-status" && item.status === "thinking" && item.envelope?.sender.kind === "agent") { const summary = executionProgress.begin(item.execution_id ?? newLocalID("execution"), now); runtime.replaceExecutionSummaries(executionProgress.snapshot()); rendererContext.renderExecutionSummary(summary); return; } if (item.kind === "execution-summary") { const summary = executionProgress.applyTrace(item.trace.execution_id, item.trace.status, item.trace.steps, now); if (summary) { runtime.replaceExecutionSummaries(executionProgress.snapshot()); rendererContext.renderExecutionSummary(summary); } return; } const terminal = item.kind === "error" ? "failed" : (item.kind === "agent-status" && item.status === "idle" ? "completed" : undefined); if (!terminal) return; const executionID = item.kind === "agent-status" ? item.execution_id : undefined; if (!executionID) { const summaries = executionProgress.finishAllActive(terminal, now); if (summaries.length === 0) return; runtime.replaceExecutionSummaries(executionProgress.snapshot()); for (const summary of summaries) rendererContext.renderExecutionSummary(summary); return; } const summary = executionProgress.finish(executionID, terminal, now); if (!summary) return; runtime.replaceExecutionSummaries(executionProgress.snapshot()); rendererContext.renderExecutionSummary(summary); } function currentConversationKey(): string { // Surface 和全局 Runtime 事件都需要一个会话键;未登录时使用保留的全局作用域。 return runtime.currentConversationID() ?? "runtime:global"; } /** * Only host-owned bytes can make artifact.save requestable. The protocol's * local_ref is opaque and never dereferenced as a path/URL; if a host has no * cache entry the capability is absent from the next client.inventory. */ function reconcileArtifactSaveAvailability(): void { // 只有“已验证 Artifact + Host 仍持有对应临时内容”同时成立时,才把 artifact.save // 暴露给 Agent。内容失效后要立即从下一版 Inventory 中撤回该能力。 const available = artifactState.snapshot().some(artifact => artifact.integrity === "verified" && artifactContentCache.has(artifact.local_ref)); const before = capabilityRegistry.resolve("artifact.save").disposition === "available"; if (before === available) return; capabilityRegistry.setVisible("artifact.save", available); void publishInventory(); } function saveCachedArtifact(artifactID: string): void { // 这里只能保存 Host 自己缓存的 Blob。协议里的 local_ref 是不透明引用,绝不当作 // 文件路径或 URL 访问,下载完成后立即释放临时 Object URL。 const artifact = artifactState.get(artifactID); const blob = artifact?.integrity === "verified" && artifact.local_ref ? artifactContentCache.get(artifact.local_ref) : undefined; if (!artifact || !blob) throw new Error("artifact_cache_unavailable"); const objectURL = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = objectURL; anchor.download = artifact.name; anchor.hidden = true; document.body.append(anchor); try { anchor.click(); } finally { anchor.remove(); window.setTimeout(() => URL.revokeObjectURL(objectURL), 0); } } /** * HTTPS/Tauri hosts use the standard asynchronous Clipboard API. The HTTP * development reference host cannot rely on that API being exposed, so it * falls back to the browser's user-activation-gated copy command. Both paths * are reached only after the native capability confirmation; the temporary * textarea is removed synchronously and no clipboard value is persisted or * reflected into a protocol result. */ async function writeClipboardFromTrustedHost(text: string): Promise { // 复制操作只能在 Capability 状态机批准后执行。HTTPS/Tauri 优先使用 Clipboard API; // HTTP 开发 Host 退回到用户激活约束下的 execCommand,不持久化剪贴板内容。 if (navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(text); return; } catch { // Development HTTP contexts often expose the property but reject the // permission-gated call. Fall through to the user-activation fallback. } } const input = document.createElement("textarea"); input.value = text; input.readOnly = true; input.setAttribute("aria-hidden", "true"); input.style.cssText = "position:fixed;left:-9999px;top:0;opacity:0;pointer-events:none"; document.body.append(input); try { input.focus(); input.select(); if (!document.execCommand("copy")) throw new Error("clipboard_copy_unavailable"); } finally { input.remove(); } } /** * An optional small binary body is a bounded delivery payload, not a URL or * filesystem reference. It must bind exactly to the metadata size and opaque * local ref before it enters the host-owned volatile cache. */ function cacheArtifactContent(payload: JsonObject, artifact: { size_bytes: number; local_ref?: string }): boolean { // 可选二进制内容必须是有大小上限的 base64,并且实际字节数要和 Artifact 元数据一致。 // 通过后只进入 Host 的易失缓存,不进入 Runtime 的持久化对话记录。 const encoded = payload.content_base64; if (encoded === undefined) return true; if (typeof encoded !== "string" || !artifact.local_ref || encoded.length > 8 * 1024 * 1024) return false; try { const binary = atob(encoded); if (binary.length !== artifact.size_bytes || binary.length > 6 * 1024 * 1024) return false; const bytes = Uint8Array.from(binary, character => character.charCodeAt(0)); return artifactContentCache.put(artifact.local_ref, new Blob([bytes])); } catch { return false; } } /** * Cache optional bytes before persistence, then remove them from the durable * transcript. A reload can show metadata but cannot retain volatile content * or silently restore a save operation without a new host-owned offer. */ function prepareArtifactOffer(item: Extract): Extract | undefined { // Runtime 写入 Store 前先调用这个 Host hook:缓存内容后从持久化 payload 移除 base64, // 让刷新后仍能恢复 Artifact 元数据,但不能无提示地恢复本地文件保存动作。 const payload = item.envelope.payload; const localRef = typeof payload.local_ref === "string" ? payload.local_ref : undefined; const size = typeof payload.size_bytes === "number" ? payload.size_bytes : -1; if (!cacheArtifactContent(payload, { size_bytes: size, ...(localRef ? { local_ref: localRef } : {}) })) return undefined; if (!("content_base64" in payload)) return item; const { content_base64: _content, ...metadata } = payload; return { ...item, envelope: { ...item.envelope, payload: metadata } }; } function persistCapabilityCall(record: CapabilityCallRecord, occurredAt: string): void { // Capability 状态变化同时更新 Runtime 快照,并写入不含敏感参数的最小审计记录。 runtime.replaceCapabilityCalls(capabilityCalls.snapshot()); const resolution = capabilityRegistry.resolve(record.request.capability); if (resolution.disposition === "available") capabilityAudit.record(record, resolution.definition.risk, occurredAt); } function rejectCapabilityCall(callID: string): CapabilityCallRecord | undefined { // 用户拒绝能力请求:只推进本地状态并向 Agent 回传受限结果,不执行 Host handler。 const now = new Date().toISOString(); const transition = capabilityCalls.reject(callID, now); if (transition.disposition !== "accepted" || !transition.record) return transition.record; persistCapabilityCall(transition.record, now); queueAppResult(transition.record); return transition.record; } function expireCapabilityCall(callID: string): CapabilityCallRecord | undefined { // 请求过期与拒绝类似,属于终态;过期请求不能再次被批准或执行。 const now = new Date().toISOString(); const transition = capabilityCalls.expire(callID, now); if (transition.disposition !== "accepted" || !transition.record) return transition.record; persistCapabilityCall(transition.record, now); queueAppResult(transition.record); return transition.record; } async function approveCapabilityCall(callID: string): Promise { // 用户批准后仍要重新检查当前 Capability Policy,防止“显示确认卡片后策略已变化” // 的旧请求绕过最新权限。只有状态机进入 executing 才会调用 Host handler。 let now = new Date().toISOString(); const approval = capabilityCalls.approve(callID, now); if (approval.disposition !== "accepted" || !approval.record) return approval.record; persistCapabilityCall(approval.record, now); // Re-check local policy at the moment authority is consumed. A stale // inventory or a policy change between display and click cannot run a host // handler merely because a confirmation card was previously visible. const resolution = capabilityRegistry.resolve(approval.record.request.capability); if (resolution.disposition !== "available") { const unsupported = capabilityCalls.unsupported(callID, new Date().toISOString()); if (unsupported.record) { persistCapabilityCall(unsupported.record, new Date().toISOString()); queueAppResult(unsupported.record); } return unsupported.record; } now = new Date().toISOString(); const beginning = capabilityCalls.begin(callID, now); if (beginning.disposition !== "accepted" || !beginning.record) return beginning.record; persistCapabilityCall(beginning.record, now); const execution = await capabilityExecutor.execute(beginning.record); now = new Date().toISOString(); const terminal = execution.status === "cancelled" ? capabilityCalls.cancel(callID, now) : capabilityCalls.finish(callID, execution.status, execution.result, now); if (terminal.record) { persistCapabilityCall(terminal.record, now); if (terminal.disposition === "accepted") queueAppResult(terminal.record); } return terminal.record; } /** * Sends a bounded outcome projection, never a capability argument, native * exception, local file path or Artifact reference. This is also the durable * idempotency boundary: only a newly accepted terminal transition queues it. */ function queueAppResult(record: CapabilityCallRecord): void { // 回传给 Agent 的只是有限结果(call_id、能力名、状态和有限错误码),不包含 URL、 // 剪贴板正文、本地路径、异常堆栈或 Artifact 引用等敏感输入。 const conversationID = chatSDK.ui.snapshot().conversation_id; if (!conversationID) return; const payload = safeCapabilityResult(record); void chatSDK.runtime.dispatch({ type: "chat.report_app_result", conversation_id: conversationID, result: payload }) .catch(reason => rendererContext.renderSystemMessage(storageFailureMessage(reason))); } function safeCapabilityResult(record: CapabilityCallRecord): JsonObject { // 构造稳定、可幂等消费的能力结果投影;成功只报告 completed,失败只报告受控 code。 const outcome: JsonObject = { call_id: record.call_id, capability: record.request.capability, status: record.status, }; if (record.status === "failed") outcome.code = typeof record.result?.code === "string" ? record.result.code : "failed"; if (record.status === "completed") outcome.completed = true; return outcome; } function pickFileFromTrustedHost(): Promise { // 通过浏览器原生文件选择器获得最小文件元数据。这里不读取或保存本地真实路径, // 用户取消选择时也必须显式结束 pending Capability,避免状态机永久执行中。 return new Promise(resolve => { const input = document.createElement("input"); input.type = "file"; input.hidden = true; let settled = false; let onFocus: () => void; const finish = (value: PickedFile | undefined) => { if (settled) return; settled = true; window.removeEventListener("focus", onFocus); input.remove(); resolve(value); }; input.addEventListener("change", () => { const file = input.files?.item(0); finish(file ? { name: file.name, mime_type: file.type || "application/octet-stream", size_bytes: file.size } : undefined); }, { once: true }); // Cancelling a native picker does not consistently dispatch `change`. // Once focus returns to the trusted host, resolve a no-selection outcome; // no input.value/path is ever read or retained. onFocus = () => window.setTimeout(() => { const file = input.files?.item(0); if (!file) finish(undefined); }, 0); document.body.append(input); // Register before triggering the native dialog. Some browser/desktop // combinations return focus immediately on cancellation; registering in // a later task can miss that only signal and leave the capability call // permanently executing. window.addEventListener("focus", onFocus, { once: true }); input.click(); }); } function queueSurfaceCancel(instanceID: string): void { // Surface 内部事件只转换为 Runtime Action;它不能直接发送网络消息或调用系统能力。 const conversationID = chatSDK.ui.snapshot().conversation_id; if (!conversationID) return; void chatSDK.runtime.dispatch({ type: "chat.cancel_surface", conversation_id: conversationID, instance_id: instanceID }) .then(receipt => { if (receipt.disposition === "accepted") rendererContext.renderSystemMessage("已请求取消任务。"); }) .catch(reason => rendererContext.renderSystemMessage(storageFailureMessage(reason))); } // 登录是唯一会创建 Runtime 远端会话的页面操作。登录成功后由 Runtime 返回恢复快照, // main.ts 负责把快照投影到 Chat、挂载现有 Surface,并启动同步循环。 $("#login-form").addEventListener("submit", async event => { event.preventDefault(); const apiInput = $("#api"); const phone = $("#phone").value.trim(); const code = $("#code").value.trim(); const error = $("#login-error"); const submit = $("#login-submit"); let endpoint: string; try { endpoint = normalizeAPIAddress(apiInput.value); } catch (reason) { error.textContent = loginFailureMessage(reason); apiInput.focus(); return; } if (!phone) { error.textContent = "请输入手机号。"; $("#phone").focus(); return; } if (!code) { error.textContent = "请输入验证码。"; $("#code").focus(); return; } localStorage.setItem("lineup.api", endpoint); error.textContent = `正在连接 ${endpoint}…`; submit.disabled = true; submit.textContent = "正在进入…"; try { // Runtime 内部负责 Transport 登录、打开 Conversation Store 和恢复 Tool/Task 状态。 const snapshot = await runtime.login({ api: endpoint, phone, code, fallback_agent_uid: "agent_hermes_main", fallback_channel_id: "agent_default_channel", fallback_channel_type: 2, }); // 先恢复可持久化的交互状态,再清空并重建页面投影。 surfaceInstances.restore(snapshot.surfaces); capabilityCalls.restore(snapshot.capability_calls); executionProgress.restore(snapshot.execution_summaries); messages.replaceChildren(); rendererContext.reset(); loginView.hidden = true; chatView.hidden = false; // Status envelopes describe ephemeral activity. The durable M1-08 summary // below is the only restored execution view, so historical `thinking` // messages cannot create a fresh or expanded pseudo-live card on reload. // thinking 等状态消息只描述当时的临时活动,不在刷新后重新创建一张“正在执行”卡; // 可恢复的执行摘要由下面的 restoreExecutionSummaries 统一恢复。 for (const stored of snapshot.items) { if (stored.item.kind !== "agent-status") renderIncoming(stored.item); } // Runtime retained Agent deliveries while this Core App was not mounted. // The restored transcript above is now their successful Chat projection, // so acknowledge the durable Inbox records without replaying duplicate // DOM nodes or re-executing a user action. // 这些 Inbox 消息已经通过持久化 transcript 成功渲染,因此这里只 ACK,不再次渲染, // 避免刷新后出现重复消息或重新执行用户动作。 for (const pending of chatSDK.ui.listAgentMessages()) chatSDK.ui.acknowledgeAgentMessage(pending.message_id); for (const instance of surfaceInstances.snapshot().instances) surfaceHost.mount(instance); rendererContext.restoreExecutionSummaries(snapshot.execution_summaries); await mountM4SignedFixture(); rendererContext.renderSystemMessage("已进入与 AI Agent 的对话"); rendererContext.setPresence("正在同步消息…"); void publishInventory(); // 页面恢复完成后才开始增量同步,避免历史恢复与实时投递交错造成重复显示。 runtime.startSync(); } catch (reason) { error.textContent = loginFailureMessage(reason, endpoint); } finally { submit.disabled = false; submit.textContent = "进入 LineUp"; } }); // 发送消息必须通过 Chat SDK 提交 Runtime Action。Runtime 会负责本地回显、outbox、 // 作用域校验和 Transport 发送,页面只负责采集输入和显示结果。 $("#message-form").addEventListener("submit", async event => { event.preventDefault(); const input = $("#message-input"); const send = $("#send"); const text = input.value.trim(); if (!text) return; const conversationID = chatSDK.ui.snapshot().conversation_id; if (!conversationID) { rendererContext.renderSystemMessage("当前会话尚未初始化,无法发送消息;请退出后重新登录。"); input.focus(); return; } input.value = ""; send.disabled = true; try { const receipt = await chatSDK.runtime.dispatch({ type: "chat.send_text", conversation_id: conversationID, text }); if (receipt.disposition === "rejected") rendererContext.renderSystemMessage("当前会话无法发送消息,请退出后重新登录。"); } catch (reason) { rendererContext.renderSystemMessage(storageFailureMessage(reason)); } finally { send.disabled = false; input.focus(); } }); // 退出时停止同步、清理 Runtime 会话和当前页面投影,但保留 localStorage 中的配置/历史, // 以便下一次登录时由 Runtime 重新恢复。 $("#logout").addEventListener("click", () => { runtime.logout(); messages.replaceChildren(); rendererContext.reset(); chatView.hidden = true; loginView.hidden = false; });