Files
DevFlow/src-tauri/src/commands/ai/title.rs
T
lxy 6a50193b34 优化: 狗粮短板收口与patch_file增强
- patch_file old_text 精确未中时空白归一化回退(行首缩进归一+多空白折叠,唯一命中按整行区间替换,warning 注明)
- patch_file anchor end 唯一性校验:多处命中报错并列前两处位置,治实测×2 锚点错位;三模式同传按优先级仲裁(anchor>old_text>replace_lines),warning 注明
- tool_result 压缩 memoization:PerConvState 加 tool_compress_cache(内容哈希→压缩结果),主 loop+子代理双调用点接线,治 369 组同内容反复重压
- 标题采样改两段式:user 首条必进锚定主题+其余按序补足 6 条,治 assistant 首句被当主题
- prompt 注入文件读取纪律段(父会话+子代理同源):read_file 分页/search 直读,禁 run_command 切片读文件;read_file 工具描述与 truncated 提示补大 limit 建议
- 顺带 DRY#1:agentic/mod.rs 三处裸 emit 换正典双写(心跳/破坏性回环熔断/stall 熔断),miniapp 跨端盲区补齐
2026-10-05 00:35:04 +08:00

306 lines
14 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::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(),
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。
let title_req = TaskRequirements {
modalities: vec![Modality::Text],
needs_tool_use: false,
estimated_context: 0,
tier: None,
};
let title_model = select_model_id(&title_req, &provider_config.model_configs)
.unwrap_or_else(|| provider_config.default_model.clone());
// 防御:选中/兜底模型是图像模型时跳过 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 兜底。
pub(crate) fn extract_title(messages: &[ChatMessage]) -> Option<String> {
const MAX_TITLE_CHARS: usize = 60;
const SCAN_LIMIT: usize = 5;
let user_msgs: Vec<&str> = messages.iter()
.filter(|m| matches!(m.role, MessageRole::User))
.take(SCAN_LIMIT)
.map(|m| m.content.trim())
.filter(|s| !s.is_empty())
.collect();
// 取字符数最多的
let best = user_msgs.iter()
.max_by_key(|s| s.chars().count())
.copied();
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 mut chars = m.content.trim().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
}