diff --git a/tooling/build_all.sh b/tooling/build_all.sh index 5ea74a0..daec6b5 100755 --- a/tooling/build_all.sh +++ b/tooling/build_all.sh @@ -170,6 +170,9 @@ set -a set +a ( cd "$ROOT/sdk" && setsid "$PYBIN" -m uvicorn app.main:app --host "$BIND" --port 8090 \ > /tmp/plantai-sdk.log 2>&1 < /dev/null & ) +disown -a 2>/dev/null || true +# 2026-09-12 部署实测:BFF 段缺 disown 时,脚本尾部 bash 隐式 wait 长驻 BFF 进程, +# start_all 永不返回(vstore_restart.sh 有 disown 故不卡)。此处补上。 echo "BFF 启动中(8090),5 秒后自检:" sleep 5 curl -s --max-time 15 http://127.0.0.1:8090/api/health | head -c 300 diff --git a/ui/src/components/layout/StatusBar.vue b/ui/src/components/layout/StatusBar.vue index 383e702..ced932b 100644 --- a/ui/src/components/layout/StatusBar.vue +++ b/ui/src/components/layout/StatusBar.vue @@ -17,7 +17,7 @@ const agentStates = computed(() => { try { const chat = chatStore.value; if (!chat) return null; - const msg = chat.currentMessage; + const msg = chat.agentDotMessage ?? chat.currentMessage; const pipe = (msg?.blocks || []).find((b) => b?.type === 'pipeline'); const steps = pipe?.steps || []; const out = {}; diff --git a/ui/src/components/layout/TopBar.vue b/ui/src/components/layout/TopBar.vue index daae498..9cf762c 100644 --- a/ui/src/components/layout/TopBar.vue +++ b/ui/src/components/layout/TopBar.vue @@ -72,6 +72,10 @@ const aggregate = computed(() => { const phase = runningPhaseOf(chat); return { phase, running: true, error: false }; } + // 生信任务活跃期(chat/stream 已收口、busy=false 但阶段仍在运行): + // 派生 pipeline 块有 running 相位时聚合点同样点亮(2026-09-12 statusbar 修复) + const phase = runningPhaseOf(chat); + if (phase) return { phase, running: true, error: false }; if (chat.lastResult && chat.lastResult.ok === false) { return { phase: null, running: false, error: true }; } @@ -91,7 +95,7 @@ const aggregateClass = computed(() => { /** 从当前助手消息的 pipeline 块取正在运行相位的 key */ function runningPhaseOf(chat) { try { - const msg = chat.currentMessage; + const msg = chat.agentDotMessage ?? chat.currentMessage; const pipe = (msg?.blocks || []).find((b) => b?.type === 'pipeline'); const running = (pipe?.steps || []).find((s) => s?.state === 'running'); if (running && running.phase && running.phase !== 'done') return running.phase; diff --git a/ui/src/stores/chat.js b/ui/src/stores/chat.js index dffd6d0..144197b 100644 --- a/ui/src/stores/chat.js +++ b/ui/src/stores/chat.js @@ -530,6 +530,27 @@ export const useChatStore = defineStore('chat', { 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() { diff --git a/ui/tests/r3debug/statusBarDots.test.js b/ui/tests/r3debug/statusBarDots.test.js index 7b5f864..db1dc84 100644 --- a/ui/tests/r3debug/statusBarDots.test.js +++ b/ui/tests/r3debug/statusBarDots.test.js @@ -20,7 +20,10 @@ vi.mock('../../src/stores/health.js', () => ({ })); import StatusBar from '../../src/components/layout/StatusBar.vue'; -import { useChatStore } from '../../src/stores/chat.js'; +import { + useChatStore, + syncBioflowDotBlock, +} from '../../src/stores/chat.js'; const PHASES = ['planning', 'retrieval', 'evidence', 'generation']; @@ -139,6 +142,52 @@ describe('StatusBar 4 个 agent 点', () => { expect(dotClasses(wrapper)).toEqual(['', '', '', '']); }); + it('生信流已收口但任务活跃(2026-09-12 生产实测缺口)→ 回退消息列表点亮', async () => { + const chat = useChatStore(); + // 模拟真实场景:chat/stream 已在 plan_ready 后关闭,_finishCtx 清理了 ctx + // (streamCtx 无本会话键),但任务阶段仍在运行,派生块正被任务事件订阅刷新 + const aMsg = { + id: 'm6', role: 'assistant', kind: 'bioflow', status: 'streaming', + blocks: [ + { type: 'bioflow_path', status: 'done', jobId: 'j2', candidates: [{ stages: ['a', 'b'] }], selected: 0 }, + { + type: 'bioflow_progress', jobId: 'j2', jobStatus: 'running', + stages: [ + { stage: 'a', status: 'running' }, + { stage: 'b', status: 'waiting' }, + ], + }, + { + type: 'pipeline', derived: true, status: 'running', + steps: PHASES.map((p) => step(p, p === 'planning' ? 'running' : 'waiting')), + }, + ], + }; + chat.sessionId = 's-dots2'; + chat.messages = [aMsg]; + chat.sessionMsgs['s-dots2'] = [aMsg]; + chat.streamCtx = {}; // 关键:无活跃流(ctx 已被 _finishCtx 清理) + expect(chat.agentDotMessage).toBe(chat.messages[0]); // getter 回退正确 + const wrapper = mount(StatusBar); + await new Promise((r) => setTimeout(r, 30)); + await wrapper.vm.$nextTick(); + expect(dotClasses(wrapper)).toEqual(['running', '', '', '']); + // 阶段推进:事件 patch 后派生块随动(模拟任务事件订阅 onEvent 链) + const proxyMsg = chat.messages[0]; + const blk = proxyMsg.blocks.find((b) => b.type === 'bioflow_progress'); + blk.stages[0].status = 'done'; + blk.stages[1].status = 'running'; + syncBioflowDotBlock(proxyMsg); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + expect(dotClasses(wrapper)).toEqual(['done', 'running', '', '']); + // 任务终态:jobStatus done → getter 不再回退,点全空态(正确收口) + blk.jobStatus = 'done'; + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 10)); + expect(dotClasses(wrapper)).toEqual(['', '', '', '']); + }); + it('真实时序:先 mount(挂载完成)后流到达 → computed 响应式点亮', async () => { const chat = useChatStore(); // 1. 先 mount,onMounted 动态 import 完成(与真实 app 启动时序一致)