Files
AgentLab/frontend/src/run/decode.ts
T

3474 lines
176 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { CatalogDriftTable, RunEvent, SqlCheckItem, TeamMember, TeamRound, TeamRun, ToolChange } from '../types'
import type { NodeState, RunPhase } from './trace'
import { formatDuration, formatNumber } from '../lib/format'
import {
ABSENCE_CLAIM_TEXT, CATALOG_DRIFT_TEXT, CATALOG_PATCH_TEXT, COPILOT_CONTEXT_TEXT, JUDGE_TEXT, MERGE_TEXT, RUN_SQL_CHECK_TEXT, SCHEMA_PARTIAL_TEXT, SQL_CHECK_TEXT, TYPE_LABEL,
CATALOG_TABLE_FIELD_LABEL, CHALLENGE_TEXT, DUAL_CHECK_TEXT, INQUIRY_TEXT, NODE_TYPE_LABEL,
claimTally,
evidenceTally, issuanceLabel, nodeTypeLabel,
} from '../lib/terms'
import { catalogPatchOf, patchValueText, patchWhere } from './catalogPatch'
import { claimCountsOf, isJudged, statsTally } from '../lib/evidence'
import { isSqlCheckCode, sortSqlChecks, sqlCheckOf, sqlChecksOf, sqlCheckWhere, sqlRuleLabel } from '../lib/sqlcheck'
// 泳道数据画布也要用(supervisor 节点要展开成协作矩阵),所以类型放在
// types.ts 里;这里再导出一遍,老引用不用改
export type { TeamMember, TeamRound, TeamRun }
/**
* 协作团队怎么收的尾。只看 finished 分不出「调度者说完成了」和「轮数用完、硬停」——
* 后者以前把成员的原话当结论交了出去,画布和右栏却都是一个安静的「完成」。
*/
export interface TeamVerdict {
/** 轮数用完后补的那一次只判定、不派活的决定(agent.route.* 带 closing) */
closing?: boolean
/** 那次判定的结论 */
done?: boolean
reason?: string
/** 用完轮数仍未完成的结局:failed 判失败(默认),degraded 降档交付 */
outcome?: 'failed' | 'degraded'
/** 一共派了几轮 */
rounds?: number
/** 一次都没被派到的成员 */
never?: string[]
}
/** 成员这一步为什么失败(agent.step.end 的 error)。types.ts 冻结,先在这里扩 */
export interface TeamMemberEx extends TeamMember { error?: string }
export interface TeamRunEx extends TeamRun {
verdict?: TeamVerdict
/**
* 这一次执行停在审批上(human.requested)。接下来那条 node.started 是恢复后的重放,
* 同一次执行接着走;别的 node.started 都是新的一次,泳道和结局从头记
*/
paused?: true
}
/** 画布的协作矩阵读这个:runtime.team 是 reduceTeam 给的,带着结局 */
export const teamVerdictOf = (team: TeamRun | undefined | null): TeamVerdict | undefined =>
(team as TeamRunEx | undefined | null)?.verdict
/** 下一步该去哪儿改。界面据此给一个直达的入口,而不只是一句话 */
/** catalog:去数据目录页(fixTo 指到哪个数据源),助手参考的大库还没有数据目录时给 */
export type FixKind = 'canvas' | 'settings' | 'tools' | 'catalog'
/**
* 事件 → 人能看懂的步骤。
*
* 这是全站唯一的翻译层:画布时间线、助手栏、运行页都用它。分两套翻译的话,
* 同一次运行在不同地方会读出不同的故事,而用户没法判断哪个是真的。
*
* 三条原则:
*
* 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' // 开始/结束
/**
* cancelled:运行被停下时还没做完的步骤——用户主动停止不是出错,不能画成红色失败。
* suspended:服务重启时还在跑的步骤,断点还在,可以接着跑。
* skipped:skip_if 成立、这一步没有执行。和"根本没轮到"是两回事,得留痕。
*/
export type StepStatus = 'running' | 'done' | 'failed' | 'waiting' | 'cancelled' | 'suspended' | 'skipped'
/** 节点的某一次执行。循环体、轮询类节点一次运行里会执行几十上百次 */
export interface Exec {
/** 第几次执行,从 1 数 */
n: number
seq: number
status: StepStatus
ms?: number
/** 所在循环当时是第几轮(后端 node.started.data.iteration,从 1 数) */
iteration?: number
}
export interface Step {
id: string
seq: number
kind: StepKind
/** 一句人话,不带技术黑话 */
title: string
/** 一行淡色副标题:这次调用前模型在想什么、为什么跳过、从哪接着跑 */
sub?: string
/** 展开后看的东西:SQL 原文、工具参数、结果预览、思考全文 */
detail?: string
/** 技术细节:原始异常、驱动报错。默认折叠——给排查和复制用,不给人读 */
raw?: string
/** 行尾的次要信息:耗时、行数 */
meta?: string
/** 耗时(毫秒)。meta 是给人读的字,耗时细条和统计要的是数 */
ms?: number
/** 开始时刻(毫秒时间戳)。进行中的行据此走实时计时 */
startedAt?: number
status?: StepStatus
level?: 'info' | 'warn' | 'error'
nodeId?: string
/** 工件 id,可下钻到完整证据 */
artifact?: string
/** 工具/查询的返回。和 detail 分开:查询的 detail 是 SQL 原文,
* 两者要同时展示(问什么 + 查到什么),合成一个字段就只能二选一 */
result?: string
children?: Step[]
/** 协作团队的泳道数据。只有 supervisor 节点的顶层 Step 会有 */
team?: TeamRun
/** 建图阶段(Copilot 操作流)出来的步骤。问数据页把它和执行步骤排成一列,界面据此分段 */
stage?: 'plan'
/** 子步骤属于父节点的第几次执行(从 1 数)。父节点只执行过一次时不用看它 */
iter?: number
/** 节点每一次执行的记录。执行过不止一次时,界面按它把子步骤分轮折叠 */
execs?: Exec[]
/** 「开始运行」那一行:图里一共有几个节点 */
total?: number
/** 查询走的数据源 */
source?: string
/** 相邻的同一件事合并成一行后的统计(见 compactSteps) */
repeat?: { count: number; ms: number[] }
/** 后端给的机读代号(log.code 等):tool_markup_leak、team_exhausted、repair… */
code?: string
/** 怎么办:出了状况的行直接说下一步,不让人自己去猜 */
next?: string
/** 下一步该去的地方 */
fix?: FixKind
/** fix 要去的页面地址(catalog:/data/catalog/:sourceId) */
fixTo?: string
/** 工具的时限(秒,tool.start.timeout_s)。进行中的行超过它就要说破 */
limitS?: number
/** 出具那一行的档位(formal / degraded / withheld) */
tier?: string
/** 合并查询那一行(merge.end):合并了哪几个输入、几条警告。SQL 在 detail,预览在 result */
merge?: MergeStep
/** 对照数据目录的 SQL 检查结果:查询那一行(tool.end.checks)、指标所依据的查询没通过检查那一行(log.checks) */
checks?: SqlCheckItem[]
/** fix 要去的不是这一行所在的节点(指标的问题出在来源查询节点的 SQL 上):去哪个节点、落到哪一栏 */
fixNode?: string
fixField?: string
/** fixNode 在画布上的名字(入口写「打开「取数」的 SQL」) */
fixLabel?: string
}
/** 合并查询的一个输入:合并 SQL 里的别名、上游节点、行数、数据源 */
export interface MergeStepInput {
alias: string
nodeId?: string
label?: string
rows?: number
source?: string
}
export interface MergeStep {
inputs: MergeStepInput[]
/** 执行时的警告条数。警告的原文由同一节点紧接着的 log(merge_key_type…)各占一行,不在这里重复 */
warnings: number
rows?: number
truncated: boolean
}
// 这两个是流式增量,后端根本不落库(_EPHEMERAL)。单次运行的 delta 量级会
// 把列表淹掉,而且刷新页面后它们不会回来——UI 不能建立在它们之上。
const EPHEMERAL = new Set(['llm.token', 'llm.thinking.delta'])
// 只对开发者有意义、对使用者是噪音的。不是丢弃事件本身(原始事件另有视图),
// 只是不进这条给人看的流。stream.end 是连接层的结束标记,结局另外按它收尾
const SILENT = new Set(['usage'])
const num = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) ? v : undefined
/** 事件 ts 是秒(带小数);老数据没有 ts */
const tsMs = (ev: RunEvent): number | undefined => {
const t = num(ev.ts)
return t != null && t > 0 ? t * 1000 : undefined
}
/**
* 耗时,没有可显示内容时给 undefined 而不是空串。
*
* 低于 10ms 不显示。一屏全是"0 ms"看着像每步都被精确计时了,实际上只是这些
* 步骤(输入、成果这类纯赋值节点)根本没花时间——把没有信息量的数字摆出来,
* 反而把真正慢的那一步淹没了。
*
* 给 undefined 而不是 '':Step.meta 是可选字段,赋成 '' 之后"没测到耗时"和
* "测到了但不值一提"就分不开了。
*/
const dur = (ms?: number): string | undefined =>
ms != null && ms >= 10 ? formatDuration(ms) : undefined
/**
* 这次运行是不是正停在人工介入上。
*
* 从步骤推,不要去查审批列表——那是 4 秒轮询一次的,而 run.interrupted
* 事件是即时到的。用列表的话,中断后会有几秒钟界面说"这次运行已结束",
* 而实际上它正等着你点通过。
*/
export function isAwaitingHuman(steps: Step[]): boolean {
return steps.some(function walk(s): boolean {
return s.status === 'waiting' || (s.children?.some(walk) ?? false)
})
}
/** run 到终态时,所有还挂着"进行中"的生命周期行都要收尾。 */
function closeLifecycles(out: Step[], status: StepStatus) {
out.forEach((s) => {
if (s.kind === 'lifecycle' && s.status === 'running') s.status = status
})
}
// -------------------------------------------------------------------------
// 终态收尾:画布(store / trace)和这里共用这一套规则
// -------------------------------------------------------------------------
/**
* 一次运行停下来时,还挂着"进行中"的东西各自收成什么。
*
* 画布和右栏以前各收各的:画布只把 streaming 置假,节点照样转圈、入边的光点
* 照样流;右栏只收"开始运行"那一行,节点下面的工具、查询还在转,取消还被画成
* 红色失败。一个假的"运行中"比没有动效更糟,所以规则只写这一份,两边都调它。
*/
export interface Ending {
phase: RunPhase
/** 还在跑的节点、步骤、协作成员 */
running: NodeState
/** 停在审批上的 */
waiting: NodeState
/** "开始运行 / 继续运行"那种生命周期行:这一段执行本身的结局 */
drive: NodeState
}
/**
* 这条事件是不是一个结局;不是就返回 null。
*
* awaiting:此刻是否停在审批上。服务重启(log code=server_shutdown,或对账时
* 状态是 interrupted)时,停在审批上的仍然是"等人",其余的是"挂起、可接着跑"。
* type 为 stream.end 的是客户端合成的对账事件,data 为 {status, pending?};
* pending 是查过审批列表的结论,有它就以它为准。
*
* status 也认前端自己的叫法(RunPhase):画布把 runPhase 写回 run.status,挂起
* 写的是 suspended(就是"interrupted 且没有待审批");waiting 等同有待审批的
* interrupted。消费方照 decodePhase(events, {status: run.status}) 传进来时,
* 不认 suspended 的话强杀的运行会一直转圈。
*/
export function endingOf(ev: RunEvent, awaiting: boolean): Ending | null {
const d: Record<string, any> = ev.data ?? {}
const status = ev.type === 'stream.end' ? String(d.status ?? '') : ''
if (status === 'suspended') {
return { phase: 'suspended', running: 'suspended', waiting: 'suspended', drive: 'suspended' }
}
if (ev.type === 'run.finished' || status === 'succeeded') {
return { phase: 'succeeded', running: 'done', waiting: 'done', drive: 'done' }
}
if (ev.type === 'run.failed' || status === 'failed') {
// 失败的是那一个节点;别的还在跑的是被连带停下的,不是它们自己出错
return { phase: 'failed', running: 'cancelled', waiting: 'cancelled', drive: 'failed' }
}
if (ev.type === 'run.cancelled' || status === 'cancelled') {
return { phase: 'cancelled', running: 'cancelled', waiting: 'cancelled', drive: 'cancelled' }
}
if ((ev.type === 'log' && d.code === 'server_shutdown') || status === 'interrupted'
|| status === 'waiting') {
const held = status === 'waiting' || (typeof d.pending === 'boolean' ? d.pending : awaiting)
return held
? { phase: 'waiting', running: 'suspended', waiting: 'waiting', drive: 'suspended' }
: { phase: 'suspended', running: 'suspended', waiting: 'suspended', drive: 'suspended' }
}
return null
}
export interface PhaseState {
phase: RunPhase
/** 有没有停在审批上 */
awaiting: boolean
}
/**
* 相位的唯一推导:一条事件之后运行处在哪个相位。
*
* 画布的 runPhase(trace.ts)和右栏、运行页的 decodePhase 都走它。以前等待审批
* 时标题写"正在执行…"、工具栏还挂着一个点了必然 409 的"停止",根子就是四处
* 各算各的。
*/
export function nextPhase(s: PhaseState, ev: RunEvent): PhaseState {
const type = String(ev.type)
if (EPHEMERAL.has(type)) return s
if (type === 'run.started' || type === 'run.resumed') return { phase: 'running', awaiting: false }
if (type === 'run.interrupted') return { phase: 'waiting', awaiting: true }
const ending = endingOf(ev, s.awaiting)
if (ending) return { phase: ending.phase, awaiting: ending.phase === 'waiting' }
// 从半路接上的事件流(没有 run.started):有节点动了就是在跑
if ((s.phase === 'idle' || s.phase === 'queued') && type.startsWith('node.')) {
return { phase: 'running', awaiting: s.awaiting }
}
return s
}
/** 客户端知道、事件里没有的结局:GET /runs/{id} 查到的状态,以及有没有待审批 */
export interface RunFinal {
status?: string
pending?: boolean
}
const finalEvent = (final: RunFinal): RunEvent => ({
seq: 0, type: 'stream.end', node_id: null, ts: 0,
data: { status: final.status, ...(final.pending != null ? { pending: final.pending } : {}) },
})
/**
* 分支、循环的出口在图上叫什么。edge.taken 只带出口的 key:右栏写「走「team」这条路」,
* 而画布出口上写的是 case 的说明,两处对不上号。给不出(没加载图、图改过)时退回 key
*/
export type ExitLabelOf = (nodeId: string, key: string) => string | undefined
export interface DecodeOptions {
exitLabelOf?: ExitLabelOf
}
/**
* 从图里攒出口说明。出口怎么命名(兜底叫「其他」、default 撞名合并)只有画布那边的
* sourceHandles 说了算,由调用方传进来:这里 import 画布模块的话,解码器就不再是
* 能在 node 里直接跑的纯函数了
*/
export function exitLabels(
nodes: { id: string; type: string; config?: Record<string, any> }[],
handles: (type: any, config: Record<string, any>) => { id: string; label: string }[],
): ExitLabelOf {
const map = new Map<string, string>()
for (const n of nodes) {
if (n.type !== 'branch' && n.type !== 'loop') continue
for (const h of handles(n.type, n.config ?? {})) if (h.label) map.set(`${n.id}\u0000${h.id}`, h.label)
}
return (id, key) => map.get(`${id}\u0000${key}`)
}
/** 没有图时的叫法,和画布出口上写的一样 */
const LOOP_EXIT: Record<string, string> = { body: '循环体', done: '结束' }
/**
* 循环走 done 时这一轮说什么。后端的 iteration 是「这次决定之前已经跑过几轮」:走循环体时
* +1 是正要开始的那一轮,走 done 时它本身就是一共跑了几轮——按 +1 写,5 项的 foreach
* 走完就成了「第 6 轮」,一项都没有的成了「第 1 轮」
*/
function loopDoneNote(ran: number, total: number | undefined): string {
if (total != null) {
if (total === 0) return '(没有要处理的项)'
return ran >= total ? `(共 ${total} 项)` : `(执行了 ${ran} 轮,共 ${total} 项)`
}
return ran === 0 ? '(未执行)' : `(执行了 ${ran} 轮)`
}
/**
* 这次运行此刻的相位。运行面板标题、运行页卡头读它,不再从 streaming 猜:
* 等待审批时不能写"正在执行…"。
*
* final 给的是事件之外的事实:服务被强杀时连 server_shutdown 都不会发,只有
* 查运行状态(interrupted 且没有待审批)才知道它其实挂起了。
*/
export function decodePhase(events: RunEvent[], final?: RunFinal): RunPhase {
let s: PhaseState = { phase: 'idle', awaiting: false }
for (const ev of events) s = nextPhase(s, ev)
if (final?.status) s = nextPhase(s, finalEvent(final))
return s.phase
}
const STEP_OF: Partial<Record<NodeState, StepStatus>> = {
done: 'done', failed: 'failed', waiting: 'waiting', cancelled: 'cancelled',
suspended: 'suspended', running: 'running', skipped: 'skipped',
}
const stepStatusOf = (state: NodeState): StepStatus => STEP_OF[state] ?? 'done'
/**
* 把还在进行中的步骤按结局收掉,递归到子步骤——节点下面的查询、工具、协作成员
* 那几行以前一直转圈,因为只收了顶层的生命周期行。
*/
function settleSteps(steps: Step[], ending: Ending): void {
for (const s of steps) {
if (s.status === 'running') {
s.status = stepStatusOf(s.kind === 'lifecycle' ? ending.drive : ending.running)
} else if (s.status === 'waiting' && ending.waiting !== 'waiting') {
s.status = stepStatusOf(ending.waiting)
// 不再是待办:琥珀色的"等你确认"留着,会让人以为还能去点
if (s.level === 'warn') s.level = undefined
}
// 分轮折叠的轮次头读的是 execs:最后那一轮不收的话,节点收了、轮次还在转
s.execs?.forEach((x) => {
if (x.status === 'running') x.status = stepStatusOf(ending.running)
else if (x.status === 'waiting' && ending.waiting !== 'waiting') x.status = stepStatusOf(ending.waiting)
})
if (s.children) settleSteps(s.children, ending)
if (s.team) s.team = settleTeam(s.team, ending)
}
}
/**
* 协作团队的收尾:还在跑的成员按结局收掉。画布(store 里的 runtime.team)和
* 这里调同一个函数,免得右栏说"已取消"、画布上那一行还写着"进行中"。
* 没有变化时返回同一个对象。
*/
export function settleTeam(team: TeamRun, ending: Ending): TeamRun {
// 结局里不会有 skipped(那是节点自己的决定,不是运行的结局)
const as = (st: NodeState) => stepStatusOf(st) as TeamMember['status']
const target = (m: TeamMember): TeamMember['status'] | null =>
m.status === 'running' ? as(ending.running)
: m.status === 'waiting' && ending.waiting !== 'waiting' ? as(ending.waiting)
: null
const over = ending.phase !== 'waiting' && ending.phase !== 'suspended'
if (!team.rounds.some((r) => r.members.some((m) => target(m))) && (team.finished || !over)) {
return team
}
return {
...team,
finished: team.finished || over,
rounds: team.rounds.map((r) => (r.members.some((m) => target(m))
? { ...r, members: r.members.map((m) => { const st = target(m); return st ? { ...m, status: st } : m }) }
: r)),
}
}
/** 从 db_query 的结果预览里抠出行数——用户关心的是"查到多少",不是 JSON。 */
function rowCountOf(preview: string): number | undefined {
const m = preview.match(/"row_count":\s*(\d+)/)
return m ? Number(m[1]) : undefined
}
export interface ResultTable {
columns: string[]
rows: unknown[][]
/** 查询本身撞了行数上限——数据库里还有更多,是 guard 没让它全取回来 */
truncated: boolean
/** 这份预览被按字符数切断了,或者本来就只存了前几行——实际的行数比这里显示的多,只是没存下来 */
clipped?: boolean
/** 实际一共几行(结果里的 row_count)。比 rows 多时,表格脚注写「显示前 5 / 6 行」 */
total?: number
}
/**
* 把查询结果预览转成小表格能用的形状;解析不了就算了,原样展示。
*
* 必须容忍**被截断的 JSON**。后端的预览是按字符数硬切的(成果字段 2000、
* 工具结果 4000),切点落在 JSON 中间是常态而不是例外——严格 JSON.parse
* 对这类值一律失败,于是最典型的一次取数运行,最终成果会以满屏
* `\"attribute01\", \"attribute02\"` 的形式呈现。那不是"降级展示",那是
* 什么都没展示。所以解析失败时回退到"截到最后一条完整记录"再解析。
*/
export function parseQueryResult(preview: string): ResultTable | null {
const text = preview.trim()
if (!text) return null
// 值常常被 JSON 编码过一层(字符串里套字符串),截断后连外层引号都收不了口
const unwrapped = text.startsWith('"') ? unescapeJsonString(text) : text
if (!unwrapped.includes('"columns"')) return null
const direct = tryTable(unwrapped)
if (direct) return direct
const repaired = cutAtLastCompleteRow(unwrapped)
if (repaired) {
const table = tryTable(repaired)
if (table) return { ...table, clipped: true }
}
return null
}
function tryTable(text: string): ResultTable | null {
try {
const data = JSON.parse(text)
if (Array.isArray(data?.columns) && Array.isArray(data?.rows)) {
const total = typeof data.row_count === 'number' && data.row_count > data.rows.length ? data.row_count : undefined
return { columns: data.columns, rows: data.rows, truncated: !!data.truncated,
...(data.clipped === true || total != null ? { clipped: true } : {}), ...(total != null ? { total } : {}) }
}
} catch {
/* 交给调用方决定要不要修 */
}
return null
}
/** 去掉外层 JSON 字符串的引号和转义。截断的串 JSON.parse 不了,只能手工拆。 */
function unescapeJsonString(text: string): string {
const body = text.slice(1)
return body.replace(/\\(u[0-9a-fA-F]{4}|.)/g, (_, c: string) => {
if (c[0] === 'u') return String.fromCharCode(parseInt(c.slice(1), 16))
return { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f' }[c] ?? c
})
}
/**
* 把截断的 `{"columns":[...],"rows":[[...],[...],[...`
* 砍到最后一条完整记录后补上收尾括号。
*/
function cutAtLastCompleteRow(text: string): string | null {
const start = text.indexOf('"rows"')
if (start < 0) return null
const open = text.indexOf('[', start)
if (open < 0) return null
let depth = 0
let inString = false
let escaped = false
let lastRowEnd = -1
for (let i = open; i < text.length; i += 1) {
const ch = text[i]
if (escaped) { escaped = false; continue }
if (ch === '\\') { escaped = true; continue }
if (ch === '"') { inString = !inString; continue }
if (inString) continue
if (ch === '[' || ch === '{') depth += 1
else if (ch === ']' || ch === '}') {
depth -= 1
// depth 回到 1 表示一条记录刚闭合(0 是 rows 数组本身)
if (depth === 1) lastRowEnd = i + 1
else if (depth === 0) return text.slice(0, i + 1) + '}' // rows 其实是完整的
}
}
if (lastRowEnd < 0) return null
return text.slice(0, lastRowEnd) + ']}'
}
/**
* 一次运行的一句话摘要,给列表用。
*
* 运行列表原本每行只有"临时图 · 1.2s · 340 tok"——三条运行长得一模一样,
* 要分清哪条是哪条只能挨个点开。人记得住的是内容(问了什么、出了什么),
* 不是耗时。
*
* 优先成果:跑完了的话,"查到多少"比"问了什么"更能认出这一条。
*/
export function summarizeRun(
input?: Record<string, any> | null,
output?: Record<string, any> | null,
): string {
const pick = (obj?: Record<string, any> | null) => {
if (!obj) return ''
for (const [k, v] of Object.entries(obj)) {
if (k.startsWith('_')) continue
if (v == null || v === '') continue
if (typeof v === 'string') {
const table = parseQueryResult(v)
if (table) {
return `${table.rows.length}${table.clipped ? '+' : ''} 行 × ${table.columns.length} 列`
}
return v.replace(/\s+/g, ' ').slice(0, 70)
}
if (typeof v === 'object') {
const table = parseQueryResult(JSON.stringify(v))
if (table) return `${table.rows.length} 行 × ${table.columns.length} 列`
const text = JSON.stringify(v)
if (text !== '{}' && text !== '[]') return text.slice(0, 70)
continue
}
return String(v).slice(0, 70)
}
return ''
}
return pick(output) || pick(input)
}
/**
* agent 步骤的配对键。
*
* 光用 agent 名不够:supervisor 会在多轮里反复派给同一个 agent,第二轮的
* end 会错配到第一轮的 start 上。round 才是区分轮次的那一维。
*/
function agentKey(nodeId: string | undefined, d: any): string {
return [nodeId ?? '_', String(d?.agent ?? ''), String(d?.round ?? '')].join('|')
}
/** 标题的上限。窄栏只有 360px,再长就只能靠折行撑高整行 */
const TITLE_MAX = 60
const clip = (text: string, max = TITLE_MAX): string =>
text.length > max ? `${text.slice(0, max - 1)}…` : text
/** 标识符去掉引号、库名前缀:`"ANALYTICS"."v_kpi"` → v_kpi */
const bareIdent = (s: string): string =>
s.replace(/[`"[\]]/g, '').split('.').pop() ?? s
export interface SqlGist {
tables: string[]
groupBy: string[]
aggregate: boolean
limit?: number
}
/**
* 从 SQL 里认出查的是哪几张表、按什么汇总。
*
* 一次取数运行常有五六条查询,以前标题全是「在 bi 上查询数据」,只能靠「50 行
* · 9.5 s」这种 meta 去分——用户得逐条展开 SQL 才知道哪条查了什么。这里只做
* 够起标题的那点识别(FROM / JOIN / GROUP BY / 聚合 / 行数上限),不是 SQL
* 解析器;认不出表名就返回 null,由调用方退回数据源名。
*/
export function describeSql(sql: string): SqlGist | null {
const text = sql
.replace(/--[^\n]*/g, ' ')
.replace(/\/\*[\s\S]*?\*\//g, ' ')
.replace(/'(?:[^']|'')*'/g, "''")
if (!/\b(select|with)\b/i.test(text)) return null
// WITH 里定义的临时名不是表
const ctes = new Set([...text.matchAll(/(?:\bwith|,)\s*([\w$]+)\s+as\s*\(/gi)]
.map((m) => m[1].toLowerCase()))
const tables: string[] = []
for (const m of text.matchAll(/\b(?:from|join)\s+([`"[]?[\w$.]+[`"\]]?(?:\.[`"[]?[\w$]+[`"\]]?)*)/gi)) {
const name = bareIdent(m[1])
if (!name || ctes.has(name.toLowerCase()) || tables.includes(name)) continue
// 「FROM (SELECT …)」「FROM DUAL」这类不是业务表
if (/^(select|dual|lateral|unnest)$/i.test(name)) continue
tables.push(name)
}
if (!tables.length) return null
const groupBy = groupClause(text).split(',')
.map((c) => c.trim())
// 位置序号(GROUP BY 1, 2)认不出是哪一列,宁可不说
.filter((c) => c && !/^\d+$/.test(c))
.map((c) => bareIdent(c.match(/^\w+\(\s*([\w$."`[\]]+)\s*\)$/)?.[1] ?? c))
.filter((c) => /^[\w$一-龥]+$/.test(c))
const aggregate = /\b(count|sum|avg|min|max)\s*\(/i.test(text)
const lim = text.match(/\blimit\s+(\d+)|\bfetch\s+first\s+(\d+)|\btop\s+(\d+)/i)
const limit = lim ? Number(lim[1] ?? lim[2] ?? lim[3]) : undefined
return { tables, groupBy, aggregate, ...(limit != null ? { limit } : {}) }
}
/**
* GROUP BY 后面那一段。按括号配平往后扫:「GROUP BY DATE(ts)」里的右括号是函数的,
* 子查询收尾那个多出来的右括号才是边界
*/
function groupClause(text: string): string {
const m = /\bgroup\s+by\s+/i.exec(text)
if (!m) return ''
const rest = text.slice(m.index + m[0].length)
let depth = 0
for (let i = 0; i < rest.length; i += 1) {
const ch = rest[i]
if (ch === '(') depth += 1
else if (ch === ')') {
if (depth === 0) return rest.slice(0, i)
depth -= 1
} else if (ch === ';') return rest.slice(0, i)
else if (depth === 0 && /^\s(order\s+by|having|limit|fetch|union|window)\b/i.test(rest.slice(i))) {
return rest.slice(0, i)
}
}
return rest
}
function queryTitle(sql: string, source: string): string {
const g = describeSql(sql)
if (!g) return `在 ${source} 上查询数据`
const what = g.tables.length > 2
? `${g.tables[0]} 等 ${g.tables.length} 张表`
: g.tables.join(' + ')
const how = g.groupBy.length
? `按 ${g.groupBy.slice(0, 2).join('、')}${g.groupBy.length > 2 ? ' 等' : ''} 汇总`
: g.aggregate ? '汇总'
: g.limit != null ? `取前 ${formatNumber(g.limit)} 行`
: ''
return clip(`查询 ${what}${how ? ` · ${how}` : ''}`)
}
/** agent 开了 cite_fields 时循环结束后那次结构化抽取 */
const EXTRACT_TITLE = '按出处抽取字段'
/**
* report.checked 带的裁判摘要 → 「结论 4 句(有依据 1 · 证据相矛盾 1 · 未裁判 2)」,和出具横幅同一种说法。
* 「不是结论句」不算,旧数据的 unsupported 照样计入;没有摘要(没开裁判、老后端)返回空串
*/
function judgeTally(judge: unknown): string {
const c = judge && typeof judge === 'object' ? (judge as { counts?: Record<string, unknown> }).counts : undefined
if (!c || typeof c !== 'object') return ''
return claimTally(claimCountsOf(c))
}
/** 报告撰写节点请裁判模型判断结论句(四期,claims: judge):units 是这一批几句 */
const judgeTitle = (units: unknown) =>
`请裁判模型判断${typeof units === 'number' && units > 0 ? ` ${formatNumber(units)} 句` : ''}结论`
const JUDGE_FAILED_TITLE = '结论句裁判未能执行'
/** 审批行末尾:批的时候点了「始终允许」,这个工具之后不再问人 */
const ALWAYS_NOTE = ',并设为「始终允许 · 门控把关」'
/** 「审批人质询」那一行的标题里,问题最多带几个字(按字数,不按 UTF-16 码元) */
const INQUIRY_TITLE = 40
/**
* approval.inquiry → 一行「审批人质询」:标题带问题的前 40 字,副标题说回答是否完成、查了几次(封存之后问的另写一句)。
*
* 只认服务端给的:ok 为真才说「回答完成」,带着触顶原因的说不完整、写明是哪个上限;没说成没成(缺字段)又有回答正文的
* 只说「已回答」。查了几次认 queries,没有再认 inquiry_artifacts,两样都没有就不说次数——不拿 0 去顶。字段类型不对的
* 当没给:问题不是字符串就只写「审批人质询」
*/
function inquiryStep(d: Record<string, any>, seq: number): Step {
const T = INQUIRY_TEXT
const str = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
const question = str(d.question)
const chars = [...question]
const head = chars.length > INQUIRY_TITLE ? `${chars.slice(0, INQUIRY_TITLE).join('')}…` : question
const limit = str(d.limit)
const limitText = limit ? T.limit[limit] ?? T.limitOther : ''
const answer = str(d.answer_text)
const error = str(d.error)
const state = d.ok === true ? (limit ? T.incomplete(limitText) : T.complete)
: d.ok === false || !answer ? T.failed(limitText || null) : T.answered
const runs = Array.isArray(d.queries) ? d.queries.length
: Array.isArray(d.inquiry_artifacts) ? d.inquiry_artifacts.length : null
const fine = !limit && !error && (d.ok === true || (d.ok !== false && !!answer))
const quote = str(d.quote)
const model = str(d.model)
const doc = str(d.answer_doc)
const detail = [
question ? T.detail.question(question) : '',
quote ? T.detail.quote(quote) : '',
answer ? T.detail.answer(answer) : '',
error ? T.detail.error(error) : '',
model ? T.detail.model(model) : '',
].filter(Boolean).join('\n')
return {
id: `iq-${seq}`, seq, kind: 'human', status: 'done', code: 'approval_inquiry',
level: fine ? 'info' : 'warn',
title: T.timeline(head),
sub: [state, runs != null ? T.queries(runs) : '', d.post_seal === true ? T.postSeal : ''].filter(Boolean).join(' · '),
...(detail ? { detail } : {}),
// 回答组装成的文档(数字带出处):展开区可以下钻
...(doc ? { artifact: doc } : {}),
}
}
/**
* 门控模型(工具信任档「始终允许 · 门控把关」)对一次调用的结论。team:协作团队里拦下的,
* 成员停不下来等人,这次调用不执行。没有工具名(老数据、字段缺了)就说「一次工具调用」
*/
function gateTitle(tool: string, verdict: 'allow' | 'escalate' | 'team', reason: string): string {
const what = tool ? ` ${tool}` : '一次工具调用'
const head = verdict === 'allow' ? `门控通过${what}`
: `门控拦截${what},${verdict === 'team' ? '协作团队中不执行' : '转交人工审批'}`
return reason ? `${head}:${reason}` : head
}
/**
* 双盲复算、反方审稿的日志代码(期 3 前后端共用的约定)。这几条按 code 和结构化字段解读,不看原话;后端发成 info 的
* (一致、裁决、记入数据目录、审稿完成)也上时间线,不然这两种节点就是一个跑了几十秒、什么都没说的黑盒。
* 无法复算(dual_check_unchecked)、未能反查(challenge_unreviewed)、只查了部分数据源(challenge_sources_lost)是
* 期 4 补上的:以前认不得,只能照原话显示
*/
const VERIFY_LOG_CODES = new Set([
'dual_check_skipped', 'dual_check_same_model', 'dual_check_agreed', 'dual_check_disagreed', 'dual_check_resolved',
'dual_check_catalog', 'dual_check_limit', 'dual_check_unchecked',
'challenge_skipped', 'challenge_same_model', 'challenge_done', 'challenge_refuted', 'challenge_limit',
'challenge_unreviewed', 'challenge_sources_lost',
])
const DC = DUAL_CHECK_TEXT
const CL = CHALLENGE_TEXT.log
/**
* 出具缺口里哪几条是双盲复算、反方审稿报上来的:出具契约给上游节点报的缺口加了「节点类型「节点名」:」的前缀
* (nodes/io.py 的 _upstream_issuance_gaps,节点名就是类型叫法时只写「节点类型:」),按这两种节点类型的叫法认。
* 时间线、成果卡片、证据页把它们另起,不和「校验没跑全」的缺口混在一起,再分两组(叫法在 DUAL_CHECK_TEXT.verifyGaps):
* - failed「核查未通过」:核查查出来的问题——两路结果不一致(审核人选了盲算、两边都不对、没作出选择),结论被推翻;
* - unfinished「核查未完成」:没核成的——指标无法复算(nodes/dual_check.py 的 GAP_UNCHECKED「指标「X」无法复算:原因」)。
* 合并查询、遮罩列的输入做不了复算,盲算触顶、出错的也没核过,说成「未通过」读的人会以为这个数被查出了错。
* 其余的照旧
*/
const VERIFY_GAP_KINDS = [NODE_TYPE_LABEL.dual_check, NODE_TYPE_LABEL.challenge]
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
/** 双盲复算报的「无法复算」:前缀(节点名可有可无)之后紧跟 GAP_UNCHECKED 的开头 */
const UNCHECKED_GAP = new RegExp(`^${escapeRe(NODE_TYPE_LABEL.dual_check)}(?:「.*?」)?:指标「.+?」无法复算:`)
export function splitIssuanceGaps(gaps: readonly string[]): { failed: string[]; unfinished: string[]; other: string[] } {
const out = { failed: [] as string[], unfinished: [] as string[], other: [] as string[] }
for (const g of gaps) {
const verify = VERIFY_GAP_KINDS.some((kind) => g.startsWith(`${kind}「`) || g.startsWith(`${kind}:`))
;(!verify ? out.other : UNCHECKED_GAP.test(g) ? out.unfinished : out.failed).push(g)
}
return out
}
const isRecord = (v: unknown): v is Record<string, any> => !!v && typeof v === 'object' && !Array.isArray(v)
/** 老日志、别的服务端版本可能给的是一句中文原因而不是代码:中文的照写,认不得的代码不往界面上放 */
const cjkText = (v: unknown): string | undefined => (typeof v === 'string' && /[㐀-鿿]/.test(v) ? v : undefined)
/** 个数:给的是数就取整,给的是清单就数一下,别的(缺了、写坏了)当不知道 */
const countOf = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.round(v)
: Array.isArray(v) ? v.length : undefined)
/**
* 几种「看着跑完了、其实没做成」的状况(NI-3/4/5)。后端这几条日志是写给排查的,原样
* 贴出来是一串标记和术语,而且不说该去哪儿改——这里说成人话,并给出下一步。
* 认不得的 code 返回 null,照原话显示。
*
* 双盲复算、反方审稿那几条(VERIFY_LOG_CODES)另带 level(该不该用提醒色)和 artifact(完整对照、审稿记录可下钻)
*/
function explainLog(code: string | undefined, message: string, d: Record<string, any> = {},
): Pick<Step, 'title' | 'sub' | 'next' | 'fix' | 'fixNode' | 'fixField' | 'checks' | 'level' | 'artifact'> | null {
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined)
const strs = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && !!x) : [])
switch (code) {
case 'tool_markup_leak': {
// 「数据查询把工具调用写成了文字(<||DSML||…),没有真正调用工具,已提醒它重试一次」
const who = message.match(/^(.+?)(?:把工具调用写成了文字|以文本形式输出了工具调用)/)?.[1]?.trim() || '模型'
// 收尾轮那种是真调过工具、只是步数用完了还想接着查:不是没绑工具,节点也照常完成,
// 说成「没有真正执行」会让人去查一个并不存在的故障
const settle = message.includes('收尾')
return {
title: settle ? `${who === '模型' ? '' : `${who}:`}已达步数上限,收尾时仍试图调用工具`
: `${who}以文本形式输出了工具调用,未实际执行`,
sub: settle ? '收尾时的工具调用未执行,结论仅基于此前的查询结果'
: message.includes('重试') ? '已要求模型重试一次' : undefined,
next: settle ? '请在画布中调大该步骤的「最大步数」,或将问题描述得更具体'
: `常见原因:${who === '模型' ? '该节点' : `成员「${who}」`}未绑定工具,或模型不支持工具调用。请在画布中为其绑定所需工具`,
fix: 'canvas',
}
}
case 'team_exhausted': {
const x = exhaustedOf(message)
return {
title: `协作团队${x?.rounds != null ? `已用完 ${x.rounds} 轮` : '已用完轮数'}仍未完成 · 按降档交付`,
sub: [x?.reason, x?.never?.length ? `未分派:${x.never.join('、')}` : ''].filter(Boolean).join(' · ') || undefined,
next: '交付的是成员最后一次回复,不可作为结论使用。请在画布中检查成员是否绑定了所需工具,再调大「最多轮数」',
fix: 'canvas',
}
}
case 'report_repair': {
// 「报告里有 3 处没通过核对(「12」「m:nope」),已要求写作者重写(第 1 次)」。没通过的
// 不只是裸数字,还有解析不了的引用(m:nope),标题不能只说「没写引用」
const n = message.match(/报告里?有\s*(\d+)\s*处/)?.[1]
const round = message.match(/第\s*(\d+)\s*次/)?.[1]
return {
title: `报告${n ? `有 ${n} 处` : ''}未通过核对,已要求模型按清单重写${round ? `(第 ${round} 次)` : ''}`,
sub: message.match(/(?:没|未)通过核对((.+?))/)?.[1],
}
}
case 'report_absence_claim': {
// 证据目录有省略时报告写了缺失断言,重写后仍在(report.py):读结构化字段 count、texts,不从原话里认
const n = typeof d.count === 'number' && d.count > 0 ? d.count : undefined
const texts = strs(d.texts)
return {
title: ABSENCE_CLAIM_TEXT.title(n),
sub: texts.length ? texts.map((t) => `「${t}」`).join('、') : undefined,
next: ABSENCE_CLAIM_TEXT.next,
}
}
case 'report_catalog_trimmed': {
// 写作目录超出篇幅(report.py _trimmed_message)。读结构化字段(queries、omitted、nodes),不从原话里认
const total = typeof d.queries === 'number' ? d.queries : undefined
const omitted = typeof d.omitted === 'number' ? d.omitted : undefined
const compact = typeof d.compact === 'number' ? d.compact : undefined
const rows: any[] = Array.isArray(d.nodes) ? d.nodes : []
const per = rows.filter((r) => typeof r?.omitted === 'number' && r.omitted > 0 && str(r?.title))
.map((r) => `${r.title} 省略 ${formatNumber(r.omitted)}/${formatNumber(r.total)} 次`)
const all = total != null ? `${formatNumber(total)} 次查询都列出了数据` : '查询都列出了数据'
if (omitted === 0) {
// 放不下但一次查询都没省略:有的只列了前几行,或者只省略了表和字段的说明。不写「0 次只列了编号和摘要」
const rowsPer = rows.filter((r) => typeof r?.compact === 'number' && r.compact > 0 && str(r?.title))
.map((r) => `${r.title} ${formatNumber(r.compact)}/${formatNumber(r.total)} 次只列前几行`)
return {
title: compact ? `证据目录超出篇幅,${all},${formatNumber(compact)} 次只列了前几行`
: compact === 0 ? `证据目录超出篇幅,${all},表和字段的说明有省略`
: `证据目录超出篇幅,${all},部分明细有省略`,
sub: rowsPer.length ? rowsPer.join('、') : undefined,
next: '写作模型看到了每次查询,但有的只看到前几行;其余的行仍在证据台账里,报告不得据此断言没有数据。'
+ '需要完整明细时,请换用上下文窗口更大的写作模型,或让 Agent 在 SQL 中先汇总再交出结果',
}
}
return {
title: omitted != null
? `证据目录超出篇幅,${total != null ? `${formatNumber(total)} 次查询中 ` : ''}${formatNumber(omitted)} 次只列了编号和摘要`
: '证据目录超出篇幅,部分查询只列了编号和摘要',
sub: per.length ? per.join('、') : undefined,
next: '写作模型按来源均衡地看到了一部分查询的明细,被省略的查询仍在证据台账里,报告不得据此断言没有数据。'
+ '需要完整明细时,请换用上下文窗口更大的写作模型,或让 Agent 在 SQL 中先汇总再交出结果',
}
}
case 'agent_field_mismatch': {
// 「有 1 个字段和查询快照对不上,已按快照取值:order_cnt 模型报 1240,快照是 1234」(llm.py _field_warnings)
const n = message.match(/有\s*(\d+)\s*个字段/)?.[1]
return {
title: `有${n ? ` ${n} 个` : ''}字段与查询快照不一致,已按快照取值`,
sub: message.match(/已按快照取值[::]\s*(.+)$/)?.[1]?.trim() || undefined,
next: '下游使用查询快照中的值,而非模型给出的值。模型给出的数值可能存在抄录错误、四舍五入或自行计算的情况',
}
}
case 'agent_field_unverified': {
// 两种原话:「有 N 个字段核对不了出处,记为空值(没有兜底成 0):a(原因);b(原因)」;
// 抽取没成:「<原因>。N 个字段都记为空值(没有兜底成 0)」,原因是「结构化抽取没跑成:<调用的错>」
// 或者「抽取结果不是一个对象」(llm.py 的 failed)
const failed = message.match(/^(?:结构化抽取(?:没跑成|失败)[::])?([\s\S]+?)。\s*(\d+)\s*个字段都?记为空值/)
if (failed) {
return {
title: `按出处抽取字段失败,${failed[2]} 个字段记为空值(未以 0 代替)`,
sub: failed[1].trim() || undefined,
next: '这些字段以空值传给下游,口径卡按缺少输入处理。问题解决后请重新运行',
}
}
const n = message.match(/有\s*(\d+)\s*个字段/)?.[1]
return {
title: `有${n ? ` ${n} 个` : ''}字段无法核对出处,记为空值(未以 0 代替)`,
sub: message.match(/记为空值([^)]*)[::]\s*(.+)$/)?.[1]?.trim() || undefined,
next: '模型给出的出处在查询结果中不存在。请让模型先查询到这些字段再提交,查询不到的字段记为空值',
}
}
case 'agent_field_truncated': {
// 结构化抽取两次都被截断(llm.py TRUNCATED_EXTRACT):「结构化抽取没有完成:模型的输出额度在推理阶段就用完了
// (最后一次的额度为 32768 token),结构化结论没能写出来。5 个字段都记为空值(未以 0 代替)。可以……」。
// 字段数、额度读结构化字段(fields、max_tokens),不从原话里认;不说「稍后重试」——重试解决不了额度问题
const n = strs(d.fields).length || Number(message.match(/(\d+)\s*个字段都?记为空值/)?.[1] ?? 0)
const budget = typeof d.max_tokens === 'number' && d.max_tokens > 0 ? d.max_tokens : undefined
return {
title: `模型的输出额度在推理阶段用完,${n ? `${n} 个` : ''}字段记为空值(未以 0 代替)`,
sub: `按出处抽取字段时输出被截断${budget ? `(已加大额度重试,最后一次为 ${formatNumber(budget)} token)` : ''},`
+ '结构化结论没能写出来',
next: '这些字段以空值传给下游,口径卡按缺少输入处理,报告不得据此断言没有数据。请为该 Agent 换用非推理模型'
+ '(抽取沿用 Agent 的模型),或调大它的「最大输出 token」后重新运行',
fix: 'canvas',
fixField: 'max_tokens',
}
}
case 'metric_incomplete': {
// 结构化字段(metrics.py):metric_name、reason(「基于被截断的查询结果计算(只取回了前 1000 行),结果不完整」)。
// 老运行的日志没有这两个字段:只给不点名的标题,原话留在展开区,不从中文里解析
const name = str(d.metric_name)
const reason = str(d.reason)?.replace(/,结果不完整$/, '')
return {
title: name ? `指标「${name}」结果不完整` : '指标结果不完整',
sub: reason,
next: '口径卡对截断的查询结果计数、求和,只算到了取回的部分,出具按缺口降档。'
+ '请在 SQL 中直接聚合(如 COUNT、SUM)或缩小查询范围后重新运行',
fix: 'canvas',
}
}
case 'metric_sql_check': {
// 一条没通过 SQL 检查的来源查询一行(metrics.py _emit_sql_check_logs),结构化字段:metric_names(受影响的指标)、
// problems(问题那半句)、checks(级别、规则、表和列)、source_node / source_field(来源查询节点和它的 SQL 那一栏)。
// 「打开设置」落到来源查询节点的 SQL 上:要改的是那条 SQL,不是口径卡。老运行没有这些字段:只给不点名的标题
const names = strs(d.metric_names)
const problems = strs(d.problems)
const checks = sqlChecksOf(d.checks)
const source = str(d.source_node)
return {
title: names.length ? RUN_SQL_CHECK_TEXT.metricsTitle(names, Math.max(1, problems.length)) : SQL_CHECK_TEXT.metricTitleAnon,
sub: problems.join(';') || undefined,
next: source ? RUN_SQL_CHECK_TEXT.metricNext : SQL_CHECK_TEXT.metricNext,
fix: 'canvas',
...(source ? { fixNode: source } : {}),
...(source && str(d.source_field) ? { fixField: str(d.source_field) } : {}),
...(checks.length ? { checks } : {}),
}
}
case 'judge_limit': {
// 「结论句裁判已到上限(这份报告的裁判金额上限 $0.05):3 句没判,记为未裁判;已判的保留」(judge.py run_request)
const n = message.match(/[::]\s*(\d+)\s*句(?:没判|未裁判)/)?.[1]
return {
title: `结论句裁判已达上限${n ? `,${n} 句未裁判` : ''}(已裁判的结果保留)`,
sub: message.match(/已[到达]上限((.+?))/)?.[1],
next: '存在未裁判的结论句时不能完整出具。请在报告撰写节点的「结论句裁判」中调高上限(或设为不限),或在「设置 → 偏好设置 → 证据裁判」中修改默认值',
fix: 'canvas',
}
}
case 'merge_key_type': {
// 结构化字段(engine/merge_query._warnings):keys(两边的列)、detail(「s.门店 是文本(例如 '01'),v.门店 是数值(例如 1)」)
const keys = strs(d.keys)
return {
title: keys.length === 2 ? `合并键类型不一致:${keys[0]} 与 ${keys[1]}` : '合并键类型不一致',
sub: str(d.detail),
next: '两边类型不同时,SQLite 按隐式转换比较,可能错配或完全匹配不上。请在源查询中统一类型,或在合并 SQL 中用 CAST 明确转换',
fix: 'canvas',
}
}
case 'merge_rows_grew': {
// 结构化字段:rows(合并结果行数)、input / input_rows(行数最多的输入)、duplicates(各输入里重复的键)
const rows = num(d.rows)
const input = str(d.input)
const inputRows = num(d.input_rows)
return {
title: '合并结果行数多于输入,合并键可能不唯一',
sub: [rows != null && input && inputRows != null
? `合并结果 ${formatNumber(rows)} 行,行数最多的输入「${input}」${formatNumber(inputRows)} 行` : '',
str(d.duplicates) ?? ''].filter(Boolean).join(';') || undefined,
next: '请检查合并条件是否覆盖了全部合并键(例如同时按日期和门店),或先在源库里聚合到相同粒度',
fix: 'canvas',
}
}
case 'merge_truncated': {
const rows = num(d.rows)
return {
title: `合并结果超过上限,${rows != null ? `只保留了前 ${formatNumber(rows)} 行` : '已截断'}`,
next: '下游拿到的不是完整结果。请在合并 SQL 中聚合或加条件缩小范围',
fix: 'canvas',
}
}
case 'judge_failed': {
// 「结论句裁判没跑完:有 2 句结论没裁判:裁判调用失败(…)」
return {
title: '结论句裁判未完成,未判定的结论句记为未裁判',
sub: message.replace(/^结论句裁判(?:没跑完|未完成)[::]\s*/, '') || undefined,
next: '未裁判的结论句记为缺口,不能完整出具。请先检查裁判模型的接入(设置 → 偏好设置 → 证据裁判)是否可用,再重新运行',
}
}
case 'judge_unpriced': {
// 「模型「x」不在价格目录里,按令牌估不出金额:金额上限(每份报告、每次点击、每日)对它不起作用,…」
const model = message.match(/模型「(.+?)」/)?.[1]
return {
title: `裁判模型${model ? `「${model}」` : ''}无法估算金额,金额上限对其无效`,
sub: message.split(/[::]/).slice(1).join(':').trim() || undefined,
next: '费用仅受句数和时长上限约束。如需按金额控制,请换用价格目录中已有的裁判模型',
}
}
case 'judge_same_model': {
// 「裁判模型和写作模型都是「x」:等于自己审自己,…。到设置里选一个不同的「证据裁判模型」…」
const model = message.match(/「(.+?)」/)?.[1]
return {
title: model ? `裁判模型与写作模型同为「${model}」,审查缺乏独立性` : '裁判模型与写作模型相同,审查缺乏独立性',
next: '请在「设置 → 偏好设置 → 证据裁判」中选择其他模型,或在报告撰写节点的「结论句裁判」中指定裁判模型',
fix: 'canvas',
}
}
case 'report_rewrite': {
// 「裁判认为 2 句结论证据不支持(「…」「…」),已交回写作者只改这几句」
const n = message.match(/裁判认为\s*(\d+)\s*句/)?.[1]
return {
title: `裁判认为${n ? ` ${n} 句` : ''}结论缺乏证据支持,已退回写作模型仅修改这几句`,
sub: message.match(/证据(?:不支持|支持不足)((.+))/)?.[1],
}
}
case 'report_rewrite_rejected': {
// 「改写稿冒出 1 处原稿没有的问题(「…」),没有采用,保留原稿和原来的判定」
const n = message.match(/(?:冒出|出现了?|新增了?)\s*(\d+)\s*处/)?.[1]
return {
title: `改写稿新增了${n ? ` ${n} 处` : ''}原稿没有的问题,未予采用`,
sub: message.match(/问题((.+))/)?.[1],
next: '已保留原稿及原有判定',
}
}
case 'repair_invented': {
// 「第 1 次修复作废:修复时出现了原文没有的值:total_count=0」
const values = message.match(/原文中?(?:没有|不存在)的值[::]\s*(.+)$/)?.[1]?.trim()
const what = values ? `(${values})` : ''
return {
title: clip(`修复已作废:出现了原文中不存在的值${what}`),
sub: `结果中包含原文不存在的值${what},已作废`,
next: '修复只能调整格式,不能补充数据。请先检查上游节点为何未获取到数据(常见原因是未绑定查询工具)',
fix: 'canvas',
}
}
// ---- 双盲复算:只读结构化字段。老日志缺了字段就不点名、不报数,标题照样说得通
case 'dual_check_skipped': {
const reason = str(d.reason)
if (reason === 'disabled') {
return { title: DC.log.skippedDisabled, next: DC.log.skippedDisabledNext, fix: 'canvas', fixField: 'enabled', level: 'info' }
}
// 放了这个节点却一个指标都复算不了:多半是配错了,用提醒色
if (reason === 'no_inputs') {
return { title: DC.log.skippedNoInputs, next: DC.log.skippedNoInputsNext, fix: 'canvas', fixField: 'metrics', level: 'warn' }
}
return { title: DC.log.skipped, sub: cjkText(d.reason), level: 'info' }
}
case 'dual_check_same_model':
return { title: DC.log.sameModel(str(d.model)), next: DC.log.sameModelNext, fix: 'canvas', fixField: 'model', level: 'warn' }
case 'dual_check_agreed':
return { title: DC.log.agreed(countOf(d.metrics) || undefined), level: 'info' }
case 'dual_check_disagreed': {
// metrics 是 [{id, name}]:副标题点名,没起名字的指标写 id
const list = Array.isArray(d.metrics) ? d.metrics.filter(isRecord) : []
const names = list.map((m) => str(m.name) ?? str(m.id)).filter((n): n is string => !!n)
return {
title: DC.log.disagreed(list.length || undefined),
sub: names.length ? names.map((n) => `「${n}」`).join('') : undefined,
next: DC.log.disagreedNext,
level: 'warn',
...(str(d.card_artifact) ? { artifact: str(d.card_artifact) } : {}),
}
}
case 'dual_check_resolved': {
// choices 是 {指标 id: a | b | neither};gaps 是记成缺口的个数(给成清单的也数得出来)
const picked = isRecord(d.choices) ? Object.values(d.choices) : []
const n = (c: string) => picked.filter((v) => v === c).length
const parts = [
n('a') ? DC.log.resolvedA(n('a')) : '', n('b') ? DC.log.resolvedB(n('b')) : '', n('neither') ? DC.log.resolvedNeither(n('neither')) : '',
].filter(Boolean)
const gaps = countOf(d.gaps)
// 没给缺口数时按约定推:采用盲算、两边都不对都会记缺口
const warn = gaps != null ? gaps > 0 : n('b') + n('neither') > 0
return { title: DC.log.resolved(parts), sub: gaps != null ? DC.log.gaps(gaps) : undefined, level: warn ? 'warn' : 'info' }
}
case 'dual_check_catalog': {
// written:[{source, table, item, value}] 写进数据目录的已确认事实;skipped:[{reason}] 没写的(已有已确认的不覆盖……)。
// item 是表级目录项的键(business_date / valid_filter / dedup),按数据目录页的叫法写;认不出的键不往界面上放,
// 只写表;老版本给的一句中文照写。value 是写进去的说法(期 4 起带),写法同审批卡上的「会记为」,两处一字不差
const written = Array.isArray(d.written) ? d.written.filter(isRecord) : []
const skipped = Array.isArray(d.skipped) ? d.skipped.filter(isRecord) : []
const what = written.map((w) => {
const key = str(w.item)
const known = !!key && Object.hasOwn(CATALOG_TABLE_FIELD_LABEL, key)
const item = known ? CATALOG_TABLE_FIELD_LABEL[key as keyof typeof CATALOG_TABLE_FIELD_LABEL] : cjkText(key)
const where = DC.log.catalogItem(str(w.table), str(w.source), item)
const value = known && w.value != null && w.value !== '' ? patchValueText(key, w.value) : ''
return where && value ? DC.log.catalogValue(where, value) : where
}).filter(Boolean)
const why = skipped.map((s) => str(s.reason)).filter((r): r is string => !!r)
const rest = skipped.length ? `${DC.log.catalogSkipped(skipped.length)}${why.length ? `:${why.join(';')}` : ''}` : ''
return {
title: written.length ? DC.log.catalog(written.length) : DC.log.catalogNone,
sub: [what.join(';'), rest].filter(Boolean).join(';') || undefined,
next: written.length ? DC.log.catalogNext : undefined,
level: 'info',
}
}
case 'dual_check_limit': {
const which = str(d.limit)
return {
title: DC.log.limit((which && DC.limitLabel[which]) || DC.limitAny, str(d.metric)),
next: which === 'daily_max_usd' ? DC.limitDailyNext : DC.log.limitNext,
level: 'warn',
}
}
case 'dual_check_unchecked': {
// metrics 是 [{id, name, reason}]:副标题点名、说原因(没起名字的写 id)
const list = Array.isArray(d.metrics) ? d.metrics.filter(isRecord) : []
const parts = list.map((m) => {
const name = str(m.name) ?? str(m.id)
const why = cjkText(m.reason)
return name ? `「${name}」${why ? `:${why}` : ''}` : ''
}).filter(Boolean)
return {
title: DC.log.unchecked(list.length || undefined),
sub: parts.length ? parts.join(';') : undefined,
next: DC.log.uncheckedNext,
level: 'warn',
}
}
// ---- 反方审稿:同上,文案在 CHALLENGE_TEXT.log(时间线的解读统一在这里做)
case 'challenge_skipped': {
const reason = str(d.reason)
if (reason === 'disabled') {
return { title: CL.skippedDisabled, next: CL.skippedDisabledNext, fix: 'canvas', fixField: 'enabled', level: 'info' }
}
if (reason === 'no_report') return { title: CL.skippedNoReport, next: CL.skippedNoReportNext, fix: 'canvas', level: 'warn' }
if (reason === 'no_claims') return { title: CL.skippedNoClaims, level: 'info' }
return { title: CL.skipped, sub: cjkText(d.reason), level: 'info' }
}
case 'challenge_same_model':
return { title: CL.sameModel(str(d.model)), next: CL.sameModelNext, fix: 'canvas', fixField: 'model', level: 'warn' }
case 'challenge_done': {
// counts:{stands, weakened, refuted, inconclusive},四种结论各几句;为 0 的不写
const c = isRecord(d.counts) ? d.counts : {}
const keys = ['stands', 'weakened', 'refuted', 'inconclusive']
const nums = keys.map((k) => countOf(c[k]) ?? 0)
const total = nums.reduce((a, b) => a + b, 0)
const parts = keys.map((k, i) => (nums[i] ? `${CL.verdict[k]} ${formatNumber(nums[i])}` : '')).filter(Boolean)
return {
title: CL.done(total || undefined),
sub: parts.length ? parts.join(' · ') : undefined,
level: nums[2] > 0 ? 'warn' : 'info',
...(str(d.artifact) ? { artifact: str(d.artifact) } : {}),
}
}
case 'challenge_refuted': {
const items = Array.isArray(d.items) ? d.items.filter(isRecord) : []
const texts = items.map((i) => str(i.text)).filter((t): t is string => !!t)
return {
title: CL.refuted(items.length || undefined),
sub: texts.length ? texts.map((t) => `「${t}」`).join('') : undefined,
next: CL.refutedNext,
level: 'warn',
}
}
case 'challenge_limit': {
const which = str(d.limit)
const skipped = countOf(d.claims_skipped)
return {
title: CL.limit((which && DC.limitLabel[which]) || DC.limitAny, skipped || undefined),
next: which === 'daily_max_usd' ? DC.limitDailyNext : CL.limitNext,
level: 'warn',
}
}
case 'challenge_unreviewed': {
// claims_skipped:未能反查的句数;reason:第一句未能反查的原因(一句中文)
return {
title: CL.unreviewed(countOf(d.claims_skipped) || undefined),
sub: cjkText(d.reason),
next: CL.unreviewedNext,
level: 'warn',
}
}
case 'challenge_sources_lost': {
// claims:只查了部分数据源的句数;sources:没查成的数据源名
const names = strs(d.sources)
return {
title: CL.sourcesLost(countOf(d.claims) || undefined),
sub: names.length ? CL.sourcesLostSub(names) : undefined,
next: CL.sourcesLostNext,
level: 'warn',
}
}
default:
return null
}
}
/** tool.start / tool.end 的配对键:有 call_id 用它;独立的调用工具节点不带 call_id,用「节点 + 工具名」 */
function toolKey(nodeId: string | undefined, d: Record<string, any>, seq?: number): string {
if (d.call_id) return `call:${String(d.call_id)}`
return `${nodeId ?? '_'}:${String(d.tool || (seq ?? ''))}`
}
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__/, '')
const sql = String(args?.sql ?? '')
// 数据源名放进展开区的表头:五条查询都挂着同一个库名,标题上是噪音
return { ...base, kind: 'query', title: queryTitle(sql, source), detail: sql, source }
}
if (tool.startsWith('db_schema')) {
// 一次看十几张表时 table 是逗号拼起来的一整串:没有断行机会,标题会越过
// 卡片右边框一直伸到视口外。标题只摘前几张,完整清单逐行放进详情
const tables = String(args?.table ?? '').split(',').map((t) => t.trim()).filter(Boolean)
if (!tables.length) return { ...base, kind: 'schema', title: '列出有哪些表' }
if (tables.length <= 3) {
return { ...base, kind: 'schema', title: clip(`查看 ${tables.join('、')} 的字段`) }
}
const head = tables.slice(0, 3).join('、')
return { ...base, kind: 'schema', title: clip(`查看 ${tables.length} 张表的字段:${head}…`),
detail: tables.join('\n') }
}
const argText = Object.keys(args ?? {}).length
? JSON.stringify(args, null, 2) : ''
return { ...base, kind: 'tool', title: clip(`调用 ${tool}`), detail: argText || undefined }
}
/** 低于这个毫秒数的节点不参与并行分组:瞬时节点谁跟谁都"重叠",是噪音不是信息 */
const CONCURRENT_MIN_MS = 30
/** 开始时刻相差在这个数以内算"同一批出发"。实测 fan-out 的几路相差不到 1ms */
const SAME_WAVE_MS = 250
/**
* 认出哪几个节点是**同时**跑的,把它们收进一个分组里。
*
* 图上一个节点连出多条边就是 fan-out,LangGraph 会在同一个 superstep 里并发
* 执行它们——这是真并发(实测 3 个各 sleep 2 秒的节点,全部 +0.00s 开始、
* +2.37s 结束,墙钟 2.6 秒)。但在时间线上它们只是穿插出现的几行,
* "这三路是同时跑的、因此省下了 3.6 秒"这件事一个字都没说。
*
* 判定用时间跨度重叠,而不是看事件顺序——顺序只说明"先后发出",
* 说明不了"同时在跑"。
*
* 判据是"同一批出发":fan-out 的几路由 LangGraph 在同一个 superstep 里启动,
* 开始时刻相差以毫秒计。**不能只看跨度重叠**——三路同时开始但先后结束时,
* 长的那条在时间上确实"包含"短的,按包含关系去排会把先跑完的那路漏掉
* (实测 leg0 [0.05,2.21] / leg1 [0.05,3.01] / leg2 [0.05,1.51],
* 漏的就是 leg2)。
*
* 子工作流那种真嵌套则是父节点明显更早开始、更晚结束,出发时刻对不上。
*/
function groupConcurrent(
out: Step[], spans: Map<string, { start: number; end: number; ms: number }>,
): void {
const idx = new Map<Step, number>()
out.forEach((s, i) => idx.set(s, i))
const candidates = out.filter((s) => {
const sp = s.nodeId ? spans.get(s.nodeId) : undefined
return !!sp && sp.ms >= CONCURRENT_MIN_MS
})
if (candidates.length < 2) return
const together = (a: Step, b: Step): boolean => {
const x = spans.get(a.nodeId!)!
const y = spans.get(b.nodeId!)!
if (x.start >= y.end || y.start >= x.end) return false // 压根没重叠
return Math.abs(x.start - y.start) * 1000 <= SAME_WAVE_MS // 同一批出发
}
const used = new Set<Step>()
const groups: Step[][] = []
for (const a of candidates) {
if (used.has(a)) continue
const g = [a]
for (const b of candidates) {
if (b === a || used.has(b)) continue
// 和组里每一个都重叠才算同一批,否则 A|B 重叠、B|C 重叠但 A|C 不重叠
// 也会被串成一组,而那三个并不是同时在跑
if (g.every((m) => together(m, b))) g.push(b)
}
if (g.length > 1) { g.forEach((m) => used.add(m)); groups.push(g) }
}
for (const g of groups) {
g.sort((a, b) => (idx.get(a) ?? 0) - (idx.get(b) ?? 0))
const sp = g.map((m) => spans.get(m.nodeId!)!)
const wallMs = Math.round(
(Math.max(...sp.map((x) => x.end)) - Math.min(...sp.map((x) => x.start))) * 1000)
const sumMs = sp.reduce((acc, x) => acc + x.ms, 0)
const at = Math.min(...g.map((m) => out.indexOf(m)))
const first = g[0]
for (const m of g) out.splice(out.indexOf(m), 1)
out.splice(at, 0, {
id: `par-${first.id}`, seq: first.seq, kind: 'branch', status: 'done',
title: `${g.length} 路并行`,
// 省下多少是这一组存在的理由,放 meta 里一眼看得到
meta: sumMs > wallMs
? `合计 ${dur(sumMs)},实际 ${dur(wallMs)}`
: dur(wallMs),
children: g,
})
}
}
/**
* 「协作团队用完 N 轮仍未完成:<理由>。一次都没被派到的成员:A、B。…」里的几样东西。
* 判失败的报错和降档的那条日志是同一个开头;产出里的 never_dispatched 进事件时被
* 缩成了「[1 项]」,成员名只能从这句话里认。canvas/NodeCard 也用它(更早的后端写「还未完成」,一并认)。
* 这几个锚点是后端 multi.py 有意保留的,改后端那句话时要同步这里
*/
export function exhaustedOf(text: string): Pick<TeamVerdict, 'rounds' | 'reason' | 'never'> | null {
const m = text.match(/用完\s*(\d+)\s*轮(?:仍|还)?未完成[::]\s*([\s\S]*?)(?:。一次都没被派到的成员|。先看成员|。按降档交付|$)/)
if (!m) return null
const never = text.match(/一次都没被派到的成员[::]\s*([^。]+)/)?.[1]
.split('、').map((s) => s.trim()).filter(Boolean) ?? []
const reason = m[2].trim()
return { rounds: Number(m[1]), ...(reason ? { reason } : {}), never }
}
/** 这一次执行收了尾:审批早已答过(或者驳回后失败),不再等那条重放 */
function settledExec(team: TeamRun): TeamRun {
if (!(team as TeamRunEx).paused) return team
const { paused: _, ...rest } = team as TeamRunEx
return rest
}
/**
* 把一个事件并进协作团队的泳道数据,返回**新的** TeamRun。
*
* 时间线和画布都要这份数据:时间线用它画泳道,画布用它把 supervisor 节点
* 展开成协作矩阵。两边各写一遍的话,同一个运行在右栏和画布上会给出不同的
* 并行度——而"几个人同时在跑、省了多少"正是这个节点唯一值得看的东西。
* 所以只留这一份翻译,两处都调它。
*
* 不可变:每次返回新对象。画布那份挂在 zustand 的节点状态上,原地改的话
* 引用不变,React 收不到更新。
*
* 返回 null 表示这个事件和协作团队无关(绝大多数事件都是)。
*/
export function reduceTeam(prev: TeamRun | undefined, event: RunEvent): TeamRunEx | null {
const d = (event.data ?? {}) as Record<string, any>
const nodeId = event.node_id
if (!nodeId) return null
const verdictOf = (team: TeamRun) => (team as TeamRunEx).verdict
const withVerdict = (team: TeamRun, patch: TeamVerdict): TeamRunEx =>
({ ...team, verdict: { ...verdictOf(team), ...patch } })
const roundOf = (team: TeamRun, round: number): TeamRound =>
team.rounds.find((x) => x.round === round) ?? {
round, parallel: 1, wallMs: 0, sumMs: 0, members: [],
}
const withRound = (team: TeamRun, round: number, patch: Partial<TeamRound>): TeamRun => {
const next = { ...roundOf(team, round), ...patch }
const rounds = [...team.rounds.filter((x) => x.round !== round), next]
.sort((a, b) => a.round - b.round)
// 只算整轮都交回了的:一轮刚开始时各人耗时都是 0,算出来的"省下 0ms"是
// 在报一个还没发生的收益
const savedMs = rounds.reduce((acc, x) => acc
+ (x.members.length && x.members.every((m) => m.status === 'done')
? Math.max(0, x.sumMs - x.wallMs) : 0), 0)
return { ...team, rounds, savedMs }
}
switch (event.type) {
case 'node.started': {
// 新的一次执行(循环体又一轮、接着跑、服务重启后再跑):上一次的泳道和结局不能挂着。
// 调大轮数接着跑成功了,结局还写「用完 4 轮仍未完成」;循环里第二轮的 round 又从 0 数,
// 会并进第一轮同号的那一列。规则和 trace.ts 一样,只有等过审批之后的那次重放算同一次——
// 接着跑时后端也标 resumed:true,所以不能只看 resumed
if (!prev) return null
if ((prev as TeamRunEx).paused) {
const { paused: _, ...same } = prev as TeamRunEx
return same
}
return { members: [], rounds: [], savedMs: 0, finished: false }
}
case 'human.requested':
return prev && !(prev as TeamRunEx).paused ? { ...prev, paused: true } : null
case 'agent.step.start': {
const team = prev ?? { members: [], rounds: [], savedMs: 0, finished: false }
const name = String(d.agent ?? '')
const round = num(d.round) ?? 0
const cur = roundOf(team, round)
const members = [...cur.members, {
agent: name, instruction: String(d.instruction ?? ''), ms: 0,
status: 'running' as TeamMember['status'],
}]
return withRound(
{ ...team, members: team.members.includes(name) ? team.members : [...team.members, name] },
round,
{ members, parallel: Math.max(cur.parallel, num(d.parallel) ?? 1) },
)
}
case 'agent.step.end': {
if (!prev) return null
const name = String(d.agent ?? '')
const round = num(d.round) ?? 0
const cur = roundOf(prev, round)
const ms = num(d.duration_ms) ?? 0
const preview = String(d.preview ?? '').slice(0, 2000)
// 失败的成员(异常,或者把工具调用写成了文字):格子画成失败,而不是一个写着
// 失败原因的「完成」格子
const ended: Partial<TeamMemberEx> = d.failed
? { ms, status: 'failed', result: preview || undefined, error: String(d.error ?? '') || undefined }
: { ms, status: 'done', result: preview || undefined }
const idx = cur.members.findIndex((x) => x.agent === name && x.status === 'running')
const members: TeamMember[] = idx >= 0
? cur.members.map((x, i) => (i === idx ? { ...x, ...ended } : x))
: [...cur.members, { agent: name, instruction: '', ms, status: 'done', ...ended }]
// 这一轮实际花的是最慢那个,各人之和减去它就是省下的
return withRound(prev, round, {
members,
wallMs: Math.max(...members.map((x) => x.ms)),
sumMs: members.reduce((acc, x) => acc + x.ms, 0),
})
}
case 'log': {
// 降档交付的那一条:轮数用完、调度者始终没说完成,按降档把成员原话交出去
if (d.code === 'team_exhausted') {
const team = prev ?? { members: [], rounds: [], savedMs: 0, finished: false }
return withVerdict({ ...team, finished: true }, { outcome: 'degraded', ...exhaustedOf(String(d.message ?? '')) })
}
// 带 round 的 info 日志是调度决策,不是排查日志
if (String(d.level ?? 'info') !== 'info' || d.round == null) return null
const team = prev ?? { members: [], rounds: [], savedMs: 0, finished: false }
const structured = Array.isArray(d.agents)
const text = String(d.message ?? '')
const legacy = text.match(/^调度\s*→\s*([^((]+)[((](.*)[))]\s*$/)
const done = structured ? !!d.done : legacy?.[1]?.trim() === 'FINISH'
const reason = structured ? String(d.reason ?? '') : (legacy?.[2]?.trim() ?? '')
if (done) return { ...team, finished: true }
// 最后那次判定的 round 是「派过的轮数」,不是新的一轮:记进一列的话矩阵会多出一列空的
if (d.closing) return team
return reason ? withRound(team, num(d.round) ?? 0, { reason }) : team
}
case 'agent.route.start': {
// 普通的一轮等 route.end 带着派给谁再记;只有收尾判定要先记一笔「在判定」,
// 不然那几秒泳道上什么都不说,看着像卡住了
if (!d.closing) return null
return withVerdict(prev ?? { members: [], rounds: [], savedMs: 0, finished: false }, { closing: true })
}
case 'agent.route.end': {
// 调度者的结构化决策。后端同一轮还会再发一条带 round 的 log,两条说的是
// 同一件事,按哪条来结果都一样;只认 log 的话,哪天 log 不发了理由就丢了
const team = prev ?? { members: [], rounds: [], savedMs: 0, finished: false }
const reason = String(d.reason ?? '')
if (d.closing) {
// 轮数用完后补的那一次判定:只下结论、不派活,不是第 N+1 轮
const judged = withVerdict(team, { closing: true, done: !!d.done, ...(reason ? { reason } : {}) })
return d.done ? { ...judged, finished: true } : judged
}
if (d.done) return { ...team, finished: true }
return reason ? withRound(team, num(d.round) ?? 0, { reason }) : team
}
case 'node.finished': {
// 降档交付:产出里带 exhausted。老后端没有 team_exhausted 那条日志时也认得出
const p = d.preview
if (!prev) return null
const settled = settledExec(prev)
if (!p || typeof p !== 'object' || !p.exhausted) return settled === prev ? null : settled
const known = verdictOf(prev)
// 产出里没有轮数(后端只给 exhausted / exhausted_reason / never_dispatched):
// 先认日志原话里的「用完 N 轮」,再退到这一次实际派过几轮
const rounds = num(p.rounds) ?? known?.rounds ?? (prev.rounds.length || undefined)
return withVerdict({ ...settled, finished: true }, {
outcome: 'degraded',
...(rounds != null ? { rounds } : {}),
...(!known?.reason && p.exhausted_reason ? { reason: String(p.exhausted_reason) } : {}),
})
}
case 'node.failed': {
// 判失败(on_exhausted 默认):报错首句就是「协作团队用完 N 轮仍未完成:…」
if (!prev) return null
const settled = settledExec(prev)
const exhausted = exhaustedOf(String(d.error ?? ''))
if (!exhausted) return settled === prev ? null : settled
return withVerdict({ ...settled, finished: true }, { outcome: 'failed', ...exhausted })
}
default:
return null
}
}
/**
* 把一次运行的事件解码成步骤树。
*
* 顶层是节点,节点内部的模型/工具调用是子步骤。没有 node_id 的事件
* (run.*、issuance 这类)留在顶层,按时间顺序穿插。
*
* 同一个节点在一次运行里执行多次(循环体、轮询、审批驳回后重来)时仍然只占
* 一行:每次执行记进 execs,子步骤带上 iter,界面据此按轮折叠。以前各轮的子
* 步骤全平铺进同一个父节点,一次 145 拍的运行就是 145 行一模一样的「运行
* Python 代码」,内层循环的「第 3 轮」在不同外层拍之间反复出现、对不上号。
*
* final 是事件之外知道的结局(见 decodePhase):服务被强杀的运行事件停在半路,
* 只有查到它是 interrupted、又没有待审批,才能把那几行转圈收成"挂起"。
*/
export function decodeRun(events: RunEvent[], final?: RunFinal, opts?: DecodeOptions): 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>()
/** supervisor 里待配对的单个 agent 步骤 */
const pendingAgents = new Map<string, Step>()
/** node_id|round → 调度者这一轮的决策行(route.start 开、route.end 收) */
const pendingRoutes = new Map<string, Step>()
/** 已经有结构化调度事件的轮次。同一轮那条带 round 的 log 说的是同一件事,不再成行 */
const routed = new Set<string>()
/** node_id → 协作团队的泳道数据。边解码边攒,最后挂到该节点的顶层 Step 上 */
const teams = new Map<string, TeamRunEx>()
/** node_id → 校验节点最近一次修复。被作废的说明(repair_invented)折进这一行 */
const repairs = new Map<string, Step>()
/**
* node_id → 门控拦下、还没见到下文的调用。下文是 human.requested(agent、工具节点:交给人批),
* 或者 code 为 tool_needs_approval 的 log(协作团队:成员停不下来,这次不执行)
*/
const escalated = new Map<string, { step: Step; tool: string; reason: string }[]>()
/** node_id → 起止时刻。用来事后认出"哪几个节点是同时跑的" */
const spans = new Map<string, { start: number; end: number; ms: number }>()
/** 协作团队的状态只有一个来源:reduceTeam。画布那边调的是同一个函数 */
const trackTeam = (event: RunEvent): void => {
const next = reduceTeam(teams.get(event.node_id ?? ''), event)
if (next && event.node_id) teams.set(event.node_id, next)
}
/**
* node_id → 这个节点当前那条尚未闭合的审批步骤。
*
* 审批是"开→闭"配对的,不能只靠内容去重。同一次中断会发三条事件
* (human.requested、run.interrupted,恢复后节点重放又来一条
* human.requested),三条内容完全一样;而一个循环里连续三轮审批
* (驳回 → 改写 → 再审)内容也完全一样。按内容去重的话,要么把重放
* 算成新的一轮,要么把真实的第二轮当成重放吞掉——两种都错。
*
* 开着就是同一次,闭了才是新一次。
*/
const openInterrupts = new Map<string, Step>()
const push = (step: Step, nodeId?: string) => {
const parent = nodeId ? nodeSteps.get(nodeId) : undefined
if (parent) {
// 记下属于父节点的第几次执行:分轮折叠靠它
if (parent.execs && parent.execs.length > 1) step.iter = parent.execs.length
else step.iter = 1
;(parent.children ??= []).push(step)
} else out.push(step)
}
/** 父节点这一次执行 */
const execOf = (nodeId?: string): Exec | undefined => {
const execs = nodeId ? nodeSteps.get(nodeId)?.execs : undefined
return execs?.[execs.length - 1]
}
/**
* 老后端的 agent 不发 llm.end,一次模型调用做没做完只能看它后面来了什么:同一节点接着
* 调了工具、开始了下一次调用、节点收了尾,前一次就是做完了。不收的话它们一直挂着,
* 运行被取消或失败时终态清扫把一整列「思考并作答」都写成已取消——而它们后面明明跟着
* 真执行过的工具。结束时刻取那条事件的时刻;清扫只剩最后那一次真在进行中的
*/
const thought = new WeakSet<Step>()
const closeLlm = (nodeId: string | undefined, status: StepStatus, at?: number) => {
const step = pendingLlm.get(nodeId ?? '_')
if (!step) return
step.status = status
if (step.ms == null && at != null && step.startedAt != null) {
step.ms = Math.max(0, Math.round(at - step.startedAt))
step.meta = dur(step.ms)
}
pendingLlm.delete(nodeId ?? '_')
}
/**
* 把决定折进那条审批行,而不是另起一行——"问了什么 → 怎么定的"是一件事。
*
* 谁定的照事件里的 actor 写。以前一律写"你驳回了",而多人使用时批的可能是
* 别人;没署名(actor 为 null、老数据没有这个字段)就只说结果,不猜是谁。
*/
const verdict = (approved: unknown, actor: unknown): string => {
const who = typeof actor === 'string' && actor.trim() ? actor.trim() : ''
const what = approved === false ? '驳回' : '批准'
return who ? `${who} 已${what}` : `已${what}`
}
const closeInterrupt = (key: string, approved: unknown, note: string, actor?: unknown, always = false): boolean => {
const step = openInterrupts.get(key)
if (!step) return false
step.status = 'done'
step.level = undefined
step.title = `${step.title} → ${verdict(approved, actor)}${always ? ALWAYS_NOTE : ''}`
if (note) step.detail = [step.detail, `备注:${note}`].filter(Boolean).join('\n')
openInterrupts.delete(key)
return true
}
/** 上一条就是恢复事件——用来认出紧随其后的那条重复的"继续运行" */
let justResumed = false
/** 恢复事件带来的签批人:run.resumed 有,紧接着的 human.resolved 老后端不一定有 */
let resumedActor: unknown
/** 相位跟着事件走,结局要知道此刻是不是停在审批上 */
let phase: PhaseState = { phase: 'idle', awaiting: false }
const settle = (ending: Ending) => {
settleSteps(out, ending)
for (const [id, team] of teams) teams.set(id, settleTeam(team, ending))
// 协作成员那几行挂在 pendingAgents 上等配对;结局之后不会再有 end 了
if (ending.phase !== 'waiting') {
pendingAgents.clear()
pendingTools.clear()
pendingLlm.clear()
pendingRoutes.clear()
if (ending.waiting !== 'waiting') openInterrupts.clear()
}
}
for (const event of events) {
const type = String(event.type)
if (EPHEMERAL.has(type) || SILENT.has(type)) continue
const awaiting = phase.awaiting
phase = nextPhase(phase, event)
const d: any = event.data ?? {}
const seq = event.seq ?? 0
const nodeId = event.node_id ?? undefined
const at = tsMs(event)
// 每条事件都会把"刚恢复过"清掉;只有恢复分支会重新点亮它,
// 所以这个标记只在紧挨着的下一条上为真
const wasJustResumed = justResumed
justResumed = false
switch (type) {
case 'run.started':
case 'run.resumed': {
const resumed = type === 'run.resumed' || !!d.resumed
if (type === 'run.resumed') resumedActor = d.actor
// 恢复一次会紧挨着发两条:run.resumed 和 run.started(resumed=true)。
// 说两遍"继续运行"会让人以为恢复了两次。
//
// 判据是"这两条是不是紧挨着的",不是"界面上还有没有待办"——后者
// 曾经能用,但只要多一处会产生 waiting 状态的地方(比如把被中断的
// 节点也标成等待),它就失效了。相邻性是这两条事件的固有性质,
// 不会因为别处改了展示而变。
if (resumed && wasJustResumed) break
if (resumed) {
justResumed = true
// 人已经答过了,那条不该再是橙色的"等你确认"。但配对关系要留着:
// 紧接着 LangGraph 会重放该节点,又发一条一模一样的
// human.requested,配对还开着才能认出那是重放而不是新一轮。
// 真正的闭合(把决定折进标题)交给带 node_id 的 human.resolved。
openInterrupts.forEach((s) => {
if (s.status === 'waiting') { s.status = 'done'; s.level = undefined }
})
}
const who = typeof d.actor === 'string' && d.actor.trim() ? d.actor.trim() : ''
out.push({
id: `s-${seq}`, seq, kind: 'lifecycle', status: 'running', startedAt: at,
title: resumed ? '继续运行' : num(d.nodes) != null ? `开始运行(${d.nodes} 个节点)` : '开始运行',
...(num(d.nodes) != null && !resumed ? { total: d.nodes } : {}),
// 「从「X」接着跑,保留了前面 3 个节点」——从哪接、谁点的,续跑才对得上账
...(resumed && (d.message || who)
? { sub: [String(d.message ?? ''), who ? `由 ${who} 发起` : ''].filter(Boolean).join(' · ') }
: {}),
})
break
}
case 'node.started': {
trackTeam(event)
// data.label 是节点标题随事件传递的唯一来源——靠画布 nodes 反查的话,
// 助手栏和运行页没加载画布,标题会退化成裸 node_id。但没起过名字的
// 节点它等于节点 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 || '执行步骤'
const iteration = num(d.iteration)
if (nodeId && !spans.has(nodeId)) {
spans.set(nodeId, { start: event.ts ?? 0, end: event.ts ?? 0, ms: 0 })
}
const existing = nodeId ? nodeSteps.get(nodeId) : undefined
if (existing) {
const execs = existing.execs ??= [{ n: 1, seq: existing.seq, status: existing.status ?? 'done' }]
// 同一个节点又开始了,两种可能:
// - 重放:审批恢复、接着跑之后 LangGraph 会把停下的那个节点再执行一遍。
// 那不是"又执行了一个步骤",是同一步继续——新后端标 resumed,老数据
// 认"它上次没做完"(还停在等人 / 在跑 / 挂起)
// - 新的一轮:上次已经做完了(循环体、驳回后重来)
const replay = !!d.resumed || existing.status === 'waiting'
|| existing.status === 'running' || existing.status === 'suspended'
if (replay) {
const cur = execs[execs.length - 1]
cur.status = 'running'
} else {
execs.push({ n: execs.length + 1, seq, status: 'running',
...(iteration != null ? { iteration } : {}) })
// 上一次的降档说明、跳过原因说的是上一次:循环第 2 轮正常收尾了,行上还写着
// 「用完 2 轮仍未完成,按降档交付」
existing.sub = undefined
}
existing.status = 'running'
// 上一轮的失败、耗时不能挂在正在跑的这一轮上
existing.level = undefined
existing.meta = undefined
existing.startedAt = at
break
}
const step: Step = {
id: `n-${nodeId ?? seq}`, seq, kind: 'node', nodeId, title, status: 'running',
startedAt: at,
execs: [{ n: 1, seq, status: 'running', ...(iteration != null ? { iteration } : {}) }],
}
if (nodeId) nodeSteps.set(nodeId, step)
out.push(step)
break
}
case 'node.skipped': {
// skip_if 成立:这一步没有执行。以前整类事件被静默丢掉,用户分不清"被
// 条件跳过"和"根本没轮到",受限编排做过的这个决定在时间线上没有痕迹
const label = String(d.label ?? '')
const name = (label && label !== nodeId ? label : TYPE_LABEL[String(d.node_type ?? '')])
|| nodeId || '此步骤'
const reason = String(d.reason ?? '')
const existing = nodeId ? nodeSteps.get(nodeId) : undefined
if (existing) {
const cur = execOf(nodeId)
if (existing.status !== 'running') {
existing.execs?.push({ n: existing.execs.length + 1, seq, status: 'skipped' })
} else if (cur) cur.status = 'skipped'
existing.status = 'skipped'
existing.sub = reason || existing.sub
break
}
const step: Step = {
id: `sk-${nodeId ?? seq}`, seq, kind: 'node', nodeId, status: 'skipped',
title: `跳过「${name}」`,
...(reason ? { sub: reason } : {}),
execs: [{ n: 1, seq, status: 'skipped' }],
}
if (nodeId) nodeSteps.set(nodeId, step)
out.push(step)
break
}
case 'node.finished': {
// 人工节点跑完就说明决定已经生效了。正常路径上 human.resolved 早就
// 闭合了配对,这里只是兜底:万一那条事件没发出来(旧运行、别的
// 审批来源),配对不能一直挂着——挂着的话这个节点下一轮真实审批
// 会被当成重放吞掉
if (nodeId && openInterrupts.has(nodeId)) {
const p = d.preview ?? {}
closeInterrupt(nodeId,
typeof p === 'object' ? p.approved : undefined,
typeof p === 'object' ? String(p.note ?? '') : '',
resumedActor)
}
const span = nodeId ? spans.get(nodeId) : undefined
if (span) {
span.end = event.ts ?? span.end
span.ms = num(d.duration_ms) ?? Math.round((span.end - span.start) * 1000)
}
closeLlm(nodeId, 'done', at)
const step = nodeId ? nodeSteps.get(nodeId) : undefined
if (step) {
const ms = num(d.duration_ms)
const cur = execOf(nodeId)
if (cur) { cur.status = 'done'; if (ms != null) cur.ms = ms }
step.status = 'done'
step.artifact = d.artifact || step.artifact
const execs = step.execs ?? []
if (execs.length > 1) {
// 执行过多次:行尾说"几次、一共多久",每一次的耗时在分轮折叠里看
const total = execs.reduce((acc, x) => acc + (x.ms ?? 0), 0)
step.ms = total
step.meta = [`×${execs.length}`, dur(total) && `共 ${dur(total)}`].filter(Boolean).join(' · ')
} else {
step.ms = ms
const attempt = num(d.attempt)
step.meta = [dur(ms), attempt && attempt > 1 ? `重试 ${attempt - 1} 次` : '']
.filter(Boolean).join(' · ') || undefined
}
// 空对象/空串不是"详情",显示出来只是噪音
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)
}
}
trackTeam(event)
// 协作团队降档交付:节点确实跑完了,但交出去的是成员最后的原话,不是一个安静的勾。
// 轮数跟泳道的结局同一个来源(先 trackTeam):产出里不带,要从日志原话、派过的轮数里认
if (step && d.preview && typeof d.preview === 'object' && d.preview.exhausted) {
step.level = 'warn'
const rounds = teamVerdictOf(teams.get(nodeId ?? ''))?.rounds
step.sub = `${rounds != null ? `已用完 ${rounds} 轮` : '已用完轮数'}仍未完成,按降档交付`
}
break
}
case 'node.failed': {
// 节点失败时还在进行中的那次调用就是出错的那次,跟节点一起算失败,不留给清扫写成已取消
closeLlm(nodeId, 'failed', at)
const step = nodeId ? nodeSteps.get(nodeId) : undefined
const ms = num(d.duration_ms)
// detail 是原始异常:给排查用,不给人读,折起来
const raw = typeof d.detail === 'string' && d.detail ? d.detail : undefined
if (step) {
const cur = execOf(nodeId)
if (cur) { cur.status = 'failed'; if (ms != null) cur.ms = ms }
step.status = 'failed'
step.level = 'error'
step.detail = String(d.error ?? '')
step.raw = raw
step.ms = ms
step.meta = dur(ms)
} else {
push({ id: `e-${seq}`, seq, kind: 'error', level: 'error', status: 'failed',
title: String(d.error ?? '此步骤失败'), nodeId, raw }, nodeId)
}
trackTeam(event)
break
}
case 'llm.start': {
// 一出现就成行:模型想的那几十秒里得有一行在走,而不是等 end 才冒出来。
// 团队成员和调度者的调用没有 start(它们的 end 只用来记账),不会走到这
closeLlm(nodeId, 'done', at)
// agent 开了 cite_fields:循环结束后多一次结构化抽取,按出处把字段交出来。它不是在「作答」。
// 报告撰写节点请裁判模型判断结论句(purpose judge)同理,另起名字
const extract = d.purpose === 'cite_fields'
const judging = d.purpose === 'judge'
const step: Step = {
id: `llm-${seq}`, seq, kind: 'llm',
title: extract ? EXTRACT_TITLE : judging ? judgeTitle(d.units) : '思考并作答',
status: 'running', nodeId, startedAt: at,
...(extract ? { code: 'cite_fields' } : judging ? { code: 'judge' } : {}),
...(d.model ? { detail: `模型:${d.model}` } : {}),
}
pendingLlm.set(nodeId ?? '_', step)
push(step, nodeId)
break
}
case 'llm.end': {
if (d.purpose === 'repair') {
// 校验节点让模型修格式:一次真实的模型调用(前面没有 llm.start)。以前它不成行,
// 修复编出来的值被作废时,时间线上连「修过」这件事都看不到
const ms = num(d.duration_ms)
const step: Step = {
id: `rp-${seq}`, seq, kind: 'llm', code: 'repair', nodeId, status: 'done',
title: '让模型修复格式', ms, meta: dur(ms),
...(d.model ? { detail: `模型:${d.model}` } : {}),
}
repairs.set(nodeId ?? '_', step)
push(step, nodeId)
break
}
const step = pendingLlm.get(nodeId ?? '_')
if (!step) break
step.status = 'done'
// 不给 token 数和美元——那是账单视角,不是"它干了什么"。
// 用量在运行面板底栏、运行详情的用量区单独看。
step.ms = num(d.duration_ms)
step.meta = dur(step.ms)
if (d.model && !step.detail) step.detail = `模型:${d.model}`
if (d.purpose === 'cite_fields' && d.error) {
// 抽取没跑成:agent 的结论还在,字段记空值、接着发警告——节点不失败,这一行是提醒。
// 状态记成做完(琥珀色)而不是 failed:failed 会把这一行和「执行」那一栏的标头都画成红的
step.level = 'warn'
step.title = `${EXTRACT_TITLE}失败`
step.detail = [String(d.error), step.detail].filter(Boolean).join('\n')
}
if (d.purpose === 'judge' && d.error) {
// 裁判没跑成(超时、调用失败):这一批记为未裁判、报告照常交——同样是提醒,不是失败
step.level = 'warn'
step.title = JUDGE_FAILED_TITLE
step.detail = [String(d.error), step.detail].filter(Boolean).join('\n')
}
pendingLlm.delete(nodeId ?? '_')
break
}
case 'llm.thinking': {
// 只有 Anthropic 系模型会产出(thinking_text 认的是 thinking 块)。
// 换个 provider 就没有——所以它是增强信息,主干靠上面那些步骤撑着。
const text = String(d.text ?? d.delta ?? '')
if (!text.trim()) break
const full = text + (d.truncated ? '\n\n(已截断)' : '')
// 思考是这次调用的"意图",不单独成行:挂到它所属的那次模型调用上当副
// 标题。以前一次运行 23 行里 9 行是斜体的思考摘录,把真正的动作挤散了
// 一次调用只在收尾时落一条完整思考:进行中的那次已经带着一条,说明它早做完了
const open = pendingLlm.get(nodeId ?? '_')
if (open && thought.has(open)) closeLlm(nodeId, 'done', at)
const siblings = nodeId ? nodeSteps.get(nodeId)?.children : undefined
const host = pendingLlm.get(nodeId ?? '_')
?? [...(siblings ?? [])].reverse().find((s) => s.kind === 'llm' && !s.sub)
if (host) {
thought.add(host)
host.sub = thinkingHeadline(text, false)
host.detail = [full, host.detail].filter(Boolean).join('\n\n')
break
}
push({
id: `th-${seq}`, seq, kind: 'think', nodeId, status: 'done',
title: thinkingHeadline(text, false), detail: full,
}, nodeId)
break
}
case 'tool.start': {
closeLlm(nodeId, 'done', at)
const step = toolStep(seq, String(d.tool ?? ''), d.args ?? {})
step.nodeId = nodeId
step.startedAt = at
// 引擎到点会放弃等待:进行中的秒表越过它时,界面要说「已超出上限」,而不是一直走
const limit = num(d.timeout_s)
if (limit != null && limit > 0) step.limitS = limit
// 协作成员调的工具说清是谁调的
if (d.agent) step.sub = `${d.agent} 调用`
// call_id 才是可靠的配对键:同一节点并发调同名工具时,按名字配会错位。
// 独立 Tool 节点不带 call_id,回退到「节点 + 工具名」:两个并行的查询节点用同一个工具时,
// 只按工具名配,后开始的那条会顶掉先开始的,结果配错节点
pendingTools.set(toolKey(nodeId, d, seq), step)
push(step, nodeId)
break
}
case 'tool.end':
case 'tool.error': {
const key = toolKey(nodeId, d)
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'
if (d.timed_out) {
// 到点被放弃的:数据库那边可能还在跑,这里只是不等了。上限写出来,才知道
// 该缩小查询范围还是去调时限
const limit = step.limitS ?? num(Number(preview.match(/超过\s*(\d+(?:\.\d+)?)\s*(?:s|秒)/)?.[1]))
const why = limit != null ? `超过 ${formatNumber(limit)} 秒上限,已停止等待` : '超过时限,已停止等待'
step.sub = d.agent ? `${why} · ${d.agent} 调用` : why
}
// tool.error 只有 {tool,error},没有 duration_ms/preview——不能假设统一形状
const rows = rowCountOf(preview)
step.ms = num(d.duration_ms)
const parts = [
rows != null ? `${formatNumber(rows)} 行` : '',
dur(step.ms) ?? '',
].filter(Boolean)
step.meta = parts.join(' · ') || undefined
step.artifact = d.artifact
if (typeof d.detail === 'string' && d.detail) step.raw = d.detail
// 这次查询对照数据目录的 SQL 检查(tool.end.checks):preview 只是结果的前一截,检查结果通常被截掉,单独带着。
// 副标题数一下错误、提醒、提示,展开看每一条
const checks = sqlChecksOf(d.checks)
if (checks.length && !failed) {
step.checks = sortSqlChecks(checks)
const n = (lv: string) => checks.filter((c) => c.level === lv).length
const counts = SQL_CHECK_TEXT.assistantTitle(SQL_CHECK_TEXT.counts(n('error'), n('warning'), n('info')))
step.sub = step.sub ? `${step.sub} · ${counts}` : counts
if (n('error') || n('warning')) step.level = 'warn'
}
if (step.kind === 'query' || step.kind === 'schema' || failed) {
// 查询保留 SQL 作为 detail,结果另挂;失败时结果就是错误原因
step.detail = failed ? `${step.detail ?? ''}\n\n${preview}`.trim() : step.detail
step.result = preview
} else {
step.detail = preview.slice(0, 4000) || undefined
}
pendingTools.delete(key)
}
break
}
case 'tool.gated': {
// 放行是安静的一行(接着就是正常的工具调用);拦下的要人看见,并说清交给了谁。
// 要调什么是那次模型调用答出来的,问门控时它已经做完了(同 tool.start)
closeLlm(nodeId, 'done', at)
const tool = typeof d.tool === 'string' ? d.tool : ''
const reason = typeof d.reason === 'string' ? d.reason.trim() : ''
const allow = d.verdict === 'allow'
const full = gateTitle(tool, allow ? 'allow' : 'escalate', reason)
const step: Step = {
id: `gt-${seq}`, seq, kind: 'note', nodeId, status: 'done', code: 'tool_gated',
level: allow ? 'info' : 'warn',
title: clip(full, 120),
...(full.length > 120 ? { detail: reason } : {}),
...(d.agent ? { sub: `${d.agent} 调用` } : {}),
meta: [typeof d.model === 'string' && d.model ? `门控 ${d.model}` : '', dur(num(d.duration_ms)) ?? '']
.filter(Boolean).join(' · ') || undefined,
}
push(step, nodeId)
if (!allow) {
const key = nodeId ?? '_'
escalated.set(key, [...(escalated.get(key) ?? []), { step, tool, reason }])
}
break
}
case 'sandbox.start':
pendingTools.set(`sandbox-${nodeId ?? seq}`, (() => {
const step: Step = {
id: `sb-${seq}`, seq, kind: 'code', nodeId, status: 'running', startedAt: at,
title: `运行${d.language === 'python' ? ' Python ' : d.language ? ` ${d.language} ` : ''}代码`,
// 渲染后的代码。编辑器里那份带着 {{ }},送进沙箱的是替换完的——
// 值里有引号或换行就会把程序写坏,而报错说的是渲染后的行号。
// 只有插过值的才值得摆出来,没插值的两份一模一样,显示等于噪音
detail: d.interpolated && d.code
? `# 实际执行的代码(模板已展开)\n${String(d.code)}`
+ (d.code_truncated ? '\n\n(已截断)' : '')
: undefined,
}
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.ms = num(d.duration_ms)
step.meta = dur(step.ms)
const body = [d.stdout, d.stderr].filter(Boolean).join('\n').slice(0, 4000)
// 和查询步骤同一个分工:detail 是"跑的是什么"(渲染后的代码),
// result 是"跑出了什么"。塞进同一个字段的话,报错时最需要的两样
// 东西——实际代码和 traceback——只能看见一样
step.result = body || (d.ok ? '(无输出)'
: num(d.exit_code) != null ? `退出码 ${d.exit_code}` : '沙箱未返回退出码')
if (!step.detail) step.detail = step.result
pendingTools.delete(`sandbox-${nodeId ?? seq}`)
}
break
}
case 'edge.taken': {
const key = String(d.branch ?? '')
const ran = num(d.iteration)
const loop = d.mode === 'foreach' || d.mode === 'while' || ran != null
const name = (nodeId && opts?.exitLabelOf?.(nodeId, key))
|| (loop ? LOOP_EXIT[key] : key === 'default' ? '其他' : undefined) || key
push({
id: `br-${seq}`, seq, kind: 'branch', nodeId, status: 'done',
title: `转入「${name}」分支`
+ (ran == null ? '' : key === 'done' ? loopDoneNote(ran, num(d.total)) : `(第 ${ran + 1} 轮)`),
detail: d.reason ? String(d.reason) : undefined,
}, nodeId)
break
}
case 'human.requested':
case 'run.interrupted': {
// 同一次中断发三条事件:human.requested、run.interrupted,恢复后
// 节点重放又来一条 human.requested(LangGraph 的重放语义,不是 bug)。
// 三条都指向同一次"等你确认",界面上只该有一条。
trackTeam(event)
// 门控拦下的那次调用交到了人手里:「交给人工审批」说的是实话,不会再等协作团队那条 log
escalated.delete(nodeId ?? '_')
const payload = d.payload ?? d
const key = String(payload.node_id ?? nodeId ?? '_')
if (openInterrupts.has(key)) { // 同一次中断的后续事件
if (type === 'run.interrupted') closeLifecycles(out, 'done')
break
}
// agent 要调的工具等审批:要调什么是那次模型调用答出来的,它已经做完了
closeLlm(nodeId, 'done', at)
const step: Step = {
id: `hm-${seq}`, seq, kind: 'human', nodeId, status: 'waiting', level: 'warn',
startedAt: at,
title: String(payload.title || d.title || '待审批'),
detail: [payload.message, payload.tool ? `工具:${payload.tool}` : '']
.filter(Boolean).join('\n') || undefined,
}
openInterrupts.set(key, step)
push(step, nodeId)
// 承载它的那个节点也不是"在跑"——它停下来等人了。转着蓝圈说的是
// "在忙,你等着",而实际情况正相反:它在等你
const host = nodeId ? nodeSteps.get(nodeId) : undefined
if (host && host.status === 'running') {
host.status = 'waiting'
const cur = execOf(nodeId)
if (cur) cur.status = 'waiting'
}
// 这一段执行到此停下等人:"开始运行"不能再转圈。恢复后会另起一行"继续运行"
if (type === 'run.interrupted') closeLifecycles(out, 'done')
break
}
case 'human.resolved': {
// 人工节点把答复包在 response 里;agent / 工具节点的工具审批是平铺的 {tool, approved, note, always}。
// 以前只读 response,工具审批被驳回也写成「放行了」
const r = d.response && typeof d.response === 'object' ? d.response : d
const approved = r.approved
const note = String(r.note ?? '')
// 审批卡上点的是「始终允许」:批了这次,这个工具之后改由门控把关
const always = r.always === true || d.always === true
const actor = d.actor !== undefined ? d.actor : resumedActor
if (closeInterrupt(String(nodeId ?? '_'), approved, note, actor, always)) break
// 没有对应的待决审批(历史事件不全、或者审批发生在别处)——
// 还是要把决定说出来,只是没地方折进去
push({
id: `hr-${seq}`, seq, kind: 'human', nodeId, status: 'done',
title: `${verdict(approved, actor)}${always ? ALWAYS_NOTE : ''}`,
detail: note || undefined,
}, nodeId)
break
}
case 'issuance': {
const tier = String(d.tier ?? '')
const label = issuanceLabel(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} 个数字无法追溯到指标集`)
// 校验本身没跑全(叙述渲染为空、指标集为空)也会降档。不写出来的话,
// 时间线上就是一个光秃秃的「降档出具」,看起来和"全部通过"只差一个字。
// 双盲复算、反方审稿报上来的缺口另起,按查出了问题、没核成分成两组,不归在「校验不完整」下
const { failed, unfinished, other: notRun } = splitIssuanceGaps(Array.isArray(d.gaps) ? d.gaps.map(String) : [])
if (failed.length) gaps.push(`${DC.verifyGaps.failed}:${failed.join(';')}`)
if (unfinished.length) gaps.push(`${DC.verifyGaps.unfinished}:${unfinished.join(';')}`)
if (notRun.length) gaps.push(`校验不完整:${notRun.join(';')}`)
const checked = [
num(d.metrics_checked) != null ? `核对 ${d.metrics_checked} 个指标` : '',
num(d.matched_numbers) != null ? `可追溯 ${d.matched_numbers} 个数字` : '',
].filter(Boolean).join(' · ')
out.push({
id: `is-${seq}`, seq, kind: 'issuance', status: 'done', ...(tier ? { tier } : {}),
level: tier === 'formal' ? 'info' : tier === 'withheld' ? 'error' : 'warn',
title: clip(gaps.length ? `${label}:${gaps.join(';')}` : label, 120),
// 核对了几个指标、回指了几个数字放进展开区:横幅上有同样的数,窄栏的行尾放不下
...(gaps.length || checked ? { detail: [...gaps, checked].filter(Boolean).join('\n') } : {}),
})
break
}
case 'report.checked': {
// 报告撰写节点核对完自己写的报告:几个数字有出处、哪些地方没有证据。说法和出具
// 横幅那一行同一种(evidenceTally)。载荷缺字段(别的版本的后端)就只说核对过
// 前半句只数数字(无证据 = 总数 − 有出处,算得平),不是数字的解析不了的引用另起一句
const counts = statsTally(d.stats && typeof d.stats === 'object' ? d.stats : null)
const none = counts ? counts.none + counts.other : 0
const violations: any[] = Array.isArray(d.violations) ? d.violations : []
const repairs = num(d.repairs) ?? 0
const tally = [counts ? evidenceTally(counts.cited, counts.total, counts.other) : '', judgeTally(d.judge)]
.filter(Boolean).join(' · ')
const listed = violations.slice(0, 8)
.map((v) => `· ${String(v?.message ?? v?.text ?? v?.code ?? '')}`).filter((x) => x.length > 2)
const more = violations.length > listed.length ? [`…另有 ${violations.length - listed.length} 处`] : []
// on_violation=fail 且重写后仍不过:节点接着就失败,这一步不能画成完成。老后端没有 failed 字段,照旧
const failed = d.failed === true
push({
id: `rc-${seq}`, seq, kind: 'issuance', nodeId, status: failed ? 'failed' : 'done', code: 'report_checked',
level: failed ? 'error' : none || violations.length ? 'warn' : 'info',
title: tally ? `核对报告:${tally}` : '核对报告',
...(listed.length ? { detail: [...listed, ...more].join('\n') } : {}),
...(repairs ? { meta: `重写 ${repairs} 次` } : {}),
...(typeof d.doc_artifact === 'string' && d.doc_artifact ? { artifact: d.doc_artifact } : {}),
}, nodeId)
break
}
case 'evidence.judged': {
// 探索运行跑完、封存之后,有人点开结论句请模型判断:判定追加在封存之后,不改封存的报告
const verdicts = d.verdicts && typeof d.verdicts === 'object' ? Object.values(d.verdicts as Record<string, any>) : []
const judged = verdicts.filter((v) => isJudged(v)).length
const hit: string[] = Array.isArray(d.limits_hit) ? d.limits_hit : []
push({
id: `ej-${seq}`, seq, kind: 'note', nodeId, status: 'done', code: 'evidence_judged',
level: hit.length || (Array.isArray(d.gaps) && d.gaps.length) ? 'warn' : 'info',
title: `封存后按需裁判了 ${formatNumber(judged)} 句结论${hit.length ? ',部分因达到上限未裁判' : ''}`,
sub: '封存后追加 · 模型判断,非确定',
// 裁判模型和写作模型相同、模型不在价格表里:和证据面板「模型的解释」同样的两句提醒
...(d.model ? { detail: [`模型:${d.model}`, ...(d.same_model === true ? [JUDGE_TEXT.sameModel] : []),
...(d.priced === false ? [JUDGE_TEXT.noPrice] : []),
...(Array.isArray(d.gaps) ? d.gaps.map(String) : [])].join('\n') } : {}),
}, nodeId)
break
}
case 'approval.inquiry':
// 审批人在批准前就待审的报告提问(审批质询):运行停在审批上时服务端追加这一条,node_id 为空,运行本身没动、
// 报告也不改(相位照旧,见 nextPhase)。封存之后探索运行也能问,带 post_seal。一条事件就是一问一答,挂在顶层
out.push(inquiryStep(d, seq))
break
case 'catalog.drift': {
// 从发布版本发起的正式运行:发布之后数据目录有变化。只提醒、运行照常,所以是一条警告,不是失败
const drift = catalogDrift(d)
if (!drift) break
out.push({
id: `cd-${seq}`, seq, kind: 'note', level: 'warn', status: 'done', code: 'catalog_drift',
title: drift.title, sub: CATALOG_DRIFT_TEXT.sub, detail: drift.detail,
})
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.route.start': {
// 调度者决策前一条。它常常占掉团队节点大半的时间——几十秒里时间线上
// 得有一行在走,不然看着像卡住了
const round = num(d.round) ?? 0
const step: Step = {
id: `rs-${seq}`, seq, kind: 'branch', nodeId, status: 'running', startedAt: at,
// 轮数用完后补的那一次只判定、不派活。它的 round 是「派过几轮」,写成「第 N+1 轮」
// 读起来像还有新的一轮
title: d.closing ? '轮数用完 · 调度者正在做最后判定…' : `第 ${round + 1} 轮:调度者正在规划下一步…`,
}
pendingRoutes.set(`${nodeId}|${round}`, step)
push(step, nodeId)
trackTeam(event)
break
}
case 'agent.route.end': {
const round = num(d.round) ?? 0
const key = `${nodeId}|${round}`
routed.add(key)
const agents: string[] = Array.isArray(d.agents) ? d.agents.map(String) : []
const ms = num(d.duration_ms)
const reason = String(d.reason ?? '')
// 最后那次判定说「没完成」时不能写「结束协作」:那读起来像团队正常收尾了
const title = d.closing
? d.done ? '轮数用完 · 调度者判定:已完成'
: clip(`轮数用完 · 调度者判定:未完成${reason ? `(${reason})` : ''}`)
: d.done || !agents.length ? `第 ${round + 1} 轮:结束协作`
: agents.length > 1 ? `第 ${round + 1} 轮:交给 ${agents.join('、')}(并行)`
: `第 ${round + 1} 轮:交给 ${agents[0]}`
const verdictLevel = d.closing && !d.done ? { level: 'warn' as const } : {}
const step = pendingRoutes.get(key)
if (step) {
Object.assign(step, { status: 'done', title, ms, meta: dur(ms), ...verdictLevel,
...(reason ? { detail: reason } : {}) })
pendingRoutes.delete(key)
} else {
push({ id: `re-${seq}`, seq, kind: 'branch', nodeId, status: 'done', title, ms,
meta: dur(ms), ...verdictLevel, ...(reason ? { detail: reason } : {}) }, nodeId)
}
trackTeam(event)
break
}
case 'agent.step.start': {
// 和 tool.start/tool.end 一样是一件事的两个时刻。拆成两行的话,
// "派给 researcher 什么任务"和"它回了什么"会变成两条互不相干的记录,
// 而且 start 那条永远停在转圈——多 agent 节点跑完了它还在转。
const step: Step = {
id: `as-${seq}`, seq, kind: 'note', nodeId, status: 'running', startedAt: at,
title: clip(`${d.agent}:${String(d.instruction ?? '')}`, 80),
}
pendingAgents.set(agentKey(nodeId, d), step)
push(step, nodeId)
trackTeam(event)
break
}
case 'agent.step.end': {
const key = agentKey(nodeId, d)
const step = pendingAgents.get(key)
const preview = String(d.preview ?? '').slice(0, 2000)
const ms = num(d.duration_ms)
// 成员这一步失败了(异常,或者把工具调用写成了文字):原因写在副标题上,不用展开才看得到。
// 标记那种的原话是一整句排查说明,窄栏里截断后只剩半句,这里换成和报错行同一个说法
const why = String(d.error ?? '')
const failure: Partial<Step> = d.failed
? { status: 'failed', level: 'error',
sub: /没有真正调用工具|未实际调用工具|工具调用的原始标记/.test(why) ? '模型未实际调用工具,此步骤未查询到任何数据'
: clip(why || '此步骤失败', 80) }
: { status: 'done' }
if (step) {
Object.assign(step, failure)
step.ms = ms
step.meta = dur(ms)
step.result = preview || undefined
pendingAgents.delete(key)
} else {
push({
id: `ae-${seq}`, seq, kind: 'note', nodeId, status: 'done',
title: `${d.agent} 回复`, ms, meta: dur(ms),
detail: preview || undefined, ...failure,
}, nodeId)
}
trackTeam(event)
break
}
case 'log': {
const level = String(d.level ?? 'info')
const ending = endingOf(event, awaiting)
if (ending) settle(ending)
// supervisor 的调度决策后端标成了 info,但它不是排查用的日志——
// "为什么派给 researcher"、"为什么只跑一轮就 FINISH",不显示的话
// 多 agent 节点在界面上就是一个跑了 31 秒的黑盒。带 round 字段的
// 就是它,和普通 info 日志区分得开
if (level === 'info' && d.round != null) {
trackTeam(event)
// 新后端先发了结构化的 agent.route.end,这条 log 说的是同一件事
if (routed.has(`${nodeId}|${num(d.round) ?? 0}`)) break
const text = String(d.message ?? '')
const round = (num(d.round) ?? 0) + 1
// 优先读结构化字段。以前只有一句中文消息串,这里得拿正则去拆
// 「调度 → X(理由)」——后端文案一改就散架,而文案是会改的。
// 老运行的事件没有这些字段,正则那条路留着兜底
const structured = Array.isArray(d.agents)
const m = text.match(/^调度\s*→\s*([^((]+)[((](.*)[))]\s*$/)
const legacyTarget = m?.[1].trim() ?? ''
const agents: string[] = structured
? (d.agents as unknown[]).map(String)
: (legacyTarget && legacyTarget !== 'FINISH' ? [legacyTarget] : [])
const done = structured ? !!d.done : legacyTarget === 'FINISH'
const reason = structured ? String(d.reason ?? '') : (m?.[2].trim() ?? '')
// FINISH 是协议里的收尾标记,不是某个 agent。"交给 FINISH"
// 会让人以为还有个叫 FINISH 的成员
const title = done ? `第 ${round} 轮:结束协作`
: agents.length > 1 ? `第 ${round} 轮:交给 ${agents.join('、')}(并行)`
: agents.length === 1 ? `第 ${round} 轮:交给 ${agents[0]}`
: clip(text)
push({
id: `sv-${seq}`, seq, kind: 'branch', nodeId, status: 'done',
title, detail: reason || undefined,
}, nodeId)
break
}
// 子工作流的进出是 info,但子图节点在时间线上本来就是个黑盒,这两句
// 是它唯一的内部痕迹
const message = String(d.message ?? '')
if (level === 'info' && /^(进入子工作流|子工作流「)/.test(message)) {
push({ id: `lg-${seq}`, seq, kind: 'note', nodeId, status: 'done', title: clip(message) },
nodeId)
break
}
if (d.code === 'tool_needs_approval') {
// 门控刚拦下的调用落在协作团队里:成员停不下来等人,这次调用不执行。折进门控那一行,
// 改口说「协作团队里不执行」,而不是先说交给人工、再冒出一行不执行。成员并行时几条
// 可能交错,按日志带的 tool 认(老后端没有这个字段,按原话里的工具名认);认不出就是最近那一条
const list = escalated.get(nodeId ?? '_')
if (list?.length) {
const named = typeof d.tool === 'string' && d.tool ? d.tool : ''
let i = list.length - 1
for (let j = list.length - 1; j >= 0; j -= 1) {
if (list[j].tool && (named ? list[j].tool === named : message.includes(list[j].tool))) { i = j; break }
}
const [gate] = list.splice(i, 1)
const full = gateTitle(gate.tool, 'team', gate.reason)
gate.step.title = clip(full, 120)
gate.step.detail = [full.length > 120 ? gate.reason : '', message].filter(Boolean).join('\n') || undefined
break
}
}
// 双盲复算、反方审稿的日志按 code 和结构化字段解读:info 的也上时间线,没有原话的老日志照样说得出来。
// 后端标了 warn / error 的照它的来,explainLog 认为该提醒的(有缺口、有推翻)也用提醒色
if (typeof d.code === 'string' && VERIFY_LOG_CODES.has(d.code)) {
const said = explainLog(d.code, message, d)
if (said) {
const tone = level === 'error' ? 'error' : level === 'warn' || said.level === 'warn' ? 'warn' : 'info'
push({
id: `lg-${seq}`, seq, kind: 'note', nodeId, status: 'done', code: d.code, ...said, level: tone,
...(message ? { detail: message } : {}),
}, nodeId)
break
}
}
// 其余 info 是给排查用的,不进主流程——但 warn/error 用户必须看到
if (level === 'info' || !message) break
const code = typeof d.code === 'string' && d.code ? d.code : undefined
const said = explainLog(code, message, d)
if (code === 'repair_invented') {
// 修复编出了原文没有的值:折进刚才那一行「让模型修复格式」,一件事一行
const repair = repairs.get(nodeId ?? '_')
if (repair && said) {
Object.assign(repair, { level: 'warn', sub: said.sub, next: said.next, fix: said.fix,
detail: [repair.detail, message].filter(Boolean).join('\n') })
repairs.delete(nodeId ?? '_')
break
}
}
if (code === 'team_exhausted') trackTeam(event)
// 要改的是另一个节点(来源查询):入口写它的名字,认不出就只写「打开来源查询的 SQL」
const fixLabel = said?.fixNode ? nodeSteps.get(said.fixNode)?.title : undefined
push({
id: `lg-${seq}`, seq, kind: 'note', nodeId, status: 'done',
level: level === 'error' ? 'error' : 'warn',
...(code ? { code } : {}),
...(said
? { ...said, detail: message, ...(fixLabel ? { fixLabel } : {}) }
: { title: clip(message, 120), ...(message.length > 120 ? { detail: message } : {}) }),
}, nodeId)
break
}
case 'run.failed': {
const msg = String(d.error ?? '运行失败')
// 节点失败会先报一次,run.failed 往往是同一句话——说两遍不会让人
// 更明白,只会让人以为出了两个错
const dup = out.some(function same(s): boolean {
return (s.status === 'failed' && (s.detail === msg || s.title === msg))
|| (s.children?.some(same) ?? false)
})
settle(endingOf(event, awaiting)!)
if (!dup) {
// 能定位到节点的话带上,点这一行画布就能取景过去
const where = String(d.label ?? '') || (d.node_id ? nodeSteps.get(d.node_id)?.title : '')
out.push({
id: `rf-${seq}`, seq, kind: 'error', level: 'error', status: 'failed', title: msg,
...(d.node_id ? { nodeId: String(d.node_id) } : {}),
...(where ? { sub: `出错的节点:${where}` } : {}),
...(typeof d.detail === 'string' && d.detail ? { raw: d.detail } : {}),
})
}
break
}
case 'run.cancelled': {
// 用户主动停下的,不是出错:收成中性的"已取消",不画红色的失败。谁停的、放弃等
// 审批时一并关了几条,写在副标题上——多人用时「怎么没了」得有个交代
settle(endingOf(event, awaiting)!)
// 说法和画布胶囊的那一句一致:「张工 放弃了这次运行,1 条待审批一并关闭」
const who = typeof d.actor === 'string' && d.actor.trim() ? d.actor.trim() : ''
const said = String(d.message ?? '').trim()
const sub = said ? `${who ? `${who} ` : ''}${said}` : who ? `${who} 取消了本次运行` : ''
out.push({ id: `rc-${seq}`, seq, kind: 'lifecycle', status: 'cancelled', title: '已取消',
...(sub ? { sub } : {}) })
break
}
case 'run.finished': {
// 开头那条"开始运行"要收尾,否则跑完了还挂着一个转圈的图标。
// 注意是全部而不是第一条:审批恢复过的运行有"开始运行"+"继续运行"
// 两条,只收第一条会让"继续运行"永远转圈——明明已经完成了。
settle(endingOf(event, awaiting)!)
const timing = d.timing ?? {}
const active = num(timing.active_ms) ?? num(d.duration_ms)
const wait = num(timing.wait_ms)
out.push({
id: `rd-${seq}`, seq, kind: 'lifecycle', status: 'done',
title: '完成', ms: active,
// 等人的时间单独说:审批停了三分钟的运行,"执行 1.2 s"和"用了 3 分钟"
// 都对,混成一个数就哪个都不对了
meta: [dur(active), wait ? `等待审批 ${formatDuration(wait)}` : ''].filter(Boolean).join(' · ')
|| undefined,
})
break
}
case 'stream.end': {
// 连接层的结束标记(WS 回放完、服务关停),不成行;带着的状态照样收尾
const ending = endingOf(event, awaiting)
if (ending) settle(ending)
break
}
case 'memory.end': {
// 写入要把**记了什么**原样显示出来。只说"写入 1 条"等于没说——
// 用户没法判断系统记的是不是他想让它记的,而这东西会影响以后每次对话
const action = String(d.action ?? '')
const count = num(d.count) ?? 0
const content = String(d.content ?? '')
if (action === 'write') {
push({
id: `mw-${seq}`, seq, kind: 'note', nodeId, status: 'done',
title: content ? `已写入记忆:${content.slice(0, 40)}${content.length > 40 ? '…' : ''}`
: '已写入一条记忆',
detail: content || undefined,
}, nodeId)
break
}
if (action === 'clear') {
push({
id: `mc-${seq}`, seq, kind: 'note', nodeId, status: 'done', level: 'warn',
title: `已清空记忆域「${String(d.scope ?? '')}」中的 ${count} 条记忆`,
}, nodeId)
break
}
push({
id: `mr-${seq}`, seq, kind: 'schema', nodeId,
status: count ? 'done' : 'failed',
level: count ? undefined : 'warn',
title: count ? `召回 ${count} 条相关记忆` : '未召回相关记忆',
}, nodeId)
break
}
case 'merge.end': {
// 合并查询:几次查询的结果在库外按键合并。和查询同一类(kind query):detail 是合并 SQL,result 是预览,
// 工件是合并结果的查询快照。输入列在 merge 里,展开区单列一块;警告的原文由紧跟着的 log 各占一行
const inputs: MergeStepInput[] = (Array.isArray(d.inputs) ? d.inputs : [])
.filter((i: any) => i && typeof i === 'object' && typeof i.alias === 'string')
.map((i: any) => ({
alias: String(i.alias),
...(typeof i.node_id === 'string' ? { nodeId: i.node_id } : {}),
...(typeof i.label === 'string' && i.label ? { label: i.label } : {}),
...(num(i.rows) != null ? { rows: num(i.rows) } : {}),
...(typeof i.source === 'string' && i.source ? { source: i.source } : {}),
}))
const warnings = Array.isArray(d.warnings) ? d.warnings.filter((w: any) => w && w.message).length : 0
const rows = num(d.rows)
const ms = num(d.duration_ms)
const preview = Array.isArray(d.preview_rows) ? d.preview_rows : []
push({
id: `mg-${seq}`, seq, kind: 'query', nodeId, status: 'done', code: 'merge',
...(warnings ? { level: 'warn' as const, sub: MERGE_TEXT.warnings(warnings) } : {}),
title: MERGE_TEXT.stepTitle(inputs.map((i) => i.alias)),
detail: typeof d.sql === 'string' && d.sql ? d.sql : undefined,
// 预览只存了前几行:那是「此处仅为预览」(clipped),不是「查询已达行数上限」(truncated)。
// 合并结果本身超过上限才是 truncated;row_count 记全部行数,表格脚注写「显示前 5 / 6 行」
result: Array.isArray(d.columns)
? JSON.stringify({ columns: d.columns, rows: preview, truncated: !!d.truncated,
clipped: rows != null && rows > preview.length, ...(rows != null ? { row_count: rows } : {}) })
: undefined,
meta: [rows != null ? MERGE_TEXT.rows(rows) : '', dur(ms) ?? ''].filter(Boolean).join(' · ') || undefined,
ms,
...(typeof d.query_artifact === 'string' && d.query_artifact ? { artifact: d.query_artifact } : {}),
merge: { inputs, warnings, ...(rows != null ? { rows } : {}), truncated: !!d.truncated },
}, nodeId)
break
}
case 'retrieve.end': {
// 知识检索。以前只发一条 info 日志,于是"这句结论依据的是哪份文档的
// 哪一段"在轨迹里查不到——而 SQL 取数那条路早就能下钻到工件
const count = num(d.count) ?? 0
const collection = String(d.collection ?? '')
const top = num(d.top_score)
push({
id: `rt-${seq}`, seq, kind: 'schema', nodeId,
status: count ? 'done' : 'failed',
level: d.degraded ? 'warn' : count ? undefined : 'warn',
title: count
? `在「${collection}」中检索到 ${count} 段`
: `在「${collection}」中未检索到内容`,
detail: String(d.query ?? '') || undefined,
meta: [
top != null ? `最高分 ${top}` : '',
// 退回关键词是"少了一半能力",不标出来用户只会觉得最近搜得不准
d.degraded ? '已回退为关键词检索' : '',
].filter(Boolean).join(' · ') || undefined,
artifact: d.artifact,
}, nodeId)
break
}
default:
// 新增事件类型时不要静默吞掉——宁可显示一条,也比让人觉得"什么都没
// 发生"好。但内部类型名(agent.route.end 这种)对使用者是一串代码,
// 标题、副标题都不放,收进展开区给排查的人认。加了新事件记得回来补映射
push({
id: `x-${seq}`, seq, kind: 'note', nodeId, status: 'done',
title: '未识别的事件',
detail: `事件类型:${type}\n${JSON.stringify(d, null, 2).slice(0, 1000)}`,
}, nodeId)
}
}
// 事件之外知道的结局(对账查到的状态)。已经收过的再收一次也不会变
if (final?.status) {
const ending = endingOf(finalEvent(final), phase.awaiting)
if (ending) settle(ending)
}
// 还没收到 end 的工具/模型调用保持 running——它们正在进行,不是丢了
groupConcurrent(out, spans)
// 泳道挂到 supervisor 节点的顶层 Step 上。节点内部那些"第N轮交给谁""X:指令"
// 仍然留着——泳道给的是一眼看清的形状,那些步骤给的是内容,两者不重复
for (const [nodeId, team] of teams) {
if (!team.rounds.length) continue
const step = nodeSteps.get(nodeId)
if (step) step.team = team
}
return out
}
// -------------------------------------------------------------------------
// 给界面用的统计:进度、用量、相邻重复行的合并
// -------------------------------------------------------------------------
export interface Progress {
/** 已经完成(或跳过)的不同节点数 */
done: number
/** 图里一共几个节点;老数据没有就是 undefined */
total?: number
/** 此刻正在跑的那一步(最深的一层) */
current?: Step
}
/**
* 跑到第几个节点了。按不同节点数算,不按执行次数:循环会让同一节点执行十几次,
* 按次数算会出现「9/8」这种比总数还多的读数。
*/
export function progressOf(steps: Step[]): Progress {
let total: number | undefined
const nodes = new Map<string, Step>()
let current: Step | undefined
const walk = (list: Step[], depth: number) => {
for (const s of list) {
if (s.kind === 'lifecycle' && s.total != null && total == null) total = s.total
if (s.kind === 'node' && s.nodeId && depth <= 1) nodes.set(s.nodeId, s)
if (s.status === 'running' && s.kind !== 'lifecycle') current = s
if (s.children) walk(s.children, s.kind === 'branch' && !s.nodeId ? depth : depth + 1)
}
}
walk(steps, 0)
let done = 0
for (const s of nodes.values()) {
if (s.status === 'done' || s.status === 'skipped' || (s.execs?.some((x) => x.status === 'done'))) done += 1
}
return { done, ...(total != null ? { total } : {}), ...(current ? { current } : {}) }
}
export interface UsageSummary {
tokensIn: number
tokensOut: number
costUsd: number
/** true = 取自 run.finished 的后端累计(权威);false = 实时累加的,可能偏少 */
final: boolean
activeMs?: number
waitMs?: number
wallMs?: number
}
/**
* 一次运行的用量和时长。运行中按 llm.end 累加(只用来显示,会偏少),到终态用
* run.finished 的后端累计校正——和画布 store 的 usageLive 同一个口径。
*/
export function usageOf(events: RunEvent[]): UsageSummary {
let tokensIn = 0
let tokensOut = 0
let costUsd = 0
let final = false
let timing: Record<string, any> | undefined
for (const e of events) {
const d: any = e.data ?? {}
if (e.type === 'llm.end') {
tokensIn += num(d.input_tokens) ?? 0
tokensOut += num(d.output_tokens) ?? 0
costUsd += num(d.cost_usd) ?? 0
} else if (e.type === 'run.finished' && d.usage) {
tokensIn = num(d.usage.input_tokens) ?? tokensIn
tokensOut = num(d.usage.output_tokens) ?? tokensOut
costUsd = num(d.usage.cost_usd) ?? costUsd
final = true
}
if ((e.type === 'run.finished' || e.type === 'run.failed' || e.type === 'run.cancelled')) {
timing = { ...(d.timing ?? {}), ...(num(d.duration_ms) != null && !d.timing ? { active_ms: d.duration_ms } : {}) }
}
}
return {
tokensIn, tokensOut, costUsd, final,
...(num(timing?.active_ms) != null ? { activeMs: timing!.active_ms } : {}),
...(num(timing?.wait_ms) != null ? { waitMs: timing!.wait_ms } : {}),
...(num(timing?.wall_ms) != null ? { wallMs: timing!.wall_ms } : {}),
}
}
const median = (xs: number[]): number => {
const s = [...xs].sort((a, b) => a - b)
const mid = Math.floor(s.length / 2)
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2
}
/**
* 相邻的同一件事并成一行:「运行 Python 代码 ×145 · 中位 12 ms · 最慢 63 ms」。
*
* 轮询、逐拍控制这类流程会连着出现上百行一模一样的步骤,一行一行排开只是
* 让人滚动几万像素,最慢那一拍、出警告的那一拍反而被淹没。并的时候保留
* level:「循环达到上限 ×136」照样是琥珀色。有子步骤、有泳道、没做完的不并——
* 它们各有各的内容。返回新数组,原数组不动。
*/
export function compactSteps(steps: Step[]): Step[] {
const out: Step[] = []
// 出了状况的行,副标题和下一步说的是为什么、怎么办:说法不同就是两件事,并成一行只剩第一件
const same = (a: Step, b: Step) => a.kind === b.kind && a.title === b.title
&& a.level === b.level && a.status === b.status && a.nodeId === b.nodeId
&& (!a.level || (a.sub === b.sub && a.next === b.next))
const mergeable = (s: Step) => !s.children?.length && !s.team && !s.execs?.length
&& s.status !== 'running' && s.status !== 'waiting'
for (const s of steps) {
const last = out[out.length - 1]
if (last && mergeable(s) && mergeable(last) && same(last, s)) {
const rep = last.repeat ?? { count: 1, ms: last.ms != null ? [last.ms] : [] }
rep.count += 1
if (s.ms != null) rep.ms.push(s.ms)
out[out.length - 1] = { ...last, repeat: rep, meta: repeatMeta(rep) }
continue
}
out.push(s)
}
return out
}
function repeatMeta(rep: { count: number; ms: number[] }): string {
const parts = [`×${rep.count}`]
if (rep.ms.length >= 2) {
const m = dur(median(rep.ms))
const max = dur(Math.max(...rep.ms))
if (m) parts.push(`中位 ${m}`)
if (max && max !== m) parts.push(`最慢 ${max}`)
}
return parts.join(' · ')
}
/** 分轮折叠用:按子步骤的 iter 分组,保持出现顺序 */
export function childrenByExec(step: Step): { exec: Exec; steps: Step[] }[] {
const execs = step.execs ?? []
const groups = execs.map((exec) => ({ exec, steps: [] as Step[] }))
for (const c of step.children ?? []) {
const g = groups[(c.iter ?? 1) - 1] ?? groups[groups.length - 1]
g?.steps.push(c)
}
return groups
}
/** 一串耗时的统计,给分轮折叠的摘要行和折线用 */
export function spread(ms: number[]): { median?: number; max?: number; maxAt?: number } {
const xs = ms.filter((x) => Number.isFinite(x))
if (!xs.length) return {}
const max = Math.max(...xs)
return { median: median(xs), max, maxAt: ms.indexOf(max) }
}
// -------------------------------------------------------------------------
// Copilot 建图阶段:操作流 → 同一套 Step
// -------------------------------------------------------------------------
export interface CopilotOp {
op: string
[k: string]: any
}
/**
* 一段思考在列表里显示哪一句。
*
* 还在想的时候取**最后一句**:思考是流式的,第一句往往是"用户想要一个……"
* 这种复述,之后几十秒里它其实一直在推进("先看看现有的节点"、"这里需要
* 一个分支")。固定显示开头,等于把一段活的叙述冻在起点上,看着就像卡住了。
*
* 想完了取**开头**:这时它是一条历史记录,开头那句最接近"这段在想什么"。
*/
function thinkingHeadline(text: string, live: boolean): string {
const flat = text.replace(/\s+/g, ' ').trim()
if (!flat) return '正在思考…'
if (!live) return flat.slice(0, 60) + (flat.length > 60 ? '…' : '')
// 按中英文句末切;最后一段往往还没说完,取它前面那句更完整
const parts = flat.split(/(?<=[。!?;.!?;])\s*/).filter(Boolean)
const tail = parts.length > 1 && parts[parts.length - 1].length < 6
? parts[parts.length - 2]
: parts[parts.length - 1]
const line = tail ?? flat
return line.length > 60 ? '…' + line.slice(-60) : line
}
/**
* 助手生成工作流的阶段文案。全站唯一一份:画布顶部的锁定条、输入框下的进度行、
* 助手栏的轮次状态、助手时间线的阶段行都从这里取,不要再各写一份
*/
export const COPILOT_PHASE_TEXT: Record<string, string> = {
connecting: '正在连接模型',
planning: '正在理解需求、规划步骤',
building: '正在搭建工作流',
wiring: '正在连接数据流',
finalizing: '正在排版和校验',
// 以「正在」开头:收尾时去掉「正在」就是完成态(按自查结果修正)
repairing: '正在按自查结果修正',
}
/**
* 出具档位的说明(悬停提示、横幅副标题)。全站唯一一份:运行记录的档位芯片和
* 出具横幅都从这里取。档位名本身在 lib/terms 的 ISSUANCE_LABEL
*/
export const ISSUANCE_HINT: Record<'formal' | 'degraded' | 'withheld', string> = {
formal: '指标齐全,叙述中的数字均可追溯到口径卡',
degraded: '存在缺口,请对照声明使用结论',
withheld: '必需指标缺失或数字无法追溯,本期结论不予采用',
}
/** 自查最多修几轮。和后端 copilot._SELF_CHECK_ROUNDS 一致,check 事件只带当前第几轮 */
export const SELF_CHECK_ROUNDS = 2
export interface CopilotIssue {
message: string
/** 问题落在哪个节点上(能认出来时)。有它才能给「定位」 */
nodeId?: string
/** 那个节点叫什么:操作流里加过、改过它就认得出,再退到画布上的名字。认不出就不给 */
label?: string
/** 落在节点的哪一项配置上(condition、tools、agents[1].tools):「定位」据此聚焦到检查器里那个输入框 */
field?: string
/** 机读代号:datasource_out_of_scope、tools_dropped……;SQL 检查是规则编号(fanout_sum……) */
code?: string
/** error / warning / info。老会话、老后端不给 */
level?: 'error' | 'warning' | 'info'
/** SQL 检查的问题按检查读出来的那一条(lib/sqlcheck 的 sqlCheckOf:级别、规则、表和列、关系编号、SQL 片段) */
sql?: SqlCheckItem
}
/**
* 自查问题两种形状:新后端给对象 {level, node_id, edge_id, message, field, code?};
* 老会话里存的是拼好的一行字「「lp」循环条件写错了」,节点 id 包在开头的「」里,
* 认出来才能在卡片上配「定位」;认不出就只是一行字。
*/
function parseIssue(v: unknown, labels?: (id: string) => string | undefined): CopilotIssue {
const named = (x: CopilotIssue): CopilotIssue => {
const label = x.nodeId ? labels?.(x.nodeId) : undefined
return label && label !== x.nodeId ? { ...x, label } : x
}
if (v && typeof v === 'object') {
const o = v as Record<string, unknown>
const str = (x: unknown) => (typeof x === 'string' && x ? x : undefined)
// SQL 检查和证据面板、运行时间线同一个读法(sqlCheckOf,关系编号也留着)。自查交回模型改的问题老后端不给级别,
// 那时交回去的都是错误:没给级别的按错误读
const sql = isSqlCheckCode(o.code) ? sqlCheckOf({ ...o, level: o.level ?? 'error' }) : null
return named({
message: String(o.message ?? ''),
...(o.node_id ? { nodeId: String(o.node_id) } : {}),
...(str(o.field) ? { field: str(o.field) } : {}),
...(str(o.code) ? { code: str(o.code) } : {}),
...(o.level === 'error' || o.level === 'warning' || o.level === 'info' ? { level: o.level } : {}),
...(sql ? { sql } : {}),
})
}
const text = String(v ?? '')
const m = text.match(/^「([^」]+)」\s*(.*)$/)
return named(m ? { nodeId: m[1], message: m[2] || text } : { message: text })
}
/**
* 一条自查问题写成一行:带节点名(认得出时),认不出才退到 id。SQL 检查的问题前面写中文规则名、后面写涉及的表和列,
* 不露规则编号
*/
export const issueLine = (x: CopilotIssue): string => {
const where = x.sql ? sqlCheckWhere(x.sql) : ''
const body = x.sql
? `${sqlRuleLabel(x.sql.code)}:${x.message}${where ? `(${SQL_CHECK_TEXT.where} ${where})` : ''}`
: x.message
return x.nodeId ? `「${x.label ?? x.nodeId}」${body}` : body
}
/** 一条自查问题按 SQL 检查读(界面上画级别、规则名、表和列、SQL 片段);不是 SQL 检查的返回 null */
export function issueSqlCheck(x: CopilotIssue): SqlCheckItem | null {
return x.sql ?? null
}
/** final.issues 里对照数据目录的 SQL 检查结果(不挡运行的提醒、提示也在里面);自查没修好、已经列过的错误不再算 */
function sqlIssuesOf(ops: CopilotOp[], labels: (id: string) => string | undefined): CopilotIssue[] {
const final = [...ops].reverse().find((o) => o.op === 'final')
const list: unknown[] = final && Array.isArray(final.issues) ? final.issues : []
const failed = [...ops].reverse().find((o) => o.op === 'check' && o.status === 'failed')
const listed = new Set((failed && Array.isArray(failed.issues) ? failed.issues : [])
.map((x: unknown) => parseIssue(x)).map((x: CopilotIssue) => `${x.nodeId ?? ''}|${x.code ?? ''}|${x.message}`))
return list.map((x) => parseIssue(x, labels))
.filter((x) => isSqlCheckCode(x.code) && !listed.has(`${x.nodeId ?? ''}|${x.code ?? ''}|${x.message}`))
}
/**
* 节点 id → 名字。先认这一轮操作流里加过、改过的(问数据页没有画布,只有这一份),
* 再退到调用方给的画布查询
*/
function labelsOf(ops: CopilotOp[], fallback?: (id: string) => string | undefined): (id: string) => string | undefined {
const map = new Map<string, string>()
for (const op of ops) {
if (op.op === 'add_node' && op.node?.id) {
const label = op.node.data?.label || op.node.label
if (label) map.set(String(op.node.id), String(label))
} else if (op.op === 'update_node' && op.id && op.label) {
map.set(String(op.id), String(op.label))
}
}
return (id) => map.get(id) ?? fallback?.(id)
}
/** 一张表的那一行:「scenic」入园记录(visits):发布时第 3 版,现为第 5 版 */
export function catalogDriftLine(t: CatalogDriftTable): string {
return CATALOG_DRIFT_TEXT.line(t.source, CATALOG_DRIFT_TEXT.table(t.label, t.table),
CATALOG_DRIFT_TEXT.version(t.published), CATALOG_DRIFT_TEXT.version(t.current))
}
/**
* catalog.drift 事件 → 提醒的标题和逐表明细。每张表带 impact:direct 是 SQL 里写着的表,possible 是 Agent 可能
* 查询的表(Agent 绑定了数据源的查询工具,SQL 运行时才生成),两组分开说:
* - 标题按第一张直接引用的表说(「入园记录」等 2 张表有变化),还有可能涉及的补一句「另有 N 张 Agent 可能查询的表
* 有变化」;只有可能涉及的,标题直说「Agent 可能查询的表有变化」
* - 明细每张表一行:发布时第几版、现在第几版(0 写「尚无目录」);可能涉及的放在小标题下面
* 老事件的表没有 impact,按直接引用。认不出的形状返回 null
*/
export function catalogDrift(d: Record<string, any>): {
title: string; detail: string; tables: CatalogDriftTable[]; direct: CatalogDriftTable[]; possible: CatalogDriftTable[]
} | null {
const tables: CatalogDriftTable[] = (Array.isArray(d?.tables) ? d.tables : [])
.filter((t: any) => t && typeof t === 'object' && typeof t.table === 'string' && typeof t.source === 'string')
.map((t: any) => ({
source: t.source, source_id: String(t.source_id ?? ''), table: t.table,
label: typeof t.label === 'string' && t.label ? t.label : null,
published: num(t.published) ?? null, current: num(t.current) ?? 0,
impact: t.impact === 'possible' ? 'possible' : 'direct',
}))
if (!tables.length) return null
const direct = tables.filter((t) => t.impact === 'direct')
const possible = tables.filter((t) => t.impact === 'possible')
const name = (t: CatalogDriftTable) => t.label ?? t.table
const title = direct.length
? CATALOG_DRIFT_TEXT.title(name(direct[0]), direct.length) + (possible.length ? CATALOG_DRIFT_TEXT.alsoPossible(possible.length) : '')
: CATALOG_DRIFT_TEXT.possibleTitle(name(possible[0]), possible.length)
return {
title,
detail: [
...direct.map(catalogDriftLine),
...(possible.length ? [`${CATALOG_DRIFT_TEXT.possibleHead}:`, ...possible.map(catalogDriftLine)] : []),
].join('\n'),
tables, direct, possible,
}
}
/** context 操作里一个数据源的那一项(服务端 copilot_context.DatasourceContext.op) */
export interface CopilotContextSource {
source: string
/** 模型看得到全部字段的表(全名)。只给了表名的不在这里 */
tables: string[]
/** model:按需求挑的;all:库不大,全部表都带字段;fallback:没能挑出来,只给了表名 */
selectedBy: 'model' | 'all' | 'fallback'
/** 这个库一共几张表(数据库里的真实总数) */
total?: number
/** 探查结构截断了时,探查到几张(服务端只在截断时给):模型只看得到这几张 */
explored?: number
/** 没能挑出来的原因 */
reason?: string
/** 数据源 id:去数据目录页的入口用(老服务端不给) */
sourceId?: string
/** 有数据目录的表数(老服务端不给:不知道就不提示) */
catalogTables?: number
}
/** 挑表失败时的说法:模型这一轮只看到表名,字段要它自己去查 */
export const CONTEXT_FALLBACK_TEXT = '未能按需求挑选,已提供全部表名'
function contextSources(op: CopilotOp): CopilotContextSource[] {
const list: unknown[] = Array.isArray(op.sources) ? op.sources : []
return list
.filter((x): x is Record<string, any> => !!x && typeof x === 'object' && typeof (x as any).source === 'string')
.map((x) => {
const total = num(x.total)
const explored = num(x.explored)
return {
source: x.source,
tables: Array.isArray(x.tables) ? x.tables.map(String) : [],
selectedBy: x.selected_by === 'model' || x.selected_by === 'fallback' ? x.selected_by : 'all',
...(total != null ? { total } : {}),
...(total != null && explored != null && explored < total ? { explored } : {}),
...(typeof x.reason === 'string' && x.reason ? { reason: x.reason } : {}),
...(typeof x.source_id === 'string' && x.source_id ? { sourceId: x.source_id } : {}),
...(num(x.catalog_tables) != null ? { catalogTables: num(x.catalog_tables) } : {}),
}
})
}
/**
* 没有数据目录的大库(要按需求挑表的那种):挑表只能照表名猜,SQL 检查也没有目录可对照。真实运行里数据源一张目录
* 都没有,用户也不知道该先去起草。只认服务端明说了 0 张的;小库全部表都带字段,不提示
*/
function noCatalog(groups: CopilotContextSource[]): { next: string; fixTo?: string } | null {
const bare = groups.filter((g) => g.catalogTables === 0 && g.selectedBy !== 'all')
if (!bare.length) return null
const who = groups.length === 1 ? null : `「${bare.map((g) => g.source).join('」「')}」`
const to = bare.find((g) => g.sourceId)?.sourceId
return { next: COPILOT_CONTEXT_TEXT.noCatalog(who), ...(to ? { fixTo: `/data/catalog/${encodeURIComponent(to)}` } : {}) }
}
/**
* 一个库在展开区里的那一行:怎么来的 + 表名。探查结构截断了的库写明「共 205 张,只探查了 200 张」:
* 模型只看得到探查到的那些,用户说的表不在里面时,先得知道是没探查到,不是库里没有
*/
function contextLine(g: CopilotContextSource): string {
const names = g.tables.join('、')
const partial = g.total != null && g.explored != null ? SCHEMA_PARTIAL_TEXT.context(g.total, g.explored) : ''
const of = partial || (g.total != null ? `共 ${g.total} 张表` : '')
switch (g.selectedBy) {
case 'all':
return `「${g.source}」全部 ${g.tables.length} 张表${partial ? `(${partial})` : ''}:${names}`
case 'model':
return g.tables.length
? partial
? `「${g.source}」${partial},按需求挑出 ${g.tables.length} 张:${names}`
: `「${g.source}」按需求${g.total != null ? `从 ${g.total} 张表中` : ''}挑出 ${g.tables.length} 张:${names}`
: `「${g.source}」${of ? `${of},` : ''}未挑中与需求相关的表,已提供全部表名`
default:
return `「${g.source}」${of ? `${of},` : ''}${CONTEXT_FALLBACK_TEXT}`
+ (g.tables.length ? `;现有工作流用到的 ${g.tables.length} 张表附带全部字段:${names}` : '')
}
}
/**
* 助手这一轮参考了哪些表(context 操作)→ 过程里的一行。
*
* 大库放不下全部字段,服务端先按需求挑表,挑中的表给模型全部字段。这件事要看得见:生成的 SQL 用错了表,
* 先得知道模型当时看到的是哪几张。标题只说几张(「参考了 8 张表」),展开按数据源分组列表名;挑表失败时
* 标题直说只给了表名,原因放在展开区。认不出的形状返回 null:不出这一行,也不报错。
*/
export function copilotContext(op: CopilotOp):
{ title: string; sub?: string; detail: string; ms?: number; next?: string; fixTo?: string } | null {
// 展开区里按需求挑过表的库排在前面:请求里点名的那个库往往就是它,排在一串小库后面得在内层滚动框里翻才看得到
const order: Record<CopilotContextSource['selectedBy'], number> = { model: 0, all: 1, fallback: 2 }
const groups = contextSources(op).map((g, i) => ({ g, i }))
.sort((a, b) => order[a.g.selectedBy] - order[b.g.selectedBy] || a.i - b.i).map((x) => x.g)
if (!groups.length) return null
// 两个数分开说:带着全部字段的表(count),和只提供了表名的表(namesOnly:没挑成的库、一张没挑中的库)。
// 以前标题只写前者,「参考了 26 张表」看不出另有 206 张只给了表名
const count = groups.reduce((n, g) => n + g.tables.length, 0)
const namesOnly = groups.reduce((n, g) => n + namesOnlyOf(g), 0)
const failed = groups.filter((g) => g.selectedBy === 'fallback')
// 有表带着字段(小库,或者现有工作流用到的表)时标题仍说几张,没挑成的事放副标题
const who = failed.length && failed.length < groups.length ? `「${failed.map((g) => g.source).join('」「')}」` : ''
const sub = count && failed.length
? namesOnly ? `${who}${COPILOT_CONTEXT_TEXT.notPicked},${COPILOT_CONTEXT_TEXT.namesOnly(namesOnly)}` : `${who}${CONTEXT_FALLBACK_TEXT}`
: count && namesOnly ? COPILOT_CONTEXT_TEXT.namesOnly(namesOnly) : undefined
const reasons = [...new Set(failed.map((g) => g.reason).filter(Boolean))]
// 挑过表才有 elapsed_ms:挑表是生成之前多出来的一次模型调用,没挑成(比如超时)也要让人看到等了多久
const ms = num(op.elapsed_ms)
const hint = noCatalog(groups)
return {
title: count ? (namesOnly ? COPILOT_CONTEXT_TEXT.withFields(count) : `参考了 ${count} 张表`) : CONTEXT_FALLBACK_TEXT,
...(sub ? { sub } : {}),
detail: [...groups.map(contextLine), ...reasons.map((r) => `原因:${r}`)].join('\n'),
...(ms != null && ms >= 10 ? { ms } : {}),
...(hint ?? {}),
}
}
/** 这个库里只提供了表名、没带字段的表有几张(模型看得到的那些:探查截断了的按探查到的算) */
function namesOnlyOf(g: CopilotContextSource): number {
const seen = g.explored ?? g.total
if (seen == null) return 0
if (g.selectedBy === 'fallback' || (g.selectedBy === 'model' && !g.tables.length)) return Math.max(0, seen - g.tables.length)
return 0
}
/** 用了限定范围之外的数据源:自查交回模型改也改不掉时,人要知道是范围的事,不是图写错了 */
const OUT_OF_SCOPE_NEXT = '本轮限定了查询的数据源,但工作流使用了范围之外的数据库。请取消限定后重新提问,或将所需数据库加入查询范围'
/**
* Copilot 的操作流解码。
*
* 和运行事件是两套完全独立的协议(不共享 seq、node_id、不落库),只在这里
* 统一成同一种 Step,让用户看到的是一条连续的过程,而不是"建图"和"执行"
* 两段风格迥异的日志。每一行都标 stage='plan',问数据页把建图和执行排成
* 一列时,界面据此分出「规划」「执行」两段。
*
* context:画布上从不自动运行,「没有自动运行」是问数据页的说法,放到画布上
* 读起来像出了别的故障。
*
* unchanged:收到了 final,但画布一处都没变(store 比对出来的,操作流里看不出)。
* 那一行不能再说「流程搭好了」。
*/
export function decodeCopilot(ops: CopilotOp[], opts?: {
context?: 'canvas' | 'chat'
unchanged?: boolean
/** 画布上的节点名。自查问题、「去掉了」这类只带 id 的行据此写成名字 */
labelOf?: (id: string) => string | undefined
}): Step[] {
const canvas = opts?.context === 'canvas'
const labels = labelsOf(ops, opts?.labelOf)
const out: Step[] = []
let i = 0
let nodeCount = 0
let repairRound = 0
/**
* 思考结束了:不再显示"最新一句",换成开头那句,收掉转圈。收全部而不是最后
* 一条——心跳穿插进来之后思考会被切成几段,只收最后一段的话,前面那几段
* 在「流程搭好了」之后还在转
*/
const settleThinking = () => {
for (const s of out) {
if (s.kind === 'think' && s.status === 'running') {
s.status = 'done'
s.title = thinkingHeadline(s.detail ?? '', false)
}
}
}
/**
* 这一段结束了:还挂着的阶段行、思考行一律收掉。阶段行的「正在…」改成过去
* 时——收了尾的行还写着「正在理解需求」,读起来就是还没好
*/
const settleAll = (status: StepStatus) => {
settleThinking()
for (const s of out) {
if (s.kind === 'lifecycle' && s.status === 'running') s.title = s.title.replace(/^正在/, '')
}
closeLifecycles(out, status)
}
const add = (step: Omit<Step, 'stage'>) => { out.push({ ...step, stage: 'plan' }) }
for (const op of ops) {
i += 1
// 除了继续思考和心跳,任何一条操作都说明它已经想完、开始动手了
if (op.op !== 'thinking' && op.op !== 'heartbeat') settleThinking()
switch (op.op) {
case 'thinking': {
const text = String(op.delta ?? '')
if (!text.trim()) break
// 思考是连续流,一片 delta 一行会碎成几十条。同一段连续思考并成一条,
// 详情是全文——这才是"一次思考是一个节点"。心跳插在中间不算打断:
// 往回找最近那条还在想的,中间隔着的只能是阶段行
let host: Step | undefined
for (let k = out.length - 1; k >= 0; k -= 1) {
const s = out[k]
if (s.kind === 'think' && s.status === 'running') { host = s; break }
if (s.kind !== 'lifecycle') break
}
if (host) {
host.detail = (host.detail ?? '') + text
host.title = thinkingHeadline(host.detail, true)
} else {
add({ id: `ct-${i}`, seq: i, kind: 'think', status: 'running',
title: thinkingHeadline(text, true), detail: text })
}
break
}
case 'heartbeat': {
// 心跳只更新"还活着"的那一条阶段行,不该每 3 秒堆一行,也不该插在
// 思考中间把一段思考切成两截
const label = op.phase === 'repairing' && repairRound
? `${COPILOT_PHASE_TEXT.repairing}(第 ${repairRound}/${SELF_CHECK_ROUNDS} 轮)`
: COPILOT_PHASE_TEXT[String(op.phase)] ?? '正在处理'
const elapsed = num(op.elapsed_ms)
const phaseRow = [...out].reverse().find((s) => s.kind === 'lifecycle' && s.status === 'running')
if (phaseRow) {
phaseRow.title = label
if (elapsed) { phaseRow.ms = elapsed; phaseRow.meta = formatDuration(elapsed) }
break
}
const last = out[out.length - 1]
if (last?.kind === 'think' && last.status === 'running') {
if (elapsed) { last.ms = elapsed; last.meta = formatDuration(elapsed) }
break
}
add({ id: `hb-${i}`, seq: i, kind: 'lifecycle', status: 'running', title: label,
...(elapsed ? { ms: elapsed, meta: formatDuration(elapsed) } : {}) })
break
}
case 'context': {
// 这一轮参考了哪些表:标题说几张,展开看按数据源分组的表名
const ctx = copilotContext(op)
if (!ctx) break
add({ id: `cx-${i}`, seq: i, kind: 'schema', status: 'done', code: 'copilot_context',
title: ctx.title, detail: ctx.detail,
...(ctx.sub ? { sub: ctx.sub } : {}),
...(ctx.ms != null ? { ms: ctx.ms, meta: formatDuration(ctx.ms) } : {}),
// 没有数据目录的大库:下一步去起草目录,入口直达那个数据源的目录页
...(ctx.next ? { next: ctx.next, ...(ctx.fixTo ? { fix: 'catalog' as const, fixTo: ctx.fixTo } : {}) } : {}) })
break
}
case 'catalog_patch': {
// 目录修改提案:过程里记一行(哪张表、几项,展开看改前改后),保存、忽略在轮次里的卡片上做(catalogPatchesOf)
const patch = catalogPatchOf(op, String(i - 1))
if (!patch) break
add({ id: `cpt-${i}`, seq: i, kind: 'schema', status: 'done', code: 'catalog_patch',
title: CATALOG_PATCH_TEXT.step(patch.tableLabel ?? patch.table, patch.changes.length),
sub: CATALOG_PATCH_TEXT.stepSub,
detail: patch.changes.map((c) => `${patchWhere(c)}:${patchValueText(c.path, c.before)} → ${patchValueText(c.path, c.after)}`)
.join('\n') })
break
}
case 'plan':
add({ id: `cp-${i}`, seq: i, kind: 'note', status: 'done',
title: clip(String(op.summary ?? '已完成规划'), 120) })
break
case 'add_node':
nodeCount += 1
add({
id: `cn-${i}`, seq: i, kind: 'node', status: 'done',
title: clip(String(op.node?.data?.label || op.node?.label || op.node?.id || '新步骤')),
// 类型说中文:行尾挂着 input / memory 这种英文,读起来像日志
...(op.node?.type ? { meta: nodeTypeLabel(String(op.node.type)) } : {}),
...(op.node?.id ? { nodeId: String(op.node.id) } : {}),
})
break
case 'update_node':
// 只改配置的 update_node 常常不带 label:名字从操作流里认,再向画布要,都没有才写 id
add({ id: `cu-${i}`, seq: i, kind: 'note', status: 'done', nodeId: String(op.id ?? ''),
title: clip(`调整了「${op.label || labels(String(op.id)) || op.id}」`) })
break
case 'remove_node':
add({ id: `cr-${i}`, seq: i, kind: 'note', status: 'done',
title: clip(`移除了「${labels(String(op.id)) ?? op.id}」`) })
break
// 连线不单独成行:用户关心有哪些步骤,不关心箭头
case 'add_edge':
case 'remove_edge':
break
case 'done':
// 心跳那条"正在理解需求…"要收尾,否则生成完了它还在转圈
settleAll('done')
add({ id: `cd-${i}`, seq: i, kind: 'lifecycle', status: 'done',
title: `工作流已搭建完成,新增 ${nodeCount} 个步骤`,
detail: String(op.explanation ?? '') || undefined })
break
case 'check': {
// 服务端自查:用运行时同一套规则过一遍,有问题交回模型改。
// 这件事得看得见——否则多出来的那十几秒像是卡住了,改了什么也无从知道
const issues = Array.isArray(op.issues) ? op.issues.map((x: unknown) => parseIssue(x, labels)) : []
const lines = issues.map(issueLine)
const outOfScope = issues.some((x) => x.code === 'datasource_out_of_scope')
if (op.status === 'repairing') {
repairRound = num(op.round) ?? repairRound + 1
add({ id: `ck-${i}`, seq: i, kind: 'note', status: 'done', level: 'warn',
title: `自查发现 ${issues.length} 处问题,已交回模型修正(第 ${repairRound}/${SELF_CHECK_ROUNDS} 轮)`,
detail: lines.join('\n') || undefined })
} else if (op.status === 'passed') {
settleAll('done')
// 规则上通过了,但有节点的工具被悄悄去掉了(模型改提示词时漏写 tools):这不是
// 一句安静的「自查通过」能交代的,图照样能跑,只是跑起来什么都查不到
const dropped = droppedOf(op)
add({ id: `ck-${i}`, seq: i, kind: 'note', status: 'done',
title: `${op.repaired ? '自查通过:问题已修正' : '自查通过'}${dropped.length
? `,但有 ${dropped.length} 处工具被移除` : ''}`,
...(dropped.length ? { level: 'warn' as const, code: 'tools_dropped',
detail: dropped.map((x) => x.message).join('\n') } : {}) })
} else {
// 修正那一轮本身是跑完了的;没修好由下面这行红字说,不把阶段行也画红
settleAll('done')
add({ id: `ck-${i}`, seq: i, kind: 'error', level: 'error', status: 'failed',
title: op.status === 'failed'
? canvas
? `自查后仍有 ${issues.length} 处问题未能自动修正`
: `自查后仍有 ${issues.length} 处问题,未自动运行`
: String(op.message ?? '自查未能完成'),
detail: [...lines, ...droppedOf(op).map((x) => x.message)].join('\n') || undefined,
...(outOfScope ? { next: OUT_OF_SCOPE_NEXT, code: 'datasource_out_of_scope' } : {}),
...(typeof op.detail === 'string' && op.detail ? { raw: op.detail } : {}) })
}
break
}
case 'final': {
// 后端排版校验后的最终图才知道整张图有几步。nodeCount 只是这一轮
// 新增的数量——在"改图"场景下说"共 2 步"是错的,图上明明有四个节点。
// 自查的记录会排在"搭好了"后面,所以往回找那一行,不能只看最后一条
const total = op.graph?.nodes?.length
const last = out[out.length - 1]
// 流没等到 done 就断了时没有那一行,沿用原来的做法:改写最后一条生命周期
const built = [...out].reverse().find((s) => s.id.startsWith('cd-'))
?? (last?.kind === 'lifecycle' ? last : undefined)
if (total && built) {
built.title = opts?.unchanged ? `已检查,无需改动(工作流共 ${total} 个步骤)`
: nodeCount && nodeCount < total
? `工作流已搭建完成,新增 ${nodeCount} 个步骤,共 ${total} 个步骤`
: `工作流已搭建完成,共 ${total} 个步骤`
}
settleAll('done')
// 模型写了不存在的节点类型:那一步被跳过了。不说出来的话"共 N 步"是
// 一句不完整的真话,用户要到运行结果不对才发现少了一步
for (const t of skippedTypes(op)) {
add({ id: `cm-${i}-${t}`, seq: i, kind: 'note', status: 'done', level: 'warn',
title: clip(`已跳过一个步骤:节点类型「${t}」不存在`, 80) })
}
// 对照数据目录的 SQL 检查:错误交回模型改过(没改好的在自查那一行),不挡运行的提醒、提示照样要看得见
const sql = sqlIssuesOf(ops.slice(0, i), labels)
if (sql.length) {
const n = { error: 0, warning: 0, info: 0 }
for (const x of sql) n[x.level ?? 'error'] += 1
const nodes = [...new Set(sql.map((x) => x.nodeId).filter(Boolean))]
add({
id: `cs-${i}`, seq: i, kind: 'note', status: 'done', code: 'sql_check',
...(n.error || n.warning ? { level: 'warn' as const } : {}),
title: SQL_CHECK_TEXT.assistantTitle(SQL_CHECK_TEXT.counts(n.error, n.warning, n.info)),
detail: sortSqlChecks(sql).map(issueLine).join('\n'),
// 问数据页没有画布:下一步说去画布里打开这个工作流
next: n.error || n.warning ? (canvas ? SQL_CHECK_TEXT.assistantNext : SQL_CHECK_TEXT.assistantNextChat)
: SQL_CHECK_TEXT.assistantInfoNext,
...(nodes.length === 1 ? { nodeId: nodes[0], ...(canvas ? { fix: 'canvas' as const } : {}) } : {}),
})
}
// 工具绑定变化单独成一行:改图回执以前只说「修改 1」,模型改提示词时漏写 tools、
// 把查库的工具整个抹掉,要到运行结果不对才发现
const changes = toolChangesOf(op)
if (changes.length) {
const lost = changes.filter((c) => c.removed.length)
const one = changes.length === 1 ? changes[0] : null
add({
id: `cg-${i}`, seq: i, kind: 'tool', status: 'done', code: 'tool_changes',
...(lost.length ? { level: 'warn' as const } : {}),
title: clip(one
? one.removed.length && !one.after.length ? `${changeWho(one)}的工具已全部移除`
: one.removed.length ? `${changeWho(one)}移除了 ${one.removed.length} 个工具`
: `${changeWho(one)}的工具绑定已变更`
: `${changes.length} 处工具绑定已变更${lost.length ? `,其中 ${lost.length} 处移除了工具` : ''}`, 80),
detail: changes.map((c) => `${changeWho(c)}:${c.before.join('、') || '(空)'} → ${c.after.join('、') || '(空)'}`)
.join('\n'),
})
}
break
}
case 'reply':
// 直接回答、没有改图。回答本身作为成果显示,这里只把还在转的收掉
settleAll('done')
break
case 'error':
settleAll('failed')
add({ id: `ce-${i}`, seq: i, kind: 'error', level: 'error', status: 'failed',
title: String(op.message ?? '生成失败'),
...(op.hint ? { sub: String(op.hint) } : {}),
...(typeof op.detail === 'string' && op.detail ? { raw: op.detail } : {}) })
break
default:
break
}
}
return out
}
/** final.tool_changes:后端比对新旧图得出的工具绑定变化(NI-1) */
function toolChangesOf(op: CopilotOp): ToolChange[] {
const list: any[] = Array.isArray(op.tool_changes) ? op.tool_changes : []
const names = (v: unknown): string[] => (Array.isArray(v) ? v.map(String) : [])
return list.filter((c) => c && typeof c === 'object').map((c) => ({
node_id: String(c.node_id ?? ''),
field: String(c.field ?? 'tools'),
label: String(c.label ?? c.node_id ?? ''),
member: c.member == null ? null : String(c.member),
before: names(c.before), after: names(c.after), added: names(c.added), removed: names(c.removed),
}))
}
/** 「数据查询」/「销售分析团队」的成员「取数员」 */
const changeWho = (c: ToolChange): string =>
c.member ? `「${c.label}」的成员「${c.member}」` : `「${c.label}」`
/** 自查带回的「工具被去掉了」警告(code=tools_dropped)。check 里有就用它,否则看 final.issues */
function droppedOf(op: CopilotOp): CopilotIssue[] {
const list: any[] = [...(Array.isArray(op.warnings) ? op.warnings : []),
...(op.op === 'final' && Array.isArray(op.issues) ? op.issues : [])]
return list.filter((x) => x && typeof x === 'object' && x.code === 'tools_dropped').map((x) => parseIssue(x))
}
function skippedTypes(op: CopilotOp): string[] {
const issues: any[] = Array.isArray(op.issues) ? op.issues : []
return [...new Set(issues
.filter((x) => x && typeof x === 'object' && x.code === 'unknown_node_type')
.map((x) => String(x.type ?? '').trim() || '未知'))]
}
/**
* 一轮 Copilot 的结局,给卡片标题和补救动作用。
*
* 以前卡片只看 phase:done 一律写「流程已更新到画布」——模型只回了一句话、
* 自查没修好、少放了一步,标题都一样。这几种的下一步完全不同(读回答 /
* 去修 / 让它补上),标题得先分开。
*/
export interface CopilotOutcome {
/** reply:只回了话;built:图放上去了;error:出错;running:还在生成;empty:流结束了但什么都没做 */
kind: 'running' | 'reply' | 'built' | 'error' | 'empty'
reply?: string
/** 自查:passed 通过(repaired 为修过几轮)、failed 没修好、error 自查本身没跑成 */
check?: { status: 'passed' | 'failed' | 'error'; repaired: number; issues: CopilotIssue[] }
/** 正在修第几轮 */
repairing?: number
/** 被跳过的节点类型(模型编出来的) */
skipped: string[]
/** 整张图几个节点 */
total?: number
added: number
updated: number
removed: number
error?: { message: string; hint?: string; raw?: string }
/** 工具绑定变化(final.tool_changes)。removed 非空的那几处要提醒 */
toolChanges: ToolChange[]
/** 自查的「工具被去掉了」警告(tools_dropped):这一轮的要求里没提到去掉 */
dropped: CopilotIssue[]
/** 对照数据目录的 SQL 检查结果(final.issues),自查没修好、已经列在 check 里的不重复 */
sqlChecks: CopilotIssue[]
}
export function copilotOutcome(
ops: CopilotOp[], running = false, labelOf?: (id: string) => string | undefined,
): CopilotOutcome {
const res: CopilotOutcome = {
kind: running ? 'running' : 'empty', skipped: [], added: 0, updated: 0, removed: 0,
toolChanges: [], dropped: [], sqlChecks: [],
}
const labels = labelsOf(ops, labelOf)
for (const op of ops) {
switch (op.op) {
case 'add_node': res.added += 1; break
case 'update_node': res.updated += 1; break
case 'remove_node': res.removed += 1; break
case 'reply':
res.reply = String(op.text ?? '')
if (!running) res.kind = 'reply'
break
case 'check': {
const issues = Array.isArray(op.issues) ? op.issues.map((x: unknown) => parseIssue(x, labels)) : []
if (op.status === 'repairing') {
res.repairing = num(op.round) ?? (res.repairing ?? 0) + 1
} else {
res.check = {
status: op.status === 'passed' ? 'passed' : op.status === 'failed' ? 'failed' : 'error',
repaired: num(op.repaired) ?? 0,
issues,
}
res.dropped = droppedOf(op)
}
break
}
case 'final':
res.total = op.graph?.nodes?.length
res.skipped = skippedTypes(op)
res.toolChanges = toolChangesOf(op)
// 同一批警告 final.issues 里也有一份:自查已经带回来了就不再算一遍
if (!res.dropped.length) res.dropped = droppedOf(op)
res.sqlChecks = sqlIssuesOf(ops, labels)
if (!running) res.kind = 'built'
break
case 'error':
res.error = {
message: String(op.message ?? '生成失败'),
...(op.hint ? { hint: String(op.hint) } : {}),
...(typeof op.detail === 'string' && op.detail ? { raw: op.detail } : {}),
}
if (!running) res.kind = 'error'
break
}
}
return res
}