Initialize LineUp app server

This commit is contained in:
2026-08-07 13:36:56 +08:00
commit d6f6a92bab
24 changed files with 2184 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# Private deployment configuration for run-server.sh.
# Copy to .env, generate a strong random value, and never commit it.
LINEUP_AGENT_SHARED_SECRET=
+5
View File
@@ -0,0 +1,5 @@
.env
node_modules/
bin/
*.log
*.pid
+333
View File
@@ -0,0 +1,333 @@
# LineUp App Server — 项目设计文档
> 版本:0.1(提案)
> 日期:2026-07-30
> 状态:待确认
> 目标:精简的蓝牙应用服务端,帮助用户与远端 Agent 服务建立连接,实现互相通信
---
## 1. 项目定位
### 1.1 与唐僧叨叨的关系
唐僧叨叨是一个完整的 IM 业务系统(用户/群组/文件/朋友圈/工作台/推送...),模块众多、功能庞大。LineUp App Server 不是唐僧叨叨的副本,而是:
```
唐僧叨叨:消费级 IM 全栈(群聊、朋友圈、文件、搜索...)
LineUp App Server:只做两件事 ——
① 用户认证身份 → 接入通讯网络
② 消息路由 → 连接用户与 Agent
```
**所以我们不是在唐僧叨叨上改,而是从它那里取经:**
- 复用它的 WuKongIM 集成模式(怎么调用 API、怎么写 Webhook
- 复用它的 HTTP 路由注册方式(gin 框架 + module 自注册)
- 复用它的 Webhook 消息处理逻辑(msg.notify、msg.offline 等事件)
- 丢弃所有跟业务无关的模块(群聊、文件、推送、朋友圈...)
### 1.2 LineUp App Server 的定义
> LineUp App Server 是一个轻量级的**通讯网关**。
> 它的唯一职责是:让一个通过蓝牙/WiFi 连接的移动客户端,能够接入 WuKongIM 3.0 通讯网络,并与远端的 Agent 服务进行消息交换。
**它不是什么:**
- 不是社交平台(不处理好友、朋友圈、群聊)
- 不是文件存储(不处理文件上传/下载)
- 不是推送网关(不处理 APNS/HMS/Mi Push
- 不是管理后台(不处理统计、审核、工作台)
- 不是机器人平台(不处理自定义机器人接入)
---
## 2. 核心概念
### 2.1 三种参与者
```
┌──────────┐ ┌─────────────────┐ ┌──────────┐
│ 用户 Client │ ◄─► │ LineUp App Server │ ◄─► │ Agent 服务 │
│ (手机 App) │ │ (通讯网关) │ │ (远端 LLM) │
└──────────┘ └────────┬────────┘ └──────────┘
┌──────▼──────┐
│ WuKongIM 3.0 │
│ (消息管道) │
└──────────────┘
```
- **用户 Client**:运行在手机上的 App,通过蓝牙/WiFi 与 App Server 通信
- **Agent 服务**:远端的 AI Agent(如 Hermes),有自己的 WuKongIM 客户端连接
- **WuKongIM 3.0**:消息传输管道,负责可靠投递
- **LineUp App Server**:通讯网关,负责身份认证 + 消息路由
### 2.2 消息流转模型
```
用户发送消息 "帮我查天气"
① Client → App Server
用户 App 通过 HTTP/WS 发送消息到 App Server
② App Server → WuKongIM
App Server 调用 WuKongIM API 将消息投递到 Agent 所在的频道
③ WuKongIM → Agent Service
WuKongIM 将消息推送到 Agent 的 WebSocket 连接
④ Agent Service 处理
Agent 处理请求(可能调用外部工具)
⑤ Agent Service → WuKongIM
Agent 回复消息,发送到 WuKongIM
⑥ WuKongIM → App Server (Webhook)
WuKongIM 通过 Webhook 通知 App Server 有新消息
⑦ App Server → Client
App Server 将消息推送给用户 Client
```
---
## 3. 通信协议分层
```
┌─────────────────────────────────────────┐
│ LineUp 自定义消息协议 │ ← 业务层
│ (text, choice, progress, canvas...) │
├─────────────────────────────────────────┤
│ 唐僧叨叨消息模型 │ ← 适配层
│ (Message, Channel, Conversation) │
├──────────────────┬──────────────────────┤
│ WKProto WebSocket│ HTTP API + Webhook │ ← WuKongIM 3.0
│ (双向实时, 15200) │ (管理面, 15001) │
│ │ (通知面, callback) │
└──────────────────┴──────────────────────┘
```
**WuKongIM 3.0 的三层能力:**
| 层 | 协议 | 端口 | 方向 | 用途 |
|---|---|---|---|---|
| **数据面** | WKProto WebSocket / TCP | 15200 / 15100 | **双向全双工** | 客户端收发消息 |
| **管理面** | HTTP REST API | 15001 | App Server → WK | 用户/频道/消息管理 |
| **通知面** | HTTP Webhook | Callback | WK → App Server | 事件通知(新消息/离线/在线) |
> 客户端直连 WuKongIM 的 WebSocket (15200),所有实时消息都在这个管道里跑。
> App Server 只做两件事: ① 通过 HTTP API 做管理; ② 通过 Webhook 接收事件推送给 Client。
---
## 4. 架构设计
### 4.1 服务端口
```
LineUp App Server:
- HTTP API: :8090 (客户端 REST API)
- WebSocket: :8090/ws (客户端实时推送)
- Webhook: :8090/v1/webhook (接收 WuKongIM 回调)
WuKongIM 3.0: (已部署)
- API: :15001 (管理 API: /route, /channel, /message/send...)
- TCP: :15100 (WKProto TCP)
- WebSocket: :15200 (WKProto WebSocket)
- Manager: :15301 (管理后台)
```
### 4.2 模块划分
```
lineup-app-server/
├── main.go # 入口:加载配置 → 注册模块 → 启动 HTTP
├── go.mod / go.sum
├── configs/
│ └── lineup.yaml # 配置:WuKongIM 地址、Token、端口
├── internal/
│ ├── app.go # App 生命周期:Init → Start → Stop
│ ├── config.go # 配置结构体
│ └── db.go # 数据库(轻量,仅存储用户/Token)
└── modules/
├── user/ # 用户模块:登录/注册/Token管理
│ ├── module.go # → 模块自注册
│ ├── api.go # → REST: POST /login, POST /register
│ ├── db.go # → 用户数据存储
│ └── service.go # → 业务逻辑
├── channel/ # 频道模块:创建Agent对话频道
│ ├── module.go
│ ├── api.go # → REST: POST /channels, GET /channels
│ └── service.go # → 调用 WuKongIM Channel API
├── message/ # 消息模块:收发消息
│ ├── module.go
│ ├── api.go # → REST: POST /messages, GET /messages/sync
│ └── service.go # → 调用 WuKongIM Message API
├── webhook/ # Webhook模块:接收WuKongIM回调
│ ├── module.go
│ ├── handler.go # → HTTP: POST /v1/webhook
│ └── event.go # → 事件分发:msg.notify → 推送Client
└── client/ # 客户端连接模块(蓝牙/WS网关)
├── module.go
├── ws.go # → WebSocket: /ws (向Client实时推送)
└── push.go # → 消息推送:把WuKongIM消息推给客户端
```
**只有 6 个模块(vs 唐僧叨叨的 15 个),代码量预计减少 70%+。**
### 4.3 数据库设计(最简方案)
选项 A(推荐起步): **不使用关系数据库,用文件/内存存储**
```go
// 用户注册本质上就是向WuKongIM申请一个token
// agent的对话频道和消息都存储在WuKongIM内部
// App Server 本身不需要持久化存储
```
选项 B(如果需要持久化): **SQLite / PebbleDB**
```sql
-- 极简表设计
CREATE TABLE users (
uid TEXT PRIMARY KEY, -- 用户ID(与WuKongIM一致)
token TEXT, -- WuKongIM Token
name TEXT, -- 显示名称
created INTEGER -- 创建时间
);
```
---
## 5. 核心通讯流程
### 5.1 用户上线流程
```
Step 1: Client 通过 HTTP POST /login
→ App Server 验证用户凭证
→ App Server 调用 WuKongIM API 获取 Route 信息
→ 返回 {token, route} 给 Client
Step 2: Client 使用 token 和 route 直连 WuKongIM WebSocket
→ WuKongIM WebSocket: ws://host:15200?token=xxx
→ 这一步完全绕开 App ServerClient 自己跟 WuKongIM 通信
Step 3: Client 同时连接 App Server WebSocket
→ ws://app-server:8090/ws?token=xxx
→ 用于接收实时推送(Webhook 转发过来的消息)
```
**关键设计决策:客户端直连 WuKongIMApp Server 只作为 Webhook 中转。**
### 5.2 发送消息流程
```
Client → WuKongIM WebSocket (直连)
→ 消息直接写入 WuKongIM,不经过 App Server
→ App Server 通过 Webhook 收到通知后,可以通过 WebSocket 推送给对端
```
### 5.3 Agent 连接方式
```
Agent Service 有两种接入方式:
方式 A(推荐):直接作为 WuKongIM 客户端
- Agent 用 Go/Python SDK 连接 WuKongIM
- 收发消息走 WuKongIM 原生协议
- App Server 完全透明,只做 Webhook 中转
方式 B:通过 App Server API
- Agent 注册为 App Server 的"特殊用户"
- 消息通过 App Server 的 HTTP API 中转
- 适合 Agent 无法直连 WuKongIM 的场景
```
---
## 6. WuKongIM 3.0 集成清单
### 6.1 调用的 WuKongIM API
| API | 用途 | 代码位置 |
|---|---|---|
| `POST /user/token` | 生成用户 Token | `modules/user/service.go` |
| `GET /route?uid=` | 获取连接地址 | `modules/user/service.go` |
| `POST /channel` | 创建 Agent 对话频道 | `modules/channel/service.go` |
| `POST /channel/messagesync` | 同步频道消息 | `modules/message/service.go` |
| `POST /message/send` | 发送消息(App Server 代发) | `modules/message/service.go` |
### 6.2 接收的 Webhook 事件
| 事件 | 含义 | 处理方式 |
|---|---|---|
| `msg.notify` | 新消息通知 | 推送给对应的 Client WebSocket |
| `msg.offline` | 离线消息 | 推送给刚上线的 Client |
| `user.onlinestatus` | 用户在线状态变化 | 更新 Agent 在线状态 |
### 6.3 v3 兼容性对照(已有验证结论)
| 功能 | 状态 | 备注 |
|---|---|---|
| Token 生成 | ✅ | API 兼容 |
| Route 查询 | ✅ | 格式兼容(uid参数在v3被忽略但不影响) |
| Channel 管理 | ✅ | API 兼容 |
| Message Send | ✅ | API 兼容 |
| Message Sync | ✅ | v3 使用 `channel/messagesync` |
| Webhook 事件名 | ✅ | msg.notify/msg.offline/user.onlinestatus 完全一致 |
| Webhook HTTP | ✅ | v3 原生 HTTP Webhook(不需要改 gRPC 适配) |
| Message Search | ❌ 不需要 | 精简版不做消息搜索 |
| 文件上传 | ❌ 不需要 | 精简版不做文件服务 |
---
## 7. 实施计划
### Phase 1: 最小骨架(1-2 天)
```
目标:Go 项目编译通过,HTTP 服务启动,能连接 WuKongIM 3.0
□ 创建 go.mod,引入唐僧叨叨的 server/config 和 wkhttp
□ 实现 main.go:加载配置 + 启动 gin HTTP 服务
□ 实现 user 模块:POST /login(调 WuKongIM /user/token
□ 实现 webhook 模块:POST /v1/webhook(接收并打印事件日志)
□ 手工测试:curl /login → 拿到 token → 用 token 连 WuKongIM
```
### Phase 2: 消息通路(2-3 天)
```
目标:用户和 Agent 能通过 App Server + WuKongIM 互相发消息
□ 实现 channel 模块:创建/查询频道
□ 实现 message 模块:发送消息、同步消息
□ 实现 client WebSocket:向 Client 实时推送消息
□ 端到端测试:用户发消息 → Agent 收到 → Agent 回复 → 用户收到
```
### Phase 3: 蓝牙/本地通信适配(2-3 天)
```
目标:手机 App 通过本地网络连接 App Server
□ 实现蓝牙发现 / 局域网发现协议
□ App Server 暴露本地 HTTP 服务(mDNS 广播)
□ 安全认证:设备配对 Token
```
### Phase 4: 稳定与优化(1-2 天)
```
□ 连接断开重连
□ 消息确认与去重
□ 监控与日志
□ 性能调优
```
---
## 8. 参考资料
- WuKongIM 3.0 源码:`wukongim/`
- WuKongIM 3.0 本地环境:`infra/wukongim-v3/`
- 兼容性验证:`项目状态记录.md` §六-§八
- LineUp App 层架构方案:`设计/02.正式方案/lineup-app-layer-architecture.md`
---
*本文档为项目设计提案,待确认后进入 Phase 1 实现。*
+204
View File
@@ -0,0 +1,204 @@
<!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="#f7f4ee">
<title>LineUp</title>
<script defer src="/vendor/marked/marked.umd.js"></script>
<script defer src="/vendor/dompurify/purify.min.js"></script>
<script defer src="/chat/interaction-kernel.js"></script>
<style>
:root { --paper:#f7f4ee; --surface:#fffdf9; --ink:#25231f; --muted:#77736b; --line:#e8e2d8; --accent:#47736b; --accent-soft:#e1eee9; --agent:#f0eee8; --danger:#b64c48; }
* { box-sizing:border-box; } body { margin:0; min-height:100vh; font-family:ui-rounded,"SF Pro Rounded",-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; color:var(--ink); background:radial-gradient(circle at 20% 0,#fff 0,transparent 32%),var(--paper); }
button,input { font:inherit; } button { cursor:pointer; }
#login { width:min(360px,calc(100% - 32px)); margin:14vh auto; padding:32px; border:1px solid var(--line); border-radius:22px; background:rgba(255,253,249,.9); box-shadow:0 20px 55px rgba(58,48,35,.11); text-align:center; }
#login h1 { font-family:ui-serif,Georgia,serif; font-size:32px; letter-spacing:-1px; margin:0 0 7px; } #login p { margin:0 0 24px; color:var(--muted); }
#login input { width:100%; margin:0 0 10px; padding:12px 14px; border:1px solid var(--line); border-radius:10px; outline:none; background:#fff; } #login input:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(71,115,107,.12); }
.primary { border:0; border-radius:10px; padding:12px 18px; color:#fff; background:var(--accent); font-weight:700; } .primary:hover { background:#345b54; } .error { min-height:19px; margin-top:9px; color:var(--danger); font-size:13px; }
#chat { display:none; width:min(900px,100%); min-height:100vh; margin:auto; background:rgba(255,253,249,.93); border-inline:1px solid var(--line); flex-direction:column; }
#chat-header { display:flex; align-items:center; justify-content:space-between; gap:16px; min-height:72px; padding:14px 24px; border-bottom:1px solid var(--line); background:rgba(255,253,249,.86); backdrop-filter:blur(12px); position:sticky; top:0; z-index:2; }
.agent-title { display:flex; align-items:center; gap:11px; }.avatar { display:grid; place-items:center; width:38px; height:38px; border-radius:13px; color:#fff; background:linear-gradient(145deg,#5f9085,#315a54); box-shadow:inset 0 1px rgba(255,255,255,.3); }.agent-title h1 { margin:0; font-size:15px; letter-spacing:.1px; }.presence { display:flex; align-items:center; gap:5px; margin-top:3px; color:var(--muted); font-size:12px; }.presence-dot { width:7px; height:7px; border-radius:50%; background:#aaa; }.presence.online .presence-dot { background:#43a976; box-shadow:0 0 0 3px rgba(67,169,118,.13); }.presence.thinking .presence-dot { background:#d68d36; animation:pulse 1.2s ease-in-out infinite; }.presence.offline .presence-dot { background:#aaa; }.text-button { padding:7px 9px; border:0; background:transparent; color:var(--muted); font-size:13px; }.text-button:hover { color:var(--danger); }
#messages { display:flex; flex:1; flex-direction:column; gap:16px; overflow-y:auto; padding:28px clamp(16px,5vw,56px); scroll-behavior:smooth; }.system-message { align-self:center; max-width:100%; color:var(--muted); font-size:12px; text-align:center; }.message-row { display:flex; gap:9px; max-width:100%; }.message-row.user { justify-content:flex-end; }.message-row.agent { align-items:flex-start; }.message-avatar { flex:0 0 25px; display:grid; place-items:center; width:25px; height:25px; margin-top:4px; border-radius:9px; background:var(--accent-soft); color:var(--accent); font-size:13px; }.message { max-width:min(690px,calc(100% - 34px)); padding:12px 15px; border:1px solid transparent; border-radius:16px; line-height:1.65; font-size:15px; overflow-wrap:anywhere; }.user .message { color:#fff; background:var(--accent); border-bottom-right-radius:5px; }.agent .message { background:var(--agent); border-bottom-left-radius:5px; }.message-meta { display:flex; align-items:center; justify-content:flex-end; gap:5px; margin-top:7px; color:inherit; font-size:11px; opacity:.65; }.delivery.failed { color:#ffe3e1; opacity:1; }.message.thinking { display:flex; align-items:center; gap:7px; min-height:43px; color:#5e5b55; font-size:13px; }.thinking-dots { display:flex; gap:3px; }.thinking-dots i { width:5px; height:5px; border-radius:50%; background:var(--accent); animation:bounce 1s infinite; }.thinking-dots i:nth-child(2) { animation-delay:.16s; }.thinking-dots i:nth-child(3) { animation-delay:.32s; }
.markdown { color:var(--ink); }.markdown>:first-child { margin-top:0; }.markdown>:last-child { margin-bottom:0; }.markdown h1,.markdown h2,.markdown h3,.markdown h4 { margin:1.05em 0 .42em; line-height:1.28; font-family:ui-serif,Georgia,serif; letter-spacing:-.2px; }.markdown h1 { font-size:1.48em; }.markdown h2 { font-size:1.28em; }.markdown h3 { font-size:1.1em; }.markdown p,.markdown ul,.markdown ol,.markdown blockquote { margin:.65em 0; }.markdown ul,.markdown ol { padding-left:1.35em; }.markdown li+li { margin-top:.2em; }.markdown blockquote { padding:.1em 0 .1em .9em; color:#615c54; border-left:3px solid #b9cfc8; }.markdown a { color:#236a60; text-decoration-color:#8bb8ad; text-underline-offset:2px; }.markdown code { padding:.13em .35em; border-radius:5px; background:rgba(43,57,52,.09); font-family:"SFMono-Regular",Consolas,monospace; font-size:.87em; }.markdown pre { margin:.8em 0; padding:13px; overflow:auto; border:1px solid #dedbd3; border-radius:10px; background:#292b29; color:#f5f1e9; }.markdown pre code { padding:0; background:none; color:inherit; }.markdown table { display:block; max-width:100%; overflow:auto; border-collapse:collapse; font-size:.92em; }.markdown th,.markdown td { padding:7px 9px; border:1px solid #dcd7ce; text-align:left; }.markdown th { background:rgba(255,255,255,.55); }.markdown img { display:block; max-width:100%; height:auto; border-radius:9px; }.markdown hr { border:0; border-top:1px solid #d8d2c8; margin:1em 0; }
.long-reply { padding:0; overflow:hidden; }.long-reply>summary { padding:12px 15px; color:#37675e; cursor:pointer; font-weight:650; list-style:none; }.long-reply>summary::-webkit-details-marker { display:none; }.long-reply>summary::after { content:"⌄"; float:right; transition:transform .18s; }.long-reply[open]>summary::after { transform:rotate(180deg); }.long-reply .markdown { padding:0 15px 13px; border-top:1px solid rgba(116,104,88,.14); }.code-block { margin:.8em 0; border-radius:10px; overflow:hidden; background:#292b29; }.code-block>summary { padding:7px 10px; color:#c9d4cf; font:12px "SFMono-Regular",Consolas,monospace; cursor:pointer; list-style:none; border-bottom:1px solid rgba(255,255,255,.1); }.code-block>summary::-webkit-details-marker { display:none; }.code-block>summary::before { content:"⌘"; margin-right:6px; color:#89b6aa; }.code-block pre { margin:0; border:0; border-radius:0; }.copy-code { float:right; border:0; padding:0; color:#b5c7c0; background:transparent; font-size:12px; }.copy-code:hover { color:#fff; }
.card { margin-top:8px; padding:13px; border:1px solid #dfe5de; border-radius:12px; background:#f8faf7; }.card-title { margin-bottom:7px; font-weight:700; }.card-body { color:#5e5b55; font-size:13px; }.card-actions { display:flex; flex-wrap:wrap; gap:7px; margin-top:11px; }.card-actions button { padding:7px 12px; border:1px solid #7aa499; border-radius:8px; color:#315f57; background:#fff; font-size:13px; }.card-actions button:hover { color:#fff; background:var(--accent); }.progress-bar { height:6px; overflow:hidden; border-radius:20px; background:#d9e2dd; }.progress-fill { height:100%; border-radius:inherit; background:var(--accent); transition:width .35s; }
.surface { width:min(690px,calc(100vw - 82px)); overflow:hidden; border:1px solid #d6dfd9; border-radius:14px; background:#fff; box-shadow:0 8px 25px rgba(49,66,57,.08); }.surface-header { display:flex; align-items:center; justify-content:space-between; gap:8px; min-height:38px; padding:8px 11px; border-bottom:1px solid #e4ebe6; color:#50645d; background:#f6faf7; font-size:12px; }.surface-header strong { color:#365e55; }.surface-frame { display:block; width:100%; min-height:120px; border:0; background:#fff; }.surface-error { padding:14px; color:var(--danger); font-size:13px; }.surface-close { border:0; color:#64726d; background:transparent; font-size:16px; }.surface-close:hover { color:var(--danger); }
#input-area { display:flex; align-items:flex-end; gap:9px; padding:14px clamp(16px,5vw,56px) max(16px,env(safe-area-inset-bottom)); border-top:1px solid var(--line); background:rgba(255,253,249,.9); }.input-shell { display:flex; flex:1; align-items:center; min-height:43px; padding:0 4px 0 13px; border:1px solid #dcd6cb; border-radius:13px; background:#fff; transition:.15s; }.input-shell:focus-within { border-color:#78a99d; box-shadow:0 0 0 3px rgba(71,115,107,.12); }.input-shell input { flex:1; min-width:0; border:0; outline:0; background:transparent; color:var(--ink); }.send { width:43px; height:35px; border:0; border-radius:10px; color:#fff; background:var(--accent); font-size:16px; }.send:disabled { cursor:not-allowed; opacity:.45; }@keyframes pulse { 50% { transform:scale(.68); opacity:.55; } }@keyframes bounce { 50% { transform:translateY(-4px); opacity:.45; } }
@media (max-width:620px) { #chat { border:0; }.message { max-width:calc(100% - 30px); }.message-row { width:100%; } #chat-header { padding-inline:16px; } #messages { padding-top:20px; } }
</style>
</head>
<body>
<main id="login">
<h1>LineUp</h1><p>你的 AI 搭档</p>
<input id="phone" type="text" autocomplete="tel" placeholder="手机号" value="13800138000">
<input id="code" type="text" inputmode="numeric" placeholder="验证码" value="123456">
<button class="primary" type="button" onclick="login()">进入对话</button>
<div id="loginError" class="error" aria-live="polite"></div>
</main>
<main id="chat">
<header id="chat-header">
<div class="agent-title"><div class="avatar" aria-hidden="true"></div><div><h1>AI Agent</h1><div id="presence" class="presence offline"><span class="presence-dot"></span><span id="status">正在连接…</span></div></div></div>
<button class="text-button" type="button" onclick="logout()">退出</button>
</header>
<section id="messages" aria-live="polite" aria-label="对话消息"></section>
<form id="input-area" onsubmit="sendMsg(); return false;"><div class="input-shell"><input id="msgInput" autocomplete="off" placeholder="问问你的 AI 搭档…"></div><button id="sendBtn" class="send" type="submit" title="发送"></button></form>
</main>
<script>
const API = '';
let state = { uid:'', token:'', agentUID:'agent_hermes_main', channelID:'agent_default_channel', channelType:2, lastSeq:0, active:true };
let polling = false, healthTimer = null, thinkingEl = null;
let itemFactory = null, rendererRegistry = null;
const surfaces = new Map();
const MAX_SURFACE_BYTES = 160 * 1024;
function timeLabel(date = new Date()) { return `${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`; }
function scrollToLatest() { const el = document.getElementById('messages'); el.scrollTop = el.scrollHeight; }
function setPresence(kind, label) { const el = document.getElementById('presence'); el.className = `presence ${kind}`; document.getElementById('status').textContent = label; }
function showLoginError(message) { document.getElementById('loginError').textContent = message; }
async function login() {
const phone = document.getElementById('phone').value.trim(), code = document.getElementById('code').value.trim();
if (!phone || !code) return showLoginError('请填写手机号和验证码');
try {
const response = await fetch(`${API}/login`, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({phone, code}) });
const data = await response.json(); if (!response.ok) return showLoginError(data.error || '登录失败');
state = { ...state, uid:data.uid, token:data.token, agentUID:data.agent_uid || state.agentUID, channelID:data.channel_id || state.channelID, channelType:data.channel_type || state.channelType, lastSeq:0, active:true };
document.getElementById('login').style.display = 'none'; document.getElementById('chat').style.display = 'flex';
addSystemMsg('已进入与 AI Agent 的私密对话'); setPresence('offline','正在确认 Agent 状态…'); document.getElementById('msgInput').focus();
pollMessages(); pollAgentHealth(); healthTimer = window.setInterval(pollAgentHealth, 10000);
} catch (error) { showLoginError(`网络错误:${error.message}`); }
}
function logout() { state.active = false; window.clearInterval(healthTimer); healthTimer = null; surfaces.forEach(surface => surface.row.remove()); surfaces.clear(); document.getElementById('chat').style.display = 'none'; document.getElementById('login').style.display = 'block'; document.getElementById('messages').replaceChildren(); thinkingEl = null; state = { uid:'', token:'', agentUID:'agent_hermes_main', channelID:'agent_default_channel', channelType:2, lastSeq:0, active:true }; }
async function pollAgentHealth() {
if (!state.uid || !state.active) return;
try { const response = await fetch(`${API}/agents/${encodeURIComponent(state.agentUID)}/health`); const health = await response.json(); if (!state.active) return;
if (thinkingEl) setPresence('thinking','正在思考…'); else if (health.online) setPresence('online','在线'); else setPresence('offline', health.known ? '暂时离线' : '等待 Agent 上线');
} catch { if (state.active) setPresence('offline','状态不可用'); }
}
async function sendMsg() {
const input = document.getElementById('msgInput'), button = document.getElementById('sendBtn'), text = input.value.trim(); if (!text || !state.uid) return;
input.value = ''; button.disabled = true; const message = addMessage(text, 'user'); setDelivery(message, '发送中');
try { const response = await fetch(`${API}/messages/send`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({from_uid:state.uid, channel_id:state.channelID, channel_type:state.channelType, payload:text})}); const data = await response.json();
if (!response.ok) throw new Error(data.error || '发送失败'); if (data.message_seq) state.lastSeq = Math.max(state.lastSeq, data.message_seq); setDelivery(message, '已发送');
} catch (error) { setDelivery(message, '发送失败', true); addSystemMsg(`消息未送达:${error.message}`); } finally { button.disabled = false; input.focus(); }
}
async function pollMessages() {
if (polling) return; polling = true;
while (state.active && document.getElementById('chat').style.display !== 'none') {
// WuKongIM returns start_message_seq inclusively. Continue from the
// next unconsumed sequence after the local cursor.
try { const startSeq = state.lastSeq === 0 ? 0 : state.lastSeq + 1; const response = await fetch(`${API}/messages/sync`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({channel_id:state.channelID, channel_type:state.channelType, login_uid:state.uid, start_message_seq:startSeq, limit:50})}); const data = await response.json();
if (!response.ok) throw new Error(data.error || '同步失败'); for (const message of (data.messages || [])) { if (message.message_seq > state.lastSeq) state.lastSeq = message.message_seq; if (message.from_uid !== state.uid) renderAgentMessage(base64Decode(message.payload)); }
} catch { if (state.active) setPresence('offline','消息同步中断,正在重试'); }
await sleep(1500);
} polling = false;
}
function addMessage(text, role) {
const row = document.createElement('article'); row.className = `message-row ${role}`;
if (role === 'agent') { const avatar = document.createElement('div'); avatar.className = 'message-avatar'; avatar.textContent = '✦'; row.append(avatar); }
const bubble = document.createElement('div'); bubble.className = 'message';
if (role === 'agent') appendMarkdown(bubble, text); else bubble.textContent = text;
const meta = document.createElement('div'); meta.className = 'message-meta'; meta.textContent = timeLabel(); bubble.append(meta); row.append(bubble); document.getElementById('messages').append(row); scrollToLatest(); return row;
}
function setDelivery(row, text, failed = false) { const meta = row.querySelector('.message-meta'); meta.textContent = `${timeLabel()} · ${text}`; meta.classList.toggle('delivery', true); meta.classList.toggle('failed', failed); }
function addSystemMsg(text) { const item = document.createElement('div'); item.className = 'system-message'; item.textContent = text; document.getElementById('messages').append(item); scrollToLatest(); }
function showThinking(detail = '') { if (thinkingEl) thinkingEl.remove(); const row = document.createElement('article'); row.className = 'message-row agent'; row.id = 'thinkingIndicator'; const avatar = document.createElement('div'); avatar.className = 'message-avatar'; avatar.textContent = '✦'; const bubble = document.createElement('div'); bubble.className = 'message thinking'; bubble.innerHTML = '<span class="thinking-dots"><i></i><i></i><i></i></span><span></span>'; bubble.querySelector('span:last-child').textContent = detail || '正在思考'; row.append(avatar,bubble); document.getElementById('messages').append(row); thinkingEl = row; setPresence('thinking','正在思考…'); scrollToLatest(); }
function hideThinking() { if (thinkingEl) thinkingEl.remove(); thinkingEl = null; }
function appendMarkdown(container, source) {
const markdown = document.createElement('div'); markdown.className = 'markdown';
const parsed = window.marked ? marked.parse(String(source), {gfm:true, breaks:true}) : escapeHtml(source).replace(/\n/g,'<br>');
markdown.innerHTML = window.DOMPurify ? DOMPurify.sanitize(parsed, {USE_PROFILES:{html:true}, FORBID_TAGS:['style','form','input','button']}) : parsed;
markdown.querySelectorAll('a').forEach(link => { link.target = '_blank'; link.rel = 'noopener noreferrer'; });
markdown.querySelectorAll('pre').forEach(pre => {
const code = pre.querySelector('code'); const language = [...(code?.classList || [])].find(name => name.startsWith('language-'))?.slice(9) || '代码'; const disclosure = document.createElement('details'); disclosure.className = 'code-block'; disclosure.open = true; const summary = document.createElement('summary'); summary.textContent = language; const copy = document.createElement('button'); copy.className = 'copy-code'; copy.type = 'button'; copy.textContent = '复制'; copy.onclick = async event => { event.preventDefault(); try { await navigator.clipboard.writeText(code?.textContent || ''); copy.textContent = '已复制'; setTimeout(() => copy.textContent = '复制', 1400); } catch { copy.textContent = '请手动复制'; } }; summary.append(copy); pre.replaceWith(disclosure); disclosure.append(summary,pre);
});
if (String(source).length > 1200 || markdown.querySelectorAll('pre,table,blockquote').length > 2) { const details = document.createElement('details'); details.className = 'long-reply'; const summary = document.createElement('summary'); summary.textContent = '展开完整回复'; details.append(summary,markdown); container.append(details); } else container.append(markdown);
}
// UI Surfaces are intentionally not normal DOM content. Agent-supplied
// HTML/CSS/JS runs only in a unique-origin sandbox without network access;
// it can emit validated user events but cannot read this page, its tokens,
// or invoke a client capability by itself.
function builtInSurface(requested) {
if (!requested || requested.id !== 'com.lineup.task.dashboard') return null;
return {
id: 'com.lineup.task.dashboard', name: '任务执行面板', version: '1.0.0',
html: '<main class="task"><header><div><p class="eyebrow">LINEUP TASK</p><h1 id="title">任务处理中</h1></div><span id="phase" class="phase">运行中</span></header><p id="summary" class="summary"></p><div class="progress"><i id="progress"></i></div><p id="percent" class="percent"></p><ol id="steps" class="steps"></ol><footer><button id="cancel" class="cancel" type="button">取消任务</button></footer></main>',
css: '.task{padding:18px;font-family:ui-sans-serif,system-ui,sans-serif;color:#20312d}.task header{display:flex;justify-content:space-between;gap:12px;align-items:flex-start}.eyebrow{margin:0 0 3px;color:#668078;font-size:10px;font-weight:700;letter-spacing:.12em}.task h1{margin:0;font-size:19px;line-height:1.25}.phase{padding:4px 8px;border-radius:99px;background:#e2eee9;color:#35675a;font-size:12px;white-space:nowrap}.summary{margin:12px 0;color:#5e6965;font-size:14px;line-height:1.55}.progress{height:7px;overflow:hidden;border-radius:8px;background:#e4ece8}.progress i{display:block;height:100%;border-radius:inherit;background:#4b8474;transition:width .25s}.percent{margin:6px 0 13px;color:#60716b;font-size:12px;text-align:right}.steps{display:grid;gap:8px;margin:0;padding:0;list-style:none}.steps li{display:flex;gap:9px;align-items:flex-start;padding:9px;border-radius:9px;background:#f4f7f5;font-size:13px}.steps b{display:block;color:#30423d}.steps small{display:block;margin-top:2px;color:#718078}.mark{width:18px;height:18px;border-radius:50%;text-align:center;line-height:18px;background:#d9e8e1;color:#397363;font-size:11px}.mark.running{background:#f8e9ca;color:#9a6824}.mark.failed{background:#f6d8d6;color:#a64743}.task footer{display:flex;justify-content:flex-end;margin-top:15px}.cancel{padding:7px 11px;border:1px solid #cf827e;border-radius:8px;background:#fff;color:#a74743;font-size:13px}',
js: "const symbols={completed:'✓',running:'…',failed:'!',pending:'○',cancelled:''};function renderTask(state){document.querySelector('#title').textContent=state.title||'任务处理中';document.querySelector('#summary').textContent=state.summary||'';const phase=state.phase||'running';document.querySelector('#phase').textContent={running:'运行中',waiting_input:'等待确认',completed:'已完成',failed:'失败',cancelled:'已取消'}[phase]||phase;const percent=Math.max(0,Math.min(100,Number(state.percent)||0));document.querySelector('#progress').style.width=percent+'%';document.querySelector('#percent').textContent=percent+'%';const steps=document.querySelector('#steps');steps.replaceChildren(...(Array.isArray(state.steps)?state.steps:[]).map(step=>{const li=document.createElement('li'),mark=document.createElement('span'),body=document.createElement('div'),label=document.createElement('b'),detail=document.createElement('small'),status=step.status||'pending';mark.className='mark '+status;mark.textContent=symbols[status]||'○';label.textContent=step.title||'未命名步骤';detail.textContent=step.detail||'';body.append(label,detail);li.append(mark,body);return li;}));document.querySelector('#cancel').hidden=state.cancellable===false||['completed','failed','cancelled'].includes(phase);}LineUpSurface.onState(renderTask);document.querySelector('#cancel').onclick=()=>LineUpSurface.event('cancel',{});"
};
}
function surfaceDocument(app) {
const title = escapeHtml(String(app.name || app.id || 'LineUp Surface'));
const html = typeof app.html === 'string' ? app.html : '<main>此界面没有内容。</main>';
const css = typeof app.css === 'string' ? app.css : '';
const javascript = typeof app.js === 'string' ? app.js : '';
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src 'none'; img-src data: blob:; style-src 'unsafe-inline'; script-src 'unsafe-inline'"><title>${title}</title><style>html,body{margin:0;min-height:100%;font-family:system-ui,-apple-system,sans-serif;color:#25231f;background:#fff}button,input,select,textarea{font:inherit}${css}</style></head><body>${html}<script>const LineUpSurface={event:(event,data={})=>parent.postMessage({namespace:'lineup.surface.v1',type:'event',event,data},'*'),ready:()=>parent.postMessage({namespace:'lineup.surface.v1',type:'ready'},'*'),resize:(height)=>parent.postMessage({namespace:'lineup.surface.v1',type:'resize',height},'*'),onState:(handler)=>addEventListener('message',e=>{const d=e.data||{};if(d.namespace==='lineup.surface.v1'&&d.type==='state')handler(d.state||{})})};${javascript};LineUpSurface.ready();<\/script></body></html>`;
}
function isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
function jsonSize(value) { try { return new TextEncoder().encode(JSON.stringify(value)).length; } catch { return Number.POSITIVE_INFINITY; } }
async function sendProtocol(type, payload, conversationID) {
const envelope = { v:1, id:`msg_${crypto.randomUUID ? crypto.randomUUID() : Date.now()}`, type, conversation_id:conversationID || `lineup:${state.channelID}:${state.uid}`, sender:{kind:'human',id:state.uid}, target:{kind:'agent',id:state.agentUID}, timestamp:new Date().toISOString(), payload };
const response = await fetch(`${API}/messages/send`, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({from_uid:state.uid, channel_id:state.channelID, channel_type:state.channelType, payload:JSON.stringify(envelope)})});
if (!response.ok) { const body = await response.json().catch(() => ({})); throw new Error(body.error || '协议消息发送失败'); }
}
function renderSurface(envelope) {
const payload = envelope.payload || {}, requestedApp = payload.app, app = builtInSurface(requestedApp) || requestedApp, instanceID = String(payload.instance_id || '');
if (!instanceID || !/^[A-Za-z0-9._:-]{1,128}$/.test(instanceID) || !isPlainObject(app) || typeof app.id !== 'string' || typeof app.version !== 'string' || jsonSize(app) > MAX_SURFACE_BYTES) { addSystemMsg('Agent 请求的扩展界面无效或超过安全大小限制。'); return; }
closeSurface(instanceID, false);
const row = document.createElement('article'); row.className = 'message-row agent'; const avatar = document.createElement('div'); avatar.className = 'message-avatar'; avatar.textContent = '✦'; const shell = document.createElement('section'); shell.className = 'surface'; shell.setAttribute('aria-label', `${app.name || app.id} 扩展界面`);
const header = document.createElement('div'); header.className = 'surface-header'; const name = document.createElement('strong'); name.textContent = String(app.name || app.id); const close = document.createElement('button'); close.type = 'button'; close.className = 'surface-close'; close.title = '关闭界面'; close.textContent = '×'; close.onclick = () => closeSurface(instanceID, true); header.append(name,close);
const initialState = isPlainObject(payload.state) ? payload.state : {}; const frame = document.createElement('iframe'); frame.className = 'surface-frame'; frame.title = String(app.name || app.id); frame.sandbox = 'allow-scripts'; frame.referrerPolicy = 'no-referrer'; frame.srcdoc = surfaceDocument(app); shell.append(header,frame); row.append(avatar,shell); document.getElementById('messages').append(row); surfaces.set(instanceID, {row,frame,conversationID:envelope.conversation_id,state:initialState});
frame.addEventListener('load', () => frame.contentWindow?.postMessage({namespace:'lineup.surface.v1',type:'state',state:initialState}, '*')); scrollToLatest();
}
function patchSurface(envelope) { const payload = envelope.payload || {}, surface = surfaces.get(String(payload.instance_id || '')); if (!surface) return addSystemMsg('收到一个尚未打开的扩展界面更新。'); if (!isPlainObject(payload.state) || jsonSize(payload.state) > 32 * 1024) return; surface.state = payload.state; surface.frame.contentWindow?.postMessage({namespace:'lineup.surface.v1',type:'state',state:surface.state}, '*'); }
function closeSurface(instanceID, notifyAgent) { const surface = surfaces.get(instanceID); if (!surface) return; surface.row.remove(); surfaces.delete(instanceID); if (notifyAgent) sendProtocol('lineup.v1.ui.event', {instance_id:instanceID,event:'close',data:{}}, surface.conversationID).catch(error => addSystemMsg(`无法通知 Agent 关闭界面:${error.message}`)); }
window.addEventListener('message', event => { const data = event.data; if (!isPlainObject(data) || data.namespace !== 'lineup.surface.v1') return; const entry = [...surfaces.entries()].find(([, surface]) => surface.frame.contentWindow === event.source); if (!entry) return; const [instanceID,surface] = entry;
if (data.type === 'ready') { surface.frame.contentWindow?.postMessage({namespace:'lineup.surface.v1',type:'state',state:surface.state}, '*'); return; }
if (data.type === 'resize') { const height = Number(data.height); if (Number.isFinite(height)) surface.frame.style.height = `${Math.max(120,Math.min(680,height))}px`; return; }
if (data.type !== 'event' || typeof data.event !== 'string' || !/^[a-z][a-z0-9._:-]{0,63}$/i.test(data.event) || (data.data !== undefined && !isPlainObject(data.data)) || jsonSize(data.data || {}) > 16 * 1024) return;
sendProtocol('lineup.v1.ui.event', {instance_id:instanceID,event:data.event,data:data.data || {}}, surface.conversationID).catch(error => addSystemMsg(`扩展界面事件未送达:${error.message}`));
});
function renderAppCall(envelope) { const payload = envelope.payload || {}, callID = String(payload.call_id || ''), capability = String(payload.capability || ''); if (!callID || !capability) return addSystemMsg('收到无效的 App 能力调用。'); const known = new Set(['app.open_url','clipboard.write']); const respond = (status, result = {}, error = '') => sendProtocol('lineup.v1.app.result',{call_id:callID,status,result,error},envelope.conversation_id).catch(err => addSystemMsg(`能力调用结果未送达:${err.message}`)); if (!known.has(capability)) { addSystemMsg(`此客户端不支持 Agent 请求的能力:${capability}`); respond('unsupported',{},'capability_not_registered'); return; }
const row = document.createElement('article'); row.className='message-row agent'; const avatar=document.createElement('div'); avatar.className='message-avatar'; avatar.textContent='✦'; const bubble=document.createElement('div'); bubble.className='message'; const card=document.createElement('div'); card.className='card'; const title=document.createElement('div'); title.className='card-title'; title.textContent=`应用请求:${capability}`; const body=document.createElement('div'); body.className='card-body'; body.textContent=String(payload.reason || 'Agent 请求使用一项本机能力。请确认后再执行。'); const actions=document.createElement('div'); actions.className='card-actions'; const allow=document.createElement('button'); allow.textContent='允许'; const reject=document.createElement('button'); reject.textContent='拒绝'; reject.onclick=()=>{row.remove();respond('rejected',{},'user_rejected');}; allow.onclick=async()=>{ try { const args=isPlainObject(payload.arguments)?payload.arguments:{}; if (capability==='app.open_url') { const url=new URL(String(args.url || '')); if (!['https:','http:'].includes(url.protocol)) throw new Error('仅允许 HTTP(S) 链接'); window.open(url.href,'_blank','noopener,noreferrer'); respond('completed',{opened:true}); } else { await navigator.clipboard.writeText(String(args.text || '')); respond('completed',{written:true}); } row.remove(); } catch(error) { respond('failed',{},String(error.message || error)); } }; actions.append(allow,reject); card.append(title,body,actions); bubble.append(card); row.append(avatar,bubble); document.getElementById('messages').append(row); scrollToLatest(); }
function renderAgentMessage(payload) {
if (!itemFactory || !rendererRegistry) { addSystemMsg('交互渲染层尚未就绪,请稍候。'); return; }
rendererRegistry.render(itemFactory.decode(payload));
}
function renderCard(obj) {
const row = document.createElement('article'); row.className = 'message-row agent'; const avatar = document.createElement('div'); avatar.className = 'message-avatar'; avatar.textContent = '✦'; const bubble = document.createElement('div'); bubble.className = 'message'; if (obj.text) appendMarkdown(bubble,String(obj.text)); const card = document.createElement('div'); card.className = 'card';
const labels = {choice:['🔀 ','请选择'],confirm:['❓ ','请确认'],progress:['⏳ ','处理中'],result:['✓ ','结果'],error:['⚠ ','错误']}; const label = labels[obj.type] || ['','Agent 消息']; const title = document.createElement('div'); title.className = 'card-title'; title.textContent = label[0] + String(obj.title || label[1]); card.append(title);
if (obj.type === 'progress') { const bar = document.createElement('div'); bar.className = 'progress-bar'; const fill = document.createElement('div'); fill.className = 'progress-fill'; fill.style.width = `${Math.max(0,Math.min(100,Number(obj.percent) || 0))}%`; bar.append(fill); card.append(bar); const label = document.createElement('div'); label.className = 'card-body'; label.style.marginTop='7px'; label.textContent = String(obj.status || `${obj.percent || 0}%`); card.append(label); }
else if (obj.type === 'choice' || obj.type === 'confirm') { if (obj.description) { const body = document.createElement('div'); body.className='card-body'; body.textContent=String(obj.description); card.append(body); } const actions = document.createElement('div'); actions.className='card-actions'; const options = obj.type === 'confirm' ? ['确认','取消'] : (obj.options || []); options.forEach(option => { const button = document.createElement('button'); button.type='button'; button.textContent=String(option); button.onclick=() => sendChoiceResponse(String(option)); actions.append(button); }); card.append(actions); }
else if (obj.content) { const body = document.createElement('div'); body.className='card-body'; obj.type === 'result' ? appendMarkdown(body,String(obj.content)) : body.textContent=String(obj.content); card.append(body); }
bubble.append(card); const meta = document.createElement('div'); meta.className='message-meta'; meta.textContent=timeLabel(); bubble.append(meta); row.append(avatar,bubble); document.getElementById('messages').append(row); scrollToLatest();
}
async function sendChoiceResponse(option) { const input = document.getElementById('msgInput'); input.value = option; await sendMsg(); }
function installRendererRegistry() {
if (!window.LineUp) throw new Error('LineUp Interaction Kernel 未加载');
itemFactory = new LineUp.ConversationItemFactory(); rendererRegistry = new LineUp.RendererRegistry();
rendererRegistry
.register('markdown', item => { hideThinking(); addMessage(item.data.markdown, 'agent'); })
.register('agent-status', item => { const {status,detail} = item.data; if (status === 'thinking') showThinking(detail); else if (status === 'idle') { hideThinking(); setPresence('online','在线'); } else { hideThinking(); setPresence(status === 'failed' ? 'offline' : 'online', detail || status || '在线'); } })
.register('progress', item => renderCard({type:'progress', ...item.data}))
.register('agent-error', item => { hideThinking(); addSystemMsg(`Agent 错误:${item.data.message}`); })
.register('legacy-card', item => renderCard(item.data))
.register('ui.open', item => renderSurface(item.envelope))
.register('ui.patch', item => patchSurface(item.envelope))
.register('ui.close', item => closeSurface(String(item.data.instance_id || ''), false))
.register('app-call', item => renderAppCall(item.envelope))
.register('protocol-fallback', item => addSystemMsg(item.data.reason === 'unsupported_type' ? `当前客户端暂不支持 Agent 内容:${item.data.type}` : '收到无法安全展示的 Agent 内容。'));
}
document.addEventListener('DOMContentLoaded', () => { try { installRendererRegistry(); } catch (error) { showLoginError(`初始化交互层失败:${error.message}`); } });
function base64Decode(value) { try { return decodeURIComponent(Array.from(atob(value), c => `%${(`00${c.charCodeAt(0).toString(16)}`).slice(-2)}`).join('')); } catch { try { return atob(value); } catch { return value; } } }
function escapeHtml(value) { return String(value).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); } function sleep(ms) { return new Promise(resolve => setTimeout(resolve,ms)); }
</script>
</body>
</html>
+75
View File
@@ -0,0 +1,75 @@
/*
* Platform-neutral LineUp presentation kernel.
*
* It converts untrusted transport payloads into a small, validated set of
* ConversationItems. UI hosts register trusted renderers for those items;
* neither a renderer nor a UI Surface needs to parse IM payloads directly.
*/
(function exposeLineUpKernel(root, factory) {
const api = factory();
if (typeof module === 'object' && module.exports) module.exports = api;
root.LineUp = Object.assign(root.LineUp || {}, api);
})(typeof globalThis === 'undefined' ? window : globalThis, function createKernel() {
const PROTOCOL_VERSION = 1;
const MAX_PAYLOAD_BYTES = 192 * 1024;
const UI_TYPES = new Set(['lineup.v1.ui.open', 'lineup.v1.ui.patch', 'lineup.v1.ui.close']);
function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
function byteLength(value) { return new TextEncoder().encode(String(value)).length; }
function text(value) { return typeof value === 'string' ? value : ''; }
function item(kind, data, envelope) {
return Object.freeze({ kind, data: Object.freeze(data), envelope: envelope || null, receivedAt: Date.now() });
}
class ConversationItemFactory {
decode(rawPayload) {
const raw = String(rawPayload ?? '');
if (byteLength(raw) > MAX_PAYLOAD_BYTES) return item('protocol-fallback', { reason: 'payload_too_large' });
let value;
try { value = JSON.parse(raw); } catch { return item('markdown', { markdown: raw, source: 'plain' }); }
if (!isObject(value) || typeof value.type !== 'string') return item('markdown', { markdown: raw, source: 'plain' });
if (value.v !== PROTOCOL_VERSION || !value.type.startsWith('lineup.v1.')) return this.legacy(value);
return this.envelope(value);
}
envelope(envelope) {
const payload = isObject(envelope.payload) ? envelope.payload : {};
switch (envelope.type) {
case 'lineup.v1.text':
return item('markdown', { markdown: text(payload.text), source: 'agent' }, envelope);
case 'lineup.v1.agent.status':
return item('agent-status', { status: text(payload.status) || 'unknown', detail: text(payload.detail) }, envelope);
case 'lineup.v1.agent.progress':
return item('progress', { title: text(payload.title) || '处理中', percent: Number(payload.percent) || 0, status: text(payload.status) }, envelope);
case 'lineup.v1.error':
return item('agent-error', { code: text(payload.code), message: text(payload.message) || '未知错误' }, envelope);
case 'lineup.v1.app.call':
return item('app-call', payload, envelope);
default:
if (UI_TYPES.has(envelope.type)) return item(envelope.type.slice('lineup.v1.'.length), payload, envelope);
return item('protocol-fallback', { reason: 'unsupported_type', type: envelope.type }, envelope);
}
}
legacy(value) {
if (value.v === undefined && typeof value.type === 'string') return item('legacy-card', value);
return item('protocol-fallback', { reason: 'unsupported_version', version: value.v, type: value.type });
}
}
class RendererRegistry {
constructor() { this.renderers = new Map(); }
register(kind, renderer) {
if (typeof kind !== 'string' || typeof renderer !== 'function') throw new TypeError('Renderer registration requires a kind and a function');
this.renderers.set(kind, renderer);
return this;
}
render(conversationItem, context) {
const renderer = this.renderers.get(conversationItem.kind) || this.renderers.get('protocol-fallback');
if (!renderer) throw new Error(`No renderer registered for ${conversationItem.kind}`);
return renderer(conversationItem, context);
}
}
return { PROTOCOL_VERSION, MAX_PAYLOAD_BYTES, ConversationItemFactory, RendererRegistry, isObject };
});
+29
View File
@@ -0,0 +1,29 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { ConversationItemFactory, RendererRegistry } = require('./interaction-kernel.js');
test('decodes plain agent output as a markdown conversation item', () => {
const item = new ConversationItemFactory().decode('# 标题\n\n- 一项');
assert.equal(item.kind, 'markdown');
assert.equal(item.data.markdown, '# 标题\n\n- 一项');
});
test('decodes a v1 surface lifecycle message without treating it as a card', () => {
const item = new ConversationItemFactory().decode(JSON.stringify({
v: 1, type: 'lineup.v1.ui.open', conversation_id: 'conv-1',
payload: { instance_id: 'task.01', app: { id: 'com.lineup.task', version: '1.0.0' }, state: { phase: 'running' } },
}));
assert.equal(item.kind, 'ui.open');
assert.equal(item.data.instance_id, 'task.01');
});
test('uses a safe fallback for an unsupported protocol message', () => {
const item = new ConversationItemFactory().decode(JSON.stringify({ v: 1, type: 'lineup.v1.secret.execute', payload: {} }));
assert.equal(item.kind, 'protocol-fallback');
assert.equal(item.data.reason, 'unsupported_type');
});
test('routes only validated conversation items through registered renderers', () => {
const registry = new RendererRegistry().register('markdown', item => item.data.markdown).register('protocol-fallback', () => 'fallback');
assert.equal(registry.render(new ConversationItemFactory().decode('hello')), 'hello');
});
+42
View File
@@ -0,0 +1,42 @@
# LineUp App Server 配置
mode: "debug"
addr: ":8090"
# Trusted cross-origin clients. Browser chat is served same-origin; Tauri's
# WebView origin must be explicit rather than allowing arbitrary web pages.
client:
allowedOrigins:
- "tauri://localhost"
- "https://tauri.localhost"
- "http://tauri.localhost"
# Tauri `dev` loads the trusted Host UI from this fixed local Vite URL.
# Keep it explicit; do not replace this allowlist with a wildcard.
- "http://127.0.0.1:1420"
- "http://localhost:1420"
# Browser preview / fallback ports used when the default dev port is occupied.
- "http://127.0.0.1:1421"
- "http://127.0.0.1:1422"
- "http://localhost:1421"
- "http://localhost:1422"
# Linux development Host, accessed by the Mac browser through Tailscale.
# Keep the exact origin; never replace this allowlist with a wildcard.
- "http://100.121.118.116:1420"
# Same trusted development Host, accessed by phones on the local Wi-Fi.
# Keep this exact LAN origin; never replace this allowlist with a wildcard.
- "http://192.168.3.108:1420"
# WuKongIM 3.0 连接配置
wukongim:
apiURL: "http://127.0.0.1:15001"
wsAddr: "ws://127.0.0.1:15200"
managerToken: "lineup-dev-only"
# 默认 Agent 配置
agent:
# The stable identity used by the real Hermes Adapter.
uid: "agent_hermes_main"
channelID: "agent_default_channel"
channelType: 2
# Set via LINEUP_AGENT_SHARED_SECRET in the deployment environment. Do not
# store its value in this repository; heartbeat is disabled until configured.
shared_secret: ""
+50
View File
@@ -0,0 +1,50 @@
module github.com/lineup/app-server
go 1.25.11
require (
github.com/gin-gonic/gin v1.12.0
github.com/spf13/viper v1.21.0
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
+121
View File
@@ -0,0 +1,121 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+60
View File
@@ -0,0 +1,60 @@
package internal
import (
"fmt"
"sync"
"github.com/gorilla/websocket"
)
// Client represents a connected user WebSocket connection.
type Client struct {
UID string
Conn *websocket.Conn
}
// Hub manages connected clients and their WebSocket connections.
type Hub struct {
mu sync.RWMutex
clients map[string]*Client // uid -> client
}
// NewHub creates a new Hub.
func NewHub() *Hub {
return &Hub{clients: make(map[string]*Client)}
}
// Register adds a client connection.
func (h *Hub) Register(uid string, conn *websocket.Conn) {
h.mu.Lock()
defer h.mu.Unlock()
// Close existing connection if any
if old, ok := h.clients[uid]; ok {
old.Conn.Close()
}
h.clients[uid] = &Client{UID: uid, Conn: conn}
}
// Unregister removes a client connection.
func (h *Hub) Unregister(uid string) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.clients, uid)
}
// GetClient returns a client by uid.
func (h *Hub) GetClient(uid string) *Client {
h.mu.RLock()
defer h.mu.RUnlock()
return h.clients[uid]
}
// PushToClient sends a JSON message to a specific client.
// Returns an error if the client is not connected.
func (h *Hub) PushToClient(uid string, msg []byte) error {
client := h.GetClient(uid)
if client == nil {
return fmt.Errorf("client %s not connected", uid)
}
return client.Conn.WriteMessage(websocket.TextMessage, msg)
}
+214
View File
@@ -0,0 +1,214 @@
package wkapi
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// The WuKongIM access API can briefly reject a foreground write while the
// channel route converges to its current authority. A single client message
// number is idempotent, so retrying the same request is safe.
var sendRetryDelays = []time.Duration{
50 * time.Millisecond,
100 * time.Millisecond,
200 * time.Millisecond,
400 * time.Millisecond,
}
// Client wraps HTTP calls to WuKongIM 3.0 API.
type Client struct {
apiURL string
cli *http.Client
}
// New creates a new WuKongIM API client.
func New(apiURL string) *Client {
return &Client{apiURL: apiURL, cli: &http.Client{}}
}
// TokenResponse is the response from POST /user/token.
type TokenResponse struct {
UID string `json:"uid"`
}
// RegisterToken registers a user token with WuKongIM.
func (c *Client) RegisterToken(uid, token string) error {
body := map[string]interface{}{
"uid": uid,
"token": token,
"device_flag": 0,
"device_level": 1,
}
var resp TokenResponse
return c.post("/user/token", body, &resp)
}
// UpsertChannel creates or updates a channel.
func (c *Client) UpsertChannel(channelID string, channelType uint8, subscribers []string) error {
body := map[string]interface{}{
"channel_id": channelID,
"channel_type": channelType,
}
if len(subscribers) > 0 {
body["subscribers"] = subscribers
}
return c.post("/channel", body, nil)
}
// SubscriberAdd adds subscribers to a channel.
func (c *Client) SubscriberAdd(channelID string, channelType uint8, uids []string) error {
body := map[string]interface{}{
"channel_id": channelID,
"channel_type": channelType,
"reset": 0,
"subscribers": uids,
}
return c.post("/channel/subscriber_add", body, nil)
}
// SendMessageRequest is the request body for POST /message/send.
type SendMessageRequest struct {
FromUID string `json:"from_uid"`
ChannelID string `json:"channel_id"`
ChannelType uint8 `json:"channel_type"`
ClientMsgNo string `json:"client_msg_no"`
Payload string `json:"payload"` // base64-encoded
}
// SendMessageResponse is the response from POST /message/send.
type SendMessageResponse struct {
MessageID int64 `json:"message_id"`
MessageSeq uint64 `json:"message_seq"`
Reason uint8 `json:"reason"`
}
// HTTPStatusError retains an API failure status so callers can distinguish a
// transient route response from a permanent validation or permission error.
type HTTPStatusError struct {
StatusCode int
Body string
}
func (e *HTTPStatusError) Error() string {
return fmt.Sprintf("WK API returned status %d: %s", e.StatusCode, e.Body)
}
// SendMessage sends a message via WuKongIM.
func (c *Client) SendMessage(req SendMessageRequest) (*SendMessageResponse, error) {
for attempt := 0; ; attempt++ {
var resp SendMessageResponse
err := c.post("/message/send", req, &resp)
if err == nil {
return &resp, nil
}
if !isRetryRequired(err) || attempt >= len(sendRetryDelays) {
return nil, err
}
time.Sleep(sendRetryDelays[attempt])
}
}
func isRetryRequired(err error) bool {
var statusErr *HTTPStatusError
return errors.As(err, &statusErr) &&
statusErr.StatusCode == http.StatusServiceUnavailable &&
strings.Contains(statusErr.Body, `"retry required"`)
}
// SyncChannelMessagesRequest is the request body for POST /channel/messagesync.
type SyncChannelMessagesRequest struct {
LoginUID string `json:"login_uid"`
ChannelID string `json:"channel_id"`
ChannelType uint8 `json:"channel_type"`
StartMessageSeq uint64 `json:"start_message_seq"`
Limit int `json:"limit"`
PullMode int `json:"pull_mode"`
}
// LegacyMessageResp is a message in the sync response (v3 legacy format).
type LegacyMessageResp struct {
Header map[string]interface{} `json:"header"`
Setting uint8 `json:"setting"`
MessageID uint64 `json:"message_id"`
ClientMsgNo string `json:"client_msg_no"`
MessageSeq uint64 `json:"message_seq"`
FromUID string `json:"from_uid"`
ChannelID string `json:"channel_id"`
ChannelType uint8 `json:"channel_type"`
Timestamp int32 `json:"timestamp"`
Payload string `json:"payload"` // base64-encoded
}
// SyncChannelMessagesResponse is the response from POST /channel/messagesync.
type SyncChannelMessagesResponse struct {
StartMessageSeq uint64 `json:"start_message_seq"`
EndMessageSeq uint64 `json:"end_message_seq"`
More int `json:"more"`
Messages []LegacyMessageResp `json:"messages"`
}
// SyncChannelMessages syncs messages from a channel.
func (c *Client) SyncChannelMessages(req SyncChannelMessagesRequest) (*SyncChannelMessagesResponse, error) {
var resp SyncChannelMessagesResponse
if err := c.post("/channel/messagesync", req, &resp); err != nil {
return nil, err
}
return &resp, nil
}
// RouteResponse is the response from GET /route.
type RouteResponse struct {
TCPAddr string `json:"tcp_addr"`
WSAddr string `json:"ws_addr"`
WSSAddr string `json:"wss_addr"`
}
// GetRoute gets the WebSocket route from WuKongIM.
func (c *Client) GetRoute() (*RouteResponse, error) {
var resp RouteResponse
if err := c.get("/route", &resp); err != nil {
return nil, err
}
return &resp, nil
}
// --- internal helpers ---
func (c *Client) post(path string, body interface{}, dest interface{}) error {
b, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
resp, err := c.cli.Post(c.apiURL+path, "application/json", bytes.NewReader(b))
if err != nil {
return fmt.Errorf("http post %s: %w", path, err)
}
defer resp.Body.Close()
return c.decodeResponse(resp, dest)
}
func (c *Client) get(path string, dest interface{}) error {
resp, err := c.cli.Get(c.apiURL + path)
if err != nil {
return fmt.Errorf("http get %s: %w", path, err)
}
defer resp.Body.Close()
return c.decodeResponse(resp, dest)
}
func (c *Client) decodeResponse(resp *http.Response, dest interface{}) error {
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return &HTTPStatusError{StatusCode: resp.StatusCode, Body: string(body)}
}
if dest == nil {
return nil
}
return json.NewDecoder(resp.Body).Decode(dest)
}
+69
View File
@@ -0,0 +1,69 @@
package wkapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
)
func TestSendMessageRetriesRetryRequiredWithSameClientMessageNumber(t *testing.T) {
var attempts atomic.Int32
var clientMsgNo string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/message/send" {
t.Fatalf("path = %s, want /message/send", r.URL.Path)
}
var got SendMessageRequest
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
t.Fatalf("decode request: %v", err)
}
if clientMsgNo == "" {
clientMsgNo = got.ClientMsgNo
} else if got.ClientMsgNo != clientMsgNo {
t.Fatalf("client_msg_no = %q, want stable %q", got.ClientMsgNo, clientMsgNo)
}
if attempts.Add(1) <= 2 {
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`{"error":"retry required"}`))
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"message_id":101,"message_seq":7,"reason":1}`))
}))
defer server.Close()
client := New(server.URL)
response, err := client.SendMessage(SendMessageRequest{
FromUID: "user_1", ChannelID: "agent_channel", ChannelType: 2,
ClientMsgNo: "lineup-idempotent-message", Payload: "aGVsbG8=",
})
if err != nil {
t.Fatalf("SendMessage() error = %v", err)
}
if response.MessageID != 101 || response.MessageSeq != 7 {
t.Fatalf("SendMessage() response = %#v", response)
}
if got := attempts.Load(); got != 3 {
t.Fatalf("attempts = %d, want 3", got)
}
}
func TestSendMessageDoesNotRetryPermanentAPIError(t *testing.T) {
var attempts atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attempts.Add(1)
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"invalid request"}`))
}))
defer server.Close()
_, err := New(server.URL).SendMessage(SendMessageRequest{ClientMsgNo: "invalid"})
if err == nil {
t.Fatal("SendMessage() error = nil, want permanent error")
}
if got := attempts.Load(); got != 1 {
t.Fatalf("attempts = %d, want 1", got)
}
}
+90
View File
@@ -0,0 +1,90 @@
package main
import (
"flag"
"fmt"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
"github.com/lineup/app-server/modules"
)
func main() {
var cfgFile string
flag.StringVar(&cfgFile, "config", "configs/lineup.yaml", "config file")
flag.Parse()
vp := viper.New()
vp.SetConfigFile(cfgFile)
if err := vp.ReadInConfig(); err != nil {
fmt.Println("读取配置失败:", err)
os.Exit(1)
}
vp.SetEnvPrefix("LINEUP")
vp.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
vp.AutomaticEnv()
gin.SetMode(gin.ReleaseMode)
if vp.GetString("mode") == "debug" {
gin.SetMode(gin.DebugMode)
}
r := gin.Default()
r.Use(corsMiddleware(vp.GetStringSlice("client.allowedOrigins")))
// 静态文件服务
r.Static("/chat", "./assets/chat")
// Markdown dependencies are served locally so the chat remains available on
// a private network and never executes content from a third-party CDN.
// They intentionally use their own prefix: /chat already owns a wildcard
// static route, which Gin cannot combine with nested exact file routes.
r.Static("/vendor/marked", "./node_modules/marked/lib")
r.Static("/vendor/dompurify", "./node_modules/dompurify/dist")
r.StaticFile("/", "./assets/chat/index.html")
// 注册模块路由
modules.SetupRoutes(r, vp)
fmt.Printf("LineUp App Server 启动 → %s\n", vp.GetString("addr"))
fmt.Printf("WuKongIM API → %s\n", vp.GetString("wukongim.apiURL"))
r.Run(vp.GetString("addr"))
}
// corsMiddleware permits only configured LineUp hosts to call the JSON API.
// The Web reference host is served same-origin and does not need an Origin
// header. Tauri uses its own WebView origin, so it must be explicitly allowed.
func corsMiddleware(origins []string) gin.HandlerFunc {
allowed := make(map[string]struct{}, len(origins))
for _, origin := range origins {
if origin = strings.TrimSpace(origin); origin != "" {
allowed[origin] = struct{}{}
}
}
return func(c *gin.Context) {
origin := c.GetHeader("Origin")
if origin != "" {
if _, ok := allowed[origin]; !ok {
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusForbidden)
return
}
c.Next()
return
}
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-LineUp-Agent-Secret")
c.Header("Vary", "Origin")
}
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
+96
View File
@@ -0,0 +1,96 @@
package modules
import (
"crypto/subtle"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
)
// AgentModule is the app-server-facing control plane for external Agent
// adapters. Message delivery remains in MessageModule; this module makes the
// adapter's liveness and delivery cursor observable without exposing Hermes.
type AgentModule struct {
vp *viper.Viper
mu sync.RWMutex
entries map[string]agentHeartbeat
}
type agentHeartbeat struct {
Status string `json:"status"`
LastMessageSeq uint64 `json:"last_message_seq"`
Detail string `json:"detail,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type agentHeartbeatRequest struct {
Status string `json:"status" binding:"required"`
LastMessageSeq uint64 `json:"last_message_seq"`
Detail string `json:"detail"`
}
func NewAgentModule(vp *viper.Viper) *AgentModule {
return &AgentModule{vp: vp, entries: make(map[string]agentHeartbeat)}
}
func (m *AgentModule) RegisterRoutes(r *gin.Engine) {
r.GET("/agents/:uid/health", m.handleHealth)
r.POST("/agents/:uid/heartbeat", m.handleHeartbeat)
}
func (m *AgentModule) configuredSecret() string {
return m.vp.GetString("agent.shared_secret")
}
func (m *AgentModule) authorize(c *gin.Context) bool {
secret := m.configuredSecret()
if secret == "" {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Agent heartbeat 未配置 shared_secret"})
return false
}
provided := c.GetHeader("X-LineUp-Agent-Secret")
if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Agent heartbeat 认证失败"})
return false
}
return true
}
func (m *AgentModule) handleHeartbeat(c *gin.Context) {
if !m.authorize(c) {
return
}
var req agentHeartbeatRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: status必填"})
return
}
entry := agentHeartbeat{
Status: req.Status, LastMessageSeq: req.LastMessageSeq, Detail: req.Detail, UpdatedAt: time.Now().UTC(),
}
m.mu.Lock()
m.entries[c.Param("uid")] = entry
m.mu.Unlock()
c.JSON(http.StatusOK, gin.H{"status": "ok", "updated_at": entry.UpdatedAt})
}
func (m *AgentModule) handleHealth(c *gin.Context) {
uid := c.Param("uid")
m.mu.RLock()
entry, found := m.entries[uid]
m.mu.RUnlock()
if !found {
c.JSON(http.StatusOK, gin.H{"uid": uid, "status": "offline", "known": false})
return
}
// A worker normally reports once per poll. Keep a generous threshold so a
// slow Hermes turn does not look dead while it is still being supervised.
online := time.Since(entry.UpdatedAt) <= 45*time.Second
c.JSON(http.StatusOK, gin.H{
"uid": uid, "status": entry.Status, "known": true, "online": online,
"last_message_seq": entry.LastMessageSeq, "detail": entry.Detail, "updated_at": entry.UpdatedAt,
})
}
+47
View File
@@ -0,0 +1,47 @@
package modules
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/spf13/viper"
)
func TestAgentHeartbeatRequiresConfiguredSecret(t *testing.T) {
gin.SetMode(gin.TestMode)
vp := viper.New()
vp.Set("agent.shared_secret", "test-secret")
router := gin.New()
NewAgentModule(vp).RegisterRoutes(router)
body := []byte(`{"status":"running","last_message_seq":42}`)
unauthorized := httptest.NewRequest(http.MethodPost, "/agents/agent_hermes_main/heartbeat", bytes.NewReader(body))
unauthorized.Header.Set("Content-Type", "application/json")
unauthorizedResult := httptest.NewRecorder()
router.ServeHTTP(unauthorizedResult, unauthorized)
if unauthorizedResult.Code != http.StatusUnauthorized {
t.Fatalf("unauthorized status = %d, want %d", unauthorizedResult.Code, http.StatusUnauthorized)
}
heartbeat := httptest.NewRequest(http.MethodPost, "/agents/agent_hermes_main/heartbeat", bytes.NewReader(body))
heartbeat.Header.Set("Content-Type", "application/json")
heartbeat.Header.Set("X-LineUp-Agent-Secret", "test-secret")
heartbeatResult := httptest.NewRecorder()
router.ServeHTTP(heartbeatResult, heartbeat)
if heartbeatResult.Code != http.StatusOK {
t.Fatalf("heartbeat status = %d, body = %s", heartbeatResult.Code, heartbeatResult.Body.String())
}
health := httptest.NewRequest(http.MethodGet, "/agents/agent_hermes_main/health", nil)
healthResult := httptest.NewRecorder()
router.ServeHTTP(healthResult, health)
if healthResult.Code != http.StatusOK {
t.Fatalf("health status = %d, body = %s", healthResult.Code, healthResult.Body.String())
}
if !bytes.Contains(healthResult.Body.Bytes(), []byte(`"online":true`)) {
t.Fatalf("health body = %s, want online=true", healthResult.Body.String())
}
}
+75
View File
@@ -0,0 +1,75 @@
package modules
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/lineup/app-server/internal/wkapi"
)
// ChannelModule handles channel creation and query.
type ChannelModule struct {
wk *wkapi.Client
}
// NewChannelModule creates a new ChannelModule.
func NewChannelModule(wk *wkapi.Client) *ChannelModule {
return &ChannelModule{wk: wk}
}
// RegisterRoutes registers channel routes.
func (m *ChannelModule) RegisterRoutes(r *gin.Engine) {
r.POST("/channels", m.handleCreate)
r.GET("/channels/:channel_id", m.handleGet)
r.POST("/channels/subscriber_add", m.handleSubscriberAdd)
}
type createChannelRequest struct {
ChannelID string `json:"channel_id" binding:"required"`
ChannelType uint8 `json:"channel_type" binding:"required"`
Subscribers []string `json:"subscribers"`
}
func (m *ChannelModule) handleCreate(c *gin.Context) {
var req createChannelRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: channel_id和channel_type必填"})
return
}
if err := m.wk.UpsertChannel(req.ChannelID, req.ChannelType, req.Subscribers); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建频道失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"channel_id": req.ChannelID,
"channel_type": req.ChannelType,
})
}
func (m *ChannelModule) handleGet(c *gin.Context) {
channelID := c.Param("channel_id")
_ = channelID
c.JSON(http.StatusOK, gin.H{
"channel_id": channelID,
"channel_type": 2,
})
}
type subscriberAddRequest struct {
ChannelID string `json:"channel_id" binding:"required"`
ChannelType uint8 `json:"channel_type" binding:"required"`
UIDs []string `json:"uids" binding:"required"`
}
func (m *ChannelModule) handleSubscriberAdd(c *gin.Context) {
var req subscriberAddRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: channel_id, channel_type, uids必填"})
return
}
if err := m.wk.SubscriberAdd(req.ChannelID, req.ChannelType, req.UIDs); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "添加订阅者失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
+88
View File
@@ -0,0 +1,88 @@
package modules
import (
"encoding/json"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/lineup/app-server/internal"
"github.com/lineup/app-server/internal/wkapi"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// ClientModule manages user WebSocket connections for real-time push.
type ClientModule struct {
hub *internal.Hub
wk *wkapi.Client
}
// NewClientModule creates a new ClientModule.
func NewClientModule(hub *internal.Hub, wk *wkapi.Client) *ClientModule {
return &ClientModule{hub: hub, wk: wk}
}
// RegisterRoutes registers WebSocket routes.
func (m *ClientModule) RegisterRoutes(r *gin.Engine) {
r.GET("/ws", m.handleWS)
}
func (m *ClientModule) handleWS(c *gin.Context) {
token := c.Query("token")
uid := c.Query("uid")
if token == "" || uid == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: token和uid必填"})
return
}
// Verify token with WuKongIM (register if not exists, or validate)
if err := m.wk.RegisterToken(uid, token); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Token验证失败: " + err.Error()})
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("WebSocket升级失败: %v", err)
return
}
m.hub.Register(uid, conn)
log.Printf("客户端连接: uid=%s", uid)
// Send a welcome message
welcome, _ := json.Marshal(map[string]interface{}{
"event": "connected",
"uid": uid,
})
conn.WriteMessage(websocket.TextMessage, welcome)
// Read loop (keep connection alive, handle pings)
go m.readLoop(uid, conn)
}
func (m *ClientModule) readLoop(uid string, conn *websocket.Conn) {
defer func() {
m.hub.Unregister(uid)
conn.Close()
log.Printf("客户端断开: uid=%s", uid)
}()
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
}
+143
View File
@@ -0,0 +1,143 @@
package modules
import (
"encoding/base64"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/lineup/app-server/internal/wkapi"
)
// MessageModule handles message sending and syncing.
type MessageModule struct {
wk *wkapi.Client
}
// NewMessageModule creates a new MessageModule.
func NewMessageModule(wk *wkapi.Client) *MessageModule {
return &MessageModule{wk: wk}
}
// RegisterRoutes registers message routes.
func (m *MessageModule) RegisterRoutes(r *gin.Engine) {
r.POST("/messages/send", m.handleSend)
r.GET("/messages/sync", m.handleSync)
r.POST("/messages/sync", m.handleSyncPost)
}
type sendMessageRequest struct {
FromUID string `json:"from_uid" binding:"required"`
ChannelID string `json:"channel_id" binding:"required"`
ChannelType uint8 `json:"channel_type" binding:"required"`
Payload string `json:"payload" binding:"required"` // raw text, will be base64-encoded
}
func (m *MessageModule) handleSend(c *gin.Context) {
var req sendMessageRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: from_uid, channel_id, channel_type, payload必填"})
return
}
// Base64-encode the payload for WuKongIM
encoded := base64.StdEncoding.EncodeToString([]byte(req.Payload))
resp, err := m.wk.SendMessage(wkapi.SendMessageRequest{
FromUID: req.FromUID,
ChannelID: req.ChannelID,
ChannelType: req.ChannelType,
ClientMsgNo: fmt.Sprintf("msg_%d", time.Now().UnixNano()),
Payload: encoded,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "发送消息失败: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message_id": resp.MessageID,
"message_seq": resp.MessageSeq,
})
}
type syncMessagesRequest struct {
ChannelID string `form:"channel_id" binding:"required"`
ChannelType uint8 `form:"channel_type" binding:"required"`
StartMessageSeq uint64 `form:"start_message_seq"`
Limit int `form:"limit"`
}
func (m *MessageModule) handleSync(c *gin.Context) {
var req syncMessagesRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: channel_id, channel_type必填"})
return
}
m.doSync(c, req.ChannelID, req.ChannelType, req.StartMessageSeq, req.Limit, "")
}
type syncMessagesPostRequest struct {
ChannelID string `json:"channel_id" binding:"required"`
ChannelType uint8 `json:"channel_type" binding:"required"`
LoginUID string `json:"login_uid"`
StartMessageSeq uint64 `json:"start_message_seq"`
Limit int `json:"limit"`
}
func (m *MessageModule) handleSyncPost(c *gin.Context) {
var req syncMessagesPostRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: channel_id, channel_type必填"})
return
}
m.doSync(c, req.ChannelID, req.ChannelType, req.StartMessageSeq, req.Limit, req.LoginUID)
}
func (m *MessageModule) doSync(c *gin.Context, channelID string, channelType uint8, startSeq uint64, limit int, loginUID string) {
if limit <= 0 {
limit = 20
}
resp, err := m.wk.SyncChannelMessages(wkapi.SyncChannelMessagesRequest{
LoginUID: loginUID,
ChannelID: channelID,
ChannelType: channelType,
StartMessageSeq: startSeq,
Limit: limit,
PullMode: 1,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "同步消息失败: " + err.Error()})
return
}
// Decode base64 payloads for the response
type msgItem struct {
MessageID uint64 `json:"message_id"`
MessageSeq uint64 `json:"message_seq"`
FromUID string `json:"from_uid"`
ChannelID string `json:"channel_id"`
Payload string `json:"payload"`
Timestamp int32 `json:"timestamp"`
}
messages := make([]msgItem, 0, len(resp.Messages))
for _, m := range resp.Messages {
decoded, _ := base64.StdEncoding.DecodeString(m.Payload)
messages = append(messages, msgItem{
MessageID: m.MessageID,
MessageSeq: m.MessageSeq,
FromUID: m.FromUID,
ChannelID: m.ChannelID,
Payload: string(decoded),
Timestamp: m.Timestamp,
})
}
c.JSON(http.StatusOK, gin.H{
"start_message_seq": resp.StartMessageSeq,
"end_message_seq": resp.EndMessageSeq,
"more": resp.More,
"messages": messages,
})
}
+58
View File
@@ -0,0 +1,58 @@
package modules
import (
"github.com/gin-gonic/gin"
"github.com/lineup/app-server/internal"
"github.com/lineup/app-server/internal/wkapi"
"github.com/spf13/viper"
)
// SetupRoutes 注册所有模块路由
func SetupRoutes(r *gin.Engine, vp *viper.Viper) {
// 共享依赖
hub := internal.NewHub()
wk := wkapi.New(vp.GetString("wukongim.apiURL"))
// 自动创建 Agent 频道
agentChannelID := vp.GetString("agent.channelID")
agentUID := vp.GetString("agent.uid")
agentChannelType := uint8(vp.GetInt("agent.channelType"))
if agentChannelType == 0 {
agentChannelType = 2
}
if agentChannelID != "" && agentUID != "" {
if err := wk.UpsertChannel(agentChannelID, agentChannelType, []string{agentUID}); err != nil {
// ignore upsert error, channel may already exist
_ = err
}
}
// 健康检查
r.GET("/healthz", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok"})
})
// 用户模块
userModule := &UserModule{vp: vp, wk: wk}
userModule.RegisterRoutes(r)
// Channel 模块
channelModule := NewChannelModule(wk)
channelModule.RegisterRoutes(r)
// Message 模块
messageModule := NewMessageModule(wk)
messageModule.RegisterRoutes(r)
// Agent 控制面:外部 Hermes Adapter 的存活与消费游标。
agentModule := NewAgentModule(vp)
agentModule.RegisterRoutes(r)
// Webhook 模块
webhookModule := NewWebhookModule(hub)
webhookModule.RegisterRoutes(r)
// Client WebSocket 模块
clientModule := NewClientModule(hub, wk)
clientModule.RegisterRoutes(r)
}
+95
View File
@@ -0,0 +1,95 @@
package modules
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/lineup/app-server/internal/wkapi"
"github.com/spf13/viper"
)
// UserModule 用户模块: 登录 / 注册
type UserModule struct {
vp *viper.Viper
wk *wkapi.Client
}
// RegisterRoutes 注册路由
func (m *UserModule) RegisterRoutes(r *gin.Engine) {
r.POST("/login", m.handleLogin)
}
// LoginRequest 登录请求
type LoginRequest struct {
Phone string `json:"phone" binding:"required"`
Code string `json:"code" binding:"required"`
}
// LoginResponse 登录响应
type LoginResponse struct {
UID string `json:"uid"`
Token string `json:"token"`
IMAddr string `json:"im_addr"` // WuKongIM WS地址
AgentUID string `json:"agent_uid"` // Agent的UID
ChannelID string `json:"channel_id"` // Agent频道ID
ChannelType uint8 `json:"channel_type"` // Agent频道类型
}
// handleLogin 处理登录请求
func (m *UserModule) handleLogin(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: phone和code必填"})
return
}
// 生成用户 UID
uid := fmt.Sprintf("user_%s", req.Phone)
// 生成 Token (随机32字节)
token := generateToken()
// 注册 Token 到 WuKongIM
if err := m.wk.RegisterToken(uid, token); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("注册Token失败: %v", err)})
return
}
// 确保 Agent 频道存在并将用户加入订阅者
agentChannelID := m.vp.GetString("agent.channelID")
agentUID := m.vp.GetString("agent.uid")
agentChannelType := uint8(m.vp.GetInt("agent.channelType"))
if agentChannelType == 0 {
agentChannelType = 2
}
// 创建/更新频道,确保 Agent 和用户都是订阅者
m.wk.UpsertChannel(agentChannelID, agentChannelType, []string{agentUID})
// 将用户加入频道订阅者
m.wk.SubscriberAdd(agentChannelID, agentChannelType, []string{uid})
// 获取 WebSocket 地址
route, err := m.wk.GetRoute()
wsAddr := m.vp.GetString("wukongim.wsAddr")
if err == nil && route.WSAddr != "" {
wsAddr = route.WSAddr
}
c.JSON(http.StatusOK, LoginResponse{
UID: uid,
Token: token,
IMAddr: wsAddr,
AgentUID: m.vp.GetString("agent.uid"),
ChannelID: m.vp.GetString("agent.channelID"),
ChannelType: uint8(m.vp.GetInt("agent.channelType")),
})
}
// generateToken 生成32字节随机Token
func generateToken() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
+151
View File
@@ -0,0 +1,151 @@
package modules
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/gin-gonic/gin"
"github.com/lineup/app-server/internal"
)
// WebhookModule receives WuKongIM 3.0 HTTP webhook callbacks.
type WebhookModule struct {
hub *internal.Hub
}
// NewWebhookModule creates a new WebhookModule.
func NewWebhookModule(hub *internal.Hub) *WebhookModule {
return &WebhookModule{hub: hub}
}
// RegisterRoutes registers webhook routes.
func (m *WebhookModule) RegisterRoutes(r *gin.Engine) {
r.POST("/v1/webhook", m.handleWebhook)
}
// wkMsgResp mirrors WuKongIM 3.0's msg.notify message format.
type wkMsgResp struct {
Header wkMsgHeader `json:"header"`
Setting uint8 `json:"setting"`
MessageID uint64 `json:"message_id"`
MessageSeq uint64 `json:"message_seq"`
FromUID string `json:"from_uid"`
ChannelID string `json:"channel_id"`
ChannelType uint8 `json:"channel_type"`
Timestamp int32 `json:"timestamp"`
Payload string `json:"payload"` // base64-encoded
}
type wkMsgHeader struct {
NoPersist int `json:"no_persist"`
RedDot int `json:"red_dot"`
SyncOnce int `json:"sync_once"`
}
// wkOfflineResp mirrors WuKongIM 3.0's msg.offline format.
type wkOfflineResp struct {
wkMsgResp
ToUIDs []string `json:"to_uids"`
Compress string `json:"compress"`
CompressToUIDs string `json:"compress_to_uids"`
SourceID int64 `json:"source_id"`
}
func (m *WebhookModule) handleWebhook(c *gin.Context) {
event := c.Query("event")
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取请求体失败"})
return
}
switch event {
case "msg.notify":
m.handleMsgNotify(body)
case "msg.offline":
m.handleMsgOffline(body)
case "user.onlinestatus":
m.handleOnlineStatus(body)
default:
// Unknown event, still return 200 per WK webhook contract
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
func (m *WebhookModule) handleMsgNotify(data []byte) {
var msgs []wkMsgResp
if err := json.Unmarshal(data, &msgs); err != nil {
return
}
for _, msg := range msgs {
m.pushToClient(msg.ChannelID, msg)
}
}
func (m *WebhookModule) handleMsgOffline(data []byte) {
var msg wkOfflineResp
if err := json.Unmarshal(data, &msg); err != nil {
return
}
// Push to each offline recipient
for _, uid := range msg.ToUIDs {
m.pushToClient(uid, msg.wkMsgResp)
}
// Handle gzip-compressed UIDs
if msg.Compress == "gzip" && len(msg.CompressToUIDs) > 0 {
decoded, err := base64.StdEncoding.DecodeString(msg.CompressToUIDs)
if err == nil {
var uids []string
if json.Unmarshal(decoded, &uids) == nil {
for _, uid := range uids {
m.pushToClient(uid, msg.wkMsgResp)
}
}
}
}
}
func (m *WebhookModule) handleOnlineStatus(data []byte) {
// Online status format: ["uid-flag-status", ...]
var statuses []string
if err := json.Unmarshal(data, &statuses); err != nil {
return
}
// For now, just log. Could be used to notify clients.
_ = statuses
}
// pushToClient decodes the payload and pushes to the connected client.
func (m *WebhookModule) pushToClient(uid string, msg wkMsgResp) {
if m.hub == nil {
return
}
decoded, _ := base64.StdEncoding.DecodeString(msg.Payload)
pushMsg := map[string]interface{}{
"event": "message",
"message_id": msg.MessageID,
"message_seq": msg.MessageSeq,
"from_uid": msg.FromUID,
"channel_id": msg.ChannelID,
"channel_type": msg.ChannelType,
"payload": string(decoded),
"timestamp": msg.Timestamp,
}
pushData, _ := json.Marshal(pushMsg)
if err := m.hub.PushToClient(uid, pushData); err != nil {
// Client not connected, that's OK
_ = err
}
// Also push to the other participant if it's a person channel
// This ensures both sides get the message
if msg.ChannelType == 1 && msg.FromUID != uid {
_ = m.hub.PushToClient(msg.FromUID, pushData)
}
}
// Ensure fmt is used (for potential future logging)
var _ = fmt.Sprintf
+67
View File
@@ -0,0 +1,67 @@
{
"name": "lineup-app-server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lineup-app-server",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"dompurify": "^3.4.12",
"marked": "^15.0.12",
"ws": "^8.21.1"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/dompurify": {
"version": "3.4.12",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.12.tgz",
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/marked": {
"version": "15.0.12",
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/ws": {
"version": "8.21.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "lineup-app-server",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "node --test assets/chat/*.test.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"dompurify": "^3.4.12",
"marked": "^15.0.12",
"ws": "^8.21.1"
}
}
Executable
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Minimal local supervisor for the LineUp app server. It is intentionally the
# only process manager for this binary; the Hermes Adapter has its own script.
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
BIN="$DIR/bin/lineup-server"
CONFIG="$DIR/configs/lineup.yaml"
LOG="$DIR/appserver.log"
PIDFILE="$DIR/lineup-server.pid"
RUNTIME_ENV="$DIR/.env"
# Deployment secrets live beside the service, never in lineup.yaml. Viper maps
# LINEUP_AGENT_SHARED_SECRET to agent.shared_secret automatically.
if [[ -f "$RUNTIME_ENV" ]]; then
set -a
# shellcheck disable=SC1090
source "$RUNTIME_ENV"
set +a
fi
is_running() {
[[ -f "$PIDFILE" ]] && kill -0 "$(<"$PIDFILE")" 2>/dev/null
}
start() {
if is_running; then
echo "LineUp App Server 已在运行 PID=$(<"$PIDFILE")"
return
fi
cd "$DIR"
nohup setsid "$BIN" -config "$CONFIG" >> "$LOG" 2>&1 < /dev/null &
echo "$!" > "$PIDFILE"
echo "LineUp App Server 已启动 PID=$(<"$PIDFILE")"
}
stop() {
if is_running; then
kill "$(<"$PIDFILE")"
echo "LineUp App Server 已停止"
fi
rm -f "$PIDFILE"
}
case "${1:-start}" in
start) start ;;
stop) stop ;;
restart) stop; start ;;
status) is_running && echo "LineUp App Server 运行中 PID=$(<"$PIDFILE")" || echo "LineUp App Server 未运行" ;;
*) echo "用法: $0 {start|stop|restart|status}"; exit 2 ;;
esac