11 Commits
Author SHA1 Message Date
admin 3c00e1675e feat(bioflow): skill 编排续作 WIP checkpoint(多轮链续接/plan 回填/job_id 提取/历史 blocks 文本提取) 2026-10-14 09:41:44 +08:00
admin 8ab4279b04 fix(bioflow): sendCardResult 从 raw.bioflow 读 active_job_id(BFF 归一化剥离顶层 bioflow 导致订阅没建立) 2026-10-13 14:39:33 +08:00
admin c943e8d363 fix(bioflow): sendCardResult 创建任务后补 connectJobEvents 订阅,恢复文件/参数引导等任务事件触发 2026-10-13 12:34:27 +08:00
admin 9ac36e99ba fix(bioflow): 路径选择卡空状态文案「暂无候选路径」改为「正在检索可用分析路径....」 2026-10-13 11:33:39 +08:00
admin 9cb0bf2e35 feat(bioflow): stage_done 摘要从 manifest.metrics 提取关键指标(命中/比对率等) (#010) 2026-10-13 11:28:18 +08:00
admin 6b4b5e571f feat(bioflow): 就绪卡+版本化卡接线——工具结果经 payload.bioflow 下发前端渲染 (#009) 2026-10-13 11:18:53 +08:00
admin 9797b1b414 feat(bioflow): 交付卡接线——job_done 构造 manifest 调 validate_delivery 下发交付清单 (#009-交付) 2026-10-13 11:12:39 +08:00
admin b9bc90ffb0 feat(bioflow): 诊断卡接线——stage_failed 补 G13 错误对象+guidance,前端诊断卡渲染 (#009-诊断) 2026-10-13 11:10:30 +08:00
admin 821f3c1c4e feat(bioflow): 引导帧——卡片环节补自然语言引导气泡(文件/参数/存档/进度) (#006/#007/#008/#010) 2026-10-13 11:04:32 +08:00
admin da8f531bbc fix(bioflow): check_disk_available 捕获 OSError 返回 PATH_NOT_EXIST,不再抛未捕获异常 (#004) 2026-10-13 11:01:17 +08:00
admin 21c79582bf fix(bioflow): QA 生信流加活跃任务续接分支,续接输入(提供文件/参数等)不再掉普通 QA (#011) 2026-10-13 10:59:54 +08:00
11 changed files with 374 additions and 30 deletions
+1 -1
View File
@@ -46,4 +46,4 @@ paper_writer_service/paper.log
# 部署方案文档(含主机凭据,不入库)
docs/DEPLOY_PAPER_176.md
# Python 覆盖率运行时产物(coverage.py --cov 生成)
.coverage
.coveragevstore/vstore.log
+84 -4
View File
@@ -48,6 +48,9 @@ _CHAIN_TO_CAPABILITY = {
"rnaseq": "bioflow-rnaseq",
}
# 能力名 → chain 名(反向映射,供规划阶段从命中能力回填 chain,续接多轮对话)
_CAPABILITY_TO_CHAIN = {v: k for k, v in _CHAIN_TO_CAPABILITY.items()}
def _chain_to_capability(chain: str) -> Optional[str]:
"""把 chain 名(gene_family/rnaseq)映射为能力名;未知返回 None。"""
@@ -291,6 +294,28 @@ def _empty_audit() -> Dict[str, Any]:
return {"rounds": 0, "tool_calls": 0, "used_json_fallback": False, "tool_names": [], "errors": []}
def _map_readiness(data: Dict[str, Any]) -> Dict[str, Any]:
"""check_readiness 结果 → 就绪卡字段 {ready, quadrants:[{name,ok}]}。"""
inner = data.get("result", data) if isinstance(data, dict) else {}
quadrants = []
for key in ("environment", "reference", "space", "budget"):
q = inner.get(key) or {}
quadrants.append({"name": key, "ok": bool(q.get("ok"))})
return {"ready": bool(inner.get("ready")), "quadrants": quadrants}
def _map_versioning(data: Dict[str, Any]) -> Dict[str, Any]:
"""propose_change 结果 → 版本化卡字段 {plan_revision, run_id, affected_stages, based_on_revision}。"""
inner = data.get("result", data) if isinstance(data, dict) else {}
affected = inner.get("affected") or []
return {
"plan_revision": "",
"run_id": "",
"affected_stages": list(affected),
"based_on_revision": None,
}
def _make_payload(
question: str,
answer: str,
@@ -299,6 +324,9 @@ def _make_payload(
capabilities: Optional[List[str]] = None,
active_job_id: Optional[str] = None,
chain: Optional[str] = None,
readiness: Optional[Dict[str, Any]] = None,
versioning: Optional[Dict[str, Any]] = None,
plan: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"success": True,
@@ -318,6 +346,12 @@ def _make_payload(
payload["bioflow"]["active_job_id"] = str(active_job_id)
if chain:
payload["bioflow"]["chain"] = str(chain)
if readiness:
payload["bioflow"]["readiness"] = readiness
if versioning:
payload["bioflow"]["versioning"] = versioning
if plan:
payload["bioflow"]["plan"] = plan
return payload
@@ -609,8 +643,24 @@ def _try_bioflow_agent_impl(
if isinstance(card_result, dict) and str(card_result.get("card") or "").strip() in CARD_TYPES:
return _route_card_result(card_result, llm_client, capabilities, bioflow, progress_callback)
# NL 路径:门 2 + 门 3
manifests = _candidate_capabilities(question, capabilities)
# NL 路径:活跃任务/生信链续接优先,其次意图预筛
active_job_id = (bioflow or {}).get("active_job_id")
chain = str((bioflow or {}).get("chain") or "").strip()
if active_job_id:
# 有活跃任务:跳过意图预筛,按任务 chain 续接(任务链不可变)
manifests = _registered_capabilities(capabilities)
cap_name = _chain_to_capability(chain)
if cap_name:
manifests = [m for m in manifests if m.name == cap_name]
else:
# 无活跃任务:先门 2 + 门 3 意图预筛(新任务入口)
manifests = _candidate_capabilities(question, capabilities)
# 意图未命中但 session 留有生信链(上轮规划)→ 续接该链(如路径选择「1」)
if not manifests and chain:
cap_name = _chain_to_capability(chain)
if cap_name:
registered = _registered_capabilities(capabilities)
manifests = [m for m in registered if m.name == cap_name]
if not manifests:
return None
@@ -639,13 +689,43 @@ def _try_bioflow_agent_impl(
if not answer:
return None
tool_results = (audit or {}).get("tool_results") or {}
readiness = None
versioning = None
new_job_id = None
plan = None
for name, result_text in tool_results.items():
if name in ("create_gene_family_job", "create_rnaseq_job"):
jid = _extract_job_id(result_text)
if jid:
new_job_id = jid
data = _try_parse_json(result_text)
if not isinstance(data, dict):
continue
if name.startswith("plan_") and data.get("ok"):
plan = {
"candidates": data.get("candidates") or [],
"auto_filled": data.get("auto_filled") or [],
"reuse_archive": data.get("reuse_archive"),
"reuse_name": data.get("reuse_name"),
"fallback": data.get("fallback"),
}
if name == "check_readiness":
readiness = _map_readiness(data)
elif name == "propose_change":
versioning = _map_versioning(data)
effective_chain = chain or (_CAPABILITY_TO_CHAIN.get(manifests[0].name) if manifests else "")
return _make_payload(
question,
answer,
audit=audit,
capabilities=[m.name for m in manifests],
active_job_id=(bioflow or {}).get("active_job_id"),
chain=(bioflow or {}).get("chain"),
active_job_id=(bioflow or {}).get("active_job_id") or new_job_id,
chain=effective_chain or None,
readiness=readiness,
versioning=versioning,
plan=plan,
)
@@ -109,6 +109,33 @@ class TestGateLogic:
)
assert result is None
def test_active_job_continuation_skips_intent_prescreen(self, tmp_path):
"""#011:有活跃任务时,续接输入(不含触发词)也应进入生信流,不掉普通 QA。"""
registry = _make_registry(tmp_path)
fake_tools = [
{
"type": "function",
"function": {
"name": "fetch_source_file",
"description": "获取源文件",
"parameters": {"type": "object", "properties": {}},
},
}
]
with mock.patch.object(ba, "get_registry", return_value=registry):
with mock.patch.object(ba, "_build_tools", return_value=(fake_tools, {"fetch_source_file": mock.ANY})):
with mock.patch.dict("os.environ", {ba.ENV_FUSE: "1"}):
result = ba.try_bioflow_agent(
"基因组文件在 https://example.com/rice.fa",
llm_client=FakeLLMClient(),
capabilities=None,
bioflow={"active_job_id": "job-1", "chain": "gene_family"},
)
assert result is not None
assert result["success"] is True
assert result["bioflow"]["handled"] is True
assert result["bioflow"]["capabilities"] == ["bioflow-gene-family"]
def test_not_registered_returns_none(self, tmp_path):
empty = CapabilityRegistry(tmp_path / "empty.yaml")
empty.load()
+3 -1
View File
@@ -66,6 +66,7 @@ class ToolRunner:
"tool_calls": 0,
"used_json_fallback": False,
"tool_names": [],
"tool_results": {},
"errors": [],
}
messages: List[Dict[str, Any]] = []
@@ -95,6 +96,7 @@ class ToolRunner:
break
assistant_message: Dict[str, Any] = {"role": "assistant", "content": content or ""}
tool_call_blocks: List[Dict[str, Any]] = []
messages.append(assistant_message)
for call in tool_calls:
name = str(call.get("name") or call.get("function", {}).get("name") or "")
raw_args = call.get("arguments") or call.get("function", {}).get("arguments") or {}
@@ -125,9 +127,9 @@ class ToolRunner:
"function": {"name": name or "unknown", "arguments": json.dumps(arguments, ensure_ascii=False)},
}
)
audit["tool_results"][name] = result_text
messages.append({"role": "tool", "tool_call_id": call_id, "content": result_text})
assistant_message["tool_calls"] = tool_call_blocks
messages.append(assistant_message)
continue
# 最后一轮:拿最终文本(无工具或轮次用尽后由调用方用 messages 再 complete 一次)
return self._final_text(system_text, messages, tools), audit
@@ -130,7 +130,10 @@ def check_download_budget(size_bytes: int, budget_bytes: int) -> dict[str, Any]:
def check_disk_available(dest_dir: str | Path, needed_bytes: int) -> dict[str, Any]:
"""G23:磁盘门禁——剩余空间不足阻断并返回结构化错误。"""
ok, free = check_disk_space(dest_dir, needed_bytes)
try:
ok, free = check_disk_space(dest_dir, needed_bytes)
except OSError as exc:
return error_result("PATH_NOT_EXIST", f"目标目录不可用:{exc}")
if not ok:
return error_result(
"DISK_FULL", f"磁盘空间不足:需 {needed_bytes} 字节,剩余 {free} 字节",
@@ -27,6 +27,8 @@ from pathlib import Path
from typing import Any, Awaitable, Callable
from .catalog import StageCatalog
from .delivery import delivery_validate
from .errors import classify_reason
from .events import EventBus
from .manifests import ManifestError, locate_manifest, parse_manifest
from .recovery import RecoveryBudget
@@ -615,6 +617,30 @@ def parse_progress_marker(line: str) -> dict[str, Any] | None:
return None
_METRIC_LABELS = {
"hits": "命中", "hit_count": "命中", "mapping_rate": "比对率",
"aligned": "比对", "mapped": "比对", "identified": "鉴定",
"members": "成员", "de_genes": "差异基因", "up": "上调", "down": "下调",
"enriched": "富集", "count": "数量", "total": "总数",
"records": "记录", "sequences": "序列",
}
def _metrics_summary(stage: str, metrics: dict) -> str:
"""阶段完成摘要(G 关键指标自然语言转述);无指标时退化为「阶段已完成」。"""
if not isinstance(metrics, dict) or not metrics:
return f"阶段 {stage} 已完成"
parts = []
for k, v in metrics.items():
if not isinstance(v, (int, float, str, bool)):
continue
label = _METRIC_LABELS.get(str(k), str(k))
parts.append(f"{label} {v}")
if parts:
return f"阶段 {stage} 完成:{','.join(parts)}"
return f"阶段 {stage} 已完成"
@dataclass
class JobRecord:
"""任务内存记录(DB 行 + 派生路径)。"""
@@ -798,11 +824,31 @@ class Runner:
return
record.status = JOB_DONE
await self._persist_job(record)
try:
done_stages = [
st for st in record.stage_chain
if record._stage_states.get(st, {}).get("status") == STAGE_DONE
]
delivery_manifest = {
"selected_stages": list(record.stage_chain or []),
"run_id": record.run_id or record.id,
"input_sources": list(record.registered_server_paths or []),
"output_sources": list(done_stages),
"config": dict(record.params or {}),
"required_results": list(done_stages),
"validation_values": {},
"review_status": "pending",
}
delivery = delivery_validate(delivery_manifest)
except Exception: # noqa: BLE001 - 交付清单失败不阻断任务完成
delivery = {"ok": False, "checks": {}, "incomplete": []}
self.events.publish("job_done", {
"job_id": record.id,
"title": record.title,
"progress": 100,
"elapsed_sec": None,
"guidance": "分析已完成,可以存档复用,或直接查看报告。",
"delivery": delivery,
})
except asyncio.CancelledError:
# 任务级取消:不落脏状态(阶段取消由 _run_stage 单独处理)
@@ -865,9 +911,17 @@ class Runner:
self._set_stage(record, stage, status=STAGE_WAITING_CONFIRM)
await self._persist_stage(record, stage)
defaults = self._form_defaults(stage, record)
if defaults:
param_str = "、".join(
f"{p.get('name')}(默认 {p.get('default')})" for p in defaults
)
guidance = f"阶段 {stage} 需要确认参数:{param_str}。不调整可直接继续。"
else:
guidance = f"阶段 {stage} 需要你确认后继续。"
self.events.publish("stage_await_confirm", {
"job_id": record.id, "stage": stage,
"form_defaults": defaults,
"guidance": guidance,
})
return "await"
@@ -1041,6 +1095,7 @@ class Runner:
"job_id": record.id, "stage": stage, "exit_code": exit_code,
"manifest_ref": manifest.path,
"metrics": manifest.metrics,
"summary": _metrics_summary(stage, manifest.metrics),
})
return "done"
@@ -1049,10 +1104,19 @@ class Runner:
self._set_stage(record, stage, status=STAGE_FAILED, finished_at=_now_iso(),
error=reason)
await self._persist_stage(record, stage)
cat, diag, retry, actions = classify_reason(reason, exit_code=exit_code)
guidance = f"阶段 {stage} 执行失败:{reason}。"
if actions:
guidance += "建议:" + ";".join(actions) + "。"
self.events.publish("stage_failed", {
"job_id": record.id, "stage": stage,
"exit_code": exit_code if exit_code is not None else -1,
"reason": reason,
"category": cat, "code": reason, "summary": reason,
"diagnosis_status": diag,
"retryable_without_change": retry,
"suggested_actions": actions,
"guidance": guidance,
})
def _prefix_for(self, record: JobRecord, stage: str) -> str | None:
+20 -1
View File
@@ -7,9 +7,26 @@ assemble_history(db, session_id, rounds=3, budget):
"""
from __future__ import annotations
import html as html_mod
import re
from typing import Any, List
from ..config import get_settings
from ..db import loads_json
def _blocks_text(blocks_json) -> str:
"""从落库 blocks 提取 assistant 答案纯文本(text 块 html 去标签)。"""
blocks = loads_json(blocks_json, default=[])
parts = []
for b in blocks:
if not isinstance(b, dict) or b.get("type") != "text":
continue
h = b.get("html") or b.get("text") or ""
t = html_mod.unescape(re.sub(r"<[^>]+>", "", str(h))).strip()
if t:
parts.append(t)
return "\n".join(parts)
async def assemble_history(
@@ -40,7 +57,7 @@ async def assemble_history(
return []
try:
rows = await db.fetchall(
"SELECT role, status, text FROM messages "
"SELECT role, status, text, blocks FROM messages "
"WHERE session_id = ? AND role IN ('user','assistant') "
"AND status != 'streaming' "
"ORDER BY created_at ASC, rowid ASC",
@@ -55,6 +72,8 @@ async def assemble_history(
for row in reversed(rows):
role = row["role"]
text = (row["text"] or "").strip()
if not text and role == "assistant":
text = _blocks_text(row["blocks"])
if not text:
continue
if role == "assistant":
+1
View File
@@ -66,6 +66,7 @@ function handleFiles(fileList) {
chat.uploadBioflowFileToJob(jobId.value, file, {
onProgress: (pct) => {
const rec = props.block.files.find((x) => x.name === file.name && x.status === 'uploading');
console.log('[bioflow-upload] onProgress pct=', pct, 'recFound=', !!rec, 'files=', (props.block.files || []).length);
if (rec) rec.progress = pct;
},
})
+1
View File
@@ -61,6 +61,7 @@ function backToSelect() {
/** 复用名 → 表单默认值来源标注(与后端 params 预填配合;来源标注在表单卡展示) */
async function confirmCreate() {
const cand = selectedCandidate.value;
console.log('[bioflow-card] confirmCreate selected=', selected.value, 'cand=', !!cand, 'jobId=', props.block.jobId);
if (!cand || confirming.value) return;
if (props.block.jobId) return; // 已创建过(防重复创建;FR-BIO 任务幂等语义)
confirming.value = true;
+1 -1
View File
@@ -16,7 +16,7 @@ export const BF = {
pathCandidatePurposeLabel: '用途',
pathAutoFilled: '已自动补全',
pathConfirm: '确认此路径',
pathEmpty: '暂无候选路径',
pathEmpty: '正在检索可用分析路径....',
pathPlanning: '正在分析需求并规划分析路径…',
formConfirmed: '参数已确认',
pathCreated: '任务已创建',
+168 -21
View File
@@ -19,6 +19,7 @@ import { useSessionsStore } from './sessions.js';
import { PHASE_LABEL, PHASE_DESC, T } from '../i18n/labels.js';
import { MODES } from '../config/modes.js';
import { renderMarkdown } from '../components/chat/markdown.js';
console.log('[bioflow-version] chat.js raw.bioflow-fix v2 loaded');
// ---- P3 文档生成:独立分支(send/_runStream 零改动) ----
import { DOCGEN_STEPS, CAP_BADGES, DG as DG_LABELS } from '../i18n/docgen.js';
@@ -502,6 +503,16 @@ export function patchBioflowForm(aMsg, data, { rerun = false } = {}) {
}
/** 阶段待文件 → 文件引导块(FR-BIO-005) */
export function patchBioflowGuidance(aMsg, text) {
// 自然语言引导气泡(方案:自然语言主通道,卡片辅助)——弹卡片前先插入一条 text 块。
if (!text) return null;
const html = renderMarkdown(String(text));
console.log('[bioflow-guidance] text=', String(text).slice(0, 40), 'html非空=', Boolean(html));
if ((aMsg.blocks || []).some((b) => b && b.type === 'text' && b.html === html)) return null;
aMsg.blocks.push({ type: 'text', html });
return true;
}
export function patchBioflowFile(aMsg, data) {
const stage = data.stage;
if (!stage) return null;
@@ -1159,9 +1170,12 @@ export const useChatStore = defineStore('chat', {
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++; }
if (type === 'stage_await_confirm' && data) { patchBioflowGuidance(m, data.guidance); patchBioflowForm(m, data); this.scrollSeq++; }
else if (type === 'stage_await_files' && data) { patchBioflowGuidance(m, data.guidance || data.message); patchBioflowFile(m, data); this.scrollSeq++; }
else if (type === 'stage_failed' && data) { patchBioflowGuidance(m, data.guidance); patchBioflowDiagnosis(m, data); this.scrollSeq++; }
else if (type === 'job_done' && data) {
patchBioflowGuidance(m, data.guidance);
if (data.delivery) patchBioflowDelivery(m, data.delivery);
const ab = patchBioflowArchiveSuggest(m, data.job_id || jobId);
if (ab) ab.jobId = data.job_id || jobId;
this.scrollSeq++;
@@ -2255,12 +2269,20 @@ export const useChatStore = defineStore('chat', {
const frame = patchBioflowEvent(aMsg, { type, data });
if (frame) this.scrollSeq++;
if (type === 'stage_await_confirm' && data) {
patchBioflowGuidance(aMsg, data.guidance);
patchBioflowForm(aMsg, data);
this.scrollSeq++;
} else if (type === 'stage_await_files' && data) {
patchBioflowGuidance(aMsg, data.guidance || data.message);
patchBioflowFile(aMsg, data);
this.scrollSeq++;
} else if (type === 'stage_failed' && data) {
patchBioflowGuidance(aMsg, data.guidance);
patchBioflowDiagnosis(aMsg, data);
this.scrollSeq++;
} else if (type === 'job_done' && data) {
patchBioflowGuidance(aMsg, data.guidance);
if (data.delivery) patchBioflowDelivery(aMsg, data.delivery);
const blk = patchBioflowArchiveSuggest(aMsg, data.job_id || jobId);
if (blk) blk.jobId = data.job_id || jobId;
bioflow.registerReport({
@@ -2295,18 +2317,41 @@ export const useChatStore = defineStore('chat', {
try {
const data = await uploadBioflowFile(jobId, form, {
onUploadProgress: (e) => {
item.progress = e.pct;
console.log('[bioflow-upload] onUploadProgress pct=', e.pct, 'loaded=', e.loaded, 'total=', e.total);
// item 是原始对象,直接改会绕过 Vue 响应式——经响应式数组找代理更新
const rec = found && Array.isArray(found.blk.files)
? found.blk.files.find((x) => x.name === item.name && x.status === 'uploading')
: null;
if (rec) rec.progress = e.pct;
else item.progress = e.pct;
if (typeof onProgress === 'function') onProgress(e.pct, item);
},
});
item.status = 'done';
item.progress = 1;
item.fileId = (data && data.data && data.data.file_id) || null;
const doneRec = found && Array.isArray(found.blk.files)
? found.blk.files.find((x) => x.name === item.name)
: null;
if (doneRec) {
doneRec.status = 'done';
doneRec.progress = 1;
doneRec.fileId = (data && data.data && data.data.file_id) || null;
} else {
item.status = 'done';
item.progress = 1;
item.fileId = (data && data.data && data.data.file_id) || null;
}
if (found) this.scrollSeq++;
return (data && data.data) || data || {};
} catch (err) {
item.status = 'error';
item.error = err?.message || BF.fileUploadError;
const errRec = found && Array.isArray(found.blk.files)
? found.blk.files.find((x) => x.name === item.name)
: null;
if (errRec) {
errRec.status = 'error';
errRec.error = err?.message || BF.fileUploadError;
} else {
item.status = 'error';
item.error = err?.message || BF.fileUploadError;
}
if (found) this.scrollSeq++;
throw err;
}
@@ -2437,6 +2482,7 @@ export const useChatStore = defineStore('chat', {
* 经 BioflowAgent 工具执行 + 自然语言复述;卡片与自然语言两条通道语义一致。
* card ∈ path_selection|form_confirm|file_upload|archive_choice|extension_action。 */
async sendCardResult(card, payload, { text = '', userMsg = null } = {}) {
console.log('[bioflow-card] sendCardResult', card, 'busy=', this.busy);
if (this.busy) return;
const sessions = useSessionsStore();
if (!this.sessionId) {
@@ -2529,19 +2575,64 @@ export const useChatStore = defineStore('chat', {
const p = ev.payload || {};
const answer = p.answer || '';
updateLocal(() => {
aMsg.blocks = [
{ type: 'bioflow_path', status: 'done', candidates: [] },
{ type: 'text', html: answer },
];
const blocks = [];
// 仅路径选择卡回填 path 块(候选由上一轮路径卡承载);其余卡操作只出自然语言复述
if (card === 'path_selection') {
blocks.push({ type: 'bioflow_path', status: 'done', candidates: [] });
}
blocks.push({ type: 'text', html: answer });
const bfPayload = p.bioflow || {};
if (bfPayload.readiness) {
blocks.push({ type: 'bioflow_readiness', ...bfPayload.readiness, error: null });
}
if (bfPayload.versioning) {
blocks.push({ type: 'bioflow_versioning', ...bfPayload.versioning });
}
aMsg.blocks = blocks;
aMsg.raw = p.raw ?? p;
aMsg.status = 'done';
});
// session↔job 绑定持久化:complete 的 bioflow.active_job_id → meta.bioflow
const bf = p.bioflow || {};
// BFF 归一化剥离了顶层 bioflow,active_job_id 在 raw.bioflow 里
const bf = p.bioflow || (p.raw && p.raw.bioflow) || {};
if (bf.active_job_id) {
updateSessionQuiet(sessionId, {
meta: { bioflow: { active_job_id: bf.active_job_id, chain: bf.chain || '' } },
}).catch(() => {});
// 修复:skill 化 S3 改走 sendCardResult 后丢失了任务事件订阅——
// 补上 connectJobEvents,把 stage_await_files / stage_await_confirm /
// stage_failed / job_done 桥接进消息块(guidance 气泡 + 各卡片)。
try {
const bioflow = useBioflowStore();
const jobId = bf.active_job_id;
console.log('[bioflow-guidance] sendCardResult 建立订阅 jobId=', jobId);
bioflow.connectJobEvents(jobId, sessionId, {
onEvent: (type, data, seq) => {
const frame = patchBioflowEvent(aMsg, { type, data });
if (frame) this.scrollSeq++;
if (type === 'stage_await_confirm' && data) {
patchBioflowGuidance(aMsg, data.guidance);
patchBioflowForm(aMsg, data);
this.scrollSeq++;
} else if (type === 'stage_await_files' && data) {
console.log('[bioflow-guidance] stage_await_files data=', JSON.stringify(data).slice(0, 200));
patchBioflowGuidance(aMsg, data.guidance || data.message);
patchBioflowFile(aMsg, data);
this.scrollSeq++;
} else if (type === 'stage_failed' && data) {
patchBioflowGuidance(aMsg, data.guidance);
patchBioflowDiagnosis(aMsg, data);
this.scrollSeq++;
} else if (type === 'job_done' && data) {
patchBioflowGuidance(aMsg, data.guidance);
if (data.delivery) patchBioflowDelivery(aMsg, data.delivery);
const blk = patchBioflowArchiveSuggest(aMsg, data.job_id || jobId);
if (blk) blk.jobId = data.job_id || jobId;
this.scrollSeq++;
}
},
});
} catch { /* EventSource 不可用:任务事件仍由快照兜底 */ }
}
try {
await this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw });
@@ -2625,7 +2716,12 @@ export const useChatStore = defineStore('chat', {
// S2:bioflow 帧(生信对话编排)不推 pipeline 相位——生信消息走
// bioflow 块,由 sendCardResult / bioflow 分支消费,此处仅记录。
const isBioflow = typeof p.stage === 'string' && p.stage.startsWith('bioflow_');
if (isBioflow) return;
if (isBioflow) {
// 生信消息:一旦收到 bioflow 帧,立即移除检索 pipeline 骨架(避免闪烁残留)
const pi = (aMsg.blocks || []).findIndex((b) => b && b.type === 'pipeline');
if (pi >= 0) aMsg.blocks.splice(pi, 1);
return;
}
if (!ctx.completedOnce && phase !== 'done') this._advancePipeline(aMsg, phase, label);
if (phase === 'done') {
// 上游 completed 事件:当前相位定格 done(此时 complete 尚未到达)
@@ -2664,18 +2760,68 @@ export const useChatStore = defineStore('chat', {
// complete 帧之后流即关闭:同步收口,避免「流自然结束」误判为连接中断
endTask();
const payload = ev.payload || {};
// S2 多轮续接:生信规划返回的 chain/active_job_id 写回 session,
// 下轮(如路径选择「1」)据此跳过意图预筛续接生信 agent。
const bf = payload.bioflow || (payload.raw && payload.raw.bioflow) || {};
if (bf.chain || bf.active_job_id) {
updateSessionQuiet(sessionId, {
meta: { bioflow: { active_job_id: bf.active_job_id || "", chain: bf.chain || "" } },
}).catch(() => {});
// 任务事件订阅:自然语言路径建任务后补 connectJobEvents,
// 把 stage_await_files / stage_await_confirm / stage_failed / job_done
// 桥接进消息块(文件卡/表单卡/诊断卡/交付卡),与卡片路径一致。
if (bf.active_job_id) {
try {
const bioflow = useBioflowStore();
const jobId = bf.active_job_id;
bioflow.connectJobEvents(jobId, sessionId, {
onEvent: (type, data, seq) => {
const frame = patchBioflowEvent(aMsg, { type, data });
if (frame) this.scrollSeq++;
if (type === 'stage_await_confirm' && data) {
patchBioflowGuidance(aMsg, data.guidance);
patchBioflowForm(aMsg, data);
this.scrollSeq++;
} else if (type === 'stage_await_files' && data) {
patchBioflowGuidance(aMsg, data.guidance || data.message);
patchBioflowFile(aMsg, data);
this.scrollSeq++;
} else if (type === 'stage_failed' && data) {
patchBioflowGuidance(aMsg, data.guidance);
patchBioflowDiagnosis(aMsg, data);
this.scrollSeq++;
} else if (type === 'job_done' && data) {
patchBioflowGuidance(aMsg, data.guidance);
if (data.delivery) patchBioflowDelivery(aMsg, data.delivery);
const blk = patchBioflowArchiveSuggest(aMsg, data.job_id || jobId);
if (blk) blk.jobId = data.job_id || jobId;
this.scrollSeq++;
}
},
});
} catch { /* EventSource 不可用:任务事件仍由快照兜底 */ }
}
}
const blocks = qaToBlocks(payload);
const isBioflow = !!(bf && bf.handled);
updateLocal(() => {
// text 块先不放:由揭示动画以 stream 块渐进显示,终了替换为最终 text
aMsg.blocks = [
aMsg.blocks[0],
...blocks.filter((b) => b.type !== 'text'),
];
// 生信消息不显示检索 pipeline 骨架——直接挂生信卡片 + 自然语言。
aMsg.blocks = isBioflow
? [...blocks.filter((b) => b.type !== 'text')]
: [aMsg.blocks[0], ...blocks.filter((b) => b.type !== 'text')];
// 生信卡片:路径卡/就绪卡/版本卡(与自然语言 answer 同时出现)
if (bf.plan) patchBioflowPlan(aMsg, bf.plan);
if (bf.readiness) patchBioflowReadiness(aMsg, bf.readiness);
if (bf.versioning) patchBioflowVersioning(aMsg, bf.versioning);
aMsg.raw = payload.raw ?? payload;
const pipe = aMsg.blocks[0];
pipe.status = 'done';
pipe.steps.forEach((s) => { s.state = 'done'; });
if (!isBioflow) {
const pipe = aMsg.blocks[0];
pipe.status = 'done';
pipe.steps.forEach((s) => { s.state = 'done'; });
}
});
console.log('[bioflow-debug] onComplete blocks=', (aMsg.blocks || []).map((b) => b && b.type), 'bf.handled=', !!(bf && bf.handled), 'bf.plan=', !!(bf && bf.plan));
ctx.completedOnce = true;
// 揭示动画(FR-CHAT-010):stream 块瞬态、不落库
if (payload.answer) {
@@ -2683,6 +2829,7 @@ export const useChatStore = defineStore('chat', {
} else {
updateLocal(() => { aMsg.status = 'done'; });
}
console.log('[bioflow-debug] after reveal blocks=', (aMsg.blocks || []).map((b) => b && b.type));
const dur = ((Date.now() - startAt) / 1000).toFixed(1) + 's';
// FR-PRIV-005:onComplete 整体替换 blocks 后 flush 私有 pending(防丢块)
this._flushPrivate(aMsg);