初步实现todo功能

This commit is contained in:
xiaoxixi 2026-07-15 17:48:14 +08:00
parent 9ac898acbb
commit 351af8870d
24 changed files with 1502 additions and 43 deletions

View File

@ -65,6 +65,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
| `skills` | Skills loading, management, and prompt building | `SkillsLoader`, `Skill` |
| `storage` | SQLite persistence for sessions and messages | `Storage`, `SessionMeta`, `MessageMeta` |
| `scheduler` | Cron-based job scheduling, next-run computation | `Scheduler`, `Schedule`, `next_run_for_schedule()` |
| `work` | Session-scoped active plan and concurrently executable checklist items | `WorkManager`, `TaskPlan`, `TaskItem` |
| `observability` | Observer pattern for agent/tool telemetry events | `Observer` trait, `ObserverEvent`, `MultiObserver` |
| `protocol` | WebSocket protocol message types | `WsInbound`, `WsOutbound`, `SessionSummary`, `HistoryMessage` |
| `config` | Config loading, env substitution, path resolution | `Config`, `LLMProviderConfig` |
@ -76,6 +77,7 @@ Scheduler → SessionManager scheduled execution → AgentLoop → Scheduler del
- **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
- **WorkManager** owns the single active plan per session, item state transitions, plan versions, and plan-change events; plans are optional and absent from ordinary chat context
- **Scheduler** supports legacy direct-delivery jobs and managed `task`/`monitor` jobs; managed agents cannot call `send_message`, and `on_alert` suppresses only healthy informational results
- **AgentLoop** is stateless across turns; it receives prepared history, calls LLM providers, executes tools, and returns one result
- **WebUI management APIs** only expose allowlisted config/profile/log/storage operations; keep response limits, secret redaction, atomic config writes, and profile path allowlists intact

View File

@ -10,6 +10,7 @@ PicoBot 是一个用 Rust 编写的个人 AI 助手运行时。它在本地启
- 在终端里和本地 AI 助手持续对话。
- 在浏览器中聊天,并查看日志、任务和记忆,修改运行配置与助手档案。
- 复杂任务可创建 session 级 Todo 计划,把不同子项并行委托给多个子 Agent聊天页侧栏实时显示进度。
- 将同一套 Agent 能力接入飞书/Lark。
- 让 Agent 使用本地文件、Shell、搜索、HTTP、浏览器、MCP 工具完成任务。
- 把长期偏好、事实和历史摘要存成可检索记忆。
@ -104,6 +105,7 @@ WebUI 随二进制嵌入,不需要 Node.js、npm 或单独部署静态文件
- 在线聊天、会话创建/切换、历史回放,以及基于 Gateway 实时命令清单的 `/` 斜杠命令补全。
- Cron 定时任务、最近运行记录和后台子任务状态。
- 当前聊天 session 的可展开 Todo 侧栏;计划变化时自动展开,其他 session 的变化显示未读提示。
- Knowledge/Timeline 记忆的分类与全文检索。
- 本地滚动日志的尾部查看、过滤和自动刷新。
- `config.json``~/.picobot/USER.md``~/.picobot/AGENTS.md` 编辑。
@ -180,6 +182,7 @@ TUI 会把一个随机客户端标识保存到 `~/.picobot/tui_client_id`,因
| `tools` | Agent 可调用工具集合 |
| `storage` | SQLite schema、CRUD、消息和任务持久化 |
| `scheduler` | 领取定时任务,执行普通/巡检 Agent并按投递策略记录或发送结果 |
| `work` | 管理 session 级单 active plan、并行子项状态和 WebSocket 变更事件 |
| `skills` | 加载 Skill并把 Skill 指南注入系统提示 |
| `mcp` | 连接 MCP Server将远端工具包装成普通 Tool |
| `task_supervisor` | 统一管理 Gateway 后台任务的取消和有界关停 |
@ -217,6 +220,7 @@ Session ID 使用三段式:
| `/dump` | 导出当前 dialog 为 Markdown |
| `/mcp` | 查看 MCP 服务器和工具状态 |
| `/stop` | 停止当前任务并清空队列 |
| `/todo [done\|cancel]` | 查看、完成或取消当前 session 的任务计划 |
| `/?`, `/help` | 查看帮助 |
### 记忆
@ -244,6 +248,7 @@ PicoBot 有两类记忆:
| `get_skill` | 列出或读取本地 Skill |
| `memory_store` / `memory_recall` / `timeline_recall` / `memory_forget` | 长期记忆操作 |
| `delegate` | 启动 inline、background 或 parallel 子 Agent |
| `todo` | 为复杂、多轮任务创建并更新当前 session 的持久化计划 |
| `send_message` | 向指定渠道发送消息 |
| `chat_manager` | 查看渠道、会话和历史消息 |
| `cron_add/list/remove/enable/disable/update` | 管理定时任务 |
@ -354,6 +359,7 @@ src/
observability/ Agent/tool telemetry observer
providers/ OpenAI 兼容和 Anthropic provider
scheduler/ 定时任务运行时
work/ Session 级任务计划与并行子项状态机
session/ 会话生命周期、dialog 命令、持久化集成
skills/ Skill 加载和内置 Skill 安装
storage/ SQLite schema 和 CRUD

View File

@ -65,6 +65,7 @@ flowchart LR
| `storage` | SQLite schema、迁移、原子 CRUD | 运行时调度策略 |
| `memory` | Knowledge/Timeline 的存取与召回 | 直接驱动消息发送 |
| `scheduler` | 领取到期任务、执行普通/巡检 Agent、应用投递策略、原子记录结果 | 复用聊天会话历史、直接感知 Channel |
| `work` | session 级单 active plan、并行子项状态机、版本和变更事件 | 执行模型调用、持有 Channel/WebSocket |
| `task_supervisor` | 后台任务注册、取消、限时回收 | 业务级重试和结果语义 |
## 4. 消息与控制数据流
@ -148,7 +149,9 @@ Session ID 格式为:
5. 持久化写入由 `persistence_lock` 串行化;多条相关记录应使用 Storage 的原子接口。
6. 内存先变更但持久化失败时,必须回滚精确匹配的消息后缀,不能删除无关的新状态。
SessionManager 负责组装会话上下文系统提示、Skills、召回的 Knowledge、压缩后的 Timeline 和当前消息历史。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态。
SessionManager 负责组装会话上下文系统提示、Skills、召回的 Knowledge、压缩后的 Timeline、可选的 active plan 摘要和当前消息历史。普通闲聊 session 没有 plan 摘要;计划状态由 `WorkManager` 从 SQLite 读取,在历史压缩之后追加,因此不以自然语言摘要作为权威来源。`AgentLoop` 接收完整输入执行一次模型/工具循环,本身不拥有会话状态。
每个 session 最多有一个 active plan但 plan 内多个 item 可以分别绑定不同子 Agent 并行执行。主 Agent 通过 `todo` 创建和维护计划,通过 `delegate.plan_item_id` 委托;子 Agent 不获得 `todo``delegate`。领取 item 使用条件更新防止重复执行,迟到结果只有在 plan 仍 active 且 execution ID 匹配时才允许提交。
## 6. 持久化
@ -159,7 +162,7 @@ SessionManager 负责组装会话上下文系统提示、Skills、召回的 K
- 5 秒 busy timeout。
- schema version 迁移。
持久化范围包括 sessions、messages、memories、scheduled jobs、job runs 和 background tasks。修改 schema 时应:
持久化范围包括 sessions、messages、memories、task plans/items、scheduled jobs、job runs 和 background tasks。修改 schema 时应:
1. 更新集中式 schema/迁移逻辑。
2. 保留已有数据库的升级路径。
@ -194,7 +197,7 @@ WebSocket 每个客户端的 writer task 是连接局部任务,由连接 handl
### WebUI 与管理 API
Gateway 在 `/` 提供随二进制编译的 HTML/CSS/JavaScript不依赖外部 CDN 或前端运行服务。前端源码位于 `webui/`,由 Svelte 5 + Vite 构建Bits UI 提供无样式的可访问组件原语。`build.rs` 监听前端源码、锁文件和构建配置,增量地将生产资源生成到 Cargo `OUT_DIR`Rust 再从该目录编译嵌入;前端产物不进入仓库,最终用户使用发布二进制时不需要 Node.js。浏览器聊天继续使用 `/ws``cli_chat` 渠道,因此复用现有 dialog scope、每会话串行 worker、历史持久化和出站 lane斜杠命令补全通过 `get_slash_commands` 获取 Gateway 的实时命令与别名清单不在前端重复定义WebUI 不直接调用 Provider 或 SessionManager。
Gateway 在 `/` 提供随二进制编译的 HTML/CSS/JavaScript不依赖外部 CDN 或前端运行服务。前端源码位于 `webui/`,由 Svelte 5 + Vite 构建Bits UI 提供无样式的可访问组件原语。`build.rs` 监听前端源码、锁文件和构建配置,增量地将生产资源生成到 Cargo `OUT_DIR`Rust 再从该目录编译嵌入;前端产物不进入仓库,最终用户使用发布二进制时不需要 Node.js。浏览器聊天继续使用 `/ws``cli_chat` 渠道,因此复用现有 dialog scope、每会话串行 worker、历史持久化和出站 lane聊天页的 Todo 侧栏默认隐藏,按 session 保存快照和未读状态,并通过结构化 `session_plan`/`plan_updated` 帧刷新,计划变化不会写入聊天历史;斜杠命令补全通过 `get_slash_commands` 获取 Gateway 的实时命令与别名清单不在前端重复定义WebUI 不直接调用 Provider 或 SessionManager。
同源 `/api/*` 管理接口只提供显式白名单能力:

View File

@ -32,6 +32,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
| `memory` | 长期记忆存储与检索 |
| `mcp` | MCPModel Context Protocol工具集成 |
| `task_supervisor` | Gateway 后台任务注册、取消、限时等待和强制回收 |
| `work` | 每个 session 的单 active plan、并行子项状态、版本与变更事件 |
## 功能边界
@ -43,6 +44,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
- Tools 接收原始参数,返回字符串结果
- MCP 工具在 Gateway 初始化时连接服务器、发现工具,并包装成普通 Tool 注册到 ToolRegistry
- 子 Agent 由 `delegate` 工具创建,复用 provider 配置和按需过滤后的工具集;后台任务结果通过 MessageBus 发回原会话
- 复杂任务可使用 `todo` 创建 session 级计划;多个子项可通过 `delegate.plan_item_id` 并行委托,子 Agent 不能修改计划
- WebUI 聊天复用 `/ws``cli_chat`;同源管理 API 只读取受限的日志、任务、记忆,并对白名单配置文件做原子写入
- WebUI 使用 Svelte 5 + ViteBits UI 提供无样式可访问组件;`cargo build` 增量生成前端到 Cargo `OUT_DIR`,再嵌入单二进制,仓库不保存生成产物
@ -243,6 +245,10 @@ Gateway 初始化时读取 `config.mcp.servers`
后台子 Agent 通过 `TaskSupervisor::spawn_graceful` 注册,受 `gateway.max_concurrent_background_tasks` 限制Gateway 关停时先收到取消信号,再在总宽限期内清理。
## Session Todo 计划
每个 session 最多有一个 active plan普通闲聊没有计划上下文。计划和子项分别持久化到 `task_plans``task_items`历史压缩后仍从权威状态生成精简摘要。WebUI 聊天页通过 `session_plan``plan_updated` WebSocket 帧显示默认隐藏的侧栏,当前 session 变化时自动展开,其他 session 只标记未读。
---
## 出站投递与关停

View File

@ -66,6 +66,12 @@ delegate 后台子任务表。`session_id` 不使用数据库外键,因为 ses
| `finished_at` | INTEGER | 结束时间 |
| `created_at` | INTEGER | 创建时间 |
## task_plans / task_items 表
`task_plans` 保存 session 级任务计划,通过部分唯一索引保证每个 session 最多一个 `status='active'` 的计划。`version` 在任何子项变化时递增,用于 WebSocket 快照排序和乐观并发检查。
`task_items``(plan_id, id)` 为复合主键,因此每个计划都可使用 `T1``T2` 等稳定显示 ID。子项状态为 `pending``in_progress``completed``blocked``executor_kind``execution_id` 将并行子 Agent 执行绑定到具体子项。只有 active plan 且 execution ID 匹配时,异步结果才能提交。
## memories 表
长期记忆存储。

View File

@ -143,9 +143,14 @@ Cron 不是一个带 `action` 的统一工具,而是六个独立工具;仅
| `timeout_secs` | 否 | 超时秒数,默认 3600 |
| `tasks` | parallel 必填 | 并行子任务数组 |
| `task_id` | 查询/取消必填 | 后台任务 ID |
| `plan_item_id` | 否 | 将 inline/background 子 Agent 绑定到当前计划子项parallel 数组中的每项也可分别绑定 |
默认只读工具集:`file_read``file_search``content_search``web_fetch``http_request``calculator`
## todo — Session 任务计划
仅用于明确的复杂、多轮或并行任务。`create` 创建当前 session 唯一的 active plan`view` 查看;`append` 增加子项;`update` 修改子项状态;`close` 完成或取消计划。普通闲聊和单步操作不应创建计划。子 Agent 始终被过滤掉 `todo``delegate`,计划结构只由主 Agent 管理。
---
## browser — 浏览器自动化

View File

@ -47,6 +47,8 @@ pub struct SubAgentConfig {
pub allowed_tools: Option<Vec<String>>,
pub max_iterations: Option<usize>,
pub timeout_secs: Option<u64>,
pub plan_item_id: Option<String>,
pub session_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@ -122,6 +124,7 @@ pub struct SubAgentManager {
notify_tx: tokio::sync::mpsc::UnboundedSender<TaskNotification>,
max_concurrent_background_tasks: usize,
skills_loader: Option<Arc<SkillsLoader>>,
work_manager: Option<Arc<crate::work::WorkManager>>,
task_supervisor: crate::task_supervisor::TaskSupervisor,
}
@ -144,10 +147,16 @@ impl SubAgentManager {
notify_tx,
max_concurrent_background_tasks,
skills_loader,
work_manager: None,
task_supervisor,
}
}
pub fn with_work_manager(mut self, work_manager: Arc<crate::work::WorkManager>) -> Self {
self.work_manager = Some(work_manager);
self
}
pub fn filter_tools(&self, allowed: &Option<Vec<String>>) -> Arc<ToolRegistry> {
let allowed_set: HashSet<&str> = match allowed {
Some(list) => list.iter().map(|s| s.as_str()).collect(),
@ -155,7 +164,7 @@ impl SubAgentManager {
};
let filtered = ToolRegistry::new();
for (name, tool) in self.full_tools.iter() {
if allowed_set.contains(name.as_str()) && name != "delegate" {
if allowed_set.contains(name.as_str()) && name != "delegate" && name != "todo" {
filtered.register_raw(name, tool);
}
}
@ -230,6 +239,7 @@ impl SubAgentManager {
let agent = self
.build_sub_agent(&config, tools)
.map_err(|e| SubAgentError::ProviderCreation(e.to_string()))?;
self.assign_work_item(&config, &task_id).await?;
let history = vec![
ChatMessage::system(system_prompt),
@ -246,7 +256,7 @@ impl SubAgentManager {
let duration_ms = start.elapsed().as_millis() as u64;
match result {
let result = match result {
Ok(Ok(agent_result)) => {
let (content, truncated) =
truncate_sub_agent_result(&agent_result.final_response.content);
@ -260,35 +270,37 @@ impl SubAgentManager {
.iter()
.filter(|m| m.role == "assistant" && m.tool_calls.is_some())
.count();
Ok(SubAgentResult {
task_id,
SubAgentResult {
task_id: task_id.clone(),
content,
content_truncated: truncated,
status: TaskStatus::Completed,
tool_calls_count,
iterations,
duration_ms,
})
}
Ok(Err(e)) => Ok(SubAgentResult {
task_id,
}
Ok(Err(e)) => SubAgentResult {
task_id: task_id.clone(),
content: String::new(),
content_truncated: false,
status: TaskStatus::Failed(e.to_string()),
tool_calls_count: 0,
iterations: 0,
duration_ms,
}),
Err(_elapsed) => Ok(SubAgentResult {
task_id,
},
Err(_elapsed) => SubAgentResult {
task_id: task_id.clone(),
content: String::new(),
content_truncated: false,
status: TaskStatus::TimedOut,
tool_calls_count: 0,
iterations: 0,
duration_ms,
}),
}
},
};
self.finish_work_item(&config, &result).await;
Ok(result)
}
pub async fn run_parallel(
@ -319,11 +331,12 @@ impl SubAgentManager {
.map_err(|_| SubAgentError::TooManyTasks(self.max_concurrent_background_tasks))?;
let task_id = generate_task_id();
let mut work_config = config.clone();
if work_config.session_id.is_none() {
work_config.session_id = Some(ctx.session_id.clone());
}
let cancel_token = CancellationToken::new();
self.active_tasks
.insert(task_id.clone(), cancel_token.clone());
// Write DB: pending
if let Some(ref storage) = self.storage {
let allowed_tools_json = config
@ -351,6 +364,28 @@ impl SubAgentManager {
.await
.map_err(|e| SubAgentError::Storage(e.to_string()))?;
}
if let Err(error) = self.assign_work_item(&work_config, &task_id).await {
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("plan item assignment failed"),
started_at: None,
finished_at: Some(chrono::Utc::now().timestamp_millis()),
tool_calls_count: None,
iterations: None,
},
)
.await;
}
return Err(error);
}
self.active_tasks
.insert(task_id.clone(), cancel_token.clone());
let tools = self.filter_tools(&config.allowed_tools);
let timeout_secs = config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS);
@ -381,6 +416,9 @@ impl SubAgentManager {
let ch = ctx.channel.clone();
let cid = ctx.chat_id.clone();
let prompt = config.prompt.clone();
let work_manager = self.work_manager.clone();
let work_item_id = work_config.plan_item_id.clone();
let work_session_id = work_config.session_id.clone();
let spawned = self.task_supervisor.spawn_graceful(
format!("sub-agent:{task_id}"),
@ -510,6 +548,23 @@ impl SubAgentManager {
TaskStatus::TimedOut => ("failed".to_string(), Some("timeout".to_string())),
};
if let (Some(manager), Some(session_id), Some(item_id)) =
(work_manager.as_ref(), work_session_id.as_deref(), work_item_id.as_deref())
{
let completed = matches!(result.status, TaskStatus::Completed);
let summary = if completed {
Some(result.content.as_str())
} else {
error_val.as_deref().or(Some("子 Agent 未完成任务"))
};
if let Err(error) = manager
.finish_sub_agent(session_id, item_id, &tid, completed, summary)
.await
{
tracing::warn!(task_id = %tid, item_id, error = %error, "Failed to update plan item");
}
}
if let Some(ref s) = storage {
let _ = s
.update_background_task_status(&tid, crate::storage::background_task::BackgroundTaskUpdate {
@ -554,6 +609,21 @@ impl SubAgentManager {
)
.await;
}
if let (Some(manager), Some(session_id), Some(item_id)) = (
self.work_manager.as_ref(),
work_config.session_id.as_deref(),
work_config.plan_item_id.as_deref(),
) {
let _ = manager
.finish_sub_agent(
session_id,
item_id,
&task_id,
false,
Some("gateway shutdown"),
)
.await;
}
return Err(SubAgentError::Other(
"gateway is shutting down and cannot accept background tasks".to_string(),
));
@ -632,6 +702,51 @@ impl SubAgentManager {
pub fn active_task_count(&self) -> usize {
self.active_tasks.len()
}
async fn assign_work_item(
&self,
config: &SubAgentConfig,
task_id: &str,
) -> Result<(), SubAgentError> {
if let (Some(manager), Some(session_id), Some(item_id)) = (
self.work_manager.as_ref(),
config.session_id.as_deref(),
config.plan_item_id.as_deref(),
) {
manager
.assign_sub_agent(session_id, item_id, task_id)
.await
.map_err(|error| SubAgentError::Storage(error.to_string()))?;
}
Ok(())
}
async fn finish_work_item(&self, config: &SubAgentConfig, result: &SubAgentResult) {
let (Some(manager), Some(session_id), Some(item_id)) = (
self.work_manager.as_ref(),
config.session_id.as_deref(),
config.plan_item_id.as_deref(),
) else {
return;
};
let completed = matches!(result.status, TaskStatus::Completed);
let summary = if completed {
Some(result.content.as_str())
} else {
match &result.status {
TaskStatus::Failed(error) => Some(error.as_str()),
TaskStatus::TimedOut => Some("子 Agent 执行超时"),
TaskStatus::Cancelled => Some("子 Agent 已取消"),
TaskStatus::Completed => None,
}
};
if let Err(error) = manager
.finish_sub_agent(session_id, item_id, &result.task_id, completed, summary)
.await
{
tracing::warn!(task_id = %result.task_id, item_id, error = %error, "Failed to update plan item");
}
}
}
fn generate_task_id() -> String {
@ -722,6 +837,8 @@ mod tests {
allowed_tools: None,
max_iterations: None,
timeout_secs: Some(1),
plan_item_id: None,
session_id: None,
},
DelegateContext {
session_id: "cli:test:dialog".into(),

View File

@ -46,6 +46,7 @@ impl SystemPromptBuilder {
Box::new(SafetySection),
Box::new(CrossChannelSection),
Box::new(MemorySection),
Box::new(WorkManagementSection),
Box::new(DelegationSection),
],
}
@ -319,6 +320,26 @@ impl PromptSection for MemorySection {
/// Sub-agent delegation principles.
pub struct DelegationSection;
/// Optional session-scoped planning guidance. The actual active plan is
/// injected dynamically only when one exists.
pub struct WorkManagementSection;
impl PromptSection for WorkManagementSection {
fn name(&self) -> &str {
"work_management"
}
fn build(&self, _ctx: &PromptContext<'_>) -> String {
"## 任务追踪\n\n\
- \n\
- 使 todo \n\
- Agent \n\
- Agent Agent \n\
- completed"
.to_string()
}
}
impl PromptSection for DelegationSection {
fn name(&self) -> &str {
"delegation"
@ -452,7 +473,11 @@ pub fn build_system_prompt(workspace_dir: &Path, model_name: &str, tools: &ToolR
}
/// Build a runtime context tail that should be appended to the latest user message.
pub fn build_runtime_context(session_id: Option<&str>, memory_context: Option<&str>) -> String {
pub fn build_runtime_context(
session_id: Option<&str>,
memory_context: Option<&str>,
work_context: Option<&str>,
) -> String {
let mut sections = Vec::new();
let now = chrono::Local::now();
@ -470,6 +495,10 @@ pub fn build_runtime_context(session_id: Option<&str>, memory_context: Option<&s
sections.push(format!("### 记忆上下文\n\n{}", context));
}
if let Some(context) = work_context.filter(|s| !s.trim().is_empty()) {
sections.push(context.to_string());
}
if sections.is_empty() {
String::new()
} else {
@ -574,7 +603,8 @@ mod tests {
let _ = (temp_dir, tools);
let prompt = build_runtime_context(Some("session-123"), Some("- user_pref: Prefers Rust"));
let prompt =
build_runtime_context(Some("session-123"), Some("- user_pref: Prefers Rust"), None);
assert!(prompt.contains("## 运行时上下文"));
assert!(prompt.contains("session-123"));
assert!(prompt.contains("Prefers Rust"));
@ -587,7 +617,7 @@ mod tests {
let _ = (temp_dir, tools);
let prompt = build_runtime_context(None, None);
let prompt = build_runtime_context(None, None, None);
assert!(prompt.contains("## 运行时上下文"));
assert!(prompt.contains("当前日期与时间"));
}

View File

@ -98,6 +98,29 @@ impl CliChatChannel {
}
}
/// Push a structured task-plan update to the WebSocket client that owns
/// the session. Other channels remain unaffected.
pub async fn publish_plan_changed(&self, event: crate::work::PlanChanged) {
let Some(session_id) = UnifiedSessionId::parse(&event.session_id) else {
return;
};
if session_id.channel != "cli_chat" {
return;
}
let client = self.clients.lock().await.get(&session_id.chat_id).cloned();
if let Some(client) = client {
let _ = client
.sender
.send(WsOutbound::PlanUpdated {
session_id: event.session_id,
reason: event.reason,
changed_item_ids: event.changed_item_ids,
plan: event.plan,
})
.await;
}
}
/// 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) {
@ -390,6 +413,31 @@ impl CliChatChannel {
}
}
}
WsInbound::GetSessionPlan { session_id } => {
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::GetTaskPlan {
session_id: unified_id,
},
reply_tx,
})
.await?;
match reply_rx.recv().await {
Some(Ok(SessionEvent::TaskPlan { session_id, plan })) => {
let _ = client
.sender
.send(WsOutbound::SessionPlan {
session_id: session_id.to_string(),
plan,
})
.await;
}
Some(Ok(_)) => {}
Some(Err(error)) => return Err(error),
None => return Err(ChannelError::Other("Control channel closed".to_string())),
}
}
WsInbound::RenameSession { session_id, title } => {
let target = session_id
.or(current_session_guard.clone())

View File

@ -194,6 +194,9 @@ async fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
session_id,
messages,
} => app.set_history(&session_id, messages),
// The first Todo UI is WebUI-only. CLI keeps receiving ordinary task
// notifications and may inspect plans through /todo.
WsOutbound::SessionPlan { .. } | WsOutbound::PlanUpdated { .. } => {}
WsOutbound::SessionRenamed { session_id, title } => {
if let Some(session) = app
.sessions

View File

@ -211,6 +211,22 @@ impl GatewayState {
let bus_for_outbound = bus.clone();
let session_manager = self.session_manager.clone();
// Relay structured plan changes to WebSocket clients. This remains
// separate from chat messages, so task UI updates never pollute history.
let mut plan_events = self.session_manager.work_manager().subscribe();
let cli_chat = self.cli_chat_channel();
self.task_supervisor.spawn("task-plan-events", async move {
loop {
match plan_events.recv().await {
Ok(event) => cli_chat.publish_plan_changed(event).await,
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
tracing::warn!(skipped, "Task plan event relay lagged");
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
});
// Spawn unified message processor
// This handles both inbound AI messages and control messages in one loop
self.task_supervisor.spawn("message-processor", async move {
@ -362,6 +378,11 @@ impl GatewayState {
messages,
})
.map_err(|e| ChannelError::Other(e.to_string())),
GetTaskPlan { session_id } => session_manager
.get_task_plan(&session_id)
.await
.map(|plan| SessionEvent::TaskPlan { session_id, plan })
.map_err(|e| ChannelError::Other(e.to_string())),
RenameDialog { session_id, title } => session_manager
.rename_dialog(&session_id, &title)
.await

View File

@ -18,3 +18,4 @@ pub mod storage;
pub mod task_supervisor;
pub mod tools;
pub mod util;
pub mod work;

View File

@ -66,6 +66,8 @@ pub enum WsInbound {
#[serde(default, skip_serializing_if = "Option::is_none")]
limit: Option<u32>,
},
#[serde(rename = "get_session_plan")]
GetSessionPlan { session_id: String },
#[serde(rename = "rename_session")]
RenameSession {
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -122,6 +124,18 @@ pub enum WsOutbound {
session_id: String,
messages: Vec<HistoryMessage>,
},
#[serde(rename = "session_plan")]
SessionPlan {
session_id: String,
plan: Option<crate::work::TaskPlan>,
},
#[serde(rename = "plan_updated")]
PlanUpdated {
session_id: String,
reason: String,
changed_item_ids: Vec<String>,
plan: Option<crate::work::TaskPlan>,
},
#[serde(rename = "session_renamed")]
SessionRenamed { session_id: String, title: String },
#[serde(rename = "session_archived")]

View File

@ -26,6 +26,8 @@ pub enum SessionCommand {
session_id: UnifiedSessionId,
limit: u32,
},
/// Load the active task plan for a dialog.
GetTaskPlan { session_id: UnifiedSessionId },
/// Get the current dialog for a chat
GetCurrentDialog { channel: String, chat_id: String },
/// Rename a dialog

View File

@ -36,6 +36,11 @@ pub enum SessionEvent {
session_id: UnifiedSessionId,
messages: Vec<crate::storage::message::MessageMeta>,
},
/// Active task plan for a dialog, if present.
TaskPlan {
session_id: UnifiedSessionId,
plan: Option<crate::work::TaskPlan>,
},
/// Dialog renamed
DialogRenamed {
session_id: UnifiedSessionId,

View File

@ -120,6 +120,7 @@ struct AgentTask {
struct AgentWorkerDeps {
bus: Arc<MessageBus>,
memory_manager: Arc<crate::memory::MemoryManager>,
work_manager: Arc<crate::work::WorkManager>,
skills_loader: Arc<SkillsLoader>,
task_supervisor: crate::task_supervisor::TaskSupervisor,
}
@ -893,6 +894,7 @@ pub struct SessionManager {
storage: Arc<Storage>,
pub(super) bus: Arc<MessageBus>,
memory_manager: Arc<crate::memory::MemoryManager>,
work_manager: Arc<crate::work::WorkManager>,
sub_agent_manager: Arc<crate::agent::SubAgentManager>,
task_supervisor: crate::task_supervisor::TaskSupervisor,
}
@ -982,6 +984,11 @@ pub static SLASH_COMMANDS: &[SlashCommand] = &[
description: "停止当前正在执行的任务并清空消息队列",
aliases: &["/stop"],
},
SlashCommand {
name: "todo",
description: "查看、完成或取消当前任务计划",
aliases: &["/todo"],
},
];
fn resolve_slash_command(command: &str) -> Option<&'static SlashCommand> {
@ -1000,6 +1007,7 @@ impl SessionManager {
AgentWorkerDeps {
bus: self.bus.clone(),
memory_manager: self.memory_manager.clone(),
work_manager: self.work_manager.clone(),
skills_loader: self.skills_loader.clone(),
task_supervisor: self.task_supervisor.clone(),
}
@ -1019,16 +1027,19 @@ impl SessionManager {
skills_loader.set_workspace_skills_dir(provider_config.workspace_dir.clone());
let skills_loader = Arc::new(skills_loader);
let work_manager = Arc::new(crate::work::WorkManager::new(storage.clone()));
let tools = Arc::new(create_default_tools(
skills_loader.clone(),
memory_manager.clone(),
work_manager.clone(),
None, // SubAgentManager created below
browser_config.as_ref(),
));
// Create SubAgentManager and register DelegateTool
let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel();
let sub_agent_manager = Arc::new(crate::agent::SubAgentManager::new(
let sub_agent_manager = Arc::new(
crate::agent::SubAgentManager::new(
provider_config.clone(),
tools.clone(),
Some(storage.clone()),
@ -1036,7 +1047,9 @@ impl SessionManager {
max_concurrent_background_tasks,
Some(skills_loader.clone()),
task_supervisor.clone(),
));
)
.with_work_manager(work_manager.clone()),
);
tools.register(crate::tools::DelegateTool::new(sub_agent_manager.clone()));
// Start background task notification consumer
@ -1045,13 +1058,17 @@ impl SessionManager {
while let Some(notif) = notify_rx.recv().await {
let content =
format_task_notification(&notif.task_id, &notif.status, &notif.result_summary);
let metadata = HashMap::from([
("_type".to_string(), "notification".to_string()),
("_session_id".to_string(), notif.session_id),
]);
let outbound = OutboundMessage {
channel: notif.channel,
chat_id: notif.chat_id,
content,
reply_to: None,
media: vec![],
metadata: std::collections::HashMap::new(),
metadata,
delivery: None,
};
let _ = sm_bus.publish_outbound(outbound).await;
@ -1088,6 +1105,7 @@ impl SessionManager {
storage,
bus,
memory_manager,
work_manager,
sub_agent_manager,
task_supervisor,
})
@ -1104,6 +1122,10 @@ impl SessionManager {
self.tools.clone()
}
pub fn work_manager(&self) -> Arc<crate::work::WorkManager> {
self.work_manager.clone()
}
/// 为定时任务创建一个无 session 绑定的 AgentLoop
pub fn create_cron_agent(&self) -> Result<AgentLoop, AgentError> {
let provider = create_provider(self.provider_config.clone())
@ -1452,6 +1474,49 @@ impl SessionManager {
};
Ok((None, resp))
}
"todo" => {
let sid = current_session_id
.ok_or_else(|| AgentError::Other("no active session".to_string()))?;
let action = args.map(str::trim).filter(|value| !value.is_empty());
match action {
Some("cancel") => self
.work_manager
.close_plan(&sid.to_string(), "cancelled", None)
.await
.map(|plan| (None, format!("任务计划已取消:{}", plan.objective)))
.map_err(|error| AgentError::Other(error.to_string())),
Some("done") => self
.work_manager
.close_plan(&sid.to_string(), "completed", None)
.await
.map(|plan| (None, format!("任务计划已完成:{}", plan.objective)))
.map_err(|error| AgentError::Other(error.to_string())),
Some(_) => Err(AgentError::Other("Usage: /todo [done|cancel]".to_string())),
None => match self.work_manager.active_plan(&sid.to_string()).await {
Ok(Some(plan)) => {
let mut lines = vec![format!(
"任务计划:{}version {}",
plan.objective, plan.version
)];
for item in plan.items {
let icon = match item.status.as_str() {
"completed" => "",
"in_progress" => "",
"blocked" => "!",
_ => "",
};
lines.push(format!(
"{icon} {} [{}] {}",
item.id, item.status, item.title
));
}
Ok((None, lines.join("\n")))
}
Ok(None) => Ok((None, "当前 session 没有 active plan。".to_string())),
Err(error) => Err(AgentError::Other(error.to_string())),
},
}
}
_ => Err(AgentError::Other(format!(
"未知命令:/{}。输入 /? 获取帮助。",
cmd.name
@ -1674,6 +1739,21 @@ impl SessionManager {
.map_err(|e| AgentError::Other(format!("failed to load dialog history: {e}")))
}
pub async fn get_task_plan(
&self,
session_id: &UnifiedSessionId,
) -> Result<Option<crate::work::TaskPlan>, AgentError> {
let session_id = session_id.to_string();
self.storage
.get_session(&session_id)
.await
.map_err(|error| AgentError::Other(format!("failed to load dialog: {error}")))?;
self.work_manager
.plan_for_session(&session_id)
.await
.map_err(|error| AgentError::Other(format!("failed to load task plan: {error}")))
}
pub async fn list_dialogs(
&self,
channel: &str,
@ -2084,6 +2164,7 @@ fn spawn_agent_worker(
let AgentWorkerDeps {
bus,
memory_manager,
work_manager,
skills_loader,
task_supervisor,
} = deps;
@ -2224,8 +2305,19 @@ fn spawn_agent_worker(
_ => None,
};
let runtime_context =
build_runtime_context(Some(unified_str.as_str()), memory_context.as_deref());
let work_context = match work_manager.active_plan(&unified_str).await {
Ok(Some(plan)) => Some(plan.compact_context()),
Ok(None) => None,
Err(error) => {
tracing::warn!(error = %error, "Failed to load active task plan");
None
}
};
let runtime_context = build_runtime_context(
Some(unified_str.as_str()),
memory_context.as_deref(),
work_context.as_deref(),
);
let system_prompt_out = {
let guard = session.lock().await;

View File

@ -14,7 +14,7 @@ use sqlx::{Pool, Row, Sqlite};
use std::path::Path;
use tokio::time::{Duration, sleep};
const SCHEMA_VERSION: i64 = 2;
const SCHEMA_VERSION: i64 = 3;
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@ -170,6 +170,64 @@ impl Storage {
.execute(&self.pool)
.await?;
// Session-scoped task plans. A session may have at most one active plan,
// while independent items can be executed concurrently.
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS task_plans (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
objective TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
closed_at INTEGER
)
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE UNIQUE INDEX IF NOT EXISTS idx_task_plans_active_session
ON task_plans(session_id) WHERE status = 'active'
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS task_items (
id TEXT NOT NULL,
plan_id TEXT NOT NULL,
ordinal INTEGER NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
executor_kind TEXT,
execution_id TEXT,
result_summary TEXT,
error TEXT,
version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY(plan_id, id),
FOREIGN KEY(plan_id) REFERENCES task_plans(id) ON DELETE CASCADE,
UNIQUE(plan_id, ordinal)
)
"#,
)
.execute(&self.pool)
.await?;
sqlx::query(
"CREATE INDEX IF NOT EXISTS idx_task_items_plan ON task_items(plan_id, ordinal)",
)
.execute(&self.pool)
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS memories (
@ -1556,6 +1614,16 @@ mod tests {
.await
.unwrap();
assert_eq!(schema_version, SCHEMA_VERSION);
for table in ["task_plans", "task_items"] {
let exists: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?",
)
.bind(table)
.fetch_one(storage.pool())
.await
.unwrap();
assert_eq!(exists, 1, "missing migrated table {table}");
}
}
#[tokio::test]

View File

@ -71,6 +71,10 @@ impl Tool for DelegateTool {
"type": "array",
"items": { "type": "string" },
"description": "该子任务的工具列表"
},
"plan_item_id": {
"type": "string",
"description": "可选,绑定当前计划中的子项 ID如 T2"
}
},
"required": ["prompt"]
@ -79,6 +83,10 @@ impl Tool for DelegateTool {
"task_id": {
"type": "string",
"description": "后台任务IDaction=check_task/cancel_task 时必填)"
},
"plan_item_id": {
"type": "string",
"description": "inline/background 模式可选,绑定当前计划中的子项 ID"
}
},
"required": ["action"]
@ -133,6 +141,8 @@ impl DelegateTool {
allowed_tools,
max_iterations,
timeout_secs,
plan_item_id: args["plan_item_id"].as_str().map(str::to_string),
session_id: None,
})
}
@ -156,7 +166,14 @@ impl DelegateTool {
match mode {
ExecutionMode::Inline => {
let config = self.parse_config_from_args(args)?;
let mut config = self.parse_config_from_args(args)?;
if config.plan_item_id.is_some() {
config.session_id = Some(
crate::agent::sub_agent::get_delegate_context()
.map_err(anyhow::Error::msg)?
.session_id,
);
}
let result = self
.sub_agent_manager
.run_inline(config)
@ -187,10 +204,11 @@ impl DelegateTool {
}
}
ExecutionMode::Background => {
let config = self.parse_config_from_args(args)?;
let mut config = self.parse_config_from_args(args)?;
let ctx = crate::agent::sub_agent::get_delegate_context().map_err(|_| {
anyhow::anyhow!("delegate context not available: not in an agent worker")
})?;
config.session_id = Some(ctx.session_id.clone());
let task_id = self
.sub_agent_manager
@ -209,6 +227,7 @@ impl DelegateTool {
.as_array()
.ok_or_else(|| anyhow::anyhow!("parallel mode requires 'tasks' array"))?;
let ctx = crate::agent::sub_agent::get_delegate_context().ok();
let mut configs = Vec::new();
for task in tasks {
let prompt = task["prompt"]
@ -228,6 +247,8 @@ impl DelegateTool {
allowed_tools,
max_iterations: args["max_iterations"].as_u64().map(|v| v as usize),
timeout_secs: args["timeout_secs"].as_u64(),
plan_item_id: task["plan_item_id"].as_str().map(str::to_string),
session_id: ctx.as_ref().map(|ctx| ctx.session_id.clone()),
});
}

View File

@ -19,6 +19,7 @@ pub mod pty;
pub mod registry;
pub mod schema;
pub mod send_message;
pub mod todo;
pub mod traits;
pub mod web_fetch;
@ -39,6 +40,7 @@ pub use memory::{MemoryForgetTool, MemoryRecallTool, MemoryStoreTool, TimelineRe
pub use pty::{PtyManager, PtyTool};
pub use registry::ToolRegistry;
pub use send_message::SendMessageTool;
pub use todo::TodoTool;
pub use traits::{OutboundMessenger, Tool, ToolResult};
pub use web_fetch::WebFetchTool;
@ -54,6 +56,7 @@ use std::sync::Arc;
pub fn create_default_tools(
skills_loader: Arc<SkillsLoader>,
memory: Arc<MemoryManager>,
work_manager: Arc<crate::work::WorkManager>,
sub_agent_manager: Option<Arc<SubAgentManager>>,
browser_config: Option<&BrowserConfig>,
) -> ToolRegistry {
@ -79,6 +82,7 @@ pub fn create_default_tools(
registry.register(MemoryRecallTool::new(memory.clone()));
registry.register(TimelineRecallTool::new(memory.clone()));
registry.register(MemoryForgetTool::new(memory.clone()));
registry.register(TodoTool::new(work_manager));
if let Some(cfg) = browser_config
&& cfg.enabled

163
src/tools/todo.rs Normal file
View File

@ -0,0 +1,163 @@
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::json;
use crate::tools::traits::{Tool, ToolResult};
use crate::work::{TaskPlan, WorkManager};
pub struct TodoTool {
work_manager: Arc<WorkManager>,
}
impl TodoTool {
pub fn new(work_manager: Arc<WorkManager>) -> Self {
Self { work_manager }
}
fn context() -> anyhow::Result<crate::agent::DelegateContext> {
crate::agent::sub_agent::get_delegate_context()
.map_err(|_| anyhow::anyhow!("todo context not available outside a session worker"))
}
}
#[async_trait]
impl Tool for TodoTool {
fn name(&self) -> &str {
"todo"
}
fn description(&self) -> &str {
"管理当前 session 的复杂任务计划。仅用于明确需要多步骤、跨轮次或并行委托的任务;闲聊、问答和单步操作不要创建计划。"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["create", "view", "append", "update", "close"]
},
"objective": { "type": "string", "description": "create 时的总体目标" },
"items": {
"type": "array",
"items": { "type": "string" },
"description": "create 时的有序子项"
},
"item_id": { "type": "string", "description": "update 时的 T1/T2..." },
"title": { "type": "string", "description": "append 时的新子项标题" },
"status": {
"type": "string",
"enum": ["pending", "in_progress", "completed", "blocked", "cancelled"],
"description": "update 的子项状态或 close 的计划状态"
},
"summary": { "type": "string", "description": "完成结果或阻塞原因" },
"expected_version": { "type": "integer", "description": "可选的计划版本并发校验" }
},
"required": ["action"]
})
}
fn read_only(&self) -> bool {
false
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let action = args["action"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("missing action"))?;
let ctx = Self::context()?;
let expected = args["expected_version"].as_i64();
let result = match action {
"create" => {
let objective = required_str(&args, "objective")?;
let items = args["items"]
.as_array()
.ok_or_else(|| anyhow::anyhow!("create requires items"))?
.iter()
.filter_map(|value| value.as_str().map(str::to_string))
.collect::<Vec<_>>();
self.work_manager
.create_plan(&ctx.session_id, objective, &items)
.await
}
"view" => match self.work_manager.active_plan(&ctx.session_id).await? {
Some(plan) => Ok(plan),
None => {
return Ok(ToolResult {
success: true,
output: "当前 session 没有 active plan".to_string(),
error: None,
});
}
},
"append" => {
self.work_manager
.append_item(&ctx.session_id, required_str(&args, "title")?, expected)
.await
}
"update" => {
self.work_manager
.update_item(
&ctx.session_id,
required_str(&args, "item_id")?,
required_str(&args, "status")?,
args["summary"].as_str(),
expected,
)
.await
}
"close" => {
self.work_manager
.close_plan(&ctx.session_id, required_str(&args, "status")?, expected)
.await
}
_ => return Err(anyhow::anyhow!("unknown todo action: {action}")),
};
match result {
Ok(plan) => Ok(ToolResult {
success: true,
output: render_plan(&plan),
error: None,
}),
Err(error) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(error.to_string()),
}),
}
}
}
fn required_str<'a>(args: &'a serde_json::Value, key: &str) -> anyhow::Result<&'a str> {
args[key]
.as_str()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| anyhow::anyhow!("missing required parameter: {key}"))
}
fn render_plan(plan: &TaskPlan) -> String {
let mut output = format!(
"计划 {}{}version {}\n目标:{}\n",
plan.id, plan.status, plan.version, plan.objective
);
for item in &plan.items {
let icon = match item.status.as_str() {
"completed" => "",
"in_progress" => "",
"blocked" => "!",
_ => "",
};
output.push_str(&format!(
"{icon} {} [{}] {}",
item.id, item.status, item.title
));
if let Some(summary) = item.result_summary.as_deref().or(item.error.as_deref()) {
output.push_str(&format!("{summary}"));
}
output.push('\n');
}
output.trim_end().to_string()
}

689
src/work/mod.rs Normal file
View File

@ -0,0 +1,689 @@
use std::sync::Arc;
use dashmap::DashMap;
use serde::{Deserialize, Serialize};
use sqlx::Row;
use tokio::sync::broadcast;
use crate::storage::{Storage, StorageError};
const MAX_OBJECTIVE_CHARS: usize = 2_000;
const MAX_ITEM_TITLE_CHARS: usize = 500;
const MAX_INITIAL_ITEMS: usize = 50;
const MAX_ITEMS: usize = 100;
const MAX_SUMMARY_CHARS: usize = 4_000;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TaskItem {
pub id: String,
pub ordinal: i64,
pub title: String,
pub status: String,
pub executor_kind: Option<String>,
pub execution_id: Option<String>,
pub result_summary: Option<String>,
pub error: Option<String>,
pub version: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TaskPlan {
pub id: String,
pub session_id: String,
pub objective: String,
pub status: String,
pub version: i64,
pub created_at: i64,
pub updated_at: i64,
pub closed_at: Option<i64>,
pub items: Vec<TaskItem>,
}
impl TaskPlan {
pub fn compact_context(&self) -> String {
let completed = self
.items
.iter()
.filter(|item| item.status == "completed")
.count();
let mut lines = vec![
"## 当前任务计划".to_string(),
format!("- 目标:{}", self.objective),
format!("- 进度:{completed}/{}", self.items.len()),
];
let running: Vec<_> = self
.items
.iter()
.filter(|item| item.status == "in_progress")
.take(8)
.map(|item| {
let executor = item.executor_kind.as_deref().unwrap_or("main_agent");
format!(" - {} {}{}", item.id, item.title, executor)
})
.collect();
if !running.is_empty() {
lines.push("- 正在执行:".to_string());
lines.extend(running);
}
let blocked: Vec<_> = self
.items
.iter()
.filter(|item| item.status == "blocked")
.take(8)
.map(|item| {
format!(
" - {} {}{}",
item.id,
item.title,
item.error.as_deref().unwrap_or("原因未知")
)
})
.collect();
if !blocked.is_empty() {
lines.push("- 阻塞:".to_string());
lines.extend(blocked);
}
if let Some(next) = self.items.iter().find(|item| item.status == "pending") {
lines.push(format!("- 下一待处理:{} {}", next.id, next.title));
}
lines.push(format!("- 计划版本:{}", self.version));
lines.join("\n")
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanChanged {
pub session_id: String,
pub reason: String,
pub changed_item_ids: Vec<String>,
pub plan: Option<TaskPlan>,
}
#[derive(Clone)]
pub struct WorkManager {
storage: Arc<Storage>,
events: broadcast::Sender<PlanChanged>,
active_cache: Arc<DashMap<String, Option<TaskPlan>>>,
}
impl WorkManager {
pub fn new(storage: Arc<Storage>) -> Self {
let (events, _) = broadcast::channel(128);
Self {
storage,
events,
active_cache: Arc::new(DashMap::new()),
}
}
pub fn subscribe(&self) -> broadcast::Receiver<PlanChanged> {
self.events.subscribe()
}
pub async fn active_plan(&self, session_id: &str) -> Result<Option<TaskPlan>, StorageError> {
if let Some(cached) = self.active_cache.get(session_id) {
return Ok(cached.clone());
}
let row = sqlx::query(
"SELECT id, session_id, objective, status, version, created_at, updated_at, closed_at \
FROM task_plans WHERE session_id = ? AND status = 'active' LIMIT 1",
)
.bind(session_id)
.fetch_optional(self.storage.pool())
.await?;
let Some(row) = row else {
self.active_cache.insert(session_id.to_string(), None);
return Ok(None);
};
let plan = self.plan_from_row(row).await?;
self.cache_active_plan(&plan);
Ok(Some(plan))
}
pub async fn plan_for_session(
&self,
session_id: &str,
) -> Result<Option<TaskPlan>, StorageError> {
self.active_plan(session_id).await
}
async fn plan_from_row(&self, row: sqlx::sqlite::SqliteRow) -> Result<TaskPlan, StorageError> {
let plan_id: String = row.get("id");
let item_rows = sqlx::query(
"SELECT id, ordinal, title, status, executor_kind, execution_id, result_summary, \
error, version, updated_at FROM task_items WHERE plan_id = ? ORDER BY ordinal",
)
.bind(&plan_id)
.fetch_all(self.storage.pool())
.await?;
Ok(TaskPlan {
id: plan_id,
session_id: row.get("session_id"),
objective: row.get("objective"),
status: row.get("status"),
version: row.get("version"),
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
closed_at: row.get("closed_at"),
items: item_rows.into_iter().map(item_from_row).collect(),
})
}
async fn plan_by_id(&self, plan_id: &str) -> Result<TaskPlan, StorageError> {
let row = sqlx::query(
"SELECT id, session_id, objective, status, version, created_at, updated_at, closed_at \
FROM task_plans WHERE id = ?",
)
.bind(plan_id)
.fetch_optional(self.storage.pool())
.await?
.ok_or_else(|| StorageError::NotFound(plan_id.to_string()))?;
self.plan_from_row(row).await
}
pub async fn create_plan(
&self,
session_id: &str,
objective: &str,
items: &[String],
) -> Result<TaskPlan, StorageError> {
if objective.trim().is_empty() || items.is_empty() {
return Err(StorageError::Conflict(
"计划目标不能为空,且至少需要一个子项".to_string(),
));
}
validate_length("计划目标", objective, MAX_OBJECTIVE_CHARS)?;
if items.len() > MAX_INITIAL_ITEMS {
return Err(StorageError::Conflict(format!(
"初始计划最多包含 {MAX_INITIAL_ITEMS} 个子项"
)));
}
if self.active_plan(session_id).await?.is_some() {
return Err(StorageError::Conflict(
"当前 session 已有 active plan".to_string(),
));
}
let now = chrono::Utc::now().timestamp_millis();
let plan_id = format!("plan-{}", crate::util::short_id());
let mut tx = self.storage.pool().begin().await?;
sqlx::query(
"INSERT INTO task_plans (id, session_id, objective, status, version, created_at, updated_at) \
VALUES (?, ?, ?, 'active', 1, ?, ?)",
)
.bind(&plan_id)
.bind(session_id)
.bind(objective.trim())
.bind(now)
.bind(now)
.execute(&mut *tx)
.await?;
for (index, title) in items.iter().enumerate() {
let title = title.trim();
if title.is_empty() {
return Err(StorageError::Conflict("计划子项不能为空".to_string()));
}
validate_length("计划子项", title, MAX_ITEM_TITLE_CHARS)?;
sqlx::query(
"INSERT INTO task_items (id, plan_id, ordinal, title, status, version, created_at, updated_at) \
VALUES (?, ?, ?, ?, 'pending', 1, ?, ?)",
)
.bind(format!("T{}", index + 1))
.bind(&plan_id)
.bind(index as i64 + 1)
.bind(title)
.bind(now)
.bind(now)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
let plan = self.plan_by_id(&plan_id).await?;
self.cache_active_plan(&plan);
self.emit("plan_created", vec![], Some(plan.clone()));
Ok(plan)
}
pub async fn append_item(
&self,
session_id: &str,
title: &str,
expected_version: Option<i64>,
) -> Result<TaskPlan, StorageError> {
let plan = self.require_active(session_id, expected_version).await?;
if title.trim().is_empty() {
return Err(StorageError::Conflict("计划子项不能为空".to_string()));
}
if plan.items.len() >= MAX_ITEMS {
return Err(StorageError::Conflict(format!(
"计划最多包含 {MAX_ITEMS} 个子项"
)));
}
validate_length("计划子项", title, MAX_ITEM_TITLE_CHARS)?;
let now = chrono::Utc::now().timestamp_millis();
let ordinal = plan.items.last().map_or(1, |item| item.ordinal + 1);
let item_id = format!("T{ordinal}");
let mut tx = self.storage.pool().begin().await?;
sqlx::query(
"INSERT INTO task_items (id, plan_id, ordinal, title, status, version, created_at, updated_at) \
VALUES (?, ?, ?, ?, 'pending', 1, ?, ?)",
)
.bind(&item_id)
.bind(&plan.id)
.bind(ordinal)
.bind(title.trim())
.bind(now)
.bind(now)
.execute(&mut *tx)
.await?;
bump_plan(&mut tx, &plan.id, now).await?;
tx.commit().await?;
let updated = self.plan_by_id(&plan.id).await?;
self.cache_active_plan(&updated);
self.emit("item_added", vec![item_id], Some(updated.clone()));
Ok(updated)
}
pub async fn update_item(
&self,
session_id: &str,
item_id: &str,
status: &str,
summary: Option<&str>,
expected_version: Option<i64>,
) -> Result<TaskPlan, StorageError> {
validate_item_status(status)?;
let plan = self.require_active(session_id, expected_version).await?;
let current = plan
.items
.iter()
.find(|item| item.id == item_id)
.ok_or_else(|| StorageError::NotFound(item_id.to_string()))?;
validate_transition(&current.status, status)?;
let now = chrono::Utc::now().timestamp_millis();
let summary = summary.map(|value| truncate_chars(value, MAX_SUMMARY_CHARS));
let error = (status == "blocked")
.then_some(summary.as_deref())
.flatten();
let result_summary = (status == "completed")
.then_some(summary.as_deref())
.flatten();
let executor = match status {
"in_progress" => Some("main_agent"),
"completed" | "blocked" => current.executor_kind.as_deref(),
_ => None,
};
let execution_id = matches!(status, "completed" | "blocked")
.then_some(current.execution_id.as_deref())
.flatten();
let mut tx = self.storage.pool().begin().await?;
let update = sqlx::query(
"UPDATE task_items SET status = ?, executor_kind = ?, execution_id = ?, \
result_summary = ?, error = ?, \
version = version + 1, updated_at = ? \
WHERE plan_id = ? AND id = ? AND status = ? AND version = ?",
)
.bind(status)
.bind(executor)
.bind(execution_id)
.bind(result_summary)
.bind(error)
.bind(now)
.bind(&plan.id)
.bind(item_id)
.bind(&current.status)
.bind(current.version)
.execute(&mut *tx)
.await?;
if update.rows_affected() != 1 {
return Err(StorageError::Conflict(format!(
"子项 {item_id} 已被其他执行更新,请读取最新计划后重试"
)));
}
bump_plan(&mut tx, &plan.id, now).await?;
tx.commit().await?;
let updated = self.plan_by_id(&plan.id).await?;
self.cache_active_plan(&updated);
self.emit(
"item_updated",
vec![item_id.to_string()],
Some(updated.clone()),
);
Ok(updated)
}
pub async fn assign_sub_agent(
&self,
session_id: &str,
item_id: &str,
execution_id: &str,
) -> Result<TaskPlan, StorageError> {
let plan = self.require_active(session_id, None).await?;
let item = plan
.items
.iter()
.find(|item| item.id == item_id)
.ok_or_else(|| StorageError::NotFound(item_id.to_string()))?;
if item.status != "pending" {
return Err(StorageError::Conflict(format!(
"子项 {item_id} 当前状态为 {},不能重复委托",
item.status
)));
}
let now = chrono::Utc::now().timestamp_millis();
let mut tx = self.storage.pool().begin().await?;
let result = sqlx::query(
"UPDATE task_items SET status = 'in_progress', executor_kind = 'sub_agent', \
execution_id = ?, error = NULL, version = version + 1, updated_at = ? \
WHERE plan_id = ? AND id = ? AND status = 'pending'",
)
.bind(execution_id)
.bind(now)
.bind(&plan.id)
.bind(item_id)
.execute(&mut *tx)
.await?;
if result.rows_affected() != 1 {
return Err(StorageError::Conflict(format!(
"子项 {item_id} 已被其他执行领取"
)));
}
bump_plan(&mut tx, &plan.id, now).await?;
tx.commit().await?;
let updated = self.plan_by_id(&plan.id).await?;
self.cache_active_plan(&updated);
self.emit(
"item_assigned",
vec![item_id.to_string()],
Some(updated.clone()),
);
Ok(updated)
}
pub async fn finish_sub_agent(
&self,
session_id: &str,
item_id: &str,
execution_id: &str,
completed: bool,
summary: Option<&str>,
) -> Result<Option<TaskPlan>, StorageError> {
let Some(plan) = self.active_plan(session_id).await? else {
return Ok(None);
};
let item = plan.items.iter().find(|item| item.id == item_id);
if !item.is_some_and(|item| {
item.status == "in_progress" && item.execution_id.as_deref() == Some(execution_id)
}) {
return Ok(Some(plan));
}
let status = if completed { "completed" } else { "blocked" };
let summary = summary.map(|value| truncate_chars(value, MAX_SUMMARY_CHARS));
let now = chrono::Utc::now().timestamp_millis();
let mut tx = self.storage.pool().begin().await?;
let result = sqlx::query(
"UPDATE task_items SET status = ?, result_summary = ?, error = ?, \
version = version + 1, updated_at = ? \
WHERE plan_id = ? AND id = ? AND execution_id = ? AND status = 'in_progress'",
)
.bind(status)
.bind(completed.then_some(summary.as_deref()).flatten())
.bind((!completed).then_some(summary.as_deref()).flatten())
.bind(now)
.bind(&plan.id)
.bind(item_id)
.bind(execution_id)
.execute(&mut *tx)
.await?;
if result.rows_affected() != 1 {
drop(tx);
return Ok(Some(self.plan_by_id(&plan.id).await?));
}
bump_plan(&mut tx, &plan.id, now).await?;
tx.commit().await?;
let updated = self.plan_by_id(&plan.id).await?;
self.cache_active_plan(&updated);
self.emit(
if completed {
"item_completed"
} else {
"item_blocked"
},
vec![item_id.to_string()],
Some(updated.clone()),
);
Ok(Some(updated))
}
pub async fn close_plan(
&self,
session_id: &str,
status: &str,
expected_version: Option<i64>,
) -> Result<TaskPlan, StorageError> {
if !matches!(status, "completed" | "cancelled") {
return Err(StorageError::Conflict(
"计划只能 completed 或 cancelled".to_string(),
));
}
let plan = self.require_active(session_id, expected_version).await?;
if status == "completed" && plan.items.iter().any(|item| item.status != "completed") {
return Err(StorageError::Conflict(
"仍有未完成子项,不能关闭计划".to_string(),
));
}
let now = chrono::Utc::now().timestamp_millis();
sqlx::query(
"UPDATE task_plans SET status = ?, version = version + 1, updated_at = ?, closed_at = ? \
WHERE id = ? AND status = 'active'",
)
.bind(status)
.bind(now)
.bind(now)
.bind(&plan.id)
.execute(self.storage.pool())
.await?;
let closed = self.plan_by_id(&plan.id).await?;
self.active_cache.insert(session_id.to_string(), None);
self.emit("plan_closed", vec![], Some(closed.clone()));
Ok(closed)
}
async fn require_active(
&self,
session_id: &str,
expected_version: Option<i64>,
) -> Result<TaskPlan, StorageError> {
let plan = self
.active_plan(session_id)
.await?
.ok_or_else(|| StorageError::NotFound("active plan".to_string()))?;
if expected_version.is_some_and(|version| version != plan.version) {
return Err(StorageError::Conflict(format!(
"计划版本已变化expected {}, actual {}",
expected_version.unwrap_or_default(),
plan.version
)));
}
Ok(plan)
}
fn emit(&self, reason: &str, changed_item_ids: Vec<String>, plan: Option<TaskPlan>) {
let Some(session_id) = plan.as_ref().map(|plan| plan.session_id.clone()) else {
return;
};
let _ = self.events.send(PlanChanged {
session_id,
reason: reason.to_string(),
changed_item_ids,
plan,
});
}
fn cache_active_plan(&self, plan: &TaskPlan) {
let session_id = plan.session_id.clone();
match self.active_cache.entry(session_id) {
dashmap::mapref::entry::Entry::Occupied(mut entry) => {
let should_replace = entry
.get()
.as_ref()
.is_none_or(|cached| cached.id != plan.id || cached.version <= plan.version);
if should_replace {
entry.insert(Some(plan.clone()));
}
}
dashmap::mapref::entry::Entry::Vacant(entry) => {
entry.insert(Some(plan.clone()));
}
}
}
}
fn item_from_row(row: sqlx::sqlite::SqliteRow) -> TaskItem {
TaskItem {
id: row.get("id"),
ordinal: row.get("ordinal"),
title: row.get("title"),
status: row.get("status"),
executor_kind: row.get("executor_kind"),
execution_id: row.get("execution_id"),
result_summary: row.get("result_summary"),
error: row.get("error"),
version: row.get("version"),
updated_at: row.get("updated_at"),
}
}
async fn bump_plan(
tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>,
plan_id: &str,
now: i64,
) -> Result<(), sqlx::Error> {
sqlx::query("UPDATE task_plans SET version = version + 1, updated_at = ? WHERE id = ?")
.bind(now)
.bind(plan_id)
.execute(&mut **tx)
.await?;
Ok(())
}
fn validate_item_status(status: &str) -> Result<(), StorageError> {
if matches!(status, "pending" | "in_progress" | "completed" | "blocked") {
Ok(())
} else {
Err(StorageError::Conflict(format!("未知子项状态:{status}")))
}
}
fn validate_transition(from: &str, to: &str) -> Result<(), StorageError> {
let allowed = matches!(
(from, to),
("pending", "in_progress")
| ("pending", "completed")
| ("pending", "blocked")
| ("in_progress", "completed")
| ("in_progress", "blocked")
| ("blocked", "pending")
| ("blocked", "in_progress")
| ("blocked", "completed")
) || from == to;
if allowed {
Ok(())
} else {
Err(StorageError::Conflict(format!(
"不允许的状态迁移:{from} -> {to}"
)))
}
}
fn validate_length(label: &str, value: &str, max_chars: usize) -> Result<(), StorageError> {
if value.chars().count() <= max_chars {
Ok(())
} else {
Err(StorageError::Conflict(format!(
"{label}不能超过 {max_chars} 个字符"
)))
}
}
fn truncate_chars(value: &str, max_chars: usize) -> String {
value.chars().take(max_chars).collect()
}
#[cfg(test)]
mod tests {
use super::*;
async fn manager() -> (WorkManager, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let storage = Arc::new(Storage::new(&dir.path().join("work.db")).await.unwrap());
(WorkManager::new(storage), dir)
}
#[tokio::test]
async fn one_active_plan_per_session_and_parallel_items() {
let (manager, _dir) = manager().await;
let plan = manager
.create_plan("cli:a:d1", "ship it", &["one".into(), "two".into()])
.await
.unwrap();
assert_eq!(plan.items.len(), 2);
let other = manager
.create_plan("cli:b:d2", "other", &["one".into()])
.await
.unwrap();
assert_eq!(other.items[0].id, "T1");
assert!(
manager
.create_plan("cli:a:d1", "another", &["x".into()])
.await
.is_err()
);
manager
.assign_sub_agent("cli:a:d1", "T1", "run-1")
.await
.unwrap();
assert!(
manager
.assign_sub_agent("cli:a:d1", "T1", "run-duplicate")
.await
.is_err()
);
let plan = manager
.assign_sub_agent("cli:a:d1", "T2", "run-2")
.await
.unwrap();
assert_eq!(
plan.items
.iter()
.filter(|item| item.status == "in_progress")
.count(),
2
);
}
#[tokio::test]
async fn late_sub_agent_result_does_not_mutate_cancelled_plan() {
let (manager, _dir) = manager().await;
manager
.create_plan("cli:a:d1", "ship", &["one".into()])
.await
.unwrap();
manager
.assign_sub_agent("cli:a:d1", "T1", "run-1")
.await
.unwrap();
manager
.close_plan("cli:a:d1", "cancelled", None)
.await
.unwrap();
assert!(
manager
.finish_sub_agent("cli:a:d1", "T1", "run-1", true, Some("done"))
.await
.unwrap()
.is_none()
);
}
}

View File

@ -1,5 +1,6 @@
use picobot::protocol::{HistoryMessage, SessionSummary, WsInbound, WsOutbound};
use picobot::providers::{ChatCompletionRequest, Message};
use picobot::work::{TaskItem, TaskPlan};
/// Test that message with special characters is properly escaped
#[test]
@ -147,3 +148,55 @@ fn test_bounded_session_history_protocol() {
other => panic!("unexpected decoded variant: {other:?}"),
}
}
#[test]
fn test_session_plan_protocol_is_structured_and_versioned() {
let inbound = WsInbound::GetSessionPlan {
session_id: "cli_chat:client:dialog".to_string(),
};
assert!(
serde_json::to_string(&inbound)
.unwrap()
.contains(r#""type":"get_session_plan""#)
);
let plan = TaskPlan {
id: "plan-1".to_string(),
session_id: "cli_chat:client:dialog".to_string(),
objective: "实现 Todo".to_string(),
status: "active".to_string(),
version: 3,
created_at: 1,
updated_at: 2,
closed_at: None,
items: vec![TaskItem {
id: "T1".to_string(),
ordinal: 1,
title: "实现协议".to_string(),
status: "in_progress".to_string(),
executor_kind: Some("sub_agent".to_string()),
execution_id: Some("run-1".to_string()),
result_summary: None,
error: None,
version: 2,
updated_at: 2,
}],
};
let outbound = WsOutbound::PlanUpdated {
session_id: plan.session_id.clone(),
reason: "item_assigned".to_string(),
changed_item_ids: vec!["T1".to_string()],
plan: Some(plan),
};
let decoded: WsOutbound =
serde_json::from_str(&serde_json::to_string(&outbound).unwrap()).unwrap();
match decoded {
WsOutbound::PlanUpdated { plan, reason, .. } => {
let plan = plan.unwrap();
assert_eq!(plan.version, 3);
assert_eq!(plan.items[0].execution_id.as_deref(), Some("run-1"));
assert_eq!(reason, "item_assigned");
}
other => panic!("unexpected decoded variant: {other:?}"),
}
}

View File

@ -15,11 +15,16 @@
let selectedCommand = $state(0);
let commandMenuDismissed = $state(false);
let thinking = $state(false);
let plansBySession = $state({});
let unseenPlanSessions = $state({});
let todoOpen = $state(false);
let messageBox;
let input;
let reconnectTimer;
let stopped = false;
const currentSession = $derived(sessions.find((item) => item.session_id === currentId));
const currentPlan = $derived(currentId ? plansBySession[currentId] || null : null);
const completedItems = $derived(currentPlan?.items?.filter((item) => item.status === "completed").length || 0);
const filteredSessions = $derived(sessions.filter((item) => item.title.toLowerCase().includes(search.toLowerCase())));
const commandQuery = $derived(
!commandMenuDismissed && draft.startsWith("/") && !/[\s]/.test(draft)
@ -50,6 +55,9 @@
socket = ws;
ws.onopen = () => {
connected = true;
plansBySession = {};
unseenPlanSessions = {};
todoOpen = false;
send({ type: "list_sessions", include_archived: false });
send({ type: "get_slash_commands" });
};
@ -77,6 +85,7 @@
case "session_loaded":
currentId = frame.session_id;
send({ type: "get_session_history", session_id: currentId, limit: 1000 });
send({ type: "get_session_plan", session_id: currentId });
break;
case "session_history":
if (frame.session_id === currentId) {
@ -84,6 +93,30 @@
scrollToBottom();
}
break;
case "session_plan":
if (frame.plan) {
const previous = plansBySession[frame.session_id];
if (frame.plan.id !== previous?.id || frame.plan.version >= (previous?.version || 0)) {
plansBySession[frame.session_id] = frame.plan;
}
} else if (plansBySession[frame.session_id]?.status !== "active") {
plansBySession[frame.session_id] = null;
}
break;
case "plan_updated": {
const previousPlan = plansBySession[frame.session_id];
const previousVersion = previousPlan?.version || 0;
const nextVersion = frame.plan?.version || 0;
if (frame.plan?.id === previousPlan?.id && nextVersion && nextVersion <= previousVersion) break;
plansBySession[frame.session_id] = frame.plan;
if (frame.session_id === currentId) {
todoOpen = true;
unseenPlanSessions[frame.session_id] = false;
} else {
unseenPlanSessions[frame.session_id] = true;
}
break;
}
case "slash_commands_list":
commands = frame.commands || [];
selectedCommand = 0;
@ -115,9 +148,18 @@
if (!id) return;
currentId = id;
messages = [];
todoOpen = Boolean(unseenPlanSessions[id]);
unseenPlanSessions[id] = false;
send({ type: "load_session", session_id: id });
}
function itemIcon(status) {
if (status === "completed") return "✓";
if (status === "in_progress") return "●";
if (status === "blocked") return "!";
return "○";
}
function submit() {
const content = draft.trim();
if (!content || !connected) return;
@ -200,14 +242,14 @@
});
</script>
<section class="page active chat-layout">
<section class:todo-open={todoOpen && currentPlan} class="page active chat-layout">
<aside class="sessions-panel">
<button class="primary full" onclick={() => send({ type: "create_session", title: null })}> 新建对话</button>
<label class="search"><span></span><input bind:value={search} placeholder="搜索对话" /></label>
<div class="session-list">
{#each filteredSessions as session (session.session_id)}
<button class:active={session.session_id === currentId} class="session-item" onclick={() => loadSession(session.session_id)}>
<strong>{session.title}</strong><small><span>{session.message_count} 条消息</span><span>{formatTime(session.last_active_at).split(" ")[0]}</span></small>
<strong>{session.title}{#if unseenPlanSessions[session.session_id]}<i class="plan-unread" aria-label="任务计划有更新"></i>{/if}</strong><small><span>{session.message_count} 条消息</span><span>{formatTime(session.last_active_at).split(" ")[0]}</span></small>
</button>
{:else}<div class="empty-card compact">暂无对话</div>{/each}
</div>
@ -215,11 +257,18 @@
<div class="chat-panel">
<div class="chat-heading">
<div><strong>{currentSession?.title || "新对话"}</strong><small>WebUI 会话</small></div>
<div class="chat-heading-actions">
{#if currentPlan}
<button class:active={todoOpen} class="todo-toggle" onclick={() => todoOpen = !todoOpen} aria-expanded={todoOpen}>
任务 {completedItems}/{currentPlan.items.length}
</button>
{/if}
<Tooltip.Root>
<Tooltip.Trigger class="icon-button" aria-label="刷新会话" onclick={() => send({ type: "list_sessions", include_archived: false })}>↻</Tooltip.Trigger>
<Tooltip.Trigger class="icon-button" aria-label="刷新会话" onclick={() => { send({ type: "list_sessions", include_archived: false }); if (currentId) send({ type: "get_session_plan", session_id: currentId }); }}>↻</Tooltip.Trigger>
<Tooltip.Portal><Tooltip.Content class="tooltip" sideOffset={7}>刷新会话<Tooltip.Arrow class="tooltip-arrow" /></Tooltip.Content></Tooltip.Portal>
</Tooltip.Root>
</div>
</div>
<div class="messages" bind:this={messageBox}>
{#if messages.length === 0}
<div class="empty"><div class="empty-logo">P</div><h2>今天想做些什么?</h2><p>消息与 CLI 客户端使用同一套会话、记忆和工具能力。</p></div>
@ -267,4 +316,25 @@
<small><span class:online={connected}>{connected ? "已连接" : "已断开,正在重连"}</span><span>/ 打开命令 · Shift+Enter 换行</span></small>
</form>
</div>
{#if currentPlan}
<aside class="todo-panel" aria-label="当前任务计划">
<div class="todo-heading">
<div><small>当前计划</small><strong>{currentPlan.objective}</strong></div>
<button class="icon-button" aria-label="关闭任务侧栏" onclick={() => todoOpen = false}>×</button>
</div>
<div class="todo-progress"><span style={`width:${currentPlan.items.length ? completedItems / currentPlan.items.length * 100 : 0}%`}></span></div>
<div class="todo-summary"><span>{completedItems}/{currentPlan.items.length} 已完成</span><span>v{currentPlan.version}</span></div>
<div class="todo-items">
{#each currentPlan.items as item (item.id)}
<article class:blocked={item.status === "blocked"} class:running={item.status === "in_progress"} class:done={item.status === "completed"} class="todo-item">
<span class="todo-icon">{itemIcon(item.status)}</span>
<div><strong>{item.id} · {item.title}</strong><small>{item.executor_kind === "sub_agent" ? "子 Agent" : item.status === "in_progress" ? "主 Agent" : item.status}</small>
{#if item.result_summary}<p>{item.result_summary}</p>{/if}
{#if item.error}<p class="error-text">{item.error}</p>{/if}
</div>
</article>
{/each}
</div>
</aside>
{/if}
</section>

View File

@ -42,7 +42,8 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
.topbar p { font-size: 12px; color: var(--muted); margin: 4px 0 0; }
.topbar .menu { display: none; background: none; border: 0; color: var(--text); font-size: 20px; margin-right: 12px; }
.page { min-height: 0; flex: 1; }
.chat-layout { display: grid; grid-template-columns: 270px 1fr; }
.chat-layout { display: grid; grid-template-columns: 270px minmax(0, 1fr); }
.chat-layout.todo-open { grid-template-columns: 270px minmax(0, 1fr) 320px; }
.sessions-panel { border-right: 1px solid var(--line); padding: 16px; background: rgb(14 17 21 / 66%); overflow: auto; }
.primary, .secondary { border-radius: 9px; padding: 9px 14px; font-weight: 650; cursor: pointer; }
.primary { border: 1px solid var(--accent); background: var(--accent); color: #11160a; }
@ -57,11 +58,15 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
.session-item:hover, .session-item.active { background: var(--panel-2); border-color: var(--line); }
.session-item.active { border-left-color: var(--accent); }
.session-item strong { font-size: 13px; display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.plan-unread { display: inline-block; width: 7px; height: 7px; margin-left: 7px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 9px var(--accent); }
.session-item small { color: var(--muted); font-size: 11px; display: flex; justify-content: space-between; margin-top: 6px; }
.chat-panel { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.chat-heading { height: 57px; flex: 0 0 57px; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; padding: 0 20px; }
.chat-heading strong, .chat-heading small { display: block; }
.chat-heading strong { font-size: 13px; }
.chat-heading-actions { display: flex; align-items: center; gap: 9px; }
.todo-toggle { border: 1px solid var(--line); border-radius: 8px; padding: 6px 9px; background: var(--panel-2); color: var(--muted); font-size: 11px; cursor: pointer; }
.todo-toggle.active { border-color: #657d32; color: var(--accent); }
.chat-heading small { font-size: 10px; color: var(--muted); margin-top: 3px; }
.icon-button { border: 0; background: none; color: var(--muted); font-size: 20px; cursor: pointer; border-radius: 6px; }
.tooltip { z-index: 50; border: 1px solid #3c4652; background: #222831; color: var(--text); padding: 6px 9px; border-radius: 7px; font-size: 11px; box-shadow: 0 8px 24px #0008; }
@ -85,6 +90,29 @@ main { min-width: 0; height: 100vh; display: flex; flex-direction: column; }
.send { width: 36px; height: 36px; border-radius: 10px; background: var(--accent); border: 0; font-size: 19px; cursor: pointer; }
.composer small { grid-column: 1 / -1; display: flex; justify-content: space-between; padding: 3px 7px; color: var(--muted); font-size: 10px; }
.composer small .online { color: var(--accent); }
.todo-panel { display: none; min-width: 0; border-left: 1px solid var(--line); background: #0e1115; overflow: hidden; }
.todo-open .todo-panel { display: flex; flex-direction: column; }
.todo-heading { min-height: 78px; padding: 14px 14px 12px 16px; border-bottom: 1px solid var(--line); display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
.todo-heading small, .todo-heading strong { display: block; }
.todo-heading small { color: var(--muted); font-size: 10px; margin-bottom: 5px; }
.todo-heading strong { font-size: 13px; line-height: 1.4; }
.todo-progress { height: 3px; background: #252b32; }
.todo-progress span { display: block; height: 100%; background: var(--accent); transition: width .2s; }
.todo-summary { display: flex; justify-content: space-between; padding: 11px 16px; color: var(--muted); font-size: 10px; }
.todo-items { min-height: 0; overflow-y: auto; padding: 4px 12px 18px; }
.todo-item { display: grid; grid-template-columns: 24px 1fr; gap: 8px; padding: 10px 8px; border-radius: 9px; color: #b8bec6; }
.todo-item + .todo-item { margin-top: 3px; }
.todo-item.running { background: #202519; color: var(--text); }
.todo-item.blocked { background: #28191c; }
.todo-item.done { color: #727a83; }
.todo-icon { width: 21px; height: 21px; display: grid; place-items: center; border: 1px solid var(--line); border-radius: 50%; font-size: 11px; }
.todo-item.running .todo-icon { color: var(--accent); border-color: #657d32; }
.todo-item.blocked .todo-icon { color: #ff8d8d; border-color: #74363b; }
.todo-item.done .todo-icon { color: var(--accent); }
.todo-item strong, .todo-item small { display: block; }
.todo-item strong { font-size: 12px; line-height: 1.45; }
.todo-item small { color: var(--muted); font-size: 10px; margin-top: 3px; }
.todo-item p { margin: 6px 0 0; color: #aab1ba; font-size: 10px; line-height: 1.45; white-space: pre-wrap; overflow-wrap: anywhere; }
.command-menu { position: absolute; z-index: 5; left: 0; right: 0; bottom: calc(100% + 8px); max-height: min(360px, 48vh); overflow-y: auto; padding: 6px; border: 1px solid #3b454f; border-radius: 13px; background: #14181d; box-shadow: 0 18px 50px #000b; }
.command-menu-heading { position: sticky; top: -6px; z-index: 1; display: flex; justify-content: space-between; gap: 16px; padding: 9px 10px 8px; color: var(--muted); background: #14181df2; font-size: 10px; }
.command-menu-heading kbd { color: #89919a; font: inherit; }
@ -146,7 +174,9 @@ code { color: var(--accent); }
.sidebar.open { transform: none; }
.topbar .menu { display: block; }
.chat-layout { grid-template-columns: 1fr; }
.chat-layout.todo-open { grid-template-columns: 1fr; }
.sessions-panel { display: none; }
.todo-open .todo-panel { position: fixed; z-index: 20; top: 76px; right: 0; bottom: 0; width: min(340px, 92vw); box-shadow: -18px 0 50px #000b; }
.content-page { padding: 16px; }
.settings-grid { grid-template-columns: 1fr; }
.settings-nav { flex-direction: row; overflow: auto; }