fix(statusbar): 生信任务活跃期回退消息列表点亮 4 点

生产实测暴露缺口:生信对话的 chat/stream 在 plan_ready 后即关闭
(_finishCtx 清 streamCtx→currentMessage 为 null),而任务阶段恰在
流关闭后才真正运行——StatusBar 只读 currentMessage,阶段运行期
间 4 点仍不亮。这正是用户报的 bug 主场景。

- chat.js 新增 agentDotMessage getter:优先流式中 currentMessage;
  流已收口时回退到消息列表里持有活跃生信任务(progress.jobId 且
  jobStatus ∈ created/running/paused)的最后一条助手消息——其派生
  pipeline 块正被任务事件订阅(connectJobEvents onEvent →
  patchBioflowEvent → syncBioflowDotBlock)持续刷新。
- StatusBar.vue 数据源换 agentDotMessage;TopBar.vue runningPhaseOf
  同换 + aggregate 在生信任务活跃期(busy=false 但有 running 相位)
  点亮聚合点。
- 单测补生产实测场景:流已收口任务活跃→回退点亮;阶段推进随动;
  任务终态正确收口空态。

验证:r3debug 89/89(新增回退用例)、主套件 373/373、dist 重建。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 18:22:03 +08:00
co-authored by Claude Fable 5
parent 9d7ed1bdd3
commit 36fc2a7250
5 changed files with 80 additions and 3 deletions
+3
View File
@@ -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
+1 -1
View File
@@ -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 = {};
+5 -1
View File
@@ -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;
+21
View File
@@ -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() {
+50 -1
View File
@@ -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 启动时序一致)