Compare commits

..

5 Commits

Author SHA1 Message Date
428df8da59 feat: 自动从数据库恢复当前话题,增强会话管理功能 2026-05-16 20:45:28 +08:00
6a902b9ff9 feat: 添加带话题的消息追加功能,支持在会话中关联当前话题 ID 2026-05-16 20:32:00 +08:00
831832664d feat: 重构会话管理逻辑,添加获取当前话题的方法,简化命令处理中的会话获取逻辑 2026-05-16 20:19:49 +08:00
3591822145 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.
2026-05-16 19:48:39 +08:00
20f32a3f96 feat: 添加保存话题功能,支持将当前话题内容保存为 Markdown 文件 2026-05-16 19:33:42 +08:00
23 changed files with 1026 additions and 455 deletions

View File

@ -40,26 +40,40 @@ impl InputAdapter for ChannelInputAdapter {
})); }));
} }
// 解析 /save 命令 // 解析 /save 命令 - 保存当前话题
if trimmed == "/save" { if trimmed == "/save" {
return Ok(Some(Command::SaveTopic {
filepath: None,
}));
}
if let Some(filepath) = trimmed.strip_prefix("/save ") {
let filepath = filepath.trim();
return Ok(Some(Command::SaveTopic {
filepath: Some(filepath.to_string()),
}));
}
// 解析 /save-session 命令 - 保存整个会话
if trimmed == "/save-session" {
return Ok(Some(Command::SaveSession { return Ok(Some(Command::SaveSession {
filepath: None, filepath: None,
include_all: false, include_all: false,
})); }));
} }
if let Some(args) = trimmed.strip_prefix("/save ") { if let Some(args) = trimmed.strip_prefix("/save-session ") {
let args = args.trim(); let args = args.trim();
// 解析参数:可能是 "all"、路径、或 "all 路径" // 解析参数:可能是 "all"、路径、或 "all 路径"
let (include_all, filepath) = if args == "all" { let (include_all, filepath) = if args == "all" {
// /save all - 保存全部消息 // /save-session all - 保存全部消息
(true, None) (true, None)
} else if args.starts_with("all ") { } else if args.starts_with("all ") {
// /save all <filepath> - 保存全部消息到指定路径 // /save-session all <filepath> - 保存全部消息到指定路径
let path = args[4..].trim(); let path = args[4..].trim();
(true, Some(path.to_string())) (true, Some(path.to_string()))
} else { } else {
// /save <filepath> - 保存活跃消息到指定路径 // /save-session <filepath> - 保存活跃消息到指定路径
(false, Some(args.to_string())) (false, Some(args.to_string()))
}; };
return Ok(Some(Command::SaveSession { filepath, include_all })); return Ok(Some(Command::SaveSession { filepath, include_all }));
@ -91,6 +105,11 @@ impl InputAdapter for ChannelInputAdapter {
return Ok(Some(Command::GetCurrentSession)); return Ok(Some(Command::GetCurrentSession));
} }
// 解析 /help 命令 - 显示所有支持的命令
if trimmed == "/help" {
return Ok(Some(Command::Help));
}
// 不是命令,返回 None // 不是命令,返回 None
Ok(None) Ok(None)
} }

View File

@ -41,26 +41,40 @@ impl InputAdapter for CliInputAdapter {
})); }));
} }
// 解析 /save 命令 // 解析 /save 命令 - 保存当前话题
if trimmed == "/save" { if trimmed == "/save" {
return Ok(Some(Command::SaveTopic {
filepath: None,
}));
}
if let Some(filepath) = trimmed.strip_prefix("/save ") {
let filepath = filepath.trim();
return Ok(Some(Command::SaveTopic {
filepath: Some(filepath.to_string()),
}));
}
// 解析 /save-session 命令 - 保存整个会话
if trimmed == "/save-session" {
return Ok(Some(Command::SaveSession { return Ok(Some(Command::SaveSession {
filepath: None, filepath: None,
include_all: false, include_all: false,
})); }));
} }
if let Some(args) = trimmed.strip_prefix("/save ") { if let Some(args) = trimmed.strip_prefix("/save-session ") {
let args = args.trim(); let args = args.trim();
// 解析参数:可能是 "all"、路径、或 "all 路径" // 解析参数:可能是 "all"、路径、或 "all 路径"
let (include_all, filepath) = if args == "all" { let (include_all, filepath) = if args == "all" {
// /save all - 保存全部消息 // /save-session all - 保存全部消息
(true, None) (true, None)
} else if args.starts_with("all ") { } else if args.starts_with("all ") {
// /save all <filepath> - 保存全部消息到指定路径 // /save-session all <filepath> - 保存全部消息到指定路径
let path = args[4..].trim(); let path = args[4..].trim();
(true, Some(path.to_string())) (true, Some(path.to_string()))
} else { } else {
// /save <filepath> - 保存活跃消息到指定路径 // /save-session <filepath> - 保存活跃消息到指定路径
(false, Some(args.to_string())) (false, Some(args.to_string()))
}; };
return Ok(Some(Command::SaveSession { filepath, include_all })); return Ok(Some(Command::SaveSession { filepath, include_all }));
@ -92,6 +106,11 @@ impl InputAdapter for CliInputAdapter {
return Ok(Some(Command::GetCurrentSession)); return Ok(Some(Command::GetCurrentSession));
} }
// 解析 /help 命令 - 显示所有支持的命令
if trimmed == "/help" {
return Ok(Some(Command::Help));
}
// 不是命令,返回 None // 不是命令,返回 None
Ok(None) Ok(None)
} }
@ -223,23 +242,52 @@ mod tests {
} }
#[test] #[test]
fn test_cli_input_adapter_save_without_path() { fn test_cli_input_adapter_save_topic_without_path() {
let adapter = CliInputAdapter::new(); let adapter = CliInputAdapter::new();
let ctx = AdapterContext::new("test"); let ctx = AdapterContext::new("test");
let result = adapter.try_parse("/save", ctx).unwrap(); let result = adapter.try_parse("/save", ctx).unwrap();
assert!(result.is_some());
let cmd = result.unwrap();
assert!(matches!(cmd, Command::SaveTopic { filepath: None }));
}
#[test]
fn test_cli_input_adapter_save_topic_with_path() {
let adapter = CliInputAdapter::new();
let ctx = AdapterContext::new("test");
let result = adapter.try_parse("/save ./debug/topic.md", ctx).unwrap();
assert!(result.is_some());
let cmd = result.unwrap();
assert!(matches!(
cmd,
Command::SaveTopic {
filepath: Some(ref p),
} if p == "./debug/topic.md"
));
}
#[test]
fn test_cli_input_adapter_save_session_without_path() {
let adapter = CliInputAdapter::new();
let ctx = AdapterContext::new("test");
let result = adapter.try_parse("/save-session", ctx).unwrap();
assert!(result.is_some()); assert!(result.is_some());
let cmd = result.unwrap(); let cmd = result.unwrap();
assert!(matches!(cmd, Command::SaveSession { filepath: None, include_all: false })); assert!(matches!(cmd, Command::SaveSession { filepath: None, include_all: false }));
} }
#[test] #[test]
fn test_cli_input_adapter_save_with_path() { fn test_cli_input_adapter_save_session_with_path() {
let adapter = CliInputAdapter::new(); let adapter = CliInputAdapter::new();
let ctx = AdapterContext::new("test"); let ctx = AdapterContext::new("test");
let result = adapter.try_parse("/save ./debug/session.md", ctx).unwrap(); let result = adapter.try_parse("/save-session ./debug/session.md", ctx).unwrap();
assert!(result.is_some()); assert!(result.is_some());
let cmd = result.unwrap(); let cmd = result.unwrap();
@ -253,11 +301,11 @@ mod tests {
} }
#[test] #[test]
fn test_cli_input_adapter_save_all() { fn test_cli_input_adapter_save_session_all() {
let adapter = CliInputAdapter::new(); let adapter = CliInputAdapter::new();
let ctx = AdapterContext::new("test"); let ctx = AdapterContext::new("test");
let result = adapter.try_parse("/save all", ctx).unwrap(); let result = adapter.try_parse("/save-session all", ctx).unwrap();
assert!(result.is_some()); assert!(result.is_some());
let cmd = result.unwrap(); let cmd = result.unwrap();
@ -265,11 +313,11 @@ mod tests {
} }
#[test] #[test]
fn test_cli_input_adapter_save_all_with_path() { fn test_cli_input_adapter_save_session_all_with_path() {
let adapter = CliInputAdapter::new(); let adapter = CliInputAdapter::new();
let ctx = AdapterContext::new("test"); let ctx = AdapterContext::new("test");
let result = adapter.try_parse("/save all ./debug/session.md", ctx).unwrap(); let result = adapter.try_parse("/save-session all ./debug/session.md", ctx).unwrap();
assert!(result.is_some()); assert!(result.is_some());
let cmd = result.unwrap(); let cmd = result.unwrap();
@ -287,11 +335,11 @@ mod tests {
let adapter = CliOutputAdapter::new(); let adapter = CliOutputAdapter::new();
let request_id = uuid::Uuid::new_v4(); let request_id = uuid::Uuid::new_v4();
let response = CommandResponse::success(request_id) let response = CommandResponse::success(request_id)
.with_message(MessageKind::Notification, "Session saved to: session.md") .with_message(MessageKind::Notification, "Topic saved to: topic.md")
.with_metadata("filepath", "session.md"); .with_metadata("filepath", "topic.md");
let output = adapter.adapt(response); let output = adapter.adapt(response);
assert!(output.contains("Session saved to: session.md")); assert!(output.contains("Topic saved to: topic.md"));
} }
} }

View File

@ -5,6 +5,15 @@ use crate::command::Command;
use crate::agent::AgentError; use crate::agent::AgentError;
use crate::gateway::session::SessionManager; use crate::gateway::session::SessionManager;
use async_trait::async_trait; 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 /// 命令处理器 trait
/// ///
@ -15,6 +24,11 @@ pub trait CommandHandler: Send + Sync {
/// 是否可以处理此命令 /// 是否可以处理此命令
fn can_handle(&self, cmd: &Command) -> bool; fn can_handle(&self, cmd: &Command) -> bool;
/// 返回命令元数据(用于 /help 命令)
fn metadata(&self) -> Option<CommandMetadata> {
None
}
/// 执行命令 /// 执行命令
/// ///
/// # Arguments /// # Arguments
@ -64,6 +78,7 @@ pub trait InChatCommandHandler: Send + Sync {
/// 负责将命令分发到合适的处理器 /// 负责将命令分发到合适的处理器
pub struct CommandRouter { pub struct CommandRouter {
handlers: Vec<Box<dyn CommandHandler>>, handlers: Vec<Box<dyn CommandHandler>>,
metadata: Arc<std::sync::Mutex<Vec<CommandMetadata>>>,
} }
impl CommandRouter { impl CommandRouter {
@ -71,6 +86,7 @@ impl CommandRouter {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
handlers: Vec::new(), handlers: Vec::new(),
metadata: Arc::new(std::sync::Mutex::new(Vec::new())),
} }
} }
@ -79,9 +95,17 @@ impl CommandRouter {
/// # Arguments /// # Arguments
/// * `handler` - 要注册的处理器 /// * `handler` - 要注册的处理器
pub fn register(&mut self, handler: Box<dyn CommandHandler>) { pub fn register(&mut self, handler: Box<dyn CommandHandler>) {
if let Some(meta) = handler.metadata() {
self.metadata.lock().unwrap().push(meta);
}
self.handlers.push(handler); self.handlers.push(handler);
} }
/// 获取已注册命令的元数据列表(用于 Help 命令)
pub fn metadata_arc(&self) -> Arc<std::sync::Mutex<Vec<CommandMetadata>>> {
self.metadata.clone()
}
/// 分发命令到合适的处理器 /// 分发命令到合适的处理器
/// ///
/// # Arguments /// # Arguments

View File

@ -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<SessionStore>,
}
impl GetCurrentSessionCommandHandler {
pub fn new(store: Arc<SessionStore>) -> Self {
Self { store }
}
}
#[async_trait]
impl CommandHandler for GetCurrentSessionCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::GetCurrentSession)
}
fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata {
name: "current",
description: "获取当前话题信息",
usage: "/current",
})
}
async fn handle(
&self,
cmd: Command,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
match cmd {
Command::GetCurrentSession => handle_get_current_session(self, ctx).await,
_ => unreachable!(),
}
}
}
async fn handle_get_current_session(
handler: &GetCurrentSessionCommandHandler,
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()))
}
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)
}
}

View File

@ -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<Mutex<Vec<CommandMetadata>>>,
}
impl HelpCommandHandler {
/// 创建新的 Help 命令处理器
///
/// # Arguments
/// * `metadata` - CommandRouter 的元数据列表引用
pub fn new(metadata: Arc<Mutex<Vec<CommandMetadata>>>) -> Self {
Self { metadata }
}
}
#[async_trait]
impl CommandHandler for HelpCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::Help)
}
fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata {
name: "help",
description: "显示所有支持的命令",
usage: "/help",
})
}
async fn handle(
&self,
_cmd: Command,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
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
}

View File

@ -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<SessionStore>,
}
impl ListSessionsCommandHandler {
pub fn new(store: Arc<SessionStore>) -> Self {
Self { store }
}
}
#[async_trait]
impl CommandHandler for ListSessionsCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::ListSessions { .. })
}
fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata {
name: "list",
description: "列出所有话题",
usage: "/list [all]",
})
}
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
}
_ => unreachable!(),
}
}
}
async fn handle_list_sessions(
handler: &ListSessionsCommandHandler,
_include_archived: bool,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
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 <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))
}
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)
}
}

View File

@ -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()))
}

View File

@ -1,3 +1,14 @@
pub mod get_current;
pub mod help;
pub mod list_sessions;
pub mod load_session;
pub mod save_session; pub mod save_session;
pub mod save_topic;
pub mod session; pub mod session;
pub mod session_query; pub mod switch_session;
// 导出公共函数供其他模块复用
pub use save_session::{
escape_yaml_string, format_message_content, format_timestamp,
generate_messages_markdown, generate_system_prompt_markdown,
};

View File

@ -1,7 +1,7 @@
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider}; use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
use crate::bus::InboundMessage; use crate::bus::InboundMessage;
use crate::command::context::CommandContext; 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::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command; use crate::command::Command;
use crate::storage::{SessionRecord, SessionStore}; use crate::storage::{SessionRecord, SessionStore};
@ -107,6 +107,14 @@ impl CommandHandler for SaveSessionCommandHandler {
matches!(cmd, Command::SaveSession { .. }) matches!(cmd, Command::SaveSession { .. })
} }
fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata {
name: "save-session",
description: "保存当前会话到 Markdown 文件",
usage: "/save-session [all] [filepath]",
})
}
async fn handle( async fn handle(
&self, &self,
cmd: Command, cmd: Command,
@ -237,17 +245,52 @@ pub fn generate_markdown(
output.push_str(&format!("message_count: {}\n", messages.len())); output.push_str(&format!("message_count: {}\n", messages.len()));
output.push_str("---\n\n"); output.push_str("---\n\n");
// 系统提示词 // 系统提示词(复用公共函数)
output.push_str("# System Prompt\n\n"); output.push_str(&generate_system_prompt_markdown(system_prompt));
if let Some(prompt) = system_prompt {
output.push_str("```\n"); // 消息历史(复用公共函数)
output.push_str(&prompt.content); output.push_str(&generate_messages_markdown(messages));
output.push_str("\n```\n\n");
} else { output
output.push_str("*No system prompt available*\n\n"); }
}
/// 格式化消息内容
///
/// 如果内容包含特殊字符,使用代码块包装
pub fn format_message_content(content: &str) -> String {
// 如果内容包含代码块标记或表格标记,使用原始格式
if content.contains("```") || content.contains("| ") {
format!("```\n{}\n```", content)
} else {
content.to_string()
}
}
/// 转义 YAML 字符串
pub fn escape_yaml_string(s: &str) -> String {
if s.contains('\n') || s.contains('"') || s.contains(':') || s.starts_with(' ') {
// 使用双引号包裹并转义内部的双引号
format!("\"{}\"", s.replace('"', "\\\""))
} else {
s.to_string()
}
}
/// 格式化时间戳
pub fn format_timestamp(ts: i64) -> String {
Local
.timestamp_millis_opt(ts)
.single()
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| format!("{}", ts))
}
/// 生成消息历史的 Markdown 内容
///
/// 这是一个通用函数,可被 Session 和 Topic 的保存逻辑复用
pub fn generate_messages_markdown(messages: &[crate::bus::ChatMessage]) -> String {
let mut output = String::new();
// 消息历史
output.push_str("# Message History\n\n"); output.push_str("# Message History\n\n");
for (idx, msg) in messages.iter().enumerate() { for (idx, msg) in messages.iter().enumerate() {
@ -319,35 +362,20 @@ pub fn generate_markdown(
output output
} }
/// 格式化消息内容 /// 生成系统提示词部分的 Markdown
/// pub fn generate_system_prompt_markdown(system_prompt: &Option<SystemPrompt>) -> String {
/// 如果内容包含特殊字符,使用代码块包装 let mut output = String::new();
fn format_message_content(content: &str) -> String {
// 如果内容包含代码块标记或表格标记,使用原始格式
if content.contains("```") || content.contains("| ") {
format!("```\n{}\n```", content)
} else {
content.to_string()
}
}
/// 转义 YAML 字符串 output.push_str("# System Prompt\n\n");
fn escape_yaml_string(s: &str) -> String { if let Some(prompt) = system_prompt {
if s.contains('\n') || s.contains('"') || s.contains(':') || s.starts_with(' ') { output.push_str("```\n");
// 使用双引号包裹并转义内部的双引号 output.push_str(&prompt.content);
format!("\"{}\"", s.replace('"', "\\\"")) output.push_str("\n```\n\n");
} else { } else {
s.to_string() output.push_str("*No system prompt available*\n\n");
} }
}
/// 格式化时间戳 output
pub fn format_timestamp(ts: i64) -> String {
Local
.timestamp_millis_opt(ts)
.single()
.map(|dt| dt.format("%Y-%m-%d %H:%M:%S").to_string())
.unwrap_or_else(|| format!("{}", ts))
} }
/// 解析文件路径 /// 解析文件路径
@ -557,6 +585,7 @@ mod tests {
assert!(handler.can_handle(&Command::SaveSession { filepath: None, include_all: false })); assert!(handler.can_handle(&Command::SaveSession { filepath: None, include_all: false }));
assert!(handler.can_handle(&Command::SaveSession { filepath: None, include_all: true })); assert!(handler.can_handle(&Command::SaveSession { filepath: None, include_all: true }));
assert!(!handler.can_handle(&Command::CreateSession { title: None })); assert!(!handler.can_handle(&Command::CreateSession { title: None }));
assert!(!handler.can_handle(&Command::SaveTopic { filepath: None }));
} }
/// 测试用的系统提示词提供者 /// 测试用的系统提示词提供者

View File

@ -0,0 +1,242 @@
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
use crate::command::context::CommandContext;
use crate::command::handler::{CommandHandler, CommandMetadata};
use crate::command::handlers::{
escape_yaml_string, format_timestamp, generate_messages_markdown,
generate_system_prompt_markdown,
};
use crate::command::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command;
use crate::storage::{SessionStore, TopicRecord};
use async_trait::async_trait;
use chrono::Local;
use std::path::PathBuf;
use std::sync::Arc;
/// 保存话题到文件
pub async fn save_topic_to_file(
topic_id: &str,
filepath: Option<String>,
store: &SessionStore,
system_prompt_provider: &dyn SystemPromptProvider,
) -> Result<PathBuf, String> {
// 获取话题记录
let topic = store
.get_topic(topic_id)
.map_err(|e| format!("Failed to get topic: {}", e))?
.ok_or_else(|| "Topic not found".to_string())?;
// 加载话题消息
let messages = store
.load_messages_for_topic(topic_id)
.map_err(|e| format!("Failed to load messages: {}", e))?;
// 获取 session 信息(用于系统提示词)
let session = store
.get_session(&topic.session_id)
.map_err(|e| format!("Failed to get session: {}", e))?;
// 构建系统提示词
let user_message_count = messages.iter().filter(|m| m.role == "user").count();
let system_prompt = build_system_prompt(system_prompt_provider, &session, user_message_count);
// 生成 Markdown 内容
let markdown = generate_topic_markdown(&topic, &system_prompt, &messages);
// 确定输出路径
let output_path = resolve_topic_filepath(filepath, &topic);
// 创建父目录
if let Some(parent) = output_path.parent() {
if !parent.as_os_str().is_empty() && !parent.exists() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory: {}", e))?;
}
}
// 写入文件
std::fs::write(&output_path, markdown)
.map_err(|e| format!("Failed to write file: {}", e))?;
Ok(output_path)
}
/// 构建系统提示词
fn build_system_prompt(
provider: &dyn SystemPromptProvider,
session: &Option<crate::storage::SessionRecord>,
user_message_count: usize,
) -> Option<SystemPrompt> {
let session = session.as_ref()?;
let context = SystemPromptContext {
session_id: Some(session.id.clone()),
chat_id: session.chat_id.clone(),
user_message_count,
};
provider.build(&context)
}
/// 生成话题 Markdown 内容(复用公共函数)
fn generate_topic_markdown(
topic: &TopicRecord,
system_prompt: &Option<SystemPrompt>,
messages: &[crate::bus::ChatMessage],
) -> String {
let mut output = String::new();
// YAML frontmatterTopic 特有)
output.push_str("---\n");
output.push_str(&format!("title: {}\n", escape_yaml_string(&topic.title)));
output.push_str(&format!("topic_id: {}\n", topic.id));
output.push_str(&format!("session_id: {}\n", topic.session_id));
if let Some(ref desc) = topic.description {
output.push_str(&format!("description: {}\n", escape_yaml_string(desc)));
}
output.push_str(&format!(
"created_at: {}\n",
format_timestamp(topic.created_at)
));
output.push_str(&format!(
"updated_at: {}\n",
format_timestamp(topic.updated_at)
));
output.push_str(&format!(
"last_active_at: {}\n",
format_timestamp(topic.last_active_at)
));
output.push_str(&format!("message_count: {}\n", messages.len()));
output.push_str("---\n\n");
// 系统提示词(复用公共函数)
output.push_str(&generate_system_prompt_markdown(system_prompt));
// 消息历史(复用公共函数)
output.push_str(&generate_messages_markdown(messages));
output
}
/// 解析话题文件路径Topic 特有)
fn resolve_topic_filepath(filepath: Option<String>, topic: &TopicRecord) -> PathBuf {
match filepath {
Some(path) => PathBuf::from(path),
None => {
let safe_title = topic
.title
.replace(' ', "_")
.replace('/', "_")
.replace('\\', "_")
.replace(':', "_")
.replace('<', "_")
.replace('>', "_")
.replace('|', "_")
.replace('?', "_")
.replace('*', "_")
.replace('"', "_");
let base_name = if safe_title.is_empty() {
format!("topic_{}", &topic.id[..8.min(topic.id.len())])
} else {
safe_title
};
let timestamp = Local::now().format("%Y%m%d_%H%M%S");
let filename = format!("{}_{}.md", base_name, timestamp);
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".picobot")
.join("topics")
.join(filename)
}
}
}
/// 保存话题命令处理器
pub struct SaveTopicCommandHandler {
store: Arc<SessionStore>,
system_prompt_provider: Arc<dyn SystemPromptProvider>,
}
impl SaveTopicCommandHandler {
pub fn new(
store: Arc<SessionStore>,
system_prompt_provider: Arc<dyn SystemPromptProvider>,
) -> Self {
Self {
store,
system_prompt_provider,
}
}
}
#[async_trait]
impl CommandHandler for SaveTopicCommandHandler {
fn can_handle(&self, cmd: &Command) -> bool {
matches!(cmd, Command::SaveTopic { .. })
}
fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata {
name: "save",
description: "保存当前话题到 Markdown 文件",
usage: "/save [filepath]",
})
}
async fn handle(
&self,
cmd: Command,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
match cmd {
Command::SaveTopic { filepath } => handle_save_topic(self, filepath, ctx).await,
_ => unreachable!(),
}
}
}
async fn handle_save_topic(
handler: &SaveTopicCommandHandler,
filepath: Option<String>,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
tracing::debug!(
ctx_topic_id = ?ctx.topic_id,
ctx_session_id = ?ctx.session_id,
channel = %ctx.channel_name,
"SaveTopic command received"
);
let topic_id = ctx
.topic_id
.as_deref()
.ok_or_else(|| CommandError::new("NO_TOPIC", "No active topic".to_string()))?;
tracing::debug!(topic_id = %topic_id, "Attempting to save topic");
// 调用保存函数
let output_path = save_topic_to_file(
topic_id,
filepath,
&*handler.store,
&*handler.system_prompt_provider,
)
.await
.map_err(|e| CommandError::new("SAVE_ERROR", e))?;
// 获取消息数量
let message_count = handler
.store
.load_messages_for_topic(topic_id)
.map_err(|e| CommandError::new("LOAD_MESSAGES_ERROR", e.to_string()))?
.len();
Ok(CommandResponse::success(ctx.request_id)
.with_message(
MessageKind::Notification,
&format!("Topic saved to: {}", output_path.display()),
)
.with_metadata("filepath", output_path.to_string_lossy().as_ref())
.with_metadata("message_count", &message_count.to_string()))
}

View File

@ -1,5 +1,5 @@
use crate::command::context::CommandContext; 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::response::{CommandError, CommandResponse, MessageKind};
use crate::command::Command; use crate::command::Command;
use crate::gateway::session::SessionManager; use crate::gateway::session::SessionManager;
@ -40,6 +40,14 @@ impl CommandHandler for SessionCommandHandler {
matches!(cmd, Command::CreateSession { .. }) matches!(cmd, Command::CreateSession { .. })
} }
fn metadata(&self) -> Option<CommandMetadata> {
Some(CommandMetadata {
name: "new",
description: "创建新话题",
usage: "/new [title]",
})
}
async fn handle( async fn handle(
&self, &self,
cmd: Command, cmd: Command,

View File

@ -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);
}
}

View File

@ -0,0 +1,110 @@
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()))
}

View File

@ -13,7 +13,9 @@ use serde::{Deserialize, Serialize};
pub enum Command { pub enum Command {
/// 创建新话题(在同一个 Session 内) /// 创建新话题(在同一个 Session 内)
CreateSession { title: Option<String> }, CreateSession { title: Option<String> },
/// 保存话题内容到 Markdown 文件 /// 保存当前话题内容到 Markdown 文件
SaveTopic { filepath: Option<String> },
/// 保存会话内容到 Markdown 文件
SaveSession { SaveSession {
filepath: Option<String>, filepath: Option<String>,
include_all: bool, include_all: bool,
@ -26,6 +28,8 @@ pub enum Command {
SwitchSession { session_id: String }, SwitchSession { session_id: String },
/// 获取当前话题信息 /// 获取当前话题信息
GetCurrentSession, GetCurrentSession,
/// 显示所有支持的命令
Help,
} }
impl Command { impl Command {
@ -33,11 +37,13 @@ impl Command {
pub fn name(&self) -> &'static str { pub fn name(&self) -> &'static str {
match self { match self {
Command::CreateSession { .. } => "create_session", Command::CreateSession { .. } => "create_session",
Command::SaveTopic { .. } => "save_topic",
Command::SaveSession { .. } => "save_session", Command::SaveSession { .. } => "save_session",
Command::ListSessions { .. } => "list_sessions", Command::ListSessions { .. } => "list_sessions",
Command::LoadSession { .. } => "load_session", Command::LoadSession { .. } => "load_session",
Command::SwitchSession { .. } => "switch_session", Command::SwitchSession { .. } => "switch_session",
Command::GetCurrentSession => "get_current_session", Command::GetCurrentSession => "get_current_session",
Command::Help => "help",
} }
} }
} }

View File

@ -20,6 +20,7 @@ impl CliSessionService {
} }
/// 创建指定通道的会话 /// 创建指定通道的会话
#[allow(dead_code)]
pub(crate) fn create_with_channel( pub(crate) fn create_with_channel(
&self, &self,
channel_name: &str, channel_name: &str,
@ -30,12 +31,14 @@ impl CliSessionService {
.map_err(|err| AgentError::Other(format!("create session error: {}", err))) .map_err(|err| AgentError::Other(format!("create session error: {}", err)))
} }
#[allow(dead_code)]
pub(crate) fn get(&self, session_id: &str) -> Result<Option<SessionRecord>, AgentError> { pub(crate) fn get(&self, session_id: &str) -> Result<Option<SessionRecord>, AgentError> {
self.store self.store
.get_session(session_id) .get_session(session_id)
.map_err(|err| AgentError::Other(format!("get session error: {}", err))) .map_err(|err| AgentError::Other(format!("get session error: {}", err)))
} }
#[allow(dead_code)]
pub(crate) fn list(&self, include_archived: bool) -> Result<Vec<SessionRecord>, AgentError> { pub(crate) fn list(&self, include_archived: bool) -> Result<Vec<SessionRecord>, AgentError> {
self.store self.store
.list_sessions("cli", include_archived) .list_sessions("cli", include_archived)
@ -43,6 +46,7 @@ impl CliSessionService {
} }
/// 列出指定通道的会话 /// 列出指定通道的会话
#[allow(dead_code)]
pub(crate) fn list_by_channel( pub(crate) fn list_by_channel(
&self, &self,
channel_name: &str, channel_name: &str,
@ -53,24 +57,28 @@ impl CliSessionService {
.map_err(|err| AgentError::Other(format!("list sessions error: {}", err))) .map_err(|err| AgentError::Other(format!("list sessions error: {}", err)))
} }
#[allow(dead_code)]
pub(crate) fn rename(&self, session_id: &str, title: &str) -> Result<(), AgentError> { pub(crate) fn rename(&self, session_id: &str, title: &str) -> Result<(), AgentError> {
self.store self.store
.rename_session(session_id, title) .rename_session(session_id, title)
.map_err(|err| AgentError::Other(format!("rename session error: {}", err))) .map_err(|err| AgentError::Other(format!("rename session error: {}", err)))
} }
#[allow(dead_code)]
pub(crate) fn archive(&self, session_id: &str) -> Result<(), AgentError> { pub(crate) fn archive(&self, session_id: &str) -> Result<(), AgentError> {
self.store self.store
.archive_session(session_id) .archive_session(session_id)
.map_err(|err| AgentError::Other(format!("archive session error: {}", err))) .map_err(|err| AgentError::Other(format!("archive session error: {}", err)))
} }
#[allow(dead_code)]
pub(crate) fn delete(&self, session_id: &str) -> Result<(), AgentError> { pub(crate) fn delete(&self, session_id: &str) -> Result<(), AgentError> {
self.store self.store
.delete_session(session_id) .delete_session(session_id)
.map_err(|err| AgentError::Other(format!("delete session error: {}", err))) .map_err(|err| AgentError::Other(format!("delete session error: {}", err)))
} }
#[allow(dead_code)]
pub(crate) fn clear_messages(&self, session_id: &str) -> Result<(), AgentError> { pub(crate) fn clear_messages(&self, session_id: &str) -> Result<(), AgentError> {
self.store self.store
.clear_messages(session_id) .clear_messages(session_id)

View File

@ -7,9 +7,14 @@ use crate::bus::{InboundMessage, MessageBus, OutboundMessage};
use crate::command::adapter::InputAdapter; use crate::command::adapter::InputAdapter;
use crate::command::adapters::channel::ChannelInputAdapter; use crate::command::adapters::channel::ChannelInputAdapter;
use crate::command::handler::CommandRouter; 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_session::SaveSessionCommandHandler;
use crate::command::handlers::save_topic::SaveTopicCommandHandler;
use crate::command::handlers::session::SessionCommandHandler; 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::config::LLMProviderConfig;
use crate::gateway::agent_prompt_provider::AgentPromptProvider; use crate::gateway::agent_prompt_provider::AgentPromptProvider;
use crate::skills::SkillPromptProvider; use crate::skills::SkillPromptProvider;
@ -42,13 +47,21 @@ impl InboundProcessor {
.with_session_manager(session_manager.clone()); .with_session_manager(session_manager.clone());
command_router.register(Box::new(session_handler)); command_router.register(Box::new(session_handler));
// 注册 session_query 处理器 // 注册 list_sessions 处理器
let session_query_handler = SessionQueryCommandHandler::new(store) command_router.register(Box::new(ListSessionsCommandHandler::new(store.clone())));
// 注册 switch_session 处理器
let switch_handler = SwitchSessionCommandHandler::new(store.clone())
.with_session_manager(session_manager.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 处理器 // 注册 save_session 处理器
let store = session_manager.store();
let skills = session_manager.skills(); let skills = session_manager.skills();
let prompt_repository = session_manager.store().clone(); let prompt_repository = session_manager.store().clone();
let system_prompt_provider: Arc<dyn crate::agent::SystemPromptProvider> = Arc::new(CompositeSystemPromptProvider::new(vec![ let system_prompt_provider: Arc<dyn crate::agent::SystemPromptProvider> = Arc::new(CompositeSystemPromptProvider::new(vec![
@ -60,10 +73,20 @@ impl InboundProcessor {
Box::new(SkillPromptProvider::new(skills)), Box::new(SkillPromptProvider::new(skills)),
])); ]));
command_router.register(Box::new(SaveSessionCommandHandler::new( command_router.register(Box::new(SaveSessionCommandHandler::new(
store, store.clone(),
system_prompt_provider.clone(),
)));
// 注册 save_topic 处理器
command_router.register(Box::new(SaveTopicCommandHandler::new(
store.clone(),
system_prompt_provider, system_prompt_provider,
))); )));
// 注册 help 处理器(最后注册,获取所有已注册命令的元数据)
let metadata = command_router.metadata_arc();
command_router.register(Box::new(HelpCommandHandler::new(metadata)));
Self { Self {
bus, bus,
session_manager, session_manager,
@ -122,13 +145,10 @@ impl InboundProcessor {
// 计算正确的 session_id根据 channel_name 和 chat_id // 计算正确的 session_id根据 channel_name 和 chat_id
let session_id = persistent_session_id(&inbound.channel, &inbound.chat_id); let session_id = persistent_session_id(&inbound.channel, &inbound.chat_id);
// 获取当前话题(如果有 Session // 获取当前话题(封装了 session 创建逻辑)
let current_topic = if let Some(session) = self.session_manager.get(&inbound.channel).await { let current_topic = self.session_manager
let guard = session.lock().await; .get_current_topic(&inbound.channel, &inbound.chat_id)
guard.current_topic(&inbound.chat_id).map(|s| s.to_string()) .await?;
} else {
None
};
// 使用 ChannelInputAdapter 尝试解析命令 // 使用 ChannelInputAdapter 尝试解析命令
let adapter = ChannelInputAdapter::new(); let adapter = ChannelInputAdapter::new();

View File

@ -11,7 +11,7 @@ use crate::storage::{
}; };
use crate::tools::{ use crate::tools::{
DefaultSubAgentRuntime, InMemoryTaskRepository, NoopSessionMessageSender, DefaultSubAgentRuntime, InMemoryTaskRepository, NoopSessionMessageSender,
SessionMessageSender, SubAgentRuntime, SubAgentRuntimeConfig, ToolRegistry, SessionMessageSender, SubAgentRuntimeConfig, ToolRegistry,
}; };
use super::agent_factory::AgentFactory; use super::agent_factory::AgentFactory;
@ -89,7 +89,6 @@ pub(crate) fn build_session_manager_with_sender(
scheduler_jobs, scheduler_jobs,
skill_events.clone(), skill_events.clone(),
session_message_sender.clone(), session_message_sender.clone(),
conversations.clone(),
known_agents, known_agents,
default_timezone, default_timezone,
disabled_tools, disabled_tools,

View File

@ -547,6 +547,36 @@ impl SessionManager {
self.lifecycle.get(channel_name).await self.lifecycle.get(channel_name).await
} }
/// 获取指定 chat 的当前话题(确保 session 存在,自动从数据库恢复)
pub async fn get_current_topic(&self, channel_name: &str, chat_id: &str) -> Result<Option<String>, AgentError> {
self.ensure_session(channel_name).await?;
if let Some(session) = self.get(channel_name).await {
let mut guard = session.lock().await;
// 如果内存中没有当前话题,从数据库恢复最近活跃的话题
if guard.current_topic(chat_id).is_none() {
let session_id = guard.persistent_session_id(chat_id);
let topics = self.store.list_topics(&session_id)
.map_err(|e| AgentError::Other(format!("Failed to list topics: {}", e)))?;
if let Some(latest_topic) = topics.first() {
// 设置最近活跃的话题为当前话题
guard.set_current_topic(chat_id, Some(latest_topic.id.clone()));
tracing::info!(
chat_id = %chat_id,
topic_id = %latest_topic.id,
topic_title = %latest_topic.title,
"Restored current topic from database"
);
}
}
Ok(guard.current_topic(chat_id).map(|s| s.to_string()))
} else {
Ok(None)
}
}
/// 更新最后活跃时间 /// 更新最后活跃时间
pub async fn touch(&self, channel_name: &str) { pub async fn touch(&self, channel_name: &str) {
self.lifecycle.touch(channel_name).await; self.lifecycle.touch(channel_name).await;

View File

@ -187,8 +187,10 @@ impl SessionHistory {
message: ChatMessage, message: ChatMessage,
) -> Result<(), AgentError> { ) -> Result<(), AgentError> {
let session_id = self.persistent_session_id(chat_id); let session_id = self.persistent_session_id(chat_id);
// 获取当前话题 ID用于关联消息
let topic_id = self.chat_topic_ids.get(chat_id).map(|s| s.as_str());
self.conversations self.conversations
.append_message(&session_id, &message) .append_message_with_topic(&session_id, topic_id, &message)
.map_err(|err| { .map_err(|err| {
AgentError::Other(format!("append message persistence error: {}", err)) AgentError::Other(format!("append message persistence error: {}", err))
})?; })?;

View File

@ -3,7 +3,7 @@ use std::sync::Arc;
use crate::config::TaskConfig; use crate::config::TaskConfig;
use crate::skills::SkillRuntime; use crate::skills::SkillRuntime;
use crate::storage::{ConversationRepository, MemoryRepository, SchedulerJobRepository, SkillEventRepository}; use crate::storage::{MemoryRepository, SchedulerJobRepository, SkillEventRepository};
use crate::tools::{ use crate::tools::{
BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool, BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool,
HttpRequestTool, MemoryManageTool, MemorySearchTool, HttpRequestTool, MemoryManageTool, MemorySearchTool,
@ -18,7 +18,6 @@ pub(crate) struct ToolRegistryFactory {
scheduler_jobs: Arc<dyn SchedulerJobRepository>, scheduler_jobs: Arc<dyn SchedulerJobRepository>,
skill_events: Arc<dyn SkillEventRepository>, skill_events: Arc<dyn SkillEventRepository>,
session_message_sender: Arc<dyn SessionMessageSender>, session_message_sender: Arc<dyn SessionMessageSender>,
conversations: Arc<dyn ConversationRepository>,
known_agents: HashSet<String>, known_agents: HashSet<String>,
default_timezone: String, default_timezone: String,
disabled_tools: HashSet<String>, disabled_tools: HashSet<String>,
@ -33,7 +32,6 @@ impl ToolRegistryFactory {
scheduler_jobs: Arc<dyn SchedulerJobRepository>, scheduler_jobs: Arc<dyn SchedulerJobRepository>,
skill_events: Arc<dyn SkillEventRepository>, skill_events: Arc<dyn SkillEventRepository>,
session_message_sender: Arc<dyn SessionMessageSender>, session_message_sender: Arc<dyn SessionMessageSender>,
conversations: Arc<dyn ConversationRepository>,
known_agents: HashSet<String>, known_agents: HashSet<String>,
default_timezone: String, default_timezone: String,
disabled_tools: HashSet<String>, disabled_tools: HashSet<String>,
@ -45,7 +43,6 @@ impl ToolRegistryFactory {
scheduler_jobs, scheduler_jobs,
skill_events, skill_events,
session_message_sender, session_message_sender,
conversations,
known_agents, known_agents,
default_timezone, default_timezone,
disabled_tools, disabled_tools,

View File

@ -5,9 +5,13 @@ use crate::command::adapter::{InputAdapter, OutputAdapter};
use crate::command::adapters::websocket::{WebSocketInputAdapter, WebSocketOutputAdapter}; use crate::command::adapters::websocket::{WebSocketInputAdapter, WebSocketOutputAdapter};
use crate::command::context::CommandContext; use crate::command::context::CommandContext;
use crate::command::handler::CommandRouter; 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_session::SaveSessionCommandHandler;
use crate::command::handlers::session::SessionCommandHandler; 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::gateway::agent_prompt_provider::AgentPromptProvider;
use crate::protocol::{WsInbound, WsOutbound, parse_inbound, serialize_outbound}; use crate::protocol::{WsInbound, WsOutbound, parse_inbound, serialize_outbound};
use crate::skills::SkillPromptProvider; use crate::skills::SkillPromptProvider;
@ -224,18 +228,27 @@ async fn handle_inbound(
])); ]));
let mut router = CommandRouter::new(); let mut router = CommandRouter::new();
// 注册 Session 处理器,添加 SessionManager // 注册 Session 处理器
let session_handler = SessionCommandHandler::new(store.clone()) let session_handler = SessionCommandHandler::new(store.clone())
.with_session_manager(state.session_manager.clone()); .with_session_manager(state.session_manager.clone());
router.register(Box::new(session_handler)); router.register(Box::new(session_handler));
// 注册 SessionQuery 处理器 // 注册 list_sessions 处理器
let session_query_handler = SessionQueryCommandHandler::new(store.clone()) router.register(Box::new(ListSessionsCommandHandler::new(store.clone())));
// 注册 switch_session 处理器
let switch_handler = SwitchSessionCommandHandler::new(store.clone())
.with_session_manager(state.session_manager.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( router.register(Box::new(SaveSessionCommandHandler::new(
store, store.clone(),
system_prompt_provider, system_prompt_provider.clone(),
))); )));
// 注册 help 处理器
let metadata = router.metadata_arc();
router.register(Box::new(HelpCommandHandler::new(metadata)));
// 构建命令上下文 // 构建命令上下文
tracing::debug!( tracing::debug!(

View File

@ -15,6 +15,13 @@ pub trait ConversationRepository: Send + Sync + 'static {
fn append_message(&self, session_id: &str, message: &ChatMessage) -> Result<(), StorageError>; fn append_message(&self, session_id: &str, message: &ChatMessage) -> Result<(), StorageError>;
fn append_message_with_topic(
&self,
session_id: &str,
topic_id: Option<&str>,
message: &ChatMessage,
) -> Result<(), StorageError>;
fn clear_messages(&self, session_id: &str) -> Result<(), StorageError>; fn clear_messages(&self, session_id: &str) -> Result<(), StorageError>;
fn reset_session(&self, session_id: &str) -> Result<(), StorageError>; fn reset_session(&self, session_id: &str) -> Result<(), StorageError>;
@ -140,6 +147,15 @@ impl ConversationRepository for super::SessionStore {
super::SessionStore::append_message(self, session_id, message) super::SessionStore::append_message(self, session_id, message)
} }
fn append_message_with_topic(
&self,
session_id: &str,
topic_id: Option<&str>,
message: &ChatMessage,
) -> Result<(), StorageError> {
super::SessionStore::append_message_with_topic(self, session_id, topic_id, message)
}
fn clear_messages(&self, session_id: &str) -> Result<(), StorageError> { fn clear_messages(&self, session_id: &str) -> Result<(), StorageError> {
super::SessionStore::clear_messages(self, session_id) super::SessionStore::clear_messages(self, session_id)
} }

View File

@ -13,7 +13,7 @@ use crate::tools::{ToolContext, ToolRegistry};
use super::error::TaskError; use super::error::TaskError;
use super::prompt::{extract_summary, SubagentPromptBuilder}; use super::prompt::{extract_summary, SubagentPromptBuilder};
use super::repository::TaskRepository; use super::repository::TaskRepository;
use super::types::{SubagentType, TaskDefinition, TaskHandle, TaskSession, TaskSessionState, TaskToolResult}; use super::types::{SubagentType, TaskDefinition, TaskSession, TaskToolResult};
/// 子代理运行时配置 /// 子代理运行时配置
#[derive(Debug, Clone)] #[derive(Debug, Clone)]