use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider}; use crate::bus::ChatMessage; use crate::command::Command; use crate::command::context::CommandContext; use crate::command::handler::{CommandHandler, CommandMetadata}; use crate::command::handlers::{ SubagentTaskData, escape_yaml_string, format_timestamp, generate_messages_markdown, generate_subagent_tasks_markdown, generate_system_prompt_markdown, load_subagent_data, }; use crate::command::response::{CommandError, CommandResponse, MessageKind}; use crate::storage::{SessionStore, TopicRecord}; use crate::tools::task::repository::TaskRepository; 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, include_subagents: bool, store: &SessionStore, task_repository: Option<&dyn TaskRepository>, system_prompt_provider: &dyn SystemPromptProvider, messages: &[ChatMessage], ) -> Result { // 获取话题记录 let topic = store .get_topic(topic_id) .map_err(|e| format!("Failed to get topic: {}", e))? .ok_or_else(|| "Topic not found".to_string())?; // 获取 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); // 加载子智能体消息(如果启用) let subagent_data = if include_subagents { load_subagent_data(&topic.session_id, Some(topic_id), store, task_repository).await } else { Vec::new() }; // 生成 Markdown 内容 let markdown = generate_topic_markdown(&topic, &system_prompt, messages, &subagent_data); // 确定输出路径 let output_path = resolve_topic_filepath(filepath, &topic); // 创建父目录 if let Some(parent) = output_path.parent() && !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, user_message_count: usize, ) -> Option { 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, messages: &[crate::bus::ChatMessage], subagent_data: &[SubagentTaskData], ) -> String { let mut output = String::new(); // YAML frontmatter(Topic 特有) 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())); if !subagent_data.is_empty() { output.push_str(&format!("subagent_count: {}\n", subagent_data.len())); } output.push_str("---\n\n"); // 系统提示词(复用公共函数) output.push_str(&generate_system_prompt_markdown(system_prompt)); // 子智能体任务(如果有) if !subagent_data.is_empty() { output.push_str(&generate_subagent_tasks_markdown(subagent_data)); } // 消息历史(复用公共函数) output.push_str(&generate_messages_markdown(messages)); output } /// 解析话题文件路径(Topic 特有) fn resolve_topic_filepath(filepath: Option, topic: &TopicRecord) -> PathBuf { match filepath { Some(path) => PathBuf::from(path), None => { let safe_title = topic .title .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, task_repository: Arc, system_prompt_provider: Arc, } impl SaveTopicCommandHandler { pub fn new( store: Arc, task_repository: Arc, system_prompt_provider: Arc, ) -> Self { Self { store, task_repository, system_prompt_provider, } } } #[async_trait] impl CommandHandler for SaveTopicCommandHandler { fn can_handle(&self, cmd: &Command) -> bool { matches!(cmd, Command::SaveTopic { .. }) } fn metadata(&self) -> Option { Some(CommandMetadata { name: "save", description: "保存当前话题到 Markdown 文件", usage: "/save [filepath]", }) } async fn handle( &self, cmd: Command, ctx: CommandContext, ) -> Result { match cmd { Command::SaveTopic { filepath, include_subagents, } => handle_save_topic(self, filepath, include_subagents, ctx).await, _ => unreachable!(), } } } async fn handle_save_topic( handler: &SaveTopicCommandHandler, filepath: Option, include_subagents: bool, ctx: CommandContext, ) -> Result { tracing::debug!( ctx_topic_id = ?ctx.topic_id, ctx_session_id = ?ctx.session_id, channel = %ctx.channel_name, include_subagents = include_subagents, "SaveTopic command received" ); let topic_id = ctx .topic_id .as_deref() .ok_or_else(|| CommandError::new("NO_TOPIC", "No active topic".to_string()))?; let chat_id = ctx .chat_id .as_deref() .ok_or_else(|| CommandError::new("NO_CHAT_ID", "No chat id".to_string()))?; tracing::debug!(topic_id = %topic_id, chat_id = %chat_id, "Attempting to save topic"); // 直读 DB 按话题加载消息(不依赖 SessionManager 内存,重启后也能正确获取历史) let topic_record = 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 messages = handler .store .load_messages_for_topic_full(topic_id, Some(&topic_record.session_id)) .map_err(|e| CommandError::new("LOAD_MESSAGES_ERROR", e.to_string()))?; tracing::debug!( message_count = messages.len(), "Loaded messages from DB for topic" ); // 调用保存函数 let output_path = save_topic_to_file( topic_id, filepath, include_subagents, &handler.store, Some(handler.task_repository.as_ref()), &*handler.system_prompt_provider, &messages, ) .await .map_err(|e| CommandError::new("SAVE_ERROR", e))?; let message_count = messages.len(); Ok(CommandResponse::success(ctx.request_id) .with_message( MessageKind::Notification, // 路径中的反斜杠在 Markdown 渲染时会被当作转义符吃掉, // 统一转换为正斜杠以保证显示完整(跨平台兼容) format!( "Topic saved to: {}", output_path.display().to_string().replace('\\', "/") ), ) .with_metadata( "filepath", output_path.display().to_string().replace('\\', "/"), ) .with_metadata("message_count", message_count.to_string())) }