优化: 知识检索同项目加权与拒绝池

This commit is contained in:
lxy
2026-10-05 23:41:27 +08:00
parent 0f3c3ce1aa
commit 9e81182b4f
9 changed files with 387 additions and 22 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ function toggleExpanded(current: boolean) {
:title="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')"
:aria-label="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')"
>
<svg class="ai-enrichment-chevron" width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<svg aria-hidden="true" class="ai-enrichment-chevron" width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
<path d="M2 3l3 4 3-4" />
</svg>
<span class="ai-enrichment-badge">{{ enrichmentBadgeText }}</span>
+11 -1
View File
@@ -13,6 +13,14 @@
<button class="btn btn-primary btn-sm" :disabled="submitting" @click="emit('publish')">{{ t('knowledge.publish') }}</button>
<button class="btn btn-ghost btn-sm" :disabled="submitting" @click="emit('reject')">{{ t('knowledge.reject') }}</button>
</template>
<!-- 归档箱:恢复发布(archived→published 唯一出边,可逆语义对齐项目/任务归档) -->
<template v-else-if="topTab === 'archived'">
<button class="btn btn-primary btn-sm" :disabled="submitting" @click="emit('restore')">{{ t('knowledge.restore') }}</button>
</template>
<!-- 拒绝池:重新送审(rejected→candidate 唯一出边,修改后重审) -->
<template v-else-if="topTab === 'rejected'">
<button class="btn btn-primary btn-sm" :disabled="submitting" @click="emit('resubmit')">{{ t('knowledge.resubmit') }}</button>
</template>
<button v-else class="btn btn-ghost btn-sm" :disabled="submitting" @click="emit('archive')">{{ t('knowledge.archive') }}</button>
</div>
<div class="detail-actions" v-else>
@@ -155,7 +163,7 @@ import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '@/api/types'
// 编辑保存与审核操作通过 emit 回传父级执行 IPC(publish/reject/archive/save)。
const props = defineProps<{
detail: KnowledgeDetailPayload
topTab: 'library' | 'inbox'
topTab: 'library' | 'inbox' | 'rejected' | 'archived'
submitting: boolean
}>()
@@ -164,6 +172,8 @@ const emit = defineEmits<{
(e: 'publish'): void
(e: 'reject'): void
(e: 'archive'): void
(e: 'restore'): void
(e: 'resubmit'): void
}>()
const { t } = useI18n()
+12 -1
View File
@@ -31,6 +31,11 @@ import { setContextCallbacks } from './useAiEvents'
// 若需 per-window 独立压缩态需改为事件同步/inject-provide,改造成本大,暂不处理。
const isCompressing = ref(false)
// B4 自动压缩可见信号:AiAutoCompressed 到达时置 true,4s 后自灭。输入区 hint 行消费,
// 长会话用户对「上下文何时被压缩」有一次性感知(不打断 LLM 流,不弹 toast)。
const autoCompressedFlash = ref(false)
let autoCompressedFlashTimer: ReturnType<typeof setTimeout> | null = null
/**
* 注入上下文生命周期回调到统一事件分发器(幂等)。
*
@@ -49,8 +54,12 @@ async function initContextListener(callbacks: {
callbacks.onCompressed?.(convId, summary)
},
onAutoCompressed: () => {
// loop 自动压缩静默——仅复位 loading(冗余兜底,Auto 路径前端 loading 本就 false)
// loop 自动压缩:B4 可见信号——置 flash(输入区 hint 行短暂显示「上下文已自动压缩」),
// 4s 自灭;仍不弹 toast、不刷新视图(不打断 LLM 流,消息本就可见非折叠困惑)。
isCompressing.value = false
autoCompressedFlash.value = true
if (autoCompressedFlashTimer) clearTimeout(autoCompressedFlashTimer)
autoCompressedFlashTimer = setTimeout(() => { autoCompressedFlash.value = false }, 4000)
},
onCleared: (convId) => callbacks.onCleared?.(convId),
onError: (convId, message) => {
@@ -108,6 +117,8 @@ export function useAiContext() {
return {
// 状态
isCompressing,
// B4: 自动压缩完成一次性可见信号(输入区 hint 行消费,4s 自灭)
autoCompressedFlash,
// 操作
clearContext,
compressContext,
+15 -1
View File
@@ -6,20 +6,30 @@ export default {
library: 'Library',
// inbox = pending items (candidate + pending_review); library is published-only (F-260616-02 decision a)
inbox: 'Pending',
// Archive box (2026-10-05 archive tail): low-frequency visible & restorable
archived: 'Archive Box',
// Rejected pool (2026-10-05 split): review rejections, separate from archive
rejected: 'Rejected',
},
searchPlaceholder: 'Search title, tags, content...',
categoryAll: 'All',
emptyInbox: '✅ No pending items',
emptyLibrary: '📭 No matching knowledge',
emptyArchived: '🗄️ Archive box is empty',
emptyRejected: '🚫 No rejected knowledge',
edit: '✏️ Edit',
publish: 'Publish',
reject: 'Reject',
archive: 'Archive',
restore: 'Restore',
resubmit: 'Resubmit',
// ⑤ Operation feedback toast
savedToast: 'Saved',
publishedToast: 'Published to library',
rejectedToast: 'Rejected',
rejectedToast: 'Rejected, moved to rejected pool',
archivedToast: 'Archived',
restoredToast: 'Restored to library',
resubmittedToast: 'Resubmitted, moved to pending',
createdToast: 'Created, moved to pending',
reuseCount: '🔄 Reused {n} times',
confidenceBadge: 'Confidence: {label}',
@@ -66,6 +76,7 @@ export default {
pending_review: 'Reviewing',
published: 'Published',
archived: 'Archived',
rejected: 'Rejected',
},
confidence: {
high: 'High',
@@ -89,6 +100,9 @@ export default {
createFailed: 'Failed to create knowledge',
updateStatusFailed: 'Failed to update status',
archiveFailed: 'Failed to archive',
restoreFailed: 'Failed to restore',
rejectFailed: 'Failed to reject',
resubmitFailed: 'Failed to resubmit',
saveFailed: 'Failed to save',
loadConfigFailed: 'Failed to load config',
saveConfigFailed: 'Failed to save config',
+15 -1
View File
@@ -7,6 +7,10 @@ export default {
library: '知识库',
// inbox = 待处理收件箱(candidate + pending_review);library 纯 published(F-260616-02 决策 a)
inbox: '待处理',
// 归档箱(2026-10-05 归档功能尾巴):低频可见可恢复
archived: '归档箱',
// 拒绝池(2026-10-05 分池):审核否决独立可见
rejected: '已拒绝',
},
// 搜索
searchPlaceholder: '搜索标题、标签、内容...',
@@ -15,16 +19,22 @@ export default {
// 列表空状态
emptyInbox: '✅ 暂无待处理条目',
emptyLibrary: '📭 没有匹配的知识',
emptyArchived: '🗄️ 归档箱为空',
emptyRejected: '🚫 没有被拒绝的知识',
// 详情头部操作
edit: '✏️ 编辑',
publish: '发布',
reject: '拒绝',
archive: '归档',
restore: '恢复发布',
resubmit: '重新送审',
// ⑤ 操作反馈 toast
savedToast: '已保存',
publishedToast: '已发布到知识库',
rejectedToast: '已拒绝',
rejectedToast: '已拒绝,进入拒绝池',
archivedToast: '已归档',
restoredToast: '已恢复到知识库',
resubmittedToast: '已重新送审,进入待处理',
createdToast: '已创建,进入待处理',
// 详情徽章
reuseCount: '🔄 复用 {n} 次',
@@ -80,6 +90,7 @@ export default {
pending_review: '审核中',
published: '已发布',
archived: '已归档',
rejected: '已拒绝',
},
// 置信度标签
confidence: {
@@ -105,6 +116,9 @@ export default {
createFailed: '创建知识失败',
updateStatusFailed: '更新状态失败',
archiveFailed: '归档失败',
restoreFailed: '恢复失败',
rejectFailed: '拒绝失败',
resubmitFailed: '重新送审失败',
saveFailed: '保存失败',
loadConfigFailed: '加载配置失败',
saveConfigFailed: '保存配置失败',
+73 -1
View File
@@ -17,6 +17,8 @@ import type {
const state = reactive({
items: [] as KnowledgeRecord[],
candidates: [] as KnowledgeRecord[],
archived: [] as KnowledgeRecord[],
rejected: [] as KnowledgeRecord[],
config: null as KnowledgeConfig | null,
loading: false,
error: null as string | null,
@@ -161,11 +163,78 @@ export function useKnowledgeStore() {
return ok === true
}
// 分池:审核否决(候选/审核中 → rejected 独立池,不混归档箱)
async function reject(id: string): Promise<boolean> {
const ok = await runWithCatch(state, t('knowledge.err.rejectFailed'), async () => {
await knowledgeApi.updateStatus(id, 'rejected')
state.items = state.items.filter(k => k.id !== id)
state.candidates = state.candidates.filter(k => k.id !== id)
state.rejected = state.rejected.filter(k => k.id !== id)
return true
})
return ok === true
}
// 分池恢复:rejected → candidate 重新送审(拒绝池唯一出边)
async function resubmit(id: string): Promise<boolean> {
const ok = await runWithCatch(state, t('knowledge.err.resubmitFailed'), async () => {
await knowledgeApi.updateStatus(id, 'candidate')
state.rejected = state.rejected.filter(k => k.id !== id)
return true
})
return ok === true
}
async function archive(id: string): Promise<boolean> {
const ok = await runWithCatch(state, t('knowledge.err.archiveFailed'), async () => {
await knowledgeApi.archive(id)
state.items = state.items.filter(k => k.id !== id)
state.candidates = state.candidates.filter(k => k.id !== id)
state.archived = state.archived.filter(k => k.id !== id)
return true
})
return ok === true
}
// ── 归档箱(2026-10-05 归档功能尾巴):查归档 + 恢复发布 ──
async function loadArchived() {
const seq = ++_itemsSeq
state.loading = true
state.error = null
try {
const result = await knowledgeApi.list('archived')
if (seq !== _itemsSeq) return
state.archived = result
} catch (e: any) {
if (seq !== _itemsSeq) return
state.error = e?.toString() ?? t('knowledge.err.loadFailed')
} finally {
if (seq === _itemsSeq) state.loading = false
}
}
// 拒绝池(2026-10-05 分池):与归档箱分开可见
async function loadRejected() {
const seq = ++_itemsSeq
state.loading = true
state.error = null
try {
const result = await knowledgeApi.list('rejected')
if (seq !== _itemsSeq) return
state.rejected = result
} catch (e: any) {
if (seq !== _itemsSeq) return
state.error = e?.toString() ?? t('knowledge.err.loadFailed')
} finally {
if (seq === _itemsSeq) state.loading = false
}
}
async function restore(id: string): Promise<boolean> {
// archived → published 状态机恢复边(后端 validate_transition 已放行)
const ok = await runWithCatch(state, t('knowledge.err.restoreFailed'), async () => {
await knowledgeApi.updateStatus(id, 'published')
state.archived = state.archived.filter(k => k.id !== id)
return true
})
return ok === true
@@ -228,12 +297,15 @@ export function useKnowledgeStore() {
// 状态用 getter 实时读 state(防快照不跟随,同 project store 模式)
get items() { return state.items },
get candidates() { return state.candidates },
get archived() { return state.archived },
get rejected() { return state.rejected },
get config() { return state.config },
get loading() { return state.loading },
get error() { return state.error },
candidateCount,
// actions
loadList, loadCandidates, search, create, updateStatus, archive,
loadList, loadCandidates, search, create, updateStatus, archive, loadArchived, restore,
reject, loadRejected, resubmit,
loadConfig, saveConfig, extractNow,
getDetail, update, getEvents,
clearError,
+65 -6
View File
@@ -28,6 +28,16 @@
{{ $t('knowledge.tab.inbox') }}
<span v-if="store.candidates.length" class="inbox-badge">{{ store.candidates.length }}</span>
</button>
<!-- 归档箱(2026-10-05 归档功能尾巴):低频可见可恢复,与回收站/正式库分池 -->
<button class="top-tab" :class="{ active: topTab === 'archived' }" @click="switchTopTab('archived')">
🗄️ {{ $t('knowledge.tab.archived') }}
<span v-if="store.archived.length" class="inbox-badge">{{ store.archived.length }}</span>
</button>
<!-- 拒绝池(2026-10-05 分池):审核否决独立可见,与归档分池 -->
<button class="top-tab" :class="{ active: topTab === 'rejected' }" @click="switchTopTab('rejected')">
🚫 {{ $t('knowledge.tab.rejected') }}
<span v-if="store.rejected.length" class="inbox-badge">{{ store.rejected.length }}</span>
</button>
</div>
<!-- 左右分栏: 列表 + 详情 -->
@@ -83,7 +93,7 @@
</div>
<div v-if="listItems.length === 0" class="list-empty">
<div>{{ topTab === 'inbox' ? $t('knowledge.emptyInbox') : $t('knowledge.emptyLibrary') }}</div>
<div>{{ topTab === 'inbox' ? $t('knowledge.emptyInbox') : topTab === 'rejected' ? $t('knowledge.emptyRejected') : topTab === 'archived' ? $t('knowledge.emptyArchived') : $t('knowledge.emptyLibrary') }}</div>
</div>
</div>
</div>
@@ -99,6 +109,8 @@
@publish="publishCurrent"
@reject="rejectCurrent"
@archive="archiveCurrent"
@restore="restoreCurrent"
@resubmit="resubmitCurrent"
/>
<div v-else class="detail-empty">
@@ -174,7 +186,7 @@ const { toast, showToast } = useToast()
// topTab/activeKind/searchQuery 持久化 localStorage(跨刷新保留用户视图态)
// 仅持久化视图态:不持久化详情选中(id 跨刷新可能已失效)。usePersistedRef 无类型校验,
// 解析失败/无效值回退 default,与 Tasks/Ideas 一致。
const topTab = usePersistedRef<'library' | 'inbox'>('knowledge.topTab', 'library')
const topTab = usePersistedRef<'library' | 'inbox' | 'rejected' | 'archived'>('knowledge.topTab', 'library')
const categories = [
{ key: 'all', icon: '📦' },
@@ -208,12 +220,16 @@ onUnmounted(() => {
}
})
function switchTopTab(tab: 'library' | 'inbox') {
function switchTopTab(tab: 'library' | 'inbox' | 'rejected' | 'archived') {
topTab.value = tab
selectedId.value = null
detail.value = null
if (tab === 'inbox') {
store.loadCandidates()
} else if (tab === 'rejected') {
store.loadRejected()
} else if (tab === 'archived') {
store.loadArchived()
} else {
store.loadList()
}
@@ -226,7 +242,10 @@ function clearError() {
// 左侧列表(随 Tab 切换数据源 + 知识库的分类过滤)
const listItems = computed(() => {
const src = topTab.value === 'inbox' ? store.candidates : store.items
const src = topTab.value === 'inbox' ? store.candidates
: topTab.value === 'rejected' ? store.rejected
: topTab.value === 'archived' ? store.archived
: store.items
if (topTab.value === 'library' && activeKind.value !== 'all') {
return src.filter(i => i.kind === activeKind.value)
}
@@ -312,9 +331,10 @@ async function rejectCurrent() {
const idx = store.candidates.findIndex(c => c.id === currentId)
submitting.value = true
try {
const ok = await store.archive(currentId)
// 分池(2026-10-05):审核否决走 rejected 独立池,与 archived 分开
const ok = await store.reject(currentId)
if (!ok) {
showToast(t('knowledge.err.archiveFailed'), 'error')
showToast(t('knowledge.err.rejectFailed'), 'error')
return
}
await selectNextCandidate(idx)
@@ -348,6 +368,44 @@ async function archiveCurrent() {
}
}
// ===== 归档箱恢复(2026-10-05 归档功能尾巴) =====
async function restoreCurrent() {
if (!detail.value) return
const currentId = detail.value.knowledge.id
submitting.value = true
try {
const ok = await store.restore(currentId)
if (!ok) {
showToast(t('knowledge.err.restoreFailed'), 'error')
return
}
selectedId.value = null
detail.value = null
showToast(t('knowledge.restoredToast'), 'success')
} finally {
submitting.value = false
}
}
// ===== 拒绝池恢复(2026-10-05 分池):rejected → candidate 重新送审 =====
async function resubmitCurrent() {
if (!detail.value) return
const currentId = detail.value.knowledge.id
submitting.value = true
try {
const ok = await store.resubmit(currentId)
if (!ok) {
showToast(t('knowledge.err.resubmitFailed'), 'error')
return
}
selectedId.value = null
detail.value = null
showToast(t('knowledge.resubmittedToast'), 'success')
} finally {
submitting.value = false
}
}
// 当前条目从 candidates 移除后,选中同位置后续条目;末尾则清空详情
// 实现策略:操作前记下 currentId 在原列表中的 index,移除后从剩余列表的同
// index 取(若无则取前一条),保证「下一条」语义而非跳回开头
@@ -510,6 +568,7 @@ onMounted(() => {
.st-pending_review { background: rgba(108,159,255,0.15); color: var(--df-info); }
.st-published { background: rgba(100,255,218,0.15); color: var(--df-success); }
.st-archived { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
.st-rejected { background: rgba(240,84,110,0.15); color: var(--df-danger); }
.conf-high { background: rgba(100,255,218,0.15); color: var(--df-success); padding: 1px 6px; border-radius: var(--df-radius-xs); }
.conf-medium { background: rgba(255,217,61,0.15); color: var(--df-warning); padding: 1px 6px; border-radius: var(--df-radius-xs); }
.conf-low { background: rgba(255,107,107,0.15); color: var(--df-danger); padding: 1px 6px; border-radius: var(--df-radius-xs); }