Files
DevFlow/src-tauri/src/commands/ai/title.rs
绝尘 7adaf97377 修复: PowerShell 路径转义 + shell 失败提示 + 标题合并同 role + L0 防抖
- run_command 路径反斜杠传递修正
- 命令失败追加 PowerShell 适配提示引导 LLM 改正
- 标题生成前合并连续同 role 消息避免 Anthropic 1214
- V33 迁移追加 workflow_executions.updated_at 列
- ai-client-ready 3 秒防抖避免重复握手
2026-06-28 13:57:53 +08:00

250 lines
11 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(行为不变)。
// 注:路由已解耦(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<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 总结
// 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<ChatMessage> = 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<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,
};
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<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 }
}
/// 从消息历史中提取对话标题(取第一条用户消息前 30 字)
pub(crate) fn extract_title(messages: &[ChatMessage]) -> Option<String> {
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<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
}