新增: 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:
2026-06-14 14:08:20 +08:00
parent 98393b4908
commit cf017f81e2
167 changed files with 19549 additions and 6886 deletions

View 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 }
})
}