@@ -234,6 +242,8 @@ const kgDown = computed(() => !bench.kgReady && bench.kgStatus != null);
}
.offline-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--pa-error); flex: none; }
.offline-text { font-size: 12px; color: var(--pa-error); }
+/* 缺陷 #32:空结果提示——非错误态(200 空 results 属正常),用中性副文案色 */
+.no-result-note { font-size: 12px; color: var(--pa-ink-subtle); }
.degrade-note { font-size: 11.5px; color: var(--pa-ink-subtle); }
.node-list {
display: flex;
diff --git a/ui/src/components/bench/SourceRow.vue b/ui/src/components/bench/SourceRow.vue
index 87a583b..82345c5 100644
--- a/ui/src/components/bench/SourceRow.vue
+++ b/ui/src/components/bench/SourceRow.vue
@@ -47,6 +47,18 @@ const badgeTitle = computed(() => {
return raw;
});
+// 缺陷 #27:title 渲染空值 fallback——后端未回填 title 时绝不渲染空文本
+// (空文本会让条目不可读、视觉上「串行」),也绝不让正文片段被当成标题。
+// 显示优先级:非空 title > paper_id > id > i18n 占位「(无标题)」。
+const displayTitle = computed(() => {
+ const s = props.source || {};
+ const t = s.title;
+ if (t != null && String(t).trim() !== '') return String(t);
+ const raw = badgeTitle.value;
+ if (raw) return raw;
+ return T.sourceNoTitle;
+});
+
const metaParts = computed(() => {
const s = props.source || {};
const parts = [];
@@ -63,7 +75,7 @@ const metaParts = computed(() => {
- {{ source.title }}
+ {{ displayTitle }}
{{ metaParts.join(' · ') }}
diff --git a/ui/src/components/bench/SourcesPane.vue b/ui/src/components/bench/SourcesPane.vue
index a97ad7b..bc2be37 100644
--- a/ui/src/components/bench/SourcesPane.vue
+++ b/ui/src/components/bench/SourcesPane.vue
@@ -11,11 +11,14 @@ import ChainGroup from './ChainGroup.vue';
const bench = useBenchStore();
const chains = ref({}); // chain_id → open
-// 会话级聚合按需拉取(仅当用户切到「本会话全部」且尚无缓存时)
+// 会话级聚合按需拉取(仅当用户切到「本会话全部」且尚无缓存时);
+// 缺陷 #27:新回答完成后 chat.js 终态置 sourcesStale → 自动 force 重拉
+//(无需手动切 TAB/刷新页面),拉取成功后标记由 store 内部清除。
watch(
- () => [bench.sourceScope, bench.currentAssistantMessage],
+ () => [bench.sourceScope, bench.currentAssistantMessage, bench.sourcesStale],
([scope]) => {
- if (scope === 'session' && !bench.sessionSources && bench._activeSessionId()) {
+ if (scope === 'session' && bench._activeSessionId()
+ && (!bench.sessionSources || bench.sourcesStale)) {
bench.loadSessionSources().catch(() => {});
}
},
diff --git a/ui/src/i18n/labels.js b/ui/src/i18n/labels.js
index 18ee371..2126926 100644
--- a/ui/src/i18n/labels.js
+++ b/ui/src/i18n/labels.js
@@ -134,6 +134,11 @@ export const T = {
extractUploadFail: '上传失败:',
kgEmpty: '本次检索未产生图谱关系,可使用下方搜索浏览知识库',
kgSearchPlaceholder: '搜索实体名,如 OsSPL14',
+ // 缺陷 #32:搜索成功但无结果(200 + results:[])的明确提示——与 kgEmpty(初始空态)、
+ // kgDown(服务不可用/超时)三态区分、触发条件互斥
+ kgNoResult: '未找到相关实体',
+ // 缺陷 #27:文献来源条目 title 为空时的占位(绝不渲染空文本或正文片段)
+ sourceNoTitle: '(无标题)',
chainBadge: '跳',
sessionEmptyTitle: '海古纪 PlantAI 植物科研大模型',
sessionEmptySub: '基于文献知识库的基因家族问答 —— 每一次提问都有证据锚定',
diff --git a/ui/src/intent.js b/ui/src/intent.js
index 4bb07ca..e51b6f6 100644
--- a/ui/src/intent.js
+++ b/ui/src/intent.js
@@ -3,7 +3,9 @@
// 判定顺序(优先级从高到低,命中即返回;漏判安全:不命中一律 qa):
// 1. export:hasActiveArtifact 且含 EXPORT_WORD 且含 FORMAT_WORD
// 2. edit: hasActiveArtifact 且含 EDIT_WORD
-// 3. docgen:含 WRITE_VERB 且含 DOC_TYPE(排除词优先,防误判)
+// 3. docgen:含 WRITE_VERB 且含 DOC_TYPE(排除词优先,防误判;
+// 缺陷 #52a 细化:纯总结类动词句的「总结」自身不作 DOC_TYPE 证据,
+// 须命中其他文档类型词如「论文」才进 docgen)
// 4. docgen:hasAttachments 且含 WRITE_VERB(附件兜底信号)
// 5. qa
@@ -16,7 +18,12 @@ const FORMAT_WORD = ['pdf', 'docx', 'doc', 'word'];
const EDIT_WORD = ['改', '修改', '调整', '删', '删除', '加一段', '补充', '润色', '缩写', '扩写', '重写', '改写', '优化', '加个', '增加一段'];
// —— 写文档意图词典 ——
-const WRITE_VERB = ['写', '撰写', '拟', '起草', '编制', '生成', '编写', '拟定', '草拟', '制定'];
+// 缺陷 #52a:补「总结/概括/归纳」——「帮我总结论文」+附件走 docgen(规则 4 附件兜底)。
+// 纯总结类动词句(无其他写动词、无附件)须命中「论文」等其他文档类型词才进
+// docgen(规则 3 细化,详见 WRITE_VERB_SUMMARIZE_EXCLUDE 注释)——
+// 无附件「总结一下植物光合作用的过程」仍走 QA(漏判安全)。
+// 后端同词典:sdk/app/services/docgen/intent.py 的 WRITE_VERB(由 Dev-B1 同步)。
+const WRITE_VERB = ['写', '撰写', '拟', '起草', '编制', '生成', '编写', '拟定', '草拟', '制定', '总结', '概括', '归纳'];
const DOC_TYPE = [
'论文', '报告', '通知', '通告', '公告', '请示', '批复', '函', '申请书', '申请',
'申报书', '标书', '合同', '协议', '方案', '计划', '总结', '汇报', '讲话稿', '致辞',
@@ -28,6 +35,15 @@ const DOC_TYPE = [
];
// 明确排除(不属于 DOC_TYPE,防误判):介绍、内容、代码、诗、作业、问题、建议、想法
const DOC_TYPE_EXCLUDE = ['介绍', '内容', '代码', '诗', '作业', '问题', '建议', '想法'];
+// 缺陷 #52a:「总结/概括/归纳」入 WRITE_VERB 后,「总结」本身会命中 DOC_TYPE
+// (如「总结一下植物光合作用的过程」无附件也被规则 3 拉进 docgen)。
+// 防护语义(TL 裁决):句子的写动词仅由总结类新动词构成时,「总结」自身
+// 不作 DOC_TYPE 证据——必须有其他文档类型词(如「总结这篇论文」的「论文」)
+// 才进 docgen;带附件场景由规则 4 兜底,不受影响。
+// 「写一篇总结」「写一份工作总结」等既有写类动词 + 总结类文档词路径不受影响。
+const WRITE_VERB_SUMMARIZE_EXCLUDE = ['总结', '概括', '归纳'];
+const WRITE_VERB_NON_SUMMARIZE = WRITE_VERB.filter((w) => !WRITE_VERB_SUMMARIZE_EXCLUDE.includes(w));
+const DOC_TYPE_NON_SUMMARIZE = DOC_TYPE.filter((w) => !WRITE_VERB_SUMMARIZE_EXCLUDE.includes(w));
/** 文本是否含词典中任一词(子串判定;英文不区分大小写——中文不受影响,与后端一致) */
export function hasAnyWord(text, words) {
@@ -43,6 +59,21 @@ export function hasAnyExcludeWord(text) {
return hasAnyWord(text, DOC_TYPE_EXCLUDE);
}
+/** 缺陷 #52a:文本含总结类新动词 → 规则 3 需额外校验 */
+export function hasAnySummarizeVerb(text) {
+ return hasAnyWord(text, WRITE_VERB_SUMMARIZE_EXCLUDE);
+}
+
+/** 文本含除总结类新动词外的其他写类动词(写/撰写/生成/制定…) */
+export function hasNonSummarizeWriteVerb(text) {
+ return hasAnyWord(text, WRITE_VERB_NON_SUMMARIZE);
+}
+
+/** 文本命中非总结类的文档类型词(「总结」自身不作证据) */
+export function hasNonSummarizeDocType(text) {
+ return hasAnyWord(text, DOC_TYPE_NON_SUMMARIZE);
+}
+
/** 导出文档类型词词典(任务书约定的导出类型词;判定走 EXPORT_WORD+FORMAT_WORD,此处导出供组件复用) */
export const EXPORT_DOC_TYPE_WORDS = [
'论文', '报告', '通知', '通告', '公告', '请示', '批复', '函', '申请书', '申报书',
@@ -64,7 +95,12 @@ export function route(text, hasAttachments, hasActiveArtifact) {
// 2. 编辑意图(需活动文档)
if (hasActiveArtifact && hasAnyWord(s, EDIT_WORD)) return 'edit';
// 3. 写文档意图:写类动词 + 文档类型词(排除词防误判优先)
- if (hasAnyWord(s, WRITE_VERB) && !hasAnyExcludeWord(s) && hasAnyWord(s, DOC_TYPE)) return 'docgen';
+ // 缺陷 #52a:纯总结类动词句(无其他写动词)须命中非总结类文档类型词
+ // (如「论文」)才进 docgen——「总结一下植物光合作用的过程」仍走 QA
+ if (hasAnyWord(s, WRITE_VERB) && !hasAnyExcludeWord(s) && hasAnyWord(s, DOC_TYPE)) {
+ const summarizeOnly = hasAnySummarizeVerb(s) && !hasNonSummarizeWriteVerb(s);
+ if (!summarizeOnly || hasNonSummarizeDocType(s)) return 'docgen';
+ }
// 4. 附件兜底:拖入文件 + 写类动词,即使无文档类型词也视为生成意图
if (hasAttachments && hasAnyWord(s, WRITE_VERB)) return 'docgen';
// 5. 漏判安全:一律 qa
diff --git a/ui/src/stores/bench.js b/ui/src/stores/bench.js
index 2923702..400109a 100644
--- a/ui/src/stores/bench.js
+++ b/ui/src/stores/bench.js
@@ -78,6 +78,10 @@ export const useBenchStore = defineStore('bench', {
kgReady: false, // Neo4j 是否可用(false → 离线降级)
kgError: false,
kgNodeLimit: 50, // 移动端由 KgPane 下调为 30
+ // ---- 会话来源缓存失效标记(缺陷 #27:新回答完成后强制重拉)----
+ // 由 chat.js 终态(onComplete/_runSearch 完成)置 true;loadSessionSources
+ // 拉取成功后置 false。SourcesPane 监听此标记自动 force 重拉。
+ sourcesStale: false,
// ---- 统一状态机 ----
refreshSeq: 0, // 每轮「置空 → 加载」自增,用于丢弃过期响应
needRefresh: {}, // pane → true:下次激活时重新装配(离线重试等场景)
@@ -174,6 +178,11 @@ export const useBenchStore = defineStore('bench', {
setSourceScope(scope) {
this.sourceScope = scope;
},
+ /** 缺陷 #27:一轮问答终态时由 chat.js 调用——标记会话来源缓存失效,
+ * 促使 SourcesPane 自动 force 重拉(不在流式过程中逐帧触发)。 */
+ markSourcesStale() {
+ this.sourcesStale = true;
+ },
// FR-CHAT-015:引用 chip 点击 → 切到来源 tab 并高亮对应条目 2s(PM 集成补丁,B2 依赖)
// 缺陷 #27 修复:参数由 index 改为 key(paper_id/id)——「本次回答」域 items 与
// chip 的来源块同序,key 匹配即可精确定位,不再依赖行序号(会话聚合域 id 错位
@@ -211,7 +220,10 @@ export const useBenchStore = defineStore('bench', {
* degraded/离线时保留已有数据并标记错误(失败必须可见,见 PRD §2.2)。
*/
async loadSessionSources({ force = false } = {}) {
- if (!force && this.sessionSources) return this.sessionSources;
+ // 缺陷 #27:终态失效标记(chat.js 完成一轮后置 sourcesStale=true)——
+ // 与 force 一样需要绕过缓存短路;成功落库后清除失效标记
+ const mustReload = force || this.sourcesStale;
+ if (!mustReload && this.sessionSources) return this.sessionSources;
const sid = this._activeSessionId();
if (!sid) return null;
const seq = ++this.refreshSeq;
@@ -219,9 +231,13 @@ export const useBenchStore = defineStore('bench', {
this.sourcesError = false;
try {
const data = await httpGet(`/api/sessions/${sid}/sources`);
- if (seq === this.refreshSeq) this.sessionSources = data;
+ if (seq === this.refreshSeq) {
+ this.sessionSources = data;
+ this.sourcesStale = false;
+ }
return data;
} catch (err) {
+ // 失败保留失效标记:下次 scope/pane 激活仍会重试
if (seq === this.refreshSeq) this.sourcesError = true;
throw err;
} finally {
@@ -351,6 +367,7 @@ export const useBenchStore = defineStore('bench', {
resetSessionData() {
this.localStageEvents = [];
this.sessionSources = null;
+ this.sourcesStale = false;
this.sourcesLoading = false;
this.sourcesError = false;
this.jobDetail = {};
diff --git a/ui/src/stores/chat.js b/ui/src/stores/chat.js
index 3926a2d..8342370 100644
--- a/ui/src/stores/chat.js
+++ b/ui/src/stores/chat.js
@@ -12,7 +12,7 @@
// (streamCtx),切走后流在后台继续(消息数组 sessionMsgs 持续更新),
// 切回时直接拿实时数组;stop/重试只作用于当前会话的流。
// 原 FR-SESSION-004 的「切会话即中断」行为废止,见 CHANGELOG。
-import { defineStore } from 'pinia';
+import { defineStore, getActivePinia } from 'pinia';
import { createMessage, updateMessage } from '../api/sessions.js';
import { chatStream, search } from '../api/qa.js';
import { useSessionsStore } from './sessions.js';
@@ -1532,6 +1532,19 @@ export const useChatStore = defineStore('chat', {
}
},
+ /** 缺陷 #27:终态后标记会话来源聚合缓存失效(bench.sourcesStale = true)。
+ * SourcesPane 监听到标记后自动 force 重拉——缓存只在选中会话时有意义,
+ * 终态才触发一次,流式过程中不反复重拉。
+ * 同步实现:经 getActivePinia 取 bench store(与 bench.js 读 chat 的方式对称,
+ * 无动态 import 的微任务窗口——发送后立即断言/挂载渲染都能看到标记)。 */
+ _markSourcesStaleSafe(sessionId) {
+ try {
+ if (sessionId && this.sessionId !== sessionId) return; // 跨会话(切走后后台完成):不打扰当前视图
+ const bench = getActivePinia()?._s.get('bench');
+ bench?.markSourcesStale();
+ } catch { /* bench 未就绪(测试/降级):静默 */ }
+ },
+
async _runStream(q, modeCfg, aMsg, sessionId, userId, ctx) {
const controller = new AbortController();
ctx.controller = controller;
@@ -1627,6 +1640,8 @@ export const useChatStore = defineStore('chat', {
// 落库失败不掩盖成功事实:本地消息保持 done,仅记录
console.warn('persistFinal failed after complete:', persistErr);
}
+ // 缺陷 #27:回答完成后会话来源聚合缓存失效(列表自动刷新)
+ this._markSourcesStaleSafe(sessionId);
this._finishCtx(ctx, {
ok: true,
payload,
@@ -1759,6 +1774,8 @@ export const useChatStore = defineStore('chat', {
const dur = ((Date.now() - startAt) / 1000).toFixed(1) + 's';
this._flushPrivate(aMsg); // FR-PRIV-005:blocks 赋值后 flush 私有 pending
await this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw });
+ // 缺陷 #27:检索模式回答完成后会话来源聚合缓存失效(列表自动刷新)
+ this._markSourcesStaleSafe(sessionId);
this._finishCtx(ctx, {
ok: true,
payload,
diff --git a/ui/src/stores/docgen.js b/ui/src/stores/docgen.js
index ac659c5..36a8f1d 100644
--- a/ui/src/stores/docgen.js
+++ b/ui/src/stores/docgen.js
@@ -75,8 +75,16 @@ export const useDocgenStore = defineStore('docgen', {
if (!id) return;
const prevId = this.activeArtifact ? (this.activeArtifact.artifact_id || this.activeArtifact.id) : null;
const sameArtifact = prevId === id;
- if (sameArtifact && !force && this.md) return; // 同 id 且有本地内容:复用不重复拉取
+ if (sameArtifact && !force && this.md) {
+ // 同 id 且有本地内容:复用不重复拉取;但资料库视图下重开同一文档
+ // 必须复位视图(缺陷 #47b 自含复位语义)
+ this.viewMode = 'doc';
+ return;
+ }
this.activeArtifact = { ...artifact, artifact_id: id };
+ // 缺陷 #47b:打开/切换文档永远回到文档视图——close() 后活动文档已清空,
+ // 重开必须自含地把视图复位;否则关闭→重开后停在空态/资料库视图「看不到文档」。
+ this.viewMode = 'doc';
this.loadError = null;
this.saveError = null;
this.editError = null;
@@ -90,6 +98,8 @@ export const useDocgenStore = defineStore('docgen', {
this.letterhead = '';
this.exportError = {};
this.exporting = {};
+ this._contentSeq = (this._contentSeq || 0) + 1; // 拉取归属序号整体换代:上一次打开的全部迟到响应作废
+ this._htmlSeq = (this._htmlSeq || 0) + 1;
}
if (!this.md) await this.fetchContent(id);
},
@@ -117,17 +127,21 @@ export const useDocgenStore = defineStore('docgen', {
} finally {
if (seq === this._contentSeq) this.loading = false;
}
- if (seq === this._contentSeq) await this.fetchHtml(id);
+ // 缺陷 #47b:html 拉取同样以内容序号守卫——文档 A 关闭后其迟到的
+ // html 响应曾覆盖重开文档 B 的内容(空壳残留);归属不匹配即丢弃
+ if (seq === this._contentSeq) await this.fetchHtml(id, seq);
},
/** 拉取模版 HTML(预览与导出同源);失败静默(预览回退客户端 md 渲染)
- * 2026-09-04 问题5:序号防竞态——迟到的旧 html 响应不再覆盖新文档内容。 */
- async fetchHtml(id) {
+ * 2026-09-04 问题5:序号防竞态——迟到的旧 html 响应不再覆盖新文档内容。
+ * 缺陷 #47b:contentSeq 一并纳入守卫——文档已关闭/切换后迟到响应作废。 */
+ async fetchHtml(id, contentSeq) {
const seq = (this._htmlSeq || 0) + 1;
this._htmlSeq = seq;
try {
const data = await getArtifactHtml(id);
if (seq !== this._htmlSeq) return;
+ if (contentSeq != null && contentSeq !== this._contentSeq) return;
if (data && data.ok) {
this.html = String(data.html || '');
this.css = String(data.css || '');
@@ -137,6 +151,7 @@ export const useDocgenStore = defineStore('docgen', {
}
} catch {
if (seq !== this._htmlSeq) return;
+ if (contentSeq != null && contentSeq !== this._contentSeq) return;
this.html = '';
this.css = '';
this.renderNonce++; // 值可能不变(此前已空):bump 序号强制预览走 md 回退渲染
@@ -158,10 +173,21 @@ export const useDocgenStore = defineStore('docgen', {
const id = artifact.artifact_id || artifact.id;
if (!id) return;
this.activeArtifact = { ...artifact, artifact_id: id };
+ // 缺陷 #47b:生成接管同样自含复位文档视图(与 open 同一状态机);
+ // 拉取归属序号换代——上一文档的全部迟到响应作废
+ this.viewMode = 'doc';
this.loadError = null;
this.editError = null;
this.saveError = null;
this.editing = false;
+ this.html = '';
+ this.css = '';
+ this.templateKey = '';
+ this.letterhead = '';
+ this.exportError = {};
+ this.exporting = {};
+ this._contentSeq = (this._contentSeq || 0) + 1;
+ this._htmlSeq = (this._htmlSeq || 0) + 1;
this.md = String(initialMd ?? '');
if (!this.md) await this.fetchContent(id);
},
@@ -275,7 +301,10 @@ export const useDocgenStore = defineStore('docgen', {
},
/** 关闭活动文档(切走 doc TAB 不清空;DocPane「关闭文档」入口)
- * 缺陷 #55:关闭时复位视图模式(回到资料库空态区视图) */
+ * 缺陷 #55:关闭时复位视图模式(回到资料库空态区视图)。
+ * 缺陷 #47b:关闭必须作废全部在途拉取——close() 只清结果状态,
+ * 若之前 open/adopt 的 fetchHtml 仍在途,其迟到响应会把已清空的
+ * html/css/templateKey 重新写回(宿主 v-if 已销毁,回来看不到文档)。 */
close() {
this.viewMode = 'doc';
this.activeArtifact = null;
@@ -290,6 +319,9 @@ export const useDocgenStore = defineStore('docgen', {
this.saveError = null;
this.editError = null;
this.exportError = {};
+ // 在途序号作废:迟到的 content/html 响应不得再写任何状态
+ this._contentSeq = (this._contentSeq || 0) + 1;
+ this._htmlSeq = (this._htmlSeq || 0) + 1;
},
/** 会话切换时清空文档域状态(会话级产物不跨会话) */
diff --git a/ui/tests/r3debug/r4fixes.test.js b/ui/tests/r3debug/r4fixes.test.js
new file mode 100644
index 0000000..9d26a92
--- /dev/null
+++ b/ui/tests/r3debug/r4fixes.test.js
@@ -0,0 +1,468 @@
+// @vitest-environment jsdom
+// tests/r4debug/r4fixes.test.js —— r4 前端四项缺陷单测(Dev-F 归属)
+// #27 消息级来源 title 空值 fallback + 新回答完成后会话来源强制重拉
+// #32 知识图谱搜索成功但空结果的明确提示(与 kgDown/kgEmpty 三态互斥)
+// #47b DocPane 视图状态机:关闭→重开 / 资料库↔文档往返 / 会话切换
+// #52a 意图路由 WRITE_VERB 补「总结/概括/归纳」(带附件进 docgen,普通问答不误伤)
+//
+// 运行方式(worktree 内):cd ui && npx vitest run --config tests/r3debug/vitest.config.js tests/r4debug/r4fixes.test.js
+// (r3debug/vitest.config.js 的 include 覆盖 tests/r3debug/**,显式指定文件路径即可跑本文件)
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { mount, flushPromises } from '@vue/test-utils';
+import { setActivePinia, createPinia, getActivePinia } from 'pinia';
+
+// jsdom 无 scrollTo:polyfill(SourceRow 高亮滚动 / 组件挂载用)
+if (typeof window !== 'undefined' && typeof window.Element !== 'undefined' && !window.Element.prototype.scrollTo) {
+ window.Element.prototype.scrollTo = vi.fn();
+}
+
+// ---- mock 依赖链(与 r3debug/docgenPoll.test.js 同款)----
+vi.mock('../../src/api/sessions.js', () => ({
+ createMessage: vi.fn(async () => ({ id: 'm' + Math.random().toString(36).slice(2, 6) })),
+ updateMessage: vi.fn(async () => ({ updated_at: new Date().toISOString() })),
+}));
+vi.mock('../../src/api/qa.js', () => ({
+ chatStream: vi.fn(async () => {}),
+ search: vi.fn(async () => ({ answer: '' })),
+}));
+vi.mock('../../src/api/private.js', () => ({
+ askPrivate: vi.fn(async () => ({ hit: false })),
+}));
+vi.mock('../../src/api/docgen.js', () => ({
+ generate: vi.fn(),
+ getTask: vi.fn(),
+ getArtifactContent: vi.fn(async () => ({ markdown: '' })),
+ getArtifactHtml: vi.fn(async () => ({ ok: true, html: '', css: '' })),
+ saveArtifactContent: vi.fn(async () => ({})),
+ editArtifact: vi.fn(async () => ({})),
+ exportArtifact: vi.fn(async () => ({})),
+ uploadLibrary: vi.fn(),
+ listLibrary: vi.fn(async () => ({ items: [] })),
+ removeLibrary: vi.fn(async () => ({ ok: true })),
+ listTasks: vi.fn(async () => ({ tasks: [] })),
+}));
+vi.mock('../../src/api/kg.js', () => ({
+ searchEntities: vi.fn(),
+ getNeighborhood: vi.fn(),
+ kgStatus: vi.fn(async () => ({ ok: true, active: true })),
+}));
+
+// ---- 被测模块 ----
+import { useChatStore } from '../../src/stores/chat.js';
+import { useSessionsStore } from '../../src/stores/sessions.js';
+import { useBenchStore, __setHttpGet } from '../../src/stores/bench.js';
+import { useDocgenStore } from '../../src/stores/docgen.js';
+import SourceRow from '../../src/components/bench/SourceRow.vue';
+import KgPane from '../../src/components/bench/KgPane.vue';
+import { route } from '../../src/intent.js';
+
+function freshPinia() {
+ const pinia = createPinia();
+ setActivePinia(pinia);
+ return pinia;
+}
+
+/** 预置会话(跳过 create 网络路径) */
+function seedSession() {
+ const chat = useChatStore();
+ const sessions = useSessionsStore();
+ chat.sessionId = 's1';
+ sessions.activeId = 's1';
+ sessions.items = [{ id: 's1', title: 't', status: 'done', created_at: '', updated_at: '' }];
+ return { chat, sessions };
+}
+
+describe('#27 SourceRow title 空值 fallback(消息级来源条目)', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ function mountRow(source) {
+ return mount(SourceRow, {
+ props: { source, index: 0, count: null },
+ global: { stubs: { teleport: true } },
+ });
+ }
+
+ it('title 为空 → 显示 paper_id(绝不渲染空文本或正文片段)', () => {
+ const w = mountRow({ paper_id: 'PMC12345678', title: '', snippet: 'RESEARCH ARTICLE 正文片段…', score: 0.21 });
+ const name = w.find('.ft-name');
+ expect(name.text()).toBe('PMC12345678');
+ // tooltip 与显示文本一致(完整 paper_id)
+ expect(name.attributes('title')).toBe('PMC12345678');
+ // 正文片段绝不进入标题行
+ expect(name.text()).not.toContain('RESEARCH ARTICLE');
+ });
+
+ it('title 与 paper_id 都空 → 显示 id', () => {
+ const w = mountRow({ id: 'src-9', title: null, snippet: 'x' });
+ expect(w.find('.ft-name').text()).toBe('src-9');
+ });
+
+ it('title/paper_id/id 全空 → 显示 i18n 占位「(无标题)」', () => {
+ const w = mountRow({ title: ' ', snippet: 'x' });
+ expect(w.find('.ft-name').text()).toBe('(无标题)');
+ });
+
+ it('正常 title 显示不受影响(r3 契约不回归)', () => {
+ const w = mountRow({ paper_id: 'P1', title: '水稻抗病基因研究进展', score: 0.5 });
+ expect(w.find('.ft-name').text()).toBe('水稻抗病基因研究进展');
+ // 徽标真实 paper_id 契约不回归
+ expect(w.find('.ft-ico.idx').text()).toBe('P1');
+ });
+});
+
+describe('#27 新回答完成后会话来源强制重拉(sourcesStale 链路)', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ __setHttpGet(null);
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ __setHttpGet(null);
+ });
+
+ it('有缓存且无失效标记 → 缓存短路不重拉', async () => {
+ freshPinia();
+ seedSession();
+ const bench = useBenchStore();
+ bench.sessionSources = { sources: [{ paper_id: 'P1', count: 1 }] };
+ const http = vi.fn(async () => ({ sources: [] }));
+ __setHttpGet(http);
+ const out = await bench.loadSessionSources();
+ expect(out.sources[0].paper_id).toBe('P1');
+ expect(http).not.toHaveBeenCalled();
+ });
+
+ it('chat 检索模式完成 → sourcesStale 置真 → 自动绕过缓存重拉,成功后标记清除', async () => {
+ freshPinia();
+ const { chat } = seedSession();
+ const bench = useBenchStore();
+ bench.sessionSources = { sources: [{ paper_id: 'P1', count: 1 }] };
+ const http = vi.fn(async () => ({ sources: [{ paper_id: 'P2', count: 2 }] }));
+ __setHttpGet(http);
+
+ const { search } = await import('../../src/api/qa.js');
+ vi.mocked(search).mockResolvedValue({ answer: 'ok', sources: [{ paper_id: 'P2', title: 't' }], degraded: false });
+ await chat.send('问题', { mode: 'search' });
+ // 终态后失效标记已置
+ expect(bench.sourcesStale).toBe(true);
+
+ const out = await bench.loadSessionSources();
+ expect(http).toHaveBeenCalledWith('/api/sessions/s1/sources', undefined);
+ expect(out.sources[0].paper_id).toBe('P2');
+ expect(bench.sourcesStale).toBe(false); // 成功后清除,不会反复重拉
+ });
+
+ it('QA 流 onComplete 终态 → sourcesStale 置真(终态才一次,不在流式中触发)', async () => {
+ freshPinia();
+ const { chat } = seedSession();
+ const bench = useBenchStore();
+
+ const { chatStream } = await import('../../src/api/qa.js');
+ let captured = null;
+ vi.mocked(chatStream).mockImplementation(async (_body, handlers) => {
+ captured = handlers;
+ handlers.onProgress({ payload: { phase: 'retrieval', label: 'r' } });
+ handlers.onProgress({ payload: { phase: 'generation', label: 'g' } });
+ handlers.onComplete({ payload: { answer: '答案', sources: [] } });
+ });
+ const p = chat.send('问题', { mode: 'ask' });
+ await p;
+ expect(captured).not.toBeNull();
+ // 完成前(流式过程中)不触发失效:终态后才一次
+ expect(chat.messages.find((m) => m.role === 'assistant')?.status).toBe('done');
+ expect(bench.sourcesStale).toBe(true);
+ });
+
+ it('跨会话后台完成不打扰当前视图(sessionId 不匹配时失效标记不置真)', async () => {
+ freshPinia();
+ const { chat } = seedSession();
+ const bench = useBenchStore();
+ // 当前视图在 s2;后台流属于 s1
+ chat.sessionId = 's2';
+ chat._markSourcesStaleSafe('s1');
+ expect(bench.sourcesStale).toBe(false);
+ chat._markSourcesStaleSafe('s2');
+ expect(bench.sourcesStale).toBe(true);
+ });
+
+ it('resetSessionData 清理 sourcesStale(会话切换不残留失效标记)', () => {
+ freshPinia();
+ const bench = useBenchStore();
+ bench.sourcesStale = true;
+ bench.resetSessionData();
+ expect(bench.sourcesStale).toBe(false);
+ expect(bench.sessionSources).toBeNull();
+ });
+});
+
+describe('#32 知识图谱搜索空结果提示(kgNoResult)', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ function mountKg() {
+ return mount(KgPane, {
+ global: { plugins: [getActivePinia()], stubs: { teleport: true, KgGraphView: true, KgEntityDetail: true } },
+ });
+ }
+
+ it('搜索成功但 results 空 → 显示「未找到相关实体」,搜索按钮恢复可用', async () => {
+ freshPinia();
+ const bench = useBenchStore();
+ bench.kgReady = true;
+ bench.kgStatus = { ok: true };
+
+ const { searchEntities } = await import('../../src/api/kg.js');
+ vi.mocked(searchEntities).mockResolvedValue({ results: [] });
+
+ const w = mountKg();
+ await w.find('.search-box').setValue('水稻');
+ await w.find('.search-btn').trigger('click');
+ await flushPromises();
+
+ expect(w.text()).toContain('未找到相关实体');
+ // 与 kgDown(服务不可用)互斥:不出现离线文案
+ expect(w.text()).not.toContain('知识图谱服务离线');
+ // 搜索按钮恢复可用(空结果不锁按钮)
+ const btn = w.find('.search-btn');
+ expect(btn.attributes('disabled')).toBeUndefined();
+ });
+
+ it('搜索失败(kgDown)不出现空结果提示(触发条件互斥)', async () => {
+ freshPinia();
+ const bench = useBenchStore();
+ bench.kgReady = true;
+ bench.kgStatus = { ok: true };
+
+ const { searchEntities } = await import('../../src/api/kg.js');
+ vi.mocked(searchEntities).mockRejectedValue(new Error('down'));
+
+ const w = mountKg();
+ await w.find('.search-box').setValue('水稻');
+ await w.find('.search-btn').trigger('click');
+ await flushPromises();
+
+ expect(w.text()).toContain('知识图谱服务离线');
+ expect(w.text()).not.toContain('未找到相关实体');
+ });
+
+ it('正常命中路径不回归:arginine 命中渲染结果列表、无「未找到」提示', async () => {
+ freshPinia();
+ const bench = useBenchStore();
+ bench.kgReady = true;
+ bench.kgStatus = { ok: true };
+
+ const { searchEntities } = await import('../../src/api/kg.js');
+ vi.mocked(searchEntities).mockResolvedValue({
+ results: [{ id: 'e1', name: 'arginine', type: 'Compound', support_count: 42 }],
+ });
+
+ const w = mountKg();
+ await w.find('.search-box').setValue('arginine');
+ await w.find('.search-btn').trigger('click');
+ await flushPromises();
+
+ expect(w.find('.result-item').exists()).toBe(true);
+ expect(w.find('.rs-name').text()).toBe('arginine');
+ expect(w.text()).not.toContain('未找到相关实体');
+ });
+
+ it('初始空态 kgEmpty 与空结果提示不同文案(三态区分)', () => {
+ freshPinia();
+ const bench = useBenchStore();
+ bench.kgReady = true;
+ const w = mountKg();
+ expect(w.find('.empty-note').text()).toContain('本次检索未产生图谱关系');
+ expect(w.find('.empty-note').text()).not.toContain('未找到相关实体');
+ });
+});
+
+describe('#47b DocPane 视图状态机(close→重开 / 资料库往返 / 会话切换)', () => {
+ beforeEach(() => {
+ freshPinia();
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it('打开文档 A → 关闭 → 重开:viewMode 复位为 doc、内容完整渲染', async () => {
+ const docgen = useDocgenStore();
+ await docgen.open({ artifact_id: 'a1', title: 'A', template_name: 't', status: 'done' });
+ expect(docgen.viewMode).toBe('doc');
+ expect(docgen.activeArtifact?.artifact_id).toBe('a1');
+
+ docgen.close();
+ expect(docgen.viewMode).toBe('doc');
+ expect(docgen.activeArtifact).toBeNull();
+ expect(docgen.md).toBe('');
+ expect(docgen.html).toBe('');
+
+ await docgen.open({ artifact_id: 'a1', title: 'A', template_name: 't', status: 'done' });
+ expect(docgen.viewMode).toBe('doc');
+ expect(docgen.activeArtifact?.artifact_id).toBe('a1');
+ expect(docgen.loading).toBe(false);
+ expect(docgen.loadError).toBeNull();
+ });
+
+ it('反复关闭 3 次:状态干净、无残留(html/css/templateKey/letterhead 全空)', async () => {
+ const docgen = useDocgenStore();
+ for (let i = 0; i < 3; i++) {
+ await docgen.open({ artifact_id: 'a1', title: 'A', status: 'done' });
+ docgen.close();
+ expect(docgen.activeArtifact).toBeNull();
+ expect(docgen.md).toBe('');
+ expect(docgen.html).toBe('');
+ expect(docgen.css).toBe('');
+ expect(docgen.templateKey).toBe('');
+ expect(docgen.letterhead).toBe('');
+ expect(docgen.viewMode).toBe('doc');
+ expect(docgen.loading).toBe(false);
+ expect(docgen.loadError).toBeNull();
+ }
+ });
+
+ it('资料库↔文档往返:资料库→返回文档→再资料库,viewMode 切换无残留', async () => {
+ const docgen = useDocgenStore();
+ const { getArtifactContent } = await import('../../src/api/docgen.js');
+ vi.mocked(getArtifactContent).mockResolvedValue({ markdown: '# 文档 A', title: 'A' });
+ await docgen.open({ artifact_id: 'a1', title: 'A', status: 'done' });
+
+ for (let i = 0; i < 3; i++) {
+ docgen.viewMode = 'library';
+ expect(docgen.viewMode).toBe('library');
+ docgen.viewMode = 'doc';
+ expect(docgen.viewMode).toBe('doc');
+ expect(docgen.activeArtifact?.artifact_id).toBe('a1');
+ expect(docgen.md).toBe('# 文档 A');
+ }
+ // 资料库视图下重开同一文档 → open 自含复位回 doc
+ docgen.viewMode = 'library';
+ await docgen.open({ artifact_id: 'a1', title: 'A', status: 'done' });
+ expect(docgen.viewMode).toBe('doc');
+ });
+
+ it('文档 A 关闭后迟到的 html 响应作废:不得复活 A 的状态(#47b 根因)', async () => {
+ const docgen = useDocgenStore();
+ const { getArtifactHtml } = await import('../../src/api/docgen.js');
+
+ // A 的 html 响应悬挂 10s:模拟打开过程中用户点击「关闭文档」(缺陷场景)
+ vi.mocked(getArtifactHtml).mockImplementation(async (id) => {
+ await new Promise((r) => setTimeout(r, 10000));
+ return { ok: true, html: `
${id}
`, css: 'body{}', template_key: 'official', letterhead: '' };
+ });
+
+ const openA = docgen.open({ artifact_id: 'a1', title: 'A', status: 'done' });
+ await vi.advanceTimersByTimeAsync(0); // content 微任务排干,html 已发出(悬挂中)
+ // 打开未完成时关闭:close 必须作废在途拉取
+ docgen.close();
+ expect(docgen.activeArtifact).toBeNull();
+
+ // 放行悬挂响应
+ await vi.advanceTimersByTimeAsync(10000);
+ await flushPromises();
+ await openA.catch(() => {});
+
+ // 关闭后迟到响应被序号守卫作废:A 的 html/css/templateKey 不复活
+ expect(docgen.html).toBe('');
+ expect(docgen.css).toBe('');
+ expect(docgen.templateKey).toBe('');
+ expect(docgen.activeArtifact).toBeNull();
+
+ // 重开文档 B:完整渲染不受 A 残留影响
+ vi.mocked(getArtifactHtml).mockResolvedValue({ ok: true, html: 'B
', css: 'b{}', template_key: 'report', letterhead: '' });
+ await docgen.open({ artifact_id: 'a2', title: 'B', status: 'done' });
+ expect(docgen.activeArtifact?.artifact_id).toBe('a2');
+ expect(docgen.html).toBe('B
');
+ expect(docgen.viewMode).toBe('doc');
+ });
+
+ it('adoptArtifact 自含复位视图并清空旧内容(生成接管)', async () => {
+ const docgen = useDocgenStore();
+ docgen.viewMode = 'library';
+ docgen.html = 'old
';
+ docgen.css = 'old{}';
+ docgen.templateKey = 'old';
+ docgen.letterhead = 'old';
+ await docgen.adoptArtifact({ artifact_id: 'a1', title: 'A', status: 'done' });
+ expect(docgen.viewMode).toBe('doc');
+ expect(docgen.html).toBe('');
+ expect(docgen.css).toBe('');
+ expect(docgen.templateKey).toBe('');
+ expect(docgen.letterhead).toBe('');
+ expect(docgen.activeArtifact?.artifact_id).toBe('a1');
+ });
+
+ it('会话切换 resetSession 清空文档域:A 不串挂到 B', async () => {
+ const docgen = useDocgenStore();
+ await docgen.open({ artifact_id: 'a1', title: 'A', status: 'done' });
+ expect(docgen.hasActiveArtifact).toBe(true);
+
+ docgen.resetSession();
+ expect(docgen.activeArtifact).toBeNull();
+ expect(docgen.md).toBe('');
+ expect(docgen.html).toBe('');
+ expect(docgen.viewMode).toBe('doc');
+ expect(docgen.templateCandidates).toEqual([]);
+ });
+});
+
+describe('#52a 意图路由 WRITE_VERB 补总结/概括/归纳', () => {
+ it('「帮我总结论文」+ 附件 → docgen(规则 4 附件兜底)', () => {
+ expect(route('帮我总结论文', true, false)).toBe('docgen');
+ });
+
+ it('「帮我概括这篇文章」+ 附件 → docgen', () => {
+ expect(route('帮我概括这篇文章', true, false)).toBe('docgen');
+ });
+
+ it('「帮我归纳资料」+ 附件 → docgen', () => {
+ expect(route('帮我归纳资料', true, false)).toBe('docgen');
+ });
+
+ it('无附件「总结一下植物光合作用的过程」→ qa(无其他 DOC_TYPE 证据)', () => {
+ expect(route('总结一下植物光合作用的过程', false, false)).toBe('qa');
+ });
+
+ it('无附件「概括一下 OsSPL14 的功能」→ qa(无其他 DOC_TYPE 证据)', () => {
+ expect(route('概括一下 OsSPL14 的功能', false, false)).toBe('qa');
+ });
+
+ it('无附件「总结这篇论文」→ docgen(TL 裁决:DOC_TYPE 含「论文」,接受该行为变化)', () => {
+ expect(route('总结这篇论文', false, false)).toBe('docgen');
+ });
+
+ it('无附件「帮我概括这篇文章」→ docgen(DOC_TYPE 含「文章」类材料词之外,按 DOC_TYPE 命中)', () => {
+ // 「文章」不在 DOC_TYPE:按漏判安全应走 qa(附规则 4 附件兜底覆盖带附件场景)
+ expect(route('帮我概括这篇文章', false, false)).toBe('qa');
+ expect(route('帮我概括这篇文章', true, false)).toBe('docgen');
+ });
+
+ it('普通问答不误伤:你好 / 介绍自己 → qa', () => {
+ expect(route('你好,介绍一下你自己', false, false)).toBe('qa');
+ expect(route('你是谁', true, false)).toBe('qa');
+ });
+
+ it('既有写类动词回归:写/撰写 + 论文 → docgen;无附件无 DOC_TYPE 写类仍 qa', () => {
+ expect(route('帮我写一份水稻研究报告', false, false)).toBe('docgen');
+ expect(route('撰写论文', false, false)).toBe('docgen');
+ expect(route('写一首诗', false, false)).toBe('qa'); // DOC_TYPE_EXCLUDE 优先
+ });
+
+ it('「写一篇总结」仍进 docgen(DOC_TYPE 含「总结」,行为不变)', () => {
+ expect(route('写一篇总结', false, false)).toBe('docgen');
+ });
+
+ it('既有写类动词 + 总结类文档词不回归:「写一份工作总结」→ docgen', () => {
+ expect(route('写一份工作总结', false, false)).toBe('docgen');
+ });
+
+ it('规则 4 附件兜底:总结类动词带附件无 DOC_TYPE 也进 docgen', () => {
+ expect(route('总结一下这份资料', true, false)).toBe('docgen');
+ expect(route('归纳一下内容', true, false)).toBe('docgen');
+ });
+});