forked from admin/PlantAI-rag-new
fix(bioflow): 路径卡确认创建后事件断链——任务 201 创建但 UI 无反馈
根因:FlowPathCard.confirmCreate 创建成功后 emit(updated) 全链无人监听 (与 r3-07-01 跳过按钮同模式的事件断链,基线即存在)。后果:无进度卡、 无任务事件订阅、不落库(刷新即丢 jobId)。 修复:chat store 新增 confirmBioflowPath 一站式 action(创建→挂 job_created 进度块→connectJobEvents 带 onEvent 桥接推进消息块→落库);FlowPathCard 改调 action 并防重复创建;hydrateDetail 恢复未终态任务的订阅(刷新后续流转); connectJobEvents 加 onEvent 回调。单测 5 项新增,全套 373 全绿。
This commit is contained in:
@@ -62,21 +62,26 @@ function backToSelect() {
|
||||
async function confirmCreate() {
|
||||
const cand = selectedCandidate.value;
|
||||
if (!cand || confirming.value) return;
|
||||
if (props.block.jobId) return; // 已创建过(防重复创建;FR-BIO 任务幂等语义)
|
||||
confirming.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const sessionId = chat.sessionId || '';
|
||||
const detail = await bioflow.createJobFromPlan({
|
||||
sessionId,
|
||||
// r4 修复:不再 emit('updated')(该事件全链无人监听,任务创建后 UI 静默)——
|
||||
// 改走 chat store 一站式 confirmBioflowPath:创建 → 挂进度块 → 订阅任务事件
|
||||
// 桥接推进 → 落库。aMsg 从 chat 消息流定位(当前消息 = 含本 path 块的 assistant 消息)。
|
||||
const aMsg = (chat.messages || []).find(
|
||||
(m) => m && m.role === 'assistant' && Array.isArray(m.blocks) && m.blocks.includes(props.block),
|
||||
);
|
||||
const detail = await chat.confirmBioflowPath({
|
||||
title: (chat.messages || []).find((m) => m.role === 'user')?.text?.slice(0, 50) || '生信任务',
|
||||
stages: cand.stages,
|
||||
params: {},
|
||||
autoContinue: false,
|
||||
archiveId: reuseArchive.value || null,
|
||||
aMsg: aMsg || null,
|
||||
});
|
||||
// 进度块由任务事件流/详情回填;此处标记 path 块已确认
|
||||
props.block.jobId = detail.id || null;
|
||||
emit('updated');
|
||||
// 进度块由 confirmBioflowPath 挂载;此处标记 path 块已确认
|
||||
props.block.jobId = (detail && detail.id) || null;
|
||||
} catch (err) {
|
||||
error.value = err?.message || BF.pathStageCreating;
|
||||
emit('flow-error', err);
|
||||
|
||||
@@ -273,7 +273,7 @@ export const useBioflowStore = defineStore('bioflow', {
|
||||
// 事件订阅(SSE 断线重连回放;EventSource 契约允许测试注入 mock)
|
||||
// ---------------------------------------------------------------
|
||||
/** 打开 /jobs/{id}/events 订阅(首帧快照整体刷新 + 回放 + 实时;终态服务端关闭) */
|
||||
connectJobEvents(jobId, sessionId, { sourceFactory, onOpen } = {}) {
|
||||
connectJobEvents(jobId, sessionId, { sourceFactory, onOpen, onEvent } = {}) {
|
||||
const key = sessionId || 'default';
|
||||
this.disconnectJobEvents(key);
|
||||
const lastSeq = this.currentJob.lastSeq || 0;
|
||||
@@ -295,6 +295,10 @@ export const useBioflowStore = defineStore('bioflow', {
|
||||
this.applyJobEvent({ type, data, seq });
|
||||
if (typeof seq === 'number') rec.lastSeq = Math.max(rec.lastSeq, seq);
|
||||
}
|
||||
// 事件桥接(r4 修复:路径卡确认创建后消息进度块与 store 同源推进)
|
||||
if (typeof onEvent === 'function') {
|
||||
try { onEvent(type, data, seq); } catch { /* 桥接异常不阻断事件应用 */ }
|
||||
}
|
||||
};
|
||||
es.addEventListener('snapshot', (e) => {
|
||||
try { apply('snapshot', JSON.parse(e.data || '{}'), 0); } catch { /* 坏帧忽略 */ }
|
||||
|
||||
@@ -572,6 +572,32 @@ export const useChatStore = defineStore('chat', {
|
||||
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++;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch { /* 生信未就绪容错 */ }
|
||||
// 缺陷 #52:恢复 docgen 任务轮询(hydrate 后 aMsg 已是响应式代理)
|
||||
if (this._docgenResumeQueue && this._docgenResumeQueue.length) {
|
||||
const queue = this._docgenResumeQueue;
|
||||
@@ -1360,6 +1386,64 @@ export const useChatStore = defineStore('chat', {
|
||||
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');
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// tests/r3debug/bioflowPathConfirm.test.js —— 生信路径卡确认创建后 UI 断链修复
|
||||
// 缺陷:FlowPathCard.confirmCreate 创建任务成功后 emit('updated') 无人监听,
|
||||
// UI 无进度卡/无事件订阅/不落库(刷新即丢)。修复后走 store 一站式 confirmBioflowPath。
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
|
||||
vi.mock('../../src/api/sessions.js', () => ({
|
||||
createMessage: vi.fn(async () => ({ id: 'm' + Math.random().toString(36).slice(2, 6) })),
|
||||
updateMessage: vi.fn(async () => ({ updated_at: new Date().toISOString() })),
|
||||
}));
|
||||
vi.mock('../../src/api/qa.js', () => ({
|
||||
chatStream: vi.fn(async () => {}),
|
||||
search: vi.fn(async () => ({ answer: '' })),
|
||||
}));
|
||||
vi.mock('../../src/api/private.js', () => ({
|
||||
askPrivate: vi.fn(async () => ({ hit: false })),
|
||||
}));
|
||||
vi.mock('../../src/api/docgen.js', () => ({
|
||||
generate: vi.fn(), getTask: vi.fn(),
|
||||
getArtifactContent: vi.fn(async () => ({ markdown: '' })),
|
||||
getArtifactHtml: vi.fn(async () => ({ ok: true, html: '', css: '' })),
|
||||
saveArtifactContent: vi.fn(async () => ({})), editArtifact: vi.fn(async () => ({})),
|
||||
exportArtifact: vi.fn(async () => ({})), uploadLibrary: vi.fn(),
|
||||
listLibrary: vi.fn(async () => ({ items: [] })), removeLibrary: vi.fn(async () => ({ ok: true })),
|
||||
listTasks: vi.fn(async () => ({ tasks: [] })),
|
||||
}));
|
||||
|
||||
const connectJobEventsSpy = vi.fn();
|
||||
const createJobFromPlanSpy = vi.fn();
|
||||
vi.mock('../../src/stores/bioflow.js', async () => {
|
||||
const actual = await vi.importActual('../../src/stores/bioflow.js');
|
||||
const { useBioflowStore: useReal } = actual;
|
||||
return {
|
||||
...actual,
|
||||
useBioflowStore: () => {
|
||||
const st = useReal();
|
||||
st.connectJobEvents = connectJobEventsSpy;
|
||||
st.createJobFromPlan = createJobFromPlanSpy;
|
||||
return st;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { useChatStore } from '../../src/stores/chat.js';
|
||||
import { useSessionsStore } from '../../src/stores/sessions.js';
|
||||
import { useBioflowStore } from '../../src/stores/bioflow.js';
|
||||
import { updateMessage } from '../../src/api/sessions.js';
|
||||
|
||||
function freshStores() {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
const chat = useChatStore();
|
||||
const sessions = useSessionsStore();
|
||||
chat.sessionId = 's1';
|
||||
sessions.activeId = 's1';
|
||||
sessions.items = [{ id: 's1', title: 't', status: 'done', created_at: '', updated_at: '' }];
|
||||
return { chat, sessions };
|
||||
}
|
||||
|
||||
describe('confirmBioflowPath 一站式任务创建', () => {
|
||||
beforeEach(() => {
|
||||
connectJobEventsSpy.mockReset();
|
||||
createJobFromPlanSpy.mockReset();
|
||||
vi.mocked(updateMessage).mockClear();
|
||||
});
|
||||
|
||||
it('创建成功 → 挂 job_created 进度块 + 订阅任务事件 + 落库', async () => {
|
||||
const { chat } = freshStores();
|
||||
createJobFromPlanSpy.mockResolvedValue({
|
||||
id: 'job-1', title: '生信任务', status: 'created',
|
||||
stages: [
|
||||
{ stage: 'genome_prepare', status: 'waiting' },
|
||||
{ stage: 'identify', status: 'waiting' },
|
||||
],
|
||||
});
|
||||
const aMsg = {
|
||||
id: 'am1', role: 'assistant', status: 'done',
|
||||
blocks: [
|
||||
{ type: 'bioflow_path', status: 'done', candidates: [{ stages: ['genome_prepare', 'identify'] }], selected: 0 },
|
||||
],
|
||||
raw: {},
|
||||
};
|
||||
chat.messages = [aMsg];
|
||||
|
||||
const detail = await chat.confirmBioflowPath({
|
||||
title: '生信任务',
|
||||
stages: ['genome_prepare', 'identify'],
|
||||
aMsg,
|
||||
});
|
||||
|
||||
// 1. 返回 detail 与创建入参一致
|
||||
expect(detail.id).toBe('job-1');
|
||||
// 2. path 块落定 jobId + selected 固化
|
||||
const pathBlk = aMsg.blocks.find((b) => b.type === 'bioflow_path');
|
||||
expect(pathBlk.jobId).toBe('job-1');
|
||||
expect(pathBlk.selected).toBe(0);
|
||||
// 3. 进度块挂上且 jobStatus=created、阶段 waiting
|
||||
const progBlk = aMsg.blocks.find((b) => b.type === 'bioflow_progress');
|
||||
expect(progBlk).toBeTruthy();
|
||||
expect(progBlk.jobId).toBe('job-1');
|
||||
expect(progBlk.jobStatus).toBe('created');
|
||||
expect(progBlk.stages.length).toBe(2);
|
||||
// 4. 订阅了任务事件流(带 onEvent 桥接)
|
||||
expect(connectJobEventsSpy).toHaveBeenCalledTimes(1);
|
||||
expect(connectJobEventsSpy.mock.calls[0][0]).toBe('job-1');
|
||||
expect(typeof connectJobEventsSpy.mock.calls[0][2].onEvent).toBe('function');
|
||||
// 5. 落库(updateMessage 调用)
|
||||
expect(updateMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('onEvent 桥接把任务事件 patch 进消息进度块(阶段推进)', async () => {
|
||||
const { chat } = freshStores();
|
||||
createJobFromPlanSpy.mockResolvedValue({
|
||||
id: 'job-2', title: '生信任务', status: 'created', stages: [{ stage: 'identify', status: 'waiting' }],
|
||||
});
|
||||
const aMsg = {
|
||||
id: 'am2', role: 'assistant', status: 'done',
|
||||
blocks: [{ type: 'bioflow_path', candidates: [{ stages: ['identify'] }], selected: 0 }],
|
||||
raw: {},
|
||||
};
|
||||
chat.messages = [aMsg];
|
||||
await chat.confirmBioflowPath({ title: 't', stages: ['identify'], aMsg });
|
||||
|
||||
const onEvent = connectJobEventsSpy.mock.calls[0][2].onEvent;
|
||||
onEvent('stage_started', { job_id: 'job-2', stage: 'identify' });
|
||||
onEvent('stage_done', { job_id: 'job-2', stage: 'identify' });
|
||||
|
||||
const progBlk = aMsg.blocks.find((b) => b.type === 'bioflow_progress');
|
||||
const st = progBlk.stages.find((s) => s.stage === 'identify');
|
||||
expect(st.status).toBe('done'); // 终态不回退语义保持
|
||||
});
|
||||
|
||||
it('job_done 事件 → 挂存档提示块', async () => {
|
||||
const { chat } = freshStores();
|
||||
createJobFromPlanSpy.mockResolvedValue({
|
||||
id: 'job-3', title: '生信任务', status: 'created', stages: [{ stage: 'identify', status: 'waiting' }],
|
||||
});
|
||||
const aMsg = {
|
||||
id: 'am3', role: 'assistant', status: 'done',
|
||||
blocks: [{ type: 'bioflow_path', candidates: [{ stages: ['identify'] }], selected: 0 }],
|
||||
raw: {},
|
||||
};
|
||||
chat.messages = [aMsg];
|
||||
await chat.confirmBioflowPath({ title: 't', stages: ['identify'], aMsg });
|
||||
|
||||
connectJobEventsSpy.mock.calls[0][2].onEvent('job_done', { job_id: 'job-3', report_path: '/r.html', figures: [] });
|
||||
|
||||
const archBlk = aMsg.blocks.find((b) => b.type === 'bioflow_archive');
|
||||
expect(archBlk).toBeTruthy();
|
||||
expect(archBlk.jobId).toBe('job-3');
|
||||
});
|
||||
|
||||
it('创建失败不挂块、不落库', async () => {
|
||||
const { chat } = freshStores();
|
||||
createJobFromPlanSpy.mockRejectedValue(new Error('创建失败'));
|
||||
const aMsg = {
|
||||
id: 'am4', role: 'assistant', status: 'done',
|
||||
blocks: [{ type: 'bioflow_path', candidates: [{ stages: ['identify'] }], selected: 0 }],
|
||||
raw: {},
|
||||
};
|
||||
chat.messages = [aMsg];
|
||||
await expect(
|
||||
chat.confirmBioflowPath({ title: 't', stages: ['identify'], aMsg }),
|
||||
).rejects.toThrow('创建失败');
|
||||
expect(aMsg.blocks.some((b) => b.type === 'bioflow_progress')).toBe(false);
|
||||
expect(updateMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hydrateDetail 恢复订阅:含未终态进度块的消息重连事件流', async () => {
|
||||
const { chat, sessions } = freshStores();
|
||||
const progressBlk = { type: 'bioflow_progress', jobId: 'job-9', jobStatus: 'running', stages: [] };
|
||||
sessions.fetchDetail = vi.fn(async () => ({
|
||||
messages: [
|
||||
{ id: 'u1', role: 'user', text: 'q' },
|
||||
{ id: 'a1', role: 'assistant', kind: 'qa', status: 'done', blocks: [progressBlk], raw: {} },
|
||||
],
|
||||
}));
|
||||
await chat.loadSession('s1');
|
||||
|
||||
expect(connectJobEventsSpy).toHaveBeenCalledWith('job-9', 's1', expect.anything());
|
||||
// 终态不重连
|
||||
connectJobEventsSpy.mockClear();
|
||||
sessions.fetchDetail = vi.fn(async () => ({
|
||||
messages: [
|
||||
{ id: 'a2', role: 'assistant', kind: 'qa', status: 'done', blocks: [{ type: 'bioflow_progress', jobId: 'job-10', jobStatus: 'done', stages: [] }], raw: {} },
|
||||
],
|
||||
}));
|
||||
await chat.loadSession('s1');
|
||||
expect(connectJobEventsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user