diff --git a/src-tauri/src/commands/ai/knowledge_inject.rs b/src-tauri/src/commands/ai/knowledge_inject.rs index 04c5171..838648b 100644 --- a/src-tauri/src/commands/ai/knowledge_inject.rs +++ b/src-tauri/src/commands/ai/knowledge_inject.rs @@ -13,7 +13,7 @@ use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole}; use df_ai::router::{ is_likely_image_model, select_model_id, Modality, TaskRequirements, }; -use df_storage::crud::{AiConversationRepo, AiMessageRepo, KnowledgeRepo}; +use df_storage::crud::{AiConversationRepo, AiMessageRepo, AiProviderRepo, KnowledgeRepo}; use df_storage::db::Database; use df_storage::models::{AiProviderRecord, KnowledgeRecord}; @@ -196,12 +196,12 @@ fn flush_cjk_bigrams(buf: &mut Vec, out: &mut Vec) { /// 嵌入模型由 embedding provider 的池构成(用户在 KnowledgeSettings 配 embedding provider, /// 该 provider 的 model_configs 通常即嵌入模型),故 needs_tool_use=false 宽松过滤即可命中。 async fn resolve_embed_provider( - state: &AppState, + providers: &AiProviderRepo, config: &crate::state::KnowledgeConfig, ) -> Option<(Box, String)> { let id = config.embedding_provider_id.as_ref()?; let fallback_model = config.embedding_model.clone().unwrap_or_else(|| "embedding-3".to_string()); - let rec = match state.ai_providers.get_by_id(id).await { + let rec = match providers.get_by_id(id).await { Ok(Some(r)) => r, _ => { tracing::warn!("embedding provider 不存在: {}", id); @@ -236,7 +236,7 @@ async fn generate_embedding( text: &str, config: &crate::state::KnowledgeConfig, ) -> Option> { - let (provider, model) = resolve_embed_provider(state, config).await?; + let (provider, model) = resolve_embed_provider(&state.ai_providers, config).await?; // 截断防超 token 上限(8192 token ≈ 8000 中文字) let input: String = text.chars().take(8000).collect(); match provider.embed(&model, vec![input]).await { @@ -312,19 +312,32 @@ pub async fn spawn_embedding_for_knowledge( record: &df_storage::models::KnowledgeRecord, ) { let config = state.knowledge_config.lock().await.clone(); + spawn_embedding_for_record(&state.db, &config, record).await; +} + +/// [`spawn_embedding_for_knowledge`] 的 db+config 版,嵌入语义与发布路径完全一致: +/// vector_enabled 关闭跳过;provider 解析失败标 failed(修好配置后可补偿重试)。 +/// +/// 供只持 db 无 AppState 上下文的调用方使用(AI 工具 handler 捕获仅 Arc, +/// 自动晋升在 spawn 的后台任务内同样拿不到 AppState),配置经 +/// [`crate::state::load_knowledge_config`] 从 KV 现读,与 IPC 路径同一真相源。 +pub(crate) async fn spawn_embedding_for_record( + db: &Arc, + config: &crate::state::KnowledgeConfig, + record: &KnowledgeRecord, +) { if !config.vector_enabled { return; } - let Some((provider, model)) = resolve_embed_provider(state, &config).await else { - // provider 解析失败(配置缺/密钥不可用)也标记 failed,允许用户修好配置后补偿重试。 - // 老行为是静默 return(无人感知),现在落 failed 让状态可见可补。 - let repo = KnowledgeRepo::new(&state.db); + let Some((provider, model)) = resolve_embed_provider(&AiProviderRepo::new(db), config).await + else { + let repo = KnowledgeRepo::new(db); mark_embedding_failed(&repo, &record.id, "provider 解析失败").await; return; }; - // 发布路径单条:Box → Arc 装箱复用 spawn_embedding_task(统一嵌入逻辑,DRY)。 + // 单条:Box → Arc 装箱复用 spawn_embedding_task(统一嵌入逻辑,DRY)。 spawn_embedding_task( - state.db.clone(), + db.clone(), Arc::from(provider), model, record.id.clone(), @@ -362,7 +375,7 @@ pub async fn retry_failed_embeddings(state: &AppState) -> anyhow::Result if !config.vector_enabled { return Ok(count); // 关闭:不动 failed 状态,仅返回条数(前端可据此提示) } - let resolved = resolve_embed_provider(state, &config).await; + let resolved = resolve_embed_provider(&state.ai_providers, &config).await; tauri::async_runtime::spawn(async move { match resolved { Some((provider, model)) => { @@ -507,7 +520,9 @@ pub(crate) async fn build_knowledge_context( let conv_id = conv_id.to_string(); let query_clone = query.to_string(); let user_message_id = user_message_id.map(|s| s.to_string()); - // 晋升开关与阈值取配置快照值拷贝进闭包(决策单未拍板前默认保守,开关默认关) + // 晋升开关与阈值取配置快照值拷贝进闭包(决策单未拍板前默认保守,开关默认关); + // 配置整体快照一并拷入,晋升成功后补嵌入(spawn_embedding_for_record)用 + let embed_config = config.clone(); let auto_publish_enabled = config.auto_publish_enabled; let auto_publish_threshold = config.auto_publish_reuse_threshold; tauri::async_runtime::spawn(async move { @@ -526,6 +541,11 @@ pub(crate) async fn build_knowledge_context( // 晋升留痕:生命线记 status_changed(source_ref=auto_publish), // fire-and-forget 与上方 record_referenced 同风格,失败仅 warn 非阻断。 timeline.record_auto_publish(id, auto_publish_threshold).await; + // 晋升即进检索面,补嵌入对齐 IPC 发布路径(失败标 failed 可补偿重试, + // 非阻断;vector_enabled 关闭时内部直接跳过) + if let Ok(Some(rec)) = repo.get_by_id(id).await { + spawn_embedding_for_record(&db, &embed_config, &rec).await; + } } Ok(false) => {} Err(e) => tracing::warn!("candidate 自动晋升检查失败(非阻断): {e}"), diff --git a/src-tauri/src/commands/ai/tools/knowledge.rs b/src-tauri/src/commands/ai/tools/knowledge.rs index 45a67db..0d5a7b5 100644 --- a/src-tauri/src/commands/ai/tools/knowledge.rs +++ b/src-tauri/src/commands/ai/tools/knowledge.rs @@ -30,8 +30,11 @@ use df_storage::db::Database; use df_storage::models::KnowledgeRecord; use df_types::types::new_id; +use crate::commands::ai::audit::truncate_chars; +use crate::commands::ai::knowledge_inject::{extract_query_keywords, spawn_embedding_for_record}; use crate::commands::knowledge_timeline::KnowledgeTimeline; use crate::commands::now_millis; +use crate::state::load_knowledge_config; /// search 结果单条 content 的截断长度:知识全文可能很长,原样返回会回灌 prompt /// 挤占上下文;截 500 字给 LLM 足够判断「这条是否相关」,需全文时走 IPC/前端看详情。 @@ -49,7 +52,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { registry, db: Arc, "knowledge_search", - "检索本地知识库(仅已发布知识,按关键词 LIKE 匹配,按复用次数降序)。使用时机:回答前需要过往经验/项目约定/踩坑记录时调用,如用户问「之前怎么解决的」「有没有相关约定」。用户说「记住/记一下/以后注意」时不要用本工具,改用 knowledge_insert。参数:query=知识主题关键词(必填),limit=返回条数(可选,默认 5,上限 10)", + "检索本地知识库(仅已发布知识,query 自动分词提取关键词匹配,标题命中优先、复用次数降序)。使用时机:回答前需要过往经验/项目约定/踩坑记录时调用,如用户问「之前怎么解决的」「有没有相关约定」。用户说「记住/记一下/以后注意」时不要用本工具,改用 knowledge_insert。参数:query=知识主题关键词或自然语言描述(必填,自动分词),limit=返回条数(可选,默认 5,上限 10)", RiskLevel::Low, schema: object_schema(vec![("query", "string", true), ("limit", "integer", false)]), args => { @@ -59,7 +62,11 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { } let limit = args["limit"].as_u64().unwrap_or(5).clamp(1, 10) as usize; let repo = df_storage::crud::KnowledgeRepo::new(&db); - let hits = repo.search(query, None, limit).await?; + // 整句 LIKE 对中文自然语言命中率≈0(知识库走查 2026-09-29),与注入检索同源 + // 分词(ASCII 词 + 中文 2-gram)后逐词匹配,任一关键词命中即入候选。 + // 仅已发布(include_candidates=false):LLM 检索面与人工审核面一致。 + let keywords = extract_query_keywords(query); + let hits = repo.search_keywords(&keywords, false, limit).await?; // 空结果返回可执行的下一步指引而非裸空数组:弱模型拿到 [] 常直接回「没找到」 // 结束,带上「让用户口述 + insert 沉淀」能把检索失败转化为知识积累入口。 if hits.is_empty() { @@ -71,21 +78,10 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { let results: Vec = hits .iter() .map(|k| { - // 按 chars 截断而非字节切片:中文内容按字节切会落在 UTF-8 边界上 panic - let snippet = if k.content.chars().count() > SEARCH_CONTENT_SNIPPET_LEN { - let truncated: String = k - .content - .chars() - .take(SEARCH_CONTENT_SNIPPET_LEN) - .collect(); - format!("{truncated}…") - } else { - k.content.clone() - }; serde_json::json!({ "title": k.title, "kind": k.kind, - "content": snippet, + "content": truncate_chars(&k.content, SEARCH_CONTENT_SNIPPET_LEN), "status": k.status, "reuse_count": k.reuse_count, }) @@ -194,6 +190,12 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { KnowledgeTimeline::new(&db) .record_manual_publish(id, "agent") .await; + // 转正即进检索面,后台补嵌入对齐 IPC 发布路径(knowledge_update_status); + // 失败标 failed 可补偿重试,非阻断,vector_enabled 关闭时内部直接跳过 + if let Some(rec) = repo.get_by_id(id).await? { + let config = load_knowledge_config(&db).await; + spawn_embedding_for_record(&db, &config, &rec).await; + } Ok(serde_json::json!({ "id": id, "status": "published", diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index a8007dd..e9bcd01 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -23,6 +23,7 @@ pub use allowed_dirs::{ }; pub use knowledge_config::{ ExtractTrigger, KnowledgeConfig, KNOWLEDGE_CONFIG_KEY, APPROVAL_TIMEOUT_KEY, + load_knowledge_config, }; pub use llm_concurrency::{LlmConcurrency, ProviderAcquire}; @@ -379,17 +380,11 @@ impl AppState { /// /// 原纯内存 knowledge_config 启动 default() 覆盖致用户配置重启全丢;save_config 落 /// KNOWLEDGE_CONFIG_KEY KV,本方法启动恢复。失败(读 KV/反序列化)不阻断启动,保持 default。 + /// 加载与降级逻辑下沉 [`load_knowledge_config`](knowledge_config::load_knowledge_config), + /// 与只持 db 的 AI 工具路径共用。 pub async fn reload_knowledge_config(&self) { - match self.settings.get(KNOWLEDGE_CONFIG_KEY).await { - Ok(Some(json)) => match serde_json::from_str::(&json) { - Ok(cfg) => *self.knowledge_config.lock().await = cfg, - Err(e) => tracing::warn!( - "[KNOWLEDGE-CONFIG] KV 反序列化失败,保持 default: {}", e - ), - }, - Ok(None) => {} // 首次启动无持久化,保持 default - Err(e) => tracing::warn!("[KNOWLEDGE-CONFIG] 读 KV 失败,保持 default: {}", e), - } + let cfg = knowledge_config::load_knowledge_config(&self.db).await; + *self.knowledge_config.lock().await = cfg; } /// 从 Settings KV 恢复审批超时分钟数(默认 15 分钟,0=禁用)。 diff --git a/src-tauri/src/state/knowledge_config.rs b/src-tauri/src/state/knowledge_config.rs index 4a9f9d3..5488016 100644 --- a/src-tauri/src/state/knowledge_config.rs +++ b/src-tauri/src/state/knowledge_config.rs @@ -1,5 +1,10 @@ +use std::sync::Arc; + use serde::{Deserialize, Serialize}; +use df_storage::crud::SettingsRepo; +use df_storage::db::Database; + // ============================================================ // 知识库配置(提取 + 注入) // ============================================================ @@ -80,6 +85,24 @@ fn default_auto_publish_reuse_threshold() -> i64 { 5 } +/// 从 Settings KV 加载知识库配置,缺 KV/读失败/反序列化失败一律回退 default(不阻断调用方)。 +/// +/// AppState::reload_knowledge_config(启动恢复)与只持 db 的调用方(AI 工具 handler 无 +/// AppState 上下文)共用,保证两条路径同一真相源、同一降级语义。 +pub async fn load_knowledge_config(db: &Arc) -> KnowledgeConfig { + match SettingsRepo::new(db).get(KNOWLEDGE_CONFIG_KEY).await { + Ok(Some(json)) => serde_json::from_str::(&json).unwrap_or_else(|e| { + tracing::warn!("[KNOWLEDGE-CONFIG] KV 反序列化失败,保持 default: {}", e); + KnowledgeConfig::default() + }), + Ok(None) => KnowledgeConfig::default(), + Err(e) => { + tracing::warn!("[KNOWLEDGE-CONFIG] 读 KV 失败,保持 default: {}", e); + KnowledgeConfig::default() + } + } +} + impl Default for KnowledgeConfig { fn default() -> Self { Self {