Files
DevFlow/src-tauri/src/commands/ai/title.rs
T
lxy 18e50c5af7 新增: 媒体引用化存储与消息溯源
- 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 发送前按需展开
2026-10-11 07:33:18 +08:00

351 lines
16 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 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
}