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

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
+157 -3
View File
@@ -547,16 +547,24 @@ impl IdeaRepo {
.transaction() .transaction()
.map_err(storage_err)?; .map_err(storage_err)?;
// 1. 读主体当前 related_ids // 1. 读主体当前 related_ids — 同段校验主体存在且未软删(deleted_at IS NULL)。
// 治走查🟡:原仅读 related_ids,主体不存在/已软删时步骤 5 UPDATE 静默 affected=0
// 仍提交,而步骤 3/4 已改写目标侧 related_ids → 「目标有关联、主体无记录」单向脏关联。
// 查无立即 Err 回滚整个事务,目标侧零改写。
let old_raw: Option<String> = tx let old_raw: Option<String> = tx
.query_row( .query_row(
"SELECT related_ids FROM ideas WHERE id = ?1", "SELECT related_ids FROM ideas WHERE id = ?1 AND deleted_at IS NULL",
params![subject_id], params![subject_id],
|row| row.get(0), |row| row.get(0),
) )
.optional() .optional()
.map_err(storage_err)? .map_err(storage_err)?
.flatten(); .flatten()
.ok_or_else(|| {
df_types::error::Error::Storage(format!(
"灵感 {subject_id} 不存在或已删除,无法建立关联"
))
})?;
// 2. 解析新旧集合 // 2. 解析新旧集合
let old_set: std::collections::HashSet<String> = let old_set: std::collections::HashSet<String> =
@@ -815,6 +823,82 @@ impl KnowledgeRepo {
.map_err(storage_err)? .map_err(storage_err)?
} }
/// 项目维度软排序检索(跨会话召回最小闭环,D2 拍板:软排序非硬过滤)。
///
/// 排序语义:同项目(source_project = ?)第一档 > 全局(IS NULL)第二档 > 异项目第三档;
/// 同档内保持原有 title 命中分 → reuse_count → created_at 顺序。`limit` 不变只改顺序,
/// token 膨胀为零。`project_id = None` 时行为与 [`Self::search_keywords`] 逐字一致(回归门)。
///
/// 与 [`Self::search_keywords`] 的关系:后者是本方法的 project_id=None 特例(旧签名
/// 保留给 MCP 等外部调用方,内部转调本方法,零重复实现)。
pub async fn search_keywords_scoped(
&self,
keywords: &[String],
include_candidates: bool,
limit: usize,
project_id: Option<&str>,
) -> Result<Vec<KnowledgeRecord>> {
if project_id.is_none() {
return self.search_keywords(keywords, include_candidates, limit).await;
}
if keywords.is_empty() {
return Ok(Vec::new());
}
let project_id = project_id.unwrap().to_string();
let conn = self.conn.clone();
// 每个关键词占一个编号占位符(1..n);project_id 占 ?(n+1),WHERE 的 ORDER BY CASE
// 与三档投影各复用同号(SQLite 允许 ?N 复用)。patterns 只装关键词一份。
let mut patterns: Vec<String> = Vec::with_capacity(keywords.len());
let mut where_parts: Vec<String> = Vec::with_capacity(keywords.len());
let mut order_terms: Vec<String> = Vec::with_capacity(keywords.len() + 1);
for (i, kw) in keywords.iter().enumerate() {
let n = i + 1;
where_parts.push(format!(
"(title LIKE ?{n} ESCAPE '|' OR content LIKE ?{n} ESCAPE '|')"
));
order_terms.push(format!("CASE WHEN title LIKE ?{n} ESCAPE '|' THEN 1 ELSE 0 END"));
patterns.push(format!("%{}%", escape_like(kw)));
}
// 项目档位表达式复用 ?(n+1):0=同项目 1=全局(NULL) 2=异项目
// 档位是**第一排序键**(ASC:0 最前),title 命分是同档内第二键(DESC)——
// 若档位与 title 分相加,档位会被 title 分稀释(异项目 title 高分压过同项目)。
let pid_n = keywords.len() + 1;
let project_tier =
format!("CASE WHEN source_project = ?{pid_n} THEN 0 WHEN source_project IS NULL THEN 1 ELSE 2 END");
let title_terms = order_terms.join(" + ");
let status_clause = if include_candidates {
"status IN ('published','candidate')"
} else {
"status = 'published'"
};
let limit_i = limit as i64;
let sql = format!(
"SELECT {KNOWLEDGE_COLS} FROM knowledges \
WHERE {status_clause} AND ({}) \
ORDER BY ({project_tier}) ASC, ({title_terms}) DESC, reuse_count DESC, created_at DESC LIMIT ?{}",
where_parts.join(" OR "),
pid_n + 1,
);
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut param_refs: Vec<&dyn rusqlite::ToSql> =
patterns.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
param_refs.push(&project_id);
param_refs.push(&limit_i);
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
let rows = stmt
.query_map(param_refs.as_slice(), |row| knowledge_from_row(row))
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
/// 按状态列出(审核收件箱用): 按 confidence 语义排序(high>medium>low),次按 created_at /// 按状态列出(审核收件箱用): 按 confidence 语义排序(high>medium>low),次按 created_at
/// ///
/// 用 CASE WHEN 替代纯 TEXT 排序(字典序 high>low>medium 非预期语义)。 /// 用 CASE WHEN 替代纯 TEXT 排序(字典序 high>low>medium 非预期语义)。
@@ -1544,6 +1628,76 @@ mod tests {
assert_eq!(hits[0].id, "pub1"); assert_eq!(hits[0].id, "pub1");
} }
// ---------- search_keywords_scoped(跨会话召回 D2 软排序) ----------
/// 同项目知识排前:同档内 title 命分定序,异档同项目压过异项目高复用
#[tokio::test]
async fn scoped_same_project_ranks_above_foreign_despite_reuse() {
let repo = setup_repo().await;
// 外项目知识:标题命中 + 复用 999(档位应压过复用)
let mut foreign = krec("foreign", "Rust 所有权", "...", "lesson", "published", None, 999);
foreign.source_project = Some("other-proj".to_string());
repo.insert(foreign).await.unwrap();
// 同项目(proj-1,krec 默认):内容命中 + 复用 1
repo.insert(krec("same", "无关标题", "聊聊 Rust 所有权", "lesson", "published", None, 1))
.await
.unwrap();
let kws = vec!["所有权".to_string(), "Rust".to_string()];
let hits = repo
.search_keywords_scoped(&kws, false, 10, Some("proj-1"))
.await
.unwrap();
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].id, "same", "同项目知识应压过异项目(即便后者 title 命中+高复用)");
assert_eq!(hits[1].id, "foreign");
}
/// 全局知识(NULL)居中档:同项目 > 全局 > 异项目
#[tokio::test]
async fn scoped_global_null_tier_between_same_and_foreign() {
let repo = setup_repo().await;
let mut foreign = krec("foreign", "Rust 异步", "...", "lesson", "published", None, 1);
foreign.source_project = Some("other-proj".to_string());
repo.insert(foreign).await.unwrap();
let mut global = krec("global", "Rust 生命周期", "...", "lesson", "published", None, 1);
global.source_project = None;
repo.insert(global).await.unwrap();
repo.insert(krec("same", "Rust 借用", "...", "lesson", "published", None, 1))
.await
.unwrap();
let kws = vec!["Rust".to_string()];
let hits = repo
.search_keywords_scoped(&kws, false, 10, Some("proj-1"))
.await
.unwrap();
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
assert_eq!(ids, vec!["same", "global", "foreign"], "三档顺序: 同项目>全局>异项目");
}
/// 回归门:project_id=None 与旧 search_keywords 行为逐字一致(同输入同顺序)
#[tokio::test]
async fn scoped_none_project_matches_legacy_search_keywords() {
let repo = setup_repo().await;
let mut foreign = krec("foreign", "Rust 异步", "...", "lesson", "published", None, 50);
foreign.source_project = Some("other-proj".to_string());
repo.insert(foreign).await.unwrap();
repo.insert(krec("same", "Rust 借用", "...", "lesson", "published", None, 1))
.await
.unwrap();
let kws = vec!["Rust".to_string()];
let legacy = repo.search_keywords(&kws, false, 10).await.unwrap();
let scoped = repo
.search_keywords_scoped(&kws, false, 10, None)
.await
.unwrap();
let a: Vec<_> = legacy.iter().map(|h| h.id.as_str()).collect();
let b: Vec<_> = scoped.iter().map(|h| h.id.as_str()).collect();
assert_eq!(a, b, "project_id=None 应与旧路径行为逐字一致(回归门)");
}
#[tokio::test] #[tokio::test]
async fn search_keywords_like_wildcards_escaped() { async fn search_keywords_like_wildcards_escaped() {
let repo = setup_repo().await; let repo = setup_repo().await;
+38 -7
View File
@@ -46,15 +46,21 @@ pub struct KnowledgeSearchInput {
/// 状态转换合法矩阵校验 /// 状态转换合法矩阵校验
/// ///
/// candidate → pending_review | published | archived /// candidate → pending_review | published | rejected | archived
/// pending_review → published | archived /// pending_review → published | rejected | archived
/// published → archived /// published → archived
/// rejected → candidate(重新送审;rejected 独立分池语义:拒绝≠归档,
/// 被否决的知识可修改后重审,也可留在拒绝池沉淀——与 archived 分开可见)
/// archived → published(归档箱恢复;2026-10-05 归档功能尾巴——原 archived 为汇点,
/// 归档即不可逆,与项目/任务/灵感/会话归档「可逆语义」不一致)
/// (其他组合非法) /// (其他组合非法)
fn validate_transition(from: &str, to: &str) -> Result<(), String> { fn validate_transition(from: &str, to: &str) -> Result<(), String> {
let legal = match from { let legal = match from {
"candidate" => matches!(to, "pending_review" | "published" | "archived"), "candidate" => matches!(to, "pending_review" | "published" | "rejected" | "archived"),
"pending_review" => matches!(to, "published" | "archived"), "pending_review" => matches!(to, "published" | "rejected" | "archived"),
"published" => matches!(to, "archived"), "published" => matches!(to, "archived"),
"rejected" => matches!(to, "candidate"),
"archived" => matches!(to, "published"),
_ => false, _ => false,
}; };
if legal { if legal {
@@ -500,6 +506,8 @@ mod tests {
fn pending_review_legal_transitions() { fn pending_review_legal_transitions() {
assert!(validate_transition("pending_review", "published").is_ok()); assert!(validate_transition("pending_review", "published").is_ok());
assert!(validate_transition("pending_review", "archived").is_ok()); assert!(validate_transition("pending_review", "archived").is_ok());
// 分池:审核否决走 rejected(独立池),不再混入 archived
assert!(validate_transition("pending_review", "rejected").is_ok());
} }
#[test] #[test]
@@ -524,9 +532,32 @@ mod tests {
} }
#[test] #[test]
fn archived_is_terminal() { fn archived_restore_to_published_legal() {
// 归档为终态,任何转换都非法 // 归档箱恢复(2026-10-05 归档功能尾巴):archived → published 唯一出边
for to in ["candidate", "pending_review", "published", "archived"] { assert!(validate_transition("archived", "published").is_ok());
}
#[test]
fn rejected_legal_transitions() {
// 分池语义:rejected 唯一出边=回 candidate 重新送审(修改后重审)
assert!(validate_transition("candidate", "rejected").is_ok());
assert!(validate_transition("pending_review", "rejected").is_ok());
assert!(validate_transition("rejected", "candidate").is_ok());
}
#[test]
fn rejected_illegal_transitions() {
// 拒绝池不可直接发布(必须走重审 candidate → published)、不可归档/自转
for to in ["published", "pending_review", "archived", "rejected"] {
assert!(validate_transition("rejected", to).is_err(),
"rejected → {to} 应为非法");
}
}
#[test]
fn archived_illegal_transitions() {
// 归档态除恢复发布外均非法(不回 candidate/pending_review,不自转)
for to in ["candidate", "pending_review", "archived"] {
assert!(validate_transition("archived", to).is_err(), assert!(validate_transition("archived", to).is_err(),
"archived → {to} 应为非法"); "archived → {to} 应为非法");
} }
+1 -1
View File
@@ -46,7 +46,7 @@ function toggleExpanded(current: boolean) {
:title="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')" :title="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')"
:aria-label="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" /> <path d="M2 3l3 4 3-4" />
</svg> </svg>
<span class="ai-enrichment-badge">{{ enrichmentBadgeText }}</span> <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-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> <button class="btn btn-ghost btn-sm" :disabled="submitting" @click="emit('reject')">{{ t('knowledge.reject') }}</button>
</template> </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> <button v-else class="btn btn-ghost btn-sm" :disabled="submitting" @click="emit('archive')">{{ t('knowledge.archive') }}</button>
</div> </div>
<div class="detail-actions" v-else> <div class="detail-actions" v-else>
@@ -155,7 +163,7 @@ import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '@/api/types'
// 编辑保存与审核操作通过 emit 回传父级执行 IPC(publish/reject/archive/save)。 // 编辑保存与审核操作通过 emit 回传父级执行 IPC(publish/reject/archive/save)。
const props = defineProps<{ const props = defineProps<{
detail: KnowledgeDetailPayload detail: KnowledgeDetailPayload
topTab: 'library' | 'inbox' topTab: 'library' | 'inbox' | 'rejected' | 'archived'
submitting: boolean submitting: boolean
}>() }>()
@@ -164,6 +172,8 @@ const emit = defineEmits<{
(e: 'publish'): void (e: 'publish'): void
(e: 'reject'): void (e: 'reject'): void
(e: 'archive'): void (e: 'archive'): void
(e: 'restore'): void
(e: 'resubmit'): void
}>() }>()
const { t } = useI18n() const { t } = useI18n()
+12 -1
View File
@@ -31,6 +31,11 @@ import { setContextCallbacks } from './useAiEvents'
// 若需 per-window 独立压缩态需改为事件同步/inject-provide,改造成本大,暂不处理。 // 若需 per-window 独立压缩态需改为事件同步/inject-provide,改造成本大,暂不处理。
const isCompressing = ref(false) 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) callbacks.onCompressed?.(convId, summary)
}, },
onAutoCompressed: () => { onAutoCompressed: () => {
// loop 自动压缩静默——仅复位 loading(冗余兜底,Auto 路径前端 loading 本就 false) // loop 自动压缩:B4 可见信号——置 flash(输入区 hint 行短暂显示「上下文已自动压缩」),
// 4s 自灭;仍不弹 toast、不刷新视图(不打断 LLM 流,消息本就可见非折叠困惑)。
isCompressing.value = false isCompressing.value = false
autoCompressedFlash.value = true
if (autoCompressedFlashTimer) clearTimeout(autoCompressedFlashTimer)
autoCompressedFlashTimer = setTimeout(() => { autoCompressedFlash.value = false }, 4000)
}, },
onCleared: (convId) => callbacks.onCleared?.(convId), onCleared: (convId) => callbacks.onCleared?.(convId),
onError: (convId, message) => { onError: (convId, message) => {
@@ -108,6 +117,8 @@ export function useAiContext() {
return { return {
// 状态 // 状态
isCompressing, isCompressing,
// B4: 自动压缩完成一次性可见信号(输入区 hint 行消费,4s 自灭)
autoCompressedFlash,
// 操作 // 操作
clearContext, clearContext,
compressContext, compressContext,
+15 -1
View File
@@ -6,20 +6,30 @@ export default {
library: 'Library', library: 'Library',
// inbox = pending items (candidate + pending_review); library is published-only (F-260616-02 decision a) // inbox = pending items (candidate + pending_review); library is published-only (F-260616-02 decision a)
inbox: 'Pending', 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...', searchPlaceholder: 'Search title, tags, content...',
categoryAll: 'All', categoryAll: 'All',
emptyInbox: '✅ No pending items', emptyInbox: '✅ No pending items',
emptyLibrary: '📭 No matching knowledge', emptyLibrary: '📭 No matching knowledge',
emptyArchived: '🗄️ Archive box is empty',
emptyRejected: '🚫 No rejected knowledge',
edit: '✏️ Edit', edit: '✏️ Edit',
publish: 'Publish', publish: 'Publish',
reject: 'Reject', reject: 'Reject',
archive: 'Archive', archive: 'Archive',
restore: 'Restore',
resubmit: 'Resubmit',
// ⑤ Operation feedback toast // ⑤ Operation feedback toast
savedToast: 'Saved', savedToast: 'Saved',
publishedToast: 'Published to library', publishedToast: 'Published to library',
rejectedToast: 'Rejected', rejectedToast: 'Rejected, moved to rejected pool',
archivedToast: 'Archived', archivedToast: 'Archived',
restoredToast: 'Restored to library',
resubmittedToast: 'Resubmitted, moved to pending',
createdToast: 'Created, moved to pending', createdToast: 'Created, moved to pending',
reuseCount: '🔄 Reused {n} times', reuseCount: '🔄 Reused {n} times',
confidenceBadge: 'Confidence: {label}', confidenceBadge: 'Confidence: {label}',
@@ -66,6 +76,7 @@ export default {
pending_review: 'Reviewing', pending_review: 'Reviewing',
published: 'Published', published: 'Published',
archived: 'Archived', archived: 'Archived',
rejected: 'Rejected',
}, },
confidence: { confidence: {
high: 'High', high: 'High',
@@ -89,6 +100,9 @@ export default {
createFailed: 'Failed to create knowledge', createFailed: 'Failed to create knowledge',
updateStatusFailed: 'Failed to update status', updateStatusFailed: 'Failed to update status',
archiveFailed: 'Failed to archive', archiveFailed: 'Failed to archive',
restoreFailed: 'Failed to restore',
rejectFailed: 'Failed to reject',
resubmitFailed: 'Failed to resubmit',
saveFailed: 'Failed to save', saveFailed: 'Failed to save',
loadConfigFailed: 'Failed to load config', loadConfigFailed: 'Failed to load config',
saveConfigFailed: 'Failed to save config', saveConfigFailed: 'Failed to save config',
+15 -1
View File
@@ -7,6 +7,10 @@ export default {
library: '知识库', library: '知识库',
// inbox = 待处理收件箱(candidate + pending_review);library 纯 published(F-260616-02 决策 a) // inbox = 待处理收件箱(candidate + pending_review);library 纯 published(F-260616-02 决策 a)
inbox: '待处理', inbox: '待处理',
// 归档箱(2026-10-05 归档功能尾巴):低频可见可恢复
archived: '归档箱',
// 拒绝池(2026-10-05 分池):审核否决独立可见
rejected: '已拒绝',
}, },
// 搜索 // 搜索
searchPlaceholder: '搜索标题、标签、内容...', searchPlaceholder: '搜索标题、标签、内容...',
@@ -15,16 +19,22 @@ export default {
// 列表空状态 // 列表空状态
emptyInbox: '✅ 暂无待处理条目', emptyInbox: '✅ 暂无待处理条目',
emptyLibrary: '📭 没有匹配的知识', emptyLibrary: '📭 没有匹配的知识',
emptyArchived: '🗄️ 归档箱为空',
emptyRejected: '🚫 没有被拒绝的知识',
// 详情头部操作 // 详情头部操作
edit: '✏️ 编辑', edit: '✏️ 编辑',
publish: '发布', publish: '发布',
reject: '拒绝', reject: '拒绝',
archive: '归档', archive: '归档',
restore: '恢复发布',
resubmit: '重新送审',
// ⑤ 操作反馈 toast // ⑤ 操作反馈 toast
savedToast: '已保存', savedToast: '已保存',
publishedToast: '已发布到知识库', publishedToast: '已发布到知识库',
rejectedToast: '已拒绝', rejectedToast: '已拒绝,进入拒绝池',
archivedToast: '已归档', archivedToast: '已归档',
restoredToast: '已恢复到知识库',
resubmittedToast: '已重新送审,进入待处理',
createdToast: '已创建,进入待处理', createdToast: '已创建,进入待处理',
// 详情徽章 // 详情徽章
reuseCount: '🔄 复用 {n} 次', reuseCount: '🔄 复用 {n} 次',
@@ -80,6 +90,7 @@ export default {
pending_review: '审核中', pending_review: '审核中',
published: '已发布', published: '已发布',
archived: '已归档', archived: '已归档',
rejected: '已拒绝',
}, },
// 置信度标签 // 置信度标签
confidence: { confidence: {
@@ -105,6 +116,9 @@ export default {
createFailed: '创建知识失败', createFailed: '创建知识失败',
updateStatusFailed: '更新状态失败', updateStatusFailed: '更新状态失败',
archiveFailed: '归档失败', archiveFailed: '归档失败',
restoreFailed: '恢复失败',
rejectFailed: '拒绝失败',
resubmitFailed: '重新送审失败',
saveFailed: '保存失败', saveFailed: '保存失败',
loadConfigFailed: '加载配置失败', loadConfigFailed: '加载配置失败',
saveConfigFailed: '保存配置失败', saveConfigFailed: '保存配置失败',
+73 -1
View File
@@ -17,6 +17,8 @@ import type {
const state = reactive({ const state = reactive({
items: [] as KnowledgeRecord[], items: [] as KnowledgeRecord[],
candidates: [] as KnowledgeRecord[], candidates: [] as KnowledgeRecord[],
archived: [] as KnowledgeRecord[],
rejected: [] as KnowledgeRecord[],
config: null as KnowledgeConfig | null, config: null as KnowledgeConfig | null,
loading: false, loading: false,
error: null as string | null, error: null as string | null,
@@ -161,11 +163,78 @@ export function useKnowledgeStore() {
return ok === true 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> { async function archive(id: string): Promise<boolean> {
const ok = await runWithCatch(state, t('knowledge.err.archiveFailed'), async () => { const ok = await runWithCatch(state, t('knowledge.err.archiveFailed'), async () => {
await knowledgeApi.archive(id) await knowledgeApi.archive(id)
state.items = state.items.filter(k => k.id !== id) state.items = state.items.filter(k => k.id !== id)
state.candidates = state.candidates.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 true
}) })
return ok === true return ok === true
@@ -228,12 +297,15 @@ export function useKnowledgeStore() {
// 状态用 getter 实时读 state(防快照不跟随,同 project store 模式) // 状态用 getter 实时读 state(防快照不跟随,同 project store 模式)
get items() { return state.items }, get items() { return state.items },
get candidates() { return state.candidates }, get candidates() { return state.candidates },
get archived() { return state.archived },
get rejected() { return state.rejected },
get config() { return state.config }, get config() { return state.config },
get loading() { return state.loading }, get loading() { return state.loading },
get error() { return state.error }, get error() { return state.error },
candidateCount, candidateCount,
// actions // actions
loadList, loadCandidates, search, create, updateStatus, archive, loadList, loadCandidates, search, create, updateStatus, archive, loadArchived, restore,
reject, loadRejected, resubmit,
loadConfig, saveConfig, extractNow, loadConfig, saveConfig, extractNow,
getDetail, update, getEvents, getDetail, update, getEvents,
clearError, clearError,
+65 -6
View File
@@ -28,6 +28,16 @@
{{ $t('knowledge.tab.inbox') }} {{ $t('knowledge.tab.inbox') }}
<span v-if="store.candidates.length" class="inbox-badge">{{ store.candidates.length }}</span> <span v-if="store.candidates.length" class="inbox-badge">{{ store.candidates.length }}</span>
</button> </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> </div>
<!-- 左右分栏: 列表 + 详情 --> <!-- 左右分栏: 列表 + 详情 -->
@@ -83,7 +93,7 @@
</div> </div>
<div v-if="listItems.length === 0" class="list-empty"> <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> </div>
</div> </div>
@@ -99,6 +109,8 @@
@publish="publishCurrent" @publish="publishCurrent"
@reject="rejectCurrent" @reject="rejectCurrent"
@archive="archiveCurrent" @archive="archiveCurrent"
@restore="restoreCurrent"
@resubmit="resubmitCurrent"
/> />
<div v-else class="detail-empty"> <div v-else class="detail-empty">
@@ -174,7 +186,7 @@ const { toast, showToast } = useToast()
// topTab/activeKind/searchQuery 持久化 localStorage(跨刷新保留用户视图态) // topTab/activeKind/searchQuery 持久化 localStorage(跨刷新保留用户视图态)
// 仅持久化视图态:不持久化详情选中(id 跨刷新可能已失效)。usePersistedRef 无类型校验, // 仅持久化视图态:不持久化详情选中(id 跨刷新可能已失效)。usePersistedRef 无类型校验,
// 解析失败/无效值回退 default,与 Tasks/Ideas 一致。 // 解析失败/无效值回退 default,与 Tasks/Ideas 一致。
const topTab = usePersistedRef<'library' | 'inbox'>('knowledge.topTab', 'library') const topTab = usePersistedRef<'library' | 'inbox' | 'rejected' | 'archived'>('knowledge.topTab', 'library')
const categories = [ const categories = [
{ key: 'all', icon: '📦' }, { key: 'all', icon: '📦' },
@@ -208,12 +220,16 @@ onUnmounted(() => {
} }
}) })
function switchTopTab(tab: 'library' | 'inbox') { function switchTopTab(tab: 'library' | 'inbox' | 'rejected' | 'archived') {
topTab.value = tab topTab.value = tab
selectedId.value = null selectedId.value = null
detail.value = null detail.value = null
if (tab === 'inbox') { if (tab === 'inbox') {
store.loadCandidates() store.loadCandidates()
} else if (tab === 'rejected') {
store.loadRejected()
} else if (tab === 'archived') {
store.loadArchived()
} else { } else {
store.loadList() store.loadList()
} }
@@ -226,7 +242,10 @@ function clearError() {
// 左侧列表(随 Tab 切换数据源 + 知识库的分类过滤) // 左侧列表(随 Tab 切换数据源 + 知识库的分类过滤)
const listItems = computed(() => { 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') { if (topTab.value === 'library' && activeKind.value !== 'all') {
return src.filter(i => i.kind === activeKind.value) return src.filter(i => i.kind === activeKind.value)
} }
@@ -312,9 +331,10 @@ async function rejectCurrent() {
const idx = store.candidates.findIndex(c => c.id === currentId) const idx = store.candidates.findIndex(c => c.id === currentId)
submitting.value = true submitting.value = true
try { try {
const ok = await store.archive(currentId) // 分池(2026-10-05):审核否决走 rejected 独立池,与 archived 分开
const ok = await store.reject(currentId)
if (!ok) { if (!ok) {
showToast(t('knowledge.err.archiveFailed'), 'error') showToast(t('knowledge.err.rejectFailed'), 'error')
return return
} }
await selectNextCandidate(idx) 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 移除后,选中同位置后续条目;末尾则清空详情 // 当前条目从 candidates 移除后,选中同位置后续条目;末尾则清空详情
// 实现策略:操作前记下 currentId 在原列表中的 index,移除后从剩余列表的同 // 实现策略:操作前记下 currentId 在原列表中的 index,移除后从剩余列表的同
// index 取(若无则取前一条),保证「下一条」语义而非跳回开头 // index 取(若无则取前一条),保证「下一条」语义而非跳回开头
@@ -510,6 +568,7 @@ onMounted(() => {
.st-pending_review { background: rgba(108,159,255,0.15); color: var(--df-info); } .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-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-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-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-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); } .conf-low { background: rgba(255,107,107,0.15); color: var(--df-danger); padding: 1px 6px; border-radius: var(--df-radius-xs); }