feat: initialize LineUp Tauri reference host
@@ -0,0 +1,7 @@
|
||||
# macOS Finder metadata
|
||||
.DS_Store
|
||||
|
||||
# Local-only environment overrides. Commit an explicit .env.example instead
|
||||
# when the client later needs documented non-secret defaults.
|
||||
.env
|
||||
.env.local
|
||||
@@ -0,0 +1,317 @@
|
||||
# LineUp App — 移动端设计文档
|
||||
|
||||
> 版本:0.1(提案)
|
||||
> 日期:2026-07-30
|
||||
> 依赖:LineUp App Server + WuKongIM 3.0
|
||||
> 基础:唐僧叨叨 Android 客户端(wkbase / wklogin / 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`
|
||||
@@ -0,0 +1,25 @@
|
||||
# LineUp App
|
||||
|
||||
这里是 LineUp 多端客户端的工作区。
|
||||
|
||||
当前 Kotlin Android 客户端是 IM、协议、设备接入与交互闭环的快速实验基线;长期目标是在系统 WebView / WebKit 之上构建 LineUp Mini Runtime,为内置 Renderer 与受限第三方小程序 Surface 提供统一的运行环境。
|
||||
|
||||
## 文档入口
|
||||
|
||||
- [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 能力的协议定义。
|
||||
|
||||
## 当前目录边界
|
||||
|
||||
```text
|
||||
lineup-app/
|
||||
├── DESIGN.md # Android 快速实验设计
|
||||
├── TECHNOLOGY_SELECTION.md # 已确认的客户端 / Mini Runtime 技术路线
|
||||
├── tauri/ # Tauri 2 Reference Host(第一优先 Spike)
|
||||
└── README.md # 本目录说明与文档入口
|
||||
```
|
||||
|
||||
实际的唐僧叨叨 Android 上游实验代码保留在 `../upstream/tangsengdaodao-android/`,不与未来的 LineUp 多端客户端工程混放。
|
||||
@@ -0,0 +1,158 @@
|
||||
# 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 实验工程保持不变。
|
||||
@@ -0,0 +1,3 @@
|
||||
/dist/
|
||||
/node_modules/
|
||||
/src-tauri/target/
|
||||
@@ -0,0 +1,82 @@
|
||||
# LineUp Tauri Reference Host
|
||||
|
||||
这是 LineUp Mini Runtime 的第一版 Tauri 2 Reference Host。它独立于当前 `upstream/tangsengdaodao-android/` 快速实验工程,不替换或修改现有 Android 验证路径。
|
||||
|
||||
## 已实现的最小闭环
|
||||
|
||||
- 可信 Host 前端:AppServer 地址、手机号/验证码登录、退出;
|
||||
- AppServer 交互:`/login`、`/messages/send`、`/messages/sync`;
|
||||
- 用户消息本地即时回显与送达状态;
|
||||
- HTTP 同步游标:首次 `0`、后续最后序号 `+ 1`;
|
||||
- `lineup.v1.text`、`agent.status`、`agent.progress`、`error` 的基础 Renderer;
|
||||
- `marked + DOMPurify` 的 GFM Markdown 安全渲染;
|
||||
- Tauri 2 Rust Host、capability 文件与桌面窗口配置。
|
||||
|
||||
AppServer 已配置只允许 `tauri://localhost`、`https://tauri.localhost`、`http://tauri.localhost` 三个 Tauri Host origin 跨源调用。若产品使用新的 Tauri origin 或独立 Web 客户端,应先在 `lineup-app-server/configs/lineup.yaml` 的 `client.allowedOrigins` 显式加入,不能放开任意 Origin。
|
||||
|
||||
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 或直接授予设备能力。
|
||||
|
||||
## 本地命令
|
||||
|
||||
```bash
|
||||
cd lineup-app/tauri
|
||||
npm install
|
||||
|
||||
# 日常开发:启动 Vite 热更新 + 未打包的 Tauri 桌面壳
|
||||
npm run desktop:dev
|
||||
|
||||
# 仅检查并构建前端静态文件
|
||||
npm run build
|
||||
|
||||
# Linux:仅构建 Debian 包,避免为不需要的 AppImage 下载额外运行器
|
||||
npm run tauri -- build --debug --bundles deb
|
||||
|
||||
# macOS(Apple Silicon / Intel):生成 .app 与 .dmg
|
||||
npm run tauri build -- --debug
|
||||
```
|
||||
|
||||
`npm run desktop:dev` 不生成 `.app`、`.dmg` 或安装包。前端 TypeScript / CSS / HTML 保存后会由 Vite 自动热更新;改动 `src-tauri/` 中的 Rust Host 代码时,Tauri 会重新编译并重启开发窗口。只有需要交付可安装文件时才执行 `tauri build`。
|
||||
|
||||
本项目的 `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 安装在其他位置,可临时覆盖:
|
||||
|
||||
```bash
|
||||
RUST_TOOLCHAIN_BIN=/path/to/rust/bin npm run tauri build -- --debug
|
||||
```
|
||||
|
||||
## 本机构建状态(2026-08-03)
|
||||
|
||||
- `npm run build`:已通过;
|
||||
- Cargo/Tauri Rust crates:已下载并开始编译;
|
||||
- Tauri Linux desktop build:已通过;debug 可执行文件和 Debian 包已生成;
|
||||
- Android Spike:尚未开始。仍需要 Android NDK、Rust Android targets 与 Tauri Android 初始化。
|
||||
|
||||
Linux 桌面构建依赖以下系统开发库;本机已具备。新环境缺少时可安装:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y pkg-config libdbus-1-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
```
|
||||
|
||||
本机已生成的产物:
|
||||
|
||||
```text
|
||||
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` 生成。
|
||||
|
||||
## 目录边界
|
||||
|
||||
```text
|
||||
tauri/
|
||||
├── src/ # 可信 LineUp Host 前端
|
||||
│ ├── main.ts # 会话 UI、AppServer transport、Renderer 调度
|
||||
│ └── protocol.ts # 平台无关的 LineUp v1 → ConversationItem 解码
|
||||
├── src-tauri/ # Tauri Rust Host 与 capability 配置
|
||||
└── package.json # Vite、Tauri CLI 与前端依赖
|
||||
```
|
||||
|
||||
第三方 Agent Surface 未来必须运行于独立隔离执行域,只能经 LineUp Mini Runtime 事件桥与 Capability Gateway 通信;不得访问 Tauri `invoke`、Host DOM、登录态或本机特权。
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#16211e" />
|
||||
<title>LineUp</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "lineup-tauri-client",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 127.0.0.1 --port 1420",
|
||||
"desktop:dev": "./scripts/tauri.sh dev",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 127.0.0.1",
|
||||
"tauri": "./scripts/tauri.sh",
|
||||
"check:rust": "./scripts/tauri.sh cargo-metadata"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"dompurify": "^3.2.6",
|
||||
"marked": "^15.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
# Tauri CLI launches Cargo itself. Choose a direct Rust toolchain bin first so
|
||||
# the project works even if a shell has not sourced ~/.cargo/env. The override
|
||||
# is useful for custom or CI toolchain locations.
|
||||
if [ -z "${RUST_TOOLCHAIN_BIN:-}" ]; then
|
||||
case "$(uname -s)-$(uname -m)" in
|
||||
Linux-x86_64) RUST_TOOLCHAIN_BIN="$HOME/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin" ;;
|
||||
Darwin-arm64) RUST_TOOLCHAIN_BIN="$HOME/.rustup/toolchains/stable-aarch64-apple-darwin/bin" ;;
|
||||
Darwin-x86_64) RUST_TOOLCHAIN_BIN="$HOME/.rustup/toolchains/stable-x86_64-apple-darwin/bin" ;;
|
||||
*) RUST_TOOLCHAIN_BIN="" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ -n "$RUST_TOOLCHAIN_BIN" ] && [ -d "$RUST_TOOLCHAIN_BIN" ]; then
|
||||
export PATH="$RUST_TOOLCHAIN_BIN:$PATH"
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "cargo-metadata" ]; then
|
||||
exec cargo metadata --manifest-path src-tauri/Cargo.toml --no-deps --format-version 1
|
||||
fi
|
||||
|
||||
exec ./node_modules/.bin/tauri "$@"
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "lineup-tauri"
|
||||
version = "0.1.0"
|
||||
description = "LineUp Tauri Reference Host"
|
||||
authors = ["LineUp"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "lineup_tauri_lib"
|
||||
crate-type = ["lib", "cdylib", "staticlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!--
|
||||
The LineUp Reference Host currently connects to a user-configured AppServer
|
||||
on a private network/Tailscale over HTTP. Restrict this exception to the
|
||||
WKWebView content process; native Rust networking remains subject to ATS.
|
||||
Remove it when every AppServer endpoint is served over HTTPS.
|
||||
-->
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capabilities for the trusted LineUp host frontend.",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"default":{"identifier":"default","description":"Capabilities for the trusted LineUp host frontend.","local":true,"windows":["main"],"permissions":["core:default"]}}
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="LineUp">
|
||||
<defs>
|
||||
<linearGradient id="background" x1="44" y1="34" x2="470" y2="488" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#294c3d"/>
|
||||
<stop offset="1" stop-color="#101b16"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="mark" x1="164" y1="132" x2="351" y2="374" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#d8fae8"/>
|
||||
<stop offset="1" stop-color="#86d7b0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="118" fill="url(#background)"/>
|
||||
<path d="M256 106c10 72 38 108 108 120-70 12-98 48-108 120-10-72-38-108-108-120 70-12 98-48 108-120Z" fill="url(#mark)"/>
|
||||
<path d="M354 294c0 58-44 104-98 104-17 0-33-4-47-12l-58 30 16-62c-7-17-11-37-11-60 0-58 45-104 100-104s98 46 98 104Z" fill="#143125" stroke="#9be2bf" stroke-width="14" stroke-linejoin="round"/>
|
||||
<circle cx="215" cy="294" r="12" fill="#d8fae8"/>
|
||||
<circle cx="256" cy="294" r="12" fill="#d8fae8"/>
|
||||
<circle cx="297" cy="294" r="12" fill="#d8fae8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 744 B |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1001 B |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
@@ -0,0 +1,6 @@
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running LineUp Tauri host");
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
lineup_tauri_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "LineUp",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.lineup.client",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://127.0.0.1:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [{ "label": "main", "title": "LineUp", "width": 1060, "height": 760, "minWidth": 360, "minHeight": 600 }],
|
||||
"security": { "csp": "default-src 'self'; connect-src 'self' http: https: ws: wss:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'" }
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"macOS": {
|
||||
"infoPlist": "Info.plist"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
import "./style.css";
|
||||
import { decodeConversationItem, type ConversationItem, type Envelope } from "./protocol";
|
||||
|
||||
type Session = {
|
||||
api: string;
|
||||
uid: string;
|
||||
agentUID: string;
|
||||
channelID: string;
|
||||
channelType: number;
|
||||
lastSeq: number;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type LoginResponse = {
|
||||
uid: string;
|
||||
agent_uid?: string;
|
||||
channel_id?: string;
|
||||
channel_type?: number;
|
||||
};
|
||||
|
||||
type SyncedMessage = { message_seq: number; from_uid: string; payload: string };
|
||||
|
||||
const DEFAULT_API = "http://127.0.0.1:8090";
|
||||
let session: Session = { api: localStorage.getItem("lineup.api") || DEFAULT_API, uid: "", agentUID: "agent_hermes_main", channelID: "agent_default_channel", channelType: 2, lastSeq: 0, active: false };
|
||||
let polling = false;
|
||||
let thinking: HTMLElement | undefined;
|
||||
|
||||
const app = document.querySelector<HTMLDivElement>("#app");
|
||||
if (!app) throw new Error("LineUp host root is missing");
|
||||
|
||||
app.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><button id="logout" class="quiet">退出</button></header>
|
||||
<section id="messages" class="messages" aria-live="polite"></section>
|
||||
<form id="message-form" class="composer"><textarea id="message-input" rows="1" placeholder="问问你的 AI 搭档…"></textarea><button id="send" type="submit">发送</button></form>
|
||||
</section>
|
||||
</main>`;
|
||||
|
||||
const $ = <T extends Element>(selector: string): T => {
|
||||
const found = document.querySelector<T>(selector);
|
||||
if (!found) throw new Error(`Missing element: ${selector}`);
|
||||
return found;
|
||||
};
|
||||
const messages = $<HTMLElement>("#messages");
|
||||
const loginView = $<HTMLElement>("#login-view");
|
||||
const chatView = $<HTMLElement>("#chat-view");
|
||||
const presence = $<HTMLElement>("#presence");
|
||||
$<HTMLInputElement>("#api").value = session.api;
|
||||
|
||||
function api(path: string): string { return `${session.api.replace(/\/$/, "")}${path}`; }
|
||||
function scrollLatest(): void { messages.scrollTop = messages.scrollHeight; }
|
||||
function setPresence(value: string): void { presence.textContent = value; }
|
||||
function addSystem(message: string): void { const item = document.createElement("p"); item.className = "system"; item.textContent = message; messages.append(item); scrollLatest(); }
|
||||
|
||||
function normalizeAPIAddress(value: string): string {
|
||||
const candidate = value.trim();
|
||||
if (!candidate) throw new Error("请输入 AppServer 地址。");
|
||||
const withProtocol = /^https?:\/\//i.test(candidate) ? candidate : `http://${candidate}`;
|
||||
let parsed: URL;
|
||||
try { parsed = new URL(withProtocol); } catch { throw new Error("AppServer 地址格式不正确,例如 http://100.121.118.116:8090。"); }
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("AppServer 地址仅支持 http:// 或 https://。");
|
||||
if (!parsed.hostname) throw new Error("AppServer 地址缺少主机名或 IP 地址。");
|
||||
return parsed.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function loginFailureMessage(reason: unknown): string {
|
||||
if (reason instanceof DOMException && reason.name === "AbortError") return "连接 AppServer 超时(10 秒)。请确认 Tailscale 已连接,且服务地址和端口可访问。";
|
||||
const message = reason instanceof Error ? reason.message : "";
|
||||
if (message === "Load failed" || message === "Failed to fetch") return "无法连接 AppServer。请确认 Tailscale 已连接,并检查地址是否为 http://100.121.118.116:8090。";
|
||||
return message || "登录失败,请稍后重试。";
|
||||
}
|
||||
|
||||
function appendBubble(role: "human" | "agent", content: string, markdown = false): HTMLElement {
|
||||
const row = document.createElement("article"); row.className = `message-row ${role}`;
|
||||
if (role === "agent") { const avatar = document.createElement("span"); avatar.className = "message-avatar"; avatar.textContent = "✦"; row.append(avatar); }
|
||||
const bubble = document.createElement("div"); bubble.className = "bubble";
|
||||
if (markdown) {
|
||||
bubble.classList.add("markdown");
|
||||
bubble.innerHTML = DOMPurify.sanitize(marked.parse(content, { gfm: true, breaks: true }) as string, { FORBID_TAGS: ["style", "form", "input", "button"] });
|
||||
bubble.querySelectorAll("a").forEach(link => { link.target = "_blank"; link.rel = "noopener noreferrer"; });
|
||||
} else bubble.textContent = content;
|
||||
const meta = document.createElement("small"); meta.className = "meta"; meta.textContent = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
bubble.append(meta); row.append(bubble); messages.append(row); scrollLatest(); return row;
|
||||
}
|
||||
|
||||
function showThinking(detail = "正在思考…"): void {
|
||||
thinking?.remove();
|
||||
thinking = appendBubble("agent", detail);
|
||||
thinking.classList.add("thinking");
|
||||
setPresence("正在思考");
|
||||
}
|
||||
function hideThinking(): void { thinking?.remove(); thinking = undefined; }
|
||||
|
||||
function render(item: ConversationItem): void {
|
||||
switch (item.kind) {
|
||||
case "markdown": hideThinking(); appendBubble("agent", item.markdown, true); break;
|
||||
case "agent-status": item.status === "thinking" ? showThinking(item.detail || "正在思考…") : (hideThinking(), setPresence(item.detail || item.status || "在线")); break;
|
||||
case "progress": { const percent = Math.max(0, Math.min(100, item.percent)); addSystem(`${item.title || "Agent 任务"}:${percent}%${item.status ? ` · ${item.status}` : ""}`); break; }
|
||||
case "error": hideThinking(); addSystem(`Agent 错误:${item.message}`); break;
|
||||
case "surface": addSystem(`收到扩展界面请求(${item.envelope.type});Mini Runtime Surface Host 将在下一步接入。`); break;
|
||||
case "app-call": addSystem("收到 App 能力调用请求;Capability 授权 UI 将在下一步接入。"); break;
|
||||
case "fallback": addSystem(item.reason === "unsupported_type" ? `暂不支持的 Agent 内容:${item.envelope?.type ?? "unknown"}` : "收到无法安全展示的消息。"); break;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64(value: string): string {
|
||||
try { return decodeURIComponent([...atob(value)].map(c => `%${c.charCodeAt(0).toString(16).padStart(2, "0")}`).join("")); } catch { return value; }
|
||||
}
|
||||
|
||||
async function sendPayload(payload: string): Promise<number | undefined> {
|
||||
const response = await fetch(api("/messages/send"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ from_uid: session.uid, channel_id: session.channelID, channel_type: session.channelType, payload }) });
|
||||
const body = await response.json().catch(() => ({})) as { error?: string; message_seq?: number };
|
||||
if (!response.ok) throw new Error(body.error || "消息发送失败");
|
||||
return body.message_seq;
|
||||
}
|
||||
|
||||
async function syncLoop(): Promise<void> {
|
||||
if (polling) return;
|
||||
polling = true;
|
||||
while (session.active) {
|
||||
try {
|
||||
const response = await fetch(api("/messages/sync"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ channel_id: session.channelID, channel_type: session.channelType, login_uid: session.uid, start_message_seq: session.lastSeq === 0 ? 0 : session.lastSeq + 1, limit: 50 }) });
|
||||
const body = await response.json().catch(() => ({})) as { error?: string; messages?: SyncedMessage[] };
|
||||
if (!response.ok) throw new Error(body.error || "同步失败");
|
||||
for (const message of body.messages ?? []) {
|
||||
session.lastSeq = Math.max(session.lastSeq, message.message_seq);
|
||||
if (message.from_uid !== session.uid) render(decodeConversationItem(decodeBase64(message.payload)));
|
||||
}
|
||||
} catch {
|
||||
if (session.active) setPresence("同步中断,正在重试…");
|
||||
}
|
||||
await new Promise(resolve => window.setTimeout(resolve, 1500));
|
||||
}
|
||||
polling = false;
|
||||
}
|
||||
|
||||
$<HTMLFormElement>("#login-form").addEventListener("submit", async event => {
|
||||
event.preventDefault();
|
||||
const apiInput = $<HTMLInputElement>("#api");
|
||||
const phone = $<HTMLInputElement>("#phone").value.trim();
|
||||
const code = $<HTMLInputElement>("#code").value.trim();
|
||||
const error = $<HTMLElement>("#login-error");
|
||||
const submit = $<HTMLButtonElement>("#login-submit");
|
||||
let endpoint: string;
|
||||
try { endpoint = normalizeAPIAddress(apiInput.value); }
|
||||
catch (reason) { error.textContent = loginFailureMessage(reason); apiInput.focus(); return; }
|
||||
if (!phone) { error.textContent = "请输入手机号。"; $<HTMLInputElement>("#phone").focus(); return; }
|
||||
if (!code) { error.textContent = "请输入验证码。"; $<HTMLInputElement>("#code").focus(); return; }
|
||||
|
||||
session.api = endpoint;
|
||||
localStorage.setItem("lineup.api", session.api);
|
||||
error.textContent = "正在连接 AppServer…";
|
||||
submit.disabled = true;
|
||||
submit.textContent = "正在进入…";
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), 10_000);
|
||||
try {
|
||||
const response = await fetch(api("/login"), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ phone, code }), signal: controller.signal });
|
||||
const body = await response.json().catch(() => ({})) as LoginResponse & { error?: string };
|
||||
if (!response.ok) throw new Error(body.error || "登录失败");
|
||||
session = { ...session, uid: body.uid, agentUID: body.agent_uid || session.agentUID, channelID: body.channel_id || session.channelID, channelType: body.channel_type || session.channelType, lastSeq: 0, active: true };
|
||||
loginView.hidden = true; chatView.hidden = false; addSystem("已进入与 AI Agent 的对话"); setPresence("正在同步消息…"); void syncLoop();
|
||||
} catch (reason) { error.textContent = loginFailureMessage(reason); }
|
||||
finally { window.clearTimeout(timeout); submit.disabled = false; submit.textContent = "进入 LineUp"; }
|
||||
});
|
||||
|
||||
$<HTMLFormElement>("#message-form").addEventListener("submit", async event => {
|
||||
event.preventDefault();
|
||||
const input = $<HTMLTextAreaElement>("#message-input"); const send = $<HTMLButtonElement>("#send"); const text = input.value.trim();
|
||||
if (!text || !session.uid) return;
|
||||
input.value = ""; send.disabled = true;
|
||||
const row = appendBubble("human", text); const meta = row.querySelector<HTMLElement>(".meta");
|
||||
try { const sequence = await sendPayload(text); if (sequence) session.lastSeq = Math.max(session.lastSeq, sequence); if (meta) meta.textContent += " · 已发送"; }
|
||||
catch (reason) { if (meta) meta.textContent += " · 发送失败"; addSystem(reason instanceof Error ? reason.message : "发送失败"); }
|
||||
finally { send.disabled = false; input.focus(); }
|
||||
});
|
||||
|
||||
$<HTMLButtonElement>("#logout").addEventListener("click", () => { session.active = false; messages.replaceChildren(); chatView.hidden = true; loginView.hidden = false; });
|
||||
|
||||
export function makeProtocolEvent(type: string, payload: Record<string, unknown>, conversationID: string): Envelope {
|
||||
return { v: 1, id: `msg_${crypto.randomUUID()}`, type, conversation_id: conversationID, payload };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export const LINEUP_PROTOCOL_VERSION = 1;
|
||||
|
||||
export type Envelope = {
|
||||
v: number;
|
||||
id?: string;
|
||||
type: string;
|
||||
conversation_id?: string;
|
||||
payload?: unknown;
|
||||
};
|
||||
|
||||
export type ConversationItem =
|
||||
| { kind: "markdown"; markdown: string; envelope?: Envelope }
|
||||
| { kind: "agent-status"; status: string; detail: string; envelope?: Envelope }
|
||||
| { kind: "progress"; title: string; percent: number; status: string; envelope?: Envelope }
|
||||
| { kind: "error"; message: string; envelope?: Envelope }
|
||||
| { kind: "surface"; envelope: Envelope }
|
||||
| { kind: "app-call"; envelope: Envelope }
|
||||
| { kind: "fallback"; reason: string; envelope?: Envelope };
|
||||
|
||||
function object(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
export function decodeConversationItem(raw: string): ConversationItem {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { kind: "markdown", markdown: raw };
|
||||
}
|
||||
const envelope = object(parsed) as Envelope | null;
|
||||
if (!envelope || envelope.v !== LINEUP_PROTOCOL_VERSION || !envelope.type?.startsWith("lineup.v1.")) {
|
||||
return { kind: "fallback", reason: "invalid_envelope" };
|
||||
}
|
||||
const payload = object(envelope.payload) ?? {};
|
||||
switch (envelope.type) {
|
||||
case "lineup.v1.text":
|
||||
return { kind: "markdown", markdown: text(payload.markdown) || text(payload.text), envelope };
|
||||
case "lineup.v1.agent.status":
|
||||
return { kind: "agent-status", status: text(payload.status), detail: text(payload.detail), envelope };
|
||||
case "lineup.v1.agent.progress":
|
||||
return { kind: "progress", title: text(payload.title), percent: Number(payload.percent) || 0, status: text(payload.status), envelope };
|
||||
case "lineup.v1.error":
|
||||
return { kind: "error", message: text(payload.message) || "Agent 执行失败", envelope };
|
||||
case "lineup.v1.ui.open":
|
||||
case "lineup.v1.ui.patch":
|
||||
case "lineup.v1.ui.close":
|
||||
return { kind: "surface", envelope };
|
||||
case "lineup.v1.app.call":
|
||||
return { kind: "app-call", envelope };
|
||||
default:
|
||||
return { kind: "fallback", reason: "unsupported_type", envelope };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#e9f0ec;background:#101815;font-synthesis:none}*{box-sizing:border-box}body{margin:0;min-width:320px;min-height:100vh}.shell{min-height:100vh;background:radial-gradient(circle at 5% 0,#29473d 0,transparent 32rem),#101815}.login-view{display:grid;place-items:center;gap:2.5rem;min-height:100vh;padding:2rem}.brand{display:flex;align-items:center;gap:1rem}.brand>span,.avatar{display:grid;place-items:center;border-radius:50%;background:#b8e4cf;color:#11251d;font-weight:800}.brand>span{width:3.2rem;height:3.2rem;font-size:1.4rem}.brand p{margin:0;color:#a5c9b8;font-size:.75rem;letter-spacing:.16em}.brand h1{margin:.25rem 0 0;font-size:1.5rem}.login-card{display:grid;width:min(100%,26rem);gap:1rem;padding:1.5rem;border:1px solid #365245;border-radius:1rem;background:#18251f;box-shadow:0 1rem 4rem #0006}.login-card label{display:grid;gap:.45rem;color:#b8c9c0;font-size:.85rem}.login-card input,.composer textarea{border:1px solid #456253;border-radius:.65rem;background:#0e1713;color:#eff7f2;padding:.75rem;font:inherit}.login-card button,.composer button{border:0;border-radius:.65rem;padding:.8rem 1rem;background:#a9dfc4;color:#102018;font-weight:750;cursor:pointer}.error{min-height:1.2rem;margin:0;color:#ffaaa4;font-size:.85rem}.chat-view{display:grid;grid-template-rows:auto 1fr auto;height:100vh;max-width:62rem;margin:auto;border-inline:1px solid #2d4438;background:#142019}.chat-header{display:flex;align-items:center;justify-content:space-between;padding:1rem 1.25rem;border-bottom:1px solid #2c4437}.agent{display:flex;align-items:center;gap:.75rem}.avatar{width:2.4rem;height:2.4rem}.agent strong,.agent small{display:block}.agent small{margin-top:.12rem;color:#9bb5a7;font-size:.8rem}.quiet{border:0;background:transparent;color:#b7cabe;cursor:pointer}.messages{overflow:auto;padding:1.25rem;display:grid;align-content:start;gap:.75rem}.message-row{display:flex;gap:.6rem;max-width:min(85%,45rem)}.message-row.human{justify-self:end}.message-row.human .bubble{background:#a9dfc4;color:#122119}.message-avatar{display:grid;place-items:center;width:1.75rem;height:1.75rem;border-radius:50%;background:#315d4a;color:#d8f5e5;font-size:.8rem}.bubble{padding:.75rem .9rem;border-radius:.9rem;background:#23342b;color:#e7f0eb;line-height:1.55;overflow-wrap:anywhere}.meta{display:block;margin-top:.45rem;opacity:.6;font-size:.68rem}.markdown :first-child{margin-top:0}.markdown :last-child{margin-bottom:0}.markdown pre{overflow:auto;padding:.7rem;border-radius:.5rem;background:#0b110e}.markdown code{font-family:"SFMono-Regular",Consolas,monospace}.system{justify-self:center;margin:.35rem 0;color:#98afa2;font-size:.78rem}.thinking .bubble{color:#c7e3d5}.composer{display:flex;gap:.75rem;padding:1rem;border-top:1px solid #2c4437;background:#16241d}.composer textarea{flex:1;max-height:8rem;resize:vertical}.composer button:disabled{opacity:.5}@media(max-width:560px){.chat-view{border:0}.message-row{max-width:94%}.login-view{gap:1.4rem;padding:1rem}}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||