Compare commits

..

No commits in common. "27d126cf7310d2ac9bc7be30675d6dd4fb8a78d4" and "5079458f55b68f4de65e2f5f4b9137e2aa0be67f" have entirely different histories.

74 changed files with 2910 additions and 4942 deletions

View File

@ -1,7 +1,5 @@
# PicoBot
This file is the operational contract for coding agents working in this repository. Read [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) before making architectural or lifecycle changes. Code and tests are the final source of truth; update the document when an architectural invariant changes.
## Build & Run
- `cargo build` — build the binary
@ -10,23 +8,20 @@ This file is the operational contract for coding agents working in this reposito
## Config
- Config load order: `~/.picobot/config.json` then fallback to `./config.json` (`Config::load_default` in `src/config/mod.rs`)
- Config load order: `~/.picobot/config.json` then fallback to `./config.json` (`src/config/mod.rs:237-267`)
- `.env` (cwd) is loaded with a custom parser, not via dotenv crate; env var placeholders `<VAR_NAME>` in config JSON are substituted
- Config example: `resources/templates/config.example.json` (released to `~/.picobot/` on first run)
- CLI TUI identity is stored in `~/.picobot/tui_client_id`; it is a non-secret stable chat scope used to restore dialogs across reconnects
## Tests
- `cargo test --lib` — run unit tests (runs all `#[test]` in `src/`)
- `cargo clippy --all-targets --all-features -- -D warnings` — required for Rust changes
- `cargo test --test test_scheduler` and `cargo test --test test_request_format` — offline integration/protocol tests
- `cargo test --test test_integration -- --ignored` and `cargo test --test test_tool_calling -- --ignored` — model API integration tests
- API-backed tests require `tests/test.env` with real API keys; copy from `tests/test.env.example` and fill in keys
- API-backed tests are `#[ignore]` by default; use `-- --ignored` to run them
- `cargo test --test test_integration -- --ignored` — run integration tests (also `test_tool_calling`, `test_request_format`)
- **All** integration tests require `tests/test.env` with real API keys; copy from `tests/test.env.example` and fill in keys
- Integration tests are `#[ignore]` by default; use `-- --ignored` to run them
## Reference
- `reference/` — third-party reference implementations; not part of this project; do not modify
- `reference/` — third-party reference implementations (nanobot, Mini-Agent, zeroclaw); not part of this project; do not modify
## Architecture
@ -38,12 +33,9 @@ This file is the operational contract for coding agents working in this reposito
### Core Data Flow
```
Channel → MessageBus.inbound → Gateway processor → SessionManager → per-session worker → AgentLoop
↑ │
└── Channel ← OutboundDispatcher ← per-conversation lane ← MessageBus.outbound
WebSocket/Channel → MessageBus.control → Gateway processor → SessionManager (dialog operations)
Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message tool
Channel → MessageBus → SessionManager → AgentLoop → (tools) → SessionManager → MessageBus → OutboundDispatcher → Channel
ControlChannel ──→ SessionManager (dialog ops: create/switch/archive/delete)
```
### Modules
@ -53,8 +45,8 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
| `gateway` | Server lifecycle, HTTP/WS endpoints, owns `GatewayState` | `GatewayState`, `run()` |
| `client` | TUI rendering, WebSocket client for CLI chat | `App`, `run()` |
| `channels` | External integrations (Feishu, CLI chat) | `ChannelManager`, `Channel` trait |
| `bus` | Bounded async queues and ordered outbound delivery lanes | `MessageBus`, `OutboundDispatcher`, `InboundMessage`, `OutboundMessage`, `ControlMessage` |
| `session` | Conversation lifecycle, dialog operations, per-session serialization, persistence coordination | `SessionManager`, `Session` |
| `bus` | Async message queue (inbound/outbound/control channels) | `MessageBus`, `InboundMessage`, `OutboundMessage`, `ControlMessage` |
| `session` | Conversation session lifecycle, dialog operations | `SessionManager`, `Session` |
| `agent` | LLM call loop, tool execution, context compression | `AgentLoop` |
| `providers` | LLM API clients (OpenAI-compatible, Anthropic) | `LLMProvider` trait, factory `create_provider()` |
| `tools` | Agent tools (bash, file ops, http, web, get_skill) | `ToolRegistry`, `Tool` trait |
@ -62,54 +54,25 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
| `storage` | SQLite persistence for sessions and messages | `Storage`, `SessionMeta`, `MessageMeta` |
| `scheduler` | Cron-based job scheduling, next-run computation | `Scheduler`, `Schedule`, `next_run_for_schedule()` |
| `observability` | Observer pattern for agent/tool telemetry events | `Observer` trait, `ObserverEvent`, `MultiObserver` |
| `protocol` | WebSocket protocol message types | `WsInbound`, `WsOutbound`, `SessionSummary`, `HistoryMessage` |
| `protocol` | WebSocket protocol message types | `WsInbound`, `WsOutbound`, `SessionSummary` |
| `config` | Config loading, env substitution, path resolution | `Config`, `LLMProviderConfig` |
| `logging` | Tracing initialization with file rotation | `init_logging()`, `init_logging_console_only()` |
| `task_supervisor` | Owns, cancels, and boundedly joins gateway background tasks | `TaskSupervisor` |
### Functional Boundaries
- **Channels** only send/receive messages via `MessageBus`; they know nothing about sessions or LLM
- **MessageBus** owns bounded inbound/outbound/control queues; outbound routing and retries belong to `OutboundDispatcher`, not the queue
- **SessionManager** owns session state, dialog operations, context construction, per-session work queues, and persistence coordination
- **AgentLoop** is stateless across turns; it receives prepared history, calls LLM providers, executes tools, and returns one result
- **MessageBus** is a pure async queue; it routes nothing, just passes messages
- **SessionManager** owns session state and dialog operations; it does NOT call LLM directly
- SessionManager is responsible for injecting skills prompt into conversation history
- **AgentLoop** receives dialog events from `SessionManager`, calls LLM via `providers`, executes tools, returns text responses
- AgentLoop is stateless; all state is managed by Session/SessionManager
- **Providers** are pure HTTP clients; no bus/session/channel awareness
- **Tools** are executed by `AgentLoop`; they receive raw arguments and return string results
### Concurrency and Lifecycle Invariants
- Messages in one session are processed serially through a bounded queue; different sessions may run concurrently
- Outbound messages are ordered per `(channel, chat_id)`; a slow destination must not block unrelated destinations
- Never hold a Session mutex across model, network, or database I/O unless a documented invariant requires it
- Slow work derived from session state must validate `worker_generation`/`state_version` before committing results
- Related durable mutations use Storage transaction APIs; persistence failure must not leave silent memory/database divergence
- Long-lived gateway tasks must be owned by `TaskSupervisor`; connection-local tasks must be explicitly joined or aborted by their owner
- Connection, retry sleep, queue wait, and shutdown join paths must observe cancellation and have hard time bounds
- Retry only errors explicitly classified as transient; permanent failures must surface immediately
- Never log secrets, authorization headers, or full connection URLs containing temporary credentials
- The workspace cwd is not a filesystem sandbox: default file tools accept absolute paths and Bash inherits process permissions; add explicit canonical-path/process isolation when a hard boundary is required
### Key Constraints
- Gateway **changes working directory** to workspace in `GatewayState::new` (`src/gateway/mod.rs`)
- Gateway **changes working directory** to workspace on startup (`src/gateway/mod.rs:31`)
- Session/message persistence uses SQLite via `sqlx`; DB stored in workspace as `picobot.db` by default
- `ChannelManager` owns the `MessageBus` and all channel instances
- `OutboundDispatcher` routes outbound messages to the correct channel via `ChannelManager`
- Config `.env` loading uses `unsafe { env::set_var(...) }` — don't refactor to safer patterns without understanding side effects
## Change Workflow
1. Inspect the relevant implementation, tests, and `docs/ARCHITECTURE.md`; do not rely on names or old reports alone.
2. Search `reference/` only for comparison. Never edit it or copy behavior without checking PicoBot's boundaries.
3. Preserve unrelated user changes in a dirty worktree. Use `rg` for search and `apply_patch` for edits.
4. Add regression tests for bugs, especially cancellation, timeout, queue saturation, stale state, persistence failure, and retry classification.
5. For Rust changes run targeted tests, `cargo test --lib`, Clippy with warnings denied, and `cargo build`. Integration tests require real credentials.
6. For documentation-only changes verify links, commands, paths, and `git diff --check`.
7. Update README, this file, and the architecture document together when public behavior or an architectural invariant changes.
## Documentation Roles
- `README.md` — user-facing overview, setup, capabilities, and navigation
- `docs/ARCHITECTURE.md` — maintainer-facing runtime design, invariants, lifecycle, and extension guidance
- `AGENTS.md` — concise operational rules for repository agents
- `resources/skills/about-picobot/references/` — runtime knowledge shipped to PicoBot; update it only when the assistant's built-in product knowledge must change

View File

@ -5,6 +5,7 @@ edition = "2024"
[dependencies]
reqwest = { version = "0.13.3", default-features = false, features = ["json", "rustls", "multipart"] }
dotenv = "0.15"
serde = { version = "1.0", features = ["derive"] }
regex = "1.12"
serde_json = "1.0"
@ -29,12 +30,13 @@ base64 = "0.22"
tempfile = "3"
cron = "0.16"
chrono-tz = "0.10"
meval = "0.2"
ratatui = "0.30"
crossterm = { version = "0.29", features = ["event-stream"] }
termimad = "0.34"
textwrap = "0.16"
unicode-width = "0.2"
chrono = "0.4"
hostname = "0.4"
sqlx = { version = "0.8", features = ["sqlite", "macros", "chrono", "runtime-tokio"] }
jieba-rs = "0.9"
which = "8"
@ -51,9 +53,6 @@ tar = "0.4"
fantoccini = { version = "0.22", default-features = false, features = ["rustls-tls"] }
portable-pty = "0.9"
[dev-dependencies]
dotenv = "0.15"
[build-dependencies]
zstd = "0.13"
tar = "0.4"

View File

@ -4,7 +4,7 @@ PicoBot 是一个用 Rust 编写的个人 AI 助手运行时。它在本地启
它更像一个可扩展的“个人助手操作系统”渠道负责收发消息SessionManager 负责会话和上下文AgentLoop 负责模型与工具循环Storage 负责可靠落盘。
完整的组件边界、并发不变量、启动/关停顺序和扩展指南见 [架构文档](docs/ARCHITECTURE.md)。
![PicoBot runtime architecture](docs/assets/runtime-architecture.svg)
## 适合做什么
@ -91,28 +91,11 @@ cargo run -- chat
CLI 默认连接 `ws://127.0.0.1:19876/ws`。如需指定地址,可使用 `--gateway-url`
TUI 会把一个随机客户端标识保存到 `~/.picobot/tui_client_id`,因此关闭并重新打开客户端后会恢复同一组 dialog 和最近使用的会话。界面支持历史回放、会话列表与归档筛选、命令补全、Unicode/中文编辑、括号粘贴和多行输入。
常用快捷键:
| 快捷键 | 操作 |
|--------|------|
| `F1` / `Ctrl+H` | 打开帮助 |
| `Tab` / `Ctrl+S` | 切换焦点 / 聚焦会话列表 |
| `Ctrl+N` | 新建会话 |
| `Ctrl+R` / `Ctrl+A` / `Ctrl+D` | 重命名 / 归档 / 删除所选会话 |
| `Ctrl+L` / `Ctrl+O` | 清空历史 / 显示归档会话 |
| `Enter` / `Shift+Enter` | 发送 / 换行 |
| `PageUp` / `PageDown` | 滚动对话历史 |
| 连按两次 `Ctrl+C` | 退出客户端 |
## 运行时数据流
用户消息进入 PicoBot 后,会被转换为统一的 inbound message经由 MessageBus 交给 SessionManager。SessionManager 选择当前 dialog、组装上下文、调用 AgentLoopAgentLoop 调用模型和工具,最终响应通过 outbound bus 回到原渠道。
详细时序和失败语义见 [架构文档:消息与控制数据流](docs/ARCHITECTURE.md#4-消息与控制数据流)。
同一 session 的普通消息由专属有界队列串行处理,不同 session 可以并发;出站消息按 `(channel, chat_id)` 分 lane 保序慢渠道不会阻塞其他目标。Gateway 的长生命周期任务统一由 `TaskSupervisor` 取消和限时回收。
![Message flow](docs/assets/message-flow.svg)
核心边界:
@ -128,7 +111,6 @@ TUI 会把一个随机客户端标识保存到 `~/.picobot/tui_client_id`,因
| `scheduler` | 轮询 Cron 任务并把任务 prompt 送入目标会话 |
| `skills` | 加载 Skill并把 Skill 指南注入系统提示 |
| `mcp` | 连接 MCP Server将远端工具包装成普通 Tool |
| `task_supervisor` | 统一管理 Gateway 后台任务的取消和有界关停 |
## 核心能力
@ -174,7 +156,7 @@ PicoBot 有两类记忆:
| Knowledge | 偏好、事实、项目规则、长期可复用信息 | 长期保留,手动删除 |
| Timeline | 长对话压缩后的历史摘要 | 默认保留 90 天 |
每轮处理用户消息时MemoryManager 会按用户输入召回 Knowledge并作为运行时上下文附加到本轮用户消息。当前召回上限固定为 5`memory.recall_limit` 已支持解析但尚未接入 worker。上下文压缩产生的摘要会保存为 Timeline后续可通过 `timeline_recall` 工具检索。
每轮处理用户消息时MemoryManager 会按用户输入召回最多 `memory.recall_limit` 条 Knowledge并注入系统提示。上下文压缩产生的摘要会保存为 Timeline后续可通过 `timeline_recall` 工具检索。
### 工具
@ -232,7 +214,7 @@ Skill 是包含 `SKILL.md` 的目录。加载优先级从高到低:
| `gateway.max_concurrent_background_tasks` | `10` |
| `gateway.scheduler.enabled` | `true` |
| `client.gateway_url` | `ws://127.0.0.1:19876/ws` |
| `memory.recall_limit` | `5`(当前运行时固定为 5 |
| `memory.recall_limit` | `5` |
| `memory.timeline_retention_days` | `90` |
| `mcp.tool_timeout_secs` | `180` |
| `browser.enabled` | `false` |
@ -257,14 +239,13 @@ Inbound 消息类型:
| `create_session` | 可选 `title` |
| `list_sessions` | `include_archived` |
| `load_session` | `session_id` |
| `get_session_history` | `session_id`,可选 `limit`(服务端限制为 12000 |
| `rename_session` | 可选 `session_id``title` |
| `archive_session` | 可选 `session_id` |
| `delete_session` | 可选 `session_id` |
| `get_slash_commands` | 无 |
| `ping` | 无 |
Outbound 消息类型包括 `assistant_response``error``session_established``session_created``session_list``session_loaded``session_history`、`session_renamed`、`session_archived``session_deleted``history_cleared``slash_commands_list``pong``command_executed``system_notification`其中异步 `assistant_response` / `system_notification` 可携带 `session_id`,客户端应避免把迟到结果显示到其他 dialog。
Outbound 消息类型包括 `assistant_response``error``session_established``session_created``session_list``session_loaded``session_renamed`、`session_archived``session_deleted``history_cleared``slash_commands_list``pong``command_executed``system_notification`
## 测试
@ -272,17 +253,14 @@ Outbound 消息类型包括 `assistant_response`、`error`、`session_establishe
# 单元测试
cargo test --lib
# 离线集成/协议测试
cargo test --test test_scheduler
cargo test --test test_request_format
# 模型 API 集成测试需要 tests/test.env 中有真实 API key
# 集成测试需要 tests/test.env 中有真实 API key
cp tests/test.env.example tests/test.env
cargo test --test test_integration -- --ignored
cargo test --test test_tool_calling -- --ignored
cargo test --test test_request_format -- --ignored
```
会真实调用模型 API 的测试标记为 `#[ignore]`;离线集成测试默认执行
集成测试默认 `#[ignore]`,因为它们会真实调用模型 API
## 项目结构
@ -303,12 +281,11 @@ src/
skills/ Skill 加载和内置 Skill 安装
storage/ SQLite schema 和 CRUD
tools/ Agent 工具实现
task_supervisor.rs Gateway 后台任务的生命周期管理
resources/
skills/ 构建时嵌入的内置 Skills
templates/ 首次运行释放的配置和用户模板
tests/ 单元测试和 ignored 集成测试
docs/ 面向维护者和 Agent 的架构与开发文档
docs/ 分析报告、文档插图和补充资料
```
## 关键依赖
@ -327,9 +304,9 @@ docs/ 面向维护者和 Agent 的架构与开发文档
## 进一步阅读
- [维护者架构文档](docs/ARCHITECTURE.md)
- [内置 Skill架构机制](resources/skills/about-picobot/references/architecture.md)
- [架构机制](resources/skills/about-picobot/references/architecture.md)
- [配置说明](resources/skills/about-picobot/references/config.md)
- [命令说明](resources/skills/about-picobot/references/commands.md)
- [工具说明](resources/skills/about-picobot/references/tools.md)
- [数据库结构](resources/skills/about-picobot/references/db-schema.md)
- [代码质量分析](docs/CODE_QUALITY_ANALYSIS.md)

View File

@ -1,287 +0,0 @@
# PicoBot 架构
本文档描述 PicoBot 当前实现的运行时边界、数据流、并发模型和演进约束。它面向维护者和后续参与改进的 Agent是代码架构的主入口行为细节仍以代码和测试为最终依据。
## 1. 设计目标
PicoBot 是一个单进程、异步、可扩展的个人 AI 助手运行时。核心目标是:
- 用统一消息模型接入不同聊天渠道。
- 隔离渠道、会话、模型、工具和持久化职责。
- 同一会话内保持消息顺序,不同会话之间允许并发。
- 让外部 I/O、后台任务和程序关停都有明确边界。
- 通过 SQLite 保存会话、消息、记忆、定时任务及后台任务状态。
当前不是分布式系统。除调度任务使用数据库租约防止重复领取外,运行时会话状态由单个 Gateway 进程持有。
## 2. 运行模式与进程边界
PicoBot 只有一个二进制,提供两种模式:
| 模式 | 入口 | 职责 |
|------|------|------|
| Gateway | `cargo run -- gateway` | 组装服务、监听 HTTP/WebSocket、运行渠道、会话、调度器和后台任务 |
| CLI client | `cargo run -- chat` | 运行 Ratatui UI通过 WebSocket 使用 Gateway不持有业务状态 |
CLI TUI 在 `~/.picobot/tui_client_id` 保存非敏感客户端标识,并通过 WebSocket 查询参数 `client_id` 传给 Gateway。`cli_chat` 以该标识作为稳定 chat scope重连时恢复内存中的当前 dialogGateway 重启后则恢复该 scope 最近活跃的未归档 dialog。无效或缺失的标识会退化为连接级随机 scope。
Gateway 启动时会切换进程工作目录到 `workspace_dir`。因此所有相对路径都应按 workspace 解释,不能假设仍位于源码仓库。
## 3. 组件关系
```mermaid
flowchart LR
External[CLI / Feishu] --> Channels[channels]
Channels -->|InboundMessage| Bus[MessageBus]
Bus --> Processor[Gateway message processor]
Processor --> Sessions[SessionManager]
Sessions --> Agent[AgentLoop]
Agent --> Providers[LLM providers]
Agent --> Tools[ToolRegistry / MCP]
Sessions <--> Storage[(SQLite)]
Sessions -->|OutboundMessage| Bus
Scheduler[Scheduler] --> Sessions
Bus --> Dispatcher[OutboundDispatcher]
Dispatcher --> Channels
Supervisor[TaskSupervisor] -. lifecycle .-> Processor
Supervisor -. lifecycle .-> Dispatcher
Supervisor -. lifecycle .-> Scheduler
Supervisor -. lifecycle .-> Sessions
```
### 模块职责
| 模块 | 拥有的职责 | 不应承担的职责 |
|------|------------|----------------|
| `gateway` | 依赖装配、HTTP/WS 入口、启动和关停顺序 | 业务规则、渠道协议细节 |
| `channels` | 外部协议适配、权限检查、媒体收发 | 会话选择、LLM 调用 |
| `bus` | 三条有界异步队列与出站投递协调 | 会话路由、业务状态 |
| `session` | dialog 路由、会话状态、串行工作队列、上下文和持久化协调 | 外部渠道协议 |
| `agent` | 单次无状态模型/工具循环、上下文压缩、子 Agent | 持有 dialog 生命周期 |
| `providers` | 把统一请求映射到模型 API | Session、Bus 或 Channel 感知 |
| `tools` / `mcp` | 工具定义、注册和执行适配 | 隐式修改会话路由 |
| `storage` | SQLite schema、迁移、原子 CRUD | 运行时调度策略 |
| `memory` | Knowledge/Timeline 的存取与召回 | 直接驱动消息发送 |
| `scheduler` | 领取到期任务、限并发执行、原子记录结果 | 复用聊天会话历史 |
| `task_supervisor` | 后台任务注册、取消、限时回收 | 业务级重试和结果语义 |
## 4. 消息与控制数据流
`MessageBus` 包含三条容量相同的 Tokio MPSC 队列:
- `inbound`Channel → Gateway message processor。
- `outbound`Session/Tool → `OutboundDispatcher`
- `control`WebSocket/Channel → Gateway message processor用于 dialog 操作。
### 普通消息
```mermaid
sequenceDiagram
participant C as Channel
participant B as MessageBus
participant G as Message processor
participant S as SessionManager
participant W as Per-session worker
participant A as AgentLoop
participant D as OutboundDispatcher
C->>B: publish InboundMessage
B->>G: consume inbound
G->>S: handle_message
S->>W: try_send AgentTask
S-->>G: AgentProcessing
W->>A: process(history)
A-->>W: final response
W->>B: publish OutboundMessage
B->>D: consume outbound
D->>C: Channel::send
```
关键语义:
- Gateway 的主消息处理循环不等待模型完成;普通消息进入对应 session worker 后立即返回 `AgentProcessing`
- 每个 session 有一条容量为 32 的队列,同一 session 串行处理,不同 session 的 worker 可并发执行。
- 队列满时明确拒绝新消息,不允许无界积压。
- Slash command 不进入 Agent 队列,由 `SessionManager` 直接执行,因此 `/stop` 等控制操作不会排在长模型调用之后。
### 出站投递
`OutboundDispatcher``(channel, chat_id)` 建立独立 lane
- 同一目标的消息保持顺序。
- 慢目标不会阻塞其他目标。
- 每条 lane 容量为 64空闲 300 秒后退出。
- 单次发送超时 30 秒;最多尝试 3 次,前两次失败后分别等待 1/2 秒。只有 `ConnectionError``SendError` 会重试。
- `deliver_outbound` 可等待渠道真实投递结果,等待上限 120 秒;普通 `publish_outbound` 只保证成功入队。
不要把“已进入 Bus”误认为“外部渠道已收到”。需要确认语义时必须使用 `deliver_outbound`
### Control 消息
WebSocket dialog 操作通过 `ControlMessage` 携带一次性回复通道。Gateway 在统一 message processor 中调用 `SessionManager`,再将 `SessionEvent` 回传给发起者。Bus 只承载消息,不解释操作。
TUI 的历史回放同样走 control 队列:`get_session_history` 先校验 session 属于当前客户端 scope再由 SessionManager 从 Storage 读取最近消息。单次查询限制为 12000 条TUI 默认请求最近 1000 条;迟到的历史响应只有在目标仍是当前 dialog 时才允许更新界面。
Agent worker 发出的异步回复和通知在 OutboundMessage metadata 中标记来源 session`cli_chat` 将其映射为 WebSocket `session_id`。TUI 切换 dialog 后不渲染其他 session 的迟到结果;结果仍按原 session 持久化,切回时通过历史回放显示。
## 5. 会话模型与并发不变量
Session ID 格式为:
```text
<channel>:<chat_id>:<dialog_id>
```
`SessionManagerInner` 保存:
- `sessions`:完整 Session ID 到内存 Session 的映射。
- `current_sessions``channel:chat_id` 到当前 dialog 的映射。
必须维护以下不变量:
1. 同一 Session 的 Agent 工作由一个 generation 对应的 worker 串行执行。
2. `/stop` 或 worker 替换会递增 `worker_generation`;旧 worker 不得再提交结果。
3. 慢操作模型、记忆召回、压缩、SQLite I/O不能长期持有 Session mutex。
4. 慢操作开始前记录 `state_version`,提交前重新验证,防止旧快照覆盖 `/clear``/delete` 等并发修改。
5. 持久化写入由 `persistence_lock` 串行化;多条相关记录应使用 Storage 的原子接口。
6. 内存先变更但持久化失败时,必须回滚精确匹配的消息后缀,不能删除无关的新状态。
SessionManager 负责组装会话上下文系统提示、Skills、召回的 Knowledge、压缩后的 Timeline 和当前消息历史。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态。
## 6. 持久化
`Storage` 使用 SQLx + SQLite默认数据库为 `{workspace_dir}/picobot.db`。连接启用:
- WAL journal mode。
- foreign keys。
- 5 秒 busy timeout。
- schema version 迁移。
持久化范围包括 sessions、messages、memories、scheduled jobs、job runs 和 background tasks。修改 schema 时应:
1. 更新集中式 schema/迁移逻辑。
2. 保留已有数据库的升级路径。
3. 为新库初始化和旧库迁移分别增加测试。
4. 对“状态更新 + 执行记录”等复合写入使用事务。
### 安全边界
- API Key 和渠道凭据只来自配置占位符、`.env` 或进程环境,不得写入仓库。
- 日志不得输出 token、secret、Authorization header或包含临时凭据的完整 URL应记录脱敏后的 host/path 和必要诊断字段。
- Gateway 把 cwd 切到 workspace因此相对文件路径和 Shell 默认从 workspace 开始这不是硬沙箱。当前内置文件工具接受绝对路径Bash 也可访问进程权限允许的位置。若某场景需要硬边界,必须显式配置/实现 allowed directory 和进程隔离。
- `http_request``web_fetch` 的私网/回环地址校验属于 SSRF 防线,重构网络层时不能绕过。
- 外部内容、Tool 输出和 MCP 响应均是不可信输入;解析错误应返回结构化失败,不能 panic。
## 7. 后台任务与生命周期
`TaskSupervisor` 是 Gateway 内部后台任务的统一所有者。message processor、outbound dispatcher、scheduler、session workers、outbound lanes、通知消费者和子 Agent 后台任务都应通过它注册。
两种注册方式:
- `spawn`:收到全局取消后直接丢弃任务 future适合无需异步清理的任务。
- `spawn_graceful`:任务自己观察 cancellation token 并清理Supervisor 在总宽限期结束后再强制 abort。
新增长生命周期任务时必须满足:
- 有明确 owner禁止无法回收的裸 `tokio::spawn`
- 能响应取消;外部连接、重试 sleep 和阻塞式等待也要纳入取消分支。
- 等待任务退出必须有硬超时,超时后 abort 并回收 JoinHandle。
- 任务 panic、超时和永久错误要可观测且不能阻止其他组件清理。
WebSocket 每个客户端的 writer task 是连接局部任务,由连接 handler 自己限时回收;它不跨越连接生命周期。
## 8. 启动与关停顺序
### 启动
1. 加载配置和 `.env`,解析 workspace。
2. 创建并切换到 workspace。
3. 初始化 SQLite、MemoryManager、MessageBus 和 SessionManager。
4. 注册内置工具、渠道、MCP 工具和 Cron 工具。
5. 启动所有 Channel。
6. 通过 TaskSupervisor 启动 message processor、dispatcher 和 scheduler。
7. 绑定 Axum listener开始接收请求。
### 关停
1. `Ctrl-C` 触发 Axum graceful shutdown并取消所有 WebSocket 连接。
2. `ChannelManager::stop_all` 先停止外部消息入口并注销渠道。
3. 取消 TaskSupervisor停止接受新后台任务。
4. 在共享的 10 秒总宽限期内等待任务退出,之后 abort 剩余任务。
渠道自己的 `stop()` 也必须有界。以飞书为例端点请求、WebSocket 建连、重试等待和已连接循环共享 CancellationToken另有 5 秒强制回收兜底。
## 9. 扩展指南
### 新增 Channel
1. 实现 `Channel` trait仅处理外部协议和统一消息转换。
2. 在 `ChannelManager::init` 注册,并通过同一个 MessageBus 收发。
3. 将可重试错误表示为 `ConnectionError`/`SendError`,永久错误使用其他类型。
4. 为 start/stop 幂等性、取消建连、投递失败和媒体边界增加测试。
5. 不要从 Channel 直接调用 SessionManager 或 Provider。
### 新增 Tool
1. 实现 `Tool`,在集中注册点加入 `ToolRegistry`
2. 参数 schema 必须明确;返回值保持可供模型消费的字符串协议。
3. 明确工具需要“workspace 默认目录”还是“不可逃逸的硬边界”;后者必须显式校验 canonical path不能只依赖 cwd。
4. 网络工具必须保留 SSRF/私网地址校验。
5. 长操作应有超时;后台执行应交给 SubAgentManager/TaskSupervisor。
### 新增 Provider
1. 实现 `LLMProvider`,保持其为纯 HTTP/API 适配器。
2. 统一映射文本、媒体、tool calls、usage 和错误。
3. 不在 Provider 中访问 Session、Bus 或 Channel。
4. 为请求序列化和响应兼容性编写离线单元测试。
### 修改 Session
先画出锁、慢 I/O、版本检查和持久化顺序。任何跨 `await` 的 Session 锁都需要特别审查;任何由旧快照产生的结果都必须在提交前验证 generation/state version。
## 10. 验证策略
按改动范围选择最小但充分的验证:
| 改动 | 至少执行 |
|------|----------|
| 文档 | 检查链接、命令和源码路径;`git diff --check` |
| Rust 实现 | 相关定向测试、`cargo test --lib``cargo clippy --all-targets --all-features -- -D warnings` |
| 构建/依赖 | 上述检查加 `cargo build` |
| Provider/真实渠道 | 离线测试;有凭据时再运行 ignored integration tests |
| SQLite schema | 新库测试、迁移测试、原子性测试 |
| 生命周期/并发 | 成功、失败、超时、取消、队列满和重复 start/stop 测试 |
模型 API 集成测试需要 `tests/test.env` 中的真实 API Key并默认 ignored`test_scheduler``test_request_format` 是可直接运行的离线测试。不应在无凭据时假装已经验证真实 Provider。
## 11. 演进决策清单
提交架构性修改前,逐项确认:
- 是否仍遵守 Channel → Bus → Session → Agent 的依赖方向?
- 是否引入第二个 owner、重复状态或绕过统一注册点
- 队列、并发、重试和等待是否全部有界?
- 取消信号能否覆盖建连、sleep、I/O 和清理阶段?
- 是否在持锁时执行了网络、模型或数据库慢操作?
- 内存与 SQLite 失败时能否保持一致?
- 错误是否区分瞬态和永久语义?
- 是否有测试覆盖正常路径与最危险的失败路径?
- README、AGENTS.md 和本文档是否需要同步更新?
## 12. 代码导航
| 主题 | 入口 |
|------|------|
| Gateway 装配和关停 | `src/gateway/mod.rs` |
| WebSocket 生命周期 | `src/gateway/ws.rs` |
| Bus 和消息类型 | `src/bus/mod.rs`, `src/bus/message.rs` |
| 出站并发与重试 | `src/bus/dispatcher.rs` |
| Channel 接口与注册 | `src/channels/base.rs`, `src/channels/manager.rs` |
| Session 核心 | `src/session/session.rs` |
| Session 命令/事件 | `src/session/commands.rs`, `src/session/events.rs` |
| Agent loop | `src/agent/agent_loop.rs` |
| 后台任务监督 | `src/task_supervisor.rs` |
| SQLite 初始化和迁移 | `src/storage/mod.rs` |
| Scheduler | `src/scheduler/mod.rs` |
| 配置加载 | `src/config/mod.rs` |

View File

@ -0,0 +1,369 @@
# PicoBot 代码质量分析报告
审查日期2026-06-15
## 结论摘要
PicoBot 的总体架构方向是清晰的Gateway 负责装配Channel 只做收发MessageBus 解耦输入输出SessionManager 管理会话AgentLoop 保持无状态并执行工具Storage 统一持久化。这条主线是成立的,也已经具备较完整的 AI 助手运行时能力。
当前主要质量风险集中在三类:
1. 会话/CLI 路由语义不一致,导致多客户端隔离、加载会话、当前会话追踪不可靠。
2. 若干公开控制接口是空实现或弱实现,协议层暴露的能力和后端实际行为不匹配。
3. 工具和后台任务的资源边界偏弱文件、shell、HTTP、长期任务在异常情况下容易突破预期的安全或稳定性边界。
如果只安排一轮修复,优先处理会话路由和控制接口。这些问题会直接影响用户看到的行为;工具安全和大模块拆分可以作为第二阶段。
## 修复状态
- 已修复CLI 会话路由现在按每个 WebSocket client 的稳定 `chat_id` 隔离,普通输入、创建、列表、加载和 outbound 投递不再混用完整 `session_id``chat_id`
- 已修复Dialog 控制接口已补齐当前会话查询、列表 current 标记、归档、清空历史和 `/delete` 删除当前会话后新建的行为;`include_archived` 现在由 Storage 查询生效。
- 已修复Session 主处理路径不再在持有 session mutex 时执行 memory recall、上下文压缩、标题 LLM 生成、消息持久化、`/stop` sub-agent 取消或清历史存储操作;慢操作改为锁外执行并用 `state_version`/`worker_generation` 防止陈旧结果覆盖当前会话。
- 已修复Bash 超时清理、文件读取大文件限制、HTTP DNS 私网校验、Bus 关闭退出、Cron `from` 语义和 PTY 工具接入等中等级问题已完成清扫。
- 待处理:工具文件边界仍是后续质量风险。
## 主要发现
### 已修复CLI 会话路由会破坏会话连续性和多客户端隔离
位置:
- `src/channels/cli_chat.rs:113-126`
- `src/channels/cli_chat.rs:160-164`
- `src/channels/cli_chat.rs:225-249`
- `src/channels/cli_chat.rs:479-494`
- `src/session/session.rs:1305-1310`
问题:
`Client.current_session_id` 存的是完整 session id但 CLI channel 在多个地方把它当作 `chat_id` 使用。普通用户输入如果没有显式传 `chat_id`,会在 `src/channels/cli_chat.rs:119` 生成新的短 ID而不是复用当前 client 的 chat scope。`CreateSession` 又把当前完整 session id 当成新会话的 chat_id。`LoadSession` 解析了传入 session id但随后调用 `GetCurrentDialog`,而后端 `get_current_dialog()` 固定返回 `None`
同时,`send()` 会把所有 `OutboundMessage` 广播给所有 CLI WebSocket client没有按 `msg.chat_id` 或 client 当前会话过滤。这意味着一个客户端的回复可能出现在另一个客户端里。
影响:
- CLI 多轮对话可能落入不同 chat scope。
- 创建/列出/加载会话得到的结果可能不符合 UI 预期。
- 多个 CLI 客户端同时连接时存在串话。
建议:
- 将 client 状态拆成 `chat_id``current_session_id`,不要混用。
- 注册 client 时生成稳定 `chat_id`,后续 `UserInput` 默认复用它。
- `send()``OutboundMessage.chat_id` 精确投递;必要时维护 `chat_id -> clients` 映射。
- `LoadSession` 应直接切换到指定 session或通过 `SwitchDialog` 使用其中的 `dialog_id`
- 为 CLI WebSocket 增加多客户端路由测试。
### 已修复Dialog 控制接口与协议承诺不一致
位置:
- `src/session/session.rs:996-997`
- `src/session/session.rs:1305-1310`
- `src/session/session.rs:1329-1349`
- `src/session/session.rs:1378-1384`
- `src/channels/cli_chat.rs:128-158`
问题:
后端暴露了 create/list/load/rename/archive/delete/clear 等 dialog 操作,但部分行为是空实现或语义错位:
- `/delete` 只创建新 session并没有删除当前 session。
- `get_current_dialog()` 固定返回 `Ok(None)`
- `list_dialogs()` 忽略 `include_archived`,且总是返回 `current_dialog_id = None`
- `archive_dialog()` 是空操作。
- `clear_dialog_history()` 直接返回不可用,但 WebSocket 协议仍暴露 `clear_history`
影响:
用户通过 slash command 和 WebSocket 调用同一类能力时,会得到不一致结果。前端难以基于协议实现可靠状态同步。
建议:
- 明确“archive/clear 是否支持”。不支持就从协议和命令列表移除;支持就实现到底。
- `/delete` 应调用 `delete_dialog(current_session_id)`,再创建一个新的 current session。
- `get_current_dialog()` 应读取 `current_sessions[channel:chat_id]` 并解析为 `UnifiedSessionId`
- `list_dialogs()` 返回真实 current dialog并补上 archived 模型或移除 archived 参数。
### 高优先级:工具文件边界不符合“工作目录内工具”的架构约束
位置:
- `src/tools/mod.rs:56-62`
- `src/tools/path_utils.rs:3-23`
- `src/tools/bash.rs:146-185`
问题:
文件工具默认通过 `FileReadTool::new()``FileWriteTool::new()` 等注册,没有传入 workspace allowlist。`resolve_path()` 对绝对路径直接放行;即使传入 allowlist也只是做 `Path::starts_with()` 的词法判断,没有 canonicalize不能防御 `..`、符号链接等路径逃逸。
`bash` 默认工作目录是 `"."`Gateway 启动时切到 workspace这对相对路径有效但 shell 命令仍然可以访问绝对路径。当前 denylist 只挡少数危险模式,不构成权限边界。
影响:
Agent 工具实际可以读写 workspace 外文件,和文档/架构里的“工作目录内操作”不一致。对于个人助手这可能是有意设计,但如果未来接入外部渠道、多用户或 MCP风险会放大。
建议:
- 工具注册时传入 `workspace_dir`,默认所有文件工具限制在 workspace。
- `resolve_path()` 使用 `std::fs::canonicalize``path_absolutize` 风格逻辑,并处理目标文件不存在时的父目录 canonicalize。
- 写工具禁止跟随危险符号链接,或至少在文档中明确该能力是全文件系统权限。
- shell 工具如果保留,应在配置中显式开关,并区分本地可信模式和渠道暴露模式。
### 已修复Session 锁内执行过多异步操作
位置:
- `src/session/session.rs:1001-1018`
- `src/session/session.rs:1604-1711`
问题:
`/compact` 在持有 session mutex 时执行压缩和持久化。agent worker 的 Phase 1 也在持有 session mutex 时执行用户消息落库、memory recall、上下文压缩、session meta 持久化和 agent 创建。其中 `compress_if_needed()` 可能触发 LLM 摘要,属于慢操作。
影响:
- 同一 session 的 slash command、stop、消息排队、状态查询会被慢操作阻塞。
- 当压缩或存储出现抖动时,用户感觉像“卡死”。
- 后续如果在这些慢操作里间接需要 session 状态,容易形成锁顺序问题。
已采取修复:
- 为 `Session` 增加 `state_version`,慢操作提交前检查会话是否已被 `/stop`、清历史或其它内存变更替换。
- `/compact` 改为锁内取 history 快照,锁外压缩,锁内提交压缩结果,锁外持久化 meta。
- agent worker Phase 1 改为锁内只创建用户消息、agent、cancel handle 和 history 快照memory recall 与 context compression 都在锁外执行。
- context overflow retry 的二次压缩移到锁外。
- 标题生成改为锁内取 prompt/provider 快照,锁外调用 LLM锁内应用标题锁外持久化。
- `add_message` 拆出内存更新和持久化快照,主消息路径在释放 session 锁后写入 SQLite。
- `/stop` 和清历史不再持有 session 锁等待 sub-agent 取消或 Storage 操作。
### 已修复Bash 超时不会显式终止子进程
位置:
- `src/tools/bash.rs:150-174`
- `src/tools/bash.rs:180-207`
问题:
`timeout()` 包裹的是 `run_command()` future。超时后 future 被取消,但代码没有持有 child 句柄并显式 `kill()` / `wait()`。对于已经启动的长运行命令或子进程树,可能留下后台进程。
影响:
长任务、服务进程或卡住的 shell 命令会泄漏进程和资源,后续工具调用的行为也会变得不可预测。
已采取修复:
- Bash 一次性命令改用 `wait_with_output()`,避免 stdout/stderr 顺序读取造成 pipe 阻塞。
- 子进程启用 `kill_on_drop(true)`,超时后丢弃等待 future 时会清理 child。
- 新增大 stderr 输出测试,覆盖不会因为 stderr pipe 填满而卡住。
- 持久/交互式进程通过已接入的 PTY 工具承载。
### 已修复:文件读取对大二进制文件没有输出上限
位置:
- `src/tools/file_read.rs:121-131`
- `src/tools/file_read.rs:214-229`
问题:
`file_read``std::fs::read()` 读取整个文件。文本路径有 `MAX_CHARS` 截断,但二进制路径会完整 base64 编码后返回,没有大小限制。
影响:
读取大文件会造成内存膨胀、响应膨胀、上下文污染,甚至拖垮进程。
已采取修复:
- `file_read` 在读取前检查 metadata size超过安全阈值直接拒绝。
- 二进制 inline base64 增加单独大小上限,超限只返回错误和文件信息。
- 含 NUL 字节内容按二进制处理,避免全 0 文件被 UTF-8 路径误判为文本。
- 增加大文件和大二进制文件测试。
### 已修复HTTP 私网防护只检查字面 host未做 DNS 解析校验
位置:
- `src/tools/http_request.rs:31-59`
问题:
`http_request` 阻止 localhost、私网 IP 字面量和 `.local`,但普通域名不会解析后检查最终 IP。DNS rebinding 或内网域名解析到私网地址时,当前校验拦不住。
影响:
如果该工具暴露给非完全可信输入,存在 SSRF 风险。
已采取修复:
- `http_request``web_fetch` 在发送请求前通过 DNS 解析 host并拒绝解析到 loopback、private、link-local、multicast、unspecified 的地址。
- IPv6 unique-local 和 link-local 地址也纳入私网判定。
- 禁用 reqwest 自动重定向,避免跳转到未校验的内网地址。
- 增加端口解析和 IPv6 私网判断测试。
### 已修复:后台任务和主循环缺少监督与优雅关闭
位置:
- `src/bus/mod.rs:51-99`
- `src/gateway/mod.rs:187-244`
- `src/gateway/mod.rs:247-266`
问题:
Gateway 中多个长期任务通过 `tokio::spawn` 启动后没有保存 JoinHandle也没有统一 cancellation token。MessageBus 的 `consume_*()` 在 channel 关闭时使用 `expect()` panic。
影响:
- 某个后台 loop 异常退出后Gateway 不一定能发现。
- 关闭流程只能 stop channel无法系统性停止 scheduler、dispatcher、agent workers、notification publishers。
- bus channel 关闭时更像崩溃,而不是可恢复状态。
已采取修复:
- `MessageBus::consume_inbound/consume_outbound/consume_control` 不再在 channel 关闭时 `expect()` panic改为返回 `Option<T>`
- Gateway message processor 在 inbound/control bus 关闭时记录 warning 并退出 loop。
- OutboundDispatcher 在 outbound bus 关闭时记录 warning 并退出 loop。
- 这不是完整 runtime supervisor但已消除 bus 关闭导致的 panic 崩溃路径,为后续集中 JoinHandle 管理留出接口。
### 已修复Cron 计算函数没有按入参 `from` 计算 cron 下一次时间
位置:
- `src/scheduler/mod.rs:18-40`
问题:
`next_run_for_schedule(schedule, from)` 的注释说基于 `from` 计算,但 cron 分支创建了 `from_dt` 后没有传给 `cron_schedule`,实际使用的是 `upcoming(Utc)``upcoming(tz)` 的当前时间。
影响:
单元测试或补偿调度传入历史/未来时间时,结果不符合函数契约。线上 reschedule 当前使用 now影响较小但函数语义是错的。
已采取修复:
- cron 分支改用 `cron_schedule.after(&from_dt).next()`
- timezone 分支用 `from_dt.with_timezone(&tz)` 作为计算起点。
- 增加 UTC 和 Asia/Shanghai 固定时间输入测试。
### 已修复:存在未接入或半接入代码,增加维护噪音
位置:
- `src/tools/pty.rs`
- `src/tools/mod.rs:1-20`
- `src/tools/mod.rs:49-88`
问题:
仓库里有完整 `pty.rs`,但 `tools/mod.rs` 没有声明 `pub mod pty``create_default_tools()` 也没有注册 PTY 工具。类似情况会让文档、计划和实现状态难以判断。
影响:
维护者会误以为功能已上线。未来改动容易遗漏测试和注册路径。
已采取修复:
- `src/tools/pty.rs` 已接入 `tools/mod.rs`,导出 `PtyManager`/`PtyTool`
- `create_default_tools()` 默认注册共享 `PtyManager``PtyTool`
- 修复 PTY 原本因未编译暴露不出的借用问题。
## 架构评价
### 做得好的地方
- 模块分层方向清楚Channel、Bus、Session、Agent、Provider、Tool、Storage 边界基本可理解。
- AgentLoop 设计为无状态,历史由 SessionManager 管理,这一点利于恢复、压缩和测试。
- Provider 抽象简单直接OpenAI-compatible 与 Anthropic 的差异被限制在 provider 层。
- Storage 集中初始化 schema便于部署单二进制应用。
- Skill、memory、MCP、delegate 这几条扩展线已经形成统一的 ToolRegistry 接入点。
### 主要架构债务
- SessionManager 承担过多职责会话生命周期、命令解析、memory recall、压缩、agent worker、任务取消、send_message 目标解析都在一个 2000 行文件内。
- Channel 和 Session 对 chat_id/session_id/dialog_id 的边界没有类型保护,导致 CLI 层混用字符串。
- Tool 权限模型不够显式:工具是否能访问全文件系统、是否能联网、是否能修改状态主要靠工具自身约定。
- 后台任务生命周期分散gateway loop、agent worker、notification publisher、scheduler、sub-agent task 各自 spawn缺少统一管理。
## 模块级分析
### gateway
`GatewayState::new()` 是清晰的装配中心配置、workspace、storage、memory、bus、session manager、channels、MCP、scheduler 都在这里接线。问题是启动后任务监督不足,且 scheduler 默认 `unwrap_or_default()` 会在省略 `gateway.scheduler` 时启用调度器,这和“省略配置是否代表开启”需要产品层确认。
### channels
Feishu channel 功能较厚,单文件接近 2000 行,建议后续按 API client、message parsing、media handling、outbound rendering 拆分。CLI channel 目前是质量风险最高的 channel核心问题是会话身份混用和广播投递。
### bus
MessageBus 简洁,但当前消费者 API 通过 mutex 包住 receiver 并 `expect()`,更像“单消费者内部队列”。这没问题,但应该把“只能有一个 consumer”写进类型/文档,并把关闭作为正常状态处理。
### session
这是系统核心,也是债务最集中的模块。建议把 `session.rs` 拆成:
- `manager.rs`SessionManager 状态和 dialog 生命周期
- `worker.rs`per-session agent worker 和 cancellation
- `commands.rs`slash command 执行
- `outbound.rs`OutboundMessenger 实现
- `restore.rs`storage 恢复与 tool call chain repair
拆分之前,先补行为测试,尤其是 CLI/WS session lifecycle。
### agent
AgentLoop 的职责相对聚焦:请求模型、执行工具、回填 tool result、循环直到 final response。需要关注的是工具并发的语义`read_only()` 目前是工具自己声明副作用工具不能错标。LoopDetector 有帮助,但属于 runtime guard不应替代工具层的资源限制。
### providers
Provider 层整体可维护。OpenAI/Anthropic 的请求构造逻辑可以继续保留在 provider 内。建议补充请求脱敏策略:当前 debug log 和 `llm_calls` 会持久化完整 request/response可能包含用户隐私、API 返回内容和文件内容。
### tools
工具体系覆盖面很强,但需要明确权限模型。建议新增统一的 `ToolExecutionContext`,包含 workspace、channel、session_id、权限策略、网络策略、输出预算。现在很多策略散落在各工具构造函数里默认值容易失控。
### storage
Storage schema 初始化实用但迁移方式是“CREATE IF NOT EXISTS + ALTER IGNORE”适合早期迭代不适合长期演进。建议引入 schema version 表或 sqlx migrations至少把每次迁移记录下来。
### skills
Skill 加载优先级清晰,内置 skill 打包也实用。需要注意 `SkillsLoader` 使用同步文件系统扫描和 `std::sync::Mutex`,在请求路径频繁 `reload_if_changed()` 时可能造成阻塞。短期可以接受,长期建议缓存刷新放到后台 watcher。
## 建议修复路线
### P0先修会话正确性
1. 修正 CLI `chat_id/current_session_id` 数据模型。
2. 修正 CLI 出站按 client/chat_id 投递。
3. 实现 `get_current_dialog()``list_dialogs()` current 返回。
4. 修正 `/delete``clear_history``archive` 的真实行为或从协议移除。
5. 增加 WebSocket session lifecycle 测试。
### P1收紧工具和资源边界
1. 文件工具默认限制 workspace路径 canonicalize。
2. bash 超时杀进程,必要时引入进程组。
3. file_read 增加文件大小上限和二进制输出上限。
4. HTTP/web 工具增加 DNS 解析后的私网校验和重定向校验。
5. 明确高危工具的配置开关。
### P2降低架构复杂度
1. 拆分 `session.rs``feishu.rs``storage/mod.rs``browser.rs`
2. 引入任务 supervisor 和统一 shutdown token。
3. 引入正式数据库迁移。
4. 增加工具注册快照测试,避免死代码和文档漂移。
## 建议测试补充
- CLI 多客户端并发:两个 WebSocket client 同时发消息,互不串话。
- CLI 不传 chat_id 的连续对话:所有消息应进入同一 session。
- Load/switch/list/delete/clear 的完整 WebSocket 流程。
- `/delete` 后旧 session 软删除、新 session 成为 current。
- 文件路径逃逸:`../`、绝对路径、符号链接、workspace 前缀欺骗。
- bash timeout 后检查子进程不存在。
- cron `next_run_for_schedule()` 使用固定 `from` 的 deterministic 测试。
- HTTP 工具对 DNS 解析到 `127.0.0.1` / `10.0.0.0/8` 的域名拒绝测试。

View File

@ -0,0 +1,496 @@
# PicoBot 跨渠道交互式消息规划
规划日期2026-06-16
## 背景
飞书交互式卡片可以让用户直接在消息卡片上点击按钮、提交选择或触发回调。这个能力很适合用于工具调用审批、快捷回复、任务确认、表单收集等 agent 交互。
参考项目调研结果:
- `reference/zeroclaw` 已实现飞书/Lark 工具审批卡片:发送 Card JSON 2.0 按钮卡片,收到 `card.action.trigger` 后解析 `approval_id``decision`,唤醒等待中的 approval future并 PATCH 原卡片为已处理状态。
- `reference/nanobot` 主要使用飞书 CardKit 做 agent 输出展示和流式更新,适合参考消息渲染体验,但没有完整的按钮回调驱动 agent 流程。
- `reference/openlark` 是 SDK/API 封装,支持发送 interactive card 和 CardKit API不包含完整 agent channel 编排。
PicoBot 当前飞书渠道已经会把普通 markdown 回复发送成 interactive card但还缺少“用户在卡片上操作 -> 统一交互事件 -> Session/Agent/Tool 流程继续”的抽象。
## 目标
1. 支持飞书交互式卡片按钮回调。
2. 设计成跨渠道能力,后续 Slack、Telegram、Discord、CLI chat 等渠道可以复用同一套交互语义。
3. 支持渠道降级:不支持按钮的渠道也能用纯文本命令完成同样操作。
4. 保持 PicoBot 现有边界Channel 只做收发和渠道适配SessionManager 管会话AgentLoop 执行 LLM 和工具。
5. 为工具调用审批、快捷回复和未来表单交互预留扩展点。
非目标:
- 本阶段不立即实现完整功能。
- 不把飞书卡片细节泄漏到 AgentLoop 或工具层。
- 不要求所有渠道同时支持原生交互组件。
## 核心原则
交互语义和渠道渲染分离。
Agent、工具或 Session 层只表达“我要一个 approval/quick reply/form interaction”。具体是飞书卡片按钮、Slack Block Kit、Telegram inline keyboard还是 CLI 里显示编号选项,由 Channel 根据能力渲染。
回调也要统一。
飞书的 `card.action.trigger`、Telegram 的 `callback_query`、Slack 的 interaction payload 都应归一化成 PicoBot 内部的 `InteractionEvent`,再交给统一的处理器。
## 数据模型
建议新增一个 `interaction` 模块,定义渠道无关的数据结构。
```rust
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum InteractionKind {
QuickReply,
Approval,
FormSubmit,
Command,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum InteractionStyle {
Default,
Primary,
Danger,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct InteractionAction {
pub id: String,
pub label: String,
pub value: String,
pub style: InteractionStyle,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct InteractionPayload {
pub interaction_id: String,
pub kind: InteractionKind,
pub title: Option<String>,
pub body: String,
pub actions: Vec<InteractionAction>,
pub expires_at: Option<i64>,
}
#[derive(Debug, Clone)]
pub struct InteractionEvent {
pub channel: String,
pub chat_id: String,
pub sender_id: String,
pub interaction_id: String,
pub action_id: String,
pub action_value: String,
pub timestamp: i64,
pub metadata: std::collections::HashMap<String, String>,
}
```
`InteractionPayload` 用于 outbound 渲染,`InteractionEvent` 用于 inbound 回调。
## OutboundMessage 扩展
短期兼容方案:
- 继续使用 `OutboundMessage.metadata` 携带交互描述。
- 例如:
- `interaction.kind = "approval"`
- `interaction.id = "<uuid>"`
- `interaction.actions = "<json>"`
长期推荐方案:
```rust
pub struct OutboundMessage {
pub channel: String,
pub chat_id: String,
pub content: String,
pub reply_to: Option<String>,
pub media: Vec<MediaItem>,
pub metadata: HashMap<String, String>,
pub interaction: Option<InteractionPayload>,
}
```
推荐长期方案。它能避免把结构化交互塞进字符串 metadata也让每个 Channel 的 `send()` 更清晰。
## Channel 能力声明
`Channel` 增加可选能力声明:
```rust
#[derive(Debug, Clone, Default)]
pub struct ChannelCapabilities {
pub interactive_buttons: bool,
pub forms: bool,
pub message_update: bool,
pub markdown_cards: bool,
}
pub trait Channel {
fn capabilities(&self) -> ChannelCapabilities {
ChannelCapabilities::default()
}
}
```
渠道能力示例:
| 渠道 | 原生按钮 | 表单 | 更新原消息 | 降级策略 |
|------|----------|------|------------|----------|
| Feishu | 是interactive card | 可后续支持 | 是PATCH message/card | 文本命令 |
| Slack | 是Block Kit | 是 | 是 | 文本命令 |
| Telegram | 是inline keyboard | 有限 | 可编辑消息 | 文本命令 |
| Discord | 是components | 有限 | 可编辑消息 | 文本命令 |
| CLI chat | 否 | 否 | 局部可模拟 | 编号/命令输入 |
| Webhook/Email/SMS | 否 | 否 | 通常否 | 纯文本命令或链接 |
## 渲染策略
每个 channel 实现一个渠道内的渲染函数:
```rust
async fn send_interaction(
&self,
chat_id: &str,
payload: &InteractionPayload,
) -> Result<(), ChannelError>;
```
也可以先不改 trait`send()` 内部判断 `msg.interaction`
飞书渲染:
- 使用 Card JSON 2.0。
- `schema = "2.0"`
- body 用 markdown 展示 `payload.body`
- actions 渲染为 button。
- 每个按钮的 callback value 写入:
```json
{
"interaction_id": "...",
"action_id": "...",
"action_value": "approve"
}
```
需要兼容飞书 Card 2.0 回调路径:
- `/action/value`
- `/action/behaviors/0/value`
CLI 降级渲染:
```text
需要确认:
Tool: bash
Args: cargo test --lib
可选操作:
1. Approve
2. Deny
3. Always approve
回复:
/_interaction <interaction_id> approve
/_interaction <interaction_id> deny
/_interaction <interaction_id> always
```
纯文本渠道都可以复用这个 fallback renderer。
## Inbound 回调归一化
飞书 WebSocket 当前在 `src/channels/feishu.rs` 里处理 `im.message.receive_v1`。需要新增对 `card.action.trigger` 的识别:
1. ACK 仍要尽快发送,飞书要求 3 秒内响应。
2. 如果 event type 是 `card.action.trigger`,不要走普通消息解析。
3. 从 event payload 中解析 `interaction_id``action_id``action_value`
4. 构造 `InteractionEvent` 发布给统一处理器。
5. 对未知、过期或重复 interaction 返回成功但记录日志,不应导致渠道重连或报错。
如果短期不新增 interaction bus可以把回调转成特殊 `InboundMessage`
```text
content = "/_interaction <interaction_id> <action_value>"
metadata["event.kind"] = "interaction"
metadata["interaction.id"] = "<interaction_id>"
metadata["interaction.action_id"] = "<action_id>"
metadata["interaction.action_value"] = "<action_value>"
```
但必须由 SessionManager 或 InteractionManager 先拦截,不能把 `/_interaction` 当普通用户文本直接送进 LLM。
长期推荐新增 bus 通道:
```rust
pub enum InboundEvent {
Message(InboundMessage),
Interaction(InteractionEvent),
}
```
或者在 `MessageBus` 上增加 `interaction_tx`
## InteractionManager
建议新增 `InteractionManager`,集中管理 pending 交互状态,而不是让每个 Channel 各自维护。
职责:
- 生成 `interaction_id`
- 保存 pending interaction。
- 处理超时和过期。
- 接收 `InteractionEvent` 并解析成业务结果。
- 对重复点击、未知 interaction、过期 interaction 做幂等处理。
- 必要时通知 channel 更新原消息。
内部状态示例:
```rust
pub struct PendingInteraction {
pub id: String,
pub kind: InteractionKind,
pub channel: String,
pub chat_id: String,
pub sender_id: Option<String>,
pub session_id: Option<String>,
pub created_at: i64,
pub expires_at: Option<i64>,
pub status: InteractionStatus,
pub responder: InteractionResponder,
pub message_ref: Option<InteractionMessageRef>,
}
pub struct InteractionMessageRef {
pub channel: String,
pub chat_id: String,
pub message_id: String,
pub metadata: HashMap<String, String>,
}
```
`InteractionResponder` 可以先支持 oneshot
```rust
pub enum InteractionResponder {
Approval(tokio::sync::oneshot::Sender<ApprovalDecision>),
InboundMessage,
}
```
后续如果需要持久化长期交互oneshot 不够,需要落库。
## 工具审批流程
工具审批是第一批最适合落地的交互类型。
推荐流程:
1. AgentLoop 准备执行需要审批的工具。
2. 调用 `InteractionManager::request_approval(...)`
3. InteractionManager 创建 `InteractionPayload`,通过 outbound 发送到原 channel/chat。
4. AgentLoop 等待 oneshot带 timeout。
5. 用户在飞书卡片上点击 Approve/Deny/Always。
6. FeishuChannel 收到 `card.action.trigger`,发布 `InteractionEvent`
7. InteractionManager resolve pending approval。
8. AgentLoop 收到结果,继续执行或拒绝工具。
9. 如果 channel 支持更新消息InteractionManager 或 Channel 把原卡片更新成 resolved 状态。
审批 action 建议:
```rust
approve -> ApprovalDecision::Approve
deny -> ApprovalDecision::Deny
always -> ApprovalDecision::AlwaysApprove
```
`DenyWithEdit` 可后续支持,适合 ACP/Web/CLI 这类能输入文本的渠道。
## 快捷回复流程
快捷回复不是阻塞工具执行,而是把用户点击转成新的用户输入。
示例:
```json
{
"kind": "QuickReply",
"body": "你想继续哪个操作?",
"actions": [
{ "label": "继续分析", "value": "继续分析" },
{ "label": "生成报告", "value": "生成报告" }
]
}
```
用户点击后:
- `InteractionEvent.action_value` 转成一条普通 `InboundMessage.content`
- `sender_id``chat_id` 保留原用户和会话。
- metadata 标记来源为 interaction供审计或 UI 使用。
## 消息更新
支持原消息更新的渠道应在交互完成后更新 UI避免重复点击。
飞书:
- 发送卡片后保存 `data.message_id`
- resolve 后 PATCH `/im/v1/messages/{message_id}`
- 卡片 schema 发送和更新都使用 Card JSON 2.0,参考项目指出跨版本 PATCH 可能返回成功但客户端不重渲染。
不支持更新的渠道:
- 发送一条新消息提示“已批准/已拒绝”。
- 或仅在后台幂等拒绝重复点击。
## 安全和权限
交互回调必须校验:
- `interaction_id` 是否存在。
- 是否已过期。
- 是否已处理。
- 点击用户是否允许处理该 interaction。
- 当前 channel/chat 是否匹配。
对于工具审批,默认建议只有触发该 agent turn 的用户或允许列表用户可以审批。群聊里要特别注意 `sender_id`,不能只看 `chat_id`
日志中避免记录原始飞书回调敏感字段:
- callback token
- operator open_id/union_id/user_id/tenant_key
- open_chat_id/open_message_id
可以记录脱敏后的 payload shape用于排查飞书回调字段变化。
## 持久化策略
第一阶段可以只做内存 pending map
- 适合短时工具审批。
- 进程重启后旧按钮点击会变成 unknown/expired。
- 实现简单。
后续如果要支持长期任务或跨重启交互,需要持久化:
- `interactions` 表保存 id、kind、channel、chat_id、sender_id、status、payload、created_at、expires_at。
- `interaction_actions` 可选,或直接 JSON 存在 payload 中。
- resolve 时事务更新 status防止重复点击竞态。
## 与现有架构的关系
现有数据流:
```text
Channel -> MessageBus -> SessionManager -> AgentLoop -> tools -> SessionManager -> MessageBus -> OutboundDispatcher -> Channel
```
加入 interaction 后建议:
```text
Outbound:
AgentLoop/Tool approval -> InteractionManager -> MessageBus outbound -> OutboundDispatcher -> Channel renderer
Inbound:
Channel callback -> InteractionEvent -> InteractionManager -> pending waiter / synthetic InboundMessage
```
Channel 仍然只做渠道协议适配:
- 飞书负责 Card JSON 和 `card.action.trigger`
- Slack 负责 Block Kit 和 signing secret。
- Telegram 负责 callback query。
- CLI 负责文本命令 fallback。
InteractionManager 负责语义:
- 这是 approval 还是 quick reply。
- 是否过期。
- 是否有权限。
- 应该唤醒哪个等待者。
SessionManager/AgentLoop 不需要知道飞书卡片格式。
## 分阶段实施计划
### 阶段 1模型和 fallback
- 新增 `src/interaction/` 模块。
- 定义 `InteractionPayload``InteractionAction``InteractionEvent`
- 增加 fallback text renderer。
- 为 `OutboundMessage` 增加 `interaction: Option<InteractionPayload>`,或短期使用 metadata。
- 增加单元测试覆盖序列化和 fallback 文本。
### 阶段 2Feishu card action
- Feishu outbound 支持把 `InteractionPayload` 渲染为 Card JSON 2.0。
- Feishu inbound 在 WebSocket frame 中识别 `card.action.trigger`
- 解析 `/action/value``/action/behaviors/0/value`
- 发布统一 `InteractionEvent`
- 保存 `message_id`,支持完成后 PATCH resolved card。
- 增加 fixtures 测试真实/模拟的 `card.action.trigger` payload。
### 阶段 3InteractionManager 和审批
- 新增内存版 `InteractionManager`
- 支持 request/resolve/timeout。
- 接入工具执行前审批点。
- 支持 Approve/Deny/AlwaysApprove。
- 未知、过期、重复点击保持幂等。
- 增加 agent/tool 审批单元测试。
### 阶段 4其他渠道兼容
- CLI chat 支持 `/_interaction <id> <value>` fallback。
- 其他不支持原生按钮的渠道使用纯文本 fallback。
- 后续按需实现 Slack/Telegram/Discord 原生按钮。
### 阶段 5持久化和高级交互
- 需要时落库 pending interaction。
- 支持 quick reply 生成 synthetic inbound message。
- 支持表单提交。
- 支持 `DenyWithEdit`
- 支持长期任务交互和重启恢复。
## 测试计划
单元测试:
- `InteractionPayload` 序列化。
- fallback text renderer 输出。
- Feishu Card JSON 包含正确 callback value。
- Feishu 回调同时支持 `/action/value``/action/behaviors/0/value`
- unknown/expired interaction 不报错。
- 重复点击只 resolve 一次。
集成测试:
- 模拟 Feishu `card.action.trigger`,验证 pending approval 被唤醒。
- 模拟超时,验证默认 deny。
- CLI fallback 输入 `/_interaction`,验证能 resolve。
- 不支持按钮的 channel 能收到可读 fallback 文本。
手工验证:
- 飞书群聊点击 Approve/Deny/Always。
- 私聊点击。
- 点击后原卡片更新为 resolved。
- 重复点击不会重复执行工具。
- 非触发用户点击时按权限策略处理。
## 开放问题
1. 工具审批应该由 AgentLoop 直接调用 InteractionManager还是通过 SessionManager 代理?
2. 群聊中是否只允许原始触发者审批,还是允许配置中的所有 allowed user 审批?
3. `AlwaysApprove` 的作用域是本次会话、本 dialog、本 chat还是全局工具策略
4. 是否需要第一阶段就修改 `OutboundMessage` 结构,还是先用 metadata 降低改动面?
5. 飞书 CardKit 流式输出是否要与交互卡片统一,还是继续保持普通回复卡片和交互卡片两套路径?

View File

@ -0,0 +1,101 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1120" height="460" viewBox="0 0 1120 460" role="img" aria-labelledby="title desc">
<title id="title">PicoBot message flow</title>
<desc id="desc">Message flow from channel input through bus, session manager, agent loop, tools, provider, storage, outbound dispatcher, and back to the channel.</desc>
<defs>
<style>
.bg { fill: #fbfbf8; }
.lane { fill: #ffffff; stroke: #d6d3d1; stroke-width: 2; rx: 18; }
.step { fill: #f4f4f5; stroke: #71717a; stroke-width: 1.6; rx: 12; }
.in { fill: #e0f2fe; stroke: #0284c7; }
.state { fill: #ecfdf5; stroke: #059669; }
.agent { fill: #fff7ed; stroke: #ea580c; }
.out { fill: #f5f3ff; stroke: #7c3aed; }
.text { font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #18181b; }
.title { font-size: 30px; font-weight: 800; }
.label { font-size: 17px; font-weight: 700; }
.small { font-size: 14px; fill: #52525b; }
.arrow { stroke: #3f3f46; stroke-width: 2.4; fill: none; marker-end: url(#arrow); }
.soft { stroke: #71717a; stroke-width: 2; fill: none; stroke-dasharray: 7 6; marker-end: url(#arrow-soft); }
.num { fill: #18181b; font-size: 13px; font-weight: 800; }
.badge { fill: #ffffff; stroke: #a1a1aa; }
</style>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M2,2 L10,6 L2,10 Z" fill="#3f3f46" />
</marker>
<marker id="arrow-soft" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M2,2 L10,6 L2,10 Z" fill="#71717a" />
</marker>
</defs>
<rect class="bg" width="1120" height="460" />
<text class="text title" x="48" y="56">Message Flow</text>
<text class="text small" x="48" y="82">A user message becomes a session-scoped agent run, then returns to the original channel.</text>
<rect class="lane" x="38" y="122" width="1044" height="236" />
<g transform="translate(70 182)">
<rect class="step in" width="130" height="78" />
<circle class="badge" cx="18" cy="18" r="13" />
<text class="text num" x="14" y="23">1</text>
<text class="text label" x="32" y="37">Channel</text>
<text class="text small" x="22" y="60">CLI / Feishu</text>
</g>
<g transform="translate(230 182)">
<rect class="step" width="130" height="78" />
<circle class="badge" cx="18" cy="18" r="13" />
<text class="text num" x="14" y="23">2</text>
<text class="text label" x="30" y="37">MessageBus</text>
<text class="text small" x="29" y="60">inbound queue</text>
</g>
<g transform="translate(390 182)">
<rect class="step state" width="150" height="78" />
<circle class="badge" cx="18" cy="18" r="13" />
<text class="text num" x="14" y="23">3</text>
<text class="text label" x="34" y="37">SessionManager</text>
<text class="text small" x="31" y="60">dialog + context</text>
</g>
<g transform="translate(580 182)">
<rect class="step agent" width="130" height="78" />
<circle class="badge" cx="18" cy="18" r="13" />
<text class="text num" x="14" y="23">4</text>
<text class="text label" x="34" y="37">AgentLoop</text>
<text class="text small" x="23" y="60">LLM/tool loop</text>
</g>
<g transform="translate(750 182)">
<rect class="step agent" width="130" height="78" />
<circle class="badge" cx="18" cy="18" r="13" />
<text class="text num" x="14" y="23">5</text>
<text class="text label" x="48" y="37">Tools</text>
<text class="text small" x="29" y="60">side effects</text>
</g>
<g transform="translate(920 182)">
<rect class="step out" width="130" height="78" />
<circle class="badge" cx="18" cy="18" r="13" />
<text class="text num" x="14" y="23">6</text>
<text class="text label" x="30" y="37">Response</text>
<text class="text small" x="28" y="60">outbound bus</text>
</g>
<path class="arrow" d="M200 221 H230" />
<path class="arrow" d="M360 221 H390" />
<path class="arrow" d="M540 221 H580" />
<path class="arrow" d="M710 221 H750" />
<path class="arrow" d="M880 221 H920" />
<path class="soft" d="M455 182 C460 120 620 116 652 181" />
<text class="text small" x="500" y="126">recall memory + build prompt</text>
<path class="soft" d="M645 260 C640 322 482 326 458 260" />
<text class="text small" x="496" y="334">persist messages and metadata</text>
<path class="soft" d="M815 182 C820 122 918 122 958 181" />
<text class="text small" x="828" y="126">provider calls</text>
<path class="arrow" d="M985 260 C972 390 139 392 135 260" />
<text class="text small" x="420" y="408">OutboundDispatcher routes the reply back to the same channel/chat scope.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -0,0 +1,88 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1120" height="620" viewBox="0 0 1120 620" role="img" aria-labelledby="title desc">
<title id="title">PicoBot runtime architecture</title>
<desc id="desc">High level architecture diagram showing channels, gateway, message bus, session manager, agent loop, tools, providers, storage, scheduler, skills, and MCP.</desc>
<defs>
<style>
.bg { fill: #f8fafc; }
.panel { fill: #ffffff; stroke: #cbd5e1; stroke-width: 2; rx: 18; }
.box { fill: #f1f5f9; stroke: #64748b; stroke-width: 1.5; rx: 12; }
.accent { fill: #e0f2fe; stroke: #0284c7; }
.warm { fill: #fff7ed; stroke: #ea580c; }
.green { fill: #ecfdf5; stroke: #059669; }
.violet { fill: #f5f3ff; stroke: #7c3aed; }
.text { font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; fill: #0f172a; }
.small { font-size: 16px; }
.label { font-size: 18px; font-weight: 700; }
.title { font-size: 30px; font-weight: 800; }
.note { font-size: 14px; fill: #475569; }
.arrow { stroke: #334155; stroke-width: 2.4; fill: none; marker-end: url(#arrow); }
.soft { stroke: #64748b; stroke-width: 2; fill: none; stroke-dasharray: 7 6; marker-end: url(#arrow-soft); }
</style>
<marker id="arrow" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M2,2 L10,6 L2,10 Z" fill="#334155" />
</marker>
<marker id="arrow-soft" markerWidth="12" markerHeight="12" refX="10" refY="6" orient="auto">
<path d="M2,2 L10,6 L2,10 Z" fill="#64748b" />
</marker>
</defs>
<rect class="bg" width="1120" height="620" />
<text class="text title" x="48" y="56">PicoBot Runtime Architecture</text>
<text class="text note" x="48" y="82">Channels stay thin, SessionManager owns conversation state, AgentLoop remains stateless.</text>
<rect class="panel" x="40" y="118" width="220" height="360" />
<text class="text label" x="70" y="154">Channels</text>
<rect class="box accent" x="70" y="185" width="160" height="58" />
<text class="text small" x="106" y="220">CLI TUI</text>
<rect class="box accent" x="70" y="263" width="160" height="58" />
<text class="text small" x="104" y="298">Feishu/Lark</text>
<rect class="box accent" x="70" y="341" width="160" height="58" />
<text class="text small" x="109" y="376">WebSocket</text>
<text class="text note" x="70" y="437">Only receive and send</text>
<rect class="panel" x="330" y="118" width="450" height="360" />
<text class="text label" x="360" y="154">Gateway Core</text>
<rect class="box" x="370" y="184" width="160" height="64" />
<text class="text small" x="414" y="222">MessageBus</text>
<rect class="box green" x="580" y="184" width="160" height="64" />
<text class="text small" x="606" y="222">SessionManager</text>
<rect class="box warm" x="580" y="288" width="160" height="64" />
<text class="text small" x="621" y="326">AgentLoop</text>
<rect class="box" x="370" y="288" width="160" height="64" />
<text class="text small" x="402" y="326">Outbound</text>
<text class="text small" x="399" y="346">Dispatcher</text>
<rect class="box violet" x="475" y="390" width="200" height="54" />
<text class="text small" x="514" y="423">Control Channel</text>
<rect class="panel" x="850" y="118" width="230" height="360" />
<text class="text label" x="880" y="154">Capabilities</text>
<rect class="box warm" x="880" y="185" width="170" height="50" />
<text class="text small" x="929" y="216">Tools</text>
<rect class="box warm" x="880" y="249" width="170" height="50" />
<text class="text small" x="916" y="280">Providers</text>
<rect class="box green" x="880" y="313" width="170" height="50" />
<text class="text small" x="926" y="344">SQLite</text>
<rect class="box violet" x="880" y="377" width="170" height="50" />
<text class="text small" x="925" y="408">Skills</text>
<text class="text note" x="880" y="457">MCP tools join ToolRegistry</text>
<rect class="box green" x="300" y="520" width="165" height="54" />
<text class="text small" x="340" y="553">Scheduler</text>
<rect class="box violet" x="505" y="520" width="165" height="54" />
<text class="text small" x="552" y="553">Memory</text>
<rect class="box accent" x="710" y="520" width="165" height="54" />
<text class="text small" x="759" y="553">MCP</text>
<path class="arrow" d="M260 216 H370" />
<path class="arrow" d="M530 216 H580" />
<path class="arrow" d="M660 248 V288" />
<path class="arrow" d="M740 320 H880" />
<path class="arrow" d="M880 274 H750" />
<path class="arrow" d="M580 320 H530" />
<path class="arrow" d="M370 320 H260" />
<path class="soft" d="M575 390 V352" />
<path class="soft" d="M660 248 C750 250 800 330 880 338" />
<path class="soft" d="M382 520 C420 470 520 465 610 352" />
<path class="soft" d="M588 520 C600 470 620 420 650 352" />
<path class="soft" d="M792 520 C825 470 860 430 880 402" />
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

View File

@ -14,11 +14,10 @@ PicoBot 是一个基于 Rust 的个人 AI 助手运行时,包含本地 Gateway
| 文件 | 内容 |
|------|------|
| `references/config.md` | 配置字段详解providers、models、agents、gateway、client、channels、memory、mcp、browser |
| `references/db-schema.md` | 数据库表结构与运行约束sessions、messages、memories、scheduled_jobs、job_runs、llm_calls、background_tasks |
| `references/architecture.md` | 核心架构:消息并发、会话系统、持久化、生命周期、上下文压缩、记忆、MCP、子 Agent |
| `references/db-schema.md` | 数据库表结构sessions、messages、memories、scheduled_jobs、llm_calls、background_tasks |
| `references/architecture.md` | 核心架构:数据流、会话系统、上下文压缩、记忆系统、Skill 优先级、MCP、子 Agent |
| `references/faq.md` | 常见问题模型切换、渠道添加、Skill 安装、历史查询、定时任务、MCP 等 |
| `references/commands.md` | 常用命令:编译、启动网关、启动客户端、运行测试 |
| `references/tools.md` | 内置工具名称、参数和重要使用约束 |
| `assets/config.example.json` | config.json 完整示例 |
Skill 根目录路径见上方 **Skill Root Directory**

View File

@ -3,12 +3,9 @@
## 核心数据流
```
Channel → MessageBus.inbound → Gateway processor → SessionManager → per-session worker → AgentLoop
↑ │
└── Channel ← OutboundDispatcher ← per-conversation lane ← MessageBus.outbound
WebSocket/Channel → MessageBus.control → Gateway processor → SessionManager (dialog 操作)
Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
Channel → MessageBus → SessionManager → AgentLoop → (tools) → SessionManager → MessageBus → OutboundDispatcher → Channel
ControlChannel → SessionManager (dialog 操作: 创建/切换/归档/删除)
```
## 模块职责
@ -18,8 +15,8 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
| `gateway` | HTTP/WebSocket 服务器,持有 GatewayState |
| `client` | TUI 聊天客户端 |
| `channels` | 外部集成飞书、CLI仅收发消息 |
| `bus` | 有界 inbound/outbound/control 队列;出站 dispatcher 与分目标 lane |
| `session` | 会话生命周期、dialog 操作、每 session 串行队列、上下文与持久化协调 |
| `bus` | 异步消息队列,纯队列不路由 |
| `session` | 会话生命周期管理、dialog 操作 |
| `agent` | LLM 调用循环、工具执行、上下文压缩、媒体处理、子 Agent |
| `providers` | LLM API 客户端OpenAI 兼容、Anthropic |
| `tools` | Agent 工具bash、文件操作、搜索、HTTP、web、browser、memory、delegate 等) |
@ -31,14 +28,13 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
| `config` | 配置加载、环境变量替换、路径解析 |
| `memory` | 长期记忆存储与检索 |
| `mcp` | MCPModel Context Protocol工具集成 |
| `task_supervisor` | Gateway 后台任务注册、取消、限时等待和强制回收 |
## 功能边界
- Channels 仅收发消息,不感知 session 或 LLM
- MessageBus 本体持有三条有界队列;出站路由、顺序和重试由 `OutboundDispatcher` 负责
- SessionManager 拥有 session 状态、dialog 路由、上下文构建和每 session worker并通过 worker 创建 AgentLoop
- AgentLoop 跨轮无状态,接收已准备的 history 调用 LLM、执行工具并返回一次结果
- MessageBus 是纯异步队列,不路由
- SessionManager 拥有 session 状态,不直接调 LLM负责注入 skills prompt
- AgentLoop 无状态,接收 dialog 事件调用 LLM、执行工具
- Providers 是纯 HTTP 客户端,无 bus/session/channel 感知
- Tools 接收原始参数,返回字符串结果
- MCP 工具在 Gateway 初始化时连接服务器、发现工具,并包装成普通 Tool 注册到 ToolRegistry
@ -52,11 +48,6 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
- OutboundDispatcher 通过 ChannelManager 路由出站消息
- Config `.env` 加载使用 `unsafe { env::set_var(...) }`
- `browser` 工具只有在 `browser.enabled=true` 时注册,依赖 Chrome/Chromium 与 WebDriver
- 同一 session 的普通消息串行处理,不同 session 可并发session 队列容量为 32满时明确拒绝
- 出站消息按 `(channel, chat_id)` 分 lane 保序lane 容量为 64慢目标不阻塞其他目标
- 长生命周期后台任务由 TaskSupervisor 管理;连接局部任务由其 owner 限时 join 或 abort
- 外部建连、重试等待和关停 join 必须可取消且有硬超时
- 不得记录 API Key、Authorization header 或包含临时凭据的完整连接 URL
## 上下文压缩
@ -110,7 +101,7 @@ create → 存入 Storage → 载入 memory → 设为当前 dialog
| `list_dialogs` | 列出 `channel:chat_id` 下最近 10 个 session |
| `rename` | 更新标题,内存 + Storage 同步 |
| `delete` | 软删除(设 deleted_at从内存移除 |
| `archive` | 设置 archived_at从内存和当前 dialog 追踪中移除;可通过 include_archived 查询 |
| `archive` | 当前为空操作 |
### SessionManager 数据结构
@ -121,17 +112,13 @@ create → 存入 Storage → 载入 memory → 设为当前 dialog
消息到达时 `resolve_dialog_id()` 按顺序确定接收 session当前 session → Storage 最近活跃 session → 新建。
### 消息处理与并发
### 消息处理三阶段
普通消息先 `try_send` 到该 session 的有界 worker 队列Gateway 主 processor 随即返回 `AgentProcessing`。Slash command 直接执行,不进入此队列,因此 `/stop` 不会排在长模型调用后。
**阶段 1持锁**:斜杠命令检测 → 用户消息入库 → 提取记忆上下文 → 构建系统提示skills + memory_context→ 上下文压缩 → 创建 AgentLoop
Worker 的处理原则:
**阶段 2无锁**`agent.process(history)` → LLM 调用 + 工具执行。上下文溢出时自动重新压缩重试
1. 短暂持 Session 锁抓取快照并记录 `worker_generation`/`state_version`
2. 释放锁后执行消息持久化、记忆召回、上下文压缩、LLM 和工具等慢操作。
3. 提交由旧快照产生的结果前重新验证 generation/version防止 `/stop``/clear``/delete` 后写回陈旧状态。
4. Session 持久化由独立 `persistence_lock` 串行化;批量消息使用原子写入,失败时精确回滚内存后缀。
5. 上下文溢出时按 Provider 返回的真实限制重新压缩并重试。
**阶段 3持锁**:持久化 agent 响应消息 → 自动生成标题(消息数 ≥ 5 且标题为"新对话"时)
### 会话恢复
@ -149,7 +136,7 @@ Worker 的处理原则:
| 类别 | 用途 | 生命周期 | 检索方式 |
|------|------|----------|----------|
| **Knowledge** | 事实、偏好、模式、洞察 | 长期保留,手动删除 | 每轮注入系统提示,关键词匹配 |
| **Timeline** | 历史会话摘要 | 配置预期保留 90 天;当前尚无自动清理循环 | `timeline_recall` 工具按需检索 |
| **Timeline** | 历史会话摘要 | 自动清理(默认 90 天) | `timeline_recall` 工具按需检索 |
### MemoryEntry 字段
@ -176,7 +163,7 @@ Worker 的处理原则:
→ MemoryManager::recall(content, 5, Knowledge)
返回最多 5 条匹配的知识记忆(按 importance DESC
→ 格式化为 "- key: content"
作为运行时上下文附加到本轮 user message
注入系统提示的 "记忆上下文" 部分
→ LLM 可见,辅助回答
```
@ -204,12 +191,10 @@ LLM 对话上下文接近 token 限制 (默认 128K × 70%) 时自动触发压
| 时机 | 操作 |
|------|------|
| 每次消息处理 | `memory_manager.recall()` 提取 Knowledge 上下文 |
| 系统提示构建 | `MemorySection` 渲染记忆工具指南;匹配的 Knowledge 附加到本轮 user message |
| 系统提示构建 | `MemorySection` 渲染记忆指南 + 匹配的记忆 |
| 有压缩历史时 | `HistorySection` 提示 LLM 使用 `timeline_recall` |
| 压缩完成后 | 摘要自动存储为 Timeline 记忆 |
| 会话恢复 | 加载最近 Timeline 和压缩边界后的原始消息 |
`memory.recall_limit``idle_consolidation_minutes``timeline_retention_days``max_failures_before_degrade` 当前会被配置解析;其中每轮 Knowledge 召回在 worker 中仍固定为 5其余自动维护策略尚未接入运行循环。不要把“配置可解析”误认为“行为已生效”。
| 空闲时 | 可配置自动 consolidation`idle_consolidation_minutes` |
---
@ -238,23 +223,6 @@ Gateway 初始化时读取 `config.mcp.servers`
默认工具集是只读工具:`file_read``file_search``content_search``web_fetch``http_request``calculator`。调用时可通过 `allowed_tools` 显式放开其他工具。后台任务会写入 `background_tasks` 表,默认 24 小时后清理。
后台子 Agent 通过 `TaskSupervisor::spawn_graceful` 注册,受 `gateway.max_concurrent_background_tasks` 限制Gateway 关停时先收到取消信号,再在总宽限期内清理。
---
## 出站投递与关停
`OutboundDispatcher` 对每个 `(channel, chat_id)` 创建独立 lane同一目标保持顺序单次发送超时 30 秒,最多尝试 3 次。只有 `ConnectionError``SendError` 被视为瞬态错误;永久错误不重试。`deliver_outbound` 等待真实渠道投递结果,`publish_outbound` 只表示成功入队。
Gateway 关停顺序:
1. Ctrl-C 停止 Axum 接入并取消 WebSocket 连接。
2. `ChannelManager::stop_all` 停止外部渠道并注销它们。
3. 取消 TaskSupervisor并在共享的 10 秒宽限期内等待后台任务。
4. 超时后 abort 剩余任务并回收 JoinHandle。
渠道 `stop()` 也必须有界。飞书端点请求、WebSocket 建连、重试 sleep 和连接循环共享 CancellationToken另有 5 秒强制终止兜底。
---
## 当前斜杠命令

View File

@ -13,16 +13,6 @@ cargo run -- chat
# 运行单元测试
cargo test --lib
# 运行离线集成/协议测试
cargo test --test test_scheduler
cargo test --test test_request_format
# 运行代码检查Rust 修改必跑)
cargo clippy --all-targets --all-features -- -D warnings
# 运行模型 API 集成测试(需配置 tests/test.env
# 运行集成测试 (需配置 tests/test.env)
cargo test --test test_integration -- --ignored
cargo test --test test_tool_calling -- --ignored
```
`test_scheduler``test_request_format` 不需要 API Key也没有标记 `#[ignore]`。只有会真实调用 Provider 的测试需要从 `tests/test.env.example` 创建 `tests/test.env` 后使用 `-- --ignored`

View File

@ -55,9 +55,9 @@
|------|------|------|------|
| `host` | string | 127.0.0.1 | 监听地址 |
| `port` | int | 19876 | 监听端口 |
| `session_ttl_hours` | int | - | 兼容/预留字段;当前没有会话 TTL 清理循环 |
| `session_ttl_hours` | int | - | 会话过期小时数 |
| `session_db_path` | string | - | SQLite 数据库路径,默认在 workspace 下 |
| `cleanup_interval_minutes` | int | - | 兼容/预留字段;当前没有按此间隔运行的 session 清理任务 |
| `cleanup_interval_minutes` | int | - | 清理间隔 |
| `max_concurrent_background_tasks` | int | 10 | delegate 后台子任务最大并发数 |
| `scheduler` | object | - | 调度器配置 |
@ -67,21 +67,18 @@
|------|------|------|------|
| `enabled` | bool | true | 是否启动调度器并注册 cron 工具 |
| `poll_interval_secs` | int | 60 | 检查到期任务的轮询间隔 |
| `max_concurrent` | int | 1 | 每批到期任务的最大并发数,运行时限制在 1256 |
| `execution_timeout_secs` | int | 900 | 单个定时任务 Agent 执行的硬超时;租约会额外增加 30 秒 |
| `max_concurrent` | int | 1 | 最大并发任务数,当前实现预留 |
## memory 字段
| 字段 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `consolidation_provider` | string | 主 Agent provider | 当前记录在 MemoryManager 中供后续归并使用;压缩摘要仍使用 Session provider |
| `consolidation_model` | string | 主 Agent model | 当前记录在 MemoryManager 中供后续归并使用;压缩摘要仍使用 Session model |
| `recall_limit` | int | 5 | 预期的每轮知识召回上限;当前 worker 固定使用 5 |
| `idle_consolidation_minutes` | int | 10 | 预留的空闲归并阈值;当前无对应循环 |
| `timeline_retention_days` | int | 90 | 预留的 Timeline 保留期;当前无自动清理循环 |
| `max_failures_before_degrade` | int | 3 | 预留的归并失败阈值;当前无失败降级循环 |
注意:这些字段都会被解析,但当前 worker 的 Knowledge 召回数量仍固定为 5idle consolidation、Timeline 自动清理和失败降级循环尚未接入。配置存在不等于对应后台行为已经生效。
| `consolidation_provider` | string | - | 记忆归并 LLM 提供商 |
| `consolidation_model` | string | - | 记忆归并 LLM 模型 |
| `recall_limit` | int | 5 | 每轮注入的知识记忆条数 |
| `idle_consolidation_minutes` | int | 10 | 空闲后触发归并的分钟数 |
| `timeline_retention_days` | int | 90 | 时间线记忆保留天数 |
| `max_failures_before_degrade` | int | 3 | 归并失败次数阈值 |
## channels.feishu 字段
@ -92,7 +89,7 @@
| `app_secret` | string | - | 飞书应用密钥 |
| `allow_from` | []string | ["*"] | 允许交互的用户列表 |
| `agent` | string | - | 使用的 agent 名称 |
| `media_dir` | string | ~/.picobot/media/feishu | 配置默认值Gateway 注册渠道时会覆盖为 `{workspace}/media/feishu` |
| `media_dir` | string | ~/.picobot/media/feishu | 媒体存储目录 |
| `reaction_emoji` | string | "Typing" | 回复意向表达的表情 |
## mcp 字段

View File

@ -2,8 +2,6 @@
数据库为 SQLite默认位于 workspace 下的 `picobot.db`
连接启用 WAL、`synchronous=NORMAL`、foreign keys、5 秒 busy timeout连接池最多 8 个连接。当前 `PRAGMA user_version=1`;启动时会在事务内补齐旧库字段和索引,遇到比程序更新的 schema version 会拒绝启动。
## sessions 表
会话表,一个 session 对应一个 (channel, chat_id, dialog_id) 组合。
@ -15,16 +13,13 @@
| `chat_id` | TEXT | 聊天/群组标识 |
| `dialog_id` | TEXT | 对话标识 |
| `title` | TEXT | 会话标题(默认 "新对话" |
| `created_at` | INTEGER | 创建时间(Unix 毫秒) |
| `last_active_at` | INTEGER | 最后活跃时间Unix 毫秒) |
| `created_at` | INTEGER | 创建时间(unix 秒) |
| `last_active_at` | INTEGER | 最后活跃时间 |
| `message_count` | INTEGER | 消息计数 |
| `routing_info` | TEXT | 路由信息 |
| `archived_at` | INTEGER | 归档时间Unix 毫秒NULL 表示未归档 |
| `deleted_at` | INTEGER | 软删除时间戳 |
| `last_consolidated_at` | INTEGER | 上次记忆归并时间 |
| `last_compressed_message_at` | INTEGER | 上次上下文压缩边界时间戳 |
`(channel, chat_id, dialog_id)` 唯一。普通列表排除 `deleted_at`;是否包含归档记录由查询参数决定。
| `last_compressed_message_at` | INTEGER | 上次上下文压缩消息序号 |
## messages 表
@ -40,11 +35,9 @@
| `tool_name` | TEXT | 工具名称 |
| `tool_calls` | TEXT | 工具调用参数 JSON |
| `source` | TEXT | 消息来源(跨会话消息时标记来源 session_id |
| `created_at` | INTEGER | 创建时间(Unix 毫秒) |
| `created_at` | INTEGER | 创建时间(unix 秒) |
| `reasoning_content` | TEXT | provider 返回的推理内容(如有) |
`(session_id, seq)` 有唯一索引,防止并发写入重复序号。删除 session 会通过外键级联删除 messages。
## background_tasks 表
delegate 后台子任务表。`session_id` 不使用数据库外键,因为 session 使用软删除,关联关系由应用层维护。
@ -89,24 +82,17 @@ delegate 后台子任务表。`session_id` 不使用数据库外键,因为 ses
|------|------|------|
| `id` | TEXT PK | 任务 UUID |
| `name` | TEXT | 任务名称 |
| `schedule` | TEXT | 调度规则 JSONat/every/cron |
| `schedule` | TEXT | 调度规则 JSONonce/every/cron |
| `prompt` | TEXT | 任务提示词 |
| `channel` | TEXT | 执行渠道 |
| `chat_id` | TEXT | 目标对话 |
| `model` | TEXT | 可选模型标记;当前会存储/展示,但 Scheduler 执行仍使用默认 Agent 模型 |
| `model` | TEXT | 使用的模型(可选) |
| `enabled` | INTEGER | 是否启用 (1/0) |
| `delete_after_run` | INTEGER | 执行后自动删除 (1/0) |
| `next_run_at` | INTEGER | 下次执行时间 |
| `last_run_at` | INTEGER | 上次执行时间 |
| `last_status` | TEXT | 上次执行状态 |
| `last_error` | TEXT | 上次错误信息 |
| `locked_at` | INTEGER | 本次领取时间 |
| `lock_owner` | TEXT | 领取任务的 Scheduler owner UUID |
| `lease_until` | INTEGER | 租约到期时间;进程崩溃后允许其他实例重新领取 |
| `created_at` | INTEGER | 创建时间Unix 毫秒) |
| `updated_at` | INTEGER | 更新时间Unix 毫秒) |
Scheduler 使用原子 `UPDATE ... RETURNING` 领取到期任务。任务结果、下次运行时间和租约释放在同一事务中提交,并校验 owner防止过期 worker 覆盖已恢复的任务。
## job_runs 表
@ -135,5 +121,3 @@ Scheduler 使用原子 `UPDATE ... RETURNING` 领取到期任务。任务结果
| `response_body` | TEXT | 响应体 JSON |
| `error` | TEXT | 错误信息 |
| `duration_ms` | INTEGER | 耗时(毫秒) |
`request_body`/`response_body` 可能包含用户内容,排障和导出数据库时应按敏感数据处理。

View File

@ -20,8 +20,6 @@
`~/.picobot/skills/about-picobot/`SKILL.md 为索引references/ 下为各详细文档assets/ 下为 config 示例。如被删除,重启程序自动重新安装。
内置 Skill 只在目标目录不存在时释放,不会覆盖已安装目录。升级 PicoBot 后如需获取新版内置文档,应先备份自己的修改,再删除旧的 `~/.picobot/skills/about-picobot/` 并重启。也可把定制版放在 `{workspace}/skills/about-picobot/`,它的优先级更高。
## Q: 数据库文件在哪里?
默认 `{workspace}/picobot.db`workspace 默认 `~/.picobot/workspace/`
@ -32,7 +30,7 @@
## Q: 如何创建定时任务?
使用 `cron_add` 创建,`cron_list` 查看,`cron_update``cron_enable``cron_disable``cron_remove` 管理。Schedule 类型为 `at`Unix 毫秒时间戳)、`every`(毫秒间隔)或 6 段 `cron` 表达式,可指定 IANA 时区
使用 `cron` 工具,支持一次性 (`once`)、周期性 (`every`) 和 cron 表达式调度
## Q: 上下文压缩是什么意思?
@ -45,13 +43,3 @@
## Q: 如何查看 LLM 调用日志?
LLM 调用记录存储在 `llm_calls` 表中。可通过 SQLite 客户端直接查询,或在代码中通过 storage 模块访问。
该表可能包含完整用户消息、工具参数和模型响应,按敏感数据处理;不要直接上传或粘贴到公开 issue。
## Q: 为什么修改了某些 memory 配置却没有看到行为变化?
当前 `recall_limit``idle_consolidation_minutes``timeline_retention_days``max_failures_before_degrade` 都能被配置解析,但每轮 Knowledge 召回仍固定为 5自动 idle consolidation、Timeline 清理和失败降级循环尚未接入。以当前代码行为为准。
## Q: Gateway 为什么无法立即退出?
正常关停会先停止渠道,再取消受 TaskSupervisor 管理的后台任务,并最多等待 10 秒。飞书自身另有 5 秒连接任务兜底。如果持续超过这些上限,应检查是否新增了未受监督的 `tokio::spawn`、不可取消的外部 I/O或没有 timeout 的 JoinHandle 等待。

View File

@ -12,8 +12,16 @@
| `content` | 是 | 消息文本内容 |
| `files` | 否 | 文件路径列表 |
| `origin` | 否 | 消息来源标识,不填则自动使用当前 session_id |
| `file_types` | 否 | 指定文件发送类型,`{"路径": "audio"|"file"}`。未指定则自动判断 |
`files` 支持绝对路径和 workspace 相对路径,媒体类型由文件扩展名/MIME 自动判断。目前 schema 不支持手工指定 `file_types`
### file_types 说明
控制文件以何种消息类型发送,主要用于飞书渠道:
- `"audio"`:作为语音消息发送(仅 opus 格式支持)
- `"file"`:作为文件附件发送
飞书渠道限制上传类型和消息类型必须一致。opus 文件以 `"audio"` 发送其他音频mp3、wav 等)只能以 `"file"` 发送。
### 示例
@ -21,12 +29,11 @@
{
"target_chat_id": "feishu:oc_abc123",
"content": "这是生成的音乐文件",
"files": ["/workspace/music.mp3"]
"files": ["/workspace/music.mp3"],
"file_types": {"/workspace/music.mp3": "file"}
}
```
发送流程会先把消息写入目标 Session再调用 `MessageBus::deliver_outbound` 等待真实渠道投递结果(上限 120 秒)。投递失败会作为工具失败返回,不应把“已入队”报告成“已送达”。
---
## chat_manager — 会话管理
@ -46,28 +53,20 @@
---
## Cron 定时任务工具
## cron — 定时任务管理
Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅在 `gateway.scheduler.enabled=true` 时注册
管理 cron 定时任务
| 工具 | 主要参数 | 说明 |
|------|----------|------|
| `cron_add` | `schedule`, `prompt`, `channel`, `chat_id`; 可选 `name`, `model` | 创建任务 |
| `cron_list` | 可选 `status=all|enabled|disabled` | 列出任务 |
| `cron_update` | `job_id`; 可选 `prompt`, `schedule`, `channel`, `chat_id`, `model` | 更新指定字段 |
| `cron_remove` | `job_id` | 永久删除任务和关联 job runs |
| `cron_enable` | `job_id` | 启用并重新计算下次运行时间 |
| `cron_disable` | `job_id` | 禁用但保留任务 |
### 参数
`schedule` 支持:
```json
{"type":"at","at":1750000000000}
{"type":"every","every_ms":3600000}
{"type":"cron","expr":"0 0 9 * * *","tz":"Asia/Shanghai"}
```
时间戳和间隔单位为毫秒Cron 表达式为 6 段(秒、分、时、日、月、周)。定时 Agent 不复用聊天历史,`prompt` 必须包含完整上下文,并由 Agent 使用 `send_message` 投递结果。`model` 当前会持久化和展示,但执行仍使用默认 Agent Provider/Model不能依赖它实现模型覆盖。
| 参数 | 必填 | 说明 |
|------|------|------|
| `action` | 是 | 操作: `add`, `list`, `update`, `remove`, `enable`, `disable` |
| `name` | add必须 | 任务名称 |
| `schedule` | add需要 | 调度规则: `once`(时间戳), `every`(间隔秒), `cron`(表达式) |
| `prompt` | add必须 | 任务提示词 |
| `channel` | add必须 | 执行渠道 |
| `chat_id` | add必须 | 目标对话 |
---
@ -169,19 +168,15 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅
## file_read / file_write / file_edit / file_search / content_search — 文件操作和搜索
文件读写编辑、文件名搜索和内容搜索。相对路径从 workspace cwd 解析;默认注册的文件工具也接受绝对路径,因此 workspace 不是硬沙箱。详细参数以各工具的 `parameters_schema` 为准
工作目录内的文件读写编辑、文件名搜索和内容搜索。详细的参数定义见各工具的 parameters_schema
## bash — 执行命令
默认从 workspace cwd 执行 bash 命令。参数为 `command` 和可选 `timeout`;默认 60 秒、最大 600 秒,输出最多约 50,000 字符。命令继承 Gateway 进程权限,可以访问 workspace 外路径;危险模式拦截不是完整沙箱。
## pty — 持久终端会话
用于交互式程序和需要保持状态的长运行命令。`action` 支持 `spawn``write``read``kill``list``write/read/kill` 需要 `session_id`。Gateway 进程退出时 PTY manager 会清理子进程。
在本地环境执行 bash 命令,有超时限制和安全检查(阻止 rm -rf /、fork bomb 等)。
## http_request / web_fetch — HTTP 和 Web 工具
`http_request` 支持 GET/POST/PUT/DELETE/PATCH、headers 和字符串 body`web_fetch` 提取 HTML/JSON 的可读文本。两者校验 URL 与 DNS 解析结果阻止回环、私网、link-local 和本地域名,并禁用自动重定向,以降低 SSRF 风险
发送 HTTP 请求和获取网页内容,有 URL 安全校验(阻止内网/本地访问)。
## calculator — 计算器

View File

@ -362,16 +362,16 @@ impl AgentLoop {
let end = messages.len().saturating_sub(keep_recent);
let start = 1; // protect system message at [0] if present
let mut modified = 0;
for message in messages.iter_mut().take(end).skip(start) {
if message.role != "tool" {
for i in start..end {
if messages[i].role != "tool" {
continue;
}
if message.content.len() <= max_chars {
if messages[i].content.len() <= max_chars {
continue;
}
let tool_name = message.tool_name.as_deref().unwrap_or("unknown");
let chars = message.content.len();
message.content = format!(
let tool_name = messages[i].tool_name.as_deref().unwrap_or("unknown");
let chars = messages[i].content.len();
messages[i].content = format!(
"[Tool output ({}) — {} chars, omitted from context]",
tool_name, chars
);
@ -810,14 +810,14 @@ mod tests {
fn test_should_execute_in_parallel_single_tool() {
// Would need a proper setup with AgentLoop to test fully
// For now, just verify the logic: single tool should return false
let calls = [ToolCall {
let calls = vec![ToolCall {
id: "1".to_string(),
name: "test".to_string(),
arguments: serde_json::json!({}),
}];
// If there's only 1 tool, should return false regardless
assert!(calls.len() <= 1);
assert_eq!(calls.len() <= 1, true);
}
#[test]

View File

@ -403,17 +403,18 @@ impl ContextCompressor {
// Strip tool_calls from any assistant in the head whose results
// were dropped (previously in the middle section).
for msg in &mut truncated[..self.config.protect_first_n] {
if msg.role == "assistant"
&& let Some(ref tcs) = msg.tool_calls
&& !tcs.is_empty()
{
let names: Vec<&str> = tcs.iter().map(|tc| tc.name.as_str()).collect();
msg.content = format!(
"{}\n\n[Tool calls ({}) — results dropped during truncation]",
msg.content,
names.join(", ")
);
msg.tool_calls = None;
if msg.role == "assistant" {
if let Some(ref tcs) = msg.tool_calls
&& !tcs.is_empty()
{
let names: Vec<&str> = tcs.iter().map(|tc| tc.name.as_str()).collect();
msg.content = format!(
"{}\n\n[Tool calls ({}) — results dropped during truncation]",
msg.content,
names.join(", ")
);
msg.tool_calls = None;
}
}
}
@ -536,19 +537,22 @@ impl ContextCompressor {
summary
);
let key = format!("ctx_compressed_{}", uuid::Uuid::new_v4());
if let Err(e) = self
.memory
.store(
&key,
&timeline_content,
crate::memory::MemoryCategory::Timeline,
self.session_id.as_deref(),
Some(0.3),
)
.await
{
tracing::warn!(error = %e, "Failed to store compressed context as timeline");
}
let mm = self.memory.clone();
let sid = self.session_id.clone();
tokio::spawn(async move {
if let Err(e) = mm
.store(
&key,
&timeline_content,
crate::memory::MemoryCategory::Timeline,
sid.as_deref(),
Some(0.3),
)
.await
{
tracing::warn!(error = %e, "Failed to store compressed context as timeline");
}
});
// Add summary as a special user message
new_messages.push(ChatMessage::user(format!(
@ -560,7 +564,9 @@ impl ContextCompressor {
// Add last user and everything after (protected)
let last_user_idx = user_indices[user_indices.len() - 1];
new_messages.extend_from_slice(&history[last_user_idx..]);
for i in last_user_idx..history.len() {
new_messages.push(history[i].clone());
}
// Remove orphan tool results whose declaring tool_calls were compressed away
Self::repair_tool_pairs(&mut new_messages);
@ -780,7 +786,7 @@ mod tests {
let mut messages = vec![
ChatMessage::user("Hello"),
ChatMessage::tool("call1", "bash", "x".repeat(200)),
ChatMessage::tool("call1", "bash", &"x".repeat(200)),
];
let modified = compressor.fast_trim_tool_results(&mut messages, 2);
@ -814,7 +820,7 @@ mod tests {
let messages = vec![
ChatMessage::user("Hi"),
ChatMessage::tool("call1", "bash", "x".repeat(3000)),
ChatMessage::tool("call1", "bash", &"x".repeat(3000)),
];
let result = compressor

View File

@ -67,12 +67,6 @@ pub struct MediaHandlerRegistry {
handlers: HashMap<String, Box<dyn MediaHandler>>,
}
impl Default for MediaHandlerRegistry {
fn default() -> Self {
Self::new()
}
}
impl MediaHandlerRegistry {
pub fn new() -> Self {
Self {

View File

@ -3,7 +3,6 @@ use std::sync::Arc;
use std::time::Instant;
use dashmap::DashMap;
use tokio::sync::Semaphore;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
@ -118,11 +117,9 @@ pub struct SubAgentManager {
full_tools: Arc<ToolRegistry>,
storage: Option<Arc<crate::storage::Storage>>,
active_tasks: Arc<DashMap<String, CancellationToken>>,
background_permits: Arc<Semaphore>,
notify_tx: tokio::sync::mpsc::UnboundedSender<TaskNotification>,
max_concurrent_background_tasks: usize,
skills_loader: Option<Arc<SkillsLoader>>,
task_supervisor: crate::task_supervisor::TaskSupervisor,
}
impl SubAgentManager {
@ -133,18 +130,15 @@ impl SubAgentManager {
notify_tx: tokio::sync::mpsc::UnboundedSender<TaskNotification>,
max_concurrent_background_tasks: usize,
skills_loader: Option<Arc<SkillsLoader>>,
task_supervisor: crate::task_supervisor::TaskSupervisor,
) -> Self {
Self {
provider_config,
full_tools,
storage,
active_tasks: Arc::new(DashMap::new()),
background_permits: Arc::new(Semaphore::new(max_concurrent_background_tasks)),
notify_tx,
max_concurrent_background_tasks,
skills_loader,
task_supervisor,
}
}
@ -164,10 +158,12 @@ impl SubAgentManager {
fn get_skills_prompt(&self, tools: &ToolRegistry) -> Option<String> {
let has_get_skill = tools.iter().iter().any(|(name, _)| name == "get_skill");
if has_get_skill && let Some(ref loader) = self.skills_loader {
let prompt = loader.build_skills_prompt();
if !prompt.is_empty() {
return Some(prompt);
if has_get_skill {
if let Some(ref loader) = self.skills_loader {
let prompt = loader.build_skills_prompt();
if !prompt.is_empty() {
return Some(prompt);
}
}
}
None
@ -304,7 +300,7 @@ impl SubAgentManager {
.collect();
let results = futures_util::future::join_all(futures).await;
results.into_iter().collect::<Result<Vec<_>, _>>()
Ok(results.into_iter().collect::<Result<Vec<_>, _>>()?)
}
pub async fn run_background(
@ -312,11 +308,11 @@ impl SubAgentManager {
config: SubAgentConfig,
ctx: DelegateContext,
) -> Result<String, SubAgentError> {
let permit = self
.background_permits
.clone()
.try_acquire_owned()
.map_err(|_| SubAgentError::TooManyTasks(self.max_concurrent_background_tasks))?;
if self.active_tasks.len() >= self.max_concurrent_background_tasks {
return Err(SubAgentError::TooManyTasks(
self.max_concurrent_background_tasks,
));
}
let task_id = generate_task_id();
let cancel_token = CancellationToken::new();
@ -374,7 +370,6 @@ impl SubAgentManager {
let storage = self.storage.clone();
let notify_tx = self.notify_tx.clone();
let active_tasks = Arc::clone(&self.active_tasks);
let shutdown = self.task_supervisor.cancellation_token();
let tid = task_id.clone();
let sess_id = ctx.session_id.clone();
@ -382,34 +377,30 @@ impl SubAgentManager {
let cid = ctx.chat_id.clone();
let prompt = config.prompt.clone();
let spawned = self.task_supervisor.spawn_graceful(
format!("sub-agent:{task_id}"),
async move {
let _permit = permit;
tokio::spawn(async move {
let started_at = chrono::Utc::now().timestamp_millis();
// Update DB: running
if let Some(ref s) = storage {
let _ = s
.update_background_task_status(&tid, crate::storage::background_task::BackgroundTaskUpdate {
status: "running",
result: None,
error: None,
started_at: Some(started_at),
finished_at: None,
tool_calls_count: None,
iterations: None,
})
.update_background_task_status(
&tid,
"running",
None,
None,
Some(started_at),
None,
)
.await;
}
let mut provider = create_provider(provider_config.clone()).ok();
if let Some(ref mut p) = provider
&& let Some(ref s) = storage
{
p.set_storage(s.clone());
if let Some(ref mut p) = provider {
if let Some(ref s) = storage {
p.set_storage(s.clone());
}
}
let provider_result: Option<Arc<dyn LLMProvider>> = provider.map(Arc::from);
let provider_result: Option<Arc<dyn LLMProvider>> = provider.map(|p| Arc::from(p));
let result = match provider_result {
Some(provider) => {
@ -434,20 +425,14 @@ impl SubAgentManager {
agent.process(history),
) => {
match r {
Ok(Ok(agent_result)) => {
let tool_calls_count = agent_result.emitted_messages
.iter().filter(|m| m.tool_calls.is_some()).count();
let iterations = agent_result.emitted_messages
.iter().filter(|m| m.role == "assistant" && m.tool_calls.is_some()).count();
SubAgentResult {
task_id: tid.clone(),
content: agent_result.final_response.content,
content_truncated: false,
status: TaskStatus::Completed,
tool_calls_count,
iterations,
duration_ms: 0,
}
Ok(Ok(agent_result)) => SubAgentResult {
task_id: tid.clone(),
content: agent_result.final_response.content,
content_truncated: false,
status: TaskStatus::Completed,
tool_calls_count: 0,
iterations: 0,
duration_ms: 0,
},
Ok(Err(e)) => SubAgentResult {
task_id: tid.clone(),
@ -478,15 +463,6 @@ impl SubAgentManager {
iterations: 0,
duration_ms: 0,
},
_ = shutdown.cancelled() => SubAgentResult {
task_id: tid.clone(),
content: String::new(),
content_truncated: false,
status: TaskStatus::Cancelled,
tool_calls_count: 0,
iterations: 0,
duration_ms: 0,
},
}
}
None => SubAgentResult {
@ -512,15 +488,14 @@ impl SubAgentManager {
if let Some(ref s) = storage {
let _ = s
.update_background_task_status(&tid, crate::storage::background_task::BackgroundTaskUpdate {
status: &status_str,
result: Some(&result.content),
error: error_val.as_deref(),
started_at: Some(started_at),
finished_at: Some(finished_at),
tool_calls_count: Some(result.tool_calls_count as i64),
iterations: Some(result.iterations as i64),
})
.update_background_task_status(
&tid,
&status_str,
Some(&result.content),
error_val.as_deref(),
Some(started_at),
Some(finished_at),
)
.await;
}
@ -536,29 +511,6 @@ impl SubAgentManager {
active_tasks.remove(&tid);
});
if !spawned {
self.active_tasks.remove(&task_id);
if let Some(ref storage) = self.storage {
let _ = storage
.update_background_task_status(
&task_id,
crate::storage::background_task::BackgroundTaskUpdate {
status: "cancelled",
result: None,
error: Some("gateway shutdown"),
started_at: None,
finished_at: Some(chrono::Utc::now().timestamp_millis()),
tool_calls_count: None,
iterations: None,
},
)
.await;
}
return Err(SubAgentError::Other(
"gateway is shutting down and cannot accept background tasks".to_string(),
));
}
Ok(task_id)
}
@ -568,15 +520,11 @@ impl SubAgentManager {
if let Some(ref s) = self.storage {
s.update_background_task_status(
task_id,
crate::storage::background_task::BackgroundTaskUpdate {
status: "cancelled",
result: None,
error: None,
started_at: None,
finished_at: Some(chrono::Utc::now().timestamp_millis()),
tool_calls_count: None,
iterations: None,
},
"cancelled",
None,
None,
None,
Some(chrono::Utc::now().timestamp_millis()),
)
.await
.map_err(|e| SubAgentError::Storage(e.to_string()))?;
@ -618,12 +566,12 @@ impl SubAgentManager {
pub async fn cancel_by_session(&self, session_id: &str) {
// Cancel all running tasks for a session by checking DB
if let Some(ref s) = self.storage
&& let Ok(tasks) = s.list_background_tasks(session_id).await
{
for task in &tasks {
if task.status == "pending" || task.status == "running" {
let _ = self.cancel_task(&task.id).await;
if let Some(ref s) = self.storage {
if let Ok(tasks) = s.list_background_tasks(session_id).await {
for task in &tasks {
if task.status == "pending" || task.status == "running" {
let _ = self.cancel_task(&task.id).await;
}
}
}
}
@ -673,65 +621,3 @@ fn summarize_for_notification(content: &str, _duration_ms: u64) -> String {
format!("{}...", &content[..truncate_at])
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn manager(max_tasks: usize) -> SubAgentManager {
let (notify_tx, _notify_rx) = tokio::sync::mpsc::unbounded_channel();
SubAgentManager::new(
LLMProviderConfig {
provider_type: "openai".into(),
name: "test".into(),
base_url: "http://localhost".into(),
api_key: "test".into(),
extra_headers: HashMap::new(),
model_id: "test".into(),
temperature: None,
max_tokens: None,
model_extra: HashMap::new(),
max_tool_iterations: 1,
token_limit: 4096,
workspace_dir: std::env::temp_dir(),
input_types: vec!["text".into()],
},
Arc::new(ToolRegistry::new()),
None,
notify_tx,
max_tasks,
None,
crate::task_supervisor::TaskSupervisor::new(),
)
}
#[tokio::test]
async fn background_limit_is_enforced_by_atomic_permit() {
let manager = manager(1);
let _permit = manager
.background_permits
.clone()
.try_acquire_owned()
.unwrap();
let error = manager
.run_background(
SubAgentConfig {
prompt: "test".into(),
mode: ExecutionMode::Background,
allowed_tools: None,
max_iterations: None,
timeout_secs: Some(1),
},
DelegateContext {
session_id: "cli:test:dialog".into(),
channel: "cli".into(),
chat_id: "test".into(),
},
)
.await
.unwrap_err();
assert!(matches!(error, SubAgentError::TooManyTasks(1)));
}
}

View File

@ -1,44 +1,27 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::mpsc;
use crate::bus::{MessageBus, OutboundMessage};
use crate::channels::ChannelManager;
use crate::channels::base::{Channel, ChannelError};
use crate::task_supervisor::TaskSupervisor;
const LANE_CAPACITY: usize = 64;
const LANE_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
const SEND_TIMEOUT: Duration = Duration::from_secs(30);
/// Dispatches outbound messages through independent per-conversation lanes.
/// Messages to the same channel/chat remain ordered, while a slow destination
/// cannot block delivery to unrelated conversations.
/// OutboundDispatcher consumes outbound messages from the MessageBus
/// and dispatches them to the appropriate Channel
pub struct OutboundDispatcher {
bus: Arc<MessageBus>,
channel_manager: ChannelManager,
task_supervisor: TaskSupervisor,
}
impl OutboundDispatcher {
pub fn new(
bus: Arc<MessageBus>,
channel_manager: ChannelManager,
task_supervisor: TaskSupervisor,
) -> Self {
pub fn new(bus: Arc<MessageBus>, channel_manager: ChannelManager) -> Self {
Self {
bus,
channel_manager,
task_supervisor,
}
}
/// Run the dispatcher loop - consumes from bus and dispatches to channels
pub async fn run(&self) {
tracing::info!(lane_capacity = LANE_CAPACITY, "OutboundDispatcher started");
let mut lanes: HashMap<String, mpsc::Sender<OutboundMessage>> = HashMap::new();
let mut messages_seen = 0_u64;
tracing::info!("OutboundDispatcher started");
loop {
let Some(msg) = self.bus.consume_outbound().await else {
@ -46,309 +29,46 @@ impl OutboundDispatcher {
break;
};
messages_seen = messages_seen.wrapping_add(1);
if messages_seen.is_multiple_of(128) {
lanes.retain(|_, sender| !sender.is_closed());
}
let channel_name = msg.channel.clone();
let channel = self.channel_manager.get_channel(&channel_name).await;
let lane_key = format!("{}\0{}", msg.channel, msg.chat_id);
let mut sender = lanes.get(&lane_key).cloned();
if sender.as_ref().is_none_or(mpsc::Sender::is_closed) {
let Some(channel) = self.channel_manager.get_channel(&msg.channel).await else {
tracing::warn!(channel = %msg.channel, "No channel found for message");
msg.complete_delivery(Err(format!("channel not found: {}", msg.channel)));
continue;
};
let (new_sender, receiver) = mpsc::channel(LANE_CAPACITY);
if !self.spawn_lane(channel, receiver, msg.channel.clone(), msg.chat_id.clone()) {
msg.complete_delivery(Err("dispatcher is shutting down".to_string()));
continue;
}
lanes.insert(lane_key.clone(), new_sender.clone());
sender = Some(new_sender);
}
let Some(sender) = sender else {
tracing::error!("Outbound lane creation did not produce a sender");
continue;
};
match sender.try_send(msg) {
Ok(()) => {}
Err(mpsc::error::TrySendError::Full(msg)) => {
tracing::error!(
channel = %msg.channel,
chat_id = %msg.chat_id,
capacity = LANE_CAPACITY,
"Outbound lane full; rejecting message instead of blocking other destinations"
);
msg.complete_delivery(Err("outbound lane is full".to_string()));
}
Err(mpsc::error::TrySendError::Closed(msg)) => {
// The lane may have expired between the closed check and
// enqueue. Recreate it once and preserve this message.
lanes.remove(&lane_key);
let Some(channel) = self.channel_manager.get_channel(&msg.channel).await else {
tracing::warn!(channel = %msg.channel, "No channel found for message");
msg.complete_delivery(Err(format!("channel not found: {}", msg.channel)));
continue;
};
let (new_sender, receiver) = mpsc::channel(LANE_CAPACITY);
if !self.spawn_lane(channel, receiver, msg.channel.clone(), msg.chat_id.clone())
{
msg.complete_delivery(Err("dispatcher is shutting down".to_string()));
continue;
}
match new_sender.try_send(msg) {
Ok(()) => {
lanes.insert(lane_key, new_sender);
}
Err(error) => {
error.into_inner().complete_delivery(Err(
"outbound lane could not be restarted during shutdown".to_string(),
));
}
match channel {
Some(ch) => {
if let Err(e) = self.send_with_retry(&*ch, msg).await {
tracing::error!(channel = %channel_name, error = %e, "Failed to send message after retries");
}
}
None => {
tracing::warn!(channel = %channel_name, "No channel found for message");
}
}
}
}
fn spawn_lane(
&self,
channel: Arc<dyn Channel + Send + Sync>,
mut receiver: mpsc::Receiver<OutboundMessage>,
channel_name: String,
chat_id: String,
) -> bool {
self.task_supervisor.spawn(
format!("outbound-lane:{channel_name}:{chat_id}"),
async move {
loop {
let msg = match tokio::time::timeout(LANE_IDLE_TIMEOUT, receiver.recv()).await {
Ok(Some(msg)) => msg,
Ok(None) | Err(_) => break,
};
let result = Self::send_with_retry(&*channel, &msg).await;
if let Err(error) = &result {
tracing::error!(
channel = %channel_name,
chat_id = %chat_id,
error = %error,
"Failed to send message after retries"
);
}
msg.complete_delivery(result.map_err(|error| error.to_string()));
}
},
)
}
/// Send a message with exponential retry
async fn send_with_retry(
&self,
channel: &dyn Channel,
msg: &OutboundMessage,
msg: OutboundMessage,
) -> Result<(), ChannelError> {
const DELAYS: &[u64] = &[1, 2, 4];
for (attempt, &delay) in DELAYS.iter().enumerate() {
let result = tokio::time::timeout(SEND_TIMEOUT, channel.send(msg.clone())).await;
match result {
Ok(Ok(())) => return Ok(()),
Ok(Err(error)) if attempt < DELAYS.len() - 1 && error.is_transient() => {
tracing::warn!(attempt = attempt + 1, delay, error = %error, "Send failed, retrying");
}
Ok(Err(error)) => return Err(error),
Err(_) if attempt < DELAYS.len() - 1 => {
tracing::warn!(attempt = attempt + 1, delay, "Send timed out, retrying");
}
Err(_) => {
return Err(ChannelError::Other(format!(
"send timed out after {} seconds",
SEND_TIMEOUT.as_secs()
)));
for (i, &delay) in DELAYS.iter().enumerate() {
match channel.send(msg.clone()).await {
Ok(()) => return Ok(()),
Err(e) if i < DELAYS.len() - 1 => {
tracing::warn!(
attempt = i + 1,
delay = delay,
error = %e,
"Send failed, retrying"
);
tokio::time::sleep(tokio::time::Duration::from_secs(delay)).await;
}
Err(e) => return Err(e),
}
tokio::time::sleep(Duration::from_secs(delay)).await;
}
unreachable!()
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::{Mutex, Notify};
struct RecordingChannel {
sent: Mutex<Vec<String>>,
notify: Notify,
}
struct PermanentFailureChannel {
attempts: AtomicUsize,
}
#[async_trait]
impl Channel for PermanentFailureChannel {
fn name(&self) -> &str {
"permanent-failure"
}
fn is_running(&self) -> bool {
true
}
async fn start(&self, _bus: Arc<MessageBus>) -> Result<(), ChannelError> {
Ok(())
}
async fn stop(&self) -> Result<(), ChannelError> {
Ok(())
}
async fn send(&self, _msg: OutboundMessage) -> Result<(), ChannelError> {
self.attempts.fetch_add(1, Ordering::SeqCst);
Err(ChannelError::Other("invalid destination".to_string()))
}
}
#[async_trait]
impl Channel for RecordingChannel {
fn name(&self) -> &str {
"recording"
}
fn is_running(&self) -> bool {
true
}
async fn start(&self, _bus: Arc<MessageBus>) -> Result<(), ChannelError> {
Ok(())
}
async fn stop(&self) -> Result<(), ChannelError> {
Ok(())
}
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
if msg.chat_id == "slow" {
tokio::time::sleep(Duration::from_millis(50)).await;
}
self.sent.lock().await.push(msg.content);
self.notify.notify_waiters();
Ok(())
}
}
fn outbound(chat_id: &str, content: &str) -> OutboundMessage {
OutboundMessage {
channel: "recording".to_string(),
chat_id: chat_id.to_string(),
content: content.to_string(),
reply_to: None,
media: vec![],
metadata: HashMap::new(),
delivery: None,
}
}
#[tokio::test]
async fn slow_conversation_does_not_block_other_conversations() {
let bus = MessageBus::new(8);
let manager = ChannelManager::with_bus(
Arc::new(crate::channels::CliChatChannel::new()),
bus.clone(),
);
let channel = Arc::new(RecordingChannel {
sent: Mutex::new(Vec::new()),
notify: Notify::new(),
});
manager.register_channel("recording", channel.clone()).await;
let supervisor = TaskSupervisor::new();
let dispatcher = OutboundDispatcher::new(bus.clone(), manager, supervisor.clone());
let task = tokio::spawn(async move { dispatcher.run().await });
bus.publish_outbound(outbound("slow", "slow-1"))
.await
.unwrap();
bus.publish_outbound(outbound("slow", "slow-2"))
.await
.unwrap();
bus.publish_outbound(outbound("fast", "fast-1"))
.await
.unwrap();
tokio::time::timeout(Duration::from_secs(1), async {
loop {
if channel.sent.lock().await.len() == 3 {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
})
.await
.unwrap();
let sent = channel.sent.lock().await.clone();
assert_eq!(sent[0], "fast-1");
assert_eq!(&sent[1..], &["slow-1", "slow-2"]);
task.abort();
supervisor.shutdown(Duration::from_secs(1)).await;
}
#[tokio::test]
async fn confirmed_delivery_reports_missing_channel() {
let bus = MessageBus::new(8);
let manager = ChannelManager::with_bus(
Arc::new(crate::channels::CliChatChannel::new()),
bus.clone(),
);
let supervisor = TaskSupervisor::new();
let dispatcher = OutboundDispatcher::new(bus.clone(), manager, supervisor.clone());
let task = tokio::spawn(async move { dispatcher.run().await });
let mut message = outbound("missing", "not delivered");
message.channel = "missing".to_string();
let error = bus.deliver_outbound(message).await.unwrap_err();
assert!(matches!(error, crate::bus::BusError::DeliveryFailed(_)));
task.abort();
supervisor.shutdown(Duration::from_secs(1)).await;
}
#[tokio::test]
async fn confirmed_delivery_waits_for_channel_send() {
let bus = MessageBus::new(8);
let manager = ChannelManager::with_bus(
Arc::new(crate::channels::CliChatChannel::new()),
bus.clone(),
);
let channel = Arc::new(RecordingChannel {
sent: Mutex::new(Vec::new()),
notify: Notify::new(),
});
manager.register_channel("recording", channel.clone()).await;
let supervisor = TaskSupervisor::new();
let dispatcher = OutboundDispatcher::new(bus.clone(), manager, supervisor.clone());
let task = tokio::spawn(async move { dispatcher.run().await });
bus.deliver_outbound(outbound("confirmed", "delivered"))
.await
.unwrap();
assert_eq!(channel.sent.lock().await.as_slice(), &["delivered"]);
task.abort();
supervisor.shutdown(Duration::from_secs(1)).await;
}
#[tokio::test]
async fn permanent_send_failure_is_not_retried() {
let channel = PermanentFailureChannel {
attempts: AtomicUsize::new(0),
};
let error = OutboundDispatcher::send_with_retry(&channel, &outbound("invalid", "message"))
.await
.unwrap_err();
assert!(matches!(error, ChannelError::Other(_)));
assert_eq!(channel.attempts.load(Ordering::SeqCst), 1);
// All retries exhausted - should not reach here as last iteration returns
Ok(())
}
}

View File

@ -264,6 +264,12 @@ pub struct InboundMessage {
pub forwarded_metadata: HashMap<String, String>,
}
impl InboundMessage {
pub fn session_key(&self) -> String {
format!("{}:{}", self.channel, self.chat_id)
}
}
// ============================================================================
// OutboundMessage - Message from Agent to Channel (bot response)
// ============================================================================
@ -276,14 +282,11 @@ pub struct OutboundMessage {
pub reply_to: Option<String>,
pub media: Vec<MediaItem>,
pub metadata: HashMap<String, String>,
pub(crate) delivery: Option<tokio::sync::watch::Sender<Option<Result<(), String>>>>,
}
impl OutboundMessage {
pub(crate) fn complete_delivery(&self, result: Result<(), String>) {
if let Some(delivery) = &self.delivery {
delivery.send_replace(Some(result));
}
pub fn is_stream_delta(&self) -> bool {
self.metadata.get("_stream_delta").is_some()
}
}

View File

@ -68,25 +68,6 @@ impl MessageBus {
.map_err(|_| BusError::Closed)
}
/// Publish an outbound message and wait for the dispatcher to report the
/// actual channel delivery result.
pub async fn deliver_outbound(&self, mut msg: OutboundMessage) -> Result<(), BusError> {
let (delivery_tx, mut delivery_rx) = tokio::sync::watch::channel(None);
msg.delivery = Some(delivery_tx);
self.publish_outbound(msg).await?;
tokio::time::timeout(std::time::Duration::from_secs(120), async {
loop {
delivery_rx.changed().await.map_err(|_| BusError::Closed)?;
if let Some(result) = delivery_rx.borrow().clone() {
return result.map_err(BusError::DeliveryFailed);
}
}
})
.await
.map_err(|_| BusError::DeliveryTimedOut)?
}
/// Consume an outbound message (Dispatcher -> Bus)
pub async fn consume_outbound(&self) -> Option<OutboundMessage> {
self.outbound_rx.lock().await.recv().await
@ -114,16 +95,12 @@ impl MessageBus {
#[derive(Debug)]
pub enum BusError {
Closed,
DeliveryFailed(String),
DeliveryTimedOut,
}
impl std::fmt::Display for BusError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
BusError::Closed => write!(f, "Bus channel closed"),
BusError::DeliveryFailed(error) => write!(f, "Outbound delivery failed: {error}"),
BusError::DeliveryTimedOut => write!(f, "Outbound delivery confirmation timed out"),
}
}
}

View File

@ -26,12 +26,6 @@ impl std::fmt::Display for ChannelError {
impl std::error::Error for ChannelError {}
impl ChannelError {
pub fn is_transient(&self) -> bool {
matches!(self, Self::ConnectionError(_) | Self::SendError(_))
}
}
impl From<BusError> for ChannelError {
fn from(e: BusError) -> Self {
ChannelError::BusError(e.to_string())

View File

@ -1,10 +1,9 @@
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::{Mutex, mpsc};
use crate::bus::{ControlMessage, InboundMessage, MessageBus, OutboundMessage};
use crate::protocol::{HistoryMessage, SlashCommandInfo, WsInbound, WsOutbound, parse_inbound};
use crate::protocol::{SlashCommandInfo, WsInbound, WsOutbound, parse_inbound};
use crate::session::{SessionCommand, SessionEvent, UnifiedSessionId};
use super::base::{Channel, ChannelError};
@ -19,19 +18,13 @@ pub(crate) struct Client {
current_session_id: Mutex<Option<String>>,
}
impl Client {
pub(crate) fn chat_id(&self) -> &str {
&self.chat_id
}
}
// ============================================================================
// CliChatChannel - Channel implementation for CLI chat
// ============================================================================
pub struct CliChatChannel {
bus: std::sync::Mutex<Option<Arc<MessageBus>>>,
clients: Mutex<HashMap<String, Arc<Client>>>,
clients: Mutex<Vec<Arc<Client>>>,
}
impl Default for CliChatChannel {
@ -44,7 +37,7 @@ impl CliChatChannel {
pub fn new() -> Self {
Self {
bus: std::sync::Mutex::new(None),
clients: Mutex::new(HashMap::new()),
clients: Mutex::new(Vec::new()),
}
}
@ -52,30 +45,25 @@ impl CliChatChannel {
pub(crate) async fn register_client(
&self,
sender: mpsc::Sender<WsOutbound>,
requested_chat_id: Option<String>,
) -> (String, Arc<Client>) {
// Each WebSocket connection gets a stable chat scope. All user input and
// dialog controls for this client stay inside that scope unless the
// protocol explicitly carries a full session id.
let chat_id = requested_chat_id.unwrap_or_else(crate::util::short_id);
let chat_id = crate::util::short_id();
let client = Arc::new(Client {
sender,
chat_id: chat_id.clone(),
current_session_id: Mutex::new(None),
});
self.clients
.lock()
.await
.insert(chat_id.clone(), client.clone());
self.clients.lock().await.push(client.clone());
// Resume the current/most-recent dialog for a stable TUI identity. Only
// create a dialog when this client has never connected before.
let session_id = match self.resume_session_via_control(&chat_id).await {
Ok(id) => id,
// Create initial session via control message
let session_id = match self.create_session_via_control(&chat_id, None).await {
Ok((id, _title)) => id,
Err(e) => {
tracing::error!(error = %e, "Failed to resume initial session");
UnifiedSessionId::new("cli_chat", &chat_id, crate::util::short_id()).to_string()
tracing::error!(error = %e, "Failed to create initial session");
UnifiedSessionId::new("cli_chat", &chat_id, &crate::util::short_id()).to_string()
}
};
@ -88,16 +76,6 @@ impl CliChatChannel {
(session_id, client)
}
pub(crate) async fn unregister_client(&self, client: &Arc<Client>) {
let mut clients = self.clients.lock().await;
if clients
.get(client.chat_id())
.is_some_and(|registered| Arc::ptr_eq(registered, client))
{
clients.remove(client.chat_id());
}
}
/// Handle an inbound message from a client
pub(crate) async fn handle_inbound(&self, client: Arc<Client>, raw_msg: &str) {
match parse_inbound(raw_msg) {
@ -165,13 +143,10 @@ impl CliChatChannel {
} => {
let (reply_tx, mut reply_rx) = mpsc::channel(1);
let session_id = if let Some(session_id) = session_id {
Self::parse_client_session(&client, &session_id)?
UnifiedSessionId::parse(&session_id).ok_or_else(|| {
ChannelError::Other("Invalid session ID format".to_string())
})?
} else if let Some(chat_id) = chat_id {
if chat_id != client.chat_id {
return Err(ChannelError::Other(
"Chat does not belong to this client".to_string(),
));
}
let (current_tx, mut current_rx) = mpsc::channel(1);
bus.publish_control(ControlMessage {
op: SessionCommand::GetCurrentDialog {
@ -202,7 +177,9 @@ impl CliChatChannel {
let target = current_session_guard
.clone()
.ok_or_else(|| ChannelError::Other("No active session".to_string()))?;
Self::parse_client_session(&client, &target)?
UnifiedSessionId::parse(&target).ok_or_else(|| {
ChannelError::Other("Invalid session ID format".to_string())
})?
};
let target = session_id.to_string();
bus.publish_control(ControlMessage {
@ -347,56 +324,14 @@ impl CliChatChannel {
}
}
}
WsInbound::GetSessionHistory { session_id, limit } => {
let unified_id = Self::parse_client_session(&client, &session_id)?;
let (reply_tx, mut reply_rx) = mpsc::channel(1);
bus.publish_control(ControlMessage {
op: SessionCommand::GetDialogHistory {
session_id: unified_id,
limit: limit.unwrap_or(1_000).clamp(1, 2_000),
},
reply_tx,
})
.await?;
match reply_rx.recv().await {
Some(Ok(SessionEvent::DialogHistory {
session_id,
messages,
})) => {
let messages = messages
.into_iter()
.filter(|message| !message.content.is_empty())
.map(|message| HistoryMessage {
id: message.id,
seq: message.seq,
role: message.role,
content: message.content,
created_at: message.created_at,
})
.collect();
let _ = client
.sender
.send(WsOutbound::SessionHistory {
session_id: session_id.to_string(),
messages,
})
.await;
}
Some(Ok(_)) => {}
Some(Err(e)) => return Err(e),
None => {
return Err(ChannelError::Other("Control channel closed".to_string()));
}
}
}
WsInbound::RenameSession { session_id, title } => {
let target = session_id
.or(current_session_guard.clone())
.ok_or_else(|| ChannelError::Other("No active session".to_string()))?;
let (reply_tx, mut reply_rx) = mpsc::channel(1);
let unified_id = Self::parse_client_session(&client, &target)?;
let unified_id = UnifiedSessionId::parse(&target)
.ok_or_else(|| ChannelError::Other("Invalid session ID format".to_string()))?;
bus.publish_control(ControlMessage {
op: SessionCommand::RenameDialog {
session_id: unified_id,
@ -434,7 +369,8 @@ impl CliChatChannel {
let was_current = current_session_guard.as_deref() == Some(&target);
let (reply_tx, mut reply_rx) = mpsc::channel(1);
let unified_id = Self::parse_client_session(&client, &target)?;
let unified_id = UnifiedSessionId::parse(&target)
.ok_or_else(|| ChannelError::Other("Invalid session ID format".to_string()))?;
bus.publish_control(ControlMessage {
op: SessionCommand::ArchiveDialog {
session_id: unified_id,
@ -482,7 +418,8 @@ impl CliChatChannel {
.ok_or_else(|| ChannelError::Other("No active session".to_string()))?;
let (reply_tx, mut reply_rx) = mpsc::channel(1);
let unified_id = Self::parse_client_session(&client, &target)?;
let unified_id = UnifiedSessionId::parse(&target)
.ok_or_else(|| ChannelError::Other("Invalid session ID format".to_string()))?;
bus.publish_control(ControlMessage {
op: SessionCommand::DeleteDialog {
session_id: unified_id,
@ -618,78 +555,6 @@ impl CliChatChannel {
None => Err(ChannelError::Other("Control channel closed".to_string())),
}
}
async fn resume_session_via_control(&self, chat_id: &str) -> Result<String, ChannelError> {
let bus = {
let guard = self.bus.lock().unwrap();
guard
.clone()
.ok_or_else(|| ChannelError::Other("Channel not started".to_string()))?
};
let (reply_tx, mut reply_rx) = mpsc::channel(1);
bus.publish_control(ControlMessage {
op: SessionCommand::GetCurrentDialog {
channel: "cli_chat".to_string(),
chat_id: chat_id.to_string(),
},
reply_tx,
})
.await?;
if let Some(Ok(SessionEvent::CurrentDialog {
session_id: Some(session_id),
})) = reply_rx.recv().await
{
return Ok(session_id.to_string());
}
let (reply_tx, mut reply_rx) = mpsc::channel(1);
bus.publish_control(ControlMessage {
op: SessionCommand::ListDialogs {
channel: "cli_chat".to_string(),
chat_id: chat_id.to_string(),
include_archived: false,
},
reply_tx,
})
.await?;
if let Some(Ok(SessionEvent::DialogList { dialogs, .. })) = reply_rx.recv().await
&& let Some(dialog) = dialogs.first()
{
let session_id = dialog.session_id.clone();
let (reply_tx, mut reply_rx) = mpsc::channel(1);
bus.publish_control(ControlMessage {
op: SessionCommand::SwitchDialog {
channel: session_id.channel.clone(),
chat_id: session_id.chat_id.clone(),
dialog_id: session_id.dialog_id.clone(),
},
reply_tx,
})
.await?;
if let Some(Ok(SessionEvent::DialogSwitched { session_id })) = reply_rx.recv().await {
return Ok(session_id.to_string());
}
}
self.create_session_via_control(chat_id, None)
.await
.map(|(session_id, _)| session_id)
}
fn parse_client_session(
client: &Client,
session_id: &str,
) -> Result<UnifiedSessionId, ChannelError> {
let unified_id = UnifiedSessionId::parse(session_id)
.ok_or_else(|| ChannelError::Other("Invalid session ID format".to_string()))?;
if unified_id.channel != "cli_chat" || unified_id.chat_id != client.chat_id {
return Err(ChannelError::Other(
"Session does not belong to this client".to_string(),
));
}
Ok(unified_id)
}
}
#[async_trait]
@ -709,112 +574,29 @@ impl Channel for CliChatChannel {
async fn stop(&self) -> Result<(), ChannelError> {
*self.bus.lock().unwrap() = None;
self.clients.lock().await.clear();
Ok(())
}
async fn send(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
let client = self.clients.lock().await.get(&msg.chat_id).cloned();
let Some(client) = client else {
tracing::debug!(chat_id = %msg.chat_id, "No active CLI client for outbound message");
return Ok(());
};
let message_type = msg.metadata.get("_type").map(String::as_str);
let session_id = msg.metadata.get("_session_id").cloned();
let outbound = if message_type == Some("notification") {
WsOutbound::SystemNotification {
content: msg.content,
session_id,
let clients = self.clients.lock().await.clone();
for client in clients {
if client.chat_id != msg.chat_id {
continue;
}
} else if message_type == Some("command") {
WsOutbound::CommandExecuted {
message: msg.content,
}
} else {
WsOutbound::AssistantResponse {
id: crate::util::short_id(),
content: msg.content,
role: "assistant".to_string(),
session_id,
}
};
if client.sender.send(outbound).await.is_err() {
let mut clients = self.clients.lock().await;
if clients
.get(&msg.chat_id)
.is_some_and(|registered| Arc::ptr_eq(registered, &client))
let outbound = if msg.metadata.get("_type").map(|v| v.as_str()) == Some("notification")
{
clients.remove(&msg.chat_id);
}
WsOutbound::SystemNotification {
content: msg.content.clone(),
}
} else {
WsOutbound::AssistantResponse {
id: crate::util::short_id(),
content: msg.content.clone(),
role: "assistant".to_string(),
}
};
let _ = client.sender.send(outbound).await;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn failed_sender_is_pruned_from_client_registry() {
let channel = CliChatChannel::new();
let (sender, receiver) = mpsc::channel(1);
drop(receiver);
let client = Arc::new(Client {
sender,
chat_id: "dead-client".to_string(),
current_session_id: Mutex::new(None),
});
channel
.clients
.lock()
.await
.insert("dead-client".to_string(), client);
channel
.send(OutboundMessage {
channel: "cli_chat".to_string(),
chat_id: "dead-client".to_string(),
content: "message".to_string(),
reply_to: None,
media: Vec::new(),
metadata: Default::default(),
delivery: None,
})
.await
.unwrap();
assert!(channel.clients.lock().await.is_empty());
}
#[tokio::test]
async fn stale_connection_cannot_unregister_replacement() {
let channel = CliChatChannel::new();
let (old_sender, _old_receiver) = mpsc::channel(1);
let (new_sender, _new_receiver) = mpsc::channel(1);
let old = Arc::new(Client {
sender: old_sender,
chat_id: "stable-client".to_string(),
current_session_id: Mutex::new(None),
});
let replacement = Arc::new(Client {
sender: new_sender,
chat_id: "stable-client".to_string(),
current_session_id: Mutex::new(None),
});
channel
.clients
.lock()
.await
.insert("stable-client".to_string(), replacement.clone());
channel.unregister_client(&old).await;
let registered = channel.clients.lock().await;
assert!(
registered
.get("stable-client")
.is_some_and(|client| Arc::ptr_eq(client, &replacement))
);
}
}

View File

@ -8,9 +8,7 @@ use futures_util::{SinkExt, StreamExt};
use prost::{Message as ProstMessage, bytes::Bytes};
use regex::Regex;
use serde::Deserialize;
use tokio::sync::{Mutex, RwLock};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tokio::sync::{RwLock, broadcast};
use crate::bus::{MediaItem, MessageBus, OutboundMessage};
use crate::channels::base::{Channel, ChannelError};
@ -22,11 +20,6 @@ const FEISHU_WS_BASE: &str = "https://open.feishu.cn";
/// Heartbeat timeout for WS connection — must be larger than ping_interval (default 120 s).
/// If no binary frame (pong or event) is received within this window, reconnect.
const WS_HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(300);
const CHANNEL_STOP_GRACE: Duration = if cfg!(test) {
Duration::from_millis(100)
} else {
Duration::from_secs(5)
};
/// Refresh tenant token this many seconds before the announced expiry.
const TOKEN_REFRESH_SKEW: Duration = Duration::from_secs(120);
/// Default tenant token TTL when `expire`/`expires_in` is absent.
@ -96,6 +89,7 @@ struct LarkEvent {
#[derive(Deserialize)]
struct LarkEventHeader {
event_type: String,
#[allow(dead_code)]
event_id: String,
}
@ -127,13 +121,19 @@ struct LarkSenderId {
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct LarkMessage {
message_id: String,
chat_id: String,
chat_type: String,
message_type: String,
#[serde(default)]
content: String,
#[serde(default)]
mentions: Vec<serde_json::Value>,
#[serde(default)]
root_id: Option<String>,
#[serde(default)]
parent_id: Option<String>,
}
@ -151,8 +151,7 @@ pub struct FeishuChannel {
config: FeishuChannelConfig,
http_client: reqwest::Client,
running: Arc<RwLock<bool>>,
shutdown: Arc<RwLock<Option<CancellationToken>>>,
run_task: Arc<Mutex<Option<JoinHandle<()>>>>,
shutdown_tx: Arc<RwLock<Option<broadcast::Sender<()>>>>,
connected: Arc<RwLock<bool>>,
/// Cached tenant access token with proactive refresh.
tenant_token: Arc<RwLock<Option<CachedTenantToken>>>,
@ -185,8 +184,7 @@ impl FeishuChannel {
config,
http_client: reqwest::Client::new(),
running: Arc::new(RwLock::new(false)),
shutdown: Arc::new(RwLock::new(None)),
run_task: Arc::new(Mutex::new(None)),
shutdown_tx: Arc::new(RwLock::new(None)),
connected: Arc::new(RwLock::new(false)),
tenant_token: Arc::new(RwLock::new(None)),
seen_message_ids: Arc::new(RwLock::new(HashMap::new())),
@ -1172,22 +1170,18 @@ impl FeishuChannel {
async fn run_ws_loop(
&self,
bus: Arc<MessageBus>,
shutdown: CancellationToken,
mut shutdown_rx: broadcast::Receiver<()>,
) -> Result<(), ChannelError> {
let (wss_url, client_config) = tokio::select! {
result = self.get_ws_endpoint(&self.http_client) => result?,
_ = shutdown.cancelled() => return Ok(()),
};
let (wss_url, client_config) = self.get_ws_endpoint(&self.http_client).await?;
let service_id = Self::extract_service_id(&wss_url);
tracing::info!(url = %wss_url, "Connecting to Feishu WebSocket");
let (ws_stream, _) = tokio::select! {
result = tokio_tungstenite::connect_async(&wss_url) => result.map_err(|e| {
let (ws_stream, _) = tokio_tungstenite::connect_async(&wss_url)
.await
.map_err(|e| {
ChannelError::ConnectionError(format!("WebSocket connection failed: {}", e))
})?,
_ = shutdown.cancelled() => return Ok(()),
};
})?;
*self.connected.write().await = true;
tracing::info!("Feishu WebSocket connected");
@ -1206,14 +1200,14 @@ impl FeishuChannel {
}],
payload: None,
};
tokio::select! {
result = write.send(tokio_tungstenite::tungstenite::Message::Binary(
write
.send(tokio_tungstenite::tungstenite::Message::Binary(
ping_frame.encode_to_vec().into(),
)) => result.map_err(|e| {
))
.await
.map_err(|e| {
ChannelError::ConnectionError(format!("Failed to send initial ping: {}", e))
})?,
_ = shutdown.cancelled() => return Ok(()),
};
})?;
let ping_interval = client_config.ping_interval.unwrap_or(120).max(10);
let mut ping_interval_tok =
@ -1262,24 +1256,29 @@ impl FeishuChannel {
forwarded_metadata.insert("feishu.parent_id".to_string(), pid.clone());
}
#[cfg(debug_assertions)]
tracing::debug!(open_id = %parsed.open_id, chat_id = %parsed.chat_id, content_len = %parsed.content.len(), media_count = %parsed.media.len(), "Publishing message to bus");
let msg = crate::bus::InboundMessage {
channel: "feishu".to_string(),
sender_id: parsed.open_id.clone(),
chat_id: parsed.chat_id.clone(),
content: parsed.content.clone(),
timestamp: crate::bus::message::current_timestamp(),
media: parsed.media.clone(),
metadata: std::collections::HashMap::new(),
forwarded_metadata,
};
if let Err(e) = self.handle_and_publish(&bus, &msg).await {
tracing::error!(error = %e, open_id = %parsed.open_id, chat_id = %parsed.chat_id, "Failed to publish Feishu message to bus");
} else {
// Publish to bus asynchronously
let channel = self.clone();
let bus = bus.clone();
tokio::spawn(async move {
#[cfg(debug_assertions)]
tracing::debug!(open_id = %parsed.open_id, chat_id = %parsed.chat_id, "Message published to bus successfully");
}
tracing::debug!(open_id = %parsed.open_id, chat_id = %parsed.chat_id, content_len = %parsed.content.len(), media_count = %parsed.media.len(), "Publishing message to bus");
let msg = crate::bus::InboundMessage {
channel: "feishu".to_string(),
sender_id: parsed.open_id.clone(),
chat_id: parsed.chat_id.clone(),
content: parsed.content.clone(),
timestamp: crate::bus::message::current_timestamp(),
media: parsed.media.clone(),
metadata: std::collections::HashMap::new(),
forwarded_metadata,
};
if let Err(e) = channel.handle_and_publish(&bus, &msg).await {
tracing::error!(error = %e, open_id = %parsed.open_id, chat_id = %parsed.chat_id, "Failed to publish Feishu message to bus");
} else {
#[cfg(debug_assertions)]
tracing::debug!(open_id = %parsed.open_id, chat_id = %parsed.chat_id, "Message published to bus successfully");
}
});
}
Ok(None) => {}
Err(e) => {
@ -1346,7 +1345,7 @@ impl FeishuChannel {
let mut seen = self.seen_message_ids.write().await;
seen.retain(|_, ts| now.duration_since(*ts) < DEDUP_CACHE_TTL);
}
_ = shutdown.cancelled() => {
_ = shutdown_rx.recv() => {
tracing::info!("Feishu channel shutdown signal received");
break;
}
@ -1358,6 +1357,52 @@ impl FeishuChannel {
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn collect_post_image_keys_finds_nested_images() {
let content = serde_json::json!({
"zh_cn": {
"title": "",
"content": [[
{"tag": "img", "image_key": "img_v3_001"},
{"tag": "text", "text": "这是哪里?"},
{"tag": "img", "image_key": "img_v3_002"},
{"tag": "img", "image_key": "img_v3_001"}
]]
}
})
.to_string();
assert_eq!(
collect_post_image_keys(&content),
vec!["img_v3_001".to_string(), "img_v3_002".to_string()]
);
}
#[test]
fn parse_post_content_preserves_image_positions() {
let content = serde_json::json!({
"zh_cn": {
"title": "",
"content": [[
{"tag": "text", "text": "这是一张图:"},
{"tag": "img", "image_key": "img_v3_001"},
{"tag": "text", "text": "看完继续说"}
]]
}
})
.to_string();
assert_eq!(
parse_post_content(&content),
"这是一张图:[image]看完继续说"
);
}
}
fn parse_post_content(content: &str) -> String {
/// Extract text from a single post element (text, link, at-mention).
fn extract_element(el: &serde_json::Value, out: &mut Vec<String>) {
@ -1687,8 +1732,13 @@ fn collect_list_items(items: &[serde_json::Value], lines: &mut Vec<String>, dept
collect_list_items(children, lines, depth + 1);
}
} else if let Some(children_arr) = item.as_array().and_then(|arr| {
arr.iter()
.find(|child| child.as_object().and_then(|o| o.get("children")).is_some())
arr.iter().find_map(|child| {
if child.as_object().and_then(|o| o.get("children")).is_some() {
Some(child)
} else {
None
}
})
}) && let Some(children) = children_arr
.as_object()
.and_then(|o| o.get("children"))
@ -1769,12 +1819,13 @@ fn resolve_image_ext(content_type: &str) -> &str {
}
fn resolve_file_ext(content_json: &serde_json::Value) -> String {
if let Some(name) = content_json.get("file_name").and_then(|v| v.as_str())
&& let Some(ext) = std::path::Path::new(name)
if let Some(name) = content_json.get("file_name").and_then(|v| v.as_str()) {
if let Some(ext) = std::path::Path::new(name)
.extension()
.and_then(|e| e.to_str())
{
return ext.to_string();
{
return ext.to_string();
}
}
String::new()
}
@ -1954,22 +2005,14 @@ impl Channel for FeishuChannel {
));
}
let mut run_task = self.run_task.lock().await;
if run_task.as_ref().is_some_and(|task| !task.is_finished()) {
return Ok(());
}
if let Some(finished) = run_task.take() {
let _ = finished.await;
}
*self.running.write().await = true;
let shutdown = CancellationToken::new();
*self.shutdown.write().await = Some(shutdown.clone());
let (shutdown_tx, _) = broadcast::channel(1);
*self.shutdown_tx.write().await = Some(shutdown_tx.clone());
let channel = self.clone();
let bus = bus.clone();
*run_task = Some(tokio::spawn(async move {
tokio::spawn(async move {
let mut consecutive_failures = 0;
let max_failures = 3;
@ -1978,7 +2021,8 @@ impl Channel for FeishuChannel {
break;
}
match channel.run_ws_loop(bus.clone(), shutdown.clone()).await {
let shutdown_rx = shutdown_tx.subscribe();
match channel.run_ws_loop(bus.clone(), shutdown_rx).await {
Ok(_) => {
tracing::info!("Feishu WebSocket disconnected");
}
@ -1992,20 +2036,17 @@ impl Channel for FeishuChannel {
}
}
if !*channel.running.read().await || shutdown.is_cancelled() {
if !*channel.running.read().await {
break;
}
tracing::info!("Feishu channel retrying in 5s...");
tokio::select! {
_ = tokio::time::sleep(tokio::time::Duration::from_secs(5)) => {}
_ = shutdown.cancelled() => break,
}
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
}
*channel.running.write().await = false;
tracing::info!("Feishu channel stopped");
}));
});
tracing::info!("Feishu channel started");
Ok(())
@ -2013,27 +2054,9 @@ impl Channel for FeishuChannel {
async fn stop(&self) -> Result<(), ChannelError> {
*self.running.write().await = false;
*self.connected.write().await = false;
if let Some(shutdown) = self.shutdown.write().await.take() {
shutdown.cancel();
}
let task = { self.run_task.lock().await.take() };
if let Some(mut task) = task {
match tokio::time::timeout(CHANNEL_STOP_GRACE, &mut task).await {
Ok(result) => result.map_err(|error| {
ChannelError::Other(format!("Feishu channel task failed to join: {error}"))
})?,
Err(_) => {
tracing::warn!(
grace_ms = CHANNEL_STOP_GRACE.as_millis(),
"Feishu channel did not stop in time; aborting connection task"
);
task.abort();
let _ = task.await;
}
}
if let Some(tx) = self.shutdown_tx.write().await.take() {
let _ = tx.send(());
}
Ok(())
@ -2248,86 +2271,3 @@ impl Channel for FeishuChannel {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_channel() -> FeishuChannel {
FeishuChannel::new(
FeishuChannelConfig {
enabled: true,
app_id: "test-app".to_string(),
app_secret: "test-secret".to_string(),
allow_from: vec!["*".to_string()],
agent: String::new(),
media_dir: String::new(),
reaction_emoji: "THUMBSUP".to_string(),
},
Path::new("/tmp"),
)
.expect("test channel should be valid")
}
#[tokio::test]
async fn stop_aborts_connection_task_that_ignores_cancellation() {
let channel = test_channel();
let shutdown = CancellationToken::new();
*channel.running.write().await = true;
*channel.connected.write().await = true;
*channel.shutdown.write().await = Some(shutdown.clone());
*channel.run_task.lock().await = Some(tokio::spawn(std::future::pending()));
tokio::time::timeout(Duration::from_secs(1), channel.stop())
.await
.expect("stop must have a hard deadline")
.expect("stop should succeed after aborting the stuck task");
assert!(shutdown.is_cancelled());
assert!(!channel.is_running());
assert!(!*channel.connected.read().await);
assert!(channel.run_task.lock().await.is_none());
}
#[test]
fn collect_post_image_keys_finds_nested_images() {
let content = serde_json::json!({
"zh_cn": {
"title": "",
"content": [[
{"tag": "img", "image_key": "img_v3_001"},
{"tag": "text", "text": "这是哪里?"},
{"tag": "img", "image_key": "img_v3_002"},
{"tag": "img", "image_key": "img_v3_001"}
]]
}
})
.to_string();
assert_eq!(
collect_post_image_keys(&content),
vec!["img_v3_001".to_string(), "img_v3_002".to_string()]
);
}
#[test]
fn parse_post_content_preserves_image_positions() {
let content = serde_json::json!({
"zh_cn": {
"title": "",
"content": [[
{"tag": "text", "text": "这是一张图:"},
{"tag": "img", "image_key": "img_v3_001"},
{"tag": "text", "text": "看完继续说"}
]]
}
})
.to_string();
assert_eq!(
parse_post_content(&content),
"这是一张图:[image]看完继续说"
);
}
}

View File

@ -2,7 +2,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::bus::MessageBus;
use crate::bus::{MessageBus, OutboundMessage};
use crate::channels::base::{Channel, ChannelError};
use crate::channels::feishu::FeishuChannel;
use crate::config::Config;
@ -16,6 +16,14 @@ pub struct ChannelManager {
}
impl ChannelManager {
pub fn new(cli_chat_channel: Arc<crate::channels::CliChatChannel>) -> Self {
Self {
channels: Arc::new(RwLock::new(HashMap::new())),
cli_chat_channel,
bus: MessageBus::new(100),
}
}
pub fn with_bus(
cli_chat_channel: Arc<crate::channels::CliChatChannel>,
bus: Arc<MessageBus>,
@ -83,53 +91,27 @@ impl ChannelManager {
}
pub async fn start_all(&self) -> Result<(), ChannelError> {
let channels: Vec<_> = self
.channels
.read()
.await
.iter()
.map(|(name, channel)| (name.clone(), channel.clone()))
.collect();
let channels = self.channels.read().await;
let bus = self.bus.clone();
let mut failures = Vec::new();
for (name, channel) in channels {
for (name, channel) in channels.iter() {
tracing::info!(channel = %name, "Starting channel");
if let Err(e) = channel.start(bus.clone()).await {
tracing::error!(channel = %name, error = %e, "Failed to start channel");
failures.push(format!("{name}: {e}"));
}
}
if failures.is_empty() {
Ok(())
} else {
Err(ChannelError::Other(format!(
"failed to start channels: {}",
failures.join("; ")
)))
}
Ok(())
}
pub async fn stop_all(&self) -> Result<(), ChannelError> {
let channels: Vec<_> = {
let mut registered = self.channels.write().await;
registered.drain().collect()
};
let mut failures = Vec::new();
for (name, channel) in channels {
let mut channels = self.channels.write().await;
for (name, channel) in channels.iter() {
tracing::info!(channel = %name, "Stopping channel");
if let Err(e) = channel.stop().await {
tracing::error!(channel = %name, error = %e, "Error stopping channel");
failures.push(format!("{name}: {e}"));
}
}
if failures.is_empty() {
Ok(())
} else {
Err(ChannelError::Other(format!(
"failed to stop channels: {}",
failures.join("; ")
)))
}
channels.clear();
Ok(())
}
pub async fn get_channel(&self, name: &str) -> Option<Arc<dyn Channel + Send + Sync>> {
@ -140,104 +122,17 @@ impl ChannelManager {
pub async fn list_channel_names(&self) -> Vec<String> {
self.channels.read().await.keys().cloned().collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
struct TestChannel {
name: &'static str,
fail_start: bool,
fail_stop: bool,
running: AtomicBool,
starts: AtomicUsize,
stops: AtomicUsize,
}
impl TestChannel {
fn new(name: &'static str, fail_start: bool, fail_stop: bool) -> Self {
Self {
name,
fail_start,
fail_stop,
running: AtomicBool::new(false),
starts: AtomicUsize::new(0),
stops: AtomicUsize::new(0),
}
/// Dispatch an outbound message to the appropriate channel
pub async fn dispatch(&self, msg: OutboundMessage) -> Result<(), ChannelError> {
let channel_name = &msg.channel;
if let Some(channel) = self.get_channel(channel_name).await {
channel.send(msg).await
} else {
Err(ChannelError::Other(format!(
"Channel not found: {}",
channel_name
)))
}
}
#[async_trait]
impl Channel for TestChannel {
fn name(&self) -> &str {
self.name
}
fn is_running(&self) -> bool {
self.running.load(Ordering::SeqCst)
}
async fn start(&self, _bus: Arc<MessageBus>) -> Result<(), ChannelError> {
self.starts.fetch_add(1, Ordering::SeqCst);
if self.fail_start {
return Err(ChannelError::ConnectionError("unavailable".into()));
}
self.running.store(true, Ordering::SeqCst);
Ok(())
}
async fn stop(&self) -> Result<(), ChannelError> {
self.stops.fetch_add(1, Ordering::SeqCst);
self.running.store(false, Ordering::SeqCst);
if self.fail_stop {
return Err(ChannelError::Other("stop failed".into()));
}
Ok(())
}
async fn send(&self, _msg: crate::bus::OutboundMessage) -> Result<(), ChannelError> {
Ok(())
}
}
fn manager() -> ChannelManager {
ChannelManager::with_bus(
Arc::new(crate::channels::CliChatChannel::new()),
MessageBus::new(8),
)
}
#[tokio::test]
async fn start_all_reports_channel_failures_and_starts_the_rest() {
let manager = manager();
let healthy = Arc::new(TestChannel::new("healthy", false, false));
let broken = Arc::new(TestChannel::new("broken", true, false));
manager.register_channel("healthy", healthy.clone()).await;
manager.register_channel("broken", broken.clone()).await;
let error = manager.start_all().await.unwrap_err().to_string();
assert!(error.contains("broken"));
assert_eq!(healthy.starts.load(Ordering::SeqCst), 1);
assert_eq!(broken.starts.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn stop_all_reports_failures_and_unregisters_every_channel() {
let manager = manager();
let healthy = Arc::new(TestChannel::new("healthy", false, false));
let broken = Arc::new(TestChannel::new("broken", false, true));
manager.register_channel("healthy", healthy.clone()).await;
manager.register_channel("broken", broken.clone()).await;
let error = manager.stop_all().await.unwrap_err().to_string();
assert!(error.contains("broken"));
assert_eq!(healthy.stops.load(Ordering::SeqCst), 1);
assert_eq!(broken.stops.load(Ordering::SeqCst), 1);
assert!(manager.list_channel_names().await.is_empty());
}
}

View File

@ -3,37 +3,31 @@ pub use crate::protocol::{WsInbound, WsOutbound, serialize_inbound, serialize_ou
mod tui;
use crate::client::tui::app::{App, MessageRole};
use crate::client::tui::event::{
handle_key_event, handle_paste, request_history, request_session_list, send,
};
use crate::client::tui::event::handle_key_event;
use crate::client::tui::ui::render_ui;
use crossterm::{
event::{self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyEventKind},
event::{self, Event},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use futures_util::StreamExt;
use futures_util::{SinkExt, StreamExt};
use ratatui::{Terminal, prelude::CrosstermBackend};
use std::io;
use std::{fs, path::PathBuf};
use tokio_tungstenite::{connect_async, tungstenite::Message};
pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
let client_id = load_or_create_client_id();
let separator = if gateway_url.contains('?') { '&' } else { '?' };
let connect_url = format!("{gateway_url}{separator}client_id={client_id}");
let (ws_stream, _) = connect_async(&connect_url).await?;
tracing::info!("Connected to gateway");
let (ws_stream, _) = connect_async(gateway_url).await?;
tracing::info!(url = %gateway_url, "Connected to gateway");
let (ws_sender, ws_receiver) = ws_stream.split();
let mut app = App::new();
let mut app = App::new(gateway_url.to_string());
app.ws_sender = Some(ws_sender);
app.ws_receiver = Some(ws_receiver);
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
@ -41,57 +35,33 @@ pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
let result = run_app(&mut terminal, app).await;
// Cleanup terminal, ignore errors
let _ = execute!(
terminal.backend_mut(),
DisableBracketedPaste,
LeaveAlternateScreen
);
let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
let _ = disable_raw_mode();
let _ = terminal.show_cursor();
result
}
fn load_or_create_client_id() -> String {
let generated = uuid::Uuid::new_v4().simple().to_string();
let Some(home) = dirs::home_dir() else {
return generated;
};
let dir = home.join(".picobot");
let path: PathBuf = dir.join("tui_client_id");
if let Ok(value) = fs::read_to_string(&path) {
let value = value.trim();
if !value.is_empty()
&& value.len() <= 64
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
{
return value.to_string();
}
}
if fs::create_dir_all(dir).is_ok() {
let _ = fs::write(path, &generated);
}
generated
}
async fn run_app(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
mut app: App,
) -> Result<(), Box<dyn std::error::Error>> {
let mut ws_receiver = app.ws_receiver.take().unwrap();
let mut event_reader = event::EventStream::new();
let mut ws_open = true;
send(&mut app, WsInbound::GetSlashCommands).await;
request_session_list(&mut app).await;
// Request command list on startup
if let Some(sender) = &mut app.ws_sender {
let inbound = WsInbound::GetSlashCommands;
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(Message::Text(text.into())).await;
}
}
loop {
terminal.draw(|f| render_ui(f, &app))?;
tokio::select! {
msg = ws_receiver.next(), if ws_open => {
msg = ws_receiver.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
if let Ok(outbound) = serde_json::from_str::<WsOutbound>(&text) {
@ -100,30 +70,14 @@ async fn run_app(
}
Some(Ok(Message::Close(_))) | None => {
tracing::info!("Gateway disconnected");
app.connected = false;
app.ws_sender = None;
ws_open = false;
app.status_message = Some("Gateway 连接已关闭;按两次 Ctrl+C 退出".to_string());
}
Some(Err(error)) => {
app.connected = false;
app.ws_sender = None;
ws_open = false;
app.status_message = Some(format!("Gateway 连接错误:{error}"));
app.quit();
}
_ => {}
}
}
event_result = event_reader.next() => {
match event_result {
Some(Ok(Event::Key(key))) if key.kind != KeyEventKind::Release => {
handle_key_event(&mut app, key).await;
}
Some(Ok(Event::Paste(text))) => handle_paste(&mut app, &text),
Some(Err(error)) => {
app.status_message = Some(format!("终端输入错误:{error}"));
}
_ => {}
if let Some(Ok(Event::Key(key))) = event_result {
handle_key_event(&mut app, key).await;
}
}
}
@ -138,39 +92,17 @@ async fn run_app(
async fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
match outbound {
WsOutbound::AssistantResponse {
content,
session_id,
..
} => {
app.pending_responses = app.pending_responses.saturating_sub(1);
app.status_message = None;
if session_id
.as_ref()
.is_none_or(|session_id| app.current_session_id.as_ref() == Some(session_id))
{
app.add_message(MessageRole::Assistant, content);
} else {
app.status_message = Some("另一个会话已完成响应".to_string());
}
request_session_list(app).await;
WsOutbound::AssistantResponse { content, .. } => {
app.add_message(MessageRole::Assistant, content);
}
WsOutbound::Error { message, .. } => {
app.pending_responses = app.pending_responses.saturating_sub(1);
app.status_message = Some(message.clone());
app.add_message(MessageRole::System, format!("Error: {}", message));
}
WsOutbound::SessionEstablished { session_id } => {
app.connected = true;
app.set_current_session(Some(session_id.clone()));
request_history(app, session_id).await;
request_session_list(app).await;
app.set_current_session(Some(session_id));
}
WsOutbound::SessionCreated { session_id, .. } => {
app.set_current_session(Some(session_id.clone()));
app.status_message = None;
request_history(app, session_id).await;
request_session_list(app).await;
app.set_current_session(Some(session_id));
}
WsOutbound::SessionList {
sessions,
@ -178,72 +110,31 @@ async fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
} => {
app.set_sessions(sessions);
if let Some(id) = current_session_id {
let changed = app.current_session_id.as_deref() != Some(&id);
app.set_current_session(Some(id.clone()));
if changed {
request_history(app, id).await;
}
app.set_current_session(Some(id));
}
}
WsOutbound::SessionLoaded { session_id, .. } => {
app.set_current_session(Some(session_id.clone()));
request_history(app, session_id).await;
request_session_list(app).await;
}
WsOutbound::SessionHistory {
session_id,
messages,
} => app.set_history(&session_id, messages),
WsOutbound::SessionRenamed { session_id, title } => {
if let Some(session) = app
.sessions
.iter_mut()
.find(|session| session.session_id == session_id)
{
session.title = title;
}
request_session_list(app).await;
}
WsOutbound::SessionArchived { session_id } => {
app.sessions
.retain(|session| session.session_id != session_id);
request_session_list(app).await;
app.set_current_session(Some(session_id));
}
WsOutbound::SessionRenamed { .. } => {}
WsOutbound::SessionArchived { .. } => {}
WsOutbound::SessionDeleted { session_id } => {
if app.current_session_id.as_ref() == Some(&session_id) {
app.set_current_session(None);
}
app.sessions
.retain(|session| session.session_id != session_id);
request_session_list(app).await;
}
WsOutbound::HistoryCleared { session_id } => {
if app.current_session_id.as_deref() == Some(&session_id) {
app.messages.clear();
}
app.status_message = None;
request_session_list(app).await;
WsOutbound::HistoryCleared { .. } => {
app.messages.clear();
}
WsOutbound::SlashCommandsList { commands } => {
app.set_commands(commands);
}
WsOutbound::Pong => {}
WsOutbound::CommandExecuted { message } => {
app.pending_responses = app.pending_responses.saturating_sub(1);
app.status_message = None;
app.add_message(MessageRole::System, message);
request_session_list(app).await;
}
WsOutbound::SystemNotification {
content,
session_id,
} => {
if session_id
.as_ref()
.is_none_or(|session_id| app.current_session_id.as_ref() == Some(session_id))
{
app.add_message(MessageRole::System, content);
}
WsOutbound::SystemNotification { content } => {
app.add_message(MessageRole::System, content);
}
}
}

View File

@ -1,11 +1,17 @@
use crate::protocol::{HistoryMessage, SessionSummary, SlashCommandInfo};
#![allow(dead_code)]
use crate::protocol::{SessionSummary, SlashCommandInfo};
use std::collections::VecDeque;
use tokio_tungstenite::tungstenite::Message;
const MAX_MESSAGES: usize = 2_000;
const MAX_INPUT_BYTES: usize = 16 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FocusArea {
TitleBar,
SessionList,
ChatHistory,
InputArea,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone)]
pub enum MessageRole {
User,
Assistant,
@ -18,26 +24,8 @@ pub struct ChatMessage {
pub content: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Input,
Sessions,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfirmAction {
Archive,
Delete,
ClearHistory,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Modal {
Rename { input: String, cursor: usize },
Confirm(ConfirmAction),
}
pub struct App {
pub gateway_url: String,
pub ws_sender: Option<
futures_util::stream::SplitSink<
tokio_tungstenite::WebSocketStream<
@ -53,49 +41,48 @@ pub struct App {
>,
>,
>,
pub current_session_id: Option<String>,
pub sessions: Vec<SessionSummary>,
pub selected_session: usize,
pub show_archived: bool,
pub messages: VecDeque<ChatMessage>,
pub focus: FocusArea,
pub input: String,
/// UTF-8 byte offset. It is always maintained at a character boundary.
pub input_cursor_pos: usize,
pub focus: Focus,
pub modal: Option<Modal>,
pub show_help: bool,
pub chat_scroll_from_bottom: u16,
pub chat_scroll_offset: u16,
pub session_scroll_offset: u16,
pub should_quit: bool,
// Quit confirmation state (double Ctrl+C to exit)
pub ctrl_c_count: u8,
pub pending_quit: bool,
pub connected: bool,
pub pending_responses: usize,
pub status_message: Option<String>,
// Command menu state
pub commands: Vec<SlashCommandInfo>,
pub show_command_menu: bool,
pub selected_command_idx: usize,
pub selected_command_idx: u16,
}
impl App {
pub fn new() -> Self {
pub fn new(gateway_url: String) -> Self {
Self {
gateway_url,
ws_sender: None,
ws_receiver: None,
current_session_id: None,
sessions: Vec::new(),
selected_session: 0,
show_archived: false,
messages: VecDeque::new(),
focus: FocusArea::InputArea,
input: String::new(),
input_cursor_pos: 0,
focus: Focus::Input,
modal: None,
show_help: false,
chat_scroll_from_bottom: 0,
chat_scroll_offset: 0,
session_scroll_offset: 0,
should_quit: false,
ctrl_c_count: 0,
pending_quit: false,
connected: true,
pending_responses: 0,
status_message: Some("正在加载会话…".to_string()),
commands: Vec::new(),
show_command_menu: false,
selected_command_idx: 0,
@ -104,161 +91,67 @@ impl App {
pub fn add_message(&mut self, role: MessageRole, content: String) {
self.messages.push_back(ChatMessage { role, content });
while self.messages.len() > MAX_MESSAGES {
self.messages.pop_front();
}
self.chat_scroll_from_bottom = 0;
}
pub fn set_history(&mut self, session_id: &str, messages: Vec<HistoryMessage>) {
if self.current_session_id.as_deref() != Some(session_id) {
return;
}
self.messages = messages
.into_iter()
.filter_map(|message| {
let role = match message.role.as_str() {
"user" => MessageRole::User,
"assistant" => MessageRole::Assistant,
"system" | "tool" => MessageRole::System,
_ => return None,
};
Some(ChatMessage {
role,
content: message.content,
})
})
.collect();
while self.messages.len() > MAX_MESSAGES {
self.messages.pop_front();
}
self.chat_scroll_from_bottom = 0;
self.status_message = None;
self.chat_scroll_offset = 0;
}
pub fn set_sessions(&mut self, sessions: Vec<SessionSummary>) {
self.sessions = sessions;
if let Some(current) = &self.current_session_id
&& let Some(index) = self
.sessions
.iter()
.position(|session| &session.session_id == current)
{
self.selected_session = index;
}
self.clamp_session_selection();
}
pub fn set_current_session(&mut self, session_id: Option<String>) {
if self.current_session_id != session_id {
self.current_session_id = session_id;
self.messages.clear();
self.chat_scroll_from_bottom = 0;
}
if let Some(current) = &self.current_session_id
&& let Some(index) = self
.sessions
.iter()
.position(|session| &session.session_id == current)
{
self.selected_session = index;
}
self.current_session_id = session_id;
self.messages.clear();
}
pub fn current_title(&self) -> &str {
self.current_session_id
.as_ref()
.and_then(|id| {
self.sessions
.iter()
.find(|session| &session.session_id == id)
})
.map(|session| session.title.as_str())
.unwrap_or("新对话")
pub fn scroll_chat_up(&mut self) {
self.chat_scroll_offset = self.chat_scroll_offset.saturating_add(1);
}
pub fn selected_session_id(&self) -> Option<String> {
self.sessions
.get(self.selected_session)
.map(|session| session.session_id.clone())
pub fn scroll_chat_down(&mut self) {
self.chat_scroll_offset = self.chat_scroll_offset.saturating_sub(1);
}
pub fn select_next_session(&mut self) {
if !self.sessions.is_empty() {
self.selected_session = (self.selected_session + 1).min(self.sessions.len() - 1);
}
pub fn scroll_session_up(&mut self) {
self.session_scroll_offset = self.session_scroll_offset.saturating_add(1);
}
pub fn select_previous_session(&mut self) {
self.selected_session = self.selected_session.saturating_sub(1);
}
fn clamp_session_selection(&mut self) {
self.selected_session = self
.selected_session
.min(self.sessions.len().saturating_sub(1));
}
pub fn scroll_chat_up(&mut self, lines: u16) {
self.chat_scroll_from_bottom = self.chat_scroll_from_bottom.saturating_add(lines);
}
pub fn scroll_chat_down(&mut self, lines: u16) {
self.chat_scroll_from_bottom = self.chat_scroll_from_bottom.saturating_sub(lines);
pub fn scroll_session_down(&mut self) {
self.session_scroll_offset = self.session_scroll_offset.saturating_sub(1);
}
pub fn input_insert_char(&mut self, c: char) {
if self.input.len() + c.len_utf8() <= MAX_INPUT_BYTES {
self.input.insert(self.input_cursor_pos, c);
self.input_cursor_pos += c.len_utf8();
}
}
pub fn input_insert_str(&mut self, text: &str) {
let remaining = MAX_INPUT_BYTES.saturating_sub(self.input.len());
let mut end = text.len().min(remaining);
while !text.is_char_boundary(end) {
end -= 1;
}
self.input.insert_str(self.input_cursor_pos, &text[..end]);
self.input_cursor_pos += end;
self.input.insert(self.input_cursor_pos, c);
self.input_cursor_pos += 1;
}
pub fn input_delete_char(&mut self) {
if let Some(previous) = previous_boundary(&self.input, self.input_cursor_pos) {
self.input.drain(previous..self.input_cursor_pos);
self.input_cursor_pos = previous;
}
}
pub fn input_delete_forward(&mut self) {
if let Some(next) = next_boundary(&self.input, self.input_cursor_pos) {
self.input.drain(self.input_cursor_pos..next);
if self.input_cursor_pos > 0 {
self.input.remove(self.input_cursor_pos - 1);
self.input_cursor_pos -= 1;
}
}
pub fn input_move_cursor_left(&mut self) {
if let Some(previous) = previous_boundary(&self.input, self.input_cursor_pos) {
self.input_cursor_pos = previous;
}
self.input_cursor_pos = self.input_cursor_pos.saturating_sub(1);
}
pub fn input_move_cursor_right(&mut self) {
if let Some(next) = next_boundary(&self.input, self.input_cursor_pos) {
self.input_cursor_pos = next;
if self.input_cursor_pos < self.input.len() {
self.input_cursor_pos += 1;
}
}
pub fn input_move_cursor_to_start(&mut self) {
let line_start = self.input[..self.input_cursor_pos]
.rfind('\n')
.map_or(0, |index| index + 1);
self.input_cursor_pos = line_start;
self.input_cursor_pos = 0;
}
pub fn input_move_cursor_to_end(&mut self) {
let tail = &self.input[self.input_cursor_pos..];
self.input_cursor_pos += tail.find('\n').unwrap_or(tail.len());
self.input_cursor_pos = self.input.len();
}
pub fn input_clear(&mut self) {
self.input.clear();
self.input_cursor_pos = 0;
}
pub fn take_input(&mut self) -> String {
@ -267,108 +160,86 @@ impl App {
input
}
pub fn handle_ctrl_c_for_quit(&mut self) {
pub fn toggle_help(&mut self) {
self.show_help = !self.show_help;
}
pub fn quit(&mut self) {
self.should_quit = true;
}
/// Handle Ctrl+C for quit confirmation (requires double press)
pub fn handle_ctrl_c_for_quit(&mut self) -> bool {
if self.pending_quit {
self.should_quit = true;
self.ctrl_c_count += 1;
if self.ctrl_c_count >= 2 {
self.should_quit = true;
return true;
}
false
} else {
self.pending_quit = true;
self.status_message = Some("再次按 Ctrl+C 退出".to_string());
self.ctrl_c_count = 1;
false
}
}
/// Cancel pending quit if user presses any other key
pub fn cancel_pending_quit(&mut self) {
if self.pending_quit {
self.pending_quit = false;
self.status_message = None;
}
self.pending_quit = false;
self.ctrl_c_count = 0;
}
// Command menu methods
pub fn set_commands(&mut self, commands: Vec<SlashCommandInfo>) {
self.commands = commands;
}
pub fn get_filtered_commands(&self) -> Vec<&SlashCommandInfo> {
let query = self
.input
.split_whitespace()
.next()
.unwrap_or("")
.to_lowercase();
let input_lower = self.input.to_lowercase();
self.commands
.iter()
.filter(|command| {
command.name.to_lowercase().contains(&query)
|| command.description.to_lowercase().contains(&query)
|| command
.filter(|cmd| {
cmd.name.to_lowercase().contains(&input_lower)
|| cmd.description.to_lowercase().contains(&input_lower)
|| cmd
.aliases
.iter()
.any(|alias| alias.to_lowercase().starts_with(&query))
.any(|a| a.to_lowercase().contains(&input_lower))
})
.collect()
}
pub fn select_next_command(&mut self) {
let len = self.get_filtered_commands().len();
if len > 0 {
self.selected_command_idx = (self.selected_command_idx + 1) % len;
let filtered = self.get_filtered_commands();
if !filtered.is_empty() {
self.selected_command_idx = (self.selected_command_idx + 1) % filtered.len() as u16;
}
}
pub fn select_previous_command(&mut self) {
let len = self.get_filtered_commands().len();
if len > 0 {
self.selected_command_idx = (self.selected_command_idx + len - 1) % len;
pub fn select_prev_command(&mut self) {
let filtered = self.get_filtered_commands();
if !filtered.is_empty() {
self.selected_command_idx = if self.selected_command_idx == 0 {
filtered.len() as u16 - 1
} else {
self.selected_command_idx - 1
};
}
}
pub fn insert_selected_command(&mut self) {
let command = self
.get_filtered_commands()
.get(self.selected_command_idx)
.and_then(|command| command.aliases.first().cloned());
if let Some(command) = command {
self.input = format!("{command} ");
self.input_cursor_pos = self.input.len();
pub fn get_selected_command(&self) -> Option<&SlashCommandInfo> {
let filtered = self.get_filtered_commands();
filtered.get(self.selected_command_idx as usize).copied()
}
pub fn insert_command(&mut self) {
if let Some(cmd) = self.get_selected_command() {
// Use the first alias as the command to insert
if let Some(alias) = cmd.aliases.first() {
self.input = alias.clone();
self.input_cursor_pos = self.input.len();
}
}
}
}
fn previous_boundary(value: &str, offset: usize) -> Option<usize> {
value[..offset]
.char_indices()
.next_back()
.map(|(index, _)| index)
}
fn next_boundary(value: &str, offset: usize) -> Option<usize> {
value[offset..]
.chars()
.next()
.map(|character| offset + character.len_utf8())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unicode_cursor_edits_only_at_character_boundaries() {
let mut app = App::new();
app.input_insert_str("你a🙂");
app.input_move_cursor_left();
app.input_delete_char();
assert_eq!(app.input, "你🙂");
assert!(app.input.is_char_boundary(app.input_cursor_pos));
app.input_delete_forward();
assert_eq!(app.input, "");
}
#[test]
fn stale_history_does_not_replace_the_active_dialog() {
let mut app = App::new();
app.set_current_session(Some("new".to_string()));
app.add_message(MessageRole::User, "keep".to_string());
app.set_history("old", Vec::new());
assert_eq!(app.messages.len(), 1);
}
}

View File

@ -3,60 +3,35 @@ use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
text::Line,
widgets::{Block, Borders, List, ListItem},
};
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
let content_width = area.width.saturating_sub(4).max(1) as usize;
let mut lines = Vec::new();
if app.messages.is_empty() {
lines.push(Line::from("开始一段对话,或从左侧选择已有会话。"));
}
for message in &app.messages {
let (label, color) = match message.role {
MessageRole::User => ("", Color::Blue),
MessageRole::Assistant => ("PicoBot", Color::Green),
MessageRole::System => ("系统", Color::Yellow),
};
lines.push(Line::from(Span::styled(
label,
Style::default().fg(color).add_modifier(Modifier::BOLD),
)));
for source_line in message.content.lines() {
let wrapped = textwrap::wrap(source_line, content_width);
if wrapped.is_empty() {
lines.push(Line::from(""));
} else {
lines.extend(
wrapped
.into_iter()
.map(|line| Line::from(line.into_owned())),
);
}
}
lines.push(Line::from(""));
}
if app.pending_responses > 0 {
lines.push(Line::from(Span::styled(
"● 正在思考…",
Style::default().fg(Color::Cyan),
)));
}
pub fn render(f: &mut Frame, area: Rect, app: &App) {
let items: Vec<ListItem> = app
.messages
.iter()
.map(|msg| {
let (prefix, color) = match msg.role {
MessageRole::User => ("[User] ", Color::Blue),
MessageRole::Assistant => ("[Assistant] ", Color::Green),
MessageRole::System => ("[System] ", Color::Red),
};
let visible_height = area.height.saturating_sub(2);
let line_count = u16::try_from(lines.len()).unwrap_or(u16::MAX);
let max_scroll = line_count.saturating_sub(visible_height);
let scroll = max_scroll.saturating_sub(app.chat_scroll_from_bottom.min(max_scroll));
let title = if app.chat_scroll_from_bottom > 0 {
" 对话 · 已暂停自动滚动 "
} else {
" 对话 "
};
frame.render_widget(
Paragraph::new(lines)
.scroll((scroll, 0))
.block(Block::default().title(title).borders(Borders::ALL)),
area,
);
let content = vec![
Line::from(vec![ratatui::text::Span::styled(
prefix,
Style::default().fg(color).add_modifier(Modifier::BOLD),
)]),
Line::from(msg.content.as_str()),
Line::from(""),
];
ListItem::new(content)
})
.collect();
let list = List::new(items).block(Block::default().title("Conversation").borders(Borders::ALL));
f.render_widget(list, area);
}

View File

@ -4,7 +4,7 @@ use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState},
widgets::{Block, Borders, List, ListItem},
};
pub fn render(f: &mut Frame, area: Rect, app: &App) {
@ -18,7 +18,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
.iter()
.enumerate()
.map(|(i, cmd)| {
let is_selected = i == app.selected_command_idx;
let is_selected = i == app.selected_command_idx as usize;
let style = if is_selected {
Style::default()
.fg(Color::White)
@ -48,6 +48,5 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
)
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
let mut state = ListState::default().with_selected(Some(app.selected_command_idx));
f.render_stateful_widget(list, area, &mut state);
f.render_widget(list, area);
}

View File

@ -2,52 +2,41 @@ use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
widgets::{Block, Borders, Clear, List, ListItem},
};
pub fn render(frame: &mut Frame, area: Rect) {
frame.render_widget(Clear, area);
let lines = vec![
Line::from(Span::styled(
"全局",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(" F1 / Ctrl+H 帮助 Tab 切换输入/会话焦点"),
Line::from(" Ctrl+N 新会话 Ctrl+S 聚焦会话列表"),
Line::from(" Ctrl+R 重命名 Ctrl+O 显示/隐藏归档"),
Line::from(" Ctrl+A 归档 Ctrl+D 删除"),
Line::from(" Ctrl+L 清空历史 Ctrl+C 两次 退出"),
Line::from(""),
Line::from(Span::styled(
"输入",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(" Enter 发送 Shift/Alt+Enter 换行"),
Line::from(" / 打开命令菜单 Tab 补全命令"),
Line::from(" PageUp/PageDown 滚动对话Ctrl+↑/↓ 微调"),
Line::from(""),
Line::from(Span::styled(
"会话列表",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(" ↑/↓ 或 j/k 选择 Enter 切换"),
Line::from(" n 新建 · r 重命名 · a 归档 · d 删除"),
Line::from(""),
Line::from(Span::styled(
"Esc / F1 关闭帮助",
Style::default().fg(Color::DarkGray),
)),
pub fn render(f: &mut Frame, area: Rect) {
f.render_widget(Clear, area);
let help_text = vec![
ListItem::new("Commands:"),
ListItem::new(" /new [title] - Archive current, start new"),
ListItem::new(" /sessions - List all conversations"),
ListItem::new(" /switch <id> - Switch to conversation"),
ListItem::new(" /rename <t> - Rename current conversation"),
ListItem::new(" /archive - Archive current conversation"),
ListItem::new(" /delete - Delete current conversation"),
ListItem::new(" /compact - Trigger context compression"),
ListItem::new(" /info - Show session information"),
ListItem::new(""),
ListItem::new("Keyboard:"),
ListItem::new(" Enter - Send message"),
ListItem::new(" Ctrl+C ×2 - Quit"),
ListItem::new(" ? - Show help"),
ListItem::new(" Arrow keys - Navigate"),
ListItem::new(" / - Show command menu"),
];
frame.render_widget(
Paragraph::new(lines)
.block(
Block::default()
.title(" 帮助 ")
.title_style(Style::default().fg(Color::Cyan))
.borders(Borders::ALL),
let list = List::new(help_text).block(
Block::default()
.title("Help")
.title_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.wrap(Wrap { trim: false }),
area,
.borders(Borders::ALL),
);
f.render_widget(list, area);
}

View File

@ -1,81 +1,21 @@
use crate::client::tui::app::{App, Focus};
use crate::client::tui::app::App;
use ratatui::{
Frame,
layout::Rect,
style::{Color, Style},
widgets::{Block, Borders, Paragraph, Wrap},
widgets::{Block, Borders, Paragraph},
};
use unicode_width::UnicodeWidthChar;
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
let active = app.focus == Focus::Input && app.modal.is_none() && !app.show_help;
let border_style = if active {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
let title = if app.connected {
" 输入 · Enter 发送 / Shift+Enter 换行 "
} else {
" 输入 · Gateway 已断开 "
};
let inner_width = area.width.saturating_sub(2).max(1);
let inner_height = area.height.saturating_sub(2).max(1);
let (cursor_row, cursor_col) = cursor_position(&app.input[..app.input_cursor_pos], inner_width);
let vertical_scroll = cursor_row.saturating_sub(inner_height.saturating_sub(1));
frame.render_widget(
Paragraph::new(app.input.as_str())
.scroll((vertical_scroll, 0))
.wrap(Wrap { trim: false })
.style(Style::default().fg(Color::White))
.block(
Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(border_style),
),
area,
);
pub fn render(f: &mut Frame, area: Rect, app: &App) {
let input = Paragraph::new(app.input.as_str())
.style(Style::default().fg(Color::White))
.block(Block::default().title("Input").borders(Borders::ALL));
if active {
let x = area.x + 1 + cursor_col.min(inner_width.saturating_sub(1));
let y = area.y + 1 + cursor_row.saturating_sub(vertical_scroll);
if x < area.right() && y < area.bottom() {
frame.set_cursor_position((x, y));
}
}
}
fn cursor_position(value: &str, width: u16) -> (u16, u16) {
let mut row = 0_u16;
let mut column = 0_u16;
for character in value.chars() {
if character == '\n' {
row = row.saturating_add(1);
column = 0;
continue;
}
let character_width = character.width().unwrap_or(0) as u16;
if column > 0 && column.saturating_add(character_width) > width {
row = row.saturating_add(1);
column = 0;
}
column = column.saturating_add(character_width);
if column >= width {
row = row.saturating_add(column / width);
column %= width;
}
}
(row, column)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cursor_accounts_for_wide_characters_and_wrapping() {
assert_eq!(cursor_position("你a", 10), (0, 3));
assert_eq!(cursor_position("1234你", 5), (1, 2));
f.render_widget(input, area);
let cursor_x = area.x + 1 + app.input_cursor_pos as u16;
let cursor_y = area.y + 1;
if cursor_x < area.right() && cursor_y < area.bottom() {
f.set_cursor_position((cursor_x, cursor_y));
}
}

View File

@ -1,60 +1,42 @@
use crate::client::tui::app::{App, Focus};
use crate::client::tui::app::App;
use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState},
widgets::{Block, Borders, List, ListItem},
};
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
let items = app.sessions.iter().enumerate().map(|(index, session)| {
let selected = app.focus == Focus::Sessions && index == app.selected_session;
let current = app.current_session_id.as_ref() == Some(&session.session_id);
let marker = if current { "" } else { " " };
let archived = if session.archived_at.is_some() {
" 归档"
} else {
""
};
let style = if selected {
Style::default().fg(Color::Black).bg(Color::Cyan)
} else if current {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
ListItem::new(vec![
Line::from(vec![
Span::raw(format!("{marker} ")),
Span::styled(session.title.clone(), style),
]),
Line::from(Span::styled(
format!(" {}{archived}", session.message_count),
Style::default().fg(Color::DarkGray),
)),
])
.style(style)
});
let mode = if app.show_archived {
"全部"
} else {
"活跃"
};
let border = if app.focus == Focus::Sessions {
Style::default().fg(Color::Cyan)
} else {
Style::default()
};
let list = List::new(items).block(
Block::default()
.title(format!(" 会话 · {mode} "))
.borders(Borders::ALL)
.border_style(border),
);
let selected = (!app.sessions.is_empty()).then_some(app.selected_session);
let mut state = ListState::default().with_selected(selected);
frame.render_stateful_widget(list, area, &mut state);
pub fn render(f: &mut Frame, area: Rect, app: &App) {
let items: Vec<ListItem> = app
.sessions
.iter()
.map(|session| {
let is_current = app.current_session_id.as_ref() == Some(&session.session_id);
let archived = session.archived_at.is_some();
let mut content = if is_current {
format!("{}", session.title)
} else {
format!(" {}", session.title)
};
if archived {
content.push_str(" [archived]");
}
let style = if is_current {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
ListItem::new(content).style(style)
})
.collect();
let list = List::new(items).block(Block::default().title("Sessions").borders(Borders::ALL));
f.render_widget(list, area);
}

View File

@ -3,34 +3,44 @@ use ratatui::{
Frame,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
let connection = if app.connected {
Span::styled("● 已连接", Style::default().fg(Color::Green))
pub fn render(f: &mut Frame, area: Rect, app: &App) {
let (title, style) = if app.pending_quit {
let msg = if let Some(session_id) = &app.current_session_id {
format!(
"PicoBot | Session: {} | Press Ctrl+C again to quit",
session_id
)
} else {
"PicoBot | Press Ctrl+C again to quit".to_string()
};
(
msg,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
} else if let Some(session_id) = &app.current_session_id {
(
format!("PicoBot | Session: {}", session_id),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
} else {
Span::styled("● 已断开", Style::default().fg(Color::Red))
(
"PicoBot".to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
};
let pending = if app.pending_responses > 0 {
format!(" · {} 个请求处理中", app.pending_responses)
} else {
String::new()
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
" PicoBot ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(format!("{} ", app.current_title())),
connection,
Span::styled(pending, Style::default().fg(Color::DarkGray)),
]))
.block(Block::default().borders(Borders::ALL)),
area,
);
let paragraph = Paragraph::new(title)
.style(style)
.block(Block::default().borders(Borders::ALL));
f.render_widget(paragraph, area);
}

View File

@ -1,341 +1,134 @@
use crate::client::tui::app::{App, ConfirmAction, Focus, MessageRole, Modal};
use crate::protocol::{WsInbound, serialize_inbound};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::client::tui::app::{App, MessageRole};
use crate::protocol::WsInbound;
use crate::protocol::serialize_inbound;
use crossterm::event::{KeyCode, KeyEvent};
use futures_util::SinkExt;
use tokio_tungstenite::tungstenite::Message;
pub async fn handle_key_event(app: &mut App, key: KeyEvent) {
if app.show_help {
if matches!(key.code, KeyCode::Esc | KeyCode::F(1))
|| (key.code == KeyCode::Char('q') && key.modifiers.is_empty())
{
app.show_help = false;
}
return;
}
if app.modal.is_some() {
handle_modal_key(app, key).await;
return;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
if ctrl && key.code == KeyCode::Char('c') {
app.handle_ctrl_c_for_quit();
return;
}
app.cancel_pending_quit();
if matches!(key.code, KeyCode::F(1)) || (ctrl && key.code == KeyCode::Char('h')) {
app.show_help = true;
return;
}
if app.show_command_menu {
match key.code {
KeyCode::Esc => close_command_menu(app),
KeyCode::Up => app.select_previous_command(),
KeyCode::Down => app.select_next_command(),
KeyCode::Tab => {
app.insert_selected_command();
close_command_menu(app);
KeyCode::Esc | KeyCode::Char('q') => {
app.toggle_help();
}
KeyCode::Enter if key.modifiers.is_empty() => {
app.insert_selected_command();
close_command_menu(app);
}
_ => handle_input_key(app, key).await,
}
return;
}
if ctrl {
match key.code {
KeyCode::Char('n') => {
send(app, WsInbound::CreateSession { title: None }).await;
app.status_message = Some("正在创建会话…".to_string());
}
KeyCode::Char('s') => app.focus = Focus::Sessions,
KeyCode::Char('r') => open_rename(app),
KeyCode::Char('a') => app.modal = Some(Modal::Confirm(ConfirmAction::Archive)),
KeyCode::Char('d') => app.modal = Some(Modal::Confirm(ConfirmAction::Delete)),
KeyCode::Char('l') => app.modal = Some(Modal::Confirm(ConfirmAction::ClearHistory)),
KeyCode::Char('o') => {
app.show_archived = !app.show_archived;
request_session_list(app).await;
}
KeyCode::Char('u') if app.focus == Focus::Input => {
app.input.clear();
app.input_cursor_pos = 0;
}
KeyCode::Up => app.scroll_chat_up(3),
KeyCode::Down => app.scroll_chat_down(3),
_ => {}
}
return;
}
match key.code {
KeyCode::Tab => {
app.focus = match app.focus {
Focus::Input => Focus::Sessions,
Focus::Sessions => Focus::Input,
};
}
KeyCode::Esc => app.focus = Focus::Input,
KeyCode::PageUp => app.scroll_chat_up(10),
KeyCode::PageDown => app.scroll_chat_down(10),
KeyCode::Home if app.focus == Focus::Sessions => app.selected_session = 0,
KeyCode::End if app.focus == Focus::Sessions => {
app.selected_session = app.sessions.len().saturating_sub(1);
}
_ if app.focus == Focus::Sessions => handle_session_key(app, key).await,
_ => handle_input_key(app, key).await,
}
}
pub fn handle_paste(app: &mut App, text: &str) {
if let Some(Modal::Rename { input, cursor }) = &mut app.modal {
let remaining = 256_usize.saturating_sub(input.len());
let mut end = text.len().min(remaining);
while !text.is_char_boundary(end) {
end -= 1;
}
input.insert_str(*cursor, &text[..end]);
*cursor += end;
} else if app.focus == Focus::Input {
app.input_insert_str(text);
update_command_menu(app);
}
}
async fn handle_session_key(app: &mut App, key: KeyEvent) {
match key.code {
KeyCode::Up | KeyCode::Char('k') => app.select_previous_session(),
KeyCode::Down | KeyCode::Char('j') => app.select_next_session(),
KeyCode::Enter => {
if let Some(session_id) = app.selected_session_id()
&& app.current_session_id.as_deref() != Some(&session_id)
{
app.status_message = Some("正在载入会话…".to_string());
send(app, WsInbound::LoadSession { session_id }).await;
if app.show_command_menu {
match key.code {
KeyCode::Esc => {
app.show_command_menu = false;
app.selected_command_idx = 0;
}
KeyCode::Up => {
app.select_prev_command();
}
KeyCode::Down => {
app.select_next_command();
}
KeyCode::Enter => {
app.insert_command();
app.show_command_menu = false;
app.selected_command_idx = 0;
}
KeyCode::Tab => {
app.insert_command();
}
_ => {
// Handle normal input and check if menu should stay open
handle_normal_input(app, key).await;
}
}
KeyCode::Char('n') => {
send(app, WsInbound::CreateSession { title: None }).await;
}
KeyCode::Char('r') => open_rename(app),
KeyCode::Char('a') => app.modal = Some(Modal::Confirm(ConfirmAction::Archive)),
KeyCode::Char('d') => app.modal = Some(Modal::Confirm(ConfirmAction::Delete)),
_ => {}
return;
}
handle_normal_input(app, key).await;
}
async fn handle_input_key(app: &mut App, key: KeyEvent) {
async fn handle_normal_input(app: &mut App, key: KeyEvent) {
// Handle Ctrl+C for quit (double press to exit)
let is_ctrl_c = key.code == KeyCode::Char('c')
&& key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL);
if is_ctrl_c {
if app.handle_ctrl_c_for_quit() {
return;
}
} else {
app.cancel_pending_quit();
}
match key.code {
KeyCode::Char('?') => {
app.toggle_help();
}
KeyCode::Char(c) => {
app.input_insert_char(c);
update_command_menu(app);
// Show command menu when input starts with /
if !app.show_command_menu
&& (app.input == "/" || (app.input.len() > 1 && app.input.starts_with('/')))
{
app.show_command_menu = true;
app.selected_command_idx = 0;
} else if app.show_command_menu && !app.input.starts_with('/') {
app.show_command_menu = false;
}
}
KeyCode::Backspace => {
app.input_delete_char();
update_command_menu(app);
// Hide menu if input no longer starts with /
if app.show_command_menu && !app.input.starts_with('/') {
app.show_command_menu = false;
app.selected_command_idx = 0;
}
}
KeyCode::Delete => app.input_delete_forward(),
KeyCode::Left => app.input_move_cursor_left(),
KeyCode::Right => app.input_move_cursor_right(),
KeyCode::Home => app.input_move_cursor_to_start(),
KeyCode::End => app.input_move_cursor_to_end(),
KeyCode::Up => app.scroll_chat_up(1),
KeyCode::Down => app.scroll_chat_down(1),
KeyCode::Enter
if key.modifiers.contains(KeyModifiers::SHIFT)
|| key.modifiers.contains(KeyModifiers::ALT) =>
{
app.input_insert_char('\n');
KeyCode::Left => {
app.input_move_cursor_left();
}
KeyCode::Right => {
app.input_move_cursor_right();
}
KeyCode::Home => {
app.input_move_cursor_to_start();
}
KeyCode::End => {
app.input_move_cursor_to_end();
}
KeyCode::Up => {
app.scroll_chat_up();
}
KeyCode::Down => {
app.scroll_chat_down();
}
KeyCode::Enter => {
let input = app.take_input();
close_command_menu(app);
if !input.trim().is_empty() {
app.add_message(MessageRole::User, input.clone());
app.pending_responses = app.pending_responses.saturating_add(1);
app.status_message = Some("PicoBot 正在处理…".to_string());
let sent = send(
app,
WsInbound::UserInput {
content: input,
channel: None,
// Session routing is owned by the server. A full session
// id is not a chat id and must never be sent here.
chat_id: None,
sender_id: None,
},
)
.await;
if !sent {
app.pending_responses = app.pending_responses.saturating_sub(1);
}
app.show_command_menu = false;
app.selected_command_idx = 0;
if !input.is_empty() {
process_input(app, input).await;
}
}
_ => {}
}
}
async fn handle_modal_key(app: &mut App, key: KeyEvent) {
match app.modal.take() {
Some(Modal::Confirm(action)) => match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
let target = target_session_id(app);
let message = match action {
ConfirmAction::Archive => target.map(|session_id| WsInbound::ArchiveSession {
session_id: Some(session_id),
}),
ConfirmAction::Delete => target.map(|session_id| WsInbound::DeleteSession {
session_id: Some(session_id),
}),
ConfirmAction::ClearHistory => {
target.map(|session_id| WsInbound::ClearHistory {
chat_id: None,
session_id: Some(session_id),
})
}
};
if let Some(message) = message {
send(app, message).await;
app.status_message = Some("正在更新会话…".to_string());
}
}
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => {}
_ => app.modal = Some(Modal::Confirm(action)),
},
Some(Modal::Rename {
mut input,
mut cursor,
}) => match key.code {
KeyCode::Esc => {}
KeyCode::Enter => {
let title = input.trim().to_string();
if !title.is_empty() {
send(
app,
WsInbound::RenameSession {
session_id: target_session_id(app),
title,
},
)
.await;
}
}
KeyCode::Char(character) => {
input.insert(cursor, character);
cursor += character.len_utf8();
app.modal = Some(Modal::Rename { input, cursor });
}
KeyCode::Backspace => {
if let Some((index, _)) = input[..cursor].char_indices().next_back() {
input.drain(index..cursor);
cursor = index;
}
app.modal = Some(Modal::Rename { input, cursor });
}
KeyCode::Delete => {
if let Some(character) = input[cursor..].chars().next() {
input.drain(cursor..cursor + character.len_utf8());
}
app.modal = Some(Modal::Rename { input, cursor });
}
KeyCode::Left => {
if let Some((index, _)) = input[..cursor].char_indices().next_back() {
cursor = index;
}
app.modal = Some(Modal::Rename { input, cursor });
}
KeyCode::Right => {
if let Some(character) = input[cursor..].chars().next() {
cursor += character.len_utf8();
}
app.modal = Some(Modal::Rename { input, cursor });
}
_ => app.modal = Some(Modal::Rename { input, cursor }),
},
None => {}
}
}
fn open_rename(app: &mut App) {
if target_session_id(app).is_some() {
let input = if app.focus == Focus::Sessions {
app.sessions
.get(app.selected_session)
.map(|session| session.title.clone())
.unwrap_or_default()
} else {
app.current_title().to_string()
async fn process_input(app: &mut App, input: String) {
app.add_message(MessageRole::User, input.clone());
if let Some(sender) = &mut app.ws_sender {
let inbound = WsInbound::UserInput {
content: input,
chat_id: app.current_session_id.clone(),
channel: None,
sender_id: None,
};
let cursor = input.len();
app.modal = Some(Modal::Rename { input, cursor });
}
}
fn target_session_id(app: &App) -> Option<String> {
if app.focus == Focus::Sessions {
app.selected_session_id()
} else {
app.current_session_id.clone()
}
}
fn update_command_menu(app: &mut App) {
app.show_command_menu = app.input.starts_with('/') && !app.input.contains('\n');
app.selected_command_idx = app
.selected_command_idx
.min(app.get_filtered_commands().len().saturating_sub(1));
}
fn close_command_menu(app: &mut App) {
app.show_command_menu = false;
app.selected_command_idx = 0;
}
pub async fn request_session_list(app: &mut App) {
send(
app,
WsInbound::ListSessions {
include_archived: app.show_archived,
},
)
.await;
}
pub async fn request_history(app: &mut App, session_id: String) {
send(
app,
WsInbound::GetSessionHistory {
session_id,
limit: Some(1_000),
},
)
.await;
}
pub async fn send(app: &mut App, inbound: WsInbound) -> bool {
let serialized = match serialize_inbound(&inbound) {
Ok(serialized) => serialized,
Err(error) => {
app.status_message = Some(format!("请求编码失败:{error}"));
return false;
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender
.send(tokio_tungstenite::tungstenite::Message::Text(text.into()))
.await;
}
};
let Some(sender) = &mut app.ws_sender else {
app.connected = false;
app.status_message = Some("Gateway 已断开".to_string());
return false;
};
if let Err(error) = sender.send(Message::Text(serialized.into())).await {
app.connected = false;
app.status_message = Some(format!("发送失败:{error}"));
return false;
}
true
}

View File

@ -0,0 +1,5 @@
#![allow(dead_code)]
pub fn render_markdown(content: &str) -> String {
content.to_string()
}

View File

@ -1,4 +1,5 @@
pub mod app;
pub mod components;
pub mod event;
pub mod markdown;
pub mod ui;

View File

@ -1,165 +1,73 @@
use crate::client::tui::app::{App, ConfirmAction, Focus, Modal};
use crate::client::tui::app::App;
use crate::client::tui::components::*;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
};
use unicode_width::UnicodeWidthStr;
pub fn render_ui(frame: &mut Frame, app: &App) {
let area = frame.area();
let input_height = app.input.lines().count().clamp(1, 6) as u16 + 2;
let rows = Layout::default()
pub fn render_ui(f: &mut Frame, app: &App) {
let size = f.area();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(input_height),
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(5),
])
.split(area);
.split(size);
title_bar::render(frame, rows[0], app);
if area.width >= 80 {
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(28), Constraint::Min(30)])
.split(rows[1]);
session_list::render(frame, columns[0], app);
chat_history::render(frame, columns[1], app);
} else {
chat_history::render(frame, rows[1], app);
}
input_area::render(frame, rows[2], app);
render_footer(frame, rows[3], app);
title_bar::render(f, chunks[0], app);
let middle_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
.split(chunks[1]);
session_list::render(f, middle_chunks[0], app);
chat_history::render(f, middle_chunks[1], app);
input_area::render(f, chunks[2], app);
// Render command menu if needed - position above input area
if app.show_command_menu && !app.get_filtered_commands().is_empty() {
let height = (app.get_filtered_commands().len().min(6) + 2) as u16;
let menu_area = Rect::new(
rows[2].x.saturating_add(1),
rows[2].y.saturating_sub(height),
rows[2].width.saturating_sub(2),
height,
);
command_menu::render(frame, menu_area, app);
let menu_area = menu_above_input(chunks[2]);
command_menu::render(f, menu_area, app);
}
if app.show_help {
help_popup::render(frame, centered_rect(72, 26, area));
}
if let Some(modal) = &app.modal {
render_modal(frame, centered_rect(64, 7, area), modal);
let help_area = centered_rect(60, 60, size);
help_popup::render(f, help_area);
}
}
fn render_footer(frame: &mut Frame, area: Rect, app: &App) {
let focus = match app.focus {
Focus::Input => "输入",
Focus::Sessions => "会话",
};
let status =
app.status_message
.as_deref()
.unwrap_or(if app.connected { "就绪" } else { "已断开" });
let line = Line::from(vec![
Span::styled(
format!(" {focus} "),
Style::default().fg(Color::Black).bg(Color::Cyan),
),
Span::raw(format!(" {status}")),
Span::styled(
" F1 帮助 Tab 切换焦点 Ctrl+N 新会话 ",
Style::default().fg(Color::DarkGray),
),
]);
frame.render_widget(Paragraph::new(line), area);
}
fn menu_above_input(input_area: Rect) -> Rect {
let max_commands = 6; // Show up to 6 commands
let menu_height = max_commands + 2; // +2 for borders
fn render_modal(frame: &mut Frame, area: Rect, modal: &Modal) {
frame.render_widget(Clear, area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));
match modal {
Modal::Rename { input, cursor } => {
frame.render_widget(
Paragraph::new(vec![
Line::from(Span::styled(
"重命名会话",
Style::default().add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(input.as_str()),
Line::from(Span::styled(
"Enter 保存 · Esc 取消",
Style::default().fg(Color::DarkGray),
)),
])
.block(block)
.wrap(Wrap { trim: false }),
area,
);
let cursor_x = area.x
+ 1
+ UnicodeWidthStr::width(&input[..*cursor])
.min(area.width.saturating_sub(3) as usize) as u16;
let cursor_y = area.y.saturating_add(3);
if cursor_x < area.right() && cursor_y < area.bottom() {
frame.set_cursor_position((cursor_x, cursor_y));
}
}
Modal::Confirm(action) => {
let prompt = match action {
ConfirmAction::Archive => "归档所选会话?",
ConfirmAction::Delete => "永久删除所选会话?此操作不可撤销。",
ConfirmAction::ClearHistory => "清空所选会话的全部历史?",
};
frame.render_widget(
Paragraph::new(vec![
Line::from(""),
Line::from(prompt),
Line::from(""),
Line::from(Span::styled(
"Y / Enter 确认 · N / Esc 取消",
Style::default().fg(Color::DarkGray),
)),
])
.block(block)
.wrap(Wrap { trim: false }),
area,
);
}
Rect {
x: input_area.x + 1,
y: input_area.y.saturating_sub(menu_height),
width: input_area.width.saturating_sub(2),
height: menu_height,
}
}
fn centered_rect(max_width: u16, max_height: u16, area: Rect) -> Rect {
let width = max_width.min(area.width.saturating_sub(2)).max(1);
let height = max_height.min(area.height.saturating_sub(2)).max(1);
Rect::new(
area.x + area.width.saturating_sub(width) / 2,
area.y + area.height.saturating_sub(height) / 2,
width,
height,
)
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
#[cfg(test)]
mod tests {
use super::*;
use ratatui::{Terminal, backend::TestBackend};
#[test]
fn narrow_terminal_renders_without_sidebar_or_panic() {
let backend = TestBackend::new(48, 14);
let mut terminal = Terminal::new(backend).unwrap();
let mut app = App::new();
app.input_insert_str("中文 input");
app.add_message(
crate::client::tui::app::MessageRole::Assistant,
"一条很长的响应,用于验证窄终端换行。".repeat(4),
);
terminal.draw(|frame| render_ui(frame, &app)).unwrap();
}
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}

View File

@ -19,10 +19,10 @@ pub fn get_default_workspace_dir() -> PathBuf {
/// Expand ~ in path to user home directory
pub fn expand_path(path: &str) -> PathBuf {
if let Some(path) = path.strip_prefix("~/") {
if path.starts_with("~/") {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(path)
.join(&path[2..])
} else {
PathBuf::from(path)
}
@ -180,12 +180,9 @@ pub struct SchedulerConfig {
/// Poll interval in seconds (how often to check for due jobs)
#[serde(default = "default_poll_interval_secs")]
pub poll_interval_secs: u64,
/// Maximum concurrent job executions.
/// Maximum concurrent job executions (currently sequential, reserved for future)
#[serde(default = "default_max_concurrent")]
pub max_concurrent: usize,
/// Hard timeout for one scheduled execution.
#[serde(default = "default_execution_timeout_secs")]
pub execution_timeout_secs: u64,
}
fn default_scheduler_enabled() -> bool {
@ -200,17 +197,12 @@ fn default_max_concurrent() -> usize {
1
}
fn default_execution_timeout_secs() -> u64 {
900
}
impl Default for SchedulerConfig {
fn default() -> Self {
Self {
enabled: true,
poll_interval_secs: 60,
max_concurrent: 1,
execution_timeout_secs: 900,
}
}
}

View File

@ -6,7 +6,7 @@ use std::sync::Arc;
use tokio::net::TcpListener;
use crate::bus::{ControlMessage, MessageBus, OutboundDispatcher};
use crate::channels::base::ChannelError;
use crate::channels::base::{Channel, ChannelError};
use crate::channels::{ChannelManager, CliChatChannel};
use crate::config::{Config, ensure_workspace_dir, expand_path};
use crate::logging;
@ -14,7 +14,6 @@ use crate::mcp;
use crate::memory::MemoryManager;
use crate::scheduler::Scheduler;
use crate::session::SessionManager;
use crate::task_supervisor::TaskSupervisor;
pub struct GatewayState {
pub config: Config,
@ -22,15 +21,11 @@ pub struct GatewayState {
pub session_manager: Arc<SessionManager>,
pub channel_manager: ChannelManager,
pub storage: Arc<crate::storage::Storage>,
pub task_supervisor: TaskSupervisor,
pub connection_shutdown: tokio_util::sync::CancellationToken,
}
impl GatewayState {
pub async fn new() -> Result<Self, Box<dyn std::error::Error>> {
let config = Config::load_default()?;
let task_supervisor = TaskSupervisor::new();
let connection_shutdown = tokio_util::sync::CancellationToken::new();
// Initialize workspace directory: expand path and ensure it exists
let workspace_path = expand_path(&config.workspace_dir);
@ -103,7 +98,6 @@ impl GatewayState {
memory_manager,
browser_config,
config.gateway.max_concurrent_background_tasks,
task_supervisor.clone(),
)?;
let session_manager = Arc::new(session_manager);
@ -177,8 +171,6 @@ impl GatewayState {
session_manager: session_manager.clone(),
channel_manager,
storage,
task_supervisor,
connection_shutdown,
})
}
@ -198,9 +190,15 @@ impl GatewayState {
let bus_for_outbound = bus.clone();
let session_manager = self.session_manager.clone();
// Start CLI Chat Channel (it's already registered in ChannelManager)
let cli_chat_channel = self.cli_chat_channel();
if let Err(e) = cli_chat_channel.start(bus.clone()).await {
tracing::error!(error = %e, "Failed to start CLI chat channel");
}
// Spawn unified message processor
// This handles both inbound AI messages and control messages in one loop
self.task_supervisor.spawn("message-processor", async move {
tokio::spawn(async move {
tracing::info!("Message processor started");
loop {
@ -226,23 +224,19 @@ impl GatewayState {
reply_to: None,
media: vec![],
metadata: inbound.forwarded_metadata,
delivery: None,
};
if let Err(e) = bus.publish_outbound(outbound).await {
tracing::error!(error = %e, "Failed to publish outbound");
}
}
Ok(crate::session::session::HandleResult::CommandOutput(content)) => {
let mut metadata = inbound.forwarded_metadata;
metadata.insert("_type".to_string(), "command".to_string());
let outbound = crate::bus::OutboundMessage {
channel: inbound.channel.clone(),
chat_id: inbound.chat_id.clone(),
content,
reply_to: None,
media: vec![],
metadata,
delivery: None,
metadata: inbound.forwarded_metadata,
};
if let Err(e) = bus.publish_outbound(outbound).await {
tracing::error!(error = %e, "Failed to publish outbound");
@ -273,17 +267,12 @@ impl GatewayState {
});
// Spawn outbound dispatcher
let dispatcher = OutboundDispatcher::new(
bus_for_outbound,
self.channel_manager.clone(),
self.task_supervisor.clone(),
);
let dispatcher = OutboundDispatcher::new(bus_for_outbound, self.channel_manager.clone());
self.task_supervisor
.spawn("outbound-dispatcher", async move {
tracing::info!("Outbound dispatcher started");
dispatcher.run().await;
});
tokio::spawn(async move {
tracing::info!("Outbound dispatcher started");
dispatcher.run().await;
});
// Spawn scheduler background task if enabled
let scheduler_config = self.config.gateway.scheduler.clone().unwrap_or_default();
@ -293,7 +282,7 @@ impl GatewayState {
self.session_manager.clone(),
scheduler_config,
));
self.task_supervisor.spawn("scheduler", async move {
tokio::spawn(async move {
sched.run().await;
});
tracing::info!("Scheduler background task spawned");
@ -341,14 +330,6 @@ impl GatewayState {
.await
.map(|session_id| SessionEvent::DialogSwitched { session_id })
.map_err(|e| ChannelError::Other(e.to_string())),
GetDialogHistory { session_id, limit } => session_manager
.get_dialog_history(&session_id, limit)
.await
.map(|messages| SessionEvent::DialogHistory {
session_id,
messages,
})
.map_err(|e| ChannelError::Other(e.to_string())),
RenameDialog { session_id, title } => session_manager
.rename_dialog(&session_id, &title)
.await
@ -431,27 +412,24 @@ pub async fn run(
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)
.with_graceful_shutdown(async move {
if let Err(error) = tokio::signal::ctrl_c().await {
tracing::error!(error = %error, "Failed to listen for shutdown signal");
}
tracing::info!("Shutdown signal received");
connection_shutdown.cancel();
})
.await;
// Graceful shutdown using oneshot channel
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
let channel_manager = state.channel_manager.clone();
// 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?;
// Spawn ctrl_c handler
tokio::spawn(async move {
tokio::signal::ctrl_c().await.ok();
tracing::info!("Shutdown signal received");
let _ = channel_manager.stop_all().await;
let _ = shutdown_tx.send(());
});
// Serve with graceful shutdown
axum::serve(listener, app)
.with_graceful_shutdown(async {
shutdown_rx.await.ok();
})
.await?;
Ok(())
}

View File

@ -1,41 +1,20 @@
use super::GatewayState;
use crate::protocol::WsOutbound;
use crate::protocol::serialize_outbound;
use axum::extract::State;
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Query, State};
use axum::response::Response;
use futures_util::{SinkExt, StreamExt};
use serde::Deserialize;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio::time::{Duration, timeout};
#[derive(Debug, Default, Deserialize)]
pub struct WsQuery {
client_id: Option<String>,
}
pub async fn ws_handler(
ws: WebSocketUpgrade,
Query(query): Query<WsQuery>,
State(state): State<Arc<GatewayState>>,
) -> Response {
pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<Arc<GatewayState>>) -> Response {
ws.on_upgrade(|socket| async move {
handle_socket(socket, state, valid_client_id(query.client_id)).await;
handle_socket(socket, state).await;
})
}
fn valid_client_id(client_id: Option<String>) -> Option<String> {
client_id.filter(|value| {
!value.is_empty()
&& value.len() <= 64
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
})
}
async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>, client_id: Option<String>) {
async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
// Create channel for sending outbound messages to this client
let (sender, mut receiver) = mpsc::channel::<WsOutbound>(100);
@ -43,9 +22,8 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>, client_id: Optio
let cli_chat_channel = state.cli_chat_channel();
// Register client with CliChatChannel and get initial session id
let (session_id, client) = cli_chat_channel
.register_client(sender.clone(), client_id)
.await;
let (session_id, client) = cli_chat_channel.register_client(sender.clone()).await;
// Send session established message
let _ = sender
.send(WsOutbound::SessionEstablished {
@ -58,7 +36,7 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>, client_id: Optio
let (mut ws_sender, mut ws_receiver) = ws.split();
// Task: forward from receiver to WebSocket
let mut writer_task = tokio::spawn(async move {
tokio::spawn(async move {
while let Some(msg) = receiver.recv().await {
if let Ok(text) = serialize_outbound(&msg)
&& ws_sender.send(WsMessage::Text(text.into())).await.is_err()
@ -69,59 +47,18 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>, client_id: Optio
});
// Main loop: receive WebSocket messages and forward to CliChatChannel
let cancellation = state.connection_shutdown.clone();
let mut writer_finished = false;
loop {
tokio::select! {
_ = cancellation.cancelled() => break,
result = &mut writer_task => {
writer_finished = true;
if let Err(error) = result {
tracing::warn!(session_id = %session_id, error = %error, "WebSocket writer task failed");
}
while let Some(msg) = ws_receiver.next().await {
match msg {
Ok(WsMessage::Text(text)) => {
cli_chat_channel.handle_inbound(client.clone(), &text).await;
}
Ok(WsMessage::Close(_)) | Err(_) => {
tracing::debug!(session_id = %session_id, "WebSocket closed");
break;
}
msg = ws_receiver.next() => {
match msg {
Some(Ok(WsMessage::Text(text))) => {
cli_chat_channel.handle_inbound(client.clone(), &text).await;
}
Some(Ok(WsMessage::Close(_))) | Some(Err(_)) | None => {
tracing::debug!(session_id = %session_id, "WebSocket closed");
break;
}
_ => {}
}
}
_ => {}
}
}
cli_chat_channel.unregister_client(&client).await;
drop(client);
drop(sender);
if !writer_finished
&& timeout(Duration::from_secs(2), &mut writer_task)
.await
.is_err()
{
writer_task.abort();
let _ = writer_task.await;
}
tracing::info!(session_id = %session_id, "CLI session ended");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn client_id_is_strictly_bounded() {
assert_eq!(
valid_client_id(Some("client_123-abc".to_string())).as_deref(),
Some("client_123-abc")
);
assert!(valid_client_id(Some("bad/query".to_string())).is_none());
assert!(valid_client_id(Some("x".repeat(65))).is_none());
assert!(valid_client_id(Some(String::new())).is_none());
}
}

View File

@ -14,6 +14,5 @@ pub mod scheduler;
pub mod session;
pub mod skills;
pub mod storage;
pub mod task_supervisor;
pub mod tools;
pub mod util;

View File

@ -59,3 +59,20 @@ pub fn init_logging() {
tracing::info!("Logging initialized. Log directory: {}", log_dir.display());
}
/// Initialize logging without file output (console only)
pub fn init_logging_console_only() {
let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
let console_layer = fmt::layer()
.with_timer(LocalTime::rfc_3339())
.with_target(true)
.with_level(true);
tracing_subscriber::registry()
.with(env_filter)
.with(console_layer)
.init();
tracing::info!("Logging initialized (console only)");
}

View File

@ -47,6 +47,8 @@ fn update_mcp_status(servers: Vec<McpServerStatus>) {
/// A connected MCP server. Holds a clonable Peer handle for tool calls,
/// and keeps the underlying service alive via a background task.
pub struct McpConnection {
#[allow(dead_code)]
pub name: String,
peer: Peer<RoleClient>,
/// Keep the service alive. When dropped, the MCP connection is closed.
_service: Option<Box<dyn std::any::Any + Send + Sync>>,
@ -228,6 +230,7 @@ async fn connect_server(config: &McpServerConfig) -> anyhow::Result<McpConnectio
let peer = service.peer().clone();
Ok(McpConnection {
name: config.name.clone(),
peer,
_service: Some(Box::new(service)),
})
@ -265,6 +268,7 @@ async fn connect_server(config: &McpServerConfig) -> anyhow::Result<McpConnectio
let peer = service.peer().clone();
Ok(McpConnection {
name: config.name.clone(),
peer,
_service: Some(Box::new(service)),
})

View File

@ -18,7 +18,7 @@ impl MemoryCategory {
}
}
pub fn parse(s: &str) -> Option<Self> {
pub fn from_str(s: &str) -> Option<Self> {
match s {
"knowledge" => Some(Self::Knowledge),
"timeline" => Some(Self::Timeline),
@ -78,13 +78,13 @@ mod tests {
#[test]
fn test_memory_category_from_str() {
assert_eq!(
MemoryCategory::parse("knowledge"),
MemoryCategory::from_str("knowledge"),
Some(MemoryCategory::Knowledge)
);
assert_eq!(
MemoryCategory::parse("timeline"),
MemoryCategory::from_str("timeline"),
Some(MemoryCategory::Timeline)
);
assert_eq!(MemoryCategory::parse("invalid"), None);
assert_eq!(MemoryCategory::from_str("invalid"), None);
}
}

View File

@ -70,6 +70,16 @@ impl ToolExecutionOutcome {
}
}
/// Create a successful outcome with duration.
pub fn success_with_duration(output: String, duration: Duration) -> Self {
Self {
output,
success: true,
error_reason: None,
duration,
}
}
/// Create a failed outcome with zero duration.
pub fn failure(output: String, error_reason: Option<String>) -> Self {
Self {
@ -79,6 +89,20 @@ impl ToolExecutionOutcome {
duration: Duration::ZERO,
}
}
/// Create a failed outcome with duration.
pub fn failure_with_duration(
output: String,
error_reason: Option<String>,
duration: Duration,
) -> Self {
Self {
output,
success: false,
error_reason,
duration,
}
}
}
/// MultiObserver broadcasts events to multiple observers.
@ -180,6 +204,16 @@ mod tests {
assert_eq!(outcome.duration, Duration::ZERO);
}
#[test]
fn test_tool_execution_outcome_success_with_duration() {
let outcome = ToolExecutionOutcome::success_with_duration(
"output content".to_string(),
Duration::from_millis(100),
);
assert!(outcome.success);
assert_eq!(outcome.duration, Duration::from_millis(100));
}
#[test]
fn test_tool_execution_outcome_failure() {
let outcome = ToolExecutionOutcome::failure(

View File

@ -19,15 +19,6 @@ pub struct SlashCommandInfo {
pub aliases: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HistoryMessage {
pub id: String,
pub seq: i64,
pub role: String,
pub content: String,
pub created_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum WsInbound {
@ -60,12 +51,6 @@ pub enum WsInbound {
},
#[serde(rename = "load_session")]
LoadSession { session_id: String },
#[serde(rename = "get_session_history")]
GetSessionHistory {
session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
limit: Option<u32>,
},
#[serde(rename = "rename_session")]
RenameSession {
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -96,8 +81,6 @@ pub enum WsOutbound {
id: String,
content: String,
role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
},
#[serde(rename = "error")]
Error { code: String, message: String },
@ -117,11 +100,6 @@ pub enum WsOutbound {
title: String,
message_count: i64,
},
#[serde(rename = "session_history")]
SessionHistory {
session_id: String,
messages: Vec<HistoryMessage>,
},
#[serde(rename = "session_renamed")]
SessionRenamed { session_id: String, title: String },
#[serde(rename = "session_archived")]
@ -137,11 +115,7 @@ pub enum WsOutbound {
#[serde(rename = "command_executed")]
CommandExecuted { message: String },
#[serde(rename = "system_notification")]
SystemNotification {
content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
},
SystemNotification { content: String },
}
pub fn parse_inbound(raw: &str) -> Result<WsInbound, serde_json::Error> {

View File

@ -87,8 +87,6 @@ pub struct AnthropicProvider {
}
impl AnthropicProvider {
// Keep this constructor aligned with OpenAIProvider and LLMProviderConfig.
#[allow(clippy::too_many_arguments)]
pub fn new(
name: String,
api_key: String,
@ -167,6 +165,7 @@ enum AnthropicContent {
},
Thinking {
#[serde(alias = "content")]
#[allow(dead_code)]
thinking: String,
},
#[serde(rename = "tool_use")]
@ -330,29 +329,24 @@ impl LLMProvider for AnthropicProvider {
return Err(format!("API error ({}): {}", status.as_u16(), error_msg).into());
}
let anthropic_resp: AnthropicResponse = match serde_json::from_str(&body_text) {
Ok(response) => response,
Err(e) => {
let err_msg = format!("decode error: {} | body: {}", e, &body_text);
if let Some(ref storage) = self.storage {
let dur = start.elapsed().as_millis() as u64;
if let Err(error) = storage
.append_llm_call(
&self.name,
&self.model_id,
&req_body_str,
Some(&body_text),
Some(&err_msg),
dur,
)
.await
{
tracing::warn!("failed to persist LLM call (decode error): {}", error);
}
}
return Err(err_msg.into());
let anthropic_resp: AnthropicResponse = serde_json::from_str(&body_text).map_err(|e| {
let err_msg = format!("decode error: {} | body: {}", e, &body_text);
if let Some(ref storage) = self.storage {
let name = self.name.clone();
let model = self.model_id.clone();
let req = req_body_str.clone();
let resp_body = body_text.clone();
let dur = start.elapsed().as_millis() as u64;
let err = err_msg.clone();
let s = storage.clone();
tokio::spawn(async move {
let _ = s
.append_llm_call(&name, &model, &req, Some(&resp_body), Some(&err), dur)
.await;
});
}
};
err_msg
})?;
let mut content = String::new();
let mut reasoning = None;

View File

@ -46,9 +46,6 @@ pub struct OpenAIProvider {
}
impl OpenAIProvider {
// Provider construction mirrors the independently configurable fields in
// LLMProviderConfig; grouping them again would only duplicate that API.
#[allow(clippy::too_many_arguments)]
pub fn new(
name: String,
api_key: String,
@ -115,10 +112,10 @@ impl OpenAIProvider {
"role": m.role,
"content": convert_content_blocks(&m.content)
});
if m.role == "assistant"
&& let Some(ref rc) = m.reasoning_content
{
msg["reasoning_content"] = json!(rc);
if m.role == "assistant" {
if let Some(ref rc) = m.reasoning_content {
msg["reasoning_content"] = json!(rc);
}
}
msg
}
@ -298,29 +295,27 @@ impl LLMProvider for OpenAIProvider {
return Err(error.into());
}
let openai_resp: OpenAIResponse = match serde_json::from_str(&text) {
Ok(response) => response,
Err(e) => {
let err_msg = format!("decode error: {} | body: {}", e, &text);
if let Some(ref storage) = self.storage {
let dur = start.elapsed().as_millis() as u64;
if let Err(error) = storage
.append_llm_call(
&self.name,
&self.model_id,
&req_body_str,
Some(&text),
Some(&err_msg),
dur,
)
let openai_resp: OpenAIResponse = serde_json::from_str(&text).map_err(|e| {
let err_msg = format!("decode error: {} | body: {}", e, &text);
if let Some(ref storage) = self.storage {
let name = self.name.clone();
let model = self.model_id.clone();
let req = req_body_str.clone();
let resp = text.clone();
let dur = start.elapsed().as_millis() as u64;
let err = err_msg.clone();
let s = storage.clone();
tokio::spawn(async move {
if let Err(e) = s
.append_llm_call(&name, &model, &req, Some(&resp), Some(&err), dur)
.await
{
tracing::warn!("failed to persist LLM call (decode error): {}", error);
tracing::warn!("failed to persist LLM call (decode error): {}", e);
}
}
return Err(err_msg.into());
});
}
};
err_msg
})?;
let first_choice = openai_resp
.choices
@ -363,7 +358,7 @@ impl LLMProvider for OpenAIProvider {
prompt_tokens: usage.prompt_tokens,
completion_tokens: usage.completion_tokens,
total_tokens: usage.total_tokens,
cached_tokens,
cached_tokens: cached_tokens,
cache_read_input_tokens: None,
cache_creation_input_tokens: None,
},

View File

@ -28,6 +28,17 @@ impl Message {
}
}
pub fn user_with_blocks(content: Vec<ContentBlock>) -> Self {
Self {
role: "user".to_string(),
content,
reasoning_content: None,
tool_call_id: None,
name: None,
tool_calls: None,
}
}
pub fn assistant(content: impl Into<String>) -> Self {
Self {
role: "assistant".to_string(),

View File

@ -2,8 +2,6 @@ pub mod types;
use std::sync::Arc;
use std::time::Instant;
use futures_util::stream::{self, StreamExt};
use tokio::time;
use crate::config::SchedulerConfig;
@ -57,7 +55,6 @@ pub struct Scheduler {
storage: Arc<Storage>,
session_manager: Arc<SessionManager>,
config: SchedulerConfig,
owner: String,
}
impl Scheduler {
@ -70,150 +67,209 @@ impl Scheduler {
storage,
session_manager,
config,
owner: uuid::Uuid::new_v4().to_string(),
}
}
/// Claim due jobs with a durable lease, then execute the claimed batch with
/// bounded concurrency.
/// Run the scheduler loop. This is a long-running async function meant to be
/// spawned as a tokio background task.
pub async fn run(self: Arc<Self>) {
let poll_duration = time::Duration::from_secs(self.config.poll_interval_secs.max(1));
let poll_duration = time::Duration::from_secs(self.config.poll_interval_secs);
let mut interval = time::interval(poll_duration);
interval.set_missed_tick_behavior(time::MissedTickBehavior::Skip);
// Keep accidental configuration values from claiming an unbounded
// batch and overwhelming the runtime or SQLite parameter conversion.
let max_concurrent = self.config.max_concurrent.clamp(1, 256);
interval.tick().await;
tracing::info!(
poll_interval_secs = self.config.poll_interval_secs,
max_concurrent,
scheduler_owner = %self.owner,
"Scheduler started"
"Scheduler started (poll interval: {}s, max concurrent: {})",
self.config.poll_interval_secs,
self.config.max_concurrent,
);
loop {
interval.tick().await;
let now = now_ms();
let lease_ms = self
.config
.execution_timeout_secs
.saturating_add(30)
.saturating_mul(1000)
.min(i64::MAX as u64) as i64;
let lease_until = now.saturating_add(lease_ms);
let jobs = match self
let due = match self
.storage
.claim_due_scheduled_jobs(now, lease_until, &self.owner, max_concurrent)
.due_scheduled_jobs(now, self.config.max_concurrent)
.await
{
Ok(jobs) => jobs,
Err(error) => {
tracing::error!(error = %error, "scheduler: failed to claim due jobs");
Err(e) => {
tracing::error!("scheduler: failed to query due jobs: {}", e);
continue;
}
};
if jobs.is_empty() {
if due.is_empty() {
continue;
}
tracing::info!(count = jobs.len(), "scheduler: claimed due jobs");
stream::iter(jobs)
.for_each_concurrent(max_concurrent, |job| {
let scheduler = self.clone();
async move { scheduler.execute_claimed_job(job).await }
})
.await;
tracing::info!("scheduler: found {} due job(s)", due.len());
for job in &due {
let start = Instant::now();
let started_at = now_ms();
if let Err(e) = self
.storage
.touch_scheduled_job_last_run(&job.id, started_at)
.await
{
tracing::error!(job_id = %job.id, "scheduler: failed to touch last_run_at: {}", e);
continue;
}
tracing::info!(
job_id = %job.id,
job_name = %job.name,
"scheduler: executing cron job"
);
let result = self
.session_manager
.handle_cron_message(
&job.channel,
&job.chat_id,
&job.prompt,
&job.id,
&job.name,
)
.await;
let finished_at = now_ms();
let duration_ms = start.elapsed().as_millis() as i64;
match result {
Ok(HandleResult::AgentResponse(output)) => {
let output_truncated = if output.len() > 8000 {
format!(
"{}...[truncated]",
&output[..output.ceil_char_boundary(8000)]
)
} else {
output.clone()
};
let run = JobRun {
id: 0,
job_id: job.id.clone(),
started_at,
finished_at,
status: "ok".to_string(),
output: Some(output_truncated),
error: None,
duration_ms,
};
if let Err(e) = self.storage.record_scheduled_job_run(&run).await {
tracing::error!(job_id = %job.id, "scheduler: failed to record run: {}", e);
}
if let Err(e) = self
.storage
.set_scheduled_job_last_status(&job.id, "ok", None)
.await
{
tracing::error!(job_id = %job.id, "scheduler: failed to set last_status: {}", e);
}
tracing::info!(
job_id = %job.id,
duration_ms = %duration_ms,
"scheduler: job completed successfully"
);
}
Ok(HandleResult::CommandOutput(output)) => {
let run = JobRun {
id: 0,
job_id: job.id.clone(),
started_at,
finished_at,
status: "ok".to_string(),
output: Some(output),
error: None,
duration_ms,
};
let _ = self.storage.record_scheduled_job_run(&run).await;
}
Ok(HandleResult::AgentProcessing) => {
tracing::warn!(job_id = %job.id, "scheduler: unexpected AgentProcessing from cron — response sent via bus");
}
Err(e) => {
let error_str = e.to_string();
let run = JobRun {
id: 0,
job_id: job.id.clone(),
started_at,
finished_at,
status: "error".to_string(),
output: None,
error: Some(error_str.clone()),
duration_ms,
};
if let Err(e2) = self.storage.record_scheduled_job_run(&run).await {
tracing::error!(job_id = %job.id, "scheduler: failed to record error run: {}", e2);
}
if let Err(e2) = self
.storage
.set_scheduled_job_last_status(&job.id, "error", Some(&error_str))
.await
{
tracing::error!(job_id = %job.id, "scheduler: failed to set error status: {}", e2);
}
tracing::error!(
job_id = %job.id,
duration_ms = %duration_ms,
error = %error_str,
"scheduler: job failed"
);
}
}
if let Err(e) = self.reschedule_after_run(job).await {
tracing::error!(job_id = %job.id, "scheduler: failed to reschedule: {}", e);
}
}
}
}
async fn execute_claimed_job(self: Arc<Self>, job: ScheduledJob) {
let start = Instant::now();
let started_at = now_ms();
tracing::info!(job_id = %job.id, job_name = %job.name, "scheduler: executing claimed job");
/// After a job runs, compute its next execution time or disable/delete it.
async fn reschedule_after_run(&self, job: &ScheduledJob) -> anyhow::Result<()> {
let now = now_ms();
let execution = self.session_manager.handle_cron_message(
&job.channel,
&job.chat_id,
&job.prompt,
&job.id,
&job.name,
);
let result = time::timeout(
time::Duration::from_secs(self.config.execution_timeout_secs.max(1)),
execution,
)
.await;
let finished_at = now_ms();
let duration_ms = start.elapsed().as_millis() as i64;
let (status, output, error) = match result {
Ok(Ok(HandleResult::AgentResponse(output) | HandleResult::CommandOutput(output))) => {
let output = if output.len() > 8000 {
format!(
"{}...[truncated]",
&output[..output.ceil_char_boundary(8000)]
)
match &job.schedule {
Schedule::At { .. } => {
if job.delete_after_run {
self.storage.remove_scheduled_job(&job.id).await?;
tracing::info!(job_id = %job.id, "scheduler: one-shot job deleted after run");
} else {
output
};
("ok".to_string(), Some(output), None)
}
Ok(Ok(HandleResult::AgentProcessing)) => (
"error".to_string(),
None,
Some("cron execution returned asynchronous processing".to_string()),
),
Ok(Err(error)) => ("error".to_string(), None, Some(error.to_string())),
Err(_) => (
"timeout".to_string(),
None,
Some(format!(
"execution exceeded {} seconds",
self.config.execution_timeout_secs.max(1)
)),
),
};
let (next_run_at, disable, delete) = match &job.schedule {
Schedule::At { .. } => (None, !job.delete_after_run, job.delete_after_run),
Schedule::Every { .. } | Schedule::Cron { .. } => {
match next_run_for_schedule(&job.schedule, finished_at) {
Some(next) => (Some(next), false, false),
None => (None, true, false),
self.storage
.set_scheduled_job_enabled(&job.id, false)
.await?;
tracing::info!(job_id = %job.id, "scheduler: one-shot job disabled after run");
}
}
Schedule::Every { .. } | Schedule::Cron { .. } => {
if let Some(next) = next_run_for_schedule(&job.schedule, now) {
self.storage
.set_scheduled_job_next_run(&job.id, next)
.await?;
tracing::info!(job_id = %job.id, next_run_at = %next, "scheduler: job rescheduled");
} else {
tracing::error!(job_id = %job.id, "scheduler: could not compute next run -- disabling job");
self.storage
.set_scheduled_job_enabled(&job.id, false)
.await?;
}
}
};
let run = JobRun {
id: 0,
job_id: job.id.clone(),
started_at,
finished_at,
status,
output,
error,
duration_ms,
};
if let Err(error) = self
.storage
.complete_scheduled_job(&run, &self.owner, next_run_at, disable, delete)
.await
{
tracing::error!(job_id = %job.id, error = %error, "scheduler: failed to commit job completion");
let _ = self
.storage
.release_scheduled_job_lease(&job.id, &self.owner)
.await;
return;
}
tracing::info!(
job_id = %job.id,
status = %run.status,
duration_ms,
"scheduler: job completed"
);
Ok(())
}
}

View File

@ -21,11 +21,6 @@ pub enum SessionCommand {
chat_id: String,
dialog_id: String,
},
/// Load persisted messages for a dialog.
GetDialogHistory {
session_id: UnifiedSessionId,
limit: u32,
},
/// Get the current dialog for a chat
GetCurrentDialog { channel: String, chat_id: String },
/// Rename a dialog

View File

@ -31,11 +31,6 @@ pub enum SessionEvent {
},
/// Dialog switched successfully
DialogSwitched { session_id: UnifiedSessionId },
/// Persisted dialog messages, ordered by sequence.
DialogHistory {
session_id: UnifiedSessionId,
messages: Vec<crate::storage::message::MessageMeta>,
},
/// Dialog renamed
DialogRenamed {
session_id: UnifiedSessionId,

View File

@ -1,113 +0,0 @@
use std::collections::HashMap;
use crate::bus::{ChatMessage, MediaItem, MessageSource, OutboundMessage};
use crate::session::UnifiedSessionId;
use crate::tools::OutboundMessenger;
use super::persistence::append_persisted_messages;
use super::session::{CURRENT_SOURCE_SESSION, SessionManager};
#[async_trait::async_trait]
impl OutboundMessenger for SessionManager {
async fn send_message(
&self,
channel: &str,
chat_id: &str,
dialog_id: Option<&str>,
content: &str,
mut source: MessageSource,
media: Vec<MediaItem>,
) -> Result<(), String> {
if source.from_session.is_none() {
source.from_session = CURRENT_SOURCE_SESSION
.try_with(|value| value.clone())
.ok()
.flatten();
}
let (target_sid, session) = if let Some(dialog_id) = dialog_id {
let session_id = UnifiedSessionId::new(channel, chat_id, dialog_id);
let session = self
.get_or_activate_session(&session_id)
.await
.map_err(|error| error.to_string())?;
(session_id, session)
} else {
let session_id = self
.resolve_dialog_id(channel, chat_id)
.await
.map_err(|error| error.to_string())?;
let session = self
.get_or_create_session(&session_id)
.await
.map_err(|error| error.to_string())?;
(session_id, session)
};
let origin = source.from_session.as_deref().unwrap_or("unknown");
let origin_id = source.from_session.clone();
let same_session = source.from_session.as_deref() == Some(target_sid.to_string().as_str());
let marked_content = if content.trim().is_empty() && !media.is_empty() && same_session {
String::new()
} else {
format!("[message from {origin}] \n{content}")
};
let message = outbound_history_message(marked_content.clone(), source, &media);
append_persisted_messages(&session, vec![message])
.await
.map_err(|error| error.to_string())?;
if let Some(origin_id) = origin_id {
self.restore_origin_dialog(&origin_id, &target_sid).await;
}
self.bus
.deliver_outbound(OutboundMessage {
channel: channel.to_string(),
chat_id: chat_id.to_string(),
content: marked_content,
reply_to: None,
media,
metadata: HashMap::new(),
delivery: None,
})
.await
.map_err(|error| error.to_string())
}
}
fn outbound_history_message(
content: impl Into<String>,
source: MessageSource,
media: &[MediaItem],
) -> ChatMessage {
let mut message = ChatMessage::assistant_with_source(content, source);
message.media_refs = media.iter().map(MediaItem::to_media_ref).collect();
message
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bus::SourceKind;
#[test]
fn outbound_history_preserves_delivered_media() {
let source = MessageSource {
kind: SourceKind::CrossChannel,
from_channel: Some("cli_chat".into()),
from_session: Some("cli_chat:source:dialog".into()),
from_user_id: None,
system_name: None,
task_id: None,
};
let media = vec![MediaItem::new("/tmp/report.pdf", "file")];
let message = outbound_history_message("report", source, &media);
assert_eq!(message.media_refs.len(), 1);
assert_eq!(message.media_refs[0].path, "/tmp/report.pdf");
assert_eq!(message.media_refs[0].media_type, "file");
}
}

View File

@ -1,10 +1,6 @@
pub mod commands;
pub mod error;
pub mod events;
mod messenger;
mod persistence;
// The public `session::session` path is retained for API compatibility.
#[allow(clippy::module_inception)]
pub mod session;
pub mod session_id;

View File

@ -1,65 +0,0 @@
use std::sync::Arc;
use tokio::sync::Mutex;
use super::session::{MessagePersistSnapshot, Session};
use crate::bus::ChatMessage;
use crate::storage::StorageError;
async fn persist_added_messages(
snapshots: Vec<Option<MessagePersistSnapshot>>,
) -> Result<(), StorageError> {
let mut storage = None;
let mut session_id = None;
let mut messages = Vec::new();
let mut final_meta = None;
for snapshot in snapshots.into_iter().flatten() {
let (snapshot_storage, snapshot_session_id, message, meta) = snapshot;
if let Some(ref expected) = session_id
&& expected != &snapshot_session_id
{
return Err(StorageError::Serialization(
"attempted to persist messages from different sessions in one turn".to_string(),
));
}
storage = Some(snapshot_storage);
session_id = Some(snapshot_session_id);
messages.push(message);
final_meta = Some(meta);
}
let (Some(storage), Some(session_id), Some(final_meta)) = (storage, session_id, final_meta)
else {
return Ok(());
};
storage
.persist_message_batch_with_retry(&session_id, &messages, &final_meta)
.await
}
pub(super) async fn append_persisted_messages(
session: &Arc<Mutex<Session>>,
messages: Vec<ChatMessage>,
) -> Result<(), StorageError> {
if messages.is_empty() {
return Ok(());
}
let persistence_lock = { session.lock().await.persistence_lock.clone() };
let _persistence_guard = persistence_lock.lock().await;
let message_ids: Vec<_> = messages.iter().map(|message| message.id.clone()).collect();
let snapshots = {
let mut guard = session.lock().await;
messages
.into_iter()
.map(|message| guard.add_message_in_memory(message, true))
.collect()
};
if let Err(error) = persist_added_messages(snapshots).await {
session.lock().await.rollback_message_suffix(&message_ids);
return Err(error);
}
Ok(())
}

File diff suppressed because it is too large Load Diff

View File

@ -5,8 +5,13 @@
/// Examples:
/// - CLI: `"cli_chat:sid_abc123:dialog_xyz"`
/// - Feishu: `"feishu:oc_123456:dialog_xyz"`
///
/// For simple cases where only one dialog exists per chat:
/// - `dialog_id` defaults to `"default"`
use serde::{Deserialize, Serialize};
pub const DEFAULT_DIALOG_ID: &str = "default";
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct UnifiedSessionId {
pub channel: String,
@ -28,6 +33,15 @@ impl UnifiedSessionId {
}
}
/// Create with default dialog_id ("default")
pub fn with_default_dialog(channel: impl Into<String>, chat_id: impl Into<String>) -> Self {
Self {
channel: channel.into(),
chat_id: chat_id.into(),
dialog_id: DEFAULT_DIALOG_ID.to_string(),
}
}
/// Parse from string format "channel:chat_id:dialog_id"
pub fn parse(s: &str) -> Option<Self> {
let parts: Vec<&str> = s.split(':').collect();
@ -41,6 +55,11 @@ impl UnifiedSessionId {
})
}
/// Convert to string format "channel:chat_id:dialog_id"
pub fn to_string(&self) -> String {
format!("{}:{}:{}", self.channel, self.chat_id, self.dialog_id)
}
/// Get the session key without dialog_id (channel:chat_id)
/// This is used to group all dialogs within a chat
pub fn chat_scope(&self) -> String {
@ -50,7 +69,7 @@ impl UnifiedSessionId {
impl std::fmt::Display for UnifiedSessionId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}:{}", self.channel, self.chat_id, self.dialog_id)
write!(f, "{}", self.to_string())
}
}
@ -68,6 +87,14 @@ mod tests {
assert_eq!(id.dialog_id, "dialog456");
}
#[test]
fn test_with_default_dialog() {
let id = UnifiedSessionId::with_default_dialog("feishu", "oc123");
assert_eq!(id.channel, "feishu");
assert_eq!(id.chat_id, "oc123");
assert_eq!(id.dialog_id, "default");
}
#[test]
fn test_parse() {
let id = UnifiedSessionId::parse("cli_chat:sid123:dialog456").unwrap();

View File

@ -17,13 +17,3 @@ pub struct BackgroundTask {
pub finished_at: Option<i64>,
pub created_at: i64,
}
pub(crate) struct BackgroundTaskUpdate<'a> {
pub status: &'a str,
pub result: Option<&'a str>,
pub error: Option<&'a str>,
pub started_at: Option<i64>,
pub finished_at: Option<i64>,
pub tool_calls_count: Option<i64>,
pub iterations: Option<i64>,
}

View File

@ -13,29 +13,4 @@ pub enum StorageError {
#[error("serialization error: {0}")]
Serialization(String),
#[error("schema migration error: {0}")]
Migration(String),
#[error("storage conflict: {0}")]
Conflict(String),
}
impl StorageError {
/// Only retry failures that can plausibly clear without changing the data.
pub fn is_transient(&self) -> bool {
let Self::Database(error) = self else {
return false;
};
match error {
sqlx::Error::PoolTimedOut => true,
sqlx::Error::Database(database) => {
matches!(database.code().as_deref(), Some("5" | "6" | "261" | "262")) || {
let message = database.message().to_ascii_lowercase();
message.contains("database is locked") || message.contains("database is busy")
}
}
_ => false,
}
}
}

View File

@ -282,7 +282,7 @@ fn parse_memory_rows(rows: &[sqlx::sqlite::SqliteRow]) -> Result<Vec<MemoryEntry
id: row.try_get("id")?,
key: row.try_get("key")?,
content: row.try_get("content")?,
category: MemoryCategory::parse(&row.try_get::<String, _>("category")?)
category: MemoryCategory::from_str(&row.try_get::<String, _>("category")?)
.unwrap_or(MemoryCategory::Knowledge),
importance: row.try_get::<f64, _>("importance")?,
session_id: row.try_get::<Option<String>, _>("session_id")?,

View File

@ -9,36 +9,10 @@ pub use background_task::BackgroundTask;
pub use error::StorageError;
pub use scheduler::{JobRun, ScheduledJob};
use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous};
use sqlx::{Pool, Row, Sqlite};
use sqlx::{Pool, Row, Sqlite, SqlitePool};
use std::path::Path;
use tokio::time::{Duration, sleep};
const SCHEMA_VERSION: i64 = 1;
const INSERT_MESSAGE_SQL: &str = r#"
INSERT INTO messages (id, session_id, seq, role, content, reasoning_content, media_refs, tool_call_id, tool_name, tool_calls, source, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#;
fn insert_message_query<'a>(
session_id: &'a str,
msg: &'a crate::storage::message::MessageMeta,
) -> sqlx::query::Query<'a, Sqlite, sqlx::sqlite::SqliteArguments<'a>> {
sqlx::query(INSERT_MESSAGE_SQL)
.bind(&msg.id)
.bind(session_id)
.bind(msg.seq)
.bind(&msg.role)
.bind(&msg.content)
.bind(&msg.reasoning_content)
.bind(&msg.media_refs)
.bind(&msg.tool_call_id)
.bind(&msg.tool_name)
.bind(&msg.tool_calls)
.bind(&msg.source)
.bind(msg.created_at)
}
pub struct Storage {
pub(crate) pool: Pool<Sqlite>,
}
@ -46,17 +20,8 @@ pub struct Storage {
impl Storage {
/// 打开或创建数据库
pub async fn new(db_path: &Path) -> Result<Self, StorageError> {
let options = SqliteConnectOptions::new()
.filename(db_path)
.create_if_missing(true)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Normal)
.busy_timeout(Duration::from_secs(5))
.foreign_keys(true);
let pool = SqlitePoolOptions::new()
.max_connections(8)
.connect_with(options)
.await?;
let database_url = format!("sqlite:{}?mode=rwc", db_path.display());
let pool = SqlitePool::connect(&database_url).await?;
let storage = Self { pool };
storage.init_schema().await?;
@ -110,7 +75,6 @@ impl Storage {
tool_name TEXT,
tool_calls TEXT,
source TEXT,
reasoning_content TEXT,
created_at INTEGER NOT NULL,
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
)
@ -128,6 +92,18 @@ impl Storage {
.execute(&self.pool)
.await?;
// Migration: add source column if upgrading from older schema
sqlx::query(r#"ALTER TABLE messages ADD COLUMN source TEXT"#)
.execute(&self.pool)
.await
.ok();
// Migration: add reasoning_content column if upgrading from older schema
sqlx::query(r#"ALTER TABLE messages ADD COLUMN reasoning_content TEXT"#)
.execute(&self.pool)
.await
.ok();
// Background tasks table — for async sub-agent tasks.
// Note: No FOREIGN KEY on session_id because sessions use soft delete (deleted_at IS NULL).
// Session and task association is maintained at the application level.
@ -187,12 +163,6 @@ impl Storage {
.execute(&self.pool)
.await?;
let memory_fts_exists: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'memory_fts')",
)
.fetch_one(&self.pool)
.await?;
// FTS5 virtual table for full-text search on memories
sqlx::query(
r#"
@ -242,14 +212,40 @@ impl Storage {
.execute(&self.pool)
.await?;
// Only a newly-created index needs a backfill. Triggers keep an
// existing index current, so rebuilding it on every startup is wasted
// work proportional to the total memory corpus.
if !memory_fts_exists {
sqlx::query("INSERT INTO memory_fts(memory_fts) VALUES ('rebuild')")
.execute(&self.pool)
.await?;
}
// Rebuild FTS5 index for any existing records
sqlx::query("INSERT INTO memory_fts(memory_fts) VALUES ('rebuild')")
.execute(&self.pool)
.await?;
// Migration: add last_consolidated_at column if not exists
sqlx::query(
r#"
ALTER TABLE sessions ADD COLUMN archived_at INTEGER
"#,
)
.execute(&self.pool)
.await
.ok();
// Migration: add last_consolidated_at column if not exists
sqlx::query(
r#"
ALTER TABLE sessions ADD COLUMN last_consolidated_at INTEGER
"#,
)
.execute(&self.pool)
.await
.ok();
// Migration: add last_compressed_message_at column if not exists
sqlx::query(
r#"
ALTER TABLE sessions ADD COLUMN last_compressed_message_at INTEGER
"#,
)
.execute(&self.pool)
.await
.ok();
sqlx::query(
r#"
@ -268,88 +264,13 @@ impl Storage {
.execute(&self.pool)
.await?;
Self::init_scheduler_schema(&self.pool).await?;
self.migrate_schema().await?;
Ok(())
}
/// Apply ordered, atomic migrations. Existing installations predate
/// `user_version`, so each step also checks the actual table shape.
async fn migrate_schema(&self) -> Result<(), StorageError> {
let current: i64 = sqlx::query_scalar("PRAGMA user_version")
.fetch_one(&self.pool)
.await?;
if current > SCHEMA_VERSION {
return Err(StorageError::Migration(format!(
"database schema version {current} is newer than supported version {SCHEMA_VERSION}"
)));
}
if current == SCHEMA_VERSION {
return Ok(());
if let Err(e) = Self::init_scheduler_schema(&self.pool).await {
tracing::warn!(
"Failed to init scheduler schema (tables may already exist): {}",
e
);
}
let mut tx = self.pool.begin().await?;
for (table, column, definition) in [
("messages", "source", "source TEXT"),
("messages", "reasoning_content", "reasoning_content TEXT"),
("sessions", "archived_at", "archived_at INTEGER"),
(
"sessions",
"last_consolidated_at",
"last_consolidated_at INTEGER",
),
(
"sessions",
"last_compressed_message_at",
"last_compressed_message_at INTEGER",
),
("scheduled_jobs", "locked_at", "locked_at INTEGER"),
("scheduled_jobs", "lock_owner", "lock_owner TEXT"),
("scheduled_jobs", "lease_until", "lease_until INTEGER"),
] {
let pragma = format!("PRAGMA table_info({table})");
let columns = sqlx::query(&pragma).fetch_all(&mut *tx).await?;
if !columns
.iter()
.any(|row| row.get::<String, _>("name") == column)
{
let alter = format!("ALTER TABLE {table} ADD COLUMN {definition}");
sqlx::query(&alter).execute(&mut *tx).await?;
}
}
let duplicate: Option<(String, i64, i64)> = sqlx::query_as(
r#"
SELECT session_id, seq, COUNT(*)
FROM messages
GROUP BY session_id, seq
HAVING COUNT(*) > 1
LIMIT 1
"#,
)
.fetch_optional(&mut *tx)
.await?;
if let Some((session_id, seq, count)) = duplicate {
return Err(StorageError::Migration(format!(
"cannot enforce unique message sequence: session {session_id} has {count} rows at seq {seq}"
)));
}
sqlx::query(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_session_seq_unique ON messages(session_id, seq)",
)
.execute(&mut *tx)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_jobs_claimable ON scheduled_jobs(enabled, next_run_at, lease_until)",
)
.execute(&mut *tx)
.await?;
sqlx::query(&format!("PRAGMA user_version = {SCHEMA_VERSION}"))
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
@ -371,9 +292,6 @@ impl Storage {
last_run_at INTEGER,
last_status TEXT,
last_error TEXT,
locked_at INTEGER,
lock_owner TEXT,
lease_until INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
@ -665,85 +583,41 @@ impl Storage {
session_id: &str,
msg: &crate::storage::message::MessageMeta,
) -> Result<i64, StorageError> {
insert_message_query(session_id, msg)
.execute(self.pool())
.await?;
sqlx::query(
r#"
INSERT INTO messages (id, session_id, seq, role, content, reasoning_content, media_refs, tool_call_id, tool_name, tool_calls, source, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&msg.id)
.bind(session_id)
.bind(msg.seq)
.bind(&msg.role)
.bind(&msg.content)
.bind(&msg.reasoning_content)
.bind(&msg.media_refs)
.bind(&msg.tool_call_id)
.bind(&msg.tool_name)
.bind(&msg.tool_calls)
.bind(&msg.source)
.bind(msg.created_at)
.execute(self.pool())
.await?;
Ok(msg.seq)
}
/// Atomically persist all messages produced by one logical turn together
/// with the resulting session metadata. A turn is either fully visible
/// after restart or not visible at all.
pub async fn persist_message_batch(
pub async fn append_messages(
&self,
session_id: &str,
msgs: &[crate::storage::message::MessageMeta],
meta: &crate::storage::session::SessionMeta,
) -> Result<(), StorageError> {
let mut tx = self.pool.begin().await?;
) -> Result<Vec<i64>, StorageError> {
let mut seqs = Vec::with_capacity(msgs.len());
for msg in msgs {
insert_message_query(session_id, msg)
.execute(&mut *tx)
.await?;
let seq = self.append_message(session_id, msg).await?;
seqs.push(seq);
}
sqlx::query(
r#"
INSERT INTO sessions (id, channel, chat_id, dialog_id, title, created_at, last_active_at, message_count, routing_info, archived_at, deleted_at, last_consolidated_at, last_compressed_message_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
title = excluded.title,
last_active_at = excluded.last_active_at,
message_count = excluded.message_count,
routing_info = excluded.routing_info,
archived_at = excluded.archived_at,
deleted_at = excluded.deleted_at,
last_consolidated_at = excluded.last_consolidated_at,
last_compressed_message_at = excluded.last_compressed_message_at
"#,
)
.bind(&meta.id)
.bind(&meta.channel)
.bind(&meta.chat_id)
.bind(&meta.dialog_id)
.bind(&meta.title)
.bind(meta.created_at)
.bind(meta.last_active_at)
.bind(meta.message_count)
.bind(&meta.routing_info)
.bind(meta.archived_at)
.bind(meta.deleted_at)
.bind(meta.last_consolidated_at)
.bind(meta.last_compressed_message_at)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
/// Persist a turn with bounded retry. Retrying the whole transaction keeps
/// message rows and metadata consistent on transient SQLite failures.
pub async fn persist_message_batch_with_retry(
&self,
session_id: &str,
msgs: &[crate::storage::message::MessageMeta],
meta: &crate::storage::session::SessionMeta,
) -> Result<(), StorageError> {
let delays = [100, 200, 300];
for (attempt, delay) in delays.iter().enumerate() {
match self.persist_message_batch(session_id, msgs, meta).await {
Ok(()) => return Ok(()),
Err(error) if attempt < delays.len() - 1 && error.is_transient() => {
tracing::warn!(attempt = attempt + 1, error = %error, "Turn persistence failed; retrying");
sleep(Duration::from_millis(*delay)).await;
}
Err(error) => return Err(error),
}
}
unreachable!()
Ok(seqs)
}
pub async fn load_messages(
@ -793,52 +667,6 @@ impl Storage {
Ok(row.get::<i64, _>("max_seq"))
}
/// Load a bounded tail of one session while preserving chronological order.
pub async fn load_recent_session_messages(
&self,
session_id: &str,
limit: u32,
) -> Result<Vec<crate::storage::message::MessageMeta>, StorageError> {
let limit = limit.clamp(1, 2_000);
let rows = sqlx::query(
r#"
SELECT id, session_id, seq, role, content, reasoning_content, media_refs,
tool_call_id, tool_name, tool_calls, source, created_at
FROM (
SELECT id, session_id, seq, role, content, reasoning_content, media_refs,
tool_call_id, tool_name, tool_calls, source, created_at
FROM messages
WHERE session_id = ?
ORDER BY seq DESC
LIMIT ?
)
ORDER BY seq ASC
"#,
)
.bind(session_id)
.bind(i64::from(limit))
.fetch_all(self.pool())
.await?;
Ok(rows
.into_iter()
.map(|row| crate::storage::message::MessageMeta {
id: row.get("id"),
session_id: row.get("session_id"),
seq: row.get("seq"),
role: row.get("role"),
content: row.get("content"),
reasoning_content: row.get("reasoning_content"),
media_refs: row.get("media_refs"),
tool_call_id: row.get("tool_call_id"),
tool_name: row.get("tool_name"),
tool_calls: row.get("tool_calls"),
source: row.get("source"),
created_at: row.get("created_at"),
})
.collect())
}
pub async fn load_messages_after_timestamp(
&self,
session_id: &str,
@ -1102,28 +930,28 @@ impl Storage {
Ok(())
}
pub(crate) async fn update_background_task_status(
pub async fn update_background_task_status(
&self,
id: &str,
update: crate::storage::background_task::BackgroundTaskUpdate<'_>,
status: &str,
result: Option<&str>,
error: Option<&str>,
started_at: Option<i64>,
finished_at: Option<i64>,
) -> Result<(), StorageError> {
sqlx::query(
r#"
UPDATE background_tasks
SET status = ?, result = COALESCE(?, result), error = COALESCE(?, error),
started_at = COALESCE(?, started_at), finished_at = COALESCE(?, finished_at),
tool_calls_count = COALESCE(?, tool_calls_count),
iterations = COALESCE(?, iterations)
started_at = COALESCE(?, started_at), finished_at = COALESCE(?, finished_at)
WHERE id = ?
"#,
)
.bind(update.status)
.bind(update.result)
.bind(update.error)
.bind(update.started_at)
.bind(update.finished_at)
.bind(update.tool_calls_count)
.bind(update.iterations)
.bind(status)
.bind(result)
.bind(error)
.bind(started_at)
.bind(finished_at)
.bind(id)
.execute(self.pool())
.await?;
@ -1226,200 +1054,6 @@ mod tests {
(storage, dir)
}
#[tokio::test]
async fn sqlite_runtime_guards_are_enabled() {
let (storage, _dir) = create_test_storage().await;
let journal_mode: String = sqlx::query_scalar("PRAGMA journal_mode")
.fetch_one(storage.pool())
.await
.unwrap();
let foreign_keys: i64 = sqlx::query_scalar("PRAGMA foreign_keys")
.fetch_one(storage.pool())
.await
.unwrap();
let busy_timeout: i64 = sqlx::query_scalar("PRAGMA busy_timeout")
.fetch_one(storage.pool())
.await
.unwrap();
let schema_version: i64 = sqlx::query_scalar("PRAGMA user_version")
.fetch_one(storage.pool())
.await
.unwrap();
assert_eq!(journal_mode, "wal");
assert_eq!(foreign_keys, 1);
assert_eq!(busy_timeout, 5000);
assert_eq!(schema_version, SCHEMA_VERSION);
let orphan = sqlx::query(
r#"
INSERT INTO messages (id, session_id, seq, role, content, created_at)
VALUES ('orphan', 'missing', 1, 'user', 'no parent', 1)
"#,
)
.execute(storage.pool())
.await;
assert!(orphan.is_err());
}
#[tokio::test]
async fn reopening_database_does_not_rebuild_existing_fts_index() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("fts.db");
let storage = Storage::new(&db_path).await.unwrap();
sqlx::query(
"INSERT INTO memory_fts(rowid, key, content) VALUES (999999, 'startup_sentinel', 'startup_sentinel')",
)
.execute(storage.pool())
.await
.unwrap();
drop(storage);
let reopened = Storage::new(&db_path).await.unwrap();
let sentinel_count: i64 = sqlx::query_scalar(
"SELECT count(*) FROM memory_fts WHERE memory_fts MATCH 'startup_sentinel'",
)
.fetch_one(reopened.pool())
.await
.unwrap();
assert_eq!(sentinel_count, 1);
}
#[tokio::test]
async fn background_task_completion_persists_execution_metrics() {
let (storage, _dir) = create_test_storage().await;
let task = crate::storage::BackgroundTask {
id: "task-metrics".into(),
session_id: "cli:test:dialog".into(),
channel: "cli".into(),
chat_id: "test".into(),
prompt: "measure".into(),
allowed_tools: None,
status: "pending".into(),
result: None,
error: None,
tool_calls_count: 0,
iterations: 0,
started_at: None,
finished_at: None,
created_at: 1,
};
storage.create_background_task(&task).await.unwrap();
storage
.update_background_task_status(
&task.id,
crate::storage::background_task::BackgroundTaskUpdate {
status: "completed",
result: Some("done"),
error: None,
started_at: Some(2),
finished_at: Some(3),
tool_calls_count: Some(4),
iterations: Some(5),
},
)
.await
.unwrap();
let persisted = storage.get_background_task(&task.id).await.unwrap();
assert_eq!(persisted.tool_calls_count, 4);
assert_eq!(persisted.iterations, 5);
}
#[tokio::test]
async fn legacy_schema_is_migrated_without_rebuild() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("legacy.db");
let pool = SqlitePoolOptions::new()
.connect_with(
SqliteConnectOptions::new()
.filename(&db_path)
.create_if_missing(true),
)
.await
.unwrap();
sqlx::query(
r#"
CREATE TABLE sessions (
id TEXT PRIMARY KEY, channel TEXT NOT NULL, chat_id TEXT NOT NULL,
dialog_id TEXT NOT NULL, title TEXT NOT NULL DEFAULT 'new',
created_at INTEGER NOT NULL, last_active_at INTEGER NOT NULL,
message_count INTEGER DEFAULT 0, routing_info TEXT, deleted_at INTEGER,
UNIQUE(channel, chat_id, dialog_id)
)
"#,
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
r#"
CREATE TABLE messages (
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, seq INTEGER NOT NULL,
role TEXT NOT NULL, content TEXT NOT NULL, media_refs TEXT,
tool_call_id TEXT, tool_name TEXT, tool_calls TEXT,
created_at INTEGER NOT NULL
)
"#,
)
.execute(&pool)
.await
.unwrap();
sqlx::query(
r#"
CREATE TABLE scheduled_jobs (
id TEXT PRIMARY KEY, name TEXT NOT NULL, schedule TEXT NOT NULL,
prompt TEXT NOT NULL, channel TEXT NOT NULL, chat_id TEXT NOT NULL,
model TEXT, enabled INTEGER NOT NULL DEFAULT 1,
delete_after_run INTEGER NOT NULL DEFAULT 0, next_run_at INTEGER NOT NULL,
last_run_at INTEGER, last_status TEXT, last_error TEXT,
created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
)
"#,
)
.execute(&pool)
.await
.unwrap();
drop(pool);
let storage = Storage::new(&db_path).await.unwrap();
for (table, expected) in [
("messages", vec!["source", "reasoning_content"]),
(
"sessions",
vec![
"archived_at",
"last_consolidated_at",
"last_compressed_message_at",
],
),
(
"scheduled_jobs",
vec!["locked_at", "lock_owner", "lease_until"],
),
] {
let columns = sqlx::query(&format!("PRAGMA table_info({table})"))
.fetch_all(storage.pool())
.await
.unwrap();
for column in expected {
assert!(
columns
.iter()
.any(|row| row.get::<String, _>("name") == column),
"missing migrated column {table}.{column}"
);
}
}
let schema_version: i64 = sqlx::query_scalar("PRAGMA user_version")
.fetch_one(storage.pool())
.await
.unwrap();
assert_eq!(schema_version, SCHEMA_VERSION);
}
#[tokio::test]
async fn test_upsert_and_get_session() {
let (storage, _dir) = create_test_storage().await;
@ -1467,8 +1101,8 @@ mod tests {
chat_id: "sid123".to_string(),
dialog_id: format!("dialog{}", i),
title: format!("会话{}", i),
created_at: i * 1000,
last_active_at: i * 1000,
created_at: i as i64 * 1000,
last_active_at: i as i64 * 1000,
message_count: i,
routing_info: None,
archived_at: None,
@ -1561,72 +1195,6 @@ mod tests {
let loaded = storage.load_messages(&session_meta.id, 0).await.unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].content, "你好");
for seq in 2..=5 {
let mut message = msg.clone();
message.id = format!("msg{seq}");
message.seq = seq;
message.content = format!("message {seq}");
storage
.append_message(&session_meta.id, &message)
.await
.unwrap();
}
let recent = storage
.load_recent_session_messages(&session_meta.id, 2)
.await
.unwrap();
assert_eq!(recent.len(), 2);
assert_eq!(recent[0].seq, 4);
assert_eq!(recent[1].seq, 5);
}
#[tokio::test]
async fn test_persist_message_batch_is_atomic() {
let (storage, _dir) = create_test_storage().await;
let session_meta = crate::storage::session::SessionMeta {
id: "cli_chat:atomic:dialog1".to_string(),
channel: "cli_chat".to_string(),
chat_id: "atomic".to_string(),
dialog_id: "dialog1".to_string(),
title: "Atomic turn".to_string(),
created_at: 1000,
last_active_at: 2000,
message_count: 1,
routing_info: None,
archived_at: None,
deleted_at: None,
last_consolidated_at: None,
last_compressed_message_at: None,
};
storage.upsert_session(&session_meta).await.unwrap();
let message = crate::storage::message::MessageMeta {
id: "duplicate-id".to_string(),
session_id: session_meta.id.clone(),
seq: 1,
role: "assistant".to_string(),
content: "must roll back".to_string(),
reasoning_content: None,
media_refs: None,
tool_call_id: None,
tool_name: None,
tool_calls: None,
source: None,
created_at: 2000,
};
let result = storage
.persist_message_batch(&session_meta.id, &[message.clone(), message], &session_meta)
.await;
assert!(result.is_err());
assert!(
storage
.load_messages(&session_meta.id, 0)
.await
.unwrap()
.is_empty()
);
}
#[tokio::test]

View File

@ -2,7 +2,6 @@ use serde::{Deserialize, Serialize};
use sqlx::Row;
use crate::scheduler::Schedule;
use crate::storage::StorageError;
/// A scheduled job stored in the database.
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -40,8 +39,8 @@ pub struct JobRun {
impl crate::storage::Storage {
/// Insert a new scheduled job.
pub async fn add_scheduled_job(&self, job: &ScheduledJob) -> Result<(), StorageError> {
let schedule_json = serialize_schedule(&job.schedule)?;
pub async fn add_scheduled_job(&self, job: &ScheduledJob) -> anyhow::Result<()> {
let schedule_json = serde_json::to_string(&job.schedule)?;
sqlx::query(
r#"
INSERT INTO scheduled_jobs
@ -72,17 +71,17 @@ impl crate::storage::Storage {
}
/// Fetch a single scheduled job by ID.
pub async fn get_scheduled_job(&self, id: &str) -> Result<ScheduledJob, StorageError> {
pub async fn get_scheduled_job(&self, id: &str) -> anyhow::Result<ScheduledJob> {
let row = sqlx::query("SELECT * FROM scheduled_jobs WHERE id = ?")
.bind(id)
.fetch_optional(self.pool())
.await?
.ok_or_else(|| StorageError::NotFound(format!("scheduled job {id}")))?;
.ok_or_else(|| anyhow::anyhow!("job not found: {id}"))?;
row_to_job(&row)
}
/// List all scheduled jobs, ordered by next_run_at ascending.
pub async fn list_scheduled_jobs(&self) -> Result<Vec<ScheduledJob>, StorageError> {
pub async fn list_scheduled_jobs(&self) -> anyhow::Result<Vec<ScheduledJob>> {
let rows = sqlx::query("SELECT * FROM scheduled_jobs ORDER BY next_run_at ASC")
.fetch_all(self.pool())
.await?;
@ -90,7 +89,7 @@ impl crate::storage::Storage {
}
/// Delete a scheduled job (cascades to job_runs).
pub async fn remove_scheduled_job(&self, id: &str) -> Result<(), StorageError> {
pub async fn remove_scheduled_job(&self, id: &str) -> anyhow::Result<()> {
sqlx::query("DELETE FROM scheduled_jobs WHERE id = ?")
.bind(id)
.execute(self.pool())
@ -99,11 +98,7 @@ impl crate::storage::Storage {
}
/// Enable or disable a scheduled job.
pub async fn set_scheduled_job_enabled(
&self,
id: &str,
enabled: bool,
) -> Result<(), StorageError> {
pub async fn set_scheduled_job_enabled(&self, id: &str, enabled: bool) -> anyhow::Result<()> {
sqlx::query("UPDATE scheduled_jobs SET enabled = ?, updated_at = ? WHERE id = ?")
.bind(enabled as i32)
.bind(now_ms())
@ -122,7 +117,7 @@ impl crate::storage::Storage {
channel: Option<String>,
chat_id: Option<String>,
model: Option<String>,
) -> Result<(), StorageError> {
) -> anyhow::Result<()> {
let now = now_ms();
if let Some(p) = prompt {
@ -134,7 +129,7 @@ impl crate::storage::Storage {
.await?;
}
if let Some(s) = schedule {
let json = serialize_schedule(&s)?;
let json = serde_json::to_string(&s)?;
sqlx::query("UPDATE scheduled_jobs SET schedule = ?, updated_at = ? WHERE id = ?")
.bind(&json)
.bind(now)
@ -174,7 +169,7 @@ impl crate::storage::Storage {
&self,
id: &str,
next_run_at: i64,
) -> Result<(), StorageError> {
) -> anyhow::Result<()> {
let now = now_ms();
sqlx::query(
"UPDATE scheduled_jobs SET next_run_at = ?, last_run_at = ?, updated_at = ? WHERE id = ?",
@ -188,132 +183,68 @@ impl crate::storage::Storage {
Ok(())
}
/// Atomically claim due jobs for one scheduler instance. A crashed worker's
/// claims become eligible again after `lease_until`.
pub async fn claim_due_scheduled_jobs(
/// Set last_run_at for a job (used when starting execution).
pub async fn touch_scheduled_job_last_run(&self, id: &str, at: i64) -> anyhow::Result<()> {
sqlx::query("UPDATE scheduled_jobs SET last_run_at = ?, updated_at = ? WHERE id = ?")
.bind(at)
.bind(at)
.bind(id)
.execute(self.pool())
.await?;
Ok(())
}
/// Set last_status and last_error after job completion.
pub async fn set_scheduled_job_last_status(
&self,
id: &str,
status: &str,
error: Option<&str>,
) -> anyhow::Result<()> {
let now = now_ms();
sqlx::query(
"UPDATE scheduled_jobs SET last_status = ?, last_error = ?, updated_at = ? WHERE id = ?",
)
.bind(status)
.bind(error)
.bind(now)
.bind(id)
.execute(self.pool())
.await?;
Ok(())
}
/// Fetch enabled jobs whose next_run_at <= now, up to `limit`.
pub async fn due_scheduled_jobs(
&self,
now: i64,
lease_until: i64,
owner: &str,
limit: usize,
) -> Result<Vec<ScheduledJob>, StorageError> {
if limit == 0 {
return Ok(Vec::new());
}
) -> anyhow::Result<Vec<ScheduledJob>> {
let rows = sqlx::query(
r#"
UPDATE scheduled_jobs
SET locked_at = ?, lock_owner = ?, lease_until = ?, last_run_at = ?, updated_at = ?
WHERE id IN (
SELECT id FROM scheduled_jobs
WHERE enabled = 1
AND next_run_at <= ?
AND (lease_until IS NULL OR lease_until <= ?)
ORDER BY next_run_at ASC
LIMIT ?
)
AND (lease_until IS NULL OR lease_until <= ?)
RETURNING *
"#,
"SELECT * FROM scheduled_jobs WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at ASC LIMIT ?",
)
.bind(now)
.bind(owner)
.bind(lease_until)
.bind(now)
.bind(now)
.bind(now)
.bind(now)
.bind(limit as i64)
.bind(now)
.fetch_all(self.pool())
.await?;
rows.iter().map(row_to_job).collect()
}
/// Persist the run result, reschedule/disable the job, and release its
/// lease in one transaction. The owner check prevents a stale worker from
/// completing a claim that has already been recovered elsewhere.
pub async fn complete_scheduled_job(
&self,
run: &JobRun,
owner: &str,
next_run_at: Option<i64>,
disable: bool,
delete: bool,
) -> Result<(), StorageError> {
let mut tx = self.pool().begin().await?;
if delete {
let result = sqlx::query("DELETE FROM scheduled_jobs WHERE id = ? AND lock_owner = ?")
.bind(&run.job_id)
.bind(owner)
.execute(&mut *tx)
.await?;
if result.rows_affected() != 1 {
return Err(StorageError::Conflict(format!(
"scheduled job lease lost before delete: {}",
run.job_id
)));
}
} else {
sqlx::query(
r#"
INSERT INTO job_runs (job_id, started_at, finished_at, status, output, error, duration_ms)
VALUES (?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(&run.job_id)
.bind(run.started_at)
.bind(run.finished_at)
.bind(&run.status)
.bind(&run.output)
.bind(&run.error)
.bind(run.duration_ms)
.execute(&mut *tx)
.await?;
let result = sqlx::query(
r#"
UPDATE scheduled_jobs
SET next_run_at = COALESCE(?, next_run_at),
enabled = CASE WHEN ? THEN 0 ELSE enabled END,
last_status = ?, last_error = ?,
locked_at = NULL, lock_owner = NULL, lease_until = NULL,
updated_at = ?
WHERE id = ? AND lock_owner = ?
"#,
)
.bind(next_run_at)
.bind(disable)
.bind(&run.status)
.bind(&run.error)
.bind(run.finished_at)
.bind(&run.job_id)
.bind(owner)
.execute(&mut *tx)
.await?;
if result.rows_affected() != 1 {
return Err(StorageError::Conflict(format!(
"scheduled job lease lost before completion: {}",
run.job_id
)));
}
}
tx.commit().await?;
Ok(())
}
pub async fn release_scheduled_job_lease(
&self,
job_id: &str,
owner: &str,
) -> Result<(), StorageError> {
/// Record a job execution run.
pub async fn record_scheduled_job_run(&self, run: &JobRun) -> anyhow::Result<()> {
sqlx::query(
"UPDATE scheduled_jobs SET locked_at = NULL, lock_owner = NULL, lease_until = NULL WHERE id = ? AND lock_owner = ?",
r#"
INSERT INTO job_runs (job_id, started_at, finished_at, status, output, error, duration_ms)
VALUES (?, ?, ?, ?, ?, ?, ?)
"#,
)
.bind(job_id)
.bind(owner)
.bind(&run.job_id)
.bind(run.started_at)
.bind(run.finished_at)
.bind(&run.status)
.bind(&run.output)
.bind(&run.error)
.bind(run.duration_ms)
.execute(self.pool())
.await?;
Ok(())
@ -324,7 +255,7 @@ impl crate::storage::Storage {
&self,
job_id: &str,
limit: usize,
) -> Result<Vec<JobRun>, StorageError> {
) -> anyhow::Result<Vec<JobRun>> {
let rows = sqlx::query(
"SELECT * FROM job_runs WHERE job_id = ? ORDER BY finished_at DESC LIMIT ?",
)
@ -349,7 +280,7 @@ impl crate::storage::Storage {
}
/// Delete disabled jobs whose updated_at is before `before`.
pub async fn cleanup_disabled_scheduled_jobs(&self, before: i64) -> Result<(), StorageError> {
pub async fn cleanup_disabled_scheduled_jobs(&self, before: i64) -> anyhow::Result<()> {
sqlx::query("DELETE FROM scheduled_jobs WHERE enabled = 0 AND updated_at < ?")
.bind(before)
.execute(self.pool())
@ -365,14 +296,9 @@ fn now_ms() -> i64 {
.as_millis() as i64
}
fn serialize_schedule(schedule: &Schedule) -> Result<String, StorageError> {
serde_json::to_string(schedule).map_err(|error| StorageError::Serialization(error.to_string()))
}
fn row_to_job(row: &sqlx::sqlite::SqliteRow) -> Result<ScheduledJob, StorageError> {
fn row_to_job(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result<ScheduledJob> {
let schedule_json: String = row.try_get("schedule")?;
let schedule: Schedule = serde_json::from_str(&schedule_json)
.map_err(|error| StorageError::Serialization(error.to_string()))?;
let schedule: Schedule = serde_json::from_str(&schedule_json)?;
Ok(ScheduledJob {
id: row.try_get("id")?,
name: row.try_get("name")?,
@ -538,6 +464,114 @@ mod tests {
assert!(!got.enabled);
}
#[tokio::test]
async fn test_due_jobs_only_returns_enabled_and_overdue() {
let storage = setup_storage().await;
let t = now();
let jobs = vec![
ScheduledJob {
id: "due".into(),
name: "due".into(),
schedule: Schedule::At { at: t },
prompt: "1".into(),
channel: "cli_chat".into(),
chat_id: "c".into(),
model: None,
enabled: true,
delete_after_run: false,
next_run_at: t - 1000,
last_run_at: None,
last_status: None,
last_error: None,
created_at: t,
updated_at: t,
},
ScheduledJob {
id: "future".into(),
name: "future".into(),
schedule: Schedule::At { at: t + 99999999 },
prompt: "2".into(),
channel: "cli_chat".into(),
chat_id: "c".into(),
model: None,
enabled: true,
delete_after_run: false,
next_run_at: t + 99999999,
last_run_at: None,
last_status: None,
last_error: None,
created_at: t,
updated_at: t,
},
ScheduledJob {
id: "disabled-due".into(),
name: "disabled due".into(),
schedule: Schedule::At { at: t },
prompt: "3".into(),
channel: "cli_chat".into(),
chat_id: "c".into(),
model: None,
enabled: false,
delete_after_run: false,
next_run_at: t - 1000,
last_run_at: None,
last_status: None,
last_error: None,
created_at: t,
updated_at: t,
},
];
for j in &jobs {
storage.add_scheduled_job(j).await.unwrap();
}
let due = storage.due_scheduled_jobs(t, 10).await.unwrap();
assert_eq!(due.len(), 1);
assert_eq!(due[0].id, "due");
}
#[tokio::test]
async fn test_record_run_and_list_runs() {
let storage = setup_storage().await;
let t = now();
let job = ScheduledJob {
id: "job-run".into(),
name: "run test".into(),
schedule: Schedule::Every { every_ms: 1000 },
prompt: "hi".into(),
channel: "cli_chat".into(),
chat_id: "c".into(),
model: None,
enabled: true,
delete_after_run: false,
next_run_at: t,
last_run_at: None,
last_status: None,
last_error: None,
created_at: t,
updated_at: t,
};
storage.add_scheduled_job(&job).await.unwrap();
let run = super::JobRun {
id: 0,
job_id: "job-run".into(),
started_at: t,
finished_at: t + 500,
status: "ok".into(),
output: Some("hello".into()),
error: None,
duration_ms: 500,
};
storage.record_scheduled_job_run(&run).await.unwrap();
let runs = storage
.list_scheduled_job_runs("job-run", 10)
.await
.unwrap();
assert_eq!(runs.len(), 1);
assert_eq!(runs[0].status, "ok");
assert_eq!(runs[0].output.as_deref(), Some("hello"));
}
#[tokio::test]
async fn test_update_job() {
let storage = setup_storage().await;
@ -574,100 +608,4 @@ mod tests {
let got = storage.get_scheduled_job("job-update").await.unwrap();
assert_eq!(got.prompt, "new prompt");
}
#[tokio::test]
async fn claim_is_exclusive_until_lease_expires() {
let storage = setup_storage().await;
let t = now();
let job = ScheduledJob {
id: "leased-job".into(),
name: "leased".into(),
schedule: Schedule::Every { every_ms: 1000 },
prompt: "run".into(),
channel: "cli_chat".into(),
chat_id: "c".into(),
model: None,
enabled: true,
delete_after_run: false,
next_run_at: t,
last_run_at: None,
last_status: None,
last_error: None,
created_at: t,
updated_at: t,
};
storage.add_scheduled_job(&job).await.unwrap();
let first = storage
.claim_due_scheduled_jobs(t, t + 100, "owner-1", 1)
.await
.unwrap();
let duplicate = storage
.claim_due_scheduled_jobs(t, t + 100, "owner-2", 1)
.await
.unwrap();
let recovered = storage
.claim_due_scheduled_jobs(t + 101, t + 201, "owner-2", 1)
.await
.unwrap();
assert_eq!(first.len(), 1);
assert!(duplicate.is_empty());
assert_eq!(recovered.len(), 1);
}
#[tokio::test]
async fn completion_is_atomic_and_releases_lease() {
let storage = setup_storage().await;
let t = now();
let job = ScheduledJob {
id: "complete-job".into(),
name: "complete".into(),
schedule: Schedule::Every { every_ms: 1000 },
prompt: "run".into(),
channel: "cli_chat".into(),
chat_id: "c".into(),
model: None,
enabled: true,
delete_after_run: false,
next_run_at: t,
last_run_at: None,
last_status: None,
last_error: None,
created_at: t,
updated_at: t,
};
storage.add_scheduled_job(&job).await.unwrap();
storage
.claim_due_scheduled_jobs(t, t + 1000, "owner", 1)
.await
.unwrap();
let run = super::JobRun {
id: 0,
job_id: job.id.clone(),
started_at: t,
finished_at: t + 10,
status: "ok".into(),
output: Some("done".into()),
error: None,
duration_ms: 10,
};
storage
.complete_scheduled_job(&run, "owner", Some(t + 2000), false, false)
.await
.unwrap();
let completed = storage.get_scheduled_job(&job.id).await.unwrap();
let runs = storage.list_scheduled_job_runs(&job.id, 10).await.unwrap();
let lease: (Option<String>, Option<i64>) =
sqlx::query_as("SELECT lock_owner, lease_until FROM scheduled_jobs WHERE id = ?")
.bind(&job.id)
.fetch_one(storage.pool())
.await
.unwrap();
assert_eq!(completed.next_run_at, t + 2000);
assert_eq!(completed.last_status.as_deref(), Some("ok"));
assert_eq!(runs.len(), 1);
assert_eq!(lease, (None, None));
}
}

View File

@ -1,202 +0,0 @@
use std::future::Future;
use std::panic::AssertUnwindSafe;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use futures_util::FutureExt;
use tokio::task::JoinHandle;
use tokio::time::{Instant, timeout_at};
use tokio_util::sync::CancellationToken;
#[derive(Clone)]
pub struct TaskSupervisor {
inner: Arc<Inner>,
}
struct Inner {
cancellation: CancellationToken,
state: Mutex<State>,
}
impl Drop for Inner {
fn drop(&mut self) {
self.cancellation.cancel();
}
}
#[derive(Default)]
struct State {
stopping: bool,
tasks: Vec<ManagedTask>,
}
struct ManagedTask {
name: String,
handle: JoinHandle<()>,
}
impl Default for TaskSupervisor {
fn default() -> Self {
Self::new()
}
}
impl TaskSupervisor {
pub fn new() -> Self {
Self {
inner: Arc::new(Inner {
cancellation: CancellationToken::new(),
state: Mutex::new(State::default()),
}),
}
}
pub fn cancellation_token(&self) -> CancellationToken {
self.inner.cancellation.clone()
}
/// Register a task before shutdown begins. Cancellation drops the task
/// future, so task code should keep externally visible state transactional.
pub fn spawn<F>(&self, name: impl Into<String>, future: F) -> bool
where
F: Future<Output = ()> + Send + 'static,
{
let name = name.into();
let cancellation = self.inner.cancellation.clone();
let mut state = self.inner.state.lock().unwrap_or_else(|e| e.into_inner());
if state.stopping {
return false;
}
// Completed handles no longer need to occupy the registry. Panics are
// observed and logged inside the wrapper below.
state.tasks.retain(|task| !task.handle.is_finished());
let task_name = name.clone();
let handle = tokio::spawn(async move {
tracing::debug!(task = %task_name, "Background task started");
let outcome = tokio::select! {
_ = cancellation.cancelled() => None,
outcome = AssertUnwindSafe(future).catch_unwind() => Some(outcome),
};
match outcome {
Some(Ok(())) => tracing::debug!(task = %task_name, "Background task finished"),
Some(Err(_)) => tracing::error!(task = %task_name, "Background task panicked"),
None => tracing::debug!(task = %task_name, "Background task cancelled"),
}
});
state.tasks.push(ManagedTask { name, handle });
true
}
/// Register a task that performs its own cooperative cancellation and
/// cleanup. The supervisor broadcasts cancellation during shutdown, but
/// does not drop this future until the grace period expires.
pub fn spawn_graceful<F>(&self, name: impl Into<String>, future: F) -> bool
where
F: Future<Output = ()> + Send + 'static,
{
let name = name.into();
let mut state = self.inner.state.lock().unwrap_or_else(|e| e.into_inner());
if state.stopping {
return false;
}
state.tasks.retain(|task| !task.handle.is_finished());
let task_name = name.clone();
let handle = tokio::spawn(async move {
tracing::debug!(task = %task_name, "Graceful background task started");
match AssertUnwindSafe(future).catch_unwind().await {
Ok(()) => tracing::debug!(task = %task_name, "Graceful background task finished"),
Err(_) => tracing::error!(task = %task_name, "Graceful background task panicked"),
}
});
state.tasks.push(ManagedTask { name, handle });
true
}
pub fn cancel(&self) {
let mut state = self.inner.state.lock().unwrap_or_else(|e| e.into_inner());
state.stopping = true;
self.inner.cancellation.cancel();
}
/// Stop accepting tasks, broadcast cancellation, and wait up to `grace`.
/// Remaining tasks are aborted so shutdown has a deterministic upper bound.
pub async fn shutdown(&self, grace: Duration) {
let mut tasks = {
let mut state = self.inner.state.lock().unwrap_or_else(|e| e.into_inner());
state.stopping = true;
self.inner.cancellation.cancel();
std::mem::take(&mut state.tasks)
};
let deadline = Instant::now() + grace;
for index in 0..tasks.len() {
let result = timeout_at(deadline, &mut tasks[index].handle).await;
match result {
Ok(Ok(())) => {}
Ok(Err(error)) if error.is_cancelled() => {}
Ok(Err(error)) => {
tracing::error!(task = %tasks[index].name, error = %error, "Background task join failed");
}
Err(_) => {
for task in &tasks[index..] {
if !task.handle.is_finished() {
tracing::warn!(task = %task.name, "Aborting background task after shutdown grace period");
task.handle.abort();
}
}
for task in &mut tasks[index..] {
let _ = (&mut task.handle).await;
}
break;
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicBool, Ordering};
#[tokio::test]
async fn shutdown_cancels_registered_task() {
let supervisor = TaskSupervisor::new();
let dropped = Arc::new(AtomicBool::new(false));
let marker = dropped.clone();
supervisor.spawn("pending", async move {
struct DropMarker(Arc<AtomicBool>);
impl Drop for DropMarker {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
let _marker = DropMarker(marker);
std::future::pending::<()>().await;
});
tokio::task::yield_now().await;
supervisor.cancel();
assert!(!supervisor.spawn("late", async {}));
supervisor.shutdown(Duration::from_secs(1)).await;
assert!(dropped.load(Ordering::SeqCst));
}
#[tokio::test]
async fn graceful_task_observes_cancellation_before_shutdown_returns() {
let supervisor = TaskSupervisor::new();
let cancellation = supervisor.cancellation_token();
let cleaned_up = Arc::new(std::sync::atomic::AtomicBool::new(false));
let task_cleaned_up = cleaned_up.clone();
assert!(supervisor.spawn_graceful("graceful", async move {
cancellation.cancelled().await;
task_cleaned_up.store(true, std::sync::atomic::Ordering::SeqCst);
}));
supervisor.shutdown(Duration::from_secs(1)).await;
assert!(cleaned_up.load(std::sync::atomic::Ordering::SeqCst));
}
}

View File

@ -39,11 +39,11 @@ struct BrowserState {
impl Drop for BrowserTool {
fn drop(&mut self) {
if let Ok(mut driver) = self.driver.lock()
&& let Some(ref mut child) = driver.take()
{
tracing::debug!("Stopping chromedriver process");
let _ = child.start_kill();
if let Ok(mut driver) = self.driver.lock() {
if let Some(ref mut child) = driver.take() {
tracing::debug!("Stopping chromedriver process");
let _ = child.start_kill();
}
}
}
}
@ -454,7 +454,10 @@ impl BrowserState {
} => {
let client = self.active_client()?;
let result: Value = client
.execute(&snapshot_script(interactive_only, compact, depth), vec![])
.execute(
&snapshot_script(interactive_only, compact, depth.map(i64::from)),
vec![],
)
.await?;
let output = serde_json::to_string_pretty(&result)?;
Ok(ToolResult {
@ -823,11 +826,11 @@ impl BrowserState {
if let Some(client) = self.client.take() {
let _ = client.close().await;
}
if let Ok(mut guard) = driver.lock()
&& let Some(ref mut child) = guard.take()
{
tracing::debug!("Stopping chromedriver process");
let _ = child.start_kill();
if let Ok(mut guard) = driver.lock() {
if let Some(ref mut child) = guard.take() {
tracing::debug!("Stopping chromedriver process");
let _ = child.start_kill();
}
}
}
@ -954,10 +957,10 @@ fn launch_chromedriver(
}
fn kill_driver_guard(driver: &std::sync::Mutex<Option<tokio::process::Child>>) {
if let Ok(mut guard) = driver.lock()
&& let Some(ref mut child) = guard.take()
{
let _ = child.start_kill();
if let Ok(mut guard) = driver.lock() {
if let Some(ref mut child) = guard.take() {
let _ = child.start_kill();
}
}
}

View File

@ -380,7 +380,7 @@ fn calc_evaluate(args: &serde_json::Value) -> Result<String, String> {
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing required parameter: expression".to_string())?;
super::expression::evaluate(expression)
meval::eval_str(expression)
.map(format_num)
.map_err(|e| format!("Expression evaluation error: {e}"))
}

View File

@ -299,8 +299,8 @@ mod tests {
chat_id: format!("sid{}", i),
dialog_id: format!("dialog{}", i),
title: format!("会话{}", i),
created_at: now - i * 3_600_000,
last_active_at: now - i * 3_600_000,
created_at: now - i * 3600_000,
last_active_at: now - i * 3600_000,
message_count: i * 5,
routing_info: None,
archived_at: None,
@ -350,7 +350,7 @@ mod tests {
let msg = crate::storage::message::MessageMeta {
id: format!("msg{}", i),
session_id: session_id.to_string(),
seq: i + 1,
seq: i as i64 + 1,
role: if i == 0 {
"user".to_string()
} else {
@ -412,7 +412,7 @@ mod tests {
let msg = crate::storage::message::MessageMeta {
id: format!("msg{}", i),
session_id: session_id.to_string(),
seq: i + 1,
seq: i as i64 + 1,
role: if i % 2 == 0 {
"user".to_string()
} else {
@ -472,7 +472,7 @@ mod tests {
let msg = crate::storage::message::MessageMeta {
id: format!("msg{}", i),
session_id: session_id.to_string(),
seq: i + 1,
seq: i as i64 + 1,
role: "user".to_string(),
content: format!("消息内容 {}", i),
reasoning_content: None,

View File

@ -303,11 +303,11 @@ impl DelegateTool {
if let Some(ref error) = task.error {
output.push_str(&format!("\n错误: {}", error));
}
if let Some(started) = task.started_at
&& let Some(finished) = task.finished_at
{
let duration = (finished - started) as f64 / 1000.0;
output.push_str(&format!("\n耗时: {:.1}s", duration));
if let Some(started) = task.started_at {
if let Some(finished) = task.finished_at {
let duration = (finished - started) as f64 / 1000.0;
output.push_str(&format!("\n耗时: {:.1}s", duration));
}
}
Ok(ToolResult {
success: true,

View File

@ -1,277 +0,0 @@
/// Evaluate a self-contained mathematical expression without executing code or
/// resolving external variables.
pub(super) fn evaluate(input: &str) -> Result<f64, String> {
const MAX_EXPRESSION_BYTES: usize = 4096;
if input.len() > MAX_EXPRESSION_BYTES {
return Err(format!(
"expression exceeds the {MAX_EXPRESSION_BYTES}-byte limit"
));
}
let mut parser = Parser {
input,
position: 0,
depth: 0,
};
let value = parser.parse_expression()?;
parser.skip_whitespace();
if parser.position != input.len() {
return Err(parser.error("unexpected trailing input"));
}
Ok(value)
}
struct Parser<'a> {
input: &'a str,
position: usize,
depth: usize,
}
impl Parser<'_> {
fn parse_expression(&mut self) -> Result<f64, String> {
let mut value = self.parse_term()?;
loop {
if self.consume(b'+') {
value += self.parse_term()?;
} else if self.consume(b'-') {
value -= self.parse_term()?;
} else {
return Ok(value);
}
}
}
fn parse_term(&mut self) -> Result<f64, String> {
let mut value = self.parse_unary()?;
loop {
if self.consume(b'*') {
value *= self.parse_unary()?;
} else if self.consume(b'/') {
value /= self.parse_unary()?;
} else if self.consume(b'%') {
value %= self.parse_unary()?;
} else {
return Ok(value);
}
}
}
fn parse_unary(&mut self) -> Result<f64, String> {
if self.consume(b'+') {
self.nested(Self::parse_unary)
} else if self.consume(b'-') {
Ok(-self.nested(Self::parse_unary)?)
} else {
self.parse_power()
}
}
fn parse_power(&mut self) -> Result<f64, String> {
let base = self.parse_primary()?;
if self.consume(b'^') {
Ok(base.powf(self.nested(Self::parse_unary)?))
} else {
Ok(base)
}
}
fn parse_primary(&mut self) -> Result<f64, String> {
self.skip_whitespace();
match self.peek() {
Some(b'(') => {
self.position += 1;
let value = self.nested(Self::parse_expression)?;
if !self.consume(b')') {
return Err(self.error("expected ')'"));
}
Ok(value)
}
Some(byte) if byte.is_ascii_digit() || byte == b'.' => self.parse_number(),
Some(byte) if byte.is_ascii_alphabetic() || byte == b'_' => self.parse_identifier(),
Some(_) => Err(self.error("expected a number, constant, function, or '('")),
None => Err(self.error("unexpected end of expression")),
}
}
fn parse_number(&mut self) -> Result<f64, String> {
self.skip_whitespace();
let start = self.position;
let mut digits = 0;
while self.peek().is_some_and(|byte| byte.is_ascii_digit()) {
self.position += 1;
digits += 1;
}
if self.peek() == Some(b'.') {
self.position += 1;
while self.peek().is_some_and(|byte| byte.is_ascii_digit()) {
self.position += 1;
digits += 1;
}
}
if digits == 0 {
return Err(self.error("invalid number"));
}
if matches!(self.peek(), Some(b'e' | b'E')) {
self.position += 1;
if matches!(self.peek(), Some(b'+' | b'-')) {
self.position += 1;
}
let exponent_start = self.position;
while self.peek().is_some_and(|byte| byte.is_ascii_digit()) {
self.position += 1;
}
if self.position == exponent_start {
return Err(self.error("invalid numeric exponent"));
}
}
self.input[start..self.position]
.parse::<f64>()
.map_err(|_| self.error("invalid number"))
}
fn parse_identifier(&mut self) -> Result<f64, String> {
self.skip_whitespace();
let start = self.position;
while self
.peek()
.is_some_and(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
{
self.position += 1;
}
let name = self.input[start..self.position].to_ascii_lowercase();
self.skip_whitespace();
if self.peek() != Some(b'(') {
return match name.as_str() {
"pi" => Ok(std::f64::consts::PI),
"e" => Ok(std::f64::consts::E),
_ => Err(self.error(&format!("unknown constant or variable '{name}'"))),
};
}
self.position += 1;
let mut arguments = Vec::new();
self.skip_whitespace();
if self.peek() != Some(b')') {
loop {
arguments.push(self.nested(Self::parse_expression)?);
if self.consume(b',') {
continue;
}
break;
}
}
if !self.consume(b')') {
return Err(self.error("expected ')' after function arguments"));
}
apply_function(&name, &arguments).map_err(|message| self.error(&message))
}
fn consume(&mut self, expected: u8) -> bool {
self.skip_whitespace();
if self.peek() == Some(expected) {
self.position += 1;
true
} else {
false
}
}
fn skip_whitespace(&mut self) {
while self.peek().is_some_and(|byte| byte.is_ascii_whitespace()) {
self.position += 1;
}
}
fn peek(&self) -> Option<u8> {
self.input.as_bytes().get(self.position).copied()
}
fn nested<T>(&mut self, parse: fn(&mut Self) -> Result<T, String>) -> Result<T, String> {
const MAX_PARSE_DEPTH: usize = 128;
if self.depth >= MAX_PARSE_DEPTH {
return Err(self.error("expression nesting limit exceeded"));
}
self.depth += 1;
let result = parse(self);
self.depth -= 1;
result
}
fn error(&self, message: &str) -> String {
format!("{message} at byte {}", self.position)
}
}
fn apply_function(name: &str, arguments: &[f64]) -> Result<f64, String> {
let unary = |function: fn(f64) -> f64| match arguments {
[value] => Ok(function(*value)),
_ => Err(format!("function '{name}' expects one argument")),
};
match name {
"sqrt" => unary(f64::sqrt),
"abs" => unary(f64::abs),
"exp" => unary(f64::exp),
"ln" => unary(f64::ln),
"log2" => unary(f64::log2),
"log10" => unary(f64::log10),
"sin" => unary(f64::sin),
"cos" => unary(f64::cos),
"tan" => unary(f64::tan),
"asin" => unary(f64::asin),
"acos" => unary(f64::acos),
"atan" => unary(f64::atan),
"sinh" => unary(f64::sinh),
"cosh" => unary(f64::cosh),
"tanh" => unary(f64::tanh),
"asinh" => unary(f64::asinh),
"acosh" => unary(f64::acosh),
"atanh" => unary(f64::atanh),
"floor" => unary(f64::floor),
"ceil" => unary(f64::ceil),
"round" => unary(f64::round),
"signum" => unary(f64::signum),
"atan2" => match arguments {
[y, x] => Ok(y.atan2(*x)),
_ => Err("function 'atan2' expects two arguments".to_string()),
},
"min" => arguments
.iter()
.copied()
.reduce(f64::min)
.ok_or_else(|| "function 'min' expects at least one argument".to_string()),
"max" => arguments
.iter()
.copied()
.reduce(f64::max)
.ok_or_else(|| "function 'max' expects at least one argument".to_string()),
_ => Err(format!("unknown function '{name}'")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn respects_precedence_and_right_associative_power() {
assert_eq!(evaluate("15*3+5^(2+1)").unwrap(), 170.0);
assert_eq!(evaluate("2^3^2").unwrap(), 512.0);
assert_eq!(evaluate("-2^2").unwrap(), -4.0);
}
#[test]
fn supports_constants_functions_and_scientific_notation() {
assert_eq!(evaluate("sqrt(1.44e2)").unwrap(), 12.0);
assert_eq!(evaluate("max(1, 2, 3) + min(4, 5)").unwrap(), 7.0);
assert!((evaluate("sin(pi / 2)").unwrap() - 1.0).abs() < f64::EPSILON);
}
#[test]
fn rejects_unknown_names_and_trailing_input() {
assert!(evaluate("unknown").is_err());
assert!(evaluate("1 + 2 garbage").is_err());
assert!(evaluate("sqrt() ").is_err());
assert!(evaluate(&"(".repeat(129)).is_err());
assert!(evaluate(&"1+".repeat(3000)).is_err());
}
}

View File

@ -5,7 +5,6 @@ pub mod chat_manager;
pub mod content_search;
pub mod cron;
pub mod delegate;
mod expression;
pub mod file_edit;
pub mod file_read;
pub mod file_search;
@ -78,10 +77,10 @@ pub fn create_default_tools(
registry.register(TimelineRecallTool::new(memory.clone()));
registry.register(MemoryForgetTool::new(memory.clone()));
if let Some(cfg) = browser_config
&& cfg.enabled
{
registry.register(BrowserTool::new(cfg));
if let Some(cfg) = browser_config {
if cfg.enabled {
registry.register(BrowserTool::new(cfg));
}
}
if let Some(mgr) = sub_agent_manager {

View File

@ -96,7 +96,11 @@ enum SessionStatus {
}
struct PtySession {
#[allow(dead_code)]
id: String,
#[allow(dead_code)]
command: String,
#[allow(dead_code)]
started_at: Instant,
status: SessionStatus,
child: Arc<Mutex<Option<Box<dyn portable_pty::Child + Send + Sync>>>>,
@ -107,11 +111,13 @@ struct PtySession {
impl PtySession {
fn new(
id: String,
command: String,
child: Box<dyn portable_pty::Child + Send + Sync>,
writer: Box<dyn Write + Send>,
) -> Self {
Self {
id,
command,
started_at: Instant::now(),
status: SessionStatus::Running,
@ -140,12 +146,6 @@ pub struct PtyManager {
sessions: Mutex<HashMap<String, Arc<Mutex<PtySession>>>>,
}
impl Default for PtyManager {
fn default() -> Self {
Self::new()
}
}
impl PtyManager {
pub fn new() -> Self {
Self {
@ -199,7 +199,7 @@ impl PtyManager {
.map_err(|e| format!("Failed to open PTY: {}", e))?;
let mut cmd = portable_pty::CommandBuilder::new("bash");
cmd.args(["-c", command]);
cmd.args(&["-c", command]);
cmd.cwd(cwd);
let child = pty_pair
@ -219,7 +219,8 @@ impl PtyManager {
.try_clone_reader()
.map_err(|e| format!("Failed to clone reader: {}", e))?;
let session = PtySession::new(command.to_string(), child, writer);
let session_id_clone = session_id.clone();
let session = PtySession::new(session_id_clone, command.to_string(), child, writer);
let session = Arc::new(Mutex::new(session));
sessions.insert(session_id.clone(), session.clone());

View File

@ -169,7 +169,7 @@ fn parse_files_arg(args: &serde_json::Value) -> Vec<MediaItem> {
files
.iter()
.filter_map(|v| v.as_str())
.map(path_to_media_item)
.map(|path| path_to_media_item(path))
.collect()
}

View File

@ -1,4 +1,4 @@
use picobot::protocol::{HistoryMessage, SessionSummary, WsInbound, WsOutbound};
use picobot::protocol::{SessionSummary, WsInbound, WsOutbound};
use picobot::providers::{ChatCompletionRequest, Message};
/// Test that message with special characters is properly escaped
@ -18,7 +18,7 @@ fn test_message_special_characters() {
/// Test that multi-line system prompt is preserved
#[test]
fn test_multiline_system_prompt() {
let messages = [
let messages = vec![
Message::system(
"You are a helpful assistant.\n\nFollow these rules:\n1. Be kind\n2. Be accurate",
),
@ -116,34 +116,3 @@ fn test_clear_history_with_session_id_serialization() {
assert!(json.contains(r#""type":"clear_history""#));
assert!(json.contains(r#""session_id":"session-1""#));
}
#[test]
fn test_bounded_session_history_protocol() {
let inbound = WsInbound::GetSessionHistory {
session_id: "cli_chat:client:dialog".to_string(),
limit: Some(1000),
};
let json = serde_json::to_string(&inbound).unwrap();
assert!(json.contains(r#""type":"get_session_history""#));
assert!(json.contains(r#""limit":1000"#));
let outbound = WsOutbound::SessionHistory {
session_id: "cli_chat:client:dialog".to_string(),
messages: vec![HistoryMessage {
id: "m1".to_string(),
seq: 1,
role: "user".to_string(),
content: "你好".to_string(),
created_at: 123,
}],
};
let decoded: WsOutbound =
serde_json::from_str(&serde_json::to_string(&outbound).unwrap()).unwrap();
match decoded {
WsOutbound::SessionHistory { messages, .. } => {
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].content, "你好");
}
other => panic!("unexpected decoded variant: {other:?}"),
}
}

View File

@ -1,5 +1,6 @@
//! Integration tests for the scheduled tasks (cron) system.
//! Run with: `cargo test --test test_scheduler`.
/// Integration tests for the scheduled tasks (cron) system.
/// Run with: cargo test --test test_scheduler
use serde_json::json;
/// Verify that Schedule types (de)serialize correctly.
#[tokio::test]