fix(r4-27/32/47b/52a): 前端四项缺陷修复

#27 消息级来源 title 空值串行:SourceRow 标题链空值 fallback(非空 title
>paper_id>id>「(无标题)」,绝不渲染空文本或正文片段);bench store 新增
sourcesStale 失效标记,chat.js QA 流 onComplete 与检索模式完成终态置标记,
SourcesPane 监听自动 force 重拉(终态一次,跨会话后台完成不打扰当前视图)。
后端 normalize title 回填由 Dev-B1 负责。

#32 KG 搜索「水稻」无结果无反馈:KgPane 成功空结果新增 kgNoResult
「未找到相关实体」提示(与 kgDown 服务离线、kgEmpty 初始空态三态文案区分、
触发条件互斥),搜索按钮空结果后保持可用,arginine 命中路径不回归。

#47b 关闭文档→返回→再关闭看不到文档:docgen store 状态机加固——open/
adoptArtifact 自含复位 viewMode='doc';close() 作废在途 content/html 拉取序号
(迟到响应不再复活已清空的 html/css/templateKey/letterhead);fetchHtml 追加
contentSeq 归属守卫。反复关闭/资料库往返/会话切换无残留。

#52a「总结论文」路由 QA:WRITE_VERB 补「总结/概括/归纳」;「总结」自身在
DOC_TYPE 中会误拉无附件普通总结句,规则 3 细化——纯总结类动词句须命中
其他文档类型词(如「论文」)才进 docgen,带附件走规则 4 兜底。后端
intent.py 词典同步由 Dev-B1 负责,请 TL 校验双端一致。

单测:tests/r3debug/r4fixes.test.js 新增 31 例(含 SourceRow fallback、
sourcesStale 链路、KgPane 三态、docgen 状态机迟到响应作废、意图路由),
r3debug 全套 68/68 通过。
This commit is contained in:
2026-09-11 13:57:00 +08:00
parent 7840527f82
commit 75877ecab7
9 changed files with 615 additions and 15 deletions
+10
View File
@@ -16,6 +16,10 @@ const query = ref('');
const results = ref([]);
const searching = ref(false);
const searchError = ref(false);
// 缺陷 #32:搜索成功但空结果(200 + results:[],非超时)的明确提示——
// 与 kgDown(服务不可用/超时)互斥:请求成功且空 results 置 true,
// 下一次搜索发起时复位。
const noResult = ref(false);
const detailNode = ref(null);
const graphData = ref(null); // 当前渲染的 {nodes, edges}(答案图谱或邻域结果)
const graphSource = ref(null); // 'answer' | 'neighborhood'
@@ -80,9 +84,11 @@ async function onSearch() {
}
searching.value = true;
searchError.value = false;
noResult.value = false; // 新一次搜索复位空结果提示
try {
const data = await searchEntities(q, 20);
results.value = data?.results || [];
noResult.value = !results.value.length; // 成功但空:明确提示(#32,非超时)
} catch {
searchError.value = true;
} finally {
@@ -197,6 +203,8 @@ const kgDown = computed(() => !bench.kgReady && bench.kgStatus != null);
</button>
</div>
<div v-if="searchError" class="offline-text">{{ T.kgDown }}</div>
<!-- 缺陷 #32:成功但空结果提示(kgNoResult,与 kgDown/kgEmpty 三态区分互斥) -->
<div v-else-if="noResult && !searching" class="no-result-note">{{ T.kgNoResult }}</div>
<div v-if="results.length" class="search-results">
<div v-for="r in results" :key="r.id" class="result-item" @click="onSelectResult(r)">
<span class="rs-name">{{ r.name }}</span>
@@ -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;
+13 -1
View File
@@ -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(() => {
<span class="ft-ico idx" :title="badgeTitle">{{ badgeId }}</span>
<div class="src-main">
<div class="src-line1">
<span class="ft-name" :title="source.title || ''">{{ source.title }}</span>
<span class="ft-name" :title="displayTitle">{{ displayTitle }}</span>
</div>
<div class="ft-size src-meta">{{ metaParts.join(' · ') }}</div>
<div v-if="source.snippet" class="src-snippet" :class="{ clamp: !expanded }" @click="expanded = !expanded">
+6 -3
View File
@@ -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(() => {});
}
},
+5
View File
@@ -134,6 +134,11 @@ export const T = {
extractUploadFail: '上传失败:',
kgEmpty: '本次检索未产生图谱关系,可使用下方搜索浏览知识库',
kgSearchPlaceholder: '搜索实体名,如 OsSPL14',
// 缺陷 #32:搜索成功但无结果(200 + results:[])的明确提示——与 kgEmpty(初始空态)、
// kgDown(服务不可用/超时)三态区分、触发条件互斥
kgNoResult: '未找到相关实体',
// 缺陷 #27:文献来源条目 title 为空时的占位(绝不渲染空文本或正文片段)
sourceNoTitle: '(无标题)',
chainBadge: '跳',
sessionEmptyTitle: '海古纪 PlantAI 植物科研大模型',
sessionEmptySub: '基于文献知识库的基因家族问答 —— 每一次提问都有证据锚定',
+39 -3
View File
@@ -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
+19 -2
View File
@@ -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 = {};
+18 -1
View File
@@ -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,
+37 -5
View File
@@ -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;
},
/** 会话切换时清空文档域状态(会话级产物不跨会话) */
+468
View File
@@ -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: `<h1>${id}</h1>`, 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: '<h1>B</h1>', 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('<h1>B</h1>');
expect(docgen.viewMode).toBe('doc');
});
it('adoptArtifact 自含复位视图并清空旧内容(生成接管)', async () => {
const docgen = useDocgenStore();
docgen.viewMode = 'library';
docgen.html = '<h1>old</h1>';
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');
});
});