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:
+19
-2
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)→ 渲染前再消毒一次(纵深防御:绝不信任持久化内容)。
|
||||
|
||||
@@ -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: '文档内容加载失败',
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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">');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user