//! 对话标题生成 use std::sync::Arc; use tauri::{AppHandle, Emitter}; use tokio::sync::Mutex; use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole}; // F-01 阶段5: 标题生成路由 — TaskRequirements(无需工具)。 // 标题是短文本摘要任务;池空/无匹配兜底 default_model(行为不变)。 // 注:路由已解耦(B-260618-03),原 Lite/Low 智力成本约束已删除,纯 weight 选模型。 use df_ai::router::{ select_model_id, Modality, TaskRequirements, }; use df_storage::crud::AiConversationRepo; use df_storage::db::Database; use df_storage::models::AiProviderRecord; use crate::state::LlmConcurrency; use super::AiSession; /// 对话完成后按需生成智能标题(仅 title 为空时触发一次,不覆盖用户改名) /// /// - 已有 title(用户改名或已生成)→ 跳过 /// - 否则调 LLM 非流式总结生成 ≤15 字标题;LLM 失败回退 extract_title 截断 /// - 完成后 emit ai-conversation-changed 通知前端侧栏刷新标题 pub(crate) async fn ensure_conversation_title( provider_config: &AiProviderRecord, db: &Arc, conv_id: &str, app_handle: &AppHandle, session_arc: &Arc>, llm_concurrency: LlmConcurrency, ) { let conv_repo = AiConversationRepo::new(db); // 已有标题(用户改名或已生成)→ 不覆盖。 // 但 "新对话" 是未生成占位(默认/LLM 返空兜底/清理后空回退),视为未生成可重新生成, // 否则一旦 title 落"新对话"便 is_some()=true 永远跳过 → 标题卡死"新对话"。 if let Ok(Some(rec)) = conv_repo.get_by_id(conv_id).await { if let Some(t) = &rec.title { if !t.is_empty() && t != "新对话" { return; } } } // 取对话文本(仅 user/assistant,跳过 tool 噪音),取前 6 条供 LLM 总结 // F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源:本函数入参)。 // conv() 惰性建:switch 触发的 spawn_ensure_title 前序 switch 已 conv();若极端未建, // conv() 建空 PerConvState → summary_msgs 空 → 下方 return(无素材不生成标题)。 let (summary_msgs, all_msgs) = { let mut session = session_arc.lock().await; let conv = session.conv(conv_id); let summary: Vec = conv.messages.iter() .filter(|m| matches!(m.role, MessageRole::User | MessageRole::Assistant)) .take(6) .map(|m| ChatMessage { id: None, // 标题摘要派生消息:不落库不溯源,无需分配 ID(对齐老消息 None 语义) role: m.role.clone(), content: m.content.clone(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: m.reasoning_content.clone(), timestamp: m.timestamp, }) .collect(); let all_msgs = conv.messages.all_messages_clone(); (summary, all_msgs) }; if summary_msgs.is_empty() { return; } // 任务3: 合并相邻同 role 消息(防 Anthropic 协议 1214 错误)。 // session.messages 历史可能存在连续同 role(如 user 失败重提、助手多轮中断后续接、tool_result 被过滤后 // user 连续),Anthropic API 严格校验「messages 必须 user/assistant 交替」,连续同 role 直接拒 1214。 // 在传给 LLM 前合并连续同 role(拼接 content),不影响其他逻辑(本函数 summary 派生数据)。 let summary_msgs = merge_consecutive_roles(summary_msgs); // B-260617-17 修复:进入即 extract_title 兜底落库 + emit,保证侧栏即时有非"新对话"标题。 // 原实现仅在 LLM 返回 None(失败)或 provider 构建失败时才落 extract——但 LLM 卡住 // (generate_title_via_llm 的 llm_concurrency 信号量 acquire 阻塞 / 网络挂起 / 后台 // spawn 未跑完即刷新)时,既不返回 None 也不落库 → 标题长期停留"新对话"。现先落 extract // 兜底,下方 LLM 生成成功后覆盖;LLM 卡住/失败/超时则保留兜底,无论如何不再"新对话"。 let fallback_title = extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string()); if let Err(e) = conv_repo.set_title(conv_id, &fallback_title).await { tracing::error!("对话标题兜底落库失败(conv_id={}, title={}): {}", conv_id, fallback_title, e); } let _ = app_handle.emit("ai-conversation-changed", ()); // 标题生成是独立一次 LLM 调用,自建 provider(便于后台 spawn,不借主 loop 的 &dyn LlmProvider) let provider: Box = match super::secret::build_provider_for(provider_config) { Ok(p) => p, Err(e) => { // 兜底已落库 + emit,LLM 跳过(密钥不可用等) tracing::warn!("标题 LLM 生成跳过(provider 密钥不可用,保留 extract_title 兜底): {}", e); return; } }; // F-01 阶段5: 标题生成路由 — select_model_id None(池空/无匹配)→ 兜底 default_model。 let title_req = TaskRequirements { modalities: vec![Modality::Text], needs_tool_use: false, estimated_context: 0, }; let title_model = select_model_id(&title_req, &provider_config.model_configs) .unwrap_or_else(|| provider_config.default_model.clone()); // B-260617-17: LLM 调用包 20s 超时,防信号量/网络挂起致后台 task 永久堆积; // 超时或返回 None 均保留上方已落的 extract 兜底,不覆盖。 let title = match tokio::time::timeout( std::time::Duration::from_secs(20), generate_title_via_llm(&*provider, &title_model, summary_msgs, conv_id, &llm_concurrency), ).await { Ok(Some(t)) => t, Ok(None) => { tracing::warn!("标题 LLM 生成返回空,保留 extract_title 兜底(conv_id={})", conv_id); return; } Err(_) => { tracing::warn!("标题 LLM 生成超时(20s),保留 extract_title 兜底(conv_id={})", conv_id); return; } }; // LLM 生成成功且有效(非空非"新对话")→ 覆盖 extract 兜底。 // LLM 返空/无效(clean_title 兜底"新对话")→ 保留上方已落的 extract 兜底,不覆盖(防弹回"新对话")。 if !title.is_empty() && title != "新对话" { if let Err(e) = conv_repo.set_title(conv_id, &title).await { tracing::error!("对话标题 LLM 落库失败(conv_id={}, title={}): {}", conv_id, title, e); } let _ = app_handle.emit("ai-conversation-changed", ()); } } /// 后台生成对话标题(不阻塞主流程;失败有 extract_title 兜底) pub(crate) fn spawn_ensure_title( provider_config: &AiProviderRecord, db: &Arc, conv_id: &str, app_handle: &AppHandle, session_arc: &Arc>, llm_concurrency: &LlmConcurrency, ) { let provider_config = provider_config.clone(); let db = db.clone(); let conv_id = conv_id.to_string(); let app_handle = app_handle.clone(); let session_arc = session_arc.clone(); let llm_concurrency = llm_concurrency.clone(); tauri::async_runtime::spawn(async move { ensure_conversation_title(&provider_config, &db, &conv_id, &app_handle, &session_arc, llm_concurrency).await; }); } /// 调 LLM 非流式生成对话标题 async fn generate_title_via_llm( provider: &dyn LlmProvider, model: &str, msgs: Vec, conv_id: &str, llm_concurrency: &LlmConcurrency, ) -> Option { let mut prompt = vec![ChatMessage::system( "你是标题生成器。根据用户与助手的对话,生成一个简短中文标题。要求:不超过15字,纯文本,不加引号不加书名号不加标点不加 emoji,只输出标题本身,不要任何前缀或解释。" )]; prompt.extend(msgs); let request = CompletionRequest { model: model.to_string(), messages: prompt, temperature: Some(0.3), max_tokens: Some(30), stream: false, tools: None, tool_choice: None, reasoning_content: None, }; // LLM 并发限流(标题生成属独立调用,纳入双层 Semaphore) // F-09 B 批5: per_conv 改 HashMap,标题针对本对话,用 conv_id 共享限流槽。 let _global_permit = llm_concurrency.acquire_global().await; let _per_conv_permit = llm_concurrency.acquire_per_conv(conv_id).await; let resp = match provider.complete(request).await { Ok(r) => r, Err(e) => { // 诊断:标题 LLM complete 失败原因(网络/模型/超时)。ensure :121 防御已保留 extract 兜底, // 此日志助定位"为何标题未 LLM 精炼生成"(complete 失败 vs resp.text 空走 clean 兜底"新对话")。 tracing::warn!("标题 LLM complete 失败(conv_id={}, model={}): {}", conv_id, model, e); return None; } }; Some(clean_title(&resp.text)) } /// 清理 LLM 返回的标题:去首尾引号/书名号/空白/末尾标点,截 15 字,空则兜底"新对话" fn clean_title(raw: &str) -> String { let t = raw.trim() .trim_matches(|c: char| matches!(c, '"' | '\'' | '「' | '」' | '《' | '》' | ' ' | '\n' | '\r')); let t = t.trim_end_matches(|c: char| matches!(c, '。' | '.' | ',' | ',' | '!' | '!' | '?' | '?' | ':' | ':')); let cleaned: String = t.chars().take(15).collect(); if cleaned.is_empty() { "新对话".to_string() } else { cleaned } } /// 从消息历史中提取对话标题(取第一条用户消息前 30 字) pub(crate) fn extract_title(messages: &[ChatMessage]) -> Option { messages.iter() .find(|m| matches!(m.role, MessageRole::User)) .map(|m| { let mut chars = m.content.chars(); let t: String = chars.by_ref().take(30).collect(); if chars.next().is_some() { format!("{}...", t) } else { t } }) } /// 合并相邻同 role 消息,拼接 content(防 Anthropic 1214 连续同 role 错误)。 /// role 比较: System/User/Assistant/Tool 中,User 和 Assistant 交替校验最严, /// 不同 role 不合并(保留边界),仅合并连续同 role。 fn merge_consecutive_roles(msgs: Vec) -> Vec { if msgs.is_empty() { return msgs; } let mut out: Vec = Vec::with_capacity(msgs.len()); for m in msgs { if let Some(last) = out.last_mut() { // role 比较(MessageRole 未派生 PartialEq,用 matches!按变体逐个比)。 let same_role = match (&last.role, &m.role) { (MessageRole::System, MessageRole::System) | (MessageRole::User, MessageRole::User) | (MessageRole::Assistant, MessageRole::Assistant) | (MessageRole::Tool, MessageRole::Tool) => true, _ => false, }; if same_role { // 连续同 role: 拼接 content(中间补换行,避免语义粘连),保留首条元数据(id/timestamp)。 // 不拼接 reasoning_content(标题摘要场景该字段无用,且不同 turn reasoning 串一起无意义)。 if !m.content.is_empty() { last.content.push('\n'); last.content.push_str(&m.content); } continue; } } out.push(m); } out }