新增: 跨会话召回四层架构与脉络卡
- 会话终态提炼: 删除前+停靠双挂点,快照与清水位同临界区 - 目标快照: 恢复引导按最终有效意图生成,旧目标不复活 - 知识检索项目维度软排序,WorkingContext 持久化迁移 - 脉络卡: published 知识按项目聚合纯读侧,带入上下文经输入框显式注入 - 压缩与缓存能力在主循环/子代理/提示词三面接线 - 两份专项设计文档与看板销账
This commit is contained in:
@@ -418,4 +418,12 @@ export const aiApi = {
|
||||
listTaskConversations(taskId: string): Promise<{ conversation_id: string; title: string | null; tool_name: string; requested_at: string }[]> {
|
||||
return invoke('list_task_conversations', { taskId })
|
||||
},
|
||||
|
||||
/**
|
||||
* 脉络卡:published 知识按 source_project 聚合(每项目取复用最高 perProject 条)。
|
||||
* 纯读侧;用户点「带入上下文」才经正常发送路径显式注入,不触碰 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 })
|
||||
},
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ import { useAppSettingsStore } from '../../stores/appSettings'
|
||||
import { getConvState } from '../../composables/ai/useAiEvents'
|
||||
import { continueList, exitListOnBackspace } from '../../composables/ai/useListContinuation'
|
||||
import { textIdle } from '../../composables/ai/useAiPendingState'
|
||||
import { getConvDraft, setConvDraft, classifySendError, type SendErrorKind } from '../../composables/ai/aiShared'
|
||||
import { getConvDraft, setConvDraft, classifySendError, convDraftAppends, takeConvDraftAppend, type SendErrorKind } from '../../composables/ai/aiShared'
|
||||
import { extractImageUrlParts } from '../../composables/ai/utils'
|
||||
import { uploadFile } from '../../composables/ai/fileUpload'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
@@ -1207,6 +1207,23 @@ watch(() => store.state.activeConversationId, (nv, ov) => {
|
||||
})
|
||||
})
|
||||
|
||||
// ── 脉络卡「带入上下文」外部投递:以输入框实时内容为基底追加(草稿 Map 不含键入
|
||||
// 过程,直接写 Map 会丢当前输入),一次性消费防重放;追加后聚焦输入框由用户决断 ──
|
||||
watch(
|
||||
() => convDraftAppends.has(store.state.activeConversationId ?? ''),
|
||||
(has) => {
|
||||
if (!has) return
|
||||
const convId = store.state.activeConversationId
|
||||
const chunk = takeConvDraftAppend(convId)
|
||||
if (!chunk) return
|
||||
inputText.value = inputText.value ? `${inputText.value}\n${chunk}` : chunk
|
||||
nextTick(() => {
|
||||
autoResize()
|
||||
focus()
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
// ── I5 发送失败错误分类文案:类别到 i18n 键的映射(分类逻辑在 aiShared.classifySendError)。
|
||||
// other 复用既有 toastSendFail 文案,行为与改造前一致 ──
|
||||
const SEND_ERR_KEY: Record<SendErrorKind, string> = {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<!-- 脉络卡:同项目近期决策摘要,纯读侧。用户点「带入上下文」向输入框投递追加文本,
|
||||
由用户编辑后自行发送——注入决策权在用户,不触碰 L3 召回铁律。 -->
|
||||
<div class="ctx-briefs">
|
||||
<button
|
||||
class="ctx-briefs-toggle"
|
||||
:title="t('aiChat.contextBriefsToggle')"
|
||||
@click="toggle"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true" width="11" height="11" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
:style="{ transform: expanded ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }"
|
||||
><polyline points="9 18 15 12 9 6"/></svg>
|
||||
<span>{{ t('aiChat.contextBriefsTitle') }}</span>
|
||||
<span v-if="!expanded && totalBriefs > 0" class="ctx-briefs-count">{{ totalBriefs }}</span>
|
||||
</button>
|
||||
<div v-if="expanded" class="ctx-briefs-body">
|
||||
<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 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-summary">{{ b.summary }}</div>
|
||||
<button
|
||||
class="ctx-briefs-bring"
|
||||
:title="t('aiChat.contextBriefsBringIn')"
|
||||
@click="bringIn(g, b)"
|
||||
>{{ t('aiChat.contextBriefsBringIn') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { aiApi } from '../../api/ai'
|
||||
import { useAiStore } from '../../stores/ai'
|
||||
import { pushConvDraftAppend } from '../../composables/ai/aiShared'
|
||||
|
||||
interface ContextBrief {
|
||||
title: string
|
||||
summary: string
|
||||
source_ref: string | null
|
||||
source_project: string | null
|
||||
reuse_count: number
|
||||
}
|
||||
interface ProjectBriefs {
|
||||
source_project: string
|
||||
briefs: ContextBrief[]
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const store = useAiStore()
|
||||
|
||||
const expanded = ref(false)
|
||||
const loading = ref(false)
|
||||
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) {
|
||||
loading.value = true
|
||||
try {
|
||||
groups.value = await aiApi.listContextBriefs()
|
||||
} catch {
|
||||
groups.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 「带入上下文」= 向当前会话输入框投递追加文本(ChatInput 以实时内容为基底合并),
|
||||
* 用户编辑确认后自行发送——注入决策权在用户,不触碰 L3 召回铁律 */
|
||||
function bringIn(g: ProjectBriefs, b: ContextBrief) {
|
||||
const convId = store.state.activeConversationId
|
||||
if (!convId) return
|
||||
pushConvDraftAppend(convId, `参考项目「${g.source_project}」的既有结论「${b.title}」:${b.summary}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ctx-briefs {
|
||||
border-bottom: 1px solid var(--border, #e0e0e0);
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ctx-briefs-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
padding: 3px 2px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-secondary, #666);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.ctx-briefs-count {
|
||||
background: var(--accent-soft, rgba(64, 128, 255, 0.15));
|
||||
color: var(--accent, #4080ff);
|
||||
border-radius: 8px;
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.ctx-briefs-body {
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.ctx-briefs-empty {
|
||||
color: var(--text-tertiary, #999);
|
||||
padding: 6px 2px;
|
||||
}
|
||||
.ctx-briefs-project {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #333);
|
||||
padding: 5px 2px 2px;
|
||||
}
|
||||
.ctx-briefs-item {
|
||||
padding: 4px 2px;
|
||||
border-top: 1px dashed var(--border, #eee);
|
||||
}
|
||||
.ctx-briefs-item-title {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
.ctx-briefs-item-summary {
|
||||
color: var(--text-secondary, #666);
|
||||
margin: 2px 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ctx-briefs-bring {
|
||||
border: 1px solid var(--border, #ddd);
|
||||
background: none;
|
||||
color: var(--accent, #4080ff);
|
||||
border-radius: 4px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.ctx-briefs-bring:hover {
|
||||
background: var(--accent-soft, rgba(64, 128, 255, 0.1));
|
||||
}
|
||||
</style>
|
||||
@@ -32,6 +32,8 @@
|
||||
/>
|
||||
<kbd class="ai-conv-search-kbd">{{ t('aiChat.searchShortcutHint') }}</kbd>
|
||||
</div>
|
||||
<!-- 脉络卡:同项目近期决策摘要,纯读侧,「带入上下文」追加进当前会话输入框 -->
|
||||
<ContextBriefsCard />
|
||||
<div class="ai-conv-list">
|
||||
<!-- UX-06 §3.1: 搜索态(searchResults 非 null)→ 平铺,活跃+归档全量按 updated_at DESC -->
|
||||
<template v-if="searchResults">
|
||||
|
||||
@@ -627,6 +627,25 @@ export function setConvDraft(convId: string | null | undefined, text: string): v
|
||||
}
|
||||
}
|
||||
|
||||
// ── 脉络卡「带入上下文」投递信箱 ──
|
||||
// 背景:convDrafts 只在切会话时被 ChatInput 读写,键入过程不落 Map——外部组件直接写
|
||||
// convDrafts 会基于陈旧值合并,丢用户当前键入内容。故走一次性投递:外部组件 push,
|
||||
// ChatInput watch 到后以「当前输入框实时内容」为合并基底追加,消费即清,不重放。
|
||||
export const convDraftAppends = reactive(new Map<string, string>())
|
||||
|
||||
/** 向某会话输入框投递一段追加文本(脉络卡带入上下文等外部来源) */
|
||||
export function pushConvDraftAppend(convId: string, text: string): void {
|
||||
convDraftAppends.set(convId, text)
|
||||
}
|
||||
|
||||
/** 取走并清除投递(一次性消费,无投递返回 undefined) */
|
||||
export function takeConvDraftAppend(convId: string | null | undefined): string | undefined {
|
||||
if (!convId) return undefined
|
||||
const v = convDraftAppends.get(convId)
|
||||
if (v !== undefined) convDraftAppends.delete(convId)
|
||||
return v
|
||||
}
|
||||
|
||||
// ── I5 发送失败错误分类 ──
|
||||
//
|
||||
// 背景:发送失败错误串原样透出到 toast,后端/上游错误原文(429 Too Many Requests、
|
||||
|
||||
@@ -363,5 +363,10 @@ export default {
|
||||
outboxActionFail: 'Action failed, please retry',
|
||||
outboxDuePast: 'Scheduled time must be in the future',
|
||||
outboxNeedConv: 'No target conversation; create one first',
|
||||
contextBriefsToggle: 'Expand or collapse project briefs',
|
||||
contextBriefsTitle: 'Project briefs',
|
||||
contextBriefsLoading: 'Loading…',
|
||||
contextBriefsEmpty: 'No published knowledge yet',
|
||||
contextBriefsBringIn: 'Bring into context',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -364,5 +364,10 @@ export default {
|
||||
outboxActionFail: '操作失败,请重试',
|
||||
outboxDuePast: '定时时间必须晚于当前时刻',
|
||||
outboxNeedConv: '无目标会话,请先创建会话',
|
||||
contextBriefsToggle: '展开或收起项目脉络',
|
||||
contextBriefsTitle: '项目脉络',
|
||||
contextBriefsLoading: '加载中…',
|
||||
contextBriefsEmpty: '暂无项目沉淀知识',
|
||||
contextBriefsBringIn: '带入上下文',
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user