From 3591822145373770ee489e48a76946141c4b851b Mon Sep 17 00:00:00 2001 From: ooodc <549496103@qq.com> Date: Sat, 16 May 2026 19:48:39 +0800 Subject: [PATCH] feat: add /help command to display all supported commands - Implemented HelpCommandHandler to handle the /help command. - Added CommandMetadata struct to store command metadata. - Registered new command handlers for GetCurrentSession, ListSessions, LoadSession, and SwitchSession. - Updated existing command handlers to provide metadata for help command. - Removed deprecated SessionQueryCommandHandler. - Added new command handlers for listing sessions and loading sessions. --- src/command/adapters/channel.rs | 5 + src/command/adapters/cli.rs | 5 + src/command/handler.rs | 24 ++ src/command/handlers/get_current.rs | 96 +++++++ src/command/handlers/help.rs | 61 ++++ src/command/handlers/list_sessions.rs | 136 +++++++++ src/command/handlers/load_session.rs | 64 +++++ src/command/handlers/mod.rs | 6 +- src/command/handlers/save_session.rs | 10 +- src/command/handlers/save_topic.rs | 10 +- src/command/handlers/session.rs | 10 +- src/command/handlers/session_query.rs | 368 ------------------------- src/command/handlers/switch_session.rs | 130 +++++++++ src/command/mod.rs | 3 + src/gateway/processor.rs | 28 +- src/gateway/ws.rs | 27 +- 16 files changed, 598 insertions(+), 385 deletions(-) create mode 100644 src/command/handlers/get_current.rs create mode 100644 src/command/handlers/help.rs create mode 100644 src/command/handlers/list_sessions.rs create mode 100644 src/command/handlers/load_session.rs delete mode 100644 src/command/handlers/session_query.rs create mode 100644 src/command/handlers/switch_session.rs diff --git a/src/command/adapters/channel.rs b/src/command/adapters/channel.rs index 1a4cd04..27724e7 100644 --- a/src/command/adapters/channel.rs +++ b/src/command/adapters/channel.rs @@ -105,6 +105,11 @@ impl InputAdapter for ChannelInputAdapter { return Ok(Some(Command::GetCurrentSession)); } + // 解析 /help 命令 - 显示所有支持的命令 + if trimmed == "/help" { + return Ok(Some(Command::Help)); + } + // 不是命令,返回 None Ok(None) } diff --git a/src/command/adapters/cli.rs b/src/command/adapters/cli.rs index 4a22f2e..4f88bae 100644 --- a/src/command/adapters/cli.rs +++ b/src/command/adapters/cli.rs @@ -106,6 +106,11 @@ impl InputAdapter for CliInputAdapter { return Ok(Some(Command::GetCurrentSession)); } + // 解析 /help 命令 - 显示所有支持的命令 + if trimmed == "/help" { + return Ok(Some(Command::Help)); + } + // 不是命令,返回 None Ok(None) } diff --git a/src/command/handler.rs b/src/command/handler.rs index 2962f3a..4b26f08 100644 --- a/src/command/handler.rs +++ b/src/command/handler.rs @@ -5,6 +5,15 @@ use crate::command::Command; use crate::agent::AgentError; use crate::gateway::session::SessionManager; use async_trait::async_trait; +use std::sync::Arc; + +/// 命令元数据(用于帮助系统) +#[derive(Debug, Clone)] +pub struct CommandMetadata { + pub name: &'static str, + pub description: &'static str, + pub usage: &'static str, +} /// 命令处理器 trait /// @@ -15,6 +24,11 @@ pub trait CommandHandler: Send + Sync { /// 是否可以处理此命令 fn can_handle(&self, cmd: &Command) -> bool; + /// 返回命令元数据(用于 /help 命令) + fn metadata(&self) -> Option { + None + } + /// 执行命令 /// /// # Arguments @@ -64,6 +78,7 @@ pub trait InChatCommandHandler: Send + Sync { /// 负责将命令分发到合适的处理器 pub struct CommandRouter { handlers: Vec>, + metadata: Arc>>, } impl CommandRouter { @@ -71,6 +86,7 @@ impl CommandRouter { pub fn new() -> Self { Self { handlers: Vec::new(), + metadata: Arc::new(std::sync::Mutex::new(Vec::new())), } } @@ -79,9 +95,17 @@ impl CommandRouter { /// # Arguments /// * `handler` - 要注册的处理器 pub fn register(&mut self, handler: Box) { + if let Some(meta) = handler.metadata() { + self.metadata.lock().unwrap().push(meta); + } self.handlers.push(handler); } + /// 获取已注册命令的元数据列表(用于 Help 命令) + pub fn metadata_arc(&self) -> Arc>> { + self.metadata.clone() + } + /// 分发命令到合适的处理器 /// /// # Arguments diff --git a/src/command/handlers/get_current.rs b/src/command/handlers/get_current.rs new file mode 100644 index 0000000..5c5be55 --- /dev/null +++ b/src/command/handlers/get_current.rs @@ -0,0 +1,96 @@ +use crate::command::context::CommandContext; +use crate::command::handler::{CommandHandler, CommandMetadata}; +use crate::command::response::{CommandError, CommandResponse, MessageKind}; +use crate::command::Command; +use crate::storage::SessionStore; +use async_trait::async_trait; +use std::sync::Arc; + +/// 获取当前话题命令处理器 +pub struct GetCurrentSessionCommandHandler { + store: Arc, +} + +impl GetCurrentSessionCommandHandler { + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[async_trait] +impl CommandHandler for GetCurrentSessionCommandHandler { + fn can_handle(&self, cmd: &Command) -> bool { + matches!(cmd, Command::GetCurrentSession) + } + + fn metadata(&self) -> Option { + Some(CommandMetadata { + name: "current", + description: "获取当前话题信息", + usage: "/current", + }) + } + + async fn handle( + &self, + cmd: Command, + ctx: CommandContext, + ) -> Result { + match cmd { + Command::GetCurrentSession => handle_get_current_session(self, ctx).await, + _ => unreachable!(), + } + } +} + +async fn handle_get_current_session( + handler: &GetCurrentSessionCommandHandler, + ctx: CommandContext, +) -> Result { + let topic_id = ctx.topic_id.as_deref() + .ok_or_else(|| CommandError::new("NO_CURRENT_TOPIC", "No current topic"))?; + + let topic = handler + .store + .get_topic(topic_id) + .map_err(|e| CommandError::new("GET_TOPIC_ERROR", e.to_string()))? + .ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)))?; + + let last_active = format_time_ago(topic.last_active_at); + let created_at = format_time_ago(topic.created_at); + + let message = format!( + "Current Topic:\n\n Topic ID: {}\n Title: {}\n Messages: {}\n Created: {}\n Last Active: {}", + topic.id, + topic.title, + topic.message_count, + created_at, + last_active + ); + + Ok(CommandResponse::success(ctx.request_id) + .with_message(MessageKind::Notification, &message) + .with_metadata("topic_id", &topic.id) + .with_metadata("title", &topic.title) + .with_metadata("message_count", &topic.message_count.to_string())) +} + +fn format_time_ago(timestamp_ms: i64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + + let diff_ms = now - timestamp_ms; + let diff_secs = diff_ms / 1000; + + if diff_secs < 60 { + "just now".to_string() + } else if diff_secs < 3600 { + format!("{} mins ago", diff_secs / 60) + } else if diff_secs < 86400 { + format!("{} hours ago", diff_secs / 3600) + } else { + format!("{} days ago", diff_secs / 86400) + } +} \ No newline at end of file diff --git a/src/command/handlers/help.rs b/src/command/handlers/help.rs new file mode 100644 index 0000000..e929881 --- /dev/null +++ b/src/command/handlers/help.rs @@ -0,0 +1,61 @@ +use crate::command::context::CommandContext; +use crate::command::handler::{CommandHandler, CommandMetadata}; +use crate::command::response::{CommandError, CommandResponse, MessageKind}; +use crate::command::Command; +use async_trait::async_trait; +use std::sync::{Arc, Mutex}; + +/// Help 命令处理器 +/// +/// 显示所有支持的命令列表 +pub struct HelpCommandHandler { + metadata: Arc>>, +} + +impl HelpCommandHandler { + /// 创建新的 Help 命令处理器 + /// + /// # Arguments + /// * `metadata` - CommandRouter 的元数据列表引用 + pub fn new(metadata: Arc>>) -> Self { + Self { metadata } + } +} + +#[async_trait] +impl CommandHandler for HelpCommandHandler { + fn can_handle(&self, cmd: &Command) -> bool { + matches!(cmd, Command::Help) + } + + fn metadata(&self) -> Option { + Some(CommandMetadata { + name: "help", + description: "显示所有支持的命令", + usage: "/help", + }) + } + + async fn handle( + &self, + _cmd: Command, + ctx: CommandContext, + ) -> Result { + let metadata = self.metadata.lock().unwrap(); + let help_text = format_help(&metadata); + + Ok(CommandResponse::success(ctx.request_id) + .with_message(MessageKind::Text, &help_text)) + } +} + +/// 格式化帮助文本 +fn format_help(commands: &[CommandMetadata]) -> String { + let mut output = String::from("# 支持的命令\n\n"); + + for cmd in commands { + output.push_str(&format!("**{}** - {}\n", cmd.usage, cmd.description)); + } + + output +} \ No newline at end of file diff --git a/src/command/handlers/list_sessions.rs b/src/command/handlers/list_sessions.rs new file mode 100644 index 0000000..ca3b9f1 --- /dev/null +++ b/src/command/handlers/list_sessions.rs @@ -0,0 +1,136 @@ +use crate::command::context::CommandContext; +use crate::command::handler::{CommandHandler, CommandMetadata}; +use crate::command::response::{CommandError, CommandResponse, MessageKind}; +use crate::command::Command; +use crate::storage::SessionStore; +use async_trait::async_trait; +use std::sync::Arc; + +/// 列出话题命令处理器 +pub struct ListSessionsCommandHandler { + store: Arc, +} + +impl ListSessionsCommandHandler { + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[async_trait] +impl CommandHandler for ListSessionsCommandHandler { + fn can_handle(&self, cmd: &Command) -> bool { + matches!(cmd, Command::ListSessions { .. }) + } + + fn metadata(&self) -> Option { + Some(CommandMetadata { + name: "list", + description: "列出所有话题", + usage: "/list [all]", + }) + } + + async fn handle( + &self, + cmd: Command, + ctx: CommandContext, + ) -> Result { + match cmd { + Command::ListSessions { include_archived } => { + handle_list_sessions(self, include_archived, ctx).await + } + _ => unreachable!(), + } + } +} + +async fn handle_list_sessions( + handler: &ListSessionsCommandHandler, + _include_archived: bool, + ctx: CommandContext, +) -> Result { + let session_id = ctx.session_id.as_deref() + .ok_or_else(|| CommandError::new("NO_SESSION", "No active session"))?; + + let topics = handler + .store + .list_topics(session_id) + .map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?; + + let current_topic_id = ctx.topic_id.as_deref().unwrap_or(""); + + let message = if topics.is_empty() { + "No topics found. Use /new to create a topic.".to_string() + } else { + let mut lines = vec![format!("Found {} topic(s):", topics.len())]; + lines.push(String::new()); + lines.push("┌────┬─────────────────┬──────────────────────┬──────────┬─────────────────┐".to_string()); + lines.push("│ No │ Topic ID │ Title │ Messages │ Last Active │".to_string()); + lines.push("├────┼─────────────────┼──────────────────────┼──────────┼─────────────────┤".to_string()); + + for (idx, topic) in topics.iter().enumerate() { + let row_num = idx + 1; + let is_current = topic.id == current_topic_id; + let num_marker = if is_current { " * ".to_string() } else { format!(" {:<2}", row_num) }; + + let topic_id_display = if topic.id.len() > 15 { + format!("{}...", &topic.id[..12]) + } else { + topic.id.clone() + }; + let title_display = if topic.title.len() > 20 { + format!("{}...", &topic.title[..17]) + } else { + topic.title.clone() + }; + + let last_active = format_time_ago(topic.last_active_at); + + lines.push(format!( + "│{}│ {:<15} │ {:<20} │ {:<8} │ {:<15} │", + num_marker, + topic_id_display, + title_display, + topic.message_count, + last_active + )); + } + + lines.push("└────┴─────────────────┴──────────────────────┴──────────┴─────────────────┘".to_string()); + lines.push(String::new()); + lines.push("* = current topic".to_string()); + lines.push("Use /use <number> or /use <topic_id> to switch".to_string()); + + lines.join("\n") + }; + + let topics_json = serde_json::to_string(&topics) + .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; + + Ok(CommandResponse::success(ctx.request_id) + .with_message(MessageKind::Notification, &message) + .with_metadata("topics", &topics_json) + .with_metadata("count", &topics.len().to_string()) + .with_metadata("current_topic_id", current_topic_id)) +} + +fn format_time_ago(timestamp_ms: i64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + + let diff_ms = now - timestamp_ms; + let diff_secs = diff_ms / 1000; + + if diff_secs < 60 { + "just now".to_string() + } else if diff_secs < 3600 { + format!("{} mins ago", diff_secs / 60) + } else if diff_secs < 86400 { + format!("{} hours ago", diff_secs / 3600) + } else { + format!("{} days ago", diff_secs / 86400) + } +} \ No newline at end of file diff --git a/src/command/handlers/load_session.rs b/src/command/handlers/load_session.rs new file mode 100644 index 0000000..19dca42 --- /dev/null +++ b/src/command/handlers/load_session.rs @@ -0,0 +1,64 @@ +use crate::command::context::CommandContext; +use crate::command::handler::{CommandHandler, CommandMetadata}; +use crate::command::response::{CommandError, CommandResponse, MessageKind}; +use crate::command::Command; +use crate::storage::SessionStore; +use async_trait::async_trait; +use std::sync::Arc; + +/// 加载话题命令处理器 +pub struct LoadSessionCommandHandler { + store: Arc<SessionStore>, +} + +impl LoadSessionCommandHandler { + pub fn new(store: Arc<SessionStore>) -> Self { + Self { store } + } +} + +#[async_trait] +impl CommandHandler for LoadSessionCommandHandler { + fn can_handle(&self, cmd: &Command) -> bool { + matches!(cmd, Command::LoadSession { .. }) + } + + fn metadata(&self) -> Option<CommandMetadata> { + Some(CommandMetadata { + name: "load", + description: "加载指定话题", + usage: "/load <session_id>", + }) + } + + async fn handle( + &self, + cmd: Command, + ctx: CommandContext, + ) -> Result<CommandResponse, CommandError> { + match cmd { + Command::LoadSession { session_id } => { + handle_load_session(self, session_id, ctx).await + } + _ => unreachable!(), + } + } +} + +async fn handle_load_session( + handler: &LoadSessionCommandHandler, + topic_id: String, + ctx: CommandContext, +) -> Result<CommandResponse, CommandError> { + let topic = handler + .store + .get_topic(&topic_id) + .map_err(|e| CommandError::new("LOAD_TOPIC_ERROR", e.to_string()))? + .ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)))?; + + Ok(CommandResponse::success(ctx.request_id) + .with_message(MessageKind::Notification, &topic.title) + .with_metadata("topic_id", &topic.id) + .with_metadata("title", &topic.title) + .with_metadata("message_count", &topic.message_count.to_string())) +} \ No newline at end of file diff --git a/src/command/handlers/mod.rs b/src/command/handlers/mod.rs index 93a0d6e..f9e4763 100644 --- a/src/command/handlers/mod.rs +++ b/src/command/handlers/mod.rs @@ -1,7 +1,11 @@ +pub mod get_current; +pub mod help; +pub mod list_sessions; +pub mod load_session; pub mod save_session; pub mod save_topic; pub mod session; -pub mod session_query; +pub mod switch_session; // 导出公共函数供其他模块复用 pub use save_session::{ diff --git a/src/command/handlers/save_session.rs b/src/command/handlers/save_session.rs index 20397dd..23e2155 100644 --- a/src/command/handlers/save_session.rs +++ b/src/command/handlers/save_session.rs @@ -1,7 +1,7 @@ use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider}; use crate::bus::InboundMessage; use crate::command::context::CommandContext; -use crate::command::handler::{CommandHandler, InChatCommandHandler}; +use crate::command::handler::{CommandHandler, CommandMetadata, InChatCommandHandler}; use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::Command; use crate::storage::{SessionRecord, SessionStore}; @@ -107,6 +107,14 @@ impl CommandHandler for SaveSessionCommandHandler { matches!(cmd, Command::SaveSession { .. }) } + fn metadata(&self) -> Option<CommandMetadata> { + Some(CommandMetadata { + name: "save-session", + description: "保存当前会话到 Markdown 文件", + usage: "/save-session [all] [filepath]", + }) + } + async fn handle( &self, cmd: Command, diff --git a/src/command/handlers/save_topic.rs b/src/command/handlers/save_topic.rs index 2d0fa7a..0301cee 100644 --- a/src/command/handlers/save_topic.rs +++ b/src/command/handlers/save_topic.rs @@ -1,6 +1,6 @@ use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider}; use crate::command::context::CommandContext; -use crate::command::handler::CommandHandler; +use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handlers::{ escape_yaml_string, format_timestamp, generate_messages_markdown, generate_system_prompt_markdown, @@ -176,6 +176,14 @@ impl CommandHandler for SaveTopicCommandHandler { matches!(cmd, Command::SaveTopic { .. }) } + fn metadata(&self) -> Option<CommandMetadata> { + Some(CommandMetadata { + name: "save", + description: "保存当前话题到 Markdown 文件", + usage: "/save [filepath]", + }) + } + async fn handle( &self, cmd: Command, diff --git a/src/command/handlers/session.rs b/src/command/handlers/session.rs index 0bce329..4f0ef24 100644 --- a/src/command/handlers/session.rs +++ b/src/command/handlers/session.rs @@ -1,5 +1,5 @@ use crate::command::context::CommandContext; -use crate::command::handler::CommandHandler; +use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::command::Command; use crate::gateway::session::SessionManager; @@ -40,6 +40,14 @@ impl CommandHandler for SessionCommandHandler { matches!(cmd, Command::CreateSession { .. }) } + fn metadata(&self) -> Option<CommandMetadata> { + Some(CommandMetadata { + name: "new", + description: "创建新话题", + usage: "/new [title]", + }) + } + async fn handle( &self, cmd: Command, diff --git a/src/command/handlers/session_query.rs b/src/command/handlers/session_query.rs deleted file mode 100644 index 94ed6fe..0000000 --- a/src/command/handlers/session_query.rs +++ /dev/null @@ -1,368 +0,0 @@ -use crate::command::context::CommandContext; -use crate::command::handler::CommandHandler; -use crate::command::response::{CommandError, CommandResponse, MessageKind}; -use crate::command::Command; -use crate::gateway::session::SessionManager; -use crate::storage::SessionStore; -use async_trait::async_trait; -use std::sync::Arc; - -/// 会话查询命令处理器 -/// -/// 处理 ListSessions、LoadSession 和 SwitchSession 命令(现在操作 Topic) -pub struct SessionQueryCommandHandler { - store: Arc<SessionStore>, - session_manager: Option<SessionManager>, -} - -impl SessionQueryCommandHandler { - /// 创建新的会话查询命令处理器 - pub fn new(store: Arc<SessionStore>) -> Self { - Self { - store, - session_manager: None, - } - } - - /// 设置 SessionManager(用于 SwitchSession 命令) - pub fn with_session_manager(mut self, session_manager: SessionManager) -> Self { - self.session_manager = Some(session_manager); - self - } -} - -#[async_trait] -impl CommandHandler for SessionQueryCommandHandler { - fn can_handle(&self, cmd: &Command) -> bool { - matches!(cmd, Command::ListSessions { .. } | Command::LoadSession { .. } | Command::SwitchSession { .. } | Command::GetCurrentSession) - } - - async fn handle( - &self, - cmd: Command, - ctx: CommandContext, - ) -> Result<CommandResponse, CommandError> { - match cmd { - Command::ListSessions { include_archived } => { - handle_list_sessions(self, include_archived, ctx).await - } - Command::LoadSession { session_id } => { - handle_load_session(self, session_id, ctx).await - } - Command::SwitchSession { session_id } => { - handle_switch_session(self, session_id, ctx).await - } - Command::GetCurrentSession => { - handle_get_current_session(self, ctx).await - } - _ => unreachable!(), - } - } -} - -/// 处理列出话题命令 -async fn handle_list_sessions( - handler: &SessionQueryCommandHandler, - _include_archived: bool, - ctx: CommandContext, -) -> Result<CommandResponse, CommandError> { - // 获取当前 session_id - let session_id = ctx.session_id.as_deref() - .ok_or_else(|| CommandError::new("NO_SESSION", "No active session"))?; - - // 查询该 session 的所有 topic - let topics = handler - .store - .list_topics(session_id) - .map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?; - - // 获取当前 topic ID - let current_topic_id = ctx.topic_id.as_deref().unwrap_or(""); - - // 构建表格格式的话题列表消息 - let message = if topics.is_empty() { - "No topics found. Use /new <title> to create a topic.".to_string() - } else { - let mut lines = vec![format!("Found {} topic(s):", topics.len())]; - lines.push(String::new()); - - // 表格头部 - lines.push("┌────┬─────────────────┬──────────────────────┬──────────┬─────────────────┐".to_string()); - lines.push("│ No │ Topic ID │ Title │ Messages │ Last Active │".to_string()); - lines.push("├────┼─────────────────┼──────────────────────┼──────────┼─────────────────┤".to_string()); - - // 表格内容 - for (idx, topic) in topics.iter().enumerate() { - let row_num = idx + 1; - let is_current = topic.id == current_topic_id; - let num_marker = if is_current { " * ".to_string() } else { format!(" {:<2}", row_num) }; - - // 截断过长的字段 - let topic_id_display = if topic.id.len() > 15 { - format!("{}...", &topic.id[..12]) - } else { - topic.id.clone() - }; - let title_display = if topic.title.len() > 20 { - format!("{}...", &topic.title[..17]) - } else { - topic.title.clone() - }; - - let last_active = format_time_ago(topic.last_active_at); - - lines.push(format!( - "│{}│ {:<15} │ {:<20} │ {:<8} │ {:<15} │", - num_marker, - topic_id_display, - title_display, - topic.message_count, - last_active - )); - } - - // 表格底部 - lines.push("└────┴─────────────────┴──────────────────────┴──────────┴─────────────────┘".to_string()); - lines.push(String::new()); - lines.push("* = current topic".to_string()); - lines.push("Use /use <number> or /use <topic_id> to switch".to_string()); - - lines.join("\n") - }; - - let topics_json = serde_json::to_string(&topics) - .map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; - - Ok(CommandResponse::success(ctx.request_id) - .with_message(MessageKind::Notification, &message) - .with_metadata("topics", &topics_json) - .with_metadata("count", &topics.len().to_string()) - .with_metadata("current_topic_id", current_topic_id)) -} - -/// 处理加载话题命令 -async fn handle_load_session( - handler: &SessionQueryCommandHandler, - topic_id: String, - ctx: CommandContext, -) -> Result<CommandResponse, CommandError> { - let topic = handler - .store - .get_topic(&topic_id) - .map_err(|e| CommandError::new("LOAD_TOPIC_ERROR", e.to_string()))? - .ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)))?; - - Ok(CommandResponse::success(ctx.request_id) - .with_message(MessageKind::Notification, &topic.title) - .with_metadata("topic_id", &topic.id) - .with_metadata("title", &topic.title) - .with_metadata("message_count", &topic.message_count.to_string())) -} - -/// 格式化时间为相对时间(如 "2 mins ago") -fn format_time_ago(timestamp_ms: i64) -> String { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_millis() as i64; - - let diff_ms = now - timestamp_ms; - let diff_secs = diff_ms / 1000; - - if diff_secs < 60 { - "just now".to_string() - } else if diff_secs < 3600 { - format!("{} mins ago", diff_secs / 60) - } else if diff_secs < 86400 { - format!("{} hours ago", diff_secs / 3600) - } else { - format!("{} days ago", diff_secs / 86400) - } -} - -/// 处理获取当前话题命令 -async fn handle_get_current_session( - handler: &SessionQueryCommandHandler, - ctx: CommandContext, -) -> Result<CommandResponse, CommandError> { - let topic_id = ctx.topic_id.as_deref() - .ok_or_else(|| CommandError::new("NO_CURRENT_TOPIC", "No current topic"))?; - - let topic = handler - .store - .get_topic(topic_id) - .map_err(|e| CommandError::new("GET_TOPIC_ERROR", e.to_string()))? - .ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", topic_id)))?; - - let last_active = format_time_ago(topic.last_active_at); - let created_at = format_time_ago(topic.created_at); - - let message = format!( - "Current Topic:\n\n Topic ID: {}\n Title: {}\n Messages: {}\n Created: {}\n Last Active: {}", - topic.id, - topic.title, - topic.message_count, - created_at, - last_active - ); - - Ok(CommandResponse::success(ctx.request_id) - .with_message(MessageKind::Notification, &message) - .with_metadata("topic_id", &topic.id) - .with_metadata("title", &topic.title) - .with_metadata("message_count", &topic.message_count.to_string())) -} - -/// 处理切换话题命令 -async fn handle_switch_session( - handler: &SessionQueryCommandHandler, - topic_id: String, - ctx: CommandContext, -) -> Result<CommandResponse, CommandError> { - // 获取当前 session_id 和 chat_id - let session_id = ctx.session_id.as_deref() - .ok_or_else(|| CommandError::new("NO_SESSION", "No active session"))?; - let chat_id = ctx.chat_id.as_deref() - .ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?; - - // 尝试解析为序号 - let target_topic_id = if let Ok(index) = topic_id.parse::<usize>() { - let topics = handler - .store - .list_topics(session_id) - .map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?; - - let index = index.saturating_sub(1); - if index >= topics.len() { - return Err(CommandError::new( - "INVALID_TOPIC_INDEX", - format!("Topic index {} is out of range (1-{})", index + 1, topics.len()) - )); - } - topics[index].id.clone() - } else { - topic_id - }; - - // 验证目标话题存在 - let topic = handler - .store - .get_topic(&target_topic_id) - .map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))? - .ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", target_topic_id)))?; - - // 如果有 SessionManager,实际切换话题历史 - if let Some(ref session_manager) = handler.session_manager { - if let Some(session) = session_manager.get(&ctx.channel_name).await { - let mut session_guard = session.lock().await; - session_guard.switch_topic(chat_id, &target_topic_id) - .map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?; - } - } - - // 返回切换成功响应 - let message = format!( - "✓ Switched to topic: {} ({} messages)", - topic.title, topic.message_count - ); - - Ok(CommandResponse::success(ctx.request_id) - .with_message(MessageKind::Notification, &message) - .with_metadata("topic_id", &topic.id) - .with_metadata("title", &topic.title) - .with_metadata("message_count", &topic.message_count.to_string())) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::storage::SessionStore; - use std::sync::Arc; - - fn create_test_handler() -> SessionQueryCommandHandler { - let store = Arc::new(SessionStore::in_memory().unwrap()); - SessionQueryCommandHandler::new(store) - } - - #[tokio::test] - async fn test_list_sessions_empty() { - let handler = create_test_handler(); - // 需要先创建一个 session 和 topic - let store = handler.store.clone(); - let session = store.create_session("cli", Some("test")).unwrap(); - - let ctx = CommandContext::new("test", "cli") - .with_session_id(&session.id); - let cmd = Command::ListSessions { - include_archived: false, - }; - - let result = handler.handle(cmd, ctx).await; - - assert!(result.is_ok()); - let resp = result.unwrap(); - assert!(resp.success); - assert!(resp.messages[0].content.contains("No topics")); - } - - #[tokio::test] - async fn test_list_sessions_with_items() { - let handler = create_test_handler(); - let store = handler.store.clone(); - let session = store.create_session("cli", Some("test")).unwrap(); - - // 创建一个 topic - store.create_topic(&session.id, "Test Topic", None).unwrap(); - - let ctx = CommandContext::new("test", "cli") - .with_session_id(&session.id); - let cmd = Command::ListSessions { - include_archived: false, - }; - - let result = handler.handle(cmd, ctx).await; - - assert!(result.is_ok()); - let resp = result.unwrap(); - assert!(resp.success); - assert!(resp.metadata.contains_key("topics")); - } - - #[tokio::test] - async fn test_load_session_not_found() { - let handler = create_test_handler(); - let store = handler.store.clone(); - let session = store.create_session("cli", Some("test")).unwrap(); - - let ctx = CommandContext::new("test", "test") - .with_session_id(&session.id); - let cmd = Command::LoadSession { - session_id: "nonexistent".to_string(), - }; - - let result = handler.handle(cmd, ctx).await; - - assert!(result.is_err()); - } - - #[tokio::test] - async fn test_load_session_success() { - let handler = create_test_handler(); - let store = handler.store.clone(); - let session = store.create_session("cli", Some("test")).unwrap(); - let topic = store.create_topic(&session.id, "Test Topic", None).unwrap(); - - let ctx = CommandContext::new("test", "test") - .with_session_id(&session.id); - let cmd = Command::LoadSession { - session_id: topic.id.clone(), - }; - - let result = handler.handle(cmd, ctx).await; - - assert!(result.is_ok()); - let resp = result.unwrap(); - assert!(resp.success); - assert_eq!(resp.metadata.get("topic_id").unwrap(), &topic.id); - } -} diff --git a/src/command/handlers/switch_session.rs b/src/command/handlers/switch_session.rs new file mode 100644 index 0000000..2f34d39 --- /dev/null +++ b/src/command/handlers/switch_session.rs @@ -0,0 +1,130 @@ +use crate::command::context::CommandContext; +use crate::command::handler::{CommandHandler, CommandMetadata}; +use crate::command::response::{CommandError, CommandResponse, MessageKind}; +use crate::command::Command; +use crate::gateway::session::SessionManager; +use crate::storage::SessionStore; +use async_trait::async_trait; +use std::sync::Arc; + +/// 切换话题命令处理器 +pub struct SwitchSessionCommandHandler { + store: Arc<SessionStore>, + session_manager: Option<SessionManager>, +} + +impl SwitchSessionCommandHandler { + pub fn new(store: Arc<SessionStore>) -> Self { + Self { store, session_manager: None } + } + + pub fn with_session_manager(mut self, session_manager: SessionManager) -> Self { + self.session_manager = Some(session_manager); + self + } +} + +#[async_trait] +impl CommandHandler for SwitchSessionCommandHandler { + fn can_handle(&self, cmd: &Command) -> bool { + matches!(cmd, Command::SwitchSession { .. }) + } + + fn metadata(&self) -> Option<CommandMetadata> { + Some(CommandMetadata { + name: "use", + description: "切换到指定话题", + usage: "/use <session_id>", + }) + } + + async fn handle( + &self, + cmd: Command, + ctx: CommandContext, + ) -> Result<CommandResponse, CommandError> { + match cmd { + Command::SwitchSession { session_id } => { + handle_switch_session(self, session_id, ctx).await + } + _ => unreachable!(), + } + } +} + +async fn handle_switch_session( + handler: &SwitchSessionCommandHandler, + topic_id: String, + ctx: CommandContext, +) -> Result<CommandResponse, CommandError> { + let session_id = ctx.session_id.as_deref() + .ok_or_else(|| CommandError::new("NO_SESSION", "No active session"))?; + let chat_id = ctx.chat_id.as_deref() + .ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat_id in context"))?; + + // 尝试解析为序号 + let target_topic_id = if let Ok(index) = topic_id.parse::<usize>() { + let topics = handler + .store + .list_topics(session_id) + .map_err(|e| CommandError::new("LIST_TOPICS_ERROR", e.to_string()))?; + + let index = index.saturating_sub(1); + if index >= topics.len() { + return Err(CommandError::new( + "INVALID_TOPIC_INDEX", + format!("Topic index {} is out of range (1-{})", index + 1, topics.len()) + )); + } + topics[index].id.clone() + } else { + topic_id + }; + + // 验证目标话题存在 + let topic = handler + .store + .get_topic(&target_topic_id) + .map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))? + .ok_or_else(|| CommandError::new("TOPIC_NOT_FOUND", format!("Topic not found: {}", target_topic_id)))?; + + // 如果有 SessionManager,实际切换话题历史 + if let Some(ref session_manager) = handler.session_manager { + if let Some(session) = session_manager.get(&ctx.channel_name).await { + let mut session_guard = session.lock().await; + session_guard.switch_topic(chat_id, &target_topic_id) + .map_err(|e| CommandError::new("SWITCH_TOPIC_ERROR", e.to_string()))?; + } + } + + let message = format!( + "✓ Switched to topic: {} ({} messages)", + topic.title, topic.message_count + ); + + Ok(CommandResponse::success(ctx.request_id) + .with_message(MessageKind::Notification, &message) + .with_metadata("topic_id", &topic.id) + .with_metadata("title", &topic.title) + .with_metadata("message_count", &topic.message_count.to_string())) +} + +fn format_time_ago(timestamp_ms: i64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as i64; + + let diff_ms = now - timestamp_ms; + let diff_secs = diff_ms / 1000; + + if diff_secs < 60 { + "just now".to_string() + } else if diff_secs < 3600 { + format!("{} mins ago", diff_secs / 60) + } else if diff_secs < 86400 { + format!("{} hours ago", diff_secs / 3600) + } else { + format!("{} days ago", diff_secs / 86400) + } +} \ No newline at end of file diff --git a/src/command/mod.rs b/src/command/mod.rs index e1a2b54..a906cc5 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -28,6 +28,8 @@ pub enum Command { SwitchSession { session_id: String }, /// 获取当前话题信息 GetCurrentSession, + /// 显示所有支持的命令 + Help, } impl Command { @@ -41,6 +43,7 @@ impl Command { Command::LoadSession { .. } => "load_session", Command::SwitchSession { .. } => "switch_session", Command::GetCurrentSession => "get_current_session", + Command::Help => "help", } } } diff --git a/src/gateway/processor.rs b/src/gateway/processor.rs index abc63ae..8ddcd16 100644 --- a/src/gateway/processor.rs +++ b/src/gateway/processor.rs @@ -7,10 +7,14 @@ use crate::bus::{InboundMessage, MessageBus, OutboundMessage}; use crate::command::adapter::InputAdapter; use crate::command::adapters::channel::ChannelInputAdapter; use crate::command::handler::CommandRouter; +use crate::command::handlers::get_current::GetCurrentSessionCommandHandler; +use crate::command::handlers::help::HelpCommandHandler; +use crate::command::handlers::list_sessions::ListSessionsCommandHandler; +use crate::command::handlers::load_session::LoadSessionCommandHandler; use crate::command::handlers::save_session::SaveSessionCommandHandler; use crate::command::handlers::save_topic::SaveTopicCommandHandler; use crate::command::handlers::session::SessionCommandHandler; -use crate::command::handlers::session_query::SessionQueryCommandHandler; +use crate::command::handlers::switch_session::SwitchSessionCommandHandler; use crate::config::LLMProviderConfig; use crate::gateway::agent_prompt_provider::AgentPromptProvider; use crate::skills::SkillPromptProvider; @@ -43,13 +47,21 @@ impl InboundProcessor { .with_session_manager(session_manager.clone()); command_router.register(Box::new(session_handler)); - // 注册 session_query 处理器 - let session_query_handler = SessionQueryCommandHandler::new(store) + // 注册 list_sessions 处理器 + command_router.register(Box::new(ListSessionsCommandHandler::new(store.clone()))); + + // 注册 switch_session 处理器 + let switch_handler = SwitchSessionCommandHandler::new(store.clone()) .with_session_manager(session_manager.clone()); - command_router.register(Box::new(session_query_handler)); + command_router.register(Box::new(switch_handler)); + + // 注册 get_current 处理器 + command_router.register(Box::new(GetCurrentSessionCommandHandler::new(store.clone()))); + + // 注册 load_session 处理器 + command_router.register(Box::new(LoadSessionCommandHandler::new(store.clone()))); // 注册 save_session 处理器 - let store = session_manager.store(); let skills = session_manager.skills(); let prompt_repository = session_manager.store().clone(); let system_prompt_provider: Arc<dyn crate::agent::SystemPromptProvider> = Arc::new(CompositeSystemPromptProvider::new(vec![ @@ -67,10 +79,14 @@ impl InboundProcessor { // 注册 save_topic 处理器 command_router.register(Box::new(SaveTopicCommandHandler::new( - store, + store.clone(), system_prompt_provider, ))); + // 注册 help 处理器(最后注册,获取所有已注册命令的元数据) + let metadata = command_router.metadata_arc(); + command_router.register(Box::new(HelpCommandHandler::new(metadata))); + Self { bus, session_manager, diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index 3cb70be..50ffc44 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -5,9 +5,13 @@ use crate::command::adapter::{InputAdapter, OutputAdapter}; use crate::command::adapters::websocket::{WebSocketInputAdapter, WebSocketOutputAdapter}; use crate::command::context::CommandContext; use crate::command::handler::CommandRouter; +use crate::command::handlers::get_current::GetCurrentSessionCommandHandler; +use crate::command::handlers::help::HelpCommandHandler; +use crate::command::handlers::list_sessions::ListSessionsCommandHandler; +use crate::command::handlers::load_session::LoadSessionCommandHandler; use crate::command::handlers::save_session::SaveSessionCommandHandler; use crate::command::handlers::session::SessionCommandHandler; -use crate::command::handlers::session_query::SessionQueryCommandHandler; +use crate::command::handlers::switch_session::SwitchSessionCommandHandler; use crate::gateway::agent_prompt_provider::AgentPromptProvider; use crate::protocol::{WsInbound, WsOutbound, parse_inbound, serialize_outbound}; use crate::skills::SkillPromptProvider; @@ -224,18 +228,27 @@ async fn handle_inbound( ])); let mut router = CommandRouter::new(); - // 注册 Session 处理器,添加 SessionManager + // 注册 Session 处理器 let session_handler = SessionCommandHandler::new(store.clone()) .with_session_manager(state.session_manager.clone()); router.register(Box::new(session_handler)); - // 注册 SessionQuery 处理器 - let session_query_handler = SessionQueryCommandHandler::new(store.clone()) + // 注册 list_sessions 处理器 + router.register(Box::new(ListSessionsCommandHandler::new(store.clone()))); + // 注册 switch_session 处理器 + let switch_handler = SwitchSessionCommandHandler::new(store.clone()) .with_session_manager(state.session_manager.clone()); - router.register(Box::new(session_query_handler)); + router.register(Box::new(switch_handler)); + // 注册 get_current 处理器 + router.register(Box::new(GetCurrentSessionCommandHandler::new(store.clone()))); + // 注册 load_session 处理器 + router.register(Box::new(LoadSessionCommandHandler::new(store.clone()))); router.register(Box::new(SaveSessionCommandHandler::new( - store, - system_prompt_provider, + store.clone(), + system_prompt_provider.clone(), ))); + // 注册 help 处理器 + let metadata = router.metadata_arc(); + router.register(Box::new(HelpCommandHandler::new(metadata))); // 构建命令上下文 tracing::debug!(