- media_store 模块: sha256 去重落盘 + 阿里云 OSS 双写(签名手写), OSS 失败降级本机引用 - 飞书图片入站: image 消息下载装配 vision 片, 3 次退避+120s 超时+残件清理防线 - 出站回传: 报告 [交付] 路径行自动上传飞书原生附件, 凭据/密钥名拦截兜底 - 溯源 V52: ai_messages.source 列, 飞书 msg_id/chat_id/发送者随消息落库 - spawn offload: 桌面粘贴图 base64 统一转落盘引用, SQLite 不进大字符串 - 引用统一 url 字段(https/file://)不自造 scheme, image_ref 发送前按需展开
351 lines
16 KiB
Rust
351 lines
16 KiB
Rust
//! 对话标题生成
|
||
|
||
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(行为不变)。
|
||
// 注:路由已解耦,原 Lite/Low 智力成本约束已删除,纯 weight 选模型。
|
||
use df_ai::router::{
|
||
is_likely_image_model, 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::compress::resolve_fallback_model;
|
||
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<Database>,
|
||
conv_id: &str,
|
||
app_handle: &AppHandle,
|
||
session_arc: &Arc<Mutex<AiSession>>,
|
||
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 总结。
|
||
// 狗粮实证(2026-10-04):混合 take(6) 在 assistant 消息靠前场景(重提/续接)会把 user 首条挤出,
|
||
// LLM 拿 assistant 首句当主题生成标题(如「信息已足够。让我看一眼...」)。改两段式:
|
||
// user 首条必进(锚定主题),其余按序补足 6 条。
|
||
// 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 sample: Vec<_> = conv.messages.iter()
|
||
.filter(|m| matches!(m.role, MessageRole::User | MessageRole::Assistant))
|
||
.collect();
|
||
// user 首条下标(无 user 消息则退回纯顺序 take(6),空素材由下方 return 兜住)
|
||
let first_user_idx = sample.iter().position(|m| matches!(m.role, MessageRole::User));
|
||
let mut picked: Vec<usize> = Vec::with_capacity(6);
|
||
if let Some(i) = first_user_idx {
|
||
picked.push(i);
|
||
}
|
||
for (i, _) in sample.iter().enumerate() {
|
||
if picked.len() >= 6 { break; }
|
||
if picked.contains(&i) { continue; }
|
||
picked.push(i);
|
||
}
|
||
picked.sort_unstable();
|
||
let summary: Vec<ChatMessage> = picked.into_iter()
|
||
.filter_map(|i| sample.get(i))
|
||
.map(|m| ChatMessage {
|
||
id: None, // 标题摘要派生消息:不落库不溯源,无需分配 ID(对齐老消息 None 语义)
|
||
role: m.role.clone(),
|
||
content: m.content.clone(),
|
||
source: None,
|
||
parts: None,
|
||
tool_call_id: None,
|
||
tool_calls: None,
|
||
model: None,
|
||
status: None,
|
||
reasoning_content: m.reasoning_content.clone(),
|
||
prompt_tokens: None,
|
||
completion_tokens: None,
|
||
prompt_cache_hit_tokens: None,
|
||
prompt_cache_miss_tokens: None,
|
||
reasoning_tokens: None,
|
||
is_estimated: None,
|
||
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);
|
||
|
||
// 修复:先用 extract 兜底 emit 让侧栏即时更新(不落库),
|
||
// 下方 LLM 生成成功后覆盖。这样 LLM 失败时有重试机会,
|
||
// 不会永久锁定一个机械截取的差标题。
|
||
let _fallback_title = extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string());
|
||
let _ = app_handle.emit("ai-conversation-changed", ());
|
||
|
||
// 标题生成是独立一次 LLM 调用,自建 provider(便于后台 spawn,不借主 loop 的 &dyn LlmProvider)
|
||
let provider: Box<dyn LlmProvider> = 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,
|
||
// 但须校验其 enabled(停用则回落池内启用模型,见 compress::resolve_fallback_model);
|
||
// 池内无任何启用模型 → 跳过 LLM 保留 extract_title 兜底,不再静默用停用模型。
|
||
let title_req = TaskRequirements {
|
||
modalities: vec![Modality::Text],
|
||
needs_tool_use: false,
|
||
estimated_context: 0,
|
||
tier: None,
|
||
};
|
||
let title_model = match select_model_id(&title_req, &provider_config.model_configs) {
|
||
Some(m) => m,
|
||
None => match resolve_fallback_model(&provider_config.model_configs, &provider_config.default_model) {
|
||
Ok(m) => m,
|
||
Err(e) => {
|
||
tracing::warn!("标题 LLM 跳过(无可用模型): {}", e);
|
||
return;
|
||
}
|
||
},
|
||
};
|
||
// 防御:选中/兜底模型是图像模型时跳过 LLM(避免文本任务 404 刷屏,治 default_model 配错为图像模型)。
|
||
// 跳过则保留上方 extract_title 兜底,不锁定差标题。
|
||
if is_likely_image_model(&title_model, &provider_config.model_configs) {
|
||
tracing::warn!(
|
||
"标题 LLM 跳过:模型 {} 是图像模型,文本任务不可用(检查 provider default_model 应为文本模型)",
|
||
title_model
|
||
);
|
||
return;
|
||
}
|
||
// 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<Database>,
|
||
conv_id: &str,
|
||
app_handle: &AppHandle,
|
||
session_arc: &Arc<Mutex<AiSession>>,
|
||
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<ChatMessage>,
|
||
conv_id: &str,
|
||
llm_concurrency: &LlmConcurrency,
|
||
) -> Option<String> {
|
||
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>,标题针对本对话,用 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 }
|
||
}
|
||
|
||
/// 从消息历史中提取对话标题。策略:取前 5 条 user 消息中字符数最多的那条。
|
||
///
|
||
/// 开头常出现测试/问候("1"、"你好"等),取最长消息自然跳过这些噪音。
|
||
/// 极端情况全部过短时(仅测试消息),用第一条非空 user 兜底。
|
||
/// #9 标题泄漏:提取前剥离 HTML/Markdown 标签(<details>/<summary>/代码围栏标记等),
|
||
/// 治「content 首行是标签结构体,截断后标题裸露 `<details> <summa…`」。
|
||
fn sanitize_title_source(raw: &str) -> String {
|
||
let mut out = String::with_capacity(raw.len());
|
||
let mut chars = raw.chars().peekable();
|
||
while let Some(c) = chars.next() {
|
||
match c {
|
||
'<' => {
|
||
// HTML 标签:吞到 '>'(含);残缺(无 '>')吞到行尾
|
||
let mut closed = false;
|
||
for n in chars.by_ref() {
|
||
if n == '>' {
|
||
closed = true;
|
||
break;
|
||
}
|
||
if n == '\n' && !closed {
|
||
break;
|
||
}
|
||
}
|
||
let _ = closed;
|
||
}
|
||
'`' => {
|
||
// 代码围栏/行内标记:跳过反引号本身(保留代码文本语义)
|
||
}
|
||
'#' | '*' | '_' | '~' if out.is_empty() || out.ends_with(|ch: char| ch.is_whitespace()) => {
|
||
// 行首 Markdown 修饰符(# 标题/* 列表/_ 强调)不进标题;行中出现的保留(可能是正常标点)
|
||
}
|
||
_ => out.push(c),
|
||
}
|
||
}
|
||
// 压缩空白(标签剥离后可能留下连续空格/空行)
|
||
out.split_whitespace().collect::<Vec<_>>().join(" ")
|
||
}
|
||
|
||
pub(crate) fn extract_title(messages: &[ChatMessage]) -> Option<String> {
|
||
const MAX_TITLE_CHARS: usize = 60;
|
||
const SCAN_LIMIT: usize = 5;
|
||
|
||
let user_msgs: Vec<String> = messages.iter()
|
||
.filter(|m| matches!(m.role, MessageRole::User))
|
||
.take(SCAN_LIMIT)
|
||
.map(|m| sanitize_title_source(m.content.trim()))
|
||
.filter(|s| !s.is_empty())
|
||
.collect();
|
||
|
||
// 取字符数最多的
|
||
let best = user_msgs.iter()
|
||
.max_by_key(|s| s.chars().count());
|
||
|
||
match best {
|
||
Some(text) => {
|
||
let mut chars = text.chars();
|
||
let t: String = chars.by_ref().take(MAX_TITLE_CHARS).collect();
|
||
if chars.next().is_some() { Some(format!("{}...", t)) } else { Some(t) }
|
||
} None => {
|
||
// 极端情况全空 → 第一条非空 user(兜底)
|
||
messages.iter()
|
||
.find(|m| matches!(m.role, MessageRole::User) && !m.content.trim().is_empty())
|
||
.map(|m| {
|
||
let sanitized = sanitize_title_source(m.content.trim());
|
||
let mut chars = sanitized.chars();
|
||
let t: String = chars.by_ref().take(MAX_TITLE_CHARS).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<ChatMessage>) -> Vec<ChatMessage> {
|
||
if msgs.is_empty() {
|
||
return msgs;
|
||
}
|
||
let mut out: Vec<ChatMessage> = 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
|
||
}
|