- 移除无用克隆与冗余引用,减少不必要内存分配 - 规范 unwrap/expect 使用,修复可提前失败路径 - 修复 anthropic provider llm_timeout_secs 死代码并补全超时日志 - cargo fmt 统一格式
288 lines
9.1 KiB
Rust
288 lines
9.1 KiB
Rust
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 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<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()))
|
||
}
|