diff --git a/ui/src/components/chat/FlowPathCard.vue b/ui/src/components/chat/FlowPathCard.vue index a7192a5..a468758 100644 --- a/ui/src/components/chat/FlowPathCard.vue +++ b/ui/src/components/chat/FlowPathCard.vue @@ -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); diff --git a/ui/src/stores/bioflow.js b/ui/src/stores/bioflow.js index 003db07..25210ba 100644 --- a/ui/src/stores/bioflow.js +++ b/ui/src/stores/bioflow.js @@ -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 { /* 坏帧忽略 */ } diff --git a/ui/src/stores/chat.js b/ui/src/stores/chat.js index 489d97c..3926a2d 100644 --- a/ui/src/stores/chat.js +++ b/ui/src/stores/chat.js @@ -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'); diff --git a/ui/tests/r3debug/bioflowPathConfirm.test.js b/ui/tests/r3debug/bioflowPathConfirm.test.js new file mode 100644 index 0000000..22faa29 --- /dev/null +++ b/ui/tests/r3debug/bioflowPathConfirm.test.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(); + }); +});