From 75877ecab73d06fd5e108a23f87b193b6361c916 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 11 Sep 2026 13:57:00 +0800 Subject: [PATCH] =?UTF-8?q?fix(r4-27/32/47b/52a):=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=9B=9B=E9=A1=B9=E7=BC=BA=E9=99=B7=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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 通过。 --- ui/src/components/bench/KgPane.vue | 10 + ui/src/components/bench/SourceRow.vue | 14 +- ui/src/components/bench/SourcesPane.vue | 9 +- ui/src/i18n/labels.js | 5 + ui/src/intent.js | 42 ++- ui/src/stores/bench.js | 21 +- ui/src/stores/chat.js | 19 +- ui/src/stores/docgen.js | 42 ++- ui/tests/r3debug/r4fixes.test.js | 468 ++++++++++++++++++++++++ 9 files changed, 615 insertions(+), 15 deletions(-) create mode 100644 ui/tests/r3debug/r4fixes.test.js diff --git a/ui/src/components/bench/KgPane.vue b/ui/src/components/bench/KgPane.vue index 1f0e929..0f5f5a3 100644 --- a/ui/src/components/bench/KgPane.vue +++ b/ui/src/components/bench/KgPane.vue @@ -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);
{{ T.kgDown }}
+ +
{{ T.kgNoResult }}
{{ r.name }} @@ -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(() => { {{ badgeId }}
- {{ 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'); + }); +});