Compare commits

..

15 Commits

Author SHA1 Message Date
719f586900 fix(webui): activity spine bright in both themes and CSP-safe dots 2026-07-24 11:06:25 +08:00
54ad4617c7 chore(release): P0 webui foundation 2026-07-24 10:51:35 +08:00
d9e3245031 feat(webui): refactor chat page onto global client and Signal Deck 2026-07-24 10:26:05 +08:00
14ca5dd7b4 feat(webui): app shell with flat nav, activity spine, and theme init 2026-07-24 10:01:07 +08:00
c64984b496 fix(webui): reset connected flag on manual disconnect 2026-07-24 09:51:59 +08:00
8dc5889b87 fix(webui): activity spine effect loop and chat client socket race 2026-07-24 09:47:52 +08:00
79821e11d5 feat(webui): global activity spine 2026-07-24 09:33:48 +08:00
33dab85c1c feat(webui): global chat websocket client 2026-07-24 09:33:17 +08:00
47e497867f feat(webui): theme helpers 2026-07-24 09:32:52 +08:00
fbffc67638 fix(webui): light-theme contrast and token consistency 2026-07-24 09:27:46 +08:00
be9fe07c27 feat(webui): Signal Deck design tokens and base styles 2026-07-24 09:18:25 +08:00
3118083707 feat(webui): embed latin fonts and serve via /fonts route 2026-07-24 09:06:48 +08:00
83d1d792bb docs: add WebUI refactor spec and P0 implementation plan 2026-07-24 08:54:43 +08:00
f7891806ab chore: ignore .superpowers brainstorm artifacts 2026-07-24 08:54:12 +08:00
0d61354ba1 feat: add gateway configuration hot reload 2026-07-21 12:59:46 +08:00
39 changed files with 3375 additions and 222 deletions

1
.gitignore vendored
View File

@ -8,4 +8,5 @@ reference/**
*.env
Cargo.lock
.worktrees/
.superpowers/
design

View File

@ -8,6 +8,7 @@ This file is the operational contract for coding agents working in this reposito
- `cargo run -- gateway` — start gateway server (binds `127.0.0.1:19876` by default)
- `cargo run -- chat` — connect to gateway as CLI client (default `ws://127.0.0.1:19876/ws`)
- `cargo run -- run "prompt"` — send one prompt through Gateway, print the terminal Turn, and exit; stdin, JSON, verbose progress, and timeout modes are available
- `cargo run -- reload` — validate and gracefully reload a running Gateway's configuration
- `docker compose up -d` — start the container with Gateway bound/published on `0.0.0.0:19876`; override `PICOBOT_GATEWAY_HOST`, `PICOBOT_PUBLISH_HOST`, or `PICOBOT_GATEWAY_PORT` as needed
- WebUI — start Gateway, then open `http://127.0.0.1:19876/`; no separate frontend build is required
- `cd webui && npm ci && npm run check && npm run build` — validate the Svelte WebUI independently (Node.js 20+); its local `dist/` is ignored
@ -91,6 +92,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
- **Scheduler** supports legacy direct-delivery jobs and managed `task`/`monitor` jobs; managed agents cannot call `send_message`, and `on_alert` suppresses only healthy informational results
- **AgentLoop** is stateless across turns; it receives prepared history, calls LLM providers, executes tools, and returns one result
- **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact
- **Gateway config reload** validates and prepares a complete next runtime generation before retiring the old one; close runtime admission before draining inbound lanes, Sessions, Scheduler jobs, and background sub-agents; MCP connects only during activation; host/port, workspace, and effective storage path remain restart-only, and runtime reload must never mutate process environment variables
- **WebUI slash completion** must consume the existing `get_slash_commands` WebSocket response; do not duplicate the backend command list in frontend source
- **WebUI chat rendering** sanitizes Markdown before inserting HTML; durable `turn_committed` deltas calibrate normal terminal Turns without a full history reload, and history must preserve structured tool-call metadata so calls and results remain independently collapsible
- **WebUI/TUI file transfer** streams bytes over authenticated HTTP and sends only short-lived upload IDs/attachment metadata over WebSocket; messages persist local media paths without guaranteeing later availability, and client responses must never expose those paths

View File

@ -1,6 +1,6 @@
[package]
name = "picobot"
version = "1.2.2"
version = "1.4.0"
edition = "2024"
[dependencies]

View File

@ -175,7 +175,7 @@ WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件
- 本地滚动日志的尾部查看、过滤和自动刷新。
- `config.json``~/.picobot/USER.md``~/.picobot/AGENTS.md` 编辑。
配置接口会掩码 API Key、secret、password 和 token保留 `********` 再保存不会覆盖原密钥。运行配置采用原子写入并在 Gateway 重启后生效,`USER.md``AGENTS.md` 则会用于后续构建的 Agent 上下文。
配置接口会掩码 API Key、secret、password 和 token保留 `********` 再保存不会覆盖原密钥。运行配置采用原子写入,保存后可执行 `picobot reload` 或发送 `/reload` 热重载;`USER.md``AGENTS.md` 的修改用于后续构建的 Agent 上下文。
WebUI 默认启用设备配对鉴权,管理 API 与 `/ws` 都拒绝未配对客户端;静态配对页、公开健康检查和配对提交接口除外。唯一的 WebSocket 例外是本机 `picobot run`:请求必须同时来自真实回环对端并持有权限为 `0600``~/.picobot/web_admin_token`,该管理令牌不能绕过任何管理 API 的设备鉴权。配对令牌只以 SHA-256 哈希写入 `~/.picobot/web_auth.json`。鉴权不提供传输加密;如果通过 `--host 0.0.0.0`、反向代理或端口转发暴露 Gateway仍必须使用 TLS。可通过 `gateway.require_pairing=false` 显式关闭配对,但不建议在非隔离环境使用。
@ -210,6 +210,14 @@ picobot service stop
picobot service uninstall
```
修改配置后无需重启 systemd service
```bash
picobot reload
```
该命令连接正在运行的 Gateway先解析并校验新配置再停止接收新工作等待当前交互 Turn、Scheduler job 和后台子 Agent 到达安全边界后切换运行代。也可在聊天中发送 `/reload`,或让根交互 Agent 在用户明确要求时调用 `reload_config` 工具;子 Agent 与定时任务不能触发重载。监听地址、workspace 和数据库路径涉及进程级资源,不能热重载;修改这些字段时命令会保留旧配置并提示使用 `picobot service restart`。重载会主动断开 WebSocketTUI/WebUI 随后可重新连接并从持久化历史恢复。受认证客户端可通过 `GET /api/config/reload/status` 查询 generation、切换阶段和最近错误。
unit 位于 `~/.config/systemd/user/picobot.service`,以执行 `service install` 时的当前目录作为初始工作目录。服务异常退出时由 systemd 自动重启;`stop``restart` 会通过 SIGTERM 触发 Gateway 的有界优雅关停。
TUI 会把一个随机客户端标识保存到 `~/.picobot/tui_client_id`,因此关闭并重新打开客户端后会恢复同一组 dialog 和最近使用的会话。界面支持流式正文、独立思考与工具状态、历史回放、会话列表与归档筛选、命令补全、Unicode/中文编辑、括号粘贴、多行输入和文件传输。
@ -292,6 +300,7 @@ Session ID 使用三段式:
| `/mcp` | 查看 MCP 服务器和工具状态 |
| `/stop` | 停止当前任务并清空队列 |
| `/todo [done\|cancel]` | 查看、完成或取消当前 session 的任务计划 |
| `/reload` | 校验并重新加载 Gateway 配置 |
| `/?`, `/help` | 查看帮助 |
### 记忆
@ -318,6 +327,7 @@ PicoBot 有两类记忆:
| `http_request` / `web_fetch` | HTTP 请求和网页文本抽取 |
| `get_skill` | 列出或读取本地 Skill |
| `memory_store` / `memory_recall` / `timeline_recall` / `memory_forget` | 长期记忆操作 |
| `reload_config` | 在用户明确要求时校验并重新加载 Gateway 配置 |
| `delegate` | 启动 inline、background 或 parallel 子 Agent |
| `todo` | 为复杂、多轮任务创建并更新当前 session 的持久化计划 |
| `send_message` | 向指定渠道或当前会话发送消息,可附带文件/截图WebUI/TUI 当前 Turn 的附件并入最终回复 |
@ -472,6 +482,7 @@ docs/ 面向维护者和 Agent 的架构与开发文档
## 进一步阅读
- [维护者架构文档](docs/ARCHITECTURE.md)
- [配置热重载设计与实现](docs/CONFIG_HOT_RELOAD_DESIGN.md)
- [WebUI 与 TUI 文件收发设计](docs/FILE_TRANSFER_DESIGN.md)
- [内置 Skill架构机制](resources/skills/about-picobot/references/architecture.md)
- [配置说明](resources/skills/about-picobot/references/config.md)

View File

@ -69,6 +69,7 @@ fn build_webui(out_dir: &Path) {
"webui/package-lock.json",
"webui/svelte.config.js",
"webui/vite.config.js",
"webui/public",
] {
println!("cargo:rerun-if-changed={path}");
}

View File

@ -3,7 +3,7 @@ services:
build:
context: .
dockerfile: Dockerfile
image: picobot:1.2.2
image: picobot:1.3.0
container_name: picobot-test
restart: unless-stopped
ports:

View File

@ -2,7 +2,7 @@
本文档描述 PicoBot 当前实现的运行时边界、数据流、并发模型和演进约束。它面向维护者和后续参与改进的 Agent是代码架构的主入口行为细节仍以代码和测试为最终依据。
流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递的详细设计与取舍见 [STREAMING_TURN_DESIGN.md](STREAMING_TURN_DESIGN.md)。用户输入路由、Session 执行拆分、终态投递确认和历史增量校准的重构方案见 [MESSAGE_FLOW_REFACTOR_DESIGN.md](MESSAGE_FLOW_REFACTOR_DESIGN.md)。
流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递的详细设计与取舍见 [STREAMING_TURN_DESIGN.md](STREAMING_TURN_DESIGN.md)。用户输入路由、Session 执行拆分、终态投递确认和历史增量校准的重构方案见 [MESSAGE_FLOW_REFACTOR_DESIGN.md](MESSAGE_FLOW_REFACTOR_DESIGN.md)。配置运行代、重载边界和失败语义见 [CONFIG_HOT_RELOAD_DESIGN.md](CONFIG_HOT_RELOAD_DESIGN.md)。
## 1. 设计目标
@ -28,13 +28,15 @@ PicoBot 只有一个二进制,提供三种运行模式:
Linux 上可通过 `picobot service install/start/stop/status/restart/uninstall` 管理 systemd 用户服务。unit 固定为 `picobot.service`,其主进程仍是普通 Gateway 模式,不引入额外 daemon/fork 层;异常退出由 systemd 按 `Restart=on-failure` 拉起。
`picobot reload``/reload` 和根交互 Agent 的 `reload_config` 工具共享 Gateway 内部的有界重载控制通道。重载先用启动前捕获的进程环境重新解析配置和 `.env`,构造完整的下一运行代;候选构造失败时旧运行代不变。成功后关闭旧代 admission等待已进入的消息、交互 Turn、Scheduler job 和后台子 Agent 到达持久化/投递边界,再停止旧渠道和受监督任务、主动关闭旧 WebSocket并在保留的监听 socket 上启动新运行代。MCP 只在新代激活时连接。每次重载有 generation ID可通过 `GET /api/config/reload/status` 查询相位。监听 host/port、workspace 和 SQLite 有效路径属于进程级不变量,变更时拒绝热重载并要求完整重启。
原生 Gateway 默认绑定 `127.0.0.1:19876``gateway.host`/`gateway.port` 可由命令行 `--host`/`--port` 覆盖。Docker Compose 为保证端口映射可达,默认向容器传入 `0.0.0.0:19876``PICOBOT_GATEWAY_HOST` 控制容器内监听地址,`PICOBOT_PUBLISH_HOST` 控制宿主机发布地址,`PICOBOT_GATEWAY_PORT` 同时控制监听与映射端口。
CLI TUI 在 `~/.picobot/tui_client_id` 保存非敏感客户端标识,并通过 WebSocket 查询参数 `client_id` 传给 Gateway。`cli_chat` 以该标识作为稳定 chat scope重连时恢复内存中的当前 dialogGateway 重启后则恢复该 scope 最近活跃的未归档 dialog。无效或缺失的标识会退化为连接级随机 scope。
One-shot client 不绕过 Gateway 直接调用 Provider。每次 `run` 生成独立的 `run-<uuid>` scope通过相同的 `cli_chat`、MessageBus、SessionManager、AgentLoop 和 Turn delivery 路径执行;它不复用 TUI scope因而不会替换同一 scope 的活动 WebSocket。默认 stdout 只投影终态 Assistant blocks进度写到 stderr超时或 Ctrl-C 会先在当前 scope 发送 `/stop`
Gateway 启动时先从配置目录 `.env`、workspace `.env` 和既有进程环境合并启动变量,再初始化日志并切换进程工作目录到 `workspace_dir`。优先级为进程环境 > workspace `.env` > 配置目录 `.env`;配置目录层先用于定位 workspaceworkspace 层不得重定向自身位置。环境文件只在单线程启动阶段写入进程环境,不能移到后台任务启动之后。切换完成后所有相对路径都应按 workspace 解释,不能假设仍位于源码仓库。
Gateway 启动时先从配置目录 `.env`、workspace `.env` 和既有进程环境合并启动变量,再初始化日志并切换进程工作目录到 `workspace_dir`。优先级为进程环境 > workspace `.env` > 配置目录 `.env`;配置目录层先用于定位 workspaceworkspace 层不得重定向自身位置。环境文件只在单线程启动阶段写入进程环境,不能移到后台任务启动之后。重载使用启动前保存的进程环境快照解析各层,但不再修改进程环境,避免多线程运行期调用 `set_var`;新 Provider、MCP 和渠道使用解析后配置中的值。切换完成后所有相对路径都应按 workspace 解释,不能假设仍位于源码仓库。
## 3. 组件关系
@ -253,7 +255,7 @@ WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输WebSocket 只
同源 `/api/*` 管理接口只提供显式白名单能力:
- 配置读取/原子写入;响应中的密钥字段统一掩码,原样提交掩码会恢复现有值。运行配置只在重启后生效,不热替换运行中组件
- 配置读取/原子写入;响应中的密钥字段统一掩码,原样提交掩码会恢复现有值。`POST /api/config/reload` 通过同一重载控制器校验并切换 Gateway 运行代,`GET /api/config/reload/status` 查询 generation、相位与最近错误
- `USER.md``AGENTS.md` 只允许固定文件名,不接受任意路径。
- 日志、记忆、任务和运行记录均限制单次返回数量;日志目录固定为 `~/.picobot/logs`
- 任务与记忆读取复用 Storage API不允许 WebUI 直接持有 SQLite 连接或拼接任意查询。

View File

@ -0,0 +1,389 @@
# PicoBot 配置热重载设计与实现
本文档描述 PicoBot 1.3.0 配置热重载功能的设计目标、运行时模型、实现边界、失败语义和维护要求。代码与测试是最终事实来源;本文用于解释为什么采用当前方案,以及后续修改必须保持哪些不变量。
## 1. 背景
PicoBot 的配置并非只在一个全局对象中读取。Gateway 启动时会把配置拆分并复制到多个长生命周期组件:
- `SessionManager`、现有 `Session`、子 Agent 和 Scheduler 持有 Provider/模型配置。
- `ChannelManager` 按配置创建并启动飞书、CLI Chat 等 Channel。
- MCP 配置在启动时用于连接 Server并把发现的工具注册到 `ToolRegistry`
- Browser、文件上传、鉴权、后台任务并发度等配置在各自组件构造时固化。
- Gateway 的监听 socket、进程 cwd 和 SQLite 连接具有进程级生命周期。
因此,简单地重新读取 `config.json` 或替换一个 `Config` 指针并不能可靠生效。这样会造成请求处理组件混用新旧配置,例如新会话使用新模型、旧 Session 仍使用旧 Provider或者配置显示飞书已禁用但旧连接仍在接收消息。
当前实现采用“运行代runtime generation切换”先在旧运行代仍然服务时解析、校验并构造完整候选运行代候选可用后排空当前交互工作再回收旧运行代并激活新运行代。
## 2. 目标与非目标
### 2.1 目标
- 提供统一的 `picobot reload``/reload``reload_config` 工具入口。
- 在停止旧运行代前完成候选配置解析、关键字段校验和依赖构造。
- 配置错误或候选构造失败时继续使用旧运行代,不中断服务。
- 尽量让正在执行及已经排队的交互 Turn 完成,避免热重载直接截断发起重载的 Turn。
- 重新创建所有启动期固化配置的组件,使 Provider、Channel、MCP、Scheduler、Browser、鉴权和上传策略一致地切换。
- 保持监听 socket不释放端口避免切换期间被其他进程抢占。
- 保持运行期环境变量操作线程安全:热重载不得调用 `std::env::set_var`
- 为所有入口提供相同的校验、排队和错误语义。
### 2.2 非目标
- 不支持热变更监听地址、workspace 或 SQLite 路径。
- 不承诺 WebSocket 连接无感迁移;切换会主动关闭旧连接,客户端需要重连。
- 不实现 nginx 式新旧 worker 长时间并行处理连接。PicoBot 是单进程、单 Gateway 运行代模型,采用保留 socket 的顺序切换。
- 不动态修改已经启动进程的环境变量;`.env` 新值只用于重新解析配置占位符和显式组件配置。
- 不把“请求已接受”解释为“切换已经完成”。触发方在候选运行代构造成功后收到响应,实际切换在排空阶段之后发生。
- 不赋予本地 admin token 调用管理 API 的新权限;重载 HTTP API 遵循现有设备鉴权边界。
## 3. 核心设计Gateway 运行代
### 3.1 生命周期结构
Gateway 进程拥有两层生命周期:
```text
进程生命周期
├── 固定配置路径
├── 启动前进程环境快照
├── 启动 cwd
├── 原始监听 socket
├── ReloadController
└── 当前 Gateway 运行代(可替换)
├── GatewayState
├── SessionManager / Session workers
├── MessageBus / routers / outbound dispatcher
├── ChannelManager / Channel connections
├── Scheduler / MCP / tools
├── AuthManager / UploadRegistry
├── Axum Router / WebSocket connections
└── TaskSupervisor
```
进程级资源在 `gateway::run()` 外层只创建一次;运行代资源由 `GatewayState::from_config()` 重新构造。
### 3.2 为什么保留监听 socket
`gateway::run()` 首次启动时创建一个非阻塞 `std::net::TcpListener`,并在整个进程生命周期内持有它。每个运行代通过 `try_clone()` 获得一个 Tokio listener 交给 Axum。
切换时旧 Axum serve future 停止接受连接并退出,但原始 listener 仍然占有地址。旧运行代清理完成后,新运行代再克隆同一个 listener 开始接受连接。这样可以:
- 避免重新 bind 失败或端口被其他进程抢占。
- 保留内核 listen backlog短暂切换窗口中的新 TCP 连接可能排队等待新运行代接收。
- 允许 Axum Router、鉴权 middleware 和 WebSocket state 随运行代完整替换。
这不是零停顿切换。旧运行代停止和受监督任务回收期间没有 Axum accept loop当前回收宽限期上限为 10 秒,通常会更短。
## 4. 重载控制通道
重载协调类型位于 `src/gateway/reload.rs`
- `ReloadHandle`:可克隆的请求端,注入 SessionManager、工具和 Gateway HTTP state。
- `ReloadController`:由 `gateway::run()` 独占,持有请求 receiver、启动环境快照和启动 cwd。
- `ReloadRequest`:包含 generation ID 与 oneshot response用于把候选校验/构造结果返回触发方。
- `ReloadStatus`:记录 `preparing``draining``activating``active``failed` 相位、时间与最近错误。
控制通道使用容量为 8 的 Tokio MPSC 队列,并用原子 pending 标记保证同一时间最多只有一次重载。`ReloadHandle::request()` 使用 `try_send`
- 已有重载尚未进入 `active``failed` 终态时立即返回 `another configuration reload is already pending`
- Gateway 正在退出、receiver 已关闭时立即返回 `gateway is shutting down`
- 请求成功入队后等待对应 oneshot 结果。
有界队列避免错误调用或模型重复调用形成无界重载积压;并发请求不会排队形成连续运行代切换,而是收到明确冲突错误。
## 5. 三种触发入口
三个入口只负责鉴权、参数适配和结果展示,最终都调用同一个 `ReloadHandle::request()`
| 入口 | 实现 | 行为 |
|------|------|------|
| `picobot reload` | `src/main.rs``client::reload_gateway()` | 把 WebSocket/HTTP Gateway URL 转为 HTTP base URL使用已保存的 TUI bearer token 调用 `POST /api/config/reload` |
| `/reload` | `SessionManager::execute_slash_command()` | 通过普通 slash command 路由执行,不进入 Agent 队列;结果作为 command output 返回当前 Channel |
| `reload_config` | `ReloadConfigTool` | 仅注册到根交互 Agent无参数、独占执行描述要求仅在用户明确要求重载时调用。子 Agent、Cron 和 managed scheduled Agent 无权获得该工具 |
`POST /api/config/reload` 返回 accepted generation`GET /api/config/reload/status` 返回当前重载相位和最近错误。并发 `POST` 返回 409Gateway 退出或候选准备失败返回 503配置或不可变字段错误返回 400。
HTTP 路由属于现有 protected Router因此
- pairing 关闭时沿用 `PairingDisabled` 身份。
- pairing 开启时需要已配对的 bearer/cookie 凭据。
- 本地 `web_admin_token` 仍只允许用于既有的 loopback WebSocket 特例,不能绕过管理 API 鉴权。
pairing 开启但本机尚未保存 TUI token 时,`picobot reload` 会收到 HTTP 401应先完成现有配对流程或从一个已认证的聊天/WebUI 连接触发 `/reload`
`GatewayState::new()` 只构造独立 state没有运行代主循环因此其中的 reload handle 明确不可用。正式 Gateway 必须通过 `gateway::run()` 启动,才能执行热重载。
## 6. 端到端时序
```mermaid
sequenceDiagram
participant Caller as CLI / Slash / Tool
participant RC as ReloadController
participant Old as Old GatewayState
participant New as Candidate GatewayState
participant HTTP as Axum / Listener
Caller->>RC: ReloadHandle::request()
RC->>RC: 重新读取 config + .env
RC->>RC: 校验 default agent、飞书凭据、不可变字段
RC->>New: GatewayState::from_config(candidate)
alt 解析、校验或构造失败
RC-->>Caller: Error
Note over Old: 旧运行代继续服务
else 候选构造成功
RC->>Old: 关闭 admission拒绝新工作
RC-->>Caller: Accepted + generation / 等待当前任务后切换
RC->>Old: 等待 inbound、Session、Scheduler 与后台 Agent 排空(最多 60s
RC->>Old: cancel WebSocket connections
RC->>HTTP: graceful shutdown 当前 serve future
RC->>Old: stop_all channels
RC->>Old: TaskSupervisor shutdown(10s)
RC->>New: start_all channels + message processing
RC->>HTTP: 从保留 listener 创建新 serve future
end
```
### 6.1 准备阶段
准备阶段在旧运行代继续提供服务时执行:
1. `load_candidate()` 重新读取当前 Gateway 启动时确定的配置文件。
2. 使用启动环境快照和启动 cwd 解析 `.env`、占位符与相对 workspace。
3. 校验 default agent 能解析为完整 `LLMProviderConfig`
4. 若飞书启用,校验 `app_id``app_secret` 非空。
5. 比较不可热变更字段。
6. 调用 `GatewayState::from_config()` 构造候选运行代。
候选构造会重新创建 Storage handle、MemoryManager、MessageBus、ChannelManager、SessionManager、工具集、MCP 配置 wrapper、AuthManager 和 UploadRegistry。外部 Channel、MCP 连接、消息 routers、outbound dispatcher 和 Scheduler 在候选成为当前运行代前不会激活。`from_config(..., false)` 也不会再次修改进程 cwd 或释放默认配置文件。
MCP 的真实连接位于激活阶段,避免准备候选时启动双份 stdio 子进程或提前覆盖进程级 `MCP_SERVER_STATUS`。单个 MCP Server 连接失败沿用启动语义:记录错误并跳过其工具,不会让整个运行代激活失败。
SessionManager 构造期注册的通知消费者和周期清理任务已经归候选 `TaskSupervisor` 所有,但在激活前没有候选消息入口;周期清理也会跳过首次 interval tick。
### 6.2 接受响应
候选运行代构造成功并关闭旧代 admission 后Controller 通过 oneshot 返回 generation 与消息:
```text
配置校验通过Gateway 将在当前任务结束后切换到新配置。
```
此响应表示候选配置已经通过准备阶段,不表示切换完成。先返回响应有两个原因:
- `/reload` 的 command output 需要通过旧运行代发送给用户。
- `reload_config` 工具需要返回 tool result让发起它的 Agent Turn 正常完成。
### 6.3 排空阶段
每个运行代有一个 `RuntimeAdmission`。Inbound 在进入会话 lane 前获取 activity guard因此已经进入 lane 的消息也计入排空;关闭 admission 后新消息不再进入会话处理,并尽量收到“正在重新加载”提示。`/reload` 的 command output 使用 outbound delivery acknowledgementguard 只有在回复实际投递成功或明确失败后才释放,不再依赖固定 sleep。
`SessionManager::wait_until_idle()` 同时检查所有已加载 Session
- `current_cancel.is_some()` 表示当前有 Agent Turn 正在执行。
- Session MPSC sender 的剩余容量小于最大容量,表示仍有排队任务。
- 必须连续空闲 100ms 才认为稳定,避免 worker 刚取出任务、尚未设置 `current_cancel` 的竞态窗口。
Scheduler 在领取和执行任务前检查 admission已执行任务持有 guard 到结果提交与投递完成;后台子 Agent 同样持有 guard。最长统一等待 60 秒。超时不会撤销已经接受的重载,而是记录 warning 并继续回收旧运行代;未完成工作随后会被取消。
Axum 自身会优雅等待已进入 handler 的 HTTP 请求;其 graceful shutdown 另有 10 秒硬上限,超过后 abort serve task。未纳入 admission 的维护型后台任务由 `TaskSupervisor` 的 10 秒有界关停负责。
### 6.4 切换与回收阶段
切换顺序是:
1. 取消旧 `connection_shutdown`,使 WebSocket handler 主动退出。
2. 取消当前 Axum generation shutdown token停止接受新请求并等待已进入的请求完成。
3. 调用旧 `ChannelManager::stop_all()`,停止外部消息入口。
4. 取消旧 `TaskSupervisor`,最多等待 10 秒,超时任务被 abort。
5. 把候选 state 设为当前 state。
6. 启动候选 Channel、MCP、消息处理循环、outbound dispatcher 和 Scheduler。
7. 从原始 listener 克隆新 Tokio listener构建并运行新 Axum Router。
旧 WebSocket 不跨运行代迁移。TUI/WebUI 重连后按原有 client scope 从 SQLite 恢复 dialog 和历史;运行中的内存 Session 不直接搬迁到新 SessionManager。
## 7. 配置与环境变量语义
### 7.1 启动加载
正常启动使用以下优先级解析配置:
```text
进程启动环境 > workspace/.env > config目录/.env
```
启动时合并的 `.env` 值会在单线程阶段写入进程环境,供后续 MCP 和工具子进程继承。
### 7.2 热重载加载
Gateway 在首次调用 `Config::load_from()` 前保存:
- 原始进程环境 `startup_process_env`
- 切换 workspace 前的 `startup_cwd`
热重载调用 `Config::load_for_reload()`
- 重新读取 config 目录 `.env` 和 workspace `.env`
- 继续以原始启动环境作为最高优先级,避免启动时注入进程环境的旧 `.env` 值错误覆盖新文件。
- 相对 `workspace_dir` 始终相对于启动 cwd 解析,不受 Gateway 已经 `chdir(workspace)` 影响。
- 只解析得到候选配置,不调用 `env::set_var`,避免多线程进程中修改全局环境。
因此,`.env` 的修改会影响配置中的 `<VAR_NAME>` 占位符和由配置显式传入的新组件。它不会改变现有进程环境;仅依赖继承环境、但没有通过配置显式传值的 Shell/子进程仍会看到启动时环境。需要变更这类继承环境时应完整重启 Gateway。
## 8. 热重载边界
### 8.1 可通过新运行代生效的配置
以下配置消费者会随 `GatewayState` 重建:
| 配置区域 | 新运行代中的效果 |
|----------|------------------|
| `providers``models``agents` | 新 SessionManager、Session、主 Agent、子 Agent 和 Scheduler Agent 使用新 Provider/模型参数 |
| `channels` | ChannelManager 重新创建并启动已启用 Channelallowlist、凭据、媒体和实时投递策略更新 |
| `mcp` | 重新连接 MCP Server并重新生成工具注册表 |
| `browser` | 根据新配置注册或移除 Browser 工具 |
| `memory` | 重建 MemoryManager维护任务使用新的 retention 配置 |
| `gateway.scheduler` | 启用、关闭或按新并发/轮询/超时参数创建 Scheduler |
| `gateway.max_concurrent_background_tasks` | 新 SubAgentManager 使用新的并发上限 |
| `gateway.file_transfer` | 新 UploadRegistry 和 WebSocket capability 使用新限制 |
| `gateway.require_pairing` | 新 Router/AuthManager 使用新鉴权要求;已有 WebSocket 在切换时断开 |
配置中尚未被运行时代码消费的字段,在热重载后仍然不会产生功能效果。例如当前 `session_ttl_hours``cleanup_interval_minutes` 只完成了解析,尚未接入 Session 清理逻辑。
`client.gateway_url` 是 CLI 侧配置:`picobot reload` 在发请求前读取它来确定目标 Gateway但它不是 Gateway 运行代设置。
### 8.2 必须完整重启的配置
| 字段 | 原因 |
|------|------|
| `gateway.host``gateway.port` | 原始监听 socket 在进程生命周期内固定;热重载不会重新 bind |
| `workspace_dir` | Gateway 已修改进程 cwd工具路径、媒体路径和相对文件语义均依赖它 |
| `gateway.session_db_path` | Storage、SessionManager、Scheduler 和历史恢复必须共享同一个数据库身份 |
| 未显式进入配置组件的继承环境变量 | 热重载禁止运行期修改进程全局环境 |
候选值与当前值不一致时,`load_candidate()` 返回错误,并明确提示重启 Gateway。`workspace_dir` 在比较前按启动 cwd 解析并 canonicalize通过校验后会被归一化为当前绝对 workspace 路径,避免候选构造时受当前 cwd 影响。
## 9. 原子性与失败语义
这里的“原子”是指请求处理组件的配置可见性:旧运行代不会被逐项改造成半套新配置。它不是数据库事务,也不是两个 worker 的瞬时指针交换;进程级 MCP status 的提前更新是下文记录的已知例外。
| 失败阶段 | 行为 |
|----------|------|
| 已有 pending 重载/控制器关闭 | 分别返回 409/503不读取配置 |
| JSON、`.env`、占位符或默认 Agent 校验失败 | 返回错误,旧运行代保持不变 |
| 不可热变更字段发生变化 | 返回 restart-required 错误,旧运行代保持不变 |
| `GatewayState::from_config()` 构造失败 | 返回错误;候选被丢弃,其 TaskSupervisor 随对象释放取消;旧请求处理运行代保持不变 |
| 单个 MCP Server 连接或工具发现失败 | 记录 MCP 失败状态,候选继续构造且不注册该 Server 的工具;这不视为整体 reload 失败 |
| 等待交互空闲超过 60 秒 | 记录 warning继续切换旧运行中的剩余工作可能被取消 |
| 旧 Channel 停止失败 | 记录 error继续回收其他组件和切换 |
| 旧受监督任务 10 秒内未退出 | TaskSupervisor abort 剩余任务,继续切换 |
| 候选激活阶段 `start_all()` 失败 | `gateway::run()` 返回错误;若由 systemd 管理,则按 service restart policy 重启 |
准备阶段成功后才向调用者返回 accepted。激活阶段仍可能遇到运行时错误因此调用者不应把 accepted 当作健康检查;可使用 `GET /api/config/reload/status` 等待相同 generation 进入 `active`,并结合 `/health` 与客户端重连确认。
WebUI `PUT /api/config` 只负责原子写文件、恢复被掩码的 secret 并返回 `restart_required: true`;它不会隐式触发重载。显式的 `POST /api/config/reload` 将文件写入与运行代切换解耦,使用户可以批量编辑后主动决定生效时机。
## 10. 并发与生命周期不变量
维护或扩展热重载时必须保持以下约束:
1. 只有 `gateway::run()` 拥有 reload receiver 和当前运行代,其他组件只能持有 `ReloadHandle`
2. 候选构造不得修改旧 `GatewayState`,也不得提前连接 MCP 或替换旧 MessageBus、Channel、ToolRegistry、MCP status 或 Router。
3. 不得在热重载路径调用 `env::set_var`;环境文件只允许在单线程首次启动时安装到进程环境。
4. 不得释放原始 listener 后再尝试 bind 同一地址。
5. 旧 WebSocket 必须观察 `connection_shutdown`,不能在新鉴权/配置运行代启动后继续无限存活。
6. 旧长生命周期任务必须由旧 `TaskSupervisor` 回收;候选任务必须由候选 Supervisor 所有。
7. 排空检查不得长时间持有 `SessionManagerInner` 或 Session mutex当前实现先复制 Session Arc再逐一短暂检查。
8. reload tool 必须保持 exclusive 且只对根交互 Agent 可见,避免后台或子 Agent 触发进程级切换。
9. admission 必须在会话 lane 入队前获取Slash 回复、Scheduler job 和后台子 Agent 必须持有 guard 到其持久化/投递边界。
10. 不可热变更字段的比较必须按有效路径归一化,并发生在候选运行代构造之前。
11. 新增启动期配置消费者时,应确认它是否由 `GatewayState::from_config()` 重建,并更新本文的热重载边界表。
## 11. 关键实现位置
| 文件/符号 | 职责 |
|-----------|------|
| `src/gateway/reload.rs` | 重载请求通道、候选配置加载、不可变字段校验 |
| `src/gateway/mod.rs::run` | 持有 listener、当前运行代和切换主循环 |
| `src/gateway/mod.rs::GatewayState::from_config` | 构造一套完整运行代依赖 |
| `src/config/mod.rs::Config::load_for_reload` | 使用启动环境/cwd 安全重新解析配置,不修改进程环境 |
| `src/session/session.rs::wait_until_idle` | 检查活动 Turn、Session 队列和稳定空闲窗口 |
| `src/session/session.rs::execute_slash_command` | `/reload` 入口 |
| `src/tools/reload_config.rs` | Agent 可调用的独占重载工具 |
| `src/gateway/http.rs::reload_config` | 受保护的 `POST /api/config/reload` |
| `src/client/mod.rs::reload_gateway` | CLI HTTP 客户端与 bearer token 注入 |
| `src/main.rs::Command::Reload` | `picobot reload` CLI 定义 |
## 12. 测试策略
当前回归测试覆盖:
- 候选配置允许 Provider/模型等运行时字段变化。
- 相对 `workspace_dir` 按启动 cwd 正确解析。
- workspace 变化被拒绝且返回明确错误。
- `None``./picobot.db` 指向同一有效数据库路径时允许重载。
- admission 关闭后拒绝新工作,并等待现有 activity guard 释放。
- command output 在 dispatcher 明确确认投递前不会释放处理任务。
- 真实子进程 Gateway 可完成 generation 2 切换;无效候选返回 400 且旧代 `/health` 继续可用。
- `/reload` alias 能解析到规范命令。
- 全量 Rust 单元测试验证 Gateway、Session、Channel、鉴权和 TaskSupervisor 既有行为。
- 离线协议集成测试验证 slash/WebSocket 相关协议没有退化。
- Clippy、Cargo build 和 WebUI check/build 验证完整构建链。
后续适合增加的集成测试:
1. 使用可控假 Provider 验证新 model 被下一 Turn 实际使用。
2. 在长 Turn 中调用 `reload_config`,验证 tool result 和最终消息投递后才断开。
3. Session 队列有积压时验证重载等待队列排空。
4. 排空超时、Channel stop 超时和候选激活失败的故障注入。
5. 重载前后鉴权策略变化以及旧 WebSocket 失效。
## 13. 运维使用
修改配置并保存后执行:
```bash
picobot reload
```
连接非默认 Gateway
```bash
picobot reload --gateway-url https://gateway.example.com
```
也可以在支持 slash command 的聊天中发送:
```text
/reload
```
若配置修改涉及监听地址、workspace、数据库路径或必须进入进程继承环境的变量应执行完整重启
```bash
picobot service restart
```
建议的运维流程是:
1. 原子保存配置文件。
2. 执行 reload 并检查返回是否为候选已接受。
3. 使用已认证请求轮询 `GET /api/config/reload/status`,确认返回的 generation 进入 `active`
4. 等待客户端重连,检查 `/health` 和关键 Channel。
5. 若激活失败,由 systemd 重启或人工恢复配置后再次启动。
## 14. 已知限制与演进方向
- 旧运行代回收与新运行代激活是顺序执行,存在短暂 accept/Channel intake 空窗。
- WebSocket 需要客户端自行重连,服务端没有连接迁移协议。
- 候选激活失败没有自动回滚到已经回收的旧运行代,依赖 systemd restart 或人工恢复。
- 同一时间只允许一个 pending 重载;并发请求返回 409不做合并或排队。
- `.env` 不能热修改进程继承环境。
- admission 覆盖消息入口、Scheduler 与后台子 Agent其他维护型任务仍依赖 TaskSupervisor 的有界关停。
可能的后续演进包括:
- 将 Channel 激活前检查拆成显式 `prepare()`,把更多运行时失败提前到旧运行代仍可回退的阶段。
- 在不破坏 Channel/Session 边界的前提下,引入动态 Router service实现新旧 HTTP generation 短期重叠。
- 为 TUI/WebUI 增加 reload 完成通知和自动重连状态提示。

View File

@ -0,0 +1,369 @@
# 配置热重载功能审核报告
> 状态:审查完成;主要意见已于 2026-07-21 落地。本文前六节保留首次审查快照,行号、测试数和“当前实现”描述可能已过时;以下处置表与代码为最新结论。
>
> 本文是对 `docs/CONFIG_HOT_RELOAD_DESIGN.md` 描述的配置热重载功能及其当前未提交实现的综合审核。审核覆盖架构合理性、实现一致性、关键不变量和改进建议;具体行为以代码和测试为最终依据。相关实现位于 `src/gateway/reload.rs``src/gateway/mod.rs::run``src/config/mod.rs::load_for_reload``src/session/session.rs::wait_until_idle``src/tools/reload_config.rs``src/gateway/http.rs::reload_config`
## 0. Review 意见处置与实现结果
| 意见 | 处置 | 结论与实现 |
|------|------|------------|
| A1 / E1 MCP 准备期副作用 | 接收 | `connect_all()` 移到运行代激活阶段,候选构造不再启动双份 MCP 或提前覆盖全局 status。 |
| A2 / E2 generation 与状态查询 | 接收 | 每次请求分配 generation新增 `GET /api/config/reload/status`,状态为 `preparing/draining/activating/active/failed`。 |
| A3 激活失败回滚 | 部分接收 | 接收“需要明确失败状态”,但驳回先启动新 Channel 再停旧 Channel。飞书长连接双开会重复消费事件风险高于短暂切换空窗完整 prepare/activate 或可恢复旧代留作后续。 |
| A4 / L4 Agent 工具风险 | 部分接收 | 保留用户要求的 `reload_config`,但只允许根交互 Agent 使用;子 Agent、Cron、managed scheduled Agent 均剔除该工具,并保持 exclusive。驳回直接删除工具。 |
| A5 / E5 后台任务排空 | 接收 | 引入运行代 admission/activity guardScheduler 已执行 job 与后台子 Agent 持有 guard新任务在 drain 后不再进入。 |
| I1 / E4 Slash 回复可能丢失 | 接收 | `/reload` 所在 inbound 在 lane 入队前持有 guardcommand output 改为显式等待 outbound delivery acknowledgement删除固定 500ms sleep。 |
| I2 / F2 DB 路径误判 | 接收 | 比较归一化后的有效路径,`None``./picobot.db` 可判为同一数据库。 |
| I3 零散预校验 | 部分接收 | 保留 default agent 与 Feishu 的快速错误提示;其余组件统一由候选 `from_config()` 构造验证,不继续扩张 ad-hoc 字段校验。长期采用显式 prepare contract。 |
| I4 HTTP 错误码 | 接收 | pending 返回 409退出/准备故障返回 503配置与不可变字段错误返回 400。错误改为 `ReloadError` 类型。 |
| I5 候选双 Storage | 驳回为正确性缺陷 | 同库多连接是 sqlx/SQLite 的正常模式,候选无消息入口;路径身份仍被强制保持一致。该点保留为测试与锁竞争观察项,而非阻止上线。 |
| F1 集成测试 | 部分接收 | 新增真实 Gateway 子进程测试,覆盖成功切换到 generation 2、状态查询、无效候选不影响旧代健康。可控假 Provider、长 Turn 和故障注入仍待补充。 |
| L1 prepare/activate 分离 | 方向接收 | 本轮已把 MCP activation 与候选构造分离;完整组件级接口留作后续架构演进。 |
| L2 动态 Router | 暂缓 | 当前保留 listener 并为 Axum graceful shutdown 增加 10 秒硬上限;不为本功能引入动态 service 复杂度。 |
| L3 客户端完成通知 | 暂缓 | 后端 generation/status 已具备TUI/WebUI 展示可在后续独立实现。 |
首次 Review 未指出、但本轮一并修复的两个关键问题:候选构造原先直接在 `select!` 分支内 await会暂停轮询旧 Axum serve 与进程信号;现在 serve 独立受监督运行,准备和排空阶段都继续响应服务退出。其次,原实现排空前没有关闭入口,持续新消息可能让排空永不稳定;现在 admission 先关闭再 drain。
## 1. 审查范围与依据
### 1.1 审查对象
- 设计文档:`docs/CONFIG_HOT_RELOAD_DESIGN.md`
- 实现:当前未提交的 17 个文件变更与 2 个新增文件(`src/gateway/reload.rs``src/tools/reload_config.rs`),共 ~304 行净增
- 关联变更:`Cargo.toml``webui/package.json` 版本号 1.2.2 → 1.3.0`README.md``AGENTS.md``docs/ARCHITECTURE.md``resources/skills/about-picobot/references/config.md` 文档同步
### 1.2 审查依据
- 仓库既有架构边界与并发不变量(见 `docs/ARCHITECTURE.md`
- 现有相似机制Channel 生命周期、TaskSupervisor、TurnController、OutboundDispatcher
- 验证命令:`cargo build``cargo clippy --all-targets --all-features -- -D warnings``cargo test --lib`330 passed`webui && npm run check && npm run build`
### 1.3 验证结果
| 命令 | 结果 |
|------|------|
| `cargo build` | 通过 |
| `cargo clippy --all-targets --all-features -- -D warnings` | 通过 |
| `cargo test --lib` | 330 passed / 0 failed |
| `cd webui && npm run check && npm run build` | 0 errors / 0 warnings |
## 2. 架构审查
### 2.1 整体架构评估
**结论架构方向正确运行代runtime generation切换模型是 PicoBot 配置散落现状下的唯一可靠方案。**
PicoBot Gateway 启动时把配置拆分复制到 `SessionManager``ChannelManager`、MCP、Scheduler、Browser、Auth、Upload 等长生命周期组件;`gateway.host/port`、进程 cwd、SQLite 连接具有进程级生命周期。在这种结构下,任何"原地替换 Config 指针"的方案都会导致请求处理组件混用新旧配置(新会话用新模型、旧 Session 仍用旧 Provider或飞书配置显示禁用但旧连接仍在接收消息
运行代切换通过"先在旧代仍服务时构造完整候选代;候选可用后排空当前交互工作,再回收旧代并激活新代"避免了半套配置暴露。这一选择与 PicoBot 单进程、单 Gateway 模型契合,不引入 daemon/fork 层。
### 2.2 运行代模型合理性
运行代模型的关键设计点均合理:
| 设计点 | 评估 |
|--------|------|
| 保留原始 `std::net::TcpListener`,每代 `try_clone()` | ✅ 避免 bind 失败与端口抢占,保留内核 backlog |
| 候选构造期间不修改旧 `GatewayState` | ✅ 避免半套配置暴露 |
| `Config::load_for_reload` 使用启动环境快照、不调 `set_var` | ✅ 多线程运行期修改进程环境的危险被正确规避 |
| 不可变字段host/port/workspace/db_path边界清晰 | ✅ 边界划分正确 |
| 三个入口CLI、`/reload``reload_config` 工具)共享控制通道 | ✅ 单一校验路径,语义一致 |
| 拒绝 nginx 式新旧 worker 长期并行 | ✅ 单进程规模不值得这份复杂度 |
| `ReloadHandle` 用有界 MPSC + `try_send` | ✅ 无界积压被正确拒绝 |
### 2.3 边界划分评估
热重载边界表(设计文档第 8 节)覆盖完整:
- 可热重载:`providers`/`models`/`agents``channels``mcp``browser``memory``gateway.scheduler``gateway.max_concurrent_background_tasks``gateway.file_transfer``gateway.require_pairing`
- 必须重启:`gateway.host`/`port``workspace_dir``gateway.session_db_path`、未进入配置组件的继承环境变量
边界划分与实现中 `load_candidate()` 的校验项一一对应。`workspace_dir` 在比较前按启动 cwd 解析并 canonicalize`reload.rs:80-89`),与 `from_config``ensure_workspace_dir` 的 canonicalize 行为一致,比较基准正确。
### 2.4 并发不变量评估
设计文档第 10 节列出的 10 条不变量在实现中均得到遵守:
| 不变量 | 实现位置 | 遵守情况 |
|--------|----------|----------|
| 只有 `run()` 拥有 receiver 与当前运行代 | `mod.rs:331` ReloadController 在 `run()` 内创建 | ✅ |
| 候选构造不修改旧 GatewayState | `mod.rs:399` `from_config` 创建独立 state | ✅ |
| 不在热重载路径调 `env::set_var` | `config/mod.rs:611` `apply_to_process=false` | ✅ |
| 不释放原始 listener 后 rebind | `mod.rs:345` listener 在 `run()` 内持有 | ✅ |
| 旧 WebSocket 观察 `connection_shutdown` | `mod.rs:421` 切换前 cancel | ✅ |
| 旧任务由旧 TaskSupervisor 回收 | `mod.rs:432-436` 旧 supervisor shutdown | ✅ |
| 排空检查不长时间持有 Session mutex | `session.rs:2113-2124` 先克隆 Arc 再短锁 | ✅ |
| reload tool 保持 exclusive | `reload_config.rs:49` `exclusive: true` | ✅ |
| 不可变字段比较在候选构造之前 | `reload.rs:80-100` | ✅ |
| 新增启动期配置消费者需更新边界表 | 文档约束 | ⚠️ 维护性约束,无机制强制 |
## 3. 实现审查
### 3.1 与设计文档的一致性
实现与设计文档的关键路径高度一致:
| 设计文档章节 | 实现位置 | 一致性 |
|--------------|----------|--------|
| §6.1 准备阶段load_candidate → from_config | `mod.rs:386-408` | ✅ |
| §6.2 接受响应:候选构造成功后通过 oneshot 返回 | `mod.rs:410-411` | ✅ |
| §6.3 排空阶段wait_until_idle 60s + 500ms 投递窗口 | `mod.rs:412-419` | ✅ |
| §6.4 切换顺序connection_shutdown → generation_shutdown → channel stop → task_supervisor → 新代启动 | `mod.rs:421-422, 429-436, 350-351` | ✅ |
| §4 控制通道:容量 8、try_send、队列满立即返回错误 | `reload.rs:8, 47-54` | ✅ |
| §7.2 环境变量语义:不修改进程环境 | `config/mod.rs:541-553` | ✅ |
| §9 失败语义:候选构造失败时旧代不变 | `mod.rs:394-408` continue 不切换 | ✅ |
### 3.2 关键路径分析
#### 3.2.1 切换时序
`gateway::run` 的主循环(`mod.rs:349-441`)正确实现了运行代切换:
```
外层 loop {
start_all / start_message_processing // 新代激活
内层 loop { select! { serve | process_signal | reload_request } }
serve.await // 等待旧 axum graceful shutdown
channel_manager.stop_all // 停止旧 channel intake
task_supervisor.cancel + shutdown(10s)// 回收旧受监督任务
state = next_state // 切换
}
```
`wait_for_shutdown_signal()` 在外层循环内创建(`mod.rs:365`),每代新建 future不存在重复 poll 已完成 future 的 UB。
#### 3.2.2 候选构造与旧代隔离
`from_config``mod.rs:51-236`)为候选创建独立的 Storage、MessageBus、ChannelManager、SessionManager、ToolRegistry、AuthManager、UploadRegistry。候选的 `channel_manager.init()` 仅构造 Channel 对象,不调用 `start()`,因此不会与旧 channel 并发连接飞书 API。
#### 3.2.3 监听 socket 保留
`std::net::TcpListener``mod.rs:345`)在进程生命周期内持有;每代通过 `try_clone()``mod.rs:353`)获得新 fd。旧代 `serve` future 退出时仅释放克隆 fd原始 socket 不释放,新代可重新克隆并 accept。内核 backlog 在切换窗口中暂存新 TCP 连接。
### 3.3 测试覆盖评估
**结论:单元测试覆盖不足,集成测试完全缺失。**
当前测试:
| 测试 | 位置 | 覆盖范围 |
|------|------|----------|
| `candidate_accepts_runtime_changes_and_rejects_workspace_changes` | `reload.rs:127` | load_candidate 的 model_id 变更接受与 workspace 拒绝 |
| `resolve_slash_command("reload")` | `session.rs:3485` | slash 命令解析 |
缺失但设计文档第 12 节明确列出的测试:
1. 启动真实 Gateway、修改 model/channel 配置、HTTP 触发重载、验证新代生效
2. 配置无效时验证旧 WebSocket 与旧 Provider 仍可工作
3. 长 Turn 中调用 `reload_config`、验证 tool result 与最终消息投递后才断开
4. Session 队列积压时验证重载等待排空
5. 排空超时、Channel stop 超时、候选激活失败的故障注入
6. 重载前后鉴权策略变化与旧 WebSocket 失效
当前测试仅覆盖纯函数路径(`load_candidate`、slash 解析),未验证任何运行时切换行为。这是上线前的主要风险点。
### 3.4 代码质量
- **Clippy**`-D warnings` 通过
- **类型安全**`ReloadHandle::unavailable()``reload.rs:39`)通过 drop receiver 使 `try_send` 返回 `Closed`,正确表达"Gateway 未由 `run()` 启动"的语义
- **错误处理**:候选构造失败时 `request.response.send(Err(...))``continue`不切换MCP 单 server 失败沿用启动语义(记录错误、跳过工具),不阻断候选构造
- **资源管理**:旧 TaskSupervisor 的 10s 有界 shutdown + abort 保证回收有硬时间边界
## 4. 发现的问题
### 4.1 架构层面问题
#### A1. MCP 在准备阶段连接,制造进程级副作用【中】
`from_config``mod.rs:171`)调用 `mcp::connect_all()`,立即建立 MCP 客户端连接或启动 stdio 子进程,并更新进程级 `MCP_SERVER_STATUS``mcp/mod.rs:42`)。这违反了设计文档第 10 节不变量 #2"候选构造不得修改旧 GatewayState"的精神——MCP status 虽非请求处理状态,但仍是旧代可见的进程级状态。
**具体影响:**
- 候选构造期间,旧代的 `/mcp` 命令显示候选的连接状态而非旧代状态
- stdio MCP 子进程双份运行(旧代 + 候选)可能竞争资源或 stdin/stdout
- 候选在 `connect_all` 之后失败(如 `ensure_default_maintenance_job` 失败,`mod.rs:194`MCP 连接被 drop 但 `MCP_SERVER_STATUS` 仍显示 `connected: true`,旧代 `/mcp` 显示陈旧数据
设计文档第 6.1 节与第 14 节将此列为"已知例外"。但该例外的收益仅为"提前发现 MCP 失败"——而 MCP 单 server 失败本就被当非致命跳过(`mcp/mod.rs:171`),不需要提前连接来验证。
#### A2. 无 generation ID 与状态查询【中】
调用方只能得到准备阶段结果("配置校验通过Gateway 将在当前任务结束后切换到新配置"),无法查询重载最终是否完成。运维需要翻日志或重连客户端确认切换状态。设计文档第 14 节将此列为"演进方向",但 generation ID一个 atomic 计数器)+ `/api/config/reload/status` 端点成本极低,收益显著,应在 v1 内置。
#### A3. 候选激活失败无回滚【中-高】
切换顺序为:停旧 channel → 取消旧 TaskSupervisor → 切换 state → 启动新 channel`mod.rs:429-351`)。若新代 `start_all()` 失败,`run()` 返回错误,依赖 systemd 拉起。但 channel 启动失败常为瞬时问题(飞书 5xx、端口冲突丢掉本来正常服务的旧代去重启是可用性损失。旧代此时已被回收无法回滚。
#### A4. `reload_config` Agent 工具的软约束【低-中】
`ReloadConfigTool``reload_config.rs`)注册到默认工具集,依赖 description"仅在用户明确要求重新加载配置时调用"约束 LLM。LLM compliance 是软约束,非可靠边界。`exclusive: true` 仅保证不与其他副作用工具并行,不保证调用时机正确。
#### A5. 排空仅覆盖交互 Session不覆盖 Scheduler/后台 Agent【低】
`wait_until_idle` 仅检查内存中 Session 的 `current_cancel``agent_tx` 队列。Scheduler job、独立后台子 Agent、HTTP handler 不在排空范围内。Scheduler job 可能正在写 DB 或发送消息,被 TaskSupervisor 10s abort 截断可能留下不一致状态。设计文档第 6.3 节已承认此范围。
### 4.2 实现层面问题
#### I1. `/reload` slash command 绕过 `wait_until_idle`【中】
`session.rs:2631` 显示 slash command 在 `handle_message` 内联处理,返回 `HandleResult::CommandOutput`**不进入 session worker 队列**。因此 `wait_until_idle` 检查 `current_cancel`/`agent_tx.capacity()` 时看不到 `/reload` Turn 的活动状态,立即返回(仅 100ms 稳定 + 500ms sleep
slash command output 经 `process_inbound``publish_command_output` → outbound dispatcher → channel API 投递,整条链路必须在 ~600ms+ serve graceful shutdown + 10s TaskSupervisor shutdown内完成。对 CLI channel 足够,但对 Feishu 等远端 channel 较紧。设计文档第 6.3 节"为 slash command output 和终态投递留出发送窗口"承认了 500ms 窗口,但 500ms 是固定值,无背压保证。
实际窗口因 TaskSupervisor 的 10s shutdown 较宽,不会丢消息——但若 channel `stop_all()` 关闭了连接in-flight 的 `send_message` 可能失败。
#### I2. `session_db_path` 比较为原始字符串【低】
`reload.rs:91` 直接比较 `current.gateway.session_db_path != candidate.gateway.session_db_path`。若用户把 `null` 改为 `"./picobot.db"`(解析后同一路径),会被误拒。保守是对的,但产生假阳性。`workspace_dir` 已做 canonicalize 比较,`session_db_path` 应保持一致。
#### I3. `load_candidate` 仅校验 Feishu 凭据【低】
`reload.rs:73-78` 仅校验飞书 `app_id`/`app_secret` 非空。其他 channel 配置若有、MCP 配置、Browser 配置等在 `from_config` 期间才验证,可能在那里失败。这与设计文档第 6.1 节一致("若飞书启用,校验 app_id 和 app_secret 非空"),但将失败发现延后到了候选构造阶段。
#### I4. `reload_config` HTTP handler 错误码语义【低】
`http.rs:355` 对所有错误用 `ApiError::bad_request`400。"gateway is shutting down"(队列关闭)更适合 503 Service Unavailable"another configuration reload is already pending"更适合 409 Conflict。
#### I5. 候选 Storage 与旧 Storage 共享同一 SQLite 文件【低】
`from_config` 为候选创建新 Storage 连接到同一 `picobot.db`。候选的 background notification consumer 与 cleanup task 已归候选 TaskSupervisorcleanup 跳过首次 tick无入口触发 notification故实际不写。理论上有并发写锁竞争可能实际风险低。设计文档未显式说明此点。
### 4.3 严重度分级
| 问题 | 严重度 | 影响 |
|------|--------|------|
| A3 候选激活失败无回滚 | 中-高 | 瞬时 channel 故障导致整个 Gateway 重启 |
| A1 MCP 准备阶段连接 | 中 | 进程级 status 污染、子进程双份、失败后状态陈旧 |
| A2 无 generation ID | 中 | 运维无法确认切换完成状态 |
| I1 `/reload` 绕过排空 | 中 | 远端 channel output 投递窗口紧 |
| A4 reload_config 软约束 | 低-中 | LLM 误调用风险 |
| A5 排空不覆盖 Scheduler | 低 | 后台 job 被硬取消可能留下不一致 |
| I2 session_db_path 假阳性 | 低 | 用户需重启而非重载 |
| I3 仅校验 Feishu | 低 | 失败发现延后 |
| I4 HTTP 错误码 | 低 | 语义不准确 |
| I5 双 Storage 连接 | 低 | 理论并发风险 |
## 5. 改进建议
### 5.1 必须修复(上线前)
#### F1. 补充端到端集成测试
至少覆盖设计文档第 12 节列出的前 3 项:
1. 启动真实 Gateway、修改 model_id、HTTP 触发重载、验证新 model 在新 Turn 中生效
2. 配置无效(如 default agent 解析失败)时验证旧 WebSocket 与旧 Provider 仍可工作
3. 长 Turn 中调用 `reload_config`、验证 tool result 与最终消息投递后才断开
这些测试无法用单元测试替代,需启动真实 Gateway 进程。
#### F2. `session_db_path` 比较归一化
`reload.rs:91` 应将 `session_db_path` 相对于 workspace 解析并 canonicalize 后比较,与 `workspace_dir` 处理方式一致。`None``"picobot.db"`(默认值)应视为等价。
### 5.2 建议增强(近期演进)
#### E1. MCP 连接移出 `from_config`,消除"已知例外"
`mcp::connect_all()``from_config``mod.rs:171`)移到 `start_all()` 阶段,与 channel 启动同相位。收益:
- 消除进程级 `MCP_SERVER_STATUS` 在候选构造期间被污染
- 消除 stdio 子进程双份运行
- 候选失败时无 MCP 连接泄漏
- 不再需要设计文档第 10 节不变量 #2 的"已知例外"声明
代价MCP 连接失败从"候选构造失败"延后到"激活失败"。但 MCP 单 server 失败本就非致命(跳过该 server 工具),整体激活失败语义不变。
#### E2. 引入 generation ID 与 status 查询
`ReloadController` 增加 `Arc<AtomicU64>` generation 计数器与 `ReloadState` enum`Idle`/`Preparing`/`Draining`/`Activating`/`Active`/`Failed`)。提供 `GET /api/config/reload/status` 端点。调用方在收到 "accepted" 后可轮询确认切换完成。成本极低(一个 atomic + 一个路由 + 一个 enum运维收益显著。
#### E3. 旧代保留至新代 `start_all()` 成功
调整切换顺序为:先启动新 channel新代 channel_manager.start_all成功后再停止旧 channel。短暂双 channel 并存对飞书 webhook 幂等消息可容忍。代价是需处理两代 channel 并存的资源冲突(如媒体目录、飞书事件去重),但避免瞬时 channel 故障导致 Gateway 整体重启。
若实现成本过高,至少应在 `start_all()` 失败时尝试重启旧代 channel旧 TaskSupervisor 已 cancel可能无法恢复需评估可行性
#### E4. `/reload` slash command 排空路径
两种方案:
- **方案 A**:让 reload controller 记录触发源channel, chat_id等待该 inbound lane 的当前消息处理完成后再切换,而非泛化等待所有 session idle
- **方案 B**:为 outbound dispatcher 增加显式 drain contract`stop_all` 前等待 outbound 队列排空或超时
方案 A 更精确,方案 B 更通用。两者都比固定 500ms sleep 可靠。
#### E5. Scheduler 与后台 job 协作式 drain
在 Scheduler job 的协作取消边界检查 reload token允许 job 在写 DB 前/后选择继续完成或退出。避免 TaskSupervisor 10s abort 截断 DB 写一半的 job。设计文档第 14 节已列出此项。
### 5.3 演进方向(中长期)
#### L1. `prepare()` / `activate()` 显式分离
`from_config` 拆为:
- `construct()`:纯内存,无 I/O可反复调用
- `prepare()`:可失败的 I/Ochannel health check、MCP 连接、Storage ping旧代仍服务
- `activate()`:开始接收消息
使更多失败前移到旧代仍可回退的阶段,而非等到 activate 才暴露。设计文档第 14 节已列出此项。
#### L2. 动态 Router service
引入新旧 HTTP generation 短期重叠,消除 accept/Channel intake 空窗。需不破坏 Channel/Session 边界。设计文档第 14 节已列出此项。
#### L3. WebUI/TUI reload 完成通知
客户端在 WebSocket 重连后显示 reload 完成状态与自动重连提示。依赖 E2 的 generation ID。
#### L4. 重新评估 `reload_config` Agent 工具
考虑移除该工具,仅保留 CLI 与 `/reload`。Agent 触发进程级状态切换的风险A4可能不抵边际收益。若保留应在 SessionManager 层做调用方校验(如要求参数带确认 token而非靠 description。
## 6. 总体结论
### 6.1 设计评估
设计文档质量高,边界清晰,不变量明确,失败语义完整。运行代切换模型是 PicoBot 当前架构下的正确选择。设计文档诚实地列出了已知限制(第 14 节),未掩饰缺陷。
主要设计层面的不足是把几件本应 v1 内置的能力generation ID、MCP 相位对齐、旧代保留至新代激活成功)推迟到"演进方向"导致可用性与可观测性打了折扣。MCP 的"已知例外"A1是设计妥协被文档化的典型留着会让后续维护者也认为"再来一个例外无所谓",应尽早消除而非长期承担。
### 6.2 实现评估
实现忠实遵循设计,关键不变量均得到遵守。代码通过 `cargo build``cargo clippy -D warnings``cargo test --lib`330 passed与 WebUI `check/build`。版本号、文档、AGENTS.md 同步更新。
主要实现层面的不足是测试覆盖:仅 `load_candidate` 与 slash 解析有单元测试无任何运行时切换行为的集成验证I1-I5 中多数问题需要集成测试才能暴露)。设计文档第 12 节明确列出但未实现的 6 项集成测试是上线前的主要风险。
### 6.3 上线建议
| 项 | 判定 |
|----|------|
| 架构方向 | ✅ 可接受 |
| 实现一致性 | ✅ 可接受 |
| 代码质量 | ✅ 可接受Clippy/tests/build 全通过) |
| 测试覆盖 | ⚠️ 不足需补集成测试F1 |
| 已知限制 | ⚠️ 文档已承认,但 A1/A3 应优先修复 |
**建议:在完成 F1集成测试与 F2session_db_path 归一化后可提交。A1MCP 相位、A3无回滚应列为后续优先修复项不应长期承担。**
## 附录:关键文件与符号索引
| 文件/符号 | 职责 | 行号 |
|-----------|------|------|
| `src/gateway/reload.rs::ReloadController` | 重载控制通道、候选配置加载、不可变字段校验 | 19-36 |
| `src/gateway/reload.rs::ReloadHandle` | 可克隆的请求端,注入 SessionManager/工具/HTTP state | 14-59 |
| `src/gateway/reload.rs::load_candidate` | 候选配置解析与不可变字段校验 | 61-102 |
| `src/gateway/mod.rs::run` | 持有 listener、当前运行代与切换主循环 | 318-441 |
| `src/gateway/mod.rs::GatewayState::from_config` | 构造一套完整运行代依赖 | 51-236 |
| `src/gateway/mod.rs::build_router` | 为每代构建 Axum Router | 448-489 |
| `src/config/mod.rs::Config::load_for_reload` | 使用启动环境/cwd 安全重新解析配置 | 541-553 |
| `src/config/mod.rs::Config::load_from_with_process_env` | 共享的配置加载实现,支持不写入进程环境 | 547-625 |
| `src/session/session.rs::wait_until_idle` | 检查活动 Turn、Session 队列与稳定空闲窗口 | 2109-2144 |
| `src/session/session.rs::execute_slash_command` | `/reload` slash 入口 | 2093-2099 |
| `src/tools/reload_config.rs::ReloadConfigTool` | Agent 可调用的独占重载工具 | 6-52 |
| `src/gateway/http.rs::reload_config` | 受保护的 `POST /api/config/reload` | 347-360 |
| `src/client/mod.rs::reload_gateway` | CLI HTTP 客户端与 bearer token 注入 | 101-124 |
| `src/main.rs::Command::Reload` | `picobot reload` CLI 定义 | 59-64, 132-138 |
| `src/mcp/mod.rs::connect_all` | MCP 连接(当前在 from_config 期间调用,见 A1 | 126-181 |
| `src/mcp/mod.rs::MCP_SERVER_STATUS` | 进程级 MCP 状态A1 的副作用源) | 36-45 |

View File

@ -0,0 +1,616 @@
# P0 地基 Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 建立 Signal Deck 设计系统(双主题 tokens + 内嵌字体)、应用外壳(扁平导航 + 全局聊天 WS + 活动脊 + 主题/鉴权),并按新设计重构聊天页,产出可工作的聊天优先控制台地基。
**Architecture:** 用 CSS 自定义属性表达 Signal Deck tokens`:root` 暗色 / `:root[data-theme="light"]` 亮色),整体重写 `styles.css`。将聊天 WebSocket 连接从 ChatPage 提升为模块级单例 `lib/chat.svelte.js`,由 App 外壳统一持有使活动脊在所有页面可用ChatPage 订阅帧并保留全部现有逻辑(会话/消息/turn 快照/计划/上传/斜杠命令)。两个拉丁字体经 vite `publicDir` 以固定名输出,`http.rs``include_bytes!` 内嵌并提供同源路由,维持单二进制与现有 CSP。
**Tech Stack:** Svelte 5runes、Bits UI、Vite、CSS custom propertiesRust/Axum字体路由、build.rs + vite嵌入管线
**验证约定(重要):** 本仓库前端**没有单元测试框架**。前端任务以 `npm run check`svelte-check+ `npm run build` + 浏览器目检为验证手段(见 AGENTS.md涉及 Rust 的任务以 `cargo build` + `cargo test --lib` + `cargo clippy --all-targets --all-features -- -D warnings` 验证。不要虚构前端测试。
**参考文档:** 设计规格 `docs/superpowers/specs/2026-07-23-webui-refactor-design.md`§4 设计系统、§5 信息架构、§6.1 聊天页、§8 前端架构)。配色/组件 mockup 见 `.superpowers/brainstorm/111044-1784795642/`design-system.html、page-chat.html
---
## File Structure
**Create:**
- `webui/public/fonts/space-grotesk-500.woff2``space-grotesk-700.woff2``jetbrains-mono-400.woff2``jetbrains-mono-700.woff2` — 内嵌拉丁字体vite publicDir 原样复制到产物根)
- `webui/public/theme-init.js` — 首屏防闪烁主题初始化脚本CSP 安全,经 `/theme-init.js` 路由提供)
- `webui/src/lib/theme.js` — 主题检测/应用/持久化
- `webui/src/lib/chat.svelte.js` — 全局聊天 WS 单例(连接/重连/订阅/发送/最新 turn 快照)
- `webui/src/lib/components/ActivitySpine.svelte` — 全局活动脊
**Modify:**
- `webui/src/styles.css` — 全面重写为 Signal Deck tokens + @font-face + 组件样式
- `webui/src/App.svelte` — 外壳:扁平导航、全局 WS、活动脊、主题切换、鉴权
- `webui/src/pages/ChatPage.svelte` — 改用全局 chat client + Signal Deck 三栏布局(保留全部逻辑)
- `webui/src/pages/PairingPage.svelte` — 套用新 tokens结构不变
- `webui/index.html` — theme-color 更新为 `#0b1017` + `<head>` 引入 `/theme-init.js`
- `webui/src/lib/ToolCallCard.svelte``TurnView.svelte``Markdown.svelte``Toast.svelte` — 套用新 tokens/类名StatusBadge 无独立样式,随 styles.css 的 `.badge.*` 更新)
- `src/gateway/http.rs` — 字体路由include_bytes! + font/woff2+ `/theme-init.js` handlerinclude_str!
- `src/gateway/mod.rs` — 公开静态路由组追加 `/fonts/{name}``/theme-init.js`
- `build.rs``rerun-if-changed` 增加 `webui/public`
**不动:** 后端聊天/配置/记忆等现有端点P0 纯前端 + 字体路由)。
---
## Chunk 1: 设计 tokens 与字体内嵌管线
### Task 1.1: 内嵌字体publicDir + http.rs 路由)
**Files:**
- Create: `webui/public/fonts/{space-grotesk-500,space-grotesk-700,jetbrains-mono-400,jetbrains-mono-700}.woff2`
- Modify: `src/gateway/http.rs`(新增字体 handler 与路由)
- Modify: `src/gateway/mod.rs`(注册 `/fonts/{name}` 路由,公开静态资源层)
- Modify: `build.rs``rerun-if-changed=webui/public`
- [ ] **Step 1: 获取并提交字体文件**
@fontsource 取 latin 子集 woff2版本锁定、可复现
```bash
cd webui
npm i -D @fontsource/space-grotesk @fontsource/jetbrains-mono
mkdir -p public/fonts
cp node_modules/@fontsource/space-grotesk/files/space-grotesk-latin-500-normal.woff2 public/fonts/space-grotesk-500.woff2
cp node_modules/@fontsource/space-grotesk/files/space-grotesk-latin-700-normal.woff2 public/fonts/space-grotesk-700.woff2
cp node_modules/@fontsource/jetbrains-mono/files/jetbrains-mono-latin-400-normal.woff2 public/fonts/jetbrains-mono-400.woff2
cp node_modules/@fontsource/jetbrains-mono/files/jetbrains-mono-latin-700-normal.woff2 public/fonts/jetbrains-mono-700.woff2
```
@fontsource 文件路径/命名随版本不同,用 `ls node_modules/@fontsource/*/files/ | grep latin` 找到对应 latin 500/700/400 的 normal woff2。确认 4 个文件均为非空 woff2。@fontsource 仅为取字体的 devDependency运行时不依赖。
- [ ] **Step 2: build.rs 监听 public 目录**
`build.rs``build_webui` 的监听列表(约 64-73 行)追加:
```rust
"webui/public",
```
- [ ] **Step 3: http.rs 增加字体 handler**
`src/gateway/http.rs``webui_styles` 之后)新增:
```rust
const EMBEDDED_FONTS: &[(&str, &[u8])] = &[
(
"space-grotesk-500.woff2",
include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/space-grotesk-500.woff2")),
),
(
"space-grotesk-700.woff2",
include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/space-grotesk-700.woff2")),
),
(
"jetbrains-mono-400.woff2",
include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/jetbrains-mono-400.woff2")),
),
(
"jetbrains-mono-700.woff2",
include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/jetbrains-mono-700.woff2")),
),
];
pub async fn webui_font(Path(name): Path<String>) -> Response {
let bytes = EMBEDDED_FONTS
.iter()
.find(|(font_name, _)| *font_name == name)
.map(|(_, bytes)| *bytes);
let Some(bytes) = bytes else {
return StatusCode::NOT_FOUND.into_response();
};
Response::builder()
.header(header::CONTENT_TYPE, "font/woff2")
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header("X-Content-Type-Options", "nosniff")
.body(Body::from(bytes))
.expect("valid font response")
}
```
`Path` 已在文件顶部 `axum::extract` 导入。)
- [ ] **Step 4: mod.rs 注册字体路由(公开层,随静态资源)**
`src/gateway/mod.rs` 的公开静态路由组(约 592-596 行,`/``/app.js``/styles.css` 处)追加:
```rust
.route("/fonts/{name}", routing::get(http::webui_font))
```
字体属静态资源层,不进设备鉴权(与 app.js/styles.css 同级CSP `default-src 'self'` 已允许同源 font
- [ ] **Step 5: 构建验证**
Run: `cargo build`(会自动触发 vite 构建public/fonts 复制到 OUT_DIR/webui/fonts
Expected: 编译成功,无 clippy 级错误。
Run: `cargo clippy --all-targets --all-features -- -D warnings`
Expected: 无警告。
- [ ] **Step 6: Commit**
```bash
git add webui/public/fonts build.rs src/gateway/http.rs src/gateway/mod.rs webui/package.json webui/package-lock.json
git commit -m "feat(webui): embed latin fonts and serve via /fonts route"
```
### Task 1.2: 重写 styles.css 为 Signal Deck tokens
**Files:**
- Modify: `webui/src/styles.css`(整体重写)
- [ ] **Step 1: 写入 @font-face 与 tokens**
`styles.css` 顶部的 `:root` / `:root[data-theme="light"]` 块整体替换为(保留文件其余组件类,随后在 Step 2 调整):
```css
@font-face {
font-family: "Space Grotesk";
src: url("/fonts/space-grotesk-500.woff2") format("woff2");
font-weight: 500; font-style: normal; font-display: swap;
}
@font-face {
font-family: "Space Grotesk";
src: url("/fonts/space-grotesk-700.woff2") format("woff2");
font-weight: 700; font-style: normal; font-display: swap;
}
@font-face {
font-family: "JetBrains Mono";
src: url("/fonts/jetbrains-mono-400.woff2") format("woff2");
font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
font-family: "JetBrains Mono";
src: url("/fonts/jetbrains-mono-700.woff2") format("woff2");
font-weight: 700; font-style: normal; font-display: swap;
}
:root {
--font-ui: "Space Grotesk", ui-sans-serif, system-ui, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
color-scheme: dark;
font-family: var(--font-ui);
color: #e7ecf3;
background: #0b1017;
--bg: #0b1017;
--panel: #0e1520;
--panel-2: #131c29;
--sidebar: #0d131c;
--header: rgb(11 16 23 / 84%);
--line: #1d2733;
--line-strong: #2c3a4c;
--muted: #8fa3b8;
--faint: #5b6b7e;
--text: #e7ecf3;
--text-soft: #b8c4d4;
--accent: #ffb454; /* amber = 活动 */
--accent-hover: #ffc370;
--accent-contrast: #1a1206;
--accent-soft: rgb(255 180 84 / 12%);
--accent-border: rgb(255 180 84 / 35%);
--signal: #2dd4bf; /* teal = 健康 */
--signal-soft: rgb(45 212 191 / 12%);
--signal-border: rgb(45 212 191 / 35%);
--info: #6aa6ff;
--info-soft: rgb(106 166 255 / 12%);
--danger: #ff7b86;
--danger-soft: rgb(255 123 134 / 12%);
--danger-border: rgb(255 123 134 / 35%);
--warning: #ffb454;
--warning-soft: rgb(255 180 84 / 10%);
--success-soft: rgb(45 212 191 / 12%);
--overlay: #101826;
--code-bg: #080c12;
--user-bubble: #221d38;
--spine-bg: #0e1520; /* 活动脊:亮色下也保持深色 */
--shadow: 0 16px 45px rgb(0 0 0 / 35%);
--radius: 11px;
}
:root[data-theme="light"] {
color-scheme: light;
color: #1a2230;
background: #eef1f5;
--bg: #eef1f5;
--panel: #ffffff;
--panel-2: #f4f6f9;
--sidebar: #f7f9fc;
--header: rgb(238 241 245 / 86%);
--line: #d8dee8;
--line-strong: #c2ccd9;
--muted: #5b6b7e;
--faint: #8494a8;
--text: #1a2230;
--text-soft: #3d4b5e;
--accent: #c47400;
--accent-hover: #a86300;
--accent-contrast: #ffffff;
--accent-soft: rgb(196 116 0 / 10%);
--accent-border: rgb(196 116 0 / 35%);
--signal: #0d9488;
--signal-soft: rgb(13 148 136 / 10%);
--signal-border: rgb(13 148 136 / 35%);
--info: #2f6fd0;
--info-soft: rgb(47 111 208 / 10%);
--danger: #d94354;
--danger-soft: rgb(217 67 84 / 10%);
--danger-border: rgb(217 67 84 / 35%);
--warning: #c47400;
--warning-soft: rgb(196 116 0 / 8%);
--success-soft: rgb(13 148 136 / 10%);
--overlay: #ffffff;
--code-bg: #f7f9fc;
--user-bubble: #ece7fb;
--spine-bg: #0e1520; /* 亮色下活动脊仍是深色 LED 条 */
--shadow: 0 16px 45px rgb(31 41 55 / 12%);
}
```
- [ ] **Step 2: 调整组件类以适配新 tokens**
逐个检查并更新其余组件类(原文件 60 行起):
- 所有 `font-family` 硬编码处改用 `var(--font-ui)`;数据/日志/时间戳/`code`/`.mono` 类用 `var(--font-mono)`
- 原紫色相关(`--accent` 旧值、`--user-bubble`)已由 tokens 替换,确认无残留硬编码 hex。
- `.primary` 按钮:`background: var(--accent); color: var(--accent-contrast);`(暗色下琥珀底深字,亮色下深琥珀底白字)。
- 状态点/在线指示:健康用 `var(--signal)`,活动/进行中用 `var(--accent)`,错误用 `var(--danger)`
- **两处硬编码绿色必须手动改为 `var(--signal)`**(否则不随 tokens 更新):`.gateway-status i.online { color: #48b985 }`(约 93 行)与 `.badge.ok { color: #38a877 }`(约 267 行StatusBadge 的颜色实际来自这里)。
- 新增工具类(供组件使用):
```css
.mono { font-family: var(--font-mono); }
.label-caps { font-family: var(--font-mono); font-size: 9px; letter-spacing: .16em; color: var(--faint); }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); }
.cap { display: inline-flex; align-items: center; gap: 4px; font-size: 9.5px; font-weight: 600; border-radius: 6px; padding: 2.5px 8px; }
.cap.signal { color: var(--signal); background: var(--signal-soft); border: 1px solid var(--signal-border); }
.cap.accent { color: var(--accent); background: var(--accent-soft); border: 1px solid var(--accent-border); }
.cap.danger { color: var(--danger); background: var(--danger-soft); border: 1px solid var(--danger-border); }
.cap.info { color: var(--info); background: var(--info-soft); border: 1px solid var(--line); }
@keyframes spine-pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
.pulse-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; animation: spine-pulse 1.6s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) { .pulse-dot { animation: none; } }
```
- [ ] **Step 3: 验证**
Run: `cd webui && npm run check && npm run build`
Expected: svelte-check 无错误;构建成功。
- [ ] **Step 4: Commit**
```bash
git add webui/src/styles.css
git commit -m "feat(webui): Signal Deck design tokens and base styles"
```
---
## Chunk 2: 核心 lib 与应用外壳
### Task 2.1: theme.js 主题管理
**Files:**
- Create: `webui/src/lib/theme.js`
- [ ] **Step 1: 实现**
```js
const STORAGE_KEY = "picobot-theme";
export function preferredTheme() {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === "light" || saved === "dark") return saved;
return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
document
.querySelector('meta[name="theme-color"]')
?.setAttribute("content", theme === "dark" ? "#0b1017" : "#eef1f5");
localStorage.setItem(STORAGE_KEY, theme);
}
```
- [ ] **Step 2: 验证**`cd webui && npm run check`(无错误)
- [ ] **Step 3: Commit**`git add webui/src/lib/theme.js && git commit -m "feat(webui): theme helpers"`
### Task 2.2: chat.svelte.js 全局聊天客户端
**Files:**
- Create: `webui/src/lib/chat.svelte.js`
将 ChatPage 的连接/重连生命周期提取为模块级单例。帧分发保留给订阅者ChatPage 搬入其 `handleFrame` 逻辑);客户端额外暴露最新 turn 快照供活动脊使用。
- [ ] **Step 1: 实现**
```js
import { clientId } from "./api.js";
class ChatClient {
connected = $state(false);
turn = $state(null); // 最新 turn 快照(任意 session供活动脊
#socket = null;
#handlers = new Set();
#reconnectTimer = null;
#stopped = false;
connect() {
if (this.#socket) return;
this.#stopped = false;
const scheme = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${scheme}://${location.host}/ws?client_id=${encodeURIComponent(clientId())}`);
this.#socket = ws;
ws.onopen = () => {
this.connected = true;
this.#dispatch({ type: "_open" });
};
ws.onerror = () => ws.close();
ws.onclose = () => {
this.connected = false;
this.#socket = null;
this.#dispatch({ type: "_close" });
if (!this.#stopped) this.#reconnectTimer = setTimeout(() => this.connect(), 1800);
};
ws.onmessage = (event) => {
const frame = JSON.parse(event.data);
if (frame.type === "turn_updated" && frame.snapshot) this.turn = frame.snapshot;
this.#dispatch(frame);
};
}
disconnect() {
this.#stopped = true;
clearTimeout(this.#reconnectTimer);
this.#socket?.close();
this.#socket = null;
}
send(frame) {
if (this.#socket?.readyState === WebSocket.OPEN) {
this.#socket.send(JSON.stringify(frame));
return true;
}
return false;
}
subscribe(handler) {
this.#handlers.add(handler);
return () => this.#handlers.delete(handler);
}
#dispatch(frame) {
for (const handler of this.#handlers) handler(frame);
}
}
export const chat = new ChatClient();
```
- [ ] **Step 2: 验证**`cd webui && npm run check`
- [ ] **Step 3: Commit**`git add webui/src/lib/chat.svelte.js && git commit -m "feat(webui): global chat websocket client"`
### Task 2.3: ActivitySpine.svelte 活动脊
**Files:**
- Create: `webui/src/lib/components/ActivitySpine.svelte`
- [ ] **Step 1: 实现**
活动脊显示Turn 实时状态(来自 `chat.turn` 快照)+ 吞吐(前端对相邻帧 `usage.completion_tokens` 差值求导)+ 连接状态。gen/uptime/metrics 等字段在 P1 由 `/api/status` 补充P0 先显示版本与连接态。
```svelte
<script>
import { chat } from "../chat.svelte.js";
let { version = "" } = $props();
let lastTokens = $state(null); // { at, completion }
let rate = $state(null);
$effect(() => {
const turn = chat.turn;
if (!turn || turn.status !== "running") { rate = null; return; }
const completion = turn.usage?.completion_tokens;
const now = Date.now();
if (completion != null && lastTokens && now > lastTokens.at) {
const delta = completion - lastTokens.completion;
const secs = (now - lastTokens.at) / 1000;
if (delta >= 0 && secs > 0) rate = Math.round(delta / secs);
}
if (completion != null) lastTokens = { at: now, completion };
});
const running = $derived(chat.turn?.status === "running");
const turnLabel = $derived(chat.turn ? `TURN ${String(chat.turn.id ?? "").slice(0, 6).toUpperCase()}` : "");
const ctx = $derived(chat.turn?.usage?.prompt_tokens != null
? `${(chat.turn.usage.prompt_tokens / 1000).toFixed(1)}k` : null);
</script>
<div class="spine mono">
{#if running}
<span class="spine-turn active"><i class="pulse-dot" style="background:var(--accent);box-shadow:0 0 10px var(--accent)"></i>{turnLabel} · STREAMING</span>
{#if rate != null}<span class="spine-rate">▲ {rate} tok/s</span>{/if}
{#if ctx}<span>ctx {ctx}</span>{/if}
{:else if chat.turn}
<span class="spine-turn idle"><i class="pulse-dot" style="background:var(--signal);animation:none"></i>IDLE</span>
<span>最近 {turnLabel}</span>
{:else}
<span class="spine-turn idle"><i class="pulse-dot" style="background:var(--signal);animation:none"></i>READY</span>
{/if}
<span class="spine-right">
<span class:spine-ok={chat.connected} class:spine-down={!chat.connected}>{chat.connected ? "已连接" : "重连中"}</span>
{#if version}<span>{version}</span>{/if}
</span>
</div>
<style>
.spine { display: flex; align-items: center; gap: 14px; font-size: 10.5px; color: var(--muted);
background: var(--spine-bg); border-bottom: 1px solid var(--line); padding: 8px 16px; }
.spine-turn { display: inline-flex; align-items: center; gap: 7px; font-weight: 700; }
.spine-turn.active { color: var(--accent); }
.spine-turn.idle { color: var(--signal); }
.spine-rate { color: var(--signal); }
.spine-right { margin-left: auto; display: inline-flex; gap: 14px; color: var(--faint); }
.spine-ok { color: var(--signal); }
.spine-down { color: var(--warning); }
</style>
```
`.mono``.pulse-dot` 来自 styles.css 工具类。)
- [ ] **Step 2: 验证**`cd webui && npm run check`
- [ ] **Step 3: Commit**`git add webui/src/lib/components/ActivitySpine.svelte && git commit -m "feat(webui): global activity spine"`
### Task 2.4: App.svelte 外壳重构
**Files:**
- Modify: `webui/src/App.svelte`
- [ ] **Step 1: 重构**
要点(保留现有鉴权/配对/health 逻辑,替换导航与布局):
- `onMount` 中:`applyTheme(preferredTheme())`health 轮询保留(取 version 传给 ActivitySpine
- **WS 生命周期跟随"已鉴权外壳"而非根 onMount**:用 `$effect` 监听 `authenticated`——`authenticated` 为真时 `chat.connect()`,为假(如凭据被撤销、外壳卸载回配对页)时 `chat.disconnect()`。避免鉴权失效后客户端仍在后台每 1.8s 静默重连。
```js
$effect(() => {
if (authenticated) { chat.connect(); } else { chat.disconnect(); }
});
```
- 页面数组改为扁平导航(图标 + 名称):`["chat","◫","聊天"]``["overview","◉","概览"]``["tools","🧰","工具&Skills"]``["logs","≋","日志"]``["memory","◇","记忆"]``["tasks","⌁","任务"]``["settings","⚙","配置"]`。P0 中 overview/tools 页面尚未实现,先渲染占位 `<div class="empty-card">即将上线</div>`P1/P2 补齐logs/memory/tasks/settings 复用现有页面组件。
- 结构:`<aside class="sidebar">`(品牌 + 扁平 nav + 底部网关状态/主题切换)+ `<main>``<ActivitySpine {version} />` 置顶 + 页面区。
- 主题切换按钮调用 `applyTheme(theme === "dark" ? "light" : "dark")` 并更新 `theme` 状态。
- 需要的新 import`import { chat } from "./lib/chat.svelte.js"``import { applyTheme, preferredTheme } from "./lib/theme.js"``import ActivitySpine from "./lib/components/ActivitySpine.svelte"`
- WS 断开由上面的 `$effect` 负责(`authenticated=false` 时 disconnect如需双保险`onMount` 清理函数 `return () => chat.disconnect()` 亦可,两者不冲突。
- [ ] **Step 2: index.html 防主题闪烁CSP 安全方案)**
现有 CSP 为 `script-src 'self'`(无 `'unsafe-inline'`**不能**写内联 `<script>`。改为独立同源脚本文件:
1. Create `webui/public/theme-init.js`vite publicDir 会原样复制到 `OUT_DIR/webui/theme-init.js`
```js
try {
var t = localStorage.getItem("picobot-theme");
if (!t) t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
document.documentElement.dataset.theme = t;
} catch (e) {}
```
2. `src/gateway/http.rs` 新增 handler`webui_script` 同构):
```rust
pub async fn webui_theme_init() -> Response {
static_response(
"text/javascript; charset=utf-8",
include_str!(concat!(env!("OUT_DIR"), "/webui/theme-init.js")),
)
}
```
3. `src/gateway/mod.rs` 公开静态路由组追加 `.route("/theme-init.js", routing::get(http::webui_theme_init))`
4. `webui/index.html``<meta name="theme-color">``#0d1117` 改为 `#0b1017``<head>` 内加解析阻塞引用 `<script src="/theme-init.js"></script>`(同源,被 `script-src 'self'` 允许)。
5. File Structure 与 Task 1.1 的 build.rs 监听已含 `webui/public`theme-init.js 随之复制)。
- [ ] **Step 3: 验证**`cd webui && npm run check && npm run build`
- [ ] **Step 4: 目检**`cargo run -- gateway` 后打开 http://127.0.0.1:19876/,确认:暗/亮主题切换生效且持久化、无首屏闪烁;活动脊显示"已连接/READY";导航 7 项齐全;未实现页面显示占位。
- [ ] **Step 5: Commit**`git add webui/public/theme-init.js src/gateway/http.rs src/gateway/mod.rs webui/src/App.svelte webui/index.html && git commit -m "feat(webui): app shell with flat nav, activity spine, and theme init"`
### Task 2.5: PairingPage 套用新 tokens
**Files:**
- Modify: `webui/src/pages/PairingPage.svelte`
- [ ] **Step 1: 将硬编码颜色替换为新 tokens**(结构与逻辑不变,仅样式对齐 Signal Deck
- [ ] **Step 2: 验证**`npm run check`;未配对状态下目检配对页。
- [ ] **Step 3: Commit**`git add webui/src/pages/PairingPage.svelte && git commit -m "style(webui): pairing page Signal Deck tokens"`
---
## Chunk 3: 聊天页重构
### Task 3.1: ChatPage 接入全局客户端 + 三栏布局
**Files:**
- Modify: `webui/src/pages/ChatPage.svelte`
这是 P0 最大的改动。**原则全部现有业务逻辑handleFrame 各分支、上传、斜杠补全、计划侧栏、历史校准)原样保留**,只做两件事:(a) 连接生命周期改用 `chat` 单例;(b) 套用 Signal Deck 类名/三栏布局。
- [ ] **Step 1: 连接改造**
- 删除组件内 `connect()`/`socket`/`reconnectTimer`/`stopped``onMount` 中的连接代码。
- `onMount` 中改为(**注意:重连时必须重置计划相关状态,与重构前 `connect()``onopen` 行为完全一致**
```js
const unsubscribe = chat.subscribe(handleFrame);
const onOpen = (frame) => {
if (frame.type !== "_open") return;
// 与重构前一致:每次(重)连接都重置计划状态再拉取
plansBySession = {};
unseenPlanSessions = {};
todoOpen = false;
chat.send({ type: "list_sessions", include_archived: false });
chat.send({ type: "get_slash_commands" });
};
const unsubOpen = chat.subscribe(onOpen);
if (chat.connected) onOpen({ type: "_open" }); // 已连接时首次挂载也走同一逻辑
return () => { unsubscribe(); unsubOpen(); clearPendingUploads(); };
```
- 所有 `send(...)` 调用改为 `chat.send(...)``connected` 改读 `chat.connected`
- `handleFrame` 中原 `session_established`/`session_list`/... 分支逻辑**不变**。
- [ ] **Step 2: 布局与样式改造**
- 顶层 `<section class="page chat-layout">` 三栏:`sessions-panel`(左)| `chat-panel`(中)| `todo-panel`(右,`{#if todoOpen && currentPlan}`)。
- 消息气泡:用户用 `var(--user-bubble)` + 右下小圆角;助手无气泡底色、正文 `var(--text-soft)`
- reasoning `<details>``.cap.info` 风格摘要;工具调用沿用 ToolCallCardTask 3.2 重制)。
- 流式 turnTurnView下方显示 `▲ tok/s`(可复用 ActivitySpine 的速率逻辑,或简单显示 `status`)。
- 输入区composer容器 `var(--panel)` + `var(--line-strong)` 边框;发送按钮 `.primary`(琥珀);连接状态点用 `--signal`/`--warning`
- 会话项激活态:左边框 `var(--accent)` + `var(--panel-2)` 底。
- 头部操作、Todo 侧栏沿用现有结构,仅换 tokens。
- [ ] **Step 3: 验证**`cd webui && npm run check && npm run build`
- [ ] **Step 4: 端到端目检**`cargo run -- gateway` + 浏览器:新建对话、发消息、收到流式回复(活动脊出现 STREAMING、工具卡片折叠展开、/ 命令补全、附件上传、Todo 侧栏随 plan_updated 弹出、切换亮/暗主题聊天页正常。
- [ ] **Step 5: Commit**`git add webui/src/pages/ChatPage.svelte && git commit -m "feat(webui): refactor chat page onto global client and Signal Deck"`
### Task 3.2: 重制共享组件ToolCallCard / TurnView / Toast / Markdown
**Files:**
- Modify: `webui/src/lib/ToolCallCard.svelte``TurnView.svelte``Toast.svelte``Markdown.svelte`
`StatusBadge.svelte` 本身无 `<style>`,其颜色来自 `styles.css``.badge.*`,已在 Task 1.2 处理,不在此列。)
- [ ] **Step 1: ToolCallCard** — 默认折叠卡片:左边框运行中=`var(--accent)`(脉冲点)、完成=`var(--signal)`、失败=`var(--danger)`;名称/耗时用 `.mono`;展开显示参数与结果(`<details>`)。
- [ ] **Step 2: TurnView** — 流式渲染 reasoning折叠+ 正文 + 工具卡片 + 光标(`.pulse-dot` 或方块闪烁)+ `▲ tok/s`
- [ ] **Step 3: Toast / Markdown** — 套用 tokensToast 用 `var(--overlay)` + 对应语义色边框Markdown 的 code/pre 用 `var(--code-bg)` + `var(--font-mono)`,链接用 `var(--info)`
- [ ] **Step 4: 验证**`npm run check && npm run build`;目检聊天流中的卡片/Toast/代码块。
- [ ] **Step 5: Commit**`git add webui/src/lib && git commit -m "style(webui): shared components Signal Deck"`
### Task 3.3: P0 收尾验证
- [ ] **Step 1: 全量构建与测试**
Run: `cd webui && npm run check && npm run build`
Run: `cargo build`
Run: `cargo test --lib`
Run: `cargo clippy --all-targets --all-features -- -D warnings`
Expected: 全部通过。
- [ ] **Step 2: 回归目检清单** — 配对流程、主题切换持久化、活动脊实时性、聊天全链路(含附件/命令/计划)、既有 logs/memory/tasks/settings 页面在新 tokens 下无样式崩坏。
- [ ] **Step 3: 版本号** — 按 AGENTS.md「功能变化后更新版本号」`Cargo.toml``webui/package.json` bump minor如 1.3.0 → 1.4.0),并同步 README 中对 WebUI 的描述(如有)。
- [ ] **Step 4: Commit**`git add -A && git commit -m "chore(release): P0 webui foundation"`
---
## P0 完成标志
- 单二进制 `cargo build` 成功,字体经 `/fonts/*` 同源提供,无 CDN。
- 亮/暗双主题覆盖外壳与聊天页,活动脊全局可见且随 turn 实时变化。
- 聊天页功能与重构前完全一致(会话/消息/流式/工具/附件/命令/计划),仅视觉与连接归属变化。
- `npm run check``npm run build``cargo build``cargo test --lib``cargo clippy -- -D warnings` 全绿。
后续 P1观测Metrics + /api/status + 概览页 + 工具&Skills 页、P2日志流式 + 记忆可写 + 任务页、P3配置编辑器将各自编写独立计划。

View File

@ -0,0 +1,304 @@
# PicoBot WebUI 全面重构设计
- 状态:设计已确认,待实现
- 日期2026-07-23
- 范围:前端(`webui/`)全面重构 + 必要的后端接口新增/调整(`src/gateway/`
## 1. 背景与目标
现有 WebUISvelte 5 + Bits UI随二进制嵌入已具备聊天、配置、记忆、任务、日志、主题切换等基础能力但视觉与交互体验一般且缺少运行状况观测、工具/Skill 浏览、实时日志等能力。本次重构目标:
1. 前端可直接与 PicoBot 沟通(聊天,已有,增强体验)
2. 可修改 PicoBot 各项配置(已有,增强)
3. 可观察 PicoBot 运行情况(**新增**:运行状况仪表盘)
4. 可查看工具列表、Skill 列表(**新增**
5. 可查看实时日志(已有轮询,**升级为流式**
6. 支持亮色/暗色的美观且易用的 UI**全面重设计**
7. 可查看并管理记忆、定时任务等信息(记忆**新增可编辑/可删除**
## 2. 约束与不变量
- **单二进制发布**:前端构建产物仍打包进二进制,运行时从内存提供(`build.rs` → Cargo `OUT_DIR``include_str!`/`include_bytes!`)。最终用户无需 Node.js。
- **无外部 CDN**:生产页面不加载任何 CDN 资源。现有 CSP 为 `default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'none'; frame-ancestors 'none'`。字体等资产必须同源内嵌。
- **设备鉴权**:所有管理 API 与 `/ws``AuthManager` 保护;新增端点与 `/ws/logs` 同样走现有设备鉴权。
- **聊天复用现有链路**:浏览器聊天继续使用 `/ws``cli_chat` 渠道,复用 dialog scope、每会话串行 worker、历史持久化、出站 lane 与 turn 快照。WebUI 不直接调用 Provider 或 SessionManager。
- **密钥安全**`/api/status` 等任何新响应不得包含密钥;日志脱敏在源头(不在日志中记录 secret
- **只读优先**:除配置(现有可写)与记忆写入(新增)外,其余新能力均为只读。
## 3. 总体方案
- **定位**:均衡控制台,但**聊天优先**——打开即聊天,其余功能区通过扁平导航平等直达;全局"活动脊"提供常驻运行感知。
- **技术栈**:继续使用 Svelte 5 + Bits UI + Vite不引入新框架或状态管理库。
- **推进方式**:一份总体设计 + 分阶段实现(见 §9
## 4. 设计系统Signal Deck
视觉方向为"仪表盘 / 工程仪器":石墨蓝基底 + 琥珀(活动)/青绿(健康)双信号色,数据全部等宽字体,顶部一条永远在呼吸的"活动脊"作为签名元素。
### 4.1 色彩 Tokens
暗色(石墨蓝基底):
| Token | Hex | 用途 |
|-------|-----|------|
| bg | `#0B1017` | 页面背景 |
| panel | `#0E1520` | 面板/卡片 |
| panel-2 | `#131C29` | 次级面板/悬停 |
| border | `#1D2733` | 边框 |
| border-strong | `#2C3A4C` | 强调边框/输入框 |
| text | `#E7ECF3` | 主文本 |
| text-soft | `#B8C4D4` | 次级文本 |
| muted | `#8FA3B8` | 辅助文本 |
| faint | `#5B6B7E` | 最弱文本/时间戳 |
| amber | `#FFB454` | 活动/进行中/警告 |
| teal | `#2DD4BF` | 健康/成功/只读 |
| danger | `#FF7B86` | 错误/危险/独占 |
| info | `#6AA6FF` | 信息/Timeline/思考 |
| code-bg | `#080C12` | 代码/日志底 |
亮色(冷纸白,信号色加深保证对比):
| Token | Hex | Token | Hex |
|-------|-----|-------|-----|
| bg | `#EEF1F5` | text | `#1A2230` |
| panel | `#FFFFFF` | text-soft | `#3D4B5E` |
| panel-2 | `#F4F6F9` | muted | `#5B6B7E` |
| border | `#D8DEE8` | faint | `#8494A8` |
| border-strong | `#C2CCD9` | amber | `#C47400`(填充 `#E08600` |
| teal | `#0D9488` | danger | `#D94354` |
| info | `#2F6FD0` | code-bg | `#F7F9FC` |
**关键规则**:亮色模式下"活动脊"仍为深色条(`#0E1520`),像物理仪器上的 LED 读数——两种主题下同一个记忆点,不做简单反色。
### 4.2 字体
- 展示 / UISpace Grotesk内嵌 woff2仅拉丁中文回落系统字体PingFang SC / Microsoft YaHei / Noto Sans SC
- 数据 / 等宽JetBrains Mono内嵌 woff2用于所有指标、日志、时间戳、small-caps 标签。
- 字号阶梯9pxsmall-caps 标签letter-spacing .12.16em/ 11pxcaption、日志/ 12.514px正文/ 16px小标题/ 19px标题/ 2432px指标数字
- 生产环境不加载 CDN字体以内嵌二进制资产提供见 §8.3)。
### 4.3 签名元素活动脊Activity Spine
全局置于每个页面顶部的等宽状态条,两种状态:
- **有 Turn 在跑**:琥珀脉冲点 + `TURN 042 · STREAMING` + 实时 `▲ tok/s``ctx``queue``ws`,右侧 `gen #N · uptime · version`
- **空闲**:青绿常亮点 + `IDLE` + 最近 turn 摘要。
Turn 实时状态来自聊天 WS 已有的 `turn_updated` 快照(本就实时推送,`WsOutbound::TurnUpdated`gen/uptime/version 等来自 `/api/status` 轮询。各字段来源:`▲ tok/s` 由前端对相邻 `turn_updated` 帧的 `usage.completion_tokens` 差值求导(快照本身不含速率字段);`ctx` 取自 `usage.prompt_tokens``queue`/`ws` 取自 `/api/status`
### 4.4 核心组件
按钮primary=amber / secondary / ghost / danger、状态徽标正常/活动中/异常/离线)、指标块(大等宽数字 + sparkline + 分段容量条、日志行level 着色)、输入框、工具调用卡片(默认折叠,运行中=琥珀脉冲、完成=青绿、表格行、标签页、Toast。图表统一手写 SVG sparkline / 分段仪表,不引入图表库。
## 5. 信息架构与应用外壳
- **导航**:左侧扁平导航——聊天(落地页)、概览、工具&Skills、日志、记忆、任务、配置底部网关状态 + 主题切换。
- **应用外壳**:全局持有聊天 WS 连接(使活动脊在每个页面可用)、主题状态(`localStorage` 持久化 + `prefers-color-scheme` 默认)、设备鉴权状态(未配对显示 PairingPage
- **页面清单**:聊天 / 概览 / 工具&Skills / 日志 / 记忆 / 任务 / 配置,外加 PairingPage鉴权
## 6. 页面设计
### 6.1 聊天页(落地页)
三栏布局:会话列表(搜索/新建/按日期分组/未读点)| 消息流 Todo 计划侧栏(默认收起,按需展开)。
- reasoning 与工具调用默认折叠为紧凑卡片(运行中=琥珀脉冲,完成=青绿)。
- 流式 turn 显示光标与 `▲ tok/s`,输入区出现"停止"按钮。
- 斜杠命令补全来自后端 `get_slash_commands`(不在前端硬编码命令表)。
- 附件走 HTTP 上传(`POST /api/chat/{client_id}/uploads`WS 只传 `upload_id`;历史附件经 `GET .../attachments/{index}` 下载,安全 MIME 白名单内联预览。
- 正常完成合并 `turn_committed` 增量校准历史,不整段重载;断线/失败/取消用 `SessionHistory` 校准。
### 6.2 概览页(运行仪表盘)
- 主状态条:`RUNNING`、运行代、uptime、版本、WS 连接数、后台任务数、上次重载。
- 指标块(带 sparkline会话数、今日 Token+费用)、工具调用(+运行中)、今日 Turns+p95 延迟)。
- Provider 表:名称、模型、状态、延迟 sparkline、今日用量、费用。
- 消息总线inbound/outbound/control 队列深度分段容量条、活跃 lane 数、调度器状态、MCP 连接。
- 渠道状态feishu / cli_chat 等连接状态。
- 调度器任务数、下次运行、7 天失败数。
- 实时活动流:最近 turn/memory/job 事件。
- 数据来自 `/api/status`,默认每 2s 轮询。
### 6.3 工具 & Skills 页
- 三个标签页:工具 / Skills / MCP。
- 工具卡片名称、来源builtin/mcp、描述、调用次数、**能力徽标**、可展开参数 schema。
- **能力标识**(来自 `Tool` trait
- `◇ 只读`teal= `read_only()`
- `⇉ 可并发`info= `read_only() && !exclusive()`(即 `concurrency_safe()`
- `△ 有副作用`amber= `!read_only()`
- `■ 独占`danger= `exclusive()`(如 bash
- 支持搜索 + 按能力筛选(全部/只读/可并发/有副作用/独占)+ 图例。
- Skills 标签名称、描述、always、来源目录。MCP 标签:服务器名 + 连接状态。
### 6.4 日志页(实时流式)
- 工具栏level 过滤(全部/INF/WRN/ERR、关键字搜索、暂停滚动、下载。
- 日志行:时间戳 + level 着色 + target + 消息,自动跟随尾部。
- 进入页面:`GET /api/logs`(保留)拉历史尾 → `/ws/logs` 接管实时;断线重连重新拉尾对齐。
- 顶部显示连接状态(实时推送中 · 行/分)。
### 6.5 记忆页(可编辑 / 可删除)
- **权限**Knowledge 与 Timeline 均可编辑、可删除。
- **大量条目展示**:统计条(总量/Knowledge/Timeline/覆盖会话)→ 语义搜索优先 → 分类/会话/排序筛选 → 日期分组高密度行(列表/卡片视图可切换)→ 虚拟滚动 + 分页加载("已显示 100 / 1,284 · 加载更多")。
- 行内编辑textarea + importance 调节 + 保存/取消(按 key upsert`updated_at` 自动刷新)。
- **删除警告分级**
- Knowledge普通确认"删除后不可恢复,影响后续召回")。
- Timeline**强警告**——"Timeline 是压缩后的历史上下文,删除后模型将永久失去该时段长期记忆且无法自动重建;原始消息仍保留在聊天历史,但不再进入模型上下文",按钮文案"我了解,确认删除"。
### 6.6 任务页
- 两个标签页:定时任务 / 后台任务。
- 定时任务表名称、cron 表达式、下次运行倒计时、上次运行、最近 10 次运行状态点(绿/琥珀/红)、启用状态;可展开运行记录(时间/耗时/摘要)。
- 后台子任务列表:名称、来源 session、运行中脉冲/完成、耗时。
- 只读浏览。
### 6.7 配置页(唯一可写页面之一)
- 标签页config.json / USER.md / AGENTS.md。
- config.jsonJSON 编辑器,密钥掩码(`********`,原样提交自动还原)、实时 JSON 校验 + default agent 有效性、未保存修改提示。
- 右侧配置大纲gateway/providers/agent/channels/memory/scheduler标注"重启生效""含密钥"。
- 重载状态卡:运行代、相位、上次重载结果。
- 操作:保存 / 保存并热重载 / 放弃修改。
- 复用现有 `GET/PUT /api/config``GET/PUT /api/profiles/{name}``POST /api/config/reload``GET /api/config/reload/status`
- 提示 host/port/workspace 与存储路径为进程级不变量,修改后热重载被拒绝、需重启。
## 7. 后端接口设计
### 7.1 新增端点总览
| 方法 | 路径 | 用途 | 数据来源 |
|------|------|------|----------|
| GET | `/api/status` | 运行状况快照 | `Metrics` + 各服务只读查询 |
| GET | `/api/tools` | 工具列表(含能力字段) | `ToolRegistry` |
| GET | `/api/skills` | Skill 列表 | `SkillsLoader` |
| PUT | `/api/memories/{key}` | 更新记忆 content/importance | `Storage::upsert_memory` |
| DELETE | `/api/memories/{key}` | 删除记忆 | `Storage::delete_memory` |
| WS | `/ws/logs` | 实时日志流 | tracing 广播层 |
所有新端点走现有设备鉴权,注册在 `src/gateway/mod.rs` 的 protected router。
### 7.2 `GET /api/status`
返回单一 JSON 快照,概览页每 2s 轮询:
```json
{
"generation": 7, "version": "1.3.0", "uptime_secs": 266400, "phase": "steady",
"ws_connections": 2, "background_tasks": 3,
"sessions": { "total": 14, "active_turns": 1 },
"metrics": { "tokens_today": 1204882, "cost_today": 0.84,
"tool_calls_today": 312, "turns_today": 87, "turn_latency_p95_ms": 4200 },
"bus": { "inbound": {"depth":0,"cap":32}, "outbound": {"depth":1,"cap":64},
"control": {"depth":0,"cap":64}, "active_lanes": 4 },
"providers": [ {"name":"openai","model":"gpt-4o","status":"ok",
"latency_ms":820,"tokens":980000,"cost":0.61} ],
"channels": [ {"name":"feishu","status":"connected","detail":"3 群"} ],
"scheduler": { "enabled": true, "jobs": 5, "failed_7d": 0 },
"mcp": [ {"name":"github","status":"connected"} ]
}
```
聚合来源分两类——**已有查询**与**需新增的内省接口**(后者是 P1 的真实后端工作量,不可当作现成只读查询):
已有 / 低成本可得:
- `reload`generation、相位现有 `ReloadStatus`
- `mcp::get_mcp_status()`MCP 服务器连接状态(现有全局状态注册表)
- `Scheduler` / Storage任务数、下次运行、7 天失败数(现有 Storage API
- `ChannelManager`:各渠道连接状态
- `Metrics`token/费用/工具调用/turn/延迟(见 §7.3,新增)
**需新增的内省接口**(当前代码无对应查询面):
- `MessageBus`:三条队列的深度与容量。现状只有 publish/consume且未保留配置容量`src/bus/mod.rs`)。实现上让 bus 保留各队列 `mpsc::Sender`/容量,深度由 `max_capacity() - capacity()` 派生tokio `mpsc::Sender` 提供这两个方法)。
- `OutboundDispatcher`:活跃 lane 数。现状只有 `new`/`run``src/bus/dispatcher.rs`),需新增计数查询。
- `TaskSupervisor`:运行中任务数。现状无查询面(`src/task_supervisor.rs`),需新增。
- `SessionManager`:会话总数与活动 Turn 数(确认现有方法是否足够,不足则补只读统计)。
- WebSocket 连接数(`ws_connections`):当前无连接计数器,需在 `ws_handler` 用一个 `Arc<AtomicUsize>` 在连接建立/断开时增减。
这些内省方法必须轻量、非阻塞(不加锁等待慢操作),以支撑每 2s 轮询。
**不含任何密钥**provider api_key 等一律不出现)。
### 7.3 指标采集(`Metrics`
- 新增 `Metrics` 结构原子计数为主tokens in/out、cost、per-tool 调用数、turns、per-provider 延迟与错误滚动窗口。
- 由 `AgentLoop` / Provider 在每次 turn / 工具调用时经 `Arc<Metrics>` 更新。
- 纯内存、不持久化、重启归零。"今日"统计为自进程启动起的滚动窗口(文档与 UI 注明,不暗示自然日)。
- provider 状态ok/降级)由最近错误率派生。
### 7.4 `WS /ws/logs`
- 给 tracing 增加一个广播层:格式化日志记录后发送到 `tokio::sync::broadcast`(容量约 1024慢客户端丢旧lag不反压。无订阅者时发送为 no-op近乎零开销。
- handler 连接后订阅,按查询参数 `level` / `search` 过滤,推送 `{ts, level, target, message}` 帧。
- 修改 `src/logging` 的订阅器初始化以挂载该广播层(保持文件轮转不变)。
- `GET /api/logs`(文件尾)保留,用于进入页面时拉取历史与重连对齐。
### 7.5 记忆写入端点
- `PUT /api/memories/{key}`body `{content, importance?}`,按 key upsert复用 `Storage::upsert_memory``updated_at` 自动刷新。
- `DELETE /api/memories/{key}`:复用 `Storage::delete_memory`
- path 中的 key 需 URL 解码;实现时校验 key 存在性,返回 404 若不存在。
- 现有 `GET /api/memories`list/search含 category/session/limit/query保留不变。
### 7.6 工具 / Skills 端点
- `GET /api/tools`:遍历 `ToolRegistry`,每项返回 `name, description, parameters_schema, source(builtin|mcp), read_only, exclusive, concurrency_safe, call_count``call_count` 取自 `Metrics` 的 per-tool 计数。
- `GET /api/skills`:数据源为 `SkillsLoader::get_loaded_skills()`(返回完整 `Skill { name, description, content, always, path }``src/skills/mod.rs`**不要**用 `list_skills()`,它只返回 `(name, description)` 二元组,缺少 `always`/`path`。返回 `name, description, always, source`,其中 `source``Skill.path` 所在目录派生(无独立来源字段)。默认不返回完整 `content`(可能较大)。
## 8. 前端架构
### 8.1 目录与数据层
- 保持 Svelte 5 runes`src/lib/api.js` 扩展为按域划分的客户端模块(如 `api/status.js``api/tools.js``api/memories.js`),不引入状态管理库。
- 组件库 `src/lib/`:在现有 `Markdown.svelte``ToolCallCard.svelte``TurnView.svelte``Toast.svelte``StatusBadge.svelte` 基础上,新增 Signal Deck 组件ActivitySpine、MetricTile、Sparkline、CapacityMeter、LogStream、BadgeSet 等)。
- 页面 `src/pages/`:重构 ChatPage、新增 OverviewPage、ToolsPage、重写 LogsPage、重构 MemoryPage、重构 TasksPage、重构 SettingsPage、保留 PairingPage。
### 8.2 主题
- 设计 tokens 以 CSS 自定义属性表达:`:root`(暗色)与 `:root[data-theme="light"]`(亮色),替换现有 `styles.css` 的变量集。
- 主题切换持久化到 `localStorage`,默认跟随 `prefers-color-scheme`
### 8.3 字体内嵌(构建管线变更)
- 字体文件latin 子集 woff2取自 @fontsource)放入 `webui/public/fonts/`。Vite 默认 `publicDir` 会把 `public/` 内容**原样、固定名**复制到产物根(`OUT_DIR/webui/fonts/*.woff2`),无需改 `vite.config.js``assetFileNames`
- `http.rs`:新增 `/fonts/{name}` 路由,用 `include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/...woff2"))` 嵌入(静态 name→bytes 映射),返回 `Content-Type: font/woff2` 与长期缓存头;属公开静态资源层(与 app.js/styles.css 同级,不进设备鉴权)。
- CSP现有 `default-src 'self'` 已允许同源字体font-src 回落到 default-src无需放宽。
- 二进制体积增量约 100150KBSpace Grotesk + JetBrains Mono可考虑子集化
- `build.rs``rerun-if-changed` 需追加 `webui/public`;依赖 stamp 逻辑不变。
### 8.4 全局 WS 与活动脊
- 聊天 WS 连接提升到应用外壳层App.svelte使活动脊在所有页面可用。
- 活动脊消费 WS 的 turn 快照得到实时 Turn 状态;其余字段轮询 `/api/status`
## 9. 分阶段实现
- **P0 地基**设计系统tokens/组件库/双主题/字体内嵌)+ 应用外壳(扁平导航 + 全局活动脊 + 主题/鉴权)+ 聊天页重构。
- **P1 观测**`Metrics` + `GET /api/status` + 概览页 + `GET /api/tools`/`/api/skills` + 工具&Skills 页。
- **P2 日志与数据**tracing 广播层 + `/ws/logs` + 日志页 + 记忆写入端点 + 记忆页重构 + 任务页重构。
- **P3 配置**:配置编辑器重构 + 配置大纲 + profile + reload 状态可视化。
每阶段独立可验证;前端改动须过 `npm run check` + `npm run build` + `cargo build`(验证 OUT_DIR 嵌入Rust 改动须过定向测试 + `cargo test --lib` + `cargo clippy --all-targets --all-features -- -D warnings`
## 10. 风险与开放项
- **字体内嵌**构建管线需同时支持文本include_str!与二进制include_bytes!)资产;需在实现期验证 vite 固定名输出与 Cargo 嵌入路径。若字体子集化复杂,可退回系统字体栈(牺牲部分排版个性)。
- **`Metrics` 侵入性**:在 AgentLoop/Provider 埋点需避免持锁慢操作,遵循"不在持锁时做网络/模型/DB 慢操作"的不变量;计数用原子操作。
- **`/api/status` 聚合成本**:每 2s 轮询,聚合多个服务的只读查询;需确保各查询轻量、不加锁阻塞。必要时缓存短 TTL 快照。
- **tracing 广播层**:需保证无订阅者时零开销、有订阅者时不阻塞日志写入;广播满时丢旧而非阻塞。
- **记忆 key 路由**key 可能含特殊字符URL 编解码与 404 语义需在实现期明确。
- **Timeline 删除语义**UI 已用强警告;后端不做额外保护(用户拥有自己的 Agent但删除为幂等硬删除。
## 11. 验收标准
- 单二进制 `cargo build` 成功WebUI 从内存提供,无外部 CDN 依赖。
- 亮/暗双主题完整覆盖所有页面与组件。
- 聊天页保留现有全部能力dialog scope、历史持久化、turn 快照、斜杠补全、附件、Todo 侧栏)。
- 概览页实时反映运行状况;活动脊在所有页面可见且实时。
- 工具页正确展示 read_only/exclusive/concurrency_safe 能力标识。
- 日志页实时流式推送,支持 level/搜索过滤与暂停。
- 记忆页支持 Knowledge/Timeline 编辑与删除,删除警告分级,大量条目下虚拟滚动流畅。
- 配置页可编辑、密钥掩码、热重载状态可视。
- 所有新端点受设备鉴权保护,响应不含密钥。
- `npm run check``npm run build``cargo build``cargo test --lib``cargo clippy -- -D warnings` 全部通过。

View File

@ -3,7 +3,7 @@
配置文件加载顺序:`~/.picobot/config.json` → 当前目录 `./config.json`
占位符 `<VAR_NAME>` 从启动环境替换。PicoBot 依次加载 `config.json` 同目录的 `.env``workspace_dir/.env`最后保留启动进程已有环境变量作为最高优先级workspace 层覆盖配置目录层。合并值也会进入进程环境,供 MCP 和工具子进程继承。workspace `.env` 不能修改用于定位自身的 `workspace_dir`
Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取时 API Key、secret、password 和 token 会显示为 `********`,保持掩码不变再保存会保留原值;写入采用同目录临时文件替换。运行配置保存后需要重启 Gateway`USER.md``AGENTS.md` 的修改用于后续构建的 Agent 上下文。
Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取时 API Key、secret、password 和 token 会显示为 `********`,保持掩码不变再保存会保留原值;写入采用同目录临时文件替换。运行配置保存后可执行 `picobot reload`、发送 `/reload`,或由根交互 Agent 在用户明确要求时调用 `reload_config` 工具。Gateway 会先校验候选配置,停止接收新工作并等待交互 Turn、Scheduler job 和后台子 Agent 到达安全边界后切换;失败时继续使用旧配置。`GET /api/config/reload/status` 可查询 generation、相位与最近错误。`gateway.host``gateway.port``workspace_dir``gateway.session_db_path` 的有效路径必须通过完整重启变更。`USER.md``AGENTS.md` 的修改用于后续构建的 Agent 上下文。
## config.json 结构
@ -105,7 +105,7 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
| `media_dir_max_bytes` | int | 536870912 | 飞书媒体目录容量上限;达到上限后拒绝新下载,不自动删除旧文件 |
| `request_timeout_secs` | int | 30 | 单次飞书 HTTP 请求及响应体读取的硬超时,运行时限制在 5120 秒 |
飞书属于外部渠道:无论是否开启实时卡片,都不会接收模型 reasoning工具只显示紧凑状态。配置修改需重启 Gateway 生效。
飞书属于外部渠道:无论是否开启实时卡片,都不会接收模型 reasoning工具只显示紧凑状态。渠道配置可通过 Gateway 配置重载生效。
## mcp 字段

View File

@ -126,6 +126,7 @@ pub struct SubAgentManager {
skills_loader: Option<Arc<SkillsLoader>>,
work_manager: Option<Arc<crate::work::WorkManager>>,
task_supervisor: crate::task_supervisor::TaskSupervisor,
admission: crate::gateway::reload::RuntimeAdmission,
}
impl SubAgentManager {
@ -149,9 +150,18 @@ impl SubAgentManager {
skills_loader,
work_manager: None,
task_supervisor,
admission: crate::gateway::reload::RuntimeAdmission::open(),
}
}
pub(crate) fn with_admission(
mut self,
admission: crate::gateway::reload::RuntimeAdmission,
) -> Self {
self.admission = admission;
self
}
pub fn with_work_manager(mut self, work_manager: Arc<crate::work::WorkManager>) -> Self {
self.work_manager = Some(work_manager);
self
@ -164,7 +174,11 @@ impl SubAgentManager {
};
let filtered = ToolRegistry::new();
for (name, tool) in self.full_tools.iter() {
if allowed_set.contains(name.as_str()) && name != "delegate" && name != "todo" {
if allowed_set.contains(name.as_str())
&& name != "delegate"
&& name != "todo"
&& name != "reload_config"
{
filtered.register_raw(name, tool);
}
}
@ -324,6 +338,12 @@ impl SubAgentManager {
config: SubAgentConfig,
ctx: DelegateContext,
) -> Result<String, SubAgentError> {
let activity = self.admission.try_enter().ok_or_else(|| {
SubAgentError::Other(
"gateway is draining for configuration reload and cannot accept background tasks"
.to_string(),
)
})?;
let permit = self
.background_permits
.clone()
@ -423,6 +443,7 @@ impl SubAgentManager {
let spawned = self.task_supervisor.spawn_graceful(
format!("sub-agent:{task_id}"),
async move {
let _activity = activity;
let _permit = permit;
let started_at = chrono::Utc::now().timestamp_millis();
@ -851,4 +872,17 @@ mod tests {
assert!(matches!(error, SubAgentError::TooManyTasks(1)));
}
#[test]
fn reload_tool_is_never_delegated_to_sub_agents() {
let manager = manager(1);
manager
.full_tools
.register(crate::tools::ReloadConfigTool::new(
crate::gateway::reload::ReloadHandle::unavailable(),
));
let filtered = manager.filter_tools(&Some(vec!["reload_config".to_string()]));
assert!(filtered.get("reload_config").is_none());
}
}

View File

@ -98,6 +98,30 @@ fn gateway_http_base_url(gateway_url: &str) -> Result<String, Box<dyn std::error
Ok(url.to_string().trim_end_matches('/').to_string())
}
pub async fn reload_gateway(gateway_url: &str) -> Result<String, Box<dyn std::error::Error>> {
let base = gateway_http_base_url(gateway_url)?;
let mut request = reqwest::Client::new().post(format!("{base}/api/config/reload"));
if let Some(token) = load_auth_token() {
request = request.bearer_auth(token);
}
let response = request.send().await?;
let status = response.status();
let body: serde_json::Value = response.json().await?;
if !status.is_success() {
return Err(body
.get("error")
.and_then(serde_json::Value::as_str)
.unwrap_or("configuration reload failed")
.to_string()
.into());
}
Ok(body
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("configuration reload scheduled")
.to_string())
}
async fn exchange_pairing_code(
gateway_url: &str,
code: &str,

View File

@ -538,6 +538,32 @@ impl Config {
}
pub(crate) fn load_from(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
let process_env = collect_process_env();
Self::load_from_with_process_env(path, &process_env, true, None)
}
/// Reload configuration without mutating the process environment. The
/// supplied environment must be the process environment captured before
/// startup `.env` layers were installed, preserving the documented
/// precedence while keeping runtime reload thread-safe.
pub(crate) fn load_for_reload(
path: &Path,
startup_process_env: &HashMap<String, String>,
startup_cwd: &Path,
) -> Result<Self, Box<dyn std::error::Error>> {
Self::load_from_with_process_env(path, startup_process_env, false, Some(startup_cwd))
}
pub(crate) fn startup_process_env() -> HashMap<String, String> {
collect_process_env()
}
fn load_from_with_process_env(
path: &Path,
process_env: &HashMap<String, String>,
apply_to_process: bool,
workspace_base: Option<&Path>,
) -> Result<Self, Box<dyn std::error::Error>> {
let config_path = if path.exists() {
path.to_path_buf()
} else {
@ -554,7 +580,6 @@ impl Config {
};
let content = fs::read_to_string(&config_path)?;
let process_env = collect_process_env();
let config_env_path = config_path
.parent()
.unwrap_or_else(|| Path::new("."))
@ -563,14 +588,19 @@ impl Config {
// The config-directory layer selects the workspace. Loading the workspace
// layer first would be circular because its location comes from config.json.
let initial_env = merge_env_layers(&config_env, &HashMap::new(), &process_env);
let initial_env = merge_env_layers(&config_env, &HashMap::new(), process_env);
let initial_content = resolve_env_placeholders(&content, &initial_env);
let initial_config: Config = serde_json::from_str(&initial_content)?;
let workspace_path = expand_path(&initial_config.workspace_dir);
let mut workspace_path = expand_path(&initial_config.workspace_dir);
if workspace_path.is_relative()
&& let Some(base) = workspace_base
{
workspace_path = base.join(workspace_path);
}
let workspace_env_path = workspace_path.join(".env");
let workspace_env = read_env_file(&workspace_env_path)?;
let effective_env = merge_env_layers(&config_env, &workspace_env, &process_env);
let effective_env = merge_env_layers(&config_env, &workspace_env, process_env);
let resolved_content = resolve_env_placeholders(&content, &effective_env);
let config: Config = serde_json::from_str(&resolved_content)?;
if config.workspace_dir != initial_config.workspace_dir {
@ -581,7 +611,9 @@ impl Config {
.into());
}
apply_env_layers(&config_env, &workspace_env, &process_env);
if apply_to_process {
apply_env_layers(&config_env, &workspace_env, process_env);
}
tracing::info!(
path = %config_path.display(),
config_env = %config_env_path.display(),

View File

@ -78,6 +78,48 @@ pub async fn webui_styles() -> Response {
)
}
pub async fn webui_theme_init() -> Response {
static_response(
"text/javascript; charset=utf-8",
include_str!(concat!(env!("OUT_DIR"), "/webui/theme-init.js")),
)
}
const EMBEDDED_FONTS: &[(&str, &[u8])] = &[
(
"space-grotesk-500.woff2",
include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/space-grotesk-500.woff2")),
),
(
"space-grotesk-700.woff2",
include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/space-grotesk-700.woff2")),
),
(
"jetbrains-mono-400.woff2",
include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/jetbrains-mono-400.woff2")),
),
(
"jetbrains-mono-700.woff2",
include_bytes!(concat!(env!("OUT_DIR"), "/webui/fonts/jetbrains-mono-700.woff2")),
),
];
pub async fn webui_font(Path(name): Path<String>) -> Response {
let bytes = EMBEDDED_FONTS
.iter()
.find(|(font_name, _)| *font_name == name)
.map(|(_, bytes)| *bytes);
let Some(bytes) = bytes else {
return StatusCode::NOT_FOUND.into_response();
};
Response::builder()
.header(header::CONTENT_TYPE, "font/woff2")
.header(header::CACHE_CONTROL, "public, max-age=31536000, immutable")
.header("X-Content-Type-Options", "nosniff")
.body(Body::from(bytes))
.expect("valid font response")
}
#[derive(Debug)]
pub struct ApiError {
status: StatusCode,
@ -106,6 +148,20 @@ impl ApiError {
}
}
fn conflict(message: impl Into<String>) -> Self {
Self {
status: StatusCode::CONFLICT,
message: message.into(),
}
}
fn service_unavailable(message: impl Into<String>) -> Self {
Self {
status: StatusCode::SERVICE_UNAVAILABLE,
message: message.into(),
}
}
fn internal(error: impl std::fmt::Display) -> Self {
tracing::error!(error = %error, "WebUI API request failed");
Self {
@ -344,6 +400,36 @@ pub struct ConfigResponse {
restart_required: bool,
}
#[derive(Serialize)]
pub struct ReloadResponse {
generation: u64,
message: String,
}
pub async fn reload_config(
State(state): State<Arc<GatewayState>>,
) -> Result<Json<ReloadResponse>, ApiError> {
let accepted = state.reload.request().await.map_err(|error| match error {
super::reload::ReloadError::AlreadyPending => ApiError::conflict(error.to_string()),
super::reload::ReloadError::ShuttingDown
| super::reload::ReloadError::PreparationFailed(_) => {
ApiError::service_unavailable(error.to_string())
}
super::reload::ReloadError::InvalidConfig(_)
| super::reload::ReloadError::ImmutableField(_) => ApiError::bad_request(error.to_string()),
})?;
Ok(Json(ReloadResponse {
generation: accepted.generation,
message: accepted.message,
}))
}
pub async fn reload_status(
State(state): State<Arc<GatewayState>>,
) -> Json<super::reload::ReloadStatus> {
Json(state.reload.status())
}
pub async fn get_config(
State(state): State<Arc<GatewayState>>,
) -> Result<Json<ConfigResponse>, ApiError> {
@ -389,7 +475,7 @@ pub async fn put_config(
let pretty = serde_json::to_string_pretty(&incoming).map_err(ApiError::internal)? + "\n";
atomic_write(&state.config_path, pretty.as_bytes()).await?;
tracing::info!(path = %state.config_path.display(), "Configuration updated from WebUI; restart required");
tracing::info!(path = %state.config_path.display(), "Configuration updated from WebUI; reload or restart required");
let mut response = incoming;
redact_secrets(&mut response);

View File

@ -1,5 +1,6 @@
pub mod auth;
pub mod http;
pub(crate) mod reload;
mod router;
pub mod uploads;
pub mod ws;
@ -32,20 +33,33 @@ pub struct GatewayState {
pub connection_shutdown: tokio_util::sync::CancellationToken,
pub auth: auth::AuthManager,
pub uploads: uploads::UploadRegistry,
pub(crate) reload: reload::ReloadHandle,
pub(crate) admission: reload::RuntimeAdmission,
}
impl GatewayState {
/// Construct a standalone state. Configuration reload is available only
/// when the state is owned by [`run`], which owns the generation loop.
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
let config_path = crate::config::resolve_default_config_path();
let config = Config::load_from(&config_path)?;
Self::from_config(config, config_path).await
Self::from_config(
config,
config_path,
reload::ReloadHandle::unavailable(),
true,
)
.await
}
async fn from_config(
config: Config,
config_path: std::path::PathBuf,
reload: reload::ReloadHandle,
initialize_process: bool,
) -> Result<Self, Box<dyn std::error::Error>> {
let task_supervisor = TaskSupervisor::new();
let admission = reload::RuntimeAdmission::open();
let delivery_coordinator = DeliveryCoordinator::new(ConversationWriteLocks::default());
let connection_shutdown = tokio_util::sync::CancellationToken::new();
let auth = auth::AuthManager::load(
@ -59,19 +73,24 @@ impl GatewayState {
let workspace_path = expand_path(&config.workspace_dir);
let workspace_path = ensure_workspace_dir(&workspace_path)?;
// Switch current working directory to workspace
std::env::set_current_dir(&workspace_path).map_err(|e| {
format!(
"Failed to switch to workspace directory {}: {}",
workspace_path.display(),
e
)
})?;
if initialize_process {
// Startup is single-threaded. Reload candidates reuse the already
// selected workspace and must not mutate process-global cwd.
std::env::set_current_dir(&workspace_path).map_err(|e| {
format!(
"Failed to switch to workspace directory {}: {}",
workspace_path.display(),
e
)
})?;
}
tracing::info!("Using workspace directory: {}", workspace_path.display());
// Release default AGENTS.md and USER.md to ~/.picobot/ if not exist
ensure_default_config_files();
if initialize_process {
ensure_default_config_files();
}
// Get provider config for SessionManager
let mut provider_config = config.get_provider_config("default")?;
@ -141,7 +160,9 @@ impl GatewayState {
memory_manager,
task_supervisor.clone(),
turn_delivery,
),
reload.clone(),
)
.with_admission(admission.clone()),
browser_config,
config.gateway.max_concurrent_background_tasks,
)?;
@ -160,21 +181,6 @@ impl GatewayState {
valid_channels.clone(),
));
// Initialize MCP servers — connect and register discovered tools
if !config.mcp.servers.is_empty() {
let mcp_tools = mcp::connect_all(&config.mcp).await;
for tool_info in mcp_tools {
let wrapper = mcp::McpToolWrapper::new(
&tool_info.server_name,
tool_info.tool_name,
tool_info.description,
tool_info.schema,
tool_info.connection,
);
session_manager.tools().register(wrapper);
}
}
// Initialize scheduler if enabled in config
let scheduler_config = config.gateway.scheduler.clone().unwrap_or_default();
if scheduler_config.enabled {
@ -225,6 +231,8 @@ impl GatewayState {
connection_shutdown,
auth,
uploads,
reload,
admission,
})
}
@ -240,6 +248,21 @@ impl GatewayState {
/// Start the message processing loops
pub async fn start_message_processing(&self) {
// MCP connections have external/process-wide side effects. Activate
// them only after this generation becomes current, never while it is
// merely a reload candidate.
let mcp_tools = mcp::connect_all(&self.config.mcp).await;
for tool_info in mcp_tools {
let wrapper = mcp::McpToolWrapper::new(
&tool_info.server_name,
tool_info.tool_name,
tool_info.description,
tool_info.schema,
tool_info.connection,
);
self.session_manager.tools().register(wrapper);
}
let bus = self.bus();
let bus_for_outbound = bus.clone();
let session_manager = self.session_manager.clone();
@ -276,7 +299,12 @@ impl GatewayState {
}
});
router::spawn_message_routers(bus.clone(), session_manager, self.task_supervisor.clone());
router::spawn_message_routers(
bus.clone(),
session_manager,
self.task_supervisor.clone(),
self.admission.clone(),
);
// Spawn outbound dispatcher
let dispatcher = OutboundDispatcher::new(
@ -295,10 +323,11 @@ impl GatewayState {
// Spawn scheduler background task if enabled
let scheduler_config = self.config.gateway.scheduler.clone().unwrap_or_default();
if scheduler_config.enabled {
let sched = Arc::new(Scheduler::new(
let sched = Arc::new(Scheduler::with_admission(
self.storage.clone(),
self.session_manager.clone(),
scheduler_config,
self.admission.clone(),
));
self.task_supervisor.spawn("scheduler", async move {
sched.run().await;
@ -313,30 +342,230 @@ pub async fn run(
port: Option<u16>,
) -> Result<(), Box<dyn std::error::Error>> {
let config_path = crate::config::resolve_default_config_path();
let startup_process_env = Config::startup_process_env();
let startup_cwd = std::env::current_dir()?;
let config = Config::load_from(&config_path)?;
// Initialize logging
logging::init_logging();
tracing::info!(config_path = %config_path.display(), "Starting PicoBot Gateway");
let state = Arc::new(GatewayState::from_config(config, config_path).await?);
// Start all channels (init already done while constructing GatewayState)
state.channel_manager.start_all().await?;
// Start message processing (inbound processor + control processor + outbound dispatcher)
state.start_message_processing().await;
let mut reload_controller = reload::ReloadController::new(startup_process_env, startup_cwd);
let mut state = Arc::new(
GatewayState::from_config(
config,
config_path.clone(),
reload_controller.handle.clone(),
true,
)
.await?,
);
// CLI args override config file values
let bind_host = host.unwrap_or_else(|| state.config.gateway.host.clone());
let bind_port = port.unwrap_or(state.config.gateway.port);
let addr = format!("{}:{}", bind_host, bind_port);
let listener = std::net::TcpListener::bind(&addr)?;
listener.set_nonblocking(true)?;
tracing::info!(address = %addr, "Gateway listening");
let process_signal = wait_for_shutdown_signal();
tokio::pin!(process_signal);
let mut current_generation = 1_u64;
loop {
if let Err(error) = state.channel_manager.start_all().await {
reload_controller.set_failed(current_generation, error.to_string());
return Err(error.into());
}
state.start_message_processing().await;
reload_controller.set_phase(current_generation, reload::ReloadPhase::Active);
let app = build_router(state.clone());
let generation_listener = TcpListener::from_std(listener.try_clone()?)?;
let generation_shutdown = tokio_util::sync::CancellationToken::new();
let shutdown_wait = generation_shutdown.clone();
let mut serve_task = tokio::spawn(async move {
axum::serve(
generation_listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async move { shutdown_wait.cancelled().await })
.await
});
let mut next_state = None;
let mut serve_result = None;
'generation: loop {
tokio::select! {
result = &mut serve_task => {
serve_result = Some(result);
state.connection_shutdown.cancel();
generation_shutdown.cancel();
break 'generation;
}
_ = &mut process_signal => {
tracing::info!("Shutdown signal received");
state.admission.close();
state.connection_shutdown.cancel();
generation_shutdown.cancel();
break 'generation;
}
request = reload_controller.receiver.recv() => {
let Some(request) = request else {
state.admission.close();
state.connection_shutdown.cancel();
generation_shutdown.cancel();
break 'generation;
};
let requested_generation = request.generation;
reload_controller.set_phase(requested_generation, reload::ReloadPhase::Preparing);
let candidate = match reload::load_candidate(
&config_path,
&reload_controller.startup_process_env,
&reload_controller.startup_cwd,
&state.config,
&state.workspace_dir,
) {
Ok(candidate) => candidate,
Err(error) => {
reload_controller.set_failed(requested_generation, error.to_string());
let _ = request.response.send(Err(error));
continue;
}
};
let preparation = GatewayState::from_config(
candidate,
config_path.clone(),
reload_controller.handle.clone(),
false,
);
tokio::pin!(preparation);
let prepared = match tokio::select! {
result = &mut preparation => Some(result),
result = &mut serve_task => {
serve_result = Some(result);
None
}
_ = &mut process_signal => None,
} {
None => {
let error = reload::ReloadError::ShuttingDown;
reload_controller.set_failed(requested_generation, error.to_string());
let _ = request.response.send(Err(error));
state.admission.close();
state.connection_shutdown.cancel();
generation_shutdown.cancel();
break 'generation;
}
Some(result) => match result {
Ok(prepared) => Arc::new(prepared),
Err(error) => {
let error = reload::ReloadError::PreparationFailed(format!(
"configuration reload failed: {error}"
));
reload_controller.set_failed(requested_generation, error.to_string());
let _ = request.response.send(Err(error));
continue;
}
}};
state.admission.close();
reload_controller.set_phase(requested_generation, reload::ReloadPhase::Draining);
let message = "配置校验通过Gateway 将在当前任务结束后切换到新配置。".to_string();
let _ = request.response.send(Ok(reload::ReloadAccepted {
generation: requested_generation,
message,
}));
let drain = async {
tokio::join!(
state.admission.wait_for_idle(),
state.session_manager.wait_until_idle(std::time::Duration::from_secs(60)),
)
};
let drain_result = tokio::select! {
result = tokio::time::timeout(std::time::Duration::from_secs(60), drain) => {
Some(matches!(result, Ok(((), true))))
}
result = &mut serve_task => {
serve_result = Some(result);
None
}
_ = &mut process_signal => None,
};
let Some(drained) = drain_result else {
reload_controller.set_failed(
requested_generation,
"gateway stopped while draining configuration reload",
);
state.connection_shutdown.cancel();
generation_shutdown.cancel();
break 'generation;
};
if !drained {
tracing::warn!("Reload drain period ended before all work became idle");
}
tracing::info!(path = %config_path.display(), "Switching to reloaded configuration");
reload_controller.set_phase(requested_generation, reload::ReloadPhase::Activating);
state.connection_shutdown.cancel();
generation_shutdown.cancel();
next_state = Some((prepared, requested_generation));
break 'generation;
}
}
}
if serve_result.is_none() {
serve_result =
match tokio::time::timeout(std::time::Duration::from_secs(10), &mut serve_task)
.await
{
Ok(result) => Some(result),
Err(_) => {
tracing::warn!("Aborting Axum generation after shutdown timeout");
serve_task.abort();
Some(serve_task.await)
}
};
}
if let Err(error) = state.channel_manager.stop_all().await {
tracing::error!(error = %error, "Failed to stop channels cleanly");
}
state.task_supervisor.cancel();
state
.task_supervisor
.shutdown(std::time::Duration::from_secs(10))
.await;
if let Some(result) = serve_result {
match result {
Ok(Ok(())) => {}
Ok(Err(error)) => return Err(error.into()),
Err(error) if error.is_cancelled() => {}
Err(error) => return Err(format!("Gateway server task failed: {error}").into()),
}
}
match next_state.take() {
Some((prepared, generation)) => {
state = prepared;
current_generation = generation;
}
None => break,
}
}
Ok(())
}
fn build_router(state: Arc<GatewayState>) -> Router {
let protected = Router::new()
.route("/api/health", routing::get(http::health))
.route(
"/api/config",
routing::get(http::get_config).put(http::put_config),
)
.route("/api/config/reload", routing::post(http::reload_config))
.route(
"/api/config/reload/status",
routing::get(http::reload_status),
)
.route(
"/api/profiles/{name}",
routing::get(http::get_profile).put(http::put_profile),
@ -360,45 +589,18 @@ pub async fn run(
auth::require_auth,
));
let app = Router::new()
Router::new()
.route("/", routing::get(http::webui_index))
.route("/app.js", routing::get(http::webui_script))
.route("/styles.css", routing::get(http::webui_styles))
.route("/theme-init.js", routing::get(http::webui_theme_init))
.route("/fonts/{name}", routing::get(http::webui_font))
.route("/health", routing::get(http::health))
.route("/api/auth/status", routing::get(auth::status))
.route("/api/auth/pair", routing::post(auth::pair))
.route("/api/auth/code", routing::post(auth::issue_code))
.merge(protected)
.with_state(state.clone());
let addr = format!("{}:{}", bind_host, bind_port);
let listener = TcpListener::bind(&addr).await?;
tracing::info!(address = %addr, "Gateway listening");
let connection_shutdown = state.connection_shutdown.clone();
let serve_result = axum::serve(
listener,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(async move {
wait_for_shutdown_signal().await;
tracing::info!("Shutdown signal received");
connection_shutdown.cancel();
})
.await;
// Stop external intake before waiting for internal work to finish.
if let Err(error) = state.channel_manager.stop_all().await {
tracing::error!(error = %error, "Failed to stop channels cleanly");
}
state.task_supervisor.cancel();
state
.task_supervisor
.shutdown(std::time::Duration::from_secs(10))
.await;
serve_result?;
Ok(())
.with_state(state)
}
async fn wait_for_shutdown_signal() {

486
src/gateway/reload.rs Normal file
View File

@ -0,0 +1,486 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, RwLock};
use serde::Serialize;
use tokio::sync::{Notify, mpsc, oneshot};
use crate::config::Config;
const RELOAD_QUEUE_CAPACITY: usize = 8;
#[derive(Clone)]
pub(crate) struct RuntimeAdmission {
inner: Arc<AdmissionInner>,
}
struct AdmissionInner {
accepting: AtomicBool,
active: AtomicUsize,
idle: Notify,
}
pub(crate) struct ActivityGuard {
admission: RuntimeAdmission,
}
impl RuntimeAdmission {
pub fn open() -> Self {
Self {
inner: Arc::new(AdmissionInner {
accepting: AtomicBool::new(true),
active: AtomicUsize::new(0),
idle: Notify::new(),
}),
}
}
pub fn try_enter(&self) -> Option<ActivityGuard> {
if !self.inner.accepting.load(Ordering::Acquire) {
return None;
}
self.inner.active.fetch_add(1, Ordering::AcqRel);
if !self.inner.accepting.load(Ordering::Acquire) {
self.leave();
return None;
}
Some(ActivityGuard {
admission: self.clone(),
})
}
pub fn close(&self) {
self.inner.accepting.store(false, Ordering::Release);
if self.inner.active.load(Ordering::Acquire) == 0 {
self.inner.idle.notify_waiters();
}
}
pub fn is_accepting(&self) -> bool {
self.inner.accepting.load(Ordering::Acquire)
}
pub async fn wait_for_idle(&self) {
loop {
let notified = self.inner.idle.notified();
if self.inner.active.load(Ordering::Acquire) == 0 {
return;
}
notified.await;
}
}
fn leave(&self) {
if self.inner.active.fetch_sub(1, Ordering::AcqRel) == 1 {
self.inner.idle.notify_waiters();
}
}
}
impl Drop for ActivityGuard {
fn drop(&mut self) {
self.admission.leave();
}
}
pub(crate) struct ReloadRequest {
pub generation: u64,
pub response: oneshot::Sender<Result<ReloadAccepted, ReloadError>>,
}
#[derive(Clone)]
pub struct ReloadHandle {
sender: mpsc::Sender<ReloadRequest>,
next_generation: Arc<AtomicU64>,
pending: Arc<AtomicBool>,
status: Arc<RwLock<ReloadStatus>>,
}
pub(crate) struct ReloadController {
pub handle: ReloadHandle,
pub receiver: mpsc::Receiver<ReloadRequest>,
pub startup_process_env: HashMap<String, String>,
pub startup_cwd: PathBuf,
status: Arc<RwLock<ReloadStatus>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReloadAccepted {
pub generation: u64,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReloadPhase {
Active,
Preparing,
Draining,
Activating,
Failed,
}
#[derive(Debug, Clone, Serialize)]
pub struct ReloadStatus {
pub generation: u64,
pub phase: ReloadPhase,
pub requested_at: Option<i64>,
pub activated_at: Option<i64>,
pub last_error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReloadError {
AlreadyPending,
ShuttingDown,
InvalidConfig(String),
ImmutableField(String),
PreparationFailed(String),
}
impl std::fmt::Display for ReloadError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::AlreadyPending => {
write!(formatter, "another configuration reload is already pending")
}
Self::ShuttingDown => write!(formatter, "gateway is shutting down"),
Self::InvalidConfig(error)
| Self::ImmutableField(error)
| Self::PreparationFailed(error) => formatter.write_str(error),
}
}
}
impl std::error::Error for ReloadError {}
impl ReloadController {
pub fn new(startup_process_env: HashMap<String, String>, startup_cwd: PathBuf) -> Self {
let (sender, receiver) = mpsc::channel(RELOAD_QUEUE_CAPACITY);
let status = Arc::new(RwLock::new(ReloadStatus {
generation: 1,
phase: ReloadPhase::Active,
requested_at: None,
activated_at: Some(chrono::Utc::now().timestamp_millis()),
last_error: None,
}));
Self {
handle: ReloadHandle {
sender,
next_generation: Arc::new(AtomicU64::new(2)),
pending: Arc::new(AtomicBool::new(false)),
status: status.clone(),
},
receiver,
startup_process_env,
startup_cwd,
status,
}
}
pub fn set_phase(&self, generation: u64, phase: ReloadPhase) {
let mut status = self
.status
.write()
.unwrap_or_else(|error| error.into_inner());
status.generation = generation;
status.phase = phase;
if phase == ReloadPhase::Preparing {
status.requested_at = Some(chrono::Utc::now().timestamp_millis());
status.activated_at = None;
status.last_error = None;
}
if phase == ReloadPhase::Active {
status.activated_at = Some(chrono::Utc::now().timestamp_millis());
self.handle.pending.store(false, Ordering::Release);
}
}
pub fn set_failed(&self, generation: u64, error: impl Into<String>) {
let mut status = self
.status
.write()
.unwrap_or_else(|error| error.into_inner());
status.generation = generation;
status.phase = ReloadPhase::Failed;
status.last_error = Some(error.into());
self.handle.pending.store(false, Ordering::Release);
}
}
impl ReloadHandle {
pub(crate) fn unavailable() -> Self {
let (sender, receiver) = mpsc::channel(1);
drop(receiver);
Self {
sender,
next_generation: Arc::new(AtomicU64::new(1)),
pending: Arc::new(AtomicBool::new(false)),
status: Arc::new(RwLock::new(ReloadStatus {
generation: 0,
phase: ReloadPhase::Failed,
requested_at: None,
activated_at: None,
last_error: Some("reload controller is unavailable".to_string()),
})),
}
}
pub async fn request(&self) -> Result<ReloadAccepted, ReloadError> {
self.pending
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.map_err(|_| ReloadError::AlreadyPending)?;
let generation = self.next_generation.fetch_add(1, Ordering::Relaxed);
let (response, receiver) = oneshot::channel();
if let Err(error) = self.sender.try_send(ReloadRequest {
generation,
response,
}) {
self.pending.store(false, Ordering::Release);
return Err(match error {
mpsc::error::TrySendError::Full(_) => ReloadError::AlreadyPending,
mpsc::error::TrySendError::Closed(_) => ReloadError::ShuttingDown,
});
}
match receiver.await {
Ok(result) => result,
Err(_) => {
self.pending.store(false, Ordering::Release);
Err(ReloadError::ShuttingDown)
}
}
}
pub fn status(&self) -> ReloadStatus {
self.status
.read()
.unwrap_or_else(|error| error.into_inner())
.clone()
}
}
pub(crate) fn load_candidate(
config_path: &Path,
startup_process_env: &HashMap<String, String>,
startup_cwd: &std::path::Path,
current: &Config,
current_workspace: &std::path::Path,
) -> Result<Config, ReloadError> {
let mut candidate = Config::load_for_reload(config_path, startup_process_env, startup_cwd)
.map_err(|error| {
ReloadError::InvalidConfig(format!("configuration reload failed: {error}"))
})?;
candidate
.get_provider_config("default")
.map_err(|error| ReloadError::InvalidConfig(format!("invalid default agent: {error}")))?;
if let Some(feishu) = candidate.channels.get("feishu")
&& feishu.enabled
&& (feishu.app_id.trim().is_empty() || feishu.app_secret.trim().is_empty())
{
return Err(ReloadError::InvalidConfig(
"enabled channels.feishu requires non-empty app_id and app_secret".to_string(),
));
}
let mut candidate_workspace = crate::config::expand_path(&candidate.workspace_dir);
if candidate_workspace.is_relative() {
candidate_workspace = startup_cwd.join(candidate_workspace);
}
let candidate_workspace = candidate_workspace
.canonicalize()
.unwrap_or(candidate_workspace);
if current_workspace != candidate_workspace {
return Err(ReloadError::ImmutableField(
"workspace_dir cannot be reloaded; restart the gateway".to_string(),
));
}
candidate.workspace_dir = current_workspace.to_string_lossy().to_string();
if effective_db_path(current, current_workspace)
!= effective_db_path(&candidate, current_workspace)
{
return Err(ReloadError::ImmutableField(
"gateway.session_db_path cannot be reloaded; restart the gateway".to_string(),
));
}
if current.gateway.host != candidate.gateway.host
|| current.gateway.port != candidate.gateway.port
{
return Err(ReloadError::ImmutableField(
"gateway.host and gateway.port cannot be reloaded; restart the gateway".to_string(),
));
}
Ok(candidate)
}
fn effective_db_path(config: &Config, workspace: &Path) -> PathBuf {
let path = config
.gateway
.session_db_path
.as_deref()
.map(crate::config::expand_path)
.unwrap_or_else(|| workspace.join("picobot.db"));
let path = if path.is_relative() {
workspace.join(path)
} else {
path
};
path.canonicalize().unwrap_or(path)
}
#[cfg(test)]
mod tests {
use super::*;
fn config_json(workspace: &std::path::Path, model_id: &str) -> String {
serde_json::json!({
"providers": {
"provider": {
"type": "openai",
"base_url": "https://example.invalid/v1",
"api_key": "test"
}
},
"models": { "model": { "model_id": model_id } },
"agents": {
"default": { "provider": "provider", "model": "model" }
},
"workspace_dir": workspace
})
.to_string()
}
#[test]
fn candidate_accepts_runtime_changes_and_rejects_workspace_changes() {
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
let config_path = temp.path().join("config.json");
let current: Config = serde_json::from_str(&config_json(&workspace, "old-model")).unwrap();
std::fs::write(
&config_path,
config_json(std::path::Path::new("workspace"), "new-model"),
)
.unwrap();
let candidate = load_candidate(
&config_path,
&HashMap::new(),
temp.path(),
&current,
&workspace,
)
.unwrap();
assert_eq!(
candidate.get_provider_config("default").unwrap().model_id,
"new-model"
);
let other_workspace = temp.path().join("other");
std::fs::create_dir_all(&other_workspace).unwrap();
std::fs::write(&config_path, config_json(&other_workspace, "new-model")).unwrap();
let error = load_candidate(
&config_path,
&HashMap::new(),
temp.path(),
&current,
&workspace,
)
.unwrap_err();
assert!(
error
.to_string()
.contains("workspace_dir cannot be reloaded")
);
}
#[test]
fn equivalent_default_database_paths_are_reloadable() {
let temp = tempfile::tempdir().unwrap();
let workspace = temp.path().join("workspace");
std::fs::create_dir_all(&workspace).unwrap();
std::fs::write(workspace.join("picobot.db"), []).unwrap();
let config_path = temp.path().join("config.json");
let current: Config = serde_json::from_str(&config_json(&workspace, "old-model")).unwrap();
let mut candidate: serde_json::Value =
serde_json::from_str(&config_json(&workspace, "new-model")).unwrap();
candidate["gateway"] = serde_json::json!({ "session_db_path": "./picobot.db" });
std::fs::write(&config_path, serde_json::to_vec(&candidate).unwrap()).unwrap();
load_candidate(
&config_path,
&HashMap::new(),
temp.path(),
&current,
&workspace,
)
.unwrap();
}
#[tokio::test]
async fn admission_closes_and_waits_for_existing_activity() {
let admission = RuntimeAdmission::open();
let activity = admission.try_enter().unwrap();
admission.close();
assert!(admission.try_enter().is_none());
let waiting = admission.wait_for_idle();
tokio::pin!(waiting);
assert!(
tokio::time::timeout(std::time::Duration::from_millis(10), &mut waiting)
.await
.is_err()
);
drop(activity);
tokio::time::timeout(std::time::Duration::from_secs(1), waiting)
.await
.unwrap();
}
#[tokio::test]
async fn one_reload_remains_pending_until_its_generation_is_terminal() {
let mut controller = ReloadController::new(HashMap::new(), PathBuf::from("."));
let handle = controller.handle.clone();
let first = tokio::spawn({
let handle = handle.clone();
async move { handle.request().await }
});
let request = controller.receiver.recv().await.unwrap();
assert_eq!(request.generation, 2);
request
.response
.send(Ok(ReloadAccepted {
generation: 2,
message: "accepted".to_string(),
}))
.unwrap();
first.await.unwrap().unwrap();
assert_eq!(
handle.request().await.unwrap_err(),
ReloadError::AlreadyPending
);
controller.set_phase(2, ReloadPhase::Active);
let next = tokio::spawn({
let handle = handle.clone();
async move { handle.request().await }
});
let request = controller.receiver.recv().await.unwrap();
assert_eq!(request.generation, 3);
controller.set_failed(3, "invalid candidate");
request
.response
.send(Err(ReloadError::InvalidConfig(
"invalid candidate".to_string(),
)))
.unwrap();
assert!(matches!(
next.await.unwrap(),
Err(ReloadError::InvalidConfig(_))
));
let status = handle.status();
assert_eq!(status.generation, 3);
assert_eq!(status.phase, ReloadPhase::Failed);
}
}

View File

@ -8,6 +8,7 @@ use tokio::sync::{Semaphore, mpsc};
use crate::bus::{ControlMessage, InboundMessage, MessageBus, OutboundMessage};
use crate::channels::ChannelError;
use crate::channels::parse_slash_command;
use crate::gateway::reload::{ActivityGuard, RuntimeAdmission};
use crate::session::{SessionCommand, SessionEvent, SessionManager};
use crate::task_supervisor::TaskSupervisor;
@ -19,8 +20,14 @@ pub(super) fn spawn_message_routers(
bus: Arc<MessageBus>,
session_manager: Arc<SessionManager>,
supervisor: TaskSupervisor,
admission: RuntimeAdmission,
) {
spawn_inbound_router(bus.clone(), session_manager.clone(), supervisor.clone());
spawn_inbound_router(
bus.clone(),
session_manager.clone(),
supervisor.clone(),
admission,
);
spawn_control_router(bus, session_manager, supervisor);
}
@ -28,11 +35,12 @@ fn spawn_inbound_router(
bus: Arc<MessageBus>,
session_manager: Arc<SessionManager>,
supervisor: TaskSupervisor,
admission: RuntimeAdmission,
) {
let lane_supervisor = supervisor.clone();
supervisor.spawn("inbound-router", async move {
tracing::info!(lane_capacity = INBOUND_LANE_CAPACITY, "Inbound router started");
let mut lanes: HashMap<String, mpsc::Sender<InboundMessage>> = HashMap::new();
let mut lanes: HashMap<String, mpsc::Sender<AdmittedInbound>> = HashMap::new();
let mut messages_seen = 0_u64;
while let Some(inbound) = bus.consume_inbound().await {
@ -41,12 +49,26 @@ fn spawn_inbound_router(
lanes.retain(|_, sender| !sender.is_closed());
}
let Some(activity) = admission.try_enter() else {
publish_command_output(
&bus,
inbound,
"Gateway 正在重新加载配置,请稍后重试。".to_string(),
)
.await;
continue;
};
let inbound = AdmittedInbound { inbound, activity };
// Stop must be able to invalidate a running worker even when an
// earlier slow slash command occupies this conversation's lane.
if is_priority_stop(&inbound.content) {
if is_priority_stop(&inbound.inbound.content) {
let request_bus = bus.clone();
let request_manager = session_manager.clone();
let task_name = format!("inbound-stop:{}:{}", inbound.channel, inbound.chat_id);
let task_name = format!(
"inbound-stop:{}:{}",
inbound.inbound.channel, inbound.inbound.chat_id
);
if !lane_supervisor.spawn(task_name, async move {
process_inbound(request_bus, request_manager, inbound).await;
}) {
@ -55,7 +77,7 @@ fn spawn_inbound_router(
continue;
}
let key = conversation_key(&inbound.channel, &inbound.chat_id);
let key = conversation_key(&inbound.inbound.channel, &inbound.inbound.chat_id);
let mut sender = lanes.get(&key).cloned();
if sender.as_ref().is_none_or(mpsc::Sender::is_closed) {
let (new_sender, receiver) = mpsc::channel(INBOUND_LANE_CAPACITY);
@ -63,8 +85,8 @@ fn spawn_inbound_router(
&lane_supervisor,
bus.clone(),
session_manager.clone(),
inbound.channel.clone(),
inbound.chat_id.clone(),
inbound.inbound.channel.clone(),
inbound.inbound.chat_id.clone(),
receiver,
) {
tracing::warn!("Inbound router is stopping");
@ -81,10 +103,10 @@ fn spawn_inbound_router(
match sender.try_send(inbound) {
Ok(()) => {}
Err(mpsc::error::TrySendError::Full(inbound)) => {
tracing::warn!(channel = %inbound.channel, chat_id = %inbound.chat_id, "Inbound conversation lane is full");
tracing::warn!(channel = %inbound.inbound.channel, chat_id = %inbound.inbound.chat_id, "Inbound conversation lane is full");
publish_command_output(
&bus,
inbound,
inbound.inbound,
"当前对话入口队列已满,请稍后重试。".to_string(),
)
.await;
@ -98,15 +120,15 @@ fn spawn_inbound_router(
&lane_supervisor,
bus.clone(),
session_manager.clone(),
inbound.channel.clone(),
inbound.chat_id.clone(),
inbound.inbound.channel.clone(),
inbound.inbound.chat_id.clone(),
receiver,
) {
break;
}
lanes.insert(key, new_sender.clone());
if let Err(error) = new_sender.try_send(inbound) {
tracing::error!(error = %error, "Failed to enqueue input into replacement lane");
if new_sender.try_send(inbound).is_err() {
tracing::error!("Failed to enqueue input into replacement lane");
}
}
}
@ -121,7 +143,7 @@ fn spawn_inbound_lane(
session_manager: Arc<SessionManager>,
channel: String,
chat_id: String,
receiver: mpsc::Receiver<InboundMessage>,
receiver: mpsc::Receiver<AdmittedInbound>,
) -> bool {
supervisor.spawn(format!("inbound-lane:{channel}:{chat_id}"), async move {
run_ordered_lane(receiver, INBOUND_LANE_IDLE_TIMEOUT, move |inbound| {
@ -152,8 +174,12 @@ async fn run_ordered_lane<T, F, Fut>(
async fn process_inbound(
bus: Arc<MessageBus>,
session_manager: Arc<SessionManager>,
inbound: InboundMessage,
admitted: AdmittedInbound,
) {
let AdmittedInbound {
inbound,
activity: _activity,
} = admitted;
let result = session_manager.handle_message(&inbound).await;
match result {
@ -172,14 +198,20 @@ async fn process_inbound(
}
async fn publish_assistant_output(bus: &MessageBus, inbound: InboundMessage, content: String) {
publish_output(bus, inbound, content, false).await;
publish_output(bus, inbound, content, false, false).await;
}
async fn publish_command_output(bus: &MessageBus, inbound: InboundMessage, content: String) {
publish_output(bus, inbound, content, true).await;
publish_output(bus, inbound, content, true, true).await;
}
async fn publish_output(bus: &MessageBus, inbound: InboundMessage, content: String, command: bool) {
async fn publish_output(
bus: &MessageBus,
inbound: InboundMessage,
content: String,
command: bool,
confirmed: bool,
) {
let mut metadata = inbound.channel_context.private;
if command {
metadata.insert("_type".to_string(), "command".to_string());
@ -193,11 +225,21 @@ async fn publish_output(bus: &MessageBus, inbound: InboundMessage, content: Stri
metadata,
delivery: None,
};
if let Err(error) = bus.publish_outbound(outbound).await {
let result = if confirmed {
bus.deliver_outbound(outbound).await
} else {
bus.publish_outbound(outbound).await
};
if let Err(error) = result {
tracing::error!(error = %error, "Failed to publish routed outbound message");
}
}
struct AdmittedInbound {
inbound: InboundMessage,
activity: ActivityGuard,
}
fn spawn_control_router(
bus: Arc<MessageBus>,
session_manager: Arc<SessionManager>,
@ -382,7 +424,10 @@ mod tests {
},
};
publish_command_output(&bus, inbound, "done".to_string()).await;
let publish_task = tokio::spawn({
let bus = bus.clone();
async move { publish_command_output(&bus, inbound, "done".to_string()).await }
});
let output = bus.consume_outbound().await.unwrap();
assert_eq!(output.reply_to.as_deref(), Some("parent"));
@ -394,6 +439,9 @@ mod tests {
output.metadata.get("_type").map(String::as_str),
Some("command")
);
assert!(!publish_task.is_finished());
output.complete_delivery(Ok(()));
publish_task.await.unwrap();
}
#[tokio::test]

View File

@ -56,6 +56,12 @@ enum Command {
#[arg(long)]
port: Option<u16>,
},
/// Reload a running gateway's configuration
Reload {
/// Gateway WebSocket or HTTP URL
#[arg(long)]
gateway_url: Option<String>,
},
/// Generate a one-time browser pairing code from the local gateway
Pair {
/// Gateway WebSocket or HTTP URL
@ -123,6 +129,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Command::Gateway { host, port } => {
picobot::gateway::run(host, port).await?;
}
Command::Reload { gateway_url } => {
let config = picobot::config::Config::load_default().ok();
let url = gateway_url
.or_else(|| config.as_ref().map(|c| c.client.gateway_url.clone()))
.unwrap_or_else(|| "ws://127.0.0.1:19876/ws".to_string());
println!("{}", picobot::client::reload_gateway(&url).await?);
}
Command::Pair {
gateway_url,
revoke_all,

View File

@ -97,6 +97,7 @@ pub struct Scheduler {
session_manager: Arc<SessionManager>,
config: SchedulerConfig,
owner: String,
admission: crate::gateway::reload::RuntimeAdmission,
}
impl Scheduler {
@ -104,12 +105,27 @@ impl Scheduler {
storage: Arc<Storage>,
session_manager: Arc<SessionManager>,
config: SchedulerConfig,
) -> Self {
Self::with_admission(
storage,
session_manager,
config,
crate::gateway::reload::RuntimeAdmission::open(),
)
}
pub(crate) fn with_admission(
storage: Arc<Storage>,
session_manager: Arc<SessionManager>,
config: SchedulerConfig,
admission: crate::gateway::reload::RuntimeAdmission,
) -> Self {
Self {
storage,
session_manager,
config,
owner: uuid::Uuid::new_v4().to_string(),
admission,
}
}
@ -132,6 +148,9 @@ impl Scheduler {
loop {
interval.tick().await;
if !self.admission.is_accepting() {
continue;
}
let now = now_ms();
let lease_ms = self
.config
@ -167,6 +186,16 @@ impl Scheduler {
}
async fn execute_claimed_job(self: Arc<Self>, job: ScheduledJob) {
let Some(_activity) = self.admission.try_enter() else {
if let Err(error) = self
.storage
.release_scheduled_job_lease(&job.id, &self.owner)
.await
{
tracing::error!(job_id = %job.id, error = %error, "scheduler: failed to release job claimed during reload drain");
}
return;
};
let start = Instant::now();
let started_at = now_ms();
tracing::info!(job_id = %job.id, job_name = %job.name, "scheduler: executing claimed job");

View File

@ -1428,6 +1428,7 @@ pub struct SessionManager {
sub_agent_manager: Arc<crate::agent::SubAgentManager>,
task_supervisor: crate::task_supervisor::TaskSupervisor,
turn_delivery: TurnDeliveryService,
reload: crate::gateway::reload::ReloadHandle,
}
/// Gateway-owned runtime services shared by all Session workers.
@ -1436,6 +1437,8 @@ pub struct SessionManagerServices {
memory_manager: Arc<crate::memory::MemoryManager>,
task_supervisor: crate::task_supervisor::TaskSupervisor,
turn_delivery: TurnDeliveryService,
reload: crate::gateway::reload::ReloadHandle,
admission: crate::gateway::reload::RuntimeAdmission,
}
impl SessionManagerServices {
@ -1444,14 +1447,25 @@ impl SessionManagerServices {
memory_manager: Arc<crate::memory::MemoryManager>,
task_supervisor: crate::task_supervisor::TaskSupervisor,
turn_delivery: TurnDeliveryService,
reload: crate::gateway::reload::ReloadHandle,
) -> Self {
Self {
bus,
memory_manager,
task_supervisor,
turn_delivery,
reload,
admission: crate::gateway::reload::RuntimeAdmission::open(),
}
}
pub(crate) fn with_admission(
mut self,
admission: crate::gateway::reload::RuntimeAdmission,
) -> Self {
self.admission = admission;
self
}
}
struct SessionManagerInner {
@ -1544,6 +1558,11 @@ pub static SLASH_COMMANDS: &[SlashCommand] = &[
description: "查看、完成或取消当前任务计划",
aliases: &["/todo"],
},
SlashCommand {
name: "reload",
description: "重新加载配置",
aliases: &["/reload"],
},
];
fn resolve_slash_command(command: &str) -> Option<&'static SlashCommand> {
@ -1581,6 +1600,8 @@ impl SessionManager {
memory_manager,
task_supervisor,
turn_delivery,
reload,
admission,
} = services;
let mut skills_loader = SkillsLoader::new();
skills_loader.load_skills();
@ -1608,9 +1629,11 @@ impl SessionManager {
Some(skills_loader.clone()),
task_supervisor.clone(),
)
.with_admission(admission)
.with_work_manager(work_manager.clone()),
);
tools.register(crate::tools::DelegateTool::new(sub_agent_manager.clone()));
tools.register(crate::tools::ReloadConfigTool::new(reload.clone()));
// Start background task notification consumer
let sm_bus = bus.clone();
@ -1669,6 +1692,7 @@ impl SessionManager {
sub_agent_manager,
task_supervisor,
turn_delivery,
reload,
})
}
@ -1689,11 +1713,12 @@ impl SessionManager {
/// 为定时任务创建一个无 session 绑定的 AgentLoop
pub fn create_cron_agent(&self) -> Result<AgentLoop, AgentError> {
let tools = self.tools.without(&["reload_config"]);
let provider = create_provider(self.provider_config.clone())
.map_err(|e| AgentError::Other(format!("failed to create cron provider: {}", e)))?;
Ok(AgentLoop::with_provider_and_tools(
Arc::from(provider),
self.tools.clone(),
tools,
self.provider_config.max_tool_iterations,
self.provider_config.model_id.clone(),
self.provider_config.workspace_dir.clone(),
@ -1710,6 +1735,7 @@ impl SessionManager {
"cron_remove",
"cron_enable",
"cron_disable",
"reload_config",
]);
let provider = create_provider(self.provider_config.clone())
.map_err(|e| AgentError::Other(format!("failed to create scheduled provider: {e}")))?;
@ -2081,6 +2107,12 @@ impl SessionManager {
},
}
}
"reload" => self
.reload
.request()
.await
.map(|accepted| (None, accepted.message))
.map_err(|error| AgentError::Other(error.to_string())),
_ => Err(AgentError::Other(format!(
"未知命令:/{}。输入 /? 获取帮助。",
cmd.name
@ -2088,6 +2120,43 @@ impl SessionManager {
}
}
/// Wait until all interactive session Turns have reached a terminal state.
/// A reload uses this to avoid cancelling the Turn that requested it.
pub async fn wait_until_idle(&self, timeout: std::time::Duration) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
let mut idle_since = None;
loop {
let sessions: Vec<_> = {
let inner = self.inner.lock().await;
inner.sessions.values().cloned().collect()
};
let mut busy = false;
for session in sessions {
let session = session.lock().await;
let queued = session
.agent_tx
.as_ref()
.is_some_and(|sender| sender.capacity() < sender.max_capacity());
if session.current_cancel.is_some() || queued {
busy = true;
break;
}
}
if busy {
idle_since = None;
} else {
let since = idle_since.get_or_insert_with(tokio::time::Instant::now);
if since.elapsed() >= std::time::Duration::from_millis(100) {
return true;
}
}
if tokio::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
}
pub async fn create_session(
&self,
channel: &str,
@ -3427,6 +3496,10 @@ mod slash_command_tests {
resolve_slash_command("/help").map(|command| command.name),
Some("?")
);
assert_eq!(
resolve_slash_command("reload").map(|command| command.name),
Some("reload")
);
assert!(resolve_slash_command("unknown").is_none());
}
}

View File

@ -17,6 +17,7 @@ pub mod memory;
pub mod path_utils;
pub mod pty;
pub mod registry;
pub mod reload_config;
pub mod schema;
pub mod send_message;
pub mod todo;
@ -39,6 +40,7 @@ pub use maintenance::RoutineMaintenanceTool;
pub use memory::{MemoryForgetTool, MemoryRecallTool, MemoryStoreTool, TimelineRecallTool};
pub use pty::{PtyManager, PtyTool};
pub use registry::ToolRegistry;
pub use reload_config::ReloadConfigTool;
pub use send_message::SendMessageTool;
pub use todo::TodoTool;
pub use traits::{OutboundDelivery, OutboundMessenger, Tool, ToolResult, ToolResultWithMedia};

View File

@ -0,0 +1,52 @@
use async_trait::async_trait;
use super::{Tool, ToolResult};
use crate::gateway::reload::ReloadHandle;
pub struct ReloadConfigTool {
reload: ReloadHandle,
}
impl ReloadConfigTool {
pub fn new(reload: ReloadHandle) -> Self {
Self { reload }
}
}
#[async_trait]
impl Tool for ReloadConfigTool {
fn name(&self) -> &str {
"reload_config"
}
fn description(&self) -> &str {
"重新读取并校验 PicoBot 配置,然后让 Gateway 优雅切换到新配置。仅在用户明确要求重新加载配置时调用。"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {},
"additionalProperties": false
})
}
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
match self.reload.request().await {
Ok(accepted) => Ok(ToolResult {
success: true,
output: accepted.message,
error: None,
}),
Err(error) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(error.to_string()),
}),
}
}
fn exclusive(&self) -> bool {
true
}
}

146
tests/test_config_reload.rs Normal file
View File

@ -0,0 +1,146 @@
use std::net::TcpListener;
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use serde_json::{Value, json};
use tokio::process::{Child, Command};
struct GatewayProcess(Child);
impl Drop for GatewayProcess {
fn drop(&mut self) {
let _ = self.0.start_kill();
}
}
fn available_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
fn config(workspace: &Path, model_id: &str) -> Value {
json!({
"providers": {
"provider": {
"type": "openai",
"base_url": "https://example.invalid/v1",
"api_key": "test"
}
},
"models": { "model": { "model_id": model_id } },
"agents": { "default": { "provider": "provider", "model": "model" } },
"gateway": { "require_pairing": false },
"workspace_dir": workspace
})
}
async fn wait_for_health(client: &reqwest::Client, base: &str) {
for _ in 0..100 {
if client
.get(format!("{base}/health"))
.send()
.await
.is_ok_and(|response| response.status().is_success())
{
return;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
panic!("gateway did not become healthy");
}
#[tokio::test]
async fn gateway_reloads_valid_config_and_keeps_serving_after_invalid_config() {
let temp = tempfile::tempdir().unwrap();
let home = temp.path().join("home");
let config_dir = home.join(".picobot");
let workspace = temp.path().join("workspace");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::create_dir_all(&workspace).unwrap();
let config_path = config_dir.join("config.json");
std::fs::write(
&config_path,
serde_json::to_vec_pretty(&config(&workspace, "old-model")).unwrap(),
)
.unwrap();
let port = available_port();
let child = Command::new(env!("CARGO_BIN_EXE_picobot"))
.args([
"gateway",
"--host",
"127.0.0.1",
"--port",
&port.to_string(),
])
.env("HOME", &home)
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true)
.spawn()
.unwrap();
let mut gateway = GatewayProcess(child);
let client = reqwest::Client::new();
let base = format!("http://127.0.0.1:{port}");
wait_for_health(&client, &base).await;
std::fs::write(
&config_path,
serde_json::to_vec_pretty(&config(&workspace, "new-model")).unwrap(),
)
.unwrap();
let accepted: Value = client
.post(format!("{base}/api/config/reload"))
.send()
.await
.unwrap()
.error_for_status()
.unwrap()
.json()
.await
.unwrap();
assert_eq!(accepted["generation"], 2);
let mut active = false;
for _ in 0..100 {
let response = client
.get(format!("{base}/api/config/reload/status"))
.send()
.await;
if let Ok(response) = response
&& let Ok(status) = response.json::<Value>().await
&& status["generation"] == 2
&& status["phase"] == "active"
{
active = true;
break;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert!(active, "reloaded generation did not become active");
std::fs::write(&config_path, b"{").unwrap();
let invalid = client
.post(format!("{base}/api/config/reload"))
.send()
.await
.unwrap();
assert_eq!(invalid.status(), reqwest::StatusCode::BAD_REQUEST);
assert!(
client
.get(format!("{base}/health"))
.send()
.await
.unwrap()
.status()
.is_success(),
"invalid candidate stopped the active generation"
);
gateway.0.start_kill().unwrap();
gateway.0.wait().await.unwrap();
}

View File

@ -4,7 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#0d1117" />
<meta name="theme-color" content="#0b1017" />
<script src="/theme-init.js"></script>
<title>PicoBot Console</title>
</head>
<body>

View File

@ -1,12 +1,12 @@
{
"name": "picobot-webui",
"version": "1.2.2",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picobot-webui",
"version": "1.2.2",
"version": "1.3.0",
"dependencies": {
"bits-ui": "^2.0.0",
"dompurify": "^3.4.12",
@ -14,6 +14,8 @@
"svelte": "^5.0.0"
},
"devDependencies": {
"@fontsource/jetbrains-mono": "^5.3.0",
"@fontsource/space-grotesk": "^5.3.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@types/node": "^24.0.0",
"svelte-check": "^4.0.0",
@ -490,6 +492,26 @@
"resolved": "https://mirrors.cloud.tencent.com/npm/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="
},
"node_modules/@fontsource/jetbrains-mono": {
"version": "5.3.0",
"resolved": "https://registry.npmmirror.com/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz",
"integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/space-grotesk": {
"version": "5.3.0",
"resolved": "https://registry.npmmirror.com/@fontsource/space-grotesk/-/space-grotesk-5.3.0.tgz",
"integrity": "sha512-ksnGizDPXIDuvqcTYTSrmZ+evx9sDlS8rp7+42BQ7wU+spt3twEoXfbJz672C+5CLg6VeUQwRy5RXshWb67LcQ==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@internationalized/date": {
"version": "3.12.2",
"resolved": "https://mirrors.cloud.tencent.com/npm/@internationalized/date/-/date-3.12.2.tgz",

View File

@ -1,7 +1,7 @@
{
"name": "picobot-webui",
"private": true,
"version": "1.2.2",
"version": "1.4.0",
"type": "module",
"engines": {
"node": ">=20"
@ -18,6 +18,8 @@
"svelte": "^5.0.0"
},
"devDependencies": {
"@fontsource/jetbrains-mono": "^5.3.0",
"@fontsource/space-grotesk": "^5.3.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0",
"@types/node": "^24.0.0",
"svelte-check": "^4.0.0",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,5 @@
try {
var t = localStorage.getItem("picobot-theme");
if (!t) t = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
document.documentElement.dataset.theme = t;
} catch (e) {}

View File

@ -2,7 +2,10 @@
import { onMount } from "svelte";
import { Tooltip } from "bits-ui";
import { api } from "./lib/api.js";
import { chat } from "./lib/chat.svelte.js";
import { applyTheme, preferredTheme } from "./lib/theme.js";
import Toast from "./lib/Toast.svelte";
import ActivitySpine from "./lib/components/ActivitySpine.svelte";
import ChatPage from "./pages/ChatPage.svelte";
import TasksPage from "./pages/TasksPage.svelte";
import MemoryPage from "./pages/MemoryPage.svelte";
@ -11,11 +14,13 @@
import PairingPage from "./pages/PairingPage.svelte";
const pages = [
["chat", "◉", "在线聊天", "与你的 PicoBot 实时对话"],
["tasks", "⌁", "任务执行", "查看定时任务、运行记录与后台子任务"],
["memory", "◇", "记忆", "检索 Knowledge 与 Timeline"],
["logs", "≋", "运行日志", "查看 Gateway 最近的本地日志"],
["settings", "⚙", "配置", "管理运行配置与助手档案"]
["chat", "◫", "聊天"],
["overview", "◉", "概览"],
["tools", "🧰", "工具&Skills"],
["logs", "≋", "日志"],
["memory", "◇", "记忆"],
["tasks", "⌁", "任务"],
["settings", "⚙", "配置"]
];
let current = $state("chat");
let menuOpen = $state(false);
@ -42,12 +47,9 @@
menuOpen = false;
}
function applyTheme(next) {
theme = next;
document.documentElement.dataset.theme = next;
document.documentElement.style.colorScheme = next;
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", next === "dark" ? "#0d1117" : "#f6f7f9");
localStorage.setItem("picobot-theme", next);
function toggleTheme() {
theme = theme === "dark" ? "light" : "dark";
applyTheme(theme);
}
async function checkAuth() {
@ -66,16 +68,26 @@
health();
}
$effect(() => {
if (authenticated) {
chat.connect();
} else {
chat.disconnect();
}
});
onMount(() => {
const saved = localStorage.getItem("picobot-theme");
applyTheme(saved === "light" || saved === "dark" ? saved : (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"));
const preferred = preferredTheme();
applyTheme(preferred);
theme = preferred;
checkAuth().then(() => authenticated && health());
const authRequired = () => authenticated = false;
const authRequired = () => (authenticated = false);
window.addEventListener("picobot-auth-required", authRequired);
const timer = setInterval(() => authenticated && health(), 30_000);
return () => {
clearInterval(timer);
window.removeEventListener("picobot-auth-required", authRequired);
chat.disconnect();
};
});
</script>
@ -94,21 +106,26 @@
<button class:active={current === page[0]} onclick={() => selectPage(page[0])}><span>{page[1]}</span>{page[2]}</button>
{/each}
</nav>
<div class="gateway-status"><i class:online></i><div><b>{online ? "运行中" : "不可用"}</b><small>{version}</small></div></div>
</aside>
<main>
<header class="topbar">
<button class="menu" aria-label="菜单" onclick={() => (menuOpen = !menuOpen)}>☰</button>
<div><h1>{meta[2]}</h1><p>{meta[3]}</p></div>
<button class="theme-toggle" aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} onclick={() => applyTheme(theme === "dark" ? "light" : "dark")}>
<div class="gateway-status">
<i class:online></i>
<div><b>{online ? "运行中" : "不可用"}</b><small>{version}</small></div>
<button class="theme-toggle" aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} onclick={toggleTheme}>
<span aria-hidden="true">{theme === "dark" ? "☀" : "☾"}</span><span>{theme === "dark" ? "浅色" : "深色"}</span>
</button>
</div>
</aside>
<main>
<ActivitySpine {version} />
<header class="topbar">
<button class="menu" aria-label="菜单" onclick={() => (menuOpen = !menuOpen)}>☰</button>
<h1>{meta[2]}</h1>
</header>
{#if current === "chat"}<ChatPage notify={(text, error) => toast.show(text, error)} />
{:else if current === "tasks"}<TasksPage />
{:else if current === "memory"}<MemoryPage />
{:else if current === "logs"}<LogsPage />
{:else}<SettingsPage notify={(text, error) => toast.show(text, error)} />{/if}
{:else if current === "memory"}<MemoryPage />
{:else if current === "tasks"}<TasksPage />
{:else if current === "settings"}<SettingsPage notify={(text, error) => toast.show(text, error)} />
{:else}<div class="empty-card">即将上线</div>{/if}
</main>
</div>
<Toast bind:this={toast} />

View File

@ -0,0 +1,66 @@
import { clientId } from "./api.js";
class ChatClient {
connected = $state(false);
turn = $state(null); // 最新 turn 快照(任意 session供活动脊
#socket = null;
#handlers = new Set();
#reconnectTimer = null;
#stopped = false;
connect() {
if (this.#socket) return;
this.#stopped = false;
clearTimeout(this.#reconnectTimer);
const scheme = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${scheme}://${location.host}/ws?client_id=${encodeURIComponent(clientId())}`);
this.#socket = ws;
ws.onopen = () => {
if (this.#socket !== ws) return;
this.connected = true;
this.#dispatch({ type: "_open" });
};
ws.onerror = () => ws.close();
ws.onclose = () => {
if (this.#socket !== ws) return;
this.connected = false;
this.#socket = null;
this.#dispatch({ type: "_close" });
if (!this.#stopped) this.#reconnectTimer = setTimeout(() => this.connect(), 1800);
};
ws.onmessage = (event) => {
if (this.#socket !== ws) return;
let frame;
try { frame = JSON.parse(event.data); } catch { return; }
if (frame.type === "turn_updated" && frame.snapshot) this.turn = frame.snapshot;
this.#dispatch(frame);
};
}
disconnect() {
this.#stopped = true;
clearTimeout(this.#reconnectTimer);
this.#socket?.close();
this.#socket = null;
this.connected = false;
}
send(frame) {
if (this.#socket?.readyState === WebSocket.OPEN) {
this.#socket.send(JSON.stringify(frame));
return true;
}
return false;
}
subscribe(handler) {
this.#handlers.add(handler);
return () => this.#handlers.delete(handler);
}
#dispatch(frame) {
for (const handler of this.#handlers) handler(frame);
}
}
export const chat = new ChatClient();

View File

@ -0,0 +1,54 @@
<script>
import { chat } from "../chat.svelte.js";
let { version = "" } = $props();
let lastTokens = null; // { at, completion } — plain bookkeeping, NOT reactive
let rate = $state(null);
$effect(() => {
const turn = chat.turn;
if (!turn || turn.status !== "running") { rate = null; lastTokens = null; return; }
const completion = turn.usage?.completion_tokens;
const now = Date.now();
if (completion != null && lastTokens && now > lastTokens.at) {
const delta = completion - lastTokens.completion;
const secs = (now - lastTokens.at) / 1000;
if (delta >= 0 && secs > 0) rate = Math.round(delta / secs);
}
if (completion != null) lastTokens = { at: now, completion };
});
const running = $derived(chat.turn?.status === "running");
const turnLabel = $derived(chat.turn ? `TURN ${String(chat.turn.id ?? "").slice(0, 6).toUpperCase()}` : "");
const ctx = $derived(chat.turn?.usage?.prompt_tokens != null
? `${(chat.turn.usage.prompt_tokens / 1000).toFixed(1)}k` : null);
</script>
<div class="spine mono">
{#if running}
<span class="spine-turn active"><i class="pulse-dot active"></i>{turnLabel} · STREAMING</span>
{#if rate != null}<span class="spine-rate">{rate} tok/s</span>{/if}
{#if ctx}<span>ctx {ctx}</span>{/if}
{:else if chat.turn}
<span class="spine-turn idle"><i class="pulse-dot idle"></i>IDLE</span>
<span>最近 {turnLabel}</span>
{:else}
<span class="spine-turn idle"><i class="pulse-dot idle"></i>READY</span>
{/if}
<span class="spine-right">
<span class:spine-ok={chat.connected} class:spine-down={!chat.connected}>{chat.connected ? "已连接" : "重连中"}</span>
{#if version}<span>{version}</span>{/if}
</span>
</div>
<style>
.spine { display: flex; align-items: center; gap: 14px; font-size: 10.5px; color: var(--spine-text);
background: var(--spine-bg); border-bottom: 1px solid var(--line); padding: 8px 16px; }
.spine-turn { display: inline-flex; align-items: center; gap: 7px; font-weight: 700; }
.spine-turn.active { color: var(--spine-accent); }
.spine-turn.idle { color: var(--spine-signal); }
.spine-rate { color: var(--spine-signal); }
.spine-right { margin-left: auto; display: inline-flex; gap: 14px; color: var(--spine-faint); }
.spine-ok { color: var(--spine-signal); }
.spine-down { color: var(--spine-accent); }
</style>

16
webui/src/lib/theme.js Normal file
View File

@ -0,0 +1,16 @@
const STORAGE_KEY = "picobot-theme";
export function preferredTheme() {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === "light" || saved === "dark") return saved;
return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
document
.querySelector('meta[name="theme-color"]')
?.setAttribute("content", theme === "dark" ? "#0b1017" : "#eef1f5");
localStorage.setItem(STORAGE_KEY, theme);
}

View File

@ -2,13 +2,12 @@
import { onMount, tick } from "svelte";
import { Tooltip } from "bits-ui";
import { clientId, formatTime, randomId } from "../lib/api.js";
import { chat } from "../lib/chat.svelte.js";
import Markdown from "../lib/Markdown.svelte";
import ToolCallCard from "../lib/ToolCallCard.svelte";
import TurnView from "../lib/TurnView.svelte";
let { notify } = $props();
let socket = $state(null);
let connected = $state(false);
let sessions = $state([]);
let currentId = $state(null);
let messages = $state([]);
@ -27,8 +26,6 @@
let todoOpen = $state(false);
let messageBox;
let input;
let reconnectTimer;
let stopped = false;
const currentSession = $derived(sessions.find((item) => item.session_id === currentId));
const currentPlan = $derived(currentId ? plansBySession[currentId] || null : null);
const completedItems = $derived(currentPlan?.items?.filter((item) => item.status === "completed").length || 0);
@ -52,28 +49,7 @@
});
function send(frame) {
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(frame));
else notify("聊天连接尚未就绪", true);
}
function connect() {
const scheme = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${scheme}://${location.host}/ws?client_id=${encodeURIComponent(clientId())}`);
socket = ws;
ws.onopen = () => {
connected = true;
plansBySession = {};
unseenPlanSessions = {};
todoOpen = false;
send({ type: "list_sessions", include_archived: false });
send({ type: "get_slash_commands" });
};
ws.onerror = () => ws.close();
ws.onclose = () => {
connected = false;
if (!stopped) reconnectTimer = setTimeout(connect, 1800);
};
ws.onmessage = (event) => handleFrame(JSON.parse(event.data));
if (!chat.send(frame)) notify("聊天连接尚未就绪", true);
}
function handleFrame(frame) {
@ -218,7 +194,7 @@
function submit() {
const content = draft.trim();
const ready = pendingUploads.filter((upload) => upload.status === "ready");
if ((!content && !ready.length) || !connected || pendingUploads.some((upload) => upload.status === "uploading")) return;
if ((!content && !ready.length) || !chat.connected || pendingUploads.some((upload) => upload.status === "uploading")) return;
appendMessage("user", content, ready.map((upload, index) => ({
index,
name: upload.name,
@ -258,7 +234,7 @@
}
function uploadFile(file) {
if (!connected) return notify("聊天连接尚未就绪", true);
if (!chat.connected) return notify("聊天连接尚未就绪", true);
const localId = randomId();
const localUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : null;
pendingUploads = [...pendingUploads, {
@ -380,11 +356,20 @@
}
onMount(() => {
connect();
const unsubscribe = chat.subscribe(handleFrame);
const onOpen = (frame) => {
if (frame.type !== "_open") return;
plansBySession = {};
unseenPlanSessions = {};
todoOpen = false;
send({ type: "list_sessions", include_archived: false });
send({ type: "get_slash_commands" });
};
const unsubOpen = chat.subscribe(onOpen);
if (chat.connected) onOpen({ type: "_open" });
return () => {
stopped = true;
clearTimeout(reconnectTimer);
socket?.close();
unsubscribe();
unsubOpen();
clearPendingUploads();
};
});
@ -515,8 +500,8 @@
aria-activedescendant={commandSuggestions.length ? `slash-command-${selectedCommand}` : undefined}
placeholder="输入消息,输入 / 查看命令"
></textarea>
<button class="send" type="submit" aria-label="发送" disabled={!connected || (!draft.trim() && !pendingUploads.some((upload) => upload.status === "ready")) || pendingUploads.some((upload) => upload.status === "uploading")}>↑</button>
<small><span class:online={connected}>{connected ? "已连接" : "已断开,正在重连"}</span><span>/ 打开命令 · Shift+Enter 换行</span></small>
<button class="send" type="submit" aria-label="发送" disabled={!chat.connected || (!draft.trim() && !pendingUploads.some((upload) => upload.status === "ready")) || pendingUploads.some((upload) => upload.status === "uploading")}>↑</button>
<small><span class:online={chat.connected}>{chat.connected ? "已连接" : "已断开,正在重连"}</span><span>/ 打开命令 · Shift+Enter 换行</span></small>
</form>
</div>
{#if currentPlan}

View File

@ -1,59 +1,109 @@
@font-face {
font-family: "Space Grotesk";
src: url("/fonts/space-grotesk-500.woff2") format("woff2");
font-weight: 500; font-style: normal; font-display: swap;
}
@font-face {
font-family: "Space Grotesk";
src: url("/fonts/space-grotesk-700.woff2") format("woff2");
font-weight: 700; font-style: normal; font-display: swap;
}
@font-face {
font-family: "JetBrains Mono";
src: url("/fonts/jetbrains-mono-400.woff2") format("woff2");
font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
font-family: "JetBrains Mono";
src: url("/fonts/jetbrains-mono-700.woff2") format("woff2");
font-weight: 700; font-style: normal; font-display: swap;
}
:root {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
color: #e8edf2;
background: #0d1117;
--font-ui: "Space Grotesk", ui-sans-serif, system-ui, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
color-scheme: dark;
font-family: var(--font-ui);
font-synthesis: none;
--bg: #0d1117;
--panel: #151a21;
--panel-2: #1b222c;
--sidebar: #10151c;
--header: rgb(13 17 23 / 82%);
--line: #28313d;
--muted: #8b96a5;
--text: #e8edf2;
--text-soft: #c2cad4;
--accent: #8b72ff;
--accent-hover: #9e89ff;
--accent-contrast: #ffffff;
--accent-soft: #231f3d;
--accent-border: #4d4089;
color: #e7ecf3;
background: #0b1017;
--bg: #0b1017;
--panel: #0e1520;
--panel-2: #131c29;
--sidebar: #0d131c;
--header: rgb(11 16 23 / 84%);
--line: #1d2733;
--line-strong: #2c3a4c;
--muted: #8fa3b8;
--faint: #5b6b7e;
--text: #e7ecf3;
--text-soft: #b8c4d4;
--accent: #ffb454;
--accent-hover: #ffc370;
--accent-contrast: #1a1206;
--accent-soft: rgb(255 180 84 / 12%);
--accent-border: rgb(255 180 84 / 35%);
--signal: #2dd4bf;
--signal-soft: rgb(45 212 191 / 12%);
--signal-border: rgb(45 212 191 / 35%);
--info: #6aa6ff;
--info-soft: rgb(106 166 255 / 12%);
--info-border: rgb(106 166 255 / 35%);
--danger: #ff7b86;
--danger-soft: #321d23;
--warning: #f1c75b;
--warning-soft: #302a18;
--success-soft: #1d3029;
--overlay: #1d2530;
--code-bg: #0b0f14;
--user-bubble: #26213f;
--danger-soft: rgb(255 123 134 / 12%);
--danger-border: rgb(255 123 134 / 35%);
--warning: #ffb454;
--warning-soft: rgb(255 180 84 / 10%);
--success-soft: rgb(45 212 191 / 12%);
--overlay: #101826;
--code-bg: #080c12;
--user-bubble: #221d38;
--spine-bg: #0e1520;
--spine-text: #b8c4d4;
--spine-signal: #2dd4bf;
--spine-accent: #ffb454;
--spine-faint: #8fa3b8;
--shadow: 0 16px 45px rgb(0 0 0 / 35%);
--radius: 13px;
--radius: 11px;
}
:root[data-theme="light"] {
color: #1c2430;
background: #f6f7f9;
--bg: #f6f7f9;
color-scheme: light;
color: #1a2230;
background: #eef1f5;
--bg: #eef1f5;
--panel: #ffffff;
--panel-2: #f1f3f6;
--sidebar: #fbfbfc;
--header: rgb(246 247 249 / 86%);
--line: #dde2e8;
--muted: #687386;
--text: #1c2430;
--text-soft: #4f5b6b;
--accent: #6748e8;
--accent-hover: #5739d4;
--panel-2: #f4f6f9;
--sidebar: #f7f9fc;
--header: rgb(238 241 245 / 86%);
--line: #d8dee8;
--line-strong: #c2ccd9;
--muted: #5b6b7e;
--faint: #8494a8;
--text: #1a2230;
--text-soft: #3d4b5e;
--accent: #a86300;
--accent-hover: #c47400;
--accent-contrast: #ffffff;
--accent-soft: #eeeaff;
--accent-border: #c9befd;
--accent-soft: rgb(196 116 0 / 10%);
--accent-border: rgb(196 116 0 / 35%);
--signal: #0d9488;
--signal-soft: rgb(13 148 136 / 10%);
--signal-border: rgb(13 148 136 / 35%);
--info: #2f6fd0;
--info-soft: rgb(47 111 208 / 10%);
--info-border: rgb(47 111 208 / 35%);
--danger: #d94354;
--danger-soft: #fff0f2;
--warning: #9b6b00;
--warning-soft: #fff7df;
--success-soft: #e8f7ef;
--danger-soft: rgb(217 67 84 / 10%);
--danger-border: rgb(217 67 84 / 35%);
/* --warning intentionally equals --accent: single-amber signal system */
--warning: #a86300;
--warning-soft: rgb(196 116 0 / 8%);
--success-soft: rgb(13 148 136 / 10%);
--overlay: #ffffff;
--code-bg: #f4f5f7;
--user-bubble: #eeeaff;
--code-bg: #f7f9fc;
--user-bubble: #ece7fb;
--spine-bg: #0e1520;
--shadow: 0 16px 45px rgb(31 41 55 / 12%);
}
@ -65,10 +115,10 @@ body { margin: 0; min-width: 320px; color: var(--text); background: var(--bg); t
.pairing-icon { width: 52px; height: 52px; display: grid; place-items: center; margin-bottom: 18px; border-radius: 14px; color: var(--accent); background: var(--accent-soft); font-size: 24px; }
.pairing-card h1 { margin: 0 0 9px; font-size: 24px; }
.pairing-card > p { margin: 0 0 18px; color: var(--muted); font-size: 13px; line-height: 1.65; }
.pairing-command { display: block; margin-bottom: 22px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px; color: var(--text); background: var(--code-bg); font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; }
.pairing-command { display: block; margin-bottom: 22px; padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px; color: var(--text); background: var(--code-bg); font: 12px/1.5 var(--font-mono); }
.pairing-card form { display: grid; gap: 10px; }
.pairing-card label { color: var(--text-soft); font-size: 11px; font-weight: 650; }
.pairing-card input { width: 100%; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; color: var(--text); background: var(--panel-2); font: 600 22px/1.2 ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .28em; text-align: center; }
.pairing-card input { width: 100%; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; color: var(--text); background: var(--panel-2); font: 600 22px/1.2 var(--font-mono); letter-spacing: .28em; text-align: center; }
.pairing-card form .primary { margin-top: 3px; padding: 11px 14px; }
.pairing-card > small { display: block; margin-top: 17px; color: var(--muted); font-size: 10px; line-height: 1.5; }
.pairing-error { padding: 8px 10px; border-radius: 8px; color: var(--danger); background: var(--danger-soft); font-size: 11px; }
@ -90,7 +140,7 @@ button:disabled { cursor: not-allowed; opacity: .45; }
.sidebar nav button.active { color: var(--accent); }
.gateway-status { margin-top: auto; border-top: 1px solid var(--line); padding: 18px 8px 2px; display: flex; gap: 10px; align-items: center; }
.gateway-status i { width: 9px; height: 9px; border-radius: 50%; color: var(--warning); background: currentColor; box-shadow: 0 0 12px currentColor; }
.gateway-status i.online { color: #48b985; background: currentColor; }
.gateway-status i.online { color: var(--signal); background: currentColor; }
.gateway-status b, .gateway-status small { display: block; font-size: 12px; }
.gateway-status small { color: var(--muted); margin-top: 2px; }
main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
@ -165,10 +215,10 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
.reasoning-content { padding: 10px 13px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; }
.reasoning-block.historical { border-style: dashed; }
.live-tool summary { display: grid; grid-template-columns: 24px 1fr auto; align-items: center; gap: 8px; }
.live-tool summary strong { color: var(--text); font: 600 12px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; }
.live-tool summary strong { color: var(--text); font: 600 12px/1.4 var(--font-mono); }
.live-tool summary small { color: var(--muted); }
.live-tool-details { display: grid; gap: 8px; padding: 10px 12px; border-top: 1px solid var(--line); }
.live-tool-details pre { max-height: 260px; margin: 0; padding: 10px; overflow: auto; border-radius: 8px; color: var(--text-soft); background: var(--code-bg); font: 11px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-wrap; }
.live-tool-details pre { max-height: 260px; margin: 0; padding: 10px; overflow: auto; border-radius: 8px; color: var(--text-soft); background: var(--code-bg); font: 11px/1.5 var(--font-mono); white-space: pre-wrap; }
.turn-status, .completion-status { color: var(--muted); font-size: 10px; }
.turn-status.failed, .turn-error { color: var(--danger); }
.turn-error { padding: 8px 10px; border-radius: 8px; background: var(--danger-soft); font-size: 11px; }
@ -186,7 +236,7 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
.markdown-body h3 { font-size: 1.1em; }
.markdown-body a { color: var(--accent); text-underline-offset: 2px; }
.markdown-body blockquote { padding-left: 12px; border-left: 3px solid var(--accent-border); color: var(--muted); }
.markdown-body code { padding: .14em .35em; border: 1px solid var(--line); border-radius: 5px; color: var(--text); background: var(--code-bg); font: .88em/1.5 ui-monospace, SFMono-Regular, Consolas, monospace; }
.markdown-body code { padding: .14em .35em; border: 1px solid var(--line); border-radius: 5px; color: var(--text); background: var(--code-bg); font: .88em/1.5 var(--font-mono); }
.markdown-body pre { max-width: 100%; padding: 12px 13px; overflow-x: auto; border: 1px solid var(--line); border-radius: 9px; background: var(--code-bg); }
.markdown-body pre code { padding: 0; border: 0; background: transparent; }
.markdown-body table { display: block; max-width: 100%; border-collapse: collapse; overflow-x: auto; }
@ -202,13 +252,13 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
.tool-call-icon { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 8px; color: var(--accent); background: var(--accent-soft); font-size: 13px; }
.tool-call-title small, .tool-call-title strong { display: block; }
.tool-call-title small { margin-bottom: 1px; color: var(--muted); font-size: 9px; font-weight: 500; }
.tool-call-title strong { font: 600 12px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; }
.tool-call-title strong { font: 600 12px/1.4 var(--font-mono); }
.tool-call-status { color: var(--muted); font-size: 10px; }
.tool-call-chevron { color: var(--muted); transition: transform .18s; }
.tool-call.open .tool-call-chevron { transform: rotate(180deg); }
.tool-call-details { display: grid; gap: 12px; padding: 12px; border-top: 1px solid var(--line); background: color-mix(in srgb, var(--panel-2) 55%, transparent); }
.tool-call-details > div > span { display: block; margin-bottom: 6px; color: var(--muted); font-size: 9px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.tool-call-details pre, .tool-result { max-height: 300px; margin: 0; padding: 10px 11px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; color: var(--text-soft); background: var(--code-bg); font: 11px/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
.tool-call-details pre, .tool-result { max-height: 300px; margin: 0; padding: 10px 11px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; color: var(--text-soft); background: var(--code-bg); font: 11px/1.55 var(--font-mono); white-space: pre-wrap; overflow-wrap: anywhere; }
.tool-result .markdown-body { font: inherit; }
.composer { position: relative; margin: 0 max(18px, calc((100% - 850px) / 2)) 18px; border: 1px solid var(--line); background: var(--panel); border-radius: 14px; padding: 10px 11px 6px; display: grid; grid-template-columns: 38px 1fr 38px; box-shadow: var(--shadow); }
.composer textarea { resize: none; max-height: 180px; background: transparent; border: 0; outline: 0; color: var(--text); padding: 7px; line-height: 1.5; }
@ -248,7 +298,7 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
.command-menu-heading kbd { color: var(--muted); font: inherit; }
.command-menu button { display: grid; grid-template-columns: minmax(100px, auto) 1fr; gap: 14px; width: 100%; padding: 9px 10px; border: 0; border-radius: 8px; color: var(--text); background: transparent; text-align: left; cursor: pointer; }
.command-menu button:hover, .command-menu button.selected { background: var(--accent-soft); }
.command-menu code { color: var(--accent); font: 600 12px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; }
.command-menu code { color: var(--accent); font: 600 12px/1.4 var(--font-mono); }
.command-menu button span { color: var(--text-soft); font-size: 12px; line-height: 1.4; }
.content-page { padding: 22px 26px; overflow: auto; }
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; gap: 10px; }
@ -264,7 +314,7 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
.card p { color: var(--text-soft); font-size: 12px; line-height: 1.55; margin: 5px 0; white-space: pre-wrap; }
.meta { display: flex; gap: 14px; flex-wrap: wrap; color: var(--muted); font-size: 11px; }
.badge { border-radius: 99px; padding: 4px 8px; font-size: 10px; background: var(--panel-2); color: var(--text-soft); white-space: nowrap; }
.badge.ok { background: var(--success-soft); color: #38a877; }
.badge.ok { background: var(--success-soft); color: var(--signal); }
.badge.fail { background: var(--danger-soft); color: var(--danger); }
.badge.run { background: var(--warning-soft); color: var(--warning); }
.details { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 10px; }
@ -274,8 +324,8 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
.metric b { font-size: 22px; display: block; }
.metric small { color: var(--muted); }
.memory-content { font-size: 13px !important; }
.memory-key { color: var(--accent); font-family: ui-monospace, monospace; }
.log-view { margin: 0; background: var(--code-bg); border: 1px solid var(--line); border-radius: 12px; padding: 16px; color: var(--text-soft); min-height: calc(100vh - 190px); font: 11px/1.65 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-wrap; overflow: auto; }
.memory-key { color: var(--accent); font-family: var(--font-mono); }
.log-view { margin: 0; background: var(--code-bg); border: 1px solid var(--line); border-radius: 12px; padding: 16px; color: var(--text-soft); min-height: calc(100vh - 190px); font: 11px/1.65 var(--font-mono); white-space: pre-wrap; overflow: auto; }
.log-meta { color: var(--muted); font-size: 11px; margin: -8px 0 8px; }
.switch-label { color: var(--muted); font-size: 12px; display: flex; gap: 7px; align-items: center; cursor: pointer; }
.switch { width: 34px; height: 20px; padding: 2px; border: 1px solid var(--line); border-radius: 99px; background: var(--panel); cursor: pointer; }
@ -288,7 +338,7 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
.editor-head { height: 62px; padding: 0 15px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--line); }
.editor-head strong, .editor-head small { display: block; }
.editor-head small { color: var(--muted); font-size: 10px; margin-top: 4px; }
.editor-card textarea { display: block; width: 100%; height: calc(100vh - 275px); min-height: 420px; resize: vertical; border: 0; outline: 0; padding: 18px; background: var(--code-bg); color: var(--text); font: 12px/1.6 ui-monospace, SFMono-Regular, Consolas, monospace; tab-size: 2; }
.editor-card textarea { display: block; width: 100%; height: calc(100vh - 275px); min-height: 420px; resize: vertical; border: 0; outline: 0; padding: 18px; background: var(--code-bg); color: var(--text); font: 12px/1.6 var(--font-mono); tab-size: 2; }
.notice { padding: 11px 15px; color: var(--muted); font-size: 11px; border-top: 1px solid var(--line); }
code { color: var(--accent); }
#toast { position: fixed; z-index: 100; right: 22px; bottom: 22px; color: var(--text); background: var(--overlay); border: 1px solid var(--line); border-radius: 10px; padding: 11px 15px; font-size: 12px; opacity: 0; transform: translateY(8px); pointer-events: none; transition: .2s; box-shadow: var(--shadow); }
@ -297,6 +347,19 @@ code { color: var(--accent); }
.loading, .empty-card { text-align: center; color: var(--muted); padding: 50px; }
.empty-card.compact { padding: 24px 8px; }
.error-text { color: var(--danger) !important; }
.mono { font-family: var(--font-mono); }
.label-caps { font-family: var(--font-mono); font-size: 9px; letter-spacing: .16em; color: var(--faint); }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: var(--radius); }
.cap { display: inline-flex; align-items: center; gap: 4px; font-size: 9.5px; font-weight: 600; border-radius: 6px; padding: 2.5px 8px; }
.cap.signal { color: var(--signal); background: var(--signal-soft); border: 1px solid var(--signal-border); }
.cap.accent { color: var(--accent); background: var(--accent-soft); border: 1px solid var(--accent-border); }
.cap.danger { color: var(--danger); background: var(--danger-soft); border: 1px solid var(--danger-border); }
.cap.info { color: var(--info); background: var(--info-soft); border: 1px solid var(--info-border); }
@keyframes spine-pulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
.pulse-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; animation: spine-pulse 1.6s ease-in-out infinite; }
.pulse-dot.active { background: var(--spine-accent); box-shadow: 0 0 10px var(--spine-accent); }
.pulse-dot.idle { background: var(--spine-signal); animation: none; }
@media (prefers-reduced-motion: reduce) { .pulse-dot { animation: none; } }
@media (max-width: 800px) {
.shell { grid-template-columns: 1fr; }