界面与交互全面翻修(前两波):运行态有了 HUD 和时间轴,终态会收尾,全站令牌、组件、文案统一

来自 2026-09 的 UI/UX 审查(142 条)。本提交是前两波,第三波(记录页接入时间轴、跨页互链、check-ui 更新、全站扫尾)另行提交。

地基
- 设计令牌:全局规则和 .btn/.field/.chip 放进 CSS 层,Tailwind 工具类重新生效,选中态、开启态不再被吞掉;字号阶梯;
  辅助文字在所有底色上 >= 4.5:1;状态色专用,节点类型色与之拉开色相;「跟随系统」真的跟随,首帧不闪深色;新字标与 favicon
- 基础组件:confirmDialog / promptDialog 取代原生对话框;toast 出错不自动消失、可复制、读屏可播报;离线横幅与断线恢复;
  九种剪影的状态徽标;唯一一份状态文案(lib/status)、格式化(lib/format)、术语(lib/terms)、报错人话(lib/errors)
- 运行态内核:事件折成航迹(run/trace.ts),可投影到任意时刻;取消 / 失败 / 服务重启后统一收尾,不再永远转圈;
  run.status 随事件回写;token 事件不再堆进 events
- 后端:协作成员逐个完成即发事件,调度者决策有事件;时长分执行 / 墙钟 / 等人;API 时间带时区;
  中断的运行也发 stream.end;运行列表可筛选翻页;删除运行 / 工作流有保护;运行默认值接通;可测未保存的配置;
  报错说清发生了什么、为什么、怎么办

功能
- 画布:状态四重编码、固定高度遥测槽、按缩放三档、运行胶囊与 HUD、航迹时间轴与回放、执行轨迹边、
  等待闸门、跟随执行、正式运行只读、一次性时刻(开场点亮、回扫、阻断级联、出具印章)
- 编辑:撤销重做、版本历史、问题面板可定位、三栏可收起、模板与表达式字段区分、变量血缘、快捷键、带搜索的选择器
- 回答流 / 问数据:贴底才跟随滚动、工件下钻、出具横幅列出缺口、答案可复制导出、按会话的忙闲、
  答案不再在 2000 字处截断、重启后这一轮会收尾
- 记录页:待审批 / 运行中 / 失败页签、筛选翻页、实时接流、排错路径指到节点、封存凭证常驻
- 管理页:新的「数据」页、写操作失败都有反馈、弹窗里先测连接、统一持久的连接状态
- 外壳:导航改版、⌘K 命令面板、? 快捷键帮助、404、页面标题与 favicon 状态点、后台提醒

验证(沙箱):tsc 干净,pytest 579 passed,14 个检查脚本中 13 个全绿;check-ui 剩 1 项旧断言(按旧日期格式找运行行),第三波更新。
This commit is contained in:
ThearchyHelios
2026-09-26 23:11:31 +08:00
parent 95ff6470d1
commit e10c4f23fc
132 changed files with 43558 additions and 5399 deletions
+293 -19
View File
@@ -14,13 +14,34 @@ const root = new URL('..', import.meta.url).pathname
const fixtures = JSON.parse(
readFileSync(`${root}frontend/src/run/__tests__/fixtures.json`, 'utf8'))
const res = await fetch(`${WEB}/src/run/decode.ts?t=${Date.now()}`)
if (!res.ok) {
console.error(`✗ 拿不到转译结果(${WEB})——前端没起?先跑 ./scripts/dev.sh`)
// decode.ts 在运行时 import 了 lib/format、lib/terms。data: URL 里的模块没法解析
// "/src/..." 这种路径,所以把依赖也各自转成 data: URL 再替换进去(同 check-trace)
const loaded = new Map()
async function load(path) {
if (loaded.has(path)) return loaded.get(path)
const res = await fetch(`${WEB}${path}?t=${Date.now()}`)
if (!res.ok) throw new Error(`${path} → HTTP ${res.status}`)
let code = await res.text()
const deps = new Set([...code.matchAll(/from\s+["'](\/src\/[^"'?]+)(?:\?[^"']*)?["']/g)]
.map((m) => m[1]))
for (const dep of deps) {
const url = await load(dep)
const pattern = new RegExp(`(["'])${dep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\?[^"']*)?\\1`, 'g')
code = code.replace(pattern, JSON.stringify(url))
}
const url = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64')
loaded.set(path, url)
return url
}
let mod, synthetic
try {
mod = await import(await load('/src/run/decode.ts'))
synthetic = await import(await load('/src/run/__tests__/synthetic.ts'))
} catch (e) {
console.error(`✗ 拿不到转译结果(${WEB})——前端没起?先跑 ./scripts/dev.sh\n ${e.message}`)
process.exit(1)
}
const mod = await import(
'data:text/javascript;base64,' + Buffer.from(await res.text()).toString('base64'))
let failed = 0
const check = (name, cond, detail = '') => {
@@ -35,8 +56,12 @@ console.log('=== 数据库查询 ===')
const steps = mod.decodeRun(fixtures.db)
const all = flatten(steps)
const q = all.find((s) => s.kind === 'query')
// 一次取数常有五六条查询,标题全写「在 X 上查询数据」只能靠 meta 分。
// 标题从 SQL 里取表名:这条是 SELECT * FROM ANALYTICS.v_device_kpi FETCH FIRST 50
check('查询步骤说人话,不是 db_query__warehouse(...)',
!!q && q.title.includes('查询数据') && !q.title.includes('db_query__'), q?.title)
!!q && q.title.startsWith('查询') && !q.title.includes('db_query__'), q?.title)
check('查询标题说出查的是哪张表', !!q?.title.includes('v_device_kpi'), q?.title)
check('数据源名留给展开区,不挤标题', q?.source === 'warehouse' && !q.title.includes('warehouse'))
check('SQL 原文留在详情里可展开', !!q?.detail?.includes('SELECT'))
check('查询结果和 SQL 分开存,不互相覆盖', !!q?.result && q.result !== q.detail)
check('工具调用挂在节点下,不是平铺',
@@ -67,9 +92,11 @@ console.log('\n=== 知识检索 ===')
.find((x) => x.kind === 'schema')
check('退回关键词这件事标出来了', bad?.level === 'warn' && !!bad?.meta?.includes('关键词'))
// 兜底那条也要还在:新事件没补映射时宁可显示原始的
const unknown = mod.decodeRun([{ seq: 1, type: 'brand.new', node_id: 'x', data: { a: 1 } }])
check('未知事件不被静默吞掉', flatten(unknown).some((x) => x.title === 'brand.new'))
// 兜底那条也要还在:新事件没补映射时宁可显示一条,但内部类型名不当标题
const unknown = flatten(mod.decodeRun([{ seq: 1, type: 'brand.new', node_id: 'x', data: { a: 1 } }]))
check('未知事件不被静默吞掉', unknown.some((x) => x.detail?.startsWith('事件类型:brand.new')),
unknown.map((x) => x.title).join(','))
check('未知事件的类型名不上标题和副标题', !unknown.some((x) => x.title.includes('brand.new') || x.sub?.includes('brand.new')))
}
console.log('\n=== 长期记忆 ===')
@@ -182,16 +209,26 @@ console.log('\n=== 循环里的多轮审批 ===')
const all = flatten(steps)
const asks = all.filter((s) => s.kind === 'human')
check('三轮审批就是三条,不多不少', asks.length === 3, `${asks.length} 条`)
check('每条都带着你的决定', asks.every((s) => /你(放行|驳回)了/.test(s.title)),
// 老数据没有签批人:只说结果,不再一律写"你"——多人使用时批的可能是别人
check('每条都带着决定', asks.every((s) => /已(放行|驳回)/.test(s.title)),
asks.map((s) => s.title).join(' | '))
check('不再替人署名为"你"', !asks.some((s) => s.title.includes('你')))
check('两次驳回一次放行,顺序没错',
asks.filter((s) => s.title.includes('驳回')).length === 2
&& asks[2]?.title.includes('放行'))
// detail 里是被审的草稿全文,打出来会刷屏——只报哪一轮带了备注
check('备注跟着那一轮走', asks.some((s) => s.detail?.includes('不高级')),
asks.map((s, i) => (s.detail?.includes('你的备注') ? `#${i + 1} 有备注` : '')).filter(Boolean).join(' '))
check('没有落单的"你放行了/你驳回了"孤行',
!all.some((s) => s.title === '你放行了' || s.title === '你驳回了'))
asks.map((s, i) => (s.detail?.includes('备注') ? `#${i + 1} 有备注` : '')).filter(Boolean).join(' '))
check('没有落单的"已放行/已驳回"孤行',
!all.some((s) => s.title === '已放行' || s.title === '已驳回'))
// 同一个节点执行了三次(驳回两次后重来):仍然只占一行,每次执行记在 execs 里,
// 子步骤按轮标号——以前三轮的子步骤平铺在同一个父节点下,分不清哪条是哪一轮
const review = steps.find((s) => s.nodeId === 'review')
check('三轮审批记成同一节点的三次执行', review?.execs?.length === 3, `${review?.execs?.length}`)
check('每轮的审批行标着自己是第几次', asks.map((s) => s.iter).join(',') === '1,2,3',
asks.map((s) => s.iter).join(','))
const rewrite = steps.find((s) => s.nodeId === 'rewrite')
check('执行多次的节点行尾说几次、共多久', /^×2 · 共 /.test(rewrite?.meta ?? ''), rewrite?.meta)
check('重放不制造重复节点', (() => {
const ids = steps.filter((s) => s.kind === 'node').map((s) => s.nodeId)
return new Set(ids).size === ids.length
@@ -260,7 +297,7 @@ console.log('\n=== 并行分支 ===')
const group = steps.find((s) => /路并行/.test(s.title))
check('认出了并行的那一批', !!group, steps.map((s) => s.title).join(' | '))
check('三路都在组里', group?.children?.length === 3, `${group?.children?.length} 路`)
check('省下多少说出来了', /合计 6\.\ds,实际 3\.\ds/.test(group?.meta ?? ''), group?.meta)
check('省下多少说出来了', /合计 6\.\d s,实际 3\.\d s/.test(group?.meta ?? ''), group?.meta)
// 顺序执行的图不能被误判成并行——误报比漏报更糟,它会让人以为省了时间
for (const name of ['db', 'human', 'loop_approve']) {
const seq = mod.decodeRun(fixtures[name])
@@ -278,12 +315,15 @@ console.log('\n=== 通用规则 ===')
check('没有原始事件名泄漏到标题',
!all.some((s) => /^(node|run|llm|tool)\./.test(s.title)),
all.filter((s) => /^(node|run|llm|tool)\./.test(s.title)).map(s=>s.title).join(','))
check('耗时是人类单位', all.filter((s) => s.meta).every((s) => /(\d+ms|\d+\.\d+s|\d+m\d+s|\d+ 行)/.test(s.meta)))
// 一屏"0ms"看着像每步都被精确计时,实际只是这些步骤没花时间,
// 全站只有一套写法(lib/format):「820 ms」「7.6 s」「1 分 14 秒」。旧的「1m14s」
// 「2min」「12345ms」一个都不能再出现
const metas = all.map((s) => s.meta).filter(Boolean)
check('耗时是全站统一的写法', metas.every((m) => !/\d(ms|s|min)\b|\dm\d/.test(m)),
metas.filter((m) => /\d(ms|s|min)\b|\dm\d/.test(m)).join(' | '))
// 一屏"0 ms"看着像每步都被精确计时,实际只是这些步骤没花时间,
// 反而把真正慢的那一步淹了
// 用词边界,不然 "130ms" 里的 "0ms" 会误判
check('不显示 0ms 这种没信息量的耗时', !all.some((s) => /\b0ms\b/.test(s.meta ?? '')),
all.filter((s) => /\b0ms\b/.test(s.meta ?? '')).map((s) => s.title).join(','))
check('不显示 0 ms 这种没信息量的耗时', !metas.some((m) => /(^|[^\d.])0 ms/.test(m)),
all.filter((s) => /(^|[^\d.])0 ms/.test(s.meta ?? '')).map((s) => s.title).join(','))
check('没有空 meta 占位', !all.some((s) => s.meta === ''))
}
@@ -385,6 +425,240 @@ console.log('\n=== Copilot 操作流 ===')
check('改不好要明说、说清没有自动运行',
!!failedCheck?.title.includes('还有 2 处问题') && failedCheck.title.includes('没有自动运行'),
failedCheck?.title)
// 画布上从不自动运行:「没有自动运行」在那儿读起来像出了别的故障
const onCanvas = mod.decodeCopilot([
{ op: 'check', status: 'failed', issues: ['「lp」循环条件写错了'] },
], { context: 'canvas' }).find((s) => s.kind === 'error')
check('画布语境说「没能自动修好」', !!onCanvas?.title.includes('没能自动修好')
&& !onCanvas.title.includes('自动运行'), onCanvas?.title)
}
console.log('\n=== 协作团队:调度者的决策(新后端的 agent.route.*)===')
{
// 以前没有映射,每一轮多出两行标题是「agent.route.start」「agent.route.end」的原始类型名
const done = mod.decodeRun(synthetic.teamRun('done'))
const all = flatten(done)
const routes = all.filter((s) => s.kind === 'branch' && s.nodeId === 'team')
check('每轮调度只有一行(route 事件和同一轮的 log 不重复)', routes.length === 2,
routes.map((s) => s.title).join(' | '))
check('说清交给谁、是不是并行', routes[0]?.title === '第 1 轮:交给 采购员、质检员、物流员(并行)', routes[0]?.title)
check('调度花了多久写在行尾', routes[0]?.meta === '2.4 s', routes[0]?.meta)
check('调度理由可展开', routes[0]?.detail === '三方面数据互不依赖,可以同时查')
check('结束协作说成人话', routes[1]?.title === '第 2 轮:结束协作', routes[1]?.title)
check('成员和调度者的 llm.end 不生成孤立的"思考并作答"',
!all.some((s) => s.kind === 'llm' && s.nodeId === 'team'))
const rawTitle = /^[a-z]+\.[a-z.]+$/
const everywhere = [
...Object.values(fixtures).flatMap((evts) => flatten(mod.decodeRun(evts))),
...flatten(done), ...flatten(mod.decodeRun(synthetic.mixedRun())),
...flatten(mod.decodeRun(synthetic.longLoop(12))), ...flatten(mod.decodeRun(synthetic.cancelledRun())),
]
check('没有任何一行的标题是内部类型名', !everywhere.some((s) => rawTitle.test(s.title) || s.title.startsWith('agent.')),
everywhere.filter((s) => rawTitle.test(s.title)).map((s) => s.title).join(','))
// 调度者还在想:那几十秒里得有一行在走
const routing = flatten(mod.decodeRun(synthetic.teamRun('routing')))
const thinking = routing.find((s) => s.kind === 'branch' && s.status === 'running')
check('调度期间有一行进行中的「调度者在想下一步」', !!thinking?.title.includes('调度者在想'), thinking?.title)
check('进行中的调度行带开始时刻(界面据此走秒表)', typeof thinking?.startedAt === 'number')
// 三人并行、只有一人交回:「省下」要等这一轮的人都交回来才说
const live = mod.decodeRun(synthetic.teamRun('members'))
const team = live.find((s) => s.nodeId === 'team')?.team
check('一轮没收齐时不报「省下」', team?.savedMs === 0, `${team?.savedMs}`)
check('还在跑的成员是 running', team?.rounds[0]?.members.filter((m) => m.status === 'running').length === 2)
const finished = done.find((s) => s.nodeId === 'team')?.team
check('收齐之后才算出省下的时间', (finished?.savedMs ?? 0) > 0, `${finished?.savedMs}`)
// 用量:运行中按 llm.end 累加(含成员、调度者),终态以 run.finished 为准
const partial = mod.usageOf(synthetic.teamRun('members'))
check('运行中的用量把调度者和成员都算上', partial.tokensIn === 820 + 640 && !partial.final,
`${partial.tokensIn} / final=${partial.final}`)
const total = mod.usageOf(synthetic.teamRun('done'))
check('终态用后端累计的总数', total.final && total.tokensIn === 4470 && total.tokensOut === 946,
`${total.tokensIn}/${total.tokensOut}`)
check('终态带着执行时长', total.activeMs === 12300, `${total.activeMs}`)
}
console.log('\n=== 被跳过的节点、思考、查询标题、技术细节、签批人、出具缺口 ===')
{
const steps = mod.decodeRun(synthetic.mixedRun())
const all = flatten(steps)
const skipped = steps.find((s) => s.nodeId === 'kb')
// skip_if 成立的节点以前被整类丢掉,和"根本没轮到"分不开
check('被跳过的节点留痕', skipped?.status === 'skipped' && !!skipped.title.includes('跳过「背景检索」'),
`${skipped?.status} ${skipped?.title}`)
check('跳过的原因写出来', !!skipped?.sub?.includes('skip_if 成立'), skipped?.sub)
// 思考不单独成行:挂到它所属的那次模型调用上当副标题
check('思考不再单独成行', !all.some((s) => s.kind === 'think'))
const llm = all.find((s) => s.kind === 'llm' && s.sub)
check('思考成了模型调用的副标题', !!llm?.sub?.includes('先看一下考勤'), llm?.sub)
check('思考全文还在展开区', !!llm?.detail?.includes('按班次汇总出勤率'))
const q1 = all.find((s) => s.kind === 'query' && s.status === 'done')
check('查询标题从 SQL 派生:表名 + 汇总维度',
!!q1?.title.includes('v_device_kpi') && q1.title.includes('按 shift_name 汇总'), q1?.title)
check('查询带着工件 id,可以下钻', !!q1?.artifact?.startsWith('4790dbfc'))
const q2 = all.find((s) => s.kind === 'query' && s.status === 'failed')
check('查询报错的原始异常收进技术细节', !!q2?.raw?.includes('OperationalError'), q2?.raw?.slice(0, 40))
check('报错的人话还在正文', !!q2?.result?.includes('查询超时'))
// 一次看十几张表:标题摘要,完整清单逐行放进详情(以前标题撑破卡片、页面横向滚动)
const schema = all.find((s) => s.kind === 'schema')
check('多表结构的标题不超过 60 字', (schema?.title.length ?? 99) <= 60, `${schema?.title.length}:${schema?.title}`)
check('说出一共几张表', !!schema?.title.includes('12 张表'), schema?.title)
check('完整表名逐行在详情里', schema?.detail?.split('\n').length === 12)
const human = all.find((s) => s.kind === 'human')
check('签批人照事件写', !!human?.title.endsWith('→ 张工 放行了'), human?.title)
check('备注跟着那一轮', !!human?.detail?.includes('备注:晚班偏低要跟进'))
const resume = steps.find((s) => s.title === '继续执行')
check('续跑是谁发起的写在副标题', !!resume?.sub?.includes('张工'), resume?.sub)
const issuance = steps.find((s) => s.kind === 'issuance')
// 只有 gaps 时以前只写一个光秃秃的「降档出具」
check('降档的原因写进出具那一行', !!issuance?.title.includes('校验不完整') && issuance.title.includes('叙述模板渲染为空'),
issuance?.title)
check('出具档位用统一叫法', !!issuance?.title.startsWith('降档出具'))
const notify = steps.find((s) => s.nodeId === 'notify')
check('节点失败的原始异常收进技术细节', notify?.status === 'failed' && !!notify.raw?.includes('ConnectError'))
check('run.failed 和节点失败是同一句话时不说两遍',
all.filter((s) => s.title === '推送失败:企业微信机器人地址没配' || s.detail === '推送失败:企业微信机器人地址没配').length === 1)
// run.failed 单独到(节点没来得及报):能定位到节点
const alone = mod.decodeRun([
{ seq: 1, type: 'run.started', node_id: null, ts: 1, data: { nodes: 2 } },
{ seq: 2, type: 'run.failed', node_id: null, ts: 2, data: { error: '整体超时', node_id: 'notify', label: '推送企业微信' } },
]).find((s) => s.kind === 'error')
check('run.failed 带上出错的节点', alone?.nodeId === 'notify' && !!alone.sub?.includes('推送企业微信'), alone?.sub)
// 模型调用一出现就成行(以前要等 llm.end 才冒出来,想的那几十秒里什么都没有)
const ev = synthetic.mixedRun()
const upto = ev.slice(0, ev.findIndex((e) => e.type === 'llm.start') + 1)
const pending = flatten(mod.decodeRun(upto)).find((s) => s.kind === 'llm')
check('模型调用一开始就有一行进行中', pending?.status === 'running', pending?.status)
check('进行中的行带开始时刻', typeof pending?.startedAt === 'number')
const p = mod.progressOf(steps)
check('进度按不同节点数算', p.total === 6 && p.done >= 4 && p.done <= 6, `${p.done}/${p.total}`)
}
console.log('\n=== 停下来的运行 ===')
{
// 用户主动停止:还在跑的查询收成中性的「已取消」,不画红色的失败,也不再转圈
const all = flatten(mod.decodeRun(synthetic.cancelledRun()))
const q = all.find((s) => s.kind === 'query')
check('取消时还在跑的查询收成已取消', q?.status === 'cancelled', q?.status)
check('取消不是失败', !all.some((s) => s.status === 'failed' || s.level === 'error'))
check('没有还在转的行', !all.some((s) => s.status === 'running'))
// 服务重启:WS 回放完发一条 stream.end。事件停在半路也得收住,而且不成行
const ev = synthetic.mixedRun()
const half = ev.slice(0, ev.findIndex((e) => e.type === 'tool.end'))
const ended = flatten(mod.decodeRun([...half,
{ seq: 999, type: 'stream.end', node_id: null, ts: 0, data: { status: 'interrupted', pending: false } }]))
check('stream.end 之后没有还在转的行', !ended.some((s) => s.status === 'running'),
ended.filter((s) => s.status === 'running').map((s) => s.title).join(','))
check('stream.end 本身不成行', !ended.some((s) => s.title.includes('stream') || s.title === '一条还没翻译的记录'))
check('挂起的步骤标成 suspended', ended.some((s) => s.status === 'suspended'))
}
console.log('\n=== 长运行:按轮折叠(runs-5)===')
{
const ev = synthetic.longLoop(145)
const steps = mod.decodeRun(ev)
// 1100 多条事件、145 拍:顶层仍然只有「开始、参数、循环、读传感器、成果、完成」这几行
check('顶层行数不随拍数增长', steps.length <= 7, `${steps.length} 行(${ev.length} 条事件)`)
const poll = steps.find((s) => s.nodeId === 'poll')
check('同一节点 145 次执行记在一行里', poll?.execs?.length === 145, `${poll?.execs?.length}`)
check('行尾说执行了几次', !!poll?.meta?.startsWith('×145'), poll?.meta)
const groups = mod.childrenByExec(poll)
check('子步骤按轮分组,一轮一组', groups.length === 145 && groups.every((g) => g.steps.length >= 1))
check('每一轮的循环序号跟着 node.started.iteration', groups[36].exec.iteration === 37)
const st = mod.spread(groups.map((g) => g.exec.ms ?? 0))
check('最慢那一拍认得出来', st.maxAt === 36 && st.max === 65, `第 ${st.maxAt + 1} 拍 ${st.max} ms`)
check('提醒挂在它所在的那一轮', groups[9].steps.some((s) => s.level === 'warn'))
// 相邻的同一件事并成一行,保留 level
const rows = mod.compactSteps([
{ id: 'a', seq: 1, kind: 'code', title: '运行 Python 代码', status: 'done', ms: 10 },
{ id: 'b', seq: 2, kind: 'code', title: '运行 Python 代码', status: 'done', ms: 12 },
{ id: 'c', seq: 3, kind: 'code', title: '运行 Python 代码', status: 'done', ms: 63 },
{ id: 'd', seq: 4, kind: 'note', title: '循环达到上限', level: 'warn', status: 'done' },
{ id: 'e', seq: 5, kind: 'note', title: '循环达到上限', level: 'warn', status: 'done' },
])
check('相邻重复行并成一行', rows.length === 2, rows.map((r) => r.title).join(' | '))
check('并后的行写出次数、中位和最慢', rows[0].meta === '×3 · 中位 12 ms · 最慢 63 ms', rows[0].meta)
check('警告并完还是警告', rows[1].level === 'warn' && rows[1].repeat?.count === 2)
// 恢复后的重放(resumed)不算新的一轮;循环体真的又跑一次才算
const replay = mod.decodeRun([
{ seq: 1, type: 'node.started', node_id: 'x', ts: 1, data: { label: '取数' } },
{ seq: 2, type: 'node.failed', node_id: 'x', ts: 2, data: { error: '超时' } },
{ seq: 3, type: 'run.resumed', node_id: null, ts: 3, data: {} },
{ seq: 4, type: 'node.started', node_id: 'x', ts: 4, data: { label: '取数', resumed: true } },
{ seq: 5, type: 'node.finished', node_id: 'x', ts: 5, data: { duration_ms: 20 } },
]).find((s) => s.nodeId === 'x')
check('接着跑的重放不算新一轮', replay?.execs?.length === 1 && replay.status === 'done' && !replay.level,
`${replay?.execs?.length} ${replay?.status} ${replay?.level}`)
}
console.log('\n=== 从 SQL 认表名 ===')
{
const g = mod.describeSql
check('JOIN 的两张表都认出来', g('SELECT a.x FROM orders a JOIN users u ON a.uid = u.id')?.tables.join(',') === 'orders,users')
check('WITH 里的临时名不算表', g('WITH t AS (SELECT * FROM sales) SELECT * FROM t')?.tables.join(',') === 'sales')
check('GROUP BY 1 这种位置序号不瞎猜', g('SELECT region, SUM(x) FROM s GROUP BY 1')?.groupBy.length === 0)
check('聚合认得出', g('SELECT COUNT(*) FROM s')?.aggregate === true)
check('函数包着的分组列取里面的列名', g('SELECT DATE(ts), COUNT(*) FROM s GROUP BY DATE(ts)')?.groupBy[0] === 'ts')
check('不是查询就不硬认', g('这不是 SQL') === null)
}
console.log('\n=== Copilot:心跳穿插、回话、少了一步、报错 ===')
{
const steps = mod.decodeCopilot(synthetic.COPILOT_STUCK, { context: 'canvas' })
check('心跳穿插的思考仍然并成一条', steps.filter((s) => s.kind === 'think').length === 1,
steps.filter((s) => s.kind === 'think').map((s) => s.title).join(' | '))
check('收尾后没有任何还在转的行', !steps.some((s) => s.status === 'running'),
steps.filter((s) => s.status === 'running').map((s) => s.title).join(','))
check('阶段行收尾后不再写「正在…」', !steps.some((s) => s.kind === 'lifecycle' && s.title.startsWith('正在')),
steps.filter((s) => s.kind === 'lifecycle').map((s) => s.title).join(' | '))
check('修正写明第几轮', steps.some((s) => s.title.includes('第 1/2 轮')))
check('少了一步说出来', steps.some((s) => s.level === 'warn' && s.title.includes('excel_export')))
check('节点类型说中文', steps.find((s) => s.kind === 'node')?.meta === '调用工具',
steps.find((s) => s.kind === 'node')?.meta)
check('建图的每一行都标着规划阶段', steps.every((s) => s.stage === 'plan'))
const out = mod.copilotOutcome(synthetic.COPILOT_STUCK)
check('结局:图放上去了', out.kind === 'built' && out.total === 2)
check('结局:自查没修好,问题带着节点 id', out.check?.status === 'failed'
&& out.check.issues[0]?.nodeId === 'lp' && out.check.issues[1]?.nodeId === 'q',
JSON.stringify(out.check?.issues))
check('结局:认出被跳过的类型', out.skipped.join(',') === 'excel_export')
const reply = mod.copilotOutcome([{ op: 'reply', text: '按 mode 分' }])
check('只回了一句话的轮次认得出', reply.kind === 'reply' && reply.reply === '按 mode 分')
check('只回话时没有残留的转圈', !mod.decodeCopilot([
{ op: 'heartbeat', phase: 'planning', elapsed_ms: 3000 }, { op: 'reply', text: 'x' },
]).some((s) => s.status === 'running'))
check('流结束了但什么都没给', mod.copilotOutcome([{ op: 'heartbeat', phase: 'planning' }]).kind === 'empty')
const err = mod.decodeCopilot([{ op: 'error', message: '助手这一轮没跑完:模型服务没响应', hint: '稍后重试', detail: 'httpx.ReadTimeout' }])
const row = err.find((s) => s.kind === 'error')
check('报错:人话当标题、怎么办当副标题、原文进技术细节',
row?.title.startsWith('助手这一轮没跑完') && row.sub === '稍后重试' && row.raw === 'httpx.ReadTimeout',
`${row?.title} / ${row?.sub} / ${row?.raw}`)
const eo = mod.copilotOutcome([{ op: 'error', message: 'm', hint: 'h', detail: 'd' }])
check('结局里的报错分开带着 hint 和 detail', eo.kind === 'error' && eo.error?.hint === 'h' && eo.error?.raw === 'd')
}
console.log('\n=== 术语 ===')
{
// 没起名的节点退到类型名,类型名跟全站同一张表
const h = mod.decodeRun([{ seq: 1, type: 'node.started', node_id: 'h', ts: 1, data: { node_type: 'human', label: 'h' } }])
check('人工节点叫「人工审批」', h[0]?.title === '人工审批', h[0]?.title)
}
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过')