Compare commits
No commits in common. "719f58690021f66be1b399cdbe78f2fa15852caa" and "a2af5f9991005c9238c70535f989670392d0de81" have entirely different histories.
719f586900
...
a2af5f9991
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,5 +8,4 @@ reference/**
|
|||||||
*.env
|
*.env
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
.worktrees/
|
.worktrees/
|
||||||
.superpowers/
|
|
||||||
design
|
design
|
||||||
|
|||||||
@ -8,7 +8,6 @@ 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 -- 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 -- 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 -- 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
|
- `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
|
- 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
|
- `cd webui && npm ci && npm run check && npm run build` — validate the Svelte WebUI independently (Node.js 20+); its local `dist/` is ignored
|
||||||
@ -92,7 +91,6 @@ 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
|
- **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
|
- **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
|
- **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 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 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
|
- **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
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "picobot"
|
name = "picobot"
|
||||||
version = "1.4.0"
|
version = "1.2.2"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
13
README.md
13
README.md
@ -175,7 +175,7 @@ WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件
|
|||||||
- 本地滚动日志的尾部查看、过滤和自动刷新。
|
- 本地滚动日志的尾部查看、过滤和自动刷新。
|
||||||
- `config.json`、`~/.picobot/USER.md`、`~/.picobot/AGENTS.md` 编辑。
|
- `config.json`、`~/.picobot/USER.md`、`~/.picobot/AGENTS.md` 编辑。
|
||||||
|
|
||||||
配置接口会掩码 API Key、secret、password 和 token;保留 `********` 再保存不会覆盖原密钥。运行配置采用原子写入,保存后可执行 `picobot reload` 或发送 `/reload` 热重载;`USER.md` 和 `AGENTS.md` 的修改用于后续构建的 Agent 上下文。
|
配置接口会掩码 API Key、secret、password 和 token;保留 `********` 再保存不会覆盖原密钥。运行配置采用原子写入并在 Gateway 重启后生效,`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` 显式关闭配对,但不建议在非隔离环境使用。
|
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,14 +210,6 @@ picobot service stop
|
|||||||
picobot service uninstall
|
picobot service uninstall
|
||||||
```
|
```
|
||||||
|
|
||||||
修改配置后无需重启 systemd service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
picobot reload
|
|
||||||
```
|
|
||||||
|
|
||||||
该命令连接正在运行的 Gateway,先解析并校验新配置,再停止接收新工作,等待当前交互 Turn、Scheduler job 和后台子 Agent 到达安全边界后切换运行代。也可在聊天中发送 `/reload`,或让根交互 Agent 在用户明确要求时调用 `reload_config` 工具;子 Agent 与定时任务不能触发重载。监听地址、workspace 和数据库路径涉及进程级资源,不能热重载;修改这些字段时命令会保留旧配置并提示使用 `picobot service restart`。重载会主动断开 WebSocket,TUI/WebUI 随后可重新连接并从持久化历史恢复。受认证客户端可通过 `GET /api/config/reload/status` 查询 generation、切换阶段和最近错误。
|
|
||||||
|
|
||||||
unit 位于 `~/.config/systemd/user/picobot.service`,以执行 `service install` 时的当前目录作为初始工作目录。服务异常退出时由 systemd 自动重启;`stop` 和 `restart` 会通过 SIGTERM 触发 Gateway 的有界优雅关停。
|
unit 位于 `~/.config/systemd/user/picobot.service`,以执行 `service install` 时的当前目录作为初始工作目录。服务异常退出时由 systemd 自动重启;`stop` 和 `restart` 会通过 SIGTERM 触发 Gateway 的有界优雅关停。
|
||||||
|
|
||||||
TUI 会把一个随机客户端标识保存到 `~/.picobot/tui_client_id`,因此关闭并重新打开客户端后会恢复同一组 dialog 和最近使用的会话。界面支持流式正文、独立思考与工具状态、历史回放、会话列表与归档筛选、命令补全、Unicode/中文编辑、括号粘贴、多行输入和文件传输。
|
TUI 会把一个随机客户端标识保存到 `~/.picobot/tui_client_id`,因此关闭并重新打开客户端后会恢复同一组 dialog 和最近使用的会话。界面支持流式正文、独立思考与工具状态、历史回放、会话列表与归档筛选、命令补全、Unicode/中文编辑、括号粘贴、多行输入和文件传输。
|
||||||
@ -300,7 +292,6 @@ Session ID 使用三段式:
|
|||||||
| `/mcp` | 查看 MCP 服务器和工具状态 |
|
| `/mcp` | 查看 MCP 服务器和工具状态 |
|
||||||
| `/stop` | 停止当前任务并清空队列 |
|
| `/stop` | 停止当前任务并清空队列 |
|
||||||
| `/todo [done\|cancel]` | 查看、完成或取消当前 session 的任务计划 |
|
| `/todo [done\|cancel]` | 查看、完成或取消当前 session 的任务计划 |
|
||||||
| `/reload` | 校验并重新加载 Gateway 配置 |
|
|
||||||
| `/?`, `/help` | 查看帮助 |
|
| `/?`, `/help` | 查看帮助 |
|
||||||
|
|
||||||
### 记忆
|
### 记忆
|
||||||
@ -327,7 +318,6 @@ PicoBot 有两类记忆:
|
|||||||
| `http_request` / `web_fetch` | HTTP 请求和网页文本抽取 |
|
| `http_request` / `web_fetch` | HTTP 请求和网页文本抽取 |
|
||||||
| `get_skill` | 列出或读取本地 Skill |
|
| `get_skill` | 列出或读取本地 Skill |
|
||||||
| `memory_store` / `memory_recall` / `timeline_recall` / `memory_forget` | 长期记忆操作 |
|
| `memory_store` / `memory_recall` / `timeline_recall` / `memory_forget` | 长期记忆操作 |
|
||||||
| `reload_config` | 在用户明确要求时校验并重新加载 Gateway 配置 |
|
|
||||||
| `delegate` | 启动 inline、background 或 parallel 子 Agent |
|
| `delegate` | 启动 inline、background 或 parallel 子 Agent |
|
||||||
| `todo` | 为复杂、多轮任务创建并更新当前 session 的持久化计划 |
|
| `todo` | 为复杂、多轮任务创建并更新当前 session 的持久化计划 |
|
||||||
| `send_message` | 向指定渠道或当前会话发送消息,可附带文件/截图;WebUI/TUI 当前 Turn 的附件并入最终回复 |
|
| `send_message` | 向指定渠道或当前会话发送消息,可附带文件/截图;WebUI/TUI 当前 Turn 的附件并入最终回复 |
|
||||||
@ -482,7 +472,6 @@ docs/ 面向维护者和 Agent 的架构与开发文档
|
|||||||
## 进一步阅读
|
## 进一步阅读
|
||||||
|
|
||||||
- [维护者架构文档](docs/ARCHITECTURE.md)
|
- [维护者架构文档](docs/ARCHITECTURE.md)
|
||||||
- [配置热重载设计与实现](docs/CONFIG_HOT_RELOAD_DESIGN.md)
|
|
||||||
- [WebUI 与 TUI 文件收发设计](docs/FILE_TRANSFER_DESIGN.md)
|
- [WebUI 与 TUI 文件收发设计](docs/FILE_TRANSFER_DESIGN.md)
|
||||||
- [内置 Skill:架构机制](resources/skills/about-picobot/references/architecture.md)
|
- [内置 Skill:架构机制](resources/skills/about-picobot/references/architecture.md)
|
||||||
- [配置说明](resources/skills/about-picobot/references/config.md)
|
- [配置说明](resources/skills/about-picobot/references/config.md)
|
||||||
|
|||||||
1
build.rs
1
build.rs
@ -69,7 +69,6 @@ fn build_webui(out_dir: &Path) {
|
|||||||
"webui/package-lock.json",
|
"webui/package-lock.json",
|
||||||
"webui/svelte.config.js",
|
"webui/svelte.config.js",
|
||||||
"webui/vite.config.js",
|
"webui/vite.config.js",
|
||||||
"webui/public",
|
|
||||||
] {
|
] {
|
||||||
println!("cargo:rerun-if-changed={path}");
|
println!("cargo:rerun-if-changed={path}");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
image: picobot:1.3.0
|
image: picobot:1.2.2
|
||||||
container_name: picobot-test
|
container_name: picobot-test
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
本文档描述 PicoBot 当前实现的运行时边界、数据流、并发模型和演进约束。它面向维护者和后续参与改进的 Agent,是代码架构的主入口;行为细节仍以代码和测试为最终依据。
|
本文档描述 PicoBot 当前实现的运行时边界、数据流、并发模型和演进约束。它面向维护者和后续参与改进的 Agent,是代码架构的主入口;行为细节仍以代码和测试为最终依据。
|
||||||
|
|
||||||
流式模型输出、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)。
|
流式模型输出、reasoning 展示、活动 Turn 快照和 Channel 实时投递的详细设计与取舍见 [STREAMING_TURN_DESIGN.md](STREAMING_TURN_DESIGN.md)。用户输入路由、Session 执行拆分、终态投递确认和历史增量校准的重构方案见 [MESSAGE_FLOW_REFACTOR_DESIGN.md](MESSAGE_FLOW_REFACTOR_DESIGN.md)。
|
||||||
|
|
||||||
## 1. 设计目标
|
## 1. 设计目标
|
||||||
|
|
||||||
@ -28,15 +28,13 @@ PicoBot 只有一个二进制,提供三种运行模式:
|
|||||||
|
|
||||||
Linux 上可通过 `picobot service install/start/stop/status/restart/uninstall` 管理 systemd 用户服务。unit 固定为 `picobot.service`,其主进程仍是普通 Gateway 模式,不引入额外 daemon/fork 层;异常退出由 systemd 按 `Restart=on-failure` 拉起。
|
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` 同时控制监听与映射端口。
|
原生 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;重连时恢复内存中的当前 dialog,Gateway 重启后则恢复该 scope 最近活跃的未归档 dialog。无效或缺失的标识会退化为连接级随机 scope。
|
CLI TUI 在 `~/.picobot/tui_client_id` 保存非敏感客户端标识,并通过 WebSocket 查询参数 `client_id` 传给 Gateway。`cli_chat` 以该标识作为稳定 chat scope;重连时恢复内存中的当前 dialog,Gateway 重启后则恢复该 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`。
|
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`;配置目录层先用于定位 workspace,workspace 层不得重定向自身位置。环境文件只在单线程启动阶段写入进程环境,不能移到后台任务启动之后。重载使用启动前保存的进程环境快照解析各层,但不再修改进程环境,避免多线程运行期调用 `set_var`;新 Provider、MCP 和渠道使用解析后配置中的值。切换完成后所有相对路径都应按 workspace 解释,不能假设仍位于源码仓库。
|
Gateway 启动时先从配置目录 `.env`、workspace `.env` 和既有进程环境合并启动变量,再初始化日志并切换进程工作目录到 `workspace_dir`。优先级为进程环境 > workspace `.env` > 配置目录 `.env`;配置目录层先用于定位 workspace,workspace 层不得重定向自身位置。环境文件只在单线程启动阶段写入进程环境,不能移到后台任务启动之后。切换完成后所有相对路径都应按 workspace 解释,不能假设仍位于源码仓库。
|
||||||
|
|
||||||
## 3. 组件关系
|
## 3. 组件关系
|
||||||
|
|
||||||
@ -255,7 +253,7 @@ WebUI/TUI 文件字节通过受鉴权的 HTTP 接口流式传输,WebSocket 只
|
|||||||
|
|
||||||
同源 `/api/*` 管理接口只提供显式白名单能力:
|
同源 `/api/*` 管理接口只提供显式白名单能力:
|
||||||
|
|
||||||
- 配置读取/原子写入;响应中的密钥字段统一掩码,原样提交掩码会恢复现有值。`POST /api/config/reload` 通过同一重载控制器校验并切换 Gateway 运行代,`GET /api/config/reload/status` 查询 generation、相位与最近错误。
|
- 配置读取/原子写入;响应中的密钥字段统一掩码,原样提交掩码会恢复现有值。运行配置只在重启后生效,不热替换运行中组件。
|
||||||
- `USER.md`、`AGENTS.md` 只允许固定文件名,不接受任意路径。
|
- `USER.md`、`AGENTS.md` 只允许固定文件名,不接受任意路径。
|
||||||
- 日志、记忆、任务和运行记录均限制单次返回数量;日志目录固定为 `~/.picobot/logs`。
|
- 日志、记忆、任务和运行记录均限制单次返回数量;日志目录固定为 `~/.picobot/logs`。
|
||||||
- 任务与记忆读取复用 Storage API,不允许 WebUI 直接持有 SQLite 连接或拼接任意查询。
|
- 任务与记忆读取复用 Storage API,不允许 WebUI 直接持有 SQLite 连接或拼接任意查询。
|
||||||
|
|||||||
@ -1,389 +0,0 @@
|
|||||||
# 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` 返回 409,Gateway 退出或候选准备失败返回 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 acknowledgement,guard 只有在回复实际投递成功或明确失败后才释放,不再依赖固定 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 重新创建并启动已启用 Channel,allowlist、凭据、媒体和实时投递策略更新 |
|
|
||||||
| `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 完成通知和自动重连状态提示。
|
|
||||||
@ -1,369 +0,0 @@
|
|||||||
# 配置热重载功能审核报告
|
|
||||||
|
|
||||||
> 状态:审查完成;主要意见已于 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 guard;Scheduler 已执行 job 与后台子 Agent 持有 guard,新任务在 drain 后不再进入。 |
|
|
||||||
| I1 / E4 Slash 回复可能丢失 | 接收 | `/reload` 所在 inbound 在 lane 入队前持有 guard;command 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 已归候选 TaskSupervisor,cleanup 跳过首次 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/O(channel 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(集成测试)与 F2(session_db_path 归一化)后可提交。A1(MCP 相位)、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 |
|
|
||||||
@ -1,616 +0,0 @@
|
|||||||
# 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 5(runes)、Bits UI、Vite、CSS custom properties;Rust/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` handler(include_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` 风格摘要;工具调用沿用 ToolCallCard(Task 3.2 重制)。
|
|
||||||
- 流式 turn(TurnView)下方显示 `▲ 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** — 套用 tokens:Toast 用 `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(配置编辑器)将各自编写独立计划。
|
|
||||||
@ -1,304 +0,0 @@
|
|||||||
# PicoBot WebUI 全面重构设计
|
|
||||||
|
|
||||||
- 状态:设计已确认,待实现
|
|
||||||
- 日期:2026-07-23
|
|
||||||
- 范围:前端(`webui/`)全面重构 + 必要的后端接口新增/调整(`src/gateway/`)
|
|
||||||
|
|
||||||
## 1. 背景与目标
|
|
||||||
|
|
||||||
现有 WebUI(Svelte 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 字体
|
|
||||||
|
|
||||||
- 展示 / UI:Space Grotesk(内嵌 woff2,仅拉丁),中文回落系统字体(PingFang SC / Microsoft YaHei / Noto Sans SC)。
|
|
||||||
- 数据 / 等宽:JetBrains Mono(内嵌 woff2),用于所有指标、日志、时间戳、small-caps 标签。
|
|
||||||
- 字号阶梯:9px(small-caps 标签,letter-spacing .12–.16em)/ 11px(caption、日志)/ 12.5–14px(正文)/ 16px(小标题)/ 19px(标题)/ 24–32px(指标数字)。
|
|
||||||
- 生产环境不加载 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.json:JSON 编辑器,密钥掩码(`********`,原样提交自动还原)、实时 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),无需放宽。
|
|
||||||
- 二进制体积增量约 100–150KB(Space 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` 全部通过。
|
|
||||||
@ -3,7 +3,7 @@
|
|||||||
配置文件加载顺序:`~/.picobot/config.json` → 当前目录 `./config.json`。
|
配置文件加载顺序:`~/.picobot/config.json` → 当前目录 `./config.json`。
|
||||||
占位符 `<VAR_NAME>` 从启动环境替换。PicoBot 依次加载 `config.json` 同目录的 `.env`、`workspace_dir/.env`,最后保留启动进程已有环境变量作为最高优先级;workspace 层覆盖配置目录层。合并值也会进入进程环境,供 MCP 和工具子进程继承。workspace `.env` 不能修改用于定位自身的 `workspace_dir`。
|
占位符 `<VAR_NAME>` 从启动环境替换。PicoBot 依次加载 `config.json` 同目录的 `.env`、`workspace_dir/.env`,最后保留启动进程已有环境变量作为最高优先级;workspace 层覆盖配置目录层。合并值也会进入进程环境,供 MCP 和工具子进程继承。workspace `.env` 不能修改用于定位自身的 `workspace_dir`。
|
||||||
|
|
||||||
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 上下文。
|
Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取时 API Key、secret、password 和 token 会显示为 `********`,保持掩码不变再保存会保留原值;写入采用同目录临时文件替换。运行配置保存后需要重启 Gateway,`USER.md` 与 `AGENTS.md` 的修改用于后续构建的 Agent 上下文。
|
||||||
|
|
||||||
## config.json 结构
|
## config.json 结构
|
||||||
|
|
||||||
@ -105,7 +105,7 @@ Gateway WebUI 的“配置”页可以编辑实际加载的配置文件。读取
|
|||||||
| `media_dir_max_bytes` | int | 536870912 | 飞书媒体目录容量上限;达到上限后拒绝新下载,不自动删除旧文件 |
|
| `media_dir_max_bytes` | int | 536870912 | 飞书媒体目录容量上限;达到上限后拒绝新下载,不自动删除旧文件 |
|
||||||
| `request_timeout_secs` | int | 30 | 单次飞书 HTTP 请求及响应体读取的硬超时,运行时限制在 5–120 秒 |
|
| `request_timeout_secs` | int | 30 | 单次飞书 HTTP 请求及响应体读取的硬超时,运行时限制在 5–120 秒 |
|
||||||
|
|
||||||
飞书属于外部渠道:无论是否开启实时卡片,都不会接收模型 reasoning;工具只显示紧凑状态。渠道配置可通过 Gateway 配置重载生效。
|
飞书属于外部渠道:无论是否开启实时卡片,都不会接收模型 reasoning;工具只显示紧凑状态。配置修改需重启 Gateway 生效。
|
||||||
|
|
||||||
## mcp 字段
|
## mcp 字段
|
||||||
|
|
||||||
|
|||||||
@ -126,7 +126,6 @@ pub struct SubAgentManager {
|
|||||||
skills_loader: Option<Arc<SkillsLoader>>,
|
skills_loader: Option<Arc<SkillsLoader>>,
|
||||||
work_manager: Option<Arc<crate::work::WorkManager>>,
|
work_manager: Option<Arc<crate::work::WorkManager>>,
|
||||||
task_supervisor: crate::task_supervisor::TaskSupervisor,
|
task_supervisor: crate::task_supervisor::TaskSupervisor,
|
||||||
admission: crate::gateway::reload::RuntimeAdmission,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SubAgentManager {
|
impl SubAgentManager {
|
||||||
@ -150,18 +149,9 @@ impl SubAgentManager {
|
|||||||
skills_loader,
|
skills_loader,
|
||||||
work_manager: None,
|
work_manager: None,
|
||||||
task_supervisor,
|
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 {
|
pub fn with_work_manager(mut self, work_manager: Arc<crate::work::WorkManager>) -> Self {
|
||||||
self.work_manager = Some(work_manager);
|
self.work_manager = Some(work_manager);
|
||||||
self
|
self
|
||||||
@ -174,11 +164,7 @@ impl SubAgentManager {
|
|||||||
};
|
};
|
||||||
let filtered = ToolRegistry::new();
|
let filtered = ToolRegistry::new();
|
||||||
for (name, tool) in self.full_tools.iter() {
|
for (name, tool) in self.full_tools.iter() {
|
||||||
if allowed_set.contains(name.as_str())
|
if allowed_set.contains(name.as_str()) && name != "delegate" && name != "todo" {
|
||||||
&& name != "delegate"
|
|
||||||
&& name != "todo"
|
|
||||||
&& name != "reload_config"
|
|
||||||
{
|
|
||||||
filtered.register_raw(name, tool);
|
filtered.register_raw(name, tool);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -338,12 +324,6 @@ impl SubAgentManager {
|
|||||||
config: SubAgentConfig,
|
config: SubAgentConfig,
|
||||||
ctx: DelegateContext,
|
ctx: DelegateContext,
|
||||||
) -> Result<String, SubAgentError> {
|
) -> 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
|
let permit = self
|
||||||
.background_permits
|
.background_permits
|
||||||
.clone()
|
.clone()
|
||||||
@ -443,7 +423,6 @@ impl SubAgentManager {
|
|||||||
let spawned = self.task_supervisor.spawn_graceful(
|
let spawned = self.task_supervisor.spawn_graceful(
|
||||||
format!("sub-agent:{task_id}"),
|
format!("sub-agent:{task_id}"),
|
||||||
async move {
|
async move {
|
||||||
let _activity = activity;
|
|
||||||
let _permit = permit;
|
let _permit = permit;
|
||||||
let started_at = chrono::Utc::now().timestamp_millis();
|
let started_at = chrono::Utc::now().timestamp_millis();
|
||||||
|
|
||||||
@ -872,17 +851,4 @@ mod tests {
|
|||||||
|
|
||||||
assert!(matches!(error, SubAgentError::TooManyTasks(1)));
|
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -98,30 +98,6 @@ fn gateway_http_base_url(gateway_url: &str) -> Result<String, Box<dyn std::error
|
|||||||
Ok(url.to_string().trim_end_matches('/').to_string())
|
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(
|
async fn exchange_pairing_code(
|
||||||
gateway_url: &str,
|
gateway_url: &str,
|
||||||
code: &str,
|
code: &str,
|
||||||
|
|||||||
@ -538,32 +538,6 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn load_from(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
|
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() {
|
let config_path = if path.exists() {
|
||||||
path.to_path_buf()
|
path.to_path_buf()
|
||||||
} else {
|
} else {
|
||||||
@ -580,6 +554,7 @@ impl Config {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let content = fs::read_to_string(&config_path)?;
|
let content = fs::read_to_string(&config_path)?;
|
||||||
|
let process_env = collect_process_env();
|
||||||
let config_env_path = config_path
|
let config_env_path = config_path
|
||||||
.parent()
|
.parent()
|
||||||
.unwrap_or_else(|| Path::new("."))
|
.unwrap_or_else(|| Path::new("."))
|
||||||
@ -588,19 +563,14 @@ impl Config {
|
|||||||
|
|
||||||
// The config-directory layer selects the workspace. Loading the workspace
|
// The config-directory layer selects the workspace. Loading the workspace
|
||||||
// layer first would be circular because its location comes from config.json.
|
// 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_content = resolve_env_placeholders(&content, &initial_env);
|
||||||
let initial_config: Config = serde_json::from_str(&initial_content)?;
|
let initial_config: Config = serde_json::from_str(&initial_content)?;
|
||||||
let mut workspace_path = expand_path(&initial_config.workspace_dir);
|
let 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_path = workspace_path.join(".env");
|
||||||
let workspace_env = read_env_file(&workspace_env_path)?;
|
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 resolved_content = resolve_env_placeholders(&content, &effective_env);
|
||||||
let config: Config = serde_json::from_str(&resolved_content)?;
|
let config: Config = serde_json::from_str(&resolved_content)?;
|
||||||
if config.workspace_dir != initial_config.workspace_dir {
|
if config.workspace_dir != initial_config.workspace_dir {
|
||||||
@ -611,9 +581,7 @@ impl Config {
|
|||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
if apply_to_process {
|
apply_env_layers(&config_env, &workspace_env, &process_env);
|
||||||
apply_env_layers(&config_env, &workspace_env, process_env);
|
|
||||||
}
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
path = %config_path.display(),
|
path = %config_path.display(),
|
||||||
config_env = %config_env_path.display(),
|
config_env = %config_env_path.display(),
|
||||||
|
|||||||
@ -78,48 +78,6 @@ 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)]
|
#[derive(Debug)]
|
||||||
pub struct ApiError {
|
pub struct ApiError {
|
||||||
status: StatusCode,
|
status: StatusCode,
|
||||||
@ -148,20 +106,6 @@ 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 {
|
fn internal(error: impl std::fmt::Display) -> Self {
|
||||||
tracing::error!(error = %error, "WebUI API request failed");
|
tracing::error!(error = %error, "WebUI API request failed");
|
||||||
Self {
|
Self {
|
||||||
@ -400,36 +344,6 @@ pub struct ConfigResponse {
|
|||||||
restart_required: bool,
|
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(
|
pub async fn get_config(
|
||||||
State(state): State<Arc<GatewayState>>,
|
State(state): State<Arc<GatewayState>>,
|
||||||
) -> Result<Json<ConfigResponse>, ApiError> {
|
) -> Result<Json<ConfigResponse>, ApiError> {
|
||||||
@ -475,7 +389,7 @@ pub async fn put_config(
|
|||||||
|
|
||||||
let pretty = serde_json::to_string_pretty(&incoming).map_err(ApiError::internal)? + "\n";
|
let pretty = serde_json::to_string_pretty(&incoming).map_err(ApiError::internal)? + "\n";
|
||||||
atomic_write(&state.config_path, pretty.as_bytes()).await?;
|
atomic_write(&state.config_path, pretty.as_bytes()).await?;
|
||||||
tracing::info!(path = %state.config_path.display(), "Configuration updated from WebUI; reload or restart required");
|
tracing::info!(path = %state.config_path.display(), "Configuration updated from WebUI; restart required");
|
||||||
|
|
||||||
let mut response = incoming;
|
let mut response = incoming;
|
||||||
redact_secrets(&mut response);
|
redact_secrets(&mut response);
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub(crate) mod reload;
|
|
||||||
mod router;
|
mod router;
|
||||||
pub mod uploads;
|
pub mod uploads;
|
||||||
pub mod ws;
|
pub mod ws;
|
||||||
@ -33,33 +32,20 @@ pub struct GatewayState {
|
|||||||
pub connection_shutdown: tokio_util::sync::CancellationToken,
|
pub connection_shutdown: tokio_util::sync::CancellationToken,
|
||||||
pub auth: auth::AuthManager,
|
pub auth: auth::AuthManager,
|
||||||
pub uploads: uploads::UploadRegistry,
|
pub uploads: uploads::UploadRegistry,
|
||||||
pub(crate) reload: reload::ReloadHandle,
|
|
||||||
pub(crate) admission: reload::RuntimeAdmission,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GatewayState {
|
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>> {
|
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let config_path = crate::config::resolve_default_config_path();
|
let config_path = crate::config::resolve_default_config_path();
|
||||||
let config = Config::load_from(&config_path)?;
|
let config = Config::load_from(&config_path)?;
|
||||||
Self::from_config(
|
Self::from_config(config, config_path).await
|
||||||
config,
|
|
||||||
config_path,
|
|
||||||
reload::ReloadHandle::unavailable(),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn from_config(
|
async fn from_config(
|
||||||
config: Config,
|
config: Config,
|
||||||
config_path: std::path::PathBuf,
|
config_path: std::path::PathBuf,
|
||||||
reload: reload::ReloadHandle,
|
|
||||||
initialize_process: bool,
|
|
||||||
) -> Result<Self, Box<dyn std::error::Error>> {
|
) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
let task_supervisor = TaskSupervisor::new();
|
let task_supervisor = TaskSupervisor::new();
|
||||||
let admission = reload::RuntimeAdmission::open();
|
|
||||||
let delivery_coordinator = DeliveryCoordinator::new(ConversationWriteLocks::default());
|
let delivery_coordinator = DeliveryCoordinator::new(ConversationWriteLocks::default());
|
||||||
let connection_shutdown = tokio_util::sync::CancellationToken::new();
|
let connection_shutdown = tokio_util::sync::CancellationToken::new();
|
||||||
let auth = auth::AuthManager::load(
|
let auth = auth::AuthManager::load(
|
||||||
@ -73,9 +59,7 @@ impl GatewayState {
|
|||||||
let workspace_path = expand_path(&config.workspace_dir);
|
let workspace_path = expand_path(&config.workspace_dir);
|
||||||
let workspace_path = ensure_workspace_dir(&workspace_path)?;
|
let workspace_path = ensure_workspace_dir(&workspace_path)?;
|
||||||
|
|
||||||
if initialize_process {
|
// Switch current working directory to workspace
|
||||||
// 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| {
|
std::env::set_current_dir(&workspace_path).map_err(|e| {
|
||||||
format!(
|
format!(
|
||||||
"Failed to switch to workspace directory {}: {}",
|
"Failed to switch to workspace directory {}: {}",
|
||||||
@ -83,14 +67,11 @@ impl GatewayState {
|
|||||||
e
|
e
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
}
|
|
||||||
|
|
||||||
tracing::info!("Using workspace directory: {}", workspace_path.display());
|
tracing::info!("Using workspace directory: {}", workspace_path.display());
|
||||||
|
|
||||||
// Release default AGENTS.md and USER.md to ~/.picobot/ if not exist
|
// Release default AGENTS.md and USER.md to ~/.picobot/ if not exist
|
||||||
if initialize_process {
|
|
||||||
ensure_default_config_files();
|
ensure_default_config_files();
|
||||||
}
|
|
||||||
|
|
||||||
// Get provider config for SessionManager
|
// Get provider config for SessionManager
|
||||||
let mut provider_config = config.get_provider_config("default")?;
|
let mut provider_config = config.get_provider_config("default")?;
|
||||||
@ -160,9 +141,7 @@ impl GatewayState {
|
|||||||
memory_manager,
|
memory_manager,
|
||||||
task_supervisor.clone(),
|
task_supervisor.clone(),
|
||||||
turn_delivery,
|
turn_delivery,
|
||||||
reload.clone(),
|
),
|
||||||
)
|
|
||||||
.with_admission(admission.clone()),
|
|
||||||
browser_config,
|
browser_config,
|
||||||
config.gateway.max_concurrent_background_tasks,
|
config.gateway.max_concurrent_background_tasks,
|
||||||
)?;
|
)?;
|
||||||
@ -181,6 +160,21 @@ impl GatewayState {
|
|||||||
valid_channels.clone(),
|
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
|
// Initialize scheduler if enabled in config
|
||||||
let scheduler_config = config.gateway.scheduler.clone().unwrap_or_default();
|
let scheduler_config = config.gateway.scheduler.clone().unwrap_or_default();
|
||||||
if scheduler_config.enabled {
|
if scheduler_config.enabled {
|
||||||
@ -231,8 +225,6 @@ impl GatewayState {
|
|||||||
connection_shutdown,
|
connection_shutdown,
|
||||||
auth,
|
auth,
|
||||||
uploads,
|
uploads,
|
||||||
reload,
|
|
||||||
admission,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -248,21 +240,6 @@ impl GatewayState {
|
|||||||
|
|
||||||
/// Start the message processing loops
|
/// Start the message processing loops
|
||||||
pub async fn start_message_processing(&self) {
|
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 = self.bus();
|
||||||
let bus_for_outbound = bus.clone();
|
let bus_for_outbound = bus.clone();
|
||||||
let session_manager = self.session_manager.clone();
|
let session_manager = self.session_manager.clone();
|
||||||
@ -299,12 +276,7 @@ impl GatewayState {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router::spawn_message_routers(
|
router::spawn_message_routers(bus.clone(), session_manager, self.task_supervisor.clone());
|
||||||
bus.clone(),
|
|
||||||
session_manager,
|
|
||||||
self.task_supervisor.clone(),
|
|
||||||
self.admission.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Spawn outbound dispatcher
|
// Spawn outbound dispatcher
|
||||||
let dispatcher = OutboundDispatcher::new(
|
let dispatcher = OutboundDispatcher::new(
|
||||||
@ -323,11 +295,10 @@ impl GatewayState {
|
|||||||
// Spawn scheduler background task if enabled
|
// Spawn scheduler background task if enabled
|
||||||
let scheduler_config = self.config.gateway.scheduler.clone().unwrap_or_default();
|
let scheduler_config = self.config.gateway.scheduler.clone().unwrap_or_default();
|
||||||
if scheduler_config.enabled {
|
if scheduler_config.enabled {
|
||||||
let sched = Arc::new(Scheduler::with_admission(
|
let sched = Arc::new(Scheduler::new(
|
||||||
self.storage.clone(),
|
self.storage.clone(),
|
||||||
self.session_manager.clone(),
|
self.session_manager.clone(),
|
||||||
scheduler_config,
|
scheduler_config,
|
||||||
self.admission.clone(),
|
|
||||||
));
|
));
|
||||||
self.task_supervisor.spawn("scheduler", async move {
|
self.task_supervisor.spawn("scheduler", async move {
|
||||||
sched.run().await;
|
sched.run().await;
|
||||||
@ -342,230 +313,30 @@ pub async fn run(
|
|||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let config_path = crate::config::resolve_default_config_path();
|
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)?;
|
let config = Config::load_from(&config_path)?;
|
||||||
|
|
||||||
// Initialize logging
|
// Initialize logging
|
||||||
logging::init_logging();
|
logging::init_logging();
|
||||||
tracing::info!(config_path = %config_path.display(), "Starting PicoBot Gateway");
|
tracing::info!(config_path = %config_path.display(), "Starting PicoBot Gateway");
|
||||||
|
|
||||||
let mut reload_controller = reload::ReloadController::new(startup_process_env, startup_cwd);
|
let state = Arc::new(GatewayState::from_config(config, config_path).await?);
|
||||||
let mut state = Arc::new(
|
|
||||||
GatewayState::from_config(
|
// Start all channels (init already done while constructing GatewayState)
|
||||||
config,
|
state.channel_manager.start_all().await?;
|
||||||
config_path.clone(),
|
|
||||||
reload_controller.handle.clone(),
|
// Start message processing (inbound processor + control processor + outbound dispatcher)
|
||||||
true,
|
state.start_message_processing().await;
|
||||||
)
|
|
||||||
.await?,
|
|
||||||
);
|
|
||||||
|
|
||||||
// CLI args override config file values
|
// CLI args override config file values
|
||||||
let bind_host = host.unwrap_or_else(|| state.config.gateway.host.clone());
|
let bind_host = host.unwrap_or_else(|| state.config.gateway.host.clone());
|
||||||
let bind_port = port.unwrap_or(state.config.gateway.port);
|
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()
|
let protected = Router::new()
|
||||||
.route("/api/health", routing::get(http::health))
|
.route("/api/health", routing::get(http::health))
|
||||||
.route(
|
.route(
|
||||||
"/api/config",
|
"/api/config",
|
||||||
routing::get(http::get_config).put(http::put_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(
|
.route(
|
||||||
"/api/profiles/{name}",
|
"/api/profiles/{name}",
|
||||||
routing::get(http::get_profile).put(http::put_profile),
|
routing::get(http::get_profile).put(http::put_profile),
|
||||||
@ -589,18 +360,45 @@ fn build_router(state: Arc<GatewayState>) -> Router {
|
|||||||
auth::require_auth,
|
auth::require_auth,
|
||||||
));
|
));
|
||||||
|
|
||||||
Router::new()
|
let app = Router::new()
|
||||||
.route("/", routing::get(http::webui_index))
|
.route("/", routing::get(http::webui_index))
|
||||||
.route("/app.js", routing::get(http::webui_script))
|
.route("/app.js", routing::get(http::webui_script))
|
||||||
.route("/styles.css", routing::get(http::webui_styles))
|
.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("/health", routing::get(http::health))
|
||||||
.route("/api/auth/status", routing::get(auth::status))
|
.route("/api/auth/status", routing::get(auth::status))
|
||||||
.route("/api/auth/pair", routing::post(auth::pair))
|
.route("/api/auth/pair", routing::post(auth::pair))
|
||||||
.route("/api/auth/code", routing::post(auth::issue_code))
|
.route("/api/auth/code", routing::post(auth::issue_code))
|
||||||
.merge(protected)
|
.merge(protected)
|
||||||
.with_state(state)
|
.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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn wait_for_shutdown_signal() {
|
async fn wait_for_shutdown_signal() {
|
||||||
|
|||||||
@ -1,486 +0,0 @@
|
|||||||
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(),
|
|
||||||
¤t,
|
|
||||||
&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(),
|
|
||||||
¤t,
|
|
||||||
&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(),
|
|
||||||
¤t,
|
|
||||||
&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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -8,7 +8,6 @@ use tokio::sync::{Semaphore, mpsc};
|
|||||||
use crate::bus::{ControlMessage, InboundMessage, MessageBus, OutboundMessage};
|
use crate::bus::{ControlMessage, InboundMessage, MessageBus, OutboundMessage};
|
||||||
use crate::channels::ChannelError;
|
use crate::channels::ChannelError;
|
||||||
use crate::channels::parse_slash_command;
|
use crate::channels::parse_slash_command;
|
||||||
use crate::gateway::reload::{ActivityGuard, RuntimeAdmission};
|
|
||||||
use crate::session::{SessionCommand, SessionEvent, SessionManager};
|
use crate::session::{SessionCommand, SessionEvent, SessionManager};
|
||||||
use crate::task_supervisor::TaskSupervisor;
|
use crate::task_supervisor::TaskSupervisor;
|
||||||
|
|
||||||
@ -20,14 +19,8 @@ pub(super) fn spawn_message_routers(
|
|||||||
bus: Arc<MessageBus>,
|
bus: Arc<MessageBus>,
|
||||||
session_manager: Arc<SessionManager>,
|
session_manager: Arc<SessionManager>,
|
||||||
supervisor: TaskSupervisor,
|
supervisor: TaskSupervisor,
|
||||||
admission: RuntimeAdmission,
|
|
||||||
) {
|
) {
|
||||||
spawn_inbound_router(
|
spawn_inbound_router(bus.clone(), session_manager.clone(), supervisor.clone());
|
||||||
bus.clone(),
|
|
||||||
session_manager.clone(),
|
|
||||||
supervisor.clone(),
|
|
||||||
admission,
|
|
||||||
);
|
|
||||||
spawn_control_router(bus, session_manager, supervisor);
|
spawn_control_router(bus, session_manager, supervisor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,12 +28,11 @@ fn spawn_inbound_router(
|
|||||||
bus: Arc<MessageBus>,
|
bus: Arc<MessageBus>,
|
||||||
session_manager: Arc<SessionManager>,
|
session_manager: Arc<SessionManager>,
|
||||||
supervisor: TaskSupervisor,
|
supervisor: TaskSupervisor,
|
||||||
admission: RuntimeAdmission,
|
|
||||||
) {
|
) {
|
||||||
let lane_supervisor = supervisor.clone();
|
let lane_supervisor = supervisor.clone();
|
||||||
supervisor.spawn("inbound-router", async move {
|
supervisor.spawn("inbound-router", async move {
|
||||||
tracing::info!(lane_capacity = INBOUND_LANE_CAPACITY, "Inbound router started");
|
tracing::info!(lane_capacity = INBOUND_LANE_CAPACITY, "Inbound router started");
|
||||||
let mut lanes: HashMap<String, mpsc::Sender<AdmittedInbound>> = HashMap::new();
|
let mut lanes: HashMap<String, mpsc::Sender<InboundMessage>> = HashMap::new();
|
||||||
let mut messages_seen = 0_u64;
|
let mut messages_seen = 0_u64;
|
||||||
|
|
||||||
while let Some(inbound) = bus.consume_inbound().await {
|
while let Some(inbound) = bus.consume_inbound().await {
|
||||||
@ -49,26 +41,12 @@ fn spawn_inbound_router(
|
|||||||
lanes.retain(|_, sender| !sender.is_closed());
|
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
|
// Stop must be able to invalidate a running worker even when an
|
||||||
// earlier slow slash command occupies this conversation's lane.
|
// earlier slow slash command occupies this conversation's lane.
|
||||||
if is_priority_stop(&inbound.inbound.content) {
|
if is_priority_stop(&inbound.content) {
|
||||||
let request_bus = bus.clone();
|
let request_bus = bus.clone();
|
||||||
let request_manager = session_manager.clone();
|
let request_manager = session_manager.clone();
|
||||||
let task_name = format!(
|
let task_name = format!("inbound-stop:{}:{}", inbound.channel, inbound.chat_id);
|
||||||
"inbound-stop:{}:{}",
|
|
||||||
inbound.inbound.channel, inbound.inbound.chat_id
|
|
||||||
);
|
|
||||||
if !lane_supervisor.spawn(task_name, async move {
|
if !lane_supervisor.spawn(task_name, async move {
|
||||||
process_inbound(request_bus, request_manager, inbound).await;
|
process_inbound(request_bus, request_manager, inbound).await;
|
||||||
}) {
|
}) {
|
||||||
@ -77,7 +55,7 @@ fn spawn_inbound_router(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = conversation_key(&inbound.inbound.channel, &inbound.inbound.chat_id);
|
let key = conversation_key(&inbound.channel, &inbound.chat_id);
|
||||||
let mut sender = lanes.get(&key).cloned();
|
let mut sender = lanes.get(&key).cloned();
|
||||||
if sender.as_ref().is_none_or(mpsc::Sender::is_closed) {
|
if sender.as_ref().is_none_or(mpsc::Sender::is_closed) {
|
||||||
let (new_sender, receiver) = mpsc::channel(INBOUND_LANE_CAPACITY);
|
let (new_sender, receiver) = mpsc::channel(INBOUND_LANE_CAPACITY);
|
||||||
@ -85,8 +63,8 @@ fn spawn_inbound_router(
|
|||||||
&lane_supervisor,
|
&lane_supervisor,
|
||||||
bus.clone(),
|
bus.clone(),
|
||||||
session_manager.clone(),
|
session_manager.clone(),
|
||||||
inbound.inbound.channel.clone(),
|
inbound.channel.clone(),
|
||||||
inbound.inbound.chat_id.clone(),
|
inbound.chat_id.clone(),
|
||||||
receiver,
|
receiver,
|
||||||
) {
|
) {
|
||||||
tracing::warn!("Inbound router is stopping");
|
tracing::warn!("Inbound router is stopping");
|
||||||
@ -103,10 +81,10 @@ fn spawn_inbound_router(
|
|||||||
match sender.try_send(inbound) {
|
match sender.try_send(inbound) {
|
||||||
Ok(()) => {}
|
Ok(()) => {}
|
||||||
Err(mpsc::error::TrySendError::Full(inbound)) => {
|
Err(mpsc::error::TrySendError::Full(inbound)) => {
|
||||||
tracing::warn!(channel = %inbound.inbound.channel, chat_id = %inbound.inbound.chat_id, "Inbound conversation lane is full");
|
tracing::warn!(channel = %inbound.channel, chat_id = %inbound.chat_id, "Inbound conversation lane is full");
|
||||||
publish_command_output(
|
publish_command_output(
|
||||||
&bus,
|
&bus,
|
||||||
inbound.inbound,
|
inbound,
|
||||||
"当前对话入口队列已满,请稍后重试。".to_string(),
|
"当前对话入口队列已满,请稍后重试。".to_string(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@ -120,15 +98,15 @@ fn spawn_inbound_router(
|
|||||||
&lane_supervisor,
|
&lane_supervisor,
|
||||||
bus.clone(),
|
bus.clone(),
|
||||||
session_manager.clone(),
|
session_manager.clone(),
|
||||||
inbound.inbound.channel.clone(),
|
inbound.channel.clone(),
|
||||||
inbound.inbound.chat_id.clone(),
|
inbound.chat_id.clone(),
|
||||||
receiver,
|
receiver,
|
||||||
) {
|
) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
lanes.insert(key, new_sender.clone());
|
lanes.insert(key, new_sender.clone());
|
||||||
if new_sender.try_send(inbound).is_err() {
|
if let Err(error) = new_sender.try_send(inbound) {
|
||||||
tracing::error!("Failed to enqueue input into replacement lane");
|
tracing::error!(error = %error, "Failed to enqueue input into replacement lane");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -143,7 +121,7 @@ fn spawn_inbound_lane(
|
|||||||
session_manager: Arc<SessionManager>,
|
session_manager: Arc<SessionManager>,
|
||||||
channel: String,
|
channel: String,
|
||||||
chat_id: String,
|
chat_id: String,
|
||||||
receiver: mpsc::Receiver<AdmittedInbound>,
|
receiver: mpsc::Receiver<InboundMessage>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
supervisor.spawn(format!("inbound-lane:{channel}:{chat_id}"), async move {
|
supervisor.spawn(format!("inbound-lane:{channel}:{chat_id}"), async move {
|
||||||
run_ordered_lane(receiver, INBOUND_LANE_IDLE_TIMEOUT, move |inbound| {
|
run_ordered_lane(receiver, INBOUND_LANE_IDLE_TIMEOUT, move |inbound| {
|
||||||
@ -174,12 +152,8 @@ async fn run_ordered_lane<T, F, Fut>(
|
|||||||
async fn process_inbound(
|
async fn process_inbound(
|
||||||
bus: Arc<MessageBus>,
|
bus: Arc<MessageBus>,
|
||||||
session_manager: Arc<SessionManager>,
|
session_manager: Arc<SessionManager>,
|
||||||
admitted: AdmittedInbound,
|
inbound: InboundMessage,
|
||||||
) {
|
) {
|
||||||
let AdmittedInbound {
|
|
||||||
inbound,
|
|
||||||
activity: _activity,
|
|
||||||
} = admitted;
|
|
||||||
let result = session_manager.handle_message(&inbound).await;
|
let result = session_manager.handle_message(&inbound).await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
@ -198,20 +172,14 @@ async fn process_inbound(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn publish_assistant_output(bus: &MessageBus, inbound: InboundMessage, content: String) {
|
async fn publish_assistant_output(bus: &MessageBus, inbound: InboundMessage, content: String) {
|
||||||
publish_output(bus, inbound, content, false, false).await;
|
publish_output(bus, inbound, content, false).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn publish_command_output(bus: &MessageBus, inbound: InboundMessage, content: String) {
|
async fn publish_command_output(bus: &MessageBus, inbound: InboundMessage, content: String) {
|
||||||
publish_output(bus, inbound, content, true, true).await;
|
publish_output(bus, inbound, content, true).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn publish_output(
|
async fn publish_output(bus: &MessageBus, inbound: InboundMessage, content: String, command: bool) {
|
||||||
bus: &MessageBus,
|
|
||||||
inbound: InboundMessage,
|
|
||||||
content: String,
|
|
||||||
command: bool,
|
|
||||||
confirmed: bool,
|
|
||||||
) {
|
|
||||||
let mut metadata = inbound.channel_context.private;
|
let mut metadata = inbound.channel_context.private;
|
||||||
if command {
|
if command {
|
||||||
metadata.insert("_type".to_string(), "command".to_string());
|
metadata.insert("_type".to_string(), "command".to_string());
|
||||||
@ -225,21 +193,11 @@ async fn publish_output(
|
|||||||
metadata,
|
metadata,
|
||||||
delivery: None,
|
delivery: None,
|
||||||
};
|
};
|
||||||
let result = if confirmed {
|
if let Err(error) = bus.publish_outbound(outbound).await {
|
||||||
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");
|
tracing::error!(error = %error, "Failed to publish routed outbound message");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct AdmittedInbound {
|
|
||||||
inbound: InboundMessage,
|
|
||||||
activity: ActivityGuard,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spawn_control_router(
|
fn spawn_control_router(
|
||||||
bus: Arc<MessageBus>,
|
bus: Arc<MessageBus>,
|
||||||
session_manager: Arc<SessionManager>,
|
session_manager: Arc<SessionManager>,
|
||||||
@ -424,10 +382,7 @@ mod tests {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let publish_task = tokio::spawn({
|
publish_command_output(&bus, inbound, "done".to_string()).await;
|
||||||
let bus = bus.clone();
|
|
||||||
async move { publish_command_output(&bus, inbound, "done".to_string()).await }
|
|
||||||
});
|
|
||||||
let output = bus.consume_outbound().await.unwrap();
|
let output = bus.consume_outbound().await.unwrap();
|
||||||
|
|
||||||
assert_eq!(output.reply_to.as_deref(), Some("parent"));
|
assert_eq!(output.reply_to.as_deref(), Some("parent"));
|
||||||
@ -439,9 +394,6 @@ mod tests {
|
|||||||
output.metadata.get("_type").map(String::as_str),
|
output.metadata.get("_type").map(String::as_str),
|
||||||
Some("command")
|
Some("command")
|
||||||
);
|
);
|
||||||
assert!(!publish_task.is_finished());
|
|
||||||
output.complete_delivery(Ok(()));
|
|
||||||
publish_task.await.unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
13
src/main.rs
13
src/main.rs
@ -56,12 +56,6 @@ enum Command {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
port: Option<u16>,
|
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
|
/// Generate a one-time browser pairing code from the local gateway
|
||||||
Pair {
|
Pair {
|
||||||
/// Gateway WebSocket or HTTP URL
|
/// Gateway WebSocket or HTTP URL
|
||||||
@ -129,13 +123,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Command::Gateway { host, port } => {
|
Command::Gateway { host, port } => {
|
||||||
picobot::gateway::run(host, port).await?;
|
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 {
|
Command::Pair {
|
||||||
gateway_url,
|
gateway_url,
|
||||||
revoke_all,
|
revoke_all,
|
||||||
|
|||||||
@ -97,7 +97,6 @@ pub struct Scheduler {
|
|||||||
session_manager: Arc<SessionManager>,
|
session_manager: Arc<SessionManager>,
|
||||||
config: SchedulerConfig,
|
config: SchedulerConfig,
|
||||||
owner: String,
|
owner: String,
|
||||||
admission: crate::gateway::reload::RuntimeAdmission,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Scheduler {
|
impl Scheduler {
|
||||||
@ -105,27 +104,12 @@ impl Scheduler {
|
|||||||
storage: Arc<Storage>,
|
storage: Arc<Storage>,
|
||||||
session_manager: Arc<SessionManager>,
|
session_manager: Arc<SessionManager>,
|
||||||
config: SchedulerConfig,
|
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 {
|
||||||
Self {
|
Self {
|
||||||
storage,
|
storage,
|
||||||
session_manager,
|
session_manager,
|
||||||
config,
|
config,
|
||||||
owner: uuid::Uuid::new_v4().to_string(),
|
owner: uuid::Uuid::new_v4().to_string(),
|
||||||
admission,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -148,9 +132,6 @@ impl Scheduler {
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
if !self.admission.is_accepting() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let now = now_ms();
|
let now = now_ms();
|
||||||
let lease_ms = self
|
let lease_ms = self
|
||||||
.config
|
.config
|
||||||
@ -186,16 +167,6 @@ impl Scheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_claimed_job(self: Arc<Self>, job: ScheduledJob) {
|
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 start = Instant::now();
|
||||||
let started_at = now_ms();
|
let started_at = now_ms();
|
||||||
tracing::info!(job_id = %job.id, job_name = %job.name, "scheduler: executing claimed job");
|
tracing::info!(job_id = %job.id, job_name = %job.name, "scheduler: executing claimed job");
|
||||||
|
|||||||
@ -1428,7 +1428,6 @@ pub struct SessionManager {
|
|||||||
sub_agent_manager: Arc<crate::agent::SubAgentManager>,
|
sub_agent_manager: Arc<crate::agent::SubAgentManager>,
|
||||||
task_supervisor: crate::task_supervisor::TaskSupervisor,
|
task_supervisor: crate::task_supervisor::TaskSupervisor,
|
||||||
turn_delivery: TurnDeliveryService,
|
turn_delivery: TurnDeliveryService,
|
||||||
reload: crate::gateway::reload::ReloadHandle,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gateway-owned runtime services shared by all Session workers.
|
/// Gateway-owned runtime services shared by all Session workers.
|
||||||
@ -1437,8 +1436,6 @@ pub struct SessionManagerServices {
|
|||||||
memory_manager: Arc<crate::memory::MemoryManager>,
|
memory_manager: Arc<crate::memory::MemoryManager>,
|
||||||
task_supervisor: crate::task_supervisor::TaskSupervisor,
|
task_supervisor: crate::task_supervisor::TaskSupervisor,
|
||||||
turn_delivery: TurnDeliveryService,
|
turn_delivery: TurnDeliveryService,
|
||||||
reload: crate::gateway::reload::ReloadHandle,
|
|
||||||
admission: crate::gateway::reload::RuntimeAdmission,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionManagerServices {
|
impl SessionManagerServices {
|
||||||
@ -1447,25 +1444,14 @@ impl SessionManagerServices {
|
|||||||
memory_manager: Arc<crate::memory::MemoryManager>,
|
memory_manager: Arc<crate::memory::MemoryManager>,
|
||||||
task_supervisor: crate::task_supervisor::TaskSupervisor,
|
task_supervisor: crate::task_supervisor::TaskSupervisor,
|
||||||
turn_delivery: TurnDeliveryService,
|
turn_delivery: TurnDeliveryService,
|
||||||
reload: crate::gateway::reload::ReloadHandle,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
bus,
|
bus,
|
||||||
memory_manager,
|
memory_manager,
|
||||||
task_supervisor,
|
task_supervisor,
|
||||||
turn_delivery,
|
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 {
|
struct SessionManagerInner {
|
||||||
@ -1558,11 +1544,6 @@ pub static SLASH_COMMANDS: &[SlashCommand] = &[
|
|||||||
description: "查看、完成或取消当前任务计划",
|
description: "查看、完成或取消当前任务计划",
|
||||||
aliases: &["/todo"],
|
aliases: &["/todo"],
|
||||||
},
|
},
|
||||||
SlashCommand {
|
|
||||||
name: "reload",
|
|
||||||
description: "重新加载配置",
|
|
||||||
aliases: &["/reload"],
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
fn resolve_slash_command(command: &str) -> Option<&'static SlashCommand> {
|
fn resolve_slash_command(command: &str) -> Option<&'static SlashCommand> {
|
||||||
@ -1600,8 +1581,6 @@ impl SessionManager {
|
|||||||
memory_manager,
|
memory_manager,
|
||||||
task_supervisor,
|
task_supervisor,
|
||||||
turn_delivery,
|
turn_delivery,
|
||||||
reload,
|
|
||||||
admission,
|
|
||||||
} = services;
|
} = services;
|
||||||
let mut skills_loader = SkillsLoader::new();
|
let mut skills_loader = SkillsLoader::new();
|
||||||
skills_loader.load_skills();
|
skills_loader.load_skills();
|
||||||
@ -1629,11 +1608,9 @@ impl SessionManager {
|
|||||||
Some(skills_loader.clone()),
|
Some(skills_loader.clone()),
|
||||||
task_supervisor.clone(),
|
task_supervisor.clone(),
|
||||||
)
|
)
|
||||||
.with_admission(admission)
|
|
||||||
.with_work_manager(work_manager.clone()),
|
.with_work_manager(work_manager.clone()),
|
||||||
);
|
);
|
||||||
tools.register(crate::tools::DelegateTool::new(sub_agent_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
|
// Start background task notification consumer
|
||||||
let sm_bus = bus.clone();
|
let sm_bus = bus.clone();
|
||||||
@ -1692,7 +1669,6 @@ impl SessionManager {
|
|||||||
sub_agent_manager,
|
sub_agent_manager,
|
||||||
task_supervisor,
|
task_supervisor,
|
||||||
turn_delivery,
|
turn_delivery,
|
||||||
reload,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1713,12 +1689,11 @@ impl SessionManager {
|
|||||||
|
|
||||||
/// 为定时任务创建一个无 session 绑定的 AgentLoop
|
/// 为定时任务创建一个无 session 绑定的 AgentLoop
|
||||||
pub fn create_cron_agent(&self) -> Result<AgentLoop, AgentError> {
|
pub fn create_cron_agent(&self) -> Result<AgentLoop, AgentError> {
|
||||||
let tools = self.tools.without(&["reload_config"]);
|
|
||||||
let provider = create_provider(self.provider_config.clone())
|
let provider = create_provider(self.provider_config.clone())
|
||||||
.map_err(|e| AgentError::Other(format!("failed to create cron provider: {}", e)))?;
|
.map_err(|e| AgentError::Other(format!("failed to create cron provider: {}", e)))?;
|
||||||
Ok(AgentLoop::with_provider_and_tools(
|
Ok(AgentLoop::with_provider_and_tools(
|
||||||
Arc::from(provider),
|
Arc::from(provider),
|
||||||
tools,
|
self.tools.clone(),
|
||||||
self.provider_config.max_tool_iterations,
|
self.provider_config.max_tool_iterations,
|
||||||
self.provider_config.model_id.clone(),
|
self.provider_config.model_id.clone(),
|
||||||
self.provider_config.workspace_dir.clone(),
|
self.provider_config.workspace_dir.clone(),
|
||||||
@ -1735,7 +1710,6 @@ impl SessionManager {
|
|||||||
"cron_remove",
|
"cron_remove",
|
||||||
"cron_enable",
|
"cron_enable",
|
||||||
"cron_disable",
|
"cron_disable",
|
||||||
"reload_config",
|
|
||||||
]);
|
]);
|
||||||
let provider = create_provider(self.provider_config.clone())
|
let provider = create_provider(self.provider_config.clone())
|
||||||
.map_err(|e| AgentError::Other(format!("failed to create scheduled provider: {e}")))?;
|
.map_err(|e| AgentError::Other(format!("failed to create scheduled provider: {e}")))?;
|
||||||
@ -2107,12 +2081,6 @@ impl SessionManager {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"reload" => self
|
|
||||||
.reload
|
|
||||||
.request()
|
|
||||||
.await
|
|
||||||
.map(|accepted| (None, accepted.message))
|
|
||||||
.map_err(|error| AgentError::Other(error.to_string())),
|
|
||||||
_ => Err(AgentError::Other(format!(
|
_ => Err(AgentError::Other(format!(
|
||||||
"未知命令:/{}。输入 /? 获取帮助。",
|
"未知命令:/{}。输入 /? 获取帮助。",
|
||||||
cmd.name
|
cmd.name
|
||||||
@ -2120,43 +2088,6 @@ 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(
|
pub async fn create_session(
|
||||||
&self,
|
&self,
|
||||||
channel: &str,
|
channel: &str,
|
||||||
@ -3496,10 +3427,6 @@ mod slash_command_tests {
|
|||||||
resolve_slash_command("/help").map(|command| command.name),
|
resolve_slash_command("/help").map(|command| command.name),
|
||||||
Some("?")
|
Some("?")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
|
||||||
resolve_slash_command("reload").map(|command| command.name),
|
|
||||||
Some("reload")
|
|
||||||
);
|
|
||||||
assert!(resolve_slash_command("unknown").is_none());
|
assert!(resolve_slash_command("unknown").is_none());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,6 @@ pub mod memory;
|
|||||||
pub mod path_utils;
|
pub mod path_utils;
|
||||||
pub mod pty;
|
pub mod pty;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod reload_config;
|
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
pub mod send_message;
|
pub mod send_message;
|
||||||
pub mod todo;
|
pub mod todo;
|
||||||
@ -40,7 +39,6 @@ pub use maintenance::RoutineMaintenanceTool;
|
|||||||
pub use memory::{MemoryForgetTool, MemoryRecallTool, MemoryStoreTool, TimelineRecallTool};
|
pub use memory::{MemoryForgetTool, MemoryRecallTool, MemoryStoreTool, TimelineRecallTool};
|
||||||
pub use pty::{PtyManager, PtyTool};
|
pub use pty::{PtyManager, PtyTool};
|
||||||
pub use registry::ToolRegistry;
|
pub use registry::ToolRegistry;
|
||||||
pub use reload_config::ReloadConfigTool;
|
|
||||||
pub use send_message::SendMessageTool;
|
pub use send_message::SendMessageTool;
|
||||||
pub use todo::TodoTool;
|
pub use todo::TodoTool;
|
||||||
pub use traits::{OutboundDelivery, OutboundMessenger, Tool, ToolResult, ToolResultWithMedia};
|
pub use traits::{OutboundDelivery, OutboundMessenger, Tool, ToolResult, ToolResultWithMedia};
|
||||||
|
|||||||
@ -1,52 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,146 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
@ -4,8 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="color-scheme" content="light dark" />
|
<meta name="color-scheme" content="light dark" />
|
||||||
<meta name="theme-color" content="#0b1017" />
|
<meta name="theme-color" content="#0d1117" />
|
||||||
<script src="/theme-init.js"></script>
|
|
||||||
<title>PicoBot Console</title>
|
<title>PicoBot Console</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
26
webui/package-lock.json
generated
26
webui/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picobot-webui",
|
"name": "picobot-webui",
|
||||||
"version": "1.3.0",
|
"version": "1.2.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picobot-webui",
|
"name": "picobot-webui",
|
||||||
"version": "1.3.0",
|
"version": "1.2.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bits-ui": "^2.0.0",
|
"bits-ui": "^2.0.0",
|
||||||
"dompurify": "^3.4.12",
|
"dompurify": "^3.4.12",
|
||||||
@ -14,8 +14,6 @@
|
|||||||
"svelte": "^5.0.0"
|
"svelte": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
|
||||||
"@fontsource/space-grotesk": "^5.3.0",
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
@ -492,26 +490,6 @@
|
|||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/@floating-ui/utils/-/utils-0.2.12.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww=="
|
"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": {
|
"node_modules/@internationalized/date": {
|
||||||
"version": "3.12.2",
|
"version": "3.12.2",
|
||||||
"resolved": "https://mirrors.cloud.tencent.com/npm/@internationalized/date/-/date-3.12.2.tgz",
|
"resolved": "https://mirrors.cloud.tencent.com/npm/@internationalized/date/-/date-3.12.2.tgz",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picobot-webui",
|
"name": "picobot-webui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.4.0",
|
"version": "1.2.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
@ -18,8 +18,6 @@
|
|||||||
"svelte": "^5.0.0"
|
"svelte": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@fontsource/jetbrains-mono": "^5.3.0",
|
|
||||||
"@fontsource/space-grotesk": "^5.3.0",
|
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
"@sveltejs/vite-plugin-svelte": "^6.0.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,5 +0,0 @@
|
|||||||
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,10 +2,7 @@
|
|||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { Tooltip } from "bits-ui";
|
import { Tooltip } from "bits-ui";
|
||||||
import { api } from "./lib/api.js";
|
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 Toast from "./lib/Toast.svelte";
|
||||||
import ActivitySpine from "./lib/components/ActivitySpine.svelte";
|
|
||||||
import ChatPage from "./pages/ChatPage.svelte";
|
import ChatPage from "./pages/ChatPage.svelte";
|
||||||
import TasksPage from "./pages/TasksPage.svelte";
|
import TasksPage from "./pages/TasksPage.svelte";
|
||||||
import MemoryPage from "./pages/MemoryPage.svelte";
|
import MemoryPage from "./pages/MemoryPage.svelte";
|
||||||
@ -14,13 +11,11 @@
|
|||||||
import PairingPage from "./pages/PairingPage.svelte";
|
import PairingPage from "./pages/PairingPage.svelte";
|
||||||
|
|
||||||
const pages = [
|
const pages = [
|
||||||
["chat", "◫", "聊天"],
|
["chat", "◉", "在线聊天", "与你的 PicoBot 实时对话"],
|
||||||
["overview", "◉", "概览"],
|
["tasks", "⌁", "任务执行", "查看定时任务、运行记录与后台子任务"],
|
||||||
["tools", "🧰", "工具&Skills"],
|
["memory", "◇", "记忆", "检索 Knowledge 与 Timeline"],
|
||||||
["logs", "≋", "日志"],
|
["logs", "≋", "运行日志", "查看 Gateway 最近的本地日志"],
|
||||||
["memory", "◇", "记忆"],
|
["settings", "⚙", "配置", "管理运行配置与助手档案"]
|
||||||
["tasks", "⌁", "任务"],
|
|
||||||
["settings", "⚙", "配置"]
|
|
||||||
];
|
];
|
||||||
let current = $state("chat");
|
let current = $state("chat");
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
@ -47,9 +42,12 @@
|
|||||||
menuOpen = false;
|
menuOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTheme() {
|
function applyTheme(next) {
|
||||||
theme = theme === "dark" ? "light" : "dark";
|
theme = next;
|
||||||
applyTheme(theme);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function checkAuth() {
|
async function checkAuth() {
|
||||||
@ -68,26 +66,16 @@
|
|||||||
health();
|
health();
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (authenticated) {
|
|
||||||
chat.connect();
|
|
||||||
} else {
|
|
||||||
chat.disconnect();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const preferred = preferredTheme();
|
const saved = localStorage.getItem("picobot-theme");
|
||||||
applyTheme(preferred);
|
applyTheme(saved === "light" || saved === "dark" ? saved : (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"));
|
||||||
theme = preferred;
|
|
||||||
checkAuth().then(() => authenticated && health());
|
checkAuth().then(() => authenticated && health());
|
||||||
const authRequired = () => (authenticated = false);
|
const authRequired = () => authenticated = false;
|
||||||
window.addEventListener("picobot-auth-required", authRequired);
|
window.addEventListener("picobot-auth-required", authRequired);
|
||||||
const timer = setInterval(() => authenticated && health(), 30_000);
|
const timer = setInterval(() => authenticated && health(), 30_000);
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(timer);
|
clearInterval(timer);
|
||||||
window.removeEventListener("picobot-auth-required", authRequired);
|
window.removeEventListener("picobot-auth-required", authRequired);
|
||||||
chat.disconnect();
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@ -106,26 +94,21 @@
|
|||||||
<button class:active={current === page[0]} onclick={() => selectPage(page[0])}><span>{page[1]}</span>{page[2]}</button>
|
<button class:active={current === page[0]} onclick={() => selectPage(page[0])}><span>{page[1]}</span>{page[2]}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</nav>
|
</nav>
|
||||||
<div class="gateway-status">
|
<div class="gateway-status"><i class:online></i><div><b>{online ? "运行中" : "不可用"}</b><small>{version}</small></div></div>
|
||||||
<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>
|
</aside>
|
||||||
<main>
|
<main>
|
||||||
<ActivitySpine {version} />
|
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<button class="menu" aria-label="菜单" onclick={() => (menuOpen = !menuOpen)}>☰</button>
|
<button class="menu" aria-label="菜单" onclick={() => (menuOpen = !menuOpen)}>☰</button>
|
||||||
<h1>{meta[2]}</h1>
|
<div><h1>{meta[2]}</h1><p>{meta[3]}</p></div>
|
||||||
|
<button class="theme-toggle" aria-label={theme === "dark" ? "切换到浅色主题" : "切换到深色主题"} onclick={() => applyTheme(theme === "dark" ? "light" : "dark")}>
|
||||||
|
<span aria-hidden="true">{theme === "dark" ? "☀" : "☾"}</span><span>{theme === "dark" ? "浅色" : "深色"}</span>
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
{#if current === "chat"}<ChatPage notify={(text, error) => toast.show(text, error)} />
|
{#if current === "chat"}<ChatPage notify={(text, error) => toast.show(text, error)} />
|
||||||
{:else if current === "logs"}<LogsPage />
|
|
||||||
{:else if current === "memory"}<MemoryPage />
|
|
||||||
{:else if current === "tasks"}<TasksPage />
|
{:else if current === "tasks"}<TasksPage />
|
||||||
{:else if current === "settings"}<SettingsPage notify={(text, error) => toast.show(text, error)} />
|
{:else if current === "memory"}<MemoryPage />
|
||||||
{:else}<div class="empty-card">即将上线</div>{/if}
|
{:else if current === "logs"}<LogsPage />
|
||||||
|
{:else}<SettingsPage notify={(text, error) => toast.show(text, error)} />{/if}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
<Toast bind:this={toast} />
|
<Toast bind:this={toast} />
|
||||||
|
|||||||
@ -1,66 +0,0 @@
|
|||||||
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();
|
|
||||||
@ -1,54 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@ -2,12 +2,13 @@
|
|||||||
import { onMount, tick } from "svelte";
|
import { onMount, tick } from "svelte";
|
||||||
import { Tooltip } from "bits-ui";
|
import { Tooltip } from "bits-ui";
|
||||||
import { clientId, formatTime, randomId } from "../lib/api.js";
|
import { clientId, formatTime, randomId } from "../lib/api.js";
|
||||||
import { chat } from "../lib/chat.svelte.js";
|
|
||||||
import Markdown from "../lib/Markdown.svelte";
|
import Markdown from "../lib/Markdown.svelte";
|
||||||
import ToolCallCard from "../lib/ToolCallCard.svelte";
|
import ToolCallCard from "../lib/ToolCallCard.svelte";
|
||||||
import TurnView from "../lib/TurnView.svelte";
|
import TurnView from "../lib/TurnView.svelte";
|
||||||
|
|
||||||
let { notify } = $props();
|
let { notify } = $props();
|
||||||
|
let socket = $state(null);
|
||||||
|
let connected = $state(false);
|
||||||
let sessions = $state([]);
|
let sessions = $state([]);
|
||||||
let currentId = $state(null);
|
let currentId = $state(null);
|
||||||
let messages = $state([]);
|
let messages = $state([]);
|
||||||
@ -26,6 +27,8 @@
|
|||||||
let todoOpen = $state(false);
|
let todoOpen = $state(false);
|
||||||
let messageBox;
|
let messageBox;
|
||||||
let input;
|
let input;
|
||||||
|
let reconnectTimer;
|
||||||
|
let stopped = false;
|
||||||
const currentSession = $derived(sessions.find((item) => item.session_id === currentId));
|
const currentSession = $derived(sessions.find((item) => item.session_id === currentId));
|
||||||
const currentPlan = $derived(currentId ? plansBySession[currentId] || null : null);
|
const currentPlan = $derived(currentId ? plansBySession[currentId] || null : null);
|
||||||
const completedItems = $derived(currentPlan?.items?.filter((item) => item.status === "completed").length || 0);
|
const completedItems = $derived(currentPlan?.items?.filter((item) => item.status === "completed").length || 0);
|
||||||
@ -49,7 +52,28 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function send(frame) {
|
function send(frame) {
|
||||||
if (!chat.send(frame)) notify("聊天连接尚未就绪", true);
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFrame(frame) {
|
function handleFrame(frame) {
|
||||||
@ -194,7 +218,7 @@
|
|||||||
function submit() {
|
function submit() {
|
||||||
const content = draft.trim();
|
const content = draft.trim();
|
||||||
const ready = pendingUploads.filter((upload) => upload.status === "ready");
|
const ready = pendingUploads.filter((upload) => upload.status === "ready");
|
||||||
if ((!content && !ready.length) || !chat.connected || pendingUploads.some((upload) => upload.status === "uploading")) return;
|
if ((!content && !ready.length) || !connected || pendingUploads.some((upload) => upload.status === "uploading")) return;
|
||||||
appendMessage("user", content, ready.map((upload, index) => ({
|
appendMessage("user", content, ready.map((upload, index) => ({
|
||||||
index,
|
index,
|
||||||
name: upload.name,
|
name: upload.name,
|
||||||
@ -234,7 +258,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function uploadFile(file) {
|
function uploadFile(file) {
|
||||||
if (!chat.connected) return notify("聊天连接尚未就绪", true);
|
if (!connected) return notify("聊天连接尚未就绪", true);
|
||||||
const localId = randomId();
|
const localId = randomId();
|
||||||
const localUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : null;
|
const localUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : null;
|
||||||
pendingUploads = [...pendingUploads, {
|
pendingUploads = [...pendingUploads, {
|
||||||
@ -356,20 +380,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
const unsubscribe = chat.subscribe(handleFrame);
|
connect();
|
||||||
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 () => {
|
return () => {
|
||||||
unsubscribe();
|
stopped = true;
|
||||||
unsubOpen();
|
clearTimeout(reconnectTimer);
|
||||||
|
socket?.close();
|
||||||
clearPendingUploads();
|
clearPendingUploads();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@ -500,8 +515,8 @@
|
|||||||
aria-activedescendant={commandSuggestions.length ? `slash-command-${selectedCommand}` : undefined}
|
aria-activedescendant={commandSuggestions.length ? `slash-command-${selectedCommand}` : undefined}
|
||||||
placeholder="输入消息,输入 / 查看命令"
|
placeholder="输入消息,输入 / 查看命令"
|
||||||
></textarea>
|
></textarea>
|
||||||
<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>
|
<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={chat.connected}>{chat.connected ? "已连接" : "已断开,正在重连"}</span><span>/ 打开命令 · Shift+Enter 换行</span></small>
|
<small><span class:online={connected}>{connected ? "已连接" : "已断开,正在重连"}</span><span>/ 打开命令 · Shift+Enter 换行</span></small>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{#if currentPlan}
|
{#if currentPlan}
|
||||||
|
|||||||
@ -1,109 +1,59 @@
|
|||||||
@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 {
|
:root {
|
||||||
--font-ui: "Space Grotesk", ui-sans-serif, system-ui, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Consolas, monospace;
|
color: #e8edf2;
|
||||||
color-scheme: dark;
|
background: #0d1117;
|
||||||
font-family: var(--font-ui);
|
|
||||||
font-synthesis: none;
|
font-synthesis: none;
|
||||||
color: #e7ecf3;
|
--bg: #0d1117;
|
||||||
background: #0b1017;
|
--panel: #151a21;
|
||||||
--bg: #0b1017;
|
--panel-2: #1b222c;
|
||||||
--panel: #0e1520;
|
--sidebar: #10151c;
|
||||||
--panel-2: #131c29;
|
--header: rgb(13 17 23 / 82%);
|
||||||
--sidebar: #0d131c;
|
--line: #28313d;
|
||||||
--header: rgb(11 16 23 / 84%);
|
--muted: #8b96a5;
|
||||||
--line: #1d2733;
|
--text: #e8edf2;
|
||||||
--line-strong: #2c3a4c;
|
--text-soft: #c2cad4;
|
||||||
--muted: #8fa3b8;
|
--accent: #8b72ff;
|
||||||
--faint: #5b6b7e;
|
--accent-hover: #9e89ff;
|
||||||
--text: #e7ecf3;
|
--accent-contrast: #ffffff;
|
||||||
--text-soft: #b8c4d4;
|
--accent-soft: #231f3d;
|
||||||
--accent: #ffb454;
|
--accent-border: #4d4089;
|
||||||
--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: #ff7b86;
|
||||||
--danger-soft: rgb(255 123 134 / 12%);
|
--danger-soft: #321d23;
|
||||||
--danger-border: rgb(255 123 134 / 35%);
|
--warning: #f1c75b;
|
||||||
--warning: #ffb454;
|
--warning-soft: #302a18;
|
||||||
--warning-soft: rgb(255 180 84 / 10%);
|
--success-soft: #1d3029;
|
||||||
--success-soft: rgb(45 212 191 / 12%);
|
--overlay: #1d2530;
|
||||||
--overlay: #101826;
|
--code-bg: #0b0f14;
|
||||||
--code-bg: #080c12;
|
--user-bubble: #26213f;
|
||||||
--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%);
|
--shadow: 0 16px 45px rgb(0 0 0 / 35%);
|
||||||
--radius: 11px;
|
--radius: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root[data-theme="light"] {
|
:root[data-theme="light"] {
|
||||||
color-scheme: light;
|
color: #1c2430;
|
||||||
color: #1a2230;
|
background: #f6f7f9;
|
||||||
background: #eef1f5;
|
--bg: #f6f7f9;
|
||||||
--bg: #eef1f5;
|
|
||||||
--panel: #ffffff;
|
--panel: #ffffff;
|
||||||
--panel-2: #f4f6f9;
|
--panel-2: #f1f3f6;
|
||||||
--sidebar: #f7f9fc;
|
--sidebar: #fbfbfc;
|
||||||
--header: rgb(238 241 245 / 86%);
|
--header: rgb(246 247 249 / 86%);
|
||||||
--line: #d8dee8;
|
--line: #dde2e8;
|
||||||
--line-strong: #c2ccd9;
|
--muted: #687386;
|
||||||
--muted: #5b6b7e;
|
--text: #1c2430;
|
||||||
--faint: #8494a8;
|
--text-soft: #4f5b6b;
|
||||||
--text: #1a2230;
|
--accent: #6748e8;
|
||||||
--text-soft: #3d4b5e;
|
--accent-hover: #5739d4;
|
||||||
--accent: #a86300;
|
|
||||||
--accent-hover: #c47400;
|
|
||||||
--accent-contrast: #ffffff;
|
--accent-contrast: #ffffff;
|
||||||
--accent-soft: rgb(196 116 0 / 10%);
|
--accent-soft: #eeeaff;
|
||||||
--accent-border: rgb(196 116 0 / 35%);
|
--accent-border: #c9befd;
|
||||||
--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: #d94354;
|
||||||
--danger-soft: rgb(217 67 84 / 10%);
|
--danger-soft: #fff0f2;
|
||||||
--danger-border: rgb(217 67 84 / 35%);
|
--warning: #9b6b00;
|
||||||
/* --warning intentionally equals --accent: single-amber signal system */
|
--warning-soft: #fff7df;
|
||||||
--warning: #a86300;
|
--success-soft: #e8f7ef;
|
||||||
--warning-soft: rgb(196 116 0 / 8%);
|
|
||||||
--success-soft: rgb(13 148 136 / 10%);
|
|
||||||
--overlay: #ffffff;
|
--overlay: #ffffff;
|
||||||
--code-bg: #f7f9fc;
|
--code-bg: #f4f5f7;
|
||||||
--user-bubble: #ece7fb;
|
--user-bubble: #eeeaff;
|
||||||
--spine-bg: #0e1520;
|
|
||||||
--shadow: 0 16px 45px rgb(31 41 55 / 12%);
|
--shadow: 0 16px 45px rgb(31 41 55 / 12%);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,10 +65,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-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 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-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 var(--font-mono); }
|
.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-card form { display: grid; gap: 10px; }
|
.pairing-card form { display: grid; gap: 10px; }
|
||||||
.pairing-card label { color: var(--text-soft); font-size: 11px; font-weight: 650; }
|
.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 var(--font-mono); 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 ui-monospace, SFMono-Regular, Consolas, monospace; letter-spacing: .28em; text-align: center; }
|
||||||
.pairing-card form .primary { margin-top: 3px; padding: 11px 14px; }
|
.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-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; }
|
.pairing-error { padding: 8px 10px; border-radius: 8px; color: var(--danger); background: var(--danger-soft); font-size: 11px; }
|
||||||
@ -140,7 +90,7 @@ button:disabled { cursor: not-allowed; opacity: .45; }
|
|||||||
.sidebar nav button.active { color: var(--accent); }
|
.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 { 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 { width: 9px; height: 9px; border-radius: 50%; color: var(--warning); background: currentColor; box-shadow: 0 0 12px currentColor; }
|
||||||
.gateway-status i.online { color: var(--signal); background: currentColor; }
|
.gateway-status i.online { color: #48b985; background: currentColor; }
|
||||||
.gateway-status b, .gateway-status small { display: block; font-size: 12px; }
|
.gateway-status b, .gateway-status small { display: block; font-size: 12px; }
|
||||||
.gateway-status small { color: var(--muted); margin-top: 2px; }
|
.gateway-status small { color: var(--muted); margin-top: 2px; }
|
||||||
main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
|
main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
|
||||||
@ -215,10 +165,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-content { padding: 10px 13px; border-top: 1px solid var(--line); color: var(--muted); font-size: 12px; }
|
||||||
.reasoning-block.historical { border-style: dashed; }
|
.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 { 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 var(--font-mono); }
|
.live-tool summary strong { color: var(--text); font: 600 12px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||||
.live-tool summary small { color: var(--muted); }
|
.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 { 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 var(--font-mono); 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 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-wrap; }
|
||||||
.turn-status, .completion-status { color: var(--muted); font-size: 10px; }
|
.turn-status, .completion-status { color: var(--muted); font-size: 10px; }
|
||||||
.turn-status.failed, .turn-error { color: var(--danger); }
|
.turn-status.failed, .turn-error { color: var(--danger); }
|
||||||
.turn-error { padding: 8px 10px; border-radius: 8px; background: var(--danger-soft); font-size: 11px; }
|
.turn-error { padding: 8px 10px; border-radius: 8px; background: var(--danger-soft); font-size: 11px; }
|
||||||
@ -236,7 +186,7 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
|
|||||||
.markdown-body h3 { font-size: 1.1em; }
|
.markdown-body h3 { font-size: 1.1em; }
|
||||||
.markdown-body a { color: var(--accent); text-underline-offset: 2px; }
|
.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 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 var(--font-mono); }
|
.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 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 { 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 pre code { padding: 0; border: 0; background: transparent; }
|
||||||
.markdown-body table { display: block; max-width: 100%; border-collapse: collapse; overflow-x: auto; }
|
.markdown-body table { display: block; max-width: 100%; border-collapse: collapse; overflow-x: auto; }
|
||||||
@ -252,13 +202,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-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, .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 small { margin-bottom: 1px; color: var(--muted); font-size: 9px; font-weight: 500; }
|
||||||
.tool-call-title strong { font: 600 12px/1.4 var(--font-mono); }
|
.tool-call-title strong { font: 600 12px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||||
.tool-call-status { color: var(--muted); font-size: 10px; }
|
.tool-call-status { color: var(--muted); font-size: 10px; }
|
||||||
.tool-call-chevron { color: var(--muted); transition: transform .18s; }
|
.tool-call-chevron { color: var(--muted); transition: transform .18s; }
|
||||||
.tool-call.open .tool-call-chevron { transform: rotate(180deg); }
|
.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 { 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 > 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 var(--font-mono); 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 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
.tool-result .markdown-body { font: inherit; }
|
.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 { 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; }
|
.composer textarea { resize: none; max-height: 180px; background: transparent; border: 0; outline: 0; color: var(--text); padding: 7px; line-height: 1.5; }
|
||||||
@ -298,7 +248,7 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
|
|||||||
.command-menu-heading kbd { color: var(--muted); font: inherit; }
|
.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 { 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 button:hover, .command-menu button.selected { background: var(--accent-soft); }
|
||||||
.command-menu code { color: var(--accent); font: 600 12px/1.4 var(--font-mono); }
|
.command-menu code { color: var(--accent); font: 600 12px/1.4 ui-monospace, SFMono-Regular, Consolas, monospace; }
|
||||||
.command-menu button span { color: var(--text-soft); font-size: 12px; line-height: 1.4; }
|
.command-menu button span { color: var(--text-soft); font-size: 12px; line-height: 1.4; }
|
||||||
.content-page { padding: 22px 26px; overflow: auto; }
|
.content-page { padding: 22px 26px; overflow: auto; }
|
||||||
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; gap: 10px; }
|
.toolbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; gap: 10px; }
|
||||||
@ -314,7 +264,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; }
|
.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; }
|
.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 { 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: var(--signal); }
|
.badge.ok { background: var(--success-soft); color: #38a877; }
|
||||||
.badge.fail { background: var(--danger-soft); color: var(--danger); }
|
.badge.fail { background: var(--danger-soft); color: var(--danger); }
|
||||||
.badge.run { background: var(--warning-soft); color: var(--warning); }
|
.badge.run { background: var(--warning-soft); color: var(--warning); }
|
||||||
.details { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 10px; }
|
.details { margin-top: 12px; border-top: 1px solid var(--line); padding-top: 10px; }
|
||||||
@ -324,8 +274,8 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
|
|||||||
.metric b { font-size: 22px; display: block; }
|
.metric b { font-size: 22px; display: block; }
|
||||||
.metric small { color: var(--muted); }
|
.metric small { color: var(--muted); }
|
||||||
.memory-content { font-size: 13px !important; }
|
.memory-content { font-size: 13px !important; }
|
||||||
.memory-key { color: var(--accent); font-family: var(--font-mono); }
|
.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 var(--font-mono); white-space: pre-wrap; overflow: auto; }
|
.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; }
|
||||||
.log-meta { color: var(--muted); font-size: 11px; margin: -8px 0 8px; }
|
.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-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; }
|
.switch { width: 34px; height: 20px; padding: 2px; border: 1px solid var(--line); border-radius: 99px; background: var(--panel); cursor: pointer; }
|
||||||
@ -338,7 +288,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 { 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 strong, .editor-head small { display: block; }
|
||||||
.editor-head small { color: var(--muted); font-size: 10px; margin-top: 4px; }
|
.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 var(--font-mono); 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 ui-monospace, SFMono-Regular, Consolas, monospace; tab-size: 2; }
|
||||||
.notice { padding: 11px 15px; color: var(--muted); font-size: 11px; border-top: 1px solid var(--line); }
|
.notice { padding: 11px 15px; color: var(--muted); font-size: 11px; border-top: 1px solid var(--line); }
|
||||||
code { color: var(--accent); }
|
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); }
|
#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); }
|
||||||
@ -347,19 +297,6 @@ code { color: var(--accent); }
|
|||||||
.loading, .empty-card { text-align: center; color: var(--muted); padding: 50px; }
|
.loading, .empty-card { text-align: center; color: var(--muted); padding: 50px; }
|
||||||
.empty-card.compact { padding: 24px 8px; }
|
.empty-card.compact { padding: 24px 8px; }
|
||||||
.error-text { color: var(--danger) !important; }
|
.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) {
|
@media (max-width: 800px) {
|
||||||
.shell { grid-template-columns: 1fr; }
|
.shell { grid-template-columns: 1fr; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user