From 823e71a162400b0a581485ebdf05b75b1bd07c5a Mon Sep 17 00:00:00 2001 From: kyugao Date: Thu, 6 Aug 2026 00:32:59 +0800 Subject: [PATCH] refactor(interact): separate runtime actions from ui projection --- APP架构设计.md | 6 +++ .../src/core-apps/chat/interaction-runtime.ts | 4 +- tauri/src/main.ts | 40 +++++++-------- tauri/src/runtime/app-management/app-sdk.ts | 38 +++++++++++++- .../coordination/lineup-runtime.test.ts | 4 ++ .../runtime/coordination/lineup-runtime.ts | 51 ++++++++++--------- 迭代/03.sdk_and_coreapp/03.sdk_and_coreapp.md | 19 +++++++ .../03.sdk_and_coreapp/04.acceptance_review.md | 20 ++++---- 8 files changed, 125 insertions(+), 57 deletions(-) diff --git a/APP架构设计.md b/APP架构设计.md index 39292b3..fd276c9 100644 --- a/APP架构设计.md +++ b/APP架构设计.md @@ -78,6 +78,12 @@ Interact 是可信内建代码,因此可以使用 LineUp 的可信 DOM 组件 必须经 SDK 请求 Runtime 行为,不能直接访问 Transport、Conversation Store、焦点栈、App Registry、原始 Agent Envelope 或系统能力。 +Interact 的 SDK 采用“通用 Runtime 操作 + 可信 UI 投影”的适配方式:Runtime 操作负责消息发送、 +标准交互提交/取消/过期、草稿和任务操作;UI 投影只负责 IM 视图、Agent 消息订阅和子会话展示。 +UI 投影不是另一套通信或存储协议,Interact 的可信 DOM 也不因此取得 Transport、Store、原始 +Agent Envelope、Host DOM 根节点或系统能力。当前 `chat` 兼容接口保留扁平方法,但新代码应按 +`sdk.runtime.*` 和 `sdk.ui.*` 使用。 + ## 3. 当前技术与 Host 基线 唯一客户端实现与验收基线是: diff --git a/tauri/src/core-apps/chat/interaction-runtime.ts b/tauri/src/core-apps/chat/interaction-runtime.ts index ad0b4c9..c6ca67b 100644 --- a/tauri/src/core-apps/chat/interaction-runtime.ts +++ b/tauri/src/core-apps/chat/interaction-runtime.ts @@ -1,11 +1,11 @@ -import type { ChatRuntimeSDK } from "@/runtime/app-management/app-sdk"; +import type { InteractRuntimeSDK } from "@/runtime/app-management/app-sdk"; import { InteractionModeRegistry, type InteractionMode } from "@/core-apps/chat/interaction-mode-registry"; /** Restricted Interaction App projection: IM today, Audio/Video when enabled. */ export class InteractionRuntime { public readonly modes = new InteractionModeRegistry(); private current: InteractionMode = "im"; - public constructor(public readonly sdk: ChatRuntimeSDK) {} + public constructor(public readonly sdk: InteractRuntimeSDK) {} public activeMode(): InteractionMode { return this.current; } public selectMode(mode: InteractionMode): boolean { if (!this.modes.get(mode).enabled) return false; diff --git a/tauri/src/main.ts b/tauri/src/main.ts index f30af70..9b64b89 100644 --- a/tauri/src/main.ts +++ b/tauri/src/main.ts @@ -102,17 +102,17 @@ let chatSDK: ReturnType; // Chat Renderer 不会获得 Transport、Store 或 Tauri 特权对象。 const mountedApp = new RuntimeAppHost(runtime, app, { toolInteractions: { - submit(callID, submission) { return chatSDK.interactions.submit(callID, submission); }, - cancel(callID, reason) { return chatSDK.interactions.cancel(callID, reason); }, - expire(callID) { return chatSDK.interactions.expire(callID); }, - read(callID) { return chatSDK.interactions.read(callID); }, - loadDraft(callID) { return chatSDK.interactions.loadDraft(callID); }, - saveDraft(callID, values) { chatSDK.interactions.saveDraft(callID, values); }, - clearDraft(callID) { chatSDK.interactions.clearDraft(callID); }, + 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.tasks.requestCancel(operationID); }, - read(operationID) { return chatSDK.tasks.read(operationID); }, + requestCancel(operationID) { return chatSDK.runtime.tasks.requestCancel(operationID); }, + read(operationID) { return chatSDK.runtime.tasks.read(operationID); }, }, capabilityInteractions: { async approve(callID) { return approveCapabilityCall(callID); }, @@ -273,15 +273,15 @@ function loginFailureMessage(reason: unknown, endpoint?: string): string { // Chat 是 Runtime 上的 Core App。它不接收 Transport 原始 payload;Runtime 会先完成 // 协议解析、作用域筛选、持久化和 App Inbox 投递,再通过 SDK 交给这里。 -chatSDK.subscribeAgentMessages(message => { +chatSDK.ui.subscribeAgentMessages(message => { // 收到 Agent 消息后,先把执行状态投影出来,再进行具体内容渲染,最后 ACK。 // ACK 表示 Chat 已经接管这条消息,Runtime 才可以从可靠 Inbox 中移除它。 projectExecutionProgress(message.item, message.received_at); renderIncoming(message.item); - chatSDK.acknowledgeAgentMessage(message.message_id); + chatSDK.ui.acknowledgeAgentMessage(message.message_id); }); -chatSDK.subscribe(view => renderAppSessions(view.app_sessions)); +chatSDK.ui.subscribe(view => renderAppSessions(view.app_sessions)); function renderAppSessions(sessions: readonly { app_session_id: string; @@ -318,7 +318,7 @@ function renderAppSessions(sessions: readonly { continueButton.type = "button"; continueButton.textContent = "继续处理"; continueButton.addEventListener("click", () => { - void chatSDK.dispatch({ type: "chat.continue_app_session", conversation_id: chatSDK.snapshot().conversation_id ?? "", app_session_id: session.app_session_id }); + 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); } @@ -667,10 +667,10 @@ async function approveCapabilityCall(callID: string): Promise rendererContext.renderSystemMessage(storageFailureMessage(reason))); } @@ -725,9 +725,9 @@ function pickFileFromTrustedHost(): Promise { function queueSurfaceCancel(instanceID: string): void { // Surface 内部事件只转换为 Runtime Action;它不能直接发送网络消息或调用系统能力。 - const conversationID = chatSDK.snapshot().conversation_id; + const conversationID = chatSDK.ui.snapshot().conversation_id; if (!conversationID) return; - void chatSDK.dispatch({ type: "chat.cancel_surface", conversation_id: conversationID, instance_id: instanceID }) + void chatSDK.runtime.dispatch({ type: "chat.cancel_surface", conversation_id: conversationID, instance_id: instanceID }) .then(receipt => { if (receipt.disposition === "accepted") rendererContext.renderSystemMessage("已请求取消任务。"); }) @@ -783,7 +783,7 @@ $("#login-form").addEventListener("submit", async event => { // DOM nodes or re-executing a user action. // 这些 Inbox 消息已经通过持久化 transcript 成功渲染,因此这里只 ACK,不再次渲染, // 避免刷新后出现重复消息或重新执行用户动作。 - for (const pending of chatSDK.listAgentMessages()) chatSDK.acknowledgeAgentMessage(pending.message_id); + 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(); @@ -801,7 +801,7 @@ $("#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.snapshot().conversation_id; + const conversationID = chatSDK.ui.snapshot().conversation_id; if (!conversationID) { rendererContext.renderSystemMessage("当前会话尚未初始化,无法发送消息;请退出后重新登录。"); input.focus(); @@ -809,7 +809,7 @@ $("#message-form").addEventListener("submit", async event => { } input.value = ""; send.disabled = true; try { - const receipt = await chatSDK.dispatch({ type: "chat.send_text", conversation_id: conversationID, text }); + 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)); diff --git a/tauri/src/runtime/app-management/app-sdk.ts b/tauri/src/runtime/app-management/app-sdk.ts index 4672b12..f623ef1 100644 --- a/tauri/src/runtime/app-management/app-sdk.ts +++ b/tauri/src/runtime/app-management/app-sdk.ts @@ -52,8 +52,41 @@ export type CommandReceipt = | { disposition: "accepted"; local_id?: string } | { disposition: "rejected"; code: "inactive" | "scope_mismatch" | "invalid_action" | "capability_not_declared" | "capability_unavailable" | "invalid_capability_request" | "capability_requires_foreground" }; -export interface ChatRuntimeSDK { +export type InteractRuntimeOperations = Readonly<{ + dispatch(action: ChatAction): Promise; + interactions: { + submit(callID: string, result: JsonObject): boolean; + cancel(callID: string, reason: string): boolean; + expire(callID: string): boolean; + read(callID: string): ToolCallRecord | undefined; + loadDraft(callID: string): JsonObject | undefined; + saveDraft(callID: string, values: JsonObject): void; + clearDraft(callID: string): void; + }; + tasks: { + requestCancel(operationID: string): boolean; + read(operationID: string): TaskRecord | undefined; + }; +}>; + +export type InteractUIProjection = Readonly<{ + snapshot(): ChatViewModel; + subscribe(listener: (view: ChatViewModel) => void): Unsubscribe; + subscribeEvents(listener: (event: Exclude) => void): Unsubscribe; + subscribeAgentMessages(listener: (message: AgentChatMessage) => void): Unsubscribe; + listAgentMessages(afterMessageID?: string): readonly AgentChatMessage[]; + acknowledgeAgentMessage(messageID: string): boolean; +}>; + +/** + * Interact's SDK is the common Runtime contract plus a trusted-DOM UI projection. + * The UI projection is an adapter for IM rendering; it is not a second transport, + * persistence, Tool or capability protocol. + */ +export interface InteractRuntimeSDK { readonly app_scope: "chat"; + readonly runtime: InteractRuntimeOperations; + readonly ui: InteractUIProjection; snapshot(): ChatViewModel; subscribe(listener: (view: ChatViewModel) => void): Unsubscribe; /** Chat-safe Runtime events, excluding raw Agent delivery (use subscribeAgentMessages). */ @@ -79,6 +112,9 @@ export interface ChatRuntimeSDK { }; } +/** @deprecated Use InteractRuntimeSDK; kept for the compatibility app_scope=chat name. */ +export type ChatRuntimeSDK = InteractRuntimeSDK; + /** * The intentionally smaller SDK granted to an installed Extension App. It * does not reveal another App's inbox, the focus stack, Transport, DOM root, diff --git a/tauri/src/runtime/coordination/lineup-runtime.test.ts b/tauri/src/runtime/coordination/lineup-runtime.test.ts index 62b3896..e9257e7 100644 --- a/tauri/src/runtime/coordination/lineup-runtime.test.ts +++ b/tauri/src/runtime/coordination/lineup-runtime.test.ts @@ -105,6 +105,10 @@ describe("LineUpRuntime", () => { ])); const chat = runtime.openApp("chat"); expect(chat.app_scope).toBe("chat"); + expect(chat.runtime.dispatch).toBe(chat.dispatch); + expect(chat.runtime.interactions).toBe(chat.interactions); + expect(chat.runtime.tasks).toBe(chat.tasks); + expect(chat.ui.snapshot).toBe(chat.snapshot); expect(chat.snapshot()).toMatchObject({ app_scope: "chat", active: false }); await runtime.login(login); diff --git a/tauri/src/runtime/coordination/lineup-runtime.ts b/tauri/src/runtime/coordination/lineup-runtime.ts index 975b90e..543b2ad 100644 --- a/tauri/src/runtime/coordination/lineup-runtime.ts +++ b/tauri/src/runtime/coordination/lineup-runtime.ts @@ -253,40 +253,43 @@ export class LineUpRuntime { const app = this.apps.get(appScope); if (!app?.enabled) throw new Error(`The ${appScope} Core App is unavailable.`); if (appScope !== "chat") throw new Error(`The ${appScope} Core App SDK is not registered.`); - return { - app_scope: "chat", + const ui = { snapshot: () => this.chatView(), - subscribe: listener => { + subscribe: (listener: (view: ChatViewModel) => void) => { this.chatStateListeners.add(listener); listener(this.chatView()); return () => this.chatStateListeners.delete(listener); }, - subscribeEvents: listener => this.onEvent(event => { - // Agent content has a separate, scope-checked SDK channel. Keeping it - // out of the generic event stream prevents a Chat renderer from - // accidentally consuming an unprojected delivery path. + subscribeEvents: (listener: (event: Exclude) => void) => this.onEvent(event => { if (event.type !== "agent-message") listener(event); }), - subscribeAgentMessages: listener => { + subscribeAgentMessages: (listener: (message: AgentChatMessage) => void) => { this.chatMessageListeners.add(listener); return () => this.chatMessageListeners.delete(listener); }, - listAgentMessages: afterMessageID => this.listChatMessages(afterMessageID), - acknowledgeAgentMessage: messageID => this.acknowledgeChatMessage(messageID), - dispatch: action => this.dispatchChatAction(action), - interactions: { - submit: (callID, result) => this.submitToolCall(callID, result), - cancel: (callID, reason) => this.cancelToolCall(callID, reason), - expire: callID => this.expireToolCall(callID), - read: callID => this.toolCalls().find(record => record.call_id === callID), - loadDraft: callID => this.getToolDraft(callID), - saveDraft: (callID, values) => this.saveToolDraft(callID, values), - clearDraft: callID => this.clearToolDraft(callID), - }, - tasks: { - requestCancel: operationID => this.requestTaskCancel(operationID), - read: operationID => this.tasks().find(task => task.operation_id === operationID), - }, + listAgentMessages: (afterMessageID?: string) => this.listChatMessages(afterMessageID), + acknowledgeAgentMessage: (messageID: string) => this.acknowledgeChatMessage(messageID), + }; + const interactions = { + submit: (callID: string, result: JsonObject) => this.submitToolCall(callID, result), + cancel: (callID: string, reason: string) => this.cancelToolCall(callID, reason), + expire: (callID: string) => this.expireToolCall(callID), + read: (callID: string) => this.toolCalls().find(record => record.call_id === callID), + loadDraft: (callID: string) => this.getToolDraft(callID), + saveDraft: (callID: string, values: JsonObject) => this.saveToolDraft(callID, values), + clearDraft: (callID: string) => this.clearToolDraft(callID), + }; + const tasks = { + requestCancel: (operationID: string) => this.requestTaskCancel(operationID), + read: (operationID: string) => this.tasks().find(task => task.operation_id === operationID), + }; + const runtime = { dispatch: (action: ChatAction) => this.dispatchChatAction(action), interactions, tasks }; + return { + app_scope: "chat", + runtime, + ui, + ...ui, + ...runtime, }; } diff --git a/迭代/03.sdk_and_coreapp/03.sdk_and_coreapp.md b/迭代/03.sdk_and_coreapp/03.sdk_and_coreapp.md index 053e020..b597305 100644 --- a/迭代/03.sdk_and_coreapp/03.sdk_and_coreapp.md +++ b/迭代/03.sdk_and_coreapp/03.sdk_and_coreapp.md @@ -119,6 +119,25 @@ Task Dashboard 和 Whiteboard 的安全与运行时模型必须与未来的一 绑定受限 Surface 的装配代码;它不得把 MiniApp 业务 UI 挂到可信 Host DOM,也不得提供 Tauri、 Transport、Store、Agent、任意网络或其他 MiniApp 数据访问。 +#### 2.4 Interact 的 SDK 适配边界 + +Interact 使用“通用 Runtime 操作 + 可信 UI 投影”的适配方式: + +```text +Interact SDK +├── runtime:消息发送、标准交互提交/取消/过期、草稿和任务操作 +└── ui:IM 视图、Agent 消息订阅、消息确认和子会话展示 +``` + +`runtime` 是行为的统一入口,所有提交、取消、发送和生命周期动作仍由 Runtime 校验、持久化和回传。 +`ui` 只是给可信 DOM Renderer 使用的 IM 投影,不是第二套 Transport、Store、Tool 或 Capability +协议。Interact 可以继续使用可信 DOM,但不能因为可信就直接访问 Transport、ConversationStore、原始 +Agent Envelope、Host DOM 根节点或系统能力。 + +当前代码中的扁平方法(例如 `sdk.dispatch()`、`sdk.interactions.submit()`)只是兼容别名;新代码应使用 +`sdk.runtime.*` 和 `sdk.ui.*`,以明确区分 Runtime 行为与 UI 展示。普通 bundled MiniApp 不获得这组 +IM UI 投影,只使用通用 MiniApp SDK 和自己的受限 Surface。 + ## 3. MiniApp SDK v1 契约 SDK 是 Runtime 根据 Manifest、实例状态、scope 和 Policy 注入的受限对象。SDK 中的所有写操作都 diff --git a/迭代/03.sdk_and_coreapp/04.acceptance_review.md b/迭代/03.sdk_and_coreapp/04.acceptance_review.md index 84ba5ac..53a2979 100644 --- a/迭代/03.sdk_and_coreapp/04.acceptance_review.md +++ b/迭代/03.sdk_and_coreapp/04.acceptance_review.md @@ -20,7 +20,7 @@ | ✅ | P1 | A2 | `bundled` MiniApp 的业务逻辑仍在可信 Host JS 中运行,没有真正的受限执行边界 | `tauri/src/main.ts`、`isolated-surface-host.ts` | 已移除 Host 直接实例化;Task Dashboard/Whiteboard 业务逻辑改在 opaque sandbox iframe 内运行,只能通过 Runtime Bridge 请求能力。待浏览器复验。 | | ✅ | P1 | A3 | SDK 暴露完整 workspace,MiniApp 可看到其他 App 实例和全局焦点栈 | `lineup-runtime.ts`、`miniapp-sdk.ts` | 已从 `LineUpMiniAppSDK` 删除 `workspace()`,不再把全局实例和焦点栈交给 bundled MiniApp。 | | ✅ | P1 | A4 | 同一个 App 的多个 instance 之间 Inbox 订阅没有隔离 | `lineup-runtime.ts` | 已按 `app_scope + conversation_id + instance_id` 过滤实时订阅,并补充双实例回归测试。 | -| 🔴 | P1 | A5 | Interact 使用专用 `ChatRuntimeSDK` 旁路,没有和其他 MiniApp 共用 SDK v1 语义 | `tauri/src/core-apps/chat/chat-app-host.ts:67-75`、`main.ts:103-125` | 需要明确并实现统一 SDK 适配:Interact 可有可信 DOM,但交互、Tool、生命周期仍必须通过统一 Runtime 契约;不能让专用接口成为另一套协议。 | +| ✅ | P1 | A5 | Interact 使用专用 `ChatRuntimeSDK` 旁路,没有和其他 MiniApp 共用 SDK v1 语义 | `tauri/src/runtime/app-management/app-sdk.ts`、`lineup-runtime.ts`、`main.ts` | 已明确为“通用 Runtime 操作 + 可信 UI 投影”:Interact 保留可信 DOM 和 IM 展示,但行为统一走 `sdk.runtime.*`,展示走 `sdk.ui.*`;旧扁平方法仅作兼容别名。 | | 🔴 | P1 | A6 | 标准交互的超时、dismiss、提交和答案 schema 没有由 Runtime 统一裁决 | `lineup-runtime.ts:507-558, 798-815`、`agent-interaction-service.ts:93-101` | 必须让 Interaction 与 Kernel Tool 共用一个终态迁移;超时/dismiss 要写唯一 Agent outbox;提交前按 `notice/choice/confirm/input` 校验答案,拒绝重复和竞态。 | | 🔴 | P2 | A7 | 迭代文档中的 Tool 名称和代码 Manifest 不一致 | 主文档 §5.2/§5.3;`reference-miniapps.ts` | 统一契约、Manifest、fixture、Agent Inventory 和验收脚本中的名称。 | | 🔴 | P2 | A8 | 当前 Task Dashboard/Whiteboard Surface 主要是 Host 硬编码的静态页面,无法证明真实业务 UI 在各自受限 Surface 内运行 | `main.ts:180-198`、`isolated-surface-host.ts` | 至少提供能代表业务边界的受限 Surface fixture,并验证 MiniApp 业务逻辑不能取得 Host DOM 或 Runtime 内部。 | @@ -68,12 +68,12 @@ Store 或 Transport 注入 MiniApp。 `inbox.list()`、ACK 和 `inbox.subscribe()` 现在都按 `app_scope + conversation_id + instance_id` 过滤;新增回归 测试验证同一 App 的两个 instance 不会互收消息。 -### A5:Interact 的 SDK 语义没有统一 +### A5:Interact 的 SDK 语义没有统一(已修复) -Interact 使用 `ChatRuntimeSDK` 和 Host 注入的 `toolInteractions`,而 Task Dashboard/Whiteboard 使用 -`LineUpMiniAppSDK`。可信 DOM 可以是 Interact 与 bundled 的不同实现方式,但交互归属、Tool 状态、Runtime -动作和错误/恢复语义不能因此分成两套没有共同契约的接口。当前代码无法用一套 SDK golden fixture 证明三类 App -遵守相同的 Runtime 边界。 +Interact 仍然可以使用可信 DOM,但它的 SDK 已明确拆成两层:`sdk.runtime.*` 提供 Runtime 行为入口, +`sdk.ui.*` 提供 IM 和子会话的可信展示投影。标准交互、消息发送、草稿、任务操作仍由 Runtime 完成;UI +投影没有 Transport、Store、Agent 原始 Envelope 或 Host 特权。旧的扁平 `ChatRuntimeSDK` 方法只保留为 +兼容别名,避免把 Interact 的 UI 适配误解成另一套协议。 ### A6:标准交互有两套状态,可能互相打架 @@ -86,16 +86,16 @@ Interact 使用 `ChatRuntimeSDK` 和 Host 注入的 `toolInteractions`,而 Tas ## 3. 验收结论 -当前结论为(完成本轮 P1-1 后): +当前结论为(完成本轮 P1-1~P1-3 后): ```text 功能回归:通过 基础构建与自动化测试:通过 真实登录和消息发送:通过 -App 架构边界:部分通过(A1/A2 已实现,A2 待浏览器复验;A5/A6 仍不通过) -MiniApp 隔离与 SDK 规范:A1~A4 已通过,Interact/状态机仍不通过 +App 架构边界:部分通过(A1/A2/A5 已实现,A2 待浏览器复验;A6 仍不通过) +MiniApp 隔离与 SDK 规范:A1~A5 已通过,标准交互状态机仍不通过 标准交互终态契约:不通过 -迭代整体验收:不通过,继续处理 A5/A6;A2 需在最终浏览器复验中确认 +迭代整体验收:不通过,继续处理 A6;A2 需在最终浏览器复验中确认 ``` 本次验收没有修改实现代码。下一步应先处理 P1,再重新运行自动化测试和真实浏览器验收;P2/P3 不能被静默