Compare commits

...

41 Commits

Author SHA1 Message Date
kyugao 91982fac33 docs: freeze runtime workspace iteration design 2026-08-06 14:39:19 +08:00
kyugao aff5c8e3aa docs(iteration): define runtime workspace milestone 2026-08-06 12:17:45 +08:00
kyugao 89626546b9 docs(iteration): make pomodoro the next validation app 2026-08-06 12:15:49 +08:00
kyugao ef528a147b docs(iteration): add post-sdk roadmap 2026-08-06 12:06:04 +08:00
kyugao 359dc1bb66 build(lineup-app): adapt client to TypeScript 6 2026-08-06 11:50:08 +08:00
kyugao 934bccb274 docs(lineup-app): reorganize program inventory by module 2026-08-06 11:37:56 +08:00
kyugao 7e94713174 docs(lineup-app): organize current design documents 2026-08-06 11:34:51 +08:00
kyugao 81470ffea7 tailscale network 2026-08-06 11:17:15 +08:00
kyugao 4df556a175 chore(tauri): add generated android project 2026-08-06 11:08:31 +08:00
kyugao 0601c2ced1 fix(chat): close all active execution summaries on turn end 2026-08-06 10:48:53 +08:00
kyugao 9f5ea6eb4a docs(iteration): record fifth sdk acceptance 2026-08-06 01:38:22 +08:00
kyugao ca9e8b9791 docs(iteration): record final sdk test count 2026-08-06 01:37:39 +08:00
kyugao fec04a90d7 fix(runtime): restrict system miniapp registration 2026-08-06 01:37:07 +08:00
kyugao e91d07b389 fix(runtime): enforce all miniapp tool permissions 2026-08-06 01:36:30 +08:00
kyugao 94817f798c docs(iteration): refresh sdk acceptance evidence 2026-08-06 01:34:17 +08:00
kyugao b80af9679d docs(iteration): correct bundled acceptance wording 2026-08-06 01:32:03 +08:00
kyugao 44b6a83a30 fix(runtime): freeze miniapp kind vocabulary 2026-08-06 01:31:38 +08:00
kyugao b81a0edcca docs(iteration): mark sdk v1 iteration complete 2026-08-06 01:25:37 +08:00
kyugao aabf58a289 docs(iteration): record completed miniapp bridge e2e 2026-08-06 01:21:44 +08:00
kyugao c7c18168b6 fix(runtime): accept camelcase miniapp bridge methods 2026-08-06 01:20:41 +08:00
kyugao c4613ce14e docs(iteration): retain appserver blocker for surface acceptance 2026-08-06 01:05:28 +08:00
kyugao 180d03896f docs(iteration): record browser surface acceptance 2026-08-06 01:04:18 +08:00
kyugao 20e63dc0dd refactor(runtime): remove legacy extension sdk bypass 2026-08-06 00:55:17 +08:00
kyugao 75529cdd49 fix(runtime): include app session context in lifecycle events 2026-08-06 00:53:31 +08:00
kyugao 0b7257f3d3 fix(runtime): persist miniapp tool progress 2026-08-06 00:51:52 +08:00
kyugao 6fa350267a fix(iteration): align reference miniapp tool names 2026-08-06 00:50:21 +08:00
kyugao 309dd6161d fix(runtime): unify standard interaction terminal states 2026-08-06 00:44:01 +08:00
kyugao 823e71a162 refactor(interact): separate runtime actions from ui projection 2026-08-06 00:32:59 +08:00
kyugao c77a0cf69b feat(runtime): enforce miniapp capability policy 2026-08-06 00:23:11 +08:00
kyugao 58e77462ad feat(runtime): isolate bundled miniapps behind bridge 2026-08-06 00:19:36 +08:00
kyugao a68b7cf3bf fix(runtime): isolate channel messages by target 2026-08-05 19:03:34 +08:00
kyugao 7cac422a31 docs(iteration): clarify final acceptance status 2026-08-05 18:47:18 +08:00
kyugao 121ee6ac73 fix(runtime): align AppServer conversation scope 2026-08-05 18:47:00 +08:00
kyugao d2a2fd0aff feat(iteration): implement sdk v1 miniapp runtime loop 2026-08-05 18:30:29 +08:00
kyugao 486280bec0 docs(iteration): freeze sdk v1 contract fixtures 2026-08-05 16:36:01 +08:00
kyugao 9fb8cd1598 feat(runtime): establish miniapp kernel and sdk design 2026-08-05 00:46:16 +08:00
kyugao d8aa087bc8 feat(m4): verify signed surface bundles across hosts 2026-08-04 01:51:23 +08:00
kyugao 4fc83e5b13 feat(m3): add consent-gated capability runtime 2026-08-04 01:09:20 +08:00
kyugao ccff57b2a1 feat(m2): ship isolated task dashboard surface 2026-08-03 23:11:29 +08:00
kyugao 9fd321c41c feat(m1-08): stream safe execution summaries 2026-08-03 23:10:49 +08:00
kyugao ffda402861 feat: reliably deliver tool interaction responses 2026-08-03 16:33:38 +08:00
159 changed files with 20811 additions and 1188 deletions
-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`
+95 -23
View File
@@ -1,46 +1,118 @@
# 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
cd lineup-app/tauri
npm run web:dev
```
Mac 浏览器通过 Tailscale 打开 `http://100.121.118.116:1420`,即可看到 Linux 工作区的 Vite 热更新。Mac 只在首次配置、切换开发机或需要本地桌面打包时再 clone / pull 源码
当前开发地址
```bash
git clone ssh://git@100.121.118.116:2222/lineup/app.git ~/Workspace/lineup-app
cd ~/Workspace/lineup-app
git pull --ff-only origin main
```text
http://100.121.118.116:1420/
```
`tauri/node_modules/``tauri/dist/``tauri/src-tauri/target/` 均为本机可再生文件,已被忽略;每台机器按自身架构重新安装和构建。
常用命令:
```bash
# Runtime / SDK / Renderer 自动化回归
npm test -- --run
# TypeScript 检查与 Web production build
npm run build
# Tauri 桌面 Host 开发
npm run desktop:dev
```
`tauri/node_modules/``tauri/dist/``tauri/src-tauri/target/` 都是可再生本机构建产物,
已被忽略。
## 文档入口
- [DESIGN.md](DESIGN.md):Android 实验阶段的产品、连接与交互设计
- [TECHNOLOGY_SELECTION.md](TECHNOLOGY_SELECTION.md):已确认的客户端与 Mini Runtime 技术路线;Tauri 2 是第一优先的 Reference Host SpikeWails v3 在同一基线后续评估
- [tauri/README.md](tauri/README.md)Tauri 2 Reference Host 的实现、构建状态和下一阶段范围
- [LineUp App 层架构设计方案](../设计/02.正式方案/lineup-app-layer-architecture.md):跨端 App Shell、Interaction Kernel、Renderer、Surface 与 Capability 的正式架构
- [LineUp UI Surface 与 App Capability 协议](../设计/02.正式方案/lineup-ui-surface-protocol.md):小程序 Surface 和 Agent 调 App 能力的协议定义。
- [设计/README.md](设计/README.md):当前设计文档入口和文档分工说明
- [APP架构设计.md](设计/APP架构设计.md)LineUp App、Runtime、MiniApp SDK、Surface/Capability 安全与后续迭代的权威架构设计
- [程序文件清单与功能说明.md](程序文件清单与功能说明.md):客户端 Runtime、Chat Core App、Tauri/Web Host 与测试的文件清单
- [tauri/src/README.md](tauri/src/README.md):源码目录、依赖方向与模块放置规则
- [tauri/README.md](tauri/README.md)Tauri/Web Host 的运行、构建、行为回归和历史里程碑;
- [迭代/](迭代/00.base/00.base.md):按迭代目录记录当前基线、设计评审和后续 Runtime Kernel / 应用编排目标;
- [LineUp App 最终设计方案](设计/02.正式方案/app_final_design.md):当前架构的唯一汇总入口;
- [LineUp App 层架构方案](设计/02.正式方案/lineup-app-layer-architecture.md):M0~M4 历史实施记录与迁移基础;
- [LineUp Runtime 与 App SDK 架构方案](设计/02.正式方案/lineup-runtime-sdk-architecture.md)Runtime / SDK 的详细契约来源;
- [LineUp UI Surface 与 App Capability 协议](设计/02.正式方案/lineup-ui-surface-protocol.md)Surface sandbox 和 Capability Gateway 协议;
## 当前目录边界
## 工作区目录
```text
lineup-app/
├── DESIGN.md # Android 快速实验设计
├── TECHNOLOGY_SELECTION.md # 已确认的客户端 / Mini Runtime 技术路线
├── tauri/ # Tauri 2 Reference Host(第一优先 Spike
└── README.md # 本目录说明与文档入口
├── README.md # LineUp App 工作区入口
├── 设计/ # 当前设计文档
│ ├── README.md
│ ├── APP架构设计.md
│ └── 02.正式方案/
└── tauri/ # Tauri Desktop + Web Reference Host
├── 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 实验工程保持不变。
+40 -21
View File
@@ -1,6 +1,15 @@
# LineUp Tauri Reference Host
这是 LineUp Mini Runtime 的第一版 Tauri 2 Reference Host。它独立于当前 `upstream/tangsengdaodao-android/` 快速实验工程,不替换或修改现有 Android 验证路径
这是同一套 LineUp Runtime 的两种运行方式:Tauri 2 Desktop Host 和 Web Reference Host
它们不是两个客户端,也不是两套聊天功能;两者加载相同的 Runtime 和 Chat Core App。
- **Tauri Desktop Host** 是正式桌面应用的外壳。它提供窗口,并在 Runtime 作出授权决定后
调用文件、通知等系统能力。
- **Web Reference Host** 是浏览器中的开发和验证入口。它方便通过 Vite 热更新、
Tailscale 联调和自动化测试,但受浏览器权限限制,不能替代桌面外壳。
无论使用哪种方式,AppServer 通信、消息同步、Store 和 Agent 协议都由 Runtime 管理。
当前不存在并行的独立 Android/Kotlin 或 Wails 客户端路线。
## 已实现的最小闭环
@@ -16,22 +25,22 @@ AppServer 已配置只允许 `tauri://localhost`、`https://tauri.localhost`、`
macOS 的 `src-tauri/Info.plist` 为当前 Tailscale / 私网 HTTP AppServer 配置了 `NSAllowsArbitraryLoadsInWebContent`。该例外仅作用于 Host 的 WKWebView 内容请求,使其能访问类似 `http://100.x.y.z:8090` 的地址,不放宽 Rust 原生网络层;待所有 AppServer 都迁移到 HTTPS 后应移除。
`ui.open / patch / close``app.call` 已被协议层识别,但目前安全降级为可见提示;它们是下一阶段 Mini Runtime Surface Host 与 Capability Registry 的实现范围,不能在当前 Host 页面中直接执行 Agent JavaScript 或直接授予设备能力。
Surface、Capability、签名 Bundle 与隔离 bridge 已有 M2~M4 的实现和测试基础,但它们不等于已开放的应用市场或通用 Installed App。当前 MVP-R1 的重点是 Runtime 托管 Chat:任何 Surface 或能力请求仍必须经过 Runtime 验证、受限 bridge、Capability Policy 和用户确认,不能在 Host 页面中直接执行 Agent JavaScript 或直接授予设备能力。
## Milestone 0 当前行为基线
本节记录 Runtime 重构前已经由 Mac 开发态验证的行为。它是 M0 的回归基线,不代表最终模块结构;正式阶段任务和准入门槛见 [App Layer 架构方案 §9.1](../../设计/02.正式方案/lineup-app-layer-architecture.md#91-阶段任务清单与硬性准入门槛)。
本节记录 Runtime 重构前已经由 Mac 开发态验证的行为。它是 M0 的回归基线,不代表最终模块结构;正式阶段任务和准入门槛见 [App Layer 架构方案 §9.1](../设计/02.正式方案/lineup-app-layer-architecture.md#91-阶段任务清单与硬性准入门槛)。
| 范围 | 当前行为 | 当前实现位置 | 重构后必须保持 |
|---|---|---|---|
| 登录 | 用户输入 AppServer 地址、手机号、验证码;登录请求 10 秒超时;地址仅保存到 localStorage | `src/main.ts``src/runtime/transport-adapter.ts` | 成功登录进入会话;失败原因对用户可见 |
| 登录 | 用户输入 AppServer 地址、手机号、验证码;登录请求 10 秒超时;地址仅保存到 localStorage | `src/main.ts``src/runtime/communication/transport-adapter.ts` | 成功登录进入会话;失败原因对用户可见 |
| 会话 | 当前只有一个 Agent channel;退出清空页面内消息并停止同步 | `src/main.ts` | 会话状态必须可由 Store 管理,不能依赖 DOM |
| 发送 | 用户消息先本地显示,再通过 Transport 调用 `/messages/send`;显示已发送或失败 | `src/main.ts``src/runtime/transport-adapter.ts` | 即时反馈、送达状态与失败可见性不退化 |
| 历史同步 | 首次从 cursor `0` 连续拉取到空页;之后以 `lastSeq + 1` 每 1.5 秒经 Transport 增量轮询 | `src/main.ts``src/runtime/transport-adapter.ts` | 保持包含式 cursor 语义,不能漏消息或无限热循环 |
| Agent 文本 | 将 `lineup.v1.text` 解码为 Markdown,使用 `marked + DOMPurify` 渲染 | `src/protocol.ts``src/runtime/trusted-dom-renderers.ts` | GFM、安全净化和外链防护不退化 |
| Agent 状态 | `thinking` 显示临时指示器;其他状态更新顶栏 presence | `src/protocol.ts``src/runtime/trusted-dom-renderers.ts` | 状态应更新已有状态项,而非无限追加气泡 |
| 进度和错误 | progress/error 目前以系统提示显示 | `src/protocol.ts``src/runtime/trusted-dom-renderers.ts` | 迁移后至少保持可见,M1 再升级为正式卡片 |
| 未实现扩展 | `ui.open / patch / close``app.call` 仅安全降级为提示,绝不执行不可信脚本或原生能力 | `src/protocol.ts``src/main.ts` | 在 M2/M3 完成前始终保持拒绝执行 |
| 发送 | 用户消息先本地显示,再通过 Transport 调用 `/messages/send`;显示已发送或失败 | `src/main.ts``src/runtime/communication/transport-adapter.ts` | 即时反馈、送达状态与失败可见性不退化 |
| 历史同步 | 首次从 cursor `0` 连续拉取到空页;之后以 `lastSeq + 1` 每 1.5 秒经 Transport 增量轮询 | `src/main.ts``src/runtime/communication/transport-adapter.ts` | 保持包含式 cursor 语义,不能漏消息或无限热循环 |
| Agent 文本 | 将 `lineup.v1.text` 解码为 Markdown,使用 `marked + DOMPurify` 渲染 | `src/runtime/protocol/lineup-v1.ts``src/core-apps/chat/trusted-dom-renderers.ts` | GFM、安全净化和外链防护不退化 |
| Agent 状态 | `thinking` 显示临时指示器;其他状态更新顶栏 presence | `src/runtime/protocol/lineup-v1.ts``src/core-apps/chat/trusted-dom-renderers.ts` | 状态应更新已有状态项,而非无限追加气泡 |
| 进度和错误 | progress/error 目前以系统提示显示 | `src/runtime/protocol/lineup-v1.ts``src/core-apps/chat/trusted-dom-renderers.ts` | 迁移后至少保持可见,M1 再升级为正式卡片 |
| 未实现扩展 | `ui.open / patch / close``app.call` 仅安全降级为提示,绝不执行不可信脚本或原生能力 | `src/runtime/protocol/lineup-v1.ts``src/main.ts` | 在 M2/M3 完成前始终保持拒绝执行 |
### M0 回归场景
@@ -52,7 +61,7 @@ M0-01M0-08 已完成。Mac 经 Linux Tailscale Web Host `http://100.121.118.1
### M1-01 Tool Call 状态机(已完成)
`src/protocol.ts` 现可严格解码 `lineup.v1.tool.call``lineup.v1.tool.result``lineup.v1.tool.cancel`,只接受 `choice``confirm``input` 三种声明性请求。`src/runtime/tool-call-state.ts` 是独立于 UI、Storage、Transport、Tauri 与 Capability 的纯状态机;它以 `call_id` 为唯一关联键,只允许 `pending → submitted → completed / failed / cancelled / expired`,拒绝重复调用及非法状态跃迁。Interaction Kernel 仅投影这些状态,且只有已提交的 call 能接受 result。
`src/runtime/protocol/lineup-v1.ts` 现可严格解码 `lineup.v1.tool.call``lineup.v1.tool.result``lineup.v1.tool.cancel`,只接受 `choice``confirm``input` 三种声明性请求。`src/runtime/coordination/tool-call-state.ts` 是独立于 UI、Storage、Transport、Tauri 与 Capability 的纯状态机;它以 `call_id` 为唯一关联键,只允许 `pending → submitted → completed / failed / cancelled / expired`,拒绝重复调用及非法状态跃迁。Interaction Kernel 仅投影这些状态,且只有已提交的 call 能接受 result。
这不是可用的交互组件:M1-01 不渲染选项、不提供按钮/表单、不发送 tool result,也不修改 Hermes Adapter。可信 ActionGroup 留给 M1-02Confirm/Input/Form 留给 M1-03。`npm test` 当前为 3 个文件 / 14 个测试通过,`npm run build` 通过。
@@ -82,11 +91,11 @@ Conversation Store v5 持久化任务快照,刷新或重新登录后仍只显
### 当前结构债务(M0 的改造对象)
`src/main.ts` 目前包含 App Shell、页面 DOM、会话 cursor、同步循环与 Runtime 编排;M0-05 已将登录、发送与同步 HTTP `fetch` 迁入 `src/runtime/transport-adapter.ts`M0-06 已将所有可信 DOM renderer 迁入 `src/runtime/trusted-dom-renderers.ts`在 M0 准入前不新增 Surface 或 Capability 功能
该段记录的是 M0 历史结构债务。当前 MVP-R1 已将 Transport、Store、sync loop 与 outbox 收敛至 `src/runtime/coordination/lineup-runtime.ts`HTTP 实现位于 `src/runtime/communication/transport-adapter.ts`,可信 Chat DOM Renderer 位于 `src/core-apps/chat/trusted-dom-renderers.ts`现行目录与边界见 [src/README.md](src/README.md)
### M0-02 协议模型(已完成)
`src/protocol.ts` 是目前唯一允许把 Transport 原始 payload 转换成可信表现模型的入口。它定义了 `JsonValue``Actor`、严格 `Envelope``ConversationItem``UserAction``DeliveryState``ProtocolFallback`,并提供:
`src/runtime/protocol/lineup-v1.ts` 是目前唯一允许把 Transport 原始 payload 转换成可信表现模型的入口。它定义了 `JsonValue``Actor`、严格 `Envelope``ConversationItem``UserAction``DeliveryState``ProtocolFallback`,并提供:
- `parseEnvelope(value)`:校验版本、消息类型、`id``conversation_id`、sender/target、timestamp 和 JSON payload
- `parseEnvelopeJSON(raw)`:供不接受历史纯文本的 Transport / Kernel 路径把非法 JSON 明确归为 `invalid_json`
@@ -96,9 +105,9 @@ Conversation Store v5 持久化任务快照,刷新或重新登录后仍只显
### M0-03 Interaction Kernel 与 Renderer Registry(已完成)
`src/runtime/interaction-kernel.ts` 是 Transport 与表现层之间的唯一入口:它将原始同步/实时 payload 解码为 `ConversationItem`,对有有效 LineUp `id` 的消息实施有界去重,并将 Agent status 投影为可读取的 presence 快照。它不执行 `ui.*``app.*` 或用户动作。
`src/runtime/coordination/interaction-kernel.ts` 是 Transport 与表现层之间的唯一入口:它将原始同步/实时 payload 解码为 `ConversationItem`,对有有效 LineUp `id` 的消息实施有界去重,并将 Agent status 投影为可读取的 presence 快照。它不执行 `ui.*``app.*` 或用户动作。
`src/runtime/renderer-registry.ts` 只根据已验证的 `ConversationItem.kind` 分派已注册的可信 renderer;renderer 的输入只有表现项与 View context,不会获得 Transport 或 Capability 对象。当前聊天页已通过该 Registry 渲染同步进入的标准项。历史上 M0-03 完成时具体 DOM renderer 仍在 `main.ts`;该 M0-06 迁移现已完成,当前可信 DOM renderer 位于 `src/runtime/trusted-dom-renderers.ts`
`src/core-apps/chat/renderer-registry.ts` 只根据已验证的 `ConversationItem.kind` 分派已注册的可信 renderer;renderer 的输入只有表现项与 View context,不会获得 Transport 或 Capability 对象。当前聊天页已通过该 Registry 渲染同步进入的标准项。历史上 M0-03 完成时具体 DOM renderer 仍在 `main.ts`;该 M0-06 迁移现已完成,当前可信 DOM renderer 位于 `src/core-apps/chat/trusted-dom-renderers.ts`
## 本地命令
@@ -127,7 +136,13 @@ npm run tauri build -- --debug
`npm run desktop:dev` 不生成 `.app``.dmg` 或安装包。前端 TypeScript / CSS / HTML 保存后会由 Vite 自动热更新;改动 `src-tauri/` 中的 Rust Host 代码时,Tauri 会重新编译并重启开发窗口。只有需要交付可安装文件时才执行 `tauri build`
`npm run web:dev` 只运行同一份可信 Host Web UI,适合在浏览器中快速调试登录、消息和 Renderer。它监听 `0.0.0.0:1420`Mac 通过 Tailscale 打开 `http://100.121.118.116:1420`,直接验证 Linux 工作区的 Vite 热更新;不需要为每次验证将源码同步到 Mac。它不具备 Tauri 原生能力,也不应作为第三方 Mini Runtime Surface 的安全验收环境。AppServer 仅对固定开发 Origin `http://127.0.0.1:1420``http://100.121.118.116:1420` 配置 CORS;若改动 Vite 端口或 Host 地址,必须同步收紧更新 AppServer 的 `client.allowedOrigins`,不得改为 wildcard。
`npm run web:dev` 是在浏览器里启动同一份可信 Host UI,适合快速检查登录、消息和
Renderer。它监听 `0.0.0.0:1420`Mac 可经 Tailscale 打开
`http://100.121.118.116:1420`,直接看到 Linux 工作区的 Vite 热更新,无须把源码同步到
Mac。浏览器模式没有 Tauri 的原生能力,也不能用作第三方 Mini Runtime Surface 的安全验收
环境。AppServer 只对固定开发 Origin `http://127.0.0.1:1420`
`http://100.121.118.116:1420` 配置 CORS;若改动 Vite 端口或 Host 地址,必须同步更新并
保持收紧 AppServer 的 `client.allowedOrigins`,不得改成允许任意来源的 wildcard。
本项目的 `tauri``check:rust` npm 脚本会自动识别 Linux x86_64、macOS Apple Silicon、macOS Intel 的 Rust stable toolchain,并加入 `PATH`。因此不要直接执行裸 `tauri build`;统一使用 `npm run tauri build -- --debug`。如 Rust toolchain 安装在其他位置,可临时覆盖:
@@ -140,7 +155,7 @@ RUST_TOOLCHAIN_BIN=/path/to/rust/bin npm run tauri build -- --debug
- `npm run build`:已通过;
- Cargo/Tauri Rust crates:已下载并开始编译;
- Tauri Linux desktop build:已通过;debug 可执行文件和 Debian 包已生成;
- Android Spike:尚未开始。仍需要 Android NDK、Rust Android targets 与 Tauri Android 初始化
- 独立 Android/Kotlin 与 Wails Host:不在当前或后续规划范围内
Linux 桌面构建依赖以下系统开发库;本机已具备。新环境缺少时可安装:
@@ -156,17 +171,21 @@ src-tauri/target/debug/lineup-tauri
src-tauri/target/debug/bundle/deb/LineUp_0.1.0_amd64.deb
```
Tauri 构建产物位于 `src-tauri/target/`,已被 Git 忽略。图标源文件是 `src-tauri/icons/icon.svg`,其余 PNG/ICO/ICNS 与 Android/iOS 图标由 `npm run tauri icon src-tauri/icons/icon.svg` 生成
Tauri 构建产物位于 `src-tauri/target/`,已被 Git 忽略。图标源文件是 `src-tauri/icons/icon.svg`;当前交付只面向 Tauri Desktop Host,其他图标派生产物不代表额外客户端路线
## 目录边界
```text
tauri/
├── src/ # 可信 LineUp Host 前端
│ ├── main.ts # 会话 UI、AppServer transport、Renderer 调度
── protocol.ts # 平台无关的 LineUp v1 → ConversationItem 解码
├── src/
│ ├── main.ts # 应用启动装配入口:连接 Host、Runtime 与默认 App
── core-apps/chat/ # Chat Shell、可信 Renderer 与样式
│ └── runtime/ # 按通信、协调、存储、应用、Surface、能力等分层
├── src-tauri/ # Tauri Rust Host 与 capability 配置
├── vite.config.ts # @/ → src/ 路径别名(Vite / Vitest
└── package.json # Vite、Tauri CLI 与前端依赖
```
各 Runtime 子目录、依赖方向与新增模块约定见 [src/README.md](src/README.md)。
第三方 Agent Surface 未来必须运行于独立隔离执行域,只能经 LineUp Mini Runtime 事件桥与 Capability Gateway 通信;不得访问 Tauri `invoke`、Host DOM、登录态或本机特权。
+4 -4
View File
@@ -14,7 +14,7 @@
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"typescript": "^5.7.3",
"typescript": "^6.0.3",
"vite": "^6.1.0",
"vitest": "^3.2.7"
}
@@ -1732,9 +1732,9 @@
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
+1 -1
View File
@@ -20,7 +20,7 @@
},
"devDependencies": {
"@tauri-apps/cli": "^2.0.0",
"typescript": "^5.7.3",
"typescript": "^6.0.3",
"vite": "^6.1.0",
"vitest": "^3.2.7"
}
+12
View File
@@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
+20
View File
@@ -0,0 +1,20 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
build
/captures
.externalNativeBuild
.cxx
local.properties
key.properties
keystore.properties
/.tauri
/tauri.settings.gradle
@@ -0,0 +1,6 @@
/src/main/**/generated
/src/main/jniLibs/**/*.so
/src/main/assets/tauri.conf.json
/tauri.build.gradle.kts
/proguard-tauri.pro
/tauri.properties
@@ -0,0 +1,71 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("rust")
}
val tauriProperties = Properties().apply {
val propFile = file("tauri.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
android {
compileSdk = 36
namespace = "com.lineup.client"
defaultConfig {
manifestPlaceholders["usesCleartextTraffic"] = "false"
applicationId = "com.lineup.client"
minSdk = 24
targetSdk = 36
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
}
buildTypes {
getByName("debug") {
manifestPlaceholders["usesCleartextTraffic"] = "true"
isDebuggable = true
isJniDebuggable = true
isMinifyEnabled = false
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
jniLibs.keepDebugSymbols.add("*/x86/*.so")
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
}
}
getByName("release") {
isMinifyEnabled = true
proguardFiles(
*fileTree(".") { include("**/*.pro") }
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
.toList().toTypedArray()
)
}
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
}
}
rust {
rootDirRel = "../../../"
}
dependencies {
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.lifecycle:lifecycle-process:2.10.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
}
apply(from = "tauri.build.gradle.kts")
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.lineup_tauri"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,11 @@
package com.lineup.client
import android.os.Bundle
import androidx.activity.enableEdgeToEdge
class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
}
}
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.lineup_tauri" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,4 @@
<resources>
<string name="app_name">"LineUp"</string>
<string name="main_activity_title">"LineUp"</string>
</resources>
@@ -0,0 +1,6 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.lineup_tauri" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Customize your theme here. -->
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
@@ -0,0 +1,22 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.11.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
tasks.register("clean").configure {
delete("build")
}
@@ -0,0 +1,23 @@
plugins {
`kotlin-dsl`
}
gradlePlugin {
plugins {
create("pluginsForCoolKids") {
id = "rust"
implementationClass = "RustPlugin"
}
}
}
repositories {
google()
mavenCentral()
}
dependencies {
compileOnly(gradleApi())
implementation("com.android.tools.build:gradle:8.11.0")
}
@@ -0,0 +1,68 @@
import java.io.File
import org.apache.tools.ant.taskdefs.condition.Os
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.logging.LogLevel
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction
open class BuildTask : DefaultTask() {
@Input
var rootDirRel: String? = null
@Input
var target: String? = null
@Input
var release: Boolean? = null
@TaskAction
fun assemble() {
val executable = """npm""";
try {
runTauriCli(executable)
} catch (e: Exception) {
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
// Try different Windows-specific extensions
val fallbacks = listOf(
"$executable.exe",
"$executable.cmd",
"$executable.bat",
)
var lastException: Exception = e
for (fallback in fallbacks) {
try {
runTauriCli(fallback)
return
} catch (fallbackException: Exception) {
lastException = fallbackException
}
}
throw lastException
} else {
throw e;
}
}
}
fun runTauriCli(executable: String) {
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
val target = target ?: throw GradleException("target cannot be null")
val release = release ?: throw GradleException("release cannot be null")
val args = listOf("run", "--", "tauri", "android", "android-studio-script");
project.exec {
workingDir(File(project.projectDir, rootDirRel))
executable(executable)
args(args)
if (project.logger.isEnabled(LogLevel.DEBUG)) {
args("-vv")
} else if (project.logger.isEnabled(LogLevel.INFO)) {
args("-v")
}
if (release) {
args("--release")
}
args(listOf("--target", target))
}.assertNormalExitValue()
}
}
@@ -0,0 +1,85 @@
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.DefaultTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.get
const val TASK_GROUP = "rust"
open class Config {
lateinit var rootDirRel: String
}
open class RustPlugin : Plugin<Project> {
private lateinit var config: Config
override fun apply(project: Project) = with(project) {
config = extensions.create("rust", Config::class.java)
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
extensions.configure<ApplicationExtension> {
@Suppress("UnstableApiUsage")
flavorDimensions.add("abi")
productFlavors {
create("universal") {
dimension = "abi"
ndk {
abiFilters += abiList
}
}
defaultArchList.forEachIndexed { index, arch ->
create(arch) {
dimension = "abi"
ndk {
abiFilters.add(defaultAbiList[index])
}
}
}
}
}
afterEvaluate {
for (profile in listOf("debug", "release")) {
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
val buildTask = tasks.maybeCreate(
"rustBuildUniversal$profileCapitalized",
DefaultTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for all targets"
}
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
for (targetPair in targetsList.withIndex()) {
val targetName = targetPair.value
val targetArch = archList[targetPair.index]
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
val targetBuildTask = project.tasks.maybeCreate(
"rustBuild$targetArchCapitalized$profileCapitalized",
BuildTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for $targetArch"
rootDirRel = config.rootDirRel
target = targetName
release = profile == "release"
}
buildTask.dependsOn(targetBuildTask)
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
targetBuildTask
)
}
}
}
}
}
@@ -0,0 +1,24 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.nonFinalResIds=false
Binary file not shown.
@@ -0,0 +1,6 @@
#Tue May 10 19:22:52 CST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@@ -0,0 +1,3 @@
include ':app'
apply from: 'tauri.settings.gradle'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,3 +1,4 @@
// Tauri Rust Host 的应用构造入口;Runtime 协议和业务路由仍位于 TypeScript LineUpRuntime。
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
+1
View File
@@ -1,3 +1,4 @@
// Tauri 桌面二进制入口:仅委托给 lib.rs 中的可信 Host 构造逻辑。
fn main() {
lineup_tauri_lib::run();
}
+1 -1
View File
@@ -5,7 +5,7 @@
"identifier": "com.lineup.client",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://127.0.0.1:1420",
"devUrl": "http://192.168.3.13:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
+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`。这样新应用只增加自己的体验,
不会破坏已有聊天和连接逻辑。
+93
View File
@@ -0,0 +1,93 @@
/**
* 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;
appSessions: 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 appSessions = query<HTMLElement>(root, "#app-sessions");
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,
appSessions,
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));
}
+27
View File
@@ -0,0 +1,27 @@
/**
* 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="app-sessions" class="app-sessions" aria-label="应用子会话"></section>
<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 { InteractRuntimeSDK } from "@/runtime/app-management/app-sdk";
import { InteractionModeRegistry, type InteractionMode } from "@/core-apps/chat/interaction-mode-registry";
/** Restricted Interaction App projection: IM today, Audio/Video when enabled. */
export class InteractionRuntime {
public readonly modes = new InteractionModeRegistry();
private current: InteractionMode = "im";
public constructor(public readonly sdk: InteractRuntimeSDK) {}
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 ItemOfKind<K extends ConversationItemKind> = Extract<ConversationItem, { kind: K }>;
File diff suppressed because one or more lines are too long
@@ -0,0 +1,23 @@
/* Chat 中可信 Capability 确认卡片的视觉状态;样式不授予或执行任何系统能力。 */
.capability-card {
display: grid;
justify-self: start;
gap: .7rem;
width: min(100%, 32rem);
padding: 1rem;
border: 1px solid #537362;
border-radius: .9rem;
background: #1c2c24;
}
.capability-card > p { margin: 0; color: #bfd1c6; line-height: 1.45; }
.capability-risk, .capability-status { color: #aac5b5; }
.capability-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
.capability-actions button {
border: 0; border-radius: .65rem; padding: .8rem 1rem;
background: #a9dfc4; color: #102018; font-weight: 750; cursor: pointer;
}
.capability-actions .secondary-action { background: #395447; color: #e2f1e8; }
.capability-card[data-status="completed"] { border-color: #89c7a6; }
.capability-card[data-status="rejected"], .capability-card[data-status="expired"],
.capability-card[data-status="failed"], .capability-card[data-status="unsupported"] { border-color: #587064; }
.capability-card button:disabled { opacity: .5; cursor: not-allowed; }
@@ -0,0 +1,45 @@
/* Chat 内受限执行摘要卡片:只展示 Runtime/Adapter 已归约的公开步骤。 */
.execution-progress-card {
justify-self: start;
width: min(100%, 32rem);
border: 1px solid #577563;
border-radius: .85rem;
background: #182820;
color: #cde3d5;
}
.execution-progress-card summary {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: .8rem;
padding: .72rem .85rem;
cursor: pointer;
list-style: none;
}
.execution-progress-card summary::-webkit-details-marker { display: none; }
.execution-progress-card summary::before { content: ""; font-size: 1.2rem; color: #a9dfc4; transition: transform .15s ease; }
.execution-progress-card[open] summary::before { transform: rotate(90deg); }
.execution-progress-card[data-status="running"] summary::before { content: "◌"; transform: none; }
.execution-progress-heading strong { font-size: .9rem; }
.execution-progress-duration { color: #9cbbaa; white-space: nowrap; }
.execution-progress-stages {
display: grid;
gap: .34rem;
margin: 0;
padding: 0 .9rem .85rem 2rem;
color: #b9d1c2;
font-size: .82rem;
}
.execution-progress-stages li::marker { color: #85caa2; }
.execution-progress-stages li[data-status="completed"] { color: #d5f0df; }
.execution-progress-stages li[data-status="running"] { color: #d7e6a4; }
.execution-progress-card[data-status="failed"] { border-color: #82625e; }
.execution-progress-card[data-status="failed"] .execution-progress-stages li[data-status="failed"] { color: #ffb2aa; }
/* A trace belongs to the final Agent turn, not to the global message stream. */
.message-agent-content { display: grid; gap: .45rem; min-width: 0; }
.message-agent-content > .execution-progress-card { width: min(100%, 32rem); }
@@ -1,3 +1,9 @@
/**
* Chat Core App DOM
*
* Markdown sanitizer
* SDK TransportStore Tauri
*/
import DOMPurify from "dompurify";
import { marked } from "marked";
import type {
@@ -6,11 +12,15 @@ import type {
ConversationItem,
DeliveryState,
InputForm,
NoticeDefinition,
JsonObject,
ToolCallRequest,
} from "../protocol";
import type { ToolCallRecord } from "./tool-call-state";
import type { TaskRecord } from "./task-state";
} from "@/runtime/protocol/lineup-v1";
import type { ToolCallRecord } from "@/runtime/coordination/tool-call-state";
import type { TaskRecord } from "@/runtime/coordination/task-state";
import type { ExecutionProgressSummary } from "@/runtime/coordination/execution-progress-state";
import type { ArtifactRecord } from "@/runtime/artifacts/artifact-state";
import type { CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state";
export type ToolInteractionHandlers = {
submit: (callID: string, submission: JsonObject) => boolean;
@@ -27,30 +37,49 @@ export type TaskInteractionHandlers = {
read: (operationID: string) => TaskRecord | undefined;
};
export type CapabilityInteractionHandlers = {
approve: (callID: string) => Promise<CapabilityCallRecord | undefined>;
reject: (callID: string) => CapabilityCallRecord | undefined;
expire: (callID: string) => CapabilityCallRecord | undefined;
read: (callID: string) => CapabilityCallRecord | undefined;
};
/**
* Trusted presentation context for the first-party chat shell. It owns only
* DOM nodes and display-local state; it deliberately has no Store, Transport,
* Kernel, Tauri bridge, or capability reference.
*/
export class TrustedDOMRendererContext {
private thinking: HTMLElement | undefined;
private readonly userRows = new Map<string, HTMLElement>();
private readonly toolCards = new Map<string, { card: HTMLElement; status: HTMLElement; controls: readonly (HTMLButtonElement | HTMLInputElement | HTMLTextAreaElement)[] }>();
private readonly taskCards = new Map<string, { card: HTMLElement; title: HTMLElement; progress: HTMLProgressElement; status: HTMLElement; cancel: HTMLButtonElement }>();
private readonly capabilityCards = new Map<string, { card: HTMLElement; status: HTMLElement; approve: HTMLButtonElement; reject: HTMLButtonElement }>();
private readonly executionCards = new Map<string, HTMLDetailsElement>();
private readonly agentReplyGroups = new Map<string, HTMLElement>();
private readonly executionSummaries = new Map<string, ExecutionProgressSummary>();
private readonly expirationTimers = new Set<number>();
private readonly executionTicker: number;
public constructor(
private readonly messages: HTMLElement,
private readonly presence: HTMLElement,
private readonly toolInteractions?: ToolInteractionHandlers,
private readonly taskInteractions?: TaskInteractionHandlers,
) {}
private readonly capabilityInteractions?: CapabilityInteractionHandlers,
) {
// Status envelopes are sparse. Keep elapsed time honest between them
// without persisting a ticking value or treating it as Agent progress.
this.executionTicker = window.setInterval(() => this.refreshExecutionDurations(), 1_000);
}
public reset(): void {
this.thinking = undefined;
this.userRows.clear();
this.toolCards.clear();
this.taskCards.clear();
this.capabilityCards.clear();
this.executionCards.clear();
this.agentReplyGroups.clear();
this.executionSummaries.clear();
for (const timer of this.expirationTimers) window.clearTimeout(timer);
this.expirationTimers.clear();
}
@@ -61,16 +90,21 @@ export class TrustedDOMRendererContext {
}
public renderMarkdown(item: Extract<ConversationItem, { kind: "markdown" }>): void {
this.hideThinking();
this.appendBubble("agent", item.markdown, true);
const row = this.appendBubble("agent", item.markdown, true);
if (item.execution_id) {
row.dataset.executionId = item.execution_id;
this.agentReplyGroups.set(item.execution_id, row);
this.attachExecutionCard(item.execution_id);
}
}
public renderAgentStatus(item: Extract<ConversationItem, { kind: "agent-status" }>): void {
if (item.status === "thinking") this.showThinking(item.detail || "正在思考…");
else {
this.hideThinking();
this.setPresence(item.detail || item.status || "在线");
}
// Status detail is never displayed as thought content or an operation.
this.setPresence(item.status === "thinking" ? "正在处理你的请求" : "已同步,等待消息");
}
public renderExecutionTrace(_item: Extract<ConversationItem, { kind: "execution-summary" }>): void {
// main.ts projects the validated trace into the existing summary card.
}
public renderProgress(item: Extract<ConversationItem, { kind: "progress" }>): void {
@@ -87,12 +121,58 @@ export class TrustedDOMRendererContext {
}
public renderError(item: Extract<ConversationItem, { kind: "error" }>): void {
this.hideThinking();
this.renderSystemMessage(`Agent 错误:${item.message}`);
}
/** Renders only protocol-validated public steps; raw tool data never reaches this card. */
public renderExecutionSummary(summary: ExecutionProgressSummary): void {
this.executionSummaries.set(summary.id, summary);
// A completed reply which performed no publicly summarizable operation
// must not leave a misleading empty ledger frame behind. A later genuine
// trace still recreates and attaches the card under this same reply.
if (summary.status !== "running" && summary.steps.length === 0) {
const empty = this.executionCards.get(summary.id);
empty?.remove();
this.executionCards.delete(summary.id);
return;
}
const current = this.executionCards.get(summary.id);
if (current) {
this.updateExecutionSummary(current, summary);
return;
}
const card = document.createElement("details");
card.className = "execution-progress-card";
card.dataset.executionId = summary.id;
const heading = document.createElement("summary");
heading.className = "execution-progress-heading";
const title = document.createElement("strong");
const duration = document.createElement("small");
duration.className = "execution-progress-duration";
heading.append(title, duration);
const steps = document.createElement("ul");
steps.className = "execution-progress-stages";
card.append(heading, steps);
this.executionCards.set(summary.id, card);
this.updateExecutionSummary(card, summary);
this.attachExecutionCard(summary.id);
if (!card.isConnected) this.messages.append(card);
this.scrollLatest();
}
/** Only terminal summaries survive a page restore; live animation never does. */
public restoreExecutionSummaries(summaries: readonly ExecutionProgressSummary[]): void {
for (const summary of summaries) {
if (summary.status !== "running") this.renderExecutionSummary(summary);
}
}
public renderToolCall(item: Extract<ConversationItem, { kind: "tool-call" }>): void {
const record = this.toolInteractions?.read(item.call.call_id);
if (item.call.tool === "notice" && item.call.notice) {
this.renderNoticeCard(item.call, item.call.notice);
return;
}
if (item.call.tool === "choice" && item.call.action_group) {
this.renderActionGroup(item.call, item.call.action_group, record);
return;
@@ -108,6 +188,30 @@ export class TrustedDOMRendererContext {
this.renderSystemMessage("收到无法安全展示的标准交互请求。");
}
private renderNoticeCard(call: ToolCallRequest, notice: NoticeDefinition): void {
const card = document.createElement("article");
card.className = "tool-card notice-card";
card.dataset.callId = call.call_id;
const heading = document.createElement("strong");
heading.textContent = call.title || "提示";
const detail = document.createElement("p");
detail.textContent = notice.message;
const status = document.createElement("small");
status.className = "tool-card-status";
status.textContent = notice.dismiss_label ?? "已收到";
card.append(heading, detail, status);
this.toolCards.set(call.call_id, { card, status, controls: [] });
this.messages.append(card);
this.scrollLatest();
// Toast is only an additional short reminder; the durable IM card above remains.
const toast = document.createElement("div");
toast.className = "notice-toast";
toast.textContent = notice.message;
toast.setAttribute("role", "status");
this.messages.parentElement?.append(toast);
globalThis.setTimeout(() => toast.remove(), 4_000);
}
public renderToolResult(item: Extract<ConversationItem, { kind: "tool-result" }>): void {
const entry = this.toolCards.get(item.result.call_id);
if (!entry) return;
@@ -124,8 +228,85 @@ export class TrustedDOMRendererContext {
this.renderSystemMessage(`收到扩展界面请求(${item.envelope.type});Mini Runtime Surface Host 将在下一步接入。`);
}
public renderAppCall(_item: Extract<ConversationItem, { kind: "app-call" }>): void {
this.renderSystemMessage("收到 App 能力调用请求;Capability 授权 UI 将在下一步接入。");
/**
* A capability card is native chrome, never an Agent-provided form. The
* request arguments deliberately do not cross this presentation boundary:
* URL, clipboard text, local path and Artifact content cannot be displayed
* or copied from the confirmation UI.
*/
public renderAppCall(item: Extract<ConversationItem, { kind: "app-call" }>): void {
const callID = typeof item.envelope.payload.call_id === "string" ? item.envelope.payload.call_id : "";
const record = callID ? this.capabilityInteractions?.read(callID) : undefined;
if (!record) {
this.renderSystemMessage("收到无法安全确认的 App 能力请求。");
return;
}
const existing = this.capabilityCards.get(record.call_id);
if (existing) { this.updateCapabilityCard(existing, record); return; }
const card = document.createElement("article");
card.className = "capability-card";
card.dataset.callId = record.call_id;
const heading = document.createElement("strong");
const reason = document.createElement("p");
const risk = document.createElement("small");
risk.className = "capability-risk";
const status = document.createElement("small");
status.className = "capability-status";
const controls = document.createElement("div");
controls.className = "capability-actions";
const reject = document.createElement("button");
reject.type = "button"; reject.className = "secondary-action"; reject.textContent = "拒绝";
const approve = document.createElement("button");
approve.type = "button"; approve.textContent = "允许一次";
const entry = { card, status, approve, reject };
reject.addEventListener("click", () => {
const next = this.capabilityInteractions?.reject(record.call_id);
if (next) this.updateCapabilityCard(entry, next);
});
approve.addEventListener("click", () => {
approve.disabled = true; reject.disabled = true;
void this.capabilityInteractions?.approve(record.call_id).then(next => {
if (next) this.updateCapabilityCard(entry, next);
});
});
heading.textContent = capabilityLabel(record.request.capability);
reason.textContent = record.request.reason;
risk.textContent = capabilityRiskText(record.request.capability);
controls.append(reject, approve);
card.append(heading, reason, risk, status, controls);
this.capabilityCards.set(record.call_id, entry);
this.updateCapabilityCard(entry, record);
this.messages.append(card);
this.scrollLatest();
const delay = Date.parse(record.request.expires_at) - Date.now();
if (delay > 0 && record.status === "pending") {
const timer = window.setTimeout(() => {
this.expirationTimers.delete(timer);
const next = this.capabilityInteractions?.expire(record.call_id);
if (next) this.updateCapabilityCard(entry, next);
}, delay);
this.expirationTimers.add(timer);
}
}
/** Native Artifact display. It deliberately has no download URL or local reference. */
public renderArtifact(record: ArtifactRecord): void {
const card = document.createElement("article");
card.className = "artifact-card";
card.dataset.artifactId = record.artifact_id;
const name = document.createElement("strong");
name.textContent = record.name;
const metadata = document.createElement("small");
metadata.textContent = `${record.mime_type} · ${formatBytes(record.size_bytes)}`;
const integrity = document.createElement("small");
integrity.className = "artifact-integrity";
integrity.dataset.integrity = record.integrity;
integrity.textContent = record.integrity === "verified" ? "完整性已验证" : record.integrity === "failed" ? "完整性校验失败" : "完整性未验证";
card.append(name, metadata, integrity);
this.messages.append(card);
this.scrollLatest();
}
public renderFallback(item: Extract<ConversationItem, { kind: "fallback" }>): void {
@@ -181,7 +362,14 @@ export class TrustedDOMRendererContext {
meta.className = "meta";
meta.textContent = `${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}${delivery ? ` · ${delivery}` : ""}`;
bubble.append(meta);
row.append(bubble);
if (role === "agent") {
const content = document.createElement("div");
content.className = "message-agent-content";
content.append(bubble);
row.append(content);
} else {
row.append(bubble);
}
this.messages.append(row);
this.scrollLatest();
return row;
@@ -228,6 +416,16 @@ export class TrustedDOMRendererContext {
entry.cancel.disabled = requested;
}
private updateCapabilityCard(entry: { card: HTMLElement; status: HTMLElement; approve: HTMLButtonElement; reject: HTMLButtonElement }, record: CapabilityCallRecord): void {
entry.card.dataset.status = record.status;
const active = record.status === "pending";
entry.approve.hidden = !active;
entry.reject.hidden = !active;
entry.approve.disabled = !active;
entry.reject.disabled = !active;
entry.status.textContent = capabilityStatusText(record.status);
}
private renderActionGroup(call: ToolCallRequest, group: ActionGroup, record?: ToolCallRecord): void {
const card = document.createElement("article");
card.className = "action-group-card";
@@ -244,7 +442,7 @@ export class TrustedDOMRendererContext {
const buttons: HTMLButtonElement[] = [];
const selected = new Set<string>();
const submit = (actionIDs: readonly string[]) => {
const accepted = this.toolInteractions?.submit(call.call_id, { action_ids: [...actionIDs] }) ?? false;
const accepted = this.toolInteractions?.submit(call.call_id, { action_id: actionIDs[0] }) ?? false;
if (!accepted) {
status.textContent = "该交互已结束或已提交";
return;
@@ -321,7 +519,7 @@ export class TrustedDOMRendererContext {
const locked = this.applyCallStatus(card, status, call, record);
for (const button of buttons) button.disabled = locked;
approve.addEventListener("click", () => {
if (!this.toolInteractions?.submit(call.call_id, { confirmed: true })) return this.markInteractionUnavailable(status);
if (!this.toolInteractions?.submit(call.call_id, { approved: true })) return this.markInteractionUnavailable(status);
this.markSubmitted(card, status, buttons);
});
cancel.addEventListener("click", () => {
@@ -384,7 +582,8 @@ export class TrustedDOMRendererContext {
const values = this.readFormValues(fields);
const problem = validateFormValues(form, values);
if (problem) { errors.textContent = problem; return; }
if (!this.toolInteractions?.submit(call.call_id, { values })) return this.markInteractionUnavailable(status);
const text = values[form.fields[0]?.id ?? ""];
if (!this.toolInteractions?.submit(call.call_id, { text })) return this.markInteractionUnavailable(status);
errors.textContent = "";
this.markSubmitted(card, status, controls);
});
@@ -453,16 +652,42 @@ export class TrustedDOMRendererContext {
for (const control of entry.controls) control.disabled = true;
}
private showThinking(detail: string): void {
this.thinking?.remove();
this.thinking = this.appendBubble("agent", detail);
this.thinking.classList.add("thinking");
this.setPresence("正在思考");
private updateExecutionSummary(card: HTMLDetailsElement, summary: ExecutionProgressSummary): void {
card.dataset.status = summary.status;
card.open = summary.status === "running";
const elapsed = executionElapsed(summary);
const heading = card.querySelector<HTMLElement>(".execution-progress-heading strong");
const duration = card.querySelector<HTMLElement>(".execution-progress-duration");
const steps = card.querySelector<HTMLUListElement>(".execution-progress-stages");
if (!heading || !duration || !steps) return;
heading.textContent = summary.status === "running"
? "正在执行操作"
: summary.status === "completed"
? "已完成处理 · 查看过程摘要"
: "处理未完成 · 查看过程摘要";
duration.textContent = `${elapsed}`;
steps.replaceChildren(...summary.steps.map(step => {
const row = document.createElement("li");
row.dataset.status = step.status;
row.textContent = `${step.status === "running" ? "◌" : step.status === "completed" ? "✓" : "×"} ${step.title}`;
return row;
}));
}
private hideThinking(): void {
this.thinking?.remove();
this.thinking = undefined;
/** Moves a turn's safe ledger card under its matching final Agent reply. */
private attachExecutionCard(executionID: string): void {
const card = this.executionCards.get(executionID);
const reply = this.agentReplyGroups.get(executionID);
const container = reply?.querySelector<HTMLElement>(".message-agent-content");
if (card && container && card.parentElement !== container) container.append(card);
}
private refreshExecutionDurations(): void {
for (const [id, summary] of this.executionSummaries) {
if (summary.status !== "running") continue;
const card = this.executionCards.get(id);
if (card) this.updateExecutionSummary(card, summary);
}
}
private scrollLatest(): void {
@@ -470,12 +695,53 @@ export class TrustedDOMRendererContext {
}
}
function executionElapsed(summary: ExecutionProgressSummary): number {
const end = summary.status === "running" ? Date.now() : Date.parse(summary.finished_at ?? summary.updated_at);
const start = Date.parse(summary.started_at);
return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, Math.round((end - start) / 1_000)) : 0;
}
function deliveryLabel(delivery: DeliveryState): string {
if (delivery.status === "local_pending") return "发送中";
if (delivery.status === "failed") return "发送失败";
return "已发送";
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${Math.ceil(bytes / 1024)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function capabilityLabel(capability: CapabilityCallRecord["request"]["capability"]): string {
switch (capability) {
case "app.open_url": return "打开外部网站";
case "clipboard.write": return "写入剪贴板";
case "device.pick_file": return "选择一个文件";
case "artifact.save": return "保存已验证的文件";
}
}
function capabilityRiskText(capability: CapabilityCallRecord["request"]["capability"]): string {
return capability === "device.pick_file"
? "这会打开系统文件选择器;只有你选择的文件元数据会返回给 Agent。"
: "此操作仅在你本次明确允许后执行一次。";
}
function capabilityStatusText(status: CapabilityCallRecord["status"]): string {
switch (status) {
case "pending": return "等待你的确认";
case "approved":
case "executing": return "正在执行已允许的操作…";
case "completed": return "已完成";
case "rejected": return "你已拒绝此操作";
case "cancelled": return "操作已取消";
case "expired": return "确认已过期";
case "unsupported": return "此客户端当前不支持该操作";
case "failed": return "操作未能完成";
}
}
function validateFormValues(form: InputForm, values: JsonObject): string | undefined {
for (const field of form.fields) {
const value = values[field.id];
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { TaskDashboardMiniApp } from "@/core-apps/task-dashboard/task-dashboard-miniapp";
describe("TaskDashboardMiniApp", () => {
it("keeps user-created task form data local and reports Tool lifecycle through SDK", async () => {
const calls: string[] = [];
const sdk = {
context: { app_scope: "task-dashboard", instance_id: "task:1", conversation_id: "c", app_version: "1.0.0", state: "foreground" as const },
inbox: { list: () => [], subscribe: () => () => undefined, acknowledge: () => true },
tools: {
subscribe: () => () => undefined,
get: () => undefined,
reportProgress: async (id: string) => { calls.push(`progress:${id}`); return { disposition: "accepted" as const }; },
complete: async (id: string) => { calls.push(`complete:${id}`); return { disposition: "accepted" as const }; },
fail: async () => ({ disposition: "accepted" as const }),
cancel: async () => ({ disposition: "accepted" as const }),
},
lifecycle: { requestForeground: async () => ({ disposition: "accepted" as const }), requestBackground: async () => ({ disposition: "accepted" as const }), requestClose: async () => ({ disposition: "accepted" as const }) },
surfaces: { request: async () => ({ disposition: "accepted" as const }) },
capabilities: { request: async () => ({ disposition: "accepted" as const }) },
workspace: () => ({ version: 1 as const, instances: { version: 1 as const, instances: [] }, focus: { version: 1 as const, stack: [] } }),
};
const app = new TaskDashboardMiniApp(sdk);
app.start();
app.createTask("task-1", { title: "本地任务" });
await app.completeTask("call-1", "task-1");
expect(calls).toEqual(["progress:call-1", "complete:call-1"]);
app.stop();
});
});
@@ -0,0 +1,61 @@
import type { LineUpMiniAppSDK } from "@/runtime/app-management/miniapp-sdk";
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
/** Minimal bundled reference App: exercises Inbox, Tool progress/result and lifecycle. */
export class TaskDashboardMiniApp {
private readonly tasks = new Map<string, JsonObject>();
private unsubscribeInbox: (() => void) | undefined;
private unsubscribeTools: (() => void) | undefined;
public constructor(private readonly sdk: LineUpMiniAppSDK) {}
public start(): void {
void this.sdk.surfaces.request("open", { state: { title: "任务面板", status: "等待 Agent 任务", percent: 0, steps: [] } });
this.unsubscribeInbox = this.sdk.inbox.subscribe(message => {
if (message.item.kind === "app-call" || message.item.kind === "miniapp-tool-call") this.sdk.inbox.acknowledge(message.message_id);
});
this.unsubscribeTools = this.sdk.tools.subscribe(call => {
if (call.instance_id !== this.sdk.context.instance_id) return;
void this.handleTool(call);
});
}
private async handleTool(call: { call_id: string; tool_id: string; input: JsonObject }): Promise<void> {
await this.sdk.tools.reportProgress(call.call_id, { status: "running", percent: 10, detail: call.tool_id });
if (call.tool_id === "task-dashboard.open") {
const title = typeof call.input.title === "string" ? call.input.title : "未命名任务";
const taskID = typeof call.input.task_id === "string" ? call.input.task_id : `task-${call.call_id}`;
this.createTask(taskID, { title, status: "open" });
await this.sdk.surfaces.request("patch", { state: { title: "任务面板", status: `已创建:${title}`, percent: 100, steps: [title] } });
await this.sdk.tools.complete(call.call_id, { task_id: taskID });
return;
}
if (call.tool_id === "task-dashboard.update") {
const taskID = typeof call.input.task_id === "string" ? call.input.task_id : `task-${call.call_id}`;
const previous = this.tasks.get(taskID) ?? {};
const next = { ...previous, ...(typeof call.input.status === "string" ? { status: call.input.status } : {}), ...(typeof call.input.progress === "number" ? { progress: call.input.progress } : {}) };
this.tasks.set(taskID, next);
await this.sdk.tools.complete(call.call_id, { task_id: taskID, task: next });
return;
}
await this.sdk.tools.fail(call.call_id, { code: "tool_not_supported", message: `不支持的任务工具:${call.tool_id}` });
}
/** App-local form submission; it is not an Agent standard interaction. */
public createTask(taskID: string, values: JsonObject): void {
this.tasks.set(taskID, values);
}
public async completeTask(callID: string, taskID: string): Promise<void> {
const task = this.tasks.get(taskID) ?? {};
await this.sdk.tools.reportProgress(callID, { status: "completed", percent: 100 });
await this.sdk.tools.complete(callID, { status: "completed", task_id: taskID, task });
}
public stop(): void {
this.unsubscribeInbox?.();
this.unsubscribeTools?.();
this.unsubscribeInbox = undefined;
this.unsubscribeTools = undefined;
}
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { WhiteboardMiniApp } from "@/core-apps/whiteboard/whiteboard-miniapp";
describe("WhiteboardMiniApp", () => {
it("uses only the Surface bridge for board state", async () => {
const events: string[] = [];
const sdk = {
context: { app_scope: "whiteboard", instance_id: "whiteboard:1", conversation_id: "c", app_version: "1.0.0", state: "foreground" as const },
inbox: { list: () => [], subscribe: () => () => undefined, acknowledge: () => true },
tools: { subscribe: () => () => undefined, get: () => undefined, reportProgress: async () => ({ disposition: "accepted" as const }), complete: async () => ({ disposition: "accepted" as const }), fail: async () => ({ disposition: "accepted" as const }), cancel: async () => ({ disposition: "accepted" as const }) },
lifecycle: { requestForeground: async () => ({ disposition: "accepted" as const }), requestBackground: async () => ({ disposition: "accepted" as const }), requestClose: async () => ({ disposition: "accepted" as const }) },
surfaces: { request: async (event: "open" | "patch" | "close") => { events.push(event); return { disposition: "accepted" as const }; } },
capabilities: { request: async () => ({ disposition: "accepted" as const }) },
workspace: () => ({ version: 1 as const, instances: { version: 1 as const, instances: [] }, focus: { version: 1 as const, stack: [] } }),
};
const app = new WhiteboardMiniApp(sdk);
await app.open({ nodes: [] });
await app.patch({ nodes: [{ id: "n1" }] });
await app.close();
expect(events).toEqual(["open", "patch", "close"]);
});
});
@@ -0,0 +1,52 @@
import type { LineUpMiniAppSDK } from "@/runtime/app-management/miniapp-sdk";
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
/** Minimal bundled reference App: exercises isolated Surface requests and artifact metadata. */
export class WhiteboardMiniApp {
private state: JsonObject = {};
private unsubscribeTools: (() => void) | undefined;
public constructor(private readonly sdk: LineUpMiniAppSDK) {}
public async open(initialState: JsonObject = {}): Promise<void> {
this.state = { ...initialState };
await this.sdk.surfaces.request("open", { state: this.state });
}
public start(): void {
void this.open({ title: "Whiteboard", status: "可编辑", elements: [] });
this.unsubscribeTools = this.sdk.tools.subscribe(call => { void this.handleTool(call); });
}
private async handleTool(call: { call_id: string; tool_id: string; input: JsonObject }): Promise<void> {
if (call.tool_id === "whiteboard.open") {
await this.sdk.tools.reportProgress(call.call_id, { status: "opening", percent: 40 });
await this.sdk.tools.complete(call.call_id, { status: "completed" });
return;
}
if (call.tool_id === "whiteboard.submit") {
const artifactID = typeof call.input.artifact_id === "string" ? call.input.artifact_id : `whiteboard-${call.call_id}`;
await this.sdk.tools.reportProgress(call.call_id, { status: "exporting", percent: 80 });
await this.sdk.surfaces.request("patch", { state: this.state, artifact: { artifact_id: artifactID } });
await this.sdk.tools.complete(call.call_id, { artifact_id: artifactID });
return;
}
await this.sdk.tools.fail(call.call_id, { code: "tool_not_supported", message: `不支持的画板工具:${call.tool_id}` });
}
/** Drawing/editing is private Surface state and never enters the IM transcript. */
public async patch(delta: JsonObject): Promise<void> {
this.state = { ...this.state, ...delta };
await this.sdk.surfaces.request("patch", { state: this.state });
}
public async exportArtifact(artifact: JsonObject): Promise<void> {
await this.sdk.surfaces.request("patch", { artifact });
}
public async close(): Promise<void> {
this.unsubscribeTools?.();
this.unsubscribeTools = undefined;
await this.sdk.surfaces.request("close", {});
}
}
+744 -240
View File
File diff suppressed because it is too large Load Diff
@@ -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,69 @@
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_session_id?: string;
app_scope: AppScope;
conversation_id?: string;
state: AppInstanceState;
parent_instance_id?: string;
continued_from_app_session_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) || (raw.app_session_id !== undefined && !validID(raw.app_session_id)) || (raw.continued_from_app_session_id !== undefined && !validID(raw.continued_from_app_session_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; app_session_id?: string; parent_instance_id?: string; continued_from_app_session_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,13 @@
import { describe, expect, it } from "vitest";
import { CoreAppRegistry } from "@/runtime/app-management/app-registry";
describe("CoreAppRegistry SDK v1 kind policy", () => {
it("keeps Interact as the only system MiniApp", () => {
const registry = new CoreAppRegistry();
expect(registry.get("chat")?.kind).toBe("system");
expect(() => registry.install({
app_scope: "other-system", kind: "system", enabled: true, default_eligible: false, recovery: false,
manifest: { app_scope: "other-system", version: "1.0.0", permissions: [], tools: [] },
})).toThrow("Only the Interact system MiniApp");
});
});
@@ -0,0 +1,181 @@
/**
* 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.
*/
import { validateMiniAppManifest, type MiniAppManifestV1 } from "@/runtime/app-management/miniapp-manifest";
import { BUNDLED_REFERENCE_MANIFESTS } from "@/runtime/app-management/reference-miniapps";
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
export type AppScope = "chat" | "app-registry" | "settings" | "runtime" | (string & {});
export type MiniAppKind = "system" | "bundled";
const SYSTEM_APP_SCOPE: AppScope = "chat";
export type AppToolDefinition = {
name: string;
handling: "direct" | "interactive" | "launch" | "foreground" | "operation";
/** JSON-object property names accepted by this tool. */
parameters: readonly string[];
requires_permissions?: readonly string[];
input_schema?: JsonObject;
output_schema?: JsonObject;
target?: {
app_scope: AppScope;
requires_foreground: boolean;
restore_previous_focus: boolean;
};
};
/**
* 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: MiniAppKind;
enabled: boolean;
default_eligible: boolean;
recovery: boolean;
manifest: AppManifest;
};
/**
* The Runtime installation boundary uses the frozen SDK v1 vocabulary.
* Legacy host labels (`core` / `extension`) are intentionally not accepted
* here; callers must migrate before they can register an App.
*/
export type CoreAppRecordInput = Omit<CoreAppRecord, "kind"> & { kind: MiniAppKind };
const INITIAL_CORE_APPS: readonly CoreAppRecord[] = [
{
app_scope: "chat", kind: "system", 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 CoreAppRecordInput[] = INITIAL_CORE_APPS) {
for (const record of records) this.register(record);
if (records === INITIAL_CORE_APPS) {
for (const manifest of BUNDLED_REFERENCE_MANIFESTS) this.installMiniAppManifest(manifest);
}
}
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: CoreAppRecordInput): void { this.register(record); }
/** Admits a validated SDK v1 Manifest without exposing the Manifest object to Apps. */
public installMiniAppManifest(manifest: MiniAppManifestV1, options: Pick<CoreAppRecord, "enabled" | "default_eligible" | "recovery"> = { enabled: true, default_eligible: false, recovery: false }): void {
const validation = validateMiniAppManifest(manifest);
if (!validation.accepted) throw new Error(`MiniApp Manifest denied: ${validation.detail}`);
this.install({
app_scope: manifest.app_scope,
kind: manifest.kind,
...options,
manifest: {
app_scope: manifest.app_scope,
version: manifest.version,
permissions: [...manifest.requested_capabilities],
tools: manifest.tools.map(tool => ({
name: tool.id,
handling: tool.handling,
parameters: [],
input_schema: tool.input_schema,
output_schema: tool.output_schema,
target: tool.target,
...(tool.permissions?.length ? { requires_permissions: [...tool.permissions] } : {}),
})),
},
});
}
private register(record: CoreAppRecordInput): 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 (record.kind === "system" && record.app_scope !== SYSTEM_APP_SCOPE) throw new Error("Only the Interact system MiniApp may use kind=system in SDK v1.");
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], ...(tool.input_schema ? { input_schema: clone(tool.input_schema) } : {}), ...(tool.output_schema ? { output_schema: clone(tool.output_schema) } : {}) })),
},
};
}
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.input_schema || isJsonObject(tool.input_schema))
&& (!tool.output_schema || isJsonObject(tool.output_schema))
&& (!tool.requires_permissions || (tool.requires_permissions.length > 0
&& new Set(tool.requires_permissions).size === tool.requires_permissions.length
&& tool.requires_permissions.every(permission => /^[a-z][a-z0-9._-]{0,63}$/.test(permission)
&& manifest.permissions.includes(permission))));
toolNames.add(tool.name);
return valid;
});
}
function clone<T>(value: T): T { return JSON.parse(JSON.stringify(value)) as T; }
function isJsonObject(value: unknown): value is JsonObject { return Boolean(value && typeof value === "object" && !Array.isArray(value)); }
export function isAppScope(value: unknown): value is AppScope {
return typeof value === "string" && /^[a-z][a-z0-9-]{0,63}$/.test(value);
}
+138
View File
@@ -0,0 +1,138 @@
/**
* 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[];
/** Collapsed App sub-session summaries; business UI actions are not included. */
app_sessions: readonly Readonly<{
app_session_id: string;
app_scope: AppScope;
instance_id: string;
state: string;
interaction_count: number;
pending_interaction_count: number;
history: readonly Readonly<{ id: string; role: "agent" | "user"; text: string }>[];
continued_from_app_session_id?: string;
}>[];
};
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 }
| { type: "chat.continue_app_session"; conversation_id: string; app_session_id: string };
export type CommandReceipt =
| { disposition: "accepted"; local_id?: string }
| { disposition: "rejected"; code: "inactive" | "scope_mismatch" | "invalid_action" | "capability_not_declared" | "capability_unavailable" | "invalid_capability_request" | "capability_requires_foreground" };
export type InteractRuntimeOperations = Readonly<{
dispatch(action: ChatAction): Promise<CommandReceipt>;
interactions: {
submit(callID: string, result: JsonObject): boolean;
cancel(callID: string, reason: string): boolean;
expire(callID: string): boolean;
read(callID: string): ToolCallRecord | undefined;
loadDraft(callID: string): JsonObject | undefined;
saveDraft(callID: string, values: JsonObject): void;
clearDraft(callID: string): void;
};
tasks: {
requestCancel(operationID: string): boolean;
read(operationID: string): TaskRecord | undefined;
};
}>;
export type InteractUIProjection = Readonly<{
snapshot(): ChatViewModel;
subscribe(listener: (view: ChatViewModel) => void): Unsubscribe;
subscribeEvents(listener: (event: Exclude<RuntimeAppEvent, { type: "agent-message" }>) => void): Unsubscribe;
subscribeAgentMessages(listener: (message: AgentChatMessage) => void): Unsubscribe;
listAgentMessages(afterMessageID?: string): readonly AgentChatMessage[];
acknowledgeAgentMessage(messageID: string): boolean;
}>;
/**
* Interact's SDK is the common Runtime contract plus a trusted-DOM UI projection.
* The UI projection is an adapter for IM rendering; it is not a second transport,
* persistence, Tool or capability protocol.
*/
export interface InteractRuntimeSDK {
readonly app_scope: "chat";
readonly runtime: InteractRuntimeOperations;
readonly ui: InteractUIProjection;
snapshot(): ChatViewModel;
subscribe(listener: (view: ChatViewModel) => void): Unsubscribe;
/** Chat-safe Runtime events, excluding raw Agent delivery (use subscribeAgentMessages). */
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;
};
}
/** @deprecated Use InteractRuntimeSDK; kept for the compatibility app_scope=chat name. */
export type ChatRuntimeSDK = InteractRuntimeSDK;
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" }
/** A Runtime-approved local Surface request from a bundled MiniApp. */
| {
type: "surface-request";
app_scope: AppScope;
instance_id: string;
event: "open" | "patch" | "close";
data: JsonObject;
};
export type AppMessageSubscription = {
app_scope: AppScope;
listener: (message: ScopedConversationItem) => void;
};
@@ -0,0 +1,66 @@
{
"contract": "lineup-miniapp-sdk-v1-golden-1",
"description": "03.sdk_and_coreapp frozen runtime contract cases. These cases express the required observable result; they do not grant MiniApps access to Runtime internals.",
"cases": [
{
"id": "bundled-manifest-requires-surface",
"input": { "manifest": { "kind": "bundled", "host": { "surface_required": true } } },
"expected": { "accepted": true }
},
{
"id": "bundled-manifest-without-surface-rejected",
"input": { "manifest": { "kind": "bundled", "host": { "surface_required": false } } },
"expected": { "accepted": false, "code": "manifest_denied" }
},
{
"id": "interactive-without-app-context-goes-to-main-im",
"input": { "tool": { "handling": "interactive" } },
"expected": { "app_session_id": null, "creates_business_instance": false, "enters_miniapp_inbox": false }
},
{
"id": "interactive-with-verified-closed-app-session-keeps-history-context",
"input": { "tool": { "handling": "interactive" }, "app_session_context": { "app_session_id": "session-whiteboard-1", "state": "ended", "same_agent": true, "same_conversation": true } },
"expected": { "app_session_id": "session-whiteboard-1", "creates_business_instance": false, "revives_instance": false }
},
{
"id": "interactive-with-cross-conversation-app-session-rejected",
"input": { "tool": { "handling": "interactive" }, "app_session_context": { "app_session_id": "session-other", "state": "active", "same_agent": true, "same_conversation": false } },
"expected": { "accepted": false, "code": "app_session_context_invalid", "creates_interaction": false }
},
{
"id": "answerable-interaction-uses-default-expiry",
"input": { "request": { "kind": "confirm" } },
"expected": { "expires_in_ms": 900000 }
},
{
"id": "answerable-interaction-expiry-boundary",
"input": { "request": { "kind": "input", "expires_in_ms": 60000 } },
"expected": { "accepted": true, "expires_in_ms": 60000 }
},
{
"id": "notice-does-not-accept-expiry",
"input": { "request": { "kind": "notice", "expires_in_ms": 60000 } },
"expected": { "accepted": false, "code": "interaction_request_invalid" }
},
{
"id": "dismiss-is-idempotent-and-addressed-by-call-id",
"input": { "dismiss": { "control_id": "dismiss-1", "call_id": "interactive-call-1" }, "interaction": { "status": "pending", "same_agent": true, "same_conversation": true } },
"expected": { "status": "cancelled", "tool_outbox_count": 1, "replay_outbox_count": 0 }
},
{
"id": "accepted-app-close-cancels-running-tool",
"input": { "close": { "accepted": true }, "tool": { "status": "running", "instance_id": "task-dashboard-1" } },
"expected": { "tool_status": "cancelled", "cancel_reason": "app_closed", "tool_outbox_count": 1, "close_surface": true, "stop_instance": true }
},
{
"id": "accepted-app-close-preserves-submitted-result",
"input": { "close": { "accepted": true }, "tool": { "status": "submitted", "instance_id": "whiteboard-1" } },
"expected": { "tool_status": "submitted", "continue_existing_outbox": true, "close_surface": true, "stop_instance": true }
},
{
"id": "tool-complete-does-not-close-bundled-app",
"input": { "tool": { "status": "completed", "app_kind": "bundled" } },
"expected": { "close_surface": false, "stop_instance": false, "end_app_session": false }
}
]
}
@@ -0,0 +1,98 @@
/**
* MiniApp SDK v1 的声明性 Manifest 契约。
*
* 该模块只验证 Runtime 接受的声明,不加载 Bundle、不创建 DOM,也不授予任何能力。
* Runtime、Inventory 和 Tool Router 共享它,避免各自解释 Manifest。
*/
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
export type MiniAppKind = "system" | "bundled";
export type MiniAppToolHandling = "direct" | "interactive" | "launch" | "foreground" | "operation";
export type MiniAppToolTarget = Readonly<{
app_scope: string;
requires_foreground: boolean;
restore_previous_focus: boolean;
}>;
export type MiniAppToolDescriptor = Readonly<{
id: string;
version: 1;
handling: MiniAppToolHandling;
target?: MiniAppToolTarget;
input_schema: JsonObject;
output_schema: JsonObject;
permissions?: readonly string[];
timeout_ms?: number;
}>;
export type MiniAppManifestV1 = Readonly<{
app_scope: string;
version: string;
kind: MiniAppKind;
tools: readonly MiniAppToolDescriptor[];
subscriptions: readonly string[];
requested_capabilities: readonly string[];
host: Readonly<{
min_version: string;
surface_required: boolean;
}>;
}>;
export type ManifestValidation =
| Readonly<{ accepted: true }>
| Readonly<{ accepted: false; code: "manifest_denied"; detail: string }>;
const APP_SCOPE = /^[a-z][a-z0-9-]{0,63}$/;
const TOOL_ID = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/;
const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
const CAPABILITY = /^[a-z][a-z0-9._-]{0,63}$/;
const HANDLINGS = new Set<MiniAppToolHandling>(["direct", "interactive", "launch", "foreground", "operation"]);
/**
* Checks only static, local rules. Policy and per-user capability approval are
* intentionally deferred to Runtime, because a Manifest never grants access.
*/
export function validateMiniAppManifest(manifest: MiniAppManifestV1): ManifestValidation {
if (!APP_SCOPE.test(manifest.app_scope)) return reject("app_scope must be a lowercase Runtime scope.");
if (!SEMVER.test(manifest.version) || !SEMVER.test(manifest.host.min_version)) return reject("version and host.min_version must be semantic versions.");
if (manifest.kind !== "system" && manifest.kind !== "bundled") return reject("kind must be system or bundled.");
if (manifest.kind === "bundled" && manifest.host.surface_required !== true) return reject("bundled MiniApps require a restricted Surface.");
if (!Array.isArray(manifest.tools) || !Array.isArray(manifest.subscriptions) || !Array.isArray(manifest.requested_capabilities)) return reject("manifest collections must be arrays.");
if (!manifest.subscriptions.every(entry => typeof entry === "string" && entry.length > 0)) return reject("subscriptions must be non-empty strings.");
if (!manifest.requested_capabilities.every(entry => CAPABILITY.test(entry))) return reject("requested capabilities contain an invalid identifier.");
const toolIDs = new Set<string>();
for (const tool of manifest.tools) {
if (!TOOL_ID.test(tool.id) || toolIDs.has(tool.id)) return reject("tool IDs must be unique dotted identifiers.");
toolIDs.add(tool.id);
if (tool.version !== 1 || !HANDLINGS.has(tool.handling)) return reject("tool version or handling is invalid.");
if (!isJsonObject(tool.input_schema) || !isJsonObject(tool.output_schema)) return reject("tool schemas must be JSON objects.");
if (tool.permissions && !tool.permissions.every((permission: string) => manifest.requested_capabilities.includes(permission))) return reject("tool permissions must be declared by the manifest.");
if (tool.timeout_ms !== undefined && (!Number.isInteger(tool.timeout_ms) || tool.timeout_ms <= 0)) return reject("tool timeout_ms must be a positive integer.");
if (tool.handling === "interactive") {
if (tool.target !== undefined) return reject("interactive Tools must not target a MiniApp.");
if (tool.timeout_ms !== undefined) return reject("interactive Tools use interaction expiry, not tool timeout_ms.");
} else if (!validTarget(tool.target)) {
return reject("non-interactive Tools require a valid target.");
}
}
return { accepted: true };
}
function validTarget(target: MiniAppToolTarget | undefined): target is MiniAppToolTarget {
return Boolean(target
&& APP_SCOPE.test(target.app_scope)
&& typeof target.requires_foreground === "boolean"
&& typeof target.restore_previous_focus === "boolean");
}
function isJsonObject(value: unknown): value is JsonObject {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function reject(detail: string): ManifestValidation {
return { accepted: false, code: "manifest_denied", detail };
}
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import fixture from "@/runtime/app-management/golden/miniapp-sdk-v1.json";
import { validateMiniAppManifest, type MiniAppManifestV1 } from "@/runtime/app-management/miniapp-manifest";
import { validateStandardInteractionAnswer, validateStandardInteractionRequest } from "@/runtime/coordination/standard-interaction-contract";
type GoldenCase = Readonly<{
id: string;
input: Readonly<Record<string, unknown>>;
expected: Readonly<Record<string, unknown>>;
}>;
const cases = fixture.cases as readonly GoldenCase[];
describe("MiniApp SDK v1 frozen golden contract", () => {
it("uses the versioned 03.sdk_and_coreapp fixture and stable case ordering", () => {
expect(fixture.contract).toBe("lineup-miniapp-sdk-v1-golden-1");
expect(cases.map(entry => entry.id)).toEqual([
"bundled-manifest-requires-surface",
"bundled-manifest-without-surface-rejected",
"interactive-without-app-context-goes-to-main-im",
"interactive-with-verified-closed-app-session-keeps-history-context",
"interactive-with-cross-conversation-app-session-rejected",
"answerable-interaction-uses-default-expiry",
"answerable-interaction-expiry-boundary",
"notice-does-not-accept-expiry",
"dismiss-is-idempotent-and-addressed-by-call-id",
"accepted-app-close-cancels-running-tool",
"accepted-app-close-preserves-submitted-result",
"tool-complete-does-not-close-bundled-app",
]);
});
it("contains only cases with an input and observable expected result", () => {
for (const entry of cases) {
expect(entry.id).toMatch(/^[a-z][a-z0-9-]+$/);
expect(Object.keys(entry.input).length).toBeGreaterThan(0);
expect(Object.keys(entry.expected).length).toBeGreaterThan(0);
}
});
it("enforces the frozen bundled Surface boundary", () => {
const manifest = (surfaceRequired: boolean): MiniAppManifestV1 => ({
app_scope: "task-dashboard",
version: "1.0.0",
kind: "bundled",
tools: [],
subscriptions: [],
requested_capabilities: [],
host: { min_version: "1.0.0", surface_required: surfaceRequired },
});
expect(validateMiniAppManifest(manifest(true))).toEqual({ accepted: true });
expect(validateMiniAppManifest(manifest(false))).toMatchObject({ accepted: false, code: "manifest_denied" });
});
it("enforces frozen standard-interaction expiry rules", () => {
const now = new Date("2026-08-05T00:00:00.000Z");
expect(validateStandardInteractionRequest({ kind: "confirm", title: "继续", prompt: "继续吗?" }, now)).toEqual({
accepted: true,
expires_at: "2026-08-05T00:15:00.000Z",
});
expect(validateStandardInteractionRequest({ kind: "input", title: "描述", prompt: "请输入", expires_in_ms: 60_000, field: { id: "text", label: "描述", type: "textarea" } }, now)).toMatchObject({ accepted: true });
expect(validateStandardInteractionRequest({ kind: "notice", title: "提示", message: "已保存", expires_in_ms: 60_000 } as never, now)).toEqual({ accepted: false, code: "interaction_request_invalid" });
});
it("accepts only the frozen answer shape for each standard interaction", () => {
const choice = { kind: "choice", title: "选择", prompt: "选一个", mode: "single-choice", actions: [{ id: "a", label: "A" }, { id: "b", label: "B" }] } as const;
const confirm = { kind: "confirm", title: "确认", prompt: "继续吗?" } as const;
const input = { kind: "input", title: "描述", prompt: "请输入", field: { id: "text", label: "描述", type: "textarea" } } as const;
expect(validateStandardInteractionAnswer(choice, { action_id: "a" })).toBe(true);
expect(validateStandardInteractionAnswer(choice, { action_ids: ["a"] })).toBe(false);
expect(validateStandardInteractionAnswer(confirm, { approved: true })).toBe(true);
expect(validateStandardInteractionAnswer(confirm, { confirmed: true })).toBe(false);
expect(validateStandardInteractionAnswer(input, { text: "hello" })).toBe(true);
expect(validateStandardInteractionAnswer(input, { values: { text: "hello" } })).toBe(false);
});
});
@@ -0,0 +1,44 @@
import type { AppScope } from "@/runtime/app-management/app-registry";
import type { ScopedConversationItem } from "@/runtime/coordination/runtime-envelope";
import type { MiniAppToolCallRecord } from "@/runtime/coordination/miniapp-tool-state";
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
import type { CommandReceipt, Unsubscribe } from "@/runtime/app-management/app-sdk";
export type MiniAppSDKContext = Readonly<{
app_scope: AppScope;
instance_id: string;
conversation_id: string;
app_version: string;
state: "starting" | "foreground" | "background" | "suspended";
}>;
export type MiniAppToolProgress = Readonly<{ percent?: number; status?: string; detail?: string }>;
export type MiniAppToolFailure = Readonly<{ code?: string; message: string }>;
export interface LineUpMiniAppSDK {
readonly context: MiniAppSDKContext;
readonly inbox: {
list(after_message_id?: string): readonly ScopedConversationItem[];
subscribe(listener: (message: ScopedConversationItem) => void): Unsubscribe;
acknowledge(message_id: string): boolean;
};
readonly tools: {
subscribe(listener: (call: MiniAppToolCallRecord) => void): Unsubscribe;
get(call_id: string): MiniAppToolCallRecord | 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>;
};
readonly lifecycle: {
requestForeground(): Promise<CommandReceipt>;
requestBackground(): Promise<CommandReceipt>;
requestClose(reason?: string): Promise<CommandReceipt>;
};
readonly surfaces: {
request(event: "open" | "patch" | "close", data: JsonObject): Promise<CommandReceipt>;
};
readonly capabilities: {
request(name: string, reason: string, input?: JsonObject): Promise<CommandReceipt>;
};
}
@@ -0,0 +1,14 @@
import { describe, expect, it } from "vitest";
import { validateMiniAppManifest } from "@/runtime/app-management/miniapp-manifest";
import { BUNDLED_REFERENCE_MANIFESTS } from "@/runtime/app-management/reference-miniapps";
describe("bundled MiniApp reference manifests", () => {
it("ships Task Dashboard and Whiteboard as restricted bundled apps", () => {
expect(BUNDLED_REFERENCE_MANIFESTS.map(manifest => manifest.app_scope)).toEqual(["task-dashboard", "whiteboard"]);
for (const manifest of BUNDLED_REFERENCE_MANIFESTS) {
expect(manifest.kind).toBe("bundled");
expect(validateMiniAppManifest(manifest)).toEqual({ accepted: true });
expect(manifest.host.surface_required).toBe(true);
}
});
});
@@ -0,0 +1,58 @@
import type { MiniAppManifestV1 } from "@/runtime/app-management/miniapp-manifest";
/** The two bundled SDK reference manifests shipped by the Host in SDK v1. */
export const TASK_DASHBOARD_MANIFEST: MiniAppManifestV1 = {
app_scope: "task-dashboard",
version: "1.0.0",
kind: "bundled",
subscriptions: ["lineup.v1.app.call", "lineup.v1.tool.call", "lineup.v1.text"],
requested_capabilities: [],
host: { min_version: "1.0.0", surface_required: true },
tools: [
{
id: "task-dashboard.open",
version: 1,
handling: "launch",
target: { app_scope: "task-dashboard", requires_foreground: true, restore_previous_focus: true },
input_schema: { type: "object", required: ["title"], properties: { task_id: { type: "string", minLength: 1 }, title: { type: "string", minLength: 1, maxLength: 200 }, initial_state: { type: "object" } }, additionalProperties: false },
output_schema: { type: "object", required: ["status", "task_id"], properties: { status: { type: "string", enum: ["completed", "cancelled", "failed"] }, task_id: { type: "string", minLength: 1 }, task: { type: "object" } }, additionalProperties: false },
},
{
id: "task-dashboard.update",
version: 1,
handling: "direct",
target: { app_scope: "task-dashboard", requires_foreground: false, restore_previous_focus: false },
input_schema: { type: "object", required: ["task_id"], properties: { task_id: { type: "string", minLength: 1 }, progress: { type: "number", minimum: 0, maximum: 100 }, status: { type: "string" } }, additionalProperties: false },
output_schema: { type: "object", required: ["task_id"], properties: { task_id: { type: "string", minLength: 1 }, task: { type: "object" } }, additionalProperties: false },
},
],
};
export const WHITEBOARD_MANIFEST: MiniAppManifestV1 = {
app_scope: "whiteboard",
version: "1.0.0",
kind: "bundled",
subscriptions: ["lineup.v1.ui.open", "lineup.v1.ui.patch", "lineup.v1.ui.close", "lineup.v1.artifact.offer"],
requested_capabilities: [],
host: { min_version: "1.0.0", surface_required: true },
tools: [
{
id: "whiteboard.open",
version: 1,
handling: "launch",
target: { app_scope: "whiteboard", requires_foreground: true, restore_previous_focus: true },
input_schema: { type: "object", required: ["board_id", "title"], properties: { board_id: { type: "string", minLength: 1 }, title: { type: "string", minLength: 1 }, initial_state: { type: "object" } }, additionalProperties: false },
output_schema: { type: "object" },
},
{
id: "whiteboard.submit",
version: 1,
handling: "operation",
target: { app_scope: "whiteboard", requires_foreground: true, restore_previous_focus: true },
input_schema: { type: "object", required: ["board_id"], properties: { board_id: { type: "string", minLength: 1 }, submit_request: { type: "object" } }, additionalProperties: false },
output_schema: { type: "object", properties: { artifact_id: { type: "string", minLength: 1 }, summary: { type: "object" } }, additionalProperties: false },
},
],
};
export const BUNDLED_REFERENCE_MANIFESTS: readonly MiniAppManifestV1[] = [TASK_DASHBOARD_MANIFEST, WHITEBOARD_MANIFEST];
@@ -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);
}
}
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { ArtifactContentCache } from "@/runtime/artifacts/artifact-content-cache";
describe("ArtifactContentCache", () => {
it("accepts only host-owned opaque references and never exposes paths", () => {
const cache = new ArtifactContentCache();
expect(cache.put("artifact-cache-001", new Blob(["verified bytes"]))).toBe(true);
expect(cache.has("artifact-cache-001")).toBe(true);
expect(cache.get("artifact-cache-001")?.size).toBe(14);
expect(cache.put("/tmp/report.pdf", new Blob(["x"]))).toBe(false);
expect(cache.has("/tmp/report.pdf")).toBe(false);
});
});
@@ -0,0 +1,27 @@
/**
* Host 所有的易失 Artifact 内容缓存。
*
* 它只保存已经通过 ArtifactState 接纳的宿主字节;内容不写入 Conversation Store
* Agent 只能引用不透明 local_ref,不能直接填充或读取缓存。
*
* Host-owned volatile bytes for Artifact metadata already accepted by
* ArtifactState. This cache has no network, persistence, DOM or protocol
* dependency: an Agent can name an opaque ref but cannot populate or read it.
*/
export class ArtifactContentCache {
private readonly blobs = new Map<string, Blob>();
public put(localRef: string, content: Blob): boolean {
if (!opaqueRef(localRef) || content.size > 512 * 1024 * 1024) return false;
this.blobs.set(localRef, content);
return true;
}
public get(localRef: string): Blob | undefined { return this.blobs.get(localRef); }
public has(localRef: string | undefined): boolean { return Boolean(localRef && this.blobs.has(localRef)); }
public clear(): void { this.blobs.clear(); }
}
function opaqueRef(value: string): boolean {
return /^[A-Za-z0-9_.:-]{1,128}$/.test(value) && !/[\\/]/.test(value);
}
@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { ArtifactState } from "@/runtime/artifacts/artifact-state";
describe("ArtifactState", () => {
it("keeps only bounded metadata and rejects paths or duplicate offers", () => {
const artifacts = new ArtifactState();
const offered = { artifact_id: "report_001", name: "report.pdf", mime_type: "application/pdf", size_bytes: 42, integrity: "verified", created_at: "2026-08-03T00:00:00.000Z", local_ref: "artifact-cache-001" };
expect(artifacts.offer(offered)).toEqual(expect.objectContaining({ artifact_id: "report_001", integrity: "verified" }));
expect(artifacts.getByLocalRef("artifact-cache-001")).toEqual(expect.objectContaining({ artifact_id: "report_001" }));
expect(artifacts.offer(offered)).toBeUndefined();
expect(artifacts.offer({ ...offered, artifact_id: "report_002", local_ref: "/tmp/secret.pdf" })).toBeUndefined();
});
});
@@ -0,0 +1,51 @@
/**
* Artifact 的元数据状态机。
*
* 这里仅维护名称、MIME、大小与完整性结果;不保存下载内容、文件路径,也不触发任何 DOM
* 或系统操作。
*/
export type ArtifactIntegrity = "verified" | "unverified" | "failed";
export type ArtifactRecord = Readonly<{
artifact_id: string;
name: string;
mime_type: string;
size_bytes: number;
integrity: ArtifactIntegrity;
created_at: string;
local_ref?: string;
}>;
/** Metadata-only local Artifact registry. No download, file path, or DOM action. */
export class ArtifactState {
private readonly records = new Map<string, ArtifactRecord>();
public offer(value: unknown): ArtifactRecord | undefined {
const record = parse(value);
if (!record || this.records.has(record.artifact_id)) return undefined;
this.records.set(record.artifact_id, record);
return { ...record };
}
public get(id: string): ArtifactRecord | undefined { const record = this.records.get(id); return record ? { ...record } : undefined; }
public getByLocalRef(localRef: string): ArtifactRecord | undefined {
const record = [...this.records.values()].find(candidate => candidate.local_ref === localRef);
return record ? { ...record } : undefined;
}
/** Removes a just-offered record when its separately validated host bytes fail to cache. */
public discard(id: string): void { this.records.delete(id); }
public snapshot(): readonly ArtifactRecord[] { return [...this.records.values()].sort((left, right) => left.artifact_id.localeCompare(right.artifact_id)).map(record => ({ ...record })); }
}
function parse(value: unknown): ArtifactRecord | undefined {
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
const item = value as Record<string, unknown>;
const id = text(item.artifact_id); const name = text(item.name); const mime = text(item.mime_type); const integrity = item.integrity;
if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/.test(id) || !name || name.length > 255 || !/^[a-z]+\/[a-z0-9.+-]+$/i.test(mime)
|| typeof item.size_bytes !== "number" || !Number.isSafeInteger(item.size_bytes) || item.size_bytes < 0 || item.size_bytes > 512 * 1024 * 1024
|| (integrity !== "verified" && integrity !== "unverified" && integrity !== "failed") || typeof item.created_at !== "string" || Number.isNaN(Date.parse(item.created_at))) return undefined;
const localRef = item.local_ref === undefined ? undefined : text(item.local_ref);
// A reference is opaque, bounded, and must never be a filesystem path or URL.
if (localRef !== undefined && (!/^[A-Za-z0-9_.:-]{1,128}$/.test(localRef) || /[\\/]/.test(localRef))) return undefined;
return { artifact_id: id, name, mime_type: mime, size_bytes: item.size_bytes, integrity, created_at: item.created_at, ...(localRef ? { local_ref: localRef } : {}) };
}
function text(value: unknown): string { return typeof value === "string" ? value.trim() : ""; }
@@ -0,0 +1,15 @@
import { describe, expect, it } from "vitest";
import { CapabilityAuditLog } from "@/runtime/capabilities/capability-audit";
describe("CapabilityAuditLog", () => {
it("records authority metadata but never arguments or result values", () => {
const audit = new CapabilityAuditLog();
audit.record({
call_id: "cap_001", conversation_id: "conversation",
request: { call_id: "cap_001", capability: "clipboard.write", reason: "复制内容", expires_at: "2026-08-03T01:00:00.000Z", arguments: { text: "super-secret" } },
status: "rejected", created_at: "2026-08-03T00:00:00.000Z", updated_at: "2026-08-03T00:00:01.000Z", result: { echoed: "super-secret" },
}, "user_confirmation", "2026-08-03T00:00:01.000Z");
expect(audit.snapshot()).toEqual([expect.objectContaining({ call_id: "cap_001", capability: "clipboard.write", status: "rejected" })]);
expect(JSON.stringify(audit.snapshot())).not.toContain("super-secret");
});
});
@@ -0,0 +1,41 @@
/**
* Capability 调用的最小审计日志。
*
* 只记录可安全公开的调用标识、风险等级、处置和时间,绝不写入文件路径、表单内容、Token
* 或宿主异常细节。
*/
import type { CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state";
import type { CapabilityRisk } from "@/runtime/capabilities/capability-registry";
export type CapabilityAuditEntry = Readonly<{
call_id: string;
capability: string;
risk: CapabilityRisk;
status: CapabilityCallRecord["status"];
occurred_at: string;
}>;
/**
* Bounded audit projection. Parameters and results are intentionally absent:
* a URL, clipboard value, selected path, artifact bytes or native error must
* never become a durable UI audit field merely because a capability ran.
*/
export class CapabilityAuditLog {
private readonly entries: CapabilityAuditEntry[] = [];
public record(record: CapabilityCallRecord, risk: CapabilityRisk, occurredAt: string): CapabilityAuditEntry {
const entry: CapabilityAuditEntry = { call_id: record.call_id, capability: record.request.capability, risk, status: record.status, occurred_at: occurredAt };
this.entries.push(entry);
if (this.entries.length > 200) this.entries.splice(0, this.entries.length - 200);
return { ...entry };
}
public recordDecision(callID: string, capability: string, risk: CapabilityRisk, status: CapabilityCallRecord["status"], occurredAt: string): CapabilityAuditEntry {
const entry: CapabilityAuditEntry = { call_id: callID, capability, risk, status, occurred_at: occurredAt };
this.entries.push(entry);
if (this.entries.length > 200) this.entries.splice(0, this.entries.length - 200);
return { ...entry };
}
public snapshot(): readonly CapabilityAuditEntry[] { return this.entries.map(entry => ({ ...entry })); }
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import { CapabilityCallStateMachine, type CapabilityCallRequest } from "@/runtime/capabilities/capability-call-state";
import { parseCapabilityCall } from "@/runtime/capabilities/capability-call-state";
import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry";
const now = "2026-08-03T00:00:00.000Z";
const request: CapabilityCallRequest = { call_id: "cap_001", capability: "app.open_url", reason: "打开文档", expires_at: "2026-08-03T00:05:00.000Z", arguments: { url: "https://example.com" } };
describe("CapabilityCallStateMachine", () => {
it("requires explicit approval before a handler can start", () => {
const calls = new CapabilityCallStateMachine();
calls.receive(request, "conversation", now);
expect(calls.begin("cap_001", now).disposition).toBe("invalid_transition");
expect(calls.approve("cap_001", now).disposition).toBe("accepted");
expect(calls.begin("cap_001", now).disposition).toBe("accepted");
expect(calls.finish("cap_001", "completed", { opened: true }, now)).toMatchObject({ disposition: "accepted", record: { status: "completed", result: { opened: true } } });
});
it("is idempotent and provides terminal results for denial and expiry", () => {
const calls = new CapabilityCallStateMachine();
calls.receive(request, "conversation", now);
expect(calls.receive({ ...request, reason: "changed" }, "other", now).disposition).toBe("duplicate");
expect(calls.reject("cap_001", now)).toMatchObject({ disposition: "accepted", record: { status: "rejected" } });
expect(calls.reject("cap_001", now).disposition).toBe("invalid_transition");
calls.receive({ ...request, call_id: "cap_002", expires_at: "2026-08-03T00:01:00.000Z" }, "conversation", now);
expect(calls.approve("cap_002", "2026-08-03T00:02:00.000Z")).toMatchObject({ record: { status: "expired" } });
});
it("admits only current registry capabilities with bounded, unexpired arguments", () => {
const registry = new CapabilityRegistry({ visible: ["app.open_url"] });
expect(parseCapabilityCall(request, registry, now)).toMatchObject({ disposition: "accepted", request: { capability: "app.open_url" } });
expect(parseCapabilityCall({ ...request, capability: "clipboard.write", arguments: { text: "x" } }, registry, now)).toEqual({ disposition: "unsupported" });
expect(parseCapabilityCall({ ...request, arguments: { url: "https://example.com", bypass: "yes" } }, registry, now)).toEqual({ disposition: "invalid" });
expect(parseCapabilityCall({ ...request, expires_at: "2026-08-02T00:00:00.000Z" }, registry, now)).toEqual({ disposition: "invalid" });
expect(parseCapabilityCall({ ...request, reason: "打开 https://private.example/path?q=secret" }, registry, now)).toMatchObject({ disposition: "accepted", request: { reason: "打开 链接" } });
});
it("restores a durable call ledger without reopening terminal authority", () => {
const original = new CapabilityCallStateMachine();
original.receive(request, "conversation", now);
original.approve("cap_001", now);
const restored = new CapabilityCallStateMachine();
restored.restore(original.snapshot());
expect(restored.get("cap_001")).toMatchObject({ status: "approved" });
expect(restored.unsupported("cap_001", now)).toMatchObject({ disposition: "accepted", record: { status: "unsupported" } });
expect(restored.begin("cap_001", now).disposition).toBe("invalid_transition");
expect(restored.receive(request, "conversation", now).disposition).toBe("duplicate");
});
});
@@ -0,0 +1,125 @@
/**
* Capability 调用的纯状态机。
*
* 它只裁决 pending、确认、执行和终态之间的合法跃迁;不持久化、不调用 DOM、网络、权限
* 或实际 Host handler,副作用必须在 Runtime/Host 边界之外执行。
*/
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
import type { CapabilityDefinition } from "@/runtime/capabilities/capability-registry";
import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry";
export type CapabilityCallStatus = "pending" | "approved" | "executing" | "completed" | "rejected" | "cancelled" | "expired" | "unsupported" | "failed";
export type CapabilityCallRequest = Readonly<{
call_id: string;
capability: CapabilityDefinition["name"];
reason: string;
expires_at: string;
arguments: JsonObject;
}>;
export type CapabilityCallRecord = Readonly<{
call_id: string;
conversation_id: string;
request: CapabilityCallRequest;
status: CapabilityCallStatus;
created_at: string;
updated_at: string;
result?: JsonObject;
}>;
export type CapabilityCallTransition = Readonly<{ disposition: "accepted" | "duplicate" | "missing" | "invalid_transition"; record?: CapabilityCallRecord }>;
export type CapabilityCallParseResult =
| Readonly<{ disposition: "accepted"; request: CapabilityCallRequest }>
| Readonly<{ disposition: "unsupported" | "invalid" }>;
/**
* Narrows an untrusted wire payload before a trusted confirmation UI sees it.
* Registry availability is intentionally checked here as well as at execution
* time, so an old inventory revision cannot create a usable approval card.
*/
export function parseCapabilityCall(payload: unknown, registry: CapabilityRegistry, now: string): CapabilityCallParseResult {
if (!isObject(payload)) return { disposition: "invalid" };
const callID = text(payload.call_id);
const capability = text(payload.capability) as CapabilityDefinition["name"];
const reason = sanitizeCapabilityReason(text(payload.reason));
const expiresAt = text(payload.expires_at);
if (!/^[A-Za-z][A-Za-z0-9_.:-]{0,127}$/.test(callID) || !reason || reason.length > 500 || !expiresAt || Number.isNaN(Date.parse(expiresAt))) return { disposition: "invalid" };
const resolution = registry.resolve(capability);
if (resolution.disposition !== "available") return { disposition: "unsupported" };
if (!registry.validateArguments(capability, payload.arguments) || Date.parse(expiresAt) <= Date.parse(now)) return { disposition: "invalid" };
return { disposition: "accepted", request: { call_id: callID, capability, reason, expires_at: expiresAt, arguments: clone(payload.arguments as JsonObject) } };
}
/** Lifecycle gate only. No DOM, persistence, network, permission or handler exists here. */
export class CapabilityCallStateMachine {
private readonly calls = new Map<string, CapabilityCallRecord>();
public receive(request: CapabilityCallRequest, conversationID: string, now: string): CapabilityCallTransition {
const existing = this.calls.get(request.call_id);
if (existing) return { disposition: "duplicate", record: copy(existing) };
const expired = Date.parse(request.expires_at) <= Date.parse(now);
const record: CapabilityCallRecord = { call_id: request.call_id, conversation_id: conversationID, request: copyRequest(request), status: expired ? "expired" : "pending", created_at: now, updated_at: now };
this.calls.set(record.call_id, record);
return { disposition: "accepted", record: copy(record) };
}
public approve(callID: string, now: string): CapabilityCallTransition { return this.transition(callID, now, ["pending"], "approved"); }
public reject(callID: string, now: string): CapabilityCallTransition { return this.transition(callID, now, ["pending"], "rejected"); }
public begin(callID: string, now: string): CapabilityCallTransition { return this.transition(callID, now, ["approved"], "executing"); }
public cancel(callID: string, now: string): CapabilityCallTransition { return this.transition(callID, now, ["pending", "approved", "executing"], "cancelled"); }
public unsupported(callID: string, now: string): CapabilityCallTransition { return this.transition(callID, now, ["pending", "approved"], "unsupported"); }
public finish(callID: string, status: "completed" | "failed", result: JsonObject, now: string): CapabilityCallTransition {
const transition = this.transition(callID, now, ["executing"], status);
if (transition.disposition !== "accepted" || !transition.record) return transition;
const record = this.calls.get(callID)! as CapabilityCallRecord & { result?: JsonObject };
record.result = clone(result);
return { disposition: "accepted", record: copy(record) };
}
public expire(callID: string, now: string): CapabilityCallTransition { return this.transition(callID, now, ["pending", "approved"], "expired"); }
public get(callID: string): CapabilityCallRecord | undefined { const record = this.calls.get(callID); return record ? copy(record) : undefined; }
public snapshot(): readonly CapabilityCallRecord[] { return [...this.calls.values()].map(copy); }
/** Restores only records already normalized by the persistence boundary. */
public restore(records: readonly CapabilityCallRecord[]): void {
this.calls.clear();
for (const record of records) {
if (this.calls.has(record.call_id)) continue;
this.calls.set(record.call_id, copy(record));
}
}
private transition(callID: string, now: string, allowed: readonly CapabilityCallStatus[], next: CapabilityCallStatus): CapabilityCallTransition {
const record = this.calls.get(callID);
if (!record) return { disposition: "missing" };
if (Date.parse(record.request.expires_at) <= Date.parse(now) && (record.status === "pending" || record.status === "approved")) {
(record as { status: CapabilityCallStatus; updated_at: string }).status = "expired";
(record as { updated_at: string }).updated_at = now;
return { disposition: "invalid_transition", record: copy(record) };
}
if (!allowed.includes(record.status)) return { disposition: "invalid_transition", record: copy(record) };
(record as { status: CapabilityCallStatus; updated_at: string }).status = next;
(record as { updated_at: string }).updated_at = now;
return { disposition: "accepted", record: copy(record) };
}
}
function copyRequest(request: CapabilityCallRequest): CapabilityCallRequest { return { ...request, arguments: clone(request.arguments) }; }
function copy(record: CapabilityCallRecord): CapabilityCallRecord { return { ...record, request: copyRequest(record.request), ...(record.result ? { result: clone(record.result) } : {}) }; }
function clone<T>(value: T): T { return JSON.parse(JSON.stringify(value)) as T; }
function text(value: unknown): string { return typeof value === "string" ? value : ""; }
/** A confirmation reason is an intent label, never a covert argument preview. */
export function sanitizeCapabilityReason(value: string): string {
return value.trim()
.replace(/https?:\/\/\S+/giu, "")
.replace(/(?:[A-Za-z]:)?[\\/]\S+/gu, "")
.replace(/\s+/gu, " ")
.slice(0, 500)
.trim();
}
function isObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
@@ -0,0 +1,42 @@
import { describe, expect, it, vi } from "vitest";
import { ArtifactState } from "@/runtime/artifacts/artifact-state";
import { CapabilityExecutor } from "@/runtime/capabilities/capability-executor";
import type { CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state";
const now = "2026-08-03T00:00:00.000Z";
function call(capability: CapabilityCallRecord["request"]["capability"], argumentsValue: Record<string, string>): CapabilityCallRecord {
return { call_id: "cap_001", conversation_id: "conversation", request: { call_id: "cap_001", capability, reason: "测试", expires_at: "2026-08-03T01:00:00.000Z", arguments: argumentsValue }, status: "executing", created_at: now, updated_at: now };
}
describe("CapabilityExecutor", () => {
it("uses injected host handlers only after execution has begun", async () => {
const handlers = { openURL: vi.fn(async () => {}), writeClipboard: vi.fn(async () => {}), pickFile: vi.fn(async () => undefined), saveArtifact: vi.fn(async () => {}) };
const executor = new CapabilityExecutor(handlers, new ArtifactState());
await expect(executor.execute({ ...call("app.open_url", { url: "https://example.com" }), status: "pending" })).resolves.toEqual({ status: "failed", result: { code: "not_executing" } });
await expect(executor.execute(call("app.open_url", { url: "https://example.com" }))).resolves.toEqual({ status: "completed", result: { opened: true } });
expect(handlers.openURL).toHaveBeenCalledWith("https://example.com");
await expect(executor.execute(call("app.open_url", { url: "javascript:alert(1)" }))).resolves.toEqual({ status: "failed", result: { code: "invalid_url" } });
handlers.openURL.mockRejectedValueOnce(new Error("popup blocked"));
await expect(executor.execute(call("app.open_url", { url: "https://example.com" }))).resolves.toEqual({ status: "failed", result: { code: "handler_failed" } });
});
it("never saves an unknown or unverified artifact", async () => {
const handlers = { openURL: vi.fn(async () => {}), writeClipboard: vi.fn(async () => {}), pickFile: vi.fn(async () => undefined), saveArtifact: vi.fn(async () => {}) };
const artifacts = new ArtifactState();
const executor = new CapabilityExecutor(handlers, artifacts);
await expect(executor.execute(call("artifact.save", { artifact_id: "missing" }))).resolves.toEqual({ status: "failed", result: { code: "artifact_unavailable" } });
artifacts.offer({ artifact_id: "unverified", name: "x.txt", mime_type: "text/plain", size_bytes: 1, integrity: "unverified", created_at: now });
await expect(executor.execute(call("artifact.save", { artifact_id: "unverified" }))).resolves.toEqual({ status: "failed", result: { code: "artifact_unavailable" } });
expect(handlers.saveArtifact).not.toHaveBeenCalled();
artifacts.offer({ artifact_id: "verified", name: "x.txt", mime_type: "text/plain", size_bytes: 1, integrity: "verified", created_at: now });
await executor.execute(call("artifact.save", { artifact_id: "verified" }));
expect(handlers.saveArtifact).toHaveBeenCalledWith("verified");
});
it("turns a cancelled file selection into an argument-free terminal outcome", async () => {
const handlers = { openURL: vi.fn(async () => {}), writeClipboard: vi.fn(async () => {}), pickFile: vi.fn(async () => undefined), saveArtifact: vi.fn(async () => {}) };
const executor = new CapabilityExecutor(handlers, new ArtifactState());
await expect(executor.execute(call("device.pick_file", {}))).resolves.toEqual({ status: "cancelled", result: {} });
expect(handlers.pickFile).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,69 @@
/**
* 可信 Host 能力执行适配层。
*
* 该层只接收已经过 Runtime 状态机与策略确认的调用,执行 Host handler 后把结果收敛为
* 可安全回传的有限结果,不能把原始异常、文件路径或宿主对象泄漏给 Agent。
*/
import type { JsonObject } from "@/runtime/protocol/lineup-v1";
import type { ArtifactState } from "@/runtime/artifacts/artifact-state";
import type { CapabilityCallRecord } from "@/runtime/capabilities/capability-call-state";
export type PickedFile = Readonly<{ name: string; mime_type: string; size_bytes: number }>;
export type CapabilityHostHandlers = Readonly<{
openURL: (url: string) => Promise<void>;
writeClipboard: (text: string) => Promise<void>;
pickFile: () => Promise<PickedFile | undefined>;
saveArtifact: (artifactID: string) => Promise<void>;
}>;
export type CapabilityExecution = Readonly<{ status: "completed" | "failed" | "cancelled"; result: JsonObject }>;
/**
* Platform handler dispatcher. It contains no confirmation UI and must be
* invoked only after CapabilityCallStateMachine has moved the call to
* `executing`; callers own that state transition and audit recording.
*/
export class CapabilityExecutor {
public constructor(private readonly handlers: CapabilityHostHandlers, private readonly artifacts: ArtifactState) {}
public async execute(record: CapabilityCallRecord): Promise<CapabilityExecution> {
if (record.status !== "executing") return { status: "failed", result: { code: "not_executing" } };
try {
switch (record.request.capability) {
case "app.open_url": {
const url = requiredText(record.request.arguments, "url");
if (!url || !isSafeExternalURL(url)) return { status: "failed", result: { code: "invalid_url" } };
await this.handlers.openURL(url);
return { status: "completed", result: { opened: true } };
}
case "clipboard.write": {
const text = requiredText(record.request.arguments, "text");
if (!text) return { status: "failed", result: { code: "invalid_text" } };
await this.handlers.writeClipboard(text);
return { status: "completed", result: { written: true } };
}
case "device.pick_file": {
const file = await this.handlers.pickFile();
return file ? { status: "completed", result: { selected: true, name: file.name, mime_type: file.mime_type, size_bytes: file.size_bytes } } : { status: "cancelled", result: {} };
}
case "artifact.save": {
const artifactID = requiredText(record.request.arguments, "artifact_id");
const artifact = artifactID ? this.artifacts.get(artifactID) : undefined;
if (!artifact || artifact.integrity !== "verified") return { status: "failed", result: { code: "artifact_unavailable" } };
await this.handlers.saveArtifact(artifact.artifact_id);
return { status: "completed", result: { saved: true, artifact_id: artifact.artifact_id } };
}
}
} catch {
return { status: "failed", result: { code: "handler_failed" } };
}
}
}
function requiredText(value: JsonObject, key: string): string | undefined {
const text = value[key];
return typeof text === "string" && text.trim() ? text : undefined;
}
function isSafeExternalURL(value: string): boolean {
try { const parsed = new URL(value); return parsed.protocol === "https:" && Boolean(parsed.hostname); } catch { return false; }
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { CapabilityRegistry } from "@/runtime/capabilities/capability-registry";
describe("CapabilityRegistry", () => {
it("declares requestability without making a capability executable", () => {
const registry = new CapabilityRegistry();
expect(registry.resolve("app.open_url")).toEqual(expect.objectContaining({ disposition: "available", definition: expect.objectContaining({ risk: "user_confirmation" }) }));
expect(registry.validateArguments("app.open_url", { url: "https://example.com" })).toBe(true);
expect(registry.validateArguments("app.open_url", { url: "https://example.com", bypass: "yes" })).toBe(false);
expect(registry.resolve("artifact.save")).toEqual({ disposition: "revoked" });
});
it("treats unknown and revoked capabilities as unavailable", () => {
const registry = new CapabilityRegistry({ visible: ["artifact.save"] });
expect(registry.resolve("clipboard.write")).toEqual({ disposition: "revoked" });
expect(registry.resolve("camera.capture")).toEqual({ disposition: "unknown" });
expect(registry.validateArguments("clipboard.write", { text: "secret" })).toBe(false);
});
it("restores only known capability names", () => {
const registry = new CapabilityRegistry({ visible: [] });
registry.restore({ version: 1, policy: { visible: ["app.open_url", "camera.capture"] } });
expect(registry.inventory().map(definition => definition.name)).toEqual(["app.open_url"]);
});
});
@@ -0,0 +1,111 @@
/**
* 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 CapabilityInputSchema = Readonly<{
type: "object";
required: readonly string[];
properties: Readonly<Record<string, "string">>;
}>;
export type CapabilityDefinition = Readonly<{
name: "app.open_url" | "clipboard.write" | "device.pick_file" | "artifact.save";
version: "1.0";
risk: CapabilityRisk;
input_schema: CapabilityInputSchema;
foreground_required: boolean;
}>;
export type CapabilityPolicy = Readonly<{
visible: readonly CapabilityDefinition["name"][];
}>;
export type CapabilityRegistrySnapshot = Readonly<{
version: 1;
policy: CapabilityPolicy;
}>;
export type CapabilityResolution =
| Readonly<{ disposition: "available"; definition: CapabilityDefinition }>
| Readonly<{ disposition: "unknown" | "revoked" }>;
const DEFINITIONS: readonly CapabilityDefinition[] = [
{ name: "app.open_url", version: "1.0", risk: "user_confirmation", input_schema: { type: "object", required: ["url"], properties: { url: "string" } }, foreground_required: true },
{ name: "clipboard.write", version: "1.0", risk: "user_confirmation", input_schema: { type: "object", required: ["text"], properties: { text: "string" } }, foreground_required: true },
{ name: "device.pick_file", version: "1.0", risk: "system_permission", input_schema: { type: "object", required: [], properties: {} }, foreground_required: true },
{ name: "artifact.save", version: "1.0", risk: "user_confirmation", input_schema: { type: "object", required: ["artifact_id"], properties: { artifact_id: "string" } }, foreground_required: true },
];
const NAMES = new Set(DEFINITIONS.map(definition => definition.name));
// An Artifact offer in M3 is metadata-only; no host-owned bytes/cache exist
// yet. Do not advertise a `save` operation until M4 provides a verified local
// Artifact cache. Callers with such a cache may explicitly enable it.
const DEFAULT_VISIBLE = DEFINITIONS
.filter(definition => definition.name !== "artifact.save")
.map(definition => definition.name);
/**
* M3's local authority boundary. This has no DOM, storage, transport,
* Surface bridge, browser permission, or handler side effect. It can say a
* capability is requestable, never that an Agent is authorized to execute it.
*/
export class CapabilityRegistry {
private readonly visible = new Set<CapabilityDefinition["name"]>();
public constructor(policy: CapabilityPolicy = { visible: DEFAULT_VISIBLE }) {
this.restore({ version: 1, policy });
}
public resolve(name: unknown): CapabilityResolution {
if (typeof name !== "string" || !NAMES.has(name as CapabilityDefinition["name"])) return { disposition: "unknown" };
const definition = DEFINITIONS.find(candidate => candidate.name === name)!;
return this.visible.has(definition.name) ? { disposition: "available", definition } : { disposition: "revoked" };
}
public setVisible(name: CapabilityDefinition["name"], visible: boolean): CapabilityResolution {
const result = this.resolveKnown(name);
if (!result) return { disposition: "unknown" };
if (visible) this.visible.add(name); else this.visible.delete(name);
return this.resolve(name);
}
public validateArguments(name: unknown, argumentsValue: unknown): boolean {
const resolution = this.resolve(name);
if (resolution.disposition !== "available" || !isPlainObject(argumentsValue)) return false;
const schema = resolution.definition.input_schema;
const keys = Object.keys(argumentsValue);
if (keys.some(key => !(key in schema.properties))) return false;
if (schema.required.some(key => typeof argumentsValue[key] !== "string" || !argumentsValue[key].trim())) return false;
return keys.every(key => typeof argumentsValue[key] === "string" && argumentsValue[key].length <= 8_192);
}
public snapshot(): CapabilityRegistrySnapshot {
return { version: 1, policy: { visible: DEFINITIONS.map(definition => definition.name).filter(name => this.visible.has(name)) } };
}
public inventory(): readonly CapabilityDefinition[] {
return DEFINITIONS.filter(definition => this.visible.has(definition.name));
}
public restore(snapshot: unknown): void {
this.visible.clear();
if (!isPlainObject(snapshot) || snapshot.version !== 1 || !isPlainObject(snapshot.policy) || !Array.isArray(snapshot.policy.visible)) return;
for (const name of snapshot.policy.visible) if (typeof name === "string" && NAMES.has(name as CapabilityDefinition["name"])) this.visible.add(name as CapabilityDefinition["name"]);
}
private resolveKnown(name: CapabilityDefinition["name"]): CapabilityDefinition | undefined {
return DEFINITIONS.find(definition => definition.name === name);
}
}
function isPlainObject(value: unknown): value is JsonObject {
if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
@@ -1,4 +1,9 @@
/**
* AppServer
*
* HTTP
* 使 `fetch` `Response`便 Runtime/App
*
* The Transport Adapter is the only M0 boundary that knows the current
* AppServer HTTP endpoints. Its callers work with conversation data and do
* not receive Response objects or call fetch directly, so this contract can
@@ -1,113 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ToolCallRecord } from "./tool-call-state";
import type { TaskRecord } from "./task-state";
import { ConversationStore } from "./conversation-store";
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); }
}
const conversationID = "user_test:2:agent_channel";
const createdAt = "2026-08-03T00:00:00.000Z";
describe("ConversationStore", () => {
it("keeps the inclusive sync cursor at zero after a send acknowledgement", () => {
const store = new ConversationStore(new MemoryStorage());
store.open(conversationID);
store.appendLocalText("local_001", "hello", createdAt);
store.markSubmitted("local_001", 41, createdAt);
expect(store.snapshot()).toMatchObject({ cursor: 0, outbox: [] });
expect(store.snapshot().items).toEqual([expect.objectContaining({
local_id: "local_001",
message_seq: 41,
item: expect.objectContaining({ kind: "user-message", delivery: expect.objectContaining({ status: "submitted" }) }),
})]);
});
it("deduplicates the inclusive sync echo of a locally submitted user message", () => {
const store = new ConversationStore(new MemoryStorage());
store.open(conversationID);
store.appendLocalText("local_001", "hello", createdAt);
store.markSubmitted("local_001", 41, createdAt);
const restored = store.recordSyncedUserText(41, "hello", createdAt);
expect(restored).toBeUndefined();
expect(store.snapshot()).toMatchObject({ cursor: 41 });
expect(store.snapshot().items).toHaveLength(1);
});
it("persists a local echo and restores it for the same conversation", () => {
const storage = new MemoryStorage();
const first = new ConversationStore(storage);
first.open(conversationID);
first.appendLocalText("local_002", "persist me", createdAt);
const reopened = new ConversationStore(storage);
const snapshot = reopened.open(conversationID);
expect(snapshot.cursor).toBe(0);
expect(snapshot.outbox).toEqual([expect.objectContaining({ local_id: "local_002", payload: "persist me" })]);
expect(snapshot.items).toEqual([expect.objectContaining({
local_id: "local_002",
item: expect.objectContaining({ kind: "user-message", text: "persist me" }),
})]);
});
it("accepts one remote message per IM sequence and advances the cursor only from sync data", () => {
const store = new ConversationStore(new MemoryStorage());
store.open(conversationID);
const item = { kind: "markdown" as const, id: "evt_remote_001", markdown: "remote" };
expect(store.recordIncoming(item, 42, createdAt)).toBe(true);
expect(store.recordIncoming(item, 42, createdAt)).toBe(false);
expect(store.snapshot()).toMatchObject({ cursor: 42 });
expect(store.snapshot().items).toHaveLength(1);
});
it("persists Tool Call state and an unsubmitted form draft per conversation", () => {
const storage = new MemoryStorage();
const state: ToolCallRecord = {
call_id: "call_form_001",
conversation_id: conversationID,
request: {
call_id: "call_form_001",
tool: "input",
title: "Deploy",
prompt: "Provide a version.",
data: { form: {} },
},
status: "pending",
created_at: createdAt,
updated_at: createdAt,
};
const first = new ConversationStore(storage);
first.open(conversationID);
first.replaceToolCalls([state]);
first.saveToolDraft("call_form_001", { version: "1.2.3" });
const reopened = new ConversationStore(storage).open(conversationID);
expect(reopened.tool_calls).toEqual([expect.objectContaining({ call_id: "call_form_001", status: "pending" })]);
expect(reopened.tool_drafts).toEqual({ call_form_001: { version: "1.2.3" } });
});
it("persists the latest aggregated task record per conversation", () => {
const storage = new MemoryStorage();
const task: TaskRecord = {
operation_id: "task_store_001", conversation_id: conversationID, title: "Render preview", percent: 70, status: "running", cancellable: true, updated_at: createdAt,
};
const first = new ConversationStore(storage);
first.open(conversationID);
first.replaceTasks([task]);
expect(new ConversationStore(storage).open(conversationID).tasks).toEqual([expect.objectContaining({ operation_id: "task_store_001", percent: 70 })]);
});
});
-260
View File
@@ -1,260 +0,0 @@
import type { ConversationItem, DeliveryState, JsonObject } from "../protocol";
import type { ToolCallRecord } from "./tool-call-state";
import type { TaskRecord } from "./task-state";
export type StoredConversationItem = {
local_id: string;
item: ConversationItem;
created_at: string;
message_seq?: number;
};
export type OutboxEntry = {
local_id: string;
payload: string;
created_at: string;
};
export type ConversationSnapshot = {
conversation_id: string;
cursor: number;
items: readonly StoredConversationItem[];
outbox: readonly OutboxEntry[];
tool_calls: readonly ToolCallRecord[];
tool_drafts: Readonly<Record<string, JsonObject>>;
tasks: readonly TaskRecord[];
};
type PersistedConversation = {
version: 5;
cursor: number;
items: StoredConversationItem[];
outbox: OutboxEntry[];
tool_calls: ToolCallRecord[];
tool_drafts: Record<string, JsonObject>;
tasks: TaskRecord[];
};
const STORE_PREFIX = "lineup.conversation.v1";
/**
* Conversation-local state and its browser persistence adapter.
*
* It stores only validated ConversationItems, Tool Call cache/drafts, and
* outgoing plaintext awaiting transport acknowledgement. It has no DOM,
* renderer, HTTP, or IM dependency.
* A native Store can implement the same snapshot semantics in M4 hosts.
*/
export class ConversationStore {
private activeConversationID: string | undefined;
private state: PersistedConversation = emptyConversation();
public constructor(private readonly storage: Storage) {}
public open(conversationID: string): ConversationSnapshot {
this.activeConversationID = conversationID;
this.state = this.load(conversationID);
return this.snapshot();
}
public close(): void {
this.activeConversationID = undefined;
this.state = emptyConversation();
}
public snapshot(): ConversationSnapshot {
return {
conversation_id: this.requireActiveConversationID(),
cursor: this.state.cursor,
items: this.state.items.map(item => ({ ...item })),
outbox: this.state.outbox.map(entry => ({ ...entry })),
tool_calls: clone(this.state.tool_calls),
tool_drafts: clone(this.state.tool_drafts),
tasks: clone(this.state.tasks),
};
}
/** Replaces only the current conversation's validated Kernel Tool Call projection. */
public replaceToolCalls(records: readonly ToolCallRecord[]): void {
this.state.tool_calls = records.map(record => clone(record));
this.persist();
}
public getToolDraft(callID: string): JsonObject | undefined {
const draft = this.state.tool_drafts[callID];
return draft ? clone(draft) : undefined;
}
public saveToolDraft(callID: string, values: JsonObject): void {
this.state.tool_drafts[callID] = clone(values);
this.persist();
}
public clearToolDraft(callID: string): void {
if (!(callID in this.state.tool_drafts)) return;
delete this.state.tool_drafts[callID];
this.persist();
}
public replaceTasks(records: readonly TaskRecord[]): void {
this.state.tasks = records.map(record => clone(record));
this.persist();
}
public appendLocalText(localID: string, text: string, createdAt: string): StoredConversationItem {
const delivery: DeliveryState = { status: "local_pending", updated_at: createdAt };
const stored: StoredConversationItem = {
local_id: localID,
created_at: createdAt,
item: { kind: "user-message", id: localID, text, delivery },
};
this.state.items.push(stored);
this.state.outbox.push({ local_id: localID, payload: text, created_at: createdAt });
this.persist();
return { ...stored };
}
/** Transport acknowledgement gives the local message its durable IM sequence. */
public markSubmitted(localID: string, messageSeq: number, updatedAt: string): void {
const stored = this.state.items.find(item => item.local_id === localID);
if (!stored || stored.item.kind !== "user-message") return;
stored.message_seq = messageSeq;
stored.item = {
...stored.item,
delivery: { status: "submitted", updated_at: updatedAt, transport_id: String(messageSeq) },
};
this.state.outbox = this.state.outbox.filter(entry => entry.local_id !== localID);
// A send acknowledgement is not a sync acknowledgement. The legacy
// `/messages/sync` cursor is inclusive, so advancing it here would make a
// refresh start at `messageSeq + 1` and permanently skip this user's echo.
// Only a message that has actually passed through the sync path advances
// the cursor (recordSyncedUserText / recordIncoming / advanceCursor).
this.persist();
}
public markFailed(localID: string, error: string, updatedAt: string): void {
const stored = this.state.items.find(item => item.local_id === localID);
if (!stored || stored.item.kind !== "user-message") return;
stored.item = {
...stored.item,
delivery: { status: "failed", updated_at: updatedAt, error, retryable: true },
};
this.persist();
}
public advanceCursor(messageSeq: number): void {
if (messageSeq <= this.state.cursor) return;
this.state.cursor = messageSeq;
this.persist();
}
/**
* Inserts a remote item exactly once. Sequence is the primary IM identity;
* an envelope id catches duplicate messages arriving through another source.
*/
public recordIncoming(item: ConversationItem, messageSeq: number, createdAt: string): boolean {
if (this.state.items.some(existing => existing.message_seq === messageSeq)) {
this.state.cursor = Math.max(this.state.cursor, messageSeq);
this.persist();
return false;
}
if (item.id && this.state.items.some(existing => existing.item.id === item.id)) {
this.state.cursor = Math.max(this.state.cursor, messageSeq);
this.persist();
return false;
}
this.state.items.push({
local_id: `remote_${messageSeq}`,
item,
created_at: createdAt,
message_seq: messageSeq,
});
this.state.cursor = Math.max(this.state.cursor, messageSeq);
this.persist();
return true;
}
/** Restores a user message sent by this client when no local record exists. */
public recordSyncedUserText(messageSeq: number, text: string, createdAt: string): StoredConversationItem | undefined {
if (this.state.items.some(existing => existing.message_seq === messageSeq)) {
this.state.cursor = Math.max(this.state.cursor, messageSeq);
this.persist();
return undefined;
}
const item: ConversationItem = {
kind: "user-message",
id: `remote_${messageSeq}`,
text,
delivery: { status: "delivered", updated_at: createdAt, transport_id: String(messageSeq) },
};
this.state.items.push({ local_id: `remote_${messageSeq}`, item, created_at: createdAt, message_seq: messageSeq });
this.state.cursor = Math.max(this.state.cursor, messageSeq);
this.persist();
return { local_id: `remote_${messageSeq}`, item, created_at: createdAt, message_seq: messageSeq };
}
private load(conversationID: string): PersistedConversation {
try {
const parsed: unknown = JSON.parse(this.storage.getItem(this.key(conversationID)) ?? "null");
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return emptyConversation();
const candidate = parsed as { version?: unknown; cursor?: unknown; items?: unknown; outbox?: unknown; tool_calls?: unknown; tool_drafts?: unknown; tasks?: unknown };
if ((candidate.version !== 1 && candidate.version !== 2 && candidate.version !== 3 && candidate.version !== 4 && candidate.version !== 5) || !Array.isArray(candidate.items) || !Array.isArray(candidate.outbox) || typeof candidate.cursor !== "number") {
return emptyConversation();
}
if (candidate.version !== 5) {
// v1 advanced its cursor from a send acknowledgement. Some browsers
// had already been upgraded to v2 before that correction, retaining a
// high cursor with holes for their own echoes. Preserve every valid
// local item but replay the server history once to repair those holes;
// the next persist upgrades this record to v5.
return {
version: 5,
cursor: candidate.version === 4 ? Math.max(0, candidate.cursor) : 0,
items: candidate.items as StoredConversationItem[],
outbox: candidate.outbox as OutboxEntry[],
tool_calls: candidate.version === 4 && Array.isArray(candidate.tool_calls) ? candidate.tool_calls as ToolCallRecord[] : [],
tool_drafts: candidate.version === 4 && isDraftMap(candidate.tool_drafts) ? candidate.tool_drafts : {},
tasks: [],
};
}
return {
version: 5,
cursor: Math.max(0, candidate.cursor),
items: candidate.items as StoredConversationItem[],
outbox: candidate.outbox as OutboxEntry[],
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[] : [],
};
} catch {
return emptyConversation();
}
}
private persist(): void {
this.storage.setItem(this.key(this.requireActiveConversationID()), JSON.stringify(this.state));
}
private key(conversationID: string): string {
return `${STORE_PREFIX}:${conversationID}`;
}
private requireActiveConversationID(): string {
if (!this.activeConversationID) throw new Error("ConversationStore has no active conversation.");
return this.activeConversationID;
}
}
function emptyConversation(): PersistedConversation {
return { version: 5, cursor: 0, items: [], outbox: [], tool_calls: [], tool_drafts: {}, tasks: [] };
}
function isDraftMap(value: unknown): value is Record<string, JsonObject> {
return value !== null && typeof value === "object" && !Array.isArray(value)
&& Object.values(value).every(draft => draft !== null && typeof draft === "object" && !Array.isArray(draft));
}
function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { AgentInteractionService } from "@/runtime/coordination/agent-interaction-service";
const base = {
interaction_id: "interaction-1",
call_id: "call-1",
agent_id: "agent-1",
conversation_id: "conversation-1",
interact_instance_id: "chat-1",
created_at: "2026-08-05T00:00:00.000Z",
};
describe("AgentInteractionService", () => {
it("creates, presents and accepts notice exactly once", () => {
const service = new AgentInteractionService();
expect(service.create({ ...base, request: { kind: "notice", title: "已保存", message: "任务已保存" } })).toMatchObject({ disposition: "accepted" });
expect(service.present("interaction-1", "2026-08-05T00:00:01.000Z")).toMatchObject({ disposition: "accepted", record: { status: "completed", result: { outcome: "accepted" } } });
expect(service.present("interaction-1", "2026-08-05T00:00:02.000Z")).toMatchObject({ disposition: "invalid_transition", record: { status: "completed" } });
});
it("lets only the first answer/dismiss/expiry transition win", () => {
const service = new AgentInteractionService();
service.create({ ...base, request: { kind: "confirm", title: "确认", prompt: "继续吗?", expires_in_ms: 60_000 } });
service.present("interaction-1", "2026-08-05T00:00:01.000Z");
expect(service.submit("interaction-1", { approved: true }, "2026-08-05T00:00:02.000Z")).toMatchObject({ record: { status: "completed", result: { outcome: "answered" } } });
expect(service.dismiss({ call_id: "call-1", agent_id: "agent-1", conversation_id: "conversation-1" }, "2026-08-05T00:00:03.000Z")).toMatchObject({ disposition: "invalid_transition", record: { status: "completed" } });
});
it("rejects cross-agent dismiss and restores expired records", () => {
const service = new AgentInteractionService();
service.create({ ...base, request: { kind: "input", title: "描述", prompt: "输入", expires_in_ms: 60_000, field: { id: "text", label: "描述", type: "textarea" } } });
expect(service.dismiss({ call_id: "call-1", agent_id: "other-agent", conversation_id: "conversation-1" }, "2026-08-05T00:00:01.000Z")).toEqual({ disposition: "invalid_transition" });
service.restore(service.list(), "2026-08-05T00:01:00.000Z");
expect(service.get("interaction-1")).toMatchObject({ status: "expired", result: { outcome: "expired" } });
});
});
@@ -0,0 +1,179 @@
import {
validateStandardInteractionRequest,
validateStandardInteractionAnswer,
type StandardInteractionRequest,
} from "@/runtime/coordination/standard-interaction-contract";
export type StandardInteractionKind = StandardInteractionRequest["kind"];
export type StandardInteractionResult =
| Readonly<{ outcome: "accepted" }>
| Readonly<{ outcome: "answered"; answer: Readonly<Record<string, unknown>> }>
| Readonly<{ outcome: "cancelled" }>
| Readonly<{ outcome: "expired" }>
| Readonly<{ outcome: "failed"; error_code: string }>;
export type StandardInteractionStatus = "pending" | "presented" | "submitted" | "completed" | "cancelled" | "expired" | "failed";
export type StandardInteractionRecord = Readonly<{
interaction_id: string;
call_id: string;
agent_id: string;
conversation_id: string;
interact_instance_id: string;
app_session_id?: string;
kind: StandardInteractionKind;
request: StandardInteractionRequest;
status: StandardInteractionStatus;
result?: StandardInteractionResult;
created_at: string;
updated_at: string;
expires_at?: string;
}>;
export type InteractionTransition =
| Readonly<{ disposition: "accepted"; record: StandardInteractionRecord }>
| Readonly<{ disposition: "duplicate" | "invalid_transition" | "missing" | "invalid_request"; record?: StandardInteractionRecord }>;
export type CreateInteractionInput = Readonly<{
interaction_id: string;
call_id: string;
agent_id: string;
conversation_id: string;
interact_instance_id: string;
app_session_id?: string;
request: StandardInteractionRequest;
created_at: string;
}>;
/**
* Runtime-owned state machine for Agent-facing Interact records.
* It deliberately knows nothing about renderers or transport. Outbox writes
* are made by the caller only for an accepted terminal transition.
*/
export class AgentInteractionService {
private readonly records = new Map<string, StandardInteractionRecord>();
public create(input: CreateInteractionInput): InteractionTransition {
const existing = this.records.get(input.interaction_id) ?? this.byCall(input.call_id);
if (existing) return { disposition: "duplicate", record: copy(existing) };
const validation = validateStandardInteractionRequest(input.request, new Date(input.created_at));
if (!validation.accepted) return { disposition: "invalid_request" };
const record: StandardInteractionRecord = {
interaction_id: input.interaction_id,
call_id: input.call_id,
agent_id: input.agent_id,
conversation_id: input.conversation_id,
interact_instance_id: input.interact_instance_id,
...(input.app_session_id ? { app_session_id: input.app_session_id } : {}),
kind: input.request.kind,
request: cloneRequest(input.request),
status: "pending",
created_at: input.created_at,
updated_at: input.created_at,
...(validation.expires_at ? { expires_at: validation.expires_at } : {}),
};
this.records.set(record.interaction_id, record);
return { disposition: "accepted", record: copy(record) };
}
public present(interactionID: string, now: string): InteractionTransition {
const record = this.records.get(interactionID);
if (!record) return { disposition: "missing" };
if (record.status !== "pending") return { disposition: "invalid_transition", record: copy(record) };
const presented: StandardInteractionRecord = { ...record, status: "presented", updated_at: now };
this.records.set(interactionID, presented);
if (presented.kind === "notice") {
const completed: StandardInteractionRecord = { ...presented, status: "completed", result: { outcome: "accepted" }, updated_at: now };
this.records.set(interactionID, completed);
return { disposition: "accepted", record: copy(completed) };
}
return { disposition: "accepted", record: copy(presented) };
}
public submit(interactionID: string, answer: Readonly<Record<string, unknown>>, now: string): InteractionTransition {
const record = this.records.get(interactionID);
if (!record) return { disposition: "missing" };
if (record.status !== "pending" && record.status !== "presented") return { disposition: "invalid_transition", record: copy(record) };
if (expired(record, now)) return this.expire(interactionID, now);
if (!validateStandardInteractionAnswer(record.request, answer)) return { disposition: "invalid_request", record: copy(record) };
const submitted: StandardInteractionRecord = { ...record, status: "submitted", result: { outcome: "answered", answer: { ...answer } }, updated_at: now };
const completed: StandardInteractionRecord = { ...submitted, status: "completed", updated_at: now };
this.records.set(interactionID, completed);
return { disposition: "accepted", record: copy(completed) };
}
public dismiss(input: Readonly<{ call_id: string; agent_id: string; conversation_id: string }>, now: string): InteractionTransition {
const record = this.byCall(input.call_id);
if (!record) return { disposition: "missing" };
if (record.agent_id !== input.agent_id || record.conversation_id !== input.conversation_id) return { disposition: "invalid_transition" };
if (record.status !== "pending" && record.status !== "presented") return { disposition: "invalid_transition", record: copy(record) };
const cancelled: StandardInteractionRecord = { ...record, status: "cancelled", result: { outcome: "cancelled" }, updated_at: now };
this.records.set(record.interaction_id, cancelled);
return { disposition: "accepted", record: copy(cancelled) };
}
public expire(interactionID: string, now: string): InteractionTransition {
const record = this.records.get(interactionID);
if (!record) return { disposition: "missing" };
if (record.status !== "pending" && record.status !== "presented") return { disposition: "invalid_transition", record: copy(record) };
if (!expired(record, now)) return { disposition: "invalid_transition", record: copy(record) };
const final: StandardInteractionRecord = { ...record, status: "expired", result: { outcome: "expired" }, updated_at: now };
this.records.set(interactionID, final);
return { disposition: "accepted", record: copy(final) };
}
public get(interactionID: string): StandardInteractionRecord | undefined {
const record = this.records.get(interactionID);
return record ? copy(record) : undefined;
}
public list(): readonly StandardInteractionRecord[] { return [...this.records.values()].map(copy); }
public restore(records: readonly StandardInteractionRecord[], now: string): void {
this.records.clear();
for (const record of records) {
if (!isRecord(record) || this.records.has(record.interaction_id)) continue;
const restored = copy(record);
if ((restored.status === "pending" || restored.status === "presented") && expired(restored, now)) {
this.records.set(restored.interaction_id, { ...restored, status: "expired", result: { outcome: "expired" }, updated_at: now });
} else {
this.records.set(restored.interaction_id, restored);
}
}
}
public reset(): void { this.records.clear(); }
private byCall(callID: string): StandardInteractionRecord | undefined {
return [...this.records.values()].find(record => record.call_id === callID);
}
}
function expired(record: StandardInteractionRecord, now: string): boolean {
return Boolean(record.expires_at && Date.parse(record.expires_at) <= Date.parse(now));
}
function isRecord(value: unknown): value is StandardInteractionRecord {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const candidate = value as Partial<StandardInteractionRecord>;
return typeof candidate.interaction_id === "string"
&& typeof candidate.call_id === "string"
&& typeof candidate.agent_id === "string"
&& typeof candidate.conversation_id === "string"
&& typeof candidate.interact_instance_id === "string"
&& typeof candidate.created_at === "string"
&& !Number.isNaN(Date.parse(candidate.created_at))
&& typeof candidate.updated_at === "string"
&& !Number.isNaN(Date.parse(candidate.updated_at))
&& Boolean(candidate.request)
&& ["pending", "presented", "submitted", "completed", "cancelled", "expired", "failed"].includes(candidate.status ?? "");
}
function cloneRequest(request: StandardInteractionRequest): StandardInteractionRequest {
return JSON.parse(JSON.stringify(request)) as StandardInteractionRequest;
}
function copy(record: StandardInteractionRecord): StandardInteractionRecord {
return { ...record, request: cloneRequest(record.request), ...(record.result ? { result: JSON.parse(JSON.stringify(record.result)) as StandardInteractionResult } : {}) };
}
@@ -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; continued_from_app_session_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_session_id: this.newID(`${appScope}:session`), app_scope: appScope, conversation_id: conversationID, ...(options.parent_instance_id ? { parent_instance_id: options.parent_instance_id } : {}), ...(options.continued_from_app_session_id ? { continued_from_app_session_id: options.continued_from_app_session_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 } : {}) };
}
}
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { ExecutionProgressState } from "@/runtime/coordination/execution-progress-state";
const T0 = "2026-08-03T00:00:00.000Z";
const T1 = "2026-08-03T00:00:18.000Z";
describe("ExecutionProgressState", () => {
it("starts a turn with no invented operation steps", () => {
const state = new ExecutionProgressState();
expect(state.begin("exec_001", T0)).toEqual(expect.objectContaining({ id: "exec_001", status: "running", steps: [] }));
});
it("only accepts bounded, canonical execution steps for the matching turn", () => {
const state = new ExecutionProgressState();
state.begin("exec_001", T0);
expect(state.applyTrace("exec_other", "completed", [], T1)).toBeUndefined();
const summary = state.applyTrace("exec_001", "completed", [{ id: "step_01", title: "检查 qmtbridge health", status: "completed" }], T1);
expect(summary).toEqual(expect.objectContaining({ status: "completed", steps: [{ id: "step_01", title: "检查 qmtbridge health", status: "completed" }] }));
});
it("updates a real running step in place before the turn ends", () => {
const state = new ExecutionProgressState();
state.begin("exec_001", T0);
expect(state.applyTrace("exec_001", "running", [{ id: "step_01", title: "查询公开资料", status: "running" }], T1))
.toEqual(expect.objectContaining({ status: "running", finished_at: undefined, steps: [{ id: "step_01", title: "查询公开资料", status: "running" }] }));
expect(state.applyTrace("exec_001", "completed", [{ id: "step_01", title: "查询公开资料", status: "completed" }], T1))
.toEqual(expect.objectContaining({ status: "completed", finished_at: T1 }));
});
it("finishes without a trace without fabricating a fixed lifecycle", () => {
const state = new ExecutionProgressState();
state.begin("exec_001", T0);
expect(state.finishActive("completed", T1)).toEqual(expect.objectContaining({ steps: [] }));
});
it("keeps overlapping execution ids independent and finishes only the matching turn", () => {
const state = new ExecutionProgressState();
state.begin("exec_001", T0);
state.begin("exec_002", T1);
state.finish("exec_002", "completed", T1);
expect(state.snapshot()).toEqual([
expect.objectContaining({ id: "exec_001", status: "running" }),
expect.objectContaining({ id: "exec_002", status: "completed" }),
]);
});
it("finishes every live summary when the turn terminal has no execution id", () => {
const state = new ExecutionProgressState();
state.begin("exec_001", T0);
state.begin("exec_002", T1);
expect(state.finishAllActive("completed", T1)).toEqual([
expect.objectContaining({ id: "exec_001", status: "completed", finished_at: T1 }),
expect.objectContaining({ id: "exec_002", status: "completed", finished_at: T1 }),
]);
expect(state.snapshot().every(summary => summary.status === "completed")).toBe(true);
});
});

Some files were not shown because too many files have changed in this diff Show More