feat(runtime): establish miniapp kernel and sdk design

This commit is contained in:
2026-08-05 00:46:16 +08:00
parent d8aa087bc8
commit 9fb8cd1598
86 changed files with 4615 additions and 1364 deletions
+528
View File
@@ -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 Providernotice / choice / confirm / input
```
当前实现仍保留兼容名称:
```text
产品概念:Interact MiniApp
当前 app_scopechat
当前目录: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<CommandReceipt>;
complete(call_id: string, result: JsonObject): Promise<CommandReceipt>;
fail(call_id: string, failure: MiniAppToolFailure): Promise<CommandReceipt>;
cancel(call_id: string, reason?: string): Promise<CommandReceipt>;
}
```
`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 MiniAppMiniApp 不可自行创建 Host 弹窗或直接将结果发送给 Agent。
复杂、高频或私有的业务 UI(例如 Whiteboard 画布文字编辑、画笔、颜色选择、拖放和工具栏)留在
MiniApp 自己的 Surface 内,不使用标准交互库。系统权限确认(文件、麦克风、通知等)则属于
Runtime/Host Capability Gateway,而不是普通 `confirm`
```ts
interface MiniAppLifecycleAPI {
requestForeground(): Promise<CommandReceipt>;
requestBackground(): Promise<CommandReceipt>;
requestClose(reason?: string): Promise<CommandReceipt>;
}
```
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 中声明 ToolAgent 只能调用当前 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):跨文档详细契约来源;
若与本文的产品术语或当前迭代顺序冲突,以本文为准并同步更新上游方案。
-319
View File
@@ -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 HostAndroid 为 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_<phone>"
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 SurfaceAgent 可发送 `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`
-33
View File
@@ -1,33 +0,0 @@
# M4 兼容矩阵与发布策略
本文定义 LineUp v1 的 Host 行为,不以特定框架的便利接口扩展协议。Tauri 是当前唯一已验收的 Reference HostAndroid 按当前范围暂缓;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 状态。错误率或安全事件触发时撤销 artifactHost 仅切回已验证 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 交叉编译 SpikeLinux native GUI 仍受本机 GTK4/WebKitGTK 6.0 开发依赖限制,因而尚未获得 Linux 生产批准。Android 按用户范围暂缓。
+91 -24
View File
@@ -1,47 +1,114 @@
# LineUp App # 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` 兼容实现是其上的第一个系统级 MiniAppInteract 的 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-R1Runtime 托管 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 HostMac 或其他设备可经 Tailscale 用浏览器
直接打开它,看到的就是 Linux 工作区中正在修改的同一份前端,无须每次改动都同步或重新
clone 源码。
```bash ```bash
cd lineup-app/tauri cd lineup-app/tauri
npm run web:dev npm run web:dev
``` ```
Mac 浏览器通过 Tailscale 打开 `http://100.121.118.116:1420`,即可看到 Linux 工作区的 Vite 热更新。Mac 只在首次配置、切换开发机或需要本地桌面打包时再 clone / pull 源码 当前开发地址
```bash ```text
git clone ssh://git@100.121.118.116:2222/lineup/app.git ~/Workspace/lineup-app http://100.121.118.116:1420/
cd ~/Workspace/lineup-app
git pull --ff-only origin main
``` ```
`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 实验阶段的产品、连接与交互设计; - [APP架构设计.md](APP架构设计.md)LineUp App、Runtime、MiniApp SDK、Surface/Capability 安全与后续迭代的权威架构设计;
- [TECHNOLOGY_SELECTION.md](TECHNOLOGY_SELECTION.md):已确认的客户端与 Mini Runtime 技术路线;Tauri 2 是第一优先的 Reference Host SpikeWails v3 在同一基线后续评估 - [程序文件清单与功能说明.md](程序文件清单与功能说明.md):客户端 Runtime、Chat Core App、Tauri/Web Host 与测试的文件清单
- [M4_COMPATIBILITY_AND_RELEASE.md](M4_COMPATIBILITY_AND_RELEASE.md):M4 的跨 Host 兼容矩阵、签名 Surface 发布、验证与回滚策略 - [tauri/src/README.md](tauri/src/README.md):源码目录、依赖方向与模块放置规则
- [tauri/README.md](tauri/README.md)Tauri 2 Reference Host 的实现、构建状态和下一阶段范围 - [tauri/README.md](tauri/README.md)Tauri/Web Host 的运行、构建、行为回归和历史里程碑
- [LineUp App 层架构设计方案](../设计/02.正式方案/lineup-app-layer-architecture.md):跨端 App Shell、Interaction Kernel、Renderer、Surface 与 Capability 的正式架构 - [迭代/](迭代/00.base.md):按迭代记录当前基线和后续 Runtime Kernel / 应用编排目标
- [LineUp UI Surface 与 App Capability 协议](../设计/02.正式方案/lineup-ui-surface-protocol.md):小程序 Surface 和 Agent 调 App 能力的协议定义。 - [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 ```text
lineup-app/ lineup-app/
├── DESIGN.md # Android 快速实验设计 ├── README.md # LineUp App 工作区入口
├── TECHNOLOGY_SELECTION.md # 已确认的客户端 / Mini Runtime 技术路线 ├── APP架构设计.md # 当前权威架构、SDK 与发布安全设计
── tauri/ # Tauri 2 Reference Host(第一优先 Spike ── tauri/ # Tauri Desktop + Web Reference Host
└── README.md # 本目录说明与文档入口 ├── src/ # Runtime、Core App 与 Host 组合入口
└── src-tauri/ # Tauri Rust Host
``` ```
实际的唐僧叨叨 Android 上游实验代码保留在 `../upstream/tangsengdaodao-android/`,不与未来的 LineUp 多端客户端工程混放。
-158
View File
@@ -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 2Reference 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 v3Go 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 实验工程保持不变。
+39 -20
View File
@@ -1,6 +1,15 @@
# LineUp Tauri Reference Host # 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 后应移除。 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 当前行为基线 ## 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 | | 会话 | 当前只有一个 Agent channel;退出清空页面内消息并停止同步 | `src/main.ts` | 会话状态必须可由 Store 管理,不能依赖 DOM |
| 发送 | 用户消息先本地显示,再通过 Transport 调用 `/messages/send`;显示已发送或失败 | `src/main.ts``src/runtime/transport-adapter.ts` | 即时反馈、送达状态与失败可见性不退化 | | 发送 | 用户消息先本地显示,再通过 Transport 调用 `/messages/send`;显示已发送或失败 | `src/main.ts``src/runtime/communication/transport-adapter.ts` | 即时反馈、送达状态与失败可见性不退化 |
| 历史同步 | 首次从 cursor `0` 连续拉取到空页;之后以 `lastSeq + 1` 每 1.5 秒经 Transport 增量轮询 | `src/main.ts``src/runtime/transport-adapter.ts` | 保持包含式 cursor 语义,不能漏消息或无限热循环 | | 历史同步 | 首次从 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/protocol.ts``src/runtime/trusted-dom-renderers.ts` | GFM、安全净化和外链防护不退化 | | 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/protocol.ts``src/runtime/trusted-dom-renderers.ts` | 状态应更新已有状态项,而非无限追加气泡 | | Agent 状态 | `thinking` 显示临时指示器;其他状态更新顶栏 presence | `src/runtime/protocol/lineup-v1.ts``src/core-apps/chat/trusted-dom-renderers.ts` | 状态应更新已有状态项,而非无限追加气泡 |
| 进度和错误 | progress/error 目前以系统提示显示 | `src/protocol.ts``src/runtime/trusted-dom-renderers.ts` | 迁移后至少保持可见,M1 再升级为正式卡片 | | 进度和错误 | progress/error 目前以系统提示显示 | `src/runtime/protocol/lineup-v1.ts``src/core-apps/chat/trusted-dom-renderers.ts` | 迁移后至少保持可见,M1 再升级为正式卡片 |
| 未实现扩展 | `ui.open / patch / close``app.call` 仅安全降级为提示,绝不执行不可信脚本或原生能力 | `src/protocol.ts``src/main.ts` | 在 M2/M3 完成前始终保持拒绝执行 | | 未实现扩展 | `ui.open / patch / close``app.call` 仅安全降级为提示,绝不执行不可信脚本或原生能力 | `src/runtime/protocol/lineup-v1.ts``src/main.ts` | 在 M2/M3 完成前始终保持拒绝执行 |
### M0 回归场景 ### M0 回归场景
@@ -52,7 +61,7 @@ M0-01M0-08 已完成。Mac 经 Linux Tailscale Web Host `http://100.121.118.1
### M1-01 Tool Call 状态机(已完成) ### 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-02Confirm/Input/Form 留给 M1-03。`npm test` 当前为 3 个文件 / 14 个测试通过,`npm run build` 通过。 这不是可用的交互组件:M1-01 不渲染选项、不提供按钮/表单、不发送 tool result,也不修改 Hermes Adapter。可信 ActionGroup 留给 M1-02Confirm/Input/Form 留给 M1-03。`npm test` 当前为 3 个文件 / 14 个测试通过,`npm run build` 通过。
@@ -82,11 +91,11 @@ Conversation Store v5 持久化任务快照,刷新或重新登录后仍只显
### 当前结构债务(M0 的改造对象) ### 当前结构债务(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 协议模型(已完成) ### 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 - `parseEnvelope(value)`:校验版本、消息类型、`id``conversation_id`、sender/target、timestamp 和 JSON payload
- `parseEnvelopeJSON(raw)`:供不接受历史纯文本的 Transport / Kernel 路径把非法 JSON 明确归为 `invalid_json` - `parseEnvelopeJSON(raw)`:供不接受历史纯文本的 Transport / Kernel 路径把非法 JSON 明确归为 `invalid_json`
@@ -96,9 +105,9 @@ Conversation Store v5 持久化任务快照,刷新或重新登录后仍只显
### M0-03 Interaction Kernel 与 Renderer Registry(已完成) ### M0-03 Interaction Kernel 与 Renderer Registry(已完成)
`src/runtime/interaction-kernel.ts` 是 Transport 与表现层之间的唯一入口:它将原始同步/实时 payload 解码为 `ConversationItem`,对有有效 LineUp `id` 的消息实施有界去重,并将 Agent status 投影为可读取的 presence 快照。它不执行 `ui.*``app.*` 或用户动作。 `src/runtime/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 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 安装在其他位置,可临时覆盖: 本项目的 `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`:已通过; - `npm run build`:已通过;
- Cargo/Tauri Rust crates:已下载并开始编译; - Cargo/Tauri Rust crates:已下载并开始编译;
- Tauri Linux desktop build:已通过;debug 可执行文件和 Debian 包已生成; - Tauri Linux desktop build:已通过;debug 可执行文件和 Debian 包已生成;
- Android Spike:尚未开始。仍需要 Android NDK、Rust Android targets 与 Tauri Android 初始化 - 独立 Android/Kotlin 与 Wails Host:不在当前或后续规划范围内
Linux 桌面构建依赖以下系统开发库;本机已具备。新环境缺少时可安装: Linux 桌面构建依赖以下系统开发库;本机已具备。新环境缺少时可安装:
@@ -156,17 +171,21 @@ src-tauri/target/debug/lineup-tauri
src-tauri/target/debug/bundle/deb/LineUp_0.1.0_amd64.deb 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 ```text
tauri/ tauri/
├── src/ # 可信 LineUp Host 前端 ├── src/
│ ├── main.ts # 会话 UI、AppServer transport、Renderer 调度 │ ├── main.ts # 应用启动装配入口:连接 Host、Runtime 与默认 App
── protocol.ts # 平台无关的 LineUp v1 → ConversationItem 解码 ── core-apps/chat/ # Chat Shell、可信 Renderer 与样式
│ └── runtime/ # 按通信、协调、存储、应用、Surface、能力等分层
├── src-tauri/ # Tauri Rust Host 与 capability 配置 ├── src-tauri/ # Tauri Rust Host 与 capability 配置
├── vite.config.ts # @/ → src/ 路径别名(Vite / Vitest
└── package.json # Vite、Tauri CLI 与前端依赖 └── package.json # Vite、Tauri CLI 与前端依赖
``` ```
各 Runtime 子目录、依赖方向与新增模块约定见 [src/README.md](src/README.md)。
第三方 Agent Surface 未来必须运行于独立隔离执行域,只能经 LineUp Mini Runtime 事件桥与 Capability Gateway 通信;不得访问 Tauri `invoke`、Host DOM、登录态或本机特权。 第三方 Agent Surface 未来必须运行于独立隔离执行域,只能经 LineUp Mini Runtime 事件桥与 Capability Gateway 通信;不得访问 Tauri `invoke`、Host DOM、登录态或本机特权。
+1
View File
@@ -1,3 +1,4 @@
// Tauri Rust Host 的应用构造入口;Runtime 协议和业务路由仍位于 TypeScript LineUpRuntime。
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
+1
View File
@@ -1,3 +1,4 @@
// Tauri 桌面二进制入口:仅委托给 lib.rs 中的可信 Host 构造逻辑。
fn main() { fn main() {
lineup_tauri_lib::run(); lineup_tauri_lib::run();
} }
+61
View File
@@ -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`。这样新应用只增加自己的体验,
不会破坏已有聊天和连接逻辑。
+90
View File
@@ -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<TrustedDOMRendererContext>;
};
function query<T extends Element>(root: ParentNode, selector: string): T {
const found = root.querySelector<T>(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<HTMLElement>(root, "#messages");
const loginView = query<HTMLElement>(root, "#login-view");
const chatView = query<HTMLElement>(root, "#chat-view");
const presence = query<HTMLElement>(root, "#presence");
const rendererContext = new TrustedDOMRendererContext(
messages,
presence,
options.toolInteractions,
options.taskInteractions,
options.capabilityInteractions,
);
const rendererRegistry = new RendererRegistry<TrustedDOMRendererContext>();
registerChatRenderers(rendererRegistry);
return {
app_scope: "chat",
sdk: runtime.openApp("chat"),
messages,
loginView,
chatView,
presence,
rendererContext,
rendererRegistry,
};
}
function registerChatRenderers(registry: RendererRegistry<TrustedDOMRendererContext>): 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));
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Chat Core App 的可信 DOM 外壳。
*
* 这里只声明登录与聊天页面结构;Agent 消息、状态和用户动作都必须经过
* ChatRuntimeSDK,而不能在页面中直接接触 Runtime 的通信或存储实现。
*/
export function mountChatShell(root: HTMLElement): void {
root.innerHTML = `
<main class="shell">
<section id="login-view" class="login-view">
<div class="brand"><span>✦</span><div><p>LINEUP</p><h1>你的 AI 协作空间</h1></div></div>
<form id="login-form" class="login-card" novalidate>
<label>AppServer 地址<input id="api" inputmode="url" placeholder="http://100.x.y.z:8090" required /></label>
<label>手机号<input id="phone" inputmode="tel" autocomplete="tel" placeholder="请输入手机号" required /></label>
<label>验证码<input id="code" inputmode="numeric" autocomplete="one-time-code" placeholder="请输入验证码" required /></label>
<p id="login-error" class="error" role="alert"></p>
<button id="login-submit" type="submit">进入 LineUp</button>
</form>
</section>
<section id="chat-view" class="chat-view" hidden>
<header class="chat-header"><div class="agent"><span class="avatar">✦</span><div><strong>AI Agent</strong><small id="presence">正在连接…</small></div></div><div><button id="surface-demo" class="quiet" type="button">启用任务面板</button><button id="logout" class="quiet">退出</button></div></header>
<section id="messages" class="messages" aria-live="polite"></section>
<form id="message-form" class="composer"><textarea id="message-input" rows="1" placeholder="问问你的 AI 搭档…"></textarea><button id="send" type="submit">发送</button></form>
</section>
</main>`;
}
@@ -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<InteractionMode, InteractionModeRecord>([
["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 }); }
}
@@ -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;
}
}
@@ -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 ConversationItemKind = ConversationItem["kind"];
export type ItemOfKind<K extends ConversationItemKind> = Extract<ConversationItem, { kind: K }>; export type ItemOfKind<K extends ConversationItemKind> = Extract<ConversationItem, { kind: K }>;
File diff suppressed because one or more lines are too long
@@ -1,3 +1,4 @@
/* Chat 中可信 Capability 确认卡片的视觉状态;样式不授予或执行任何系统能力。 */
.capability-card { .capability-card {
display: grid; display: grid;
justify-self: start; justify-self: start;
@@ -1,3 +1,4 @@
/* Chat 内受限执行摘要卡片:只展示 Runtime/Adapter 已归约的公开步骤。 */
.execution-progress-card { .execution-progress-card {
justify-self: start; justify-self: start;
width: min(100%, 32rem); width: min(100%, 32rem);
@@ -1,3 +1,9 @@
/**
* Chat Core App DOM
*
* Markdown sanitizer
* SDK TransportStore Tauri
*/
import DOMPurify from "dompurify"; import DOMPurify from "dompurify";
import { marked } from "marked"; import { marked } from "marked";
import type { import type {
@@ -8,12 +14,12 @@ import type {
InputForm, InputForm,
JsonObject, JsonObject,
ToolCallRequest, ToolCallRequest,
} from "../protocol"; } from "@/runtime/protocol/lineup-v1";
import type { ToolCallRecord } from "./tool-call-state"; import type { ToolCallRecord } from "@/runtime/coordination/tool-call-state";
import type { TaskRecord } from "./task-state"; import type { TaskRecord } from "@/runtime/coordination/task-state";
import type { ExecutionProgressSummary } from "./execution-progress-state"; import type { ExecutionProgressSummary } from "@/runtime/coordination/execution-progress-state";
import type { ArtifactRecord } from "./artifact-state"; import type { ArtifactRecord } from "@/runtime/artifacts/artifact-state";
import type { CapabilityCallRecord } from "./capability-call-state"; import type { CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state";
export type ToolInteractionHandlers = { export type ToolInteractionHandlers = {
submit: (callID: string, submission: JsonObject) => boolean; submit: (callID: string, submission: JsonObject) => boolean;
+218 -370
View File
@@ -1,43 +1,34 @@
import "./style.css"; /**
import "./capability-card.css"; * Tauri/Desktop 与 Web Reference Host 的组合入口。
import "./execution-progress.css"; *
* 本文件负责创建 Host 侧适配、LineUpRuntime 和默认 Chat Core App,并连接可信的
* Renderer、Surface、Capability 集成。Transport、Store、同步循环、outbox 与原始
* Agent 消息解析均由 Runtime 独占,不能重新回流到此入口。
*/
// 协议层只提供经过 Runtime 校验后的表现模型类型;本入口不直接解析原始网络数据。
import { import {
type Actor,
type ConversationItem, type ConversationItem,
type Envelope,
type JsonObject, type JsonObject,
parseEnvelopeJSON, } from "@/runtime/protocol/lineup-v1";
} from "./protocol"; // 下面这些模块分别对应 Host 侧的扩展界面、系统能力、Artifact 和执行状态适配。
import { ConversationStore } from "./runtime/conversation-store"; import { ExecutionProgressState } from "@/runtime/coordination/execution-progress-state";
import { ExecutionProgressState } from "./runtime/execution-progress-state"; import { SurfaceRegistry } from "@/runtime/surfaces/surface-registry";
import { InteractionKernel } from "./runtime/interaction-kernel"; import { SurfaceInstanceManager } from "@/runtime/surfaces/surface-instance-manager";
import { RendererRegistry } from "./runtime/renderer-registry"; import { CachedVerifiedSurfaceDocumentResolver, IsolatedSurfaceHost } from "@/runtime/surfaces/isolated-surface-host";
import { HttpTransportAdapter, type TransportAdapter } from "./runtime/transport-adapter"; import { ProductionSurfaceManifestRegistry } from "@/runtime/surfaces/production-surface-manifest";
import { TrustedDOMRendererContext } from "./runtime/trusted-dom-renderers"; import { ProductionSurfacePolicy } from "@/runtime/surfaces/production-surface-policy";
import { SurfaceRegistry } from "./runtime/surface-registry"; import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, WebCryptoEd25519SurfaceManifestSignatureVerifier } from "@/runtime/surfaces/surface-bundle-cache";
import { SurfaceInstanceManager } from "./runtime/surface-instance-manager"; import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry";
import { CachedVerifiedSurfaceDocumentResolver, IsolatedSurfaceHost } from "./runtime/isolated-surface-host"; import { ClientInventoryPublisher } from "@/runtime/inventory/client-inventory";
import { ProductionSurfaceManifestRegistry } from "./runtime/production-surface-manifest"; import { ArtifactState } from "@/runtime/artifacts/artifact-state";
import { ProductionSurfacePolicy } from "./runtime/production-surface-policy"; import { ArtifactContentCache } from "@/runtime/artifacts/artifact-content-cache";
import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, WebCryptoEd25519SurfaceManifestSignatureVerifier } from "./runtime/surface-bundle-cache"; import { CapabilityCallStateMachine, type CapabilityCallRecord, parseCapabilityCall } from "@/runtime/capabilities/capability-call-state";
import { CapabilityRegistry } from "./runtime/capability-registry"; import { CapabilityAuditLog } from "@/runtime/capabilities/capability-audit";
import { ClientInventoryPublisher } from "./runtime/client-inventory"; import { CapabilityExecutor, type PickedFile } from "@/runtime/capabilities/capability-executor";
import { ArtifactState } from "./runtime/artifact-state"; import { LineUpRuntime } from "@/runtime/coordination/lineup-runtime";
import { ArtifactContentCache } from "./runtime/artifact-content-cache"; import { RuntimeAppHost } from "@/runtime/app-management/runtime-app-host";
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;
};
// Vite 的开发标记和 M4 测试控制器需要扩展浏览器的全局类型。
declare global { declare global {
interface ImportMeta { interface ImportMeta {
readonly env: Readonly<{ DEV: boolean }>; readonly env: Readonly<{ DEV: boolean }>;
@@ -49,13 +40,16 @@ declare global {
} }
} }
const DEFAULT_API = "http://127.0.0.1:8090"; // 这是登录表单首次显示时使用的本地默认地址;用户提交后的地址会保存到 localStorage。
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 }; // `0.0.0.0` is a server bind address, never a browser destination. When the
let transport: TransportAdapter | undefined; // Web Host is opened locally use loopback; when it is opened through the
let polling = false; // trusted Tailscale Host use the matching reachable AppServer address.
let flushingOutbox = false; const DEFAULT_API = location.hostname === "127.0.0.1" || location.hostname === "localhost"
const interactionKernel = new InteractionKernel(); ? "http://127.0.0.1:8090"
const conversationStore = new ConversationStore(localStorage); : "http://100.121.118.116:8090";
// 这些状态对象属于当前可信 Host 的装配层:Runtime 负责会话、消息和可靠性,
// 它们负责将 Surface、Capability、Artifact 和执行进度投影到当前页面。
const executionProgress = new ExecutionProgressState(); const executionProgress = new ExecutionProgressState();
const surfaceRegistry = new SurfaceRegistry(); const surfaceRegistry = new SurfaceRegistry();
const surfaceInstances = new SurfaceInstanceManager(surfaceRegistry); const surfaceInstances = new SurfaceInstanceManager(surfaceRegistry);
@@ -65,6 +59,8 @@ const artifactState = new ArtifactState();
const artifactContentCache = new ArtifactContentCache(); const artifactContentCache = new ArtifactContentCache();
const capabilityCalls = new CapabilityCallStateMachine(); const capabilityCalls = new CapabilityCallStateMachine();
const capabilityAudit = new CapabilityAuditLog(); const capabilityAudit = new CapabilityAuditLog();
// CapabilityExecutor 只在 Capability 状态机已经批准调用后才会触发这些 Host handler。
// 它不会因为 Agent 发来一个 payload 就直接调用浏览器或桌面能力。
const capabilityExecutor = new CapabilityExecutor({ const capabilityExecutor = new CapabilityExecutor({
async openURL(url) { async openURL(url) {
// With `noopener`, Chromium intentionally returns null even when it did // With `noopener`, Chromium intentionally returns null even when it did
@@ -77,46 +73,64 @@ const capabilityExecutor = new CapabilityExecutor({
pickFile: pickFileFromTrustedHost, pickFile: pickFileFromTrustedHost,
async saveArtifact(artifactID) { saveCachedArtifact(artifactID); }, async saveArtifact(artifactID) { saveCachedArtifact(artifactID); },
}, artifactState); }, 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 = ""; let lastInventoryRevision = "";
const SURFACE_REGISTRY_STORAGE_KEY = "lineup.surface-registry.v1"; 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); } 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<HTMLDivElement>("#app"); const app = document.querySelector<HTMLDivElement>("#app");
if (!app) throw new Error("LineUp host root is missing"); if (!app) throw new Error("LineUp host root is missing");
app.innerHTML = ` // Host 页面是可信的静态 Shell。缺少必要节点属于装配错误,应尽早失败,而不是运行到
<main class="shell"> // 登录或消息处理时才出现难以定位的空引用。
<section id="login-view" class="login-view">
<div class="brand"><span>✦</span><div><p>LINEUP</p><h1>你的 AI 协作空间</h1></div></div>
<form id="login-form" class="login-card" novalidate>
<label>AppServer 地址<input id="api" inputmode="url" placeholder="http://100.x.y.z:8090" required /></label>
<label>手机号<input id="phone" inputmode="tel" autocomplete="tel" placeholder="请输入手机号" required /></label>
<label>验证码<input id="code" inputmode="numeric" autocomplete="one-time-code" placeholder="请输入验证码" required /></label>
<p id="login-error" class="error" role="alert"></p>
<button id="login-submit" type="submit">进入 LineUp</button>
</form>
</section>
<section id="chat-view" class="chat-view" hidden>
<header class="chat-header"><div class="agent"><span class="avatar">✦</span><div><strong>AI Agent</strong><small id="presence">正在连接…</small></div></div><div><button id="surface-demo" class="quiet" type="button">启用任务面板</button><button id="logout" class="quiet">退出</button></div></header>
<section id="messages" class="messages" aria-live="polite"></section>
<form id="message-form" class="composer"><textarea id="message-input" rows="1" placeholder="问问你的 AI 搭档…"></textarea><button id="send" type="submit">发送</button></form>
</section>
</main>`;
const $ = <T extends Element>(selector: string): T => { const $ = <T extends Element>(selector: string): T => {
const found = document.querySelector<T>(selector); const found = document.querySelector<T>(selector);
if (!found) throw new Error(`Missing element: ${selector}`); if (!found) throw new Error(`Missing element: ${selector}`);
return found; return found;
}; };
const messages = $<HTMLElement>("#messages"); // 当前默认 Core App 的 SDK。变量在挂载应用后赋值;前面传入的回调只会在用户操作后执行,
const loginView = $<HTMLElement>("#login-view"); // 因此它们可以安全地引用这个稍后完成初始化的 SDK。
const chatView = $<HTMLElement>("#chat-view"); let chatSDK: ReturnType<LineUpRuntime["openApp"]>;
const presence = $<HTMLElement>("#presence"); // 将 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, { const surfaceHost = new IsolatedSurfaceHost(messages, {
ready(instanceID) { surfaceInstances.ready(instanceID, new Date().toISOString()); }, ready(instanceID) { surfaceInstances.ready(instanceID, new Date().toISOString()); },
event(instanceID) { queueSurfaceCancel(instanceID); }, event(instanceID) { queueSurfaceCancel(instanceID); },
}); });
$<HTMLInputElement>("#api").value = session.api; $<HTMLInputElement>("#api").value = localStorage.getItem("lineup.api") || DEFAULT_API;
/** /**
* A pre-signed public fixture used only by the headed M4 acceptance route. * A pre-signed public fixture used only by the headed M4 acceptance route.
@@ -124,7 +138,10 @@ $<HTMLInputElement>("#api").value = session.api;
* the actual Ed25519 → size → SHA-256 → cache → exact resolver path. * the actual Ed25519 → size → SHA-256 → cache → exact resolver path.
*/ */
async function mountM4SignedFixture(): Promise<void> { async function mountM4SignedFixture(): Promise<void> {
// 这是开发环境专用的签名 Surface 验收入口。正常启动和生产构建不会执行这里。
if (!import.meta.env.DEV || !new URLSearchParams(location.search).has("m4-signed-surface-e2e")) return; 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 encodedBundle = "PG1haW4-PGgyIGlkPSJtNC1zaWduZWQiPk00IOW3suetvuWQjSBTdXJmYWNlPC9oMj48cCBpZD0ic3RhdGUiPuetieW-heeKtuaAgTwvcD48c2NyaXB0PndpbmRvdy5hZGRFdmVudExpc3RlbmVyKCJtZXNzYWdlIixlPT57Y29uc3QgZD1lLmRhdGE7aWYoZCYmZC50eXBlPT09ImxpbmV1cC5zdXJmYWNlLnYxLnN0YXRlIilkb2N1bWVudC5xdWVyeVNlbGVjdG9yKCIjc3RhdGUiKS50ZXh0Q29udGVudD1TdHJpbmcoZC5zdGF0ZT8uc3RhdHVzfHwi6L-Q6KGM5LitIil9KTt3aW5kb3cucGFyZW50LnBvc3RNZXNzYWdlKHt2OjEsdHlwZToibGluZXVwLnN1cmZhY2UudjEucmVhZHkiLGluc3RhbmNlX2lkOndpbmRvdy5fX0xJTkVVUF9TVVJGQUNFX0lOU1RBTkNFX199LCIqIik8L3NjcmlwdD48L21haW4-";
const standardBase64 = encodedBundle.replace(/-/g, "+").replace(/_/g, "/"); const standardBase64 = encodedBundle.replace(/-/g, "+").replace(/_/g, "/");
const bundle = Uint8Array.from(atob(standardBase64 + "=".repeat((4 - standardBase64.length % 4) % 4)), value => value.charCodeAt(0)); const bundle = Uint8Array.from(atob(standardBase64 + "=".repeat((4 - standardBase64.length % 4) % 4)), value => value.charCodeAt(0));
@@ -155,6 +172,7 @@ async function mountM4SignedFixture(): Promise<void> {
}; };
} }
/** 生成只在本地 Store 中使用的临时 ID,不把它当作服务器身份或消息协议 ID。 */
function newLocalID(prefix: string): string { function newLocalID(prefix: string): string {
// `crypto.randomUUID` is absent from older Safari / WKWebView versions. // `crypto.randomUUID` is absent from older Safari / WKWebView versions.
// A local item id needs uniqueness within this browser store, not a // 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)}`}`; return `${prefix}_${uuid ?? `${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`}`;
} }
/** 将 Store/SDK 异常转换成用户可以理解的提示,同时保留发送尝试的语义。 */
function storageFailureMessage(reason: unknown): string { function storageFailureMessage(reason: unknown): string {
const detail = reason instanceof Error && reason.message ? `${reason.message}` : ""; const detail = reason instanceof Error && reason.message ? `${reason.message}` : "";
return `本地对话历史暂时无法保存${detail};消息仍会尝试发送,刷新后将从服务端重新同步。`; return `本地对话历史暂时无法保存${detail};消息仍会尝试发送,刷新后将从服务端重新同步。`;
} }
/** 校验并规范化 AppServer 地址,只允许 HTTP/HTTPS,避免把任意协议交给 Transport。 */
function normalizeAPIAddress(value: string): string { function normalizeAPIAddress(value: string): string {
const candidate = value.trim(); const candidate = value.trim();
if (!candidate) throw new Error("请输入 AppServer 地址。"); if (!candidate) throw new Error("请输入 AppServer 地址。");
@@ -180,6 +200,7 @@ function normalizeAPIAddress(value: string): string {
return parsed.toString().replace(/\/$/, ""); return parsed.toString().replace(/\/$/, "");
} }
/** 把网络、超时和输入错误转换成登录页可显示的中文原因。 */
function loginFailureMessage(reason: unknown, endpoint?: string): string { function loginFailureMessage(reason: unknown, endpoint?: string): string {
const destination = endpoint ? ` ${endpoint}` : "上方填写的服务地址"; const destination = endpoint ? ` ${endpoint}` : "上方填写的服务地址";
if (reason instanceof Error && reason.message === "LINEUP_LOGIN_TIMEOUT") return `连接 AppServer 超时(10 秒)。请确认当前网络可以访问${destination}`; 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 || "登录失败,请稍后重试。"; return message || "登录失败,请稍后重试。";
} }
const rendererContext = new TrustedDOMRendererContext(messages, presence, { // Chat 是 Runtime 上的 Core App。它不接收 Transport 原始 payloadRuntime 会先完成
submit(callID, submission) { // 协议解析、作用域筛选、持久化和 App Inbox 投递,再通过 SDK 交给这里。
const transition = interactionKernel.submitToolCall(callID, new Date().toISOString(), submission); chatSDK.subscribeAgentMessages(message => {
conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls); // 收到 Agent 消息后,先把执行状态投影出来,再进行具体内容渲染,最后 ACK。
if (transition.disposition === "accepted") { // ACK 表示 Chat 已经接管这条消息,Runtime 才可以从可靠 Inbox 中移除它。
conversationStore.clearToolDraft(callID); projectExecutionProgress(message.item, message.received_at);
queueToolAction("tool-result", callID, { call_id: callID, status: "completed", result: submission }); renderIncoming(message.item);
} chatSDK.acknowledgeAgentMessage(message.message_id);
return transition.disposition === "accepted"; });
},
cancel(callID, reason) { chatSDK.subscribeEvents(event => {
const transition = interactionKernel.cancelToolCall(callID, reason, new Date().toISOString()); // 这些是 Runtime 给 Chat 的安全事件投影,不包含原始 Agent delivery。
conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls); if (event.type === "local-item") {
if (transition.disposition === "accepted") { renderIncoming(event.item);
conversationStore.clearToolDraft(callID); return;
queueToolAction("tool-cancel", callID, { call_id: callID, reason }); }
} if (event.type === "delivery" && event.item?.kind === "user-message") {
return transition.disposition === "accepted"; rendererContext.updateUserDelivery(event.local_id, event.item.delivery);
}, return;
expire(callID) { }
const transition = interactionKernel.expireToolCall(callID, new Date().toISOString()); if (event.type === "sync-status") {
conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls); rendererContext.setPresence(event.status === "syncing" ? "正在同步消息…" : event.status === "idle" ? "已同步,等待消息" : "同步中断,正在重试…");
return transition.disposition === "accepted"; return;
}, }
read(callID) { return interactionKernel.snapshot().tool_calls.find(record => record.call_id === callID); }, if (event.type === "scope-rejected") {
loadDraft(callID) { return conversationStore.getToolDraft(callID); }, rendererContext.renderSystemMessage("收到作用域不匹配的 Agent 消息,已安全忽略。");
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); },
}); });
const rendererRegistry = new RendererRegistry<TrustedDOMRendererContext>();
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 { function renderIncoming(item: ConversationItem): void {
// 这是 Chat 的统一表现入口。只有 Runtime 已经验证并转换成 ConversationItem 的内容
// 才能到达这里;高风险类型仍需经过各自状态机或策略再次检查。
if (item.kind === "app-call") { if (item.kind === "app-call") {
// App/Capability 请求不能直接变成按钮点击。先解析能力声明,再交给状态机记录
// pending 状态;Renderer 只显示确认卡片,真正执行发生在用户批准之后。
const now = new Date().toISOString(); const now = new Date().toISOString();
const parsed = parseCapabilityCall(item.envelope.payload, capabilityRegistry, now); const parsed = parseCapabilityCall(item.envelope.payload, capabilityRegistry, now);
if (parsed.disposition === "unsupported") { if (parsed.disposition === "unsupported") {
@@ -264,6 +263,8 @@ function renderIncoming(item: ConversationItem): void {
return; return;
} }
if (item.kind === "artifact-offer") { if (item.kind === "artifact-offer") {
// Artifact 先验证元数据和可选内容,再由 Host 负责显示。持久化只保存元数据,
// 不把二进制内容写入对话 Store。
const artifact = artifactState.offer(item.envelope.payload); const artifact = artifactState.offer(item.envelope.payload);
if (!artifact) { if (!artifact) {
rendererContext.renderSystemMessage("收到无法安全验证的 Artifact。 "); rendererContext.renderSystemMessage("收到无法安全验证的 Artifact。 ");
@@ -274,28 +275,32 @@ function renderIncoming(item: ConversationItem): void {
return; return;
} }
if (item.kind === "surface") { if (item.kind === "surface") {
// Surface 生命周期消息分别对应打开、更新和关闭。每一步都由
// SurfaceInstanceManager 做幂等、作用域和状态跃迁检查。
const now = new Date().toISOString(); const now = new Date().toISOString();
if (item.envelope.type === "lineup.v1.ui.open") { if (item.envelope.type === "lineup.v1.ui.open") {
const result = surfaceInstances.open(item.envelope.payload, item.envelope.conversation_id, now); 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.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; return;
} }
if (item.envelope.type === "lineup.v1.ui.patch") { if (item.envelope.type === "lineup.v1.ui.patch") {
const instanceID = item.envelope.payload.instance_id; 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 }; 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.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; return;
} }
if (item.envelope.type === "lineup.v1.ui.close") { if (item.envelope.type === "lineup.v1.ui.close") {
const instanceID = item.envelope.payload.instance_id; const instanceID = item.envelope.payload.instance_id;
if (typeof instanceID === "string") surfaceInstances.close(instanceID); if (typeof instanceID === "string") surfaceInstances.close(instanceID);
if (typeof instanceID === "string") surfaceHost.unmount(instanceID); if (typeof instanceID === "string") surfaceHost.unmount(instanceID);
conversationStore.replaceSurfaces(surfaceInstances.snapshot()); runtime.replaceSurfaces(surfaceInstances.snapshot());
return; return;
} }
} }
// 普通文本、状态、Tool、错误和 fallback 等内容交给受信 Renderer Registry
// 未注册的类型只能安全降级为提示,不能执行未知脚本。
if (!rendererRegistry.render(item, rendererContext)) rendererContext.renderSystemMessage("收到当前客户端没有可信 Renderer 的内容。"); if (!rendererRegistry.render(item, rendererContext)) rendererContext.renderSystemMessage("收到当前客户端没有可信 Renderer 的内容。");
} }
@@ -310,7 +315,10 @@ function renderIncoming(item: ConversationItem): void {
* through SurfaceInstanceManager and SurfaceRegistry. * through SurfaceInstanceManager and SurfaceRegistry.
*/ */
function replayStoredSurfaceLifecycle(): void { function replayStoredSurfaceLifecycle(): void {
const snapshot = conversationStore.open(currentConversationKey()); // 某个 Surface 可能在对应本地应用启用前就已到达。Runtime 保留了对话记录,
// 用户启用应用后这里只重放持久化的 Surface 过渡,不会自动下载、启用或放宽校验。
const snapshot = runtime.snapshot();
if (!snapshot) return;
for (const stored of snapshot.items) { for (const stored of snapshot.items) {
if (stored.item.kind === "surface") renderIncoming(stored.item); if (stored.item.kind === "surface") renderIncoming(stored.item);
} }
@@ -322,26 +330,23 @@ function replayStoredSurfaceLifecycle(): void {
* availability changes, never because of a retry. * availability changes, never because of a retry.
*/ */
async function publishInventory(): Promise<void> { async function publishInventory(): Promise<void> {
if (!session.active || !session.uid || !transport) return; // Inventory 是“当前客户端有哪些公开能力”的声明,不是 Agent 发来的执行命令。
const update = inventoryPublisher.sync(surfaceRegistry.snapshot(), capabilityRegistry.inventory()); // 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; if (update.inventory.revision === lastInventoryRevision) return;
const envelope = makeProtocolEvent( await runtime.sendProtocolEnvelope("lineup.v1.client.inventory", update.inventory as unknown as JsonObject);
"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) });
lastInventoryRevision = update.inventory.revision; lastInventoryRevision = update.inventory.revision;
} }
$<HTMLButtonElement>("#surface-demo").addEventListener("click", () => { $<HTMLButtonElement>("#surface-demo").addEventListener("click", () => {
// 这是当前 MVP 的本地演示开关,用于启用一个可信 Surface 并刷新 Agent 可见 Inventory。
const now = new Date().toISOString(); const now = new Date().toISOString();
surfaceRegistry.enable("lineup.task-dashboard", "0.1.0", now); surfaceRegistry.enable("lineup.task-dashboard", "0.1.0", now);
localStorage.setItem(SURFACE_REGISTRY_STORAGE_KEY, JSON.stringify(surfaceRegistry.snapshot())); localStorage.setItem(SURFACE_REGISTRY_STORAGE_KEY, JSON.stringify(surfaceRegistry.snapshot()));
replayStoredSurfaceLifecycle(); replayStoredSurfaceLifecycle();
conversationStore.replaceSurfaces(surfaceInstances.snapshot()); runtime.replaceSurfaces(surfaceInstances.snapshot());
void publishInventory(); void publishInventory();
}); });
@@ -350,16 +355,18 @@ $<HTMLButtonElement>("#surface-demo").addEventListener("click", () => {
* the separately validated adapter trace, never from Agent status/detail. * the separately validated adapter trace, never from Agent status/detail.
*/ */
function projectExecutionProgress(item: ConversationItem, now: string): void { 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") { if (item.kind === "agent-status" && item.status === "thinking" && item.envelope?.sender.kind === "agent") {
const summary = executionProgress.begin(item.execution_id ?? newLocalID("execution"), now); const summary = executionProgress.begin(item.execution_id ?? newLocalID("execution"), now);
conversationStore.replaceExecutionSummaries(executionProgress.snapshot()); runtime.replaceExecutionSummaries(executionProgress.snapshot());
rendererContext.renderExecutionSummary(summary); rendererContext.renderExecutionSummary(summary);
return; return;
} }
if (item.kind === "execution-summary") { if (item.kind === "execution-summary") {
const summary = executionProgress.applyTrace(item.trace.execution_id, item.trace.status, item.trace.steps, now); const summary = executionProgress.applyTrace(item.trace.execution_id, item.trace.status, item.trace.steps, now);
if (summary) { if (summary) {
conversationStore.replaceExecutionSummaries(executionProgress.snapshot()); runtime.replaceExecutionSummaries(executionProgress.snapshot());
rendererContext.renderExecutionSummary(summary); rendererContext.renderExecutionSummary(summary);
} }
return; return;
@@ -369,12 +376,13 @@ function projectExecutionProgress(item: ConversationItem, now: string): void {
const executionID = item.kind === "agent-status" ? item.execution_id : undefined; const executionID = item.kind === "agent-status" ? item.execution_id : undefined;
const summary = executionProgress.finish(executionID, terminal, now); const summary = executionProgress.finish(executionID, terminal, now);
if (!summary) return; if (!summary) return;
conversationStore.replaceExecutionSummaries(executionProgress.snapshot()); runtime.replaceExecutionSummaries(executionProgress.snapshot());
rendererContext.renderExecutionSummary(summary); rendererContext.renderExecutionSummary(summary);
} }
function currentConversationKey(): string { 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. * cache entry the capability is absent from the next client.inventory.
*/ */
function reconcileArtifactSaveAvailability(): void { function reconcileArtifactSaveAvailability(): void {
// 只有“已验证 Artifact + Host 仍持有对应临时内容”同时成立时,才把 artifact.save
// 暴露给 Agent。内容失效后要立即从下一版 Inventory 中撤回该能力。
const available = artifactState.snapshot().some(artifact => artifact.integrity === "verified" && artifactContentCache.has(artifact.local_ref)); const available = artifactState.snapshot().some(artifact => artifact.integrity === "verified" && artifactContentCache.has(artifact.local_ref));
const before = capabilityRegistry.resolve("artifact.save").disposition === "available"; const before = capabilityRegistry.resolve("artifact.save").disposition === "available";
if (before === available) return; if (before === available) return;
@@ -391,6 +401,8 @@ function reconcileArtifactSaveAvailability(): void {
} }
function saveCachedArtifact(artifactID: string): void { function saveCachedArtifact(artifactID: string): void {
// 这里只能保存 Host 自己缓存的 Blob。协议里的 local_ref 是不透明引用,绝不当作
// 文件路径或 URL 访问,下载完成后立即释放临时 Object URL。
const artifact = artifactState.get(artifactID); const artifact = artifactState.get(artifactID);
const blob = artifact?.integrity === "verified" && artifact.local_ref ? artifactContentCache.get(artifact.local_ref) : undefined; const blob = artifact?.integrity === "verified" && artifact.local_ref ? artifactContentCache.get(artifact.local_ref) : undefined;
if (!artifact || !blob) throw new Error("artifact_cache_unavailable"); if (!artifact || !blob) throw new Error("artifact_cache_unavailable");
@@ -415,6 +427,8 @@ function saveCachedArtifact(artifactID: string): void {
* reflected into a protocol result. * reflected into a protocol result.
*/ */
async function writeClipboardFromTrustedHost(text: string): Promise<void> { async function writeClipboardFromTrustedHost(text: string): Promise<void> {
// 复制操作只能在 Capability 状态机批准后执行。HTTPS/Tauri 优先使用 Clipboard API
// HTTP 开发 Host 退回到用户激活约束下的 execCommand,不持久化剪贴板内容。
if (navigator.clipboard?.writeText) { if (navigator.clipboard?.writeText) {
try { try {
await navigator.clipboard.writeText(text); await navigator.clipboard.writeText(text);
@@ -445,6 +459,8 @@ async function writeClipboardFromTrustedHost(text: string): Promise<void> {
* local ref before it enters the host-owned volatile cache. * local ref before it enters the host-owned volatile cache.
*/ */
function cacheArtifactContent(payload: JsonObject, artifact: { size_bytes: number; local_ref?: string }): boolean { function cacheArtifactContent(payload: JsonObject, artifact: { size_bytes: number; local_ref?: string }): boolean {
// 可选二进制内容必须是有大小上限的 base64,并且实际字节数要和 Artifact 元数据一致。
// 通过后只进入 Host 的易失缓存,不进入 Runtime 的持久化对话记录。
const encoded = payload.content_base64; const encoded = payload.content_base64;
if (encoded === undefined) return true; if (encoded === undefined) return true;
if (typeof encoded !== "string" || !artifact.local_ref || encoded.length > 8 * 1024 * 1024) return false; 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. * or silently restore a save operation without a new host-owned offer.
*/ */
function prepareArtifactOffer(item: Extract<ConversationItem, { kind: "artifact-offer" }>): Extract<ConversationItem, { kind: "artifact-offer" }> | undefined { function prepareArtifactOffer(item: Extract<ConversationItem, { kind: "artifact-offer" }>): Extract<ConversationItem, { kind: "artifact-offer" }> | undefined {
// Runtime 写入 Store 前先调用这个 Host hook:缓存内容后从持久化 payload 移除 base64
// 让刷新后仍能恢复 Artifact 元数据,但不能无提示地恢复本地文件保存动作。
const payload = item.envelope.payload; const payload = item.envelope.payload;
const localRef = typeof payload.local_ref === "string" ? payload.local_ref : undefined; const localRef = typeof payload.local_ref === "string" ? payload.local_ref : undefined;
const size = typeof payload.size_bytes === "number" ? payload.size_bytes : -1; const size = typeof payload.size_bytes === "number" ? payload.size_bytes : -1;
@@ -472,12 +490,14 @@ function prepareArtifactOffer(item: Extract<ConversationItem, { kind: "artifact-
} }
function persistCapabilityCall(record: CapabilityCallRecord, occurredAt: string): void { function persistCapabilityCall(record: CapabilityCallRecord, occurredAt: string): void {
conversationStore.replaceCapabilityCalls(capabilityCalls.snapshot()); // Capability 状态变化同时更新 Runtime 快照,并写入不含敏感参数的最小审计记录。
runtime.replaceCapabilityCalls(capabilityCalls.snapshot());
const resolution = capabilityRegistry.resolve(record.request.capability); const resolution = capabilityRegistry.resolve(record.request.capability);
if (resolution.disposition === "available") capabilityAudit.record(record, resolution.definition.risk, occurredAt); if (resolution.disposition === "available") capabilityAudit.record(record, resolution.definition.risk, occurredAt);
} }
function rejectCapabilityCall(callID: string): CapabilityCallRecord | undefined { function rejectCapabilityCall(callID: string): CapabilityCallRecord | undefined {
// 用户拒绝能力请求:只推进本地状态并向 Agent 回传受限结果,不执行 Host handler。
const now = new Date().toISOString(); const now = new Date().toISOString();
const transition = capabilityCalls.reject(callID, now); const transition = capabilityCalls.reject(callID, now);
if (transition.disposition !== "accepted" || !transition.record) return transition.record; if (transition.disposition !== "accepted" || !transition.record) return transition.record;
@@ -487,6 +507,7 @@ function rejectCapabilityCall(callID: string): CapabilityCallRecord | undefined
} }
function expireCapabilityCall(callID: string): CapabilityCallRecord | undefined { function expireCapabilityCall(callID: string): CapabilityCallRecord | undefined {
// 请求过期与拒绝类似,属于终态;过期请求不能再次被批准或执行。
const now = new Date().toISOString(); const now = new Date().toISOString();
const transition = capabilityCalls.expire(callID, now); const transition = capabilityCalls.expire(callID, now);
if (transition.disposition !== "accepted" || !transition.record) return transition.record; if (transition.disposition !== "accepted" || !transition.record) return transition.record;
@@ -496,6 +517,8 @@ function expireCapabilityCall(callID: string): CapabilityCallRecord | undefined
} }
async function approveCapabilityCall(callID: string): Promise<CapabilityCallRecord | undefined> { async function approveCapabilityCall(callID: string): Promise<CapabilityCallRecord | undefined> {
// 用户批准后仍要重新检查当前 Capability Policy,防止“显示确认卡片后策略已变化”
// 的旧请求绕过最新权限。只有状态机进入 executing 才会调用 Host handler。
let now = new Date().toISOString(); let now = new Date().toISOString();
const approval = capabilityCalls.approve(callID, now); const approval = capabilityCalls.approve(callID, now);
if (approval.disposition !== "accepted" || !approval.record) return approval.record; if (approval.disposition !== "accepted" || !approval.record) return approval.record;
@@ -536,17 +559,17 @@ async function approveCapabilityCall(callID: string): Promise<CapabilityCallReco
* idempotency boundary: only a newly accepted terminal transition queues it. * idempotency boundary: only a newly accepted terminal transition queues it.
*/ */
function queueAppResult(record: CapabilityCallRecord): void { function queueAppResult(record: CapabilityCallRecord): void {
if (!session.uid) return; // 回传给 Agent 的只是有限结果(call_id、能力名、状态和有限错误码),不包含 URL、
// 剪贴板正文、本地路径、异常堆栈或 Artifact 引用等敏感输入。
const conversationID = chatSDK.snapshot().conversation_id;
if (!conversationID) return;
const payload = safeCapabilityResult(record); const payload = safeCapabilityResult(record);
const createdAt = new Date().toISOString(); void chatSDK.dispatch({ type: "chat.report_app_result", conversation_id: conversationID, result: payload })
const envelope = makeProtocolEvent("lineup.v1.app.result", payload, currentConversationKey(), { kind: "human", id: session.uid }, { kind: "agent", id: session.agentUID }); .catch(reason => rendererContext.renderSystemMessage(storageFailureMessage(reason)));
try {
conversationStore.enqueueToolAction(newLocalID("app-result"), "app-result", JSON.stringify(envelope), createdAt);
void flushOutbox();
} catch (reason) { rendererContext.renderSystemMessage(storageFailureMessage(reason)); }
} }
function safeCapabilityResult(record: CapabilityCallRecord): JsonObject { function safeCapabilityResult(record: CapabilityCallRecord): JsonObject {
// 构造稳定、可幂等消费的能力结果投影;成功只报告 completed,失败只报告受控 code。
const outcome: JsonObject = { const outcome: JsonObject = {
call_id: record.call_id, call_id: record.call_id,
capability: record.request.capability, capability: record.request.capability,
@@ -558,6 +581,8 @@ function safeCapabilityResult(record: CapabilityCallRecord): JsonObject {
} }
function pickFileFromTrustedHost(): Promise<PickedFile | undefined> { function pickFileFromTrustedHost(): Promise<PickedFile | undefined> {
// 通过浏览器原生文件选择器获得最小文件元数据。这里不读取或保存本地真实路径,
// 用户取消选择时也必须显式结束 pending Capability,避免状态机永久执行中。
return new Promise(resolve => { return new Promise(resolve => {
const input = document.createElement("input"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
@@ -592,160 +617,19 @@ function pickFileFromTrustedHost(): Promise<PickedFile | undefined> {
}); });
} }
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 { function queueSurfaceCancel(instanceID: string): void {
if (!session.uid) return; // Surface 内部事件只转换为 Runtime Action;它不能直接发送网络消息或调用系统能力。
const createdAt = new Date().toISOString(); const conversationID = chatSDK.snapshot().conversation_id;
const envelope = makeProtocolEvent("lineup.v1.ui.event", { instance_id: instanceID, event: "cancel", data: {} }, currentConversationKey(), { kind: "human", id: session.uid }, { kind: "agent", id: session.agentUID }); if (!conversationID) return;
try { void chatSDK.dispatch({ type: "chat.cancel_surface", conversation_id: conversationID, instance_id: instanceID })
conversationStore.enqueueToolAction(newLocalID("surface"), "surface-event", JSON.stringify(envelope), createdAt); .then(receipt => {
rendererContext.renderSystemMessage("已请求取消任务。"); if (receipt.disposition === "accepted") rendererContext.renderSystemMessage("已请求取消任务。");
void flushOutbox(); })
} catch (reason) { rendererContext.renderSystemMessage(storageFailureMessage(reason)); } .catch(reason => rendererContext.renderSystemMessage(storageFailureMessage(reason)));
}
async function flushOutbox(): Promise<void> {
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<void> {
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;
} }
// 登录是唯一会创建 Runtime 远端会话的页面操作。登录成功后由 Runtime 返回恢复快照,
// main.ts 负责把快照投影到 Chat、挂载现有 Surface,并启动同步循环。
$<HTMLFormElement>("#login-form").addEventListener("submit", async event => { $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
event.preventDefault(); event.preventDefault();
const apiInput = $<HTMLInputElement>("#api"); const apiInput = $<HTMLInputElement>("#api");
@@ -759,110 +643,74 @@ $<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
if (!phone) { error.textContent = "请输入手机号。"; $<HTMLInputElement>("#phone").focus(); return; } if (!phone) { error.textContent = "请输入手机号。"; $<HTMLInputElement>("#phone").focus(); return; }
if (!code) { error.textContent = "请输入验证码。"; $<HTMLInputElement>("#code").focus(); return; } if (!code) { error.textContent = "请输入验证码。"; $<HTMLInputElement>("#code").focus(); return; }
session.api = endpoint; localStorage.setItem("lineup.api", endpoint);
localStorage.setItem("lineup.api", session.api); error.textContent = `正在连接 ${endpoint}`;
error.textContent = `正在连接 ${session.api}`;
submit.disabled = true; submit.disabled = true;
submit.textContent = "正在进入…"; submit.textContent = "正在进入…";
try { try {
const candidateTransport = new HttpTransportAdapter(session.api); // Runtime 内部负责 Transport 登录、打开 Conversation Store 和恢复 Tool/Task 状态。
const body = await candidateTransport.login({ phone, code }); const snapshot = await runtime.login({
interactionKernel.reset(); api: endpoint,
transport = candidateTransport; phone,
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 }; code,
const snapshot = conversationStore.open(currentConversationKey()); fallback_agent_uid: "agent_hermes_main",
fallback_channel_id: "agent_default_channel",
fallback_channel_type: 2,
});
// 先恢复可持久化的交互状态,再清空并重建页面投影。
surfaceInstances.restore(snapshot.surfaces); surfaceInstances.restore(snapshot.surfaces);
interactionKernel.restoreToolCalls(snapshot.tool_calls, new Date().toISOString());
interactionKernel.restoreTasks(snapshot.tasks);
capabilityCalls.restore(snapshot.capability_calls); capabilityCalls.restore(snapshot.capability_calls);
executionProgress.restore(snapshot.execution_summaries); executionProgress.restore(snapshot.execution_summaries);
conversationStore.replaceToolCalls(interactionKernel.snapshot().tool_calls);
conversationStore.replaceTasks(interactionKernel.snapshot().tasks);
session.lastSeq = snapshot.cursor;
messages.replaceChildren(); rendererContext.reset(); messages.replaceChildren(); rendererContext.reset();
loginView.hidden = true; chatView.hidden = false; loginView.hidden = true; chatView.hidden = false;
// Status envelopes describe ephemeral activity. The durable M1-08 summary // Status envelopes describe ephemeral activity. The durable M1-08 summary
// below is the only restored execution view, so historical `thinking` // below is the only restored execution view, so historical `thinking`
// messages cannot create a fresh or expanded pseudo-live card on reload. // messages cannot create a fresh or expanded pseudo-live card on reload.
// thinking 等状态消息只描述当时的临时活动,不在刷新后重新创建一张“正在执行”卡;
// 可恢复的执行摘要由下面的 restoreExecutionSummaries 统一恢复。
for (const stored of snapshot.items) { for (const stored of snapshot.items) {
if (stored.item.kind !== "agent-status") renderIncoming(stored.item); 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); for (const instance of surfaceInstances.snapshot().instances) surfaceHost.mount(instance);
rendererContext.restoreExecutionSummaries(snapshot.execution_summaries); rendererContext.restoreExecutionSummaries(snapshot.execution_summaries);
await mountM4SignedFixture(); 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); } } catch (reason) { error.textContent = loginFailureMessage(reason, endpoint); }
finally { submit.disabled = false; submit.textContent = "进入 LineUp"; } finally { submit.disabled = false; submit.textContent = "进入 LineUp"; }
}); });
// 发送消息必须通过 Chat SDK 提交 Runtime Action。Runtime 会负责本地回显、outbox、
// 作用域校验和 Transport 发送,页面只负责采集输入和显示结果。
$<HTMLFormElement>("#message-form").addEventListener("submit", async event => { $<HTMLFormElement>("#message-form").addEventListener("submit", async event => {
event.preventDefault(); event.preventDefault();
const input = $<HTMLTextAreaElement>("#message-input"); const send = $<HTMLButtonElement>("#send"); const text = input.value.trim(); const input = $<HTMLTextAreaElement>("#message-input"); const send = $<HTMLButtonElement>("#send"); const text = input.value.trim();
if (!text) return; if (!text) return;
if (!session.uid) { const conversationID = chatSDK.snapshot().conversation_id;
if (!conversationID) {
rendererContext.renderSystemMessage("当前会话尚未初始化,无法发送消息;请退出后重新登录。"); rendererContext.renderSystemMessage("当前会话尚未初始化,无法发送消息;请退出后重新登录。");
input.focus(); input.focus();
return; return;
} }
input.value = ""; send.disabled = true; 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 { try {
if (!transport) throw new Error("当前会话传输尚未初始化,请退出后重新登录。"); const receipt = await chatSDK.dispatch({ type: "chat.send_text", conversation_id: conversationID, text });
const sequence = await transport.sendText({ if (receipt.disposition === "rejected") rendererContext.renderSystemMessage("当前会话无法发送消息,请退出后重新登录。");
from_uid: session.uid, } catch (reason) {
channel_id: session.channelID, rendererContext.renderSystemMessage(storageFailureMessage(reason));
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);
} }
finally { send.disabled = false; input.focus(); } finally { send.disabled = false; input.focus(); }
}); });
$<HTMLButtonElement>("#logout").addEventListener("click", () => { session.active = false; transport = undefined; interactionKernel.reset(); conversationStore.close(); messages.replaceChildren(); rendererContext.reset(); chatView.hidden = true; loginView.hidden = false; }); // 退出时停止同步、清理 Runtime 会话和当前页面投影,但保留 localStorage 中的配置/历史,
// 以便下一次登录时由 Runtime 重新恢复。
export function makeProtocolEvent( $<HTMLButtonElement>("#logout").addEventListener("click", () => { runtime.logout(); messages.replaceChildren(); rendererContext.reset(); chatView.hidden = true; loginView.hidden = false; });
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,
};
}
@@ -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() }; }
}
@@ -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<AppInstanceState>(["starting", "foreground", "background", "suspended", "stopping"]);
/** Runtime's authoritative instance table; Apps receive only their own record. */
export class AppInstanceManager {
private readonly records = new Map<string, AppInstanceRecord>();
public create(record: Omit<AppInstanceRecord, "state" | "started_at"> & { 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<AppInstanceState, AppInstanceState[]>)[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 }; }
@@ -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" });
});
});
@@ -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() }; }
}
@@ -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<AppScope, CoreAppRecord>();
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<string>();
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);
}
@@ -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<RuntimeAppEvent, { type: "agent-message" }>) => void): Unsubscribe;
subscribeAgentMessages(
listener: (message: AgentChatMessage) => void,
): Unsubscribe;
listAgentMessages(afterMessageID?: string): readonly AgentChatMessage[];
acknowledgeAgentMessage(messageID: string): boolean;
dispatch(action: ChatAction): Promise<CommandReceipt>;
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<CommandReceipt>;
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;
};
@@ -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<string, string>();
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 = '<main id="chat-view"><form id="login-form"></form></main>';
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"');
});
});
@@ -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<AppScope, CoreAppHostFactory>();
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);
}
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { ArtifactContentCache } from "./artifact-content-cache"; import { ArtifactContentCache } from "@/runtime/artifacts/artifact-content-cache";
describe("ArtifactContentCache", () => { describe("ArtifactContentCache", () => {
it("accepts only host-owned opaque references and never exposes paths", () => { it("accepts only host-owned opaque references and never exposes paths", () => {
@@ -1,4 +1,9 @@
/** /**
* Host Artifact
*
* ArtifactState 宿 Conversation Store
* Agent local_ref
*
* Host-owned volatile bytes for Artifact metadata already accepted by * Host-owned volatile bytes for Artifact metadata already accepted by
* ArtifactState. This cache has no network, persistence, DOM or protocol * ArtifactState. This cache has no network, persistence, DOM or protocol
* dependency: an Agent can name an opaque ref but cannot populate or read it. * dependency: an Agent can name an opaque ref but cannot populate or read it.
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { ArtifactState } from "./artifact-state"; import { ArtifactState } from "@/runtime/artifacts/artifact-state";
describe("ArtifactState", () => { describe("ArtifactState", () => {
it("keeps only bounded metadata and rejects paths or duplicate offers", () => { it("keeps only bounded metadata and rejects paths or duplicate offers", () => {
@@ -1,3 +1,9 @@
/**
* Artifact
*
* MIME DOM
*
*/
export type ArtifactIntegrity = "verified" | "unverified" | "failed"; export type ArtifactIntegrity = "verified" | "unverified" | "failed";
export type ArtifactRecord = Readonly<{ export type ArtifactRecord = Readonly<{
artifact_id: string; artifact_id: string;
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { CapabilityAuditLog } from "./capability-audit"; import { CapabilityAuditLog } from "@/runtime/capabilities/capability-audit";
describe("CapabilityAuditLog", () => { describe("CapabilityAuditLog", () => {
it("records authority metadata but never arguments or result values", () => { it("records authority metadata but never arguments or result values", () => {
@@ -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<{ export type CapabilityAuditEntry = Readonly<{
call_id: string; call_id: string;
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { CapabilityCallStateMachine, type CapabilityCallRequest } from "./capability-call-state"; import { CapabilityCallStateMachine, type CapabilityCallRequest } from "@/runtime/capabilities/capability-call-state";
import { parseCapabilityCall } from "./capability-call-state"; import { parseCapabilityCall } from "@/runtime/capabilities/capability-call-state";
import { CapabilityRegistry } from "./capability-registry"; import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry";
const now = "2026-08-03T00:00:00.000Z"; 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" } }; 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" } };
@@ -1,6 +1,12 @@
import type { JsonObject } from "../protocol"; /**
import type { CapabilityDefinition } from "./capability-registry"; * Capability
import { CapabilityRegistry } from "./capability-registry"; *
* 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"; export type CapabilityCallStatus = "pending" | "approved" | "executing" | "completed" | "rejected" | "cancelled" | "expired" | "unsupported" | "failed";
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { ArtifactState } from "./artifact-state"; import { ArtifactState } from "@/runtime/artifacts/artifact-state";
import { CapabilityExecutor } from "./capability-executor"; import { CapabilityExecutor } from "@/runtime/capabilities/capability-executor";
import type { CapabilityCallRecord } from "./capability-call-state"; import type { CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state";
const now = "2026-08-03T00:00:00.000Z"; const now = "2026-08-03T00:00:00.000Z";
function call(capability: CapabilityCallRecord["request"]["capability"], argumentsValue: Record<string, string>): CapabilityCallRecord { function call(capability: CapabilityCallRecord["request"]["capability"], argumentsValue: Record<string, string>): CapabilityCallRecord {
@@ -1,6 +1,12 @@
import type { JsonObject } from "../protocol"; /**
import type { ArtifactState } from "./artifact-state"; * Host
import type { CapabilityCallRecord } from "./capability-call-state"; *
* 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 PickedFile = Readonly<{ name: string; mime_type: string; size_bytes: number }>;
export type CapabilityHostHandlers = Readonly<{ export type CapabilityHostHandlers = Readonly<{
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { CapabilityRegistry } from "./capability-registry"; import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry";
describe("CapabilityRegistry", () => { describe("CapabilityRegistry", () => {
it("declares requestability without making a capability executable", () => { it("declares requestability without making a capability executable", () => {
@@ -1,4 +1,10 @@
import type { JsonObject } from "../protocol"; /**
* Host
*
* Runtime schema Agent InventorySurface Agent
*
*/
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
export type CapabilityRisk = "display_only" | "user_confirmation" | "system_permission" | "restricted"; export type CapabilityRisk = "display_only" | "user_confirmation" | "system_permission" | "restricted";
@@ -1,4 +1,9 @@
/** /**
* AppServer
*
* HTTP
* 使 `fetch` `Response`便 Runtime/App
*
* The Transport Adapter is the only M0 boundary that knows the current * The Transport Adapter is the only M0 boundary that knows the current
* AppServer HTTP endpoints. Its callers work with conversation data and do * AppServer HTTP endpoints. Its callers work with conversation data and do
* not receive Response objects or call fetch directly, so this contract can * not receive Response objects or call fetch directly, so this contract can
@@ -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 } : {}) };
}
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; 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 T0 = "2026-08-03T00:00:00.000Z";
const T1 = "2026-08-03T00:00:18.000Z"; const T1 = "2026-08-03T00:00:18.000Z";
@@ -1,4 +1,9 @@
/** /**
* Agent
*
* Adapter URL
* CoT /
*
* Display-safe projection of one Agent turn. * Display-safe projection of one Agent turn.
* *
* The client never turns status text, model reasoning, commands, arguments, * The client never turns status text, model reasoning, commands, arguments,
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { decodeConversationItem, parseEnvelopeJSON } from "../protocol"; import { decodeConversationItem, parseEnvelopeJSON } from "@/runtime/protocol/lineup-v1";
import { InteractionKernel } from "./interaction-kernel"; import { InteractionKernel } from "@/runtime/coordination/interaction-kernel";
function envelope(overrides: Record<string, unknown> = {}): string { function envelope(overrides: Record<string, unknown> = {}): string {
return JSON.stringify({ return JSON.stringify({
@@ -1,12 +1,18 @@
/**
* Runtime
*
* ConversationItem envelope Agent
* Tool/Task DOM
*/
import { import {
decodeConversationItem, decodeConversationItem,
type Actor, type Actor,
type ConversationItem, type ConversationItem,
type JsonObject, type JsonObject,
type ToolCallFinalStatus, type ToolCallFinalStatus,
} from "../protocol"; } from "@/runtime/protocol/lineup-v1";
import { ToolCallStateMachine, type ToolCallRecord } from "./tool-call-state"; import { ToolCallStateMachine, type ToolCallRecord } from "@/runtime/coordination/tool-call-state";
import { TaskStateMachine, type TaskRecord } from "./task-state"; import { TaskStateMachine, type TaskRecord } from "@/runtime/coordination/task-state";
/** Transport metadata is intentionally opaque to the protocol and renderer. */ /** Transport metadata is intentionally opaque to the protocol and renderer. */
export type IncomingMessage = { export type IncomingMessage = {
@@ -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<string, string>();
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<LoginResult> {
return { uid: "user_1", agent_uid: "agent_1", channel_id: "channel_1", channel_type: 2 };
}
public async sendText(request: SendTextRequest): Promise<number | undefined> {
this.sent.push(request);
return 91 + this.sent.length;
}
public async sync(_request: SyncRequest): Promise<readonly TransportMessage[]> {
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, unknown> = {}): 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" }) }) })]);
});
});
@@ -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<void>;
/** 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<void>;
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<ConversationSnapshot> {
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<void> {
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<void> {
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<OutboxEntry["kind"], "text">,
type: string,
payload: JsonObject,
): Promise<CommandReceipt> {
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<void> {
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<CommandReceipt> {
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<void> {
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<string, string | number>): 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;
}
}
@@ -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 } : {}),
},
};
}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; 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"; const TIME = "2026-08-03T09:00:00.000Z";
@@ -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 & { export type TaskRecord = TaskProgress & {
conversation_id: string; conversation_id: string;
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { ToolCallRequest } from "../protocol"; import type { ToolCallRequest } from "@/runtime/protocol/lineup-v1";
import { ToolCallStateMachine } from "./tool-call-state"; import { ToolCallStateMachine } from "@/runtime/coordination/tool-call-state";
const RECEIVED_AT = "2026-08-03T08:00:00.000Z"; const RECEIVED_AT = "2026-08-03T08:00:00.000Z";
@@ -1,10 +1,16 @@
/**
* Tool Call
*
* `call_id` choiceconfirminput
* Store Runtime Action/outbox
*/
import type { import type {
JsonObject, JsonObject,
ToolCallCancel, ToolCallCancel,
ToolCallFinalStatus, ToolCallFinalStatus,
ToolCallRequest, ToolCallRequest,
ToolCallResult, ToolCallResult,
} from "../protocol"; } from "@/runtime/protocol/lineup-v1";
export type ToolCallStatus = "pending" | "submitted" | ToolCallFinalStatus; export type ToolCallStatus = "pending" | "submitted" | ToolCallFinalStatus;
@@ -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<string, unknown> = {}) {
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" });
});
});
@@ -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; }
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { CapabilityRegistry } from "./capability-registry"; import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry";
import { ClientInventoryPublisher } from "./client-inventory"; import { ClientInventoryPublisher } from "@/runtime/inventory/client-inventory";
import { SurfaceRegistry } from "./surface-registry"; import { SurfaceRegistry } from "@/runtime/surfaces/surface-registry";
describe("ClientInventoryPublisher", () => { describe("ClientInventoryPublisher", () => {
it("publishes only enabled apps and policy-visible capabilities", () => { 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; const inventory = new ClientInventoryPublisher().sync(new SurfaceRegistry().snapshot(), new CapabilityRegistry().inventory()).inventory;
expect(inventory.capabilities.map(capability => capability.name)).not.toContain("artifact.save"); 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");
});
}); });
@@ -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<{ export type ClientInventory = Readonly<{
revision: string; revision: string;
@@ -7,7 +14,8 @@ export type ClientInventory = Readonly<{
applications: readonly Readonly<{ applications: readonly Readonly<{
id: string; id: string;
version: 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[]; capabilities: readonly CapabilityDefinition[];
}>; }>;
@@ -23,9 +31,14 @@ export class ClientInventoryPublisher {
private previousFingerprint = ""; private previousFingerprint = "";
private current: ClientInventory | undefined; private current: ClientInventory | undefined;
public sync(surfaces: SurfaceRegistrySnapshot, capabilities: readonly CapabilityDefinition[]): { changed: boolean; inventory: ClientInventory } { public sync(surfaces: SurfaceRegistrySnapshot, capabilities: readonly CapabilityDefinition[], apps: readonly CoreAppRecord[] = []): { changed: boolean; inventory: ClientInventory } {
const applications = surfaces.enabled const surfaceApplications = surfaces.enabled
.map(enabled => ({ id: enabled.app_id, version: enabled.version, surfaces: [{ id: "task-dashboard" as const, events: ["cancel"] as ["cancel"] }] })) .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}`)); .sort((left, right) => `${left.id}@${left.version}`.localeCompare(`${right.id}@${right.version}`));
const allowedCapabilities = [...capabilities] const allowedCapabilities = [...capabilities]
.sort((left, right) => left.name.localeCompare(right.name)) .sort((left, right) => left.name.localeCompare(right.name))
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { ToolCallRecord } from "./tool-call-state"; import type { ToolCallRecord } from "@/runtime/coordination/tool-call-state";
import type { TaskRecord } from "./task-state"; import type { TaskRecord } from "@/runtime/coordination/task-state";
import { ConversationStore } from "./conversation-store"; import { ConversationStore } from "@/runtime/persistence/conversation-store";
class MemoryStorage implements Storage { class MemoryStorage implements Storage {
private readonly values = new Map<string, string>(); private readonly values = new Map<string, string>();
@@ -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", () => { it("removes legacy Artifact content bytes from memory and durable storage on open", () => {
const storage = new MemoryStorage(); const storage = new MemoryStorage();
const key = "lineup.conversation.v1:user_test:2:agent_channel"; const key = "lineup.conversation.v1:user_test:2:agent_channel";
@@ -202,7 +249,7 @@ describe("ConversationStore", () => {
updated_at: createdAt, updated_at: createdAt,
steps: [], steps: [],
detail: "do not persist this", 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"); expect(JSON.stringify(store.snapshot().execution_summaries)).not.toContain("do not persist this");
}); });
@@ -1,9 +1,17 @@
import type { ConversationItem, DeliveryState, JsonObject } from "../protocol"; /**
import type { ToolCallRecord } from "./tool-call-state"; * Runtime
import type { TaskRecord } from "./task-state"; *
import type { ExecutionProgressSummary } from "./execution-progress-state"; * cursortyped outboxTool/Task App Inbox
import type { SurfaceInstanceSnapshot } from "./surface-instance-manager"; * DOMApp Runtime ACK
import { sanitizeCapabilityReason, type CapabilityCallRecord } from "./capability-call-state"; */
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 = { export type StoredConversationItem = {
local_id: string; local_id: string;
@@ -19,6 +27,19 @@ export type OutboxEntry = {
created_at: string; 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 = { export type ConversationSnapshot = {
conversation_id: string; conversation_id: string;
cursor: number; cursor: number;
@@ -31,10 +52,13 @@ export type ConversationSnapshot = {
execution_summaries: readonly ExecutionProgressSummary[]; execution_summaries: readonly ExecutionProgressSummary[];
surfaces: SurfaceInstanceSnapshot; surfaces: SurfaceInstanceSnapshot;
capability_calls: readonly CapabilityCallRecord[]; capability_calls: readonly CapabilityCallRecord[];
app_inbox: readonly AppInboxEntry[];
/** Runtime-owned instance/focus state. Never supplied by an App. */
app_workspace: AppWorkspaceSnapshot;
}; };
type PersistedConversation = { type PersistedConversation = {
version: 10; version: 12;
cursor: number; cursor: number;
items: StoredConversationItem[]; items: StoredConversationItem[];
outbox: OutboxEntry[]; outbox: OutboxEntry[];
@@ -44,6 +68,8 @@ type PersistedConversation = {
execution_summaries: ExecutionProgressSummary[]; execution_summaries: ExecutionProgressSummary[];
surfaces: SurfaceInstanceSnapshot; surfaces: SurfaceInstanceSnapshot;
capability_calls: CapabilityCallRecord[]; capability_calls: CapabilityCallRecord[];
app_inbox: AppInboxEntry[];
app_workspace: AppWorkspaceSnapshot;
}; };
const STORE_PREFIX = "lineup.conversation.v1"; const STORE_PREFIX = "lineup.conversation.v1";
@@ -89,6 +115,8 @@ export class ConversationStore {
execution_summaries: clone(this.state.execution_summaries), execution_summaries: clone(this.state.execution_summaries),
surfaces: clone(this.state.surfaces), surfaces: clone(this.state.surfaces),
capability_calls: clone(this.state.capability_calls), 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(); 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 { public appendLocalText(localID: string, text: string, createdAt: string): StoredConversationItem {
const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt }; const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt };
const stored: StoredConversationItem = { const stored: StoredConversationItem = {
@@ -246,19 +302,39 @@ export class ConversationStore {
try { try {
const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null"); const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyConversation(); 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 }; 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) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") { 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(); 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 // v1 advanced its cursor from a send acknowledgement. Some browsers
// had already been upgraded to v2 before that correction, retaining a // had already been upgraded to v2 before that correction, retaining a
// high cursor with holes for their own echoes. Preserve every valid // high cursor with holes for their own echoes. Preserve every valid
// local item but replay the server history once to repair those holes; // local item but replay the server history once to repair those holes;
// the next persist upgrades this record to v5. // the next persist upgrades this record to v5.
return { return {
version: 10, version: 12,
cursor: candidate.version === 4 || candidate.version === 5 || candidate.version === 6 || candidate.version === 7 ? Math.max(0, candidate.cursor) : 0, cursor: candidate.version === 4 || candidate.version === 5 || candidate.version === 6 || candidate.version === 7 ? Math.max(0, candidate.cursor) : 0,
items: normalizeStoredItems(candidate.items), items: normalizeStoredItems(candidate.items),
outbox: normalizeOutbox(candidate.outbox), outbox: normalizeOutbox(candidate.outbox),
@@ -269,10 +345,12 @@ export class ConversationStore {
execution_summaries: [], execution_summaries: [],
surfaces: emptySurfaces(), surfaces: emptySurfaces(),
capability_calls: [], capability_calls: [],
app_inbox: [],
app_workspace: emptyAppWorkspace(),
}; };
} }
return { return {
version: 10, version: 12,
cursor: Math.max(0, candidate.cursor), cursor: Math.max(0, candidate.cursor),
items: normalizeStoredItems(candidate.items), items: normalizeStoredItems(candidate.items),
outbox: normalizeOutbox(candidate.outbox), outbox: normalizeOutbox(candidate.outbox),
@@ -282,6 +360,8 @@ export class ConversationStore {
execution_summaries: normalizeExecutionSummaries(candidate.execution_summaries), execution_summaries: normalizeExecutionSummaries(candidate.execution_summaries),
surfaces: normalizeSurfaces(candidate.surfaces), surfaces: normalizeSurfaces(candidate.surfaces),
capability_calls: normalizeCapabilityCalls(candidate.capability_calls), capability_calls: normalizeCapabilityCalls(candidate.capability_calls),
app_inbox: normalizeAppInbox(candidate.app_inbox, conversationID),
app_workspace: normalizeAppWorkspace(candidate.app_workspace),
}; };
} catch { } catch {
return emptyConversation(); return emptyConversation();
@@ -303,11 +383,18 @@ export class ConversationStore {
} }
function emptyConversation(): PersistedConversation { 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 emptySurfaces(): SurfaceInstanceSnapshot { return { version: 1, instances: [] }; }
function normalizeSurfaces(value: unknown): SurfaceInstanceSnapshot { return value && typeof value === "object" && !Array.isArray(value) ? value as SurfaceInstanceSnapshot : emptySurfaces(); } 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<AppWorkspaceSnapshot>;
if (candidate.version !== 1 || !candidate.instances || !candidate.focus) return emptyAppWorkspace();
return candidate as AppWorkspaceSnapshot;
}
function normalizeOutbox(value: unknown): OutboxEntry[] { function normalizeOutbox(value: unknown): OutboxEntry[] {
if (!Array.isArray(value)) return []; 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<string>();
return value.flatMap(raw => {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
const entry = raw as Partial<AppInboxEntry>;
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. * Artifact content is delivery-only data for the host-owned volatile cache.
* It must never survive in the durable conversation transcript, including * It must never survive in the durable conversation transcript, including
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { decodeConversationItem, type ConversationItem, type JsonObject } from "../protocol"; import { decodeConversationItem, type ConversationItem, type JsonObject } from "@/runtime/protocol/lineup-v1";
import { InteractionKernel } from "./interaction-kernel"; import { InteractionKernel } from "@/runtime/coordination/interaction-kernel";
import fixture from "./golden/lineup-v1.json"; import fixture from "@/runtime/protocol/golden/lineup-v1.json";
type GoldenExpected = Readonly<{ type GoldenExpected = Readonly<{
item_kind: ConversationItem["kind"]; item_kind: ConversationItem["kind"];
@@ -1,4 +1,9 @@
/** /**
* LineUp v1
*
* payload ConversationItem DOMTauri
* Runtime app_scopeconversation_id App
*
* Platform-neutral LineUp v1 protocol model. * Platform-neutral LineUp v1 protocol model.
* *
* This module deliberately contains no DOM, network, Tauri, or storage code. * This module deliberately contains no DOM, network, Tauri, or storage code.
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { CachedVerifiedSurfaceDocumentResolver, parseReadyMessage, productionSurfaceDocument, surfaceDocument } from "./isolated-surface-host"; import { CachedVerifiedSurfaceDocumentResolver, parseReadyMessage, productionSurfaceDocument, surfaceDocument } from "@/runtime/surfaces/isolated-surface-host";
import { VerifiedSurfaceBundleCache, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "./surface-bundle-cache"; import { VerifiedSurfaceBundleCache, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "@/runtime/surfaces/surface-bundle-cache";
describe("isolated Surface bridge contract", () => { describe("isolated Surface bridge contract", () => {
it("accepts only the exact minimal ready message", () => { it("accepts only the exact minimal ready message", () => {
@@ -1,6 +1,12 @@
import type { JsonObject } from "../protocol"; /**
import type { SurfaceInstance } from "./surface-instance-manager"; * Surface iframe Host
import type { VerifiedSurfaceBundleCache } from "./surface-bundle-cache"; *
* Host Bundle opaque-origin iframe bridge ready
* Surface DOMTauri 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<{ export type SurfaceHostCallbacks = Readonly<{
ready: (instanceID: string) => void; ready: (instanceID: string) => void;
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; 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 = { const manifest = {
v: 1, app_id: "lineup.task-dashboard", version: "1.2.0", v: 1, app_id: "lineup.task-dashboard", version: "1.2.0",
@@ -1,4 +1,9 @@
/** /**
* Surface Manifest
*
* Manifest Bundle URL
* Agent/
*
* M4 production Surface declaration. A manifest is only immutable metadata; * M4 production Surface declaration. A manifest is only immutable metadata;
* it contains neither a bundle URL nor executable source. Download, signature * it contains neither a bundle URL nor executable source. Download, signature
* verification and cache installation are deliberately separate M4-02 steps. * verification and cache installation are deliberately separate M4-02 steps.
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { ProductionSurfaceManifestRegistry } from "./production-surface-manifest"; import { ProductionSurfaceManifestRegistry } from "@/runtime/surfaces/production-surface-manifest";
import { ProductionSurfacePolicy } from "./production-surface-policy"; import { ProductionSurfacePolicy } from "@/runtime/surfaces/production-surface-policy";
import { SurfaceInstanceManager } from "./surface-instance-manager"; import { SurfaceInstanceManager } from "@/runtime/surfaces/surface-instance-manager";
import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "./surface-bundle-cache"; import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache, type SurfaceBundleDigest, type SurfaceManifestSignatureVerifier } from "@/runtime/surfaces/surface-bundle-cache";
const bytes = new TextEncoder().encode("<main>verified</main>"); const bytes = new TextEncoder().encode("<main>verified</main>");
const digest: SurfaceBundleDigest = { async sha256() { return "a".repeat(64); } }; const digest: SurfaceBundleDigest = { async sha256() { return "a".repeat(64); } };
@@ -1,7 +1,13 @@
import type { JsonObject } from "../protocol"; /**
import { parseProductionSurfaceManifest, ProductionSurfaceManifestRegistry, type ProductionManifestDisposition } from "./production-surface-manifest"; * Surface
import { TrustedSurfaceBundleDownloader, VerifiedSurfaceBundleCache } from "./surface-bundle-cache"; *
import type { SurfaceAdmissionPolicy, SurfaceRegistryDisposition, SurfaceRegistryResult } from "./surface-registry"; * 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<{ export type ProductionSurfacePreparation = Readonly<{
disposition: ProductionManifestDisposition | "installed" | "download_failed" | "signature_invalid" | "size_mismatch" | "digest_mismatch" | "invalid_manifest"; disposition: ProductionManifestDisposition | "installed" | "download_failed" | "signature_invalid" | "size_mismatch" | "digest_mismatch" | "invalid_manifest";
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; 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 bytes = new Uint8Array([1, 2, 3, 4]);
const digest: SurfaceBundleDigest = { async sha256(value) { return value.join("").padEnd(64, "0"); } }; const digest: SurfaceBundleDigest = { async sha256(value) { return value.join("").padEnd(64, "0"); } };
@@ -1,4 +1,10 @@
import { canonicalManifestPayload, parseProductionSurfaceManifest, type ProductionSurfaceManifest } from "./production-surface-manifest"; /**
* Surface Bundle
*
* Bundle ManifestEd25519 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 BundleVerificationDisposition = "installed" | "duplicate" | "invalid_manifest" | "download_failed" | "signature_invalid" | "size_mismatch" | "digest_mismatch";
export type BundleInstallResult = Readonly<{ disposition: BundleVerificationDisposition; entry?: InstalledSurfaceBundle }>; export type BundleInstallResult = Readonly<{ disposition: BundleVerificationDisposition; entry?: InstalledSurfaceBundle }>;
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { SurfaceInstanceManager } from "./surface-instance-manager"; import { SurfaceInstanceManager } from "@/runtime/surfaces/surface-instance-manager";
import { SurfaceRegistry } from "./surface-registry"; import { SurfaceRegistry } from "@/runtime/surfaces/surface-registry";
const APP = "lineup.task-dashboard"; const APP = "lineup.task-dashboard";
const VERSION = "0.1.0"; const VERSION = "0.1.0";
@@ -1,9 +1,15 @@
import type { JsonObject } from "../protocol"; /**
* Surface
*
* `instance_id` openreadypatchclose restore App//
*
*/
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
import { import {
parseSurfaceOpenRequest, parseSurfaceOpenRequest,
type SurfaceAdmissionPolicy, type SurfaceAdmissionPolicy,
type SurfaceRegistryDisposition, type SurfaceRegistryDisposition,
} from "./surface-registry"; } from "@/runtime/surfaces/surface-registry";
export type SurfaceInstancePhase = "opening" | "ready"; export type SurfaceInstancePhase = "opening" | "ready";
@@ -3,7 +3,7 @@ import {
DEVELOPMENT_SURFACE_MANIFESTS, DEVELOPMENT_SURFACE_MANIFESTS,
SurfaceRegistry, SurfaceRegistry,
parseSurfaceOpenRequest, parseSurfaceOpenRequest,
} from "./surface-registry"; } from "@/runtime/surfaces/surface-registry";
const APP = "lineup.task-dashboard"; const APP = "lineup.task-dashboard";
const VERSION = "0.1.0"; const VERSION = "0.1.0";
@@ -1,4 +1,10 @@
import type { JsonObject } from "../protocol"; /**
* Surface
*
* Host App/ payload HTMLJavaScriptURL
* Bundle 沿
*/
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
/** /**
* M2-01 development-time Surface declaration. These records are authored by * M2-01 development-time Surface declaration. These records are authored by
+4
View File
@@ -6,6 +6,10 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true, "skipLibCheck": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"allowImportingTsExtensions": false, "allowImportingTsExtensions": false,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
+13
View File
@@ -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",
},
},
});
-13
View File
@@ -1,13 +0,0 @@
# LineUp Wails v3 对照 SpikeM4-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 bindingsCapability 只能由顶层 Host 在显式同意后执行。
本 Spike 的边界和后续准入:
- 已验证:beta.3 Go module、Wails application API 的 Windows/amd64 交叉编译、共享 golden fixture 的本机可执行投影、Surface source 字段拒绝。
- 尚不作为生产批准:完整窗口隔离/CSP、签名 artifact 加载、原生 capability handler、移动端和发布包,需要在 Wails 版本稳定后用与 Tauri 相同的有头验收清单完成。
- 这不改变 Android 当前暂缓状态,也不改变 LineUp v1 协议。
-129
View File
@@ -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
}
-14
View File
@@ -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
)
-16
View File
@@ -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=
-30
View File
@@ -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("<!doctype html><meta charset=\"utf-8\"><title>LineUp Wails Contract Spike</title><main>Wails contract spike</main>"))
})},
})
app.Window.NewWithOptions(application.WebviewWindowOptions{
Title: "LineUp Wails Contract Spike",
URL: "/",
})
return app
}
func main() {
_ = newSpikeApplication().Run()
}
-151
View File
@@ -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
}
+159
View File
@@ -0,0 +1,159 @@
# LineUp App 程序文件清单与功能说明
> 最后核验:2026-08-04Asia/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。两种外壳都会启动同一个
RuntimeRuntime 再加载默认 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 SDKCore 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` | 生产 Manifestimmutable 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 HostTailscale 开发)
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):架构决策的唯一汇总入口。
+124
View File
@@ -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 AppTauri 用于正式桌面交付,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` 中。
+293
View File
@@ -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 AppCore 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 Modeforeground
```
用户说:“我们来玩一局你画我猜吧。” 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 个测试和生产构建不能退化。
```
+733
View File
@@ -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 Providernotice / choice / confirm / input
```
当前实现仍保持兼容名称:
```text
产品概念:Interact MiniApp
当前 app_scopechat
当前目录: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<CommandReceipt>;
complete(call_id: string, result: JsonObject): Promise<CommandReceipt>;
fail(call_id: string, failure: MiniAppToolFailure): Promise<CommandReceipt>;
cancel(call_id: string, reason?: string): Promise<CommandReceipt>;
}
```
每一个调用都必须有 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<CommandReceipt>;
requestBackground(): Promise<CommandReceipt>;
requestClose(reason?: string): Promise<CommandReceipt>;
}
```
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<InteractionReceipt>;
get(interaction_id: string): StandardInteractionRecord | undefined;
subscribe(listener: (record: StandardInteractionRecord) => void): Unsubscribe;
cancel(interaction_id: string, reason?: string): Promise<CommandReceipt>;
}
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<CommandReceipt>;
update(surface_id: string, patch: JsonObject): Promise<CommandReceipt>;
requestClose(surface_id: string): Promise<CommandReceipt>;
}
interface MiniAppCapabilityRequestAPI {
request(request: {
capability: string;
reason: string;
arguments: JsonObject;
}): Promise<CommandReceipt>;
}
```
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 DashboardTool 与生命周期样本
**身份:** `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。
```
+287
View File
@@ -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 → ownersdk.ui 状态更新
ownerlegacy 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. 先决定 **B1Task Dashboard 的信任级别与 UI 容器**
2. 冻结 **I2Renderer → Runtime 的提交/Bridge 契约**
3. 冻结 **I3/I4StandardInteractionRecord 与 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 专用旁路。