forked from admin/PlantAI-rag-new
- 后端写稿循环不再落空 paper_progress 块(重载后出现空卡),改落开始撰写文本 - 前端 confirmPaper 时用大纲节标题本地 seed 进度卡,2s 轮询 patch 逐节状态 - 当前节按 current_section_id 标 writing(…动画)
3336 lines
144 KiB
JavaScript
3336 lines
144 KiB
JavaScript
// 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 */ }
|
||
}
|