Files
AgentLab/scripts/check-canvas-layout.mjs
T
ThearchyHelios e10c4f23fc 界面与交互全面翻修(前两波):运行态有了 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 项旧断言(按旧日期格式找运行行),第三波更新。
2026-09-26 23:11:31 +08:00

365 lines
14 KiB
JavaScript
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.
// 画布排版 + 连线走线的自动检查:真后端排版,真前端走线,然后按几何算。
//
// 这一层守的是"图好不好看"里唯一能算的那部分:线有没有叠在一起、长边有没有
// 从卡片中间穿过去、线有没有接在节点外面。check-ui / e2e-check 只看得到
// "页面没报错、节点在、能点",连线叠成一团它们一个字都不会说。
//
// 跑之前前端得起着(./scripts/dev.sh),因为走线模块是 Vite 现编的 TS:
// 检查脚本直接 import('/src/canvas/routing.ts'),用的是页面上跑的那份代码,
// 不是抄一遍逻辑——抄一遍就等于只测了抄的那份。
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
const API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
const CHROME = process.env.CHROME_PATH
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
// ---------------------------------------------------------------- 夹具
const node = (id, type, config = {}) => ({ id, type, data: { label: id, config } })
const edge = (source, target, sourceHandle) => ({ source, target, sourceHandle })
/** 三路并行 → 归并 → 分支,再带一条跨层长边 */
const PARALLEL = {
nodes: [
node('in', 'input'), node('a1', 'agent'), node('a2', 'agent'), node('a3', 'agent'),
node('m1', 'llm'), node('m2', 'llm'), node('join', 'llm'),
node('gate', 'branch', { cases: [{ key: 'yes' }, { key: 'no' }] }),
node('fix', 'llm'), node('ok', 'output'), node('bad', 'output'),
],
edges: [
edge('in', 'a1'), edge('in', 'a2'), edge('in', 'a3'),
edge('a1', 'm1'), edge('a2', 'm1'), edge('a3', 'm2'),
edge('m1', 'join'), edge('m2', 'join'), edge('a3', 'join'),
edge('join', 'gate'),
edge('gate', 'fix', 'no'), edge('gate', 'ok', 'yes'),
edge('fix', 'ok'), edge('fix', 'bad'),
],
}
/** 一路扇出 8 条并行支路再全部汇进一个节点:车道压力最大的形状 */
const WIDE = {
nodes: [node('in', 'input'), node('join', 'llm'),
...Array.from({ length: 8 }, (_, i) => node(`m${i}`, 'llm'))],
edges: [
...Array.from({ length: 8 }, (_, i) => edge('in', `m${i}`)),
...Array.from({ length: 8 }, (_, i) => edge(`m${i}`, 'join')),
],
}
/** 循环:一条回边要从整张图外面兜回去 */
const LOOP = {
nodes: [
node('start', 'input'), node('each', 'loop'), node('body', 'llm'),
node('collect', 'transform'), node('done', 'output'),
],
edges: [
edge('start', 'each'), edge('each', 'body', 'body'), edge('body', 'collect'),
edge('collect', 'each'), edge('each', 'done', 'done'),
],
}
/** 分支的两个出口又汇到同一个节点:入端口错开得对不对就看它 */
const MERGE_BRANCH = {
nodes: [
node('start', 'input'),
node('gate', 'branch', { cases: [{ key: 'yes' }, { key: 'no' }] }),
node('left', 'llm'), node('right', 'llm'), node('done', 'output'),
],
edges: [
edge('start', 'gate'), edge('gate', 'left', 'yes'), edge('gate', 'right', 'no'),
edge('left', 'done'), edge('right', 'done'),
],
}
/** 两个出口直接连到同一个节点:端口错开最极端的情况(起点终点都只差一点) */
const BOTH_WAYS = {
nodes: [
node('start', 'input'),
node('gate', 'branch', { cases: [{ key: 'yes' }, { key: 'no' }] }),
node('done', 'output'),
],
edges: [
edge('start', 'gate'), edge('gate', 'done', 'yes'), edge('gate', 'done', 'no'),
],
}
/** case 的 key 用了保留名 default:和兜底出口合并成一个出口,线照样接得上 */
const DEFAULT_CASE = {
nodes: [
node('start', 'input'),
node('gate', 'branch', { cases: [{ key: 'fast', label: '快速' }, { key: 'default', label: '协作' }] }),
node('quick', 'llm'), node('team', 'llm'), node('done', 'output'),
],
edges: [
edge('start', 'gate'), edge('gate', 'quick', 'fast'), edge('gate', 'team', 'default'),
edge('quick', 'done'), edge('team', 'done'),
],
}
const FIXTURES = { PARALLEL, WIDE, LOOP, MERGE_BRANCH, BOTH_WAYS, DEFAULT_CASE }
// ---------------------------------------------------------------- 几何
/** 把 M/L/Q 路径拆成横平竖直的线段。Q 是拐角的圆弧,取端点即可。 */
function segmentsOf(path) {
const tokens = path.match(/[MLQ][^MLQ]*/g) ?? []
const points = []
for (const token of tokens) {
const nums = token.slice(1).trim().split(/[\s,]+/).map(Number)
if (token[0] === 'Q') points.push({ x: nums[2], y: nums[3] })
else points.push({ x: nums[0], y: nums[1] })
}
const segments = []
for (let i = 1; i < points.length; i++) {
const a = points[i - 1]
const b = points[i]
if (Math.abs(a.x - b.x) < 0.6 && Math.abs(a.y - b.y) < 0.6) continue
segments.push({ a, b })
}
return segments
}
const isHorizontal = (s) => Math.abs(s.a.y - s.b.y) < 0.6
const isVertical = (s) => Math.abs(s.a.x - s.b.x) < 0.6
const span = (s, axis) => {
const lo = Math.min(s.a[axis], s.b[axis])
const hi = Math.max(s.a[axis], s.b[axis])
return [lo, hi]
}
const overlapOf = (s1, s2, axis) => {
const [a0, a1] = span(s1, axis)
const [b0, b1] = span(s2, axis)
return Math.min(a1, b1) - Math.max(a0, b0)
}
/** 两条线段是不是压在同一条线上 */
function collides(s1, s2) {
if (isHorizontal(s1) && isHorizontal(s2)) {
if (Math.abs(s1.a.y - s2.a.y) > 1) return 0
return overlapOf(s1, s2, 'x')
}
if (isVertical(s1) && isVertical(s2)) {
if (Math.abs(s1.a.x - s2.a.x) > 1) return 0
return overlapOf(s1, s2, 'y')
}
return 0
}
function boxCrossings(segments, boxes, ownIds) {
const hits = []
for (const s of segments) {
for (const box of boxes) {
if (ownIds.has(box.id)) continue
const [x0, x1] = span(s, 'x')
const [y0, y1] = span(s, 'y')
const pad = 2
if (x0 < box.right - pad && x1 > box.left + pad
&& y0 < box.bottom - pad && y1 > box.top + pad) {
hits.push(box.id)
}
}
}
return hits
}
// ---------------------------------------------------------------- 跑
const browser = await chromium.launch({ executablePath: CHROME })
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } })
const page = await ctx.newPage()
const pageErrors = []
page.on('pageerror', (e) => pageErrors.push(e.message))
await page.goto(`${WEB}/studio`, { waitUntil: 'networkidle' })
// 走线模块是从 dev server 现编现取的。打包产物里没有这个路径,先探一下,
// 免得后面报一句看不懂的 import 失败
try {
await page.evaluate(async () => { await import('/src/canvas/routing.ts') })
} catch (e) {
console.error(`拿不到 /src/canvas/routing.ts:这个检查跑在 Vite dev server 上,`
+ `先 ./scripts/dev.sh(当前 ${WEB})。${e.message}`)
await browser.close()
process.exit(1)
}
for (const [name, graph] of Object.entries(FIXTURES)) {
console.log(`=== ${name} ===`)
const res = await fetch(`${API}/copilot/layout`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ graph }),
})
if (!res.ok) {
check('后端排版可用', false, `${res.status}`)
continue
}
const laid = await res.json()
const report = await page.evaluate(async ({ graph, laid }) => {
const mod = await import('/src/canvas/routing.ts')
// 节点高度用实测范围里偏大的一档:卡片带摘要、带运行态时会更高,
// 用最小值去检查等于给自己放水
const heights = [74, 92, 108]
const nodes = laid.nodes.map((n, i) => ({
id: n.id,
position: n.position,
measured: { width: 238, height: heights[i % heights.length] },
data: { nodeType: n.type, config: n.data?.config ?? {} },
}))
const routes = mod.buildRoutes(nodes, laid.edges.map((e, i) => ({
id: e.id || `e${i}`, source: e.source, target: e.target,
sourceHandle: e.sourceHandle ?? null,
})))
const out = []
for (const [id, route] of routes) out.push({ id, path: route.path })
return out
}, { graph, laid })
const boxes = laid.nodes.map((n) => ({
id: n.id,
left: n.position.x,
right: n.position.x + 238,
top: n.position.y,
bottom: n.position.y + 108,
}))
check('每条边都算出了路径', report.length === laid.edges.length,
`${report.length}/${laid.edges.length}`)
const segments = new Map()
for (const item of report) segments.set(item.id, segmentsOf(item.path))
// 1. 不同边之间不能有压在一起的线段
const overlaps = []
const ids = [...segments.keys()]
for (let i = 0; i < ids.length; i++) {
for (let j = i + 1; j < ids.length; j++) {
for (const s1 of segments.get(ids[i])) {
for (const s2 of segments.get(ids[j])) {
if (collides(s1, s2) > 2) overlaps.push(`${ids[i]} × ${ids[j]}`)
}
}
}
}
check('没有两条线压在同一段上', overlaps.length === 0,
overlaps.slice(0, 4).join('、'))
// 2. 长边不能从别的卡片中间穿过去
const crossings = []
for (const item of report) {
const e = laid.edges.find((x, i) => (x.id || `e${i}`) === item.id) ?? {}
const hits = boxCrossings(segments.get(item.id), boxes, new Set([e.source, e.target]))
if (hits.length) crossings.push(`${item.id}→${[...new Set(hits)].join(',')}`)
}
check('没有线穿过别人的卡片', crossings.length === 0, crossings.slice(0, 3).join('、'))
// 3. 起点终点要落在节点边缘上(差一点没关系,掉在节点里或图外面就是错的)
const byId = new Map(laid.nodes.map((n) => [n.id, n]))
const dangling = []
for (const item of report) {
const e = laid.edges.find((x, i) => (x.id || `e${i}`) === item.id)
if (!e) continue
const segs = segments.get(item.id)
if (!segs.length) continue
const start = segs[0].a
const end = segs[segs.length - 1].b
const src = byId.get(e.source)
const dst = byId.get(e.target)
if (Math.abs(start.x - (src.position.x + 238)) > 12) {
dangling.push(`${item.id} 起点 x=${start.x.toFixed(0)}`)
}
if (Math.abs(end.x - dst.position.x) > 12) {
dangling.push(`${item.id} 终点 x=${end.x.toFixed(0)}`)
}
}
check('每条线的两端都接在节点上', dangling.length === 0, dangling.slice(0, 3).join('、'))
}
check('页面没有运行时错误', pageErrors.length === 0, pageErrors.join(' | '))
// 手拖过的节点位置是随机的:坐标乱七八糟时不能算出 NaN 路径。
// NaN 的 path 什么都不画,界面上就是"线没了",而且一声不吭。
console.log('=== 随机坐标(手拖过的图) ===')
{
const fuzz = await page.evaluate(async () => {
const mod = await import('/src/canvas/routing.ts')
let seed = 20240924
const rnd = () => (seed = (seed * 1103515245 + 12345) % 2147483648) / 2147483648
const types = ['input', 'llm', 'agent', 'branch', 'loop', 'output', 'human', 'transform']
let total = 0
let broken = 0
const examples = []
for (let trial = 0; trial < 200; trial++) {
const n = 3 + Math.floor(rnd() * 9)
const nodes = Array.from({ length: n }, (_, i) => ({
id: `n${i}`,
position: { x: Math.round(rnd() * 1800) - 200, y: Math.round(rnd() * 900) - 300 },
measured: { width: 238, height: 40 + Math.round(rnd() * 90) },
data: {
nodeType: types[Math.floor(rnd() * types.length)],
config: { cases: [{ key: 'yes' }, { key: 'no' }] },
},
}))
const edges = []
for (let i = 0; i < n; i++) {
for (let k = 0, m = Math.floor(rnd() * 3); k < m; k++) {
edges.push({
id: `e${i}_${k}`, source: `n${i}`,
target: `n${Math.floor(rnd() * n)}`,
sourceHandle: rnd() < 0.4 ? 'yes' : null,
})
}
}
const routes = mod.buildRoutes(nodes, edges)
for (const e of edges) {
total++
const route = routes.get(e.id)
const ok = route && route.path.length >= 4 && !/NaN|Infinity|undefined/.test(route.path)
&& Number.isFinite(route.labelX) && Number.isFinite(route.labelY)
if (!ok) {
broken++
if (examples.length < 3) examples.push(`${e.id}: ${route?.path?.slice(0, 60)}`)
}
}
}
return { total, broken, examples }
})
check(`随机坐标下每条边都画得出来(${fuzz.total} 条)`, fuzz.broken === 0,
fuzz.examples.join('、'))
}
// 分支出口由 case 算出来。同 id 的两个出口会让 React Flow 出两个 handle、跑完两条一起
// 亮,React 还会报 key 重复;key=default 和兜底出口在运行时本来就是同一个出口
console.log('=== 分支出口:保留名 default、重复标识 ===')
{
const r = await page.evaluate(async () => {
const { sourceHandles } = await import('/src/canvas/nodeDefs.ts')
const merged = sourceHandles('branch', { cases: [{ key: 'fast', label: '快速' }, { key: 'default', label: '协作' }] })
const dup = sourceHandles('branch', { cases: [{ key: 'a' }, { key: 'a' }, { key: 'b' }] })
const plain = sourceHandles('branch', { cases: [{ key: 'yes' }] })
return {
merged: merged.map((h) => `${h.id}:${h.label}`),
dup: dup.map((h) => h.id),
plain: plain.map((h) => h.id),
colors: merged.map((h) => h.color ?? ''),
}
})
check('key=default 的 case 和「其他」合并成一个出口', r.merged.length === 2
&& r.merged[1] === 'default:协作(兜底)', r.merged.join('、'))
check('重复的 key 只出一个出口', r.dup.join(',') === 'a,b,default', r.dup.join(','))
check('普通分支照常追加「其他」出口', r.plain.join(',') === 'yes,default', r.plain.join(','))
check('出口颜色是中性的(ok 色只留给状态)', r.colors.every((c) => !c.includes('--ok')), r.colors.join('、'))
}
console.log(failed ? `\n${failed} 项未通过` : '\n全部通过')
await browser.close()
process.exit(failed ? 1 : 0)