fix(ui): r3 前端 10 项缺陷修复(#7/#27/#28/#31/#39/#40/#42/#47b/#52/#55)

- #7a reveal 超时:长文按总时长 ≤8s 反推 interval(下限 12ms),短路径
  30ms/字符不变(step=1);跳过按钮与 reduced-motion 行为不变
- #7b 标题编号:heading_open 注入 h1「一、」/h2「1.1」/h3「1.2.3」,
  层级变化归零、自带编号跳过、md-hN class 恒挂、计数器逐次渲染重置;
  DocPane md 回退预览与私有参考块用 renderMarkdownPlain 关闭注入
  (防止编号被序列化写回正本)
- #27 会话聚合:删除前端二次聚合,会话域直接以后端 sources 为准;
  SourcesPane/SourceRow 以 paper_id/id 为 key 与高亮依据,徽标显示
  真实 paper_id(截断+悬浮),无 paper_id 退 index+1;
  setHighlightSource 参数 index→key,chip 编号契约保持
- #31 节点详情:KgEntityDetail fetchExtra 序号守卫;KgPane :key 强制
  重建;KgGraphView pointer 事件重构(pointerdown 不捕获、位移>4px
  才捕获+标记 dragging、pointerup release、onClickNode dragging 拦截)
- #39 日志刷新:打开总是重拉+「刷新日志」按钮+有 running 时 5s 独立
  轮询(onBeforeUnmount 清理)
- #40 轮询暂停:任何离开 extract 的切换 pausePolling;详情/日志展开态
  提升到 store(detailOpenId/logOpen/runLog/logError),切 TAB 保留
- #42 模板选择:Composer docgen 模式模板下拉(自动推荐+内置+as_template
  懒加载,失败降级),template_id 经 sendDocgen 传入
- #47b 会话切换 resetSession(selectSession 动态 import,只清不丢)
- #55 文档 TAB:docgen store viewMode + DocPane「资料库」/「关闭文档」/
  「返回文档」按钮
- #28 检索文案:searchNoAnswer/searchTimeoutEmpty;200 降级体按
  payload.degraded 分流,不再走 _errorMarker TIMEOUT 报错
- #52 异步轮询:api getTask/listTasks;sendDocgen 兼容 200/202,
  202 后释放 busy 锁(生成期间可继续对话)、消息块记录 task_id、
  2s 轮询 getTask、progress 驱动「生成中 x/y 节」、done 复用现有
  artifact 挂块+落库+adoptArtifact+切 TAB、error 可重试;
  会话恢复时 running 块含 task_id 续轮询(不误标「已中断」)

自测:29 个新增 r3debug 单测 + 23 个既有 private_docs 测试全绿;
npm run build 通过;playwright 冒烟(dev server 5199)无 JS 错误,
模板下拉/提取面板/日志刷新按钮渲染正常。
未 push;Debug-2 9090 未就绪,#52/#28 联调留待 9090 就绪后执行。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 16:17:11 +08:00
co-authored by Claude Fable 5
parent 21063af703
commit 671783eb1e
22 changed files with 1341 additions and 128 deletions
+19 -2
View File
@@ -3,6 +3,11 @@
// 全部经 http.js 封装;默认读超时 600s 兜底(生成/编辑最长路径不早于 BFF 超时自断)。
import { get, post, patch, del } from './http.js';
// 缺陷 #52:generate 兼容 200(旧同步契约)/202(新异步契约)两种响应形态。
// 202 由 http.js 返回 payload(202 在 fetch 语义下非错误,body 直接透传);
// 旧 BFF 无 202 时直接返回同步产物——两者统一经 generate() 归一,
// chat.sendDocgen 按 `payload.task_id` 有无区分走轮询还是直接落块。
/**
* 上传入库(用户拖拽即走此路):multipart → vstore /ingest,入库主向量库持久可用。
* @param {File} file
@@ -23,13 +28,25 @@ export function uploadLibrary(file, meta = {}, opts = {}) {
}
/**
* 同步生成(600s 兜底):前端落库消息与骨架后阻塞等待产物。
* @returns {Promise<{ok:boolean, artifact:{artifact_id, title, template_name, status, ...}}>}
* 生成(600s 兜底读超时;兼容 200 同步 / 202 异步两种响应)。
* @returns {Promise<{ok, task_id?, status?, artifact?, progress?, markdown?}>}
* 202 → {ok, task_id, status:"queued"};200 → 旧同步产物(artifact 内嵌)。
*/
export function generate(payload) {
return post('/api/docgen/generate', payload);
}
/** 缺陷 #52:任务状态轮询(GET /api/docgen/tasks/{task_id})
* → 200 {ok, task:{id, session_id, status, progress:[{section,title,state}], artifact|null, error}} */
export function getTask(taskId) {
return get(`/api/docgen/tasks/${encodeURIComponent(taskId)}`);
}
/** 缺陷 #52:会话级任务列表(GET /api/docgen/tasks?session_id=)——会话恢复时兜底查询 */
export function listTasks(sessionId) {
return get('/api/docgen/tasks', { session_id: sessionId });
}
/** 取 artifact 的 md 原文 */
export function getArtifactContent(artifactId) {
return get(`/api/docgen/artifacts/${encodeURIComponent(artifactId)}/content`);
+27 -6
View File
@@ -14,7 +14,7 @@
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
import { useDocgenStore } from '../../stores/docgen.js';
import { DG, BUILTIN_TEMPLATES } from '../../i18n/docgen.js';
import { renderMarkdown, sanitizeHtml } from '../chat/markdown.js';
import { renderMarkdownPlain, sanitizeHtml } from '../chat/markdown.js';
// FR-PRIV-006:「我的资料」管理面板(挂空态区顶部,UI_SPEC §9.2)
import PrivateLibraryPane from './PrivateLibraryPane.vue';
@@ -67,7 +67,8 @@ function mountShadow() {
inner = `<style>${css}</style><div class="doc-root">${sanitizeHtml(bodyFragment(docgen.html))}</div>`;
} else {
// 回退:客户端 md 渲染(旧制品/拉取失败)
inner = `<div class="doc-body md-text">${renderMarkdown(docgen.md)}</div>`;
// 缺陷 #7b:md 回退预览不注入标题编号——序列化会把标题写回正本,编号不得进源文
inner = `<div class="doc-body md-text">${renderMarkdownPlain(docgen.md)}</div>`;
}
root.innerHTML = inner;
if (editing.value) activateEditDom();
@@ -327,11 +328,17 @@ onBeforeUnmount(() => {
<template>
<div class="doc-pane">
<!-- 无活动文档:空态(FR-PRIV-006:我的资料面板挂空态区顶部,其余空态内容不动) -->
<div v-if="!docgen.activeArtifact" class="doc-empty">
<!-- 缺陷 #55:无活动文档(或用户切到「资料库」视图)→ 空态区
(FR-PRIV-006:我的资料面板挂空态区顶部,其余空态内容不动) -->
<div v-if="!docgen.activeArtifact || docgen.viewMode === 'library'" class="doc-empty">
<PrivateLibraryPane />
<div class="de-icon">📄</div>
<div class="de-text">{{ DG.docPaneEmpty }}</div>
<div v-if="docgen.activeArtifact && docgen.viewMode === 'library'" class="doc-empty-back">
<button class="pane-btn" type="button" @click="docgen.viewMode = 'doc'">{{ DG.docPaneBackToDoc }}</button>
</div>
<template v-else>
<div class="de-icon">📄</div>
<div class="de-text">{{ DG.docPaneEmpty }}</div>
</template>
</div>
<template v-else>
@@ -351,6 +358,17 @@ onBeforeUnmount(() => {
@click="enterEdit"
>{{ DG.docPaneEditMode }}</button>
<button v-else class="pane-btn" type="button" @click="exitEdit">{{ DG.docPanePreviewMode }}</button>
<!-- 缺陷 #55:资料库视图切换 + 关闭文档(有活动文档时显示) -->
<button
class="pane-btn"
type="button"
@click="docgen.viewMode = 'library'"
>{{ DG.docPaneLibraryView }}</button>
<button
class="pane-btn"
type="button"
@click="docgen.close()"
>{{ DG.docPaneClose }}</button>
</div>
</div>
@@ -436,6 +454,9 @@ onBeforeUnmount(() => {
text-align: center;
color: var(--pa-ink-subtle);
}
.doc-empty-back {
margin-top: 12px;
}
.de-icon {
font-size: 26px;
margin-bottom: 8px;
+70 -10
View File
@@ -1,8 +1,10 @@
<!-- ExtractionPane.vue —— 工作台 Tab3 提取任务(B3 归属)
FR-EXTRACT-001~006:prog-item 列表、详情展开(stats + plan 仅 completed)、run-log mono 滚动贴底、
上传(PDF ≤50MB 校验)、5s 轮询(存在 running 时;tab 切走由 BenchPanel 暂停)、离线降级。 -->
上传(PDF ≤50MB 校验)、5s 轮询(存在 running 时;tab 切走由 bench.setActiveTab 暂停)、离线降级。
缺陷 #40:详情/日志展开态提升到 bench store(切 TAB 再回来保留);
缺陷 #39:打开日志总是重拉 + 「刷新日志」按钮 + 有 running 时 5s 独立轮询(onBeforeUnmount 清理)。 -->
<script setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useBenchStore } from '../../stores/bench.js';
import { getRunLog, uploadPaper } from '../../api/extraction.js';
import { STATUS_LABEL, T } from '../../i18n/labels.js';
@@ -10,16 +12,31 @@ import { BENCH_LABELS } from './benchLabels.js';
import ProgressItem from './ProgressItem.vue';
const MAX_UPLOAD_BYTES = 50 * 1024 * 1024;
const LOG_POLL_MS = 5000;
const bench = useBenchStore();
const openId = ref(null);
// 缺陷 #40:展开态读写 store(detailOpenId/logOpen/runLog/logError)——
// 组件随 TAB 切换卸载重建,展开态与日志内容保留在 store。
const openId = computed({
get: () => bench.detailOpenId,
set: (v) => { bench.detailOpenId = v; },
});
const logOpen = computed({
get: () => bench.logOpen,
set: (v) => { bench.logOpen = v; },
});
const runLog = computed({
get: () => bench.runLog,
set: (v) => { bench.runLog = v; },
});
const logError = computed({
get: () => bench.logError,
set: (v) => { bench.logError = v; },
});
const fileInput = ref(null);
const uploadMsg = ref('');
const uploadOk = ref(false);
const uploading = ref(false);
const runLog = ref('');
const logOpen = ref(false);
const logError = ref(false);
const logBox = ref(null);
const jobs = computed(() => bench.jobs);
@@ -46,13 +63,18 @@ function statsRows(job) {
].filter(([, v]) => v != null);
}
let logSeq = 0; // 缺陷 #39:日志请求序号守卫(连点刷新不串台)
async function fetchRunLog() {
const seq = ++logSeq;
logError.value = false;
try {
const data = await getRunLog();
if (seq !== logSeq) return; // 迟到的旧请求:丢弃
runLog.value = data?.text || '';
if (logOpen.value) await scrollLogToBottom();
} catch {
if (seq !== logSeq) return;
logError.value = true;
}
}
@@ -62,10 +84,10 @@ async function scrollLogToBottom() {
if (logBox.value) logBox.value.scrollTop = logBox.value.scrollHeight;
}
// 缺陷 #39:每次打开总是重拉最新日志(旧缺陷:仅首次加载后不刷新)
function toggleLog() {
logOpen.value = !logOpen.value;
if (logOpen.value && !runLog.value) fetchRunLog();
else if (logOpen.value) scrollLogToBottom();
if (logOpen.value) fetchRunLog();
}
// ---- 上传(FR-EXTRACT-004:仅 PDF,单文件 ≤50MB;失败显示明确文案) ----
@@ -105,7 +127,23 @@ async function onFileChange(e) {
}
}
// ---- 轮询生命周期(FR-EXTRACT-005)----
// ---- 轮询生命周期(FR-EXTRACT-005;#40 进入 extract 恢复 / #39 日志独立 5s 轮询) ----
let logTimer = null;
const clearLogTimer = () => {
if (logTimer != null) {
clearInterval(logTimer);
logTimer = null;
}
};
// 日志打开且有 running 任务时 5s 轮询(独立 timer,与任务列表轮询互不干扰)
const syncLogPoll = () => {
if (bench.logOpen && bench.hasActiveJobs) {
if (logTimer == null) logTimer = setInterval(() => fetchRunLog(), LOG_POLL_MS);
} else {
clearLogTimer();
}
};
onMounted(() => {
bench.loadJobs({ force: true })
.then(() => {
@@ -113,6 +151,7 @@ onMounted(() => {
if (!bench.isPolling()) bench.startPolling();
})
.catch(() => {});
syncLogPoll(); // 挂载时若日志已展开且任务运行中(切回 TAB 场景)恢复日志轮询
});
watch(
@@ -124,6 +163,12 @@ watch(
},
);
watch(() => [bench.logOpen, bench.hasActiveJobs], syncLogPoll);
onBeforeUnmount(() => {
clearLogTimer(); // 缺陷 #39:组件卸载(切 TAB)清理日志轮询
});
function retryLoad() {
bench.loadJobs({ force: true }).catch(() => {});
}
@@ -148,13 +193,16 @@ function retryLoad() {
<div v-else-if="uploadMsg" class="upload-msg err">{{ uploadMsg }}</div>
<div v-else-if="uploadOk" class="upload-msg ok">{{ '✓' }}</div>
<!-- 运行日志(FR-EXTRACT-003,mono 滚动贴底) -->
<!-- 运行日志(FR-EXTRACT-003,mono 滚动贴底;#39 刷新按钮) -->
<button class="log-toggle" @click="toggleLog">
<svg class="log-chev" :class="{ open: logOpen }" width="11" height="11" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M9 18l6-6-6-6" /></svg>
<span>{{ BENCH_LABELS.extractRunLog }}</span>
</button>
<div v-if="logOpen" class="log-wrap">
<div class="log-toolbar">
<button class="log-refresh-btn" type="button" @click="fetchRunLog">{{ T.extractLogRefresh }}</button>
</div>
<div v-if="logError" class="offline-text">{{ T.upstreamError }}</div>
<div v-else ref="logBox" class="run-log">{{ runLog || T.loading }}</div>
</div>
@@ -262,6 +310,18 @@ function retryLoad() {
.log-chev { transition: transform 0.25s var(--pa-ease); }
.log-chev.open { transform: rotate(90deg); }
.log-wrap { margin-bottom: 10px; }
.log-toolbar { display: flex; justify-content: flex-end; margin-bottom: 4px; }
.log-refresh-btn {
padding: 2px 10px;
border-radius: var(--pa-r-pill);
font-size: 11px;
color: var(--pa-ink-muted);
border: 1px solid var(--pa-hairline);
background: none;
cursor: pointer;
transition: color 0.15s var(--pa-ease);
}
.log-refresh-btn:hover { color: var(--pa-ink); }
.run-log {
font-family: var(--pa-font-mono);
font-size: 11.5px;
+5 -1
View File
@@ -15,6 +15,7 @@ const emit = defineEmits(['close']);
const loading = ref(false);
const failed = ref(false);
const extra = ref(null); // Neo4j 全量属性
let fetchSeq = 0; // 缺陷 #31:请求序号守卫——旧响应落地前判序号,丢弃过期结果
const localProps = computed(() => {
const n = props.node || {};
@@ -48,15 +49,18 @@ async function fetchExtra() {
if (!n) return;
const key = n.id != null ? String(n.id) : n.label;
if (!key) return;
const seq = ++fetchSeq;
loading.value = true;
failed.value = false;
try {
const data = await getEntity(key);
if (seq !== fetchSeq) return; // 迟到的旧请求:丢弃(快速连点 A→B 不串台)
extra.value = data;
} catch {
if (seq !== fetchSeq) return;
failed.value = true; // Neo4j 不可用:仍显示消息内已有属性(FR-KG-003)
} finally {
loading.value = false;
if (seq === fetchSeq) loading.value = false;
}
}
+22 -3
View File
@@ -61,9 +61,14 @@ function setTransform(ns, nx, ny) {
ty.value = ny;
}
// 缺陷 #31:pointer 事件重构——pointerdown 只记起点不 setPointerCapture(捕获会吞掉
// 节点 click 事件,导致单击不弹详情);pointermove 位移 >4px 才捕获 + 标记 dragging
// (拖拽平移不触发 click);pointerup release + 复位 dragging。
// 双击 reset 视图保持(dblclick 与拖动正交,dblclick 前必有 click,detail 照常弹出)。
const dragging = ref(false);
function onPointerDown(e) {
container.value?.setPointerCapture?.(e.pointerId);
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY });
if (pointers.size === 2) {
// 双指开始:记录初始距离与变换
const [p1, p2] = [...pointers.values()];
@@ -82,7 +87,13 @@ function onPointerDown(e) {
function onPointerMove(e) {
if (!pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
const p = pointers.get(e.pointerId);
// 位移 >4px 判定拖拽:捕获指针并标记 dragging(单击不再弹详情)
if (!dragging.value && Math.hypot(e.clientX - p.sx, e.clientY - p.sy) > 4) {
dragging.value = true;
container.value?.setPointerCapture?.(e.pointerId);
}
pointers.set(e.pointerId, { ...p, x: e.clientX, y: e.clientY });
if (pointers.size >= 2) {
// 双指:缩放 + 平移(FR-KG-006)
const [p1, p2] = [...pointers.values()];
@@ -104,12 +115,18 @@ function onPointerMove(e) {
}
function onPointerUp(e) {
if (container.value?.hasPointerCapture?.(e.pointerId)) {
try {
container.value.releasePointerCapture(e.pointerId);
} catch { /* 已释放 */ }
}
pointers.delete(e.pointerId);
if (pointers.size === 1) {
const [p] = [...pointers.values()];
pan.value = { x: p.x, y: p.y, tx: tx.value, ty: ty.value, dist: null, scale: scale.value };
} else if (pointers.size === 0) {
pan.value = null;
dragging.value = false;
}
}
@@ -132,6 +149,8 @@ function resetView() {
}
function onClickNode(n) {
// 缺陷 #31:拖拽(位移 >4px)不弹详情;单击必弹;双击 reset 视图保持
if (dragging.value) return;
emit('node-click', nodeById.value.get(n.id) || { id: n.id, label: n.label, type: n.type });
}
+10 -2
View File
@@ -126,6 +126,14 @@ function onNodeClick(n) {
detailNode.value = n;
}
/** 缺陷 #31:详情面板强制重建 key——切节点时旧实例整体销毁重建,
* old fetchExtra 的迟到响应随实例生命周期作废(配合序号守卫双保险) */
const nodeKey = computed(() => {
const n = detailNode.value;
if (!n) return '';
return n.id != null ? String(n.id) : (n.label ?? '');
});
function onDepthChange(v) {
depth.value = v;
const meta = neighborhoodMeta.value;
@@ -206,8 +214,8 @@ const kgDown = computed(() => !bench.kgReady && bench.kgStatus != null);
</div>
</div>
<!-- 实体详情侧滑(FR-KG-003) -->
<KgEntityDetail v-if="detailNode" :node="detailNode" @close="detailNode = null" />
<!-- 实体详情侧滑(FR-KG-003);缺陷 #31::key 强制重建,节点切换无串台 -->
<KgEntityDetail v-if="detailNode" :key="nodeKey" :node="detailNode" @close="detailNode = null" />
</div>
</template>
+14 -1
View File
@@ -34,6 +34,19 @@ const linkHref = computed(() => {
return null;
});
// 缺陷 #27:徽标显示后端返回的真实 paper_id(无 paper_id 退 index+1),
// 截断 + title 悬浮展示全文——聚合视图条目 id 与 API 一致,不再「+1」错位。
const badgeId = computed(() => {
const s = props.source || {};
const raw = s.paper_id != null && s.paper_id !== '' ? String(s.paper_id) : (s.id != null && s.id !== '' ? String(s.id) : String(props.index + 1));
return raw.length > 12 ? `${raw.slice(0, 12)}…` : raw;
});
const badgeTitle = computed(() => {
const s = props.source || {};
const raw = s.paper_id != null && s.paper_id !== '' ? String(s.paper_id) : (s.id != null && s.id !== '' ? String(s.id) : '');
return raw;
});
const metaParts = computed(() => {
const s = props.source || {};
const parts = [];
@@ -47,7 +60,7 @@ const metaParts = computed(() => {
<template>
<div ref="rootEl" class="ft-item source-row" :class="{ 'hl-active': highlighted }">
<span class="ft-ico idx">{{ index + 1 }}</span>
<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>
+13 -3
View File
@@ -23,6 +23,15 @@ watch(
);
const items = computed(() => bench.sources);
/** 缺陷 #27:高亮匹配以 paper_id/id 为准(chip 跳转传 key),无 key 时回退行号 */
function isHighlighted(src, i) {
const hl = bench.highlightSource;
if (!hl || hl.until <= Date.now()) return false;
const key = hl.key;
if (key != null) return (src && (String(src.paper_id) === key || String(src.id) === key));
return hl.index === i;
}
const chainList = computed(() => {
const msg = bench.currentAssistantMessage;
if (!msg) return [];
@@ -72,16 +81,17 @@ function toggleChain(id) {
/>
</div>
<!-- mono 来源列表(FR-SOURCE-001/002;FR-CHAT-015 高亮由 highlightSource 驱动) -->
<!-- mono 来源列表(FR-SOURCE-001/002;FR-CHAT-015 高亮由 highlightSource 驱动)
缺陷 #27:key 与高亮判断以 paper_id/id 为准(后端聚合结果);:index 仅行号 fallback -->
<div class="bench-sec-title">{{ T.sourcesTitle }} · {{ items.length }}</div>
<div class="source-list">
<SourceRow
v-for="(it, i) in items"
:key="it.src.id || `${i}-${it.src.paper_id}`"
:key="it.src.paper_id || it.src.id || i"
:index="i"
:source="it.src"
:count="it.label"
:highlighted="bench.highlightSource != null && bench.highlightSource.index === i && bench.highlightSource.until > Date.now()"
:highlighted="isHighlighted(it.src, i)"
/>
</div>
</template>
+25 -2
View File
@@ -5,7 +5,7 @@
// tool(阶段卡)/ sources(内联引用 chip)/ graph(轻量摘要行,完整图在 bench kg tab)
import { computed, onMounted, onUpdated, onUnmounted, ref } from 'vue';
import { T, BENCH_TABS } from '../../i18n/labels.js';
import { sanitizeHtml, NON_MONO_HEADER, renderMarkdown } from './markdown.js';
import { sanitizeHtml, NON_MONO_HEADER, renderMarkdownPlain } from './markdown.js';
import { layoutTextRoot } from './figureLayout.js';
import PipelineCard from './PipelineCard.vue';
import ToolCard from './ToolCard.vue';
@@ -84,11 +84,12 @@ const renderedText = (html) => sanitizeHtml(html);
// FR-PRIV-005:private_doc 短答 markdown 渲染(markdown-it + DOMPurify,同 text 块纪律)。
// 纵深防御:markdown-it html:false 会把 <script> 转义成文本(DOMPurify 移不掉转义文本),
// 渲染前先剥离 script/style 标签及其内容,再消毒输出(TC-PRIV-L3-03 判据)。
// 缺陷 #7b:参考块内容可被回写正本,不注入标题编号(renderMarkdownPlain)。
const renderedPrivateAnswer = (answer) => {
const src = String(answer == null ? '' : answer)
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
return sanitizeHtml(renderMarkdown(src));
return sanitizeHtml(renderMarkdownPlain(src));
};
/** 引用 chip 编号(按 sources 块顺序) */
@@ -129,6 +130,9 @@ const errNote = computed(() => {
});
const showRetry = computed(() => !!(props.onRetry && props.message.status === 'error'));
// 缺陷 #28:检索空态/超时降级提示(非错误信封样式;store 在 _runSearch 写入 degradedNote)
const degradedNote = computed(() => props.message.degradedNote || null);
</script>
<template>
@@ -272,6 +276,11 @@ const showRetry = computed(() => !!(props.onRetry && props.message.status === 'e
</div>
</template>
<!-- 缺陷 #28:检索空态/超时降级提示(done 态下的信息性文案,非错误样式) -->
<div v-if="degradedNote && message.status === 'done'" class="degraded-note">
<span class="degraded-text">{{ degradedNote }}</span>
</div>
<!-- 错误标注 + 重试(FR-CHAT-012;失败必须可见) -->
<div v-if="errNote" class="err-note">
<span class="err-text">{{ errNote }}</span>
@@ -471,6 +480,20 @@ const showRetry = computed(() => !!(props.onRetry && props.message.status === 'e
cursor: pointer;
}
.degraded-note {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
padding: 8px 12px;
border-radius: var(--pa-r-sm);
background: var(--pa-surface-2);
border: 1px solid var(--pa-hairline);
}
.degraded-text {
font-size: 12.5px;
color: var(--pa-ink-muted);
}
.err-note {
display: flex;
align-items: center;
+6 -3
View File
@@ -38,9 +38,12 @@ const jumpToSource = (payload) => {
if (!bench) return;
if (typeof bench.setSourceScope === 'function') bench.setSourceScope('current');
bench.setActiveTab('sources');
// setHighlightSource 为 B3 后续动作;缺失时仅切 tab 不报错(集成点,PM 统一)
if (payload?.index && typeof bench.setHighlightSource === 'function') {
bench.setHighlightSource(payload.index);
// 缺陷 #27:高亮改按 paper_id/id 定位(setHighlightSource(key));
// chip 编号契约不变——消息内 chip 仍显示来源块内序号(1 起),跳转定位不再依赖行号。
if (payload && typeof bench.setHighlightSource === 'function') {
const it = payload.item || {};
const key = it.paper_id != null && it.paper_id !== '' ? it.paper_id : (it.id != null && it.id !== '' ? it.id : payload.index);
bench.setHighlightSource(key);
}
};
+91 -4
View File
@@ -14,11 +14,11 @@
// 路由收敛在本组件内,ChatPanel 等上游组件零改动。
import { ref, computed, onMounted } from 'vue';
import { MODES as MODE_LABELS, T } from '../../i18n/labels.js';
import { DG } from '../../i18n/docgen.js';
import { DG, BUILTIN_TEMPLATES } from '../../i18n/docgen.js';
import { BF } from '../../i18n/bioflow.js';
import { route } from '../../intent.js';
import { route as routeBioflow } from '../../utils/intent.js';
import { uploadLibrary } from '../../api/docgen.js';
import { uploadLibrary, listLibrary } from '../../api/docgen.js';
import { uploadBioflowFile } from '../../api/bioflow.js';
import { useDocgenStore } from '../../stores/docgen.js';
import { useChatStore } from '../../stores/chat.js';
@@ -65,6 +65,41 @@ const canSend = computed(() =>
!!draft.value.trim() && !props.busy && !props.disabled && !uploading.value,
);
// ---- 缺陷 #42:docgen 模式模板下拉 ----
// 首项「自动推荐」(value '');选项 = 内置模板 + GET /api/docgen/library?as_template=true 资料。
// 懒加载(docgen 模式下首次渲染时拉取一次);失败降级为仅内置模板(不阻断发送)。
const templateOptions = ref([]); // [{value, label}](含「自动推荐」占位在 computed 中统一合成)
const templatesLoading = ref(false);
const selectedTemplateId = ref('');
async function loadTemplateOptions() {
if (templateOptions.value.length || templatesLoading.value) return;
templatesLoading.value = true;
const builtin = Object.entries(BUILTIN_TEMPLATES).map(([key, label]) => ({ value: key, label }));
try {
const data = await listLibrary({ as_template: true });
const items = Array.isArray(data?.items) ? data.items : [];
const lib = items.map((it) => ({ value: String(it.library_id || it.id || ''), label: it.name || '' })).filter((o) => o.value);
templateOptions.value = [...builtin, ...lib];
} catch {
templateOptions.value = builtin; // 失败降级:仅内置模板(「自动推荐」仍在首项)
} finally {
templatesLoading.value = false;
}
}
/** 是否处于 docgen 模式:对当前 draft 实时跑意图路由(与发送时同一 route 函数),
* 命中 docgen 才显示模板下拉——与发送分流完全一致,无模式 pill 副作用。 */
const isDocgenMode = computed(() => {
const text = draft.value.trim();
if (!text) return false;
try {
return route(text, hasAttachments.value, hasActiveArtifact.value) === 'docgen';
} catch {
return false;
}
});
/** 意图路由 hasAttachments:至少有成功入库的引用才算带附件 */
const hasAttachments = computed(() => readyAttachments.value.length > 0);
@@ -238,9 +273,12 @@ const submit = () => {
emit('send', text); // 生信 store 未就绪(测试/降级):漏判安全退化为 QA 路径
}
} else if (intent === 'docgen') {
// 文档生成分支:chat.sendDocgen(用户消息落库 + 骨架 + 同步生成 + 自动切文档 TAB)
// 文档生成分支:chat.sendDocgen(用户消息落库 + 骨架 + 异步生成 + 自动切文档 TAB)
// 缺陷 #42:选中模板经 { templateId } 传入(空串/未选 = 自动推荐)
try {
useChatStore().sendDocgen(text, readyAttachments.value.map((a) => a.libraryId)).catch(() => {
useChatStore().sendDocgen(text, readyAttachments.value.map((a) => a.libraryId), {
templateId: selectedTemplateId.value || '',
}).catch(() => {
/* sendDocgen 内部已按失败可见原则标注错误,此处兜底吞掉重复抛出 */
});
} catch {
@@ -264,6 +302,7 @@ const submit = () => {
}
draft.value = '';
attachments.value = []; // 附件已入库(library 持久),本地引用随本轮消费
selectedTemplateId.value = ''; // 模板选择随本轮消费(下轮默认自动推荐)
};
/** 文本超出 max-height 后内部滚动(自动增高由浏览器 scrollHeight 驱动)。
@@ -314,6 +353,21 @@ defineExpose({ clear: () => { draft.value = ''; }, focus: () => textarea.value?.
{{ pm.label }}
</button>
</div>
<!-- 缺陷 #42:docgen 模式模板下拉(「自动推荐」+ 内置模板 + as_template 资料,懒加载) -->
<div v-if="isDocgenMode" class="template-row">
<label class="tpl-label" for="pa-template-select">{{ DG.docPaneTemplateLabel }}</label>
<select
id="pa-template-select"
v-model="selectedTemplateId"
class="tpl-select"
:disabled="busy || disabled"
@focus="loadTemplateOptions"
>
<option value="">{{ DG.docgenTemplateAuto }}</option>
<option v-for="o in templateOptions" :key="o.value" :value="o.value">{{ o.label }}</option>
</select>
</div>
<!-- P3 附件 chips:上传中 / 已入库(可移除)/ 失败(可重试) -->
<div v-if="attachments.length" class="attach-chips">
<span
@@ -489,6 +543,39 @@ textarea:disabled {
background: var(--pa-error);
}
.template-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.tpl-label {
flex: none;
font-size: 12px;
color: var(--pa-ink-subtle);
}
.tpl-select {
flex: none;
max-width: 240px;
height: 28px;
padding: 0 8px;
border-radius: var(--pa-r-md);
border: 1px solid var(--pa-hairline);
background: var(--pa-surface-2);
color: var(--pa-ink);
font-size: 12px;
cursor: pointer;
outline: none;
}
.tpl-select:focus {
border-color: var(--pa-g-from);
box-shadow: 0 0 12px rgba(0, 212, 255, 0.15);
}
.tpl-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ---------- P3 附件 chips ---------- */
.attach-chips {
display: flex;
+95 -4
View File
@@ -9,6 +9,72 @@ export const NON_MONO_HEADER = '说明';
let md = null;
/** 单次渲染的标题编号上下文(每次 render 入口重置,渲染器是单例但上下文必须逐次独立) */
function newHeadingEnv() {
return { h1: 0, h2: 0, h3: 0 };
}
// 缺陷 #7b:标题编号注入(h1→「一、」,h2→「1.1」,h3→「1.2.3」;
// h1 变化时 h2/h3 归零,h2 变化时 h3 归零)。
// 已带数字/中文序号开头的标题跳过注入(原文保持)。
// 编号注入只在 options.headingNumbers 开启时生效——chat 路径(qaToBlocks/reveal 终态)
// 开启;DocPane 的 md 回退预览(docgen.md 是唯一落盘正本,序列化会把标题写回
// markdown)与 private_doc 参考块关闭,避免编号被持久化进正本。
const CN_NUM = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
const cnNum = (n) => {
if (n <= 0) return '零';
if (n <= 10) return CN_NUM[n];
if (n < 20) return `十${CN_NUM[n - 10]}`;
if (n < 100) {
const tens = Math.floor(n / 10);
const ones = n % 10;
return `${CN_NUM[tens]}十${ones ? CN_NUM[ones] : ''}`;
}
return String(n); // ≥100 兜底阿拉伯数字(章节不会到百级)
};
const HAS_OWN_NUMBER = /^(\d{1,3}([.)、::]\s*|\s+|$)|[一二三四五六七八九十]+[、.])/;
function hasOwnNumber(text) {
const t = (text || '').trim();
return HAS_OWN_NUMBER.test(t);
}
/** heading_open → 注入编号的 <hN class="md-hN">(编号前移入 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(/<td/, '<td class="mono"') : open;
};
// 缺陷 #7b:heading_open 编号注入(md-hN class 恒挂;编号按 env.headingNumbers 注入)
inst.renderer.rules.heading_open = (tokens, idx, options, env, self) => {
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)→ 渲染前再消毒一次(纵深防御:绝不信任持久化内容)。
+5
View File
@@ -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: '文档内容加载失败',
+4
View File
@@ -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 检索较慢)',
+28 -18
View File
@@ -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() {
+329 -67
View File
@@ -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,248 @@ 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'; });
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 +1655,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 +1775,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 +1810,7 @@ export const useChatStore = defineStore('chat', {
this._finalizeReveal(aMsg, fullText, ctx);
resolve();
}
}, REVEAL_STEP_MS);
}, interval);
});
},
+7 -2
View File
@@ -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 = '';
+137
View File
@@ -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('');
});
});
+242
View File
@@ -0,0 +1,242 @@
// 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' });
vi.mocked(getTask).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;
await vi.advanceTimersByTimeAsync(2000);
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(0); // adoptArtifact 内部异步拉取
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-1')).toBe(true);
expect(aMsg.blocks[0].status).toBe('done');
expect(aMsg.blocks[0].steps.every((s) => s.state === 'done')).toBe(true);
// 轮询注册自清(终态后不再有定时器)
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');
});
});
+57
View File
@@ -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('<h'));
expect(hs[0]).toBe('<h1 class="md-h1">一、 第一章</h1>');
expect(hs[1]).toBe('<h2 class="md-h2">1.1 节一</h2>');
expect(hs[2]).toBe('<h3 class="md-h3">1.1.1 小节一</h3>');
expect(hs[3]).toBe('<h2 class="md-h2">1.2 节二</h2>');
expect(hs[4]).toBe('<h1 class="md-h1">二、 第二章</h1>');
expect(hs[5]).toBe('<h2 class="md-h2">2.1 节一</h2>'); // h1 变化后 h2 归零
expect(hs[6]).toBe('<h3 class="md-h3">2.1.1 小节一</h3>');
});
it('已带数字/中文序号开头的标题跳过注入(原文保持)', () => {
const out = renderMarkdown('## 1. 概述\n\n### 1.1 目标\n\n## 一、背景');
const hs = out.split('\n').filter((l) => l.startsWith('<h'));
expect(hs[0]).toBe('<h2 class="md-h2">1. 概述</h2>');
expect(hs[1]).toBe('<h3 class="md-h3">1.1 目标</h3>');
expect(hs[2]).toBe('<h2 class="md-h2">一、背景</h2>');
});
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('<h'));
expect(hs[0]).toBe('<h2 class="md-h2">1.1 直接节</h2>');
expect(hs[1]).toBe('<h3 class="md-h3">1.1.1 小节</h3>');
});
it('行内强调(strong 等)保留', () => {
const out = renderMarkdown('## 加粗**重点**标题');
expect(out).toContain('<h2 class="md-h2">1.1 加粗<strong>重点</strong>标题</h2>');
});
it('renderMarkdownPlain 不注入编号(DocPane md 回退预览保护正本)', () => {
const out = renderMarkdownPlain('# 不应编号');
expect(out.trim()).toBe('<h1 class="md-h1">不应编号</h1>');
});
it('所有 h1-h6 均挂 md-hN class(含非 1-3 级)', () => {
const out = renderMarkdown('#### 四级\n\n##### 五级');
expect(out).toContain('<h4 class="md-h4">');
expect(out).toContain('<h5 class="md-h5">');
});
});
+116
View File
@@ -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);
});
});
+19
View File
@@ -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,
},
});