From 9fb8cd15989d5d5fbd7129afba4f450289328c2d Mon Sep 17 00:00:00 2001 From: kyugao Date: Wed, 5 Aug 2026 00:46:16 +0800 Subject: [PATCH] feat(runtime): establish miniapp kernel and sdk design --- APP架构设计.md | 528 +++++++++++++ DESIGN.md | 319 -------- M4_COMPATIBILITY_AND_RELEASE.md | 33 - README.md | 115 ++- TECHNOLOGY_SELECTION.md | 158 ---- tauri/README.md | 59 +- tauri/src-tauri/src/lib.rs | 1 + tauri/src-tauri/src/main.rs | 1 + tauri/src/README.md | 61 ++ tauri/src/core-apps/chat/chat-app-host.ts | 90 +++ tauri/src/core-apps/chat/chat-shell.ts | 26 + .../chat/interaction-mode-registry.ts | 24 + .../src/core-apps/chat/interaction-runtime.ts | 15 + .../chat}/renderer-registry.ts | 8 +- .../chat/styles/app.css} | 1 + .../chat/styles}/capability-card.css | 1 + .../chat/styles}/execution-progress.css | 1 + .../chat}/trusted-dom-renderers.ts | 18 +- tauri/src/main.ts | 588 ++++++-------- .../app-management/app-focus-manager.ts | 19 + .../app-management/app-instance-manager.ts | 67 ++ .../app-lifecycle-manager.test.ts | 25 + .../app-management/app-lifecycle-manager.ts | 26 + .../runtime/app-management/app-registry.ts | 124 +++ tauri/src/runtime/app-management/app-sdk.ts | 97 +++ .../app-management/runtime-app-host.test.ts | 38 + .../app-management/runtime-app-host.ts | 55 ++ .../artifact-content-cache.test.ts | 2 +- .../{ => artifacts}/artifact-content-cache.ts | 5 + .../{ => artifacts}/artifact-state.test.ts | 2 +- .../runtime/{ => artifacts}/artifact-state.ts | 6 + .../capability-audit.test.ts | 2 +- .../{ => capabilities}/capability-audit.ts | 10 +- .../capability-call-state.test.ts | 6 +- .../capability-call-state.ts | 12 +- .../capability-executor.test.ts | 6 +- .../{ => capabilities}/capability-executor.ts | 12 +- .../capability-registry.test.ts | 2 +- .../{ => capabilities}/capability-registry.ts | 8 +- .../{ => communication}/transport-adapter.ts | 5 + .../runtime/coordination/app-orchestrator.ts | 27 + .../execution-progress-state.test.ts | 2 +- .../execution-progress-state.ts | 5 + .../interaction-kernel.test.ts | 4 +- .../{ => coordination}/interaction-kernel.ts | 12 +- .../coordination/lineup-runtime.test.ts | 249 ++++++ .../runtime/coordination/lineup-runtime.ts | 638 +++++++++++++++ .../runtime/coordination/runtime-envelope.ts | 89 +++ .../{ => coordination}/task-state.test.ts | 2 +- .../runtime/{ => coordination}/task-state.ts | 8 +- .../tool-call-state.test.ts | 4 +- .../{ => coordination}/tool-call-state.ts | 8 +- .../runtime/coordination/tool-router.test.ts | 25 + tauri/src/runtime/coordination/tool-router.ts | 47 ++ .../{ => inventory}/client-inventory.test.ts | 15 +- .../{ => inventory}/client-inventory.ts | 23 +- .../conversation-store.test.ts | 55 +- .../{ => persistence}/conversation-store.ts | 134 +++- .../{ => protocol}/golden-protocol.test.ts | 6 +- .../{ => protocol}/golden/lineup-v1.json | 0 .../protocol/lineup-v1.ts} | 5 + .../isolated-surface-host.test.ts | 4 +- .../{ => surfaces}/isolated-surface-host.ts | 12 +- .../production-surface-manifest.test.ts | 2 +- .../production-surface-manifest.ts | 5 + .../production-surface-policy.test.ts | 8 +- .../production-surface-policy.ts | 14 +- .../surface-bundle-cache.test.ts | 2 +- .../{ => surfaces}/surface-bundle-cache.ts | 8 +- .../surface-instance-manager.test.ts | 4 +- .../surface-instance-manager.ts | 10 +- .../{ => surfaces}/surface-registry.test.ts | 2 +- .../{ => surfaces}/surface-registry.ts | 8 +- tauri/tsconfig.json | 4 + tauri/vite.config.ts | 13 + wails-spike/README.md | 13 - wails-spike/contract/golden_test.go | 129 --- wails-spike/go.mod | 14 - wails-spike/go.sum | 16 - wails-spike/main.go | 30 - wails-spike/runtime_contract_test.go | 151 ---- 程序文件清单与功能说明.md | 159 ++++ 迭代/00.base.md | 124 +++ 迭代/01.kernel.md | 293 +++++++ 迭代/03.sdk_and_coreapp.md | 733 ++++++++++++++++++ 迭代/03.sdk_and_coreapp_comment.md | 287 +++++++ 86 files changed, 4615 insertions(+), 1364 deletions(-) create mode 100644 APP架构设计.md delete mode 100644 DESIGN.md delete mode 100644 M4_COMPATIBILITY_AND_RELEASE.md delete mode 100644 TECHNOLOGY_SELECTION.md create mode 100644 tauri/src/README.md create mode 100644 tauri/src/core-apps/chat/chat-app-host.ts create mode 100644 tauri/src/core-apps/chat/chat-shell.ts create mode 100644 tauri/src/core-apps/chat/interaction-mode-registry.ts create mode 100644 tauri/src/core-apps/chat/interaction-runtime.ts rename tauri/src/{runtime => core-apps/chat}/renderer-registry.ts (84%) rename tauri/src/{style.css => core-apps/chat/styles/app.css} (98%) rename tauri/src/{ => core-apps/chat/styles}/capability-card.css (89%) rename tauri/src/{ => core-apps/chat/styles}/execution-progress.css (94%) rename tauri/src/{runtime => core-apps/chat}/trusted-dom-renderers.ts (97%) create mode 100644 tauri/src/runtime/app-management/app-focus-manager.ts create mode 100644 tauri/src/runtime/app-management/app-instance-manager.ts create mode 100644 tauri/src/runtime/app-management/app-lifecycle-manager.test.ts create mode 100644 tauri/src/runtime/app-management/app-lifecycle-manager.ts create mode 100644 tauri/src/runtime/app-management/app-registry.ts create mode 100644 tauri/src/runtime/app-management/app-sdk.ts create mode 100644 tauri/src/runtime/app-management/runtime-app-host.test.ts create mode 100644 tauri/src/runtime/app-management/runtime-app-host.ts rename tauri/src/runtime/{ => artifacts}/artifact-content-cache.test.ts (86%) rename tauri/src/runtime/{ => artifacts}/artifact-content-cache.ts (78%) rename tauri/src/runtime/{ => artifacts}/artifact-state.test.ts (92%) rename tauri/src/runtime/{ => artifacts}/artifact-state.ts (93%) rename tauri/src/runtime/{ => capabilities}/capability-audit.test.ts (91%) rename tauri/src/runtime/{ => capabilities}/capability-audit.ts (73%) rename tauri/src/runtime/{ => capabilities}/capability-call-state.test.ts (93%) rename tauri/src/runtime/{ => capabilities}/capability-call-state.ts (93%) rename tauri/src/runtime/{ => capabilities}/capability-executor.test.ts (93%) rename tauri/src/runtime/{ => capabilities}/capability-executor.ts (85%) rename tauri/src/runtime/{ => capabilities}/capability-registry.test.ts (94%) rename tauri/src/runtime/{ => capabilities}/capability-registry.ts (94%) rename tauri/src/runtime/{ => communication}/transport-adapter.ts (94%) create mode 100644 tauri/src/runtime/coordination/app-orchestrator.ts rename tauri/src/runtime/{ => coordination}/execution-progress-state.test.ts (96%) rename tauri/src/runtime/{ => coordination}/execution-progress-state.ts (95%) rename tauri/src/runtime/{ => coordination}/interaction-kernel.test.ts (98%) rename tauri/src/runtime/{ => coordination}/interaction-kernel.ts (91%) create mode 100644 tauri/src/runtime/coordination/lineup-runtime.test.ts create mode 100644 tauri/src/runtime/coordination/lineup-runtime.ts create mode 100644 tauri/src/runtime/coordination/runtime-envelope.ts rename tauri/src/runtime/{ => coordination}/task-state.test.ts (95%) rename tauri/src/runtime/{ => coordination}/task-state.ts (90%) rename tauri/src/runtime/{ => coordination}/tool-call-state.test.ts (96%) rename tauri/src/runtime/{ => coordination}/tool-call-state.ts (95%) create mode 100644 tauri/src/runtime/coordination/tool-router.test.ts create mode 100644 tauri/src/runtime/coordination/tool-router.ts rename tauri/src/runtime/{ => inventory}/client-inventory.test.ts (68%) rename tauri/src/runtime/{ => inventory}/client-inventory.ts (63%) rename tauri/src/runtime/{ => persistence}/conversation-store.test.ts (82%) rename tauri/src/runtime/{ => persistence}/conversation-store.ts (75%) rename tauri/src/runtime/{ => protocol}/golden-protocol.test.ts (92%) rename tauri/src/runtime/{ => protocol}/golden/lineup-v1.json (100%) rename tauri/src/{protocol.ts => runtime/protocol/lineup-v1.ts} (98%) rename tauri/src/runtime/{ => surfaces}/isolated-surface-host.test.ts (94%) rename tauri/src/runtime/{ => surfaces}/isolated-surface-host.ts (95%) rename tauri/src/runtime/{ => surfaces}/production-surface-manifest.test.ts (95%) rename tauri/src/runtime/{ => surfaces}/production-surface-manifest.ts (96%) rename tauri/src/runtime/{ => surfaces}/production-surface-policy.test.ts (86%) rename tauri/src/runtime/{ => surfaces}/production-surface-policy.ts (86%) rename tauri/src/runtime/{ => surfaces}/surface-bundle-cache.test.ts (98%) rename tauri/src/runtime/{ => surfaces}/surface-bundle-cache.ts (96%) rename tauri/src/runtime/{ => surfaces}/surface-instance-manager.test.ts (96%) rename tauri/src/runtime/{ => surfaces}/surface-instance-manager.ts (95%) rename tauri/src/runtime/{ => surfaces}/surface-registry.test.ts (98%) rename tauri/src/runtime/{ => surfaces}/surface-registry.ts (97%) create mode 100644 tauri/vite.config.ts delete mode 100644 wails-spike/README.md delete mode 100644 wails-spike/contract/golden_test.go delete mode 100644 wails-spike/go.mod delete mode 100644 wails-spike/go.sum delete mode 100644 wails-spike/main.go delete mode 100644 wails-spike/runtime_contract_test.go create mode 100644 程序文件清单与功能说明.md create mode 100644 迭代/00.base.md create mode 100644 迭代/01.kernel.md create mode 100644 迭代/03.sdk_and_coreapp.md create mode 100644 迭代/03.sdk_and_coreapp_comment.md diff --git a/APP架构设计.md b/APP架构设计.md new file mode 100644 index 0000000..d7e4b87 --- /dev/null +++ b/APP架构设计.md @@ -0,0 +1,528 @@ +# LineUp App 架构设计 + +**状态:** 当前权威设计基线 +**更新日期:** 2026-08-04 +**适用实现:** Tauri 2 Desktop Host 与同代码 Web Reference Host +**本文取代:** `DESIGN.md`、`M4_COMPATIBILITY_AND_RELEASE.md`、`TECHNOLOGY_SELECTION.md` + +## 1. 设计结论 + +**LineUp 是用户安装和使用的主应用;LineUp Runtime 是 LineUp App 内部提供给小程序的核心 +运行环境。** Runtime 不是与小程序平级的产品 App,也不是一个独立客户端。 + +```text +LineUp App +│ +├── App Shell / Host +│ ├── 当前前台小程序的挂载与切换 +│ ├── 连接状态、通知和安全恢复入口 +│ └── Tauri / Web Host Provider +│ +├── LineUp Runtime +│ ├── AppServer / Remote Agent 通信 +│ ├── Conversation、Store、sync loop、outbox +│ ├── MiniApp Registry、Instance、Focus、Lifecycle +│ ├── Tool Router、Inbox、Inventory +│ ├── Surface、Capability、Artifact、Audit +│ └── LineUp MiniApp SDK +│ +└── MiniApps + ├── Interact(系统内建:IM / Audio / Video) + ├── Task Dashboard(参考 / 已安装小程序) + ├── Whiteboard(参考 / 已安装小程序) + ├── Draw-and-Guess(参考 / 已安装小程序) + └── 未来第三方小程序 +``` + +任何 MiniApp 都必须通过 Runtime SDK 使用通信、Tool、生命周期、Surface 和系统能力;不得 +建立独立 Agent 通信链路,或直接访问 Host 特权、Store、认证信息及其他 MiniApp 数据。 + +## 2. 产品边界与术语 + +| 术语 | 含义 | 不能做什么 | +|---|---|---| +| **LineUp App** | 用户实际使用的主应用、产品容器和交付单位。 | 不能把业务规则重新堆进 Shell。 | +| **App Shell / Host** | Tauri 或 Web 中的挂载、切换、通知、恢复和受限 Host Provider。 | 不解释 Agent 协议,不决定 Tool/App 路由。 | +| **LineUp Runtime** | App 内部的长期协调环境;唯一拥有通信、可靠性、MiniApp 调度与安全决策。 | 不负责任意 MiniApp 的具体 DOM 或业务 UX。 | +| **MiniApp** | 运行在 Runtime 上的功能单元。 | 不直连 Agent/AppServer,不直写 Runtime 状态。 | +| **System MiniApp** | 随 LineUp 发布、代码可信的内建 MiniApp,如 Interact。 | 仍不能绕过 Runtime 调度或 Capability Policy。 | +| **Installed MiniApp** | 经验证后在受限 Surface 中运行的小程序。 | 不访问 Host DOM、Tauri invoke、token、任意网络或其他 MiniApp。 | +| **MiniApp SDK** | Runtime 向 MiniApp 开放的受控能力集合。 | 不是通用 Web、Tauri 或 Agent SDK。 | +| **Tool** | Agent 通过 Runtime 调用的、Manifest 已声明的 MiniApp 功能。 | 不是 App 内部任意函数。 | +| **Capability** | Runtime/Host 保管的录音、文件、通知等系统能力。 | 不是 MiniApp 自动拥有的权限。 | +| **Surface** | 复杂交互使用的受限 UI 容器。 | 不承载未验证远端脚本或 Host 特权。 | + +### 2.1 Interact MiniApp + +`Interact` 是第一个系统级 MiniApp,也是用户与 Agent 的默认交互入口: + +```text +Interact MiniApp +├── IM Mode:文字、图片、短消息、任务和结果投影 +├── Audio Mode:实时语音(后续) +├── Video Mode:实时视频(后续) +└── IM 标准交互的默认 Renderer Provider:notice / choice / confirm / input +``` + +当前实现仍保留兼容名称: + +```text +产品概念:Interact MiniApp +当前 app_scope:chat +当前目录:tauri/src/core-apps/chat/ +``` + +目录和作用域改名属于独立迁移任务,不能和 Runtime/MiniApp SDK 重构混在一起。 + +Interact 是可信内建代码,因此可以使用 LineUp 的可信 DOM 组件;但它和所有 MiniApp 一样, +必须经 SDK 请求 Runtime 行为,不能直接访问 Transport、Conversation Store、焦点栈、App +Registry、原始 Agent Envelope 或系统能力。 + +## 3. 当前技术与 Host 基线 + +唯一客户端实现与验收基线是: + +```text +Tauri 2 Desktop Host + Web Reference Host +``` + +它们运行同一份 TypeScript Runtime 与 MiniApp 代码,并不是两个客户端: + +- **Web Reference Host**:浏览器开发、Tailscale 联调和自动化验证入口;只能提供可安全降级 + 的浏览器能力。 +- **Tauri Desktop Host**:正式桌面交付;只在 Runtime 授权后提供文件、通知、媒体等 Host + Provider 能力。 + +不规划独立 Android/Kotlin 客户端,也不规划 Wails Host。已退役的 Android/Wails Spike 只保留 +在 Git 历史中,不能作为架构、协议、目录或验收依据。 + +开发命令: + +```bash +cd lineup-app/tauri + +npm run web:dev # Web / Tailscale Reference Host +npm run desktop:dev # Tauri Desktop Host +npm test -- --run # Runtime、MiniApp 与 Host 回归 +npm run build # TypeScript 检查与 Web production build +``` + +Web Host 与 AppServer 的地址必须按访问位置匹配:同机浏览器使用 `127.0.0.1`,Tailscale 浏览器 +使用受信任的 Tailscale 地址。`0.0.0.0` 仅是服务器监听地址,不能作为浏览器 API 目标。 + +## 4. Runtime 的唯一所有权 + +Runtime 是以下对象的唯一所有者: + +```text +Transport +Conversation Store +sync loop +outbox +协议兼容与原始 Envelope 校验 +作用域筛选与 message_id 去重 +MiniApp Inbox 与 ACK +MiniApp Registry、Instance、Focus、Lifecycle +Tool Router、Inventory、Capability、Surface、审计与恢复 +``` + +当前消息路径必须保持单向: + +```text +AppServer / Remote Agent + → LineUp Runtime + → 协议、scope、权限、去重和持久化 + → MiniApp SDK Inbox / Tool Call + → MiniApp UX + → SDK Result / Progress / Lifecycle Request + → Runtime 校验、审计、outbox + → AppServer / Remote Agent +``` + +禁止项: + +```text +MiniApp 直接 fetch AppServer 或 Agent +MiniApp 直接读写 Conversation Store、sync cursor 或 outbox +MiniApp 构造或发送原始 Agent Envelope +MiniApp 直接切换其他 MiniApp、改写 focus stack 或 Registry +MiniApp 直接调用 Tauri invoke、Host DOM 根节点或系统能力 +Surface 从消息 payload 直接加载 URL、HTML、CSS、JS、Bundle 或 source +``` + +## 5. MiniApp 信任模型与 SDK 权限视图 + +所有 MiniApp 共享 Runtime 的协议语义,但按来源获得不同的 SDK 视图。 + +| 能力 | Interact / System MiniApp | Installed MiniApp | +|---|---:|---:| +| 接收自身 Inbox、ACK | 可以 | 可以 | +| 接收 Runtime Tool Call | 可以 | 可以 | +| 上报 progress / result / error | 可以 | 可以 | +| 请求前台、后台、关闭 | 可以,由 Runtime 决定 | 可以,由 Runtime 决定 | +| 请求 Surface | 可以,经 Runtime Policy | 可以,经 Runtime Policy | +| 请求 Capability | 可以,经 Runtime Policy | 可以,经 Runtime Policy | +| 可信内建 DOM 组件 | 可以 | 不可以 | +| 隔离 iframe / Surface Bridge | 可选 | 必须 | +| 直连 Transport、Store、Agent | 不可以 | 不可以 | +| 直接 Host / Tauri / OS 调用 | 不可以 | 不可以 | +| 读取其他 MiniApp 数据 | 不可以 | 不可以 | + +系统内建不等于绕过 Runtime:可信来源只决定代码的发布与 UI 执行方式,不改变 Runtime 的 +通信、焦点、Tool、权限和审计边界。 + +## 6. MiniApp 实例、焦点与恢复 + +MiniApp Registry 管理“有哪些小程序”,Instance Manager 管理“哪些实例正在运行”。二者不能 +混为一个状态。 + +```ts +type MiniAppInstanceRecord = { + instance_id: string; + app_scope: string; + conversation_id?: string; + state: + | "starting" + | "foreground" + | "background" + | "suspended" + | "stopping" + | "stopped" + | "failed"; + parent_instance_id?: string; + started_at: string; + stopped_at?: string; + error?: string; +}; +``` + +Runtime 保存焦点栈,不以单个 MiniApp 自己的布尔状态作为事实来源: + +```text +focus stack: + chat:audio-001 + draw-and-guess:game-001 + +foreground: + draw-and-guess:game-001 + +background: + chat:audio-001 +``` + +当扩展 MiniApp 关闭或失败时,Runtime 必须恢复焦点栈中的前一有效实例。重启后,Runtime +恢复有界的实例与焦点状态;`starting` 或 `stopping` 中断的实例必须安全降级为 `suspended`, +不得伪装为已完成的 Host 操作。 + +## 7. MiniApp SDK v1 + +下一阶段的核心交付是 **LineUp MiniApp SDK v1**。SDK 不是万能 API,而是 Runtime 根据 +Manifest、实例状态、权限和 Policy 开放的最小请求集合。 + +```ts +interface LineUpMiniAppSDK { + readonly context: MiniAppContext; + + inbox: MiniAppInboxAPI; + tools: MiniAppToolAPI; + ui: StandardInteractionAPI; + lifecycle: MiniAppLifecycleAPI; + surfaces: MiniAppSurfaceAPI; + capabilities: MiniAppCapabilityRequestAPI; +} + +type MiniAppContext = { + app_scope: string; + instance_id: string; + conversation_id: string; + app_version: string; + state: "starting" | "foreground" | "background" | "suspended"; +}; +``` + +### 7.1 Inbox + +```ts +interface MiniAppInboxAPI { + list(after_message_id?: string): readonly MiniAppMessage[]; + subscribe(listener: (message: MiniAppMessage) => void): Unsubscribe; + acknowledge(message_id: string): boolean; +} +``` + +Runtime 在投递前校验 Envelope、scope、目标 MiniApp、实例状态和去重;消息先持久化,再由 +MiniApp ACK。后台化、刷新、Surface 重载或 Runtime 重启不能丢失未处理消息。 + +### 7.2 Tool + +```ts +interface MiniAppToolAPI { + subscribe(listener: (call: MiniAppToolCall) => void): Unsubscribe; + get(call_id: string): MiniAppToolCall | undefined; + reportProgress(call_id: string, progress: MiniAppToolProgress): Promise; + complete(call_id: string, result: JsonObject): Promise; + fail(call_id: string, failure: MiniAppToolFailure): Promise; + cancel(call_id: string, reason?: string): Promise; +} +``` + +`complete`、`fail`、`cancel` 都是对 Runtime 的请求,不是直接网络发送。Runtime 必须验证 +`call_id` 的实例归属、合法状态迁移、输入/输出 schema、幂等性和权限,然后持久化、审计并 +写入 outbox。 + +### 7.3 标准交互、Lifecycle、Surface 与 Capability + +Runtime 通过 `sdk.ui` 提供 `notice`、`choice`、`confirm`、`input` 四种标准交互原语。这是由 +Runtime 统一拥有的标准输入输出库:Runtime 创建和持久化交互记录,注入 owner、校验请求/结果、 +负责恢复、审计与路由,并依照 Shell Policy 决定呈现位置。 + +`owner` 不属于 SDK 调用参数。Runtime 仅从 SDK 已绑定的 `MiniAppContext` 注入 +`app_scope`、`instance_id`、`conversation_id`,并可带有已验证的 `parent_call_id`、`surface_id` 与 +来源;MiniApp 不可伪造、覆盖或访问其他 owner 的交互。`presentation` 只能作为 `inline`、`modal` +或默认形式的偏好,最终仍由 Runtime/Shell 决定。 + +Runtime 内部至少持久化以下归属信息: + +```ts +type StandardInteractionOwner = { + app_scope: string; + instance_id: string; + conversation_id: string; + parent_call_id?: string; + surface_id?: string; + source: "agent_tool" | "miniapp_tool" | "runtime_policy"; +}; +``` + +Agent 在 IM 中的提问由 Interact 作为默认可信 Renderer Provider 呈现在 IM 时间线/卡片内。前台 +MiniApp 自己请求的低复杂度事务提示,可在该 MiniApp 的有效容器或 Surface 中以 Runtime 批准的 +标准 modal、sheet 或 card 呈现,不能因此强制切换到 Interact。结果始终先返回 Runtime,再仅投递 +给 owner MiniApp;MiniApp 不可自行创建 Host 弹窗或直接将结果发送给 Agent。 + +复杂、高频或私有的业务 UI(例如 Whiteboard 画布文字编辑、画笔、颜色选择、拖放和工具栏)留在 +MiniApp 自己的 Surface 内,不使用标准交互库。系统权限确认(文件、麦克风、通知等)则属于 +Runtime/Host Capability Gateway,而不是普通 `confirm`。 + +```ts +interface MiniAppLifecycleAPI { + requestForeground(): Promise; + requestBackground(): Promise; + requestClose(reason?: string): Promise; +} +``` + +MiniApp 只能请求生命周期变化;Runtime 决定是否允许,处理 pending Tool/Surface,并负责 +焦点恢复。 + +Surface 与 Capability 也只能请求: + +```text +MiniApp requestOpenSurface / requestCapability +→ Runtime 校验 Manifest、Policy、实例状态和用户确认要求 +→ Host Provider 执行受限操作 +→ Runtime 记录审计并返回受控结果 +``` + +不应在 SDK v1 提供 `fetch`、任意网络、任意文件系统、剪贴板、麦克风、摄像头或 Tauri +直接调用。高风险能力以后通过 Capability Request 按需开放。 + +## 8. Tool Contract 与 Inventory + +MiniApp 只能在 Manifest 中声明 Tool;Agent 只能调用当前 Runtime 已公布 Inventory 中的 Tool。 + +```ts +type ToolDescriptor = { + id: string; // 例如 task-dashboard.open + version: 1; + handling: "direct" | "interactive" | "launch" | "foreground" | "operation"; + target: { + app_scope: string; + requires_foreground: boolean; + restore_previous_focus: boolean; + }; + input_schema: JsonSchema; + output_schema: JsonSchema; + permissions?: readonly string[]; + timeout_ms?: number; +}; +``` + +统一调度路径: + +```text +Agent Tool Invoke +→ Runtime 校验 Envelope、Inventory revision、Tool Descriptor、参数、scope、App 状态和权限 +→ 创建可持久化 Tool Call / 审计记录 +→ Tool Router 判定 direct / interactive / launch / foreground / operation +→ 投递到目标 MiniApp instance +→ MiniApp SDK 报告 progress / result / error +→ Runtime 校验输出 schema、持久化、更新焦点、写 outbox +→ Agent 收到可靠结果 +``` + +`notice`、`choice`、`confirm`、`input` 是 Runtime 的统一 `interactive` Tool/交互记录能力。 +Interact 提供 Agent/IM 场景的默认 Renderer,而不拥有记录或绕过统一 Tool Call、持久化和结果路径; +其他 MiniApp 只能通过 `sdk.ui` 在自身有效容器使用 Runtime 批准的标准 Renderer。 + +建议稳定拒绝码至少包括: + +```text +inventory_revision_mismatch +tool_not_advertised +tool_input_invalid +tool_output_invalid +app_disabled +app_instance_not_found +app_scope_mismatch +lifecycle_denied +capability_denied +call_already_final +``` + +## 9. Surface、Bundle、Capability 与发布安全 + +复杂 UI 只能运行在受限 Surface 中。Installed MiniApp 的生产 Bundle 必须满足: + +```text +签名 Manifest +→ 精确 byte size 校验 +→ SHA-256 校验 +→ 原子写入已验证 cache +→ app_id + exact version 解析 +→ sandbox="allow-scripts" 的隔离 Surface +``` + +### 9.1 Host 兼容矩阵 + +| 情况 | Host 必须行为 | 禁止行为 | 观测信号 | +|---|---|---|---| +| 旧 Client 收到未知 `lineup.v1.*` | 安全 fallback,不执行 | 将 payload 当 HTML/能力执行 | `unsupported_type` / `unsupported_version` | +| 新 Client 收到旧 plain text | 仅作为 Markdown 显示 | 赋予交互或能力 | legacy markdown renderer | +| 不支持目标 Surface version | 不挂载,显示安全提示 | 自动前移到相邻版本 | `surface_unavailable` | +| 收到生产 Surface | 仅在签名、长度、SHA-256 成功后挂载已验证 bytes | 接收 URL、HTML、JS、Bundle/source | install disposition | +| 候选 Bundle 验证失败 | 保留当前 active 已验证版本 | 覆盖 active bytes 或执行候选 | `signature_invalid` 等 | +| active Bundle 被撤销 | 原子回滚到已验证 predecessor;没有则不可用 | 回退到未验证缓存/远端 URL | `rolled_back` / `unavailable` | +| Bridge 收到非当前 iframe 消息 | 丢弃 | 仅相信 origin 字符串或调用 Host API | bridge rejection counter | +| Capability Catalog 变化 | 发布最小新 Inventory | 用过期 Inventory 自动执行 | catalog revision | + +### 9.2 生产发布序列 + +1. 发布方创建不可变 artifact 与生产 Manifest:`app_id`、精确版本、artifact ID、长度、 + SHA-256、最小 Host 版本、权限、可信 `key_id`、Ed25519 签名。 +2. Manifest 与 Bundle 部署在 Host 配置的 HTTPS artifact 服务;Manifest 不携带 URL、HTML、 + CSS、JS 或 source。 +3. Host 在临时内存中验证签名、精确长度与 SHA-256;失败不写 cache。 +4. 验证成功后原子安装,并只解析精确 `app_id + version`;不得自动前移版本。 +5. 仅向满足最小 Host 版本且已启用目标版本的 Client 灰度发布。 +6. 监控签名、摘要、下载、Bridge 与 Capability 状态;安全事件发生时只回滚到已验证的 + predecessor。 +7. 回滚后保留审计元数据,但不记录 artifact 内容、URL、token、文件路径或 Capability 参数; + 新 artifact 必须使用新版本号。 + +正式 HTTPS Host 才可被标记为 production-capable。HTTP LAN 开发 Host 在浏览器 WebCrypto +受限时必须安全拒绝验签,不能为了联调放宽该规则。 + +## 10. 当前实现状态 + +### 10.1 已完成:历史安全基础 + +历史 M0~M4 已完成并沉淀在当前代码中;它们不是新的平行排期: + +| 历史阶段 | 保留能力 | +|---|---| +| M0 | HTTP Transport、Conversation Store、协议解码、Markdown、安全 fallback、基础回归 | +| M1 | Tool Call、Task、可靠交互结果、执行摘要、可信 choice/confirm/input | +| M2 | Surface Registry、实例生命周期、隔离 iframe 与恢复 | +| M3 | Capability Registry、用户确认、审计与受限 Host 执行 | +| M4 | 签名 Manifest、Bundle 校验/缓存/回滚、golden fixture | + +### 10.2 已完成:第二次升级 + +```text +00.base +→ Runtime 托管 Interact IM(当前兼容名 chat)的稳定基线 + +01.kernel +→ MiniApp Instance / Focus / Lifecycle、Tool Router、App Orchestrator、 + Extension SDK 雏形、Workspace 持久化和恢复 +``` + +当前实现已验证登录、同步、本地回显、Markdown、安全渲染、Tool Call、Task、Surface、 +Capability、App Inbox、outbox、实例焦点恢复和生产构建。自动化回归当前为 23 个测试文件、 +99 个测试;`npm run build` 通过。 + +Runtime 的关键结构位于: + +```text +tauri/src/runtime/app-management/ +tauri/src/runtime/coordination/ +tauri/src/runtime/persistence/ +tauri/src/runtime/protocol/ +tauri/src/runtime/surfaces/ +tauri/src/runtime/capabilities/ +tauri/src/core-apps/chat/ +``` + +### 10.3 下一阶段:03.sdk_and_coreapp + +下一阶段不是应用市场,也不是立即建设服务端下载目录。目标见 +[03.sdk_and_coreapp.md](迭代/03.sdk_and_coreapp.md):定义并验证 MiniApp SDK v1: + +```text +冻结 AppManifest、ToolDescriptor、Tool Call、App Context、Result/Progress/Error 和错误码 +→ Runtime 实现通用 SDK 请求路由、schema 校验、审计、恢复和 revisioned Inventory +→ Interact 适配为第一个 System MiniApp SDK 样本 +→ Task Dashboard 作为第一个参考 MiniApp 验证 Tool、progress、result、focus 与恢复 +→ Whiteboard 验证受限 Surface、状态 patch、Artifact 与 Capability 请求 +``` + +参考 MiniApp 在此阶段可以是 `bundled-development`:随开发 Host 内置、有 Manifest 和 Runtime +注册记录,但不依赖服务端 Catalog、远程下载或第三方发布。 + +### 10.4 后续阶段 + +MiniApp SDK 与参考实现稳定后,再按顺序推进: + +```text +03.reference-miniapps +→ 完成 Task Dashboard / Whiteboard 的端到端参考实现 + +04.app-delivery-registry +→ Catalog Entry、Manifest/Bundle 下载、验签、安装、启用、禁用、更新、回滚、移除 + +05.catalog-and-market(按产品需要) +→ 搜索、分类、发布者、安装 UX、组织分发与可能的商业能力 +``` + +“市场”属于最后的产品分发层,不能反向决定 Runtime、SDK、Tool 或安全模型。 + +## 11. 验收与发布门槛 + +每次 Runtime、MiniApp SDK 或 Host 改动至少满足: + +```text +npm test -- --run +npm run build +``` + +对于 SDK/参考 MiniApp,还必须有以下证据: + +1. 合法与非法 Manifest、Inventory revision、Tool 输入/输出 schema 的自动化测试; +2. Tool 启动 MiniApp、前后台切换、结果回传、失败回焦和重启恢复的场景测试; +3. Interact 的 `notice / choice / confirm / input` IM 呈现不退化,且覆盖 owner 自动注入、跨 owner + 访问/提交拒绝、owner 结果回路与 MiniApp 容器内呈现的测试; +4. Installed MiniApp 无法取得 Host DOM、Tauri invoke、token、任意网络或其他 MiniApp 数据; +5. 有头浏览器验证登录、同步、本地回显和代表性 MiniApp 路径; +6. 对生产 Bundle,验证签名 `open → patch → close → rollback`、隔离 Bridge 和 Capability 拒绝路径; +7. `git diff --check` 无格式错误,日志不得包含身份、会话、消息正文、Tool 参数、token 或 + artifact 内容。 + +## 12. 相关文档与源码导航 + +- [迭代/00.base.md](迭代/00.base.md):Runtime 托管 Interact IM 的稳定基线。 +- [迭代/01.kernel.md](迭代/01.kernel.md):Runtime Kernel 与 MiniApp 编排目标和验收。 +- [tauri/src/README.md](tauri/src/README.md):当前源码目录和依赖方向。 +- [tauri/README.md](tauri/README.md):Tauri/Web Host 的运行、回归与历史实施细节。 +- [程序文件清单与功能说明.md](程序文件清单与功能说明.md):实现文件和功能说明。 +- [LineUp App 最终设计方案](../设计/02.正式方案/app_final_design.md):跨文档详细契约来源; + 若与本文的产品术语或当前迭代顺序冲突,以本文为准并同步更新上游方案。 diff --git a/DESIGN.md b/DESIGN.md deleted file mode 100644 index f0dcf09..0000000 --- a/DESIGN.md +++ /dev/null @@ -1,319 +0,0 @@ -# LineUp App — Android Spike 历史设计参考 - -> 状态:**仅保留为 Android 快速实验的历史/参考设计,不是当前 App Layer 总体架构。** -> 原版本:0.1(提案) -> 日期:2026-07-30 -> 基础:唐僧叨叨 Android 客户端(wkbase / wklogin / SDK 封装) - -当前正式、跨端的 LineUp App 总体架构以 [LineUp App 层架构设计方案](../设计/02.正式方案/lineup-app-layer-architecture.md) 为准:Tauri 2 是当前 Reference Host,Android 为 Spike 基线,未来 Wails 按同一 Interaction Runtime 契约对照实现。本文件中的“Android 首端”“直接使用 WuKongIM SDK”等描述只反映当时的实验条件,不能覆盖正式架构、协议或实现优先级。 - ---- - -## 1. 定位 - -LineUp App 是一个**单通道 Agent 对话客户端**。用户打开 App 后只有一个对话——和他的 AI Agent。 - -不是社交 IM(没有好友列表、没有群聊、没有朋友圈)。 -当前版本只做一件事:**连上 WuKongIM,进入唯一频道,与 Agent 收发消息。** - -``` -┌──────────────────────────────────────────┐ -│ LineUp App │ -│ │ -│ ┌───────────────────────────────┐ │ -│ │ Agent 对话频道 │ │ -│ │ │ │ -│ │ [用户] 帮我查一下今天天气 │ │ -│ │ [Agent] 北京今天晴, 22°C │ │ -│ │ [Agent] ┌─ choice ─┐ │ │ -│ │ │ 需要更多? │ │ │ -│ │ │ [是] [否] │ │ │ -│ │ └───────────┘ │ │ -│ └───────────────────────────────┘ │ -│ │ -│ ┌───────────────────────────────┐ │ -│ │ 输入框 │ │ -│ └───────────────────────────────┘ │ -└──────────────────────────────────────────┘ -``` - ---- - -## 2. 架构分层 - -``` -┌──────────────────────────────────────┐ -│ LineUp App (Android) │ -│ │ -│ UI 层: 聊天界面 + Agent 消息渲染 │ -│ ├─ ChatView (文本/图片/语音) │ -│ └─ AgentWidgets (choice/form/canvas) │ -│ │ -│ 业务层: 频道管理 + 消息路由 │ -│ ├─ ChannelManager (只有一个频道) │ -│ └─ MessageRouter (分发到UI) │ -│ │ -│ 通讯层: WuKongIM SDK 封装 │ -│ ├─ WKClient (长连接/心跳/重连) │ -│ ├─ WKSend / WKRecv │ -│ └─ TokenManager (认证) │ -│ │ -│ 本地存储: SQLite / SharedPreferences │ -│ ├─ Token 缓存 │ -│ └─ 消息历史 (最近N条) │ -└──────────────────────────────────────┘ -``` - -### 2.1 复用唐僧叨叨的模块 - -| 唐僧叨叨模块 | 用途 | LineUp 是否复用 | -|---|---|---| -| `wklogin` | 登录 / 注册 / Token 获取 | ✅ 复用 | -| `wkbase` | 基础 UI / WebSocket 连接 / 消息模型 | ✅ 复用核心 | -| `wkpush` | 厂商推送 | ❌ 暂不需要 | -| `wkscan` | 扫码 | ❌ 不需要 | -| 群聊/通讯录/朋友圈 | — | ❌ 隐藏/移除 | - -**核心思路:保留通信管道,砍掉社交外壳。** - ---- - -## 3. 连接流程 - -### 3.1 首次启动 - -``` -Step 1: 用户打开 App - → 输入手机号 → 点"获取验证码" → 输入 123456 - → App 调 LineUp App Server: POST /login - → Server 调 WuKongIM /user/token 生成 Token - → 返回 {uid, token, im_host, im_port} - -Step 2: App 用 Token 连接 WuKongIM WebSocket - → ws://100.121.118.116:15200?token=xxx - → WuKongIM 返回 Connack → 连接建立 - -Step 3: App 进入唯一频道 - → 频道 ID: "agent_default" (硬编码) - → App 调 WuKongIM Sub 帧订阅该频道 - → 收到 Suback → 可以收发消息 - -Step 4: 显示对话界面 - → 同步最近 N 条消息 (channel/messagesync) - → 等待用户输入 -``` - -### 3.2 后续启动(Token 未过期) - -``` -Step 1: 读取本地缓存的 Token -Step 2: 直接连 WuKongIM WebSocket -Step 3: 跳过登录页,直接进入对话 -``` - -### 3.3 Agent 也在同一个频道 - -``` -用户的 WuKongIM 用户: uid="user_" -Agent 的 WuKongIM 用户: uid="agent_default" - -频道 ID: "agent_default_channel" -频道类型: 个人频道 (channel_type=1) - -用户 → Send(频道) → WuKongIM → Recv → Agent -Agent → Send(频道) → WuKongIM → Recv → App -``` - ---- - -## 4. 消息类型 - -### 4.1 普通文本消息 - -用户发什么就送什么,Agent 回文本也直接显示。 - -### 4.2 LineUp 结构化消息(信封格式) - -沿用设计文档定义的信封协议: - -```json -{ - "v": 1, - "id": "msg_xxx", - "type": "lineup.v1.tool.call", - "sender": {"kind": "agent", "id": "agent_default"}, - "target": {"kind": "device", "id": "device_phone_01"}, - "payload": {} -} -``` - -### 4.3 第一批支持的 Agent 工具消息 - -| 类型 | 触发条件 | UI 渲染 | -|---|---|---| -| `lineup.v1.tool.choice` | Agent 需要用户做选择 | 渲染为可点击的选项按钮 | -| `lineup.v1.tool.confirm` | Agent 需要用户确认操作 | 渲染确认/取消按钮 | -| `lineup.v1.tool.input` | Agent 需要用户输入值 | 渲染输入框 | -| `lineup.v1.tool.progress` | Agent 报告进度 | 渲染进度条 | -| `lineup.v1.event.error` | Agent 报错 | 渲染错误提示 | - -**这些 payload 通过 WuKongIM 的 SendPacket.Payload 字段传递,App 收到后解析 type 字段决定渲染方式。** - -### 4.4 通用 UI Surface 与上层 App 能力 - -除内置文本、Markdown、choice、confirm、progress 等稳定组件外,LineUp Client 还支持受控的 UI Surface:Agent 可发送 `lineup.v1.ui.open`,以 HTML/CSS/JS 描述一个交互界面;后续以 `ui.patch` 推送状态,以 `ui.event` 接收用户动作,以 `ui.close` 关闭实例。 - -这不是把 Agent 脚本放进 App 主进程执行。Android 端必须在隔离 WebView 中承载 Surface,禁用任意网络访问、文件访问、同源权限和未注册的 JavaScript interface。Surface 只能通过受限 bridge 上报用户事件。 - -Agent 若要调用 App 的上层能力(例如打开链接、选文件、写剪贴板、相机、定位或业务模块),必须走独立的 `lineup.v1.app.list` → `app.call` → `app.result` 协议:Client 先声明 capability,再按风险等级弹出用户确认或系统授权,绝不允许 Surface 直接越权调用。本协议详见 [LineUp UI Surface 与 App Capability 协议](../设计/02.正式方案/lineup-ui-surface-protocol.md)。 - ---- - -## 5. UI 设计 - -### 5.1 消息渲染规则 - -``` -if payload 为空: - → 普通文本消息,照常显示 - -if payload.type == "lineup.v1.tool.choice": - → 渲染为选项卡片 - → 用户点击某个选项后: - → App 组装 tool.result Payload - → Send 回 Agent - -if payload.type == "lineup.v1.tool.progress": - → 渲染为进度条卡片 - → 被动显示,无需用户操作 -``` - -### 5.2 登录页 - -``` -┌──────────────────────┐ -│ │ -│ LineUp │ -│ 你的 AI 搭档 │ -│ │ -│ ┌──────────────────┐│ -│ │ +86 手机号 ││ -│ └──────────────────┘│ -│ ┌────────┐ ┌──────┐ │ -│ │ 验证码 │ │ 获取 │ │ -│ └────────┘ └──────┘ │ -│ │ -│ [ 登 录 ] │ -│ │ -└──────────────────────┘ -``` - -### 5.3 主界面 - -``` -┌──────────────────────────────┐ -│ Agent (在线) ··· │ ← 顶部栏:Agent名称+在线状态 -├──────────────────────────────┤ -│ │ -│ [Agent] 你好,我是你的 AI │ -│ 助手,有什么可以帮 │ -│ 助你的? │ -│ │ -│ [用户] 帮我查天气 │ -│ │ -│ [Agent] ┌─ 选择城市 ────┐ │ -│ │ ○ 北京 │ │ -│ │ ○ 上海 │ │ -│ │ ● 深圳 │ │ -│ │ [确认] │ │ -│ └────────────────┘ │ -│ │ -├──────────────────────────────┤ -│ ┌────────────────────┐ 📎 │ ← 输入栏 -│ │ 输入消息... │ 📷 │ -│ └────────────────────┘ 🎤 │ -└──────────────────────────────┘ -``` - ---- - -## 6. 服务端交互协议 - -### 6.1 App → LineUp App Server - -| API | 方法 | 用途 | -|---|---|---| -| `/login` | POST | 手机号+验证码 → {uid, token, im_addr} | -| `/ws` | WebSocket | App 维持与 Server 的长连接(接收 Server 推送) | - -### 6.2 App → WuKongIM (直连) - -| 操作 | 方向 | 协议 | -|---|---|---| -| 连接 | App → WK | WKProto Connect (带 Token) | -| 发消息 | App → WK | WKProto Send (频道+Payload) | -| 收消息 | WK → App | WKProto Recv | -| 同步消息 | App → WK | channel/messagesync | -| 心跳 | 双向 | WKProto Ping/Pong | - ---- - -## 7. 与唐僧叨叨 App 的差异 - -| 功能 | 唐僧叨叨 | LineUp App | -|---|---|---| -| 登录 | 手机号+验证码 / 第三方 | 手机号+验证码(简化版) | -| 主界面 | 会话列表(多聊天) | **直接进入唯一 Agent 对话** | -| 通讯录 | 好友+群组+工作台 | 无 | -| 发现页 | 朋友圈+附近 | 无 | -| 消息类型 | 文本/图片/语音/文件/位置 | 文本 + Agent 工具卡片 | -| 设置 | 隐私/通知/通用 | 极简(仅退出/重置) | -| WuKongIM 连接 | `wkbase` SDK 封装 | 复用 `wkbase` 核心 | -| 服务端 | 唐僧叨叨业务层 | LineUp App Server | - ---- - -## 8. 实施计划 - -### Phase 1: 最小通信通路(1-2天) -``` -目标: 能登录、连 WuKongIM、收发文本消息 - - □ 基于唐僧叨叨 wklogin + wkbase 创建简化版 App 壳 - □ 移除所有非必要模块(群聊/通讯录/朋友圈) - □ 登录后直连 WuKongIM WebSocket - □ 硬编码频道 ID,进入固定对话 - □ 能发送和接收文本消息 -``` - -### Phase 2: Agent 工具消息渲染(2-3天) -``` -目标: 能解析 LineUp 信封,渲染 choice/confirm/progress - - □ 实现 Payload 解析器 - □ choice 卡片 → 选项按钮列表 - □ confirm 卡片 → 确认/取消按钮 - □ progress 卡片 → 进度条 - □ 用户操作后组装 tool.result 回传给 Agent -``` - -### Phase 3: UI 美化 + 体验优化(2-3天) -``` -目标: 不再是粗制原型,而是可用产品 - - □ 对话气泡 UI 优化 - □ Agent 在线状态指示 - □ 消息发送状态(发送中/已送达) - □ 启动动画 / 过渡效果 - □ 错误处理(断网/超时) -``` - ---- - -## 9. 参考资料 - -- 唐僧叨叨 Android 源码: `upstream/tangsengdaodao-android/` -- LineUp App Server 设计: `lineup-app-server/DESIGN.md` -- WuKongIM JS SDK: `@wukongim/sdk-js` (Agent 端用) -- LineUp App 层架构: `设计/02.正式方案/lineup-app-layer-architecture.md` -- 消息信封与 UI Surface 定义: `设计/02.正式方案/lineup-ui-surface-protocol.md` diff --git a/M4_COMPATIBILITY_AND_RELEASE.md b/M4_COMPATIBILITY_AND_RELEASE.md deleted file mode 100644 index de8bdb8..0000000 --- a/M4_COMPATIBILITY_AND_RELEASE.md +++ /dev/null @@ -1,33 +0,0 @@ -# M4 兼容矩阵与发布策略 - -本文定义 LineUp v1 的 Host 行为,不以特定框架的便利接口扩展协议。Tauri 是当前唯一已验收的 Reference Host;Android 按当前范围暂缓;Wails v3 仍是 beta 对照 Spike,尚未获得生产批准。 - -## 兼容矩阵 - -| 组合 | Host 必须行为 | 禁止行为 | 观测信号 | -|---|---|---|---| -| 旧 Client 收到 `lineup.v1.*` 新消息类型 | 显示安全 fallback;不执行 | 将未知 payload 当 Markdown/HTML 或自动重试执行 | `unsupported_type` / `unsupported_version` | -| 新 Client 收到旧 plain text | 仅作为 Markdown 文本显示 | 赋予任何交互或能力 | legacy markdown renderer | -| Client 不支持目标 Surface app/version | 不挂载 iframe;保留会话消息为安全提示 | 向相邻版本或 active 新版本前移 | `surface_unavailable` / exact-version miss | -| 支持的 Client 收到生产 Surface | 只在 manifest 签名、精确长度和 SHA-256 都成功后挂载已验证 bytes | 从 `ui.open`/`ui.patch` 接收 URL、HTML、JS、bundle 或 source | install disposition、artifact/version | -| 新 bundle 验证失败 | 保持当前已验证 active bundle;呈现不可用状态 | 覆盖 active bytes 或执行候选 bundle | `signature_invalid` / `size_mismatch` / `digest_mismatch` | -| 已验证 active bundle 被撤销 | 原子切回同 app 的上一个已验证版本;无 predecessor 则不可用 | 回退到未校验缓存或远程 URL | `rolled_back` / `unavailable` | -| Capability catalog 版本变更 | 重新发送最小 inventory;未知/撤销 capability 只回安全结果 | 使用过期 inventory 直接执行 | catalog revision、`unsupported` / `revoked` | -| Surface bridge 收到非当前 iframe 消息 | 丢弃 | 信任 origin 字符串、调用 Host/Wails/Tauri API | bridge rejection counter | -| `tool.call → submit → tool.result` | 一次有序状态收敛;重复幂等 | 从 pending 直接接受远端完成,或二次完成 | call id、transition disposition | - -## 发布序列 - -1. 发布方生成 immutable artifact,并建立生产 manifest:`app_id`、精确 `version`、`artifact_id`、byte size、SHA-256、最小 Host 版本、允许权限、trusted `key_id` 和 Ed25519 签名。 -2. 将 manifest 和 bundle 置于 Host 配置的 HTTPS 同源 artifact 服务。manifest 不能携带 URL、HTML、CSS、JS 或 source。 -3. Host 下载到临时内存,依次验证 manifest signature、精确字节长度、SHA-256;任一失败都不写 cache。 -4. 验证成功后,才原子安装并将 active 指针切到精确版本;Surface 实例只从 `app_id + version` 精确查找,不允许自动前移。 -5. 分阶段灰度:先只对具备 `min_host_version` 且已启用该 app/version 的 Client 发布;其他 Client 收到可读提示但不挂载/执行。 -6. 监控 signature/hash/download/bridge/capability 状态。错误率或安全事件触发时撤销 artifact,Host 仅切回已验证 predecessor;没有 predecessor 时关闭该 Surface。 -7. 回滚后仍保留审计元数据(不含 artifact 内容、URL、token、文件路径或能力参数)。新 artifact 必须使用新版本号;不得覆盖同版本的 immutable artifact。 - -## Host 发布门槛 - -一个 Host 只有在以下证据完整时才能标为 M4 production-capable:共享 golden fixture 通过、manifest/cache 单测通过、生产 build 通过、有头浏览器完成签名 bundle `open → patch → close → rollback`,并完成 Surface 隔离与 capability 拒绝路径验收。 - -当前状态:Tauri Reference Host 的协议/manifest/cache 自动验证已通过;在可见 Chromium 的 localhost 安全上下文中,开发期签名 fixture 已实际走过 Ed25519 → size → SHA-256 → cache → exact-version resolver → opaque iframe 的 `open → patch → close`。HTTP LAN 开发 Host 因浏览器禁用 WebCrypto 而安全拒绝验签,正式发布必须使用 HTTPS。Wails v3 beta.3 已完成共享 golden contract 执行与 Windows/amd64 API 交叉编译 Spike;Linux native GUI 仍受本机 GTK4/WebKitGTK 6.0 开发依赖限制,因而尚未获得 Linux 生产批准。Android 按用户范围暂缓。 diff --git a/README.md b/README.md index e832175..561bc2c 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,114 @@ # LineUp App -这里是 LineUp 多端客户端的工作区。 +这里是 LineUp App 的客户端工作区;LineUp Runtime 是其中向 MiniApp 提供通信、可靠性、 +调度与安全能力的核心运行环境。 -当前 Kotlin Android 客户端是 IM、协议、设备接入与交互闭环的快速实验基线;长期目标是在系统 WebView / WebKit 之上构建 LineUp Mini Runtime,为内置 Renderer 与受限第三方小程序 Surface 提供统一的运行环境。 +## 当前实现基线 -## Git 与开发验证约定 +LineUp 不只是一个聊天客户端,而是用户设备上的协作 Runtime:它连接 +AppServer 与 Remote Agent,维护会话、消息、Store、outbox、App Inbox、权限与恢复; +当前 `chat` 兼容实现是其上的第一个系统级 MiniApp:Interact 的 IM Mode。 -客户端工作区使用独立仓库,远端为 `ssh://git@100.121.118.116:2222/lineup/app.git`。Git 用于源码版本管理、备份和协作;提交应对应完整、可验证的改动,但**不再作为 Mac 浏览器验证前端改动的传输手段**。 +当前唯一的客户端实现与验收基线是: -日常 Web 联调由 Linux 工作机直接提供可信 Host: +```text +Tauri 2 Desktop Host + Web Reference Host +``` + +这里的 **Host(运行外壳)** 指的是把同一套 LineUp 程序放进不同运行环境的方式, +并不表示有两套客户端或两套聊天逻辑。它们共用同一份 TypeScript +`LineUpRuntime` 和 Chat Core App: + +- **Tauri Desktop Host** 是正式桌面应用的外壳。它负责创建桌面窗口,并在 Runtime + 授权后提供文件、通知等操作系统能力。 +- **Web Reference Host** 是浏览器中的同代码开发和验证入口。它用于 Vite 开发、 + Tailscale 联调与自动化回归,不能替代桌面版的系统能力。 + +无论运行在哪个 Host 中,连接、同步、消息存储和 Agent 协议都只能由 Runtime 管理, +不能各自实现一套。 + +不再规划、实现或维护独立 Android/Kotlin 客户端,也不再维护 Wails Host。旧实验设计仅在 +Git 历史中保留,不是当前产品、协议或交付路线。 + +## MVP-R1:Runtime 托管 Chat + +当前 MVP 已验证以下边界: + +```text +Chat Core App + └── ChatRuntimeSDK + └── LineUpRuntime + ├── Transport / sync loop / outbox + ├── Conversation Store / App Inbox + ├── app_scope + conversation_id 路由 + └── lineup.v1 Compatibility Adapter +``` + +- `LineUpRuntime` 是网络连接、消息存储、同步循环和待发送队列的唯一管理者; +- Runtime 从本地 Core App Registry(内置应用清单)加载默认 `chat`,而不是由 + `main.ts` 直接拼出聊天页面; +- Chat 只能通过 Runtime SDK 接收已筛选的消息和状态、恢复并确认已处理的 Inbox 消息, + 再向 Runtime 提交用户动作; +- Runtime 拒绝非法或不匹配的 `app_scope` / `conversation_id`,并在内部兼容既有 + `lineup.v1`; +- 登录、同步、发送、本地回显、Markdown、Agent 状态和刷新恢复保持回归通过; +- 当前 Tauri/Web 回归为 23 个测试文件、99 个测试,`npm run build` 通过。 + +完整边界、MiniApp SDK 路线和发布安全约束见 [APP架构设计.md](APP架构设计.md)。 + +## 开发与验证 + +客户端工作区使用独立仓库,远端为 `ssh://git@100.121.118.116:2222/lineup/app.git`。 +Linux 开发机通过 Vite 启动 Web Reference Host;Mac 或其他设备可经 Tailscale 用浏览器 +直接打开它,看到的就是 Linux 工作区中正在修改的同一份前端,无须每次改动都同步或重新 +clone 源码。 ```bash cd lineup-app/tauri npm run web:dev ``` -Mac 浏览器通过 Tailscale 打开 `http://100.121.118.116:1420`,即可看到 Linux 工作区的 Vite 热更新。Mac 只在首次配置、切换开发机或需要本地桌面打包时再 clone / pull 源码: +当前开发地址: -```bash -git clone ssh://git@100.121.118.116:2222/lineup/app.git ~/Workspace/lineup-app -cd ~/Workspace/lineup-app -git pull --ff-only origin main +```text +http://100.121.118.116:1420/ ``` -`tauri/node_modules/`、`tauri/dist/` 和 `tauri/src-tauri/target/` 均为本机可再生文件,已被忽略;每台机器按自身架构重新安装和构建。 +常用命令: + +```bash +# Runtime / SDK / Renderer 自动化回归 +npm test -- --run + +# TypeScript 检查与 Web production build +npm run build + +# Tauri 桌面 Host 开发 +npm run desktop:dev +``` + +`tauri/node_modules/`、`tauri/dist/` 和 `tauri/src-tauri/target/` 都是可再生本机构建产物, +已被忽略。 ## 文档入口 -- [DESIGN.md](DESIGN.md):Android 实验阶段的产品、连接与交互设计; -- [TECHNOLOGY_SELECTION.md](TECHNOLOGY_SELECTION.md):已确认的客户端与 Mini Runtime 技术路线;Tauri 2 是第一优先的 Reference Host Spike,Wails v3 在同一基线后续评估; -- [M4_COMPATIBILITY_AND_RELEASE.md](M4_COMPATIBILITY_AND_RELEASE.md):M4 的跨 Host 兼容矩阵、签名 Surface 发布、验证与回滚策略; -- [tauri/README.md](tauri/README.md):Tauri 2 Reference Host 的实现、构建状态和下一阶段范围; -- [LineUp App 层架构设计方案](../设计/02.正式方案/lineup-app-layer-architecture.md):跨端 App Shell、Interaction Kernel、Renderer、Surface 与 Capability 的正式架构; -- [LineUp UI Surface 与 App Capability 协议](../设计/02.正式方案/lineup-ui-surface-protocol.md):小程序 Surface 和 Agent 调 App 能力的协议定义。 +- [APP架构设计.md](APP架构设计.md):LineUp App、Runtime、MiniApp SDK、Surface/Capability 安全与后续迭代的权威架构设计; +- [程序文件清单与功能说明.md](程序文件清单与功能说明.md):客户端 Runtime、Chat Core App、Tauri/Web Host 与测试的文件清单; +- [tauri/src/README.md](tauri/src/README.md):源码目录、依赖方向与模块放置规则; +- [tauri/README.md](tauri/README.md):Tauri/Web Host 的运行、构建、行为回归和历史里程碑; +- [迭代/](迭代/00.base.md):按迭代记录当前基线和后续 Runtime Kernel / 应用编排目标; +- [LineUp App 最终设计方案](../设计/02.正式方案/app_final_design.md):当前架构的唯一汇总入口; +- [LineUp App 层架构方案](../设计/02.正式方案/lineup-app-layer-architecture.md):M0~M4 历史实施记录与迁移基础; +- [LineUp Runtime 与 App SDK 架构方案](../设计/02.正式方案/lineup-runtime-sdk-architecture.md):Runtime / SDK 的详细契约来源; +- [LineUp UI Surface 与 App Capability 协议](../设计/02.正式方案/lineup-ui-surface-protocol.md):Surface sandbox 和 Capability Gateway 协议; -## 当前目录边界 +## 工作区目录 ```text lineup-app/ -├── DESIGN.md # Android 快速实验设计 -├── TECHNOLOGY_SELECTION.md # 已确认的客户端 / Mini Runtime 技术路线 -├── tauri/ # Tauri 2 Reference Host(第一优先 Spike) -└── README.md # 本目录说明与文档入口 +├── README.md # LineUp App 工作区入口 +├── APP架构设计.md # 当前权威架构、SDK 与发布安全设计 +└── tauri/ # Tauri Desktop + Web Reference Host + ├── src/ # Runtime、Core App 与 Host 组合入口 + └── src-tauri/ # Tauri Rust Host ``` - -实际的唐僧叨叨 Android 上游实验代码保留在 `../upstream/tangsengdaodao-android/`,不与未来的 LineUp 多端客户端工程混放。 diff --git a/TECHNOLOGY_SELECTION.md b/TECHNOLOGY_SELECTION.md deleted file mode 100644 index d9a46e5..0000000 --- a/TECHNOLOGY_SELECTION.md +++ /dev/null @@ -1,158 +0,0 @@ -# LineUp Client 与 Mini Runtime 技术选型:原生 Android、Tauri 2 与 Wails v3 - -**版本:** 0.1(评审稿) - -**状态:** 已确认第一阶段技术路线 - -**日期:** 2026-08-02 -**关联设计:** [LineUp App 层架构设计方案](../设计/02.正式方案/lineup-app-layer-architecture.md)、[LineUp UI Surface 与 App Capability 协议](../设计/02.正式方案/lineup-ui-surface-protocol.md) - ---- - -## 1. 决策问题 - -LineUp 已有可运行的 Kotlin Android 客户端,并完成 App Server、WuKongIM、Hermes Adapter 的连通。它的定位是快速验证 IM、协议、设备接入和交互闭环,不是长期多端客户端的唯一实现。 - -长期目标是在 App 层建立 LineUp 自定义的“小程序运行环境”:利用系统 WebView / WebKit 执行 HTML、CSS、JavaScript,但由 LineUp 定义小程序包、生命周期、受限桥接、事件协议、权限与审计模型。这里不实现浏览器或 JavaScript 内核;实现的是运行在系统 Web 内核之上的 **LineUp Mini Runtime**。 - -本次只讨论客户端实现载体;LineUp Envelope、Interaction Kernel、Surface 安全模型与 Capability Registry 的协议边界不因载体而改变。 - -## 2. 已确认的技术路线 - -| 选项 | 决策 | 定位 | -|---|---|---| -| 现有 Kotlin Android | **保留** | 稳定基线、原生能力参考实现和持续交付通道 | -| Tauri 2 | **第一优先开展独立 Spike** | LineUp Mini Runtime 的 Reference Host、安全与能力基线 | -| Wails v3 | **第二阶段按同一基线评估** | 利用 Go 主技术栈的长期 Host 候选 | - -不直接重写或删除当前 Kotlin App。先通过 Tauri Spike 验证跨端 Host、小程序隔离、系统能力桥接和移动端恢复;再以完全相同的 Mini Runtime 协议与验收标准验证 Wails v3。这样可将技术选择从框架偏好转为可实证的交付能力比较。 - -### 2.1 为什么先验证 Tauri - -Tauri 的团队、社区、桌面生态和移动端路径相对成熟,适合作为高标准的参考宿主。它先回答的不是“能否把现有 Android 聊天页换成 Web”,而是“可信 LineUp 主应用与不可信小程序 Surface 能否在多端稳定且安全地共存”。 - -Wails v3 与 Go 主技术栈更匹配,仍是重要的长期候选;但它应接受已验证的 Runtime 基线,而不是由它当前的 Beta 状态来定义平台边界。 - -## 3. 当前资产与迁移匹配度 - -Web Reference Host 已存在下列可复用资产: - -```text -lineup-app-server/assets/chat/ -├── interaction-kernel.js # ConversationItemFactory + RendererRegistry -└── index.html # GFM Markdown、安全净化、Surface Host、App Call UI -``` - -可复用的核心能力包括: - -- `marked + DOMPurify` 的 GFM Markdown; -- `ConversationItem` 与 Renderer Registry; -- `lineup.v1.text`、`agent.status`、`agent.progress`、`error` renderer; -- `lineup.v1.ui.open / patch / close / event` 的 sandbox iframe Surface Host; -- `lineup.v1.app.call / result` 的用户确认交互; -- WebSocket 接入形态(服务端 WSS 尚待提供;当前 Android 是 HTTP 轮询)。 - -目前 Android 使用 OkHttp、RecyclerView、Clipboard、外链 Intent、Activity 生命周期和轮询,不依赖相机、蓝牙、音视频等重量级原生 SDK。故迁移“表现层与会话 UI”的阻力较低,但系统能力、推送和后台恢复仍必须有原生实现。 - -## 4. 方案对比 - -| 维度 | 当前 Kotlin Android | Tauri 2 | Wails v3 | -|---|---|---|---| -| Android UI | 原生 View / RecyclerView | Web 前端 + Android WebView | Web 前端 + 容器;移动端路径需进一步证实 | -| Markdown | 临时 regex + HtmlCompat,不完整 | 直接复用 GFM + sanitizer | 可复用 Web 实现 | -| LineUp Surface | 当前仅原生 Task Dashboard | 可复用 iframe sandbox Host | 需要专项验证 | -| 内置 Renderer / 标准组件 | 需逐个原生实现 | 可复用现有 Web Renderer | 可复用现有 Web Renderer | -| 系统能力 | Kotlin 直接调用 | Tauri Plugin / Kotlin bridge | Go bridge / 原生层,需专项验证 | -| Push、通知、后台、Deep Link | 路径最直接 | 仍需要原生 Plugin | 仍需要原生 bridge | -| Android 成熟度 | 已在项目真机运行 | 官方 Mobile 路径,作为首个真机 Spike | 官方 README 标为 v3 Beta,需以 Tauri 结果作对照 | -| 现有 Web 实现复用 | 低 | 高 | 高 | -| 推荐角色 | 稳定基线 | **Reference Host / 第一优先 Spike** | **Go 主栈长期候选 / 第二阶段对照实现** | - -### 4.1 Tauri 2:Reference Host - -Tauri 2 是当前最适合优先验证的跨端候选。官方 v2 文档覆盖 Android、iOS 与 Mobile;它能够复用已有 Web 表现层资产,同时仍允许通过原生插件承接 Android 系统能力。Tauri 在本项目中承担的是可信 LineUp 主应用与 Mini Runtime 的第一版参考实现,而不是第三方小程序本身。 - -它替换的是会话 UI、Markdown、Renderer、标准组件和 Surface Host,并非把 Android 原生层完全移除。Kotlin/Gradle 仍承担推送、通知、Deep Link、运行时权限、文件选择、分享、后台恢复,以及必要原生 Plugin。 - -### 4.2 Wails v3:Go Host 候选 - -Wails 的 Go 技术栈与 LineUp 的服务端、协议校验、同步、Outbox、Capability Policy 和审计逻辑高度匹配,长期上有明显的维护优势。官方 GitHub README 当前将 Wails v3 标记为 `Beta`;本次核验也没有取得足够的官方 Android 生产支持证据。因此不把它作为第一轮 Runtime 安全边界的验证载体,但在 Tauri 基线明确后,应以同一协议和验收范围做正式对照实现。 - -重点评估 Wails v3 是否能以 Go Host 达到 Tauri Reference Host 的小程序隔离、资源加载、桥接、生命周期、移动端系统能力与恢复标准;若能达到,Go 主栈优势将使其成为长期主方案的强候选。 - -## 5. LineUp Mini Runtime 与 Tauri Reference Host - -```text -LineUp Tauri Reference Host -├── Web Frontend -│ ├── Chat UI / 虚拟列表 -│ ├── CommonMark / GFM Markdown -│ ├── ConversationItem + Renderer Registry -│ ├── 标准组件库 -│ ├── Surface Host -│ └── WebSocket + HTTP 补偿 -├── Rust / Tauri Core -│ ├── 安全 capability policy -│ ├── 会话 / token 安全存储 -│ └── 原生 bridge 管理 -└── Android Kotlin 壳 - ├── Push / 通知 / Deep Link - ├── 文件、权限、系统分享 - └── 必要的原生 Plugin -``` - -Tauri 的 Web Frontend 与第三方 Surface 必须是两个不同的安全域。LineUp 自有前端可以调用受控原生能力;Agent 或第三方 Surface 只能通过经过校验的事件协议请求能力,绝不能获得 Tauri `invoke`、登录 token 或宿主 DOM 的直接访问权。 - -### 5.1 Mini Runtime 的框架无关边界 - -Mini Runtime 是可被 Tauri、Wails 或原生 Shell 实现的协议层,最小由以下模块组成: - -```text -Package Manager app_id / version / manifest / integrity / allowlist -Instance Manager instance_id / open / ready / patch / close / restore -Secure Loader 不可变 bundle / 专用 origin / CSP / 网络与跳转限制 -Event Bridge postMessage schema、大小、频率、来源与实例校验 -Mini API ui.onPatch / ui.emit / app.call / app.result -Capability Gateway policy / 用户确认 / 平台 handler / audit -``` - -第三方小程序只能获得 Mini API,不能直接获得 Tauri 或 Wails 的原生调用对象。内置 Renderer 与标准组件可运行于可信主应用前端;小程序 Surface 则必须运行于隔离执行域。 - -## 6. 不可退让的安全约束 - -1. Agent bundle 与第三方 Surface 一律视为不可信输入。 -2. Surface iframe 不得访问 Tauri invoke API、宿主登录态或宿主 DOM。 -3. 仅允许完成实例、命名空间、事件名和消息大小校验的 `postMessage` bridge。 -4. CSP 默认禁止未授权网络、远程脚本和危险跳转。 -5. `ui.patch` 只能更新 JSON state,不能替换已验证 bundle。 -6. 所有系统能力只允许走 `app.call → Capability Registry → 用户确认 → app.result`。 -7. Android 的推送、通知、后台恢复与权限结果必须可审计、可恢复,不能依赖一个不受控网页生命周期。 - -## 7. Tauri Reference Host Spike 范围与验收 - -Spike 单独建立目录或模块,接入同一 App Server、WuKongIM、Hermes 与测试会话;不得改写当前 Kotlin Android 的可运行路径。先以桌面验证主应用与小程序双安全域,再进入 Android 真机验证。 - -- [ ] 真机登录现有 App Server; -- [ ] 兼容 HTTP `/messages/sync`,为服务端 WSS 留出接入点; -- [ ] 完整 GFM Markdown 与 DOMPurify 安全净化; -- [ ] 兼容 `text / status / progress / tool.call / tool.result`; -- [ ] 实现 `ui.open / patch / close / event` sandbox Surface; -- [ ] 实现 `app.open_url` 与 `clipboard.write` 的 Capability bridge; -- [ ] PJD110 真机安装、滚动性能、断线恢复与安全隔离通过; -- [ ] 与既有 Kotlin 客户端对照确认消息顺序、去重、用户操作回传一致。 - -Wails v3 的第二阶段 Spike 使用以上相同清单。其目标不是重定义协议,而是证明 Go Host 能够以相同小程序包和安全边界完成等价交付。 - -## 8. 本机构建级验证前置条件 - -| 条件 | 当前状态 | -|---|---:| -| Android SDK / Gradle / 项目内 JDK 17 | 已有 | -| Kotlin APK 构建与真机安装 | 已验证 | -| Rust / Cargo | 已安装;本机 Rustup proxy 有清单异常,直接工具链 bin 可用 | -| Tauri CLI | 已作为 `lineup-app/tauri/` 的 npm 开发依赖安装 | -| Wails CLI | 未安装 | -| Android NDK | 未安装 | -| Tauri / Wails 现有工程 | Tauri Reference Host 已建立于 `lineup-app/tauri/`;Wails 尚未建立 | - -Tauri Spike 已进入实现阶段:前端生产构建、Linux debug 二进制和 Debian 包均已生成;Android 真机 Spike 仍需 Android NDK 与 Rust Android targets。当前 Kotlin Android 实验工程保持不变。 diff --git a/tauri/README.md b/tauri/README.md index 7c6e1f7..0b5ef13 100644 --- a/tauri/README.md +++ b/tauri/README.md @@ -1,6 +1,15 @@ # LineUp Tauri Reference Host -这是 LineUp Mini Runtime 的第一版 Tauri 2 Reference Host。它独立于当前 `upstream/tangsengdaodao-android/` 快速实验工程,不替换或修改现有 Android 验证路径。 +这是同一套 LineUp Runtime 的两种运行方式:Tauri 2 Desktop Host 和 Web Reference Host。 +它们不是两个客户端,也不是两套聊天功能;两者加载相同的 Runtime 和 Chat Core App。 + +- **Tauri Desktop Host** 是正式桌面应用的外壳。它提供窗口,并在 Runtime 作出授权决定后 + 调用文件、通知等系统能力。 +- **Web Reference Host** 是浏览器中的开发和验证入口。它方便通过 Vite 热更新、 + Tailscale 联调和自动化测试,但受浏览器权限限制,不能替代桌面外壳。 + +无论使用哪种方式,AppServer 通信、消息同步、Store 和 Agent 协议都由 Runtime 管理。 +当前不存在并行的独立 Android/Kotlin 或 Wails 客户端路线。 ## 已实现的最小闭环 @@ -16,7 +25,7 @@ AppServer 已配置只允许 `tauri://localhost`、`https://tauri.localhost`、` macOS 的 `src-tauri/Info.plist` 为当前 Tailscale / 私网 HTTP AppServer 配置了 `NSAllowsArbitraryLoadsInWebContent`。该例外仅作用于 Host 的 WKWebView 内容请求,使其能访问类似 `http://100.x.y.z:8090` 的地址,不放宽 Rust 原生网络层;待所有 AppServer 都迁移到 HTTPS 后应移除。 -`ui.open / patch / close` 和 `app.call` 已被协议层识别,但目前安全降级为可见提示;它们是下一阶段 Mini Runtime Surface Host 与 Capability Registry 的实现范围,不能在当前 Host 页面中直接执行 Agent JavaScript 或直接授予设备能力。 +Surface、Capability、签名 Bundle 与隔离 bridge 已有 M2~M4 的实现和测试基础,但它们不等于已开放的应用市场或通用 Installed App。当前 MVP-R1 的重点是 Runtime 托管 Chat:任何 Surface 或能力请求仍必须经过 Runtime 验证、受限 bridge、Capability Policy 和用户确认,不能在 Host 页面中直接执行 Agent JavaScript 或直接授予设备能力。 ## Milestone 0 当前行为基线 @@ -24,14 +33,14 @@ macOS 的 `src-tauri/Info.plist` 为当前 Tailscale / 私网 HTTP AppServer 配 | 范围 | 当前行为 | 当前实现位置 | 重构后必须保持 | |---|---|---|---| -| 登录 | 用户输入 AppServer 地址、手机号、验证码;登录请求 10 秒超时;地址仅保存到 localStorage | `src/main.ts`、`src/runtime/transport-adapter.ts` | 成功登录进入会话;失败原因对用户可见 | +| 登录 | 用户输入 AppServer 地址、手机号、验证码;登录请求 10 秒超时;地址仅保存到 localStorage | `src/main.ts`、`src/runtime/communication/transport-adapter.ts` | 成功登录进入会话;失败原因对用户可见 | | 会话 | 当前只有一个 Agent channel;退出清空页面内消息并停止同步 | `src/main.ts` | 会话状态必须可由 Store 管理,不能依赖 DOM | -| 发送 | 用户消息先本地显示,再通过 Transport 调用 `/messages/send`;显示已发送或失败 | `src/main.ts`、`src/runtime/transport-adapter.ts` | 即时反馈、送达状态与失败可见性不退化 | -| 历史同步 | 首次从 cursor `0` 连续拉取到空页;之后以 `lastSeq + 1` 每 1.5 秒经 Transport 增量轮询 | `src/main.ts`、`src/runtime/transport-adapter.ts` | 保持包含式 cursor 语义,不能漏消息或无限热循环 | -| Agent 文本 | 将 `lineup.v1.text` 解码为 Markdown,使用 `marked + DOMPurify` 渲染 | `src/protocol.ts`、`src/runtime/trusted-dom-renderers.ts` | GFM、安全净化和外链防护不退化 | -| Agent 状态 | `thinking` 显示临时指示器;其他状态更新顶栏 presence | `src/protocol.ts`、`src/runtime/trusted-dom-renderers.ts` | 状态应更新已有状态项,而非无限追加气泡 | -| 进度和错误 | progress/error 目前以系统提示显示 | `src/protocol.ts`、`src/runtime/trusted-dom-renderers.ts` | 迁移后至少保持可见,M1 再升级为正式卡片 | -| 未实现扩展 | `ui.open / patch / close` 与 `app.call` 仅安全降级为提示,绝不执行不可信脚本或原生能力 | `src/protocol.ts`、`src/main.ts` | 在 M2/M3 完成前始终保持拒绝执行 | +| 发送 | 用户消息先本地显示,再通过 Transport 调用 `/messages/send`;显示已发送或失败 | `src/main.ts`、`src/runtime/communication/transport-adapter.ts` | 即时反馈、送达状态与失败可见性不退化 | +| 历史同步 | 首次从 cursor `0` 连续拉取到空页;之后以 `lastSeq + 1` 每 1.5 秒经 Transport 增量轮询 | `src/main.ts`、`src/runtime/communication/transport-adapter.ts` | 保持包含式 cursor 语义,不能漏消息或无限热循环 | +| Agent 文本 | 将 `lineup.v1.text` 解码为 Markdown,使用 `marked + DOMPurify` 渲染 | `src/runtime/protocol/lineup-v1.ts`、`src/core-apps/chat/trusted-dom-renderers.ts` | GFM、安全净化和外链防护不退化 | +| Agent 状态 | `thinking` 显示临时指示器;其他状态更新顶栏 presence | `src/runtime/protocol/lineup-v1.ts`、`src/core-apps/chat/trusted-dom-renderers.ts` | 状态应更新已有状态项,而非无限追加气泡 | +| 进度和错误 | progress/error 目前以系统提示显示 | `src/runtime/protocol/lineup-v1.ts`、`src/core-apps/chat/trusted-dom-renderers.ts` | 迁移后至少保持可见,M1 再升级为正式卡片 | +| 未实现扩展 | `ui.open / patch / close` 与 `app.call` 仅安全降级为提示,绝不执行不可信脚本或原生能力 | `src/runtime/protocol/lineup-v1.ts`、`src/main.ts` | 在 M2/M3 完成前始终保持拒绝执行 | ### M0 回归场景 @@ -52,7 +61,7 @@ M0-01~M0-08 已完成。Mac 经 Linux Tailscale Web Host `http://100.121.118.1 ### M1-01 Tool Call 状态机(已完成) -`src/protocol.ts` 现可严格解码 `lineup.v1.tool.call`、`lineup.v1.tool.result`、`lineup.v1.tool.cancel`,只接受 `choice`、`confirm`、`input` 三种声明性请求。`src/runtime/tool-call-state.ts` 是独立于 UI、Storage、Transport、Tauri 与 Capability 的纯状态机;它以 `call_id` 为唯一关联键,只允许 `pending → submitted → completed / failed / cancelled / expired`,拒绝重复调用及非法状态跃迁。Interaction Kernel 仅投影这些状态,且只有已提交的 call 能接受 result。 +`src/runtime/protocol/lineup-v1.ts` 现可严格解码 `lineup.v1.tool.call`、`lineup.v1.tool.result`、`lineup.v1.tool.cancel`,只接受 `choice`、`confirm`、`input` 三种声明性请求。`src/runtime/coordination/tool-call-state.ts` 是独立于 UI、Storage、Transport、Tauri 与 Capability 的纯状态机;它以 `call_id` 为唯一关联键,只允许 `pending → submitted → completed / failed / cancelled / expired`,拒绝重复调用及非法状态跃迁。Interaction Kernel 仅投影这些状态,且只有已提交的 call 能接受 result。 这不是可用的交互组件:M1-01 不渲染选项、不提供按钮/表单、不发送 tool result,也不修改 Hermes Adapter。可信 ActionGroup 留给 M1-02,Confirm/Input/Form 留给 M1-03。`npm test` 当前为 3 个文件 / 14 个测试通过,`npm run build` 通过。 @@ -82,11 +91,11 @@ Conversation Store v5 持久化任务快照,刷新或重新登录后仍只显 ### 当前结构债务(M0 的改造对象) -`src/main.ts` 目前包含 App Shell、页面 DOM、会话 cursor、同步循环与 Runtime 编排;M0-05 已将登录、发送与同步 HTTP `fetch` 迁入 `src/runtime/transport-adapter.ts`,M0-06 已将所有可信 DOM renderer 迁入 `src/runtime/trusted-dom-renderers.ts`。在 M0 准入前不新增 Surface 或 Capability 功能。 +该段记录的是 M0 历史结构债务。当前 MVP-R1 已将 Transport、Store、sync loop 与 outbox 收敛至 `src/runtime/coordination/lineup-runtime.ts`;HTTP 实现位于 `src/runtime/communication/transport-adapter.ts`,可信 Chat DOM Renderer 位于 `src/core-apps/chat/trusted-dom-renderers.ts`。现行目录与边界见 [src/README.md](src/README.md)。 ### M0-02 协议模型(已完成) -`src/protocol.ts` 是目前唯一允许把 Transport 原始 payload 转换成可信表现模型的入口。它定义了 `JsonValue`、`Actor`、严格 `Envelope`、`ConversationItem`、`UserAction`、`DeliveryState` 与 `ProtocolFallback`,并提供: +`src/runtime/protocol/lineup-v1.ts` 是目前唯一允许把 Transport 原始 payload 转换成可信表现模型的入口。它定义了 `JsonValue`、`Actor`、严格 `Envelope`、`ConversationItem`、`UserAction`、`DeliveryState` 与 `ProtocolFallback`,并提供: - `parseEnvelope(value)`:校验版本、消息类型、`id`、`conversation_id`、sender/target、timestamp 和 JSON payload; - `parseEnvelopeJSON(raw)`:供不接受历史纯文本的 Transport / Kernel 路径把非法 JSON 明确归为 `invalid_json`; @@ -96,9 +105,9 @@ Conversation Store v5 持久化任务快照,刷新或重新登录后仍只显 ### M0-03 Interaction Kernel 与 Renderer Registry(已完成) -`src/runtime/interaction-kernel.ts` 是 Transport 与表现层之间的唯一入口:它将原始同步/实时 payload 解码为 `ConversationItem`,对有有效 LineUp `id` 的消息实施有界去重,并将 Agent status 投影为可读取的 presence 快照。它不执行 `ui.*`、`app.*` 或用户动作。 +`src/runtime/coordination/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 完成时具体 DOM renderer 仍在 `main.ts`;该 M0-06 迁移现已完成,当前可信 DOM renderer 位于 `src/runtime/trusted-dom-renderers.ts`。 +`src/core-apps/chat/renderer-registry.ts` 只根据已验证的 `ConversationItem.kind` 分派已注册的可信 renderer;renderer 的输入只有表现项与 View context,不会获得 Transport 或 Capability 对象。当前聊天页已通过该 Registry 渲染同步进入的标准项。历史上 M0-03 完成时具体 DOM renderer 仍在 `main.ts`;该 M0-06 迁移现已完成,当前可信 DOM renderer 位于 `src/core-apps/chat/trusted-dom-renderers.ts`。 ## 本地命令 @@ -127,7 +136,13 @@ npm run tauri build -- --debug `npm run desktop:dev` 不生成 `.app`、`.dmg` 或安装包。前端 TypeScript / CSS / HTML 保存后会由 Vite 自动热更新;改动 `src-tauri/` 中的 Rust Host 代码时,Tauri 会重新编译并重启开发窗口。只有需要交付可安装文件时才执行 `tauri build`。 -`npm run web:dev` 只运行同一份可信 Host Web UI,适合在浏览器中快速调试登录、消息和 Renderer。它监听 `0.0.0.0:1420`,Mac 通过 Tailscale 打开 `http://100.121.118.116:1420`,直接验证 Linux 工作区的 Vite 热更新;不需要为每次验证将源码同步到 Mac。它不具备 Tauri 原生能力,也不应作为第三方 Mini Runtime Surface 的安全验收环境。AppServer 仅对固定开发 Origin `http://127.0.0.1:1420` 和 `http://100.121.118.116:1420` 配置 CORS;若改动 Vite 端口或 Host 地址,必须同步收紧更新 AppServer 的 `client.allowedOrigins`,不得改为 wildcard。 +`npm run web:dev` 是在浏览器里启动同一份可信 Host UI,适合快速检查登录、消息和 +Renderer。它监听 `0.0.0.0:1420`;Mac 可经 Tailscale 打开 +`http://100.121.118.116:1420`,直接看到 Linux 工作区的 Vite 热更新,无须把源码同步到 +Mac。浏览器模式没有 Tauri 的原生能力,也不能用作第三方 Mini Runtime Surface 的安全验收 +环境。AppServer 只对固定开发 Origin `http://127.0.0.1:1420` 和 +`http://100.121.118.116:1420` 配置 CORS;若改动 Vite 端口或 Host 地址,必须同步更新并 +保持收紧 AppServer 的 `client.allowedOrigins`,不得改成允许任意来源的 wildcard。 本项目的 `tauri` 与 `check:rust` npm 脚本会自动识别 Linux x86_64、macOS Apple Silicon、macOS Intel 的 Rust stable toolchain,并加入 `PATH`。因此不要直接执行裸 `tauri build`;统一使用 `npm run tauri build -- --debug`。如 Rust toolchain 安装在其他位置,可临时覆盖: @@ -140,7 +155,7 @@ RUST_TOOLCHAIN_BIN=/path/to/rust/bin npm run tauri build -- --debug - `npm run build`:已通过; - Cargo/Tauri Rust crates:已下载并开始编译; - Tauri Linux desktop build:已通过;debug 可执行文件和 Debian 包已生成; -- Android Spike:尚未开始。仍需要 Android NDK、Rust Android targets 与 Tauri Android 初始化。 +- 独立 Android/Kotlin 与 Wails Host:不在当前或后续规划范围内。 Linux 桌面构建依赖以下系统开发库;本机已具备。新环境缺少时可安装: @@ -156,17 +171,21 @@ src-tauri/target/debug/lineup-tauri src-tauri/target/debug/bundle/deb/LineUp_0.1.0_amd64.deb ``` -Tauri 构建产物位于 `src-tauri/target/`,已被 Git 忽略。图标源文件是 `src-tauri/icons/icon.svg`,其余 PNG/ICO/ICNS 与 Android/iOS 图标由 `npm run tauri icon src-tauri/icons/icon.svg` 生成。 +Tauri 构建产物位于 `src-tauri/target/`,已被 Git 忽略。图标源文件是 `src-tauri/icons/icon.svg`;当前交付只面向 Tauri Desktop Host,其他图标派生产物不代表额外客户端路线。 ## 目录边界 ```text tauri/ -├── src/ # 可信 LineUp Host 前端 -│ ├── main.ts # 会话 UI、AppServer transport、Renderer 调度 -│ └── protocol.ts # 平台无关的 LineUp v1 → ConversationItem 解码 +├── src/ +│ ├── main.ts # 应用启动装配入口:连接 Host、Runtime 与默认 App +│ ├── core-apps/chat/ # Chat Shell、可信 Renderer 与样式 +│ └── runtime/ # 按通信、协调、存储、应用、Surface、能力等分层 ├── src-tauri/ # Tauri Rust Host 与 capability 配置 +├── vite.config.ts # @/ → src/ 路径别名(Vite / Vitest) └── package.json # Vite、Tauri CLI 与前端依赖 ``` +各 Runtime 子目录、依赖方向与新增模块约定见 [src/README.md](src/README.md)。 + 第三方 Agent Surface 未来必须运行于独立隔离执行域,只能经 LineUp Mini Runtime 事件桥与 Capability Gateway 通信;不得访问 Tauri `invoke`、Host DOM、登录态或本机特权。 diff --git a/tauri/src-tauri/src/lib.rs b/tauri/src-tauri/src/lib.rs index 25e1a98..9d48d41 100644 --- a/tauri/src-tauri/src/lib.rs +++ b/tauri/src-tauri/src/lib.rs @@ -1,3 +1,4 @@ +// Tauri Rust Host 的应用构造入口;Runtime 协议和业务路由仍位于 TypeScript LineUpRuntime。 #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() diff --git a/tauri/src-tauri/src/main.rs b/tauri/src-tauri/src/main.rs index 838d772..e47f417 100644 --- a/tauri/src-tauri/src/main.rs +++ b/tauri/src-tauri/src/main.rs @@ -1,3 +1,4 @@ +// Tauri 桌面二进制入口:仅委托给 lib.rs 中的可信 Host 构造逻辑。 fn main() { lineup_tauri_lib::run(); } diff --git a/tauri/src/README.md b/tauri/src/README.md new file mode 100644 index 0000000..49d2c49 --- /dev/null +++ b/tauri/src/README.md @@ -0,0 +1,61 @@ +# Tauri / Web Reference Host 源码导航 + +`src/` 是 LineUp 的前端实现目录。可以把它理解为三层协作:Runtime 负责连接、状态、 +存储和恢复;Chat 是用户首先看见的聊天功能;Tauri 或浏览器则是装载这套程序的运行外壳。 +同一份源码既可放进正式桌面窗口,也可在浏览器中用于开发和验证。 + +`main.ts` 是应用启动时的装配入口:它把 Host 侧实现、`LineUpRuntime` 和默认 Core App +接起来。它不负责网络收发、消息存储、同步循环或原始 Agent 消息解析;这些都由 Runtime +统一管理。 + +```text +src/ +├── main.ts # 启动装配入口:连接 Tauri/Web Host、Runtime 与默认 App +├── core-apps/ +│ └── chat/ # 第一个可信 Core App +│ ├── chat-app-host.ts # Chat 的 Host 装配器:样式、Shell、Renderer 与 SDK +│ ├── chat-shell.ts # Chat 的 DOM Shell +│ ├── trusted-dom-renderers.ts # 已验证消息 → 可信 DOM +│ ├── renderer-registry.ts # Chat Renderer 分派 +│ └── styles/ # Chat 专属样式 +└── runtime/ + ├── app-management/ # Core App Registry、Chat SDK、App Host + ├── artifacts/ # Artifact 元数据与短生命周期内容缓存 + ├── capabilities/ # Capability Registry、状态机、审计与执行器 + ├── communication/ # AppServer Transport + ├── coordination/ # Runtime、Kernel、scope 路由、Tool/Task 状态 + ├── inventory/ # Agent 可见 Client/Tool Inventory + ├── persistence/ # Conversation Store、Outbox、App Inbox + ├── protocol/ # LineUp v1 解码与 golden fixture + └── surfaces/ # Surface Registry、实例、Bundle 与隔离 Host +``` + +## 谁可以依赖谁 + +```text +core-apps/chat ──ChatRuntimeSDK──► runtime/app-management + │ + Host adapters ───────────────► runtime/coordination/LineUpRuntime + │ + communication · persistence · protocol · capabilities + │ + surfaces / inventory / artifacts +``` + +- App 不直接导入 `communication`、`persistence` 或原始协议解码模块;Chat 通过 + `ChatRuntimeSDK` 接收已筛选的状态、消息和 Inbox,并以 Runtime Action 发起用户动作。 + 简单说,Chat 可以向 Runtime 说“请发送这段文字”,但不能自己连服务器或改数据库。 +- `CoreAppRegistry` 决定默认启动哪个应用;`CoreAppHostRegistry` 决定如何挂载这个应用。 + 当前注册的实现仍是 `chat`,但 `main.ts` 不再直接导入 Chat 的样式、Renderer 或 DOM + Shell。 +- Runtime 通过 `openApp(app_scope)` 打开对应的 SDK 投影;`openChatApp()` 只作为旧调用方的 + 兼容别名,新的 Core App Host 应使用注册表作用域打开 SDK。 +- `runtime/coordination/lineup-runtime.ts` 是 Transport、Store、sync loop 和 outbox + 的唯一所有者。 +- 每个模块的测试与实现同目录放置;`runtime/protocol/golden/` 保存兼容协议 fixture。 +- `@/` 指向 `src/`,由 `tsconfig.json` 与 `vite.config.ts` 共同配置。新增代码应使用该 + 别名,避免跨层的相对路径依赖。 + +未来新增 `voice`、`whiteboard` 等 Core/Installed App 时,应给它们各自的目录和 SDK +投影;不能把页面、网络连接或持久化逻辑重新堆回 `main.ts`。这样新应用只增加自己的体验, +不会破坏已有聊天和连接逻辑。 diff --git a/tauri/src/core-apps/chat/chat-app-host.ts b/tauri/src/core-apps/chat/chat-app-host.ts new file mode 100644 index 0000000..d5bf401 --- /dev/null +++ b/tauri/src/core-apps/chat/chat-app-host.ts @@ -0,0 +1,90 @@ +/** + * Chat Core App 的 Host 装配器。 + * + * Chat 的样式、Shell、Renderer 和 DOM 上下文都属于 Chat 自己;Runtime Host + * 只负责根据 app_scope 选择这个装配器,main.ts 不再直接依赖 Chat 的内部模块。 + */ +import "@/core-apps/chat/styles/app.css"; +import "@/core-apps/chat/styles/capability-card.css"; +import "@/core-apps/chat/styles/execution-progress.css"; +import { LineUpRuntime } from "@/runtime/coordination/lineup-runtime"; +import type { ChatRuntimeSDK } from "@/runtime/app-management/app-sdk"; +import { mountChatShell } from "@/core-apps/chat/chat-shell"; +import { RendererRegistry } from "@/core-apps/chat/renderer-registry"; +import { + TrustedDOMRendererContext, + type CapabilityInteractionHandlers, + type TaskInteractionHandlers, + type ToolInteractionHandlers, +} from "@/core-apps/chat/trusted-dom-renderers"; + +export type ChatAppHostOptions = { + toolInteractions?: ToolInteractionHandlers; + taskInteractions?: TaskInteractionHandlers; + capabilityInteractions?: CapabilityInteractionHandlers; +}; + +export type MountedChatApp = { + app_scope: "chat"; + sdk: ChatRuntimeSDK; + messages: HTMLElement; + loginView: HTMLElement; + chatView: HTMLElement; + presence: HTMLElement; + rendererContext: TrustedDOMRendererContext; + rendererRegistry: RendererRegistry; +}; + +function query(root: ParentNode, selector: string): T { + const found = root.querySelector(selector); + if (!found) throw new Error(`Missing Chat element: ${selector}`); + return found; +} + +/** Mounts the trusted Chat implementation selected by the Runtime App Host. */ +export function mountChatApp( + runtime: LineUpRuntime, + root: HTMLElement, + options: ChatAppHostOptions, +): MountedChatApp { + mountChatShell(root); + const messages = query(root, "#messages"); + const loginView = query(root, "#login-view"); + const chatView = query(root, "#chat-view"); + const presence = query(root, "#presence"); + const rendererContext = new TrustedDOMRendererContext( + messages, + presence, + options.toolInteractions, + options.taskInteractions, + options.capabilityInteractions, + ); + const rendererRegistry = new RendererRegistry(); + registerChatRenderers(rendererRegistry); + + return { + app_scope: "chat", + sdk: runtime.openApp("chat"), + messages, + loginView, + chatView, + presence, + rendererContext, + rendererRegistry, + }; +} + +function registerChatRenderers(registry: RendererRegistry): void { + registry.register("user-message", (item, context) => context.renderUserMessage(item)); + registry.register("markdown", (item, context) => context.renderMarkdown(item)); + registry.register("agent-status", (item, context) => context.renderAgentStatus(item)); + registry.register("execution-summary", (item, context) => context.renderExecutionTrace(item)); + registry.register("progress", (item, context) => context.renderProgress(item)); + registry.register("error", (item, context) => context.renderError(item)); + registry.register("tool-call", (item, context) => context.renderToolCall(item)); + registry.register("tool-result", (item, context) => context.renderToolResult(item)); + registry.register("tool-cancel", (item, context) => context.renderToolCancel(item)); + registry.register("surface", (item, context) => context.renderSurface(item)); + registry.register("app-call", (item, context) => context.renderAppCall(item)); + registry.register("fallback", (item, context) => context.renderFallback(item)); +} diff --git a/tauri/src/core-apps/chat/chat-shell.ts b/tauri/src/core-apps/chat/chat-shell.ts new file mode 100644 index 0000000..a973c6d --- /dev/null +++ b/tauri/src/core-apps/chat/chat-shell.ts @@ -0,0 +1,26 @@ +/** + * Chat Core App 的可信 DOM 外壳。 + * + * 这里只声明登录与聊天页面结构;Agent 消息、状态和用户动作都必须经过 + * ChatRuntimeSDK,而不能在页面中直接接触 Runtime 的通信或存储实现。 + */ +export function mountChatShell(root: HTMLElement): void { + root.innerHTML = ` +
+ + +
`; +} diff --git a/tauri/src/core-apps/chat/interaction-mode-registry.ts b/tauri/src/core-apps/chat/interaction-mode-registry.ts new file mode 100644 index 0000000..79bb0fe --- /dev/null +++ b/tauri/src/core-apps/chat/interaction-mode-registry.ts @@ -0,0 +1,24 @@ +/** + * Product-facing Interaction App mode boundary. `chat` remains the wire and + * directory compatibility name, while IM is now explicitly one Interaction + * mode alongside Audio and Video. Modes have no Transport or Host privilege. + */ +export type InteractionMode = "im" | "audio" | "video"; + +export type InteractionModeRecord = { + mode: InteractionMode; + enabled: boolean; + requires_permission?: string; +}; + +export class InteractionModeRegistry { + private readonly modes = new Map([ + ["im", { mode: "im", enabled: true }], + ["audio", { mode: "audio", enabled: false, requires_permission: "media.microphone" }], + ["video", { mode: "video", enabled: false, requires_permission: "media.camera" }], + ]); + public list(): readonly InteractionModeRecord[] { return [...this.modes.values()].map(mode => ({ ...mode })); } + public get(mode: InteractionMode): InteractionModeRecord { return { ...(this.modes.get(mode) ?? { mode, enabled: false }) }; } + /** Only a trusted Runtime installation/configuration path can enable a mode. */ + public configure(mode: InteractionMode, enabled: boolean): void { this.modes.set(mode, { ...this.get(mode), enabled }); } +} diff --git a/tauri/src/core-apps/chat/interaction-runtime.ts b/tauri/src/core-apps/chat/interaction-runtime.ts new file mode 100644 index 0000000..ad0b4c9 --- /dev/null +++ b/tauri/src/core-apps/chat/interaction-runtime.ts @@ -0,0 +1,15 @@ +import type { ChatRuntimeSDK } 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 activeMode(): InteractionMode { return this.current; } + public selectMode(mode: InteractionMode): boolean { + if (!this.modes.get(mode).enabled) return false; + this.current = mode; + return true; + } +} diff --git a/tauri/src/runtime/renderer-registry.ts b/tauri/src/core-apps/chat/renderer-registry.ts similarity index 84% rename from tauri/src/runtime/renderer-registry.ts rename to tauri/src/core-apps/chat/renderer-registry.ts index 61dc5e0..705983d 100644 --- a/tauri/src/runtime/renderer-registry.ts +++ b/tauri/src/core-apps/chat/renderer-registry.ts @@ -1,4 +1,10 @@ -import type { ConversationItem } from "../protocol"; +/** + * Chat 的可信 Renderer 注册表。 + * + * 只允许已由 Runtime/协议层验证过的 ConversationItem 进入对应 Renderer,防止未校验的 + * 原始 Agent payload 直接驱动 DOM。 + */ +import type { ConversationItem } from "@/runtime/protocol/lineup-v1"; export type ConversationItemKind = ConversationItem["kind"]; export type ItemOfKind = Extract; diff --git a/tauri/src/style.css b/tauri/src/core-apps/chat/styles/app.css similarity index 98% rename from tauri/src/style.css rename to tauri/src/core-apps/chat/styles/app.css index 3e7b4b3..d84b590 100644 --- a/tauri/src/style.css +++ b/tauri/src/core-apps/chat/styles/app.css @@ -1 +1,2 @@ +/* Chat Core App 的页面基础样式:登录页、会话时间线、输入区与通用布局。 */ :root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#e9f0ec;background:#101815;font-synthesis:none}*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;min-width:320px;min-height:100vh}.shell{min-height:100vh;background:radial-gradient(circle at 5% 0,#29473d 0,transparent 32rem),#101815}.login-view{display:grid;place-items:center;gap:2.5rem;min-height:100vh;padding:2rem}.brand{display:flex;align-items:center;gap:1rem}.brand>span,.avatar{display:grid;place-items:center;border-radius:50%;background:#b8e4cf;color:#11251d;font-weight:800}.brand>span{width:3.2rem;height:3.2rem;font-size:1.4rem}.brand p{margin:0;color:#a5c9b8;font-size:.75rem;letter-spacing:.16em}.brand h1{margin:.25rem 0 0;font-size:1.5rem}.login-card{display:grid;width:min(100%,26rem);gap:1rem;padding:1.5rem;border:1px solid #365245;border-radius:1rem;background:#18251f;box-shadow:0 1rem 4rem #0006}.login-card label{display:grid;gap:.45rem;color:#b8c9c0;font-size:.85rem}.login-card input,.composer textarea{border:1px solid #456253;border-radius:.65rem;background:#0e1713;color:#eff7f2;padding:.75rem;font:inherit}.login-card button,.composer button,.action-group-card button,.tool-card button,.task-card button{border:0;border-radius:.65rem;padding:.8rem 1rem;background:#a9dfc4;color:#102018;font-weight:750;cursor:pointer}.error{min-height:1.2rem;margin:0;color:#ffaaa4;font-size:.85rem}.chat-view{display:grid;grid-template-rows:auto 1fr auto;height:100vh;max-width:62rem;margin:auto;border-inline:1px solid #2d4438;background:#142019}.chat-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;border-bottom:1px solid #2c4437}.agent{display:flex;align-items:center;gap:.75rem}.avatar{width:2.4rem;height:2.4rem}.agent strong,.agent small{display:block}.agent small{margin-top:.12rem;color:#9bb5a7;font-size:.8rem}.quiet{border:0;background:transparent;color:#b7cabe;cursor:pointer}.messages{overflow:auto;padding:1.25rem;display:grid;align-content:start;gap:.75rem}.message-row{display:flex;gap:.6rem;max-width:min(85%,45rem)}.message-row.human{justify-self:end}.message-row.human .bubble{background:#a9dfc4;color:#122119}.message-avatar{display:grid;place-items:center;width:1.75rem;height:1.75rem;border-radius:50%;background:#315d4a;color:#d8f5e5;font-size:.8rem}.bubble{padding:.75rem .9rem;border-radius:.9rem;background:#23342b;color:#e7f0eb;line-height:1.55;overflow-wrap:anywhere}.meta{display:block;margin-top:.45rem;opacity:.6;font-size:.68rem}.markdown :first-child{margin-top:0}.markdown :last-child{margin-bottom:0}.markdown pre{overflow:auto;padding:.7rem;border-radius:.5rem;background:#0b110e}.markdown code{font-family:"SFMono-Regular",Consolas,monospace}.system{justify-self:center;margin:.35rem 0;color:#98afa2;font-size:.78rem}.thinking .bubble{color:#c7e3d5}.action-group-card,.tool-card,.task-card{display:grid;justify-self:start;gap:.7rem;width:min(100%,32rem);padding:1rem;border:1px solid #537362;border-radius:.9rem;background:#1c2c24}.action-group-card>p,.tool-card>p{margin:0;color:#bfd1c6;line-height:1.45}.action-group-actions{display:grid;gap:.5rem}.action-group-actions.multi-choice{grid-template-columns:repeat(auto-fit,minmax(8rem,1fr))}.action-group-actions.button{grid-template-columns:repeat(auto-fit,minmax(7rem,1fr))}.action-group-action{min-height:2.7rem;text-align:left}.action-group-actions.button .action-group-action{text-align:center}.action-group-action[aria-pressed="true"]{outline:2px solid #e0f8ea;background:#d0f0db}.action-group-submit{justify-self:start}.action-group-status,.tool-card-status,.task-card-status{color:#aac5b5}.action-group-card[data-status="submitted"],.tool-card[data-status="submitted"],.task-card[data-status="completed"]{border-color:#89c7a6}.tool-card[data-status="cancelled"],.tool-card[data-status="expired"],.task-card[data-status="cancelled"],.task-card[data-status="failed"]{border-color:#587064}.task-card progress{width:100%;height:.7rem;accent-color:#a9dfc4}.task-card .secondary-action{justify-self:start;background:#395447;color:#e2f1e8}.tool-card form{display:grid;gap:.75rem}.tool-card form label{display:grid;gap:.35rem;color:#c7d9ce;font-size:.85rem}.tool-card form input,.tool-card form textarea{width:100%;border:1px solid #456253;border-radius:.65rem;background:#0e1713;color:#eff7f2;padding:.7rem;font:inherit}.tool-card form textarea{min-height:5rem;resize:vertical}.tool-card-actions{display:flex;flex-wrap:wrap;gap:.5rem}.tool-card-actions .secondary-action{background:#395447;color:#e2f1e8}.tool-card-error{min-height:1.2rem;margin:0;color:#ffaaa4;font-size:.82rem}.composer{display:flex;gap:.75rem;padding:1rem;border-top:1px solid #2c4437;background:#16241d}.composer textarea{flex:1;max-height:8rem;resize:vertical}.composer button:disabled,.action-group-card button:disabled,.tool-card button:disabled,.tool-card input:disabled,.tool-card textarea:disabled,.task-card button:disabled{opacity:.5;cursor:not-allowed}@media(max-width:560px){.chat-view{border:0}.message-row{max-width:94%}.login-view{gap:1.4rem;padding:1rem}} diff --git a/tauri/src/capability-card.css b/tauri/src/core-apps/chat/styles/capability-card.css similarity index 89% rename from tauri/src/capability-card.css rename to tauri/src/core-apps/chat/styles/capability-card.css index f2c1871..ab1e5d6 100644 --- a/tauri/src/capability-card.css +++ b/tauri/src/core-apps/chat/styles/capability-card.css @@ -1,3 +1,4 @@ +/* Chat 中可信 Capability 确认卡片的视觉状态;样式不授予或执行任何系统能力。 */ .capability-card { display: grid; justify-self: start; diff --git a/tauri/src/execution-progress.css b/tauri/src/core-apps/chat/styles/execution-progress.css similarity index 94% rename from tauri/src/execution-progress.css rename to tauri/src/core-apps/chat/styles/execution-progress.css index e62a995..28f3d4a 100644 --- a/tauri/src/execution-progress.css +++ b/tauri/src/core-apps/chat/styles/execution-progress.css @@ -1,3 +1,4 @@ +/* Chat 内受限执行摘要卡片:只展示 Runtime/Adapter 已归约的公开步骤。 */ .execution-progress-card { justify-self: start; width: min(100%, 32rem); diff --git a/tauri/src/runtime/trusted-dom-renderers.ts b/tauri/src/core-apps/chat/trusted-dom-renderers.ts similarity index 97% rename from tauri/src/runtime/trusted-dom-renderers.ts rename to tauri/src/core-apps/chat/trusted-dom-renderers.ts index d000338..0462f01 100644 --- a/tauri/src/runtime/trusted-dom-renderers.ts +++ b/tauri/src/core-apps/chat/trusted-dom-renderers.ts @@ -1,3 +1,9 @@ +/** + * Chat Core App 的可信 DOM 渲染实现。 + * + * 本模块只把受限的表现模型转成页面节点;Markdown 必须经过 sanitizer,交互卡和能力卡 + * 只能调用 SDK 提供的受限回调,不能取得 Transport、Store 或 Tauri 特权对象。 + */ import DOMPurify from "dompurify"; import { marked } from "marked"; import type { @@ -8,12 +14,12 @@ import type { InputForm, JsonObject, ToolCallRequest, -} from "../protocol"; -import type { ToolCallRecord } from "./tool-call-state"; -import type { TaskRecord } from "./task-state"; -import type { ExecutionProgressSummary } from "./execution-progress-state"; -import type { ArtifactRecord } from "./artifact-state"; -import type { CapabilityCallRecord } from "./capability-call-state"; +} from "@/runtime/protocol/lineup-v1"; +import type { ToolCallRecord } from "@/runtime/coordination/tool-call-state"; +import type { TaskRecord } from "@/runtime/coordination/task-state"; +import type { ExecutionProgressSummary } from "@/runtime/coordination/execution-progress-state"; +import type { ArtifactRecord } from "@/runtime/artifacts/artifact-state"; +import type { CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state"; export type ToolInteractionHandlers = { submit: (callID: string, submission: JsonObject) => boolean; diff --git a/tauri/src/main.ts b/tauri/src/main.ts index 1012193..06e7324 100644 --- a/tauri/src/main.ts +++ b/tauri/src/main.ts @@ -1,43 +1,34 @@ -import "./style.css"; -import "./capability-card.css"; -import "./execution-progress.css"; +/** + * Tauri/Desktop 与 Web Reference Host 的组合入口。 + * + * 本文件负责创建 Host 侧适配、LineUpRuntime 和默认 Chat Core App,并连接可信的 + * Renderer、Surface、Capability 集成。Transport、Store、同步循环、outbox 与原始 + * Agent 消息解析均由 Runtime 独占,不能重新回流到此入口。 + */ +// 协议层只提供经过 Runtime 校验后的表现模型类型;本入口不直接解析原始网络数据。 import { - type Actor, type ConversationItem, - type Envelope, type JsonObject, - parseEnvelopeJSON, -} from "./protocol"; -import { ConversationStore } from "./runtime/conversation-store"; -import { ExecutionProgressState } from "./runtime/execution-progress-state"; -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 { CachedVerifiedSurfaceDocumentResolver, IsolatedSurfaceHost } from "./runtime/isolated-surface-host"; -import { ProductionSurfaceManifestRegistry } from "./runtime/production-surface-manifest"; -import { ProductionSurfacePolicy } from "./runtime/production-surface-policy"; -import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, WebCryptoEd25519SurfaceManifestSignatureVerifier } from "./runtime/surface-bundle-cache"; -import { CapabilityRegistry } from "./runtime/capability-registry"; -import { ClientInventoryPublisher } from "./runtime/client-inventory"; -import { ArtifactState } from "./runtime/artifact-state"; -import { ArtifactContentCache } from "./runtime/artifact-content-cache"; -import { CapabilityCallStateMachine, type CapabilityCallRecord, parseCapabilityCall } from "./runtime/capability-call-state"; -import { CapabilityAuditLog } from "./runtime/capability-audit"; -import { CapabilityExecutor, type PickedFile } from "./runtime/capability-executor"; - -type Session = { - api: string; - uid: string; - agentUID: string; - channelID: string; - channelType: number; - lastSeq: number; - active: boolean; -}; +} 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 }>; @@ -49,13 +40,16 @@ declare global { } } -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 transport: TransportAdapter | undefined; -let polling = false; -let flushingOutbox = false; -const interactionKernel = new InteractionKernel(); -const conversationStore = new ConversationStore(localStorage); +// 这是登录表单首次显示时使用的本地默认地址;用户提交后的地址会保存到 localStorage。 +// `0.0.0.0` is a server bind address, never a browser destination. When the +// Web Host is opened locally use loopback; when it is opened through the +// trusted Tailscale Host use the matching reachable AppServer address. +const DEFAULT_API = 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); @@ -65,6 +59,8 @@ 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 @@ -77,46 +73,64 @@ const capabilityExecutor = new CapabilityExecutor({ 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"); -app.innerHTML = ` -
- - -
`; - +// Host 页面是可信的静态 Shell。缺少必要节点属于装配错误,应尽早失败,而不是运行到 +// 登录或消息处理时才出现难以定位的空引用。 const $ = (selector: string): T => { const found = document.querySelector(selector); if (!found) throw new Error(`Missing element: ${selector}`); return found; }; -const messages = $("#messages"); -const loginView = $("#login-view"); -const chatView = $("#chat-view"); -const presence = $("#presence"); +// 当前默认 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.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); }, + }, + taskInteractions: { + requestCancel(operationID) { return chatSDK.tasks.requestCancel(operationID); }, + read(operationID) { return chatSDK.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, 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); }, }); -$("#api").value = session.api; +$("#api").value = localStorage.getItem("lineup.api") || DEFAULT_API; /** * A pre-signed public fixture used only by the headed M4 acceptance route. @@ -124,7 +138,10 @@ $("#api").value = session.api; * 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)); @@ -155,6 +172,7 @@ async function mountM4SignedFixture(): Promise { }; } +/** 生成只在本地 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 @@ -164,11 +182,13 @@ function newLocalID(prefix: string): string { 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 地址。"); @@ -180,6 +200,7 @@ function normalizeAPIAddress(value: string): string { 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}。`; @@ -189,63 +210,41 @@ function loginFailureMessage(reason: unknown, endpoint?: string): string { return message || "登录失败,请稍后重试。"; } -const rendererContext = new TrustedDOMRendererContext(messages, presence, { - submit(callID, submission) { - const transition = interactionKernel.submitToolCall(callID, new Date().toISOString(), submission); - conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls); - if (transition.disposition === "accepted") { - conversationStore.clearToolDraft(callID); - queueToolAction("tool-result", callID, { call_id: callID, status: "completed", result: submission }); - } - return transition.disposition === "accepted"; - }, - cancel(callID, reason) { - const transition = interactionKernel.cancelToolCall(callID, reason, new Date().toISOString()); - conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls); - if (transition.disposition === "accepted") { - conversationStore.clearToolDraft(callID); - queueToolAction("tool-cancel", callID, { call_id: callID, reason }); - } - return transition.disposition === "accepted"; - }, - expire(callID) { - const transition = interactionKernel.expireToolCall(callID, new Date().toISOString()); - conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls); - return transition.disposition === "accepted"; - }, - read(callID) { return interactionKernel.snapshot().tool_calls.find(record => record.call_id === callID); }, - loadDraft(callID) { return conversationStore.getToolDraft(callID); }, - saveDraft(callID, values) { conversationStore.saveToolDraft(callID, values); }, - clearDraft(callID) { conversationStore.clearToolDraft(callID); }, -}, { - requestCancel(operationID) { - const transition = interactionKernel.requestTaskCancel(operationID, new Date().toISOString()); - conversationStore.replaceTasks(interactionKernel.snapshot().tasks); - return transition.disposition === "accepted"; - }, - read(operationID) { return interactionKernel.snapshot().tasks.find(task => task.operation_id === operationID); }, -}, { - async approve(callID) { return approveCapabilityCall(callID); }, - reject(callID) { return rejectCapabilityCall(callID); }, - expire(callID) { return expireCapabilityCall(callID); }, - read(callID) { return capabilityCalls.get(callID); }, +// Chat 是 Runtime 上的 Core App。它不接收 Transport 原始 payload;Runtime 会先完成 +// 协议解析、作用域筛选、持久化和 App Inbox 投递,再通过 SDK 交给这里。 +chatSDK.subscribeAgentMessages(message => { + // 收到 Agent 消息后,先把执行状态投影出来,再进行具体内容渲染,最后 ACK。 + // ACK 表示 Chat 已经接管这条消息,Runtime 才可以从可靠 Inbox 中移除它。 + projectExecutionProgress(message.item, message.received_at); + renderIncoming(message.item); + chatSDK.acknowledgeAgentMessage(message.message_id); +}); + +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 消息,已安全忽略。"); + } }); -const rendererRegistry = new RendererRegistry(); -rendererRegistry.register("user-message", (item, context) => context.renderUserMessage(item)); -rendererRegistry.register("markdown", (item, context) => context.renderMarkdown(item)); -rendererRegistry.register("agent-status", (item, context) => context.renderAgentStatus(item)); -rendererRegistry.register("execution-summary", (item, context) => context.renderExecutionTrace(item)); -rendererRegistry.register("progress", (item, context) => context.renderProgress(item)); -rendererRegistry.register("error", (item, context) => context.renderError(item)); -rendererRegistry.register("tool-call", (item, context) => context.renderToolCall(item)); -rendererRegistry.register("tool-result", (item, context) => context.renderToolResult(item)); -rendererRegistry.register("tool-cancel", (item, context) => context.renderToolCancel(item)); -rendererRegistry.register("surface", (item, context) => context.renderSurface(item)); -rendererRegistry.register("app-call", (item, context) => context.renderAppCall(item)); -rendererRegistry.register("fallback", (item, context) => context.renderFallback(item)); 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") { @@ -264,6 +263,8 @@ function renderIncoming(item: ConversationItem): void { return; } if (item.kind === "artifact-offer") { + // Artifact 先验证元数据和可选内容,再由 Host 负责显示。持久化只保存元数据, + // 不把二进制内容写入对话 Store。 const artifact = artifactState.offer(item.envelope.payload); if (!artifact) { rendererContext.renderSystemMessage("收到无法安全验证的 Artifact。 "); @@ -274,28 +275,32 @@ function renderIncoming(item: ConversationItem): void { 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") conversationStore.replaceSurfaces(surfaceInstances.snapshot()); + 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") conversationStore.replaceSurfaces(surfaceInstances.snapshot()); + 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); - conversationStore.replaceSurfaces(surfaceInstances.snapshot()); + runtime.replaceSurfaces(surfaceInstances.snapshot()); return; } } + // 普通文本、状态、Tool、错误和 fallback 等内容交给受信 Renderer Registry; + // 未注册的类型只能安全降级为提示,不能执行未知脚本。 if (!rendererRegistry.render(item, rendererContext)) rendererContext.renderSystemMessage("收到当前客户端没有可信 Renderer 的内容。"); } @@ -310,7 +315,10 @@ function renderIncoming(item: ConversationItem): void { * through SurfaceInstanceManager and SurfaceRegistry. */ function replayStoredSurfaceLifecycle(): void { - const snapshot = conversationStore.open(currentConversationKey()); + // 某个 Surface 可能在对应本地应用启用前就已到达。Runtime 保留了对话记录, + // 用户启用应用后这里只重放持久化的 Surface 过渡,不会自动下载、启用或放宽校验。 + const snapshot = runtime.snapshot(); + if (!snapshot) return; for (const stored of snapshot.items) { if (stored.item.kind === "surface") renderIncoming(stored.item); } @@ -322,26 +330,23 @@ function replayStoredSurfaceLifecycle(): void { * availability changes, never because of a retry. */ async function publishInventory(): Promise { - if (!session.active || !session.uid || !transport) return; - const update = inventoryPublisher.sync(surfaceRegistry.snapshot(), capabilityRegistry.inventory()); + // 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; - const envelope = makeProtocolEvent( - "lineup.v1.client.inventory", - update.inventory as unknown as JsonObject, - currentConversationKey(), - { kind: "human", id: session.uid }, - { kind: "agent", id: session.agentUID }, - ); - await transport.sendText({ from_uid: session.uid, channel_id: session.channelID, channel_type: session.channelType, payload: JSON.stringify(envelope) }); + 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(); - conversationStore.replaceSurfaces(surfaceInstances.snapshot()); + runtime.replaceSurfaces(surfaceInstances.snapshot()); void publishInventory(); }); @@ -350,16 +355,18 @@ $("#surface-demo").addEventListener("click", () => { * 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); - conversationStore.replaceExecutionSummaries(executionProgress.snapshot()); + 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) { - conversationStore.replaceExecutionSummaries(executionProgress.snapshot()); + runtime.replaceExecutionSummaries(executionProgress.snapshot()); rendererContext.renderExecutionSummary(summary); } return; @@ -369,12 +376,13 @@ function projectExecutionProgress(item: ConversationItem, now: string): void { const executionID = item.kind === "agent-status" ? item.execution_id : undefined; const summary = executionProgress.finish(executionID, terminal, now); if (!summary) return; - conversationStore.replaceExecutionSummaries(executionProgress.snapshot()); + runtime.replaceExecutionSummaries(executionProgress.snapshot()); rendererContext.renderExecutionSummary(summary); } function currentConversationKey(): string { - return `${session.uid}:${session.channelType}:${session.channelID}`; + // Surface 和全局 Runtime 事件都需要一个会话键;未登录时使用保留的全局作用域。 + return runtime.currentConversationID() ?? "runtime:global"; } /** @@ -383,6 +391,8 @@ function currentConversationKey(): string { * 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; @@ -391,6 +401,8 @@ function reconcileArtifactSaveAvailability(): void { } 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"); @@ -415,6 +427,8 @@ function saveCachedArtifact(artifactID: string): void { * 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); @@ -445,6 +459,8 @@ async function writeClipboardFromTrustedHost(text: string): Promise { * 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; @@ -462,6 +478,8 @@ function cacheArtifactContent(payload: JsonObject, artifact: { size_bytes: numbe * 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; @@ -472,12 +490,14 @@ function prepareArtifactOffer(item: Extract { + // 用户批准后仍要重新检查当前 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; @@ -536,17 +559,17 @@ async function approveCapabilityCall(callID: string): Promise rendererContext.renderSystemMessage(storageFailureMessage(reason))); } function safeCapabilityResult(record: CapabilityCallRecord): JsonObject { + // 构造稳定、可幂等消费的能力结果投影;成功只报告 completed,失败只报告受控 code。 const outcome: JsonObject = { call_id: record.call_id, capability: record.request.capability, @@ -558,6 +581,8 @@ function safeCapabilityResult(record: CapabilityCallRecord): JsonObject { } function pickFileFromTrustedHost(): Promise { + // 通过浏览器原生文件选择器获得最小文件元数据。这里不读取或保存本地真实路径, + // 用户取消选择时也必须显式结束 pending Capability,避免状态机永久执行中。 return new Promise(resolve => { const input = document.createElement("input"); input.type = "file"; @@ -592,160 +617,19 @@ function pickFileFromTrustedHost(): Promise { }); } -function queueToolAction(kind: "tool-result" | "tool-cancel", callID: string, payload: JsonObject): void { - if (!session.uid) { - rendererContext.renderSystemMessage("交互结果已保留,将在下次登录后发送。"); - return; - } - const createdAt = new Date().toISOString(); - const localID = newLocalID("tool"); - const type = kind === "tool-result" ? "lineup.v1.tool.result" : "lineup.v1.tool.cancel"; - const envelope = makeProtocolEvent( - type, payload, currentConversationKey(), - { kind: "human", id: session.uid }, { kind: "agent", id: session.agentUID }, - ); - try { - // Durable enqueue precedes the HTTP request: a reload or transient - // network failure cannot turn one accepted UI action into an untracked - // second submission. - conversationStore.enqueueToolAction(localID, kind, JSON.stringify(envelope), createdAt); - } catch (reason) { - rendererContext.renderSystemMessage(storageFailureMessage(reason)); - return; - } - 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; - try { - for (const entry of conversationStore.snapshot().outbox) { - try { - const sequence = await transport.sendText({ - from_uid: session.uid, - channel_id: session.channelID, - channel_type: session.channelType, - payload: entry.payload, - }); - if (entry.kind === "text") { - if (sequence) conversationStore.markSubmitted(entry.local_id, sequence, new Date().toISOString()); - rendererContext.updateUserDelivery(entry.local_id, { status: "submitted", updated_at: new Date().toISOString(), ...(sequence ? { transport_id: String(sequence) } : {}) }); - } else { - conversationStore.acknowledgeOutbox(entry.local_id); - } - } catch (reason) { - if (entry.kind === "text") { - const message = reason instanceof Error ? reason.message : "发送失败"; - conversationStore.markFailed(entry.local_id, message, new Date().toISOString()); - rendererContext.updateUserDelivery(entry.local_id, { status: "failed", updated_at: new Date().toISOString(), error: message, retryable: true }); - } - // Keep this and later entries durable; the next sync/login cycle - // retries in original order. - return; - } - } - } finally { - flushingOutbox = false; - } -} - -function decodeBase64(value: string): string { - try { return decodeURIComponent([...atob(value)].map(c => `%${c.charCodeAt(0).toString(16).padStart(2, "0")}`).join("")); } catch { return value; } -} - -async function syncLoop(): Promise { - if (polling) return; - polling = true; - while (session.active) { - try { - try { await publishInventory(); } catch { /* retried by the next loop */ } - // WuKongIM's legacy sync endpoint can return a single retained message - // per request even when `more` is 0. Drain until it is empty before - // switching to the paced live-polling loop; otherwise a reopened chat - // with history needs minutes to reach the latest Agent response. - while (session.active) { - const startMessageSeq = session.lastSeq === 0 ? 0 : session.lastSeq + 1; - const activeTransport = transport; - if (!activeTransport) throw new Error("当前会话传输尚未初始化。"); - const synced = await activeTransport.sync({ - channel_id: session.channelID, - channel_type: session.channelType, - login_uid: session.uid, - start_message_seq: startMessageSeq, - limit: 50, - }); - if (synced.length === 0) { - rendererContext.setPresence("已同步,等待消息"); - break; - } - - let advanced = false; - for (const message of synced) { - const previousSeq = session.lastSeq; - session.lastSeq = Math.max(session.lastSeq, message.message_seq); - advanced ||= session.lastSeq > previousSeq; - conversationStore.advanceCursor(message.message_seq); - 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" || ownEnvelope.envelope.type === "lineup.v1.ui.event" || ownEnvelope.envelope.type === "lineup.v1.client.inventory" || ownEnvelope.envelope.type === "lineup.v1.app.result")) { - // Tool action echoes advance the inclusive cursor but are never - // rendered as raw JSON user messages or replayed as a new action. - continue; - } - const restored = conversationStore.recordSyncedUserText(message.message_seq, payload, new Date().toISOString()); - if (restored) renderIncoming(restored.item); - } else { - const result = interactionKernel.ingest({ - raw: payload, - source: "sync", - transport_id: String(message.message_seq), - }); - if (result.items.some(item => item.kind === "tool-call" || item.kind === "tool-result" || item.kind === "tool-cancel")) { - conversationStore.replaceToolCalls(result.snapshot.tool_calls); - } - if (result.items.some(item => item.kind === "progress" && item.task)) { - conversationStore.replaceTasks(result.snapshot.tasks); - } - for (const item of result.items) { - const receivedAt = new Date().toISOString(); - const safeItem = item.kind === "artifact-offer" ? prepareArtifactOffer(item) : item; - if (!safeItem) continue; - if (conversationStore.recordIncoming(safeItem, message.message_seq, receivedAt)) { - projectExecutionProgress(safeItem, receivedAt); - renderIncoming(safeItem); - } - } - } - } - - // Avoid a hot loop if an upstream response repeats a sequence that - // cannot advance the local cursor. - if (!advanced) { - rendererContext.setPresence("已同步,等待消息"); - break; - } - } - } catch { - if (session.active) rendererContext.setPresence("同步中断,正在重试…"); - } - await new Promise(resolve => window.setTimeout(resolve, 1500)); - } - polling = false; + // Surface 内部事件只转换为 Runtime Action;它不能直接发送网络消息或调用系统能力。 + const conversationID = chatSDK.snapshot().conversation_id; + if (!conversationID) return; + void chatSDK.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"); @@ -759,110 +643,74 @@ $("#login-form").addEventListener("submit", async event => { if (!phone) { error.textContent = "请输入手机号。"; $("#phone").focus(); return; } if (!code) { error.textContent = "请输入验证码。"; $("#code").focus(); return; } - session.api = endpoint; - localStorage.setItem("lineup.api", session.api); - error.textContent = `正在连接 ${session.api}…`; + localStorage.setItem("lineup.api", endpoint); + error.textContent = `正在连接 ${endpoint}…`; submit.disabled = true; submit.textContent = "正在进入…"; try { - const candidateTransport = new HttpTransportAdapter(session.api); - const body = await candidateTransport.login({ phone, code }); - interactionKernel.reset(); - 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()); + // 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); - interactionKernel.restoreToolCalls(snapshot.tool_calls, new Date().toISOString()); - interactionKernel.restoreTasks(snapshot.tasks); capabilityCalls.restore(snapshot.capability_calls); executionProgress.restore(snapshot.execution_summaries); - conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls); - conversationStore.replaceTasks(interactionKernel.snapshot().tasks); - session.lastSeq = snapshot.cursor; 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.listAgentMessages()) chatSDK.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(); void flushOutbox(); void syncLoop(); + 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; - if (!session.uid) { + const conversationID = chatSDK.snapshot().conversation_id; + if (!conversationID) { rendererContext.renderSystemMessage("当前会话尚未初始化,无法发送消息;请退出后重新登录。"); input.focus(); return; } input.value = ""; send.disabled = true; - const localID = newLocalID("local"); - const createdAt = new Date().toISOString(); - // A browser storage failure must never prevent the user from seeing or - // submitting their message. The Store remains the durable source when it - // is available; the server sync is the recovery path when it is not. - const localItem: ConversationItem = { - kind: "user-message", - id: localID, - text, - delivery: { status: "local_pending", updated_at: createdAt }, - }; - renderIncoming(localItem); - let storedLocally = true; - try { conversationStore.appendLocalText(localID, text, createdAt); } - catch (reason) { storedLocally = false; rendererContext.renderSystemMessage(storageFailureMessage(reason)); } try { - if (!transport) throw new Error("当前会话传输尚未初始化,请退出后重新登录。"); - const sequence = await transport.sendText({ - from_uid: session.uid, - channel_id: session.channelID, - channel_type: session.channelType, - payload: text, - }); - if (sequence && storedLocally) { - try { conversationStore.markSubmitted(localID, sequence, new Date().toISOString()); } - catch (reason) { storedLocally = false; rendererContext.renderSystemMessage(storageFailureMessage(reason)); } - } - rendererContext.updateUserDelivery(localID, { status: "submitted", updated_at: new Date().toISOString(), ...(sequence ? { transport_id: String(sequence) } : {}) }); - } - catch (reason) { - const message = reason instanceof Error ? reason.message : "发送失败"; - if (storedLocally) { - try { conversationStore.markFailed(localID, message, new Date().toISOString()); } - catch (storageReason) { rendererContext.renderSystemMessage(storageFailureMessage(storageReason)); } - } - rendererContext.updateUserDelivery(localID, { status: "failed", updated_at: new Date().toISOString(), error: message, retryable: true }); - rendererContext.renderSystemMessage(message); + const receipt = await chatSDK.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(); } }); -$("#logout").addEventListener("click", () => { session.active = false; transport = undefined; interactionKernel.reset(); conversationStore.close(); messages.replaceChildren(); rendererContext.reset(); chatView.hidden = true; loginView.hidden = false; }); - -export function makeProtocolEvent( - type: string, - payload: JsonObject, - conversationID: string, - sender: Actor, - target?: Actor, -): Envelope { - return { - v: 1, - id: newLocalID("msg"), - type, - conversation_id: conversationID, - sender, - ...(target ? { target } : {}), - timestamp: new Date().toISOString(), - payload, - }; -} +// 退出时停止同步、清理 Runtime 会话和当前页面投影,但保留 localStorage 中的配置/历史, +// 以便下一次登录时由 Runtime 重新恢复。 +$("#logout").addEventListener("click", () => { runtime.logout(); messages.replaceChildren(); rendererContext.reset(); chatView.hidden = true; loginView.hidden = false; }); diff --git a/tauri/src/runtime/app-management/app-focus-manager.ts b/tauri/src/runtime/app-management/app-focus-manager.ts new file mode 100644 index 0000000..ceb4c1e --- /dev/null +++ b/tauri/src/runtime/app-management/app-focus-manager.ts @@ -0,0 +1,19 @@ +import type { AppInstanceRecord } from "@/runtime/app-management/app-instance-manager"; + +export type AppFocusSnapshot = { version: 1; stack: readonly string[] }; + +/** Focus is a stack, not an App-owned boolean: only Runtime may mutate it. */ +export class AppFocusManager { + private stack: string[] = []; + public foreground(): string | undefined { return this.stack.at(-1); } + public history(): readonly string[] { return [...this.stack]; } + public foregroundInstance(instances: { get(id: string): AppInstanceRecord | undefined }): AppInstanceRecord | undefined { + const id = this.foreground(); return id ? instances.get(id) : undefined; + } + public push(instanceID: string): void { this.stack = [...this.stack.filter(id => id !== instanceID), instanceID]; } + public remove(instanceID: string): void { this.stack = this.stack.filter(id => id !== instanceID); } + public restore(snapshot: AppFocusSnapshot, hasInstance: (id: string) => boolean): void { + this.stack = snapshot?.version === 1 && Array.isArray(snapshot.stack) ? snapshot.stack.filter((id): id is string => typeof id === "string" && hasInstance(id)) : []; + } + public snapshot(): AppFocusSnapshot { return { version: 1, stack: this.history() }; } +} diff --git a/tauri/src/runtime/app-management/app-instance-manager.ts b/tauri/src/runtime/app-management/app-instance-manager.ts new file mode 100644 index 0000000..27e327a --- /dev/null +++ b/tauri/src/runtime/app-management/app-instance-manager.ts @@ -0,0 +1,67 @@ +import type { AppScope } from "@/runtime/app-management/app-registry"; + +export type AppInstanceState = "starting" | "foreground" | "background" | "suspended" | "stopping" | "stopped" | "failed"; + +export type AppInstanceRecord = { + instance_id: string; + app_scope: AppScope; + conversation_id?: string; + state: AppInstanceState; + parent_instance_id?: string; + started_at: string; + stopped_at?: string; + error?: string; +}; + +export type AppInstanceSnapshot = { version: 1; instances: readonly AppInstanceRecord[] }; + +const ACTIVE = new Set(["starting", "foreground", "background", "suspended", "stopping"]); + +/** Runtime's authoritative instance table; Apps receive only their own record. */ +export class AppInstanceManager { + private readonly records = new Map(); + + public create(record: Omit & { state?: "starting"; started_at?: string }, now: string): AppInstanceRecord { + if (!validID(record.instance_id) || this.records.has(record.instance_id)) throw new Error("App Instance id must be unique and valid."); + if (record.parent_instance_id && !this.records.has(record.parent_instance_id)) throw new Error("App Instance parent is not running."); + const created: AppInstanceRecord = { ...record, state: "starting", started_at: record.started_at ?? now }; + this.records.set(created.instance_id, created); + return copy(created); + } + + public get(instanceID: string): AppInstanceRecord | undefined { const value = this.records.get(instanceID); return value && copy(value); } + public list(conversationID?: string): readonly AppInstanceRecord[] { + return [...this.records.values()].filter(record => !conversationID || record.conversation_id === conversationID).map(copy); + } + public activeFor(appScope: AppScope, conversationID?: string): AppInstanceRecord | undefined { + return this.list(conversationID).find(record => record.app_scope === appScope && ACTIVE.has(record.state)); + } + public transition(instanceID: string, state: AppInstanceState, now: string, error?: string): AppInstanceRecord { + const current = this.records.get(instanceID); + if (!current || !allowed(current.state, state)) throw new Error(`Invalid App Instance transition: ${current?.state ?? "missing"} -> ${state}`); + const next: AppInstanceRecord = { ...current, state, ...(state === "stopped" || state === "failed" ? { stopped_at: now } : {}), ...(error ? { error } : {}) }; + this.records.set(instanceID, next); + return copy(next); + } + public restore(snapshot: AppInstanceSnapshot): void { + this.records.clear(); + if (snapshot?.version !== 1 || !Array.isArray(snapshot.instances)) return; + for (const raw of snapshot.instances) { + if (!raw || !validID(raw.instance_id) || typeof raw.app_scope !== "string" || !isState(raw.state) || !validTime(raw.started_at)) continue; + // A host cannot safely resume an in-progress native operation. It can + // however present suspended apps and let the orchestrator restore focus. + const recovered = raw.state === "starting" || raw.state === "stopping" ? { ...raw, state: "suspended" as const } : raw; + this.records.set(recovered.instance_id, copy(recovered)); + } + } + public snapshot(): AppInstanceSnapshot { return { version: 1, instances: this.list() }; } +} + +function allowed(from: AppInstanceState, to: AppInstanceState): boolean { + if (from === to) return true; + return ({ starting: ["foreground", "background", "suspended", "failed", "stopping"], foreground: ["background", "suspended", "stopping", "failed"], background: ["foreground", "suspended", "stopping", "failed"], suspended: ["foreground", "background", "stopping", "failed"], stopping: ["stopped", "failed"], stopped: [], failed: [] } as Record)[from].includes(to); +} +function isState(value: unknown): value is AppInstanceState { return typeof value === "string" && ["starting", "foreground", "background", "suspended", "stopping", "stopped", "failed"].includes(value); } +function validID(value: unknown): value is string { return typeof value === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(value); } +function validTime(value: unknown): value is string { return typeof value === "string" && !Number.isNaN(Date.parse(value)); } +function copy(value: AppInstanceRecord): AppInstanceRecord { return { ...value }; } diff --git a/tauri/src/runtime/app-management/app-lifecycle-manager.test.ts b/tauri/src/runtime/app-management/app-lifecycle-manager.test.ts new file mode 100644 index 0000000..e6a9a15 --- /dev/null +++ b/tauri/src/runtime/app-management/app-lifecycle-manager.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { AppLifecycleManager } from "@/runtime/app-management/app-lifecycle-manager"; + +const now = "2026-08-04T00:00:00.000Z"; + +describe("AppLifecycleManager", () => { + it("preserves the prior foreground instance and restores a bounded workspace snapshot", () => { + const lifecycle = new AppLifecycleManager(); + lifecycle.start({ instance_id: "interaction:audio-001", app_scope: "chat", conversation_id: "conversation" }, now); + lifecycle.foreground("interaction:audio-001", now); + lifecycle.start({ instance_id: "draw-and-guess:game-001", app_scope: "draw-and-guess", conversation_id: "conversation", parent_instance_id: "interaction:audio-001" }, now); + lifecycle.foreground("draw-and-guess:game-001", now); + + expect(lifecycle.instances.get("interaction:audio-001")).toMatchObject({ state: "background" }); + expect(lifecycle.instances.get("draw-and-guess:game-001")).toMatchObject({ state: "foreground" }); + expect(lifecycle.focus.history()).toEqual(["interaction:audio-001", "draw-and-guess:game-001"]); + + lifecycle.close("draw-and-guess:game-001", now); + lifecycle.foreground("interaction:audio-001", now); + const restored = new AppLifecycleManager(); + restored.restore(lifecycle.snapshot()); + expect(restored.focus.foreground()).toBe("interaction:audio-001"); + expect(restored.instances.get("draw-and-guess:game-001")).toMatchObject({ state: "stopped" }); + }); +}); diff --git a/tauri/src/runtime/app-management/app-lifecycle-manager.ts b/tauri/src/runtime/app-management/app-lifecycle-manager.ts new file mode 100644 index 0000000..d49e32a --- /dev/null +++ b/tauri/src/runtime/app-management/app-lifecycle-manager.ts @@ -0,0 +1,26 @@ +import { AppFocusManager, type AppFocusSnapshot } from "@/runtime/app-management/app-focus-manager"; +import { AppInstanceManager, type AppInstanceRecord, type AppInstanceSnapshot } from "@/runtime/app-management/app-instance-manager"; +import type { AppScope } from "@/runtime/app-management/app-registry"; + +export type AppWorkspaceSnapshot = { version: 1; instances: AppInstanceSnapshot; focus: AppFocusSnapshot }; + +/** Coordinates legal transitions and restoration; it never mounts a DOM Host. */ +export class AppLifecycleManager { + public readonly instances = new AppInstanceManager(); + public readonly focus = new AppFocusManager(); + public start(input: { instance_id: string; app_scope: AppScope; conversation_id?: string; parent_instance_id?: string }, now: string): AppInstanceRecord { return this.instances.create(input, now); } + public foreground(instanceID: string, now: string): AppInstanceRecord { + const current = this.instances.get(instanceID); if (!current) throw new Error("Unknown App Instance."); + const prior = this.focus.foreground(); + if (prior && prior !== instanceID) { const record = this.instances.get(prior); if (record?.state === "foreground") this.instances.transition(prior, "background", now); } + const next = this.instances.transition(instanceID, "foreground", now); this.focus.push(instanceID); return next; + } + public background(instanceID: string, now: string): AppInstanceRecord { const next = this.instances.transition(instanceID, "background", now); if (this.focus.foreground() === instanceID) this.focus.remove(instanceID); return next; } + public suspend(instanceID: string, now: string): AppInstanceRecord { const next = this.instances.transition(instanceID, "suspended", now); if (this.focus.foreground() === instanceID) this.focus.remove(instanceID); return next; } + public close(instanceID: string, now: string, error?: string): AppInstanceRecord | undefined { + const current = this.instances.get(instanceID); if (!current || current.state === "stopped" || current.state === "failed") return current; + this.instances.transition(instanceID, "stopping", now); const finished = this.instances.transition(instanceID, error ? "failed" : "stopped", now, error); this.focus.remove(instanceID); return finished; + } + public restore(snapshot: AppWorkspaceSnapshot): void { this.instances.restore(snapshot?.instances); this.focus.restore(snapshot?.focus, id => Boolean(this.instances.get(id))); } + public snapshot(): AppWorkspaceSnapshot { return { version: 1, instances: this.instances.snapshot(), focus: this.focus.snapshot() }; } +} diff --git a/tauri/src/runtime/app-management/app-registry.ts b/tauri/src/runtime/app-management/app-registry.ts new file mode 100644 index 0000000..cfe5eda --- /dev/null +++ b/tauri/src/runtime/app-management/app-registry.ts @@ -0,0 +1,124 @@ +/** + * Runtime 本地 Core App 注册表。 + * + * 当前 MVP 只注册默认且可恢复的 `chat`;后续 App Registry/市场只能通过 Runtime + * 管理记录,App 自身不得直接篡改注册表。 + * + * Runtime-local application registry for the first App MVP. + * + * `app_scope` deliberately is a local routing key, not a publisher identity + * or a marketplace namespace. The initial registry is static, but the same + * record shape is the boundary that the later App Registry/Market will own. + */ +export type AppScope = "chat" | "app-registry" | "settings" | "runtime" | (string & {}); + +export type AppToolDefinition = { + name: string; + handling: "direct" | "interactive" | "launch" | "foreground" | "operation"; + /** JSON-object property names accepted by this tool. */ + parameters: readonly string[]; + requires_permission?: string; +}; + +/** + * A Runtime-validated app manifest. This deliberately contains declarative + * metadata only: no bundle URL, executable code, or Host capability handle. + */ +export type AppManifest = { + app_scope: AppScope; + version: string; + tools: readonly AppToolDefinition[]; + permissions: readonly string[]; +}; + +export type CoreAppRecord = { + app_scope: AppScope; + kind: "core" | "extension"; + enabled: boolean; + default_eligible: boolean; + recovery: boolean; + manifest: AppManifest; +}; + +const INITIAL_CORE_APPS: readonly CoreAppRecord[] = [ + { + app_scope: "chat", kind: "core", enabled: true, default_eligible: true, recovery: true, + manifest: { + app_scope: "chat", version: "1.0.0", permissions: [], + tools: [ + { name: "choice", handling: "interactive", parameters: ["call_id", "tool", "title", "prompt", "data", "expires_at"] }, + { name: "confirm", handling: "interactive", parameters: ["call_id", "tool", "title", "prompt", "data", "expires_at"] }, + { name: "input", handling: "interactive", parameters: ["call_id", "tool", "title", "prompt", "data", "expires_at"] }, + ], + }, + }, +]; + +/** + * The Runtime owns this registry. Apps may later request operations through + * a Runtime SDK, but they never mutate this collection directly. + */ +export class CoreAppRegistry { + private readonly apps = new Map(); + + public constructor(records: readonly CoreAppRecord[] = INITIAL_CORE_APPS) { + for (const record of records) this.register(record); + } + + public list(): readonly CoreAppRecord[] { + return [...this.apps.values()].map(copyRecord); + } + + public get(appScope: AppScope): CoreAppRecord | undefined { + const record = this.apps.get(appScope); + return record ? copyRecord(record) : undefined; + } + + public defaultApp(): CoreAppRecord { + const selected = this.list().find(record => record.enabled && record.default_eligible); + if (selected) return selected; + const recovery = this.list().find(record => record.enabled && record.recovery); + if (recovery) return recovery; + throw new Error("Runtime has no enabled recovery application."); + } + + /** Runtime-only installation path; extension Apps never receive this object. */ + public install(record: CoreAppRecord): void { this.register(record); } + + private register(record: CoreAppRecord): void { + if (!isAppScope(record.app_scope) || record.manifest.app_scope !== record.app_scope) throw new Error("Core App Registry received an invalid app_scope."); + if (this.apps.has(record.app_scope)) throw new Error(`Core App Registry has duplicate app_scope: ${record.app_scope}`); + if (!isManifest(record.manifest)) throw new Error(`Core App Registry has an invalid manifest: ${record.app_scope}`); + this.apps.set(record.app_scope, copyRecord(record)); + } +} + +function copyRecord(record: CoreAppRecord): CoreAppRecord { + return { + ...record, + manifest: { + ...record.manifest, + permissions: [...record.manifest.permissions], + tools: record.manifest.tools.map(tool => ({ ...tool, parameters: [...tool.parameters] })), + }, + }; +} + +function isManifest(manifest: AppManifest): boolean { + const toolNames = new Set(); + return typeof manifest.version === "string" && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(manifest.version) + && manifest.permissions.every(permission => /^[a-z][a-z0-9._-]{0,63}$/.test(permission)) + && manifest.tools.every(tool => { + const valid = /^[a-z][a-z0-9._-]{0,63}$/.test(tool.name) + && !toolNames.has(tool.name) + && ["direct", "interactive", "launch", "foreground", "operation"].includes(tool.handling) + && tool.parameters.every(parameter => /^[a-z][a-z0-9_]{0,63}$/.test(parameter)) + && (!tool.requires_permission || manifest.permissions.includes(tool.requires_permission)); + toolNames.add(tool.name); + return valid; + }); +} + +export function isAppScope(value: unknown): value is AppScope { + return typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value); +} diff --git a/tauri/src/runtime/app-management/app-sdk.ts b/tauri/src/runtime/app-management/app-sdk.ts new file mode 100644 index 0000000..4d3b563 --- /dev/null +++ b/tauri/src/runtime/app-management/app-sdk.ts @@ -0,0 +1,97 @@ +/** + * Chat Core App 面向 Runtime 的受限 SDK 契约。 + * + * SDK 暴露已筛选的状态、Agent 消息、可靠 Inbox 与 Runtime Action;它刻意不暴露 + * Transport、ConversationStore、认证信息或原始协议解析能力。 + */ +import type { ConversationItem, JsonObject } from "@/runtime/protocol/lineup-v1"; +import type { AgentPresence, KernelSnapshot } from "@/runtime/coordination/interaction-kernel"; +import type { AppScope } from "@/runtime/app-management/app-registry"; +import type { RuntimeScope, ScopedConversationItem } from "@/runtime/coordination/runtime-envelope"; +import type { TaskRecord } from "@/runtime/coordination/task-state"; +import type { ToolCallRecord } from "@/runtime/coordination/tool-call-state"; +import type { AppWorkspaceSnapshot } from "@/runtime/app-management/app-lifecycle-manager"; + +export type Unsubscribe = () => void; + +export type ChatViewModel = { + app_scope: "chat"; + conversation_id?: string; + active: boolean; + agent_presence: readonly AgentPresence[]; + tool_calls: readonly ToolCallRecord[]; + tasks: readonly TaskRecord[]; +}; + +export type AgentChatMessage = ScopedConversationItem & { + scope: RuntimeScope & { app_scope: "chat" }; +}; + +export type ChatAction = + | { type: "chat.send_text"; conversation_id: string; text: string; local_id?: string } + | { type: "chat.submit_interaction"; conversation_id: string; call_id: string; result: JsonObject } + | { type: "chat.cancel_interaction"; conversation_id: string; call_id: string; reason: string } + /** A bounded result from a user-approved capability interaction. */ + | { type: "chat.report_app_result"; conversation_id: string; result: JsonObject } + /** The current Chat Core App only exposes cancellation for a running Surface. */ + | { type: "chat.cancel_surface"; conversation_id: string; instance_id: string }; + +export type CommandReceipt = + | { disposition: "accepted"; local_id?: string } + | { disposition: "rejected"; code: "inactive" | "scope_mismatch" | "invalid_action" }; + +export interface ChatRuntimeSDK { + readonly app_scope: "chat"; + snapshot(): ChatViewModel; + subscribe(listener: (view: ChatViewModel) => void): Unsubscribe; + /** Chat-safe Runtime events, excluding raw Agent delivery (use subscribeAgentMessages). */ + subscribeEvents(listener: (event: Exclude) => void): Unsubscribe; + subscribeAgentMessages( + listener: (message: AgentChatMessage) => void, + ): Unsubscribe; + listAgentMessages(afterMessageID?: string): readonly AgentChatMessage[]; + acknowledgeAgentMessage(messageID: string): boolean; + dispatch(action: ChatAction): Promise; + readonly 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; + }; + readonly tasks: { + requestCancel(operationID: string): boolean; + read(operationID: string): TaskRecord | undefined; + }; +} + +/** + * The intentionally smaller SDK granted to an installed Extension App. It + * does not reveal another App's inbox, the focus stack, Transport, DOM root, + * storage, credentials, or Host capabilities. + */ +export interface ExtensionRuntimeSDK { + readonly app_scope: AppScope; + readonly instance_id: string; + readonly conversation_id: string; + listMessages(afterMessageID?: string): readonly ScopedConversationItem[]; + acknowledgeMessage(messageID: string): boolean; + reportResult(result: JsonObject): Promise; + reportFailure(message: string): void; +} + +export type RuntimeAppEvent = + | { type: "agent-message"; message: ScopedConversationItem } + | { type: "local-item"; item: ConversationItem; local_id: string } + | { type: "delivery"; local_id: string; item?: ConversationItem } + | { type: "sync-status"; status: "syncing" | "idle" | "retrying" } + | { type: "scope-rejected"; reason: "invalid_scope" | "scope_mismatch"; raw: string } + | { type: "state"; snapshot: KernelSnapshot } + | { type: "app-lifecycle"; workspace: AppWorkspaceSnapshot; reason: "launched" | "closed" | "failed" | "restored" }; + +export type AppMessageSubscription = { + app_scope: AppScope; + listener: (message: ScopedConversationItem) => void; +}; diff --git a/tauri/src/runtime/app-management/runtime-app-host.test.ts b/tauri/src/runtime/app-management/runtime-app-host.test.ts new file mode 100644 index 0000000..caa4267 --- /dev/null +++ b/tauri/src/runtime/app-management/runtime-app-host.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { LineUpRuntime } from "@/runtime/coordination/lineup-runtime"; +import { + CoreAppHostRegistry, + RuntimeAppHost, +} from "@/runtime/app-management/runtime-app-host"; + +class MemoryStorage implements Storage { + private readonly values = new Map(); + public get length(): number { return this.values.size; } + public clear(): void { this.values.clear(); } + public getItem(key: string): string | null { return this.values.get(key) ?? null; } + public key(index: number): string | null { return [...this.values.keys()][index] ?? null; } + public removeItem(key: string): void { this.values.delete(key); } + public setItem(key: string, value: string): void { this.values.set(key, value); } +} + +describe("RuntimeAppHost", () => { + it("uses the host registered for Runtime's selected default Core App", () => { + const runtime = new LineUpRuntime({ storage: new MemoryStorage() }); + const root = { innerHTML: "" } as HTMLElement; + const hostRegistry = new CoreAppHostRegistry(); + hostRegistry.register("chat", (selectedRuntime, selectedRoot) => { + selectedRoot.innerHTML = '
'; + return { + app_scope: "chat", + sdk: selectedRuntime.openApp("chat"), + } as never; + }); + + const mounted = new RuntimeAppHost(runtime, root, {}, hostRegistry).mountDefaultApp(); + + expect(mounted.app_scope).toBe("chat"); + expect(mounted.sdk.app_scope).toBe("chat"); + expect(root.innerHTML).toContain('id="chat-view"'); + expect(root.innerHTML).toContain('id="login-form"'); + }); +}); diff --git a/tauri/src/runtime/app-management/runtime-app-host.ts b/tauri/src/runtime/app-management/runtime-app-host.ts new file mode 100644 index 0000000..f1787d4 --- /dev/null +++ b/tauri/src/runtime/app-management/runtime-app-host.ts @@ -0,0 +1,55 @@ +/** + * Runtime 到可信 Core App 的通用 Host 加载器。 + * + * Runtime App Registry 决定“启动哪个应用”;CoreAppHostRegistry 决定“如何挂载这个 + * 应用”。两件事分开后,main.ts 不需要知道默认应用是 Chat、IM、语音还是其他模式。 + */ +import { mountChatApp, type ChatAppHostOptions, type MountedChatApp } from "@/core-apps/chat/chat-app-host"; +import type { AppScope } from "@/runtime/app-management/app-registry"; +import { LineUpRuntime } from "@/runtime/coordination/lineup-runtime"; + +export type CoreAppHost = MountedChatApp; +export type CoreAppHostFactory = ( + runtime: LineUpRuntime, + root: HTMLElement, +) => CoreAppHost; + +export class CoreAppHostRegistry { + private readonly factories = new Map(); + + public register(appScope: AppScope, factory: CoreAppHostFactory): void { + if (this.factories.has(appScope)) throw new Error(`Core App Host already registered: ${appScope}`); + this.factories.set(appScope, factory); + } + + public get(appScope: AppScope): CoreAppHostFactory | undefined { + return this.factories.get(appScope); + } +} + +export function createDefaultCoreAppHostRegistry(options: ChatAppHostOptions = {}): CoreAppHostRegistry { + const registry = new CoreAppHostRegistry(); + registry.register("chat", (runtime, root) => mountChatApp(runtime, root, options)); + return registry; +} + +export class RuntimeAppHost { + private readonly hostRegistry: CoreAppHostRegistry; + + public constructor( + private readonly runtime: LineUpRuntime, + private readonly root: HTMLElement, + private readonly options: ChatAppHostOptions = {}, + hostRegistry?: CoreAppHostRegistry, + ) { + this.hostRegistry = hostRegistry ?? createDefaultCoreAppHostRegistry(this.options); + } + + public mountDefaultApp(): CoreAppHost { + const app = this.runtime.apps.defaultApp(); + const factory = this.hostRegistry.get(app.app_scope); + if (!factory) throw new Error(`No Core App host is registered for ${app.app_scope}.`); + if (!app.enabled) throw new Error(`Core App is disabled: ${app.app_scope}.`); + return factory(this.runtime, this.root); + } +} diff --git a/tauri/src/runtime/artifact-content-cache.test.ts b/tauri/src/runtime/artifacts/artifact-content-cache.test.ts similarity index 86% rename from tauri/src/runtime/artifact-content-cache.test.ts rename to tauri/src/runtime/artifacts/artifact-content-cache.test.ts index 7de0ebc..571610b 100644 --- a/tauri/src/runtime/artifact-content-cache.test.ts +++ b/tauri/src/runtime/artifacts/artifact-content-cache.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { ArtifactContentCache } from "./artifact-content-cache"; +import { ArtifactContentCache } from "@/runtime/artifacts/artifact-content-cache"; describe("ArtifactContentCache", () => { it("accepts only host-owned opaque references and never exposes paths", () => { diff --git a/tauri/src/runtime/artifact-content-cache.ts b/tauri/src/runtime/artifacts/artifact-content-cache.ts similarity index 78% rename from tauri/src/runtime/artifact-content-cache.ts rename to tauri/src/runtime/artifacts/artifact-content-cache.ts index b78437e..5071394 100644 --- a/tauri/src/runtime/artifact-content-cache.ts +++ b/tauri/src/runtime/artifacts/artifact-content-cache.ts @@ -1,4 +1,9 @@ /** + * Host 所有的易失 Artifact 内容缓存。 + * + * 它只保存已经通过 ArtifactState 接纳的宿主字节;内容不写入 Conversation Store, + * Agent 只能引用不透明 local_ref,不能直接填充或读取缓存。 + * * Host-owned volatile bytes for Artifact metadata already accepted by * ArtifactState. This cache has no network, persistence, DOM or protocol * dependency: an Agent can name an opaque ref but cannot populate or read it. diff --git a/tauri/src/runtime/artifact-state.test.ts b/tauri/src/runtime/artifacts/artifact-state.test.ts similarity index 92% rename from tauri/src/runtime/artifact-state.test.ts rename to tauri/src/runtime/artifacts/artifact-state.test.ts index 29a8af3..a527297 100644 --- a/tauri/src/runtime/artifact-state.test.ts +++ b/tauri/src/runtime/artifacts/artifact-state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { ArtifactState } from "./artifact-state"; +import { ArtifactState } from "@/runtime/artifacts/artifact-state"; describe("ArtifactState", () => { it("keeps only bounded metadata and rejects paths or duplicate offers", () => { diff --git a/tauri/src/runtime/artifact-state.ts b/tauri/src/runtime/artifacts/artifact-state.ts similarity index 93% rename from tauri/src/runtime/artifact-state.ts rename to tauri/src/runtime/artifacts/artifact-state.ts index 7210081..5ac5701 100644 --- a/tauri/src/runtime/artifact-state.ts +++ b/tauri/src/runtime/artifacts/artifact-state.ts @@ -1,3 +1,9 @@ +/** + * Artifact 的元数据状态机。 + * + * 这里仅维护名称、MIME、大小与完整性结果;不保存下载内容、文件路径,也不触发任何 DOM + * 或系统操作。 + */ export type ArtifactIntegrity = "verified" | "unverified" | "failed"; export type ArtifactRecord = Readonly<{ artifact_id: string; diff --git a/tauri/src/runtime/capability-audit.test.ts b/tauri/src/runtime/capabilities/capability-audit.test.ts similarity index 91% rename from tauri/src/runtime/capability-audit.test.ts rename to tauri/src/runtime/capabilities/capability-audit.test.ts index 6f3cae7..b8514d0 100644 --- a/tauri/src/runtime/capability-audit.test.ts +++ b/tauri/src/runtime/capabilities/capability-audit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { CapabilityAuditLog } from "./capability-audit"; +import { CapabilityAuditLog } from "@/runtime/capabilities/capability-audit"; describe("CapabilityAuditLog", () => { it("records authority metadata but never arguments or result values", () => { diff --git a/tauri/src/runtime/capability-audit.ts b/tauri/src/runtime/capabilities/capability-audit.ts similarity index 73% rename from tauri/src/runtime/capability-audit.ts rename to tauri/src/runtime/capabilities/capability-audit.ts index 692ad78..4c256ef 100644 --- a/tauri/src/runtime/capability-audit.ts +++ b/tauri/src/runtime/capabilities/capability-audit.ts @@ -1,5 +1,11 @@ -import type { CapabilityCallRecord } from "./capability-call-state"; -import type { CapabilityRisk } from "./capability-registry"; +/** + * Capability 调用的最小审计日志。 + * + * 只记录可安全公开的调用标识、风险等级、处置和时间,绝不写入文件路径、表单内容、Token + * 或宿主异常细节。 + */ +import type { CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state"; +import type { CapabilityRisk } from "@/runtime/capabilities/capability-registry"; export type CapabilityAuditEntry = Readonly<{ call_id: string; diff --git a/tauri/src/runtime/capability-call-state.test.ts b/tauri/src/runtime/capabilities/capability-call-state.test.ts similarity index 93% rename from tauri/src/runtime/capability-call-state.test.ts rename to tauri/src/runtime/capabilities/capability-call-state.test.ts index b7c5003..404c115 100644 --- a/tauri/src/runtime/capability-call-state.test.ts +++ b/tauri/src/runtime/capabilities/capability-call-state.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { CapabilityCallStateMachine, type CapabilityCallRequest } from "./capability-call-state"; -import { parseCapabilityCall } from "./capability-call-state"; -import { CapabilityRegistry } from "./capability-registry"; +import { CapabilityCallStateMachine, type CapabilityCallRequest } from "@/runtime/capabilities/capability-call-state"; +import { parseCapabilityCall } from "@/runtime/capabilities/capability-call-state"; +import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry"; const now = "2026-08-03T00:00:00.000Z"; const request: CapabilityCallRequest = { call_id: "cap_001", capability: "app.open_url", reason: "打开文档", expires_at: "2026-08-03T00:05:00.000Z", arguments: { url: "https://example.com" } }; diff --git a/tauri/src/runtime/capability-call-state.ts b/tauri/src/runtime/capabilities/capability-call-state.ts similarity index 93% rename from tauri/src/runtime/capability-call-state.ts rename to tauri/src/runtime/capabilities/capability-call-state.ts index a877aa0..97d7d3c 100644 --- a/tauri/src/runtime/capability-call-state.ts +++ b/tauri/src/runtime/capabilities/capability-call-state.ts @@ -1,6 +1,12 @@ -import type { JsonObject } from "../protocol"; -import type { CapabilityDefinition } from "./capability-registry"; -import { CapabilityRegistry } from "./capability-registry"; +/** + * Capability 调用的纯状态机。 + * + * 它只裁决 pending、确认、执行和终态之间的合法跃迁;不持久化、不调用 DOM、网络、权限 + * 或实际 Host handler,副作用必须在 Runtime/Host 边界之外执行。 + */ +import type { JsonObject } from "@/runtime/protocol/lineup-v1"; +import type { CapabilityDefinition } from "@/runtime/capabilities/capability-registry"; +import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry"; export type CapabilityCallStatus = "pending" | "approved" | "executing" | "completed" | "rejected" | "cancelled" | "expired" | "unsupported" | "failed"; diff --git a/tauri/src/runtime/capability-executor.test.ts b/tauri/src/runtime/capabilities/capability-executor.test.ts similarity index 93% rename from tauri/src/runtime/capability-executor.test.ts rename to tauri/src/runtime/capabilities/capability-executor.test.ts index 4d139bd..946133a 100644 --- a/tauri/src/runtime/capability-executor.test.ts +++ b/tauri/src/runtime/capabilities/capability-executor.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; -import { ArtifactState } from "./artifact-state"; -import { CapabilityExecutor } from "./capability-executor"; -import type { CapabilityCallRecord } from "./capability-call-state"; +import { ArtifactState } from "@/runtime/artifacts/artifact-state"; +import { CapabilityExecutor } from "@/runtime/capabilities/capability-executor"; +import type { CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state"; const now = "2026-08-03T00:00:00.000Z"; function call(capability: CapabilityCallRecord["request"]["capability"], argumentsValue: Record): CapabilityCallRecord { diff --git a/tauri/src/runtime/capability-executor.ts b/tauri/src/runtime/capabilities/capability-executor.ts similarity index 85% rename from tauri/src/runtime/capability-executor.ts rename to tauri/src/runtime/capabilities/capability-executor.ts index f7a248f..f12e43b 100644 --- a/tauri/src/runtime/capability-executor.ts +++ b/tauri/src/runtime/capabilities/capability-executor.ts @@ -1,6 +1,12 @@ -import type { JsonObject } from "../protocol"; -import type { ArtifactState } from "./artifact-state"; -import type { CapabilityCallRecord } from "./capability-call-state"; +/** + * 可信 Host 能力执行适配层。 + * + * 该层只接收已经过 Runtime 状态机与策略确认的调用,执行 Host handler 后把结果收敛为 + * 可安全回传的有限结果,不能把原始异常、文件路径或宿主对象泄漏给 Agent。 + */ +import type { JsonObject } from "@/runtime/protocol/lineup-v1"; +import type { ArtifactState } from "@/runtime/artifacts/artifact-state"; +import type { CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state"; export type PickedFile = Readonly<{ name: string; mime_type: string; size_bytes: number }>; export type CapabilityHostHandlers = Readonly<{ diff --git a/tauri/src/runtime/capability-registry.test.ts b/tauri/src/runtime/capabilities/capability-registry.test.ts similarity index 94% rename from tauri/src/runtime/capability-registry.test.ts rename to tauri/src/runtime/capabilities/capability-registry.test.ts index 37fe09a..b8e5cd2 100644 --- a/tauri/src/runtime/capability-registry.test.ts +++ b/tauri/src/runtime/capabilities/capability-registry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { CapabilityRegistry } from "./capability-registry"; +import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry"; describe("CapabilityRegistry", () => { it("declares requestability without making a capability executable", () => { diff --git a/tauri/src/runtime/capability-registry.ts b/tauri/src/runtime/capabilities/capability-registry.ts similarity index 94% rename from tauri/src/runtime/capability-registry.ts rename to tauri/src/runtime/capabilities/capability-registry.ts index 88a8ef0..5400c14 100644 --- a/tauri/src/runtime/capability-registry.ts +++ b/tauri/src/runtime/capabilities/capability-registry.ts @@ -1,4 +1,10 @@ -import type { JsonObject } from "../protocol"; +/** + * Host 可公开能力的声明与策略注册表。 + * + * Runtime 依据这里的风险等级、可见性和参数 schema 生成 Agent Inventory;Surface 或 Agent + * 不可绕过本表直接请求系统能力。 + */ +import type { JsonObject } from "@/runtime/protocol/lineup-v1"; export type CapabilityRisk = "display_only" | "user_confirmation" | "system_permission" | "restricted"; diff --git a/tauri/src/runtime/transport-adapter.ts b/tauri/src/runtime/communication/transport-adapter.ts similarity index 94% rename from tauri/src/runtime/transport-adapter.ts rename to tauri/src/runtime/communication/transport-adapter.ts index 1411b1d..6d6f16c 100644 --- a/tauri/src/runtime/transport-adapter.ts +++ b/tauri/src/runtime/communication/transport-adapter.ts @@ -1,4 +1,9 @@ /** + * AppServer 通信适配边界。 + * + * 本模块是当前 HTTP 登录、发送、同步、超时和响应校验的唯一实现位置;调用方只处理会话 + * 数据,不直接使用 `fetch` 或 `Response`,以便未来替换传输方式而不改变 Runtime/App。 + * * The Transport Adapter is the only M0 boundary that knows the current * AppServer HTTP endpoints. Its callers work with conversation data and do * not receive Response objects or call fetch directly, so this contract can diff --git a/tauri/src/runtime/coordination/app-orchestrator.ts b/tauri/src/runtime/coordination/app-orchestrator.ts new file mode 100644 index 0000000..0f3695f --- /dev/null +++ b/tauri/src/runtime/coordination/app-orchestrator.ts @@ -0,0 +1,27 @@ +import type { AppInstanceRecord } from "@/runtime/app-management/app-instance-manager"; +import { AppLifecycleManager } from "@/runtime/app-management/app-lifecycle-manager"; +import type { AppScope, CoreAppRegistry } from "@/runtime/app-management/app-registry"; + +export type AppOrchestrationResult = + | { disposition: "foreground"; instance: AppInstanceRecord; previous?: AppInstanceRecord } + | { disposition: "rejected"; code: "not_installed" | "disabled" }; + +/** Applies Tool Router decisions atomically to instance and focus state. */ +export class AppOrchestrator { + public constructor(public readonly lifecycle: AppLifecycleManager, private readonly apps: CoreAppRegistry, private readonly newID: (prefix: string) => string) {} + public launch(appScope: AppScope, conversationID: string, now: string, options: { instance_id?: string; parent_instance_id?: string } = {}): AppOrchestrationResult { + const app = this.apps.get(appScope); + if (!app) return { disposition: "rejected", code: "not_installed" }; + if (!app.enabled) return { disposition: "rejected", code: "disabled" }; + const previous = this.lifecycle.focus.foregroundInstance(this.lifecycle.instances); + const existing = this.lifecycle.instances.activeFor(appScope, conversationID); + const instance = existing ?? this.lifecycle.start({ instance_id: options.instance_id ?? this.newID(`${appScope}:instance`), app_scope: appScope, conversation_id: conversationID, ...(options.parent_instance_id ? { parent_instance_id: options.parent_instance_id } : {}) }, now); + return { disposition: "foreground", instance: this.lifecycle.foreground(instance.instance_id, now), ...(previous ? { previous } : {}) }; + } + public close(instanceID: string, now: string, error?: string): { closed?: AppInstanceRecord; restored?: AppInstanceRecord } { + const closed = this.lifecycle.close(instanceID, now, error); + const restoreID = this.lifecycle.focus.foreground(); + const restored = restoreID ? this.lifecycle.foreground(restoreID, now) : undefined; + return { ...(closed ? { closed } : {}), ...(restored ? { restored } : {}) }; + } +} diff --git a/tauri/src/runtime/execution-progress-state.test.ts b/tauri/src/runtime/coordination/execution-progress-state.test.ts similarity index 96% rename from tauri/src/runtime/execution-progress-state.test.ts rename to tauri/src/runtime/coordination/execution-progress-state.test.ts index ef56d14..27db78c 100644 --- a/tauri/src/runtime/execution-progress-state.test.ts +++ b/tauri/src/runtime/coordination/execution-progress-state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { ExecutionProgressState } from "./execution-progress-state"; +import { ExecutionProgressState } from "@/runtime/coordination/execution-progress-state"; const T0 = "2026-08-03T00:00:00.000Z"; const T1 = "2026-08-03T00:00:18.000Z"; diff --git a/tauri/src/runtime/execution-progress-state.ts b/tauri/src/runtime/coordination/execution-progress-state.ts similarity index 95% rename from tauri/src/runtime/execution-progress-state.ts rename to tauri/src/runtime/coordination/execution-progress-state.ts index 13fd5d6..ca00afa 100644 --- a/tauri/src/runtime/execution-progress-state.ts +++ b/tauri/src/runtime/coordination/execution-progress-state.ts @@ -1,4 +1,9 @@ /** + * 单个 Agent 回合的可展示执行摘要投影。 + * + * 只接受 Adapter 提供的受控步骤;不能从状态文本、模型推理、命令、参数、URL、路径或工具 + * 输出推断步骤,从而避免把隐私或 CoT 显示/持久化到客户端。 + * * Display-safe projection of one Agent turn. * * The client never turns status text, model reasoning, commands, arguments, diff --git a/tauri/src/runtime/interaction-kernel.test.ts b/tauri/src/runtime/coordination/interaction-kernel.test.ts similarity index 98% rename from tauri/src/runtime/interaction-kernel.test.ts rename to tauri/src/runtime/coordination/interaction-kernel.test.ts index 2c2b4b8..2103245 100644 --- a/tauri/src/runtime/interaction-kernel.test.ts +++ b/tauri/src/runtime/coordination/interaction-kernel.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { decodeConversationItem, parseEnvelopeJSON } from "../protocol"; -import { InteractionKernel } from "./interaction-kernel"; +import { decodeConversationItem, parseEnvelopeJSON } from "@/runtime/protocol/lineup-v1"; +import { InteractionKernel } from "@/runtime/coordination/interaction-kernel"; function envelope(overrides: Record = {}): string { return JSON.stringify({ diff --git a/tauri/src/runtime/interaction-kernel.ts b/tauri/src/runtime/coordination/interaction-kernel.ts similarity index 91% rename from tauri/src/runtime/interaction-kernel.ts rename to tauri/src/runtime/coordination/interaction-kernel.ts index 1ee7f3f..f029c96 100644 --- a/tauri/src/runtime/interaction-kernel.ts +++ b/tauri/src/runtime/coordination/interaction-kernel.ts @@ -1,12 +1,18 @@ +/** + * Runtime 的协议接纳与交互状态协调内核。 + * + * 它把原始传输文本解码为受信任的 ConversationItem、抑制重复 envelope、维护 Agent 状态和 + * Tool/Task 投影;不操作 DOM、网络、持久化或系统能力。 + */ import { decodeConversationItem, type Actor, type ConversationItem, type JsonObject, type ToolCallFinalStatus, -} from "../protocol"; -import { ToolCallStateMachine, type ToolCallRecord } from "./tool-call-state"; -import { TaskStateMachine, type TaskRecord } from "./task-state"; +} from "@/runtime/protocol/lineup-v1"; +import { ToolCallStateMachine, type ToolCallRecord } from "@/runtime/coordination/tool-call-state"; +import { TaskStateMachine, type TaskRecord } from "@/runtime/coordination/task-state"; /** Transport metadata is intentionally opaque to the protocol and renderer. */ export type IncomingMessage = { diff --git a/tauri/src/runtime/coordination/lineup-runtime.test.ts b/tauri/src/runtime/coordination/lineup-runtime.test.ts new file mode 100644 index 0000000..be14419 --- /dev/null +++ b/tauri/src/runtime/coordination/lineup-runtime.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from "vitest"; +import type { LoginRequest, LoginResult, SendTextRequest, SyncRequest, TransportAdapter, TransportMessage } from "@/runtime/communication/transport-adapter"; +import { LineUpRuntime } from "@/runtime/coordination/lineup-runtime"; + +class MemoryStorage implements Storage { + private readonly values = new Map(); + public get length(): number { return this.values.size; } + public clear(): void { this.values.clear(); } + public getItem(key: string): string | null { return this.values.get(key) ?? null; } + public key(index: number): string | null { return [...this.values.keys()][index] ?? null; } + public removeItem(key: string): void { this.values.delete(key); } + public setItem(key: string, value: string): void { this.values.set(key, value); } +} + +class FakeTransport implements TransportAdapter { + public readonly sent: SendTextRequest[] = []; + private readonly pages: TransportMessage[][]; + + public constructor(pages: TransportMessage[][] = []) { + this.pages = [...pages]; + } + + public async login(_request: LoginRequest): Promise { + return { uid: "user_1", agent_uid: "agent_1", channel_id: "channel_1", channel_type: 2 }; + } + + public async sendText(request: SendTextRequest): Promise { + this.sent.push(request); + return 91 + this.sent.length; + } + + public async sync(_request: SyncRequest): Promise { + return this.pages.shift() ?? []; + } +} + +const login = { + api: "http://lineup.test", + phone: "13800000000", + code: "123456", + fallback_agent_uid: "agent_fallback", + fallback_channel_id: "channel_fallback", + fallback_channel_type: 2, +}; + +function agentEnvelope(id: string, conversationID: string, extra: Record = {}): string { + return JSON.stringify({ + v: 1, + id, + type: "lineup.v1.text", + conversation_id: conversationID, + sender: { kind: "agent", id: "agent_1" }, + payload: { markdown: "**hello**" }, + ...extra, + }); +} + +describe("LineUpRuntime", () => { + it("launches an installed extension, backgrounds Interaction IM, restores focus after completion, and recovers workspace", async () => { + const storage = new MemoryStorage(); + const conversationID = "user_1:2:channel_1"; + const launch = JSON.stringify({ + v: 1, id: "launch-game", type: "lineup.v1.app.call", conversation_id: conversationID, + sender: { kind: "agent", id: "agent_1" }, + payload: { call_id: "game-call", capability: "runtime.launch_app", reason: "Start game", expires_at: "2026-08-05T00:00:00.000Z", arguments: { app_scope: "draw-and-guess", instance_id: "draw-and-guess:game-001" } }, + }); + const transport = new FakeTransport([[{ message_seq: 30, from_uid: "agent_1", payload: launch }], []]); + const runtime = new LineUpRuntime({ storage, createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z" }); + runtime.apps.install({ + app_scope: "draw-and-guess", kind: "extension", enabled: true, default_eligible: false, recovery: false, + manifest: { app_scope: "draw-and-guess", version: "1.0.0", permissions: [], tools: [{ name: "app.launch", handling: "launch", parameters: ["app_scope", "instance_id"] }] }, + }); + await runtime.login(login); + await runtime.syncOnce(); + + expect(runtime.lifecycle.instances.get("draw-and-guess:game-001")).toMatchObject({ state: "foreground", conversation_id: conversationID }); + const interaction = runtime.lifecycle.instances.activeFor("chat", conversationID); + expect(interaction).toMatchObject({ state: "background" }); + expect(runtime.snapshot()?.app_inbox).toEqual([expect.objectContaining({ app_scope: "draw-and-guess", message_id: "launch-game" })]); + const game = runtime.openExtensionApp("draw-and-guess", "draw-and-guess:game-001"); + expect(game.listMessages()).toEqual([expect.objectContaining({ scope: { app_scope: "draw-and-guess", conversation_id: conversationID } })]); + expect(await game.reportResult({ winner: "user_1" })).toMatchObject({ disposition: "accepted" }); + expect(transport.sent.at(-1)?.payload).toContain("lineup.v1.app.result"); + + runtime.closeAppInstance("draw-and-guess:game-001"); + expect(runtime.lifecycle.focus.foreground()).toBe(interaction?.instance_id); + expect(runtime.lifecycle.instances.get(interaction!.instance_id)).toMatchObject({ state: "foreground" }); + + const recovered = new LineUpRuntime({ storage, createTransport: () => new FakeTransport(), now: () => "2026-08-04T00:00:00.000Z" }); + await recovered.login(login); + expect(recovered.workspaceSnapshot().focus.stack).toEqual([interaction!.instance_id]); + expect(recovered.lifecycle.instances.get("draw-and-guess:game-001")).toMatchObject({ state: "stopped" }); + }); + + it("loads chat from the Core App Registry and exposes only a scoped Chat SDK", async () => { + const transport = new FakeTransport(); + const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport }); + + expect(runtime.apps.list()).toEqual([expect.objectContaining({ app_scope: "chat", enabled: true, recovery: true })]); + const chat = runtime.openApp("chat"); + expect(chat.app_scope).toBe("chat"); + expect(chat.snapshot()).toMatchObject({ app_scope: "chat", active: false }); + + await runtime.login(login); + expect(chat.snapshot()).toMatchObject({ app_scope: "chat", active: true, conversation_id: "user_1:2:channel_1" }); + }); + + it("adapts legacy LineUp v1 messages into chat scope before SDK delivery", async () => { + const conversationID = "user_1:2:channel_1"; + const transport = new FakeTransport([[{ message_seq: 7, from_uid: "agent_1", payload: agentEnvelope("agent_msg_7", conversationID) }], []]); + const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport, now: () => "2026-08-04T00:00:00.000Z" }); + const chat = runtime.openChatApp(); + const received: unknown[] = []; + chat.subscribeAgentMessages(message => received.push(message)); + + await runtime.login(login); + await runtime.syncOnce(); + + expect(received).toEqual([expect.objectContaining({ + message_id: "agent_msg_7", + scope: { app_scope: "chat", conversation_id: conversationID }, + item: expect.objectContaining({ kind: "markdown", markdown: "**hello**" }), + })]); + expect(chat.listAgentMessages()).toEqual([expect.objectContaining({ message_id: "agent_msg_7" })]); + }); + + it("projects Agent state and Chat-safe Runtime status through the SDK", async () => { + const conversationID = "user_1:2:channel_1"; + const transport = new FakeTransport([[ + { + message_seq: 8, + from_uid: "agent_1", + payload: agentEnvelope("agent_thinking_8", conversationID, { + type: "lineup.v1.agent.status", + payload: { status: "thinking" }, + }), + }, + ], []]); + const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport }); + const chat = runtime.openChatApp(); + const views: Array<{ active: boolean; agent_presence: readonly { status: string }[] }> = []; + const statuses: string[] = []; + chat.subscribe(view => views.push(view)); + chat.subscribeEvents(event => { if (event.type === "sync-status") statuses.push(event.status); }); + + await runtime.login(login); + await runtime.syncOnce(); + + expect(views[0]).toMatchObject({ active: false, agent_presence: [] }); + expect(views).toContainEqual(expect.objectContaining({ + active: true, + agent_presence: [expect.objectContaining({ status: "thinking" })], + })); + expect(statuses).toEqual(["syncing", "idle"]); + }); + + it("rejects an explicit App or conversation scope mismatch before Chat can observe it", async () => { + const conversationID = "user_1:2:channel_1"; + const transport = new FakeTransport([[ + { message_seq: 8, from_uid: "agent_1", payload: agentEnvelope("wrong_app_scope", conversationID, { + scope: { app_scope: "whiteboard", conversation_id: conversationID }, + }) }, + { message_seq: 9, from_uid: "agent_1", payload: agentEnvelope("wrong_conversation_scope", conversationID, { + scope: { app_scope: "chat", conversation_id: "other:2:conversation" }, + }) }, + ], []]); + const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport }); + const chat = runtime.openChatApp(); + const received: unknown[] = []; + const rejected: unknown[] = []; + chat.subscribeAgentMessages(message => received.push(message)); + runtime.onEvent(event => { if (event.type === "scope-rejected") rejected.push(event); }); + + await runtime.login(login); + await runtime.syncOnce(); + + expect(received).toEqual([]); + expect(chat.listAgentMessages()).toEqual([]); + expect(rejected).toEqual([ + expect.objectContaining({ reason: "scope_mismatch" }), + expect.objectContaining({ reason: "scope_mismatch" }), + ]); + }); + + it("rejects malformed scope and never delivers the same message id twice", async () => { + const conversationID = "user_1:2:channel_1"; + const duplicate = agentEnvelope("dedupe_me", conversationID); + const malformed = agentEnvelope("bad_scope", conversationID, { + scope: { app_scope: 42, conversation_id: conversationID }, + }); + const transport = new FakeTransport([[ + { message_seq: 20, from_uid: "agent_1", payload: duplicate }, + { message_seq: 21, from_uid: "agent_1", payload: duplicate }, + { message_seq: 22, from_uid: "agent_1", payload: malformed }, + ], []]); + const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport }); + const chat = runtime.openChatApp(); + const delivered: string[] = []; + const rejected: string[] = []; + chat.subscribeAgentMessages(message => delivered.push(message.message_id)); + runtime.onEvent(event => { if (event.type === "scope-rejected") rejected.push(event.reason); }); + + await runtime.login(login); + await runtime.syncOnce(); + + expect(delivered).toEqual(["dedupe_me"]); + expect(chat.listAgentMessages()).toHaveLength(1); + expect(rejected).toEqual(["invalid_scope"]); + }); + + it("persists an unacknowledged App Inbox message and restores it after Runtime recreation", async () => { + const storage = new MemoryStorage(); + const conversationID = "user_1:2:channel_1"; + const firstTransport = new FakeTransport([[{ message_seq: 11, from_uid: "agent_1", payload: agentEnvelope("agent_msg_11", conversationID) }], []]); + const first = new LineUpRuntime({ storage, createTransport: () => firstTransport }); + first.openChatApp(); + await first.login(login); + await first.syncOnce(); + + const restored = new LineUpRuntime({ storage, createTransport: () => new FakeTransport() }); + const chat = restored.openChatApp(); + await restored.login(login); + + expect(chat.listAgentMessages()).toEqual([expect.objectContaining({ + message_id: "agent_msg_11", + scope: { app_scope: "chat", conversation_id: conversationID }, + })]); + expect(chat.acknowledgeAgentMessage("agent_msg_11")).toBe(true); + expect(chat.listAgentMessages()).toEqual([]); + }); + + it("sends user text through Runtime Action, local echo and Runtime-owned outbox", async () => { + const transport = new FakeTransport(); + const runtime = new LineUpRuntime({ storage: new MemoryStorage(), createTransport: () => transport }); + const chat = runtime.openChatApp(); + const local: unknown[] = []; + chat.subscribeEvents(event => { if (event.type === "local-item") local.push(event); }); + await runtime.login(login); + + const receipt = await chat.dispatch({ type: "chat.send_text", conversation_id: "user_1:2:channel_1", text: "hello runtime", local_id: "local_test" }); + await runtime.flushOutbox(); + + expect(receipt).toEqual({ disposition: "accepted", local_id: "local_test" }); + expect(local).toEqual([expect.objectContaining({ local_id: "local_test", item: expect.objectContaining({ kind: "user-message", text: "hello runtime" }) })]); + expect(transport.sent).toEqual([expect.objectContaining({ from_uid: "user_1", channel_id: "channel_1", payload: "hello runtime" })]); + expect(runtime.snapshot()?.outbox).toEqual([]); + expect(runtime.snapshot()?.items).toEqual([expect.objectContaining({ local_id: "local_test", item: expect.objectContaining({ delivery: expect.objectContaining({ status: "submitted" }) }) })]); + }); +}); diff --git a/tauri/src/runtime/coordination/lineup-runtime.ts b/tauri/src/runtime/coordination/lineup-runtime.ts new file mode 100644 index 0000000..ab918eb --- /dev/null +++ b/tauri/src/runtime/coordination/lineup-runtime.ts @@ -0,0 +1,638 @@ +/** + * LineUp Runtime 的唯一协调器。 + * + * Runtime 独占 Transport、Conversation Store、sync loop 和 outbox;它在入站时完成旧协议 + * 兼容、scope 校验、去重、持久化和 App Inbox 投递,再经 SDK 向 Chat 提供受限投影。 + */ +import { + parseEnvelopeJSON, + type Actor, + type ConversationItem, + type Envelope, + type JsonObject, +} from "@/runtime/protocol/lineup-v1"; +import { CoreAppRegistry, type AppScope } from "@/runtime/app-management/app-registry"; +import { + type AgentChatMessage, + type ChatAction, + type ChatRuntimeSDK, + type ChatViewModel, + type CommandReceipt, + type ExtensionRuntimeSDK, + type RuntimeAppEvent, + type Unsubscribe, +} from "@/runtime/app-management/app-sdk"; +import { + ConversationStore, + type ConversationSnapshot, + type OutboxEntry, +} from "@/runtime/persistence/conversation-store"; +import { InteractionKernel, type KernelSnapshot } from "@/runtime/coordination/interaction-kernel"; +import { resolveIncomingScope, type ScopedConversationItem } from "@/runtime/coordination/runtime-envelope"; +import { + HttpTransportAdapter, + type LoginRequest, + type LoginResult, + type TransportAdapter, + type TransportMessage, +} from "@/runtime/communication/transport-adapter"; +import type { SurfaceInstanceSnapshot } from "@/runtime/surfaces/surface-instance-manager"; +import type { CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state"; +import type { ExecutionProgressSummary } from "@/runtime/coordination/execution-progress-state"; +import type { TaskRecord } from "@/runtime/coordination/task-state"; +import type { ToolCallRecord } from "@/runtime/coordination/tool-call-state"; +import { AppLifecycleManager, type AppWorkspaceSnapshot } from "@/runtime/app-management/app-lifecycle-manager"; +import { AppOrchestrator } from "@/runtime/coordination/app-orchestrator"; +import { ToolRouter } from "@/runtime/coordination/tool-router"; + +export type RuntimeSession = { + api: string; + uid: string; + agent_uid: string; + channel_id: string; + channel_type: number; + last_seq: number; + active: boolean; +}; + +export type RuntimeLoginRequest = LoginRequest & { + api: string; + fallback_agent_uid: string; + fallback_channel_id: string; + fallback_channel_type: number; +}; + +export type RuntimeDependencies = { + storage: Storage; + createTransport?: (api: string) => TransportAdapter; + now?: () => string; + newID?: (prefix: string) => string; + sleep?: (milliseconds: number) => Promise; + /** Host-only sanitizer/cache hook; Runtime still owns persistence/routing. */ + prepareIncoming?: (item: ConversationItem) => ConversationItem | undefined; +}; + +const CONTROL_ECHO_TYPES = new Set([ + "lineup.v1.tool.result", + "lineup.v1.tool.cancel", + "lineup.v1.ui.event", + "lineup.v1.client.inventory", + "lineup.v1.app.result", +]); + +/** + * The only owner of Transport, ConversationStore, sync loop and outbox. + * + * It deliberately has no DOM dependency. A Core App gets a narrow SDK view; + * host rendering and platform-specific artifact cache hooks are injected at + * the edge instead of letting the Chat UI depend on transport/storage. + */ +export class LineUpRuntime { + public readonly apps = new CoreAppRegistry(); + public readonly lifecycle = new AppLifecycleManager(); + + private readonly store: ConversationStore; + private readonly kernel = new InteractionKernel(); + private readonly listeners = new Set<(event: RuntimeAppEvent) => void>(); + private readonly chatStateListeners = new Set<(view: ChatViewModel) => void>(); + private readonly chatMessageListeners = new Set<(message: AgentChatMessage) => void>(); + private readonly createTransport: (api: string) => TransportAdapter; + private readonly now: () => string; + private readonly newID: (prefix: string) => string; + private readonly sleep: (milliseconds: number) => Promise; + private readonly prepareIncoming: (item: ConversationItem) => ConversationItem | undefined; + private readonly orchestrator: AppOrchestrator; + private readonly toolRouter: ToolRouter; + + private transport: TransportAdapter | undefined; + private session: RuntimeSession = { + api: "", + uid: "", + agent_uid: "", + channel_id: "", + channel_type: 2, + last_seq: 0, + active: false, + }; + private syncing = false; + private syncRequested = false; + + public constructor(dependencies: RuntimeDependencies) { + this.store = new ConversationStore(dependencies.storage); + this.createTransport = dependencies.createTransport ?? (api => new HttpTransportAdapter(api)); + this.now = dependencies.now ?? (() => new Date().toISOString()); + this.newID = dependencies.newID ?? defaultID; + this.sleep = dependencies.sleep ?? (milliseconds => new Promise(resolve => globalThis.setTimeout(resolve, milliseconds))); + this.prepareIncoming = dependencies.prepareIncoming ?? (item => item); + this.orchestrator = new AppOrchestrator(this.lifecycle, this.apps, this.newID); + this.toolRouter = new ToolRouter(this.apps); + } + + public getSession(): RuntimeSession { + return { ...this.session }; + } + + public currentConversationID(): string | undefined { + return this.session.active ? `${this.session.uid}:${this.session.channel_type}:${this.session.channel_id}` : undefined; + } + + public snapshot(): ConversationSnapshot | undefined { + return this.session.active ? this.store.snapshot() : undefined; + } + + public kernelSnapshot(): KernelSnapshot { + return this.kernel.snapshot(); + } + + public workspaceSnapshot(): AppWorkspaceSnapshot { return this.lifecycle.snapshot(); } + + /** Runtime-only lifecycle entry point used when a mounted App reports completion. */ + public closeAppInstance(instanceID: string, error?: string): void { + if (!this.session.active) return; + this.orchestrator.close(instanceID, this.now(), error); + this.persistWorkspace(); + runtimeLog("app.lifecycle", { action: error ? "failed" : "closed", instance_id: instanceID }); + this.emit({ type: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: error ? "failed" : "closed" }); + } + + public onEvent(listener: (event: RuntimeAppEvent) => void): Unsubscribe { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** + * Opens the SDK projection for a Runtime-registered Core App. + * + * The first SDK implementation is still Chat-shaped, but the selection key + * now comes from the App Registry rather than from a Chat-specific Runtime + * entry point. New App SDK projections can be added behind this boundary. + */ + public openApp(appScope: AppScope): ChatRuntimeSDK { + 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", + snapshot: () => this.chatView(), + subscribe: listener => { + 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. + if (event.type !== "agent-message") listener(event); + }), + subscribeAgentMessages: listener => { + 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), + }, + }; + } + + /** Opens a per-instance Extension projection after Runtime has started it. */ + public openExtensionApp(appScope: AppScope, instanceID: string): ExtensionRuntimeSDK { + const app = this.apps.get(appScope); + const instance = this.lifecycle.instances.get(instanceID); + const conversationID = this.requireConversationID(); + if (!app || app.kind !== "extension" || !app.enabled || !instance || instance.app_scope !== appScope || instance.conversation_id !== conversationID) { + throw new Error("Extension App SDK is unavailable for this instance."); + } + return { + app_scope: appScope, instance_id: instanceID, conversation_id: conversationID, + listMessages: afterMessageID => this.listAppMessages(appScope, afterMessageID), + acknowledgeMessage: messageID => this.store.acknowledgeAppInbox(appScope, messageID), + reportResult: result => this.queueProtocolAction("app-result", "lineup.v1.app.result", { instance_id: instanceID, app_scope: appScope, result }), + reportFailure: message => this.closeAppInstance(instanceID, boundedFailure(message)), + }; + } + + /** @deprecated Use openApp("chat") so App selection remains registry-driven. */ + public openChatApp(): ChatRuntimeSDK { + return this.openApp("chat"); + } + + public async login(request: RuntimeLoginRequest): Promise { + const transport = this.createTransport(request.api); + const result = await transport.login({ phone: request.phone, code: request.code }); + this.stopSync(); + this.transport = transport; + this.kernel.reset(); + this.session = { + api: request.api, + uid: result.uid, + agent_uid: result.agent_uid || request.fallback_agent_uid, + channel_id: result.channel_id || request.fallback_channel_id, + channel_type: result.channel_type || request.fallback_channel_type, + last_seq: 0, + active: true, + }; + const snapshot = this.store.open(this.requireConversationID()); + this.lifecycle.restore(snapshot.app_workspace); + this.kernel.restoreToolCalls(snapshot.tool_calls, this.now()); + this.kernel.restoreTasks(snapshot.tasks); + this.store.replaceToolCalls(this.kernel.snapshot().tool_calls); + this.store.replaceTasks(this.kernel.snapshot().tasks); + this.session.last_seq = snapshot.cursor; + if (!this.lifecycle.instances.activeFor("chat", this.requireConversationID())) { + this.orchestrator.launch("chat", this.requireConversationID(), this.now()); + this.persistWorkspace(); + runtimeLog("app.lifecycle", { action: "started", app_scope: "chat" }); + } + runtimeLog("session.restored", { restored_instances: this.lifecycle.instances.list(this.requireConversationID()).length, foreground: this.lifecycle.focus.foreground() ?? "none" }); + this.emitState(); + return this.store.snapshot(); + } + + public logout(): void { + this.stopSync(); + this.transport = undefined; + this.kernel.reset(); + this.store.close(); + this.session = { ...this.session, uid: "", agent_uid: "", channel_id: "", last_seq: 0, active: false }; + this.emitState(); + } + + public startSync(): void { + if (!this.session.active || this.syncRequested) return; + this.syncRequested = true; + void this.syncLoop(); + } + + public stopSync(): void { + this.syncRequested = false; + } + + /** A deterministic single drain for tests and for the production sync loop. */ + public async syncOnce(): Promise { + if (!this.session.active || !this.transport) return; + this.emit({ type: "sync-status", status: "syncing" }); + while (this.session.active && this.transport) { + const startMessageSeq = this.session.last_seq === 0 ? 0 : this.session.last_seq + 1; + const messages = await this.transport.sync({ + channel_id: this.session.channel_id, + channel_type: this.session.channel_type, + login_uid: this.session.uid, + start_message_seq: startMessageSeq, + limit: 50, + }); + if (messages.length === 0) { + this.emit({ type: "sync-status", status: "idle" }); + return; + } + let advanced = false; + for (const message of messages) { + const previousSeq = this.session.last_seq; + this.session.last_seq = Math.max(this.session.last_seq, message.message_seq); + advanced ||= this.session.last_seq > previousSeq; + this.store.advanceCursor(message.message_seq); + this.processTransportMessage(message); + } + if (!advanced) { + this.emit({ type: "sync-status", status: "idle" }); + return; + } + } + } + + public async flushOutbox(): Promise { + if (this.syncing || !this.session.active || !this.transport) return; + this.syncing = true; + try { + for (const entry of this.store.snapshot().outbox) { + try { + const sequence = await this.transport.sendText({ + from_uid: this.session.uid, + channel_id: this.session.channel_id, + channel_type: this.session.channel_type, + payload: entry.payload, + }); + this.handleOutboxSuccess(entry, sequence); + } catch (reason) { + this.handleOutboxFailure(entry, reason); + return; + } + } + } finally { + this.syncing = false; + this.emitState(); + } + } + + public submitToolCall(callID: string, result: JsonObject): boolean { + const transition = this.kernel.submitToolCall(callID, this.now(), result); + this.store.replaceToolCalls(this.kernel.snapshot().tool_calls); + if (transition.disposition !== "accepted") return false; + this.store.clearToolDraft(callID); + void this.queueProtocolAction("tool-result", "lineup.v1.tool.result", { call_id: callID, status: "completed", result }); + this.emitState(); + return true; + } + + public cancelToolCall(callID: string, reason: string): boolean { + const transition = this.kernel.cancelToolCall(callID, reason, this.now()); + this.store.replaceToolCalls(this.kernel.snapshot().tool_calls); + if (transition.disposition !== "accepted") return false; + this.store.clearToolDraft(callID); + void this.queueProtocolAction("tool-cancel", "lineup.v1.tool.cancel", { call_id: callID, reason }); + this.emitState(); + return true; + } + + public expireToolCall(callID: string): boolean { + const transition = this.kernel.expireToolCall(callID, this.now()); + this.store.replaceToolCalls(this.kernel.snapshot().tool_calls); + this.emitState(); + return transition.disposition === "accepted"; + } + + public requestTaskCancel(operationID: string): boolean { + const transition = this.kernel.requestTaskCancel(operationID, this.now()); + this.store.replaceTasks(this.kernel.snapshot().tasks); + this.emitState(); + return transition.disposition === "accepted"; + } + + public toolCalls(): readonly ToolCallRecord[] { return this.kernel.snapshot().tool_calls; } + public tasks(): readonly TaskRecord[] { return this.kernel.snapshot().tasks; } + public getToolDraft(callID: string): JsonObject | undefined { return this.store.getToolDraft(callID); } + public saveToolDraft(callID: string, values: JsonObject): void { this.store.saveToolDraft(callID, values); } + public clearToolDraft(callID: string): void { this.store.clearToolDraft(callID); } + public replaceTasks(records: readonly TaskRecord[]): void { this.store.replaceTasks(records); this.emitState(); } + public replaceExecutionSummaries(records: readonly ExecutionProgressSummary[]): void { this.store.replaceExecutionSummaries(records); } + public replaceSurfaces(snapshot: SurfaceInstanceSnapshot): void { this.store.replaceSurfaces(snapshot); } + public replaceCapabilityCalls(records: readonly CapabilityCallRecord[]): void { this.store.replaceCapabilityCalls(records); } + + public acknowledgeChatMessage(messageID: string): boolean { + return this.store.acknowledgeAppInbox("chat", messageID); + } + + /** Runtime-owned durable protocol result path for non-Chat host modules. */ + public async queueProtocolAction( + kind: Exclude, + type: string, + payload: JsonObject, + ): Promise { + if (!this.session.active) return { disposition: "rejected", code: "inactive" }; + const localID = this.newID(kind); + const envelope: Envelope = { + v: 1, + id: this.newID("msg"), + type, + conversation_id: this.requireConversationID(), + sender: { kind: "human", id: this.session.uid }, + target: { kind: "agent", id: this.session.agent_uid }, + timestamp: this.now(), + payload, + }; + this.store.enqueueToolAction(localID, kind, JSON.stringify(envelope), this.now()); + void this.flushOutbox(); + return { disposition: "accepted", local_id: localID }; + } + + /** Runtime-owned ephemeral route used only for declarative inventory updates. */ + public async sendProtocolEnvelope(type: string, payload: JsonObject): Promise { + if (!this.session.active || !this.transport) return; + const envelope: Envelope = { + v: 1, + id: this.newID("msg"), + type, + conversation_id: this.requireConversationID(), + sender: { kind: "human", id: this.session.uid }, + target: { kind: "agent", id: this.session.agent_uid }, + timestamp: this.now(), + payload, + }; + await this.transport.sendText({ + from_uid: this.session.uid, + channel_id: this.session.channel_id, + channel_type: this.session.channel_type, + payload: JSON.stringify(envelope), + }); + } + + private async dispatchChatAction(action: ChatAction): Promise { + if (!this.session.active) return { disposition: "rejected", code: "inactive" }; + if (action.conversation_id !== this.requireConversationID()) return { disposition: "rejected", code: "scope_mismatch" }; + if (action.type === "chat.send_text") { + const text = action.text.trim(); + if (!text) return { disposition: "rejected", code: "invalid_action" }; + const localID = action.local_id ?? this.newID("local"); + const stored = this.store.appendLocalText(localID, text, this.now()); + this.emit({ type: "local-item", item: stored.item, local_id: localID }); + this.emitState(); + void this.flushOutbox(); + return { disposition: "accepted", local_id: localID }; + } + if (action.type === "chat.submit_interaction") { + return this.submitToolCall(action.call_id, action.result) + ? { disposition: "accepted" } + : { disposition: "rejected", code: "invalid_action" }; + } + if (action.type === "chat.cancel_interaction") { + return this.cancelToolCall(action.call_id, action.reason) + ? { disposition: "accepted" } + : { disposition: "rejected", code: "invalid_action" }; + } + if (action.type === "chat.report_app_result") { + return this.queueProtocolAction("app-result", "lineup.v1.app.result", action.result); + } + if (action.type === "chat.cancel_surface") { + if (!action.instance_id) return { disposition: "rejected", code: "invalid_action" }; + return this.queueProtocolAction("surface-event", "lineup.v1.ui.event", { + instance_id: action.instance_id, + event: "cancel", + data: {}, + }); + } + return { disposition: "rejected", code: "invalid_action" }; + } + + private processTransportMessage(message: TransportMessage): void { + const payload = decodeTransportPayload(message.payload); + if (message.from_uid === this.session.uid) { + const own = parseEnvelopeJSON(payload); + if (own.ok && CONTROL_ECHO_TYPES.has(own.envelope.type)) return; + const restored = this.store.recordSyncedUserText(message.message_seq, payload, this.now()); + if (restored) this.emit({ type: "local-item", item: restored.item, local_id: restored.local_id }); + return; + } + + const scopeResolution = resolveIncomingScope(payload, { + app_scope: "chat", conversation_id: this.requireConversationID(), + acceptsAppScope: scope => Boolean(this.apps.get(scope)?.enabled), + }); + if (scopeResolution.disposition === "rejected") { + runtimeLog("message.rejected", { stage: "scope", reason: scopeResolution.reason }); + this.emit({ type: "scope-rejected", reason: scopeResolution.reason, raw: payload }); + return; + } + const route = this.toolRouter.route(payload, { app_scope: scopeResolution.scope.app_scope, conversation_id: this.requireConversationID() }); + if (route.disposition === "rejected") { + runtimeLog("tool.rejected", { reason: route.code }); + this.emit({ type: "scope-rejected", reason: route.code === "scope_mismatch" ? "scope_mismatch" : "invalid_scope", raw: payload }); + return; + } + const result = this.kernel.ingest({ raw: payload, source: "sync", transport_id: String(message.message_seq), received_at: this.now() }); + this.store.replaceToolCalls(result.snapshot.tool_calls); + this.store.replaceTasks(result.snapshot.tasks); + for (const rawItem of result.items) { + const item = this.prepareIncoming(rawItem); + if (!item) continue; + const receivedAt = this.now(); + if (!this.store.recordIncoming(item, message.message_seq, receivedAt)) continue; + const messageID = item.id ?? `transport:${message.message_seq}`; + const scoped: ScopedConversationItem = { + message_id: messageID, + scope: scopeResolution.scope, + item, + received_at: receivedAt, + message_seq: message.message_seq, + }; + const targetScope = route.disposition === "launch" ? route.app_scope : scopeResolution.scope.app_scope; + this.store.enqueueAppInbox({ + message_id: messageID, + app_scope: targetScope, + conversation_id: scopeResolution.scope.conversation_id, + item, + received_at: receivedAt, + message_seq: message.message_seq, + }); + this.emit({ type: "agent-message", message: scoped }); + if (targetScope === "chat") this.emitChatMessage(scoped); + } + if (route.disposition === "launch") { + const launched = this.orchestrator.launch(route.app_scope, this.requireConversationID(), this.now(), { instance_id: route.instance_id }); + if (launched.disposition === "foreground") { + this.persistWorkspace(); + runtimeLog("app.lifecycle", { action: "launched", app_scope: route.app_scope, instance_id: launched.instance.instance_id, prior_instance_id: launched.previous?.instance_id ?? "none" }); + this.emit({ type: "app-lifecycle", workspace: this.workspaceSnapshot(), reason: "launched" }); + } + } + this.emit({ type: "state", snapshot: result.snapshot }); + this.emitState(); + } + + private handleOutboxSuccess(entry: OutboxEntry, sequence: number | undefined): void { + if (entry.kind === "text") { + if (sequence) this.store.markSubmitted(entry.local_id, sequence, this.now()); + const item = this.store.snapshot().items.find(candidate => candidate.local_id === entry.local_id)?.item; + this.emit({ type: "delivery", local_id: entry.local_id, ...(item ? { item } : {}) }); + return; + } + this.store.acknowledgeOutbox(entry.local_id); + } + + private handleOutboxFailure(entry: OutboxEntry, reason: unknown): void { + runtimeLog("outbox.delivery_failed", { kind: entry.kind }); + if (entry.kind !== "text") return; + const message = reason instanceof Error ? reason.message : "发送失败"; + this.store.markFailed(entry.local_id, message, this.now()); + const item = this.store.snapshot().items.find(candidate => candidate.local_id === entry.local_id)?.item; + this.emit({ type: "delivery", local_id: entry.local_id, ...(item ? { item } : {}) }); + } + + private async syncLoop(): Promise { + while (this.syncRequested && this.session.active) { + try { + await this.flushOutbox(); + await this.syncOnce(); + } catch { + if (this.session.active) this.emit({ type: "sync-status", status: "retrying" }); + } + if (this.syncRequested && this.session.active) await this.sleep(1_500); + } + } + + private chatView(): ChatViewModel { + const snapshot = this.kernel.snapshot(); + return { + app_scope: "chat", + conversation_id: this.currentConversationID(), + active: this.session.active, + agent_presence: snapshot.agent_presence, + tool_calls: snapshot.tool_calls, + tasks: snapshot.tasks, + }; + } + + private listChatMessages(afterMessageID?: string): readonly AgentChatMessage[] { + return this.listAppMessages("chat", afterMessageID) as readonly AgentChatMessage[]; + } + + private listAppMessages(appScope: AppScope, afterMessageID?: string): readonly ScopedConversationItem[] { + const entries = this.store.listAppInbox(appScope); + const start = afterMessageID ? entries.findIndex(entry => entry.message_id === afterMessageID) + 1 : 0; + return entries.slice(Math.max(0, start)).map(entry => ({ + message_id: entry.message_id, + scope: { app_scope: appScope, conversation_id: entry.conversation_id }, + item: entry.item, + received_at: entry.received_at, + ...(entry.message_seq !== undefined ? { message_seq: entry.message_seq } : {}), + })); + } + + private emitChatMessage(message: ScopedConversationItem): void { + if (message.scope.app_scope !== "chat") return; + const scoped = message as AgentChatMessage; + for (const listener of this.chatMessageListeners) listener(scoped); + } + + private emitState(): void { + const view = this.chatView(); + for (const listener of this.chatStateListeners) listener(view); + } + + private persistWorkspace(): void { this.store.replaceAppWorkspace(this.lifecycle.snapshot()); } + + private emit(event: RuntimeAppEvent): void { + for (const listener of this.listeners) listener(event); + } + + private requireConversationID(): string { + const conversationID = this.currentConversationID(); + if (!conversationID) throw new Error("LineUpRuntime has no active conversation."); + return conversationID; + } +} + +function boundedFailure(value: string): string { + const message = value.trim(); + return message ? message.slice(0, 500) : "Extension App failed."; +} + +/** Deliberately excludes identities, conversation IDs, message bodies and Tool arguments. */ +function runtimeLog(event: string, fields: Record): void { + console.info("[LineUp Runtime]", event, fields); +} + +function defaultID(prefix: string): string { + const uuid = globalThis.crypto?.randomUUID?.(); + return `${prefix}_${uuid ?? `${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`}`; +} + +function decodeTransportPayload(value: string): string { + try { + return decodeURIComponent([...atob(value)].map(character => `%${character.charCodeAt(0).toString(16).padStart(2, "0")}`).join("")); + } catch { + return value; + } +} diff --git a/tauri/src/runtime/coordination/runtime-envelope.ts b/tauri/src/runtime/coordination/runtime-envelope.ts new file mode 100644 index 0000000..f0bd0e4 --- /dev/null +++ b/tauri/src/runtime/coordination/runtime-envelope.ts @@ -0,0 +1,89 @@ +/** + * Runtime 内部可路由消息的作用域模型与兼容适配器。 + * + * 所有投递到 App 的消息必须有合法 `app_scope + conversation_id`。旧 `lineup.v1` 未携带 + * scope 时只在 Runtime 内映射到当前 Chat 会话;显式非法或不匹配 scope 一律拒绝。 + */ +import type { ConversationItem } from "@/runtime/protocol/lineup-v1"; +import { isAppScope, type AppScope } from "@/runtime/app-management/app-registry"; + +export type RuntimeScope = { + app_scope: AppScope; + conversation_id: string; + instance_id?: string; + operation_id?: string; +}; + +export type RuntimeEnvelope = { + v: 1; + id: string; + type: string; + timestamp: string; + scope: RuntimeScope; + payload: unknown; +}; + +export type ScopedConversationItem = { + message_id: string; + scope: RuntimeScope; + item: ConversationItem; + received_at: string; + message_seq?: number; +}; + +export type ScopeResolution = + | { disposition: "accepted"; scope: RuntimeScope } + | { disposition: "rejected"; reason: "invalid_scope" | "scope_mismatch" }; + +/** + * Compatibility adapter for the existing LineUp v1 wire protocol. + * + * Old envelopes have only `conversation_id`; until all producers have been + * upgraded, Runtime attaches the receiving Core App's local scope. New + * producers may include `scope`, but it is never trusted without exact + * validation against the active conversation and target app. + */ +export function resolveIncomingScope( + raw: string, + expected: { app_scope: AppScope; conversation_id: string; acceptsAppScope?: (scope: AppScope) => boolean }, +): ScopeResolution { + const legacyScope: RuntimeScope = { app_scope: expected.app_scope, conversation_id: expected.conversation_id }; + let parsed: unknown; + try { parsed = JSON.parse(raw); } catch { + // Legacy plaintext is a Chat message in the active conversation. + return { disposition: "accepted", scope: legacyScope }; + } + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return { disposition: "accepted", scope: legacyScope }; + } + const candidate = parsed as { scope?: unknown; conversation_id?: unknown }; + if (candidate.scope === undefined) { + // Existing v1 envelope: conversation_id remains authoritative. + if (typeof candidate.conversation_id === "string" && candidate.conversation_id !== expected.conversation_id) { + return { disposition: "rejected", reason: "scope_mismatch" }; + } + return { disposition: "accepted", scope: legacyScope }; + } + if (!candidate.scope || typeof candidate.scope !== "object" || Array.isArray(candidate.scope)) { + return { disposition: "rejected", reason: "invalid_scope" }; + } + const scope = candidate.scope as { app_scope?: unknown; conversation_id?: unknown; instance_id?: unknown; operation_id?: unknown }; + if (!isAppScope(scope.app_scope) || typeof scope.conversation_id !== "string" || !scope.conversation_id) { + return { disposition: "rejected", reason: "invalid_scope" }; + } + if (scope.conversation_id !== expected.conversation_id || (expected.acceptsAppScope ? !expected.acceptsAppScope(scope.app_scope) : scope.app_scope !== expected.app_scope)) { + return { disposition: "rejected", reason: "scope_mismatch" }; + } + if ((scope.instance_id !== undefined && typeof scope.instance_id !== "string") || (scope.operation_id !== undefined && typeof scope.operation_id !== "string")) { + return { disposition: "rejected", reason: "invalid_scope" }; + } + return { + disposition: "accepted", + scope: { + app_scope: scope.app_scope, + conversation_id: scope.conversation_id, + ...(typeof scope.instance_id === "string" ? { instance_id: scope.instance_id } : {}), + ...(typeof scope.operation_id === "string" ? { operation_id: scope.operation_id } : {}), + }, + }; +} diff --git a/tauri/src/runtime/task-state.test.ts b/tauri/src/runtime/coordination/task-state.test.ts similarity index 95% rename from tauri/src/runtime/task-state.test.ts rename to tauri/src/runtime/coordination/task-state.test.ts index d3fffac..ec6487a 100644 --- a/tauri/src/runtime/task-state.test.ts +++ b/tauri/src/runtime/coordination/task-state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { TaskStateMachine } from "./task-state"; +import { TaskStateMachine } from "@/runtime/coordination/task-state"; const TIME = "2026-08-03T09:00:00.000Z"; diff --git a/tauri/src/runtime/task-state.ts b/tauri/src/runtime/coordination/task-state.ts similarity index 90% rename from tauri/src/runtime/task-state.ts rename to tauri/src/runtime/coordination/task-state.ts index ed3b3d1..dff2122 100644 --- a/tauri/src/runtime/task-state.ts +++ b/tauri/src/runtime/coordination/task-state.ts @@ -1,4 +1,10 @@ -import type { TaskProgress, TaskStatus } from "../protocol"; +/** + * 按 `operation_id` 聚合 Agent 任务进度的纯状态机。 + * + * 同一任务的更新只替换最新投影,避免聊天时间线因进度变化无限新增卡片;取消请求也必须 + * 经过受限状态跃迁。 + */ +import type { TaskProgress, TaskStatus } from "@/runtime/protocol/lineup-v1"; export type TaskRecord = TaskProgress & { conversation_id: string; diff --git a/tauri/src/runtime/tool-call-state.test.ts b/tauri/src/runtime/coordination/tool-call-state.test.ts similarity index 96% rename from tauri/src/runtime/tool-call-state.test.ts rename to tauri/src/runtime/coordination/tool-call-state.test.ts index 5116406..a9ca714 100644 --- a/tauri/src/runtime/tool-call-state.test.ts +++ b/tauri/src/runtime/coordination/tool-call-state.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import type { ToolCallRequest } from "../protocol"; -import { ToolCallStateMachine } from "./tool-call-state"; +import type { ToolCallRequest } from "@/runtime/protocol/lineup-v1"; +import { ToolCallStateMachine } from "@/runtime/coordination/tool-call-state"; const RECEIVED_AT = "2026-08-03T08:00:00.000Z"; diff --git a/tauri/src/runtime/tool-call-state.ts b/tauri/src/runtime/coordination/tool-call-state.ts similarity index 95% rename from tauri/src/runtime/tool-call-state.ts rename to tauri/src/runtime/coordination/tool-call-state.ts index 0f948f5..35cc976 100644 --- a/tauri/src/runtime/tool-call-state.ts +++ b/tauri/src/runtime/coordination/tool-call-state.ts @@ -1,10 +1,16 @@ +/** + * 标准 Tool Call 的纯状态机。 + * + * `call_id` 是唯一关联键,只允许声明性 choice、confirm、input 在合法生命周期内迁移; + * 本模块不渲染、不写 Store、不联网,实际结果发送由 Runtime Action/outbox 完成。 + */ import type { JsonObject, ToolCallCancel, ToolCallFinalStatus, ToolCallRequest, ToolCallResult, -} from "../protocol"; +} from "@/runtime/protocol/lineup-v1"; export type ToolCallStatus = "pending" | "submitted" | ToolCallFinalStatus; diff --git a/tauri/src/runtime/coordination/tool-router.test.ts b/tauri/src/runtime/coordination/tool-router.test.ts new file mode 100644 index 0000000..7b8fb2f --- /dev/null +++ b/tauri/src/runtime/coordination/tool-router.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { CoreAppRegistry } from "@/runtime/app-management/app-registry"; +import { ToolRouter } from "@/runtime/coordination/tool-router"; + +function extension(enabled = true) { + return { + app_scope: "draw-and-guess", kind: "extension" as const, enabled, default_eligible: false, recovery: false, + manifest: { app_scope: "draw-and-guess", version: "1.0.0", permissions: [], tools: [{ name: "app.launch", handling: "launch" as const, parameters: ["app_scope"] }] }, + }; +} +function launch(scope = "draw-and-guess", extra: Record = {}) { + return JSON.stringify({ v: 1, id: "launch-1", type: "lineup.v1.app.call", conversation_id: "c1", sender: { kind: "agent", id: "agent" }, payload: { call_id: "call-1", capability: "runtime.launch_app", reason: "Start a game", expires_at: "2026-08-05T00:00:00.000Z", arguments: { app_scope: scope, ...extra } } }); +} + +describe("ToolRouter", () => { + it("accepts only a manifest-authorized installed extension launch", () => { + const apps = new CoreAppRegistry(); apps.install(extension()); + expect(new ToolRouter(apps).route(launch(), { app_scope: "chat", conversation_id: "c1" })).toMatchObject({ disposition: "launch", app_scope: "draw-and-guess", call_id: "call-1" }); + }); + it("rejects unknown and malformed launch requests before an App can see them", () => { + const apps = new CoreAppRegistry(); apps.install(extension()); const router = new ToolRouter(apps); + expect(router.route(launch("not-installed"), { app_scope: "chat", conversation_id: "c1" })).toEqual({ disposition: "rejected", code: "not_installed" }); + expect(router.route(launch("draw-and-guess", { bundle_url: "https://evil.invalid" }), { app_scope: "chat", conversation_id: "c1" })).toEqual({ disposition: "rejected", code: "invalid_request" }); + }); +}); diff --git a/tauri/src/runtime/coordination/tool-router.ts b/tauri/src/runtime/coordination/tool-router.ts new file mode 100644 index 0000000..7ceb025 --- /dev/null +++ b/tauri/src/runtime/coordination/tool-router.ts @@ -0,0 +1,47 @@ +/** + * Runtime Tool Router. It is the only interpreter for Agent requests that + * alter the application workspace. A renderer merely receives the already + * routed result and can never turn an arbitrary app.call into a launch. + */ +import type { AppManifest, AppScope, CoreAppRegistry } from "@/runtime/app-management/app-registry"; +import { parseEnvelopeJSON, type Envelope, type JsonObject } from "@/runtime/protocol/lineup-v1"; + +export type ToolRoute = + | { disposition: "pass" } + | { disposition: "launch"; call_id: string; app_scope: AppScope; instance_id?: string; arguments: JsonObject } + | { disposition: "rejected"; code: "invalid_request" | "scope_mismatch" | "not_installed" | "disabled" | "manifest_denied" | "permission_denied" }; + +export class ToolRouter { + public constructor(private readonly apps: CoreAppRegistry) {} + + /** Returns pass for ordinary protocol messages, preserving 00.base handling. */ + public route(raw: string, expected: { conversation_id: string; app_scope: AppScope }): ToolRoute { + const parsed = parseEnvelopeJSON(raw); + if (!parsed.ok) return { disposition: "pass" }; + const envelope = parsed.envelope; + if (envelope.type !== "lineup.v1.app.call" || envelope.payload.capability !== "runtime.launch_app") return { disposition: "pass" }; + if (envelope.conversation_id !== expected.conversation_id) return { disposition: "rejected", code: "scope_mismatch" }; + return this.routeLaunch(envelope); + } + + private routeLaunch(envelope: Envelope): ToolRoute { + const callID = text(envelope.payload.call_id); + const args = object(envelope.payload.arguments); + const requestedScope = args && text(args.app_scope); + if (!callID || !args || !requestedScope || Object.keys(args).some(key => key !== "app_scope" && key !== "instance_id" && key !== "parameters")) return { disposition: "rejected", code: "invalid_request" }; + const record = this.apps.get(requestedScope); + if (!record) return { disposition: "rejected", code: "not_installed" }; + if (!record.enabled) return { disposition: "rejected", code: "disabled" }; + const definition = record.manifest.tools.find(tool => tool.name === "app.launch" && tool.handling === "launch"); + if (!definition) return { disposition: "rejected", code: "manifest_denied" }; + if (definition.requires_permission && !record.manifest.permissions.includes(definition.requires_permission)) return { disposition: "rejected", code: "permission_denied" }; + const instanceID = args.instance_id === undefined ? undefined : text(args.instance_id); + if (args.instance_id !== undefined && (!instanceID || !/^[A-Za-z0-9._:-]{1,128}$/.test(instanceID))) return { disposition: "rejected", code: "invalid_request" }; + const parameters = args.parameters === undefined ? {} : object(args.parameters); + if (!parameters) return { disposition: "rejected", code: "invalid_request" }; + return { disposition: "launch", call_id: callID, app_scope: record.app_scope, ...(instanceID ? { instance_id: instanceID } : {}), arguments: parameters }; + } +} + +function object(value: unknown): JsonObject | undefined { return value && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : undefined; } +function text(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } diff --git a/tauri/src/runtime/client-inventory.test.ts b/tauri/src/runtime/inventory/client-inventory.test.ts similarity index 68% rename from tauri/src/runtime/client-inventory.test.ts rename to tauri/src/runtime/inventory/client-inventory.test.ts index c638296..703c990 100644 --- a/tauri/src/runtime/client-inventory.test.ts +++ b/tauri/src/runtime/inventory/client-inventory.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { CapabilityRegistry } from "./capability-registry"; -import { ClientInventoryPublisher } from "./client-inventory"; -import { SurfaceRegistry } from "./surface-registry"; +import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry"; +import { ClientInventoryPublisher } from "@/runtime/inventory/client-inventory"; +import { SurfaceRegistry } from "@/runtime/surfaces/surface-registry"; describe("ClientInventoryPublisher", () => { it("publishes only enabled apps and policy-visible capabilities", () => { @@ -33,4 +33,13 @@ describe("ClientInventoryPublisher", () => { const inventory = new ClientInventoryPublisher().sync(new SurfaceRegistry().snapshot(), new CapabilityRegistry().inventory()).inventory; expect(inventory.capabilities.map(capability => capability.name)).not.toContain("artifact.save"); }); + + it("advertises only enabled Runtime manifests and their declarative tools", () => { + const inventory = new ClientInventoryPublisher().sync(new SurfaceRegistry().snapshot(), new CapabilityRegistry().inventory(), [{ + app_scope: "draw-and-guess", kind: "extension", enabled: true, default_eligible: false, recovery: false, + manifest: { app_scope: "draw-and-guess", version: "1.0.0", permissions: [], tools: [{ name: "app.launch", handling: "launch", parameters: ["app_scope"] }] }, + }]).inventory; + expect(inventory.applications).toContainEqual(expect.objectContaining({ id: "draw-and-guess", tools: [{ name: "app.launch", handling: "launch" }] })); + expect(JSON.stringify(inventory)).not.toContain("permissions"); + }); }); diff --git a/tauri/src/runtime/client-inventory.ts b/tauri/src/runtime/inventory/client-inventory.ts similarity index 63% rename from tauri/src/runtime/client-inventory.ts rename to tauri/src/runtime/inventory/client-inventory.ts index 6ef8b96..0a5b7fe 100644 --- a/tauri/src/runtime/client-inventory.ts +++ b/tauri/src/runtime/inventory/client-inventory.ts @@ -1,5 +1,12 @@ -import type { CapabilityDefinition } from "./capability-registry"; -import type { SurfaceRegistrySnapshot } from "./surface-registry"; +/** + * 面向 Agent 的客户端能力清单生成器。 + * + * 根据当前公开的 Surface 和 Capability 计算带 revision 的 Inventory;只有可见能力变化时 + * 才更新,避免重试或内部状态抖动错误扩大 Agent 的可调用范围。 + */ +import type { CapabilityDefinition } from "@/runtime/capabilities/capability-registry"; +import type { SurfaceRegistrySnapshot } from "@/runtime/surfaces/surface-registry"; +import type { CoreAppRecord } from "@/runtime/app-management/app-registry"; export type ClientInventory = Readonly<{ revision: string; @@ -7,7 +14,8 @@ export type ClientInventory = Readonly<{ applications: readonly Readonly<{ id: string; version: string; - surfaces: readonly Readonly<{ id: "task-dashboard"; events: readonly ["cancel"] }>[]; + surfaces: readonly Readonly<{ id: string; events: readonly string[] }>[]; + tools?: readonly Readonly<{ name: string; handling: string }>[]; }>[]; capabilities: readonly CapabilityDefinition[]; }>; @@ -23,9 +31,14 @@ export class ClientInventoryPublisher { private previousFingerprint = ""; private current: ClientInventory | undefined; - public sync(surfaces: SurfaceRegistrySnapshot, capabilities: readonly CapabilityDefinition[]): { changed: boolean; inventory: ClientInventory } { - const applications = surfaces.enabled + public sync(surfaces: SurfaceRegistrySnapshot, capabilities: readonly CapabilityDefinition[], apps: readonly CoreAppRecord[] = []): { changed: boolean; inventory: ClientInventory } { + const surfaceApplications = surfaces.enabled .map(enabled => ({ id: enabled.app_id, version: enabled.version, surfaces: [{ id: "task-dashboard" as const, events: ["cancel"] as ["cancel"] }] })) + const manifestApplications = apps.filter(app => app.enabled).map(app => ({ + id: app.app_scope, version: app.manifest.version, surfaces: [], + tools: app.manifest.tools.map(tool => ({ name: tool.name, handling: tool.handling })), + })); + const applications = [...surfaceApplications, ...manifestApplications] .sort((left, right) => `${left.id}@${left.version}`.localeCompare(`${right.id}@${right.version}`)); const allowedCapabilities = [...capabilities] .sort((left, right) => left.name.localeCompare(right.name)) diff --git a/tauri/src/runtime/conversation-store.test.ts b/tauri/src/runtime/persistence/conversation-store.test.ts similarity index 82% rename from tauri/src/runtime/conversation-store.test.ts rename to tauri/src/runtime/persistence/conversation-store.test.ts index 5188def..ea0980f 100644 --- a/tauri/src/runtime/conversation-store.test.ts +++ b/tauri/src/runtime/persistence/conversation-store.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import type { ToolCallRecord } from "./tool-call-state"; -import type { TaskRecord } from "./task-state"; -import { ConversationStore } from "./conversation-store"; +import type { ToolCallRecord } from "@/runtime/coordination/tool-call-state"; +import type { TaskRecord } from "@/runtime/coordination/task-state"; +import { ConversationStore } from "@/runtime/persistence/conversation-store"; class MemoryStorage implements Storage { private readonly values = new Map(); @@ -62,6 +62,53 @@ describe("ConversationStore", () => { })]); }); + it("persists a scoped App Inbox independently of the rendered conversation transcript", () => { + const storage = new MemoryStorage(); + const first = new ConversationStore(storage); + first.open(conversationID); + first.enqueueAppInbox({ + message_id: "agent_inbox_001", + app_scope: "chat", + conversation_id: conversationID, + item: { kind: "markdown", id: "agent_inbox_001", markdown: "recover me" }, + received_at: createdAt, + message_seq: 77, + }); + + const reopened = new ConversationStore(storage); + reopened.open(conversationID); + expect(reopened.listAppInbox("chat")).toEqual([expect.objectContaining({ + message_id: "agent_inbox_001", app_scope: "chat", message_seq: 77, + })]); + expect(reopened.acknowledgeAppInbox("chat", "agent_inbox_001")).toBe(true); + expect(reopened.listAppInbox("chat")).toEqual([]); + }); + + it("upgrades v10 durable conversations without dropping existing state while adding App Inbox", () => { + const storage = new MemoryStorage(); + const key = "lineup.conversation.v1:user_test:2:agent_channel"; + storage.setItem(key, JSON.stringify({ + version: 10, + cursor: 9, + items: [], + outbox: [], + tool_calls: [{ call_id: "preserved", conversation_id: conversationID, status: "completed" }], + tool_drafts: { preserved: { value: "yes" } }, + tasks: [{ operation_id: "task", conversation_id: conversationID, title: "Task", percent: 50, status: "running", cancellable: false, updated_at: createdAt }], + execution_summaries: [], + surfaces: { version: 1, instances: [] }, + capability_calls: [], + })); + + const snapshot = new ConversationStore(storage).open(conversationID); + expect(snapshot.cursor).toBe(9); + expect(snapshot.tool_calls).toEqual([expect.objectContaining({ call_id: "preserved" })]); + expect(snapshot.tool_drafts).toEqual({ preserved: { value: "yes" } }); + expect(snapshot.tasks).toEqual([expect.objectContaining({ operation_id: "task" })]); + expect(snapshot.app_inbox).toEqual([]); + expect(storage.getItem(key)).toContain('"version":12'); + }); + it("removes legacy Artifact content bytes from memory and durable storage on open", () => { const storage = new MemoryStorage(); const key = "lineup.conversation.v1:user_test:2:agent_channel"; @@ -202,7 +249,7 @@ describe("ConversationStore", () => { updated_at: createdAt, steps: [], detail: "do not persist this", - } as unknown as import("./execution-progress-state").ExecutionProgressSummary]); + } as unknown as import("@/runtime/coordination/execution-progress-state").ExecutionProgressSummary]); expect(JSON.stringify(store.snapshot().execution_summaries)).not.toContain("do not persist this"); }); diff --git a/tauri/src/runtime/conversation-store.ts b/tauri/src/runtime/persistence/conversation-store.ts similarity index 75% rename from tauri/src/runtime/conversation-store.ts rename to tauri/src/runtime/persistence/conversation-store.ts index 8152dee..e6a4e2b 100644 --- a/tauri/src/runtime/conversation-store.ts +++ b/tauri/src/runtime/persistence/conversation-store.ts @@ -1,9 +1,17 @@ -import type { ConversationItem, DeliveryState, JsonObject } from "../protocol"; -import type { ToolCallRecord } from "./tool-call-state"; -import type { TaskRecord } from "./task-state"; -import type { ExecutionProgressSummary } from "./execution-progress-state"; -import type { SurfaceInstanceSnapshot } from "./surface-instance-manager"; -import { sanitizeCapabilityReason, type CapabilityCallRecord } from "./capability-call-state"; +/** + * 当前会话的 Runtime 持久化存储。 + * + * 保存已验证消息、同步 cursor、typed outbox、Tool/Task 投影及每个 App 的可靠 Inbox;不含 + * DOM、网络或协议解码。App 刷新、崩溃或暂停后由 Runtime 从这里恢复未 ACK 的投递。 + */ +import type { ConversationItem, DeliveryState, JsonObject } from "@/runtime/protocol/lineup-v1"; +import type { ToolCallRecord } from "@/runtime/coordination/tool-call-state"; +import type { TaskRecord } from "@/runtime/coordination/task-state"; +import type { ExecutionProgressSummary } from "@/runtime/coordination/execution-progress-state"; +import type { SurfaceInstanceSnapshot } from "@/runtime/surfaces/surface-instance-manager"; +import { sanitizeCapabilityReason, type CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state"; +import type { AppScope } from "@/runtime/app-management/app-registry"; +import type { AppWorkspaceSnapshot } from "@/runtime/app-management/app-lifecycle-manager"; export type StoredConversationItem = { local_id: string; @@ -19,6 +27,19 @@ export type OutboxEntry = { created_at: string; }; +/** + * A durable, per-App delivery record. Runtime owns this inbox so an App can + * recover messages that arrived while its UI was suspended or reloading. + */ +export type AppInboxEntry = { + message_id: string; + app_scope: AppScope; + conversation_id: string; + item: ConversationItem; + received_at: string; + message_seq?: number; +}; + export type ConversationSnapshot = { conversation_id: string; cursor: number; @@ -31,10 +52,13 @@ export type ConversationSnapshot = { execution_summaries: readonly ExecutionProgressSummary[]; surfaces: SurfaceInstanceSnapshot; capability_calls: readonly CapabilityCallRecord[]; + app_inbox: readonly AppInboxEntry[]; + /** Runtime-owned instance/focus state. Never supplied by an App. */ + app_workspace: AppWorkspaceSnapshot; }; type PersistedConversation = { - version: 10; + version: 12; cursor: number; items: StoredConversationItem[]; outbox: OutboxEntry[]; @@ -44,6 +68,8 @@ type PersistedConversation = { execution_summaries: ExecutionProgressSummary[]; surfaces: SurfaceInstanceSnapshot; capability_calls: CapabilityCallRecord[]; + app_inbox: AppInboxEntry[]; + app_workspace: AppWorkspaceSnapshot; }; const STORE_PREFIX = "lineup.conversation.v1"; @@ -89,6 +115,8 @@ export class ConversationStore { execution_summaries: clone(this.state.execution_summaries), surfaces: clone(this.state.surfaces), capability_calls: clone(this.state.capability_calls), + app_inbox: this.state.app_inbox.map(entry => ({ ...entry })), + app_workspace: clone(this.state.app_workspace), }; } @@ -135,6 +163,34 @@ export class ConversationStore { this.persist(); } + public replaceAppWorkspace(snapshot: AppWorkspaceSnapshot): void { + this.state.app_workspace = normalizeAppWorkspace(snapshot); + this.persist(); + } + + /** Adds a Runtime-validated Agent message to its target App's durable inbox. */ + public enqueueAppInbox(entry: AppInboxEntry): boolean { + if (entry.conversation_id !== this.requireActiveConversationID()) throw new Error("App Inbox conversation scope mismatch."); + if (this.state.app_inbox.some(existing => existing.app_scope === entry.app_scope && existing.message_id === entry.message_id)) return false; + this.state.app_inbox.push({ ...entry }); + this.persist(); + return true; + } + + public listAppInbox(appScope: AppScope): readonly AppInboxEntry[] { + return this.state.app_inbox + .filter(entry => entry.app_scope === appScope) + .map(entry => ({ ...entry })); + } + + public acknowledgeAppInbox(appScope: AppScope, messageID: string): boolean { + const before = this.state.app_inbox.length; + this.state.app_inbox = this.state.app_inbox.filter(entry => !(entry.app_scope === appScope && entry.message_id === messageID)); + if (this.state.app_inbox.length === before) return false; + this.persist(); + return true; + } + public appendLocalText(localID: string, text: string, createdAt: string): StoredConversationItem { const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt }; const stored: StoredConversationItem = { @@ -246,19 +302,39 @@ export class ConversationStore { try { const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null"); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyConversation(); - const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown; tool_calls?: unknown; tool_drafts?: unknown; tasks?: unknown; execution_summaries?: unknown; surfaces?: unknown; capability_calls?: unknown }; - if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 && candidate.version !== 5 && candidate.version !== 6 && candidate.version !== 7 && candidate.version !== 8 && candidate.version !== 9 && candidate.version !== 10) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") { + const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown; tool_calls?: unknown; tool_drafts?: unknown; tasks?: unknown; execution_summaries?: unknown; surfaces?: unknown; capability_calls?: unknown; app_inbox?: unknown; app_workspace?: unknown }; + if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 && candidate.version !== 5 && candidate.version !== 6 && candidate.version !== 7 && candidate.version !== 8 && candidate.version !== 9 && candidate.version !== 10 && candidate.version !== 11 && candidate.version !== 12) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") { return emptyConversation(); } - if (candidate.version !== 10) { + if (candidate.version === 10 || candidate.version === 11) { + // v10 is the immediately preceding complete conversation format. Add + // the Runtime-owned App Inbox without discarding any already durable + // tool, task, Surface, capability, or execution projection. + return { + version: 12, + cursor: Math.max(0, candidate.cursor), + items: normalizeStoredItems(candidate.items), + outbox: normalizeOutbox(candidate.outbox), + tool_calls: Array.isArray(candidate.tool_calls) ? candidate.tool_calls as ToolCallRecord[] : [], + tool_drafts: isDraftMap(candidate.tool_drafts) ? candidate.tool_drafts : {}, + tasks: Array.isArray(candidate.tasks) ? candidate.tasks as TaskRecord[] : [], + execution_summaries: normalizeExecutionSummaries(candidate.execution_summaries), + surfaces: normalizeSurfaces(candidate.surfaces), + capability_calls: normalizeCapabilityCalls(candidate.capability_calls), + app_inbox: normalizeAppInbox(candidate.app_inbox, conversationID), + app_workspace: emptyAppWorkspace(), + }; + } + + if (candidate.version !== 12) { // v1 advanced its cursor from a send acknowledgement. Some browsers // had already been upgraded to v2 before that correction, retaining a // high cursor with holes for their own echoes. Preserve every valid // local item but replay the server history once to repair those holes; // the next persist upgrades this record to v5. return { - version: 10, + version: 12, cursor: candidate.version === 4 || candidate.version === 5 || candidate.version === 6 || candidate.version === 7 ? Math.max(0, candidate.cursor) : 0, items: normalizeStoredItems(candidate.items), outbox: normalizeOutbox(candidate.outbox), @@ -269,10 +345,12 @@ export class ConversationStore { execution_summaries: [], surfaces: emptySurfaces(), capability_calls: [], + app_inbox: [], + app_workspace: emptyAppWorkspace(), }; } return { - version: 10, + version: 12, cursor: Math.max(0, candidate.cursor), items: normalizeStoredItems(candidate.items), outbox: normalizeOutbox(candidate.outbox), @@ -282,6 +360,8 @@ export class ConversationStore { execution_summaries: normalizeExecutionSummaries(candidate.execution_summaries), surfaces: normalizeSurfaces(candidate.surfaces), capability_calls: normalizeCapabilityCalls(candidate.capability_calls), + app_inbox: normalizeAppInbox(candidate.app_inbox, conversationID), + app_workspace: normalizeAppWorkspace(candidate.app_workspace), }; } catch { return emptyConversation(); @@ -303,11 +383,18 @@ export class ConversationStore { } function emptyConversation(): PersistedConversation { - return { version: 10, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {}, tasks: [], execution_summaries: [], surfaces: emptySurfaces(), capability_calls: [] }; + return { version: 12, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {}, tasks: [], execution_summaries: [], surfaces: emptySurfaces(), capability_calls: [], app_inbox: [], app_workspace: emptyAppWorkspace() }; } function emptySurfaces(): SurfaceInstanceSnapshot { return { version: 1, instances: [] }; } function normalizeSurfaces(value: unknown): SurfaceInstanceSnapshot { return value && typeof value === "object" && !Array.isArray(value) ? value as SurfaceInstanceSnapshot : emptySurfaces(); } +function emptyAppWorkspace(): AppWorkspaceSnapshot { return { version: 1, instances: { version: 1, instances: [] }, focus: { version: 1, stack: [] } }; } +function normalizeAppWorkspace(value: unknown): AppWorkspaceSnapshot { + if (!value || typeof value !== "object" || Array.isArray(value)) return emptyAppWorkspace(); + const candidate = value as Partial; + if (candidate.version !== 1 || !candidate.instances || !candidate.focus) return emptyAppWorkspace(); + return candidate as AppWorkspaceSnapshot; +} function normalizeOutbox(value: unknown): OutboxEntry[] { if (!Array.isArray(value)) return []; @@ -320,6 +407,27 @@ function normalizeOutbox(value: unknown): OutboxEntry[] { }); } +function normalizeAppInbox(value: unknown, conversationID: string): AppInboxEntry[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + return value.flatMap(raw => { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return []; + const entry = raw as Partial; + if (typeof entry.message_id !== "string" || !entry.message_id || typeof entry.app_scope !== "string" || !/^[a-z][a-z0-9-]{0,63}$/.test(entry.app_scope) || entry.conversation_id !== conversationID || typeof entry.received_at !== "string" || !entry.item || typeof entry.item !== "object") return []; + const key = `${entry.app_scope}:${entry.message_id}`; + if (seen.has(key)) return []; + seen.add(key); + return [{ + message_id: entry.message_id, + app_scope: entry.app_scope, + conversation_id: conversationID, + item: entry.item as ConversationItem, + received_at: entry.received_at, + ...(typeof entry.message_seq === "number" ? { message_seq: entry.message_seq } : {}), + }]; + }); +} + /** * Artifact content is delivery-only data for the host-owned volatile cache. * It must never survive in the durable conversation transcript, including diff --git a/tauri/src/runtime/golden-protocol.test.ts b/tauri/src/runtime/protocol/golden-protocol.test.ts similarity index 92% rename from tauri/src/runtime/golden-protocol.test.ts rename to tauri/src/runtime/protocol/golden-protocol.test.ts index 8b4c701..160a2b1 100644 --- a/tauri/src/runtime/golden-protocol.test.ts +++ b/tauri/src/runtime/protocol/golden-protocol.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { decodeConversationItem, type ConversationItem, type JsonObject } from "../protocol"; -import { InteractionKernel } from "./interaction-kernel"; -import fixture from "./golden/lineup-v1.json"; +import { decodeConversationItem, type ConversationItem, type JsonObject } from "@/runtime/protocol/lineup-v1"; +import { InteractionKernel } from "@/runtime/coordination/interaction-kernel"; +import fixture from "@/runtime/protocol/golden/lineup-v1.json"; type GoldenExpected = Readonly<{ item_kind: ConversationItem["kind"]; diff --git a/tauri/src/runtime/golden/lineup-v1.json b/tauri/src/runtime/protocol/golden/lineup-v1.json similarity index 100% rename from tauri/src/runtime/golden/lineup-v1.json rename to tauri/src/runtime/protocol/golden/lineup-v1.json diff --git a/tauri/src/protocol.ts b/tauri/src/runtime/protocol/lineup-v1.ts similarity index 98% rename from tauri/src/protocol.ts rename to tauri/src/runtime/protocol/lineup-v1.ts index 864bc8d..75fbb70 100644 --- a/tauri/src/protocol.ts +++ b/tauri/src/runtime/protocol/lineup-v1.ts @@ -1,4 +1,9 @@ /** + * 平台无关的 LineUp v1 协议模型与安全解码边界。 + * + * 本模块把不可信传输 payload 转为受信任的 ConversationItem;它不依赖 DOM、网络、Tauri 或 + * 本地存储。Runtime 在此基础上再施加 app_scope、conversation_id 与 App 路由策略。 + * * Platform-neutral LineUp v1 protocol model. * * This module deliberately contains no DOM, network, Tauri, or storage code. diff --git a/tauri/src/runtime/isolated-surface-host.test.ts b/tauri/src/runtime/surfaces/isolated-surface-host.test.ts similarity index 94% rename from tauri/src/runtime/isolated-surface-host.test.ts rename to tauri/src/runtime/surfaces/isolated-surface-host.test.ts index 23446dd..1be2b8c 100644 --- a/tauri/src/runtime/isolated-surface-host.test.ts +++ b/tauri/src/runtime/surfaces/isolated-surface-host.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { CachedVerifiedSurfaceDocumentResolver, parseReadyMessage, productionSurfaceDocument, surfaceDocument } from "./isolated-surface-host"; -import { VerifiedSurfaceBundleCache, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "./surface-bundle-cache"; +import { CachedVerifiedSurfaceDocumentResolver, parseReadyMessage, productionSurfaceDocument, surfaceDocument } from "@/runtime/surfaces/isolated-surface-host"; +import { VerifiedSurfaceBundleCache, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "@/runtime/surfaces/surface-bundle-cache"; describe("isolated Surface bridge contract", () => { it("accepts only the exact minimal ready message", () => { diff --git a/tauri/src/runtime/isolated-surface-host.ts b/tauri/src/runtime/surfaces/isolated-surface-host.ts similarity index 95% rename from tauri/src/runtime/isolated-surface-host.ts rename to tauri/src/runtime/surfaces/isolated-surface-host.ts index 66129a0..6535832 100644 --- a/tauri/src/runtime/isolated-surface-host.ts +++ b/tauri/src/runtime/surfaces/isolated-surface-host.ts @@ -1,6 +1,12 @@ -import type { JsonObject } from "../protocol"; -import type { SurfaceInstance } from "./surface-instance-manager"; -import type { VerifiedSurfaceBundleCache } from "./surface-bundle-cache"; +/** + * 受限 Surface 的隔离 iframe Host。 + * + * 只把 Host 已验证的 Bundle 文档装入 opaque-origin iframe,并通过严格 bridge 接收 ready + * 或声明性事件;Surface 不可获得父 DOM、登录态、Tauri API 或任意网络能力。 + */ +import type { JsonObject } from "@/runtime/protocol/lineup-v1"; +import type { SurfaceInstance } from "@/runtime/surfaces/surface-instance-manager"; +import type { VerifiedSurfaceBundleCache } from "@/runtime/surfaces/surface-bundle-cache"; export type SurfaceHostCallbacks = Readonly<{ ready: (instanceID: string) => void; diff --git a/tauri/src/runtime/production-surface-manifest.test.ts b/tauri/src/runtime/surfaces/production-surface-manifest.test.ts similarity index 95% rename from tauri/src/runtime/production-surface-manifest.test.ts rename to tauri/src/runtime/surfaces/production-surface-manifest.test.ts index e636b11..8cd2aac 100644 --- a/tauri/src/runtime/production-surface-manifest.test.ts +++ b/tauri/src/runtime/surfaces/production-surface-manifest.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { canonicalManifestPayload, compareSemver, parseProductionSurfaceManifest, ProductionSurfaceManifestRegistry } from "./production-surface-manifest"; +import { canonicalManifestPayload, compareSemver, parseProductionSurfaceManifest, ProductionSurfaceManifestRegistry } from "@/runtime/surfaces/production-surface-manifest"; const manifest = { v: 1, app_id: "lineup.task-dashboard", version: "1.2.0", diff --git a/tauri/src/runtime/production-surface-manifest.ts b/tauri/src/runtime/surfaces/production-surface-manifest.ts similarity index 96% rename from tauri/src/runtime/production-surface-manifest.ts rename to tauri/src/runtime/surfaces/production-surface-manifest.ts index 2010fb0..d0d3cfe 100644 --- a/tauri/src/runtime/production-surface-manifest.ts +++ b/tauri/src/runtime/surfaces/production-surface-manifest.ts @@ -1,4 +1,9 @@ /** + * 生产 Surface Manifest 的严格声明与解析。 + * + * Manifest 仅描述不可变元数据,不能携带 Bundle URL 或可执行源码;下载、签名校验和缓存 + * 安装必须在后续独立步骤完成,避免 Agent/消息直接注入界面代码。 + * * M4 production Surface declaration. A manifest is only immutable metadata; * it contains neither a bundle URL nor executable source. Download, signature * verification and cache installation are deliberately separate M4-02 steps. diff --git a/tauri/src/runtime/production-surface-policy.test.ts b/tauri/src/runtime/surfaces/production-surface-policy.test.ts similarity index 86% rename from tauri/src/runtime/production-surface-policy.test.ts rename to tauri/src/runtime/surfaces/production-surface-policy.test.ts index 5a1c6e6..6a83624 100644 --- a/tauri/src/runtime/production-surface-policy.test.ts +++ b/tauri/src/runtime/surfaces/production-surface-policy.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import { ProductionSurfaceManifestRegistry } from "./production-surface-manifest"; -import { ProductionSurfacePolicy } from "./production-surface-policy"; -import { SurfaceInstanceManager } from "./surface-instance-manager"; -import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "./surface-bundle-cache"; +import { ProductionSurfaceManifestRegistry } from "@/runtime/surfaces/production-surface-manifest"; +import { ProductionSurfacePolicy } from "@/runtime/surfaces/production-surface-policy"; +import { SurfaceInstanceManager } from "@/runtime/surfaces/surface-instance-manager"; +import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "@/runtime/surfaces/surface-bundle-cache"; const bytes = new TextEncoder().encode("
verified
"); const digest: SurfaceBundleDigest = { async sha256() { return "a".repeat(64); } }; diff --git a/tauri/src/runtime/production-surface-policy.ts b/tauri/src/runtime/surfaces/production-surface-policy.ts similarity index 86% rename from tauri/src/runtime/production-surface-policy.ts rename to tauri/src/runtime/surfaces/production-surface-policy.ts index 9b8fd56..ca70a67 100644 --- a/tauri/src/runtime/production-surface-policy.ts +++ b/tauri/src/runtime/surfaces/production-surface-policy.ts @@ -1,7 +1,13 @@ -import type { JsonObject } from "../protocol"; -import { parseProductionSurfaceManifest, ProductionSurfaceManifestRegistry, type ProductionManifestDisposition } from "./production-surface-manifest"; -import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache } from "./surface-bundle-cache"; -import type { SurfaceAdmissionPolicy, SurfaceRegistryDisposition, SurfaceRegistryResult } from "./surface-registry"; +/** + * 生产 Surface 的接纳策略。 + * + * 只有签名、版本、完整性和本地缓存均已验证的精确 app/version 才能创建实例;策略层不执行 + * DOM 或下载副作用,只返回可审计的接纳/拒绝结果。 + */ +import type { JsonObject } from "@/runtime/protocol/lineup-v1"; +import { parseProductionSurfaceManifest, ProductionSurfaceManifestRegistry, type ProductionManifestDisposition } from "@/runtime/surfaces/production-surface-manifest"; +import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache } from "@/runtime/surfaces/surface-bundle-cache"; +import type { SurfaceAdmissionPolicy, SurfaceRegistryDisposition, SurfaceRegistryResult } from "@/runtime/surfaces/surface-registry"; export type ProductionSurfacePreparation = Readonly<{ disposition: ProductionManifestDisposition | "installed" | "download_failed" | "signature_invalid" | "size_mismatch" | "digest_mismatch" | "invalid_manifest"; diff --git a/tauri/src/runtime/surface-bundle-cache.test.ts b/tauri/src/runtime/surfaces/surface-bundle-cache.test.ts similarity index 98% rename from tauri/src/runtime/surface-bundle-cache.test.ts rename to tauri/src/runtime/surfaces/surface-bundle-cache.test.ts index 5459550..cc503dc 100644 --- a/tauri/src/runtime/surface-bundle-cache.test.ts +++ b/tauri/src/runtime/surfaces/surface-bundle-cache.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, WebCryptoEd25519SurfaceManifestSignatureVerifier, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "./surface-bundle-cache"; +import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, WebCryptoEd25519SurfaceManifestSignatureVerifier, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "@/runtime/surfaces/surface-bundle-cache"; const bytes = new Uint8Array([1, 2, 3, 4]); const digest: SurfaceBundleDigest = { async sha256(value) { return value.join("").padEnd(64, "0"); } }; diff --git a/tauri/src/runtime/surface-bundle-cache.ts b/tauri/src/runtime/surfaces/surface-bundle-cache.ts similarity index 96% rename from tauri/src/runtime/surface-bundle-cache.ts rename to tauri/src/runtime/surfaces/surface-bundle-cache.ts index be5bbb4..ef03ec5 100644 --- a/tauri/src/runtime/surface-bundle-cache.ts +++ b/tauri/src/runtime/surfaces/surface-bundle-cache.ts @@ -1,4 +1,10 @@ -import { canonicalManifestPayload, parseProductionSurfaceManifest, type ProductionSurfaceManifest } from "./production-surface-manifest"; +/** + * Surface Bundle 的下载、验签、完整性校验与可回滚缓存。 + * + * Bundle 只有经 Manifest、Ed25519、精确大小和 SHA-256 全部验证后才可成为 active cache; + * 失败不会污染已有版本,并可安全回滚到此前已验证的版本。 + */ +import { canonicalManifestPayload, parseProductionSurfaceManifest, type ProductionSurfaceManifest } from "@/runtime/surfaces/production-surface-manifest"; export type BundleVerificationDisposition = "installed" | "duplicate" | "invalid_manifest" | "download_failed" | "signature_invalid" | "size_mismatch" | "digest_mismatch"; export type BundleInstallResult = Readonly<{ disposition: BundleVerificationDisposition; entry?: InstalledSurfaceBundle }>; diff --git a/tauri/src/runtime/surface-instance-manager.test.ts b/tauri/src/runtime/surfaces/surface-instance-manager.test.ts similarity index 96% rename from tauri/src/runtime/surface-instance-manager.test.ts rename to tauri/src/runtime/surfaces/surface-instance-manager.test.ts index fbb7dbb..bce20b0 100644 --- a/tauri/src/runtime/surface-instance-manager.test.ts +++ b/tauri/src/runtime/surfaces/surface-instance-manager.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { SurfaceInstanceManager } from "./surface-instance-manager"; -import { SurfaceRegistry } from "./surface-registry"; +import { SurfaceInstanceManager } from "@/runtime/surfaces/surface-instance-manager"; +import { SurfaceRegistry } from "@/runtime/surfaces/surface-registry"; const APP = "lineup.task-dashboard"; const VERSION = "0.1.0"; diff --git a/tauri/src/runtime/surface-instance-manager.ts b/tauri/src/runtime/surfaces/surface-instance-manager.ts similarity index 95% rename from tauri/src/runtime/surface-instance-manager.ts rename to tauri/src/runtime/surfaces/surface-instance-manager.ts index 6d21d6e..5fe76c0 100644 --- a/tauri/src/runtime/surface-instance-manager.ts +++ b/tauri/src/runtime/surfaces/surface-instance-manager.ts @@ -1,9 +1,15 @@ -import type { JsonObject } from "../protocol"; +/** + * Surface 实例的纯生命周期管理器。 + * + * 以 `instance_id` 管理 open、ready、patch、close 和 restore,并验证所属 App/版本/会话; + * 重试必须幂等,冲突复用与非法状态替换必须被拒绝。 + */ +import type { JsonObject } from "@/runtime/protocol/lineup-v1"; import { parseSurfaceOpenRequest, type SurfaceAdmissionPolicy, type SurfaceRegistryDisposition, -} from "./surface-registry"; +} from "@/runtime/surfaces/surface-registry"; export type SurfaceInstancePhase = "opening" | "ready"; diff --git a/tauri/src/runtime/surface-registry.test.ts b/tauri/src/runtime/surfaces/surface-registry.test.ts similarity index 98% rename from tauri/src/runtime/surface-registry.test.ts rename to tauri/src/runtime/surfaces/surface-registry.test.ts index e0ee455..46652e8 100644 --- a/tauri/src/runtime/surface-registry.test.ts +++ b/tauri/src/runtime/surfaces/surface-registry.test.ts @@ -3,7 +3,7 @@ import { DEVELOPMENT_SURFACE_MANIFESTS, SurfaceRegistry, parseSurfaceOpenRequest, -} from "./surface-registry"; +} from "@/runtime/surfaces/surface-registry"; const APP = "lineup.task-dashboard"; const VERSION = "0.1.0"; diff --git a/tauri/src/runtime/surface-registry.ts b/tauri/src/runtime/surfaces/surface-registry.ts similarity index 97% rename from tauri/src/runtime/surface-registry.ts rename to tauri/src/runtime/surfaces/surface-registry.ts index d7d3369..4d77f39 100644 --- a/tauri/src/runtime/surface-registry.ts +++ b/tauri/src/runtime/surfaces/surface-registry.ts @@ -1,4 +1,10 @@ -import type { JsonObject } from "../protocol"; +/** + * 本地可信 Surface 注册表与开发期接纳契约。 + * + * 记录由 Host 打包的精确 App/版本,不接受会话 payload 传来的 HTML、JavaScript、URL 或 + * Bundle 替换字段;后续生产策略沿用相同的受限接纳边界。 + */ +import type { JsonObject } from "@/runtime/protocol/lineup-v1"; /** * M2-01 development-time Surface declaration. These records are authored by diff --git a/tauri/tsconfig.json b/tauri/tsconfig.json index 6e3f156..0b5e889 100644 --- a/tauri/tsconfig.json +++ b/tauri/tsconfig.json @@ -6,6 +6,10 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "skipLibCheck": true, "moduleResolution": "bundler", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + }, "allowImportingTsExtensions": false, "resolveJsonModule": true, "isolatedModules": true, diff --git a/tauri/vite.config.ts b/tauri/vite.config.ts new file mode 100644 index 0000000..b51fdee --- /dev/null +++ b/tauri/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vite"; + +/** + * Keep Vite, Vitest and TypeScript on the same source-root import contract. + * Module boundaries may evolve without introducing fragile ../../ imports. + */ +export default defineConfig({ + resolve: { + alias: { + "@": "/src", + }, + }, +}); diff --git a/wails-spike/README.md b/wails-spike/README.md deleted file mode 100644 index b676122..0000000 --- a/wails-spike/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# LineUp Wails v3 对照 Spike(M4-05) - -此目录是受限的 Wails 可行性验证,不是第二套 LineUp 客户端、更不是生产发布物。 - -固定依赖为 `github.com/wailsapp/wails/v3 v3.0.0-beta.3`。`go test ./contract` 在本机执行 Tauri Reference Host 维护的同一份版本化协议 fixture:`../tauri/src/runtime/golden/lineup-v1.json`。`GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -c ./...` 则编译真实 Wails `application` API;这是为不具备 GTK4/WebKitGTK 的 Linux 开发机保留的 API/跨编译门禁。 - -当前结论:Wails 能作为桌面 Host 候选运行同一份纯数据协议、Tool lifecycle 与安全拒绝 fixture;它不能因此获得任何比 Tauri 更多的权限。Surface 必须继续由 Host 校验 artifact、以 opaque/隔离 WebView 承载,并且不能注入 Wails bindings;Capability 只能由顶层 Host 在显式同意后执行。 - -本 Spike 的边界和后续准入: - -- 已验证:beta.3 Go module、Wails application API 的 Windows/amd64 交叉编译、共享 golden fixture 的本机可执行投影、Surface source 字段拒绝。 -- 尚不作为生产批准:完整窗口隔离/CSP、签名 artifact 加载、原生 capability handler、移动端和发布包,需要在 Wails 版本稳定后用与 Tauri 相同的有头验收清单完成。 -- 这不改变 Android 当前暂缓状态,也不改变 LineUp v1 协议。 diff --git a/wails-spike/contract/golden_test.go b/wails-spike/contract/golden_test.go deleted file mode 100644 index 3e35352..0000000 --- a/wails-spike/contract/golden_test.go +++ /dev/null @@ -1,129 +0,0 @@ -// Package contract is deliberately framework-free: production Wails wiring -// must not change LineUp's envelope/state semantics. -package contract - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" -) - -type fixture struct { - Contract string `json:"contract"` - Cases []testCase `json:"cases"` -} - -type testCase struct { - ID string `json:"id"` - Expected expected `json:"expected"` - Envelopes []envelope `json:"envelopes"` - Transitions []transition `json:"transitions"` -} - -type expected struct { - ItemKind string `json:"item_kind"` - Fallback string `json:"fallback"` - KernelToolStatus string `json:"kernel_tool_status"` -} - -type transition struct { - AfterEnvelope int `json:"after_envelope"` - Kind string `json:"kind"` - CallID string `json:"call_id"` -} - -type envelope struct { - Version int `json:"v"` - Type string `json:"type"` - Payload map[string]interface{} `json:"payload"` -} - -func TestSharedLineUpV1GoldenContract(t *testing.T) { - bytes, err := os.ReadFile(filepath.Join("..", "..", "tauri", "src", "runtime", "golden", "lineup-v1.json")) - if err != nil { - t.Fatalf("read shared fixture: %v", err) - } - var spec fixture - if err := json.Unmarshal(bytes, &spec); err != nil { - t.Fatalf("parse shared fixture: %v", err) - } - if spec.Contract != "lineup-v1-golden-1" || len(spec.Cases) != 6 { - t.Fatalf("unexpected fixture declaration: %q (%d cases)", spec.Contract, len(spec.Cases)) - } - for _, c := range spec.Cases { - t.Run(c.ID, func(t *testing.T) { - toolStates := map[string]string{} - kind, fallback := "", "" - for index, wire := range c.Envelopes { - kind, fallback = project(wire) - if kind == "tool-call" { - toolStates[stringValue(wire.Payload["call_id"])] = "pending" - } - for _, action := range c.Transitions { - if action.AfterEnvelope == index && action.Kind == "submit_tool_call" && toolStates[action.CallID] == "pending" { - toolStates[action.CallID] = "submitted" - } - } - if kind == "tool-result" { - callID := stringValue(wire.Payload["call_id"]) - if toolStates[callID] == "submitted" { - toolStates[callID] = stringValue(wire.Payload["status"]) - } - } - } - if kind != c.Expected.ItemKind || fallback != c.Expected.Fallback { - t.Fatalf("got kind=%q fallback=%q; want kind=%q fallback=%q", kind, fallback, c.Expected.ItemKind, c.Expected.Fallback) - } - if c.Expected.KernelToolStatus != "" { - ok := false - for _, state := range toolStates { - ok = ok || state == c.Expected.KernelToolStatus - } - if !ok { - t.Fatalf("tool state never reached %q: %#v", c.Expected.KernelToolStatus, toolStates) - } - } - }) - } -} - -func project(wire envelope) (kind, fallback string) { - if wire.Version != 1 { - return "fallback", "unsupported_version" - } - switch wire.Type { - case "lineup.v1.tool.call": - if stringValue(wire.Payload["call_id"]) == "" || stringValue(wire.Payload["tool"]) == "" { - return "fallback", "invalid_payload" - } - return "tool-call", "" - case "lineup.v1.tool.result": - if stringValue(wire.Payload["call_id"]) == "" || stringValue(wire.Payload["status"]) == "" { - return "fallback", "invalid_payload" - } - return "tool-result", "" - case "lineup.v1.ui.open", "lineup.v1.ui.patch", "lineup.v1.ui.close": - for _, forbidden := range []string{"bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"} { - if _, exists := wire.Payload[forbidden]; exists { - return "fallback", "invalid_payload" - } - } - return "surface", "" - case "lineup.v1.app.call": - if stringValue(wire.Payload["call_id"]) == "" || stringValue(wire.Payload["capability"]) == "" || stringValue(wire.Payload["reason"]) == "" || stringValue(wire.Payload["expires_at"]) == "" { - return "fallback", "invalid_payload" - } - if _, ok := wire.Payload["arguments"].(map[string]interface{}); !ok { - return "fallback", "invalid_payload" - } - return "app-call", "" - default: - return "fallback", "unsupported_type" - } -} - -func stringValue(value interface{}) string { - text, _ := value.(string) - return text -} diff --git a/wails-spike/go.mod b/wails-spike/go.mod deleted file mode 100644 index 2d0ec06..0000000 --- a/wails-spike/go.mod +++ /dev/null @@ -1,14 +0,0 @@ -module lineup-wails-spike - -go 1.26.0 - -require github.com/wailsapp/wails/v3 v3.0.0-beta.3 - -require ( - github.com/adrg/xdg v0.5.3 // indirect - github.com/go-ole/go-ole v1.3.0 // indirect - github.com/godbus/dbus/v5 v5.2.2 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - golang.org/x/sys v0.45.0 // indirect -) diff --git a/wails-spike/go.sum b/wails-spike/go.sum deleted file mode 100644 index 85516d7..0000000 --- a/wails-spike/go.sum +++ /dev/null @@ -1,16 +0,0 @@ -github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= -github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= -github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= -github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= -github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/wailsapp/wails/v3 v3.0.0-beta.3 h1:BrcZunEBVucncRx+xgkk9TzlXU4qc0ygJuEhKAAGaeA= -github.com/wailsapp/wails/v3 v3.0.0-beta.3/go.mod h1:BzATbK71VFikMMMCo434wAi0QcaI03P+xeaWgDvQvjw= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/wails-spike/main.go b/wails-spike/main.go deleted file mode 100644 index 95c505a..0000000 --- a/wails-spike/main.go +++ /dev/null @@ -1,30 +0,0 @@ -// The Wails M4 comparison host intentionally has no LineUp business logic in -// Go. The rendered shell consumes the same host-neutral contract fixture as -// Tauri; Go owns only native lifecycle and capability boundaries. -package main - -import ( - "net/http" - - "github.com/wailsapp/wails/v3/pkg/application" -) - -func newSpikeApplication() *application.App { - app := application.New(application.Options{ - Name: "LineUp Wails Contract Spike", - Description: "M4 parity probe; not a production LineUp Host", - Assets: application.AssetOptions{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - _, _ = w.Write([]byte("LineUp Wails Contract Spike
Wails contract spike
")) - })}, - }) - app.Window.NewWithOptions(application.WebviewWindowOptions{ - Title: "LineUp Wails Contract Spike", - URL: "/", - }) - return app -} - -func main() { - _ = newSpikeApplication().Run() -} diff --git a/wails-spike/runtime_contract_test.go b/wails-spike/runtime_contract_test.go deleted file mode 100644 index ffceff0..0000000 --- a/wails-spike/runtime_contract_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package main - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/wailsapp/wails/v3/pkg/application" -) - -type goldenFixture struct { - Contract string `json:"contract"` - Cases []goldenCase `json:"cases"` -} - -type goldenCase struct { - ID string `json:"id"` - Expected goldenExpected `json:"expected"` - Envelopes []wireEnvelope `json:"envelopes"` - Transitions []hostTransition `json:"transitions"` -} - -type goldenExpected struct { - ItemKind string `json:"item_kind"` - Fallback string `json:"fallback"` - KernelToolStatus string `json:"kernel_tool_status"` -} - -type hostTransition struct { - AfterEnvelope int `json:"after_envelope"` - Kind string `json:"kind"` - CallID string `json:"call_id"` -} - -type wireEnvelope struct { - Version int `json:"v"` - Type string `json:"type"` - Payload map[string]interface{} `json:"payload"` -} - -// TestGoldenContract is deliberately independent of Wails' JS bindings. It -// proves that a Wails Host can run the exact versioned fixture, and that the -// host's admission gate remains data-only before a WebView receives anything. -func TestGoldenContract(t *testing.T) { - var fixture goldenFixture - path := filepath.Join("..", "tauri", "src", "runtime", "golden", "lineup-v1.json") - bytes, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read shared golden fixture: %v", err) - } - if err := json.Unmarshal(bytes, &fixture); err != nil { - t.Fatalf("parse shared golden fixture: %v", err) - } - if fixture.Contract != "lineup-v1-golden-1" { - t.Fatalf("unexpected golden fixture contract %q", fixture.Contract) - } - if len(fixture.Cases) != 6 { - t.Fatalf("expected six shared cases, got %d", len(fixture.Cases)) - } - - for _, test := range fixture.Cases { - t.Run(test.ID, func(t *testing.T) { - status := map[string]string{} - kind := "" - fallback := "" - for index, envelope := range test.Envelopes { - kind, fallback = project(envelope) - if kind == "tool-call" { - status[stringValue(envelope.Payload["call_id"])] = "pending" - } - if kind == "tool-result" { - callID := stringValue(envelope.Payload["call_id"]) - if status[callID] == "submitted" { - status[callID] = stringValue(envelope.Payload["status"]) - } - } - for _, transition := range test.Transitions { - if transition.AfterEnvelope == index && transition.Kind == "submit_tool_call" && status[transition.CallID] == "pending" { - status[transition.CallID] = "submitted" - } - } - } - if kind != test.Expected.ItemKind { - t.Fatalf("projection = %q, want %q (fallback %q)", kind, test.Expected.ItemKind, fallback) - } - if fallback != test.Expected.Fallback { - t.Fatalf("fallback = %q, want %q", fallback, test.Expected.Fallback) - } - if test.Expected.KernelToolStatus != "" { - matched := false - for _, got := range status { - matched = matched || got == test.Expected.KernelToolStatus - } - if !matched { - t.Fatalf("no tool lifecycle reached %q: %#v", test.Expected.KernelToolStatus, status) - } - } - }) - } -} - -// Compile-time integration boundary: the Spike uses the actual Wails v3 API, -// but LineUp never grants a Surface a Wails binding. Any future production -// implementation must retain this separation. -func TestWailsApplicationBoundaryCompiles(t *testing.T) { - var options application.Options - if options.Name != "" { - t.Fatal("zero-value options changed unexpectedly") - } -} - -func project(envelope wireEnvelope) (kind, fallback string) { - if envelope.Version != 1 { - return "fallback", "unsupported_version" - } - switch envelope.Type { - case "lineup.v1.tool.call": - if stringValue(envelope.Payload["call_id"]) == "" || stringValue(envelope.Payload["tool"]) == "" { - return "fallback", "invalid_payload" - } - return "tool-call", "" - case "lineup.v1.tool.result": - if stringValue(envelope.Payload["call_id"]) == "" || stringValue(envelope.Payload["status"]) == "" { - return "fallback", "invalid_payload" - } - return "tool-result", "" - case "lineup.v1.ui.open", "lineup.v1.ui.patch", "lineup.v1.ui.close": - for _, forbidden := range []string{"bundle", "bundle_id", "bundle_url", "url", "html", "css", "javascript", "js", "script", "source"} { - if _, exists := envelope.Payload[forbidden]; exists { - return "fallback", "invalid_payload" - } - } - return "surface", "" - case "lineup.v1.app.call": - if stringValue(envelope.Payload["call_id"]) == "" || stringValue(envelope.Payload["capability"]) == "" || stringValue(envelope.Payload["reason"]) == "" || stringValue(envelope.Payload["expires_at"]) == "" { - return "fallback", "invalid_payload" - } - if _, ok := envelope.Payload["arguments"].(map[string]interface{}); !ok { - return "fallback", "invalid_payload" - } - return "app-call", "" - default: - return "fallback", "unsupported_type" - } -} - -func stringValue(value interface{}) string { - text, _ := value.(string) - return text -} diff --git a/程序文件清单与功能说明.md b/程序文件清单与功能说明.md new file mode 100644 index 0000000..bc4dd8b --- /dev/null +++ b/程序文件清单与功能说明.md @@ -0,0 +1,159 @@ +# LineUp App 程序文件清单与功能说明 + +> 最后核验:2026-08-04(Asia/Shanghai) +> +> 本文是 `lineup-app/` 的客户端源码导航,覆盖 Tauri/Desktop Host、Web Reference Host、 +> `LineUpRuntime`、Interact MiniApp(当前兼容名 Chat)与客户端测试。跨项目的 AppServer、Hermes Adapter、 +> WuKongIM 和运行配置见[根程序文件清单](../程序文件清单与功能说明.md)。 + +## 1. 当前客户端怎样运行 + +LineUp 不是“聊天页面直接连 Agent”。用户首先打开的是一个运行外壳:正式交付时是 Tauri +桌面窗口,开发和联调时可在浏览器中打开 Web Reference Host。两种外壳都会启动同一个 +Runtime;Runtime 再加载默认 Chat,并负责 Chat 与 AppServer / Remote Agent 之间的全部 +通信。 + +```text +Tauri Desktop Host / Web Reference Host + → main.ts(启动时把 Host、Runtime 与默认 App 接起来) + → LineUpRuntime + → CoreAppRegistry → chat Core App + → ChatRuntimeSDK + → AppServer / Remote Agent +``` + +当前唯一的客户端实现与验收基线是 Tauri 2 Desktop Host + Web Reference Host。两种 Host +复用同一份 TypeScript Runtime 与 Chat Core App,不是两套客户端。`main.ts` 只在启动时把 +Host、`LineUpRuntime` 和默认 App 接起来;网络传输、消息存储、同步循环、待发送队列、 +scope 路由和旧 `lineup.v1` 兼容都由 Runtime 统一管理。 + +## 2. 工作区顶层文件 + +| 文件 / 目录 | 功能 | +|---|---| +| `README.md` | LineUp App 工作区入口、当前实现摘要、开发与文档导航。 | +| `程序文件清单与功能说明.md` | 本文:客户端源码与测试导航。 | +| `APP架构设计.md` | LineUp App、Runtime、MiniApp SDK、Surface/Capability 安全、发布约束和后续路线。 | +| `tauri/` | Tauri Desktop Host 与 Web Reference Host 的工程目录。 | + +## 3. Tauri/Web 工程入口 + +目录:`tauri/` + +| 文件 / 目录 | 功能 | +|---|---| +| `package.json` | Vite、Vitest、Tauri CLI 与开发/构建脚本。 | +| `vite.config.ts` | Vite/Vitest 的 `@/ → src/` 源码别名。 | +| `tsconfig.json` | TypeScript 严格检查与同一 `@/` 路径别名。 | +| `index.html` | Vite 页面入口。 | +| `scripts/tauri.sh` | Tauri/Rust 工具链包装脚本。 | +| `src/` | TypeScript Host、Runtime、Core App 与测试。 | +| `src-tauri/` | Tauri Rust Host、窗口配置、能力声明和图标。 | +| `README.md` | Tauri/Web Host 的运行、构建、回归和历史里程碑。 | + +## 4. TypeScript Runtime 与 Chat Core App + +目录:`tauri/src/`。完整依赖方向见 [src/README.md](tauri/src/README.md)。 + +### 4.1 Host 组合与 Chat Core App + +| 文件 / 目录 | 功能 | +|---|---| +| `main.ts` | 应用启动装配入口:创建 Runtime,并请求通用 `RuntimeAppHost` 根据 App Registry 挂载默认 Core App。它不直接导入 Chat Renderer,也不拥有网络传输、消息存储、同步循环或待发送队列。 | +| `core-apps/chat/chat-shell.ts` | Chat Core App 的可信 DOM Shell。 | +| `core-apps/chat/chat-app-host.ts` | Chat Core App 的 Host 装配器:集中加载 Chat 样式、Shell、Renderer、DOM 上下文和 Chat SDK,避免这些细节进入 `main.ts`。 | +| `core-apps/chat/trusted-dom-renderers.ts` | 已验证消息到可信 DOM:Markdown、消息、交互卡、任务、执行摘要、Surface、Capability、Artifact 与 fallback。 | +| `core-apps/chat/renderer-registry.ts` | 按受信任 `ConversationItem.kind` 分派 Chat Renderer。 | +| `core-apps/chat/styles/` | Chat Shell、能力卡片、执行摘要样式。 | + +### 4.2 App 管理、SDK 与 Runtime 协调 + +| 文件 | 功能 | +|---|---| +| `runtime/coordination/lineup-runtime.ts` | Runtime 唯一协调器:持有 Transport、Conversation Store、sync loop、outbox、scope 筛选、`lineup.v1` 兼容、可靠 App Inbox 与 Chat SDK 投影。 | +| `runtime/app-management/app-registry.ts` | 本地 Core App Registry;当前静态注册默认/恢复 `chat`。 | +| `runtime/app-management/app-sdk.ts` | 当前 Chat/IM Runtime SDK:Core App 与 Runtime 之间的受限接口;提供状态/消息订阅、Inbox ACK、Runtime Action、Tool/Task 投影。Runtime 通过 `openApp(app_scope)` 选择对应 SDK。 | +| `runtime/app-management/runtime-app-host.ts` | 从 Runtime App Registry 解析默认 App,再通过 Core App Host Registry 找到对应装配器并挂载可信 Core App。 | +| `runtime/coordination/interaction-kernel.ts` | 无副作用协议入口:decode、重复抑制、Agent 状态、Tool/Task 投影。 | +| `runtime/coordination/runtime-envelope.ts` | 内部 `RuntimeEnvelope`、`app_scope + conversation_id` 校验及旧协议作用域映射。 | +| `runtime/coordination/tool-call-state.ts` | `choice / confirm / input` 交互状态机。 | +| `runtime/coordination/task-state.ts` | `agent.progress` 按 `operation_id` 聚合。 | +| `runtime/coordination/execution-progress-state.ts` | 按 `execution_id` 关联 ACP 实时步骤。 | + +### 4.3 通信、持久化与协议 + +| 文件 | 功能 | +|---|---| +| `runtime/communication/transport-adapter.ts` | HTTP Transport:登录、发送、同步、超时和响应校验。 | +| `runtime/persistence/conversation-store.ts` | 会话持久化:cursor、消息、typed outbox、App Inbox、Tool/Task、摘要、Surface/Capability 快照。 | +| `runtime/protocol/lineup-v1.ts` | LineUp v1 受限协议模型与解析边界。 | +| `runtime/protocol/golden/lineup-v1.json` | `lineup-v1-golden-1` Runtime compatibility fixture。 | + +### 4.4 Surface、能力、Artifact 与 Inventory + +| 文件 | 功能 | +|---|---| +| `runtime/surfaces/surface-registry.ts` | 开发期 Surface Manifest 与本地启用注册表;仅精确 app id/version 可用。 | +| `runtime/surfaces/surface-instance-manager.ts` | `open / ready / patch / close / restore` 的纯状态生命周期、幂等与冲突拒绝。 | +| `runtime/surfaces/isolated-surface-host.ts` | opaque-origin iframe 宿主;`sandbox="allow-scripts"`、严格 CSP、仅受限 bridge。 | +| `runtime/surfaces/production-surface-manifest.ts` | 生产 Manifest:immutable artifact、版本、大小、SHA-256、Ed25519、key id、最小 Host、权限 allowlist。 | +| `runtime/surfaces/production-surface-policy.ts` | production admission policy:只允许已验证的精确 app/version 创建实例。 | +| `runtime/surfaces/surface-bundle-cache.ts` | HTTPS 下载、Ed25519 验签、大小/SHA-256 校验、verified cache、失效与回滚。 | +| `runtime/inventory/client-inventory.ts` | 生成 revisioned `lineup.v1.client.inventory`。 | +| `runtime/capabilities/capability-registry.ts` | 能力声明、风险等级、可见性与参数 schema 校验。 | +| `runtime/capabilities/capability-call-state.ts` | 能力调用状态机:`pending → approved → executing → terminal`。 | +| `runtime/capabilities/capability-executor.ts` | 调用 Host handler,并将结果归约为安全确定的 `app.result`。 | +| `runtime/capabilities/capability-audit.ts` | 最小审计:call id、能力、风险、处置、时间;不保存敏感参数。 | +| `runtime/artifacts/artifact-state.ts` | Artifact 元数据状态:名称、MIME、大小、完整性。 | +| `runtime/artifacts/artifact-content-cache.ts` | Host-owned 易失内容缓存;内容不进入 Conversation Store。 | + +## 5. Tauri Rust Host + +目录:`tauri/src-tauri/` + +| 文件 | 功能 | +|---|---| +| `src/main.rs` | Tauri 桌面二进制入口。 | +| `src/lib.rs` | Tauri 应用构造与运行库入口。 | +| `tauri.conf.json` | 窗口、应用元数据与构建配置。 | +| `capabilities/default.json` | Tauri 权限声明;当前只保留核心默认能力。 | +| `Info.plist` | macOS WebView / ATS 兼容配置。 | +| `build.rs` | Tauri/Rust 构建脚本入口。 | + +## 6. 客户端测试与构建 + +各 `src/runtime/**` 模块的 `.test.ts` 与实现同目录放置,主要覆盖 Runtime/SDK、协议与 +Kernel、Store/App Inbox、Tool Call、Task、执行摘要、Surface、Capability、Artifact、 +生产 Bundle 与 golden fixture。 + +`runtime/coordination/lineup-runtime.test.ts` 覆盖 MVP-R1 的 Core App Registry、scope +筛选、`lineup.v1` 兼容、App Inbox 恢复、去重和 Runtime Action 边界; +`runtime/app-management/runtime-app-host.test.ts` 验证默认 Chat 由 Runtime Registry 加载。 + +```bash +cd lineup-app/tauri + +# Runtime / SDK / Renderer 自动化回归 +npm test -- --run + +# TypeScript 检查与 Web production build +npm run build + +# Web Reference Host(Tailscale 开发) +npm run web:dev + +# Tauri Desktop Host +npm run desktop:dev +``` + +当前验证基线:23 个测试文件、99 个测试通过,`npm run build` 通过。 + +## 7. 相关文档 + +- [工作区 README](README.md):产品基线、MVP-R1、开发入口与文档导航。 +- [迭代基线与目标](迭代/00.base.md):`00.base` 已实现内容和 `01.kernel` Runtime Kernel 目标。 +- [APP 架构设计](APP架构设计.md):LineUp App、Runtime、MiniApp SDK、发布安全与后续阶段。 +- [Tauri/Web 源码导航](tauri/src/README.md):目录边界与依赖方向。 +- [Tauri/Web Host README](tauri/README.md):Host 运行、构建、行为回归与历史记录。 +- [根程序文件清单](../程序文件清单与功能说明.md):AppServer、Hermes、WuKongIM 和运行配置。 +- [App 最终设计方案](../设计/02.正式方案/app_final_design.md):架构决策的唯一汇总入口。 diff --git a/迭代/00.base.md b/迭代/00.base.md new file mode 100644 index 0000000..01d038b --- /dev/null +++ b/迭代/00.base.md @@ -0,0 +1,124 @@ +# LineUp App 迭代基线:Runtime 托管 Chat + +**迭代编号:** 00.base +**状态:** 已实现、已验证 +**日期:** 2026-08-04 +**定位:** 后续迭代必须保持的客户端基础能力 + +## 1. 基线说明 + +本迭代建立了 LineUp Runtime 承载第一个 Core App 的最小闭环。当前实现中的 `chat` 是 +图文 IM 形态的应用;在后续设计中,它将演进为 `Interaction App` 的 IM 模式,但现阶段 +仍保留 `app_scope = "chat"` 作为代码和协议兼容名称。 + +当前客户端基线是: + +```text +Tauri 2 Desktop Host + Web Reference Host +``` + +两种 Host 运行同一份 TypeScript Runtime 和 Core App:Tauri 用于正式桌面交付,Web 用于 +浏览器开发、Tailscale 联调和自动化验证。它们不是两套客户端,也不各自实现通信、同步或 +消息存储。 + +## 2. 已实现的 Runtime 边界 + +`LineUpRuntime` 是以下对象的唯一所有者: + +```text +Transport +Conversation Store +sync loop +outbox +App Inbox +作用域筛选 +旧 lineup.v1 兼容 +去重与恢复 +``` + +Chat 不能直接访问 AppServer、Transport、Store、登录态或 Host 特权能力,只能使用 +`ChatRuntimeSDK`: + +```text +Runtime + → 校验、持久化和筛选 Agent 消息 + → 投递 app_scope + conversation_id 匹配的消息 + → 通过 SDK 提供订阅、Inbox、ACK 和 Runtime Action + → 接收 Chat 的用户动作并进入 outbox +``` + +## 3. 已实现的应用加载关系 + +当前已经将“默认应用选择”和“应用具体挂载”分开: + +```text +CoreAppRegistry + → 选择默认 app_scope = chat + +CoreAppHostRegistry + → 找到 chat 对应的 Host 装配器 + +chat-app-host.ts + → 加载 Chat Shell、样式、Renderer、DOM Context 和 SDK +``` + +`main.ts` 现在只负责启动 Runtime、创建 Host 绑定和请求挂载默认 App,不再直接导入 +Chat 的样式、Renderer 或 Shell。 + +相关实现: + +- `tauri/src/runtime/coordination/lineup-runtime.ts` +- `tauri/src/runtime/app-management/app-registry.ts` +- `tauri/src/runtime/app-management/runtime-app-host.ts` +- `tauri/src/core-apps/chat/chat-app-host.ts` +- `tauri/src/main.ts` + +## 4. 已实现的消息和交互能力 + +当前基线已经覆盖: + +- 登录、退出和会话恢复; +- HTTP 增量同步与包含式 cursor; +- 用户消息本地回显、发送状态和 outbox; +- Agent Markdown 消息和安全 DOM 渲染; +- Agent status、progress、error 和执行摘要; +- `choice`、`confirm`、`input` Tool Call 的状态机和可信交互卡片; +- Tool Result / Tool Cancel 回声; +- Surface 生命周期的基础管理; +- Capability Registry、确认、受限 Host 执行和审计; +- Artifact 元数据校验与 Host 临时内容缓存; +- App Inbox 的持久化、恢复、ACK 和 `message_id` 去重。 + +所有入站内容必须先经过 Runtime 协议和作用域校验。非法或不匹配的消息不得进入 Chat, +未知内容只能安全降级,不能执行远端脚本或系统能力。 + +## 5. 基线验证 + +```text +npm test -- --run +21 个测试文件通过 +94 个测试通过 + +npm run build +TypeScript 检查通过 +Vite production build 通过 +``` + +## 6. 本基线的边界 + +以下能力尚未作为本迭代的完整实现: + +```text +动态 App Registry +App 安装、更新、回滚和移除 +App Instance Manager +App Focus Manager +Runtime Tool Router +Interaction App 的 IM / Audio / Video 模式统一运行时 +扩展 App 的前后台切换和恢复 +完整应用市场 +``` + +这些内容属于 [01.kernel.md](01.kernel.md) 定义的下一阶段目标。`00.base` 的意义是保护 +已经完成的通信、可靠投递、作用域和安全边界,后续重构不得让这些能力回到 App 或 +`main.ts` 中。 diff --git a/迭代/01.kernel.md b/迭代/01.kernel.md new file mode 100644 index 0000000..da6e2f5 --- /dev/null +++ b/迭代/01.kernel.md @@ -0,0 +1,293 @@ +# LineUp App 迭代目标:Runtime Kernel 与应用编排 + +**迭代编号:** 01.kernel +**状态:** 目标设计,待实现 +**日期:** 2026-08-04 +**前置基线:** [00.base.md](00.base.md) + +## 1. 迭代目标 + +本迭代要把 LineUp 从“Runtime 加载一个 Chat 页面”推进为“Runtime 管理一个应用工作区”。 +Runtime 不仅负责网络、消息和存储,还要负责应用实例、工具路由、前后台焦点和恢复; +`Interaction App` 则负责用户与 Agent 的核心交互体验。 + +目标结构: + +```text +LineUp Runtime +│ +├── Runtime Shell / Desktop +│ ├── 应用启动 +│ ├── 应用切换 +│ ├── 前后台与焦点 +│ ├── 通知和安全恢复 +│ └── Host 挂载协调 +│ +├── Interaction App(Core App) +│ ├── IM Mode:文字、图片、短消息 +│ ├── Audio Mode:实时语音 +│ ├── Video Mode:实时视频 +│ └── 标准交互原语:choice / confirm / input +│ +└── Installed / Extension Apps + ├── Whiteboard + ├── Draw-and-Guess + ├── Task Dashboard + └── 其他可安装应用 +``` + +当前代码中的 `chat` 继续作为兼容实现名称,产品概念上将其定位为: + +```text +Interaction App 的 IM 实现 +``` + +后续是否将目录和作用域从 `chat` 正式迁移到 `interaction`,另行作为命名迁移任务处理, +不与本迭代的运行时编排重构混在一起。 + +## 2. 核心职责分工 + +### 2.1 LineUp Runtime + +Runtime 是整个应用工作区的底层协调中心,拥有最终调度和安全决策权: + +- 管理 App Registry、App Instance 和焦点栈; +- 校验 Agent Tool Call、Manifest、Inventory、参数和作用域; +- 决定调用应直接执行、启动 App、切换前台、等待用户交互还是创建异步任务; +- 维护 App 的启动、运行、后台、挂起、恢复、关闭和失败状态; +- 管理 Conversation、Store、App Inbox、outbox、权限和审计; +- 在扩展 App 结束或启动失败时恢复原来的前台实例。 + +### 2.2 Runtime Shell / Desktop + +Runtime Shell 是一个拥有特殊权限的内置工作区。它在产品体验上类似桌面或 Launcher, +但最终的生命周期和安全决策仍由 Runtime Core 管理。 + +它负责: + +- 把 App 实例挂载到 Tauri 或 Web Host; +- 显示当前前台 App; +- 管理应用切换、恢复和关闭; +- 提供全局连接状态、通知和安全恢复入口; +- 向 App 传递受限的 Host 能力。 + +普通 App 不能伪造 Runtime Shell,也不能直接修改焦点栈。 + +### 2.3 Interaction App + +Interaction App 是默认的人与 Agent 交互应用,但不是整个应用生态的调度器。 + +它负责: + +- 当前 Conversation 的主要交互体验; +- IM、Audio、Video 等交互模式; +- 选择框、确认框、输入框和进度卡片等标准交互原语; +- 显示普通 Agent 消息、任务、Tool 结果和扩展 App 的结果; +- 通过 SDK 提交用户动作。 + +它不负责: + +- 直接连接 AppServer 或 Agent; +- 决定其他 App 是否启动; +- 管理其他 App 的前后台状态; +- 直接执行未经 Runtime 授权的 Tool 或 Capability。 + +### 2.4 Extension App + +扩展 App 与 Interaction App 是 Runtime 上的平级应用。画板、你画我猜和任务面板拥有 +自己的页面、状态、Tool、Surface 和实例生命周期,但必须使用 Runtime SDK。 + +扩展 App 可以请求: + +```text +启动自己 +创建 Surface +进入前台 +进入后台 +提交结果 +请求关闭 +``` + +最终是否允许、如何持久化、是否需要用户确认,由 Runtime 决定。 + +## 3. 应用实例与焦点模型 + +App Registry 管理“有哪些应用”,App Instance Manager 管理“哪些应用正在运行”。两者 +不能混为一个状态。 + +```ts +type AppInstanceRecord = { + instance_id: string; + app_scope: string; + conversation_id?: string; + state: + | "starting" + | "foreground" + | "background" + | "suspended" + | "stopping" + | "stopped" + | "failed"; + parent_instance_id?: string; + started_at: string; + stopped_at?: string; + error?: string; +}; +``` + +Runtime 需要保存当前会话的焦点栈: + +```text +focus_stack: + interaction:audio-001 + draw-and-guess:game-001 + +foreground: + draw-and-guess:game-001 + +background: + interaction:audio-001 +``` + +应用切换不会自动删除原实例。原实例可能进入 `background` 或 `suspended`,在新应用关闭、 +失败或用户返回时恢复。 + +## 4. Tool 调度模型 + +Tool 调度权在 Runtime,不在 Interaction App。 + +```text +Agent Tool Call + → Runtime 校验 Envelope / Inventory / Manifest / 参数 / Scope + → Tool Router 判断处理方式 + → App Orchestrator 创建或切换 App Instance + → Focus Manager 调整前后台 + → Interaction App / Mode / Extension App 承接 + → App 通过 SDK 返回 progress / result / error + → Runtime 校验、持久化、审计并回传 Agent +``` + +Tool 的处理方式至少包括: + +```text +direct 直接由 Runtime 或受信 App 处理 +interactive 交给 Interaction App 等待用户选择/确认/输入 +launch 启动或唤醒一个扩展 App +foreground 要求目标 App 进入前台 +operation 创建长时间运行的异步任务 +``` + +Interact 可以请求 Runtime 启动扩展 App,但不能直接加载 Bundle、切换其他 App 或执行 +系统能力。 + +## 5. 典型场景:语音切换到你画我猜 + +开始时: + +```text +Runtime Shell + └── Interaction App + └── Audio Mode(foreground) +``` + +用户说:“我们来玩一局你画我猜吧。” Agent 发来启动请求后: + +```text +Agent launch(draw-and-guess) + → Runtime 检查 App 是否已安装、启用和兼容 + → 校验 Tool、Manifest、Inventory、参数和权限 + → 创建 draw-and-guess:game-001 + → 保存 interaction:audio-001 的焦点位置 + → Audio Mode 进入 background / suspended + → Draw-and-Guess 进入 starting → foreground +``` + +游戏完成后: + +```text +Draw-and-Guess App + → 通过 SDK 提交 game.result + → Runtime 持久化并回传 Agent + → 游戏实例关闭或挂起 + → Runtime 恢复焦点栈中的 Audio Mode + → Interaction App 显示游戏结果 +``` + +游戏和原来的语音交互使用同一个 `conversation_id`,但拥有不同的 `instance_id`。这样既 +能把结果归还给同一个 Agent 会话,也能独立管理每个应用实例。 + +## 6. 标准交互原语与扩展 App 的边界 + +以下内容属于 Interaction App 的内建能力,不需要安装独立 App: + +```text +choice +confirm +input +progress +error +``` + +例如 Agent 请求选择框: + +```text +Agent tool.call(choice) + → Runtime 校验并持久化 pending Tool Call + → Interaction App 在 IM 中显示 Choice Card + → 用户选择 + → Runtime 校验 call_id 并写入 outbox + → Choice Card 变为 submitted / completed,不再可操作 + → IM 时间线显示“你选择了……” +``` + +选择框可以从界面上退出可操作状态,但交互记录不能从 Store 中删除。这样才能支持刷新 +恢复、Agent 回声、幂等去重和审计。 + +画板、游戏和复杂任务面板则属于可安装扩展 App,拥有自己的 Tool 和 Surface。 + +## 7. 目标模块 + +```text +runtime/app-management/ +├── app-registry.ts +├── app-instance-manager.ts +├── app-focus-manager.ts +├── app-lifecycle-manager.ts +└── runtime-app-host.ts + +runtime/coordination/ +├── tool-router.ts +├── app-orchestrator.ts +└── interaction-orchestrator.ts + +core-apps/interaction/(当前由 core-apps/chat 兼容实现) +├── interaction-runtime.ts +├── interaction-shell.ts +├── interaction-mode-registry.ts +├── modes/im/ +├── modes/audio/ +├── modes/video/ +└── primitives/ + +installed-apps/ +├── whiteboard/ +├── draw-and-guess/ +└── task-dashboard/ +``` + +`RuntimeAppHost` 负责实际挂载和卸载;App Instance、Focus、Lifecycle 和 Tool Router 负责 +状态和决策。这样不会把所有业务规则重新堆回 `main.ts`。 + +## 8. 本迭代验收目标 + +```text +1. Runtime 能从 App Registry 选择默认 Interaction App。 +2. Runtime 能创建、前台化、后台化、挂起、恢复和关闭 App Instance。 +3. Agent 启动扩展 App 时,当前前台 App 能安全进入后台。 +4. 扩展 App 结束或失败后,Runtime 能恢复原来的焦点和交互模式。 +5. Tool 调用必须经过 Runtime 的 Inventory、Manifest、参数、作用域和权限校验。 +6. Interaction App 能处理 choice / confirm / input 等标准交互原语。 +7. 扩展 App 只能通过 SDK 返回结果,不能直接访问 Agent、Host DOM 或系统特权。 +8. 断线或重启后,App Instance、Tool Call、App Inbox、outbox 和焦点栈可以有界恢复。 +9. 00.base 中已经通过的 21 个测试文件、94 个测试和生产构建不能退化。 +``` diff --git a/迭代/03.sdk_and_coreapp.md b/迭代/03.sdk_and_coreapp.md new file mode 100644 index 0000000..92892e1 --- /dev/null +++ b/迭代/03.sdk_and_coreapp.md @@ -0,0 +1,733 @@ +# LineUp App 迭代定义:MiniApp SDK v1 与内置参考 MiniApp + +**迭代编号:** 03.sdk_and_coreapp +**状态:** 目标设计,待实现 +**日期:** 2026-08-04 +**前置基线:** [00.base.md](00.base.md)、[01.kernel.md](01.kernel.md) +**权威架构:** [APP架构设计.md](../APP架构设计.md) + +## 1. 迭代目标 + +本迭代不建设应用市场、服务端 Catalog、远程下载或第三方发布能力。它的目标是定义并实现 +**LineUp MiniApp SDK v1**,再用多个随 LineUp 开发版本内置的参考 MiniApp 验证这份 SDK。 + +```text +LineUp App + → LineUp Runtime + → LineUp MiniApp SDK v1 + → Interact MiniApp + → Task Dashboard MiniApp + → Whiteboard MiniApp +``` + +本迭代完成后,应能证明: + +```text +Runtime 能以同一套 SDK 语义承载系统级 MiniApp 与受限参考 MiniApp; +MiniApp 只能通过 SDK 收消息、接 Tool、报告进度/结果/错误、请求生命周期与 Surface; +Runtime 始终拥有通信、Tool、焦点、存储、权限和审计的最终决定权。 +``` + +这里的“内置”指 `bundled-development`:MiniApp 的 Manifest、代码和测试 fixture 随当前 +LineUp 开发 Host 提供。它们不是远程下载的 Bundle,也不等于已经开放的应用市场。 + +## 2. 本迭代的产品与信任模型 + +### 2.1 正确层级 + +```text +LineUp App 用户使用的主应用 +├── App Shell / Host 挂载、切换、通知、恢复、Host Provider +├── LineUp Runtime 通信、可靠性、MiniApp 调度和安全决策 +└── MiniApps 运行在 Runtime 上的功能单元 + ├── System MiniApp: Interact + ├── Bundled Reference: Task Dashboard + └── Bundled Reference: Whiteboard +``` + +Runtime 不是一个与 MiniApp 平级的产品 App;它是 LineUp App 内部提供给 MiniApp 的运行环境。 + +### 2.2 Interact 的定位 + +`Interact` 是第一个系统级 MiniApp,是用户与 Agent 的默认入口: + +```text +Interact +├── IM Mode:本迭代必须实现并适配 SDK +├── Audio Mode:只定义 SDK / 生命周期扩展位,不实现真实媒体能力 +├── Video Mode:只定义 SDK / 生命周期扩展位,不实现真实媒体能力 +└── IM 标准交互的默认 Renderer Provider:notice / choice / confirm / input +``` + +当前实现仍保持兼容名称: + +```text +产品概念:Interact MiniApp +当前 app_scope:chat +当前目录:tauri/src/core-apps/chat/ +``` + +本迭代不得把 `chat` 重命名为 `interact`,不得迁移目录或旧 `lineup.v1` scope;命名迁移另行 +定义,避免破坏已验证的通信与恢复行为。 + +### 2.3 相同语义,不同权限视图 + +所有 MiniApp 都遵守 MiniApp SDK v1 的同一语义;系统级与参考/已安装 MiniApp 的区别是来源和 +UI 容器,而不是是否可以绕过 Runtime。 + +| 能力 | Interact System MiniApp | Task Dashboard / Whiteboard | +|---|---:|---:| +| 读取自身 Inbox、ACK | 可以 | 可以 | +| 接收 Runtime Tool Call | 可以 | 可以 | +| 上报 progress / result / error | 可以 | 可以 | +| 请求前台、后台、关闭 | 可以,Runtime 决定 | 可以,Runtime 决定 | +| 请求 Surface / Capability | 可以,经 Policy | 可以,经 Policy | +| 使用可信内建 DOM | 可以 | Task Dashboard 可由受信 Host adapter 挂载;Whiteboard 必须走受限 Surface | +| 直接访问 Transport、Store、Agent | 不可以 | 不可以 | +| 直接访问 Tauri、Host DOM 根节点、任意网络 | 不可以 | 不可以 | +| 修改 Focus、Registry、其他 MiniApp 数据 | 不可以 | 不可以 | + +## 3. MiniApp SDK v1 契约 + +SDK 是 Runtime 根据 Manifest、实例状态、scope 和 Policy 注入的受限对象。SDK 中的所有写操作都 +是 **request**,不是对 Host、Store、Transport 或其他 MiniApp 的直接命令。 + +```ts +interface LineUpMiniAppSDK { + readonly context: MiniAppContext; + + readonly inbox: MiniAppInboxAPI; + readonly tools: MiniAppToolAPI; + readonly ui: StandardInteractionAPI; + readonly lifecycle: MiniAppLifecycleAPI; + readonly surfaces: MiniAppSurfaceAPI; + readonly capabilities: MiniAppCapabilityRequestAPI; +} + +type MiniAppContext = { + app_scope: string; + instance_id: string; + conversation_id: string; + app_version: string; + state: "starting" | "foreground" | "background" | "suspended"; +}; +``` + +`context` 由 Runtime 在创建实例时注入。MiniApp 不可传入、伪造或修改 `app_scope`、 +`instance_id`、`conversation_id`,也不可获得用户身份、Agent 身份、登录 token、AppServer +地址、Transport endpoint 或其他实例上下文。 + +### 3.1 Inbox API + +```ts +interface MiniAppInboxAPI { + list(after_message_id?: string): readonly MiniAppMessage[]; + subscribe(listener: (message: MiniAppMessage) => void): Unsubscribe; + acknowledge(message_id: string): boolean; +} +``` + +行为约束: + +```text +Agent / Runtime 消息 +→ Runtime 校验 Envelope、scope、目标 app_scope、instance 和去重 +→ 先持久化到该 MiniApp Inbox +→ SDK 投递已筛选消息 +→ MiniApp 成功接管后 ACK +→ Runtime 移除该可投递记录 +``` + +后台化、刷新、Surface 重载、断线和 Runtime 重启不得丢失未 ACK 的 Inbox 消息。MiniApp 只能 +列出和 ACK 自己 `app_scope + conversation_id + instance_id` 范围内的投递。 + +### 3.2 Tool API + +```ts +interface MiniAppToolAPI { + subscribe(listener: (call: MiniAppToolCall) => void): Unsubscribe; + get(call_id: string): MiniAppToolCall | undefined; + reportProgress(call_id: string, progress: MiniAppToolProgress): Promise; + complete(call_id: string, result: JsonObject): Promise; + fail(call_id: string, failure: MiniAppToolFailure): Promise; + cancel(call_id: string, reason?: string): Promise; +} +``` + +每一个调用都必须有 Runtime 持久化的通用记录: + +```ts +type RuntimeToolCallRecord = { + call_id: string; + tool_id: string; + inventory_revision: string; + app_scope: string; + instance_id?: string; + conversation_id: string; + status: + | "received" + | "routing" + | "waiting_for_app" + | "running" + | "submitted" + | "completed" + | "failed" + | "cancelled" + | "expired" + | "rejected"; + input: JsonObject; + result?: JsonObject; + error_code?: string; + created_at: string; + updated_at: string; +}; +``` + +`complete`、`fail`、`cancel` 绝不能直接发送网络请求。Runtime 必须验证: + +```text +call_id 属于当前 MiniApp instance +调用状态允许此迁移 +结果符合 ToolDescriptor.output_schema +调用不是重复终态 +调用仍属于当前 conversation / scope +MiniApp 和 Tool 仍处于启用状态 +``` + +验证成功后,Runtime 持久化记录和审计,再写入可靠 outbox 并回传 Agent。 + +### 3.3 Lifecycle API + +```ts +interface MiniAppLifecycleAPI { + requestForeground(): Promise; + requestBackground(): Promise; + requestClose(reason?: string): Promise; +} +``` + +MiniApp 只能请求,不能直接改变焦点或挂载其他 MiniApp: + +```text +MiniApp requestClose() +→ Runtime 检查 pending Tool、Surface、operation 和 Policy +→ 允许关闭或返回受控拒绝码 +→ 若关闭成功,Runtime 调整 focus stack +→ Runtime 恢复前一有效 foreground instance +``` + +### 3.4 标准交互 API + +SDK v1 通过 `sdk.ui` 提供由 **Runtime 管理的标准交互库**。这是一组低复杂度、可恢复、可审计的 +标准输入输出,不是 Interact 专有 API,也不是 MiniApp 取得 Host 弹窗权限的旁路。Runtime 创建、 +持久化和校验记录,并依据调用者、容器和 Shell Policy 选择实际呈现方式。 + +每次请求的 `owner` 必须由 Runtime 根据 SDK 已绑定的 `MiniAppContext` 自动注入;MiniApp 不能传入、 +伪造或覆盖 `app_scope`、`instance_id`、`conversation_id` 或 `surface_id`。调用者可选提供 +`parent_call_id`,但 Runtime 必须验证该调用属于当前实例且状态允许关联,不能将其作为可信 owner。 + +```ts +interface StandardInteractionAPI { + request( + request: StandardInteractionRequest, + options?: { + parent_call_id?: string; + presentation?: "default" | "inline" | "modal"; + expires_at?: string; + }, + ): Promise; + + get(interaction_id: string): StandardInteractionRecord | undefined; + subscribe(listener: (record: StandardInteractionRecord) => void): Unsubscribe; + cancel(interaction_id: string, reason?: string): Promise; +} + +type StandardInteractionRequest = + | NoticeRequest + | ChoiceRequest + | ConfirmRequest + | InputRequest; +``` + +第一版固定支持以下四种原语: + +| 原语 | 用途 | 用户结果 | +|---|---|---| +| `notice` | 安全显示只读提示、状态或下一步说明。 | `acknowledged`;也可由 Runtime 记录为只读。 | +| `choice` | 单选、多选或有限按钮选择。 | 有序且去重的 `action_ids`。 | +| `confirm` | 明确同意/取消一个可解释操作。 | `approved: boolean`。 | +| `input` | 受 schema 限制的文本、多行文本或数字输入。 | 按字段 ID 返回的结构化值。 | + +最低请求形态: + +```ts +type NoticeRequest = { + kind: "notice"; + title: string; + message: string; + acknowledge_label?: string; +}; + +type ChoiceRequest = { + kind: "choice"; + title: string; + prompt: string; + mode: "single-choice" | "multi-choice" | "button"; + actions: readonly { id: string; label: string; description?: string }[]; +}; + +type ConfirmRequest = { + kind: "confirm"; + title: string; + prompt: string; + approve_label?: string; + cancel_label?: string; +}; + +type InputRequest = { + kind: "input"; + title: string; + prompt: string; + fields: readonly { + id: string; + label: string; + type: "text" | "textarea" | "number"; + required?: boolean; + placeholder?: string; + min_length?: number; + max_length?: number; + minimum?: number; + maximum?: number; + }[]; + submit_label?: string; + cancel_label?: string; +}; +``` + +标准交互的 owner 路由与呈现策略如下: + +```text +Task Dashboard / Whiteboard 正在处理自身工作 +→ sdk.ui.request(choice / confirm / input / ..., { parent_call_id? }) +→ Runtime 从当前 SDK Context 注入不可伪造的 owner,并校验可选 parent_call_id、schema 与 Policy +→ Runtime 创建持久化 StandardInteractionRecord +→ Runtime / Shell 根据 owner 的有效容器选择受批准的标准 Renderer + ├── Agent 在 IM 中的提问:由 Interact 在时间线/卡片内呈现 + └── 前台 MiniApp 的通用事务提示:在该 MiniApp 自己有效的容器/Surface 中呈现标准 modal、sheet 或 card +→ 用户选择、确认、输入、取消或超时 +→ Runtime 持久化结果,仅投递回 owner MiniApp 的 Tool/Inbox +→ 原 MiniApp 决定 complete / fail / 继续 progress +``` + +`presentation` 只是调用偏好,不是强制指令;Runtime / Shell 可基于焦点、Surface、Policy 和可用 +Renderer 降级或拒绝。Agent 发来的 `lineup.v1.tool.call(choice | confirm | input)` 保持兼容:Runtime +创建同一类记录,其 owner 是 Interact 的 IM 上下文(`source = agent_tool`),并由 Interact 作为 +默认可信 IM Renderer Provider 呈现。它们不需要 MiniApp 提供父 Tool Call。 + +这意味着标准交互不会成为 MiniApp 直连用户界面或跨 App 操作的旁路: + +```text +MiniApp 不能在 Host DOM 注入任意弹窗、表单或远端 HTML +MiniApp 不能伪造 owner、interaction_id,或替其他 owner 查询、提交、取消结果 +Interact 不能直接把结果发送给 Agent +交互结果必须先回到 Runtime,再交给拥有该 owner 的 MiniApp +``` + +`notice` 是 SDK v1 新增的只读确认原语。Agent IM 与 MiniApp 发起的请求共享一套 +`StandardInteractionRecord`、状态机、持久化、过期、去重、owner 路由和标准 Renderer 协议, +但不要求共享一个 Interact 卡片实例或切换到 Interact。 + +`StandardInteractionRecord` 至少包含以下 Runtime 内部字段;其中 `owner` 只可由 Runtime 写入: + +```ts +type StandardInteractionOwner = { + app_scope: string; + instance_id: string; + conversation_id: string; + parent_call_id?: string; + surface_id?: string; + source: "agent_tool" | "miniapp_tool" | "runtime_policy"; +}; + +type StandardInteractionRecord = { + interaction_id: string; + owner: StandardInteractionOwner; + kind: "notice" | "choice" | "confirm" | "input"; + request: StandardInteractionRequest; + status: "pending" | "presented" | "submitted" | "completed" | "cancelled" | "expired" | "failed"; + result?: StandardInteractionResult; + created_at: string; + updated_at: string; + expires_at?: string; +}; +``` + +状态必须有界: + +```text +pending → presented → submitted → completed | cancelled | expired | failed +``` + +终态不可重新操作;刷新、断线和重启后可恢复尚未终态的可信卡片与未提交的 `input` 草稿。 + +### 3.5 Surface 与 Capability API + +SDK v1 定义请求语义,不直接授予 Host 权限: + +```ts +interface MiniAppSurfaceAPI { + requestOpen(request: MiniAppSurfaceRequest): Promise; + update(surface_id: string, patch: JsonObject): Promise; + requestClose(surface_id: string): Promise; +} + +interface MiniAppCapabilityRequestAPI { + request(request: { + capability: string; + reason: string; + arguments: JsonObject; + }): Promise; +} +``` + +Runtime 必须根据 Manifest、实例状态、Capability Policy、用户确认和 Host 支持情况决定是否 +执行。SDK v1 不开放: + +```text +fetch / 任意网络 +任意文件系统 +Tauri invoke +直接剪贴板、麦克风、摄像头 +Host DOM 根节点 +原始 AppServer / Agent 协议 +``` + +本迭代可定义 Capability Request 的类型、拒绝码、审计和测试,但不必为参考 MiniApp 开放真实 +麦克风、摄像头或文件选择权限。 + +## 4. Manifest、Tool Contract 与 Inventory + +### 4.1 MiniApp Manifest v1 + +本迭代冻结最小 Manifest 契约: + +```ts +type MiniAppManifest = { + app_scope: string; + version: string; + kind: "system" | "bundled-reference"; + tools: readonly ToolDescriptor[]; + subscriptions: readonly MiniAppSubscription[]; + requested_capabilities: readonly string[]; + host: { + min_version: string; + surface_required: boolean; + }; +}; +``` + +Manifest 是 Runtime 的输入,不是 MiniApp 可写状态;MiniApp 不可在运行时扩展 Tool、订阅或 +权限。动态安装、下载、更新、回滚和移除不属于本迭代。 + +### 4.2 Tool Descriptor v1 + +```ts +type ToolDescriptor = { + id: string; // 例如 task-dashboard.open + version: 1; + handling: "direct" | "interactive" | "launch" | "foreground" | "operation"; + target: { + app_scope: string; + requires_foreground: boolean; + restore_previous_focus: boolean; + }; + input_schema: JsonSchema; + output_schema: JsonSchema; + permissions?: readonly string[]; + timeout_ms?: number; +}; +``` + +第一版 JSON Schema 只支持可明确实现和测试的子集: + +```text +object / string / number / boolean / array +required +properties +additionalProperties = false +enum +minLength / maxLength +minimum / maximum +maxItems +``` + +禁止接受远端 schema 中的递归引用、正则执行、脚本、URL 加载、函数名或任意扩展关键字。 + +### 4.3 Revisioned Inventory + +Runtime 在 MiniApp 的启用状态、Manifest Tool 或可见 Capability 发生变化时,生成最小化且带 +revision 的 Inventory。Agent Tool Invoke 必须携带该 revision: + +```text +Agent Tool Invoke +→ Runtime 发现 inventory_revision 不匹配 +→ 拒绝,不投递给 MiniApp,不执行任何 Host 行为 +→ 以受控状态提示 Agent 使用最新 Inventory +``` + +本迭代可复用现有 `lineup.v1.client.inventory` 发送路径;AppServer 只需要像普通会话消息一样 +透明转发,不需要实现应用目录或下载 API。 + +### 4.4 通用 Tool 路由 + +```text +Agent Tool Invoke +→ Runtime 校验 Envelope、Inventory revision、ToolDescriptor、输入 schema、scope、MiniApp 状态和权限 +→ 创建 RuntimeToolCallRecord 与审计记录 +→ Tool Router 判定 direct / interactive / launch / foreground / operation +→ App Orchestrator 创建或复用 instance,调整 focus +→ SDK Tool Inbox 投递 +→ MiniApp 经 SDK 报告 progress / result / error +→ Runtime 校验 output schema,持久化,写 outbox,回传 Agent +``` + +`notice`、`choice`、`confirm`、`input` 必须映射为 Runtime 统一的 `interactive` Tool Call / +`StandardInteractionRecord`。Interact 负责 Agent/IM 情况下的默认可信渲染;其他 MiniApp 可在其 +有效容器中使用 Runtime 批准的同一标准 Renderer,不能因此保留 Chat 专用旁路或获得 Host 特权。 + +建议稳定拒绝码: + +```text +inventory_revision_mismatch +tool_not_advertised +tool_input_invalid +tool_output_invalid +app_disabled +app_instance_not_found +app_scope_mismatch +lifecycle_denied +capability_denied +call_already_final +``` + +## 5. 内置参考 MiniApp 工作定义 + +本迭代通过三个内置 MiniApp 覆盖 SDK v1 的不同能力面。它们不是应用市场候选,也不要求完整 +业务功能;每个 MiniApp 只实现足以验证 SDK 契约的最小真实闭环。 + +### 5.1 Interact:系统级 MiniApp 样本 + +**身份:** `kind = system`;当前兼容 `app_scope = chat`。 +**本迭代交付:** IM Mode 适配到 MiniApp SDK v1。 + +| 范围 | 工作定义 | +|---|---| +| Inbox | 使用通用 `sdk.inbox` 订阅、恢复和 ACK;不再依赖 Chat 特有投递语义。 | +| 用户消息 | 保留 `conversation.sendText` 这一系统级扩展,但它必须进入 Runtime Action / outbox。 | +| 标准原语 | 作为 Agent/IM 请求的默认可信 Renderer Provider,在时间线/卡片内呈现 Runtime 投递的标准交互记录。 | +| 交互边界 | 不拥有交互记录,也不直接向 Agent 回传结果;结果必须由 Runtime 按 owner 路由。其他 MiniApp 可在自身有效容器使用 Runtime 批准的标准 Renderer。 | +| Tool 结果 | 用户完成、取消或超时后调用 `sdk.tools.complete/fail/cancel`,由 Runtime 回传。 | +| Mode | 明确 IM 的 `mode = im` Context;仅定义 Audio/Video 入口和恢复语义,不启用真实媒体。 | +| 焦点 | 被参考 MiniApp 覆盖时接受 Runtime 的 background/suspended;不能自行切换回前台。 | + +**不在范围:** 语音录制、视频通话、摄像头、独立 Audio/Video MiniApp、改名 `chat → interact`。 + +### 5.2 Task Dashboard:Tool 与生命周期样本 + +**身份:** `kind = bundled-reference`,`app_scope = task-dashboard`。 +**目标:** 验证 MiniApp Tool、progress/result/error、instance、focus 和恢复的最小闭环。 + +最小 Tool: + +```text +task-dashboard.open + handling = launch + 输入:task_id、title、可选初始状态 + 输出:status = completed | cancelled | failed + +task-dashboard.update + handling = operation + 输入:task_id、进度或状态 + 输出:当前任务摘要 +``` + +最小用户体验: + +```text +Agent 调用 task-dashboard.open +→ Runtime 创建 task-dashboard instance +→ Interact 进入 background +→ Shell 挂载 Task Dashboard +→ MiniApp 展示任务标题、进度、当前状态和关闭动作 +→ MiniApp 用 sdk.tools.reportProgress / complete / fail 上报 +→ Runtime 持久化、回传 Agent、关闭实例并恢复 Interact +``` + +Task Dashboard 的业务状态必须通过 SDK Tool / Inbox 获得;不得自行访问 AppServer、Store 或 +Agent。它可以使用随 LineUp 发布的受信 Host adapter,但 adapter 只做渲染装配,不可成为 +Transport 或 Tool Router 的第二实现。 + +Task Dashboard 必须至少使用一次 `sdk.ui.request`:例如在关闭未完成任务前请求 `confirm`,或需要 +任务说明时请求 `input`。Runtime 必须从该实例注入 owner,并在 Task Dashboard 当前有效容器中使用 +受批准的标准 modal、sheet 或 card;结果经 Runtime 返回该实例,期间不得强制切换到 Interact。 + +### 5.3 Whiteboard:隔离 Surface 样本 + +**身份:** `kind = bundled-reference`,`app_scope = whiteboard`。 +**目标:** 验证 SDK Surface Bridge、隔离执行、状态 patch、Artifact 元数据与恢复。 + +最小 Tool: + +```text +whiteboard.open + handling = launch + 输入:board_id、title、可选初始画布状态 + 输出:status、artifact_id(可选) + +whiteboard.submit + handling = operation + 输入:board_id、提交请求 + 输出:artifact_id 或结构化画布摘要 +``` + +最小用户体验: + +```text +Agent 调用 whiteboard.open +→ Runtime 校验已注册 bundled-reference Manifest +→ Runtime 创建 whiteboard instance 并请求受限 Surface +→ Host 挂载 opaque-origin iframe / Surface Bridge +→ Whiteboard 仅通过 Bridge SDK 请求状态更新和提交结果 +→ Runtime 校验 patch / Artifact 元数据并持久化 +→ App 完成或失败,Runtime 关闭 Surface、恢复 Interact +``` + +Whiteboard 不需要在本迭代实现多人协作、任意网络同步或完整绘图工具;重点是证明隔离 Surface +不能读取 Host DOM、Tauri、认证状态或其他 MiniApp 数据。 + +Whiteboard 如需要低复杂度的事务提示(例如“确认提交白板?”),可通过 `sdk.ui` 请求标准交互。 +但画板内的文字编辑、画笔与颜色选择、拖放、工具栏、上下文菜单等高频或私有业务 UI 必须保留在 +Whiteboard 自己的 Surface 中,不能被错误抽象为 Runtime 标准交互。 + +## 6. Runtime 与 Host 的实现模块 + +本迭代预计在现有目录中演进,不重建并行 Runtime: + +```text +tauri/src/runtime/ +├── app-management/ +│ ├── miniapp-manifest.ts # Manifest v1、bundled-reference 注册 +│ ├── miniapp-sdk.ts # SDK v1 公共类型与受限视图 +│ ├── miniapp-tool-call-store.ts # 通用 Tool Call 持久化/恢复 +│ └── runtime-app-host.ts # 按 foreground instance 挂载/卸载 Host +│ +├── coordination/ +│ ├── tool-router.ts # revision、schema、路由决策 +│ ├── miniapp-tool-orchestrator.ts # Tool → instance → SDK Inbox +│ └── standard-interaction-service.ts # owner 注入、记录、路由、恢复与呈现 Policy +│ +├── inventory/ +│ └── client-inventory.ts # revisioned Tool Inventory +│ +└── persistence/ + └── conversation-store.ts # Tool Call、Inbox、workspace 有界恢复 + +tauri/src/ +├── core-apps/chat/ # Interact IM 的兼容实现 +│ └── standard-interaction-im-renderer.ts # Agent/IM 的默认标准交互 Renderer +├── core-apps/task-dashboard/ # bundled-reference Tool/lifecycle 样本 +└── core-apps/whiteboard/ # bundled-reference Surface/Bridge 样本 +``` + +若目录名称最终改为 `miniapps/`,应单独进行机械迁移;本迭代优先保证 Runtime 边界和 SDK +兼容,不能让命名迁移扩大风险。 + +## 7. 不在本迭代范围 + +以下内容必须明确排除: + +```text +服务端 App Catalog 或应用清单 API +远程 Manifest / Bundle 下载 +安装、更新、回滚、卸载 UI +第三方开发者发布、账号、审核、评分、支付或搜索 +任意网络 API +真实麦克风、摄像头、文件选择或系统通知授权 +多人白板、实时游戏、完整语音/视频通话 +chat / interaction 命名和协议 scope 迁移 +``` + +这些能力将在 MiniApp SDK 与参考 MiniApp 完成验证后,作为独立的应用分发和市场阶段推进。 + +## 8. 实施步骤 + +1. **冻结契约与 golden fixtures** + - 定义 `MiniAppManifest v1`、`ToolDescriptor v1`、`MiniAppContext`、通用 Tool Call、 + Result/Progress/Error、Standard Interaction、SDK 稳定错误码; + - 为合法、重复、过期 revision、非法 schema、scope 不匹配和终态重复建立 fixture; + - 明确旧 `lineup.v1.tool.call` 的 `choice/confirm/input` 到 `interactive` Tool 的兼容映射, + 并为 `notice`、owner 自动注入、MiniApp 发起的 choice/confirm/input、取消、超时和重启恢复建立 fixture。 + +2. **实现 Runtime 通用 Tool 闭环** + - 将 Inventory revision、输入/输出 schema、Tool Call Record、审计和 outbox 接入 Runtime; + - 让 `ToolRouter` 和 `AppOrchestrator` 根据 ToolDescriptor 创建/复用实例并投递 SDK Tool Call; + - 完成 Tool、Inbox、workspace 的断线与重启恢复。 + +3. **适配 Interact IM** + - 以 `sdk.inbox` 和 `sdk.tools` 替换 Chat SDK 中的专用交互旁路; + - 实现 Agent/IM 标准交互的默认 Renderer Provider,保持 Markdown、消息、notice、choice、confirm、input、Task 和现有回归行为; + - 固化 IM Mode Context 与被覆盖/恢复的生命周期语义。 + +4. **实现 Task Dashboard 参考 MiniApp** + - 注册 bundled-reference Manifest 和两个最小 Tool; + - 实现启动、进度、结果、错误、关闭、回焦和重启恢复; + - 使用真实 SDK,不测试用 Runtime 内部对象直连。 + +5. **实现 Whiteboard 参考 MiniApp** + - 注册 bundled-reference Manifest、最小 Tool 和受限 Surface; + - 验证 Bridge、patch、Artifact 元数据、关闭、失败和恢复; + - 验证隔离拒绝路径与 Capability Request 拒绝路径。 + +6. **端到端验收与文档回填** + - 运行完整单元测试和生产构建; + - 通过 Tauri/Web Reference Host 完成代表性浏览器验收; + - 将最终 SDK 形态与参考 MiniApp 结果回填 [APP架构设计.md](../APP架构设计.md)。 + +## 9. 验收目标 + +```text +1. Runtime 向每个 MiniApp instance 注入不可伪造、范围受限的 MiniAppContext。 +2. Interact、Task Dashboard、Whiteboard 均通过 SDK 获取 Inbox、Tool 和生命周期能力; + 不直接访问 Transport、Store、Agent 或 Host 特权。 +3. Manifest Tool 具有稳定 ID、输入/输出 schema、handling、目标 scope 和版本。 +4. Runtime 只接受当前 Inventory revision 中、输入 schema 合法的 Tool Invoke。 +5. Runtime 拒绝未知 Tool、过期 revision、非法输入/输出、scope 不匹配、禁用 MiniApp、 + 错误 instance 和重复终态;拒绝请求不得到达 MiniApp 或 Host。 +6. Interact 的 notice / choice / confirm / input 通过统一 Standard Interaction 在 IM 中完成、恢复和回声, + 不退化。 +7. Runtime 从 SDK-bound MiniAppContext 自动注入不可伪造的 interaction owner;可选 parent Tool + 引用必须归属于当前实例且通过验证。结果只能经 Runtime 返回 owner,不能直接发给 Agent 或其他 MiniApp。 +8. Task Dashboard 可验证 launch → foreground → progress → 标准交互 → result/error → close → + Interact 恢复;标准交互在其有效容器呈现且不触发焦点切换。 +9. Whiteboard 可验证隔离 Surface open → patch → submit/close,并不能访问 Host DOM/Tauri/token; + 画板内的文字编辑等私有高频 UI 不通过 `sdk.ui` 路由。 +10. MiniApp 的 progress/result/error 经 Runtime schema 校验、持久化、审计和 outbox 后才回传 Agent。 +11. 断线或重启后,pending Tool、Standard Interaction、MiniApp Inbox、实例、焦点和 Surface 以有界方式恢复;中断操作 + 不得伪装为完成。 +12. 不新增 AppServer Catalog、下载或市场接口;现有服务端只透明转发会话/Inventory 消息。 +13. 00.base 和 01.kernel 中的登录、同步、本地回显、Markdown、安全 fallback、Tool Call、 + Task、Surface、Capability、App Inbox、outbox 和焦点恢复测试不退化。 +14. `npm test -- --run`、`npm run build`、`git diff --check` 通过;浏览器验收无未处理错误。 +``` + +## 10. 完成定义 + +本迭代完成不是“已经有应用市场”,也不是“完成全部 MiniApp 业务功能”。完成的判断是: + +```text +LineUp Runtime 已提供经过类型、schema、scope、Inventory revision、生命周期、权限、持久化和 +审计约束的 MiniApp SDK v1; + +Interact、Task Dashboard、Whiteboard 已以不同信任和 UI 形式使用同一套 SDK 语义,证明 +LineUp 可在不增加旁路通信或 Host 特权泄漏的前提下承载系统级与受限 MiniApp。 +``` diff --git a/迭代/03.sdk_and_coreapp_comment.md b/迭代/03.sdk_and_coreapp_comment.md new file mode 100644 index 0000000..763cb6c --- /dev/null +++ b/迭代/03.sdk_and_coreapp_comment.md @@ -0,0 +1,287 @@ +# `03.sdk_and_coreapp` 设计评审记录 + +> 评审日期:2026-08-05 +> 评审基线:[APP架构设计.md](../APP架构设计.md) +> 评审对象:[03.sdk_and_coreapp.md](03.sdk_and_coreapp.md) +> 评审方式:独立子 agent 只读评审;本文件记录评审意见,不代表已采纳或已实现。 + +## 1. 总体结论 + +`03.sdk_and_coreapp` 的主方向已经与架构基线基本一致,以下核心决策已正确写入: + +- 产品层级保持为 **LineUp App → LineUp Runtime → MiniApps**;Runtime 没有被写成与 MiniApp 平级的 App。 +- SDK 使用 `sdk.ui`,不再使用旧的 `sdk.interactions`。 +- 标准交互属于 Runtime 提供的标准输入输出库;Runtime 负责记录、owner 注入、校验、恢复、审计、路由和呈现 Policy。 +- `owner` 不是 MiniApp 调用参数,而是从 SDK 已绑定的 `MiniAppContext` 自动注入;MiniApp 不能伪造或访问其他 owner 的交互。 +- Interact 是 Agent/IM 场景的默认可信 Renderer Provider,不拥有交互记录,也不直接把结果发送给 Agent。 +- Task Dashboard 可验证 `sdk.ui` 的通用事务提示,且不应因提示强制切换到 Interact。 +- Whiteboard 的文字编辑、画笔、颜色选择、拖放、工具栏等高频或私有 UI 留在自身 Surface,不走 Runtime 标准交互。 +- 保留 `lineup.v1.tool.call(choice | confirm | input)` 兼容路径;不把 `chat → interact` 命名迁移放进本迭代。 +- 本迭代不建设 AppServer Catalog、下载、安装、更新、市场、远程 Bundle 或真实媒体/文件能力。 + +但仍存在 **1 个阻塞级信任模型冲突**,以及若干会造成实现分叉或验收不可判定的重要契约缺口。建议在进入实现前完成收敛。 + +## 2. 阻塞问题 + +### B1. Task Dashboard 的可信 DOM / Host adapter 权限突破了当前信任模型 + +**架构基线**在 [APP架构设计.md](../APP架构设计.md) 的 MiniApp 信任模型中明确: + +- `Interact / System MiniApp` 可使用可信内建 DOM 组件; +- Installed MiniApp 必须经隔离 iframe / Surface Bridge 运行。 + +而 [03.sdk_and_coreapp.md](03.sdk_and_coreapp.md) 同时写了: + +```text +Task Dashboard 可由受信 Host adapter 挂载;Whiteboard 必须走受限 Surface +``` + +并将 Task Dashboard 定义为: + +```text +kind = bundled-reference +``` + +且说明它可使用“随 LineUp 发布的受信 Host adapter”。 + +**问题:** 当前权威架构没有明确授权 `bundled-reference` 获得与 System MiniApp 相同的可信 DOM 容器。若 Host adapter 可执行或挂载 Task Dashboard 的 UI,而未经过受限 Surface,隔离边界、SDK 受限视图和“禁止访问 Host DOM”就无法以同一安全模型证明。 + +**需要冻结的决策(二选一):** + +1. **保守方案,且最符合当前权威基线:** Task Dashboard 和 Whiteboard 都是非 System MiniApp,必须经隔离 Surface / Bridge 运行。Host adapter 仅作为 Runtime/Shell 的不可见装配层,不向 MiniApp 提供可直接操作的可信 Host DOM。 +2. **若产品确实需要 Task Dashboard 使用可信原生 DOM:** 将其升级为 `kind = system`,并在架构主文档新增或明确“bundled trusted system MiniApp”信任层、发布来源、可用 DOM 权限和审计边界;不能继续称其为普通 `bundled-reference`。 + +**建议验收:** + +- 若维持 `bundled-reference`,Task Dashboard 无法获得 Host 根 DOM、Tauri invoke 或未授权 Host adapter。 +- 若改为 `system`,仍要验证其特权只限 Runtime SDK,不能取得 Transport、Store 或 Agent 访问权。 + +## 3. 重要问题 + +### I1. `bundled-development` 与 `bundled-reference` 的术语、Manifest 枚举未建立映射 + +迭代文档把“内置”解释为 `bundled-development`,但冻结的 `MiniAppManifest.kind` 只有: + +```ts +kind: "system" | "bundled-reference"; +``` + +架构主文档也使用了“参考 MiniApp 在此阶段可以是 `bundled-development`”的表述。 + +这两个词可以共存,但必须明确它们不是互相竞争的 `kind` 枚举值。 + +**建议补充最小映射表:** + +| 概念 | 建议语义 | +|---|---| +| `bundled-development` | 本迭代的交付/加载方式:随开发 Host 预置,不下载、不安装、不经服务端 Catalog。 | +| `kind = bundled-reference` | Manifest 身份:参考 MiniApp,决定 SDK / Surface 信任策略。 | +| `kind = system` | 随产品发布、代码可信的系统 MiniApp,例如 Interact。 | + +并明确:`bundled-development` **不是** `MiniAppManifest.kind` 的第三个取值。 + +### I2. `sdk.ui` 缺少 Renderer 如何安全提交用户结果给 Runtime 的闭环契约 + +当前公开 API 只有: + +```ts +request(...) +get(...) +subscribe(...) +cancel(...) +``` + +但未定义: + +- Runtime 如何把被 Policy 选中、可呈现的 interaction record 交给 renderer; +- renderer 如何提交 `submitted`、`result` 或 `cancelled`; +- Runtime 如何校验 `interaction_id`、owner、renderer binding、状态迁移、一次性提交和结果 schema; +- 在 opaque-origin iframe / Surface Bridge 内,呈现和回传采用何种受限消息协议; +- renderer 与 owner 是同一 MiniApp 时,是否通过 `sdk.ui` 提交,还是只能通过 Runtime/Shell 私有 Renderer API 提交。 + +**建议冻结 Runtime 私有 Renderer Contract / 最小 Bridge Contract:** + +```text +Runtime 分配 interaction_id + renderer_binding + presentation container/surface +→ renderer 仅接收 presentation-safe request projection +→ renderer 向 Runtime-owned endpoint 提交 action/result +→ Runtime 校验: + - 被分配的 renderer / container + - interaction_id + - owner binding + - 当前状态与一次性提交 + - result schema + - expiry +→ Runtime 持久化状态迁移,并向 owner-scoped ui 订阅发出更新 +``` + +隔离 Surface 至少还要限定消息白名单、`surface_id` / `instance_id` 绑定和 nonce 或等效关联方式;不得只凭 origin 信任。 + +### I3. 标准交互与 `RuntimeToolCallRecord` 的关系不清晰 + +当前文档同时规定: + +- MiniApp 可调用 `sdk.ui.request(...)`; +- owner 的 source 可为 `agent_tool | miniapp_tool | runtime_policy`; +- 所有标准交互“必须映射为 Runtime 统一的 `interactive` Tool Call / `StandardInteractionRecord`”。 + +因此会产生问题:Task Dashboard 在没有 Agent Tool Call、例如用户点击“关闭未完成任务”时调用 `sdk.ui.request(confirm)`,Runtime 是否必须凭空创建 Agent-facing `RuntimeToolCallRecord`? + +**建议明确拆成三条路径:** + +| 来源 | Runtime 记录 | 与 Agent Tool 的关系 | +|---|---|---| +| Agent / legacy `lineup.v1.tool.call(choice/confirm/input)` | `RuntimeToolCallRecord` + `StandardInteractionRecord` | 交互终态受控驱动该 Tool 的后续处理。 | +| MiniApp `sdk.ui.request(...)` | owner-bound `StandardInteractionRecord` | 不自动创建新的 Agent Tool;若提供合法 `parent_call_id`,只建立关联。父 Tool 的完成由 owner 经 `sdk.tools.*` 决定。 | +| Runtime Policy 自发提示 | `runtime_policy` owner 的 interaction record | 需明确其是否绑定某一 Capability / lifecycle request。 | + +没有 parent Tool 的交互只改变 MiniApp 本地工作流,不应制造 Inventory、Agent 回传或 outbox 语义。 + +### I4. 结果投递位置与公开 API 不一致 + +流程写“结果仅投递回 owner MiniApp 的 Tool/Inbox”,但 SDK 已公开 `sdk.ui.get/subscribe`。 + +否则实现可能分叉成: + +1. 结果通过 `sdk.ui.subscribe` 的 record update 返回; +2. 结果作为 Inbox 消息; +3. 结果通过 parent Tool 的 SDK Tool event 返回。 + +**建议冻结为:** + +```text +sdk.ui.get / sdk.ui.subscribe += owner 获取标准交互状态与结果的唯一 UI-domain 通道 + +sdk.inbox += Agent / Runtime 入站消息;不复用为 interaction result transport + +sdk.tools.complete / fail / cancel += owner 根据 UI result 自行决定是否推进关联 parent Tool +``` + +还需定义:订阅是否立即回放 owner 当前 pending records、终态保留/清理策略、断线重连后的重放及去重语义。 + +### I5. Schema、状态机、幂等规则不足以支持安全验收 + +已有四类 request 及基础状态,但还需要明确: + +- `StandardInteractionResult` 与 `InteractionReceipt` 的类型; +- `choice.actions[].id` 的唯一性、最大数量、空数组是否允许,以及 single/multi/button 的选择基数; +- `input.fields[].id` 的唯一性、字段数上限、必填、数值解析、空字符串、长度和范围的适用规则; +- `notice` 的“只读”与 `acknowledged` 的精确定义:是否需要用户确认、是否自动完成、是否可超时; +- `request`、`submit`、`cancel`、超时、恢复之间的合法状态迁移; +- 并发提交、重放、重复取消、跨 Surface 重放的确定性返回; +- 交互专用稳定拒绝码。 + +建议至少增加: + +```text +interaction_not_found +interaction_owner_mismatch +interaction_state_invalid +interaction_expired +interaction_result_invalid +interaction_renderer_mismatch +parent_call_invalid +``` + +### I6. 持久化、审计与敏感输入的日志最小化约束尚未衔接 + +`input` 可以含用户自由文本;标准交互要求持久化、恢复和审计,但架构文档又要求日志不得包含身份、会话、消息正文、Tool 参数、token 或 artifact 内容。 + +**建议明确:** + +- 恢复所需的受保护 operational state、审计最小元数据、日志/Telemetry 三者的分层; +- `input` 草稿和结果的保留期、终态清理策略,以及是否加密; +- 审计仅记录 interaction ID、kind、受保护 owner 标识、状态迁移和错误码; +- title、prompt、字段值和 action label 不进入日志、指标、错误详情或普通审计明文; +- 验收增加日志负向断言。 + +## 4. 建议完善项 + +### S1. 扩大 `parent_call_id` 校验条件 + +除“属于当前实例且状态允许关联”外,Runtime 至少应校验: + +```text +同 app_scope +同 instance_id +同 conversation_id +父 Tool 非终态 +不存在跨 owner 关联 +``` + +还要定义父 Tool 被取消或超时时关联 interaction 的处理:自动取消、保留为独立操作,或交由 Policy 决定。否则恢复后容易遗留孤儿卡片。 + +### S2. 将 presentation policy 写成可判定矩阵 + +至少应覆盖: + +| owner 状态 / 容器 | 建议行为 | +|---|---| +| Agent IM / Interact 有效 | Interact IM timeline/card。 | +| owner 前台且存在合法 renderer | owner 容器或已绑定 Surface 的标准 modal/sheet/card。 | +| owner 后台或 suspended | 保持 pending;通知/唤醒仅经 Policy,不得抢占焦点。 | +| Surface 已关闭或失效 | 不向旧 Surface 投递;恢复、降级或安全失败。 | +| renderer 不可用 | 保持 pending 或返回稳定拒绝码;不能把 payload 注入 Host DOM。 | +| 系统能力确认 | 走 Capability Gateway,禁止降级为普通 `confirm`。 | + +### S3. 分离 Interact 的 Renderer 行为与 legacy Agent Tool 完结行为 + +当前“Renderer 呈现标准交互记录”与“Interact 以 `sdk.tools.complete/fail/cancel` 结束 Tool”的表述容易混淆。 + +建议统一为: + +```text +Renderer → Runtime:完成 interaction record +Runtime → owner:sdk.ui 状态更新 +owner(legacy Agent Tool 场景中为 Interact)→ sdk.tools.complete / fail / cancel +``` + +这样不会把一般 Renderer 误写成跨域 Tool 结果出口。 + +### S4. 统一阶段编号 + +架构主文档当前既有 `03.sdk_and_coreapp`,又有后续 `03.reference-miniapps`,但前者已经将 Task Dashboard / Whiteboard 的实现和端到端验收列为目标。 + +需要二选一: + +- 删除或合并 `03.reference-miniapps` 到当前 `03.sdk_and_coreapp`;或 +- 当前 `03` 只做 SDK/Interact 核心,参考 MiniApp 另起新编号并顺延 Catalog 阶段。 + +### S5. 在实施步骤中拆出标准交互服务和 renderer/Bridge 契约 + +建议在通用 Tool 闭环之后、适配 Interact 之前增加独立步骤: + +1. 实现 `standard-interaction-service`:owner 注入、记录、状态机、结果校验、过期、恢复、owner-scoped query/subscription; +2. 实现 renderer registration 与 presentation policy; +3. 定义并测试 System IM renderer 与 isolated Surface renderer 的最小回传协议; +4. 再接入 legacy Tool mapping、Interact 与两个参考 MiniApp。 + +这样可以避免先把能力实现成 Chat 专用逻辑、后续再进行高风险重构。 + +## 5. 建议新增的最小验收场景 + +1. A 实例提交属于 B 实例、不同 conversation 或已终态 Tool 的 `parent_call_id` 时,Runtime 拒绝且不创建 record。 +2. A 创建 interaction 后,B 无法 `get`、订阅、取消、提交,或通过伪造 `interaction_id` 获得任何 payload/result。 +3. 同一 interaction 的两次 renderer submit 只有一次成功;第二次得到稳定终态错误且不改变已持久化结果。 +4. 旧 `lineup.v1.tool.call(choice|confirm|input)` 创建关联 Tool Call 和 owner 为 Interact IM context 的 interaction;用户结果先由 Runtime 落库,再由 Interact 以 owner 身份完成 Tool。 +5. Task Dashboard 无 parent Tool 的 `sdk.ui.request(confirm)` 不创建 Agent-facing Tool Call、不写 Agent outbox;带合法 parent Tool 时仅关联,不自动完成该 Tool。 +6. Task Dashboard 前台时,标准 modal/sheet/card 在其合法容器显示,焦点栈与 Interact background 状态不被改写。 +7. Whiteboard 在 Surface reload、关闭、旧 iframe 重放提交时,`surface_id` 与 renderer binding 都被验证,旧 iframe 消息被拒绝。 +8. 非法 input、重复/未知 choice action、过期 interaction、跨 owner cancel 都被拒绝且不写错误结果。 +9. 标准交互输入值、提示正文、选择标签不出现在日志、Telemetry 或 Audit 明文中;重启恢复仅保留设计允许的最小数据。 +10. 若维持 `bundled-reference`,Task Dashboard 不能直接挂载可信 Host DOM;若改为 `system`,必须有对应的 manifest/source-trust 回归用例。 + +## 6. 建议的阅读和决策顺序 + +明天继续时,建议按以下顺序决策和回填: + +1. 先决定 **B1:Task Dashboard 的信任级别与 UI 容器**; +2. 冻结 **I2:Renderer → Runtime 的提交/Bridge 契约**; +3. 冻结 **I3/I4:StandardInteractionRecord 与 Tool Call 的关系,以及 UI result 的唯一返回通道**; +4. 细化 **I5/I6:schema、状态机、幂等、错误码、持久化与日志最小化**; +5. 把 S1~S5 与第 5 节的验收场景回填入 [03.sdk_and_coreapp.md](03.sdk_and_coreapp.md) 和必要的 [APP架构设计.md](../APP架构设计.md)。 + +在上述收敛前,不建议开始 `standard-interaction-service` 或参考 MiniApp 的实现,以免重新形成 Chat / Interact 专用旁路。