(编号前移入 text 行内,与 markdown 一致)
+ * markdown-it 14 的 token 流:heading_open 与 inline 是兄弟 token(inline 不在
+ * heading_open.children 里),标题文本取 tokens[idx+1](inline)的 content。 */
+function headingOpenNumbered(inst, prevTh, token, tokens, idx) {
+ const hNum = Number(token.tag.slice(1));
+ const inline = tokens[idx + 1] && tokens[idx + 1].type === 'inline' ? tokens[idx + 1] : null;
+ const text = inline ? String(inline.content || '') : '';
+ if (!text.trim() || hasOwnNumber(text)) {
+ // 自带编号:只挂 md-hN class,不注入
+ return `<${token.tag} class="md-h${hNum}">`;
+ }
+ let prefix = '';
+ if (hNum === 1) {
+ prevTh.h1 += 1;
+ prevTh.h2 = 0;
+ prevTh.h3 = 0;
+ prefix = `${cnNum(prevTh.h1)}、`;
+ } else if (hNum === 2) {
+ if (!prevTh.h1) prevTh.h1 = 1; // 文档以 h2 开头:父级计数兜底为 1(避免 0.1)
+ prevTh.h2 += 1;
+ prevTh.h3 = 0;
+ prefix = `${prevTh.h1}.${prevTh.h2}`;
+ } else if (hNum === 3) {
+ if (!prevTh.h1) prevTh.h1 = 1;
+ if (!prevTh.h2) prevTh.h2 = 1; // 无 h2 直出 h3:父级兜底(避免 0.0.1)
+ prevTh.h3 += 1;
+ prefix = `${prevTh.h1}.${prevTh.h2}.${prevTh.h3}`;
+ }
+ // inline token 的内容不经 escaping(content 已转义),前缀文本同样 escape
+ const escPrefix = inst.utils.escapeHtml(prefix);
+ return `<${token.tag} class="md-h${hNum}">${escPrefix} `;
+}
+
function ensureRenderer() {
if (md) return md;
const inst = new MarkdownIt({
@@ -52,7 +118,7 @@ function ensureRenderer() {
for (const t of token.children || []) s += t.content || '';
return s;
};
- const prevTh = { value: '' };
+ const prevTh = { value: '', h1: 0, h2: 0, h3: 0 };
const _thOpen = inst.renderer.rules.th_open;
const _tdOpen = inst.renderer.rules.td_open;
inst.renderer.rules.th_open = (tokens, idx, options, env, self) => {
@@ -63,6 +129,15 @@ function ensureRenderer() {
const open = _tdOpen(tokens, idx, options, env, self);
return prevTh.value !== NON_MONO_HEADER ? open.replace(/ {
+ const token = tokens[idx];
+ if (env && env.headingNumbers) {
+ return headingOpenNumbered(inst, prevTh, token, tokens, idx);
+ }
+ return `<${token.tag} class="md-h${Number(token.tag.slice(1))}">`;
+ };
+ inst._headingCtx = prevTh; // render 入口重置计数器的哨兵(见 resetHeadingCounters)
md = inst;
return md;
}
@@ -80,11 +155,16 @@ function getPurify() {
return null;
}
-/** markdown 文本 → 消毒后的 HTML(失败或无消毒环境返回转义原文,不抛异常) */
-export function renderMarkdown(text) {
+/** markdown 文本 → 消毒后的 HTML(失败或无消毒环境返回转义原文,不抛异常)
+ * @param {{headingNumbers?: boolean}} [opts] 标题编号注入开关,默认开启(chat 答案路径)。
+ * DocPane md 回退预览 / private_doc 参考块等「内容可回写正本」的场景用 renderMarkdownPlain 关闭。 */
+export function renderMarkdown(text, opts = {}) {
const src = String(text == null ? '' : text);
+ const headingNumbers = opts.headingNumbers !== false;
try {
- const html = ensureRenderer().render(src);
+ const inst = ensureRenderer();
+ resetHeadingCounters(inst);
+ const html = inst.render(src, { headingNumbers });
const purify = getPurify();
return purify ? purify.sanitize(html) : html;
} catch {
@@ -93,6 +173,17 @@ export function renderMarkdown(text) {
}
}
+/** 不注入标题编号的渲染(DocPane md 回退 / 私有参考块:内容可被序列化回写正本) */
+export function renderMarkdownPlain(text) {
+ return renderMarkdown(text, { headingNumbers: false });
+}
+
+/** 每次渲染入口重置标题计数器(渲染器单例,计数器必须逐次归零,防跨消息延续编号) */
+function resetHeadingCounters(inst) {
+ const ctx = inst._headingCtx;
+ if (ctx) ctx.h1 = ctx.h2 = ctx.h3 = 0;
+}
+
/**
* 已渲染 HTML(块联合 text.html:创建时已由 renderMarkdown 生成,
* 或后端落库的消毒 HTML)→ 渲染前再消毒一次(纵深防御:绝不信任持久化内容)。
diff --git a/ui/src/i18n/docgen.js b/ui/src/i18n/docgen.js
index 7f08e4f..977ddcf 100644
--- a/ui/src/i18n/docgen.js
+++ b/ui/src/i18n/docgen.js
@@ -64,6 +64,11 @@ export const DG = {
docPaneExportDocx: '导出 DOCX',
docPaneExporting: '导出中…',
docPaneExportReady: '下载',
+ docPaneLibraryView: '资料库', // 缺陷 #55:文档 TAB 头部视图切换按钮
+ docPaneBackToDoc: '返回文档', // 缺陷 #55:资料库视图返回活动文档
+ docgenTemplateAuto: '自动推荐', // 缺陷 #42:模板下拉首项(空 value = 自动推荐)
+ docPaneClose: '关闭文档', // 缺陷 #55:关闭活动文档回空态
+ docgenGenProgress: '生成中 {n}/{m} 节', // 缺陷 #52:异步生成进度文案(n=已完成 m=总节数)
docPaneArtifactLoading: '文档加载中…',
docPaneArtifactMissing: '文档内容不可用(可能已被清理)',
docPaneContentError: '文档内容加载失败',
diff --git a/ui/src/i18n/labels.js b/ui/src/i18n/labels.js
index 498c9a8..18ee371 100644
--- a/ui/src/i18n/labels.js
+++ b/ui/src/i18n/labels.js
@@ -158,6 +158,10 @@ export const T = {
evidenceReadyDesc: '证据已就绪,正在生成综述…',
// —— 工作台文案(原 B3 benchLabels.js 合并,2026-08-25)——
extractRunLog: '运行日志',
+ extractLogRefresh: '刷新日志', // 缺陷 #39:手动刷新按钮
+ // 缺陷 #28:检索空态与超时降级文案(与超时错误信封 TIMEOUT 区分,不渲染错误样式)
+ searchNoAnswer: '没有检索到此问题的有效回答',
+ searchTimeoutEmpty: '检索超时,未获取到有效回答(已降级返回)',
kgSearch: '搜索',
kgDepth2SlowHint: '(深度 2 较慢,约 13s)',
kgDepth2NeighborhoodSlow: '(深度 2 检索较慢)',
diff --git a/ui/src/stores/bench.js b/ui/src/stores/bench.js
index f466471..2923702 100644
--- a/ui/src/stores/bench.js
+++ b/ui/src/stores/bench.js
@@ -65,6 +65,12 @@ export const useBenchStore = defineStore('bench', {
jobDetail: {},
detailLoading: false,
detailError: false,
+ // 缺陷 #40:详情/日志展开态提升到 store——切 TAB(ExtractionPane 卸载重建)后
+ // 展开态保留(旧缺陷:切走再回来展开态丢失)。
+ detailOpenId: null, // 展开详情的 job_id | null
+ logOpen: false, // 运行日志展开态
+ runLog: '', // 运行日志内容(#39:打开总是重拉 + running 时 5s 轮询)
+ logError: false, // 日志拉取失败标记
pollTimer: null,
pollPaused: false,
// ---- 知识图谱 ----
@@ -123,27 +129,17 @@ export const useBenchStore = defineStore('bench', {
return null;
}
},
- /** 按 paper_id 去重并累计出现次数的会话级来源 */
- sessionSourceItems() {
- const s = this.sessionSources;
- if (!s || !Array.isArray(s.sources)) return [];
- const seen = new Map();
- for (const it of s.sources) {
- const key = it.paper_id || it.id || JSON.stringify(it);
- const prev = seen.get(key);
- if (prev) prev.count += it.count ?? 1;
- else seen.set(key, { ...it, count: it.count ?? 1 });
- }
- return [...seen.values()];
- },
- /** 当前来源列表(含来源标签) */
+ /** 当前来源列表(含来源标签)
+ * 缺陷 #27 修复:会话域删除前端重复聚合——后端 `GET /api/sessions/{id}/sources`
+ * 已按 paper_id 去重并累计 count,前端直接以后端 `sessionSources.sources` 为准
+ * (此前前端再按 paper_id 聚合一次造成「聚合后 id +1 错位」)。 */
sources() {
if (this.sourceScope === 'current') {
const items = this.currentSources;
return items ? items.map((s) => ({ src: s, label: null })) : null;
}
- if (!this.sessionSources) return null;
- return this.sessionSourceItems.map((s) => ({ src: s, label: s.count }));
+ if (!this.sessionSources || !Array.isArray(this.sessionSources.sources)) return null;
+ return this.sessionSources.sources.map((s) => ({ src: s, label: s.count ?? 1 }));
},
/** 存在 running/queued 任务 → 需要轮询(failed/completed 为终态) */
hasActiveJobs() {
@@ -153,6 +149,9 @@ export const useBenchStore = defineStore('bench', {
actions: {
// ---------- 基础 UI ----------
+ // 缺陷 #40 修复:任何离开 extract 的切换都暂停并停止轮询(pausePolling 内部
+ // 置 pollPaused 并清定时器);进入 extract 恢复(有 running 任务才真正重启,
+ // startPolling 内部判空)。
setActiveTab(tab) {
if (tab === this.activeTab) return;
const prev = this.activeTab;
@@ -176,8 +175,15 @@ export const useBenchStore = defineStore('bench', {
this.sourceScope = scope;
},
// FR-CHAT-015:引用 chip 点击 → 切到来源 tab 并高亮对应条目 2s(PM 集成补丁,B2 依赖)
- setHighlightSource(index) {
- this.highlightSource = { index, until: Date.now() + 2000 };
+ // 缺陷 #27 修复:参数由 index 改为 key(paper_id/id)——「本次回答」域 items 与
+ // chip 的来源块同序,key 匹配即可精确定位,不再依赖行序号(会话聚合域 id 错位
+ // 曾导致 chip 跳转与来源条目对不上)。key 缺失时回退 index(兼容旧调用)。
+ setHighlightSource(key) {
+ if (key == null) {
+ this.highlightSource = null;
+ return;
+ }
+ this.highlightSource = { key: String(key), index: typeof key === 'number' ? key : null, until: Date.now() + 2000 };
if (this._hlTimer) clearTimeout(this._hlTimer);
this._hlTimer = setTimeout(() => {
if (this.highlightSource && this.highlightSource.until <= Date.now()) {
@@ -350,6 +356,10 @@ export const useBenchStore = defineStore('bench', {
this.jobDetail = {};
this.detailLoading = false;
this.detailError = false;
+ this.detailOpenId = null;
+ this.logOpen = false;
+ this.runLog = '';
+ this.logError = false;
this.stopPolling();
},
_activeSessionId() {
diff --git a/ui/src/stores/chat.js b/ui/src/stores/chat.js
index cde5e3e..489d97c 100644
--- a/ui/src/stores/chat.js
+++ b/ui/src/stores/chat.js
@@ -22,7 +22,7 @@ import { renderMarkdown } from '../components/chat/markdown.js';
// ---- P3 文档生成:独立分支(send/_runStream 零改动) ----
import { DOCGEN_STEPS, CAP_BADGES, DG as DG_LABELS } from '../i18n/docgen.js';
-import { generate } from '../api/docgen.js';
+import { generate, getTask } from '../api/docgen.js';
import { useSettingsStore } from './settings.js';
import { useDocgenStore } from './docgen.js';
@@ -412,6 +412,13 @@ function newStreamCtx(sessionId, msgList) {
};
}
+// 缺陷 #52:docgen 异步任务轮询注册表(模块级、非响应式)。
+// 键 = task_id(同一会话可有多个任务,互不干扰);终态后自清。
+// 轮询跨会话继续(生成期间用户可切走对话——本缺陷核心诉求),
+// 完成/失败时若用户仍在产生该任务的会话则自动切文档 TAB。
+const DOCGEN_TASK_POLL_MS = 2000;
+const docgenTaskPolls = new Map(); // task_id → { timer, sessionId, aMsg }
+
export const useChatStore = defineStore('chat', {
state: () => ({
sessionId: null, // 当前会话 id(与 sessions.activeId 对应)
@@ -496,6 +503,12 @@ export const useChatStore = defineStore('chat', {
this.sessionMsgs[id] = this.messages;
}
await resetBenchData();
+ // 缺陷 #47b:会话切换时清空文档域状态(resetSession 只清不丢——切回会话 A
+ // 时消息流里的 artifact 块仍在,DocCard 可重新打开;不刷新网页不再残留 A 的文档)
+ try {
+ const { useDocgenStore } = await import('./docgen.js');
+ useDocgenStore().resetSession();
+ } catch { /* docgen store 未就绪(测试/降级):静默 */ }
},
/** 从 GET /api/sessions/{id} 恢复全部块(FR-CHAT-013) */
@@ -515,9 +528,23 @@ export const useChatStore = defineStore('chat', {
}
},
- /** 把后端 detail 转为本地运行时消息(user 无 blocks,用 text 渲染) */
+ /** 把后端 detail 转为本地运行时消息(user 无 blocks,用 text 渲染)
+ * 缺陷 #52:running/queued 的 docgen 消息块若含 task_id → 恢复任务轮询
+ * (页面刷新/会话恢复后进度继续驱动,不永久挂「生成中」)。 */
hydrateDetail(data) {
- this.messages = (data.messages || []).map((m) => {
+ const rawList = (data.messages || []);
+ for (const m of rawList) {
+ if (m && m.role === 'assistant' && m.kind === 'docgen') {
+ const pipe = (m.blocks || []).find((b) => b && b.type === 'pipeline');
+ const tid = (m.task_id) || (pipe && pipe.task_id) || null;
+ if (tid && (m.status === 'streaming' || m.status === 'interrupted' || m.status === 'queued')) {
+ // 挂载恢复:aMsg 引用在 map 后拿(响应式代理),此处记录待恢复清单
+ if (!this._docgenResumeQueue) this._docgenResumeQueue = [];
+ this._docgenResumeQueue.push({ taskId: tid, messageId: m.id });
+ }
+ }
+ }
+ this.messages = rawList.map((m) => {
const msg = { ...m };
if (msg.role === 'user') {
msg.blocks = [{ type: 'text', html: msg.text || '' }];
@@ -528,7 +555,11 @@ export const useChatStore = defineStore('chat', {
// 半成品)以及无标注的 error 一律置 error 并标「已中断」。
// 但若该会话有活跃后台流,则不标——实时数组会接管(切回时走缓存路径)。
const hasLiveCtx = !!this.streamCtx[this.sessionId];
- if (!hasLiveCtx && (msg.status === 'interrupted' || msg.status === 'streaming' ||
+ // 缺陷 #52:docgen 异步任务消息(含 task_id 的 streaming)不标「已中断」——
+ // 由 _docgenResumeQueue 恢复轮询,进度继续驱动直至终态。
+ const pipe0 = (msg.blocks || []).find((b) => b && b.type === 'pipeline');
+ const docgenTaskRunning = msg.kind === 'docgen' && !!(pipe0 && pipe0.task_id);
+ if (!hasLiveCtx && !docgenTaskRunning && (msg.status === 'interrupted' || msg.status === 'streaming' ||
(msg.status === 'error' && !msg.streamErr))) {
msg.interrupted = true;
msg.status = 'error';
@@ -541,6 +572,20 @@ export const useChatStore = defineStore('chat', {
this.lastResult = null;
this.scrollSeq++;
hydrateBench();
+ // 缺陷 #52:恢复 docgen 任务轮询(hydrate 后 aMsg 已是响应式代理)
+ if (this._docgenResumeQueue && this._docgenResumeQueue.length) {
+ const queue = this._docgenResumeQueue;
+ this._docgenResumeQueue = [];
+ for (const item of queue) {
+ const aMsg = this.messages.find((x) => x.id === item.messageId) || null;
+ if (!aMsg) continue;
+ const pipe = (aMsg.blocks || []).find((b) => b && b.type === 'pipeline');
+ if (!pipe) continue;
+ pipe.task_id = item.taskId;
+ pipe.taskStatus = pipe.taskStatus || 'queued';
+ this._startDocgenTaskPoll(item.taskId, this.sessionId, aMsg, { userMessageId: null, startAt: Date.now() });
+ }
+ }
},
// ---------------------------------------------------------------
@@ -629,12 +674,13 @@ export const useChatStore = defineStore('chat', {
},
// ---------------------------------------------------------------
- // sendDocgen(P3):文档生成独立分支——用户消息落库(现有机制)→
- // docgen 助手骨架 → 同步 generate(600s 兜底)→ 成功:pipeline(done)
- // + artifact 块 + 持久化 + 自动切文档 TAB;失败 _errorMarker 标注可重试。
+ // sendDocgen(P3,缺陷 #52 异步任务化):用户消息落库 → docgen 骨架 →
+ // POST generate(200 同步兼容 / 202 异步)→ 202 后释放 busy 锁(生成期间可继续对话)
+ // → 2s 轮询 getTask → progress 驱动「生成中 x/y 节」→ done 复用现有 artifact 挂块+
+ // 落库+adoptArtifact+切 TAB;error 标失败可重试;会话恢复时 running 块含 task_id 则续轮询。
// send()/_runStream 零改动;docgen 不经过 SSE。
// ---------------------------------------------------------------
- async sendDocgen(description, libraryIds, { userMsg } = {}) {
+ async sendDocgen(description, libraryIds, { userMsg, templateId } = {}) {
const q = String(description == null ? '' : description).trim();
if (!q || this.busy) return;
const sessions = useSessionsStore();
@@ -703,7 +749,7 @@ export const useChatStore = defineStore('chat', {
aMsg.id = null; // 骨架落库失败:终态用 replace 语义重建(防御)
}
- // 4. 同步生成(600s 兜底):期间按序推进 pipeline 相位(生成是长任务,需要视觉反馈)
+ // 4. 生成请求(缺陷 #52:兼容 200 同步 / 202 异步两种契约)
const pipe = aMsg.blocks[0];
const stepSet = (phase, state, desc) => {
const st = pipe.steps.find((x) => x.phase === phase);
@@ -714,68 +760,21 @@ export const useChatStore = defineStore('chat', {
this.scrollSeq++;
};
stepSet('planning', 'running');
+ let payload;
try {
- const payload = await generate({ description: q, session_id: sessionId, library_ids: libraryIds || [] });
- // 成功:pipeline done + artifact 块
- const artifact = payload?.artifact || null;
- pipe.status = 'done';
- pipe.steps.forEach((s) => { s.state = 'done'; });
- if (artifact) {
- const art = { ...artifact };
- const artId = art.artifact_id || art.id;
- if (artId) art.artifact_id = artId;
- aMsg.blocks.push({
- type: 'artifact',
- artifact_id: art.artifact_id,
- title: art.title || '',
- template_name: art.template_name || '',
- status: art.status || 'done',
- });
- }
- aMsg.status = 'done';
- aMsg.raw = payload ?? null;
- const dur = ((Date.now() - startAt) / 1000).toFixed(1) + 's';
- try {
- await this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw });
- } catch (persistErr) {
- // 落库失败不掩盖成功事实:本地消息保持 done,仅记录
- console.warn('persistFinal failed after docgen:', persistErr);
- }
- // 产物接管给文档 TAB 并自动切过去(B3 setActiveTab 任意 key 可用;失败不阻断)
- if (artifact) {
- try {
- const { useDocgenStore } = await import('../stores/docgen.js');
- const dg = useDocgenStore();
- // BUG-3 修复:后端 ArtifactOut 无 markdown 字段——adoptArtifact 在无本地内容时
- // 自动拉取 content(docgen store 内部 fetchContent),切 TAB 后不会出现「内容不可用」。
- await dg.adoptArtifact(artifact, {
- initialMd: payload && typeof payload === 'object' && payload.markdown != null ? payload.markdown : '',
- });
- } catch { /* docgen store 未就绪不阻断 */ }
- try {
- const { useBenchStore } = await import('../stores/bench.js');
- useBenchStore().setActiveTab('doc');
- } catch { /* bench 未就绪不阻断 */ }
- }
- this._finishCtx(ctx, {
- ok: true,
- payload,
- message: aMsg,
- sessionId,
- userMessageId: userId,
- assistantMessageId: aMsg.id,
- dur,
- blocks: aMsg.blocks.length,
- });
+ // 缺陷 #42:payload 带 template_id(Composer 模板下拉选择;空/未选 = 自动推荐)
+ const genBody = { description: q, session_id: sessionId, library_ids: libraryIds || [] };
+ if (templateId) genBody.template_id = templateId;
+ payload = await generate(genBody);
} catch (err) {
- // 失败:_errorMarker 标注(可重试;后端 error.message 直接展示)
+ // 发起失败(网络/信封):_errorMarker 标注(可重试)并释放 busy
const marker = this._errorMarker(err.code);
const text = err.message || marker.text || T.upstreamError;
aMsg.status = 'error';
aMsg.streamErr = marker.ann;
aMsg.errText = text;
pipe.status = 'error';
- const running = pipe.steps.find((s) => s.state === 'running');
+ const running = pipe.steps.find((x) => x.state === 'running');
if (running) running.state = 'done';
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, {
@@ -786,7 +785,252 @@ export const useChatStore = defineStore('chat', {
userMessageId: userId,
assistantMessageId: aMsg.id,
});
+ return;
}
+
+ // ---- 缺陷 #52:202 异步任务化 ----
+ // 关键:生成期间必须释放 busy 锁(旧缺陷:同步 600s 阻塞导致无法继续对话)。
+ // 轮询注册表独立于 streamCtx——busy 只看 streamCtx,删除后即可继续发送消息。
+ const taskId = payload?.task_id || null;
+ const isAsync = !!(taskId && (payload.status === 'queued' || payload.status === 'running'));
+ if (isAsync) {
+ aMsg.raw = payload ?? null;
+ aMsg.task_id = taskId;
+ pipe.task_id = taskId; // 持久化进 blocks(会话恢复时据此续轮询)
+ pipe.taskStatus = payload.status || 'queued';
+ stepSet('planning', 'done'); // 请求已被受理:第一步收口
+ this._persistFinal(aMsg, sessionId, 'streaming', { raw: aMsg.raw }).catch(() => {});
+ // 释放 busy 锁:清理本会话流上下文(无流、无 checkpoint)。
+ // 会话状态保持 running(任务进行中),轮询由注册表驱动,终态时收口。
+ delete this.streamCtx[sessionId];
+ this.lastResult = null;
+ this.scrollSeq++;
+ this._startDocgenTaskPoll(taskId, sessionId, aMsg, { userMessageId: userId, startAt });
+ return;
+ }
+
+ // ---- 200 同步兼容路径(旧 BFF):行为与修复前一致 ----
+ const artifact = payload?.artifact || null;
+ pipe.status = 'done';
+ pipe.steps.forEach((s) => { s.state = 'done'; });
+ if (artifact) {
+ const art = { ...artifact };
+ const artId = art.artifact_id || art.id;
+ if (artId) art.artifact_id = artId;
+ aMsg.blocks.push({
+ type: 'artifact',
+ artifact_id: art.artifact_id,
+ title: art.title || '',
+ template_name: art.template_name || '',
+ status: art.status || 'done',
+ });
+ }
+ aMsg.status = 'done';
+ aMsg.raw = payload ?? null;
+ const dur = ((Date.now() - startAt) / 1000).toFixed(1) + 's';
+ try {
+ await this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw });
+ } catch (persistErr) {
+ // 落库失败不掩盖成功事实:本地消息保持 done,仅记录
+ console.warn('persistFinal failed after docgen:', persistErr);
+ }
+ // 产物接管给文档 TAB 并自动切过去(B3 setActiveTab 任意 key 可用;失败不阻断)
+ if (artifact) {
+ try {
+ const { useDocgenStore } = await import('../stores/docgen.js');
+ const dg = useDocgenStore();
+ // BUG-3 修复:后端 ArtifactOut 无 markdown 字段——adoptArtifact 在无本地内容时
+ // 自动拉取 content(docgen store 内部 fetchContent),切 TAB 后不会出现「内容不可用」。
+ await dg.adoptArtifact(artifact, {
+ initialMd: payload && typeof payload === 'object' && payload.markdown != null ? payload.markdown : '',
+ });
+ } catch { /* docgen store 未就绪不阻断 */ }
+ try {
+ const { useBenchStore } = await import('../stores/bench.js');
+ useBenchStore().setActiveTab('doc');
+ } catch { /* bench 未就绪不阻断 */ }
+ }
+ this._finishCtx(ctx, {
+ ok: true,
+ payload,
+ message: aMsg,
+ sessionId,
+ userMessageId: userId,
+ assistantMessageId: aMsg.id,
+ dur,
+ blocks: aMsg.blocks.length,
+ });
+ },
+
+ /**
+ * 缺陷 #52:docgen 异步任务轮询(2s 间隔)。
+ * - progress 驱动 pipeline「生成中 x/y 节」(DOCGEN_STEPS 固定五步:任务 running 后
+ * 前三步视为已过,generation 步 desc 显示节进度);
+ * - done → 复用现有 artifact 挂块 + 落库 + adoptArtifact + (当前会话时)自动切文档 TAB;
+ * - error → 标失败(中文可读 error)+ 可重试(retry() 走 docgen 路径);
+ * - 消息被删除(不在 sessionMsgs 列表)→ 不再落 UI,仅静默等待终态后清注册。
+ * - 轮询跨会话继续;终态落 UI 仅在用户仍停留在产生任务的会话时打扰性动作为自动切 TAB。
+ */
+ _startDocgenTaskPoll(taskId, sessionId, aMsg, { userMessageId, startAt }) {
+ if (docgenTaskPolls.has(taskId)) return; // 会话恢复/重复触发防重入
+ const entry = { timer: null, sessionId, aMsg, startedAt: startAt || Date.now(), userMessageId: userMessageId || null };
+ docgenTaskPolls.set(taskId, entry);
+ const clear = () => {
+ if (entry.timer != null) clearTimeout(entry.timer);
+ if (docgenTaskPolls.get(taskId) === entry) docgenTaskPolls.delete(taskId);
+ };
+ const pipe = aMsg.blocks[0];
+ const stepSet = (phase, state, desc) => {
+ const st = pipe.steps.find((x) => x.phase === phase);
+ if (st) {
+ if (state) st.state = state;
+ if (desc != null) st.desc = desc;
+ }
+ };
+ const alive = () => {
+ const list = this.sessionMsgs[sessionId] || [];
+ return list.includes(aMsg);
+ };
+ const progressLabel = (task) => {
+ const pr = Array.isArray(task.progress) ? task.progress : [];
+ if (!pr.length) return null;
+ const doneN = pr.filter((x) => x && x.state === 'done').length;
+ return DG_LABELS.docgenGenProgress.replace('{n}', String(doneN)).replace('{m}', String(pr.length));
+ };
+ const tick = async () => {
+ if (!docgenTaskPolls.has(taskId) || docgenTaskPolls.get(taskId) !== entry) return;
+ let task = null;
+ try {
+ const data = await getTask(taskId);
+ task = (data && data.task) || null;
+ } catch {
+ task = null; // 瞬时网络失败:继续轮询(终态由后续 tick 收口)
+ }
+ if (!docgenTaskPolls.has(taskId) || docgenTaskPolls.get(taskId) !== entry) return;
+ if (!task) {
+ entry.timer = setTimeout(tick, DOCGEN_TASK_POLL_MS);
+ return;
+ }
+ const status = task.status || 'running';
+ if (status === 'queued') {
+ // 排队中:仅推进视觉(planning 已 done)
+ if (alive()) {
+ pipe.status = 'running';
+ stepSet('planning', 'done');
+ this.scrollSeq++;
+ }
+ entry.timer = setTimeout(tick, DOCGEN_TASK_POLL_MS);
+ return;
+ }
+ if (status === 'running') {
+ if (alive()) {
+ pipe.status = 'running';
+ pipe.taskStatus = 'running';
+ stepSet('planning', 'done');
+ stepSet('retrieval', 'done');
+ stepSet('evidence', 'done');
+ const label = progressLabel(task);
+ stepSet('generation', 'running', label || undefined);
+ this.scrollSeq++;
+ this._persistFinal(aMsg, sessionId, 'streaming', { raw: aMsg.raw }).catch(() => {});
+ }
+ entry.timer = setTimeout(tick, DOCGEN_TASK_POLL_MS);
+ return;
+ }
+ if (status === 'done') {
+ const artifact = task.artifact || null;
+ if (alive()) {
+ pipe.status = 'done';
+ pipe.taskStatus = 'done';
+ pipe.steps.forEach((st) => { st.state = 'done'; });
+ // r3-52-02:done 后还原 generation 步默认 desc(running 时被
+ // 「生成中 x/y 节」覆盖,终态不得残留进度文案)
+ const genStep = pipe.steps.find((st) => st.phase === 'generation');
+ if (genStep) genStep.desc = DOCGEN_STEPS.find((st) => st.phase === 'generation')?.desc || genStep.desc;
+ if (artifact) {
+ const art = { ...artifact };
+ const artId = art.artifact_id || art.id;
+ if (artId) art.artifact_id = artId;
+ aMsg.blocks.push({
+ type: 'artifact',
+ artifact_id: art.artifact_id,
+ title: art.title || '',
+ template_name: art.template_name || '',
+ status: art.status || 'done',
+ });
+ }
+ aMsg.status = 'done';
+ aMsg.raw = { task_id: taskId, task };
+ this.scrollSeq++;
+ this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw }).catch(() => {});
+ }
+ const sessions = useSessionsStore();
+ sessions.patchLocal(sessionId, { status: 'done' });
+ updateSessionQuiet(sessionId, { status: 'done' }).catch(() => {});
+ if (alive() && artifact) {
+ // 产物接管(仅消息仍在时):adoptArtifact + 当前会话才自动切文档 TAB
+ try {
+ const { useDocgenStore } = await import('../stores/docgen.js');
+ await useDocgenStore().adoptArtifact(artifact, { initialMd: '' });
+ } catch { /* docgen store 未就绪不阻断 */ }
+ if (this.sessionId === sessionId) {
+ try {
+ const { useBenchStore } = await import('../stores/bench.js');
+ useBenchStore().setActiveTab('doc');
+ } catch { /* bench 未就绪不阻断 */ }
+ }
+ }
+ if (this.sessionId === sessionId) {
+ this.lastResult = {
+ ok: true,
+ payload: { task_id: taskId, task },
+ message: aMsg,
+ sessionId,
+ userMessageId: entry.userMessageId,
+ assistantMessageId: aMsg.id,
+ dur: ((Date.now() - entry.startedAt) / 1000).toFixed(1) + 's',
+ blocks: aMsg.blocks.length,
+ };
+ this.scrollSeq++;
+ }
+ clear();
+ return;
+ }
+ if (status === 'error') {
+ if (alive()) {
+ const text = task.error || T.upstreamError;
+ aMsg.status = 'error';
+ aMsg.streamErr = 'message';
+ aMsg.errText = text;
+ pipe.status = 'error';
+ pipe.taskStatus = 'error';
+ const running = pipe.steps.find((st) => st.state === 'running');
+ if (running) running.state = 'done';
+ aMsg.raw = { task_id: taskId, task };
+ this.scrollSeq++;
+ this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
+ }
+ const sessions = useSessionsStore();
+ sessions.patchLocal(sessionId, { status: 'error' });
+ updateSessionQuiet(sessionId, { status: 'error' }).catch(() => {});
+ if (this.sessionId === sessionId) {
+ this.lastResult = {
+ ok: false,
+ error: { code: 'DOCGEN_FAILED', message: task.error || T.upstreamError, retryable: true },
+ message: aMsg,
+ sessionId,
+ userMessageId: entry.userMessageId,
+ assistantMessageId: aMsg.id,
+ };
+ this.scrollSeq++;
+ }
+ clear();
+ return;
+ }
+ // 未知状态(防御):继续轮询
+ entry.timer = setTimeout(tick, DOCGEN_TASK_POLL_MS);
+ };
+ entry.timer = setTimeout(tick, DOCGEN_TASK_POLL_MS);
},
// ---------------------------------------------------------------
@@ -1415,6 +1659,18 @@ export const useChatStore = defineStore('chat', {
const pipe = aMsg.blocks[0];
pipe.status = 'done';
pipe.steps.forEach((s) => { s.state = 'done'; });
+ // 缺陷 #28:空结果与超时降级(200 降级体)不再走 _errorMarker 的 TIMEOUT 报错——
+ // 以明确文案展示(非错误信封样式),与正常空结果可区分。
+ const degraded = !!payload.degraded;
+ const hasAnswer = !!(payload.answer && String(payload.answer).trim());
+ const hasSources = Array.isArray(payload.sources) && payload.sources.length > 0;
+ if (degraded && !hasAnswer && !hasSources) {
+ // 超时降级(reason TIMEOUT 等):专属文案
+ aMsg.degradedNote = T.searchTimeoutEmpty;
+ } else if (!hasAnswer && !hasSources) {
+ // 正常空结果(degraded:false):无命中文案
+ aMsg.degradedNote = T.searchNoAnswer;
+ }
aMsg.status = 'done';
const dur = ((Date.now() - startAt) / 1000).toFixed(1) + 's';
this._flushPrivate(aMsg); // FR-PRIV-005:blocks 赋值后 flush 私有 pending
@@ -1523,10 +1779,20 @@ export const useChatStore = defineStore('chat', {
aMsg.status = 'done';
return;
}
- // >2000 字符加速:总时长上限 8s(FR-CHAT-010)
- const step = total > 2000
- ? Math.ceil(total / Math.floor(REVEAL_MAX_MS / REVEAL_STEP_MS))
+ // >2000 字符加速:总时长上限 8s(FR-CHAT-010)。
+ // 缺陷 #7a 修复:加速分支此前只放大 step、定时器间隔仍写死 30ms → 长文实际
+ // 远超 8s。此处按总时长 ≤ REVEAL_MAX_MS 反推 interval,且 interval 下限
+ // 12ms 防空转(浏览器定时器最低粒度 ~4ms,每 tick 一步的开销远小于间隔)。
+ // ≤2000 字符路径保持 30ms/字符不变(step=1 字符 / interval=30ms,
+ // 与验收用例「总揭示时长 ≈ 字符数 × 30ms」一致)。
+ const interval = total > 2000
+ ? Math.max(12, Math.floor(REVEAL_MAX_MS / Math.ceil(total / REVEAL_STEP_MS)))
: REVEAL_STEP_MS;
+ // 以 tick 预算反推步进:tick 数 × interval 严格 ≤ REVEAL_MAX_MS
+ //(直接按总时长反推 step 会因 ceil 上取整略超 8s)
+ const step = total > 2000
+ ? Math.max(1, Math.ceil(total / Math.floor(REVEAL_MAX_MS / interval)))
+ : 1;
const revealId = (aMsg.revealId || 0) + 1;
aMsg.revealId = revealId;
aMsg.blocks.push({ type: 'stream', text: '' });
@@ -1548,7 +1814,7 @@ export const useChatStore = defineStore('chat', {
this._finalizeReveal(aMsg, fullText, ctx);
resolve();
}
- }, REVEAL_STEP_MS);
+ }, interval);
});
},
diff --git a/ui/src/stores/docgen.js b/ui/src/stores/docgen.js
index 8dff17f..ac659c5 100644
--- a/ui/src/stores/docgen.js
+++ b/ui/src/stores/docgen.js
@@ -40,7 +40,10 @@ export const useDocgenStore = defineStore('docgen', {
exporting: {}, // format -> true(导出请求中)
exportError: {}, // format -> {message} | null
exportRequestSeq: 0, // 导出请求信号:Composer export 分支 +1,DocPane watch 后消费
- templateCandidates: [], // 模版检索候选(生成后由后端返回;当前版本主要展示用)
+ templateCandidates: [], // 模版检索候选(生成后由后端返回;展示「上次生成使用模板」用)
+ // 缺陷 #55:文档 TAB 视图模式——'doc' 活动文档预览 / 'library' 资料库
+ // (有 activeArtifact 时 DocPane 头部提供「资料库」入口;close()/resetSession() 复位)
+ viewMode: 'doc',
renderNonce: 0, // 渲染信号:html 拉取失败等无字段变化场景强制 DocPane 重渲染(问题5)
}),
@@ -271,8 +274,10 @@ export const useDocgenStore = defineStore('docgen', {
} catch { /* bench 未就绪:仅 docgen 状态仍可用 */ }
},
- /** 关闭活动文档(切走 doc TAB 不清空;此处供未来「关闭文档」入口) */
+ /** 关闭活动文档(切走 doc TAB 不清空;DocPane「关闭文档」入口)
+ * 缺陷 #55:关闭时复位视图模式(回到资料库空态区视图) */
close() {
+ this.viewMode = 'doc';
this.activeArtifact = null;
this.md = '';
this.html = '';
diff --git a/ui/tests/r3debug/bench.test.js b/ui/tests/r3debug/bench.test.js
new file mode 100644
index 0000000..c268bfd
--- /dev/null
+++ b/ui/tests/r3debug/bench.test.js
@@ -0,0 +1,137 @@
+// tests/r3debug/bench.test.js —— #27 聚合 ID / #40 轮询暂停策略
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { setActivePinia, createPinia } from 'pinia';
+import { useBenchStore, __setHttpGet } from '../../src/stores/bench.js';
+
+function makePiniaWithChat(chatState) {
+ const pinia = createPinia();
+ setActivePinia(pinia);
+ // chat store 未注册时 currentSources 回退 null——注册一个假 chat store 提供 currentMessage
+ pinia.state.value.chat = chatState;
+ return pinia;
+}
+
+describe('#27 会话级来源聚合(后端结果为准)', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it('sessionSources.sources 原样透出(不重复聚合、不 +1)', () => {
+ makePiniaWithChat({ messages: [] });
+ const bench = useBenchStore();
+ bench.sourceScope = 'session';
+ bench.sessionSources = {
+ ok: true,
+ sources: [
+ { paper_id: 'P1', id: 'x1', title: 'A', count: 2 },
+ { paper_id: 'P2', id: 'x2', title: 'B', count: 1 },
+ ],
+ };
+ const items = bench.sources;
+ expect(items).toHaveLength(2);
+ expect(items[0].src.paper_id).toBe('P1');
+ expect(items[0].label).toBe(2);
+ expect(items[1].label).toBe(1);
+ });
+
+ it('后端已去重:重复 paper_id 不再被前端二次聚合', () => {
+ makePiniaWithChat({ messages: [] });
+ const bench = useBenchStore();
+ bench.sourceScope = 'session';
+ bench.sessionSources = {
+ sources: [
+ { paper_id: 'P1', count: 1 },
+ { paper_id: 'P1', count: 1 }, // 后端若漏去重(防御):前端也不合并(修复后语义)
+ ],
+ };
+ // 修复后:前端不再做 Map 聚合,直接透传
+ expect(bench.sources).toHaveLength(2);
+ });
+
+ it('setHighlightSource 改 key 语义:key 匹配 paper_id/id,旧 index 兼容', () => {
+ makePiniaWithChat({ messages: [] });
+ const bench = useBenchStore();
+ bench.setHighlightSource('P-123');
+ expect(bench.highlightSource.key).toBe('P-123');
+ bench.setHighlightSource(2); // 数字 → 兼容旧 index 调用
+ expect(bench.highlightSource.index).toBe(2);
+ bench.setHighlightSource(null); // 清除
+ expect(bench.highlightSource).toBeNull();
+ });
+});
+
+describe('#40 轮询暂停/恢复策略', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ __setHttpGet(null);
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ __setHttpGet(null);
+ });
+
+ it('任何离开 extract 的切换 → pollPaused 且定时器清空', () => {
+ const pinia = createPinia();
+ setActivePinia(pinia);
+ const bench = useBenchStore();
+ bench.activeTab = 'extract';
+ bench.jobs = [{ job_id: 'j1', status: 'running' }];
+ const started = bench.startPolling({ timeoutFn: setTimeout, clearFn: clearTimeout, delay: 5000 });
+ expect(started).toBe(true);
+ expect(bench.isPolling()).toBe(true);
+ bench.setActiveTab('retrieval');
+ expect(bench.pollPaused).toBe(true);
+ expect(bench.isPolling()).toBe(false);
+ // fake timer 前进:无残留轮询请求(loadJobs 不会被调)
+ const spy = vi.spyOn(bench, 'loadJobs');
+ vi.advanceTimersByTime(20000);
+ expect(spy).not.toHaveBeenCalled();
+ });
+
+ it('进入 extract 恢复轮询资格(pollPaused=false);有 running 时 pane 会重启', () => {
+ const pinia = createPinia();
+ setActivePinia(pinia);
+ const bench = useBenchStore();
+ bench.activeTab = 'sources';
+ bench.pollPaused = true;
+ bench.jobs = [{ job_id: 'j1', status: 'running' }];
+ bench.setActiveTab('extract');
+ expect(bench.pollPaused).toBe(false);
+ expect(bench.activeTab).toBe('extract');
+ const started = bench.startPolling({ timeoutFn: setTimeout, clearFn: clearTimeout, delay: 5000 });
+ expect(started).toBe(true);
+ });
+
+ it('全部终态:_tickPoll 不再续定时器', async () => {
+ const pinia = createPinia();
+ setActivePinia(pinia);
+ const bench = useBenchStore();
+ bench.activeTab = 'extract';
+ bench.jobs = [{ job_id: 'j1', status: 'completed' }];
+ bench.startPolling({ timeoutFn: setTimeout, clearFn: clearTimeout, delay: 5000 });
+ const spy = vi.spyOn(bench, 'loadJobs').mockResolvedValue({ jobs: [] });
+ vi.advanceTimersByTime(5000);
+ await Promise.resolve();
+ expect(spy).not.toHaveBeenCalled(); // 无 active 任务:tick 直接 return 不再续
+ expect(bench.isPolling()).toBe(false);
+ });
+
+ it('#40 详情/日志展开态存 store:resetSessionData 清理', () => {
+ const pinia = createPinia();
+ setActivePinia(pinia);
+ const bench = useBenchStore();
+ bench.detailOpenId = 'job-1';
+ bench.logOpen = true;
+ bench.runLog = 'log line';
+ expect(bench.detailOpenId).toBe('job-1');
+ bench.resetSessionData();
+ expect(bench.detailOpenId).toBeNull();
+ expect(bench.logOpen).toBe(false);
+ expect(bench.runLog).toBe('');
+ });
+});
diff --git a/ui/tests/r3debug/docgenPoll.test.js b/ui/tests/r3debug/docgenPoll.test.js
new file mode 100644
index 0000000..a020f23
--- /dev/null
+++ b/ui/tests/r3debug/docgenPoll.test.js
@@ -0,0 +1,264 @@
+// tests/r3debug/docgenPoll.test.js —— #52 docgen 异步轮询状态机 + #28 检索文案
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { setActivePinia, createPinia } from 'pinia';
+
+// ---- mock 依赖(chat.js 顶层 import 链) ----
+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 })),
+}));
+
+import { useChatStore } from '../../src/stores/chat.js';
+import { generate, getTask } from '../../src/api/docgen.js';
+
+// docgen generate/getTask 的 mock 控制在各用例内(vi.mocked)
+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: [] })),
+}));
+
+import { useSessionsStore } from '../../src/stores/sessions.js';
+
+function freshStores() {
+ const pinia = createPinia();
+ setActivePinia(pinia);
+ const chat = useChatStore();
+ const sessions = useSessionsStore();
+ // 预置会话,跳过 create 网络路径
+ chat.sessionId = 's1';
+ sessions.activeId = 's1';
+ sessions.items = [{ id: 's1', title: 't', status: 'done', created_at: '', updated_at: '' }];
+ return { chat, sessions };
+}
+
+describe('#52 docgen 异步轮询状态机', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.mocked(generate).mockReset();
+ vi.mocked(getTask).mockReset();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it('202 异步:释放 busy 锁、消息块记录 task_id、2s 轮询', async () => {
+ const { chat } = freshStores();
+ vi.mocked(generate).mockResolvedValue({ ok: true, task_id: 'task-1', status: 'queued' });
+ vi.mocked(getTask).mockResolvedValue({ ok: true, task: { id: 'task-1', status: 'running', progress: [], artifact: null, error: null } });
+
+ const p = chat.sendDocgen('写一份报告', []);
+ await vi.advanceTimersByTimeAsync(0);
+ await p;
+
+ // 核心缺陷:生成期间可继续对话 → busy 为 false(streamCtx 已清)
+ expect(chat.busy).toBe(false);
+ const aMsg = chat.messages.find((m) => m.role === 'assistant' && m.kind === 'docgen');
+ expect(aMsg).toBeTruthy();
+ expect(aMsg.task_id).toBe('task-1');
+ expect(aMsg.blocks[0].task_id).toBe('task-1');
+ expect(aMsg.blocks[0].taskStatus).toBe('queued');
+ expect(aMsg.status).toBe('streaming');
+
+ // 2s 轮询:advance 2s → getTask 被调
+ await vi.advanceTimersByTimeAsync(2000);
+ expect(getTask).toHaveBeenCalledWith('task-1');
+ });
+
+ it('running 进度驱动 pipeline(progress → 生成中 x/y 节)', async () => {
+ const { chat } = freshStores();
+ vi.mocked(generate).mockResolvedValue({ ok: true, task_id: 'task-2', status: 'queued' });
+ vi.mocked(getTask).mockResolvedValue({
+ ok: true,
+ task: {
+ id: 'task-2', status: 'running',
+ progress: [
+ { section: 1, title: 'a', state: 'done' },
+ { section: 2, title: 'b', state: 'done' },
+ { section: 3, title: 'c', state: 'running' },
+ { section: 4, title: 'd', state: 'pending' },
+ ],
+ artifact: null, error: null,
+ },
+ });
+ const p = chat.sendDocgen('写一份方案', []);
+ await vi.advanceTimersByTimeAsync(0);
+ await p;
+ await vi.advanceTimersByTimeAsync(2000);
+ await vi.advanceTimersByTimeAsync(0);
+ const aMsg = chat.messages.find((m) => m.role === 'assistant' && m.kind === 'docgen');
+ const pipe = aMsg.blocks[0];
+ expect(pipe.taskStatus).toBe('running');
+ const gen = pipe.steps.find((st) => st.phase === 'generation');
+ expect(gen.state).toBe('running');
+ expect(gen.desc).toContain('生成中 2/4');
+ });
+
+ it('done:挂 artifact 块 + 落库 + adoptArtifact + 切文档 TAB', async () => {
+ const { chat } = freshStores();
+ vi.mocked(generate).mockResolvedValue({ ok: true, task_id: 'task-3', status: 'queued' });
+ // r3-52-02 复现:先 running(generation desc 被「生成中 x/y 节」覆盖)→ 再 done
+ vi.mocked(getTask)
+ .mockResolvedValueOnce({
+ ok: true,
+ task: {
+ id: 'task-3', status: 'running',
+ progress: [
+ { section: 1, title: 'a', state: 'done' },
+ { section: 2, title: 'b', state: 'running' },
+ ],
+ artifact: null, error: null,
+ },
+ })
+ .mockResolvedValue({
+ ok: true,
+ task: {
+ id: 'task-3', status: 'done',
+ progress: [],
+ artifact: { artifact_id: 'art-1', title: '方案', template_name: '方案计划', template_key: 'plan', has_html: true, letterhead: false, file_name: 'f.docx' },
+ error: null,
+ },
+ });
+ const p = chat.sendDocgen('写一份方案', []);
+ await vi.advanceTimersByTimeAsync(0);
+ await p;
+ // 第一 tick:running,desc 被进度文案覆盖
+ await vi.advanceTimersByTimeAsync(2000);
+ await vi.advanceTimersByTimeAsync(0);
+ let aMsg = chat.messages.find((m) => m.role === 'assistant' && m.kind === 'docgen');
+ expect(aMsg.blocks[0].steps.find((s) => s.phase === 'generation').desc).toContain('生成中 1/2');
+ // 第二 tick:done,desc 必须还原默认文案(r3-52-02)
+ await vi.advanceTimersByTimeAsync(2000);
+ await vi.advanceTimersByTimeAsync(0);
+ await vi.advanceTimersByTimeAsync(0); // adoptArtifact 内部异步拉取
+ aMsg = chat.messages.find((m) => m.role === 'assistant' && m.kind === 'docgen');
+ expect(aMsg.status).toBe('done');
+ expect(aMsg.blocks.some((b) => b.type === 'artifact' && b.artifact_id === 'art-1')).toBe(true);
+ expect(aMsg.blocks[0].status).toBe('done');
+ expect(aMsg.blocks[0].steps.every((s) => s.state === 'done')).toBe(true);
+ const genStep = aMsg.blocks[0].steps.find((s) => s.phase === 'generation');
+ expect(genStep.desc).toBe('按大纲逐节生成正文');
+ expect(genStep.desc).not.toContain('生成中');
+ // 轮询注册自清(终态后不再有定时器)
+ const { useDocgenStore } = await import('../../src/stores/docgen.js');
+ expect(useDocgenStore().activeArtifact?.artifact_id).toBe('art-1');
+ // 切 TAB
+ const { useBenchStore } = await import('../../src/stores/bench.js');
+ expect(useBenchStore().activeTab).toBe('doc');
+ });
+
+ it('error:标失败 + 可重试(retry 走 docgen 路径)', async () => {
+ const { chat } = freshStores();
+ vi.mocked(generate).mockResolvedValue({ ok: true, task_id: 'task-4', status: 'queued' });
+ vi.mocked(getTask).mockResolvedValue({ ok: true, task: { id: 'task-4', status: 'error', progress: [], artifact: null, error: '生成失败:资料解析错误' } });
+ const p = chat.sendDocgen('写一份报告', []);
+ await vi.advanceTimersByTimeAsync(0);
+ await p;
+ await vi.advanceTimersByTimeAsync(2000);
+ await vi.advanceTimersByTimeAsync(0);
+ const aMsg = chat.messages.find((m) => m.role === 'assistant' && m.kind === 'docgen');
+ expect(aMsg.status).toBe('error');
+ expect(aMsg.errText).toContain('生成失败');
+ expect(aMsg.blocks[0].status).toBe('error');
+ expect(chat.lastResult?.error?.retryable).toBe(true);
+ });
+
+ it('200 同步兼容路径:行为与修复前一致(立即 done + artifact + 切 TAB)', async () => {
+ const { chat } = freshStores();
+ vi.mocked(generate).mockResolvedValue({
+ ok: true,
+ artifact: { artifact_id: 'art-sync', title: '同步文档', template_name: '通用文档', status: 'done' },
+ });
+ await chat.sendDocgen('写一份文档', []);
+ const aMsg = chat.messages.find((m) => m.role === 'assistant' && m.kind === 'docgen');
+ expect(aMsg.status).toBe('done');
+ expect(aMsg.blocks.some((b) => b.type === 'artifact' && b.artifact_id === 'art-sync')).toBe(true);
+ expect(getTask).not.toHaveBeenCalled();
+ });
+
+ it('#42:templateId 传入 payload', async () => {
+ const { chat } = freshStores();
+ vi.mocked(generate).mockResolvedValue({ ok: true, task_id: 'task-5', status: 'queued' });
+ vi.mocked(getTask).mockResolvedValue({ ok: true, task: { id: 'task-5', status: 'queued', progress: [], artifact: null, error: null } });
+ const p = chat.sendDocgen('写一份公文', [], { templateId: 'official' });
+ await vi.advanceTimersByTimeAsync(0);
+ await p;
+ expect(generate).toHaveBeenCalledWith(expect.objectContaining({ template_id: 'official' }));
+ });
+
+ it('会话恢复:running 消息块含 task_id → 恢复轮询', async () => {
+ const { chat } = freshStores();
+ const sessions = useSessionsStore();
+ vi.mocked(getTask).mockResolvedValue({ ok: true, task: { id: 'task-6', status: 'running', progress: [], artifact: null, error: null } });
+ sessions.fetchDetail = vi.fn(async () => ({
+ messages: [
+ { id: 'u1', role: 'user', kind: 'docgen', status: 'done', text: '写一份报告' },
+ {
+ id: 'a1', role: 'assistant', kind: 'docgen', status: 'streaming',
+ blocks: [{ type: 'pipeline', title: 't', status: 'running', task_id: 'task-6', capabilities: [], steps: [] }],
+ },
+ ],
+ }));
+ await chat.loadSession('s1');
+ expect(chat.messages.find((m) => m.id === 'a1').interrupted).toBeUndefined(); // 不误标「已中断」
+ await vi.advanceTimersByTimeAsync(2000);
+ expect(getTask).toHaveBeenCalledWith('task-6');
+ });
+});
+
+describe('#28 检索空态/降级文案(_runSearch)', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it('正常空结果(sources 空且无 answer)→ searchNoAnswer 文案,done 态非错误', async () => {
+ const { chat } = freshStores();
+ const { search } = await import('../../src/api/qa.js');
+ vi.mocked(search).mockResolvedValue({ answer: '', sources: [], degraded: false });
+ await chat.send('请问 1234567890qwertyuiop 这种字符串与植物有什么关系', { mode: 'search' });
+ const aMsg = chat.messages.find((m) => m.role === 'assistant');
+ expect(aMsg.status).toBe('done');
+ expect(aMsg.degradedNote).toBe('没有检索到此问题的有效回答');
+ expect(aMsg.streamErr).toBeNull();
+ });
+
+ it('超时降级(degraded:true)→ searchTimeoutEmpty 专属文案(与空结果区分)', async () => {
+ const { chat } = freshStores();
+ const { search } = await import('../../src/api/qa.js');
+ vi.mocked(search).mockResolvedValue({ mode: 'search', answer: '', sources: [], degraded: true, reason: 'TIMEOUT' });
+ await chat.send('触发超时的问题', { mode: 'search' });
+ const aMsg = chat.messages.find((m) => m.role === 'assistant');
+ expect(aMsg.status).toBe('done');
+ expect(aMsg.degradedNote).toBe('检索超时,未获取到有效回答(已降级返回)');
+ });
+
+ it('有 answer/sources 时不挂任何空态文案', async () => {
+ const { chat } = freshStores();
+ const { search } = await import('../../src/api/qa.js');
+ vi.mocked(search).mockResolvedValue({ answer: '正常答案', sources: [{ paper_id: 'p1', title: 't' }], degraded: false });
+ await chat.send('正常问题', { mode: 'search' });
+ const aMsg = chat.messages.find((m) => m.role === 'assistant');
+ expect(aMsg.degradedNote).toBeUndefined();
+ expect(aMsg.status).toBe('done');
+ });
+});
diff --git a/ui/tests/r3debug/markdown.test.js b/ui/tests/r3debug/markdown.test.js
new file mode 100644
index 0000000..deba8fd
--- /dev/null
+++ b/ui/tests/r3debug/markdown.test.js
@@ -0,0 +1,57 @@
+// tests/r3debug/markdown.test.js —— 缺陷 #7b 标题编号注入
+import { describe, it, expect } from 'vitest';
+import { renderMarkdown, renderMarkdownPlain } from '../../src/components/chat/markdown.js';
+
+describe('#7b 标题编号注入', () => {
+ it('h1→「一、」,h2→「1.1」,h3→「1.2.3」;层级变化归零', () => {
+ const out = renderMarkdown(
+ '# 第一章\n\n## 节一\n\n### 小节一\n\n## 节二\n\n# 第二章\n\n## 节一\n\n### 小节一',
+ );
+ const hs = out.split('\n').filter((l) => l.startsWith('一、 第一章');
+ expect(hs[1]).toBe('1.1 节一');
+ expect(hs[2]).toBe('1.1.1 小节一');
+ expect(hs[3]).toBe('1.2 节二');
+ expect(hs[4]).toBe('二、 第二章');
+ expect(hs[5]).toBe('2.1 节一'); // h1 变化后 h2 归零
+ expect(hs[6]).toBe('2.1.1 小节一');
+ });
+
+ it('已带数字/中文序号开头的标题跳过注入(原文保持)', () => {
+ const out = renderMarkdown('## 1. 概述\n\n### 1.1 目标\n\n## 一、背景');
+ const hs = out.split('\n').filter((l) => l.startsWith('1. 概述');
+ expect(hs[1]).toBe('1.1 目标');
+ expect(hs[2]).toBe('一、背景');
+ });
+
+ it('计数器每次渲染入口重置(渲染器单例不跨消息延续编号)', () => {
+ const a = renderMarkdown('# A');
+ const b = renderMarkdown('# B');
+ expect(a).toContain('一、 A');
+ expect(b).toContain('一、 B'); // 若未重置会是「二、 B」
+ });
+
+ it('文档以 h2 开头:父级计数兜底 1(不出现 0.1)', () => {
+ const out = renderMarkdown('## 直接节\n\n### 小节');
+ const hs = out.split('\n').filter((l) => l.startsWith('1.1 直接节');
+ expect(hs[1]).toBe('1.1.1 小节');
+ });
+
+ it('行内强调(strong 等)保留', () => {
+ const out = renderMarkdown('## 加粗**重点**标题');
+ expect(out).toContain('1.1 加粗重点标题');
+ });
+
+ it('renderMarkdownPlain 不注入编号(DocPane md 回退预览保护正本)', () => {
+ const out = renderMarkdownPlain('# 不应编号');
+ expect(out.trim()).toBe('不应编号');
+ });
+
+ it('所有 h1-h6 均挂 md-hN class(含非 1-3 级)', () => {
+ const out = renderMarkdown('#### 四级\n\n##### 五级');
+ expect(out).toContain('');
+ expect(out).toContain('');
+ });
+});
diff --git a/ui/tests/r3debug/reveal.test.js b/ui/tests/r3debug/reveal.test.js
new file mode 100644
index 0000000..1fedce2
--- /dev/null
+++ b/ui/tests/r3debug/reveal.test.js
@@ -0,0 +1,116 @@
+// tests/r3debug/reveal.test.js —— 缺陷 #7a reveal 超时(fake-timer 断言)
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { setActivePinia, createPinia } from 'pinia';
+import { useChatStore } from '../../src/stores/chat.js';
+
+vi.mock('../../src/api/sessions.js', () => ({
+ createMessage: vi.fn(async () => ({ id: 'm1' })),
+ 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 })),
+}));
+
+function fakeCtx(msgList, aMsg) {
+ return { sessionId: 's1', msgList, aMsg, events: [], controller: null, reveal: null };
+}
+
+describe('#7a reveal 总时长上限', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ vi.useFakeTimers();
+ // jsdom 无 matchMedia:revealText 判定 reduced 直接定格——此处 stub 返回不命中 reduce
+ vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })));
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn(() => ({ matches: false })),
+ });
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ });
+
+ it('≤2000 字符:30ms/字符节奏不变', async () => {
+ const chat = useChatStore();
+ const text = 'x'.repeat(500); // 500 字符 → 预期 500 × 30ms = 15000ms
+ const aMsg = { blocks: [], revealId: 0, status: 'streaming' };
+ const ctx = fakeCtx([aMsg], aMsg);
+ const p = chat.revealText(aMsg, text, ctx);
+ await vi.advanceTimersByTimeAsync(15000);
+ await p;
+ // 用真实时间轴:15000ms 恰好收尾;中途(14970ms)未收尾
+ expect(aMsg.status).toBe('done');
+ expect(aMsg.blocks.some((b) => b.type === 'text')).toBe(true);
+ expect(aMsg.blocks.some((b) => b.type === 'stream')).toBe(false);
+ });
+
+ it('≤2000 字符路径:总时长 = 字符数 × 30ms(±1 tick)', async () => {
+ const chat = useChatStore();
+ const text = 'x'.repeat(1999); // 需 1999 tick × 30ms = 59970ms
+ const aMsg = { blocks: [], revealId: 0, status: 'streaming' };
+ const ctx = fakeCtx([aMsg], aMsg);
+ const p = chat.revealText(aMsg, text, ctx);
+ // 1998 tick(59940ms)后未完成
+ await vi.advanceTimersByTimeAsync(1998 * 30);
+ await Promise.resolve();
+ expect(aMsg.status).toBe('streaming');
+ // 再推进 2 tick(60ms)必完成
+ await vi.advanceTimersByTimeAsync(60);
+ await p;
+ expect(aMsg.status).toBe('done');
+ });
+
+ it('>2000 字符:总时长 ≤8s(interval 下限 12ms)', async () => {
+ const chat = useChatStore();
+ const text = 'x'.repeat(5000); // 长文:加速分支
+ const aMsg = { blocks: [], revealId: 0, status: 'streaming' };
+ const ctx = fakeCtx([aMsg], aMsg);
+ const p = chat.revealText(aMsg, text, ctx);
+ await vi.advanceTimersByTimeAsync(8000);
+ await p;
+ expect(aMsg.status).toBe('done');
+ expect(aMsg.blocks.some((b) => b.type === 'text')).toBe(true);
+ });
+
+ it('长文单 tick 步进不超过 interval 允许的上限(不空转)', async () => {
+ const chat = useChatStore();
+ const text = 'x'.repeat(100000);
+ const aMsg = { blocks: [], revealId: 0, status: 'streaming' };
+ const ctx = fakeCtx([aMsg], aMsg);
+ const p = chat.revealText(aMsg, text, ctx);
+ // 观察若干 tick:stream 块内容单调递增且每 tick 增量 > 0
+ let prev = 0;
+ for (let i = 0; i < 10; i++) {
+ await vi.advanceTimersByTimeAsync(100);
+ const sb = aMsg.blocks[aMsg.blocks.length - 1];
+ expect(sb.text.length).toBeGreaterThan(prev);
+ prev = sb.text.length;
+ }
+ await vi.advanceTimersByTimeAsync(8000);
+ await p;
+ expect(aMsg.status).toBe('done');
+ });
+
+ it('revealId 守卫:skipReveal 后旧定时器下一 tick 自愈退出', async () => {
+ const chat = useChatStore();
+ chat.sessionId = 's1'; // skipReveal 经 _ctx getter 取当前会话流上下文
+ const text = 'x'.repeat(3000);
+ const aMsg = { blocks: [], revealId: 0, status: 'streaming' };
+ const ctx = fakeCtx([aMsg], aMsg);
+ chat.streamCtx['s1'] = ctx;
+ const p = chat.revealText(aMsg, text, ctx);
+ await vi.advanceTimersByTimeAsync(100);
+ chat.skipReveal();
+ await vi.advanceTimersByTimeAsync(500);
+ await p;
+ expect(aMsg.status).toBe('done');
+ expect(aMsg.blocks.some((b) => b.type === 'stream')).toBe(false);
+ expect(aMsg.blocks.some((b) => b.type === 'text')).toBe(true);
+ });
+});
diff --git a/ui/tests/r3debug/skipReveal.test.js b/ui/tests/r3debug/skipReveal.test.js
new file mode 100644
index 0000000..60a061e
--- /dev/null
+++ b/ui/tests/r3debug/skipReveal.test.js
@@ -0,0 +1,151 @@
+// @vitest-environment jsdom
+// tests/r3debug/skipReveal.test.js —— r3-07-01「跳过」按钮事件链全链路
+// 断言:AssistantMessage skip-btn 点击 → MessageList 转发 skip-reveal →
+// ChatPanel 落 store.skipReveal() → 消息定格终态(stream 块换 text 块)。
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { mount, flushPromises } from '@vue/test-utils';
+import { setActivePinia, createPinia } from 'pinia';
+
+// jsdom 无 scrollTo:polyfill(MessageList 挂载滚动用)
+if (typeof window !== 'undefined' && typeof window.Element !== 'undefined' && !window.Element.prototype.scrollTo) {
+ window.Element.prototype.scrollTo = vi.fn();
+}
+
+vi.mock('../../src/stores/health.js', () => ({
+ useHealthStore: vi.fn(() => ({ data: null, qaOk: true })),
+}));
+vi.mock('../../src/api/sessions.js', () => ({
+ listSessions: vi.fn(async () => []),
+ createSession: vi.fn(async () => ({ id: 's1' })),
+ getSession: vi.fn(async () => ({ id: 's1', status: 'done', meta: {}, messages: [] })),
+ updateSession: vi.fn(async (id, b) => ({ id, ...b })),
+ deleteSession: vi.fn(async () => {}),
+ createMessage: vi.fn(async () => ({ id: 'm1' })),
+ updateMessage: vi.fn(async () => ({ updated_at: new Date().toISOString() })),
+}));
+vi.mock('../../src/api/qa.js', () => ({
+ chatStream: vi.fn(async () => {}),
+ chat: vi.fn(),
+ search: vi.fn(),
+ info: vi.fn(),
+}));
+vi.mock('../../src/api/private.js', () => ({
+ askPrivate: vi.fn(async () => ({ hit: false })),
+}));
+vi.mock('../../src/api/docgen.js', () => ({
+ generate: vi.fn(),
+ getTask: vi.fn(),
+ listTasks: 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 })),
+}));
+
+import ChatPanel from '../../src/components/chat/ChatPanel.vue';
+import MessageList from '../../src/components/chat/MessageList.vue';
+import AssistantMessage from '../../src/components/chat/AssistantMessage.vue';
+import { useChatStore } from '../../src/stores/chat.js';
+import { useSessionsStore } from '../../src/stores/sessions.js';
+
+/** 构造揭示中的会话态:aMsg 带 stream 块 + ctx.reveal */
+function seedRevealing(chat) {
+ const msgList = [
+ { id: 'u1', role: 'user', status: 'done', text: '问题', blocks: [{ type: 'text', html: '问题' }] },
+ {
+ id: 'a1',
+ role: 'assistant',
+ kind: 'qa',
+ status: 'streaming',
+ streamErr: null,
+ revealId: 1, // revealText 正常流程会写入;skipReveal 据此定位目标消息
+ blocks: [
+ { type: 'pipeline', status: 'running', steps: [] },
+ { type: 'stream', text: '部分内容' },
+ ],
+ },
+ ];
+ chat.sessionId = 's1';
+ chat.messages = msgList;
+ chat.sessionMsgs['s1'] = msgList;
+ const aMsg = msgList[1];
+ chat.streamCtx['s1'] = {
+ sessionId: 's1',
+ msgList,
+ aMsg,
+ controller: null,
+ completedOnce: false,
+ events: [],
+ checkpointTimer: null,
+ lastCheckpointAt: 0,
+ reveal: { text: '全文内容', aMsg },
+ abortAnn: null,
+ };
+ return aMsg;
+}
+
+async function mountPanel() {
+ const w = mount(ChatPanel, { global: { stubs: { teleport: true } } });
+ await flushPromises();
+ return w;
+}
+
+describe('r3-07-01 跳过按钮事件链', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ const sessions = useSessionsStore();
+ sessions.items = [{ id: 's1', title: 't', status: 'done', created_at: '', updated_at: '' }];
+ });
+
+ it('全链路:skip-btn 点击 → MessageList 转发 → ChatPanel → store.skipReveal 定格终态', async () => {
+ const chat = useChatStore();
+ seedRevealing(chat);
+
+ const w = await mountPanel();
+ // 揭示中:skip-btn 存在
+ const skipBtn = w.find('.skip-btn');
+ expect(skipBtn.exists()).toBe(true);
+
+ await skipBtn.trigger('click');
+ await flushPromises();
+
+ // store 效果:stream 块移除、text 块定格、状态 done、reveal 清空
+ const aMsg = chat.messages[1];
+ expect(aMsg.status).toBe('done');
+ expect(aMsg.blocks.some((b) => b.type === 'stream')).toBe(false);
+ expect(aMsg.blocks.some((b) => b.type === 'text')).toBe(true);
+ expect(chat.reveal).toBeNull();
+ });
+
+ it('MessageList 层:AssistantMessage skip-reveal 事件被转发为同名 emit', async () => {
+ const chat = useChatStore();
+ seedRevealing(chat);
+ const w = mount(MessageList, {
+ props: { messages: chat.messages, busy: false, reveal: chat.reveal, scrollSeq: 0 },
+ global: { stubs: { transition: false } },
+ });
+ const am = w.findComponent(AssistantMessage);
+ expect(am.exists()).toBe(true);
+ await am.vm.$emit('skip-reveal');
+ expect(w.emitted('skip-reveal')).toBeTruthy();
+ expect(w.emitted('skip-reveal')).toHaveLength(1);
+ });
+
+ it('ChatPanel 层:MessageList skip-reveal 事件被监听(组件契约存在)', async () => {
+ const chat = useChatStore();
+ seedRevealing(chat);
+ const w = await mountPanel();
+ const ml = w.findComponent(MessageList);
+ expect(ml.exists()).toBe(true);
+ // 直接对 MessageList 发事件:ChatPanel 的 @skip-reveal 监听应落 store
+ await ml.vm.$emit('skip-reveal');
+ await flushPromises();
+ const aMsg = chat.messages[1];
+ expect(aMsg.status).toBe('done');
+ expect(aMsg.blocks.some((b) => b.type === 'text')).toBe(true);
+ });
+});
diff --git a/ui/tests/r3debug/vitest.config.js b/ui/tests/r3debug/vitest.config.js
new file mode 100644
index 0000000..966e007
--- /dev/null
+++ b/ui/tests/r3debug/vitest.config.js
@@ -0,0 +1,19 @@
+// tests/r3debug/vitest.config.js —— r3 修复轮 Debug-1 前端单测(#7/#27/#40/#52/#28/#7b)
+// 本目录在 ui/ 之内,node_modules 由 ui 直接解析;仅指定 include 与 jsdom 环境。
+// 运行方式:cd ui && npx vitest run --config tests/r3debug/vitest.config.js
+import { defineConfig } from 'vitest/config';
+import vue from '@vitejs/plugin-vue';
+
+export default defineConfig({
+ plugins: [vue()],
+ resolve: {
+ alias: {
+ vue: 'vue/dist/vue.runtime.esm-bundler.js',
+ },
+ },
+ test: {
+ environment: 'jsdom',
+ include: ['tests/r3debug/**/*.test.js'],
+ globals: false,
+ },
+});
|