mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
事件解码器:全站唯一的"事件 → 人话"翻译层
UI 重构的地基。现有的 describe()(RunPanel.tsx:392)是工程师视角——
db_query__warehouse({"sql":...}) 是函数调用语法、1234+567 tok · $0.0021 是账单、
exit=0 · microvm 是退出码,而且 25 个事件类型里一半直接 return '' 没翻译。
做成独立模块而不是散在各页面,是因为画布时间线、助手栏、运行页要读同一个
故事。两套翻译的话同一次运行在不同地方讲得不一样,用户没法判断哪个是真的。
## 三条原则
- **不回写事件**:run 终态时对全部已落库事件算 manifest_hash,事后增删改会
对不上。翻译只在前端内存里发生。
- **成对事件合并成一行**:tool.start + tool.end 是同一件事的两个时刻,拆两行
等于把"查询数据"和"查完了"当成两件事。按 call_id 配对(并发调同名工具时
按名字会错位),独立 Tool 节点没有 call_id 则回退到名字。
- **节点是天然的分组单位**:一个图节点内部可能有好几轮模型和工具调用,
它们属于同一个步骤——这正是"简要思考节点"该有的结构。
## 用真实事件调出来的五个坑
从库里导出 5 类真实运行(带查询/带审批/带出具/失败/带思考)当 fixture,
构造的样本永远不会长成那样:
- **审批重复三次**。human.requested 没有 interrupt_id、run.interrupted 才有,
两个 key 算不到一起;而审批恢复后 LangGraph 重放节点又发一遍 human.requested。
改用两者共有的 node_id|mode|title 做键,三条收敛成一条。
- **节点重放让同名节点出现两次**。node.started 第二次来时复用原来那条。
- **"继续执行"说两遍**。run.resumed 和 run.started(resumed=true) 是同一件事。
- **标题退化成裸 id**。没起过名字的节点 label 等于 id(后端兜底),"in"/"h"/"out"
对用户毫无意义——退到节点类型的中文名。
- **空 preview 当详情显示**。{} 不是详情,是噪音。
## 刻意不做的
- 不显示 token 数和美元:那是账单视角,不是"它干了什么"
- 不依赖 canvas/nodeDefs:解码器是纯翻译层,画布只是消费者之一。
16 行的类型名映射重复得起,反向依赖会让运行页被迫加载整套节点定义
- 未知事件不静默吞掉:宁可显示一条原始的,也比让人觉得"什么都没发生"好
## 验证
scripts/check-decode.mjs 用真实 fixture 跑 21 条断言(转译借 vite dev server,
测的就是应用实际运行的那份)。守的是"审批只出现一次""没有裸 id""没有事件名
泄漏到标题"这类会悄悄退化的性质。
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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<string, string> = {
|
||||
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<string, any>): 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<string, Step>()
|
||||
/** call_id(或工具名兜底)→ 待配对的工具 Step */
|
||||
const pendingTools = new Map<string, Step>()
|
||||
/** node_id → 待配对的模型调用 Step */
|
||||
const pendingLlm = new Map<string, Step>()
|
||||
/** 已见过的 interrupt_id,避免 human.requested 与 run.interrupted 各出一行 */
|
||||
const seenInterrupts = new Set<string>()
|
||||
|
||||
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<string, string> = {
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user