Compare commits
41 Commits
b3c33f69e7
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 91982fac33 | |||
| aff5c8e3aa | |||
| 89626546b9 | |||
| ef528a147b | |||
| 359dc1bb66 | |||
| 934bccb274 | |||
| 7e94713174 | |||
| 81470ffea7 | |||
| 4df556a175 | |||
| 0601c2ced1 | |||
| 9f5ea6eb4a | |||
| ca9e8b9791 | |||
| fec04a90d7 | |||
| e91d07b389 | |||
| 94817f798c | |||
| b80af9679d | |||
| 44b6a83a30 | |||
| b81a0edcca | |||
| aabf58a289 | |||
| c7c18168b6 | |||
| c4613ce14e | |||
| 180d03896f | |||
| 20e63dc0dd | |||
| 75529cdd49 | |||
| 0b7257f3d3 | |||
| 6fa350267a | |||
| 309dd6161d | |||
| 823e71a162 | |||
| c77a0cf69b | |||
| 58e77462ad | |||
| a68b7cf3bf | |||
| 7cac422a31 | |||
| 121ee6ac73 | |||
| d2a2fd0aff | |||
| 486280bec0 | |||
| 9fb8cd1598 | |||
| d8aa087bc8 | |||
| 4fc83e5b13 | |||
| ccff57b2a1 | |||
| 9fd321c41c | |||
| ffda402861 |
@@ -1,319 +0,0 @@
|
||||
# LineUp App — Android Spike 历史设计参考
|
||||
|
||||
> 状态:**仅保留为 Android 快速实验的历史/参考设计,不是当前 App Layer 总体架构。**
|
||||
> 原版本:0.1(提案)
|
||||
> 日期:2026-07-30
|
||||
> 基础:唐僧叨叨 Android 客户端(wkbase / wklogin / SDK 封装)
|
||||
|
||||
当前正式、跨端的 LineUp App 总体架构以 [LineUp App 层架构设计方案](../设计/02.正式方案/lineup-app-layer-architecture.md) 为准:Tauri 2 是当前 Reference Host,Android 为 Spike 基线,未来 Wails 按同一 Interaction Runtime 契约对照实现。本文件中的“Android 首端”“直接使用 WuKongIM SDK”等描述只反映当时的实验条件,不能覆盖正式架构、协议或实现优先级。
|
||||
|
||||
---
|
||||
|
||||
## 1. 定位
|
||||
|
||||
LineUp App 是一个**单通道 Agent 对话客户端**。用户打开 App 后只有一个对话——和他的 AI Agent。
|
||||
|
||||
不是社交 IM(没有好友列表、没有群聊、没有朋友圈)。
|
||||
当前版本只做一件事:**连上 WuKongIM,进入唯一频道,与 Agent 收发消息。**
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ LineUp App │
|
||||
│ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ Agent 对话频道 │ │
|
||||
│ │ │ │
|
||||
│ │ [用户] 帮我查一下今天天气 │ │
|
||||
│ │ [Agent] 北京今天晴, 22°C │ │
|
||||
│ │ [Agent] ┌─ choice ─┐ │ │
|
||||
│ │ │ 需要更多? │ │ │
|
||||
│ │ │ [是] [否] │ │ │
|
||||
│ │ └───────────┘ │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────┐ │
|
||||
│ │ 输入框 │ │
|
||||
│ └───────────────────────────────┘ │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构分层
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────┐
|
||||
│ LineUp App (Android) │
|
||||
│ │
|
||||
│ UI 层: 聊天界面 + Agent 消息渲染 │
|
||||
│ ├─ ChatView (文本/图片/语音) │
|
||||
│ └─ AgentWidgets (choice/form/canvas) │
|
||||
│ │
|
||||
│ 业务层: 频道管理 + 消息路由 │
|
||||
│ ├─ ChannelManager (只有一个频道) │
|
||||
│ └─ MessageRouter (分发到UI) │
|
||||
│ │
|
||||
│ 通讯层: WuKongIM SDK 封装 │
|
||||
│ ├─ WKClient (长连接/心跳/重连) │
|
||||
│ ├─ WKSend / WKRecv │
|
||||
│ └─ TokenManager (认证) │
|
||||
│ │
|
||||
│ 本地存储: SQLite / SharedPreferences │
|
||||
│ ├─ Token 缓存 │
|
||||
│ └─ 消息历史 (最近N条) │
|
||||
└──────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.1 复用唐僧叨叨的模块
|
||||
|
||||
| 唐僧叨叨模块 | 用途 | LineUp 是否复用 |
|
||||
|---|---|---|
|
||||
| `wklogin` | 登录 / 注册 / Token 获取 | ✅ 复用 |
|
||||
| `wkbase` | 基础 UI / WebSocket 连接 / 消息模型 | ✅ 复用核心 |
|
||||
| `wkpush` | 厂商推送 | ❌ 暂不需要 |
|
||||
| `wkscan` | 扫码 | ❌ 不需要 |
|
||||
| 群聊/通讯录/朋友圈 | — | ❌ 隐藏/移除 |
|
||||
|
||||
**核心思路:保留通信管道,砍掉社交外壳。**
|
||||
|
||||
---
|
||||
|
||||
## 3. 连接流程
|
||||
|
||||
### 3.1 首次启动
|
||||
|
||||
```
|
||||
Step 1: 用户打开 App
|
||||
→ 输入手机号 → 点"获取验证码" → 输入 123456
|
||||
→ App 调 LineUp App Server: POST /login
|
||||
→ Server 调 WuKongIM /user/token 生成 Token
|
||||
→ 返回 {uid, token, im_host, im_port}
|
||||
|
||||
Step 2: App 用 Token 连接 WuKongIM WebSocket
|
||||
→ ws://100.121.118.116:15200?token=xxx
|
||||
→ WuKongIM 返回 Connack → 连接建立
|
||||
|
||||
Step 3: App 进入唯一频道
|
||||
→ 频道 ID: "agent_default" (硬编码)
|
||||
→ App 调 WuKongIM Sub 帧订阅该频道
|
||||
→ 收到 Suback → 可以收发消息
|
||||
|
||||
Step 4: 显示对话界面
|
||||
→ 同步最近 N 条消息 (channel/messagesync)
|
||||
→ 等待用户输入
|
||||
```
|
||||
|
||||
### 3.2 后续启动(Token 未过期)
|
||||
|
||||
```
|
||||
Step 1: 读取本地缓存的 Token
|
||||
Step 2: 直接连 WuKongIM WebSocket
|
||||
Step 3: 跳过登录页,直接进入对话
|
||||
```
|
||||
|
||||
### 3.3 Agent 也在同一个频道
|
||||
|
||||
```
|
||||
用户的 WuKongIM 用户: uid="user_<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 Surface:Agent 可发送 `lineup.v1.ui.open`,以 HTML/CSS/JS 描述一个交互界面;后续以 `ui.patch` 推送状态,以 `ui.event` 接收用户动作,以 `ui.close` 关闭实例。
|
||||
|
||||
这不是把 Agent 脚本放进 App 主进程执行。Android 端必须在隔离 WebView 中承载 Surface,禁用任意网络访问、文件访问、同源权限和未注册的 JavaScript interface。Surface 只能通过受限 bridge 上报用户事件。
|
||||
|
||||
Agent 若要调用 App 的上层能力(例如打开链接、选文件、写剪贴板、相机、定位或业务模块),必须走独立的 `lineup.v1.app.list` → `app.call` → `app.result` 协议:Client 先声明 capability,再按风险等级弹出用户确认或系统授权,绝不允许 Surface 直接越权调用。本协议详见 [LineUp UI Surface 与 App Capability 协议](../设计/02.正式方案/lineup-ui-surface-protocol.md)。
|
||||
|
||||
---
|
||||
|
||||
## 5. UI 设计
|
||||
|
||||
### 5.1 消息渲染规则
|
||||
|
||||
```
|
||||
if payload 为空:
|
||||
→ 普通文本消息,照常显示
|
||||
|
||||
if payload.type == "lineup.v1.tool.choice":
|
||||
→ 渲染为选项卡片
|
||||
→ 用户点击某个选项后:
|
||||
→ App 组装 tool.result Payload
|
||||
→ Send 回 Agent
|
||||
|
||||
if payload.type == "lineup.v1.tool.progress":
|
||||
→ 渲染为进度条卡片
|
||||
→ 被动显示,无需用户操作
|
||||
```
|
||||
|
||||
### 5.2 登录页
|
||||
|
||||
```
|
||||
┌──────────────────────┐
|
||||
│ │
|
||||
│ LineUp │
|
||||
│ 你的 AI 搭档 │
|
||||
│ │
|
||||
│ ┌──────────────────┐│
|
||||
│ │ +86 手机号 ││
|
||||
│ └──────────────────┘│
|
||||
│ ┌────────┐ ┌──────┐ │
|
||||
│ │ 验证码 │ │ 获取 │ │
|
||||
│ └────────┘ └──────┘ │
|
||||
│ │
|
||||
│ [ 登 录 ] │
|
||||
│ │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
### 5.3 主界面
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ Agent (在线) ··· │ ← 顶部栏:Agent名称+在线状态
|
||||
├──────────────────────────────┤
|
||||
│ │
|
||||
│ [Agent] 你好,我是你的 AI │
|
||||
│ 助手,有什么可以帮 │
|
||||
│ 助你的? │
|
||||
│ │
|
||||
│ [用户] 帮我查天气 │
|
||||
│ │
|
||||
│ [Agent] ┌─ 选择城市 ────┐ │
|
||||
│ │ ○ 北京 │ │
|
||||
│ │ ○ 上海 │ │
|
||||
│ │ ● 深圳 │ │
|
||||
│ │ [确认] │ │
|
||||
│ └────────────────┘ │
|
||||
│ │
|
||||
├──────────────────────────────┤
|
||||
│ ┌────────────────────┐ 📎 │ ← 输入栏
|
||||
│ │ 输入消息... │ 📷 │
|
||||
│ └────────────────────┘ 🎤 │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 服务端交互协议
|
||||
|
||||
### 6.1 App → LineUp App Server
|
||||
|
||||
| API | 方法 | 用途 |
|
||||
|---|---|---|
|
||||
| `/login` | POST | 手机号+验证码 → {uid, token, im_addr} |
|
||||
| `/ws` | WebSocket | App 维持与 Server 的长连接(接收 Server 推送) |
|
||||
|
||||
### 6.2 App → WuKongIM (直连)
|
||||
|
||||
| 操作 | 方向 | 协议 |
|
||||
|---|---|---|
|
||||
| 连接 | App → WK | WKProto Connect (带 Token) |
|
||||
| 发消息 | App → WK | WKProto Send (频道+Payload) |
|
||||
| 收消息 | WK → App | WKProto Recv |
|
||||
| 同步消息 | App → WK | channel/messagesync |
|
||||
| 心跳 | 双向 | WKProto Ping/Pong |
|
||||
|
||||
---
|
||||
|
||||
## 7. 与唐僧叨叨 App 的差异
|
||||
|
||||
| 功能 | 唐僧叨叨 | LineUp App |
|
||||
|---|---|---|
|
||||
| 登录 | 手机号+验证码 / 第三方 | 手机号+验证码(简化版) |
|
||||
| 主界面 | 会话列表(多聊天) | **直接进入唯一 Agent 对话** |
|
||||
| 通讯录 | 好友+群组+工作台 | 无 |
|
||||
| 发现页 | 朋友圈+附近 | 无 |
|
||||
| 消息类型 | 文本/图片/语音/文件/位置 | 文本 + Agent 工具卡片 |
|
||||
| 设置 | 隐私/通知/通用 | 极简(仅退出/重置) |
|
||||
| WuKongIM 连接 | `wkbase` SDK 封装 | 复用 `wkbase` 核心 |
|
||||
| 服务端 | 唐僧叨叨业务层 | LineUp App Server |
|
||||
|
||||
---
|
||||
|
||||
## 8. 实施计划
|
||||
|
||||
### Phase 1: 最小通信通路(1-2天)
|
||||
```
|
||||
目标: 能登录、连 WuKongIM、收发文本消息
|
||||
|
||||
□ 基于唐僧叨叨 wklogin + wkbase 创建简化版 App 壳
|
||||
□ 移除所有非必要模块(群聊/通讯录/朋友圈)
|
||||
□ 登录后直连 WuKongIM WebSocket
|
||||
□ 硬编码频道 ID,进入固定对话
|
||||
□ 能发送和接收文本消息
|
||||
```
|
||||
|
||||
### Phase 2: Agent 工具消息渲染(2-3天)
|
||||
```
|
||||
目标: 能解析 LineUp 信封,渲染 choice/confirm/progress
|
||||
|
||||
□ 实现 Payload 解析器
|
||||
□ choice 卡片 → 选项按钮列表
|
||||
□ confirm 卡片 → 确认/取消按钮
|
||||
□ progress 卡片 → 进度条
|
||||
□ 用户操作后组装 tool.result 回传给 Agent
|
||||
```
|
||||
|
||||
### Phase 3: UI 美化 + 体验优化(2-3天)
|
||||
```
|
||||
目标: 不再是粗制原型,而是可用产品
|
||||
|
||||
□ 对话气泡 UI 优化
|
||||
□ Agent 在线状态指示
|
||||
□ 消息发送状态(发送中/已送达)
|
||||
□ 启动动画 / 过渡效果
|
||||
□ 错误处理(断网/超时)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 参考资料
|
||||
|
||||
- 唐僧叨叨 Android 源码: `upstream/tangsengdaodao-android/`
|
||||
- LineUp App Server 设计: `lineup-app-server/DESIGN.md`
|
||||
- WuKongIM JS SDK: `@wukongim/sdk-js` (Agent 端用)
|
||||
- LineUp App 层架构: `设计/02.正式方案/lineup-app-layer-architecture.md`
|
||||
- 消息信封与 UI Surface 定义: `设计/02.正式方案/lineup-ui-surface-protocol.md`
|
||||
@@ -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` 兼容实现是其上的第一个系统级 MiniApp:Interact 的 IM Mode。
|
||||
|
||||
客户端工作区使用独立仓库,远端为 `ssh://git@100.121.118.116:2222/lineup/app.git`。Git 用于源码版本管理、备份和协作;提交应对应完整、可验证的改动,但**不再作为 Mac 浏览器验证前端改动的传输手段**。
|
||||
当前唯一的客户端实现与验收基线是:
|
||||
|
||||
日常 Web 联调由 Linux 工作机直接提供可信 Host:
|
||||
```text
|
||||
Tauri 2 Desktop Host + Web Reference Host
|
||||
```
|
||||
|
||||
这里的 **Host(运行外壳)** 指的是把同一套 LineUp 程序放进不同运行环境的方式,
|
||||
并不表示有两套客户端或两套聊天逻辑。它们共用同一份 TypeScript
|
||||
`LineUpRuntime` 和 Chat Core App:
|
||||
|
||||
- **Tauri Desktop Host** 是正式桌面应用的外壳。它负责创建桌面窗口,并在 Runtime
|
||||
授权后提供文件、通知等操作系统能力。
|
||||
- **Web Reference Host** 是浏览器中的同代码开发和验证入口。它用于 Vite 开发、
|
||||
Tailscale 联调与自动化回归,不能替代桌面版的系统能力。
|
||||
|
||||
无论运行在哪个 Host 中,连接、同步、消息存储和 Agent 协议都只能由 Runtime 管理,
|
||||
不能各自实现一套。
|
||||
|
||||
不再规划、实现或维护独立 Android/Kotlin 客户端,也不再维护 Wails Host。旧实验设计仅在
|
||||
Git 历史中保留,不是当前产品、协议或交付路线。
|
||||
|
||||
## MVP-R1:Runtime 托管 Chat
|
||||
|
||||
当前 MVP 已验证以下边界:
|
||||
|
||||
```text
|
||||
Chat Core App
|
||||
└── ChatRuntimeSDK
|
||||
└── LineUpRuntime
|
||||
├── Transport / sync loop / outbox
|
||||
├── Conversation Store / App Inbox
|
||||
├── app_scope + conversation_id 路由
|
||||
└── lineup.v1 Compatibility Adapter
|
||||
```
|
||||
|
||||
- `LineUpRuntime` 是网络连接、消息存储、同步循环和待发送队列的唯一管理者;
|
||||
- Runtime 从本地 Core App Registry(内置应用清单)加载默认 `chat`,而不是由
|
||||
`main.ts` 直接拼出聊天页面;
|
||||
- Chat 只能通过 Runtime SDK 接收已筛选的消息和状态、恢复并确认已处理的 Inbox 消息,
|
||||
再向 Runtime 提交用户动作;
|
||||
- Runtime 拒绝非法或不匹配的 `app_scope` / `conversation_id`,并在内部兼容既有
|
||||
`lineup.v1`;
|
||||
- 登录、同步、发送、本地回显、Markdown、Agent 状态和刷新恢复保持回归通过;
|
||||
- 当前 Tauri/Web 回归为 23 个测试文件、99 个测试,`npm run build` 通过。
|
||||
|
||||
完整边界、MiniApp SDK 路线和发布安全约束见 [APP架构设计.md](设计/APP架构设计.md)。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
客户端工作区使用独立仓库,远端为 `ssh://git@100.121.118.116:2222/lineup/app.git`。
|
||||
Linux 开发机通过 Vite 启动 Web Reference Host;Mac 或其他设备可经 Tailscale 用浏览器
|
||||
直接打开它,看到的就是 Linux 工作区中正在修改的同一份前端,无须每次改动都同步或重新
|
||||
clone 源码。
|
||||
|
||||
```bash
|
||||
cd lineup-app/tauri
|
||||
npm run web:dev
|
||||
```
|
||||
|
||||
Mac 浏览器通过 Tailscale 打开 `http://100.121.118.116:1420`,即可看到 Linux 工作区的 Vite 热更新。Mac 只在首次配置、切换开发机或需要本地桌面打包时再 clone / pull 源码:
|
||||
当前开发地址:
|
||||
|
||||
```bash
|
||||
git clone ssh://git@100.121.118.116:2222/lineup/app.git ~/Workspace/lineup-app
|
||||
cd ~/Workspace/lineup-app
|
||||
git pull --ff-only origin main
|
||||
```text
|
||||
http://100.121.118.116:1420/
|
||||
```
|
||||
|
||||
`tauri/node_modules/`、`tauri/dist/` 和 `tauri/src-tauri/target/` 均为本机可再生文件,已被忽略;每台机器按自身架构重新安装和构建。
|
||||
常用命令:
|
||||
|
||||
```bash
|
||||
# Runtime / SDK / Renderer 自动化回归
|
||||
npm test -- --run
|
||||
|
||||
# TypeScript 检查与 Web production build
|
||||
npm run build
|
||||
|
||||
# Tauri 桌面 Host 开发
|
||||
npm run desktop:dev
|
||||
```
|
||||
|
||||
`tauri/node_modules/`、`tauri/dist/` 和 `tauri/src-tauri/target/` 都是可再生本机构建产物,
|
||||
已被忽略。
|
||||
|
||||
## 文档入口
|
||||
|
||||
- [DESIGN.md](DESIGN.md):Android 实验阶段的产品、连接与交互设计;
|
||||
- [TECHNOLOGY_SELECTION.md](TECHNOLOGY_SELECTION.md):已确认的客户端与 Mini Runtime 技术路线;Tauri 2 是第一优先的 Reference Host Spike,Wails v3 在同一基线后续评估;
|
||||
- [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 多端客户端工程混放。
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
# LineUp Client 与 Mini Runtime 技术选型:原生 Android、Tauri 2 与 Wails v3
|
||||
|
||||
**版本:** 0.1(评审稿)
|
||||
|
||||
**状态:** 已确认第一阶段技术路线
|
||||
|
||||
**日期:** 2026-08-02
|
||||
**关联设计:** [LineUp App 层架构设计方案](../设计/02.正式方案/lineup-app-layer-architecture.md)、[LineUp UI Surface 与 App Capability 协议](../设计/02.正式方案/lineup-ui-surface-protocol.md)
|
||||
|
||||
---
|
||||
|
||||
## 1. 决策问题
|
||||
|
||||
LineUp 已有可运行的 Kotlin Android 客户端,并完成 App Server、WuKongIM、Hermes Adapter 的连通。它的定位是快速验证 IM、协议、设备接入和交互闭环,不是长期多端客户端的唯一实现。
|
||||
|
||||
长期目标是在 App 层建立 LineUp 自定义的“小程序运行环境”:利用系统 WebView / WebKit 执行 HTML、CSS、JavaScript,但由 LineUp 定义小程序包、生命周期、受限桥接、事件协议、权限与审计模型。这里不实现浏览器或 JavaScript 内核;实现的是运行在系统 Web 内核之上的 **LineUp Mini Runtime**。
|
||||
|
||||
本次只讨论客户端实现载体;LineUp Envelope、Interaction Kernel、Surface 安全模型与 Capability Registry 的协议边界不因载体而改变。
|
||||
|
||||
## 2. 已确认的技术路线
|
||||
|
||||
| 选项 | 决策 | 定位 |
|
||||
|---|---|---|
|
||||
| 现有 Kotlin Android | **保留** | 稳定基线、原生能力参考实现和持续交付通道 |
|
||||
| Tauri 2 | **第一优先开展独立 Spike** | LineUp Mini Runtime 的 Reference Host、安全与能力基线 |
|
||||
| Wails v3 | **第二阶段按同一基线评估** | 利用 Go 主技术栈的长期 Host 候选 |
|
||||
|
||||
不直接重写或删除当前 Kotlin App。先通过 Tauri Spike 验证跨端 Host、小程序隔离、系统能力桥接和移动端恢复;再以完全相同的 Mini Runtime 协议与验收标准验证 Wails v3。这样可将技术选择从框架偏好转为可实证的交付能力比较。
|
||||
|
||||
### 2.1 为什么先验证 Tauri
|
||||
|
||||
Tauri 的团队、社区、桌面生态和移动端路径相对成熟,适合作为高标准的参考宿主。它先回答的不是“能否把现有 Android 聊天页换成 Web”,而是“可信 LineUp 主应用与不可信小程序 Surface 能否在多端稳定且安全地共存”。
|
||||
|
||||
Wails v3 与 Go 主技术栈更匹配,仍是重要的长期候选;但它应接受已验证的 Runtime 基线,而不是由它当前的 Beta 状态来定义平台边界。
|
||||
|
||||
## 3. 当前资产与迁移匹配度
|
||||
|
||||
Web Reference Host 已存在下列可复用资产:
|
||||
|
||||
```text
|
||||
lineup-app-server/assets/chat/
|
||||
├── interaction-kernel.js # ConversationItemFactory + RendererRegistry
|
||||
└── index.html # GFM Markdown、安全净化、Surface Host、App Call UI
|
||||
```
|
||||
|
||||
可复用的核心能力包括:
|
||||
|
||||
- `marked + DOMPurify` 的 GFM Markdown;
|
||||
- `ConversationItem` 与 Renderer Registry;
|
||||
- `lineup.v1.text`、`agent.status`、`agent.progress`、`error` renderer;
|
||||
- `lineup.v1.ui.open / patch / close / event` 的 sandbox iframe Surface Host;
|
||||
- `lineup.v1.app.call / result` 的用户确认交互;
|
||||
- WebSocket 接入形态(服务端 WSS 尚待提供;当前 Android 是 HTTP 轮询)。
|
||||
|
||||
目前 Android 使用 OkHttp、RecyclerView、Clipboard、外链 Intent、Activity 生命周期和轮询,不依赖相机、蓝牙、音视频等重量级原生 SDK。故迁移“表现层与会话 UI”的阻力较低,但系统能力、推送和后台恢复仍必须有原生实现。
|
||||
|
||||
## 4. 方案对比
|
||||
|
||||
| 维度 | 当前 Kotlin Android | Tauri 2 | Wails v3 |
|
||||
|---|---|---|---|
|
||||
| Android UI | 原生 View / RecyclerView | Web 前端 + Android WebView | Web 前端 + 容器;移动端路径需进一步证实 |
|
||||
| Markdown | 临时 regex + HtmlCompat,不完整 | 直接复用 GFM + sanitizer | 可复用 Web 实现 |
|
||||
| LineUp Surface | 当前仅原生 Task Dashboard | 可复用 iframe sandbox Host | 需要专项验证 |
|
||||
| 内置 Renderer / 标准组件 | 需逐个原生实现 | 可复用现有 Web Renderer | 可复用现有 Web Renderer |
|
||||
| 系统能力 | Kotlin 直接调用 | Tauri Plugin / Kotlin bridge | Go bridge / 原生层,需专项验证 |
|
||||
| Push、通知、后台、Deep Link | 路径最直接 | 仍需要原生 Plugin | 仍需要原生 bridge |
|
||||
| Android 成熟度 | 已在项目真机运行 | 官方 Mobile 路径,作为首个真机 Spike | 官方 README 标为 v3 Beta,需以 Tauri 结果作对照 |
|
||||
| 现有 Web 实现复用 | 低 | 高 | 高 |
|
||||
| 推荐角色 | 稳定基线 | **Reference Host / 第一优先 Spike** | **Go 主栈长期候选 / 第二阶段对照实现** |
|
||||
|
||||
### 4.1 Tauri 2:Reference Host
|
||||
|
||||
Tauri 2 是当前最适合优先验证的跨端候选。官方 v2 文档覆盖 Android、iOS 与 Mobile;它能够复用已有 Web 表现层资产,同时仍允许通过原生插件承接 Android 系统能力。Tauri 在本项目中承担的是可信 LineUp 主应用与 Mini Runtime 的第一版参考实现,而不是第三方小程序本身。
|
||||
|
||||
它替换的是会话 UI、Markdown、Renderer、标准组件和 Surface Host,并非把 Android 原生层完全移除。Kotlin/Gradle 仍承担推送、通知、Deep Link、运行时权限、文件选择、分享、后台恢复,以及必要原生 Plugin。
|
||||
|
||||
### 4.2 Wails v3:Go Host 候选
|
||||
|
||||
Wails 的 Go 技术栈与 LineUp 的服务端、协议校验、同步、Outbox、Capability Policy 和审计逻辑高度匹配,长期上有明显的维护优势。官方 GitHub README 当前将 Wails v3 标记为 `Beta`;本次核验也没有取得足够的官方 Android 生产支持证据。因此不把它作为第一轮 Runtime 安全边界的验证载体,但在 Tauri 基线明确后,应以同一协议和验收范围做正式对照实现。
|
||||
|
||||
重点评估 Wails v3 是否能以 Go Host 达到 Tauri Reference Host 的小程序隔离、资源加载、桥接、生命周期、移动端系统能力与恢复标准;若能达到,Go 主栈优势将使其成为长期主方案的强候选。
|
||||
|
||||
## 5. LineUp Mini Runtime 与 Tauri Reference Host
|
||||
|
||||
```text
|
||||
LineUp Tauri Reference Host
|
||||
├── Web Frontend
|
||||
│ ├── Chat UI / 虚拟列表
|
||||
│ ├── CommonMark / GFM Markdown
|
||||
│ ├── ConversationItem + Renderer Registry
|
||||
│ ├── 标准组件库
|
||||
│ ├── Surface Host
|
||||
│ └── WebSocket + HTTP 补偿
|
||||
├── Rust / Tauri Core
|
||||
│ ├── 安全 capability policy
|
||||
│ ├── 会话 / token 安全存储
|
||||
│ └── 原生 bridge 管理
|
||||
└── Android Kotlin 壳
|
||||
├── Push / 通知 / Deep Link
|
||||
├── 文件、权限、系统分享
|
||||
└── 必要的原生 Plugin
|
||||
```
|
||||
|
||||
Tauri 的 Web Frontend 与第三方 Surface 必须是两个不同的安全域。LineUp 自有前端可以调用受控原生能力;Agent 或第三方 Surface 只能通过经过校验的事件协议请求能力,绝不能获得 Tauri `invoke`、登录 token 或宿主 DOM 的直接访问权。
|
||||
|
||||
### 5.1 Mini Runtime 的框架无关边界
|
||||
|
||||
Mini Runtime 是可被 Tauri、Wails 或原生 Shell 实现的协议层,最小由以下模块组成:
|
||||
|
||||
```text
|
||||
Package Manager app_id / version / manifest / integrity / allowlist
|
||||
Instance Manager instance_id / open / ready / patch / close / restore
|
||||
Secure Loader 不可变 bundle / 专用 origin / CSP / 网络与跳转限制
|
||||
Event Bridge postMessage schema、大小、频率、来源与实例校验
|
||||
Mini API ui.onPatch / ui.emit / app.call / app.result
|
||||
Capability Gateway policy / 用户确认 / 平台 handler / audit
|
||||
```
|
||||
|
||||
第三方小程序只能获得 Mini API,不能直接获得 Tauri 或 Wails 的原生调用对象。内置 Renderer 与标准组件可运行于可信主应用前端;小程序 Surface 则必须运行于隔离执行域。
|
||||
|
||||
## 6. 不可退让的安全约束
|
||||
|
||||
1. Agent bundle 与第三方 Surface 一律视为不可信输入。
|
||||
2. Surface iframe 不得访问 Tauri invoke API、宿主登录态或宿主 DOM。
|
||||
3. 仅允许完成实例、命名空间、事件名和消息大小校验的 `postMessage` bridge。
|
||||
4. CSP 默认禁止未授权网络、远程脚本和危险跳转。
|
||||
5. `ui.patch` 只能更新 JSON state,不能替换已验证 bundle。
|
||||
6. 所有系统能力只允许走 `app.call → Capability Registry → 用户确认 → app.result`。
|
||||
7. Android 的推送、通知、后台恢复与权限结果必须可审计、可恢复,不能依赖一个不受控网页生命周期。
|
||||
|
||||
## 7. Tauri Reference Host Spike 范围与验收
|
||||
|
||||
Spike 单独建立目录或模块,接入同一 App Server、WuKongIM、Hermes 与测试会话;不得改写当前 Kotlin Android 的可运行路径。先以桌面验证主应用与小程序双安全域,再进入 Android 真机验证。
|
||||
|
||||
- [ ] 真机登录现有 App Server;
|
||||
- [ ] 兼容 HTTP `/messages/sync`,为服务端 WSS 留出接入点;
|
||||
- [ ] 完整 GFM Markdown 与 DOMPurify 安全净化;
|
||||
- [ ] 兼容 `text / status / progress / tool.call / tool.result`;
|
||||
- [ ] 实现 `ui.open / patch / close / event` sandbox Surface;
|
||||
- [ ] 实现 `app.open_url` 与 `clipboard.write` 的 Capability bridge;
|
||||
- [ ] PJD110 真机安装、滚动性能、断线恢复与安全隔离通过;
|
||||
- [ ] 与既有 Kotlin 客户端对照确认消息顺序、去重、用户操作回传一致。
|
||||
|
||||
Wails v3 的第二阶段 Spike 使用以上相同清单。其目标不是重定义协议,而是证明 Go Host 能够以相同小程序包和安全边界完成等价交付。
|
||||
|
||||
## 8. 本机构建级验证前置条件
|
||||
|
||||
| 条件 | 当前状态 |
|
||||
|---|---:|
|
||||
| Android SDK / Gradle / 项目内 JDK 17 | 已有 |
|
||||
| Kotlin APK 构建与真机安装 | 已验证 |
|
||||
| Rust / Cargo | 已安装;本机 Rustup proxy 有清单异常,直接工具链 bin 可用 |
|
||||
| Tauri CLI | 已作为 `lineup-app/tauri/` 的 npm 开发依赖安装 |
|
||||
| Wails CLI | 未安装 |
|
||||
| Android NDK | 未安装 |
|
||||
| Tauri / Wails 现有工程 | Tauri Reference Host 已建立于 `lineup-app/tauri/`;Wails 尚未建立 |
|
||||
|
||||
Tauri Spike 已进入实现阶段:前端生产构建、Linux debug 二进制和 Debian 包均已生成;Android 真机 Spike 仍需 Android NDK 与 Rust Android targets。当前 Kotlin Android 实验工程保持不变。
|
||||
@@ -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-01~M0-08 已完成。Mac 经 Linux Tailscale Web Host `http://100.121.118.1
|
||||
|
||||
### M1-01 Tool Call 状态机(已完成)
|
||||
|
||||
`src/protocol.ts` 现可严格解码 `lineup.v1.tool.call`、`lineup.v1.tool.result`、`lineup.v1.tool.cancel`,只接受 `choice`、`confirm`、`input` 三种声明性请求。`src/runtime/tool-call-state.ts` 是独立于 UI、Storage、Transport、Tauri 与 Capability 的纯状态机;它以 `call_id` 为唯一关联键,只允许 `pending → submitted → completed / failed / cancelled / expired`,拒绝重复调用及非法状态跃迁。Interaction Kernel 仅投影这些状态,且只有已提交的 call 能接受 result。
|
||||
`src/runtime/protocol/lineup-v1.ts` 现可严格解码 `lineup.v1.tool.call`、`lineup.v1.tool.result`、`lineup.v1.tool.cancel`,只接受 `choice`、`confirm`、`input` 三种声明性请求。`src/runtime/coordination/tool-call-state.ts` 是独立于 UI、Storage、Transport、Tauri 与 Capability 的纯状态机;它以 `call_id` 为唯一关联键,只允许 `pending → submitted → completed / failed / cancelled / expired`,拒绝重复调用及非法状态跃迁。Interaction Kernel 仅投影这些状态,且只有已提交的 call 能接受 result。
|
||||
|
||||
这不是可用的交互组件:M1-01 不渲染选项、不提供按钮/表单、不发送 tool result,也不修改 Hermes Adapter。可信 ActionGroup 留给 M1-02,Confirm/Input/Form 留给 M1-03。`npm test` 当前为 3 个文件 / 14 个测试通过,`npm run build` 通过。
|
||||
|
||||
@@ -82,11 +91,11 @@ Conversation Store v5 持久化任务快照,刷新或重新登录后仍只显
|
||||
|
||||
### 当前结构债务(M0 的改造对象)
|
||||
|
||||
`src/main.ts` 目前包含 App Shell、页面 DOM、会话 cursor、同步循环与 Runtime 编排;M0-05 已将登录、发送与同步 HTTP `fetch` 迁入 `src/runtime/transport-adapter.ts`,M0-06 已将所有可信 DOM renderer 迁入 `src/runtime/trusted-dom-renderers.ts`。在 M0 准入前不新增 Surface 或 Capability 功能。
|
||||
该段记录的是 M0 历史结构债务。当前 MVP-R1 已将 Transport、Store、sync loop 与 outbox 收敛至 `src/runtime/coordination/lineup-runtime.ts`;HTTP 实现位于 `src/runtime/communication/transport-adapter.ts`,可信 Chat DOM Renderer 位于 `src/core-apps/chat/trusted-dom-renderers.ts`。现行目录与边界见 [src/README.md](src/README.md)。
|
||||
|
||||
### M0-02 协议模型(已完成)
|
||||
|
||||
`src/protocol.ts` 是目前唯一允许把 Transport 原始 payload 转换成可信表现模型的入口。它定义了 `JsonValue`、`Actor`、严格 `Envelope`、`ConversationItem`、`UserAction`、`DeliveryState` 与 `ProtocolFallback`,并提供:
|
||||
`src/runtime/protocol/lineup-v1.ts` 是目前唯一允许把 Transport 原始 payload 转换成可信表现模型的入口。它定义了 `JsonValue`、`Actor`、严格 `Envelope`、`ConversationItem`、`UserAction`、`DeliveryState` 与 `ProtocolFallback`,并提供:
|
||||
|
||||
- `parseEnvelope(value)`:校验版本、消息类型、`id`、`conversation_id`、sender/target、timestamp 和 JSON payload;
|
||||
- `parseEnvelopeJSON(raw)`:供不接受历史纯文本的 Transport / Kernel 路径把非法 JSON 明确归为 `invalid_json`;
|
||||
@@ -96,9 +105,9 @@ Conversation Store v5 持久化任务快照,刷新或重新登录后仍只显
|
||||
|
||||
### M0-03 Interaction Kernel 与 Renderer Registry(已完成)
|
||||
|
||||
`src/runtime/interaction-kernel.ts` 是 Transport 与表现层之间的唯一入口:它将原始同步/实时 payload 解码为 `ConversationItem`,对有有效 LineUp `id` 的消息实施有界去重,并将 Agent status 投影为可读取的 presence 快照。它不执行 `ui.*`、`app.*` 或用户动作。
|
||||
`src/runtime/coordination/interaction-kernel.ts` 是 Transport 与表现层之间的唯一入口:它将原始同步/实时 payload 解码为 `ConversationItem`,对有有效 LineUp `id` 的消息实施有界去重,并将 Agent status 投影为可读取的 presence 快照。它不执行 `ui.*`、`app.*` 或用户动作。
|
||||
|
||||
`src/runtime/renderer-registry.ts` 只根据已验证的 `ConversationItem.kind` 分派已注册的可信 renderer;renderer 的输入只有表现项与 View context,不会获得 Transport 或 Capability 对象。当前聊天页已通过该 Registry 渲染同步进入的标准项。历史上 M0-03 完成时具体 DOM renderer 仍在 `main.ts`;该 M0-06 迁移现已完成,当前可信 DOM renderer 位于 `src/runtime/trusted-dom-renderers.ts`。
|
||||
`src/core-apps/chat/renderer-registry.ts` 只根据已验证的 `ConversationItem.kind` 分派已注册的可信 renderer;renderer 的输入只有表现项与 View context,不会获得 Transport 或 Capability 对象。当前聊天页已通过该 Registry 渲染同步进入的标准项。历史上 M0-03 完成时具体 DOM renderer 仍在 `main.ts`;该 M0-06 迁移现已完成,当前可信 DOM renderer 位于 `src/core-apps/chat/trusted-dom-renderers.ts`。
|
||||
|
||||
## 本地命令
|
||||
|
||||
@@ -127,7 +136,13 @@ npm run tauri build -- --debug
|
||||
|
||||
`npm run desktop:dev` 不生成 `.app`、`.dmg` 或安装包。前端 TypeScript / CSS / HTML 保存后会由 Vite 自动热更新;改动 `src-tauri/` 中的 Rust Host 代码时,Tauri 会重新编译并重启开发窗口。只有需要交付可安装文件时才执行 `tauri build`。
|
||||
|
||||
`npm run web:dev` 只运行同一份可信 Host Web UI,适合在浏览器中快速调试登录、消息和 Renderer。它监听 `0.0.0.0:1420`,Mac 通过 Tailscale 打开 `http://100.121.118.116:1420`,直接验证 Linux 工作区的 Vite 热更新;不需要为每次验证将源码同步到 Mac。它不具备 Tauri 原生能力,也不应作为第三方 Mini Runtime Surface 的安全验收环境。AppServer 仅对固定开发 Origin `http://127.0.0.1:1420` 和 `http://100.121.118.116:1420` 配置 CORS;若改动 Vite 端口或 Host 地址,必须同步收紧更新 AppServer 的 `client.allowedOrigins`,不得改为 wildcard。
|
||||
`npm run web:dev` 是在浏览器里启动同一份可信 Host UI,适合快速检查登录、消息和
|
||||
Renderer。它监听 `0.0.0.0:1420`;Mac 可经 Tailscale 打开
|
||||
`http://100.121.118.116:1420`,直接看到 Linux 工作区的 Vite 热更新,无须把源码同步到
|
||||
Mac。浏览器模式没有 Tauri 的原生能力,也不能用作第三方 Mini Runtime Surface 的安全验收
|
||||
环境。AppServer 只对固定开发 Origin `http://127.0.0.1:1420` 和
|
||||
`http://100.121.118.116:1420` 配置 CORS;若改动 Vite 端口或 Host 地址,必须同步更新并
|
||||
保持收紧 AppServer 的 `client.allowedOrigins`,不得改成允许任意来源的 wildcard。
|
||||
|
||||
本项目的 `tauri` 与 `check:rust` npm 脚本会自动识别 Linux x86_64、macOS Apple Silicon、macOS Intel 的 Rust stable toolchain,并加入 `PATH`。因此不要直接执行裸 `tauri build`;统一使用 `npm run tauri build -- --debug`。如 Rust toolchain 安装在其他位置,可临时覆盖:
|
||||
|
||||
@@ -140,7 +155,7 @@ RUST_TOOLCHAIN_BIN=/path/to/rust/bin npm run tauri build -- --debug
|
||||
- `npm run build`:已通过;
|
||||
- Cargo/Tauri Rust crates:已下载并开始编译;
|
||||
- Tauri Linux desktop build:已通过;debug 可执行文件和 Debian 包已生成;
|
||||
- Android Spike:尚未开始。仍需要 Android NDK、Rust Android targets 与 Tauri Android 初始化。
|
||||
- 独立 Android/Kotlin 与 Wails Host:不在当前或后续规划范围内。
|
||||
|
||||
Linux 桌面构建依赖以下系统开发库;本机已具备。新环境缺少时可安装:
|
||||
|
||||
@@ -156,17 +171,21 @@ src-tauri/target/debug/lineup-tauri
|
||||
src-tauri/target/debug/bundle/deb/LineUp_0.1.0_amd64.deb
|
||||
```
|
||||
|
||||
Tauri 构建产物位于 `src-tauri/target/`,已被 Git 忽略。图标源文件是 `src-tauri/icons/icon.svg`,其余 PNG/ICO/ICNS 与 Android/iOS 图标由 `npm run tauri icon src-tauri/icons/icon.svg` 生成。
|
||||
Tauri 构建产物位于 `src-tauri/target/`,已被 Git 忽略。图标源文件是 `src-tauri/icons/icon.svg`;当前交付只面向 Tauri Desktop Host,其他图标派生产物不代表额外客户端路线。
|
||||
|
||||
## 目录边界
|
||||
|
||||
```text
|
||||
tauri/
|
||||
├── src/ # 可信 LineUp Host 前端
|
||||
│ ├── main.ts # 会话 UI、AppServer transport、Renderer 调度
|
||||
│ └── protocol.ts # 平台无关的 LineUp v1 → ConversationItem 解码
|
||||
├── src/
|
||||
│ ├── main.ts # 应用启动装配入口:连接 Host、Runtime 与默认 App
|
||||
│ ├── core-apps/chat/ # Chat Shell、可信 Renderer 与样式
|
||||
│ └── runtime/ # 按通信、协调、存储、应用、Surface、能力等分层
|
||||
├── src-tauri/ # Tauri Rust Host 与 capability 配置
|
||||
├── vite.config.ts # @/ → src/ 路径别名(Vite / Vitest)
|
||||
└── package.json # Vite、Tauri CLI 与前端依赖
|
||||
```
|
||||
|
||||
各 Runtime 子目录、依赖方向与新增模块约定见 [src/README.md](src/README.md)。
|
||||
|
||||
第三方 Agent Surface 未来必须运行于独立隔离执行域,只能经 LineUp Mini Runtime 事件桥与 Capability Gateway 通信;不得访问 Tauri `invoke`、Host DOM、登录态或本机特权。
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 8.9 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
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
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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'
|
||||
@@ -1,3 +1,4 @@
|
||||
// Tauri Rust Host 的应用构造入口;Runtime 协议和业务路由仍位于 TypeScript LineUpRuntime。
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Tauri 桌面二进制入口:仅委托给 lib.rs 中的可信 Host 构造逻辑。
|
||||
fn main() {
|
||||
lineup_tauri_lib::run();
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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`。这样新应用只增加自己的体验,
|
||||
不会破坏已有聊天和连接逻辑。
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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 }>;
|
||||
@@ -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 提供的受限回调,不能取得 Transport、Store 或 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", {});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 Inventory;Surface 或 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 })]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||