feat(bioflow): 分析路径规划中提示——planning 帧+前端思考中状态

- 后端 chat 流在规划开始前发 planning 帧(seq=-2,与 plan_ready 同机制)
- 前端收到 planning 帧立即创建 bioflow_path 块(status=running,
  脉动圆点+「正在分析需求并规划分析路径…」),plan_ready 填入候选并关闭
- bioflow store 增加 planning/planningText 状态
- 339 后端用例 + 121 r3debug + 373 主套件全绿;已部署 176

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 16:15:17 +08:00
co-authored by Claude Fable 5
parent dcb810a758
commit becf76ecd9
6 changed files with 58 additions and 4 deletions
+4
View File
@@ -220,6 +220,10 @@ async def chat_stream(body: ChatStreamRequest) -> StreamingResponse:
return
# 1. 规划(候选路径;复用/LLM/降级)
# 规划开始帧:前端据此显示「分析路径规划中…」占位(planning 状态)
yield _sse({"type": "planning", "seq": -2, "data": {
"message": "正在分析需求并规划分析路径…",
}})
try:
plan: PlanResult = await ctx.planner.plan(body.message)
except PlannerError as exc:
+3 -3
View File
@@ -288,7 +288,7 @@ def test_catalog_unknown_chain(api):
def test_chat_stream_plan_ready(api_context):
"""TC-BIO-API-001/002:chat/stream SSE,plan_ready 下发候选。"""
events = _read_sse(_chat_stream_call("帮我跑水稻 SPL 家族鉴定"), max_events=1)
events = _read_sse(_chat_stream_call("帮我跑水稻 SPL 家族鉴定"), max_events=2)
types = [e["type"] for e in events]
assert "plan_ready" in types
plan = [e for e in events if e["type"] == "plan_ready"][0]["data"]
@@ -305,7 +305,7 @@ def test_chat_stream_unknown_stage_rejected(api_context, monkeypatch):
{"purpose": "坏", "stages": ["fake_stage_xyz"]},
{"purpose": "好", "stages": ["identify"]},
]))
events = _read_sse(_chat_stream_call("帮我跑水稻 SPL 家族鉴定"), max_events=1)
events = _read_sse(_chat_stream_call("帮我跑水稻 SPL 家族鉴定"), max_events=2)
plan = [e for e in events if e["type"] == "plan_ready"][0]["data"]
for c in plan["candidates"]:
assert "fake_stage_xyz" not in c["stages"]
@@ -343,7 +343,7 @@ def test_chat_stream_llm_timeout_degrade(api_context, monkeypatch):
from conftest import FakeLLM
import app.services.bioflow.planner as planner_mod
monkeypatch.setattr(planner_mod, "complete_json", FakeLLM(error="timeout"))
events = _read_sse(_chat_stream_call("帮我跑水稻 SPL 家族鉴定"), max_events=1)
events = _read_sse(_chat_stream_call("帮我跑水稻 SPL 家族鉴定"), max_events=2)
plan = [e for e in events if e["type"] == "plan_ready"][0]["data"]
assert plan["fallback"] # 降级提示
assert plan["candidates"]
+25 -1
View File
@@ -109,7 +109,11 @@ defineExpose({ select: pick, confirm: confirmCreate });
<!-- 选择视图(FR-BIO-002/003) -->
<template v-if="view === 'select'">
<div v-if="!candidates.length" class="fpc-empty">{{ BF.pathEmpty }}</div>
<div v-if="block.planning" class="fpc-planning" role="status" aria-live="polite">
<span class="fpc-planning-dot" aria-hidden="true"></span>
<span>{{ block.planningText || BF.pathPlanning }}</span>
</div>
<div v-else-if="!candidates.length" class="fpc-empty">{{ BF.pathEmpty }}</div>
<div
v-for="(c, i) in candidates"
:key="i"
@@ -211,6 +215,26 @@ defineExpose({ select: pick, confirm: confirmCreate });
font-size: 12px;
color: var(--pa-ink-subtle);
}
/* 规划中提示:脉动圆点 + 文案(thinking/运行中视觉) */
.fpc-planning {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 0;
font-size: 12px;
color: var(--pa-ink-subtle);
}
.fpc-planning-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--pa-g-from, #5b8def);
animation: fpc-pulse 1.1s ease-in-out infinite;
}
@keyframes fpc-pulse {
0%, 100% { opacity: 0.35; transform: scale(0.85); }
50% { opacity: 1; transform: scale(1.1); }
}
/* 候选路径行:padding 8px 10px,r-md,hover surface-3 */
.fpc-row {
padding: 8px 10px;
+1
View File
@@ -17,6 +17,7 @@ export const BF = {
pathAutoFilled: '已自动补全',
pathConfirm: '确认此路径',
pathEmpty: '暂无候选路径',
pathPlanning: '正在分析需求并规划分析路径…',
pathConfirming: '确认中…',
pathFallback: '规划降级:',
pathReuseNote: '复用存档',
+10
View File
@@ -161,6 +161,8 @@ export const useBioflowStore = defineStore('bioflow', {
autoContinue: false,
archiveId: null,
},
planning: false, // 规划中(planning 帧开、plan_ready 关;供面板/占位提示)
planningText: '',
jobEventsSource: {}, // sessionId -> { es: EventSource|null, lastSeq, closedByServer }
// ---- 面板:任务列表/详情 ----
jobs: [],
@@ -230,7 +232,15 @@ export const useBioflowStore = defineStore('bioflow', {
// ---------------------------------------------------------------
/** chat/stream 事件应用(plan_ready/job_created/…;message_done 收尾由 chat store 处理) */
applyChatEvent(type, data) {
if (type === 'planning') {
// 规划中:仅打点状态,不落 currentJob(plan_ready 才建 plan)
this.planning = true;
this.planningText = (data && data.message) || '';
return;
}
if (type === 'plan_ready') {
this.planning = false;
this.planningText = '';
this.currentJob = {
...this.currentJob,
plan: {
+15
View File
@@ -153,6 +153,8 @@ export function ensureBioflowPathBlock(aMsg) {
blk = {
type: 'bioflow_path',
status: 'running',
planning: false, // 规划中提示(planning 帧开启、plan_ready 关闭)
planningText: '', // 规划提示文案
candidates: [],
selected: null,
autoFilled: [],
@@ -165,6 +167,15 @@ export function ensureBioflowPathBlock(aMsg) {
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');
@@ -185,6 +196,7 @@ export function patchBioflowPlan(aMsg, data) {
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;
@@ -1646,6 +1658,9 @@ export const useChatStore = defineStore('chat', {
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 });