Compare commits

..

8 Commits

Author SHA1 Message Date
oudecheng
c4d10c6413 fix: merge 模式下按 content 匹配已有项,无需 agent 记住 UUID
- merge=true 时,无 id 的项先按 content 匹配已有项
- 匹配到→更新,匹配不到→新建(仍需 pending/in_progress)
- 新增 list_todos 命令处理器,支持前端自动拉取
- prompt 增加规则6:更新已有任务必须传 id(作为最佳实践提示)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 15:59:43 +08:00
oudecheng
750eed7326 feat: 添加 ListTodos 命令处理器,支持列出当前待办事项列表 2026-06-12 15:53:01 +08:00
oudecheng
3f32079f92 feat: 添加 Todo 面板,支持待办事项的展示与管理 2026-06-12 15:17:49 +08:00
oudecheng
ce6dce81f4 fix: 新创建 todo 项允许直接设为 in_progress,无需先 pending 再更新
agent 创建 todo 列表时可以将第一个任务直接标为 in_progress,
避免浪费一次工具调用。仍然禁止新项为 completed/cancelled。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 14:43:38 +08:00
oudecheng
881fcace47 feat: 添加 todo_write 工具,支持全量替换和增量合并两种模式
- Tool: 纯内存实现 (Arc<RwLock<HashMap>>),零 DB 依赖,解耦持久化
- 状态机: pending → in_progress → completed/cancelled,单 in_progress 约束
- merge=false: 全量替换模式(默认)
- merge=true: 增量更新模式,只传变更的项,其余保留
- 隔离: scope_key = topic_id.unwrap_or(session_id),topic 和子代理隔离
- 持久化: TodoRepository trait + SessionStore SQLite 实现,在 Session 拦截器层完成
- 前端推送: WsOutbound::TodoList 事件
- Prompt: TodoPromptProvider 中文指令,子代理模板也包含
- 测试: 16 个单元测试,全部通过

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 14:19:07 +08:00
oudecheng
cedd8b2a69 feat: 添加 topic_id 字段到消息结构,优化消息处理逻辑 2026-06-12 12:22:21 +08:00
oudecheng
87fc8cc3b7 feat: 更新工具调用的ID处理逻辑,确保在缺失时生成唯一ID 2026-06-12 11:48:01 +08:00
oudecheng
86543f19fe feat: 添加获取时间的工具调用,确保时间准确性 2026-06-12 10:40:34 +08:00
26 changed files with 2063 additions and 15 deletions

View File

@ -0,0 +1,74 @@
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::response::{CommandError, CommandResponse};
use crate::command::Command;
use crate::protocol::TodoItemSummary;
use crate::storage::SessionStore;
use async_trait::async_trait;
use std::sync::Arc;
pub struct ListTodosCommandHandler {
store: Arc<SessionStore>,
}
impl ListTodosCommandHandler {
pub fn new(store: Arc<SessionStore>) -> Self {
Self { store }
}
}
#[async_trait]
impl CommandHandler for ListTodosCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::ListTodos)
}
fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata {
name: "list_todos",
description: "列出当前 Todo 列表",
usage: "/list_todos",
})
}
async fn handle(
&self,
_cmd: Command,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
// scope_key = topic_id.unwrap_or(session_id)
let scope_key = ctx
.topic_id
.as_deref()
.filter(|t| !t.is_empty())
.or(ctx.session_id.as_deref())
.ok_or_else(|| {
CommandError::new(
"MISSING_CONTEXT",
"Cannot list todos: no session_id or topic_id in command context",
)
})?;
let records = self
.store
.list_todos(scope_key)
.map_err(|e| CommandError::new("LIST_TODOS_ERROR", e.to_string()))?;
let summaries: Vec<TodoItemSummary> = records
.into_iter()
.map(|r| TodoItemSummary {
id: r.id,
content: r.content,
status: r.status,
priority: r.priority,
created_at: r.created_at,
updated_at: r.updated_at,
})
.collect();
let todos_json = serde_json::to_string(&summaries)
.map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?;
Ok(CommandResponse::success(ctx.request_id).with_metadata("todos", &todos_json))
}
}

View File

@ -5,6 +5,7 @@ pub mod list_channels;
pub mod list_memories; pub mod list_memories;
pub mod list_scheduler_jobs; pub mod list_scheduler_jobs;
pub mod list_skills; pub mod list_skills;
pub mod list_todos;
pub mod memory_crud; pub mod memory_crud;
pub mod list_sessions; pub mod list_sessions;
pub mod list_sessions_by_channel; pub mod list_sessions_by_channel;

View File

@ -73,6 +73,8 @@ pub enum Command {
DeleteMemory { id: String }, DeleteMemory { id: String },
/// 列出所有技能 /// 列出所有技能
ListSkills, ListSkills,
/// 列出当前 Todo 列表
ListTodos,
} }
impl Command { impl Command {
@ -100,6 +102,7 @@ impl Command {
Command::UpdateMemory { .. } => "update_memory", Command::UpdateMemory { .. } => "update_memory",
Command::DeleteMemory { .. } => "delete_memory", Command::DeleteMemory { .. } => "delete_memory",
Command::ListSkills => "list_skills", Command::ListSkills => "list_skills",
Command::ListTodos => "list_todos",
} }
} }
} }

View File

@ -3,6 +3,7 @@ use std::sync::Arc;
use crate::agent::{AgentError, AgentLoop, CompositeSystemPromptProvider}; use crate::agent::{AgentError, AgentLoop, CompositeSystemPromptProvider};
use crate::config::LLMProviderConfig; use crate::config::LLMProviderConfig;
use crate::gateway::agent_prompt_provider::AgentPromptProvider; use crate::gateway::agent_prompt_provider::AgentPromptProvider;
use crate::gateway::todo_prompt_provider::TodoPromptProvider;
use crate::skills::{SkillPromptProvider, SkillRuntime}; use crate::skills::{SkillPromptProvider, SkillRuntime};
use crate::storage::persistent_session_id; use crate::storage::persistent_session_id;
use crate::storage::PromptInjectionRepository; use crate::storage::PromptInjectionRepository;
@ -53,6 +54,7 @@ impl AgentFactory {
self.prompt_repository.clone(), self.prompt_repository.clone(),
)), )),
Box::new(SkillPromptProvider::new(self.skills.clone())), Box::new(SkillPromptProvider::new(self.skills.clone())),
Box::new(TodoPromptProvider::new()),
])); ]));
AgentLoop::with_tools_and_system_prompt_provider( AgentLoop::with_tools_and_system_prompt_provider(

View File

@ -105,9 +105,14 @@
- 调用工具的时候必须同时用简短的话告诉用户你调用工具是做什么 - 调用工具的时候必须同时用简短的话告诉用户你调用工具是做什么
- 无需担心创建子智能体过多的问题请按用户或者skill的要求创建对应数量的子智能体这样可以隔离上下文更好完成工作 - 无需担心创建子智能体过多的问题请按用户或者skill的要求创建对应数量的子智能体这样可以隔离上下文更好完成工作
- 思考的时候建议用中文思考 - 思考的时候建议用中文思考
- 涉及到时间的都用get_time工具获取避免时间不准确
## 定时任务 ## 定时任务
- 默认创建静默任务silent_agent_task在独立后台会话中执行不干扰主对话 - 默认创建静默任务silent_agent_task在独立后台会话中执行不干扰主对话
- 静默模式下如需发送消息给用户prompt中需显式使用 send_session_message 工具 - 静默模式下如需发送消息给用户prompt中需显式使用 send_session_message 工具
## todo工具使用规范
- 严格按照既定的未完成的todo工作项执行任务如果工作项不在适用就更新不得随意遗漏工作项
- 禁止将未完成的工作项标记为已完成

View File

@ -190,6 +190,14 @@ impl AgentExecutionService {
// 只有当是最新回合时才触发历史压缩 // 只有当是最新回合时才触发历史压缩
let should_schedule_compaction = is_current_turn; let should_schedule_compaction = is_current_turn;
// 拦截 todo_write 结果:持久化 + 前端推送
if is_current_turn {
session.intercept_todo_write_results(
&request.result.emitted_messages,
request.chat_id,
);
}
Ok(FinalizedAgentResult { Ok(FinalizedAgentResult {
outbound_messages, outbound_messages,
should_schedule_compaction, should_schedule_compaction,

View File

@ -25,6 +25,7 @@ pub mod session_message_service;
pub mod session_pool; pub mod session_pool;
pub mod static_files; pub mod static_files;
pub mod tool_registry_factory; pub mod tool_registry_factory;
pub mod todo_prompt_provider;
pub mod ws; pub mod ws;
use axum::{Router, routing}; use axum::{Router, routing};

View File

@ -3,6 +3,8 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock;
use crate::agent::AgentError; use crate::agent::AgentError;
use crate::bus::MessageBus; use crate::bus::MessageBus;
use crate::config::{LLMProviderConfig, MemoryMaintenanceConfig, SubagentsConfig, TaskConfig}; use crate::config::{LLMProviderConfig, MemoryMaintenanceConfig, SubagentsConfig, TaskConfig};
@ -18,6 +20,7 @@ use crate::tools::{
SessionMessageSender, SubAgentRuntimeConfig, SubagentCatalog, ToolRegistry, SessionMessageSender, SubAgentRuntimeConfig, SubagentCatalog, ToolRegistry,
}; };
use crate::tools::task::repository::TaskRepository; use crate::tools::task::repository::TaskRepository;
use crate::tools::todo_write::TodoItem;
use super::agent_factory::AgentFactory; use super::agent_factory::AgentFactory;
use super::cli_session::CliSessionService; use super::cli_session::CliSessionService;
@ -117,6 +120,11 @@ pub(crate) fn build_session_manager_with_sender(
task_config.clone(), task_config.clone(),
); );
// Create shared todo state for TodoWriteTool
let todo_state: Arc<RwLock<HashMap<String, Vec<TodoItem>>>> =
Arc::new(RwLock::new(HashMap::new()));
let factory = factory.with_todo_state(todo_state);
// Create MCP Initializer (async, non-blocking) // Create MCP Initializer (async, non-blocking)
// MCP servers connect in background task // MCP servers connect in background task
let mut mcp_initializer = McpInitializer::with_config(mcp_config); let mut mcp_initializer = McpInitializer::with_config(mcp_config);

View File

@ -385,6 +385,84 @@ impl Session {
let _ = self.user_tx.send(msg).await; let _ = self.user_tx.send(msg).await;
} }
/// 扫描 agent 结果中的 todo_write 工具消息,
/// 提取 todos 并做持久化 + 前端推送(同步版本)。
pub(crate) fn intercept_todo_write_results(
&self,
emitted_messages: &[ChatMessage],
chat_id: &str,
) {
for msg in emitted_messages {
if msg.role != "tool" {
continue;
}
if msg.tool_name.as_deref() != Some("todo_write") {
continue;
}
// 解析工具返回的 JSON
let parsed: serde_json::Value = match serde_json::from_str(&msg.content) {
Ok(v) => v,
Err(_) => continue,
};
let Some(todos_array) = parsed
.get("current_todos")
.and_then(|v| v.as_array())
else {
continue;
};
// 计算持久化所需的 key
let session_id = crate::storage::persistent_session_id(&self.channel_name, chat_id);
let topic_id = self.current_topic(chat_id);
let scope_key = topic_id.map(|t| t.to_string()).unwrap_or_else(|| session_id.clone());
// 转换为 TodoRecord 并持久化
let records: Vec<crate::storage::TodoRecord> = todos_array
.iter()
.filter_map(|item| {
Some(crate::storage::TodoRecord {
id: item.get("id")?.as_str()?.to_string(),
scope_key: scope_key.clone(),
session_id: session_id.clone(),
topic_id: topic_id.map(|t| t.to_string()),
content: item.get("content")?.as_str()?.to_string(),
status: item.get("status")?.as_str()?.to_string(),
priority: item.get("priority")?.as_str()?.to_string(),
created_at: item.get("created_at")?.as_i64()?,
updated_at: item.get("updated_at")?.as_i64()?,
})
})
.collect();
// 持久化到 SQLite
if let Err(e) = self.store.replace_todos(&scope_key, &records) {
tracing::warn!(error = %e, scope_key = %scope_key, "Failed to persist todo list");
}
// 推送到前端(使用 try_send 避免异步)
let summaries: Vec<crate::protocol::TodoItemSummary> = records
.iter()
.map(|r| crate::protocol::TodoItemSummary {
id: r.id.clone(),
content: r.content.clone(),
status: r.status.clone(),
priority: r.priority.clone(),
created_at: r.created_at,
updated_at: r.updated_at,
})
.collect();
let _ = self.user_tx.try_send(crate::protocol::WsOutbound::TodoList {
todos: summaries,
scope_key: scope_key.clone(),
});
break; // 只处理第一个成功的 todo_write
}
}
/// 获取 provider_config 引用 /// 获取 provider_config 引用
pub fn provider_config(&self) -> &LLMProviderConfig { pub fn provider_config(&self) -> &LLMProviderConfig {
&self.provider_config &self.provider_config

View File

@ -0,0 +1,71 @@
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
pub struct TodoPromptProvider;
impl TodoPromptProvider {
pub fn new() -> Self {
Self
}
}
impl SystemPromptProvider for TodoPromptProvider {
fn build(&self, _context: &SystemPromptContext) -> Option<SystemPrompt> {
Some(SystemPrompt {
content: TODO_WRITE_INSTRUCTIONS.to_string(),
context: Some("todo_write".to_string()),
})
}
}
const TODO_WRITE_INSTRUCTIONS: &str = r#"
## TodoWrite
使 `todo_write`
### 使
- 3 使 todo_write
- todo
### merge
- `merge: false` todo
- `merge: true` **使 merge=true id**
###
- `pending`
- `in_progress`
- `completed`
- `cancelled`
###
1. `in_progress`
2. `in_progress`
3. `completed` `cancelled`
4. completed
5. `content`
6. ** `id`** id idid todo_write `current_todos`
### 使
pending in_progress id
```json
{"merge": true, "todos": [{"content": "修复登录 bug", "status": "in_progress"}]}
```
```json
{"merge": true, "todos": [{"content": "补充测试", "status": "pending"}]}
```
** id** current_todos
```json
{"merge": true, "todos": [{"id": "abc-123", "content": "修复登录 bug", "status": "completed"}]}
```
```json
{"merge": true, "todos": [
{"id": "abc-123", "content": "修复登录 bug", "status": "completed"},
{"content": "代码审查", "status": "in_progress"}
]}
```
"#;

View File

@ -1,16 +1,19 @@
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock;
use crate::config::TaskConfig; use crate::config::TaskConfig;
use crate::mcp::McpClientManager; use crate::mcp::McpClientManager;
use crate::skills::SkillRuntime; use crate::skills::SkillRuntime;
use crate::storage::{MemoryRepository, SchedulerJobRepository, SkillEventRepository}; use crate::storage::{MemoryRepository, SchedulerJobRepository, SkillEventRepository};
use crate::tools::todo_write::TodoItem;
use crate::tools::{ use crate::tools::{
BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool, BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool,
HttpRequestTool, MemoryManageTool, MemorySearchTool, HttpRequestTool, MemoryManageTool, MemorySearchTool,
SchedulerManageTool, SessionMessageSender, SessionSendTool, SkillActivateTool, SchedulerManageTool, SessionMessageSender, SessionSendTool, SkillActivateTool,
SkillManageTool, SubAgentRuntime, TaskTool, TimeTool, SkillManageTool, SubAgentRuntime, TaskTool, TimeTool,
ToolRegistry, WebFetchTool, TodoWriteTool, ToolRegistry, WebFetchTool,
}; };
pub(crate) struct ToolRegistryFactory { pub(crate) struct ToolRegistryFactory {
@ -25,6 +28,7 @@ pub(crate) struct ToolRegistryFactory {
task_config: TaskConfig, task_config: TaskConfig,
subagent_runtime: Option<Arc<dyn SubAgentRuntime>>, subagent_runtime: Option<Arc<dyn SubAgentRuntime>>,
mcp_manager: Option<Arc<McpClientManager>>, mcp_manager: Option<Arc<McpClientManager>>,
todo_state: Option<Arc<RwLock<HashMap<String, Vec<TodoItem>>>>>,
} }
impl ToolRegistryFactory { impl ToolRegistryFactory {
@ -51,9 +55,18 @@ impl ToolRegistryFactory {
task_config, task_config,
subagent_runtime: None, subagent_runtime: None,
mcp_manager: None, mcp_manager: None,
todo_state: None,
} }
} }
pub(crate) fn with_todo_state(
mut self,
state: Arc<RwLock<HashMap<String, Vec<TodoItem>>>>,
) -> Self {
self.todo_state = Some(state);
self
}
pub(crate) fn with_subagent_runtime( pub(crate) fn with_subagent_runtime(
mut self, mut self,
runtime: Arc<dyn SubAgentRuntime>, runtime: Arc<dyn SubAgentRuntime>,
@ -98,6 +111,11 @@ impl ToolRegistryFactory {
if self.is_enabled("memory_manage") { if self.is_enabled("memory_manage") {
registry.register(MemoryManageTool::new(self.memories.clone())); registry.register(MemoryManageTool::new(self.memories.clone()));
} }
if self.is_enabled("todo_write") {
if let Some(ref state) = self.todo_state {
registry.register(TodoWriteTool::new(state.clone()));
}
}
if self.is_enabled("session_send") { if self.is_enabled("session_send") {
registry.register(SessionSendTool::new(self.session_message_sender.clone())); registry.register(SessionSendTool::new(self.session_message_sender.clone()));
} }
@ -198,6 +216,13 @@ impl ToolRegistryFactory {
registry.register(SessionSendTool::new(self.session_message_sender.clone())); registry.register(SessionSendTool::new(self.session_message_sender.clone()));
} }
// Todo 追踪工具
if self.is_enabled("todo_write") {
if let Some(ref state) = self.todo_state {
registry.register(TodoWriteTool::new(state.clone()));
}
}
// 注册 MCP 工具(如果提供) // 注册 MCP 工具(如果提供)
if let Some(mcp_tools) = mcp_tools { if let Some(mcp_tools) = mcp_tools {
for tool in mcp_tools { for tool in mcp_tools {

View File

@ -12,6 +12,7 @@ use crate::command::handlers::list_channels::ListChannelsCommandHandler;
use crate::command::handlers::list_memories::ListMemoriesCommandHandler; use crate::command::handlers::list_memories::ListMemoriesCommandHandler;
use crate::command::handlers::list_skills::ListSkillsCommandHandler; use crate::command::handlers::list_skills::ListSkillsCommandHandler;
use crate::command::handlers::list_scheduler_jobs::ListSchedulerJobsCommandHandler; use crate::command::handlers::list_scheduler_jobs::ListSchedulerJobsCommandHandler;
use crate::command::handlers::list_todos::ListTodosCommandHandler;
use crate::command::handlers::memory_crud::MemoryCrudCommandHandler; use crate::command::handlers::memory_crud::MemoryCrudCommandHandler;
use crate::command::handlers::list_sessions::ListSessionsCommandHandler; use crate::command::handlers::list_sessions::ListSessionsCommandHandler;
use crate::command::handlers::list_sessions_by_channel::ListSessionsByChannelCommandHandler; use crate::command::handlers::list_sessions_by_channel::ListSessionsByChannelCommandHandler;
@ -422,6 +423,8 @@ async fn handle_inbound(
router.register(Box::new(ListMemoriesCommandHandler::new(store.clone()))); router.register(Box::new(ListMemoriesCommandHandler::new(store.clone())));
// 注册 list_skills 处理器 // 注册 list_skills 处理器
router.register(Box::new(ListSkillsCommandHandler::new(skills_for_handler))); router.register(Box::new(ListSkillsCommandHandler::new(skills_for_handler)));
// 注册 list_todos 处理器
router.register(Box::new(ListTodosCommandHandler::new(store.clone())));
// 注册 memory_crud 处理器 // 注册 memory_crud 处理器
router.register(Box::new(MemoryCrudCommandHandler::new(store.clone()))); router.register(Box::new(MemoryCrudCommandHandler::new(store.clone())));
// 注册 load_chat_messages 处理器 // 注册 load_chat_messages 处理器
@ -519,6 +522,13 @@ async fn handle_inbound(
} }
} }
// 处理 Todo 列表
if let Some(todos_json) = response.metadata.get("todos") {
if let Ok(todos) = serde_json::from_str::<Vec<crate::protocol::TodoItemSummary>>(todos_json) {
let _ = sender.send(WsOutbound::TodoList { todos, scope_key: String::new() }).await;
}
}
// 处理记忆列表 // 处理记忆列表
if let Some(memories_json) = response.metadata.get("memories") { if let Some(memories_json) = response.metadata.get("memories") {
if let Ok(memories) = serde_json::from_str::<Vec<crate::protocol::MemorySummary>>(memories_json) { if let Ok(memories) = serde_json::from_str::<Vec<crate::protocol::MemorySummary>>(memories_json) {
@ -767,7 +777,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
let tc_reasoning = if has_content_or_reasoning { None } else { msg.reasoning_content.clone() }; let tc_reasoning = if has_content_or_reasoning { None } else { msg.reasoning_content.clone() };
for tool_call in tool_calls { for tool_call in tool_calls {
outbound.push(WsOutbound::ToolCall { outbound.push(WsOutbound::ToolCall {
id: msg.id.clone(), id: tool_call.id.clone(),
tool_call_id: tool_call.id.clone(), tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(), tool_name: tool_call.name.clone(),
arguments: tool_call.arguments.clone(), arguments: tool_call.arguments.clone(),
@ -798,7 +808,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
let tool_state = msg.tool_state.as_ref().unwrap_or(&ToolMessageState::Completed); let tool_state = msg.tool_state.as_ref().unwrap_or(&ToolMessageState::Completed);
match tool_state { match tool_state {
ToolMessageState::Completed => vec![WsOutbound::ToolResult { ToolMessageState::Completed => vec![WsOutbound::ToolResult {
id: msg.id.clone(), id: msg.tool_call_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
tool_call_id: msg.tool_call_id.clone().unwrap_or_default(), tool_call_id: msg.tool_call_id.clone().unwrap_or_default(),
tool_name: msg.tool_name.clone().unwrap_or_default(), tool_name: msg.tool_name.clone().unwrap_or_default(),
content: msg.content.clone(), content: msg.content.clone(),
@ -809,7 +819,7 @@ fn chat_message_to_ws_outbound(msg: &crate::bus::ChatMessage) -> Vec<WsOutbound>
timestamp: Some(msg.timestamp / 1000), timestamp: Some(msg.timestamp / 1000),
}], }],
ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending { ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending {
id: msg.id.clone(), id: msg.tool_call_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
tool_call_id: msg.tool_call_id.clone().unwrap_or_default(), tool_call_id: msg.tool_call_id.clone().unwrap_or_default(),
tool_name: msg.tool_name.clone().unwrap_or_default(), tool_name: msg.tool_name.clone().unwrap_or_default(),
content: msg.content.clone(), content: msg.content.clone(),

View File

@ -82,6 +82,17 @@ pub struct SkillSummary {
pub source: String, pub source: String,
} }
/// Todo item 摘要(发送给前端)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TodoItemSummary {
pub id: String,
pub content: String,
pub status: String,
pub priority: String,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchedulerJobSummary { pub struct SchedulerJobSummary {
pub id: String, pub id: String,
@ -202,6 +213,8 @@ pub enum WsOutbound {
task_id: String, task_id: String,
description: String, description: String,
subagent_type: String, subagent_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
topic_id: Option<String>,
}, },
#[serde(rename = "session_established")] #[serde(rename = "session_established")]
SessionEstablished { session_id: String }, SessionEstablished { session_id: String },
@ -255,6 +268,11 @@ pub enum WsOutbound {
}, },
#[serde(rename = "execution_cancelled")] #[serde(rename = "execution_cancelled")]
ExecutionCancelled { message: String }, ExecutionCancelled { message: String },
#[serde(rename = "todo_list")]
TodoList {
todos: Vec<TodoItemSummary>,
scope_key: String,
},
#[serde(rename = "pong")] #[serde(rename = "pong")]
Pong, Pong,
} }

View File

@ -32,7 +32,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
// AssistantResponse 已携带 reasoning 时ToolCall 不再重复 // AssistantResponse 已携带 reasoning 时ToolCall 不再重复
let tc_reasoning = if has_content_or_reasoning { None } else { message.reasoning_content.clone() }; let tc_reasoning = if has_content_or_reasoning { None } else { message.reasoning_content.clone() };
outbound.extend(tool_calls.iter().map(|tool_call| WsOutbound::ToolCall { outbound.extend(tool_calls.iter().map(|tool_call| WsOutbound::ToolCall {
id: message.id.clone(), id: tool_call.id.clone(),
tool_call_id: tool_call.id.clone(), tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(), tool_name: tool_call.name.clone(),
arguments: tool_call.arguments.clone(), arguments: tool_call.arguments.clone(),
@ -63,7 +63,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
.unwrap_or(&ToolMessageState::Completed) .unwrap_or(&ToolMessageState::Completed)
{ {
ToolMessageState::Completed => vec![WsOutbound::ToolResult { ToolMessageState::Completed => vec![WsOutbound::ToolResult {
id: message.id.clone(), id: message.tool_call_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
tool_call_id: message.tool_call_id.clone().unwrap_or_default(), tool_call_id: message.tool_call_id.clone().unwrap_or_default(),
tool_name: message.tool_name.clone().unwrap_or_default(), tool_name: message.tool_name.clone().unwrap_or_default(),
content: message.content.clone(), content: message.content.clone(),
@ -74,7 +74,7 @@ pub(crate) fn ws_outbound_from_chat_message(message: &ChatMessage) -> Vec<WsOutb
timestamp: None, timestamp: None,
}], }],
ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending { ToolMessageState::PendingUserAction => vec![WsOutbound::ToolPending {
id: message.id.clone(), id: message.tool_call_id.clone().unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
tool_call_id: message.tool_call_id.clone().unwrap_or_default(), tool_call_id: message.tool_call_id.clone().unwrap_or_default(),
tool_name: message.tool_name.clone().unwrap_or_default(), tool_name: message.tool_name.clone().unwrap_or_default(),
content: message.content.clone(), content: message.content.clone(),
@ -172,6 +172,7 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve
task_id: message.metadata.get("task_id").cloned().unwrap_or_default(), task_id: message.metadata.get("task_id").cloned().unwrap_or_default(),
description: message.metadata.get("task_description").cloned().unwrap_or_default(), description: message.metadata.get("task_description").cloned().unwrap_or_default(),
subagent_type: message.metadata.get("task_subagent_type").cloned().unwrap_or_default(), subagent_type: message.metadata.get("task_subagent_type").cloned().unwrap_or_default(),
topic_id: message.metadata.get("topic_id").cloned(),
}], }],
} }
} }

View File

@ -14,13 +14,13 @@ pub mod records;
pub use error::StorageError; pub use error::StorageError;
pub use ports::{ pub use ports::{
ConversationRepository, MemoryRepository, PromptInjectionRepository, SchedulerJobRepository, ConversationRepository, MemoryRepository, PromptInjectionRepository, SchedulerJobRepository,
SkillEventRepository, SkillEventRepository, TodoRepository,
}; };
pub use records::{ pub use records::{
allowed_namespace_names, get_namespace_description, is_valid_namespace, allowed_namespace_names, get_namespace_description, is_valid_namespace,
ALLOWED_MEMORY_NAMESPACES, GLOBAL_SCOPE_KEY, MemoryRecord, MemoryUpsert, SchedulerJobRecord, ALLOWED_MEMORY_NAMESPACES, GLOBAL_SCOPE_KEY, MemoryRecord, MemoryUpsert, SchedulerJobRecord,
SchedulerJobState, SchedulerJobStatus, SchedulerJobUpsert, SessionRecord, SkillEventRecord, SchedulerJobState, SchedulerJobStatus, SchedulerJobUpsert, SessionRecord, SkillEventRecord,
TopicRecord, TodoRecord, TopicRecord,
}; };
#[derive(Clone)] #[derive(Clone)]
@ -217,6 +217,7 @@ impl SessionStore {
ensure_messages_schema(&conn)?; ensure_messages_schema(&conn)?;
ensure_scheduler_schema(&conn)?; ensure_scheduler_schema(&conn)?;
ensure_memory_scope_key_migration(&conn)?; ensure_memory_scope_key_migration(&conn)?;
ensure_todos_schema(&conn)?;
drop(conn); drop(conn);
@ -1491,6 +1492,74 @@ impl SessionStore {
) )
.map_err(StorageError::from) .map_err(StorageError::from)
} }
pub fn replace_todos(
&self,
scope_key: &str,
items: &[TodoRecord],
) -> Result<Vec<TodoRecord>, StorageError> {
let conn = self.pool.get()?;
let now = current_timestamp();
// Delete existing todos for this scope_key
conn.execute(
"DELETE FROM todos WHERE scope_key = ?1",
params![scope_key],
)?;
// Insert new todos
for item in items {
conn.execute(
"INSERT INTO todos (id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![
item.id,
scope_key,
item.session_id,
item.topic_id,
item.content,
item.status,
item.priority,
item.created_at,
now,
],
)?;
}
drop(conn);
self.list_todos(scope_key)
}
pub fn list_todos(&self, scope_key: &str) -> Result<Vec<TodoRecord>, StorageError> {
let conn = self.pool.get()?;
let mut stmt = conn.prepare(
"SELECT id, scope_key, session_id, topic_id, content, status, priority, created_at, updated_at
FROM todos
WHERE scope_key = ?1
ORDER BY created_at ASC",
)?;
let rows = stmt.query_map(params![scope_key], |row| {
Ok(TodoRecord {
id: row.get(0)?,
scope_key: row.get(1)?,
session_id: row.get(2)?,
topic_id: row.get(3)?,
content: row.get(4)?,
status: row.get(5)?,
priority: row.get(6)?,
created_at: row.get(7)?,
updated_at: row.get(8)?,
})
})?;
let mut todos = Vec::new();
for row in rows {
todos.push(row?);
}
Ok(todos)
}
} }
pub fn persistent_session_id(channel_name: &str, chat_id: &str) -> String { pub fn persistent_session_id(channel_name: &str, chat_id: &str) -> String {
@ -1800,6 +1869,42 @@ fn ensure_memory_scope_key_migration(conn: &Connection) -> Result<(), StorageErr
Ok(()) Ok(())
} }
fn ensure_todos_schema(conn: &Connection) -> Result<(), StorageError> {
let table_exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='todos'",
[],
|row| row.get::<_, i64>(0),
)
.map(|count| count > 0)?;
if !table_exists {
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS todos (
id TEXT PRIMARY KEY,
scope_key TEXT NOT NULL,
session_id TEXT NOT NULL,
topic_id TEXT,
content TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
priority TEXT NOT NULL DEFAULT 'medium',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_todos_scope
ON todos(scope_key, created_at ASC);
CREATE INDEX IF NOT EXISTS idx_todos_session
ON todos(session_id);
",
)?;
}
Ok(())
}
fn has_column( fn has_column(
conn: &Connection, conn: &Connection,
table_name: &str, table_name: &str,
@ -2009,7 +2114,7 @@ fn load_messages_after(
messages.push(row?); messages.push(row?);
} }
Ok(messages) Ok(messages)
} }
fn current_timestamp() -> i64 { fn current_timestamp() -> i64 {
std::time::SystemTime::now() std::time::SystemTime::now()

View File

@ -1,6 +1,6 @@
use super::{ use super::{
MemoryRecord, MemoryUpsert, SchedulerJobRecord, SchedulerJobState, SchedulerJobStatus, MemoryRecord, MemoryUpsert, SchedulerJobRecord, SchedulerJobState, SchedulerJobStatus,
SchedulerJobUpsert, SessionRecord, SkillEventRecord, StorageError, SchedulerJobUpsert, SessionRecord, SkillEventRecord, StorageError, TodoRecord,
}; };
use crate::bus::ChatMessage; use crate::bus::ChatMessage;
@ -145,6 +145,18 @@ pub trait SkillEventRepository: Send + Sync + 'static {
) -> Result<Vec<SkillEventRecord>, StorageError>; ) -> Result<Vec<SkillEventRecord>, StorageError>;
} }
pub trait TodoRepository: Send + Sync + 'static {
/// Replace all todos for a scope (full replacement pattern).
fn replace_todos(
&self,
scope_key: &str,
todo_records: &[TodoRecord],
) -> Result<Vec<TodoRecord>, StorageError>;
/// Load all todos for a scope, ordered by created_at.
fn list_todos(&self, scope_key: &str) -> Result<Vec<TodoRecord>, StorageError>;
}
impl ConversationRepository for super::SessionStore { impl ConversationRepository for super::SessionStore {
fn ensure_channel_session( fn ensure_channel_session(
&self, &self,
@ -356,3 +368,17 @@ impl SkillEventRepository for super::SessionStore {
super::SessionStore::list_skill_events(self, session_id) super::SessionStore::list_skill_events(self, session_id)
} }
} }
impl TodoRepository for super::SessionStore {
fn replace_todos(
&self,
scope_key: &str,
todo_records: &[TodoRecord],
) -> Result<Vec<TodoRecord>, StorageError> {
super::SessionStore::replace_todos(self, scope_key, todo_records)
}
fn list_todos(&self, scope_key: &str) -> Result<Vec<TodoRecord>, StorageError> {
super::SessionStore::list_todos(self, scope_key)
}
}

View File

@ -35,6 +35,19 @@ pub fn allowed_namespace_names() -> Vec<&'static str> {
ALLOWED_MEMORY_NAMESPACES.iter().map(|(name, _)| *name).collect() ALLOWED_MEMORY_NAMESPACES.iter().map(|(name, _)| *name).collect()
} }
#[derive(Debug, Clone)]
pub struct TodoRecord {
pub id: String,
pub scope_key: String,
pub session_id: String,
pub topic_id: Option<String>,
pub content: String,
pub status: String,
pub priority: String,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillEventRecord { pub struct SkillEventRecord {
pub id: String, pub id: String,

View File

@ -14,6 +14,7 @@ pub mod skill_activate;
pub mod skill_manage; pub mod skill_manage;
pub mod task; pub mod task;
pub mod time; pub mod time;
pub mod todo_write;
pub mod traits; pub mod traits;
pub mod web_fetch; pub mod web_fetch;
@ -39,6 +40,7 @@ pub use task::{
SubagentCatalog, TaskError, TaskRepository, TaskTool, SubagentCatalog, TaskError, TaskRepository, TaskTool,
}; };
pub use time::TimeTool; pub use time::TimeTool;
pub use todo_write::TodoWriteTool;
pub use traits::{Tool, ToolContext, ToolResult}; pub use traits::{Tool, ToolContext, ToolResult};
pub use web_fetch::WebFetchTool; pub use web_fetch::WebFetchTool;

View File

@ -52,6 +52,8 @@ impl SubagentPromptBuilder {
2. 使\n\ 2. 使\n\
3. \n\ 3. \n\
4. \n\n\ 4. \n\n\
:\n\
使 `todo_write` in_progress3使\n\n\
: 访" : 访"
} else { } else {
&def.prompt_template &def.prompt_template

View File

@ -250,6 +250,7 @@ impl DefaultSubAgentRuntime {
let mut metadata = HashMap::new(); let mut metadata = HashMap::new();
metadata.insert("subagent_task_id".to_string(), session.id.clone()); metadata.insert("subagent_task_id".to_string(), session.id.clone());
metadata.insert("is_subagent_event".to_string(), "true".to_string()); metadata.insert("is_subagent_event".to_string(), "true".to_string());
metadata.insert("topic_id".to_string(), session.parent_topic_id.clone().unwrap_or_default());
let emitter = Arc::new(PersistingEmittedMessageHandler::new( let emitter = Arc::new(PersistingEmittedMessageHandler::new(
SubAgentEmitter { SubAgentEmitter {
@ -424,6 +425,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
metadata.insert("task_id".to_string(), session.id.clone()); metadata.insert("task_id".to_string(), session.id.clone());
metadata.insert("task_description".to_string(), session.description.clone()); metadata.insert("task_description".to_string(), session.description.clone());
metadata.insert("task_subagent_type".to_string(), session.subagent_type.clone()); metadata.insert("task_subagent_type".to_string(), session.subagent_type.clone());
metadata.insert("topic_id".to_string(), session.parent_topic_id.clone().unwrap_or_default());
let event = OutboundMessage { let event = OutboundMessage {
channel: session.parent_channel_name.clone(), channel: session.parent_channel_name.clone(),

View File

@ -61,7 +61,7 @@ impl SubagentDef {
Self { Self {
name: "general".to_string(), name: "general".to_string(),
description: "通用型子代理 - 处理复杂多步骤任务".to_string(), description: "通用型子代理 - 处理复杂多步骤任务".to_string(),
prompt_template: "你是一个专注的子代理,正在执行一个独立任务。\n\n任务描述: {{description}}\n\n你应该:\n1. 专注于完成任务,不要偏离目标\n2. 使用可用的工具进行必要操作\n3. 完成后给出简洁的总结\n4. 不要尝试创建新的子代理任务\n\n注意: 你没有访问主对话历史的权限,这是一个独立的执行上下文。".to_string(), prompt_template: "你是一个专注的子代理,正在执行一个独立任务。\n\n任务描述: {{description}}\n\n你应该:\n1. 专注于完成任务,不要偏离目标\n2. 使用可用的工具进行必要操作\n3. 完成后给出简洁的总结\n4. 不要尝试创建新的子代理任务\n\n任务追踪:\n你可以使用 `todo_write` 工具追踪子任务进度。规则:同一时间只有一个 in_progress完成后再标记下一个3步以上才使用。\n\n注意: 你没有访问主对话历史的权限,这是一个独立的执行上下文。".to_string(),
body: None, body: None,
allowed_tools: None, allowed_tools: None,
max_execution_secs: None, max_execution_secs: None,

1294
src/tools/todo_write.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@ -5,6 +5,7 @@ import { TopicList } from './components/Sidebar/TopicList'
import { SchedulerJobList } from './components/Sidebar/SchedulerJobList' import { SchedulerJobList } from './components/Sidebar/SchedulerJobList'
import { MemoryPanel } from './components/Panel/MemoryPanel' import { MemoryPanel } from './components/Panel/MemoryPanel'
import { SkillList } from './components/Panel/SkillList' import { SkillList } from './components/Panel/SkillList'
import { TodoPanel } from './components/Panel/TodoPanel'
import { ConnectionStatus } from './components/ConnectionStatus' import { ConnectionStatus } from './components/ConnectionStatus'
import { ChannelSelector } from './components/Header/ChannelSelector' import { ChannelSelector } from './components/Header/ChannelSelector'
import { SessionSelector } from './components/Header/SessionSelector' import { SessionSelector } from './components/Header/SessionSelector'
@ -42,6 +43,8 @@ function App() {
// 技能 // 技能
skills, skills,
requestSkillList, requestSkillList,
todos,
requestTodoList,
// 定时任务 // 定时任务
schedulerJobs, schedulerJobs,
sidebarTab, sidebarTab,
@ -320,7 +323,7 @@ function App() {
} }
}, [sidebarTab, status, handleCommand, sendMessage, requestSchedulerJobList]) }, [sidebarTab, status, handleCommand, sendMessage, requestSchedulerJobList])
// 连接就绪时自动拉取记忆和技能列表 // 连接就绪时自动拉取记忆、技能和待办列表
useEffect(() => { useEffect(() => {
if (status === 'connected') { if (status === 'connected') {
const memCmd = requestMemoryList() const memCmd = requestMemoryList()
@ -329,8 +332,11 @@ function App() {
const skillCmd = requestSkillList() const skillCmd = requestSkillList()
handleCommand(skillCmd) handleCommand(skillCmd)
sendMessage({ type: 'command', payload: JSON.stringify(skillCmd) }) sendMessage({ type: 'command', payload: JSON.stringify(skillCmd) })
const todoCmd = requestTodoList()
handleCommand(todoCmd)
sendMessage({ type: 'command', payload: JSON.stringify(todoCmd) })
} }
}, [status, handleCommand, sendMessage, requestMemoryList, requestSkillList]) }, [status, handleCommand, sendMessage, requestMemoryList, requestSkillList, requestTodoList])
const handleRefreshMemories = useCallback(() => { const handleRefreshMemories = useCallback(() => {
const cmd = requestMemoryList() const cmd = requestMemoryList()
@ -677,6 +683,13 @@ function App() {
</div> </div>
)} )}
</div> </div>
{/* 悬浮 Todo 面板 */}
<TodoPanel
todos={todos}
requestTodoList={requestTodoList}
sendCommand={sendMemoryCommand}
/>
</div> </div>
) )
} }

View File

@ -0,0 +1,223 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { ClipboardList, ChevronUp, ChevronDown, Circle } from 'lucide-react'
import type { TodoItemSummary, Command } from '../../types/protocol'
interface TodoPanelProps {
todos: TodoItemSummary[]
requestTodoList: () => Command
sendCommand: (cmd: Command) => void
}
/* ── status helpers ───────────────────────────────────── */
interface StatusStyle { label: string; border: string; dot: string; text: string; icon: string }
const STATUS: Record<string, StatusStyle> = {
in_progress: { label: '进行中', border: 'border-amber-400/60', dot: 'bg-amber-400 shadow-[0_0_6px_#fbbf24]', text: 'text-amber-300', icon: '●' },
pending: { label: '待处理', border: 'border-slate-500/50', dot: 'bg-slate-500', text: 'text-slate-400', icon: '○' },
completed: { label: '已完成', border: 'border-emerald-400/40', dot: 'bg-emerald-400', text: 'text-emerald-400', icon: '✓' },
cancelled: { label: '已取消', border: 'border-red-400/40', dot: 'bg-red-400', text: 'text-red-400', icon: '✕' },
}
function statusStyle(s: string): StatusStyle {
return STATUS[s] ?? { label: s, border: 'border-[var(--border-color)]', dot: 'bg-[var(--text-muted)]', text: 'text-[var(--text-muted)]', icon: '?' }
}
const PRIORITY: Record<string, string> = {
high: 'text-rose-400',
medium: 'text-amber-400',
low: 'text-slate-400',
}
function priorityDot(p: string) { return PRIORITY[p] ?? 'text-slate-400' }
/* ── group helpers ────────────────────────────────────── */
const GROUP_ORDER = ['in_progress', 'pending', 'completed', 'cancelled']
const COLLAPSED_DEFAULT = new Set(['completed', 'cancelled'])
function groupTodos(todos: TodoItemSummary[]): Map<string, TodoItemSummary[]> {
const map = new Map<string, TodoItemSummary[]>()
for (const t of todos) {
const list = map.get(t.status) ?? []
list.push(t)
map.set(t.status, list)
}
return map
}
/* ── pulse dot ────────────────────────────────────────── */
function PulseDot() {
return (
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-400 shadow-[0_0_6px_#fbbf24]" />
</span>
)
}
/* ── TodoPanel ────────────────────────────────────────── */
export function TodoPanel({ todos, requestTodoList, sendCommand }: TodoPanelProps) {
const [expanded, setExpanded] = useState(() => {
try { return localStorage.getItem('picobot-todo-panel-open') === 'true' } catch { return false }
})
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set(COLLAPSED_DEFAULT))
const prevTodoIdsRef = useRef<Set<string>>(new Set())
// 持久化展开状态
useEffect(() => {
localStorage.setItem('picobot-todo-panel-open', String(expanded))
}, [expanded])
// 当有新 todo 出现时自动展开
useEffect(() => {
const newIds = new Set(todos.map(t => t.id))
const hasNewItems = todos.some(t => !prevTodoIdsRef.current.has(t.id))
if (hasNewItems && todos.length > 0) {
setExpanded(true)
}
prevTodoIdsRef.current = newIds
}, [todos])
const grouped = groupTodos(todos)
const inProgressCount = grouped.get('in_progress')?.length ?? 0
const totalCount = todos.length
const toggleGroup = useCallback((status: string) => {
setCollapsedGroups(prev => {
const next = new Set(prev)
if (next.has(status)) next.delete(status); else next.add(status)
return next
})
}, [])
// ── 空状态 ──
if (totalCount === 0) {
return (
<div className="fixed bottom-4 right-4 z-50">
<button
onClick={() => sendCommand(requestTodoList())}
className="flex items-center gap-2 px-3 py-2 rounded-xl bg-[var(--bg-tertiary)]/70 backdrop-blur-sm border border-[var(--border-color)] text-[var(--text-muted)] hover:text-[var(--text-secondary)] hover:border-[var(--border-accent)] transition-all duration-300 shadow-lg text-xs"
title="刷新待办"
>
<ClipboardList className="h-3.5 w-3.5" />
<span></span>
</button>
</div>
)
}
// ── 折叠态 ──
if (!expanded) {
return (
<div className="fixed bottom-4 right-4 z-50">
<button
onClick={() => setExpanded(true)}
className="flex items-center gap-2 px-3 py-2 rounded-xl bg-[var(--bg-tertiary)]/80 backdrop-blur-sm border border-[var(--border-color)] hover:border-[var(--accent-cyan)]/30 transition-all duration-300 shadow-lg group"
>
{inProgressCount > 0 ? <PulseDot /> : <ClipboardList className="h-3.5 w-3.5 text-[var(--text-muted)]" />}
<span className="text-sm font-medium text-[var(--text-secondary)] group-hover:text-[var(--accent-cyan)] transition-colors">
({totalCount})
</span>
<ChevronUp className="h-3.5 w-3.5 text-[var(--text-muted)]" />
</button>
</div>
)
}
// ── 展开态 ──
return (
<div className="fixed bottom-4 right-4 z-50 w-72 max-h-[60vh] flex flex-col rounded-xl border border-[var(--border-color)] bg-[var(--bg-tertiary)]/95 backdrop-blur-md shadow-2xl overflow-hidden transition-all duration-300">
{/* 标题栏 */}
<div className="shrink-0 flex items-center justify-between px-3 py-2.5 border-b border-[var(--border-color)]">
<div className="flex items-center gap-2">
<ClipboardList className="h-4 w-4 text-[var(--accent-cyan)]" />
<span className="text-sm font-semibold text-[var(--text-primary)]"></span>
<span className="text-[11px] text-[var(--text-muted)] tabular-nums"> {totalCount} </span>
</div>
<button
onClick={() => setExpanded(false)}
className="p-1 rounded hover:bg-[var(--overlay-subtle)] text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
>
<ChevronDown className="h-4 w-4" />
</button>
</div>
{/* 列表区 */}
<div className="flex-1 overflow-y-auto scrollbar-hide px-2 py-2 space-y-2">
{GROUP_ORDER.map(status => {
const items = grouped.get(status)
if (!items || items.length === 0) return null
const style = statusStyle(status)
const isCollapsed = collapsedGroups.has(status)
const isTerminal = status === 'completed' || status === 'cancelled'
return (
<div key={status}>
{/* 分组标题 */}
<button
onClick={() => toggleGroup(status)}
className="flex items-center gap-1.5 w-full px-1 py-1 text-xs text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors"
>
{isCollapsed ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
<span className={style.text}>{style.icon}</span>
<span>{style.label}</span>
<span className="tabular-nums">({items.length})</span>
</button>
{/* 卡片列表 */}
{!isCollapsed && (
<div className="space-y-1">
{items.map(item => (
<div
key={item.id}
className={`rounded-lg border-l-2 ${style.border} border border-[var(--border-color)] bg-[var(--overlay-hover)]/50 px-2.5 py-1.5 transition-colors hover:border-[var(--border-accent)]`}
>
<div className="flex items-start gap-2">
<span className={`mt-0.5 shrink-0 ${priorityDot(item.priority)}`}>
<Circle className="h-2 w-2 fill-current" />
</span>
<div className="min-w-0 flex-1">
<p className={`text-[13px] leading-snug text-[var(--text-primary)] break-words ${
isTerminal ? 'line-through opacity-50' : ''
}`}>
{item.content}
</p>
<div className="flex items-center gap-2 mt-0.5">
<span className={`text-[10px] ${priorityDot(item.priority)}`}>
{item.priority}
</span>
<span className={`text-[10px] ${style.text}`}>
{style.icon} {style.label}
</span>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
)
})}
</div>
{/* 底部刷新 */}
<div className="shrink-0 border-t border-[var(--border-color)] px-3 py-1.5">
<button
onClick={() => sendCommand(requestTodoList())}
className="text-[10px] text-[var(--text-muted)] hover:text-[var(--accent-cyan)] transition-colors"
>
</button>
</div>
</div>
)
}

View File

@ -20,6 +20,8 @@ import type {
MemoryList, MemoryList,
SkillSummary, SkillSummary,
SkillList, SkillList,
TodoItemSummary,
TodoList,
SchedulerJobList, SchedulerJobList,
SchedulerJobSummary, SchedulerJobSummary,
SchedulerJobSessionLookup, SchedulerJobSessionLookup,
@ -92,6 +94,10 @@ interface UseChatReturn {
skills: SkillSummary[] skills: SkillSummary[]
requestSkillList: () => Command requestSkillList: () => Command
// Todo 状态
todos: TodoItemSummary[]
requestTodoList: () => Command
// 定时任务状态 // 定时任务状态
schedulerJobs: SchedulerJobSummary[] schedulerJobs: SchedulerJobSummary[]
sidebarTab: 'topics' | 'scheduler' sidebarTab: 'topics' | 'scheduler'
@ -140,6 +146,7 @@ export function useChat(): UseChatReturn {
const [subAgentView, setSubAgentView] = useState<SubAgentView | null>(null) const [subAgentView, setSubAgentView] = useState<SubAgentView | null>(null)
const [memories, setMemories] = useState<MemorySummary[]>([]) const [memories, setMemories] = useState<MemorySummary[]>([])
const [skills, setSkills] = useState<SkillSummary[]>([]) const [skills, setSkills] = useState<SkillSummary[]>([])
const [todos, setTodos] = useState<TodoItemSummary[]>([])
const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([]) const [schedulerJobs, setSchedulerJobs] = useState<SchedulerJobSummary[]>([])
const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics') const [sidebarTab, setSidebarTab] = useState<'topics' | 'scheduler'>('topics')
const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null) const [schedulerView, setSchedulerView] = useState<SchedulerJobView | null>(null)
@ -181,6 +188,15 @@ export function useChat(): UseChatReturn {
return undefined return undefined
} }
// Extract topic_id from a message if present
const getTopicId = (message: WsOutbound): string | undefined => {
if (message.type === 'tool_call' || message.type === 'tool_result'
|| message.type === 'tool_pending' || message.type === 'assistant_response') {
return (message as ToolCall | ToolResult | ToolPending | AssistantResponse).topic_id
}
return undefined
}
// Convert a server message to ChatMessage (extracted from handleServerMessage logic) // Convert a server message to ChatMessage (extracted from handleServerMessage logic)
const serverMessageToChatMessage = (message: WsOutbound): ChatMessage | null => { const serverMessageToChatMessage = (message: WsOutbound): ChatMessage | null => {
switch (message.type) { switch (message.type) {
@ -311,12 +327,20 @@ export function useChat(): UseChatReturn {
appendToSubAgentViewMessage(message) appendToSubAgentViewMessage(message)
return return
} }
// 丢弃其他子智能体的消息,避免 fall through 到主消息处理
if (msgSubagentTaskId) {
return
}
} }
// In main view, skip sub-agent messages (they belong to sub-agent view). // In main view, skip sub-agent messages (they belong to sub-agent view).
// But use the task_id to associate with the running task tool card. // But use the task_id to associate with the running task tool card.
const msgSubagentTaskId = getSubagentTaskId(message) const msgSubagentTaskId = getSubagentTaskId(message)
if (msgSubagentTaskId) { if (msgSubagentTaskId) {
// 只 backfill 当前话题的 task tool_call避免跨话题串扰
const msgTopicId = getTopicId(message)
if (msgTopicId && msgTopicId !== selectedTopicRef.current) return
setMessages((prev) => { setMessages((prev) => {
for (let i = prev.length - 1; i >= 0; i--) { for (let i = prev.length - 1; i >= 0; i--) {
if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].subagentTaskId) { if (prev[i].type === 'tool_call' && prev[i].toolName === 'task' && !prev[i].subagentTaskId) {
@ -340,6 +364,9 @@ export function useChat(): UseChatReturn {
case 'task_started': { case 'task_started': {
const msg = message as TaskStarted const msg = message as TaskStarted
// 只 backfill 当前话题的 task tool_call避免跨话题串扰
if (msg.topic_id && msg.topic_id !== selectedTopicRef.current) break
// 立即更新对应的 task tool_call让用户可以点击查看实时进度 // 立即更新对应的 task tool_call让用户可以点击查看实时进度
setMessages((prev) => { setMessages((prev) => {
for (let i = prev.length - 1; i >= 0; i--) { for (let i = prev.length - 1; i >= 0; i--) {
@ -552,6 +579,11 @@ export function useChat(): UseChatReturn {
setSkills(msg.skills) setSkills(msg.skills)
break break
} }
case 'todo_list': {
const msg = message as TodoList
setTodos(msg.todos)
break
}
case 'channel_list': { case 'channel_list': {
const msg = message as ChannelList const msg = message as ChannelList
@ -602,6 +634,7 @@ export function useChat(): UseChatReturn {
const selectTopic = useCallback((topicId: string) => { const selectTopic = useCallback((topicId: string) => {
setSelectedTopic(topicId) setSelectedTopic(topicId)
setMessages([]) setMessages([])
setSubAgentView(null)
}, []) }, [])
const createTopic = useCallback((title?: string): Command => { const createTopic = useCallback((title?: string): Command => {
@ -647,6 +680,7 @@ export function useChat(): UseChatReturn {
setTopics([]) setTopics([])
setSelectedTopic(null) setSelectedTopic(null)
setMessages([]) setMessages([])
setSubAgentView(null)
setIsLoading(true) setIsLoading(true)
}, [selectedChannel]) }, [selectedChannel])
@ -656,6 +690,7 @@ export function useChat(): UseChatReturn {
setTopics([]) setTopics([])
setSelectedTopic(null) setSelectedTopic(null)
setMessages([]) setMessages([])
setSubAgentView(null)
setIsLoading(true) setIsLoading(true)
}, [selectedSessionId]) }, [selectedSessionId])
@ -727,6 +762,10 @@ export function useChat(): UseChatReturn {
return { type: 'list_skills' } return { type: 'list_skills' }
}, []) }, [])
const requestTodoList = useCallback((): Command => {
return { type: 'list_todos' }
}, [])
// 定时任务方法 // 定时任务方法
const requestSchedulerJobList = useCallback((): Command => { const requestSchedulerJobList = useCallback((): Command => {
return { type: 'list_scheduler_jobs' } return { type: 'list_scheduler_jobs' }
@ -815,6 +854,8 @@ export function useChat(): UseChatReturn {
deleteMemory, deleteMemory,
skills, skills,
requestSkillList, requestSkillList,
todos,
requestTodoList,
schedulerJobs, schedulerJobs,
sidebarTab, sidebarTab,
setSidebarTab, setSidebarTab,

View File

@ -100,6 +100,7 @@ export interface TaskStarted {
task_id: string task_id: string
description: string description: string
subagent_type: string subagent_type: string
topic_id?: string
} }
export interface SessionEstablished { export interface SessionEstablished {
@ -200,6 +201,21 @@ export interface SkillList {
skills: SkillSummary[] skills: SkillSummary[]
} }
export interface TodoItemSummary {
id: string
content: string
status: string
priority: string
created_at: number
updated_at: number
}
export interface TodoList {
type: 'todo_list'
todos: TodoItemSummary[]
scope_key: string
}
export interface SchedulerJobSessionLookup { export interface SchedulerJobSessionLookup {
channel: string channel: string
chat_id: string chat_id: string
@ -258,6 +274,7 @@ export type WsOutbound =
| SchedulerJobList | SchedulerJobList
| MemoryList | MemoryList
| SkillList | SkillList
| TodoList
| ExecutionCancelled | ExecutionCancelled
| Pong | Pong
@ -371,6 +388,10 @@ export interface ListSkillsCommand {
type: 'list_skills' type: 'list_skills'
} }
export interface ListTodosCommand {
type: 'list_todos'
}
export type Command = export type Command =
| CreateSessionCommand | CreateSessionCommand
| ListSessionsCommand | ListSessionsCommand
@ -393,6 +414,7 @@ export type Command =
| UpdateMemoryCommand | UpdateMemoryCommand
| DeleteMemoryCommand | DeleteMemoryCommand
| ListSkillsCommand | ListSkillsCommand
| ListTodosCommand
// ============================================================================ // ============================================================================
// UI Types // UI Types