新增: Phase2 阶段收尾(Sprint 1-20)
重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录 功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore 修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸 文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新 详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
140
src-tauri/src/commands/ai/title.rs
Normal file
140
src-tauri/src/commands/ai/title.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
//! 对话标题生成
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
|
||||
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);
|
||||
|
||||
// 已有标题(用户改名或已生成)→ 不覆盖
|
||||
if let Ok(Some(rec)) = conv_repo.get_by_id(conv_id).await {
|
||||
if rec.title.is_some() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 取对话文本(仅 user/assistant,跳过 tool 噪音),取前 6 条供 LLM 总结
|
||||
let (summary_msgs, all_msgs) = {
|
||||
let session = session_arc.lock().await;
|
||||
let summary: Vec<ChatMessage> = session.messages.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::User | MessageRole::Assistant))
|
||||
.take(6)
|
||||
.map(|m| ChatMessage {
|
||||
role: m.role.clone(),
|
||||
content: m.content.clone(),
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
model: None,
|
||||
})
|
||||
.collect();
|
||||
(summary, session.messages.all_messages_clone())
|
||||
};
|
||||
if summary_msgs.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 标题生成是独立一次 LLM 调用,自建 provider(便于后台 spawn,不借主 loop 的 &dyn LlmProvider)
|
||||
let provider: Box<dyn LlmProvider> = df_ai::build_provider(
|
||||
&provider_config.provider_type,
|
||||
&provider_config.base_url,
|
||||
&provider_config.api_key,
|
||||
&provider_config.default_model,
|
||||
);
|
||||
let title = match generate_title_via_llm(&*provider, &provider_config.default_model, summary_msgs, &llm_concurrency).await {
|
||||
Some(t) => t,
|
||||
None => extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string()),
|
||||
};
|
||||
|
||||
let _ = conv_repo.update_field(conv_id, "title", &title).await;
|
||||
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>,
|
||||
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,
|
||||
};
|
||||
// LLM 并发限流(标题生成属独立调用,纳入双层 Semaphore)
|
||||
let _global_permit = llm_concurrency.acquire_global().await;
|
||||
let _per_conv_permit = llm_concurrency.acquire_per_conv().await;
|
||||
let resp = provider.complete(request).await.ok()?;
|
||||
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 }
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user