diff --git a/frontend/src/run/__tests__/fixtures.json b/frontend/src/run/__tests__/fixtures.json new file mode 100644 index 00000000..aba76276 --- /dev/null +++ b/frontend/src/run/__tests__/fixtures.json @@ -0,0 +1 @@ +{"db": [{"seq": 1, "type": "run.started", "node_id": null, "ts": 1789630273.8135118, "data": {"nodes": 3, "resumed": false}}, {"seq": 2, "type": "node.started", "node_id": "input", "ts": 1789630273.820915, "data": {"node_type": "input", "label": "输入"}}, {"seq": 3, "type": "node.finished", "node_id": "input", "ts": 1789630273.8260312, "data": {"duration_ms": 0, "attempt": 1, "preview": {}, "artifact": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"}}, {"seq": 4, "type": "node.started", "node_id": "query_kpi", "ts": 1789630273.8285508, "data": {"node_type": "tool", "label": "查询 KPI 信息表"}}, {"seq": 5, "type": "tool.start", "node_id": "query_kpi", "ts": 1789630273.834141, "data": {"tool": "db_query__warehouse", "args": {"sql": "SELECT * FROM ANALYTICS.v_device_kpi FETCH FIRST 50 ROWS ONLY"}}}, {"seq": 6, "type": "tool.end", "node_id": "query_kpi", "ts": 1789630273.929114, "data": {"tool": "db_query__warehouse", "duration_ms": 95, "preview": "\"{\\\"columns\\\": [\\\"factory_code\\\", \\\"object_type\\\", \\\"object_id\\\", \\\"start_date_time\\\", \\\"end_date_time\\\", \\\"insert_time\\\", \\\"attribute_group\\\", \\\"attribute01\\\", \\\"attribute02\\\", \\\"attribute03\\\", \\\"attribute04\\\", \\\"attribute05\\\", \\\"attribute06\\\", \\\"attribute07\\\", \\\"attribute08\\\", \\\"attribute09\\\", \\\"attribute10\\\", \\\"attribute11\\\", \\\"attribute12\\\", \\\"attribute13\\\", \\\"attribute14\\\", \\\"attribute15\\\", \\\"attribute16\\\", \\\"attribute17\\\", \\\"attribute18\\\", \\\"attribute19\\\", \\\"attribute20\\\", \\\"load_time\\\", \\\"attribute21\\\", \\\"attribute22\\\", \\\"attribute23\\\", \\\"attribute24\\\", \\\"attribute25\\\", \\\"attribute26\\\", \\\"attribute27\\\", \\\"attribute28\\\", \\\"attribute29\\\", \\\"attribute30\\\", \\\"attribute31\\\", \\\"attribute32\\\", \\\"attribute33\\\", \\\"attribute34\\\", \\\"attribute35\\\", \\\"attribute36\\\", \\\"attribute37\\\", \\\"attribute38\\\", \\\"attribute39\\\", \\\"attribute40\\\", \\\"attribute41\\\", \\\"attribute42\\\", \\\"attribute43\\\", \\\"attribute44\\\", \\\"attribute45\\\", \\\"attribute46\\\", \\\"attribute47\\\", \\\"attribute48\\\", \\\"attribute49\\\", \\\"attribute50\\\", \\\"attribute51\\\", \\\"attribute52\\\", \\\"attribute53\\\", \\\"attribute54\\\", \\\"attribute55\\\", \\\"attribute56\\\", \\\"attribute57\\\", \\\"attribute58\\\", \\\"attribute59\\\", \\\"attribute60\\\", \\\"attribute61\\\", \\\"attribute62\\\", \\\"attribute63\\\", \\\"attribute64\\\", \\\"attribute65\\\", \\\"attribute66\\\", \\\"attribute67\\\", \\\"attribute68\\\", \\\"attribute69\\\", \\\"attribute70\\\"], \\\"rows\\\": [[\\\"1063\\\", \\\"设备\\\", \\\"EQ2023SWMZZZZZ0008\\\", \\\"2023-04-04T12:00:00\\\", \\\"2023-04-04T12:30:00\\\", \\\"2023-04-05T07:13:02.055000\\\", \\\"001\\\", 30, 30, \\\"1.4249\\\", \\\"7.7251\\\", 0, null, null, null, null, null, null, null, null, null, null, \\\"在线\\\", null, null, null, null, \\\"2023-04-05T07:15:01\\\", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null], [\\\"1063\\\", \\\"设备\\\", \\\"EQ2023SWMZZZZZ0009\\\", \\\"2023-04", "artifact": "49efce484256d8667b853cd69b3b421cdc7a705d6cb36e0ad4534b084b3ea177"}}, {"seq": 7, "type": "node.finished", "node_id": "query_kpi", "ts": 1789630273.9320729, "data": {"duration_ms": 99, "attempt": 1, "preview": "{\"columns\": [\"factory_code\", \"object_type\", \"object_id\", \"start_date_time\", \"end_date_time\", \"insert_time\", \"attribute_group\", \"attribute01\", \"attribute02\", \"attribute03\", \"attribute04\", \"attribute05\", \"attribute06\", \"attribute07\", \"attribute08\", \"attribute09\", \"attribute10\", \"attribute11\", \"attribute12\", \"attribute13\", \"attribute14\", \"attribute15\", \"attribute16\", \"attribute17\", \"attribute18\", \"attribute19\", \"attribute20\", \"load_time\", \"attribute21\", \"attribute22\", \"attribute23\", \"attribute24\", \"attribute25\", \"attribute26\", \"attribute27\", \"attribute28\", \"attribute29\", \"attribute30\", \"attribute31\", \"attribute32\", \"attribute33\", \"attribute34\", \"attribute35\", \"attribute36\", \"attribute37\", \"attribute38\", \"attribute39\", \"attribute40\", \"attribute41\", \"attribute42\", \"attribute43\", \"attribute44\", \"attribute45\", \"attribute46\", \"attribute47\", \"attribute48\", \"attribute49\", \"attribute50\", \"attribute51\", \"attribute52\", \"attribute53\", \"attribute54\", \"attribute55\", \"attribute56\", \"attribute57\", \"attribute58\", \"attribute59\", \"attribute60\", \"attribute61\", \"attribute62\", \"attribute63\", \"attribute64\", \"attribute65\", \"attribute66\", \"attribute67\", \"attribute68\", \"attribute69\", \"attribute70\"], \"rows\": [", "artifact": "9c915393eb335c8cae9300e969c9580fc1ab092613ab2d8a16d5281da6ea0f5e"}}, {"seq": 8, "type": "node.started", "node_id": "output", "ts": 1789630273.9351192, "data": {"node_type": "output", "label": "输出 KPI 数据"}}, {"seq": 9, "type": "node.finished", "node_id": "output", "ts": 1789630273.937645, "data": {"duration_ms": 0, "attempt": 1, "preview": {"kpi_list": "\"{\\\"columns\\\": [\\\"factory_code\\\", \\\"object_type\\\", \\\"object_id\\\", \\\"start_date_time\\\", \\\"end_date_time\\\", \\\"insert_time\\\", \\\"attribute_group\\\", \\\"attribute01\\\", \\\"attribute02\\\", \\\"attribute03\\\", \\\"attribute04\\\", \\\"attribute05\\\", \\\"attribute06\\\", \\\"attribute07\\\", \\\"attribute08\\\", \\\"attribute09\\\", \\\"attribute10\\\", \\\"attribute11\\\", \\\"attribute12\\\", \\\"attribute13\\\", \\\"attribute14\\\", \\\"attribute15\\\", \\\"attribute16\\\", \\\"attribute17\\\", \\\"attribute18\\\", \\\"attribute19\\\", \\\"attribute20\\\", \\\"load_time\\\", \\\"attribute21\\\", \\\"attribute22\\\", \\\"attribute23\\\", \\\"attribute24\\\", \\\"attribute25\\\", \\\"attribute26\\\", \\\"attribute27\\\", \\\"attribute28\\\", \\\"attribute29\\\", \\\"attribute30\\\", \\\"attribute31\\\", \\\"attribute32\\\", \\\"attribute33\\\", \\\"attribute34\\\", \\\"attribute35\\\", \\\"attribute36\\\", \\\"attribute37\\\", \\\"attribute38\\\", \\\"attribute39\\\", \\\"attribute40\\\", \\\"attribute41\\\", \\\"attribute42\\\", \\\"attribute43\\\", \\\"attribute44\\\", \\\"attribute45\\\", \\\"attribute46\\\", \\\"attribute47\\\", \\\"attribute48\\\", \\\"attribute49\\\", \\\"attribute50\\\", \\\"attribute51\\\", \\\"attribute52\\\", \\\"attribute53\\\", \\\"attribute54\\\", \\\"attribute55\\\", \\\"attribute56\\\", \\\"attribute57\\\", \\\"attribute58\\\", \\\"attribute59\\\", \\\"attribute60\\\", \\\"attribute61\\\", \\\"at"}, "artifact": "4581db6b040a22c80de69ee3d5f3ef5f07928721ce90de4212ec13e02fdf939c"}}, {"seq": 10, "type": "run.finished", "node_id": null, "ts": 1789630273.942965, "data": {"output": {"kpi_list": "\"{\\\"columns\\\": [\\\"factory_code\\\", \\\"object_type\\\", \\\"object_id\\\", \\\"start_date_time\\\", \\\"end_date_time\\\", \\\"insert_time\\\", \\\"attribute_group\\\", \\\"attribute01\\\", \\\"attribute02\\\", \\\"attribute03\\\", \\\"attribute04\\\", \\\"attribute05\\\", \\\"attribute06\\\", \\\"attribute07\\\", \\\"attribute08\\\", \\\"attribute09\\\", \\\"attribute10\\\", \\\"attribute11\\\", \\\"attribute12\\\", \\\"attribute13\\\", \\\"attribute14\\\", \\\"attribute15\\\", \\\"attribute16\\\", \\\"attribute17\\\", \\\"attribute18\\\", \\\"attribute19\\\", \\\"attribute20\\\", \\\"load_time\\\", \\\"attribute21\\\", \\\"attribute22\\\", \\\"attribute23\\\", \\\"attribute24\\\", \\\"attribute25\\\", \\\"attribute26\\\", \\\"attribute27\\\", \\\"attribute28\\\", \\\"attribute29\\\", \\\"attribute30\\\", \\\"attribute31\\\", \\\"attribute32\\\", \\\"attribute33\\\", \\\"attribute34\\\", \\\"attribute35\\\", \\\"attribute36\\\", \\\"attribute37\\\", \\\"attribute38\\\", \\\"attribute39\\\", \\\"attribute40\\\", \\\"attribute41\\\", \\\"attribute42\\\", \\\"attribute43\\\", \\\"attribute44\\\", \\\"attribute45\\\", \\\"attribute46\\\", \\\"attribute47\\\", \\\"attribute48\\\", \\\"attribute49\\\", \\\"attribute50\\\", \\\"attribute51\\\", \\\"attribute52\\\", \\\"attribute53\\\", \\\"attribute54\\\", \\\"attribute55\\\", \\\"attribute56\\\", \\\"attribute57\\\", \\\"attribute58\\\", \\\"attribute59\\\", \\\"attribute60\\\", \\\"attribute61\\\", \\\"attribute62\\\", \\\"attribute63\\\", \\\"attribute64\\\", \\\"attribute65\\\", \\\"attribute66\\\", \\\"attribute67\\\", \\\"attribute68\\\", \\\"attribute69\\\", \\\"attribute70\\\"], \\\"rows\\\": [[\\\"1063\\\", \\\"设备\\\", \\\"EQ2023SWMZZZZZ0008\\\", \\\"2023-04-04T12:00:00\\\", \\\"2023-04-04T12:30:00\\\", \\\"2023-04-05T07:13:02.055000\\\", \\\"001\\\", 30, 30, \\\"1.4249\\\", \\\"7.7251\\\", 0, null, null, null, null, null, null, null, null, null, null, \\\"在线\\\", null, null, null, null, \\\"2023-04-05T07:15:01\\\", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null], [\\\"1063\\\", \\\"设备\\\", \\\"EQ2023SWMZZZZZ0009\\\", \\\"2023-04"}, "usage": {"duration_ms": 130}, "duration_ms": 130}}], "human": [{"seq": 1, "type": "run.started", "node_id": null, "ts": 1789632951.2781608, "data": {"nodes": 3, "resumed": false}}, {"seq": 2, "type": "node.started", "node_id": "in", "ts": 1789632951.281773, "data": {"node_type": "input", "label": "in"}}, {"seq": 3, "type": "node.finished", "node_id": "in", "ts": 1789632951.284436, "data": {"duration_ms": 0, "attempt": 1, "preview": {}, "artifact": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"}}, {"seq": 4, "type": "node.started", "node_id": "h", "ts": 1789632951.28604, "data": {"node_type": "human", "label": "h"}}, {"seq": 5, "type": "human.requested", "node_id": "h", "ts": 1789632951.287274, "data": {"kind": "human_node", "node_id": "h", "mode": "approve", "title": "确认", "message": "", "schema": {}, "context": {}}}, {"seq": 6, "type": "run.interrupted", "node_id": "h", "ts": 1789632951.289222, "data": {"interrupt_id": "5b55e4440cbb6ea8fc041f3285e64356", "payload": {"kind": "human_node", "node_id": "h", "mode": "approve", "title": "确认", "message": "", "schema": {}, "context": {}}}}, {"seq": 7, "type": "run.resumed", "node_id": null, "ts": 1789632952.2943501, "data": {"response": {"approved": true, "note": ""}}}, {"seq": 8, "type": "run.started", "node_id": null, "ts": 1789632952.295941, "data": {"nodes": 3, "resumed": true}}, {"seq": 9, "type": "node.started", "node_id": "h", "ts": 1789632952.298927, "data": {"node_type": "human", "label": "h"}}, {"seq": 10, "type": "human.requested", "node_id": "h", "ts": 1789632952.299924, "data": {"kind": "human_node", "node_id": "h", "mode": "approve", "title": "确认", "message": "", "schema": {}, "context": {}}}, {"seq": 11, "type": "human.resolved", "node_id": "h", "ts": 1789632952.300632, "data": {"response": {"approved": true, "note": ""}}}, {"seq": 12, "type": "node.finished", "node_id": "h", "ts": 1789632952.302637, "data": {"duration_ms": 0, "attempt": 1, "preview": {"approved": true, "note": "", "__decision__": "approved"}, "artifact": "37c6c8932b33766e3422b1f6ba5cb0548613f0b734b2ac3a22a3decd3cf053b6"}}, {"seq": 13, "type": "node.started", "node_id": "out", "ts": 1789632952.303895, "data": {"node_type": "output", "label": "out"}}, {"seq": 14, "type": "node.finished", "node_id": "out", "ts": 1789632952.306143, "data": {"duration_ms": 0, "attempt": 1, "preview": {"result": ""}, "artifact": "f58d9226d7c4bc9e7b20258dffae67e24b8507ca86f4bab783533d6bb5810a16"}}, {"seq": 15, "type": "run.finished", "node_id": null, "ts": 1789632952.3088498, "data": {"output": {"result": ""}, "usage": {"duration_ms": 12}, "duration_ms": 12}}], "issue": [{"seq": 1, "type": "run.started", "node_id": null, "ts": 1789371953.1991081, "data": {"nodes": 3, "resumed": false}}, {"seq": 2, "type": "node.started", "node_id": "in", "ts": 1789371953.205951, "data": {"node_type": "input", "label": "in"}}, {"seq": 3, "type": "node.finished", "node_id": "in", "ts": 1789371953.2096052, "data": {"duration_ms": 0, "attempt": 1, "preview": {}, "artifact": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a"}}, {"seq": 4, "type": "node.started", "node_id": "m", "ts": 1789371953.212884, "data": {"node_type": "metrics", "label": "m"}}, {"seq": 5, "type": "log", "node_id": "m", "ts": 1789371953.214304, "data": {"level": "info", "message": "口径卡 口径@v1:产出 1 个受控指标"}}, {"seq": 6, "type": "node.finished", "node_id": "m", "ts": 1789371953.2180061, "data": {"duration_ms": 0, "attempt": 1, "preview": {"kind": "metric_set", "caliber": "口径", "caliber_version": "v1", "metrics": "[1 项]", "text": "指标清单(口径 口径 @ v1):\n- a(营收)= 100"}, "artifact": "a5a29ed14620d0a2d45c7481ed2e0c38f5ae0a33f2e0bc9a310663cae39c8dae"}}, {"seq": 7, "type": "node.started", "node_id": "out", "ts": 1789371953.224318, "data": {"node_type": "output", "label": "out"}}, {"seq": 8, "type": "issuance", "node_id": "out", "ts": 1789371953.225867, "data": {"tier": "formal", "missing_required": [], "missing_expected": [], "unmatched": 0, "calibers": [{"node": "m", "caliber": "口径", "version": "v1"}]}}, {"seq": 9, "type": "node.finished", "node_id": "out", "ts": 1789371953.228422, "data": {"duration_ms": 3, "attempt": 1, "preview": {"result": "", "_issuance": "{'tier': 'formal', 'calibers': [{'node': 'm', 'caliber': '口径', 'version': 'v1'}], 'metrics_checked': 1, 'missing_required': [], 'missing_expected': [], 'unmatched_numbers': [], 'matched_numbers': 1, 'gaps': [], 'declared_at': '2026-09-14T07:45:53.223082+00:00'}"}, "artifact": "3496d28212f7919a0320209c49e967256075ebacb7ec73797eda119742044723"}}, {"seq": 10, "type": "run.finished", "node_id": null, "ts": 1789371953.232348, "data": {"output": {"result": "", "_issuance": {"tier": "formal", "calibers": [{"node": "m", "caliber": "口径", "version": "v1"}], "metrics_checked": 1, "missing_required": [], "missing_expected": [], "unmatched_numbers": [], "matched_numbers": 1, "gaps": [], "declared_at": "2026-09-14T07:45:53.223082+00:00"}}, "usage": {"duration_ms": 33}, "duration_ms": 33}}], "failed": [{"seq": 1, "type": "run.started", "node_id": null, "ts": 1789459923.142487, "data": {"nodes": 5, "resumed": false}}, {"seq": 2, "type": "node.started", "node_id": "start", "ts": 1789459923.155498, "data": {"node_type": "input", "label": "输入"}}, {"seq": 3, "type": "node.finished", "node_id": "start", "ts": 1789459923.160125, "data": {"duration_ms": 0, "attempt": 1, "preview": {"question": "111"}, "artifact": "33cce89d8426526c7100b83d4f47b5dd04be226b8ccbbfa1d6dc915ab84698ed"}}, {"seq": 4, "type": "node.started", "node_id": "init_state", "ts": 1789459923.171509, "data": {"node_type": "code", "label": "初始化状态"}}, {"seq": 5, "type": "sandbox.start", "node_id": "init_state", "ts": 1789459923.1733382, "data": {"language": "python", "limits": {"timeout": 30, "memory_mb": 512, "cpus": 1.0, "network": false, "max_output": 20000}, "isolation": "auto"}}, {"seq": 6, "type": "sandbox.end", "node_id": "init_state", "ts": 1789459923.517236, "data": {"ok": true, "exit_code": 0, "duration_ms": 352, "backend": "microvm", "stdout": "", "stderr": ""}}, {"seq": 7, "type": "node.finished", "node_id": "init_state", "ts": 1789459923.520313, "data": {"duration_ms": 353, "attempt": 1, "preview": {"ok": true, "exit_code": 0, "stdout": "", "stderr": "", "duration_ms": 352, "timed_out": false, "truncated": false, "backend": "microvm", "files_written": "[0 项]", "error": null, "session_reset": false, "data": null, "text": ""}, "artifact": "71b0561366b2cfafee35957ecdc488e91cc9f7d55378987fdce3696b4cbbbe2f"}}, {"seq": 8, "type": "node.started", "node_id": "loop_sum", "ts": 1789459923.522104, "data": {"node_type": "loop", "label": "循环累加"}}, {"seq": 9, "type": "node.failed", "node_id": "loop_sum", "ts": 1789459923.523083, "data": {"error": "循环条件写错了:表达式里不允许出现 Set", "duration_ms": 0, "node_id": "loop_sum"}}, {"seq": 10, "type": "run.failed", "node_id": null, "ts": 1789459923.5266151, "data": {"error": "循环条件写错了:表达式里不允许出现 Set"}}], "think": [{"seq": 1, "type": "run.started", "node_id": null, "ts": 1789354107.535924, "data": {"nodes": 5, "resumed": false}}, {"seq": 2, "type": "node.started", "node_id": "start", "ts": 1789354107.544369, "data": {"node_type": "input", "label": "条目列表"}}, {"seq": 3, "type": "node.finished", "node_id": "start", "ts": 1789354107.547843, "data": {"duration_ms": 0, "attempt": 1, "preview": {"items": "[3 项]"}, "artifact": "381bf97db4e270c59c4fded9e1ef27d7bf1f398a2807824b9921ab5f727b6839"}}, {"seq": 4, "type": "node.started", "node_id": "each", "ts": 1789354107.5504498, "data": {"node_type": "loop", "label": "逐条循环"}}, {"seq": 5, "type": "edge.taken", "node_id": "each", "ts": 1789354107.5522308, "data": {"branch": "body", "iteration": 0, "total": 3, "mode": "foreach"}}, {"seq": 6, "type": "node.finished", "node_id": "each", "ts": 1789354107.5559611, "data": {"duration_ms": 0, "attempt": 1, "preview": {"__decision__": "body", "iteration": 0, "total": 3, "item": "降低获客成本", "finished": false}, "artifact": "455052b94bfefdd7929de8a9f61ddca958735e20861ce61abc54b46f9943f8bc"}}, {"seq": 7, "type": "node.started", "node_id": "handle", "ts": 1789354107.5582778, "data": {"node_type": "llm", "label": "处理单条"}}, {"seq": 8, "type": "llm.start", "node_id": "handle", "ts": 1789354107.562285, "data": {"message_count": 1}}, {"seq": 39, "type": "llm.thinking", "node_id": "handle", "ts": 1789354113.069164, "data": {"text": "I need one concrete, actionable CAC-reduction tip under 40 characters. I'm considering splitting spend by channel and cutting the lowest-ROI 20% while boosting the best performer, or alternatively launching a referral reward program for existing customers.\n\n", "chars": 258, "truncated": false}}, {"seq": 40, "type": "llm.end", "node_id": "handle", "ts": 1789354113.075391, "data": {"model": "claude-opus-5", "duration_ms": 5504, "input_tokens": 59, "output_tokens": 236, "total_tokens": 295, "cost_usd": 0.006195, "calls": 1}}, {"seq": 41, "type": "node.finished", "node_id": "handle", "ts": 1789354113.0791411, "data": {"duration_ms": 5507, "attempt": 1, "preview": {"text": "按渠道核算获客成本,每月停掉 ROI 最低的 20% 预算,转投转化最高的渠道。", "thinking": "I need one concrete, actionable CAC-reduction tip under 40 characters. I'm considering splitting spend by channel and cutting the lowest-ROI 20% while boosting the best performer, or alternatively launching a referral reward program for existing customers.\n\n", "model": "claude-opus-5", "duration_ms": 5504}, "artifact": "cf412b9c7d3ed3df930e27d76710448b926cfa343e49c29e51cd32f4bab9b7dd"}}, {"seq": 42, "type": "node.started", "node_id": "collect", "ts": 1789354113.084119, "data": {"node_type": "transform", "label": "累积结果"}}, {"seq": 43, "type": "node.finished", "node_id": "collect", "ts": 1789354113.0874162, "data": {"duration_ms": 0, "attempt": 1, "preview": "· 按渠道核算获客成本,每月停掉 ROI 最低的 20% 预算,转投转化最高的渠道。\n", "artifact": "acc84091b73979eb7e09ab041698618f5dfb4373469fe483aa934df26ea182ab"}}, {"seq": 44, "type": "node.started", "node_id": "each", "ts": 1789354113.089597, "data": {"node_type": "loop", "label": "逐条循环"}}, {"seq": 45, "type": "edge.taken", "node_id": "each", "ts": 1789354113.090702, "data": {"branch": "body", "iteration": 1, "total": 3, "mode": "foreach"}}, {"seq": 46, "type": "node.finished", "node_id": "each", "ts": 1789354113.092248, "data": {"duration_ms": 0, "attempt": 1, "preview": {"__decision__": "body", "iteration": 1, "total": 3, "item": "提升续费率", "finished": false}, "artifact": "590540b5c92efaa4636f4c0c83f2d1508b3699b7047e6547621b3236f8937460"}}, {"seq": 47, "type": "node.started", "node_id": "handle", "ts": 1789354113.093664, "data": {"node_type": "llm", "label": "处理单条"}}, {"seq": 48, "type": "llm.start", "node_id": "handle", "ts": 1789354113.09708, "data": {"message_count": 1}}, {"seq": 104, "type": "llm.thinking", "node_id": "handle", "ts": 1789354125.2800598, "data": {"text": "I need something concise and actionable, under 40 characters. I'm drafting a suggestion around pushing usage-value reports before expiration paired with a renewal discount, and checking the character count to keep it within limits.\n\nActually, I think a higher-leverage suggestion would be identifying customers whose engagement drops in the 90 days before renewal and triggering proactive customer success outreach — this targets risk earlier. I'll go with that one since it's more impactful than the simple discount nudge.\n\n", "chars": 525, "truncated": false}}, {"seq": 105, "type": "llm.end", "node_id": "handle", "ts": 1789354125.286401, "data": {"model": "claude-opus-5", "duration_ms": 12177, "input_tokens": 58, "output_tokens": 637, "total_tokens": 695, "cost_usd": 0.016215, "calls": 1}}, {"seq": 106, "type": "node.finished", "node_id": "handle", "ts": 1789354125.291695, "data": {"duration_ms": 12180, "attempt": 1, "preview": {"text": "到期前 60 天识别活跃度下滑客户,由客户成功主动介入,附价值报告与续费方案。", "thinking": "I need something concise and actionable, under 40 characters. I'm drafting a suggestion around pushing usage-value reports before expiration paired with a renewal discount, and checking the character count to keep it within limits.\n\nActually, I think a higher-leverage suggestion would be identifying customers whose engagement drops in the 90 days before renewal and triggering proactive customer success outreach — this targets risk earlier. I'll go with that one since it's more impactful than the simple discount nudge.\n\n", "model": "claude-opus-5", "duration_ms": 12177}, "artifact": "db2eccae90c3baa4135f7f9cb8a987e7c07f5ef4f17783f4ba7b5d991971203f"}}, {"seq": 107, "type": "node.started", "node_id": "collect", "ts": 1789354125.296695, "data": {"node_type": "transform", "label": "累积结果"}}, {"seq": 108, "type": "node.finished", "node_id": "collect", "ts": 1789354125.300266, "data": {"duration_ms": 0, "attempt": 1, "preview": "· 按渠道核算获客成本,每月停掉 ROI 最低的 20% 预算,转投转化最高的渠道。\n· 到期前 60 天识别活跃度下滑客户,由客户成功主动介入,附价值报告与续费方案。\n", "artifact": "ca327d4774f9631dcf16e42191e827d1de9ed3409eac4844a138d87bd811714d"}}, {"seq": 109, "type": "node.started", "node_id": "each", "ts": 1789354125.304375, "data": {"node_type": "loop", "label": "逐条循环"}}, {"seq": 110, "type": "edge.taken", "node_id": "each", "ts": 1789354125.307219, "data": {"branch": "body", "iteration": 2, "total": 3, "mode": "foreach"}}, {"seq": 111, "type": "node.finished", "node_id": "each", "ts": 1789354125.310409, "data": {"duration_ms": 0, "attempt": 1, "preview": {"__decision__": "body", "iteration": 2, "total": 3, "item": "缩短上线周期", "finished": false}, "artifact": "aa1bd1a9f8c20746ec2b0cde1cef9b445ca4dba46fcf49cef19b9c87db80adc6"}}, {"seq": 112, "type": "node.started", "node_id": "handle", "ts": 1789354125.313403, "data": {"node_type": "llm", "label": "处理单条"}}, {"seq": 113, "type": "llm.start", "node_id": "handle", "ts": 1789354125.319797, "data": {"message_count": 1}}, {"seq": 134, "type": "llm.end", "node_id": "handle", "ts": 1789354128.034537, "data": {"model": "claude-opus-5", "duration_ms": 2710, "input_tokens": 60, "output_tokens": 49, "total_tokens": 109, "cost_usd": 0.001525, "calls": 1}}, {"seq": 135, "type": "node.finished", "node_id": "handle", "ts": 1789354128.042933, "data": {"duration_ms": 2715, "attempt": 1, "preview": {"text": "建议:将部署流程自动化,代码合并后自动触发构建、测试与灰度发布,把人工审批环节压缩到单点。", "thinking": "", "model": "claude-opus-5", "duration_ms": 2710}, "artifact": "1dd859e26cadc45a1ae451b141c0b52fbf55eaa544cc2b644692c9aa999e27d7"}}, {"seq": 136, "type": "node.started", "node_id": "collect", "ts": 1789354128.0519838, "data": {"node_type": "transform", "label": "累积结果"}}, {"seq": 137, "type": "node.finished", "node_id": "collect", "ts": 1789354128.061368, "data": {"duration_ms": 0, "attempt": 1, "preview": "· 按渠道核算获客成本,每月停掉 ROI 最低的 20% 预算,转投转化最高的渠道。\n· 到期前 60 天识别活跃度下滑客户,由客户成功主动介入,附价值报告与续费方案。\n· 建议:将部署流程自动化,代码合并后自动触发构建、测试与灰度发布,把人工审批环节压缩到单点。\n", "artifact": "65a6ab813cc4b27d3d4b87eb5a043cfb496f7c11edb1ec4f5c4a613e34e469e4"}}, {"seq": 138, "type": "node.started", "node_id": "each", "ts": 1789354128.0655859, "data": {"node_type": "loop", "label": "逐条循环"}}, {"seq": 139, "type": "edge.taken", "node_id": "each", "ts": 1789354128.068269, "data": {"branch": "done", "iteration": 3, "total": 3, "mode": "foreach"}}, {"seq": 140, "type": "node.finished", "node_id": "each", "ts": 1789354128.0713658, "data": {"duration_ms": 0, "attempt": 1, "preview": {"__decision__": "done", "iteration": 3, "total": 3, "item": null, "finished": true}, "artifact": "3d1328a816ace27d9b66281108eb1132835835eb06a38b9deb49f3389e3e7ec0"}}, {"seq": 141, "type": "node.started", "node_id": "done", "ts": 1789354128.073812, "data": {"node_type": "output", "label": "汇总"}}, {"seq": 142, "type": "node.finished", "node_id": "done", "ts": 1789354128.07654, "data": {"duration_ms": 0, "attempt": 1, "preview": {"逐条建议": "· 按渠道核算获客成本,每月停掉 ROI 最低的 20% 预算,转投转化最高的渠道。\n· 到期前 60 天识别活跃度下滑客户,由客户成功主动介入,附价值报告与续费方案。\n· 建议:将部署流程自动化,代码合并后自动触发构建、测试与灰度发布,把人工审批环节压缩到单点。\n", "处理条数": "3"}, "artifact": "668932bde6139ccac8af5eff0756698a1e1fd1881c7e53eb7e2f437deae5d655"}}, {"seq": 143, "type": "run.finished", "node_id": null, "ts": 1789354128.081253, "data": {"output": {"逐条建议": "· 按渠道核算获客成本,每月停掉 ROI 最低的 20% 预算,转投转化最高的渠道。\n· 到期前 60 天识别活跃度下滑客户,由客户成功主动介入,附价值报告与续费方案。\n· 建议:将部署流程自动化,代码合并后自动触发构建、测试与灰度发布,把人工审批环节压缩到单点。\n", "处理条数": "3"}, "usage": {"input_tokens": 177, "output_tokens": 922, "total_tokens": 1099, "cost_usd": 0.023934999999999998, "calls": 3, "duration_ms": 20544}, "duration_ms": 20544}}]} \ No newline at end of file diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts new file mode 100644 index 00000000..9856f347 --- /dev/null +++ b/frontend/src/run/decode.ts @@ -0,0 +1,566 @@ +import type { RunEvent } from '../types' + +/** + * 事件 → 人能看懂的步骤。 + * + * 这是全站唯一的翻译层:画布时间线、助手栏、运行页都用它。分两套翻译的话, + * 同一次运行在不同地方会读出不同的故事,而用户没法判断哪个是真的。 + * + * 三条原则: + * + * 1. **不回写事件**。事件表是审计凭证——run 终态时对全部已落库事件算 + * manifest_hash,事后增删改会对不上。翻译只发生在前端内存里。 + * 2. **成对事件合并成一行**。tool.start + tool.end 是同一件事的两个时刻, + * 拆成两行等于把"查询数据"和"查完了"当成两件事讲。 + * 3. **节点是天然的分组单位**。一个图节点内部可能有好几轮模型调用和工具 + * 调用,它们属于同一个"步骤"——这也正是用户说的"简要思考节点": + * 节点本身就是那个节点,里面的动作是它的过程。 + */ + +export type StepKind = + | 'node' // 一个图节点的执行(可能带子步骤) + | 'think' // 模型的思考 + | 'llm' // 模型调用 + | 'query' // 数据库查询 + | 'schema' // 看表结构 + | 'tool' // 其他工具 + | 'code' // 沙箱代码 + | 'branch' // 分支走向 + | 'human' // 人工介入 + | 'issuance' // 出具判定 + | 'note' // 日志/提示 + | 'error' + | 'lifecycle' // 开始/结束 + +export type StepStatus = 'running' | 'done' | 'failed' | 'waiting' + +export interface Step { + id: string + seq: number + kind: StepKind + /** 一句人话,不带技术黑话 */ + title: string + /** 展开后看的东西:SQL 原文、工具参数、结果预览、思考全文 */ + detail?: string + /** 行尾的次要信息:耗时、行数 */ + meta?: string + status?: StepStatus + level?: 'info' | 'warn' | 'error' + nodeId?: string + /** 工件 id,可下钻到完整证据 */ + artifact?: string + children?: Step[] +} + +// 这两个是流式增量,后端根本不落库(_EPHEMERAL)。单次运行的 delta 量级会 +// 把列表淹掉,而且刷新页面后它们不会回来——UI 不能建立在它们之上。 +const EPHEMERAL = new Set(['llm.token', 'llm.thinking.delta']) + +// 节点类型的中文名。刻意内联而不是 import canvas/nodeDefs:解码器是纯粹的 +// 翻译层,画布只是它的消费者之一,反过来依赖画布会让运行页也被迫加载 +// 整套节点定义(含图标、表单 schema)。这张表只有 16 行,重复得起。 +const TYPE_LABEL: Record = { + input: '输入', output: '成果', llm: '模型调用', agent: 'Agent', + supervisor: '多 Agent 协作', tool: '调用工具', code: '沙箱代码', + branch: '条件分支', loop: '循环', subgraph: '子工作流', + memory: '长期记忆', retrieve: '知识检索', transform: '数据整形', + human: '人工介入', validate: '结构校验', metrics: '口径卡', +} + +// 只对开发者有意义、对使用者是噪音的。不是丢弃事件本身(原始事件另有视图), +// 只是不进这条给人看的流。 +const SILENT = new Set(['usage', 'node.skipped']) + +const num = (v: unknown): number | undefined => + typeof v === 'number' && Number.isFinite(v) ? v : undefined + +export function formatDuration(ms?: number): string { + if (ms == null) return '' + if (ms < 1000) return `${Math.round(ms)}ms` + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s` + return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s` +} + +/** 从 db_query 的结果预览里抠出行数——用户关心的是"查到多少",不是 JSON。 */ +function rowCountOf(preview: string): number | undefined { + const m = preview.match(/"row_count":\s*(\d+)/) + return m ? Number(m[1]) : undefined +} + +/** 把查询结果预览转成小表格能用的形状;解析不了就算了,原样展示。 */ +export function parseQueryResult(preview: string): + { columns: string[]; rows: unknown[][]; truncated: boolean } | null { + try { + const raw = preview.trim().startsWith('"') ? JSON.parse(preview) : preview + const data = typeof raw === 'string' ? JSON.parse(raw) : raw + if (Array.isArray(data?.columns) && Array.isArray(data?.rows)) { + return { columns: data.columns, rows: data.rows, truncated: !!data.truncated } + } + } catch { + /* 不是标准查询结果,当普通文本 */ + } + return null +} + +function toolStep(seq: number, tool: string, args: Record): Step { + const base = { id: `tool-${seq}`, seq, status: 'running' as StepStatus } + // 数据库工具单独认:它是这个产品最主要的取数方式,"调用 db_query__warehouse" + // 这种说法等于没翻译 + if (tool.startsWith('db_query')) { + const source = tool.replace(/^db_query__/, '') + return { ...base, kind: 'query', title: `在 ${source} 上查询数据`, + detail: String(args?.sql ?? '') } + } + if (tool.startsWith('db_schema')) { + const table = String(args?.table ?? '') + return { ...base, kind: 'schema', + title: table ? `查看 ${table} 的字段` : '列出有哪些表' } + } + const argText = Object.keys(args ?? {}).length + ? JSON.stringify(args, null, 2) : '' + return { ...base, kind: 'tool', title: `调用 ${tool}`, detail: argText } +} + +/** + * 把一次运行的事件解码成步骤树。 + * + * 顶层是节点,节点内部的模型/工具调用是子步骤。没有 node_id 的事件 + * (run.*、issuance 这类)留在顶层,按时间顺序穿插。 + */ +export function decodeRun(events: RunEvent[]): Step[] { + const out: Step[] = [] + /** node_id → 该节点的顶层 Step,用于把子步骤挂进去 */ + const nodeSteps = new Map() + /** call_id(或工具名兜底)→ 待配对的工具 Step */ + const pendingTools = new Map() + /** node_id → 待配对的模型调用 Step */ + const pendingLlm = new Map() + /** 已见过的 interrupt_id,避免 human.requested 与 run.interrupted 各出一行 */ + const seenInterrupts = new Set() + + const push = (step: Step, nodeId?: string) => { + const parent = nodeId ? nodeSteps.get(nodeId) : undefined + if (parent) (parent.children ??= []).push(step) + else out.push(step) + } + + for (const event of events) { + const type = String(event.type) + if (EPHEMERAL.has(type) || SILENT.has(type)) continue + const d: any = event.data ?? {} + const seq = event.seq ?? 0 + const nodeId = event.node_id ?? undefined + + switch (type) { + case 'run.started': + case 'run.resumed': { + const resumed = type === 'run.resumed' || !!d.resumed + // 恢复一次会同时来 run.resumed 和 run.started(resumed=true), + // 说两遍"继续执行"只会让人以为恢复了两次 + if (resumed && out.some((s) => s.kind === 'lifecycle' && s.title === '继续执行') + && !out.some((s) => s.status === 'waiting')) { + break + } + if (resumed) { + // 恢复意味着之前那条"等你确认"已经过去了 + out.forEach((s) => { if (s.status === 'waiting') s.status = 'done' }) + nodeSteps.forEach((s) => { + s.children?.forEach((c) => { if (c.status === 'waiting') c.status = 'done' }) + }) + } + out.push({ + id: `s-${seq}`, seq, kind: 'lifecycle', status: 'running', + title: resumed ? '继续执行' : `开始执行(${d.nodes ?? '?'} 步)`, + }) + break + } + + case 'node.started': { + // data.label 是节点标题**随事件传递的唯一来源**。靠画布 nodes 反查的话, + // 助手栏和运行页没加载画布,标题会退化成裸 node_id。 + // label 是节点标题随事件传递的唯一来源,但没起过名字的节点它等于 + // 节点 id(后端兜底),"in"/"h"/"out" 对用户毫无意义——退到节点 + // 类型的中文名,至少能看出这步在干嘛 + const label = String(d.label ?? '') + const typeName = TYPE_LABEL[String(d.node_type ?? '')] + const title = (label && label !== nodeId ? label : typeName) || nodeId || '执行步骤' + // 人工审批恢复后 LangGraph 会重放该节点,node.started 因此来第二遍。 + // 那不是"又执行了一个步骤",是同一步继续——复用原来那条, + // 否则时间线上每审批一次就多一个同名节点。 + const existing = nodeId ? nodeSteps.get(nodeId) : undefined + if (existing) { + existing.status = 'running' + break + } + const step: Step = { + id: `n-${nodeId ?? seq}`, seq, kind: 'node', nodeId, title, status: 'running', + } + if (nodeId) nodeSteps.set(nodeId, step) + out.push(step) + break + } + + case 'node.finished': { + const step = nodeId ? nodeSteps.get(nodeId) : undefined + if (step) { + step.status = 'done' + step.meta = formatDuration(num(d.duration_ms)) + step.artifact = d.artifact || step.artifact + // 空对象/空串不是"详情",显示出来只是噪音 + const raw = d.preview + const empty = raw == null || raw === '' || + (typeof raw === 'object' && Object.keys(raw).length === 0) + if (!empty) { + const preview = typeof raw === 'string' ? raw : JSON.stringify(raw, null, 2) + step.detail = preview.slice(0, 4000) + } + } + break + } + + case 'node.failed': { + const step = nodeId ? nodeSteps.get(nodeId) : undefined + if (step) { + step.status = 'failed' + step.level = 'error' + step.detail = String(d.error ?? '') + step.meta = formatDuration(num(d.duration_ms)) + } else { + push({ id: `e-${seq}`, seq, kind: 'error', level: 'error', + title: String(d.error ?? '这一步失败了'), nodeId }, nodeId) + } + break + } + + case 'llm.start': + pendingLlm.set(nodeId ?? '_', { + id: `llm-${seq}`, seq, kind: 'llm', title: '思考并作答', + status: 'running', nodeId, + }) + break + + case 'llm.end': { + const step = pendingLlm.get(nodeId ?? '_') + if (step) { + step.status = 'done' + // 不给 token 数和美元——那是账单视角,不是"它干了什么"。 + // 成本在运行详情的用量区单独看。 + step.meta = formatDuration(num(d.duration_ms)) + push(step, nodeId) + pendingLlm.delete(nodeId ?? '_') + } + break + } + + case 'llm.thinking': { + // 只有 Anthropic 系模型会产出(thinking_text 认的是 thinking 块)。 + // 换个 provider 就没有——所以它是增强信息,主干靠上面那些步骤撑着。 + const text = String(d.text ?? d.delta ?? '') + if (!text.trim()) break + push({ + id: `th-${seq}`, seq, kind: 'think', nodeId, status: 'done', + title: text.replace(/\s+/g, ' ').slice(0, 60) + (text.length > 60 ? '…' : ''), + detail: text + (d.truncated ? '\n\n(已截断)' : ''), + }, nodeId) + break + } + + case 'tool.start': { + const step = toolStep(seq, String(d.tool ?? ''), d.args ?? {}) + step.nodeId = nodeId + // call_id 才是可靠的配对键:同一节点并发调同名工具时,按名字配会错位。 + // 独立 Tool 节点不带 call_id,回退到工具名。 + pendingTools.set(String(d.call_id || d.tool || seq), step) + push(step, nodeId) + break + } + + case 'tool.end': + case 'tool.error': { + const key = String(d.call_id || d.tool || '') + const step = pendingTools.get(key) + const preview = String(d.preview ?? d.error ?? '') + if (step) { + const failed = type === 'tool.error' || preview.startsWith('SQL 被拒绝') + || preview.startsWith('查询失败') + step.status = failed ? 'failed' : 'done' + if (failed) step.level = 'error' + // tool.error 只有 {tool,error},没有 duration_ms/preview——不能假设统一形状 + const rows = rowCountOf(preview) + const parts = [ + rows != null ? `${rows} 行` : '', + formatDuration(num(d.duration_ms)), + ].filter(Boolean) + step.meta = parts.join(' · ') + step.artifact = d.artifact + if (step.kind === 'query' || step.kind === 'schema' || failed) { + // 查询保留 SQL 作为 detail,结果另挂;失败时结果就是错误原因 + step.detail = failed ? `${step.detail ?? ''}\n\n${preview}`.trim() : step.detail + ;(step as any).result = preview + } else { + step.detail = preview.slice(0, 4000) + } + pendingTools.delete(key) + } + break + } + + case 'sandbox.start': + pendingTools.set(`sandbox-${nodeId ?? seq}`, (() => { + const step: Step = { + id: `sb-${seq}`, seq, kind: 'code', nodeId, status: 'running', + title: `运行${d.language === 'python' ? ' Python' : d.language ? ` ${d.language}` : ''}代码`, + } + push(step, nodeId) + return step + })()) + break + + case 'sandbox.end': { + const step = pendingTools.get(`sandbox-${nodeId ?? seq}`) + if (step) { + step.status = d.ok ? 'done' : 'failed' + if (!d.ok) step.level = 'error' + step.meta = formatDuration(num(d.duration_ms)) + const body = [d.stdout, d.stderr].filter(Boolean).join('\n').slice(0, 4000) + step.detail = body || (d.ok ? '(无输出)' : `退出码 ${d.exit_code}`) + pendingTools.delete(`sandbox-${nodeId ?? seq}`) + } + break + } + + case 'edge.taken': + push({ + id: `br-${seq}`, seq, kind: 'branch', nodeId, status: 'done', + title: `走「${d.branch}」这条路` + + (d.iteration != null ? `(第 ${Number(d.iteration) + 1} 轮)` : ''), + detail: d.reason ? String(d.reason) : undefined, + }, nodeId) + break + + case 'human.requested': + case 'run.interrupted': { + // 同一次中断会来两条事件,而且审批恢复后节点重放还会再来一遍 + // (LangGraph 的重放语义,不是 bug)。三条都指向同一次"等你确认", + // 界面上只该有一个。 + // + // 不能用 interrupt_id 做键:human.requested 压根没这个字段, + // run.interrupted 才有。两者共有的是 payload 里的 node_id + mode + title, + // 用它们才能让三条算出同一个键。 + const payload = d.payload ?? d + const key = [payload.node_id ?? nodeId, payload.mode ?? '', payload.title ?? ''].join('|') + if (seenInterrupts.has(key)) break + seenInterrupts.add(key) + push({ + id: `hm-${seq}`, seq, kind: 'human', nodeId, status: 'waiting', level: 'warn', + title: String(payload.title || d.title || '等你确认'), + detail: [payload.message, payload.tool ? `工具:${payload.tool}` : ''] + .filter(Boolean).join('\n') || undefined, + }, nodeId) + break + } + + case 'human.resolved': { + const r = d.response ?? {} + const approved = typeof r === 'object' ? r.approved : undefined + push({ + id: `hr-${seq}`, seq, kind: 'human', nodeId, status: 'done', + title: approved === false ? '你驳回了' : '你放行了', + detail: typeof r === 'object' && r.note ? String(r.note) : undefined, + }, nodeId) + break + } + + case 'issuance': { + const tier = String(d.tier ?? '') + const label = { formal: '正式出具', degraded: '降档出具', withheld: '不予出具' }[tier] ?? tier + const gaps: string[] = [] + if (d.missing_required?.length) gaps.push(`缺必需指标 ${d.missing_required.join('、')}`) + if (d.missing_expected?.length) gaps.push(`缺期望指标 ${d.missing_expected.join('、')}`) + if (num(d.unmatched)) gaps.push(`${d.unmatched} 个数字无法回指指标集`) + out.push({ + id: `is-${seq}`, seq, kind: 'issuance', status: 'done', + level: tier === 'formal' ? 'info' : tier === 'withheld' ? 'error' : 'warn', + title: gaps.length ? `${label}:${gaps.join(';')}` : label, + }) + break + } + + case 'caliber.upgrade': + out.push({ + id: `cu-${seq}`, seq, kind: 'note', level: 'warn', status: 'done', + title: `口径卡 v${d.pinned} → v${d.latest} 有新版,按「${d.policy_label ?? d.policy}」处置`, + }) + break + + case 'agent.step.start': + push({ + id: `as-${seq}`, seq, kind: 'note', nodeId, status: 'running', + title: `${d.agent}:${String(d.instruction ?? '').slice(0, 80)}`, + }, nodeId) + break + + case 'agent.step.end': + push({ + id: `ae-${seq}`, seq, kind: 'note', nodeId, status: 'done', + title: `${d.agent} 回复`, meta: formatDuration(num(d.duration_ms)), + detail: String(d.preview ?? '').slice(0, 2000), + }, nodeId) + break + + case 'log': { + const level = String(d.level ?? 'info') + // info 级日志是给排查用的,不进主流程——但 warn/error 用户必须看到 + if (level === 'info') break + push({ + id: `lg-${seq}`, seq, kind: 'note', nodeId, status: 'done', + level: level === 'error' ? 'error' : 'warn', + title: String(d.message ?? ''), + }, nodeId) + break + } + + case 'run.failed': { + const msg = String(d.error ?? '运行失败') + // 节点失败会先报一次,run.failed 往往是同一句话——说两遍不会让人 + // 更明白,只会让人以为出了两个错 + const dup = out.some((s) => s.status === 'failed' && + (s.detail === msg || s.title === msg)) + out.forEach((s) => { if (s.kind === 'lifecycle' && s.status === 'running') s.status = 'failed' }) + if (!dup) { + out.push({ + id: `rf-${seq}`, seq, kind: 'error', level: 'error', status: 'failed', title: msg, + }) + } + break + } + + case 'run.cancelled': + out.push({ id: `rc-${seq}`, seq, kind: 'lifecycle', status: 'failed', title: '已取消' }) + break + + case 'run.finished': { + // 开头那条"开始执行"要收尾,否则跑完了还挂着一个转圈的图标 + const opener = out.find((s) => s.kind === 'lifecycle' && s.status === 'running') + if (opener) opener.status = 'done' + out.push({ + id: `rd-${seq}`, seq, kind: 'lifecycle', status: 'done', + title: '完成', meta: formatDuration(num(d.duration_ms)), + }) + break + } + + default: + // 新增事件类型时不要静默吞掉——宁可显示一条原始的,也比让人觉得 + // "什么都没发生"好。加了新事件记得回来补一条映射。 + push({ + id: `x-${seq}`, seq, kind: 'note', nodeId, status: 'done', + title: type, detail: JSON.stringify(d, null, 2).slice(0, 1000), + }, nodeId) + } + } + + // 还没收到 end 的工具/模型调用保持 running——它们正在进行,不是丢了 + return out +} + +// ------------------------------------------------------------------------- +// Copilot 建图阶段:操作流 → 同一套 Step +// ------------------------------------------------------------------------- + +export interface CopilotOp { + op: string + [k: string]: any +} + +const PHASE_LABEL: Record = { + connecting: '正在连接模型', + planning: '正在理解需求、规划步骤', + building: '正在搭建流程', + wiring: '正在连接数据流', + finalizing: '正在排版和校验', +} + +/** + * Copilot 的操作流解码。 + * + * 和运行事件是两套完全独立的协议(不共享 seq、node_id、不落库),只在这里 + * 统一成同一种 Step,让用户看到的是一条连续的过程,而不是"建图"和"执行" + * 两段风格迥异的日志。 + */ +export function decodeCopilot(ops: CopilotOp[]): Step[] { + const out: Step[] = [] + let i = 0 + let nodeCount = 0 + for (const op of ops) { + i += 1 + switch (op.op) { + case 'thinking': { + const text = String(op.delta ?? '') + if (!text.trim()) break + const last = out[out.length - 1] + // 思考是连续流,一片 delta 一行会碎成几十条。同一段连续思考并成一条, + // 标题取开头、详情是全文——这才是"一次思考是一个节点"。 + if (last?.kind === 'think') { + last.detail = (last.detail ?? '') + text + last.title = last.detail.replace(/\s+/g, ' ').slice(0, 60) + + (last.detail.length > 60 ? '…' : '') + } else { + out.push({ id: `ct-${i}`, seq: i, kind: 'think', status: 'done', + title: text.replace(/\s+/g, ' ').slice(0, 60), detail: text }) + } + break + } + case 'heartbeat': { + // 心跳只更新"还活着"的状态,不该每 3 秒堆一行 + const last = out[out.length - 1] + const label = PHASE_LABEL[String(op.phase)] ?? '正在处理' + const elapsed = num(op.elapsed_ms) + if (last?.kind === 'lifecycle' && last.status === 'running') { + last.title = label + last.meta = elapsed ? formatDuration(elapsed) : last.meta + } else { + out.push({ id: `hb-${i}`, seq: i, kind: 'lifecycle', status: 'running', + title: label, meta: elapsed ? formatDuration(elapsed) : undefined }) + } + break + } + case 'plan': + out.push({ id: `cp-${i}`, seq: i, kind: 'note', status: 'done', + title: String(op.summary ?? '想好了怎么做') }) + break + case 'add_node': + nodeCount += 1 + out.push({ + id: `cn-${i}`, seq: i, kind: 'node', status: 'done', + title: String(op.node?.data?.label || op.node?.label || op.node?.id || '新步骤'), + meta: String(op.node?.type ?? ''), + }) + break + case 'update_node': + out.push({ id: `cu-${i}`, seq: i, kind: 'note', status: 'done', + title: `调整了「${op.id}」` }) + break + case 'remove_node': + out.push({ id: `cr-${i}`, seq: i, kind: 'note', status: 'done', + title: `去掉了「${op.id}」` }) + break + // 连线不单独成行:用户关心有哪些步骤,不关心箭头 + case 'add_edge': + case 'remove_edge': + break + case 'done': + out.push({ id: `cd-${i}`, seq: i, kind: 'lifecycle', status: 'done', + title: `流程搭好了,共 ${nodeCount} 步`, + detail: String(op.explanation ?? '') || undefined }) + break + case 'error': + out.push({ id: `ce-${i}`, seq: i, kind: 'error', level: 'error', status: 'failed', + title: String(op.message ?? '生成失败') }) + break + default: + break + } + } + return out +} diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs new file mode 100644 index 00000000..300ec0e0 --- /dev/null +++ b/scripts/check-decode.mjs @@ -0,0 +1,116 @@ +// 事件解码器的回归检查。 +// +// 用**真实运行**导出的事件跑(fixtures.json 取自 data/agentlab.db),不是构造的样本: +// 这里要守的恰恰是真实事件的脏细节——审批会重放、成对事件字段不齐、 +// 空 preview、节点没起过名字。构造的样本永远不会长成那样。 +// +// 转译借 vite dev server(它 serve 的就是应用实际运行的那份),所以跑之前 +// 前端得起着:./scripts/dev.sh +import { readFileSync } from 'node:fs' + +const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273' +const root = new URL('..', import.meta.url).pathname +const fixtures = JSON.parse( + readFileSync(`${root}frontend/src/run/__tests__/fixtures.json`, 'utf8')) + +const res = await fetch(`${WEB}/src/run/decode.ts?t=${Date.now()}`) +if (!res.ok) { + console.error(`✗ 拿不到转译结果(${WEB})——前端没起?先跑 ./scripts/dev.sh`) + process.exit(1) +} +const mod = await import( + 'data:text/javascript;base64,' + Buffer.from(await res.text()).toString('base64')) + +let failed = 0 +const check = (name, cond, detail = '') => { + console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`) + if (!cond) failed++ +} +const flatten = (steps) => + steps.flatMap((s) => [s, ...(s.children ? flatten(s.children) : [])]) + +console.log('=== 数据库查询 ===') +{ + const steps = mod.decodeRun(fixtures.db) + const all = flatten(steps) + const q = all.find((s) => s.kind === 'query') + check('查询步骤说人话,不是 db_query__warehouse(...)', + !!q && q.title.includes('查询数据') && !q.title.includes('db_query__'), q?.title) + check('SQL 原文留在详情里可展开', !!q?.detail?.includes('SELECT')) + check('工具调用挂在节点下,不是平铺', + steps.some((s) => s.kind === 'node' && s.children?.some((c) => c.kind === 'query'))) +} + +console.log('\n=== 人工审批(含重放)===') +{ + const steps = mod.decodeRun(fixtures.human) + const all = flatten(steps) + // 一次审批会产生 human.requested ×2(重放)+ run.interrupted,界面上只该有一条 + check('"等你确认"只出现一次', + all.filter((s) => s.kind === 'human' && s.title.includes('确认')).length === 1, + `${all.filter((s) => s.kind === 'human' && s.title.includes('确认')).length} 条`) + check('"继续执行"只出现一次', + steps.filter((s) => s.title === '继续执行').length === 1, + `${steps.filter((s) => s.title === '继续执行').length} 条`) + // 节点重放不该让同一个节点在时间线上出现两遍 + const nodeIds = steps.filter((s) => s.kind === 'node').map((s) => s.nodeId) + check('节点不因重放而重复', new Set(nodeIds).size === nodeIds.length, + nodeIds.join(',')) + check('审批结果有交代', all.some((s) => s.title.includes('放行') || s.title.includes('驳回'))) +} + +console.log('\n=== 出具判定 ===') +{ + const steps = mod.decodeRun(fixtures.issue) + check('出具档位翻译成中文', steps.some((s) => s.kind === 'issuance' && s.title.includes('出具')), + steps.find((s) => s.kind === 'issuance')?.title) +} + +console.log('\n=== 失败运行 ===') +{ + const steps = mod.decodeRun(fixtures.failed) + const all = flatten(steps) + const errs = all.filter((s) => s.level === 'error') + check('错误只说一遍(节点失败与 run.failed 是同一件事)', + new Set(errs.map((s) => s.detail ?? s.title)).size === errs.length, + `${errs.length} 条错误`) + check('开头那条不会一直转圈', + !steps.some((s) => s.kind === 'lifecycle' && s.status === 'running')) +} + +console.log('\n=== 通用规则 ===') +{ + const all = Object.values(fixtures).flatMap((evts) => flatten(mod.decodeRun(evts))) + check('没有裸节点 id 当标题', + !all.some((s) => s.kind === 'node' && /^(in|out|h|m|t\d|n\d)$/.test(s.title)), + all.filter((s) => s.kind === 'node' && /^(in|out|h|m)$/.test(s.title)).map(s=>s.title).join(',')) + check('没有空详情', !all.some((s) => s.detail === '{}' || s.detail === '')) + check('没有原始事件名泄漏到标题', + !all.some((s) => /^(node|run|llm|tool)\./.test(s.title)), + all.filter((s) => /^(node|run|llm|tool)\./.test(s.title)).map(s=>s.title).join(',')) + check('耗时是人类单位', all.filter((s) => s.meta).every((s) => /(\d+ms|\d+\.\d+s|\d+m\d+s|\d+ 行)/.test(s.meta))) +} + +console.log('\n=== Copilot 操作流 ===') +{ + const ops = [ + { op: 'heartbeat', phase: 'planning', elapsed_ms: 3000 }, + { op: 'heartbeat', phase: 'planning', elapsed_ms: 6000 }, + { op: 'thinking', delta: '用户要查销量。' }, + { op: 'thinking', delta: '先看表结构。' }, + { op: 'plan', summary: '三步:取数→汇总→出具' }, + { op: 'add_node', node: { id: 'q', type: 'tool', data: { label: '取数' } } }, + { op: 'add_edge', edge: { source: 'a', target: 'q' } }, + { op: 'done', explanation: '完成' }, + ] + const steps = mod.decodeCopilot(ops) + check('心跳不堆行', steps.filter((s) => s.kind === 'lifecycle' && s.status === 'running').length === 1) + check('连续思考并成一条', steps.filter((s) => s.kind === 'think').length === 1, + `${steps.filter((s) => s.kind === 'think').length} 条`) + check('思考全文保留在详情', steps.find((s) => s.kind === 'think')?.detail?.includes('先看表结构')) + check('连线不单独成行', !steps.some((s) => s.title.includes('edge'))) + check('节点用标签不用 id', steps.some((s) => s.kind === 'node' && s.title === '取数')) +} + +console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过') +process.exit(failed ? 1 : 0)