初步实现todo功能
This commit is contained in:
parent
9ac898acbb
commit
351af8870d
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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/*` 管理接口只提供显式白名单能力:
|
||||
|
||||
|
||||
@ -32,6 +32,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
|
||||
| `memory` | 长期记忆存储与检索 |
|
||||
| `mcp` | MCP(Model 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 + Vite,Bits 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 只标记未读。
|
||||
|
||||
---
|
||||
|
||||
## 出站投递与关停
|
||||
|
||||
@ -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 表
|
||||
|
||||
长期记忆存储。
|
||||
|
||||
@ -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 — 浏览器自动化
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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("当前日期与时间"));
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -18,3 +18,4 @@ pub mod storage;
|
||||
pub mod task_supervisor;
|
||||
pub mod tools;
|
||||
pub mod util;
|
||||
pub mod work;
|
||||
|
||||
@ -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")]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,24 +1027,29 @@ 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(
|
||||
provider_config.clone(),
|
||||
tools.clone(),
|
||||
Some(storage.clone()),
|
||||
notify_tx,
|
||||
max_concurrent_background_tasks,
|
||||
Some(skills_loader.clone()),
|
||||
task_supervisor.clone(),
|
||||
));
|
||||
let sub_agent_manager = Arc::new(
|
||||
crate::agent::SubAgentManager::new(
|
||||
provider_config.clone(),
|
||||
tools.clone(),
|
||||
Some(storage.clone()),
|
||||
notify_tx,
|
||||
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(¬if.task_id, ¬if.status, ¬if.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;
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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": "后台任务ID(action=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()),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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
163
src/tools/todo.rs
Normal 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
689
src/work/mod.rs
Normal 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(¤t.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(¤t.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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,10 +257,17 @@
|
||||
<div class="chat-panel">
|
||||
<div class="chat-heading">
|
||||
<div><strong>{currentSession?.title || "新对话"}</strong><small>WebUI 会话</small></div>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger class="icon-button" aria-label="刷新会话" onclick={() => send({ type: "list_sessions", include_archived: false })}>↻</Tooltip.Trigger>
|
||||
<Tooltip.Portal><Tooltip.Content class="tooltip" sideOffset={7}>刷新会话<Tooltip.Arrow class="tooltip-arrow" /></Tooltip.Content></Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
<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 }); 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}
|
||||
@ -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>
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user