优化: 脉络卡归属置顶与源会话跳转

- 当前会话归属项目组置顶,focused 标记高亮
- source_ref 解析出可跳转会话 id,消息级引用反查补全
- 条目相对时间/空态引导/标题点击跳源,失败静默
This commit is contained in:
lxy
2026-10-12 18:12:26 +08:00
parent cb23061088
commit 9e26d6b19c
5 changed files with 313 additions and 22 deletions
+225 -5
View File
@@ -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<String>,
pub source_project: Option<String>,
pub reuse_count: i32,
/// 知识条目最近更新时间,原样透供前端展示「这条什么时候定的」
pub updated_at: String,
/// 可跳转的来源会话 id(自 source_ref 解析,消息级引用经 ai_messages 反查);
/// 解析不出(无源/引用失效)为 None,前端降级纯文本标题
pub source_conv: Option<String>,
}
/// 项目脉络分组
#[derive(serde::Serialize, Clone, Debug)]
pub struct ProjectBriefs {
pub source_project: String,
/// 是否为当前会话归属项目(仅传 focus_conv 且解析命中时为 true,前端做置顶高亮)
pub focused: bool,
pub briefs: Vec<ContextBrief>,
}
@@ -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<String> = 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::<std::collections::HashSet<_>>()
.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<String, String> = 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<df_storage::models::KnowledgeRecord>,
per_project: usize,
focus_project: Option<&str>,
) -> Vec<ProjectBriefs> {
use std::collections::HashMap;
let mut groups: HashMap<String, Vec<ContextBrief>> = 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<usize>,
focus_conv: Option<String>,
) -> Result<Vec<ProjectBriefs>, 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");
}
}
+3 -2
View File
@@ -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 })
},
}
+79 -9
View File
@@ -1,6 +1,7 @@
<template>
<!-- 脉络卡:同项目近期决策摘要,纯读侧。用户点「带入上下文」向输入框投递追加文本,
由用户编辑后自行发送——注入决策权在用户,不触碰 L3 召回铁律。 -->
由用户编辑后自行发送——注入决策权在用户,不触碰 L3 召回铁律。
有源会话的条目标题可点跳回原文,空源或跳转失败静默不打断阅读。 -->
<div class="ctx-briefs">
<button
class="ctx-briefs-toggle"
@@ -19,9 +20,19 @@
<div v-if="loading" class="ctx-briefs-empty">{{ t('aiChat.contextBriefsLoading') }}</div>
<div v-else-if="groups.length === 0" class="ctx-briefs-empty">{{ t('aiChat.contextBriefsEmpty') }}</div>
<div v-for="g in groups" v-else :key="g.source_project" class="ctx-briefs-group">
<div class="ctx-briefs-project">{{ g.source_project }}</div>
<div class="ctx-briefs-project">
<span v-if="g.focused" class="ctx-briefs-focus-dot" />{{ g.source_project }}
</div>
<div v-for="b in g.briefs" :key="g.source_project + b.title" class="ctx-briefs-item">
<div class="ctx-briefs-item-title">{{ b.title }}</div>
<div class="ctx-briefs-item-head">
<button
v-if="b.source_conv"
class="ctx-briefs-item-title ctx-briefs-item-link"
@click="openSource(b)"
>{{ b.title }}</button>
<span v-else class="ctx-briefs-item-title">{{ b.title }}</span>
<span class="ctx-briefs-item-time">{{ formatRelative(b.updated_at) }}</span>
</div>
<div class="ctx-briefs-item-summary">{{ b.summary }}</div>
<button
class="ctx-briefs-bring"
@@ -35,26 +46,32 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi } from '../../api/ai'
import { useAiStore } from '../../stores/ai'
import { pushConvDraftAppend } from '../../composables/ai/aiShared'
import { useAiConversations } from '../../composables/ai/useAiConversations'
import { formatRelative } from '../../utils/time'
interface ContextBrief {
title: string
summary: string
source_ref: string | null
source_conv: string | null
source_project: string | null
reuse_count: number
updated_at: string
}
interface ProjectBriefs {
source_project: string
focused?: boolean
briefs: ContextBrief[]
}
const { t } = useI18n()
const store = useAiStore()
const { openConversation } = useAiConversations()
const expanded = ref(false)
const loading = ref(false)
@@ -62,17 +79,39 @@ const groups = ref<ProjectBriefs[]>([])
const totalBriefs = computed(() => groups.value.reduce((n, g) => n + g.briefs.length, 0))
async function toggle() {
expanded.value = !expanded.value
if (expanded.value && groups.value.length === 0) {
/** 拉取脉络,按当前会话标注聚焦分组;会话已切走时丢弃过期响应(防旧 focused 污染新列表) */
async function load() {
const convId = store.state.activeConversationId ?? undefined
loading.value = true
try {
groups.value = await aiApi.listContextBriefs()
const result = await aiApi.listContextBriefs(undefined, convId)
if ((store.state.activeConversationId ?? undefined) === convId) groups.value = result
} catch {
groups.value = []
if ((store.state.activeConversationId ?? undefined) === convId) groups.value = []
} finally {
loading.value = false
}
}
async function toggle() {
expanded.value = !expanded.value
if (expanded.value && groups.value.length === 0) await load()
}
/** 切会话重新拉取(focused 跟随当前会话);收起态只清缓存,保持按需加载 */
watch(() => store.state.activeConversationId, () => {
groups.value = []
if (expanded.value) void load()
})
/** 跳回摘要来源会话(source_conv 由后端自 source_ref 解析,消息级引用已反查);
* 源为空或已删除(not_found)一律静默,不加 toast */
async function openSource(b: ContextBrief) {
if (!b.source_conv) return
try {
await openConversation(b.source_conv)
} catch {
// 静默:跳转失败不打断阅读
}
}
@@ -124,14 +163,45 @@ function bringIn(g: ProjectBriefs, b: ContextBrief) {
color: var(--text-primary, #333);
padding: 5px 2px 2px;
}
.ctx-briefs-focus-dot {
display: inline-block;
width: 5px;
height: 5px;
margin-right: 4px;
border-radius: 50%;
background: var(--accent, #4080ff);
vertical-align: 2px;
}
.ctx-briefs-item {
padding: 4px 2px;
border-top: 1px dashed var(--border, #eee);
}
.ctx-briefs-item-head {
display: flex;
align-items: baseline;
gap: 6px;
}
.ctx-briefs-item-title {
font-weight: 500;
color: var(--text-primary, #333);
}
.ctx-briefs-item-link {
padding: 0;
border: none;
background: none;
font: inherit;
text-align: left;
cursor: pointer;
}
.ctx-briefs-item-link:hover {
color: var(--accent, #4080ff);
}
.ctx-briefs-item-time {
margin-left: auto;
flex-shrink: 0;
font-size: 11px;
color: var(--text-tertiary, #999);
}
.ctx-briefs-item-summary {
color: var(--text-secondary, #666);
margin: 2px 0;
+1 -1
View File
@@ -366,7 +366,7 @@ export default {
contextBriefsToggle: 'Expand or collapse project briefs',
contextBriefsTitle: 'Project briefs',
contextBriefsLoading: 'Loading…',
contextBriefsEmpty: 'No published knowledge yet',
contextBriefsEmpty: 'No briefs yet. Conversation takeaways are distilled here automatically when a conversation ends',
contextBriefsBringIn: 'Bring into context',
},
}
+1 -1
View File
@@ -367,7 +367,7 @@ export default {
contextBriefsToggle: '展开或收起项目脉络',
contextBriefsTitle: '项目脉络',
contextBriefsLoading: '加载中…',
contextBriefsEmpty: '暂无项目沉淀知识',
contextBriefsEmpty: '暂无知识沉淀。会话结束时会自动提炼对话要点,沉淀到这里',
contextBriefsBringIn: '带入上下文',
},
}