From 5eb9a26843088aabf091d6272c90d743cac53d6d Mon Sep 17 00:00:00 2001 From: ooodc <549496103@qq.com> Date: Thu, 14 May 2026 23:27:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=91=BD=E4=BB=A4=E5=A4=84=E7=90=86=E5=99=A8?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E5=88=97=E5=87=BA=E5=92=8C=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E4=BC=9A=E8=AF=9D=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/command/adapters/channel.rs | 116 ---------------- src/command/context.rs | 5 +- src/command/handler.rs | 4 +- src/command/handlers/session.rs | 6 +- src/command/handlers/session_query.rs | 188 ++++++++++++++++++++++++++ src/gateway/cli_session.rs | 11 ++ src/gateway/processor.rs | 2 +- src/gateway/ws.rs | 2 +- src/protocol/mod.rs | 12 ++ src/storage/mod.rs | 22 ++- 10 files changed, 240 insertions(+), 128 deletions(-) create mode 100644 src/command/handlers/session_query.rs diff --git a/src/command/adapters/channel.rs b/src/command/adapters/channel.rs index e9af6e7..ce43ab0 100644 --- a/src/command/adapters/channel.rs +++ b/src/command/adapters/channel.rs @@ -69,119 +69,3 @@ impl InputAdapter for ChannelInputAdapter { Ok(None) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_channel_adapter_new_without_title() { - let adapter = ChannelInputAdapter::new(); - let ctx = AdapterContext::new("channel"); - - let result = adapter.try_parse("/new", ctx).unwrap(); - - assert!(result.is_some()); - let cmd = result.unwrap(); - assert!(matches!(cmd, Command::CreateSession { title: None })); - } - - #[test] - fn test_channel_adapter_new_with_title() { - let adapter = ChannelInputAdapter::new(); - let ctx = AdapterContext::new("channel"); - - let result = adapter.try_parse("/new planning session", ctx).unwrap(); - - assert!(result.is_some()); - let cmd = result.unwrap(); - assert!(matches!( - cmd, - Command::CreateSession { - title: Some(ref t) - } if t == "planning session" - )); - } - - #[test] - fn test_channel_adapter_save_without_path() { - let adapter = ChannelInputAdapter::new(); - let ctx = AdapterContext::new("channel"); - - let result = adapter.try_parse("/save", ctx).unwrap(); - - assert!(result.is_some()); - let cmd = result.unwrap(); - assert!(matches!( - cmd, - Command::SaveSession { - filepath: None, - include_all: false, - } - )); - } - - #[test] - fn test_channel_adapter_save_with_path() { - let adapter = ChannelInputAdapter::new(); - let ctx = AdapterContext::new("channel"); - - let result = adapter.try_parse("/save ./session.md", ctx).unwrap(); - - assert!(result.is_some()); - let cmd = result.unwrap(); - assert!(matches!( - cmd, - Command::SaveSession { - filepath: Some(ref p), - include_all: false, - } if p == "./session.md" - )); - } - - #[test] - fn test_channel_adapter_save_all() { - let adapter = ChannelInputAdapter::new(); - let ctx = AdapterContext::new("channel"); - - let result = adapter.try_parse("/save all", ctx).unwrap(); - - assert!(result.is_some()); - let cmd = result.unwrap(); - assert!(matches!( - cmd, - Command::SaveSession { - filepath: None, - include_all: true, - } - )); - } - - #[test] - fn test_channel_adapter_save_all_with_path() { - let adapter = ChannelInputAdapter::new(); - let ctx = AdapterContext::new("channel"); - - let result = adapter.try_parse("/save all ./session.md", ctx).unwrap(); - - assert!(result.is_some()); - let cmd = result.unwrap(); - assert!(matches!( - cmd, - Command::SaveSession { - filepath: Some(ref p), - include_all: true, - } if p == "./session.md" - )); - } - - #[test] - fn test_channel_adapter_not_command() { - let adapter = ChannelInputAdapter::new(); - let ctx = AdapterContext::new("channel"); - - let result = adapter.try_parse("hello world", ctx).unwrap(); - - assert!(result.is_none()); - } -} diff --git a/src/command/context.rs b/src/command/context.rs index dbcc551..9602bfa 100644 --- a/src/command/context.rs +++ b/src/command/context.rs @@ -12,18 +12,21 @@ pub struct CommandContext { pub chat_id: Option, /// 发送者ID pub sender_id: String, + /// 通道名称(如 "cli", "feishu", "wechat") + pub channel_name: String, /// 额外元数据 pub metadata: HashMap, } impl CommandContext { /// 创建新的命令上下文 - pub fn new(sender_id: impl Into) -> Self { + pub fn new(sender_id: impl Into, channel_name: impl Into) -> Self { Self { request_id: Uuid::new_v4(), session_id: None, chat_id: None, sender_id: sender_id.into(), + channel_name: channel_name.into(), metadata: HashMap::new(), } } diff --git a/src/command/handler.rs b/src/command/handler.rs index f50cae2..2962f3a 100644 --- a/src/command/handler.rs +++ b/src/command/handler.rs @@ -238,7 +238,7 @@ mod tests { router.register(Box::new(TestHandler)); router.register(Box::new(NoOpHandler)); - let ctx = CommandContext::new("test"); + let ctx = CommandContext::new("test", "test"); let cmd = Command::CreateSession { title: None }; let result = router.dispatch(cmd, ctx).await; @@ -252,7 +252,7 @@ mod tests { async fn test_router_no_handler() { let router = CommandRouter::new(); - let ctx = CommandContext::new("test"); + let ctx = CommandContext::new("test", "test"); let cmd = Command::CreateSession { title: None }; let result = router.dispatch(cmd, ctx).await; diff --git a/src/command/handlers/session.rs b/src/command/handlers/session.rs index 0e44ce0..7367578 100644 --- a/src/command/handlers/session.rs +++ b/src/command/handlers/session.rs @@ -48,7 +48,7 @@ async fn handle_create_session( ) -> Result { let record = handler .cli_sessions - .create(title.as_deref()) + .create_with_channel(&ctx.channel_name, title.as_deref()) .map_err(|e| CommandError::new("CREATE_SESSION_ERROR", e.to_string()))?; Ok(CommandResponse::success(ctx.request_id) @@ -74,7 +74,7 @@ mod tests { async fn test_create_session_with_title() { let service = create_test_service(); let handler = SessionCommandHandler::new(service); - let ctx = CommandContext::new("test"); + let ctx = CommandContext::new("test", "test"); let cmd = Command::CreateSession { title: Some("my session".to_string()), }; @@ -93,7 +93,7 @@ mod tests { async fn test_create_session_without_title() { let service = create_test_service(); let handler = SessionCommandHandler::new(service); - let ctx = CommandContext::new("test"); + let ctx = CommandContext::new("test", "test"); let cmd = Command::CreateSession { title: None }; let result = handler.handle(cmd, ctx).await; diff --git a/src/command/handlers/session_query.rs b/src/command/handlers/session_query.rs new file mode 100644 index 0000000..ab428bb --- /dev/null +++ b/src/command/handlers/session_query.rs @@ -0,0 +1,188 @@ +use crate::command::context::CommandContext; +use crate::command::handler::CommandHandler; +use crate::command::response::{CommandError, CommandResponse, MessageKind}; +use crate::command::Command; +use crate::gateway::cli_session::CliSessionService; +use crate::protocol::SessionSummary; +use async_trait::async_trait; + +/// 会话查询命令处理器 +/// +/// 处理 ListSessions 和 LoadSession 命令 +pub struct SessionQueryCommandHandler { + cli_sessions: CliSessionService, +} + +impl SessionQueryCommandHandler { + /// 创建新的会话查询命令处理器 + pub fn new(cli_sessions: CliSessionService) -> Self { + Self { cli_sessions } + } +} + +#[async_trait] +impl CommandHandler for SessionQueryCommandHandler { + fn can_handle(&self, cmd: &Command) -> bool { + matches!(cmd, Command::ListSessions { .. } | Command::LoadSession { .. }) + } + + async fn handle( + &self, + cmd: Command, + ctx: CommandContext, + ) -> Result { + 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 + } + _ => unreachable!(), + } + } +} + +/// 处理列出会话命令 +async fn handle_list_sessions( + handler: &SessionQueryCommandHandler, + include_archived: bool, + ctx: CommandContext, +) -> Result { + let records = handler + .cli_sessions + .list(include_archived) + .map_err(|e| CommandError::new("LIST_SESSIONS_ERROR", e.to_string()))?; + + let summaries: Vec = records + .into_iter() + .map(|r| SessionSummary { + session_id: r.id, + title: r.title, + channel_name: r.channel_name, + chat_id: r.chat_id, + message_count: r.message_count, + last_active_at: r.last_active_at, + archived_at: r.archived_at, + }) + .collect(); + + // 将会话列表序列化为 JSON 存储在 metadata 中 + let sessions_json = + serde_json::to_string(&summaries).map_err(|e| CommandError::new("SERIALIZE_ERROR", e.to_string()))?; + + let message = if summaries.is_empty() { + "No sessions found.".to_string() + } else { + format!("Found {} session(s)", summaries.len()) + }; + + Ok(CommandResponse::success(ctx.request_id) + .with_message(MessageKind::Notification, &message) + .with_metadata("sessions", &sessions_json) + .with_metadata("count", &summaries.len().to_string())) +} + +/// 处理加载会话命令 +async fn handle_load_session( + handler: &SessionQueryCommandHandler, + session_id: String, + ctx: CommandContext, +) -> Result { + let record = handler + .cli_sessions + .get(&session_id) + .map_err(|e| CommandError::new("LOAD_SESSION_ERROR", e.to_string()))? + .ok_or_else(|| CommandError::new("SESSION_NOT_FOUND", format!("Session not found: {}", session_id)))?; + + Ok(CommandResponse::success(ctx.request_id) + .with_message(MessageKind::Notification, &record.title) + .with_metadata("session_id", &record.id) + .with_metadata("title", &record.title) + .with_metadata("message_count", &record.message_count.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::SessionStore; + use std::sync::Arc; + + fn create_test_service() -> CliSessionService { + let store = Arc::new(SessionStore::in_memory().unwrap()); + CliSessionService::new(store) + } + + #[tokio::test] + async fn test_list_sessions_empty() { + let service = create_test_service(); + let handler = SessionQueryCommandHandler::new(service); + let ctx = CommandContext::new("test"); + 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 sessions")); + } + + #[tokio::test] + async fn test_list_sessions_with_items() { + let service = create_test_service(); + let handler = SessionQueryCommandHandler::new(service.clone()); + + // 创建一些会话 + service.create(Some("test session")).unwrap(); + + let ctx = CommandContext::new("test"); + 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("sessions")); + } + + #[tokio::test] + async fn test_load_session_not_found() { + let service = create_test_service(); + let handler = SessionQueryCommandHandler::new(service); + let ctx = CommandContext::new("test"); + 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 service = create_test_service(); + let handler = SessionQueryCommandHandler::new(service.clone()); + + // 创建会话 + let record = service.create(Some("test session")).unwrap(); + + let ctx = CommandContext::new("test"); + let cmd = Command::LoadSession { + session_id: record.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("session_id").unwrap(), &record.id); + } +} diff --git a/src/gateway/cli_session.rs b/src/gateway/cli_session.rs index c07d8e1..35fb4ac 100644 --- a/src/gateway/cli_session.rs +++ b/src/gateway/cli_session.rs @@ -19,6 +19,17 @@ impl CliSessionService { .map_err(|err| AgentError::Other(format!("create session error: {}", err))) } + /// 创建指定通道的会话 + pub(crate) fn create_with_channel( + &self, + channel_name: &str, + title: Option<&str>, + ) -> Result { + self.store + .create_session(channel_name, title) + .map_err(|err| AgentError::Other(format!("create session error: {}", err))) + } + pub(crate) fn get(&self, session_id: &str) -> Result, AgentError> { self.store .get_session(session_id) diff --git a/src/gateway/processor.rs b/src/gateway/processor.rs index fd80335..610d13b 100644 --- a/src/gateway/processor.rs +++ b/src/gateway/processor.rs @@ -117,7 +117,7 @@ impl InboundProcessor { if let Ok(Some(cmd)) = adapter.try_parse(&inbound.content, ctx) { // 使用命令路由器处理 - let cmd_ctx = crate::command::context::CommandContext::new(&inbound.channel) + let cmd_ctx = crate::command::context::CommandContext::new(&inbound.channel, &inbound.channel) .with_session_id(&inbound.chat_id); let response = self.command_router.dispatch_with_response(cmd, cmd_ctx).await; diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index c9c0a8b..0045066 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -227,7 +227,7 @@ async fn handle_inbound( ))); // 构建命令上下文 - let cmd_ctx = CommandContext::new("websocket") + let cmd_ctx = CommandContext::new("websocket", "cli") .with_session_id(current_session_id.as_str()); // 执行命令 diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 547c052..2882b06 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -76,6 +76,18 @@ pub enum WsOutbound { SessionEstablished { session_id: String }, #[serde(rename = "session_created")] SessionCreated { session_id: String, title: String }, + #[serde(rename = "session_list")] + SessionList { + sessions: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + current_session_id: Option, + }, + #[serde(rename = "session_loaded")] + SessionLoaded { + session_id: String, + title: String, + message_count: i64, + }, #[serde(rename = "session_saved")] SessionSaved { session_id: String, filepath: String }, #[serde(rename = "pong")] diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 3dded44..13f9515 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -204,14 +204,24 @@ impl SessionStore { Self::from_connection(Connection::open_in_memory()?) } - pub fn create_cli_session(&self, title: Option<&str>) -> Result { + pub fn create_session( + &self, + channel_name: &str, + title: Option<&str>, + ) -> Result { let now = current_timestamp(); let id = uuid::Uuid::new_v4().to_string(); let title = title .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) - .unwrap_or_else(|| format!("CLI Session {}", &id[..8])); + .unwrap_or_else(|| { + if channel_name == "cli" { + format!("CLI Session {}", &id[..8]) + } else { + format!("Session {}", &id[..8]) + } + }); let conn = self.conn.lock().expect("session db mutex poisoned"); conn.execute( @@ -220,9 +230,9 @@ impl SessionStore { id, title, channel_name, chat_id, summary, created_at, updated_at, last_active_at, archived_at, deleted_at, message_count, reset_cutoff_seq, user_turn_count, agent_prompt_reinjection_count - ) VALUES (?1, ?2, 'cli', ?3, NULL, ?4, ?4, ?4, NULL, NULL, 0, 0, 0, 0) + ) VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?5, ?5, NULL, NULL, 0, 0, 0, 0) ", - params![id, title, id, now], + params![id, title, channel_name, id, now], )?; drop(conn); @@ -230,6 +240,10 @@ impl SessionStore { .ok_or_else(|| rusqlite::Error::QueryReturnedNoRows.into()) } + pub fn create_cli_session(&self, title: Option<&str>) -> Result { + self.create_session("cli", title) + } + pub fn ensure_channel_session( &self, channel_name: &str,