PicoBot/src/command/handlers/save_topic.rs
oudecheng 1019dbe8cc refactor(code-quality): 清理 clippy 存量告警(unwrap/clone/redundant 等)
- 移除无用克隆与冗余引用,减少不必要内存分配
- 规范 unwrap/expect 使用,修复可提前失败路径
- 修复 anthropic provider llm_timeout_secs 死代码并补全超时日志
- cargo fmt 统一格式
2026-08-16 23:22:22 +08:00

288 lines
9.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<String>,
include_subagents: bool,
store: &SessionStore,
task_repository: Option<&dyn TaskRepository>,
system_prompt_provider: &dyn SystemPromptProvider,
messages: &[ChatMessage],
) -> 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())?;
// 获取 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<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],
subagent_data: &[SubagentTaskData],
) -> 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()));
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<String>, 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<SessionStore>,
task_repository: Arc<dyn TaskRepository>,
system_prompt_provider: Arc<dyn SystemPromptProvider>,
}
impl SaveTopicCommandHandler {
pub fn new(
store: Arc<SessionStore>,
task_repository: Arc<dyn TaskRepository>,
system_prompt_provider: Arc<dyn SystemPromptProvider>,
) -> 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<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,
include_subagents,
} => handle_save_topic(self, filepath, include_subagents, ctx).await,
_ => unreachable!(),
}
}
}
async fn handle_save_topic(
handler: &SaveTopicCommandHandler,
filepath: Option<String>,
include_subagents: bool,
ctx: CommandContext,
) -> Result<CommandResponse, CommandError> {
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()))
}