mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 21:21:59 +00:00
8905fe5e60
多 agent 节点原本是个黑盒:卡片转三十秒圈,然后吐一段文本。"它同时派了三个 人"、"谁还在跑"、"并行到底省了多少"全都要点开右栏才知道,而右栏是线性的 时间线,看不出并发的形状。 现在它跑起来会在卡片里展开一块协作矩阵。三条设计上的选择: - 花名册固定在原地,不按派单顺序重排——几轮下来眼睛知道谁在哪一行 - 条形长度按**这一轮里最慢的那个人**归一化。三个人并排三条长短不一的条, 墙钟为什么等于最长那条、而不是三条之和,不用解释就看懂了;页脚给出 并行一共省下多少 - 本轮没被派到的人不显示成"待命",而是留着上一轮的成绩(压暗)——它已经 交回来了,说它待命是错的,看的人会以为它还没干活 矩阵和右栏泳道读的是同一份翻译:把 decodeRun 里那段攒泳道的逻辑抽成 reduceTeam(不可变,每次返回新对象,画布那份挂在 zustand 上需要新引用)。 两边各写一遍的话,同一次运行在右栏和画布上会给出不同的并行度——而"几个人 同时在跑、省了多少"正是这个节点唯一值得看的东西。抽完之后 check-decode 一条没红。 其余节点按同一套语汇动:边框上转一圈光弧 + 扫描线 = 正在跑,绿色扫一遍 = 刚跑完(只扫一次——常驻动画会让整张画布一直在动),短促抖动 = 刚失败, 琥珀呼吸 = 在等人。循环卡片显示"第 1/5 轮";分支跑完点亮命中的出口、压暗 其余,实际走过的边留在画布上,那就是这次的执行路径。连线上的虚线换成顺着 路径跑的光点:虚线只让人觉得"在动",光点能看出往哪走,速度按路径长度归一化 (长边上的光点走得慢会像卡住了)。 原来那个往外扩的声纳圈(pulse-ring)删掉了。同一个意思说两遍只会更吵,而且 声纳扩出去会和邻居卡片的边框糊在一起。 一个前提先验证过:offset-path 在 SVG 子元素上能不能对齐路径。测下来和 getPointAtLength 的位置差 0px,才敢用它。 排版要按**展开后**的高度给多 agent 节点留位置(layout.py 的 _height 按名册 人数算)。不留的话它一跑起来就盖住下面那张卡,而且运行结束后矩阵不会收起来 (那正是要回看的东西),重叠是永久的。 前庭敏感的人由 prefers-reduced-motion 兜住:光弧、扫描线、流光条、光点整体 不绘制,而不是把时长压到 0.01ms——那样它们会停在动画的最后一帧,光弧卡在 角落、光点全堆在起点,看着像渲染坏了。静态下"谁在跑"由实心圆点和"进行中" 三个字承担。 新增 scripts/check-canvas-fx.mjs:把脚本化的事件灌进 store(走的是页面上那一份 applyEvent / NodeCard / FlowEdge,只有事件来源是脚本),断言协作矩阵真的长出来、 并行度算对、条形长度和耗时成正比、分支命中的出口被点亮、关掉动效时光效确实不 绘制。真跑一张多 agent 的图要连模型、要等十几秒,而且撞不上"三个人同时还在跑" 那一瞬间——那样的检查会变成偶发失败。另外用仓库里 fixtures.json 那份真实导出 的事件验过一遍,含老运行那种只有中文消息串的 log(走正则兜底)。 顺手修掉检查脚本互相下绊子:check-canvas-fx 原来会留下一张没坐标的工作流 (节点全叠在原点),而 check-ui 打开 /studio 会落到"最近更新"的那张上,于是 点节点被挡住。现在落库前先让后端排好版。 292 passed(新增 2 条:展开的多 agent 卡片必须在排版里留出位置)。
242 lines
12 KiB
JavaScript
242 lines
12 KiB
JavaScript
// 画布运行态动效的检查:把脚本化的事件灌进 store,看画布上真的长出了东西。
|
|
//
|
|
// 为什么不是"真跑一张图":多 agent 那张图要连模型、要等十几秒,而且撞不上
|
|
// "三个人同时还在跑"那一瞬间——检查会变成偶发失败。dev 构建把 store 挂在
|
|
// window.__studio 上,这里直接往里灌事件:走的是页面上那一份 applyEvent /
|
|
// NodeCard / FlowEdge,只有事件来源是脚本。
|
|
//
|
|
// 守的是几件"事件对了但界面没跟上"的事:协作矩阵没长出来、并行度没显示、
|
|
// 分支跑完看不出走了哪条、动效在系统关了动效时还在播。
|
|
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 agent = (name, description) => ({ name, description, system: name, tools: ['web_search'] })
|
|
|
|
const GRAPH = {
|
|
nodes: [
|
|
{ id: 'start', type: 'input', data: { label: '问题', config: { fields: [{ name: 'q' }] } } },
|
|
{ id: 'team', type: 'supervisor', data: { label: '研究协作团队', config: {
|
|
goal: '多角度研究', max_rounds: 4, max_parallel: 3,
|
|
agents: [agent('检索员', '找资料'), agent('分析员', '交叉验证'),
|
|
agent('撰写员', '写成结论'), agent('审校', '核对数字')],
|
|
} } },
|
|
{ id: 'gate', type: 'branch', data: { label: '质量门', config: { mode: 'expression', cases: [
|
|
{ key: 'ok', condition: "vars.verdict == 'ok'", label: '通过' },
|
|
{ key: 'fail', condition: "vars.verdict == 'fail'", label: '打回' },
|
|
] } } },
|
|
{ id: 'retry', type: 'loop', data: { label: '返工重试', config: { mode: 'while', condition: 'x', max_iterations: 5 } } },
|
|
{ id: 'done', type: 'output', data: { label: '出具', config: { fields: [{ name: 'r' }] } } },
|
|
],
|
|
edges: [
|
|
{ source: 'start', target: 'team' },
|
|
{ source: 'team', target: 'gate' },
|
|
{ source: 'gate', target: 'done', sourceHandle: 'ok' },
|
|
{ source: 'gate', target: 'retry', sourceHandle: 'fail' },
|
|
{ source: 'retry', target: 'done', sourceHandle: 'done' },
|
|
],
|
|
}
|
|
|
|
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, data, ts: Date.now() / 1000 })
|
|
|
|
/** 三个人同时开工,然后各自交回 */
|
|
const PARALLEL = [
|
|
ev(1, 'run.started', null, { nodes: 5 }),
|
|
ev(2, 'node.started', 'start', {}),
|
|
ev(3, 'node.finished', 'start', { duration_ms: 0, preview: { q: 'x' } }),
|
|
ev(4, 'node.started', 'team', { node_type: 'supervisor' }),
|
|
ev(5, 'log', 'team', { level: 'info', round: 0, agents: ['检索员', '分析员', '撰写员'], done: false,
|
|
parallel: 3, reason: '三个方向互不依赖', message: '调度 → 检索员、分析员、撰写员 · 3 人同时进行' }),
|
|
ev(6, 'agent.step.start', 'team', { agent: '检索员', instruction: '把资料找全', round: 0, parallel: 3 }),
|
|
ev(7, 'agent.step.start', 'team', { agent: '分析员', instruction: '交叉验证', round: 0, parallel: 3 }),
|
|
ev(8, 'agent.step.start', 'team', { agent: '撰写员', instruction: '写成结论', round: 0, parallel: 3 }),
|
|
]
|
|
|
|
const SETTLED = [
|
|
ev(9, 'agent.step.end', 'team', { agent: '检索员', duration_ms: 6600, round: 0, parallel: 3, preview: '8 篇' }),
|
|
ev(10, 'agent.step.end', 'team', { agent: '分析员', duration_ms: 9800, round: 0, parallel: 3, preview: '2 处矛盾' }),
|
|
ev(11, 'agent.step.end', 'team', { agent: '撰写员', duration_ms: 4200, round: 0, parallel: 3, preview: '草案' }),
|
|
ev(12, 'log', 'team', { level: 'info', round: 1, agents: ['审校'], done: false, parallel: 1,
|
|
reason: '数字要有人核对', message: '调度 → 审校' }),
|
|
ev(13, 'agent.step.start', 'team', { agent: '审校', instruction: '核对数字', round: 1, parallel: 1 }),
|
|
]
|
|
|
|
const FINISHED = [
|
|
ev(21, 'node.finished', 'retry', { duration_ms: 0, preview: { __decision__: 'body', iteration: 0 } }),
|
|
ev(22, 'run.finished', null, { output: {}, usage: {}, duration_ms: 30000 }),
|
|
]
|
|
|
|
const BRANCH = [
|
|
ev(14, 'node.finished', 'team', { duration_ms: 26400, preview: { text: '结论' } }),
|
|
ev(15, 'log', 'team', { level: 'info', round: 2, agents: [], done: true, reason: '可出具', message: '调度 → 结束协作' }),
|
|
ev(16, 'node.started', 'gate', {}),
|
|
ev(17, 'edge.taken', 'gate', { branch: 'fail', reason: "命中条件:vars.verdict == 'fail'", mode: 'expression' }),
|
|
ev(18, 'node.finished', 'gate', { duration_ms: 0, preview: { __decision__: 'fail' } }),
|
|
ev(19, 'node.started', 'retry', {}),
|
|
ev(20, 'edge.taken', 'retry', { branch: 'body', iteration: 0, mode: 'while' }),
|
|
]
|
|
|
|
async function openStudio(extra = {}) {
|
|
const browser = await chromium.launch({ executablePath: CHROME })
|
|
const ctx = await browser.newContext({ viewport: { width: 1400, height: 950 }, ...extra })
|
|
const page = await ctx.newPage()
|
|
const errors = []
|
|
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message))
|
|
page.on('console', (m) => { if (m.type() === 'error' && !m.text().includes('404')) errors.push(m.text()) })
|
|
|
|
// 先让后端把坐标排好再落库。图里没有坐标的话所有节点都堆在原点,
|
|
// 这张"最近更新的工作流"会被别的检查脚本(check-ui 打开 /studio 落到第一张)
|
|
// 撞上,那边点节点就会点到叠在一起的东西上——检查之间不该互相下绊子
|
|
const laid = await (await fetch(`${API}/copilot/layout`, {
|
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ graph: GRAPH }),
|
|
})).json()
|
|
|
|
const list = await (await fetch(`${API}/workflows`)).json()
|
|
const found = list.find((w) => w.name === '__fx_check__')
|
|
const id = found
|
|
? found.id
|
|
: (await (await fetch(`${API}/workflows`, {
|
|
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ name: '__fx_check__', graph: laid }),
|
|
})).json()).id
|
|
if (found) {
|
|
await fetch(`${API}/workflows/${id}`, {
|
|
method: 'PATCH', headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ graph: laid }),
|
|
})
|
|
}
|
|
await page.goto(`${WEB}/studio/${id}`, { waitUntil: 'networkidle' })
|
|
await page.waitForTimeout(900)
|
|
await page.locator('.react-flow__controls-fitview').click()
|
|
await page.waitForTimeout(500)
|
|
return { browser, page, errors, id }
|
|
}
|
|
|
|
const feed = (page, events) => page.evaluate((list) => {
|
|
for (const e of list) window.__studio.getState().applyEvent(e)
|
|
}, events)
|
|
|
|
// ---------------------------------------------------------------- 展开
|
|
|
|
console.log('=== 多 agent 节点展开协作矩阵 ===')
|
|
{
|
|
const { browser, page, errors } = await openStudio()
|
|
await feed(page, PARALLEL)
|
|
await page.waitForTimeout(500)
|
|
|
|
const matrix = page.locator('.team-matrix').first()
|
|
check('矩阵长出来了', await matrix.count() > 0)
|
|
|
|
const rows = page.locator('.team-row')
|
|
check('花名册全员在列(含没被派到的)', await rows.count() === 4, `${await rows.count()} 行`)
|
|
check('三个人同时在跑', await page.locator('.team-row-running').count() === 3,
|
|
`${await page.locator('.team-row-running').count()} 行`)
|
|
check('没被派到的那个是待命', await page.locator('.team-row-idle').count() === 1)
|
|
|
|
const body = await matrix.innerText()
|
|
check('说出了并行度', body.includes('3 人同时进行'), body.split('\n')[0])
|
|
check('说了是第几轮', /第 1 轮/.test(body))
|
|
check('在跑的人带着当前任务', body.includes('把资料找全'))
|
|
|
|
// 正在跑的节点:光环和扫描线都得在
|
|
check('运行中的卡片有光弧', await page.locator('.node-running .fx-halo').count() > 0)
|
|
check('运行中的卡片有扫描线', await page.locator('.node-running .fx-scan').count() > 0)
|
|
check('活跃边上有流动的光点', await page.locator('.edge-packet').count() > 0,
|
|
`${await page.locator('.edge-packet').count()} 个`)
|
|
|
|
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
|
|
await browser.close()
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 收束
|
|
|
|
console.log('=== 一轮交回之后 ===')
|
|
{
|
|
const { browser, page } = await openStudio()
|
|
await feed(page, [...PARALLEL, ...SETTLED])
|
|
await page.waitForTimeout(500)
|
|
|
|
const matrix = page.locator('.team-matrix').first()
|
|
const body = await matrix.innerText()
|
|
check('交回的人不再是"进行中"', await page.locator('.team-row-running').count() === 1,
|
|
`${await page.locator('.team-row-running').count()} 个还在跑`)
|
|
check('说了跑了多少轮', /^\d+ 轮$/m.test(body), body.match(/\d+ 轮/)?.[0])
|
|
check('算出了并行省下的时间', /并行省下/.test(body), body.match(/并行省下[^\n]*/)?.[0])
|
|
|
|
// 条形长度按最慢那个归一化:9.8s 的那条必须比 4.2s 的长
|
|
const widths = await page.locator('.team-bar-fill').evaluateAll(
|
|
(els) => els.map((el) => parseFloat(el.style.width)))
|
|
check('条形长度和耗时成正比', widths.length >= 2 && Math.max(...widths) === 100
|
|
&& widths.every((w) => w > 0 && w <= 100), widths.join(', '))
|
|
|
|
// 前几轮干过的人不该被写成"待命"
|
|
check('上一轮干过的人保留成绩', body.includes('6.6s') && body.includes('9.8s'))
|
|
await browser.close()
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 分支与循环
|
|
|
|
console.log('=== 分支命中的出口 / 循环第几轮 ===')
|
|
{
|
|
const { browser, page } = await openStudio()
|
|
await feed(page, [...PARALLEL, ...SETTLED, ...BRANCH])
|
|
await page.waitForTimeout(500)
|
|
|
|
const gate = page.locator('.react-flow__node[data-id="gate"]')
|
|
check('命中的出口被点亮', await gate.locator('.handle-taken').count() === 1)
|
|
check('落空的出口被压暗', await gate.locator('.handle-idle').count() === 2,
|
|
`${await gate.locator('.handle-idle').count()} 个`)
|
|
// 还在跑的时候那条边是"活跃"(虚线 + 光点);跑完才落成"走过的边"
|
|
check('跑动中那条边是活跃的', await page.locator('.react-flow__edge.edge-active').count() > 0,
|
|
`${await page.locator('.react-flow__edge.edge-active').count()} 条`)
|
|
await feed(page, FINISHED)
|
|
await page.waitForTimeout(400)
|
|
check('跑完之后执行路径留在画布上',
|
|
await page.locator('.react-flow__edge.edge-taken').count() > 0,
|
|
`${await page.locator('.react-flow__edge.edge-taken').count()} 条`)
|
|
|
|
const retry = page.locator('.react-flow__node[data-id="retry"]')
|
|
check('循环显示第几轮 / 上限', (await retry.innerText()).includes('第 1/5 轮'),
|
|
await retry.innerText().then((t) => t.split('\n')[0]))
|
|
await browser.close()
|
|
}
|
|
|
|
// ---------------------------------------------------------------- 关掉动效
|
|
|
|
console.log('=== 系统关了动效 ===')
|
|
{
|
|
const { browser, page } = await openStudio({ reducedMotion: 'reduce' })
|
|
await feed(page, PARALLEL)
|
|
await page.waitForTimeout(400)
|
|
|
|
const styles = await page.evaluate(() => {
|
|
const el = document.querySelector('.fx-halo')
|
|
return {
|
|
matches: matchMedia('(prefers-reduced-motion: reduce)').matches,
|
|
halo: el ? getComputedStyle(el).display : '不在 DOM 里',
|
|
packet: document.querySelector('.edge-packet')
|
|
? getComputedStyle(document.querySelector('.edge-packet')).display : '不在 DOM 里',
|
|
}
|
|
})
|
|
check('浏览器确实处在减少动效模式', styles.matches)
|
|
check('光弧不再绘制', styles.halo === 'none', styles.halo)
|
|
check('光点不再绘制', styles.packet === 'none', styles.packet)
|
|
// 状态本身还得看得出来,不能因为关了动效就变成"不知道在不在跑"
|
|
const body = await page.locator('.team-matrix').first().innerText()
|
|
check('静态下仍看得出谁在跑', body.includes('进行中'))
|
|
await browser.close()
|
|
}
|
|
|
|
console.log(failed ? `\n${failed} 项未通过` : '\n全部通过')
|
|
process.exit(failed ? 1 : 0)
|