From 9e26d6b19cb88a61db5a078fd32453e100e43b6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Mon, 12 Oct 2026 18:12:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E8=84=89=E7=BB=9C?= =?UTF-8?q?=E5=8D=A1=E5=BD=92=E5=B1=9E=E7=BD=AE=E9=A1=B6=E4=B8=8E=E6=BA=90?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 当前会话归属项目组置顶,focused 标记高亮 - source_ref 解析出可跳转会话 id,消息级引用反查补全 - 条目相对时间/空态引导/标题点击跳源,失败静默 --- src-tauri/src/commands/ai/context_briefs.rs | 230 +++++++++++++++++++- src/api/ai.ts | 5 +- src/components/ai/ContextBriefsCard.vue | 96 ++++++-- src/i18n/en/aiChat.ts | 2 +- src/i18n/zh-CN/aiChat.ts | 2 +- 5 files changed, 313 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/commands/ai/context_briefs.rs b/src-tauri/src/commands/ai/context_briefs.rs index baddc2d..cc6dc38 100644 --- a/src-tauri/src/commands/ai/context_briefs.rs +++ b/src-tauri/src/commands/ai/context_briefs.rs @@ -6,6 +6,8 @@ use tauri::State; +use crate::commands::ai::knowledge_inject::resolve_source_project; + /// 单条脉络摘要(字段前端契约,snake_case) #[derive(serde::Serialize, Clone, Debug)] pub struct ContextBrief { @@ -14,12 +16,19 @@ pub struct ContextBrief { pub source_ref: Option, pub source_project: Option, pub reuse_count: i32, + /// 知识条目最近更新时间,原样透供前端展示「这条什么时候定的」 + pub updated_at: String, + /// 可跳转的来源会话 id(自 source_ref 解析,消息级引用经 ai_messages 反查); + /// 解析不出(无源/引用失效)为 None,前端降级纯文本标题 + pub source_conv: Option, } /// 项目脉络分组 #[derive(serde::Serialize, Clone, Debug)] pub struct ProjectBriefs { pub source_project: String, + /// 是否为当前会话归属项目(仅传 focus_conv 且解析命中时为 true,前端做置顶高亮) + pub focused: bool, pub briefs: Vec, } @@ -35,12 +44,91 @@ fn truncate_chars(s: &str, max: usize) -> String { out } +/// source_ref 溯源引用的三种形态:对话级 `conv:{id}` 直接可跳; +/// 消息级 `conv_msg:{msg_id}` 须查 ai_messages 反查会话;其余(空/异形)不可跳。 +enum SourceConvRef { + Conv(String), + Msg(String), + None, +} + +fn parse_source_conv(source_ref: Option<&str>) -> SourceConvRef { + let Some(r) = source_ref else { + return SourceConvRef::None; + }; + if let Some(id) = r.strip_prefix("conv:") { + if id.is_empty() { + return SourceConvRef::None; + } + return SourceConvRef::Conv(id.to_string()); + } + if let Some(id) = r.strip_prefix("conv_msg:") { + if id.is_empty() { + return SourceConvRef::None; + } + return SourceConvRef::Msg(id.to_string()); + } + SourceConvRef::None +} + +/// 回填 source_conv:对话级引用直接取 id,消息级引用批量反查 ai_messages(一条 IN 查询, +/// 引用失效/表查不到保持 None,前端降级纯文本)。 +async fn fill_source_convs(db: &df_storage::db::Database, groups: &mut [ProjectBriefs]) { + for g in groups.iter_mut() { + for b in g.briefs.iter_mut() { + if let SourceConvRef::Conv(id) = parse_source_conv(b.source_ref.as_deref()) { + b.source_conv = Some(id); + } + } + } + let msg_ids: Vec = groups + .iter() + .flat_map(|g| g.briefs.iter()) + .filter_map(|b| match parse_source_conv(b.source_ref.as_deref()) { + SourceConvRef::Msg(id) => Some(id), + _ => None, + }) + .collect::>() + .into_iter() + .collect(); + if msg_ids.is_empty() { + return; + } + let conn = db.conn(); + let guard = conn.lock().await; + let Ok(mut stmt) = guard.prepare("SELECT conversation_id FROM ai_messages WHERE id = ?1") + else { + return; + }; + let mut found: std::collections::HashMap = std::collections::HashMap::new(); + for id in &msg_ids { + if let Ok(rows) = stmt.query_map([id], |row| row.get::<_, String>(0)) { + for conv_id in rows.flatten() { + found.insert(id.clone(), conv_id); + } + } + } + drop(stmt); + for g in groups.iter_mut() { + for b in g.briefs.iter_mut() { + if b.source_conv.is_some() { + continue; + } + if let SourceConvRef::Msg(id) = parse_source_conv(b.source_ref.as_deref()) { + b.source_conv = found.get(&id).cloned(); + } + } + } +} + /// 聚合纯函数:published 记录 → 按项目分组脉络。无归属项目(source_project None)的知识 /// 不入脉络卡(无分组锚点,注入侧 top-3 已覆盖全局知识);每组按 reuse_count 降序(唯一 -/// 客观信号),组间按组内最高复用数降序(活跃项目排前)。 +/// 客观信号),组间按组内最高复用数降序(活跃项目排前);`focus_project` 命中的组置顶 +/// (当前会话归属项目优先展示),未命中/不传时顺序与置顶逻辑不存在时完全一致。 fn aggregate_briefs( records: Vec, per_project: usize, + focus_project: Option<&str>, ) -> Vec { use std::collections::HashMap; let mut groups: HashMap> = HashMap::new(); @@ -54,6 +142,8 @@ fn aggregate_briefs( source_ref: r.source_ref, source_project: Some(proj.clone()), reuse_count: r.reuse_count, + updated_at: r.updated_at, + source_conv: None, }; groups.entry(proj).or_default().push(brief); } @@ -62,29 +152,45 @@ fn aggregate_briefs( .map(|(source_project, mut briefs)| { briefs.sort_by(|a, b| b.reuse_count.cmp(&a.reuse_count)); briefs.truncate(per_project); - ProjectBriefs { source_project, briefs } + let focused = focus_project == Some(source_project.as_str()); + ProjectBriefs { source_project, focused, briefs } }) .collect(); out.sort_by(|a, b| { let top = |g: &ProjectBriefs| g.briefs.first().map(|b| b.reuse_count).unwrap_or(0); top(b).cmp(&top(a)) }); + if let Some(focus) = focus_project { + if let Some(pos) = out.iter().position(|g| g.source_project == focus) { + let focused_group = out.remove(pos); + out.insert(0, focused_group); + } + } out } /// 脉络卡数据:published 知识按 source_project 聚合(每项目取复用最高的 `per_project` 条)。 +/// `focus_conv` 传当前会话 id 时解析其归属项目,命中组置顶并带 `focused` 标记;解析失败/ +/// 无归属时与不传该参数行为完全一致(软排序只调顺序,不报错不缩面)。 /// 纯读侧,无状态无副作用;knowledge 表空/无归属项目时返空(前端不渲染卡)。 #[tauri::command] pub async fn ai_context_briefs( state: State<'_, crate::AppState>, per_project: Option, + focus_conv: Option, ) -> Result, String> { let records = state .knowledge .list_by_status("published") .await .map_err(|e| e.to_string())?; - Ok(aggregate_briefs(records, per_project.unwrap_or(5))) + let focus_project = match focus_conv.as_deref() { + Some(conv_id) => resolve_source_project(&state.db, conv_id, &[]).await, + None => None, + }; + let mut out = aggregate_briefs(records, per_project.unwrap_or(5), focus_project.as_deref()); + fill_source_convs(&state.db, &mut out).await; + Ok(out) } #[cfg(test)] @@ -119,10 +225,11 @@ mod tests { rec("乙项目", "c3", Some("proj-b"), 3), rec("无归属不入卡", "c4", None, 99), ]; - let out = aggregate_briefs(recs, 5); + let out = aggregate_briefs(recs, 5, None); assert_eq!(out.len(), 2, "两项目分组,无归属剔除: {out:?}"); assert_eq!(out[0].source_project, "proj-a", "组间按最高复用降序"); assert_eq!(out[0].briefs[0].title, "甲项目高复用", "组内按复用降序"); + assert!(out.iter().all(|g| !g.focused), "无 focus 时任何组不带置顶标记"); assert!(out.iter().all(|g| g.briefs.iter().all(|b| b.source_project.is_some()))); } @@ -131,8 +238,121 @@ mod tests { let recs: Vec<_> = (0..8) .map(|i| rec(&format!("条{i}"), &"长".repeat(200), Some("proj"), i)) .collect(); - let out = aggregate_briefs(recs, 3); + let out = aggregate_briefs(recs, 3, None); assert_eq!(out[0].briefs.len(), 3, "每项目截断到 per_project"); assert!(out[0].briefs[0].summary.chars().count() <= BRIEF_SUMMARY_MAX_CHARS + 1); } + + #[test] + fn focus_project_group_moves_to_front() { + let recs = vec![ + rec("乙高复用", "c1", Some("proj-b"), 9), + rec("甲低复用", "c2", Some("proj-a"), 1), + ]; + let out = aggregate_briefs(recs, 5, Some("proj-a")); + assert_eq!(out[0].source_project, "proj-a", "会话归属项目组置顶(复用更低也排前): {out:?}"); + assert!(out[0].focused, "命中组带 focused 标记"); + assert!(!out[1].focused, "非命中组保持 focused=false"); + } + + fn group_order(groups: &[ProjectBriefs]) -> Vec<&str> { + groups.iter().map(|g| g.source_project.as_str()).collect() + } + + #[test] + fn focus_unmatched_project_keeps_order() { + let recs = vec![ + rec("乙高复用", "c1", Some("proj-b"), 9), + rec("甲低复用", "c2", Some("proj-a"), 1), + ]; + let plain = aggregate_briefs(recs.clone(), 5, None); + let unmatched = aggregate_briefs(recs, 5, Some("proj-x")); + assert_eq!(group_order(&plain), group_order(&unmatched), "解析无归属/未知项目时顺序不变"); + assert!(unmatched.iter().all(|g| !g.focused), "无命中组不误标记"); + } + + #[test] + fn brief_carries_updated_at() { + let mut r = rec("条目", "c", Some("proj"), 1); + r.updated_at = "1777777777".into(); + let out = aggregate_briefs(vec![r], 5, None); + assert_eq!(out[0].briefs[0].updated_at, "1777777777", "updated_at 原样透传"); + } + + #[test] + fn source_ref_parses_to_conv_msg_or_none() { + assert!(matches!( + parse_source_conv(Some("conv:abc-1")), + SourceConvRef::Conv(id) if id == "abc-1" + )); + assert!(matches!( + parse_source_conv(Some("conv_msg:msg_123_4")), + SourceConvRef::Msg(id) if id == "msg_123_4" + )); + assert!(matches!(parse_source_conv(Some("conv:")), SourceConvRef::None)); + assert!(matches!(parse_source_conv(Some("weird:x")), SourceConvRef::None)); + assert!(matches!(parse_source_conv(None), SourceConvRef::None)); + } + + #[tokio::test] + async fn fill_source_convs_resolves_both_ref_kinds() { + let db = df_storage::db::Database::open_in_memory() + .await + .expect("内存库"); + { + let conn = db.conn(); + let guard = conn.lock().await; + guard + .execute( + "CREATE TABLE IF NOT EXISTS ai_messages (id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL)", + [], + ) + .expect("建表"); + guard + .execute( + "INSERT INTO ai_messages (id, conversation_id, seq, role, created_at) VALUES \ + ('msg_1_0', 'conv-a', 1, 'assistant', '0'), ('msg_2_0', 'conv-b', 2, 'assistant', '0')", + [], + ) + .expect("造数"); + } + let mut groups = vec![ProjectBriefs { + source_project: "proj".into(), + focused: false, + briefs: vec![ + ContextBrief { + title: "对话级".into(), + summary: "s".into(), + source_ref: Some("conv:conv-a".into()), + source_project: Some("proj".into()), + reuse_count: 2, + updated_at: "1".into(), + source_conv: None, + }, + ContextBrief { + title: "消息级".into(), + summary: "s".into(), + source_ref: Some("conv_msg:msg_2_0".into()), + source_project: Some("proj".into()), + reuse_count: 1, + updated_at: "1".into(), + source_conv: None, + }, + ContextBrief { + title: "失效引用".into(), + summary: "s".into(), + source_ref: Some("conv_msg:msg_gone".into()), + source_project: Some("proj".into()), + reuse_count: 0, + updated_at: "1".into(), + source_conv: None, + }, + ], + }]; + fill_source_convs(&db, &mut groups).await; + let briefs = &groups[0].briefs; + assert_eq!(briefs[0].source_conv.as_deref(), Some("conv-a"), "对话级直接取 id"); + assert_eq!(briefs[1].source_conv.as_deref(), Some("conv-b"), "消息级反查回填"); + assert_eq!(briefs[2].source_conv, None, "失效引用降级 None"); + } } diff --git a/src/api/ai.ts b/src/api/ai.ts index 582acd3..d69eee3 100644 --- a/src/api/ai.ts +++ b/src/api/ai.ts @@ -421,9 +421,10 @@ export const aiApi = { /** * 脉络卡:published 知识按 source_project 聚合(每项目取复用最高 perProject 条)。 + * focusConv 标记来源为当前会话的分组(focused=true 供前端高亮);后端未合版本会忽略该参数。 * 纯读侧;用户点「带入上下文」才经正常发送路径显式注入,不触碰 L3 工具召回铁律。 */ - listContextBriefs(perProject?: number): Promise<{ source_project: string; briefs: { title: string; summary: string; source_ref: string | null; source_project: string | null; reuse_count: number }[] }[]> { - return invoke('ai_context_briefs', { perProject: perProject ?? null }) + listContextBriefs(perProject?: number, focusConv?: string): Promise<{ source_project: string; focused?: boolean; briefs: { title: string; summary: string; source_ref: string | null; source_conv: string | null; source_project: string | null; reuse_count: number; updated_at: string }[] }[]> { + return invoke('ai_context_briefs', { perProject: perProject ?? null, focusConv: focusConv ?? null }) }, } diff --git a/src/components/ai/ContextBriefsCard.vue b/src/components/ai/ContextBriefsCard.vue index c915542..e938cf5 100644 --- a/src/components/ai/ContextBriefsCard.vue +++ b/src/components/ai/ContextBriefsCard.vue @@ -1,6 +1,7 @@