Compare commits
11
Commits
1ecc4cb29e
...
3c00e1675e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c00e1675e | ||
|
|
8ab4279b04 | ||
|
|
c943e8d363 | ||
|
|
9ac36e99ba | ||
|
|
9cb0bf2e35 | ||
|
|
6b4b5e571f | ||
|
|
9797b1b414 | ||
|
|
b9bc90ffb0 | ||
|
|
821f3c1c4e | ||
|
|
da8f531bbc | ||
|
|
21c79582bf |
+1
-1
@@ -46,4 +46,4 @@ paper_writer_service/paper.log
|
||||
# 部署方案文档(含主机凭据,不入库)
|
||||
docs/DEPLOY_PAPER_176.md
|
||||
# Python 覆盖率运行时产物(coverage.py --cov 生成)
|
||||
.coverage
|
||||
.coveragevstore/vstore.log
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -16,7 +16,7 @@ export const BF = {
|
||||
pathCandidatePurposeLabel: '用途',
|
||||
pathAutoFilled: '已自动补全',
|
||||
pathConfirm: '确认此路径',
|
||||
pathEmpty: '暂无候选路径',
|
||||
pathEmpty: '正在检索可用分析路径....',
|
||||
pathPlanning: '正在分析需求并规划分析路径…',
|
||||
formConfirmed: '参数已确认',
|
||||
pathCreated: '任务已创建',
|
||||
|
||||
+168
-21
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user