mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:41:57 +00:00
e10c4f23fc
来自 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 项旧断言(按旧日期格式找运行行),第三波更新。
365 lines
14 KiB
JavaScript
365 lines
14 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'),
|
||
],
|
||
}
|
||
|
||
/** 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)
|