mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 07:02:00 +00:00
75be5f485e
原来所有边都交给 React Flow 的 smoothstep,它的竖直段一律落在源和目标的
正中间。同一列的边于是全挤在同一个 x 上:一张"入口扇出三路 → 归并 → 分支
→ 出具/驳回"的图上,跨层的 a3→join 和 m2→join 的横段**完全压在同一条线
上**,长边还直接从"归并B"卡片中间穿过去。排版那边同样糙:同层按 id 字典序
竖排,所以 out_no 排在 out_yes 上面("n" < "y"),分支的 yes/no 是反的;
层内没有任何减少交叉的排序;层间距写死 300。
后端 app/engine/layout.py 重写成分层布局:先剔回边再按最长路径分层(loop
的回边不参与分层,否则循环体会被压到循环节点左边);层内中位数启发式上下扫
六轮取交叉最少的一版,分支/循环的出口再按声明顺序占位重排;y 先均匀堆叠再按
邻居重心迭代松弛,同列节点因此和上下游对齐,长边也走得直;最后按"穿过这条
走廊的连线在竖直方向最多重叠几条"给走廊留宽——区间图上的贪心染色,和前端
用的是同一套,走廊留窄了前端再聪明也只能把线叠起来。
前端 canvas/routing.ts 自己算走线:出边沿右边缘错开、入边沿左边缘错开(入边
按上游纵向位置排序),竖直段按区间染色分配车道——真叠在一起的才各占一条,
首尾相接的(同一个扇出点)共用一条,看起来就是一条总线;跨层的长边和循环的
回边在中间各列的纵向占用里找空档绕过去,不再压过卡片。边换成自定义组件后
React Flow 不再自动加动画类,实测确认 .edge-active 仍然生效。
顺带修掉"排完版看不见图":整张图换过坐标后(自动排版 / Copilot 生成完 /
切图)自动重新取景。坐标变了视角没变,人盯着的是一片空白。
新增 scripts/check-canvas-layout.mjs:把夹具图交给真后端 /api/copilot/layout
排版,再直接 import('/src/canvas/routing.ts') 用页面上跑的那份代码算路径,
按几何断言没有两条线压在同一段上、没有线穿过别人的卡片、两端都接在节点边缘,
另有 1325 条随机坐标的 fuzz 防手拖之后算出 NaN 路径(NaN 的 path 什么都不
画,界面上就是"线没了",一声不吭)。这条检查做过变异验证——把 laneX 改成
永远返回走廊中点,它立刻报 a3→join × m2→join,正是原图上那处重叠。
285 passed(新增 12 条排版不变量)。check-ui 69 项通过,唯一失败项"本地哈希
要标出没有语义能力"已 git stash 到基线复现,与本次无关。
329 lines
12 KiB
JavaScript
329 lines
12 KiB
JavaScript
// 画布排版 + 连线走线的自动检查:真后端排版,真前端走线,然后按几何算。
|
||
//
|
||
// 这一层守的是"图好不好看"里唯一能算的那部分:线有没有叠在一起、长边有没有
|
||
// 从卡片中间穿过去、线有没有接在节点外面。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'),
|
||
],
|
||
}
|
||
|
||
const FIXTURES = { PARALLEL, WIDE, LOOP, MERGE_BRANCH, BOTH_WAYS }
|
||
|
||
// ---------------------------------------------------------------- 几何
|
||
|
||
/** 把 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('、'))
|
||
}
|
||
|
||
console.log(failed ? `\n${failed} 项未通过` : '\n全部通过')
|
||
await browser.close()
|
||
process.exit(failed ? 1 : 0)
|