Files
PlantAI-rag-new/ui/src/stores/chat.js
T
admin d37e0d9c3a fix(paper): 确认大纲后即时 seed 进度卡并逐节刷新
- 后端写稿循环不再落空 paper_progress 块(重载后出现空卡),改落开始撰写文本
- 前端 confirmPaper 时用大纲节标题本地 seed 进度卡,2s 轮询 patch 逐节状态
- 当前节按 current_section_id 标 writing(…动画)
2026-10-08 17:10:50 +08:00

3336 lines
144 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// stores/chat.js —— 对话状态机(B2 归属,核心)
// 承载 FR-CHAT-008/009/010/011/012/013:
// send(text):用户消息落库 → 会话 running → 助手骨架(pipeline 五相位 waiting)
// → fetch 流(SSE)→ 逐帧消费 → 每 ~3s 检查点 PATCH → 终态落库
// stop():AbortController 中断(FR-CHAT-011),保留已收到的块,标「已停止」
// retry():同消息位置替换(FR-CHAT-012),不新增用户消息
// loadSession():刷新后从 detail 恢复;interrupted 消息置 error 标「已中断」
// 揭示动画:complete 后 30ms/字符 typewriter,>2000 字符总时长 ≤8s,带跳过
// 错误映射:TIMEOUT / INVALID_CITATION / UPSTREAM_DOWN / 连接中断 → labels.js 文案
//
// 2026-08-26 多流重构(用户反馈):切换会话不再中断流——每会话独立流上下文
// (streamCtx),切走后流在后台继续(消息数组 sessionMsgs 持续更新),
// 切回时直接拿实时数组;stop/重试只作用于当前会话的流。
// 原 FR-SESSION-004 的「切会话即中断」行为废止,见 CHANGELOG。
import { defineStore, getActivePinia } from 'pinia';
import { createMessage, updateMessage } from '../api/sessions.js';
import { chatStream, search } from '../api/qa.js';
import { useSessionsStore } from './sessions.js';
import { PHASE_LABEL, PHASE_DESC, T } from '../i18n/labels.js';
import { MODES } from '../config/modes.js';
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, getTask } from '../api/docgen.js';
import { useSettingsStore } from './settings.js';
import { useDocgenStore } from './docgen.js';
// ---- 论文撰写(paper-writer):独立分支(send/_runStream/sendDocgen 零改动) ----
import { paperTurn, confirmPaper as confirmPaperApi, paperProgress as paperProgressApi } from '../api/paper.js';
import { PP as PP_LABELS } from '../i18n/paper.js';
// ---- P2 生信流程编排:独立分支(send/_runStream/sendDocgen 零改动) ----
import { BF } from '../i18n/bioflow.js';
import { route as routeBioflow } from '../utils/intent.js';
import { bioflowChatStream } from '../api/bioflow.js';
import { useBioflowStore } from './bioflow.js';
import { consumeSseStream } from '../sse/parser.js';
// ---- FR-PRIV 私有资料问答:ask/search 全局并行检索(fire-and-forget,失败静默) ----
import { askPrivate } from '../api/private.js';
/** docgen pipeline 卡标题(区别于 QA 的「检索流水线」) */
const DG_TITLE = DG_LABELS.docgenPipelineTitle;
const CHECKPOINT_MS = 3000;
const REVEAL_STEP_MS = 30;
const REVEAL_MAX_MS = 8000;
/** 流水线五相位(规划/检索/证据/生成/完成);完成相位不进底栏 agent 点 */
export const PIPELINE_PHASES = ['planning', 'retrieval', 'evidence', 'generation', 'done'];
/** 底栏四个 agent 点(UI_SPEC §4.8:规划/检索/证据/生成) */
export const AGENT_DOT_PHASES = ['planning', 'retrieval', 'evidence', 'generation'];
/** 新建一条骨架消息(FR-CHAT-008 步骤 3):pipeline 块五相位全 waiting */
export function newSkeletonMessage() {
return {
id: null, // POST /messages 后回填
role: 'assistant',
kind: 'qa',
status: 'streaming',
streamErr: null, // 错误标注:null | 'timeout' | 'interrupted' | 'stopped' | 'disconnected' | 'citation' | {message}
interrupted: false,
blocks: [{
type: 'pipeline',
status: 'running',
steps: PIPELINE_PHASES.map((phase) => ({
name: PHASE_LABEL[phase],
state: 'waiting',
desc: PHASE_DESC[phase],
phase,
})),
}],
raw: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
}
/** 新建一条 docgen 骨架消息(P3):pipeline 块带能力徽标 + 文档生成五步
* (相位复用 PHASE_COLOR_VAR 键 planning/retrieval/evidence/generation/done) */
export function newDocgenSkeletonMessage() {
return {
id: null, // POST /messages 后回填
role: 'assistant',
kind: 'docgen',
status: 'streaming',
streamErr: null,
interrupted: false,
blocks: [{
type: 'pipeline',
title: DG_TITLE,
status: 'running',
capabilities: [{ key: 'doc_writer', label: CAP_BADGES.doc_writer }],
steps: DOCGEN_STEPS.map((st) => ({ name: st.name, state: 'waiting', desc: st.desc, phase: st.phase })),
}],
raw: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
}
// ---------------------------------------------------------------
// P2 生信:消息更新器(SSE 事件 → patch 消息 blocks;断线重连回放快照整体刷新)
// 骨架形态:blocks=[{type:'bioflow_path', status:'running', candidates:[], selected:null}]。
// 更新器全部按「找到则 patch / 缺则 push / 未知事件忽略」的幂等规则实现,
// 供 sendBioflow 流消费与历史恢复复用(FR-BIO-013/014)。
// ---------------------------------------------------------------
/** 新建一条生信骨架消息(P2):path 块等待候选路径(FR-BIO-002) */
export function newBioflowSkeletonMessage() {
return {
id: null,
role: 'assistant',
kind: 'bioflow',
status: 'streaming',
streamErr: null,
interrupted: false,
blocks: [
{
type: 'bioflow_path',
status: 'running',
candidates: [],
selected: null,
autoFilled: [],
reuseArchive: null,
reuseName: null,
fallback: null,
},
// 派生 pipeline 块(derived:true):为底部状态栏 4 个 agent 点提供相位数据。
// 生信对话从不建原生 pipeline 块,StatusBar/TopBar 只读 pipeline 块——派生块
// 由进度块 stages 镜像而来,渲染(AssistantMessage)与落库(_persistableBlocks)
// 均跳过,仅布局组件消费。
{
type: 'pipeline',
derived: true,
status: 'running',
steps: AGENT_DOT_PHASES.map((phase) => ({
name: PHASE_LABEL[phase],
state: 'waiting',
desc: PHASE_DESC[phase],
phase,
})),
},
],
raw: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
}
/** 保证助手消息有一个 path 块(缺则 push;复用/刷新路径共用) */
export function ensureBioflowPathBlock(aMsg) {
let blk = (aMsg.blocks || []).find((b) => b.type === 'bioflow_path');
if (!blk) {
blk = {
type: 'bioflow_path',
status: 'running',
planning: false, // 规划中提示(planning 帧开启、plan_ready 关闭)
planningText: '', // 规划提示文案
candidates: [],
selected: null,
autoFilled: [],
reuseArchive: null,
reuseName: null,
fallback: null,
};
aMsg.blocks.unshift(blk);
}
return blk;
}
/** planning 帧:创建/点亮路径块「思考中」状态(FR-BIO-002 前置视觉) */
export function patchBioflowPlanning(aMsg, data) {
const blk = ensureBioflowPathBlock(aMsg);
blk.planning = true;
blk.planningText = (data && data.message) || '正在分析需求并规划分析路径…';
blk.status = 'running';
return blk;
}
/** 找到(或创建)唯一的进度块(bioflow_progress) */
export function ensureBioflowProgressBlock(aMsg) {
let blk = (aMsg.blocks || []).find((b) => b.type === 'bioflow_progress');
if (!blk) {
blk = { type: 'bioflow_progress', jobId: null, jobStatus: 'created', stages: [], autoContinue: false };
aMsg.blocks.push(blk);
}
return blk;
}
/** plan_ready → path 块候选(FR-BIO-002/003;复用句式的候选为单一装载链) */
export function patchBioflowPlan(aMsg, data) {
const blk = ensureBioflowPathBlock(aMsg);
const candidates = (Array.isArray(data.candidates) ? data.candidates : [])
.map((c) => ({ purpose: c.purpose || '', stages: Array.isArray(c.stages) ? c.stages : [] }));
blk.candidates = candidates;
blk.autoFilled = Array.isArray(data.auto_filled) ? data.auto_filled : [];
blk.reuseArchive = data.reuse_archive || null;
blk.reuseName = data.reuse_name || null;
blk.fallback = data.fallback || null;
blk.planning = false; // 规划结束,进入候选选择
blk.status = 'done';
if (blk.reuseArchive && !blk.selected) blk.selected = 0; // 复用句式:直接选中唯一候选
return blk;
}
/** 阶段状态 → 进度块阶段条目 */
function progressStage(stage, status, extra = {}) {
return {
stage,
status: status || 'waiting',
log_tail: typeof extra.log_tail === 'string' ? extra.log_tail : '',
progress: typeof extra.progress === 'number' ? extra.progress : (extra.progress ?? null),
progressMessage: typeof extra.progressMessage === 'string'
? extra.progressMessage : (extra.message || null),
error: typeof extra.error === 'string' ? extra.error : (extra.reason || null),
attempt: typeof extra.attempt === 'number' ? extra.attempt : (extra.attempt ?? 0),
params: extra.params || {},
};
}
/** snapshot → 进度块整体刷新(引用替换;断线重连回放 FR-BIO-014) */
export function patchBioflowSnapshot(aMsg, snap) {
const blk = ensureBioflowProgressBlock(aMsg);
const job = (snap && snap.job) || {};
blk.jobId = snap.job_id || blk.jobId;
blk.jobStatus = job.status || blk.jobStatus;
blk.stages = (snap && Array.isArray(snap.stages) ? snap.stages : [])
.map((s) => progressStage(s.stage, s.status, s));
blk.lastSeq = typeof snap.last_seq === 'number' ? snap.last_seq : blk.lastSeq;
syncBioflowDotBlock(aMsg);
return blk;
}
/** 生信阶段状态 → 相位点状态(派生 pipeline 块;单调:error 与 done 不因后续阶段回退) */
function dotStateOf(st) {
const s = st && st.status;
if (s === 'failed') return 'error';
if (s === 'running') return 'running';
if (s === 'done' || s === 'skipped') return 'done';
return 'waiting';
}
/**
* 进度块 stages → 底部 4 个 agent 点的 steps。
* 相位映射按「每 4 个连续阶段 = 一轮」循环(stages[r*4+i] → AGENT_DOT_PHASES[i]),
* 与 FlowProgressCard 的相位色循环同源;waiting/缺失阶段沿用上一轮该相位状态,
* 已 error 的点不因后续阶段回退。
*/
export function bioflowDotStepsFromStages(stages) {
const arr = Array.isArray(stages) ? stages : [];
const out = AGENT_DOT_PHASES.map((phase) => ({
name: PHASE_LABEL[phase],
state: 'waiting',
desc: PHASE_DESC[phase],
phase,
}));
for (let i = 0; i < arr.length; i += 1) {
const phaseIdx = i % AGENT_DOT_PHASES.length;
const step = out[phaseIdx];
const st = dotStateOf(arr[i]);
if (st === 'waiting') continue; // 沿用上一轮该相位状态
if (step.state === 'error') continue; // error 不回退
if (st === 'running') {
step.state = 'running';
} else if (step.state === 'waiting') {
step.state = st;
}
}
return out;
}
/** 找到(或创建)生信消息的派生 pipeline 块并返回之 */
export function ensureBioflowDotBlock(aMsg) {
let blk = (aMsg.blocks || []).find((b) => b && b.type === 'pipeline' && b.derived);
if (!blk) {
blk = {
type: 'pipeline',
derived: true,
status: 'running',
steps: bioflowDotStepsFromStages([]),
};
aMsg.blocks.push(blk);
}
return blk;
}
/** 用进度块 stages 重算派生 pipeline 块(实时事件与快照回放共用) */
export function syncBioflowDotBlock(aMsg) {
if (!aMsg || !Array.isArray(aMsg.blocks)) return null;
const prog = aMsg.blocks.find((b) => b && b.type === 'bioflow_progress');
const blk = ensureBioflowDotBlock(aMsg);
blk.steps = bioflowDotStepsFromStages(prog && Array.isArray(prog.stages) ? prog.stages : []);
return blk;
}
const BIOFLOW_STAGE_EVENT_STATUS = {
stage_started: 'running',
stage_log: 'running',
stage_done: 'done',
stage_failed: 'failed',
stage_skipped: 'skipped',
stage_await_confirm: 'waiting_confirm',
stage_await_files: 'await_files',
};
const BIOFLOW_STAGE_TERMINAL = new Set(['done', 'failed', 'skipped']);
const BIOFLOW_JOB_EVENT_STATUS = {
job_done: 'done',
job_paused: 'paused',
job_archived: 'archived',
};
/** 任务事件 → 进度块 patch(幂等;done/failed/skipped 不回退;日志尾追加) */
export function patchBioflowEvent(aMsg, ev) {
const type = ev && ev.type;
const data = (ev && ev.data) || {};
if (!type || !data) return null;
if (type === 'job_created') {
const blk = ensureBioflowProgressBlock(aMsg);
blk.jobId = data.job_id || blk.jobId;
blk.jobStatus = 'created';
blk.autoContinue = !!data.auto_continue;
blk.stages = (Array.isArray(data.stages) ? data.stages : [])
.map((s) => progressStage(s.stage || s, s.status || 'waiting', s));
syncBioflowDotBlock(aMsg);
return blk;
}
if (type === 'snapshot') {
return patchBioflowSnapshot(aMsg, data);
}
if (type === 'plan_ready') {
return patchBioflowPlan(aMsg, data);
}
const blk = ensureBioflowProgressBlock(aMsg);
blk.jobId = data.job_id || blk.jobId;
if (type === 'stage_progress') {
// 进度帧:不改阶段状态,只推进 progress(防回退)
if (data.stage) {
const idx = blk.stages.findIndex((s) => s.stage === data.stage);
const nextPct = typeof data.percent === 'number' ? data.percent : null;
if (idx >= 0) {
const cur = blk.stages[idx];
const curPct = typeof cur.progress === 'number' ? cur.progress : -1;
if (nextPct === null || nextPct >= curPct) {
blk.stages[idx] = {
...cur,
progress: nextPct === null ? cur.progress : nextPct,
progressMessage: data.message || (nextPct === 100 ? '' : cur.progressMessage),
};
}
} else {
blk.stages = [...blk.stages, progressStage(data.stage, 'running', data)];
}
}
} else if (BIOFLOW_STAGE_EVENT_STATUS[type] && data.stage) {
const idx = blk.stages.findIndex((s) => s.stage === data.stage);
const target = BIOFLOW_STAGE_EVENT_STATUS[type];
if (idx >= 0) {
const cur = blk.stages[idx];
// 终态不回退;例外:failed + stage_started = 重跑新 attempt 起点
if (BIOFLOW_STAGE_TERMINAL.has(cur.status)
&& !(type === 'stage_started' && cur.status === 'failed')) {
// 乱序容错:终态不回退;仅 stage_log 追加日志
if (type === 'stage_log' && data.log) {
blk.stages[idx] = { ...cur, log_tail: (cur.log_tail || '') + data.log };
}
} else {
blk.stages[idx] = progressStage(data.stage, target, {
...cur,
...data,
log_tail: type === 'stage_log'
? (cur.log_tail || '') + (data.log || '')
: (typeof data.log_tail === 'string' ? data.log_tail : (cur.log_tail || '')),
error: type === 'stage_failed' ? (data.reason || cur.error || '') : (cur.error || ''),
});
if (type === 'stage_started') {
blk.stages[idx].progress = null;
blk.stages[idx].progressMessage = null;
}
}
} else {
blk.stages = [...blk.stages, progressStage(data.stage, target, data)];
}
} else if (BIOFLOW_JOB_EVENT_STATUS[type]) {
blk.jobStatus = BIOFLOW_JOB_EVENT_STATUS[type];
} else if (type === 'job_paused' && data.current_stage) {
// 暂停附加信息(current_stage 供文案展示,状态已由映射表处理)
} else {
return null; // 未知/与进度无关的事件类型(message_done 等)
}
// 阶段推进时任务状态兜底(快照缺失场景;任务级终态由 job_done/job_paused/
// job_archived 事件驱动——阶段 failed 后任务保持 running 等待三选一)
if (!blk.jobStatus || blk.jobStatus === 'created') {
if (blk.stages.some((s) => s.status === 'running')) blk.jobStatus = 'running';
}
syncBioflowDotBlock(aMsg);
return blk;
}
/** 任务完成 → 存档提示块(FR-BIO-020;同一任务只出现一次) */
export function patchBioflowArchiveSuggest(aMsg, jobId) {
const existing = (aMsg.blocks || []).find((b) => b.type === 'bioflow_archive');
if (existing) return existing;
const blk = {
type: 'bioflow_archive',
jobId,
status: 'pending', // pending | archiving | done | dismissed | error
name: '',
description: '',
result: null,
error: null,
};
aMsg.blocks.push(blk);
return blk;
}
/** 存档状态回复(FR-BIO-021):名称/位置/大小三要素 */
export function patchBioflowArchiveDone(aMsg, blk, result) {
if (!blk || !result) return blk;
blk.status = 'done';
blk.result = result;
return blk;
}
/** 阶段待确认 → 参数表单块(FR-BIO-008);同阶段失败改参数重跑复用同一形态(FR-BIO-012) */
export function patchBioflowForm(aMsg, data, { rerun = false } = {}) {
const stage = data.stage;
if (!stage) return null;
let blk = (aMsg.blocks || []).find((b) => b.type === 'bioflow_form' && b.stage === stage);
if (!blk) {
blk = { type: 'bioflow_form', stage, fields: [], autoContinue: false, rerun, submitting: false };
aMsg.blocks.push(blk);
}
if (Array.isArray(data.form_defaults)) blk.fields = data.form_defaults;
if (data.rerun_with_params) blk.rerun = true;
return blk;
}
/** 阶段待文件 → 文件引导块(FR-BIO-005) */
export function patchBioflowFile(aMsg, data) {
const stage = data.stage;
if (!stage) return null;
let blk = (aMsg.blocks || []).find((b) => b.type === 'bioflow_file' && b.stage === stage);
if (!blk) {
blk = {
type: 'bioflow_file',
stage,
requiredFiles: Array.isArray(data.required_files) ? data.required_files : [],
message: data.message || '',
files: [], // 已上传/登记列表 {name,size,status:'uploading'|'done'|'error'}
progress: 0,
};
aMsg.blocks.push(blk);
}
if (Array.isArray(data.required_files)) blk.requiredFiles = data.required_files;
if (data.message) blk.message = data.message;
return blk;
}
/** 流程知识库咨询引用块(FR-BIO-026;引用携带 cite 供跳转) */
export function patchBioflowQa(aMsg, payload) {
if (!payload) return null;
const blk = {
type: 'bioflow_qa',
question: payload.question || '',
citations: Array.isArray(payload.citations) ? payload.citations : [],
degraded: !!payload.degraded,
};
aMsg.blocks.push(blk);
return blk;
}
// ---------------------------------------------------------------
// 用户自定义扩展分析:消息更新器(EXTENSION_CONTRACT.md §4 事件帧 → patch 消息 blocks)
// 与 patchBioflow* 同模式:全部按「找到则 patch / 缺则 push / 未知事件忽略」的幂等规则实现,
// 供 sendBioflow 流的 applyFrame 消费与历史恢复复用。
// 块类型按生命周期步骤:extension_upload(上传引导卡)→ extension_build(AI 转换卡)→
// extension_trial(试运行卡)→ extension_validate(金标/确认卡)→ extension_publish(发布卡)→
// extension_candidates(多命中候选卡)/ extension_run(运行进度卡)。
// ---------------------------------------------------------------
/** 找到(或创建)唯一的上传引导卡块(extension_upload) */
export function ensureExtensionUploadBlock(aMsg) {
let blk = (aMsg.blocks || []).find((b) => b.type === 'extension_upload');
if (!blk) {
blk = {
type: 'extension_upload',
status: 'idle', // idle | uploading | created | error
displayName: '',
command: '',
runtimeImage: 'bioflow-gf',
extId: null,
golden: null,
error: null,
files: [], // 上传记录 {name,size,status:'uploading'|'done'|'error',progress}
};
aMsg.blocks.push(blk);
}
return blk;
}
/** 找到(或创建)唯一的运行进度卡块(extension_run;run_id 未定时复用同一块) */
export function ensureExtensionRunBlock(aMsg) {
let blk = (aMsg.blocks || []).find((b) => b.type === 'extension_run');
if (!blk) {
blk = {
type: 'extension_run',
runId: null,
extId: null,
status: 'created', // created | running | done | failed
log: '',
outputs: [],
exitCode: null,
error: null,
};
aMsg.blocks.push(blk);
}
return blk;
}
/** extension_upload → 上传引导卡(§4;创建意图命中/歧义消解兜底) */
export function patchExtensionUpload(aMsg, data) {
const blk = ensureExtensionUploadBlock(aMsg);
if (data && typeof data.session_ext_id === 'string') blk.sessionExtId = data.session_ext_id;
if (data && data.message) blk.message = data.message;
return blk;
}
/** extension_created → 上传完成落定(块幂等:上传卡已有 extId 且非空时不覆盖,
* 避免迟到的 created 帧把后续 build 流程的 extId 洗掉) */
export function patchExtensionCreated(aMsg, data) {
const blk = ensureExtensionUploadBlock(aMsg);
if (data && data.ext_id) {
if (!blk.extId) blk.extId = data.ext_id;
if (data.display_name) blk.displayName = data.display_name;
}
if (data && data.status) blk.status = data.status === 'draft' ? 'created' : blk.status;
return blk;
}
/** extension_build → AI 转换卡(command_template/依赖/推荐镜像/nl_triggers 可编辑修正)。
* status=building 时重入更新占位;build_failed 展示错误且不回退已完成内容。 */
export function patchExtensionBuild(aMsg, data) {
if (!data || !data.ext_id) return null;
let blk = (aMsg.blocks || []).find((b) => b.type === 'extension_build' && b.extId === data.ext_id);
if (!blk) {
blk = {
type: 'extension_build',
extId: data.ext_id,
status: data.status || 'building',
commandTemplate: '',
originalCommand: '',
requirements: { pip: [], apt: [] },
runtimeImage: '',
nlTriggers: [],
analysisType: '',
parameters: [],
error: null,
};
aMsg.blocks.push(blk);
}
const status = data.status || blk.status;
if (status === 'build_failed') {
// 失败必须可见:仅补错误,不回退已展示内容(幂等)
if (data.error) blk.error = data.error;
blk.status = 'build_failed';
return blk;
}
if (typeof data.command_template === 'string') blk.commandTemplate = data.command_template;
if (typeof data.original_command === 'string') blk.originalCommand = data.original_command;
if (data.requirements && typeof data.requirements === 'object') {
blk.requirements = {
pip: Array.isArray(data.requirements.pip) ? data.requirements.pip : (blk.requirements.pip || []),
apt: Array.isArray(data.requirements.apt) ? data.requirements.apt : (blk.requirements.apt || []),
};
}
if (typeof data.runtime_image === 'string' && data.runtime_image) blk.runtimeImage = data.runtime_image;
if (Array.isArray(data.nl_triggers)) blk.nlTriggers = data.nl_triggers;
if (typeof data.analysis_type === 'string') blk.analysisType = data.analysis_type;
if (Array.isArray(data.parameters)) blk.parameters = data.parameters;
blk.status = status;
return blk;
}
/** extension_trial → 试运行卡(状态/日志尾追加/失败错误;running 时日志只追加不覆盖) */
export function patchExtensionTrial(aMsg, data) {
if (!data || !data.ext_id) return null;
let blk = (aMsg.blocks || []).find((b) => b.type === 'extension_trial' && b.extId === data.ext_id);
if (!blk) {
blk = {
type: 'extension_trial',
extId: data.ext_id,
status: data.status || 'running',
logTail: '',
exitCode: null,
error: null,
suggestion: null,
};
aMsg.blocks.push(blk);
}
const status = data.status || blk.status;
blk.status = status;
if (typeof data.log_tail === 'string' && data.log_tail) {
// 日志只追加:与 stage_log 同纪律(乱序/重放不重复拼接)
blk.logTail = (blk.logTail || '') + data.log_tail;
}
if (data.exit_code != null) blk.exitCode = data.exit_code;
if (status === 'failed') {
if (data.error) blk.error = data.error;
if (data.suggestion) blk.suggestion = data.suggestion;
} else if (status === 'done') {
blk.error = null;
}
return blk;
}
/** extension_repair → 试运行卡补修复状态(repair_count/suggestion;status=repairing 标循环中) */
export function patchExtensionRepair(aMsg, data) {
if (!data || !data.ext_id) return null;
let blk = (aMsg.blocks || []).find((b) => b.type === 'extension_trial' && b.extId === data.ext_id);
if (!blk) {
blk = {
type: 'extension_trial',
extId: data.ext_id,
status: 'repairing',
logTail: '',
exitCode: null,
error: null,
suggestion: null,
repairCount: 0,
};
aMsg.blocks.push(blk);
}
if (typeof data.repair_count === 'number') blk.repairCount = data.repair_count;
if (typeof data.suggestion === 'string' && data.suggestion) blk.suggestion = data.suggestion;
if (data.status) blk.status = data.status;
return blk;
}
/** extension_validate → 金标/确认卡(§4;有金标时展示对比结果,参数确认走同卡) */
export function patchExtensionValidate(aMsg, data) {
if (!data || !data.ext_id) return null;
let blk = (aMsg.blocks || []).find((b) => b.type === 'extension_validate' && b.extId === data.ext_id);
if (!blk) {
blk = {
type: 'extension_validate',
extId: data.ext_id,
golden: null,
formDefaults: [],
validated: false,
};
aMsg.blocks.push(blk);
}
if (data.golden !== undefined) blk.golden = data.golden || null;
if (Array.isArray(data.form_defaults)) blk.formDefaults = data.form_defaults;
if (data.validated != null) blk.validated = !!data.validated;
return blk;
}
/** extension_publish → 发布卡(§4 生命周期发布确认;validate 帧携带 ready_to_publish 时同源挂卡)。
* 块幂等:同 ext_id 重入只刷新 nl_triggers/display_name。 */
export function patchExtensionPublish(aMsg, data) {
if (!data || !data.ext_id) return null;
let blk = (aMsg.blocks || []).find((b) => b.type === 'extension_publish' && b.extId === data.ext_id);
if (!blk) {
blk = {
type: 'extension_publish',
extId: data.ext_id,
displayName: data.display_name || '',
nlTriggers: Array.isArray(data.nl_triggers) ? data.nl_triggers : [],
published: false,
};
aMsg.blocks.push(blk);
}
if (data.display_name) blk.displayName = data.display_name;
if (Array.isArray(data.nl_triggers)) blk.nlTriggers = data.nl_triggers;
if (data.published != null) blk.published = !!data.published;
return blk;
}
/** extension_published → 发布落定(试运行卡标已发布;发布块幂等去重) */
export function patchExtensionPublished(aMsg, data) {
if (!data || !data.ext_id) return null;
const blk = {
type: 'extension_published',
extId: data.ext_id,
displayName: data.display_name || '',
nlTriggers: Array.isArray(data.nl_triggers) ? data.nl_triggers : [],
};
const existing = (aMsg.blocks || []).find((b) => b.type === 'extension_published' && b.extId === data.ext_id);
if (existing) {
if (data.display_name) existing.displayName = data.display_name;
if (Array.isArray(data.nl_triggers)) existing.nlTriggers = data.nl_triggers;
return existing;
}
aMsg.blocks.push(blk);
// 发布确认卡同源收口(若已挂):published 置位
const pubBlk = (aMsg.blocks || []).find((b) => b.type === 'extension_publish' && b.extId === data.ext_id);
if (pubBlk) pubBlk.published = true;
return blk;
}
/** extension_candidates → 多命中候选卡(§4;purpose 用途文案,点击选择) */
export function patchExtensionCandidates(aMsg, data) {
if (!data || !Array.isArray(data.candidates)) return null;
let blk = (aMsg.blocks || []).find((b) => b.type === 'extension_candidates');
if (!blk) {
blk = { type: 'extension_candidates', candidates: [], selected: null };
aMsg.blocks.push(blk);
}
blk.candidates = data.candidates
.map((c) => c && typeof c === 'object' ? {
ext_id: c.ext_id || null,
display_name: c.display_name || '',
purpose: c.purpose || '',
} : null)
.filter(Boolean);
return blk;
}
/** extension_confirm → 运行前参数确认卡(§4;块幂等:同 ext_id 重入只刷新参数与提取值) */
export function patchExtensionConfirm(aMsg, data) {
if (!data || !data.ext_id) return null;
let blk = (aMsg.blocks || []).find((b) => b.type === 'extension_confirm' && b.extId === data.ext_id);
if (!blk) {
blk = {
type: 'extension_confirm',
extId: data.ext_id,
paramsSchema: [],
extractedParams: {},
filesRequired: [],
};
aMsg.blocks.push(blk);
}
if (Array.isArray(data.params_schema)) blk.paramsSchema = data.params_schema;
if (data.extracted_params && typeof data.extracted_params === 'object') blk.extractedParams = data.extracted_params;
if (Array.isArray(data.files_required)) blk.filesRequired = data.files_required;
return blk;
}
/** extension_run_started/run_log/run_done/run_failed → 运行卡(§4;run_id 换新则新建块,
* 同 run_id 幂等:终态不回退、日志只追加、done 结果整体替换) */
export function patchExtensionRunEvent(aMsg, data) {
const type = data && data.type;
if (!type || !data) return null;
const runId = data.run_id;
if (!runId) return null;
const existing = (aMsg.blocks || []).find((b) => b.type === 'extension_run' && b.runId === runId);
const blk = existing || (() => {
const nb = {
type: 'extension_run',
runId,
extId: data.ext_id || null,
status: 'created',
log: '',
outputs: [],
exitCode: null,
error: null,
};
aMsg.blocks.push(nb);
return nb;
})();
if (data.ext_id) blk.extId = data.ext_id;
const TERMINAL = new Set(['done', 'failed']);
if (type === 'extension_run_started') {
// 开始不重置日志(同一 run 的乱序帧防御);状态 running
blk.status = 'running';
} else if (type === 'extension_run_log') {
// 终态不回退(乱序/重放帧防御):done/failed 后迟到的 log 帧不改变状态、不追加日志
if (TERMINAL.has(blk.status)) return blk;
if (typeof data.log === 'string' && data.log) blk.log = (blk.log || '') + data.log;
blk.status = 'running';
} else if (type === 'extension_run_done') {
blk.status = 'done';
blk.outputs = Array.isArray(data.outputs) ? data.outputs : [];
blk.exitCode = 0;
blk.error = null;
} else if (type === 'extension_run_failed') {
blk.status = 'failed';
if (data.exit_code != null) blk.exitCode = data.exit_code;
if (data.error) blk.error = data.error;
if (typeof data.log_tail === 'string') blk.logTail = data.log_tail;
} else {
return null; // 未知事件忽略
}
return blk;
}
/** extension_* 帧统一分派(applyFrame 增的分支;未知事件返回 null 供上层忽略) */
export function applyExtensionFrame(aMsg, type, data) {
switch (type) {
case 'extension_upload':
return patchExtensionUpload(aMsg, data);
case 'extension_created':
return patchExtensionCreated(aMsg, data);
case 'extension_build':
return patchExtensionBuild(aMsg, data);
case 'extension_trial':
return patchExtensionTrial(aMsg, data);
case 'extension_repair':
return patchExtensionRepair(aMsg, data);
case 'extension_validate': {
const blk = patchExtensionValidate(aMsg, data);
// 试运行通过 → 发布引导(validate 帧携带 ready_to_publish/display_name 时挂发布卡;
// 契约 §4 无独立发布引导帧,发布确认由此同源驱动,extension_publish 帧刷新同卡)
if (blk && data && (data.ready_to_publish || data.ready_publish)) {
patchExtensionPublish(aMsg, {
ext_id: data.ext_id,
display_name: data.display_name || '',
nl_triggers: Array.isArray(data.nl_triggers) ? data.nl_triggers : [],
});
}
return blk;
}
case 'extension_publish':
return patchExtensionPublish(aMsg, data);
case 'extension_published':
return patchExtensionPublished(aMsg, data);
case 'extension_candidates':
return patchExtensionCandidates(aMsg, data);
case 'extension_confirm':
return patchExtensionConfirm(aMsg, data);
case 'extension_run_started':
case 'extension_run_log':
case 'extension_run_done':
case 'extension_run_failed':
return patchExtensionRunEvent(aMsg, { ...(data || {}), type });
default:
return null;
}
}
/** 收集本次实际启用的会话能力 chips(联网搜索/答案润色/Superpowers;
* label 取自 settings store 已加载的 manifest)。settings 未就绪时返回空数组(不阻断)。 */
export function collectQaCaps() {
try {
const settings = useSettingsStore();
const enabled = settings.sessionCaps || {};
const out = [];
for (const name of ['web_search', 'humanize', 'superpowers']) {
if (enabled[name] === true) {
const item = (settings.manifest || []).find((c) => c.name === name);
out.push({ key: name, label: item?.label || CAP_BADGES[name] || name });
}
}
return out;
} catch {
return []; // settings store 未就绪(测试/降级):无徽标不阻断
}
}
/** QA 路径:把本次启用的会话能力注入助手 pipeline 块徽标区
* (block.capabilities 缺省时 PipelineCard 零渲染,既有消息无影响) */
export function injectQaCaps(aMsg) {
const caps = collectQaCaps();
if (!caps.length) return;
const pipe = (aMsg.blocks || []).find((b) => b.type === 'pipeline');
if (pipe) pipe.capabilities = caps;
}
/** 把归一化 QA 响应(complete 帧 payload)装配成块联合(不含 pipeline;FR-CHAT-013)
* BUG-R1-06 修复:上游 answer 是 markdown 字符串(PRD FR-CHAT-004),装配时必须经
* renderMarkdown(markdown-it + DOMPurify)转 HTML 后再生成 text 块——此前直接以原文
* 落块,导致答案以纯文本渲染。 */
export function qaToBlocks(payload) {
if (!payload || typeof payload !== 'object') return [];
const blocks = [];
if (payload.answer) blocks.push({ type: 'text', html: renderMarkdown(payload.answer) });
if (Array.isArray(payload.sources) && payload.sources.length) {
blocks.push({ type: 'sources', items: payload.sources, source_count: payload.source_count ?? payload.sources.length });
}
if (payload.graph && (payload.graph.nodes?.length || payload.graph.edges?.length)) {
blocks.push({ type: 'graph', nodes: payload.graph.nodes || [], edges: payload.graph.edges || [], chains: payload.graph.chains || payload.chains || [] });
}
if (payload.chains?.length && !payload.graph) {
blocks.push({ type: 'graph', nodes: [], edges: [], chains: payload.chains });
}
return blocks;
}
/** 新建一个流上下文(每会话独立) */
function newStreamCtx(sessionId, msgList) {
return {
sessionId,
msgList, // 该会话的消息数组引用(切走后流仍写入此数组)
controller: null, // AbortController
completedOnce: false, // 本轮是否已收到 complete
events: [], // progress 事件(阶段卡数据源)
aMsg: null, // 流式中的助手消息
checkpointTimer: null,
lastCheckpointAt: 0,
reveal: null, // 揭示动画 { text, aMsg } | null
abortAnn: null, // 本轮 abort 的标注意图('stopped' 等)
};
}
// 缺陷 #52:docgen 异步任务轮询注册表(模块级、非响应式)。
// 键 = task_id(同一会话可有多个任务,互不干扰);终态后自清。
// 轮询跨会话继续(生成期间用户可切走对话——本缺陷核心诉求),
// 完成/失败时若用户仍在产生该任务的会话则自动切文档 TAB。
const DOCGEN_TASK_POLL_MS = 2000;
const docgenTaskPolls = new Map(); // task_id → { timer, sessionId, aMsg }
// 论文撰写(paper-writer)写稿期轮询注册表:paper_id → {timer, sessionId}
const PAPER_POLL_MS = 2000;
const paperPolls = new Map();
export const useChatStore = defineStore('chat', {
state: () => ({
sessionId: null, // 当前会话 id(与 sessions.activeId 对应)
messages: [], // 当前会话消息(视图;与 sessionMsgs[sessionId] 同引用)
mode: 'ask', // ask | plan | search
loadingDetail: false, // 会话详情加载中(骨架屏)
loadError: null, // 详情加载失败 {status, code, message} | null
runningMsgId: null, // 正在流式消费的助手消息 id(本地索引)
lastResult: null, // 最近一次终态(ok/error + payload),供组件/测试观测
lastEvents: [], // 最近一轮 progress 事件(终态后保留,供 bench 阶段卡持续展示)
scrollSeq: 0, // 消息区滚动请求序号(「回到最新」按钮复用)
// ---- 2026-08-26 多流重构 ----
sessionMsgs: {}, // sessionId → 消息数组(切走不销毁,后台流继续写入)
streamCtx: {}, // sessionId → 流上下文(见 newStreamCtx)
}),
getters: {
/** 当前会话的流上下文(无则 null) */
_ctx() {
return this.streamCtx[this.sessionId] || null;
},
/** 有进行中的流(当前会话视角;作曲器禁用发送、按钮变停止) */
busy() {
return !!this._ctx;
},
/** 当前会话流式中的助手消息(B3 契约:currentMessage) */
currentMessage() {
return this._ctx?.aMsg || null;
},
/**
* 状态栏 4 个 agent 点的数据源消息(2026-09-12 statusbar 修复):
* 优先取流式中的 currentMessage;流已收口时(生信对话的 chat/stream 在
* plan_ready 后就关闭、ctx 被 _finishCtx 清理,而任务阶段此刻才真正运行),
* 回退到消息列表里仍持有活跃生信任务的最后一条助手消息——其派生 pipeline
* 块正被任务事件订阅(connectJobEvents onEvent → patchBioflowEvent →
* syncBioflowDotBlock)持续刷新。
*/
agentDotMessage() {
const live = this._ctx?.aMsg;
if (live) return live;
const msgs = this.messages || [];
const ACTIVE_JOB = new Set(['created', 'running', 'paused']);
for (let i = msgs.length - 1; i >= 0; i -= 1) {
const m = msgs[i];
if (!m || m.role !== 'assistant' || m.kind !== 'bioflow' || !Array.isArray(m.blocks)) continue;
const prog = m.blocks.find((b) => b && b.type === 'bioflow_progress');
if (prog && prog.jobId && ACTIVE_JOB.has(prog.jobStatus)) return m;
}
return null;
},
/** 当前会话的 progress 事件(B3 契约:阶段卡数据源)。
* 流式中取 ctx.events;终态后 ctx 已清,取 lastEvents 供 bench 持续展示。 */
currentEvents() {
return this._ctx?.events || this.lastEvents;
},
/** 揭示动画状态(当前会话) */
reveal() {
return this._ctx?.reveal || null;
},
/** 揭示动画期间当前消息是否处在流式状态 */
revealing() {
return !!this.reveal;
},
/** 会话总数(B1 StatusBar 消费;sessions store 不可用时回退 null) */
sessionCount() {
try {
return useSessionsStore().items.length;
} catch {
return null;
}
},
/** 是否有中断/错误的可重试消息 */
lastRetryable() {
return this.lastResult && this.lastResult.ok === false;
},
},
actions: {
setMode(mode) {
if (mode in MODES) this.mode = mode;
},
/** 切换会话(2026-08-26 重构):不再中断流。
* 保存当前视图 → 切到目标会话(有缓存拿实时数组,无缓存加载 detail)。
* 同步 sessions.activeId(BUG-R1-02)。 */
async selectSession(id) {
if (this.sessionId === id) return;
// 保存当前视图(若与缓存同引用则无操作)
if (this.sessionId) this.sessionMsgs[this.sessionId] = this.messages;
const sessions = useSessionsStore();
this.sessionId = id;
sessions.activeId = id; // ← 活动会话单一事实源
this.loadError = null;
this.lastResult = null;
if (this.sessionMsgs[id]) {
// 切回有缓存的会话:拿实时数组(若该会话有后台流,数组正被持续写入)
this.messages = this.sessionMsgs[id];
this.scrollSeq++;
hydrateBench();
} else {
this.messages = [];
await this.loadSession(id);
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) */
async loadSession(id) {
this.loadingDetail = true;
this.loadError = null;
try {
const sessions = useSessionsStore();
const data = await sessions.fetchDetail(id);
this.hydrateDetail(data);
} catch (err) {
this.loadError = { status: err.status ?? 0, code: err.code || 'UNKNOWN', message: err.message || '' };
this.messages = [];
throw err;
} finally {
this.loadingDetail = false;
}
},
/** 把后端 detail 转为本地运行时消息(user 无 blocks,用 text 渲染)
* 缺陷 #52:running/queued 的 docgen 消息块若含 task_id → 恢复任务轮询
* (页面刷新/会话恢复后进度继续驱动,不永久挂「生成中」)。 */
hydrateDetail(data) {
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 || '' }];
} else {
const blks = Array.isArray(msg.blocks) ? msg.blocks.map((b) => ({ ...b })) : [];
msg.blocks = blks.filter((b) => b.type !== 'stream'); // stream 瞬态不落库(防御)
// 中断恢复(FR-CHAT-008 刷新中途 / FR-CHAT-013):'interrupted'/'streaming'(检查点落库的
// 半成品)以及无标注的 error 一律置 error 并标「已中断」。
// 但若该会话有活跃后台流,则不标——实时数组会接管(切回时走缓存路径)。
const hasLiveCtx = !!this.streamCtx[this.sessionId];
// 缺陷 #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';
msg.streamErr = 'interrupted';
}
}
return msg;
});
this.sessionMsgs[this.sessionId] = this.messages;
this.lastResult = null;
this.scrollSeq++;
hydrateBench();
// r4 修复:恢复生信任务事件订阅(路径卡确认创建的任务;刷新后进度卡继续流转。
// 触发条件:assistant 消息含 bioflow_progress 块且 jobId 非空、任务未终态。
// 订阅的 onEvent 桥接把每帧事件 patch 进该消息的进度块(与 confirmBioflowPath 同款)。
try {
const bioflowStore = useBioflowStore();
const BIOFLOW_TERMINAL = new Set(['done', 'paused', 'archived', 'failed', 'error']);
for (const m of this.messages) {
if (!m || m.role !== 'assistant' || !Array.isArray(m.blocks)) continue;
const blk = m.blocks.find((b) => b && b.type === 'bioflow_progress');
if (!blk || !blk.jobId || BIOFLOW_TERMINAL.has(blk.jobStatus)) continue;
const jobId = blk.jobId;
bioflowStore.connectJobEvents(jobId, this.sessionId, {
onEvent: (type, data) => {
const frame = patchBioflowEvent(m, { type, data });
if (frame) this.scrollSeq++;
if (type === 'stage_await_confirm' && data) { patchBioflowForm(m, data); this.scrollSeq++; }
else if (type === 'stage_await_files' && data) { patchBioflowFile(m, data); this.scrollSeq++; }
else if (type === 'job_done' && data) {
const ab = patchBioflowArchiveSuggest(m, data.job_id || jobId);
if (ab) ab.jobId = data.job_id || jobId;
this.scrollSeq++;
}
},
});
// 2026-09-12 statusbar 修复(hydration):派生 pipeline 块是瞬态镜像不落库,
// 刷新恢复的消息没有它——此处从持久化的进度块 stages 重建,
// 后续阶段事件经 patchBioflowEvent → syncBioflowDotBlock 持续刷新。
syncBioflowDotBlock(m);
}
} catch { /* 生信未就绪容错 */ }
// 缺陷 #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() });
}
}
},
// ---------------------------------------------------------------
// send 全流程(FR-CHAT-008 时序 1–7)
// ---------------------------------------------------------------
async send(text, { mode = this.mode } = {}) {
const q = String(text == null ? '' : text).trim();
if (!q || this.busy) return;
const sessions = useSessionsStore();
if (!this.sessionId) {
try {
const sess = await sessions.create();
this.sessionId = sess.id;
} catch {
return; // 创建失败:列表页可见错误(失败必须可见)
}
}
if (this.sessionId && !sessions.items.some((s) => s.id === this.sessionId)) {
sessions.items.unshift({ id: this.sessionId, title: q.slice(0, 30), status: 'running', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), message_count: 0, meta: {} });
}
const sessionId = this.sessionId;
const msgList = this.messages;
this.sessionMsgs[sessionId] = msgList;
const ctx = newStreamCtx(sessionId, msgList);
this.streamCtx[sessionId] = ctx;
this.mode = mode;
this.lastResult = null;
// 1. 用户消息立即入列 + 落库(mode 记在消息上供 retry 复用,FR-CHAT-012「相同模式」)
const userMsgRaw = { id: null, role: 'user', kind: 'qa', status: 'done', text: q, mode, blocks: [{ type: 'text', html: q }], raw: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
msgList.push(userMsgRaw);
// 关键(2026-08-25 PM 修复):闭包必须持响应式代理而非 raw 对象,
// raw 突变不触发 Vue 渲染(详见 CHANGELOG 的响应式断链缺陷)
const userMsg = msgList[msgList.length - 1];
let userId = null;
try {
const saved = await createMessage(sessionId, { role: 'user', text: q });
userId = saved.id;
userMsg.id = userId;
} catch (err) {
// 用户消息落库失败:按失败可见原则,终止本轮并给出可重试错误
userMsg.persistError = err;
this._finishCtx(ctx, { ok: false, error: err, message: userMsg, silentAbort: false });
return;
}
// 2. 会话 running
sessions.patchLocal(sessionId, { status: 'running' });
try {
await updateSessionQuiet(sessionId, { status: 'running' });
} catch { /* 状态 PATCH 失败不阻断(终态会再同步) */ }
// 3. 助手骨架(pipeline 五相位 waiting)+ 落库
const aMsgRaw = newSkeletonMessage();
msgList.push(aMsgRaw);
// 关键(2026-08-25 PM 修复):见上,闭包必须持代理,SSE 相位突变才触发渲染
const aMsg = msgList[msgList.length - 1];
ctx.aMsg = aMsg;
this.runningMsgId = null;
try {
const skeleton = await createMessage(sessionId, {
role: 'assistant', kind: 'qa', status: 'streaming',
blocks: [{ type: 'pipeline', status: 'running', steps: aMsg.blocks[0].steps.map((st) => ({ ...st })) }],
});
aMsg.id = skeleton.id;
this.runningMsgId = skeleton.id;
} catch {
aMsg.id = null; // 骨架落库失败:终态用 replace 语义重建(防御,正常 mock/BFF 不会发生)
}
// P3:QA 路径注入本次启用的会话能力徽标 chips(send 其余逻辑零改动)
injectQaCaps(aMsg);
// 4–5. 打开流并按模式消费
const modeCfg = MODES[mode] || MODES.ask;
if (modeCfg.endpoint !== '/api/qa/search') {
this._setupCheckpoint(ctx); // search 无流:不设定时器(防止 fake timers 下泄漏间隔)
}
// FR-PRIV-003:ask/search 两模式均并行触发私有资料检索(fire-and-forget,
// 不 await;未命中/异常静默)
this._firePrivate(q, sessionId, aMsg);
if (modeCfg.endpoint === '/api/qa/search') {
await this._runSearch(q, modeCfg, aMsg, sessionId, userId, ctx);
} else {
await this._runStream(q, modeCfg, aMsg, sessionId, userId, ctx);
}
},
// ---------------------------------------------------------------
// 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, templateId } = {}) {
const q = String(description == null ? '' : description).trim();
if (!q || this.busy) return;
const sessions = useSessionsStore();
if (!this.sessionId) {
try {
const sess = await sessions.create();
this.sessionId = sess.id;
} catch {
return; // 创建失败:列表页可见错误(失败必须可见)
}
}
if (this.sessionId && !sessions.items.some((s) => s.id === this.sessionId)) {
sessions.items.unshift({ id: this.sessionId, title: q.slice(0, 30), status: 'running', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), message_count: 0, meta: {} });
}
const sessionId = this.sessionId;
const msgList = this.messages;
this.sessionMsgs[sessionId] = msgList;
const ctx = newStreamCtx(sessionId, msgList);
this.streamCtx[sessionId] = ctx;
this.lastResult = null;
const startAt = Date.now();
// 1. 用户消息入列 + 落库(retry 复用既有用户消息时跳过——同位置替换不新增用户消息)
let userId = null;
let userMsgRef = null;
if (userMsg) {
userMsgRef = userMsg;
userId = userMsg.id;
} else {
const userMsgRaw = { id: null, role: 'user', kind: 'docgen', status: 'done', text: q, mode: 'ask', meta: { library_ids: libraryIds || [] }, blocks: [{ type: 'text', html: q }], raw: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
msgList.push(userMsgRaw);
// 关键:闭包必须持响应式代理而非 raw 对象(见 send 同款修复)
userMsgRef = msgList[msgList.length - 1];
try {
const saved = await createMessage(sessionId, { role: 'user', text: q });
userId = saved.id;
userMsgRef.id = userId;
} catch (err) {
// 用户消息落库失败:按失败可见原则,终止本轮并给出可重试错误
userMsgRef.persistError = err;
this._finishCtx(ctx, { ok: false, error: err, message: userMsgRef, silentAbort: false });
return;
}
}
// 2. 会话 running
sessions.patchLocal(sessionId, { status: 'running' });
try {
await updateSessionQuiet(sessionId, { status: 'running' });
} catch { /* 状态 PATCH 失败不阻断(终态会再同步) */ }
// 3. docgen 助手骨架(pipeline 五步 + 文档生成徽标)+ 落库
const aMsgRaw = newDocgenSkeletonMessage();
msgList.push(aMsgRaw);
const aMsg = msgList[msgList.length - 1];
ctx.aMsg = aMsg;
this.runningMsgId = null;
try {
const skeleton = await createMessage(sessionId, {
role: 'assistant', kind: 'docgen', status: 'streaming',
blocks: [{ type: 'pipeline', title: DG_TITLE, status: 'running', capabilities: aMsg.blocks[0].capabilities, steps: aMsg.blocks[0].steps.map((st) => ({ ...st })) }],
});
aMsg.id = skeleton.id;
this.runningMsgId = skeleton.id;
} catch {
aMsg.id = null; // 骨架落库失败:终态用 replace 语义重建(防御)
}
// 4. 生成请求(缺陷 #52:兼容 200 同步 / 202 异步两种契约)
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;
}
this.scrollSeq++;
};
stepSet('planning', 'running');
let payload;
try {
// 缺陷 #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 标注(可重试)并释放 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((x) => x.state === 'running');
if (running) running.state = 'done';
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, {
ok: false,
error: { code: err.code || 'NETWORK', message: text, retryable: true },
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
});
return;
}
// ---- 缺陷 #52:202 异步任务化 ----
// 关键:生成期间必须释放 busy 锁(旧缺陷:同步 600s 阻塞导致无法继续对话)。
// 轮询注册表独立于 streamCtx——busy 只看 streamCtx,删除后即可继续发送消息。
const taskId = payload?.task_id || null;
const isAsync = !!(taskId && (payload.status === 'queued' || payload.status === 'running'));
if (isAsync) {
aMsg.raw = payload ?? null;
aMsg.task_id = taskId;
pipe.task_id = taskId; // 持久化进 blocks(会话恢复时据此续轮询)
pipe.taskStatus = payload.status || 'queued';
stepSet('planning', 'done'); // 请求已被受理:第一步收口
this._persistFinal(aMsg, sessionId, 'streaming', { raw: aMsg.raw }).catch(() => {});
// 释放 busy 锁:清理本会话流上下文(无流、无 checkpoint)。
// 会话状态保持 running(任务进行中),轮询由注册表驱动,终态时收口。
delete this.streamCtx[sessionId];
this.lastResult = null;
this.scrollSeq++;
this._startDocgenTaskPoll(taskId, sessionId, aMsg, { userMessageId: userId, startAt });
return;
}
// ---- 200 同步兼容路径(旧 BFF):行为与修复前一致 ----
const artifact = payload?.artifact || null;
pipe.status = 'done';
pipe.steps.forEach((s) => { s.state = 'done'; });
if (artifact) {
const art = { ...artifact };
const artId = art.artifact_id || art.id;
if (artId) art.artifact_id = artId;
aMsg.blocks.push({
type: 'artifact',
artifact_id: art.artifact_id,
title: art.title || '',
template_name: art.template_name || '',
status: art.status || 'done',
});
}
aMsg.status = 'done';
aMsg.raw = payload ?? null;
const dur = ((Date.now() - startAt) / 1000).toFixed(1) + 's';
try {
await this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw });
} catch (persistErr) {
// 落库失败不掩盖成功事实:本地消息保持 done,仅记录
console.warn('persistFinal failed after docgen:', persistErr);
}
// 产物接管给文档 TAB 并自动切过去(B3 setActiveTab 任意 key 可用;失败不阻断)
if (artifact) {
try {
const { useDocgenStore } = await import('../stores/docgen.js');
const dg = useDocgenStore();
// BUG-3 修复:后端 ArtifactOut 无 markdown 字段——adoptArtifact 在无本地内容时
// 自动拉取 content(docgen store 内部 fetchContent),切 TAB 后不会出现「内容不可用」。
await dg.adoptArtifact(artifact, {
initialMd: payload && typeof payload === 'object' && payload.markdown != null ? payload.markdown : '',
});
} catch { /* docgen store 未就绪不阻断 */ }
try {
const { useBenchStore } = await import('../stores/bench.js');
useBenchStore().setActiveTab('doc');
} catch { /* bench 未就绪不阻断 */ }
}
this._finishCtx(ctx, {
ok: true,
payload,
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
dur,
blocks: aMsg.blocks.length,
});
},
/**
* 缺陷 #52:docgen 异步任务轮询(2s 间隔)。
* - progress 驱动 pipeline「生成中 x/y 节」(DOCGEN_STEPS 固定五步:任务 running 后
* 前三步视为已过,generation 步 desc 显示节进度);
* - done → 复用现有 artifact 挂块 + 落库 + adoptArtifact + (当前会话时)自动切文档 TAB;
* - error → 标失败(中文可读 error)+ 可重试(retry() 走 docgen 路径);
* - 消息被删除(不在 sessionMsgs 列表)→ 不再落 UI,仅静默等待终态后清注册。
* - 轮询跨会话继续;终态落 UI 仅在用户仍停留在产生任务的会话时打扰性动作为自动切 TAB。
*/
_startDocgenTaskPoll(taskId, sessionId, aMsg, { userMessageId, startAt }) {
if (docgenTaskPolls.has(taskId)) return; // 会话恢复/重复触发防重入
const entry = { timer: null, sessionId, aMsg, startedAt: startAt || Date.now(), userMessageId: userMessageId || null };
docgenTaskPolls.set(taskId, entry);
const clear = () => {
if (entry.timer != null) clearTimeout(entry.timer);
if (docgenTaskPolls.get(taskId) === entry) docgenTaskPolls.delete(taskId);
};
const pipe = aMsg.blocks[0];
const stepSet = (phase, state, desc) => {
const st = pipe.steps.find((x) => x.phase === phase);
if (st) {
if (state) st.state = state;
if (desc != null) st.desc = desc;
}
};
const alive = () => {
const list = this.sessionMsgs[sessionId] || [];
return list.includes(aMsg);
};
const progressLabel = (task) => {
const pr = Array.isArray(task.progress) ? task.progress : [];
if (!pr.length) return null;
const doneN = pr.filter((x) => x && x.state === 'done').length;
return DG_LABELS.docgenGenProgress.replace('{n}', String(doneN)).replace('{m}', String(pr.length));
};
const tick = async () => {
if (!docgenTaskPolls.has(taskId) || docgenTaskPolls.get(taskId) !== entry) return;
let task = null;
try {
const data = await getTask(taskId);
task = (data && data.task) || null;
} catch {
task = null; // 瞬时网络失败:继续轮询(终态由后续 tick 收口)
}
if (!docgenTaskPolls.has(taskId) || docgenTaskPolls.get(taskId) !== entry) return;
if (!task) {
entry.timer = setTimeout(tick, DOCGEN_TASK_POLL_MS);
return;
}
const status = task.status || 'running';
if (status === 'queued') {
// 排队中:仅推进视觉(planning 已 done)
if (alive()) {
pipe.status = 'running';
stepSet('planning', 'done');
this.scrollSeq++;
}
entry.timer = setTimeout(tick, DOCGEN_TASK_POLL_MS);
return;
}
if (status === 'running') {
if (alive()) {
pipe.status = 'running';
pipe.taskStatus = 'running';
stepSet('planning', 'done');
stepSet('retrieval', 'done');
stepSet('evidence', 'done');
const label = progressLabel(task);
stepSet('generation', 'running', label || undefined);
this.scrollSeq++;
this._persistFinal(aMsg, sessionId, 'streaming', { raw: aMsg.raw }).catch(() => {});
}
entry.timer = setTimeout(tick, DOCGEN_TASK_POLL_MS);
return;
}
if (status === 'done') {
const artifact = task.artifact || null;
if (alive()) {
pipe.status = 'done';
pipe.taskStatus = 'done';
pipe.steps.forEach((st) => { st.state = 'done'; });
// r3-52-02:done 后还原 generation 步默认 desc(running 时被
// 「生成中 x/y 节」覆盖,终态不得残留进度文案)
const genStep = pipe.steps.find((st) => st.phase === 'generation');
if (genStep) genStep.desc = DOCGEN_STEPS.find((st) => st.phase === 'generation')?.desc || genStep.desc;
if (artifact) {
const art = { ...artifact };
const artId = art.artifact_id || art.id;
if (artId) art.artifact_id = artId;
aMsg.blocks.push({
type: 'artifact',
artifact_id: art.artifact_id,
title: art.title || '',
template_name: art.template_name || '',
status: art.status || 'done',
});
}
aMsg.status = 'done';
aMsg.raw = { task_id: taskId, task };
this.scrollSeq++;
this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw }).catch(() => {});
}
const sessions = useSessionsStore();
sessions.patchLocal(sessionId, { status: 'done' });
updateSessionQuiet(sessionId, { status: 'done' }).catch(() => {});
if (alive() && artifact) {
// 产物接管(仅消息仍在时):adoptArtifact + 当前会话才自动切文档 TAB
try {
const { useDocgenStore } = await import('../stores/docgen.js');
await useDocgenStore().adoptArtifact(artifact, { initialMd: '' });
} catch { /* docgen store 未就绪不阻断 */ }
if (this.sessionId === sessionId) {
try {
const { useBenchStore } = await import('../stores/bench.js');
useBenchStore().setActiveTab('doc');
} catch { /* bench 未就绪不阻断 */ }
}
}
if (this.sessionId === sessionId) {
this.lastResult = {
ok: true,
payload: { task_id: taskId, task },
message: aMsg,
sessionId,
userMessageId: entry.userMessageId,
assistantMessageId: aMsg.id,
dur: ((Date.now() - entry.startedAt) / 1000).toFixed(1) + 's',
blocks: aMsg.blocks.length,
};
this.scrollSeq++;
}
clear();
return;
}
if (status === 'error') {
if (alive()) {
const text = task.error || T.upstreamError;
aMsg.status = 'error';
aMsg.streamErr = 'message';
aMsg.errText = text;
pipe.status = 'error';
pipe.taskStatus = 'error';
const running = pipe.steps.find((st) => st.state === 'running');
if (running) running.state = 'done';
aMsg.raw = { task_id: taskId, task };
this.scrollSeq++;
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
}
const sessions = useSessionsStore();
sessions.patchLocal(sessionId, { status: 'error' });
updateSessionQuiet(sessionId, { status: 'error' }).catch(() => {});
if (this.sessionId === sessionId) {
this.lastResult = {
ok: false,
error: { code: 'DOCGEN_FAILED', message: task.error || T.upstreamError, retryable: true },
message: aMsg,
sessionId,
userMessageId: entry.userMessageId,
assistantMessageId: aMsg.id,
};
this.scrollSeq++;
}
clear();
return;
}
// 未知状态(防御):继续轮询
entry.timer = setTimeout(tick, DOCGEN_TASK_POLL_MS);
};
entry.timer = setTimeout(tick, DOCGEN_TASK_POLL_MS);
},
// ===============================================================
// 论文撰写(paper-writer):sendPaper —— 用户消息落库 → POST /api/paper/turns
// (短同步,BFF agent 直写助手消息并返回 blocks)→ 本地回填助手消息。
// 写稿期由 confirmPaper 触发后台循环,前端 2s 轮询 paperProgress 更新进度卡,
// complete 后重载会话取 agent 直写的 artifact 块并接管到文档 TAB。
// ===============================================================
async sendPaper(description, { userMsg, templateId, paperId } = {}) {
const q = String(description == null ? '' : description).trim();
if (!q || this.busy) return;
const sessions = useSessionsStore();
if (!this.sessionId) {
try {
const sess = await sessions.create();
this.sessionId = sess.id;
} catch {
return; // 创建失败:列表页可见错误
}
}
if (this.sessionId && !sessions.items.some((s) => s.id === this.sessionId)) {
sessions.items.unshift({ id: this.sessionId, title: q.slice(0, 30), status: 'running', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), message_count: 0, meta: {} });
}
const sessionId = this.sessionId;
const msgList = this.messages;
this.sessionMsgs[sessionId] = msgList;
const ctx = newStreamCtx(sessionId, msgList);
this.streamCtx[sessionId] = ctx;
this.lastResult = null;
// 1. 用户消息入列 + 落库
let userId = null;
let userMsgRef = null;
if (userMsg) {
userMsgRef = userMsg;
userId = userMsg.id;
} else {
const raw = { id: null, role: 'user', kind: 'paper', status: 'done', text: q, mode: 'ask', meta: {}, blocks: [{ type: 'text', html: q }], raw: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
msgList.push(raw);
userMsgRef = msgList[msgList.length - 1];
try {
const saved = await createMessage(sessionId, { role: 'user', text: q });
userId = saved.id;
userMsgRef.id = userId;
} catch (err) {
userMsgRef.persistError = err;
this._finishCtx(ctx, { ok: false, error: err, message: userMsgRef, silentAbort: false });
return;
}
}
sessions.patchLocal(sessionId, { status: 'running' });
try { await updateSessionQuiet(sessionId, { status: 'running' }); } catch { /* 状态 PATCH 失败不阻断 */ }
// 2. POST turns(短同步)
let payload;
try {
payload = await paperTurn({ session_id: sessionId, text: q, paper_id: paperId || null, template_key: templateId || '' });
} catch (err) {
const marker = this._errorMarker(err.code);
const text = err.message || (marker && marker.text) || T.upstreamError;
const raw = { id: null, role: 'assistant', kind: 'paper', status: 'error', streamErr: (marker && marker.ann) || 'message', errText: text, blocks: [{ type: 'text', html: text }], raw: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
msgList.push(raw);
const am = msgList[msgList.length - 1];
this._persistFinal(am, sessionId, 'error', {}).catch(() => {});
this._finishCtx(ctx, { ok: false, error: { code: err.code || 'NETWORK', message: text, retryable: true }, message: am, sessionId, userMessageId: userId, assistantMessageId: am.id });
return;
}
// 3. 助手消息(agent 已落库;本地回填 blocks)
const blocks = Array.isArray(payload && payload.blocks) ? payload.blocks : [];
const raw = { id: null, role: 'assistant', kind: 'paper', status: 'done', text: null, blocks: blocks.length ? blocks : [{ type: 'text', html: '(论文服务未返回内容)' }], raw: payload || null, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
msgList.push(raw);
const aMsg = msgList[msgList.length - 1];
ctx.aMsg = aMsg;
this.runningMsgId = null;
try {
const saved = await createMessage(sessionId, { role: 'assistant', kind: 'paper', status: 'done', blocks: raw.blocks });
aMsg.id = saved.id;
} catch {
aMsg.id = null;
}
delete this.streamCtx[sessionId]; // 释放 busy(无流)
this.lastResult = { ok: true, payload, message: aMsg, sessionId, userMessageId: userId, assistantMessageId: aMsg.id };
this.scrollSeq++;
},
/** 确认大纲 → 本地先 seed 进度卡 → POST confirm 启动后台写节循环 → 2s 轮询进度 */
async confirmPaper(paperId) {
const sessionId = this.sessionId;
if (!paperId || !sessionId) return;
this._seedPaperProgress(sessionId, paperId);
try {
await confirmPaperApi(paperId, sessionId);
} catch { /* 启动失败:卡片可见错误 */ }
this._startPaperPoll(paperId, sessionId);
},
/** 确认后立即在消息流上一条 paper_progress 块(用大纲节标题做种子,全部 pending),
* 使 _startPaperPoll 的 _patchPaperProgress 有块可 patch,进度卡在撰写期间逐节刷新,
* 而不是等 complete 重载时一次性出现。 */
_seedPaperProgress(sessionId, paperId) {
const list = this.sessionMsgs[sessionId] || this.messages || [];
for (const m of list) {
if (!m || m.role !== 'assistant' || !Array.isArray(m.blocks)) continue;
if (m.blocks.some((b) => b && b.type === 'paper_progress' && b.paper_id === paperId)) return;
}
let sections = [];
for (let i = list.length - 1; i >= 0; i--) {
const m = list[i];
if (!m || m.role !== 'assistant' || !Array.isArray(m.blocks)) continue;
const ob = m.blocks.find((b) => b && b.type === 'paper_outline' && b.paper_id === paperId);
if (ob) {
sections = (Array.isArray(ob.sections) ? ob.sections : []).map((sec) => ({
section_id: sec.section_id, title: sec.title || '', status: 'pending',
}));
break;
}
}
const raw = {
id: null, role: 'assistant', kind: 'paper', status: 'done', text: null,
blocks: [{ type: 'paper_progress', paper_id: paperId, status: 'writing', sections, current_section_id: '' }],
raw: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
};
list.push(raw);
this.scrollSeq++;
},
_startPaperPoll(paperId, sessionId) {
if (paperPolls.has(paperId)) return;
const entry = { timer: null, sessionId };
paperPolls.set(paperId, entry);
const clear = () => {
if (entry.timer != null) clearTimeout(entry.timer);
if (paperPolls.get(paperId) === entry) paperPolls.delete(paperId);
};
const tick = async () => {
if (!paperPolls.has(paperId) || paperPolls.get(paperId) !== entry) return;
let prog = null;
try {
prog = await paperProgressApi(paperId);
} catch {
prog = null; // 瞬时失败:继续轮询
}
if (!paperPolls.has(paperId) || paperPolls.get(paperId) !== entry) return;
if (prog && prog.ok) {
this._patchPaperProgress(sessionId, prog);
if (prog.status === 'complete' || prog.status === 'cancelled' || prog.status === 'error') {
clear();
if (prog.status === 'complete') {
try {
await this.loadSession(sessionId); // 重载取 agent 直写的 artifact 块
await this._adoptPaperArtifact(sessionId);
} catch { /* 重载失败不阻断 */ }
}
return;
}
}
entry.timer = setTimeout(tick, PAPER_POLL_MS);
};
entry.timer = setTimeout(tick, PAPER_POLL_MS);
},
/** 把进度同步进消息流里的 paper_progress 块 */
_patchPaperProgress(sessionId, prog) {
const list = this.sessionMsgs[sessionId] || [];
const current = prog.current_section_id || '';
for (const m of list) {
if (!m || m.role !== 'assistant' || !Array.isArray(m.blocks)) continue;
const pb = m.blocks.find((b) => b && b.type === 'paper_progress');
if (pb) {
pb.status = prog.status;
pb.current_section_id = current;
// 后端节状态只有 pending/done;当前节用 current_section_id 标为 writing(…动画)
pb.sections = (prog.sections || []).map((sec) => ({
section_id: sec.section_id, title: sec.title,
status: (current && sec.section_id === current) ? 'writing' : sec.status,
}));
}
}
this.scrollSeq++;
},
/** 找到最新含 paper_id 的 artifact 块并接管到文档 TAB */
async _adoptPaperArtifact(sessionId) {
const list = this.sessionMsgs[sessionId] || [];
for (const m of list) {
if (!m || m.role !== 'assistant' || !Array.isArray(m.blocks)) continue;
const art = m.blocks.find((b) => b && b.type === 'artifact' && b.paper_id);
if (art) {
try {
const { useDocgenStore } = await import('../stores/docgen.js');
await useDocgenStore().adoptArtifact(art, { initialMd: '' });
} catch { /* docgen store 未就绪不阻断 */ }
if (this.sessionId === sessionId) {
try {
const { useBenchStore } = await import('../stores/bench.js');
useBenchStore().setActiveTab('doc');
} catch { /* bench 未就绪不阻断 */ }
}
return;
}
}
},
// ---------------------------------------------------------------
// sendBioflow(P2):生信对话式编排独立分支——用户消息落库(既有机制)→
// 生信骨架(path 块)→ POST /api/bioflow/chat/stream(SSE 经 sse/parser.js
// 消费,与既有 parser 完全复用)→ 事件按更新器 patch 消息 blocks →
// 阶段态订阅(FR-BIO-014:断线重连首帧快照整体刷新由本动作处理)。
// send()/_runStream/sendDocgen 零改动;不命中词典一律不进此分支(漏判安全)。
// ---------------------------------------------------------------
/**
* 进度条点击定位:请求 bioflow 面板定位 + 切右侧生信 TAB。
* BioJobsPane 消费 panelLocate(nonce 变化)后 scrollIntoView + 展开 log。
*/
locateBioflowStage({ jobId, stage }) {
const bioflow = useBioflowStore();
bioflow.requestPanelLocate({ jobId, stage });
// 切 TAB 经 bench store(与 docgen 同约定;动态 import 防环)
import('../stores/bench.js').then(({ useBenchStore }) => {
useBenchStore().setActiveTab('biojobs');
}).catch(() => {});
},
async sendBioflow(text, { userMsg, mode = 'ask', reuseName = null } = {}) {
const q = String(text == null ? '' : text).trim();
if (!q || this.busy) return;
const sessions = useSessionsStore();
if (!this.sessionId) {
try {
const sess = await sessions.create();
this.sessionId = sess.id;
} catch {
return;
}
}
if (this.sessionId && !sessions.items.some((s) => s.id === this.sessionId)) {
sessions.items.unshift({ id: this.sessionId, title: q.slice(0, 30), status: 'running', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), message_count: 0, meta: {} });
}
const sessionId = this.sessionId;
const msgList = this.messages;
this.sessionMsgs[sessionId] = msgList;
const ctx = newStreamCtx(sessionId, msgList);
this.streamCtx[sessionId] = ctx;
this.lastResult = null;
const startAt = Date.now();
// 1. 用户消息入列 + 落库(retry 复用既有用户消息时跳过——同位置替换)
let userId = null;
let userMsgRef = null;
if (userMsg) {
userMsgRef = userMsg;
userId = userMsg.id;
} else {
const userMsgRaw = { id: null, role: 'user', kind: 'bioflow', status: 'done', text: q, mode, meta: { bioflow: true }, blocks: [{ type: 'text', html: q }], raw: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
msgList.push(userMsgRaw);
userMsgRef = msgList[msgList.length - 1]; // 持代理(响应式断链陷阱)
try {
const saved = await createMessage(sessionId, { role: 'user', text: q });
userId = saved.id;
userMsgRef.id = userId;
} catch (err) {
userMsgRef.persistError = err;
this._finishCtx(ctx, { ok: false, error: err, message: userMsgRef, silentAbort: false });
return;
}
}
// 2. 会话 running
sessions.patchLocal(sessionId, { status: 'running' });
try {
await updateSessionQuiet(sessionId, { status: 'running' });
} catch { /* 状态 PATCH 失败不阻断 */ }
// 3. 生信骨架消息(path 块等待候选)+ 落库
const aMsgRaw = newBioflowSkeletonMessage();
msgList.push(aMsgRaw);
const aMsg = msgList[msgList.length - 1]; // 持代理
ctx.aMsg = aMsg;
this.runningMsgId = null;
try {
const skeleton = await createMessage(sessionId, {
role: 'assistant', kind: 'bioflow', status: 'streaming',
blocks: [{ type: 'bioflow_path', status: 'running', candidates: [] }],
});
aMsg.id = skeleton.id;
this.runningMsgId = skeleton.id;
} catch {
aMsg.id = null; // 骨架落库失败:终态 replace 语义重建(防御)
}
// 4. 事件流(chat/stream 规划入口;任务事件经 /jobs/{id}/events 订阅)
const bioflow = useBioflowStore();
bioflow.resetSession();
// 事件 patch 前保证 path 块引用稳定(computed 消费时缺块即建)
let jobEventsOpened = false;
let lastJobSeq = 0;
const openJobEvents = (jobId) => {
if (jobEventsOpened || !jobId) return;
jobEventsOpened = true;
// 订阅任务事件流:首帧快照整体刷新进度块(FR-BIO-014 断线重连回放)
try {
bioflow.connectJobEvents(jobId, sessionId, {
onOpen: () => { /* EventSource 内建重连;重连后快照自动整体刷新 */ },
});
} catch { /* EventSource 不可用(降级/测试):事件仍可由快照兜底 */ }
// 快照整体刷新 → 消息进度块同步(订阅后立即拉一次快照应用)
};
// 从 bioflow store 当前任务快照刷新消息进度块(断线重连回放兜底路径)
const syncProgressFromStore = () => {
const st = bioflow.currentJob;
if (!st || !st.jobId) return;
patchBioflowSnapshot(aMsg, {
job_id: st.jobId,
job: { status: st.jobStatus },
stages: st.stages,
last_seq: st.lastSeq,
});
};
const controller = new AbortController();
ctx.controller = controller;
let ended = false;
const endTask = () => {
ended = true;
if (ctx.controller === controller) ctx.controller = null;
this._stopCheckpoint(ctx);
};
const applyFrame = (ev) => {
if (ended) return;
const type = ev.event || 'message';
const data = ev.data || {};
if (type === 'plan_ready') {
patchBioflowPlan(aMsg, data);
bioflow.applyChatEvent('plan_ready', data);
this.scrollSeq++;
} else if (type === 'planning') {
patchBioflowPlanning(aMsg, data);
this.scrollSeq++;
} else if (type === 'job_created') {
patchBioflowEvent(aMsg, { type, data });
bioflow.applyJobEvent({ type, data });
openJobEvents(data.job_id);
this.scrollSeq++;
} else if (type === 'stage_await_confirm') {
patchBioflowEvent(aMsg, { type, data });
bioflow.applyJobEvent({ type, data });
patchBioflowForm(aMsg, data);
this.scrollSeq++;
} else if (type === 'stage_await_files') {
patchBioflowEvent(aMsg, { type, data });
bioflow.applyJobEvent({ type, data });
patchBioflowFile(aMsg, data);
this.scrollSeq++;
} else if (type === 'job_done') {
patchBioflowEvent(aMsg, { type, data });
bioflow.applyJobEvent({ type, data });
const blk = patchBioflowArchiveSuggest(aMsg, data.job_id || aMsg.jobId);
if (blk) blk.jobId = data.job_id || blk.jobId;
// P3 报告登记(FR-BIO-016):落 doc_artifacts 供 DocPane 预览;
// 登记失败不阻断对话(报告仍可直连下载 HTML)
bioflow.registerReport({
jobId: data.job_id || bioflow.currentJob.jobId,
sessionId,
title: bioflow.currentJob.title || '生信分析报告',
reportPath: (data.report_path || ''),
figures: Array.isArray(data.figures) ? data.figures : [],
}).catch(() => {});
this.scrollSeq++;
} else if (type === 'job_paused' || type === 'job_archived') {
patchBioflowEvent(aMsg, { type, data });
bioflow.applyJobEvent({ type, data });
this.scrollSeq++;
} else if (type === 'message_done') {
// 生信流收尾:非生信/流程咨询分支(bioflow_qa 引用块 / qa 转交)
endTask();
if (data && data.kind === 'flow_qa') {
patchBioflowQa(aMsg, { question: q, citations: data.citations, degraded: data.degraded });
}
updateLocalDone();
return;
} else if (type === 'error') {
// 生信服务错误帧(统一信封形态):标注可重试
endTask();
// 派生点收口:当前运行相位置 error(防点永久 running)
const dotBlk = (aMsg.blocks || []).find((b) => b && b.type === 'pipeline' && b.derived);
if (dotBlk) {
for (const s of dotBlk.steps || []) {
if (s.state === 'running') s.state = 'error';
}
}
const code = (data && data.code) || 'STREAM_ERROR';
const message = (data && data.message) || BF.bioflowRouteError;
updateLocal(() => {
aMsg.status = 'error';
aMsg.streamErr = 'message';
aMsg.errText = message;
});
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, {
ok: false,
error: { code, message, retryable: true },
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
});
return;
} else if (['stage_started', 'stage_log', 'stage_progress', 'stage_done', 'stage_failed', 'stage_skipped', 'job_recovered'].includes(type)) {
// 会话级串流场景的事件也按任务事件处理(P1 契约:规划入口心跳流;
// 任务事件主要在 jobs/{id}/events 上,这里双保险不丢事件)
patchBioflowEvent(aMsg, { type, data });
bioflow.applyJobEvent({ type, data });
this.scrollSeq++;
} else if (applyExtensionFrame(aMsg, type, data)) {
// 用户自定义扩展分析(EXTENSION_CONTRACT.md §4):extension_* 帧 → 引导卡块
this.scrollSeq++;
}
// keepalive / 未知事件:忽略
};
const updateLocal = (fn) => {
if (ctx.msgList.includes(aMsg)) fn();
};
const updateLocalDone = () => {
updateLocal(() => {
aMsg.status = 'done';
});
};
try {
const res = await bioflowChatStream(
{
session_id: sessionId,
message: q,
has_attachments: false,
has_active_artifact: this._hasActiveArtifactSafe(),
has_bioflow_context: this._sessionHasBioflowContext(sessionId),
last_seq: bioflow.currentJob.lastSeq || null,
},
{ signal: controller.signal },
);
await consumeSseStream(res, applyFrame);
// 流自然关闭:job_done 之后服务端关闭 / 心跳流断连(任务仍存活于服务端)
if (!ended) {
endTask();
// 有任务但无终态:订阅仍在(jobEventsSource),进度块随快照刷新;
// 消息状态按已收事件收口。
updateLocal(() => {
const blk = (aMsg.blocks || []).find((b) => b.type === 'bioflow_progress');
if (blk) {
aMsg.status = 'done';
} else if (!(aMsg.blocks || []).some((b) => b.type === 'bioflow_qa')) {
// 无任务(纯规划心跳断连):path 卡保留,收口状态
aMsg.status = 'done';
}
});
const dur = ((Date.now() - startAt) / 1000).toFixed(1) + 's';
try {
await this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw });
} catch (persistErr) {
console.warn('persistFinal failed after bioflow stream:', persistErr);
}
this._finishCtx(ctx, {
ok: true,
payload: bioflow.currentJob,
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
dur,
});
}
} catch (err) {
if (ended) return; // 已被某个帧处理(message_done/error)
endTask();
const isAbort = err && (err.name === 'AbortError' || err.code === 'ERR_CANCELED');
if (isAbort) {
// 用户停止(FR-CHAT-011 语义):保留已收块
updateLocal(() => {
aMsg.status = 'error';
aMsg.streamErr = 'stopped';
});
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, {
ok: false,
error: { code: 'ABORTED', message: T.stopped, retryable: false },
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
});
return;
}
if (!isAbort && err && typeof err.code === 'string' && err.code !== 'NETWORK') {
// 统一错误信封(UPSTREAM_DOWN/INTERNAL 等)透传后端 message
const text = err.message || BF.bioflowRouteError;
updateLocal(() => {
aMsg.status = 'error';
aMsg.streamErr = 'message';
aMsg.errText = text;
});
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, {
ok: false,
error: { code: err.code, message: text, retryable: true },
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
});
return;
}
// 网络中断:任务仍存活于服务端;重连回放由消息进度块 + 订阅快照兜底
updateLocal(() => {
aMsg.status = 'error';
aMsg.streamErr = 'disconnected';
});
syncProgressFromStore();
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, {
ok: false,
error: { code: 'NETWORK', message: T.disconnected, retryable: true },
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
});
}
},
/** 会话是否有生信上下文(当前会话消息含 bioflow 块;用于 chat/stream has_bioflow_context) */
_sessionHasBioflowContext(sessionId) {
const list = this.sessionMsgs[sessionId] || this.messages || [];
return list.some((m) => m && m.role === 'assistant' && Array.isArray(m.blocks)
&& m.blocks.some((b) => b && typeof b.type === 'string' && b.type.startsWith('bioflow')));
},
// ---------------------------------------------------------------
// 用户自定义扩展分析:对话引导流(EXTENSION_CONTRACT.md §4/§5)
// 入口 = 生信 chat/stream(A 层创建意图/歧义消解由服务端 intent.py 判定,
// 前端 intent.js 词典与后端同表双跑,漏判安全);返回帧为 extension_* 事件,
// 经 applyExtensionFrame 逐帧 patch 引导卡块(上传卡 → 转换卡 → 试运行卡 → …)。
// 块更新器与 patchBioflow* 同幂等纪律,供流消费与历史恢复复用。
// ---------------------------------------------------------------
/** 新建一条扩展分析骨架消息:上传引导卡等待会话扩展(extension_upload 事件回填) */
newExtensionSkeletonMessage() {
return {
id: null,
role: 'assistant',
kind: 'bioflow', // 会话上下文/恢复判定沿用生信消息(扩展是生信域内并行链路)
status: 'streaming',
streamErr: null,
interrupted: false,
blocks: [
{
type: 'extension_upload',
status: 'idle',
displayName: '',
command: '',
runtimeImage: 'bioflow-gf',
extId: null,
golden: null,
error: null,
files: [],
},
{
type: 'pipeline',
derived: true,
status: 'running',
steps: AGENT_DOT_PHASES.map((phase) => ({
name: PHASE_LABEL[phase],
state: 'waiting',
desc: PHASE_DESC[phase],
phase,
})),
},
],
raw: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
};
},
/** 发送扩展分析消息(与 sendBioflow 同模式:用户消息落库 → 扩展骨架 → chat/stream
* SSE → applyExtensionFrame 分派 → 终态收口)。发流失败时骨架按失败可见原则标注可重试。 */
async sendExtension(text, { userMsg, mode = 'ask' } = {}) {
const q = String(text == null ? '' : text).trim();
if (!q || this.busy) return;
const sessions = useSessionsStore();
if (!this.sessionId) {
try {
const sess = await sessions.create();
this.sessionId = sess.id;
} catch {
return;
}
}
if (this.sessionId && !sessions.items.some((s) => s.id === this.sessionId)) {
sessions.items.unshift({ id: this.sessionId, title: q.slice(0, 30), status: 'running', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), message_count: 0, meta: {} });
}
const sessionId = this.sessionId;
const msgList = this.messages;
this.sessionMsgs[sessionId] = msgList;
const ctx = newStreamCtx(sessionId, msgList);
this.streamCtx[sessionId] = ctx;
this.lastResult = null;
const startAt = Date.now();
// 1. 用户消息入列 + 落库(retry 复用既有用户消息时跳过——同位置替换)
let userId = null;
let userMsgRef = null;
if (userMsg) {
userMsgRef = userMsg;
userId = userMsg.id;
} else {
const userMsgRaw = { id: null, role: 'user', kind: 'bioflow', status: 'done', text: q, mode, meta: { extension: true }, blocks: [{ type: 'text', html: q }], raw: null, created_at: new Date().toISOString(), updated_at: new Date().toISOString() };
msgList.push(userMsgRaw);
userMsgRef = msgList[msgList.length - 1]; // 持代理(响应式断链陷阱)
try {
const saved = await createMessage(sessionId, { role: 'user', text: q });
userId = saved.id;
userMsgRef.id = userId;
} catch (err) {
userMsgRef.persistError = err;
this._finishCtx(ctx, { ok: false, error: err, message: userMsgRef, silentAbort: false });
return;
}
}
// 2. 会话 running
sessions.patchLocal(sessionId, { status: 'running' });
try {
await updateSessionQuiet(sessionId, { status: 'running' });
} catch { /* 状态 PATCH 失败不阻断 */ }
// 3. 扩展骨架消息(上传引导卡等待 extension_upload 帧)+ 落库
const aMsgRaw = this.newExtensionSkeletonMessage();
msgList.push(aMsgRaw);
const aMsg = msgList[msgList.length - 1]; // 持代理
ctx.aMsg = aMsg;
this.runningMsgId = null;
try {
const skeleton = await createMessage(sessionId, {
role: 'assistant', kind: 'bioflow', status: 'streaming',
blocks: [{ type: 'extension_upload', status: 'idle', extId: null, files: [] }],
});
aMsg.id = skeleton.id;
this.runningMsgId = skeleton.id;
} catch {
aMsg.id = null; // 骨架落库失败:终态用 replace 语义重建(防御)
}
// 4. 事件流(复用生信 chat/stream 规划入口;extension_* 帧经 applyExtensionFrame 分派)
const controller = new AbortController();
ctx.controller = controller;
let ended = false;
const endTask = () => {
ended = true;
if (ctx.controller === controller) ctx.controller = null;
this._stopCheckpoint(ctx);
};
const updateLocal = (fn) => {
if (ctx.msgList.includes(aMsg)) fn();
};
const applyFrame = (ev) => {
if (ended) return;
const type = ev.event || 'message';
const data = ev.data || {};
if (type === 'message_done') {
// 终态收口:与 sendBioflow 同纪律(消息终态、ctx 清理、busy 释放)
endTask();
updateLocal(() => { aMsg.status = 'done'; });
this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, {
ok: true,
payload: null,
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
dur: ((Date.now() - startAt) / 1000).toFixed(1) + 's',
});
return;
}
if (type === 'error') {
// 统一错误信封形态:标注可重试(失败必须可见)
endTask();
const dotBlk = (aMsg.blocks || []).find((b) => b && b.type === 'pipeline' && b.derived);
if (dotBlk) {
for (const s of dotBlk.steps || []) {
if (s.state === 'running') s.state = 'error';
}
}
const code = (data && data.code) || 'STREAM_ERROR';
const message = (data && data.message) || BF.bioflowRouteError;
updateLocal(() => {
aMsg.status = 'error';
aMsg.streamErr = 'message';
aMsg.errText = message;
});
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, {
ok: false,
error: { code, message, retryable: true },
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
});
return;
}
if (applyExtensionFrame(aMsg, type, data)) {
// 扩展事件收口时(发布/运行终态)推进派生 pipeline 点,避免永久 waiting
if (type === 'extension_published') {
const dotBlk = (aMsg.blocks || []).find((b) => b && b.type === 'pipeline' && b.derived);
if (dotBlk) dotBlk.steps.forEach((s) => { if (s.state === 'waiting') s.state = 'done'; });
}
this.scrollSeq++;
}
// keepalive / 未知事件:忽略
};
try {
const res = await bioflowChatStream(
{
session_id: sessionId,
message: q,
has_attachments: false,
has_active_artifact: this._hasActiveArtifactSafe(),
has_bioflow_context: this._sessionHasBioflowContext(sessionId),
last_seq: null,
},
{ signal: controller.signal },
);
await consumeSseStream(res, applyFrame);
if (!ended) {
endTask();
updateLocal(() => { aMsg.status = 'done'; });
const dur = ((Date.now() - startAt) / 1000).toFixed(1) + 's';
try {
await this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw });
} catch (persistErr) {
console.warn('persistFinal failed after extension stream:', persistErr);
}
this._finishCtx(ctx, {
ok: true,
payload: null,
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
dur,
});
}
} catch (err) {
if (ended) return; // 已被某个帧处理(message_done/error)
endTask();
const isAbort = err && (err.name === 'AbortError' || err.code === 'ERR_CANCELED');
if (isAbort) {
updateLocal(() => {
aMsg.status = 'error';
aMsg.streamErr = 'stopped';
});
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, {
ok: false,
error: { code: 'ABORTED', message: T.stopped, retryable: false },
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
});
return;
}
if (!isAbort && err && typeof err.code === 'string' && err.code !== 'NETWORK') {
const text = err.message || BF.bioflowRouteError;
updateLocal(() => {
aMsg.status = 'error';
aMsg.streamErr = 'message';
aMsg.errText = text;
});
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, {
ok: false,
error: { code: err.code, message: text, retryable: true },
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
});
return;
}
// 网络中断:按中断标注(引导卡块已收内容保留,可重试)
updateLocal(() => {
aMsg.status = 'error';
aMsg.streamErr = 'disconnected';
});
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, {
ok: false,
error: { code: 'NETWORK', message: T.disconnected, retryable: true },
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
});
}
},
/** 上传扩展 zip 并创建(ExtensionUploadCard 提交;FormData multipart → POST /extensions)。
* 结果 patch 进当前会话最近一条 extension_upload 块(上传/创建失败可见可重试)。 */
async uploadExtensionFile(blk, { kind = 'archive', zip, dirFiles = [], dirRels = [], displayName, command, runtimeImage, golden }, { onProgress } = {}) {
const { createExtension } = await import('../api/bioflow.js');
const form = new FormData();
if (kind === 'dir') {
// 目录形态:多文件直传,filename 携带相对路径(后端按路径落盘并自适应分类)
dirFiles.forEach((f, i) => {
const rel = (dirRels && dirRels[i]) || (f.webkitRelativePath || f.name || '');
form.append('code_files', f, rel);
});
} else if (zip) {
// 归档形态:zip/tar/tar.gz 原样上传,服务端解压(2026-09-14 修复字段名 file→code_zip)
form.append('code_zip', zip, zip.name || 'extension.zip');
}
form.append('display_name', String(displayName || '').trim());
form.append('command', String(command || '').trim());
if (runtimeImage) form.append('runtime_image', String(runtimeImage));
if (golden && golden instanceof Blob) {
// golden 修复:文件本身走 test_file;golden 字段传 JSON 字符串(后端期望 str)
form.append('test_file', golden, golden.name || 'golden');
form.append('golden', JSON.stringify({ test_input: golden.name || 'golden' }));
}
const itemName = kind === 'dir' ? (dirFiles.length + ' 个文件') : (zip ? zip.name : 'extension');
const itemSize = kind === 'dir'
? dirFiles.reduce((s, f) => s + (f.size || 0), 0)
: (zip ? zip.size : 0);
const item = { name: itemName, size: itemSize || 0, status: 'uploading', progress: 0 };
if (blk && !(blk.files || []).some((f) => f.name === item.name)) blk.files = [...(blk.files || []), item];
try {
const data = await createExtension(form, {
onUploadProgress: (e) => {
item.progress = e.pct;
if (typeof onProgress === 'function') onProgress(e.pct, item);
},
});
item.status = 'done';
item.progress = 1;
const result = (data && data.data) || data || {};
if (blk) {
if (!blk.extId) blk.extId = result.ext_id || result.id || null;
if (blk.extId) blk.status = 'created';
if (result.display_name) blk.displayName = result.display_name;
}
return result;
} catch (err) {
item.status = 'error';
item.error = err?.message || '';
throw err;
}
},
/** 试运行卡提交测试输入文件(可选金标输出)→ POST /extensions/{id}/trial-run。
* 返回登记结果;上传进度经 onUploadProgress 透传(卡内展示)。 */
async trialRunExtension(extId, files, { onProgress } = {}) {
const { trialRunExtension: apiTrial } = await import('../api/bioflow.js');
const form = new FormData();
for (const f of files) form.append('test_inputs', f, f.name || 'test_input');
const data = await apiTrial(extId, form, { timeout: 0, onUploadProgress: onProgress });
return (data && data.data) || data || {};
},
/** 当前会话最近一条 extension_upload 块(供上传进度/结果 patch) */
_latestExtensionUploadBlock() {
const list = this.messages || [];
for (let i = list.length - 1; i >= 0; i--) {
const m = list[i];
if (m && m.role === 'assistant' && Array.isArray(m.blocks)) {
const blk = m.blocks.find((b) => b && b.type === 'extension_upload');
if (blk) return { aMsg: m, blk };
}
}
return null;
},
// ---------------------------------------------------------------
// P2 生信文件:上传(XHR 进度)与服务器路径登记(FR-BIO-006/007)。
// 供 FlowFileCard 与 Composer 拖拽分支共用;进度/结果 patch 到当前会话
// 最近一条 bioflow_file 块的 files 列表(失败可见,可重试)。
// ---------------------------------------------------------------
/** 找到当前会话最近一条 bioflow_file 块(供上传进度 patch) */
_latestBioflowFileBlock() {
const list = this.messages || [];
for (let i = list.length - 1; i >= 0; i--) {
const m = list[i];
if (m && m.role === 'assistant' && Array.isArray(m.blocks)) {
const blk = m.blocks.find((b) => b && b.type === 'bioflow_file');
if (blk) return { aMsg: m, blk };
}
}
return null;
},
/** 路径卡「确认创建任务」(r4 修复:修复前 FlowPathCard.confirmCreate 自调
* createJobFromPlan 后 emit('updated') 无人监听——任务 201 创建成功但 UI 静默:
* 无进度卡、无任务事件订阅、不落库(刷新即丢)。此处把
* 「创建 → 挂进度块 → 订阅任务事件(桥接推进消息块)→ 落库」收拢成一站式。
* 返回 bioflow store 的 job detail(与 createJobFromPlan 同形,卡片可继续用)。 */
async confirmBioflowPath({ title, stages, params, autoContinue, archiveId, aMsg, userMessageId }) {
const bioflow = useBioflowStore();
const detail = await bioflow.createJobFromPlan({
sessionId: this.sessionId || '',
title: title || '生信任务',
stages,
params: params || {},
autoContinue: !!autoContinue,
archiveId: archiveId || null,
});
const jobId = (detail && detail.id) || null;
if (!jobId || !aMsg) return detail;
// 1. 路径卡落定:path 块打 jobId + selected 固化(复用刷新后仍可确认态)
const pathBlk = (aMsg.blocks || []).find((b) => b && b.type === 'bioflow_path');
if (pathBlk) {
pathBlk.jobId = jobId;
if (typeof pathBlk.selected !== 'number') pathBlk.selected = 0;
}
// 2. 进度块:以 job_created 事件形态挂(stage 状态 waiting,jobStatus created)
patchBioflowEvent(aMsg, { type: 'job_created', data: { job_id: jobId, auto_continue: !!autoContinue, stages: detail.stages || [] } });
// 3. 订阅任务事件并把每帧桥接进消息进度块(与 sendBioflow 流内 openJobEvents 同源)
try {
bioflow.connectJobEvents(jobId, this.sessionId || '', {
onEvent: (type, data, seq) => {
const frame = patchBioflowEvent(aMsg, { type, data });
if (frame) this.scrollSeq++;
if (type === 'stage_await_confirm' && data) {
patchBioflowForm(aMsg, data);
this.scrollSeq++;
} else if (type === 'stage_await_files' && data) {
patchBioflowFile(aMsg, data);
this.scrollSeq++;
} else if (type === 'job_done' && data) {
const blk = patchBioflowArchiveSuggest(aMsg, data.job_id || jobId);
if (blk) blk.jobId = data.job_id || jobId;
bioflow.registerReport({
jobId: data.job_id || jobId,
sessionId: this.sessionId || '',
title: bioflow.currentJob.title || '生信分析报告',
reportPath: data.report_path || '',
figures: Array.isArray(data.figures) ? data.figures : [],
}).catch(() => {});
this.scrollSeq++;
}
},
});
} catch { /* EventSource 不可用:任务事件仍由快照兜底 */ }
// 4. 落库(刷新后进度卡不丢;jobId 随块持久化)
await this._persistFinal(aMsg, this.sessionId, 'streaming', { raw: aMsg.raw }).catch(() => {});
this.scrollSeq++;
return detail;
},
/** 上传文件到生信任务(XHR 进度;FR-BIO-006)。返回登记结果。 */
async uploadBioflowFileToJob(jobId, file, { onProgress } = {}) {
const { uploadBioflowFile } = await import('../api/bioflow.js');
const form = new FormData();
form.append('file', file, file.name || 'upload');
const found = this._latestBioflowFileBlock();
const item = { name: file.name || 'upload', size: file.size || 0, status: 'uploading', progress: 0 };
if (found) {
found.blk.files = [...(found.blk.files || []), item];
this.scrollSeq++;
}
try {
const data = await uploadBioflowFile(jobId, form, {
onUploadProgress: (e) => {
item.progress = e.pct;
if (typeof onProgress === 'function') onProgress(e.pct, item);
},
});
item.status = 'done';
item.progress = 1;
item.fileId = (data && data.data && data.data.file_id) || null;
if (found) this.scrollSeq++;
return (data && data.data) || data || {};
} catch (err) {
item.status = 'error';
item.error = err?.message || BF.fileUploadError;
if (found) this.scrollSeq++;
throw err;
}
},
/** 服务器路径登记(FR-BIO-007)。返回登记结果。 */
async registerBioflowPath(jobId, serverPath) {
const { uploadBioflowFile } = await import('../api/bioflow.js');
const data = await uploadBioflowFile(jobId, { server_path: serverPath });
const result = (data && data.data) || data || {};
const found = this._latestBioflowFileBlock();
if (found) {
found.blk.files = [...(found.blk.files || []), {
name: result.name || serverPath,
size: result.size || 0,
status: 'done',
progress: 1,
source: 'server_path',
}];
this.scrollSeq++;
}
return result;
},
/** 歧义消解兜底(§5):无创建动作词且无已发布扩展命中 → 「没有匹配的已发布扩展,
* 是否创建?」引导卡(extension_upload 块带歧义注记)。幂等:同块只补一次。 */
patchExtensionNoMatch(aMsg, data) {
if (!aMsg) return null;
const blk = ensureExtensionUploadBlock(aMsg);
if (!blk.noMatch) blk.noMatch = (data && data.message) || null;
return blk;
},
/** 运行前参数确认卡提交(§5 运行时参数来源:确认卡手填值优先于提取值)→
* POST /extensions/{id}/run → 挂运行卡(run_started 形态)→ 返回 run_id。
* 参数合并:extractedParams(用户句子提取值)为底,确认卡手填值覆盖。 */
async confirmExtensionRun(blk, { params = {}, files = [], aMsg = null } = {}) {
const { runExtension } = await import('../api/bioflow.js');
const extId = blk.extId;
if (!extId) throw new Error('no ext');
let body;
if (Array.isArray(files) && files.length) {
const form = new FormData();
form.append('params', JSON.stringify(params || {}));
for (const f of files) form.append('files', f, f.name || 'input');
body = form;
} else {
body = { params: params || {} };
}
const data = await runExtension(extId, body, Array.isArray(files) && files.length ? { timeout: 0 } : {});
const result = (data && data.data) || data || {};
const runId = result.run_id || null;
const target = aMsg || (() => {
const list = this.messages || [];
for (let i = list.length - 1; i >= 0; i--) {
const m = list[i];
if (m && m.role === 'assistant' && Array.isArray(m.blocks)) {
const b = m.blocks.find((x) => x && x.type === 'extension_confirm' && x.extId === extId);
if (b) return m;
}
}
return null;
})();
if (target && runId) {
blk.submitted = true;
const runBlk = patchExtensionRunEvent(target, {
type: 'extension_run_started',
run_id: runId,
ext_id: extId,
});
if (runBlk) this.scrollSeq++;
}
return result;
},
/** P4 复用发起(FR-BIO-023):把复用预填表单挂到当前会话消息流
* (bioflow_reuse 块,FlowFormCard mode='reuse' 渲染)。
* prefill:bioflow.loadReusePrefill 产物(archive/stageChain/params)。 */
showReuseForm(prefill) {
if (!prefill || !prefill.archive) return null;
const msgList = this.messages || [];
const aMsg = [...msgList].reverse().find((m) => m.role === 'assistant');
if (!aMsg) return null;
const archive = prefill.archive;
const blk = {
type: 'bioflow_reuse',
mode: 'reuse',
stage: 'reuse',
archiveId: archive.id,
archiveName: archive.name || '',
sessionId: this.sessionId || '',
target: '',
fields: [],
prefill: prefill.params || {},
stageChain: prefill.stageChain || [],
jobId: null,
submitted: false,
};
aMsg.blocks = [...(aMsg.blocks || []), blk];
this.scrollSeq++;
return blk;
},
/** 是否存在活动文档(docgen store;未就绪容错 false) */
_hasActiveArtifactSafe() {
try {
const dg = useDocgenStore();
return dg ? dg.hasActiveArtifact : false;
} catch {
return false;
}
},
/** 缺陷 #27:终态后标记会话来源聚合缓存失效(bench.sourcesStale = true)。
* SourcesPane 监听到标记后自动 force 重拉——缓存只在选中会话时有意义,
* 终态才触发一次,流式过程中不反复重拉。
* 同步实现:经 getActivePinia 取 bench store(与 bench.js 读 chat 的方式对称,
* 无动态 import 的微任务窗口——发送后立即断言/挂载渲染都能看到标记)。 */
_markSourcesStaleSafe(sessionId) {
try {
if (sessionId && this.sessionId !== sessionId) return; // 跨会话(切走后后台完成):不打扰当前视图
const bench = getActivePinia()?._s.get('bench');
bench?.markSourcesStale();
} catch { /* bench 未就绪(测试/降级):静默 */ }
},
async _runStream(q, modeCfg, aMsg, sessionId, userId, ctx) {
const controller = new AbortController();
ctx.controller = controller;
const startAt = Date.now();
let ended = false;
const endTask = () => {
ended = true;
if (ctx.controller === controller) ctx.controller = null;
this._stopCheckpoint(ctx);
};
const updateLocal = (fn) => {
if (ctx.msgList.includes(aMsg)) fn();
};
try {
await chatStream(
{ question: q, session_id: sessionId, ...modeCfg.params },
{
signal: controller.signal,
onProgress: (ev) => {
if (ended) return;
const p = ev.payload || {};
const phase = this._normalizePhase(p.phase);
const label = p.label || p.stage || '';
updateLocal(() => {
if (!ctx.completedOnce && phase !== 'done') this._advancePipeline(aMsg, phase, label);
if (phase === 'done') {
// 上游 completed 事件:当前相位定格 done(此时 complete 尚未到达)
const steps = aMsg.blocks[0].steps;
const running = steps.find((s) => s.state === 'running');
if (running) running.state = 'done';
else steps.forEach((s) => { s.state = 'done'; });
}
});
// 必须经 streamCtx 的响应式代理推入:raw ctx.events.push 不触发
// 失效,检索 TAB 会直到对话结束(_finishCtx 重赋值)才一次性渲染。
// 2026-09-04 手工测试复现,与 :313/:337 的 raw-vs-proxy 陷阱同类。
const evProxy = this.streamCtx[sessionId] || ctx;
evProxy.events.push({ ...p, at: p.at || new Date().toISOString() });
},
// 两级体验(方案 3,2026-08-26):evidence 帧 = 并发 search 的归一化结果,
// 在 complete 之前把 sources/graph 块先挂上(来源 tab 提前可见)。
// complete 会整体替换 blocks,chat 的 sources 自动覆盖——引用编号始终对。
onEvidence: (ev) => {
if (ended) return;
const payload = ev.payload || {};
updateLocal(() => {
if (aMsg._evidenceOnce) return; // 只挂一次
aMsg._evidenceOnce = true;
const evidenceBlocks = qaToBlocks(payload).filter((b) => b.type !== 'text');
if (evidenceBlocks.length) {
aMsg.blocks = [aMsg.blocks[0], ...evidenceBlocks];
// 生成相位 desc 提示:证据已就绪,等待综述
const gen = aMsg.blocks[0].steps.find((s) => s.phase === 'generation');
if (gen) gen.desc = T.evidenceReadyDesc;
}
});
},
onComplete: async (ev) => {
if (ended) return;
// complete 帧之后流即关闭:同步收口,避免「流自然结束」误判为连接中断
endTask();
const payload = ev.payload || {};
const blocks = qaToBlocks(payload);
updateLocal(() => {
// text 块先不放:由揭示动画以 stream 块渐进显示,终了替换为最终 text
aMsg.blocks = [
aMsg.blocks[0],
...blocks.filter((b) => b.type !== 'text'),
];
aMsg.raw = payload.raw ?? payload;
const pipe = aMsg.blocks[0];
pipe.status = 'done';
pipe.steps.forEach((s) => { s.state = 'done'; });
});
ctx.completedOnce = true;
// 揭示动画(FR-CHAT-010):stream 块瞬态、不落库
if (payload.answer) {
await this.revealText(aMsg, payload.answer, ctx);
} else {
updateLocal(() => { aMsg.status = 'done'; });
}
const dur = ((Date.now() - startAt) / 1000).toFixed(1) + 's';
// FR-PRIV-005:onComplete 整体替换 blocks 后 flush 私有 pending(防丢块)
this._flushPrivate(aMsg);
// 2026-08-25 健壮性加固:收尾链(落库+_finish)必须无条件执行。
// 此前 _persistFinal 抛异常会使 _finish 永不运行 → busy 卡死、
// 会话状态停留在 running,刷新后被误判「已中断」。
try {
await this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw });
} catch (persistErr) {
// 落库失败不掩盖成功事实:本地消息保持 done,仅记录
console.warn('persistFinal failed after complete:', persistErr);
}
// 缺陷 #27:回答完成后会话来源聚合缓存失效(列表自动刷新)
this._markSourcesStaleSafe(sessionId);
this._finishCtx(ctx, {
ok: true,
payload,
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
dur,
blocks: blocks.length,
});
},
onError: (ev) => {
if (ended) return;
const p = ev.payload || {};
endTask();
const err = { code: p.code || 'STREAM_ERROR', message: p.message || '', retryable: !!p.retryable };
const map = this._errorMarker(err.code);
if (map.text) err.message = err.message || map.text;
updateLocal(() => {
aMsg.status = 'error';
aMsg.streamErr = map.ann;
// 后端/映射文案显式落 errText:ann='message'(未知码)时由 errNote 优先展示
aMsg.errText = err.message;
const pipe = aMsg.blocks[0];
pipe.status = 'error';
const running = pipe.steps.find((s) => s.state === 'running');
if (running) running.state = 'done';
});
this._flushPrivate(aMsg); // FR-PRIV-005:error 路径 flush
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, { ok: false, error: err, message: aMsg, sessionId, userMessageId: userId, assistantMessageId: aMsg.id });
},
onRaw: () => { /* raw 帧仅记录:payload 已由 raw 帧携带,此处不追加 UI 状态 */ },
onUnknown: () => { /* 无 event 名的帧:忽略(keepalive 等) */ },
},
);
if (!ended) {
// 流在无终态帧的情况下自然关闭 → 按中断处理(FR-CHAT-008 网络中断)
endTask();
const err = { code: 'CONNECTION_CLOSED', message: T.disconnected };
updateLocal(() => {
aMsg.status = 'error';
aMsg.streamErr = 'disconnected';
const pipe = aMsg.blocks[0];
pipe.status = 'error';
});
this._flushPrivate(aMsg); // FR-PRIV-005:断流 error 路径 flush
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, { ok: false, error: err, message: aMsg, sessionId, userMessageId: userId, assistantMessageId: aMsg.id });
}
} catch (err) {
if (ended) return; // 已被某个帧处理
endTask();
const isAbort = err && (err.name === 'AbortError' || err.code === 'ERR_CANCELED');
// BUG-R1-05 修复:ApiError(BFF 错误信封,code=UPSTREAM_ERROR/UPSTREAM_DOWN/…)
// 不是网络中断——优先透传 code 与后端 message(PRD §2.2:后端 error.message 直接展示)。
// 只有无 code 的原始网络错误(fetch reject TypeError)才标「连接中断」。
if (!isAbort && err && typeof err.code === 'string' && err.code !== 'NETWORK') {
const marker = this._errorMarker(err.code);
const text = err.message || marker.text || T.upstreamError;
updateLocal(() => {
aMsg.status = 'error';
aMsg.streamErr = marker.ann;
aMsg.errText = text;
const pipe = aMsg.blocks[0];
pipe.status = 'error';
const running = pipe.steps.find((s) => s.state === 'running');
if (running) running.state = 'done';
});
this._flushPrivate(aMsg); // FR-PRIV-005:ApiError error 路径 flush
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, {
ok: false,
error: { code: err.code, message: text, retryable: true },
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
});
return;
}
// 中断意图:用户停止 →「已停止」;其余 →「连接中断」
const marker = isAbort ? 'stopped' : 'disconnected';
const message = isAbort ? T.stopped : T.disconnected;
updateLocal(() => {
aMsg.status = 'error';
aMsg.streamErr = marker;
const pipe = aMsg.blocks[0];
pipe.status = 'error';
const running = pipe.steps.find((s) => s.state === 'running');
if (running) running.state = 'done';
});
// 停止时保留已收到的块(FR-CHAT-011);落库写 error 但保留 blocks
this._flushPrivate(aMsg); // FR-PRIV-005:网络中断/停止 error 路径 flush
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, {
ok: false,
error: { code: isAbort ? 'ABORTED' : (err.code || 'NETWORK'), message, retryable: !isAbort },
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
});
}
},
async _runSearch(q, modeCfg, aMsg, sessionId, userId, ctx) {
const startAt = Date.now();
try {
const payload = await search({ question: q, ...modeCfg.params });
const blocks = qaToBlocks(payload);
aMsg.blocks = [aMsg.blocks[0], ...blocks];
aMsg.raw = payload.raw ?? payload;
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
await this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw });
// 缺陷 #27:检索模式回答完成后会话来源聚合缓存失效(列表自动刷新)
this._markSourcesStaleSafe(sessionId);
this._finishCtx(ctx, {
ok: true,
payload,
message: aMsg,
sessionId,
userMessageId: userId,
assistantMessageId: aMsg.id,
dur,
blocks: blocks.length,
});
} catch (err) {
const marker = this._errorMarker(err.code);
const text = err.message || marker.text || T.upstreamError;
aMsg.status = 'error';
aMsg.streamErr = marker.ann;
aMsg.errText = text;
const pipe = aMsg.blocks[0];
pipe.status = 'error';
this._flushPrivate(aMsg); // FR-PRIV-005:search error 路径同样 flush
this._persistFinal(aMsg, sessionId, 'error', { raw: aMsg.raw }).catch(() => {});
this._finishCtx(ctx, { ok: false, error: { code: err.code || 'NETWORK', message: text, retryable: true }, message: aMsg, sessionId, userMessageId: userId, assistantMessageId: aMsg.id });
}
},
// ---------------------------------------------------------------
// FR-PRIV-003/005:私有资料问答并行接线(fire-and-forget,失败静默)
// ---------------------------------------------------------------
/**
* 并行触发私有资料检索(send 内不 await)。
* - 未命中 / 异常(含错误信封)静默 return(FR-PRIV-003:零痕迹)
* - 命中且本轮上下文仍活跃(streamCtx[sessionId].aMsg 引用相等,TL 裁决假设 9):
* 流未收口 → 暂存 aMsg._privatePending(终态由 flush 挂载并持久化)
* 已终态(aMsg 已不在 blocks 保护期)→ 直挂 + 补 _persistFinal
* - 跨轮迟到命中:上下文不匹配 → 丢弃(不得串挂到新消息)
*/
async _firePrivate(q, sessionId, aMsg) {
let payload;
try {
payload = await askPrivate({ question: q, session_id: sessionId });
} catch {
return; // 网络/信封错误一律静默
}
if (!payload || payload.ok === false || !payload.hit) return; // 未命中静默
// 本轮上下文校验(TL 裁决假设 9:streamCtx[sessionId].aMsg 引用相等):
// ctx 存在且 aMsg 不匹配 = 跨轮迟到 → 丢弃(不得串挂到新消息)
const ctx = this.streamCtx[sessionId];
if (ctx && ctx.aMsg !== aMsg) return;
if (ctx && aMsg.status === 'streaming') {
// 流未收口:暂存,待 onComplete/error 终态后 _flushPrivate 挂载
aMsg._privatePending = payload;
return;
}
// 已终态(或本轮 ctx 已清理):直挂 + 补持久化(防重入由 _appendPrivate 保证)
if (this._appendPrivate(aMsg, payload)) {
this._persistFinal(aMsg, sessionId, aMsg.status, { raw: aMsg.raw }).catch(() => {});
}
},
/**
* 把私有命中 payload 挂到 aMsg 的 private_doc 块(防重入:已存在只挂一次)。
* @returns {boolean} 本次是否实际追加了块
*/
_appendPrivate(aMsg, payload) {
const blocks = Array.isArray(aMsg.blocks) ? aMsg.blocks : [];
if (blocks.some((b) => b && b.type === 'private_doc')) return false;
const block = {
type: 'private_doc',
answer: String((payload && payload.answer) || ''),
items: Array.isArray(payload && payload.items) ? payload.items.map((it) => ({ ...it })) : [],
};
aMsg.blocks = [...blocks, block];
return true;
},
/**
* 终态 flush:只清 _privatePending(有 pending 则挂块并持久化由调用方负责——
* flush 挂点均在 _persistFinal 之前)。
*/
_flushPrivate(aMsg) {
const pending = aMsg && aMsg._privatePending;
delete aMsg._privatePending;
if (!pending) return;
this._appendPrivate(aMsg, pending);
},
// ---------------------------------------------------------------
// 揭示动画(FR-CHAT-010)
// ---------------------------------------------------------------
async revealText(aMsg, fullText, ctx) {
// 无动画环境(测试 jsdom 无 matchMedia / 用户关闭动效):直接定格最终 text
const reduced =
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function' ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduced) {
this._finalizeReveal(aMsg, fullText, ctx);
return;
}
if (!ctx.msgList.includes(aMsg)) return;
const total = fullText.length;
if (total === 0) {
aMsg.status = 'done';
return;
}
// >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: '' });
ctx.reveal = { text: fullText };
await new Promise((resolve) => {
let n = 0;
const timer = setInterval(() => {
if (!ctx.msgList.includes(aMsg) || aMsg.revealId !== revealId) {
clearInterval(timer);
if (ctx.reveal) ctx.reveal = null;
resolve();
return;
}
n = Math.min(total, n + step);
const sb = aMsg.blocks[aMsg.blocks.length - 1];
if (sb && sb.type === 'stream') sb.text = fullText.slice(0, n);
if (n >= total) {
clearInterval(timer);
this._finalizeReveal(aMsg, fullText, ctx);
resolve();
}
}, interval);
});
},
/** 揭示收尾:stream 块替换为最终 text 块(FR-CHAT-013:stream 瞬态不落库)
* 揭示动画按字符回放的是 markdown 原文,终态 text 块必须与 qaToBlocks 一致
* 走 renderMarkdown 转 HTML(BUG-R1-06:装配处已渲染,此处保持一致) */
_finalizeReveal(aMsg, fullText, ctx) {
// 使进行中的揭示定时器下一 tick 自愈退出(skipReveal 路径)
aMsg.revealId = (aMsg.revealId || 0) + 1;
const i = aMsg.blocks.findIndex((b) => b.type === 'stream');
if (i >= 0) aMsg.blocks.splice(i, 1);
if (!aMsg.blocks.some((b) => b.type === 'text')) {
aMsg.blocks.push({ type: 'text', html: renderMarkdown(fullText) });
}
aMsg.status = 'done';
ctx.reveal = null;
this.scrollSeq++;
},
/** 「跳过」:立即显示全文(仅当前会话) */
skipReveal() {
const ctx = this._ctx;
if (!ctx?.reveal) return;
const target = ctx.msgList.find((m) => m.revealId && m.blocks.some((b) => b.type === 'stream'));
if (target) this._finalizeReveal(target, ctx.reveal.text, ctx);
},
// ---------------------------------------------------------------
// 停止(FR-CHAT-011)——只作用于当前会话的流
// ---------------------------------------------------------------
async stop() {
const ctx = this._ctx;
if (!ctx) return;
const ctrl = ctx.controller;
if (ctrl) {
try {
ctrl.abort();
} catch { /* 已中止 */ }
ctx.controller = null;
}
this._stopCheckpoint(ctx);
},
// ---------------------------------------------------------------
// 重试(FR-CHAT-012):同消息位置替换,不新增用户消息(仅当前会话)
// ---------------------------------------------------------------
async retry() {
if (this.busy) return;
const idx = this.messages.length - 1;
const aMsg = this.messages[idx];
const user = this.messages[idx - 1];
if (!aMsg || aMsg.role !== 'assistant' || !user || user.role !== 'user') return;
const q = user.text || '';
const sessionId = this.sessionId;
const msgList = this.messages;
// P3:docgen 失败消息的重试走文档生成路径(同位置替换,复用既有用户消息;
// 附件引用以本地 meta.library_ids 为准——会话刷新后丢失则退化为无附件生成)
if (aMsg.kind === 'docgen' || user.kind === 'docgen') {
msgList.splice(idx, 1);
this.lastResult = null;
await this.sendDocgen(q, (user.meta && user.meta.library_ids) || [], { userMsg: user });
return;
}
// P2:生信失败消息的重试走生信路径(同位置替换;reuseName 从 user.meta 恢复)
if (aMsg.kind === 'bioflow' || user.kind === 'bioflow') {
// 扩展分析消息(user.meta.extension 标记):重试走扩展引导流(同位置替换)
if (user.meta && user.meta.extension) {
msgList.splice(idx, 1);
this.lastResult = null;
await this.sendExtension(q, { userMsg: user });
return;
}
msgList.splice(idx, 1);
this.lastResult = null;
await this.sendBioflow(q, { userMsg: user, reuseName: (user.meta && user.meta.reuseName) || null });
return;
}
msgList.splice(idx, 1);
this.lastResult = null;
const ctx = newStreamCtx(sessionId, msgList);
this.streamCtx[sessionId] = ctx;
const sessions = useSessionsStore();
const aNewRaw = newSkeletonMessage();
msgList.push(aNewRaw);
// 关键(2026-08-25 PM 修复):持代理而非 raw
const aNew = msgList[msgList.length - 1];
ctx.aMsg = aNew;
this.runningMsgId = null;
try {
const skeleton = await createMessage(sessionId, {
role: 'assistant', kind: 'qa', status: 'streaming',
blocks: [{ type: 'pipeline', status: 'running', steps: aNew.blocks[0].steps.map((st) => ({ ...st })) }],
});
aNew.id = skeleton.id;
this.runningMsgId = skeleton.id;
} catch { /* 同 send:骨架失败不阻断 */ }
const modeCfg = MODES[user.mode || this.mode] || MODES.ask;
if (user.mode) this.mode = user.mode; // 恢复原提问模式(FR-CHAT-012 相同模式重试)
if (modeCfg.endpoint !== '/api/qa/search') {
this._setupCheckpoint(ctx); // search 无流:不设定时器(send 内同约定,防止 fake timers 泄漏)
}
this._firePrivate(q, sessionId, aNew); // FR-PRIV-003:retry 轮同样触发私有检索
if (modeCfg.endpoint === '/api/qa/search') {
await this._runSearch(q, modeCfg, aNew, sessionId, user.id, ctx);
} else {
await this._runStream(q, modeCfg, aNew, sessionId, user.id, ctx);
}
},
// ---------------------------------------------------------------
// 内部:持久化 / 相位推进 / 错误映射
// ---------------------------------------------------------------
/** 检查点节奏:骨架落库后每 ~3s PATCH 一次(FR-CHAT-008 步骤 6)
* 每会话独立定时器,切走后继续为后台流服务。 */
_setupCheckpoint(ctx) {
this._stopCheckpoint(ctx);
ctx.lastCheckpointAt = Date.now();
ctx.checkpointTimer = setInterval(() => {
const aMsg = ctx.msgList.find((m) => m.role === 'assistant' && m.status === 'streaming');
if (!aMsg || !aMsg.id) return;
if (!this._changedSinceCheckpoint(aMsg, ctx)) return;
updateMessage(ctx.sessionId, aMsg.id, {
status: 'streaming',
blocks: this._persistableBlocks(aMsg),
raw: aMsg.raw ?? null,
}).then((saved) => {
const sessions = useSessionsStore();
sessions.touch(ctx.sessionId, saved.updated_at || new Date().toISOString());
ctx.lastCheckpointAt = Date.now();
}).catch(() => { /* 检查点失败由下个检查点或终态弥补 */ });
}, CHECKPOINT_MS);
},
_changedSinceCheckpoint(aMsg, ctx) {
if (ctx.completedOnce) return true;
const pipe = aMsg.blocks.find((b) => b.type === 'pipeline');
if (!pipe) return false;
return pipe.steps.some((s) => s.state !== 'waiting');
},
_stopCheckpoint(ctx) {
if (ctx.checkpointTimer != null) {
clearInterval(ctx.checkpointTimer);
ctx.checkpointTimer = null;
}
},
/** 落库块:pipeline + 真实内容块;stream 块瞬态不落库 */
_persistableBlocks(aMsg) {
const out = [];
const pipe = aMsg.blocks.find((b) => b.type === 'pipeline');
if (pipe) out.push({ ...pipe, steps: pipe.steps.map((s) => ({ ...s })) });
for (const b of aMsg.blocks) {
if (b.type === 'pipeline' || b.type === 'stream') continue;
out.push({ ...b });
}
// 派生 pipeline 块(derived:true)是布局消费的瞬态镜像,不落库
// (后端 Block union 无此类型;历史恢复不生信点,与现状一致)
return out.filter((b) => !(b.type === 'pipeline' && b.derived));
},
async _persistFinal(aMsg, sessionId, status, { raw } = {}) {
if (aMsg.id) {
await updateMessage(sessionId, aMsg.id, {
status,
blocks: this._persistableBlocks(aMsg),
raw: raw ?? aMsg.raw ?? null,
});
} else {
await createMessage(sessionId, {
role: 'assistant', kind: 'qa', status,
blocks: this._persistableBlocks(aMsg),
raw: raw ?? aMsg.raw ?? null,
});
}
const sessions = useSessionsStore();
sessions.touch(sessionId, new Date().toISOString());
},
_advancePipeline(aMsg, phase, label) {
const steps = aMsg.blocks[0].steps;
for (const s of steps) {
if (s.phase === phase) {
if (s.state === 'waiting') s.state = 'running';
if (label) s.desc = label;
} else if (['planning', 'retrieval', 'evidence', 'generation'].includes(s.phase) && s.state === 'running') {
s.state = 'done';
}
}
if (phase === 'done') steps.forEach((s) => { s.state = 'done'; });
this.scrollSeq++;
},
/** 相位兜底校验:BFF 已算 phase,此处仅校验合法性(labels.STAGE_PHASE 亦可用) */
_normalizePhase(phase) {
return PIPELINE_PHASES.includes(phase) ? phase : 'retrieval';
},
/** 错误码 → 标注与文案(labels.js;后端 error.message 直接展示) */
_errorMarker(code) {
switch (code) {
case 'TIMEOUT':
return { ann: 'timeout', text: T.timeoutMsg };
case 'INVALID_CITATION':
return { ann: 'citation', text: T.citationError };
case 'UPSTREAM_DOWN':
return { ann: 'message', text: T.qaDown };
case 'CONNECTION_CLOSED':
return { ann: 'disconnected', text: T.disconnected };
case 'NETWORK':
return { ann: 'disconnected', text: T.disconnected };
default:
return { ann: 'message', text: T.upstreamError };
}
},
/** 本轮终态统一收尾(per-session 上下文):状态同步 + 可观测结果 + 标题自动生成 + 清理 ctx */
_finishCtx(ctx, { ok, error, message, payload, sessionId, userMessageId, assistantMessageId, dur, blocks, silentAbort }) {
const sessions = useSessionsStore();
if (sessionId) {
const status = ok ? 'done' : 'error';
sessions.patchLocal(sessionId, { status });
if (!silentAbort) {
updateSessionQuiet(sessionId, { status }).catch(() => {});
}
}
// 标题自动生成(FR-SESSION-008):本轮提问后 PATCH 前 30 字符
const user = [...ctx.msgList].reverse().find((m) => m.role === 'user' && (m.text || '').trim());
if (sessionId && user) sessions.ensureTitle(sessionId, user.text).catch(() => {});
// 清理本会话的流上下文(停止/终态后),但把事件快照保留到 lastEvents。
// 2026-08-26 修复:Pinia reactive 代理陷阱——streamCtx[sessionId] 读出的是
// 代理对象,与闭包持有的 raw ctx 恒不相等(=== 永远 false),导致清理失败、
// busy 永远 true。按 key 删除,不做对象比较。
this.lastEvents = [...(ctx.events || [])];
delete this.streamCtx[sessionId];
this.lastResult = { ok, error: error || null, payload: payload || null, message: message || null, sessionId, userMessageId, assistantMessageId, dur, blocks };
this.scrollSeq++;
},
/** 刷新滚动请求(聊天区 watch 消费;0 亦可比较,故用自增序号) */
requestScroll() {
this.scrollSeq++;
},
},
});
/** 会话状态 PATCH(不抛出;失败由终态同步弥补) */
async function updateSessionQuiet(sessionId, body) {
const { updateSession } = await import('../api/sessions.js');
try {
await updateSession(sessionId, body);
} catch {
/* 静默:状态同步失败不阻断主流程 */
}
}
/** 切换会话时清空工作台会话数据(B3 bench store 契约:resetSessionData) */
async function resetBenchData() {
try {
// 动态 import 避免与 B3 的循环依赖;B3/B1 未就绪(http.js 缺失)时静默跳过
const { useBenchStore } = await import('../stores/bench.js');
useBenchStore().resetSessionData();
} catch { /* noop */ }
}
/** 历史消息装配完成后通知工作台(B3 契约:hydrate) */
function hydrateBench() {
try {
import('../stores/bench.js').then(({ useBenchStore }) => {
useBenchStore().hydrate();
}).catch(() => {});
} catch { /* noop */ }
}