mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 03:01:58 +00:00
f0fd63f0b9
用户的原话是「copilot(包括问答)可以是侧边栏的效果」「显示他的简要思考节点, 使得用户知道他大致在想什么,以及用了哪些工具」。 画布右栏从「运行 / 属性 → 时间线 / 成果 / 事件」(两层嵌套 5 个视图)变成 「助手 / 属性」一层。助手栏里是一条流:你让它做什么、它做了什么、结果是什么。 Copilot 建图和跑图在协议上完全无关(SSE 操作流 vs 事件 WebSocket,不共享 seq 和 node_id),但对用户是一件事的两半,decode.ts 把两种流译成同一种 Step 之后才能排进同一条时间轴。 Copilot 不再是弹窗。弹窗的问题不是样式:每改一次图都要重开一次,关掉之后 需求文本就没了,想微调只能重打;而画布左下角那条浮条(z-30)和 Toast (z-100)、Modal(z-50)三层各自为政,本身也是重复的一份 Copilot 状态渲染。 两个都删了,输入框就在栏里,说完一句接着说下一句。右栏没删——它是发起运行 和处理审批的唯一入口,RunLauncher 原样保留,只是跑起来会自动收起给执行过程 让位。tab 用 hidden 切换而不是卸载,不然点一下「属性」看个节点再切回来, 输入框草稿和展开状态全丢。 问数据页和运行页换成同一个 AssistantStream + 同一个 decode.ts。RunPanel 里 那套平行的 EVENT_META + describe() 删了:留着两套翻译,同一次运行在不同页面 会读出不同的故事,而用户没法判断哪个是真的。同理合并了两个耗时格式化函数 (画布节点显示"0ms",右边同一步什么都不显示,两块还并排放着)。运行页列表 加了内容摘要——原本每行只有"临时图 · 1.2s · 340 tok",十条长得一模一样, 要分清哪条是哪条只能挨个点开。 跑真实运行时发现两个缺陷: 1. 后端丢事件(app/core/bus.py)。WebSocket 先读历史、再订阅,两步之间图 正好跑出来的事件既不在历史里也不在实时流里,永远丢了。而 subscribe() 是 异步生成器,真正注册还要等到第一次迭代,窗口比看上去更大。实测一次跑图 开头的 node.finished/node.started 消失,界面上表现为某个节点永远在转圈、 它的子步骤跑到了顶层。改成先 attach 占位、再读历史,重叠部分按 seq 去重。 tests/test_bus_attach.py 里有一条刻画旧行为的测试,说明这个窗口确实存在。 2. 循环里的多轮审批被算错。我在 S1 按内容去重,但三轮审批的 title 一模一样, 和 LangGraph 的重放无法区分——结果第 2、3 次真实审批被当成重放吞掉,而 三次"你驳回了/你放行了"各自成行。改成开/闭配对:开着就是同一次,闭了才是 新一次;决定折进那条审批行而不是另起一行。真实运行(驳回 → 改写 → 再驳回 → 改写 → 放行)已收进 fixtures。 验证:三层各守一件事,共 76 条断言全过。check-decode 守翻译(含新的多轮审批 和截断结果集),check-stream 守渲染,新增 check-ui 守"接起来还对不对"——真 浏览器真后端,包括「切走再切回来草稿还在」「唯一的运行入口还在」「原始事件 切得到也切得回」。另外真跑了一次带三轮审批的图和两次 Copilot 生成(60s, deepseek-v4-pro),截图逐帧看过。后端 78 个测试全过。
143 lines
6.3 KiB
JavaScript
143 lines
6.3 KiB
JavaScript
// 三个页面的端到端检查:真浏览器、真后端、真数据。
|
|
//
|
|
// check-decode 守翻译,check-stream 守渲染,这一层守的是"接起来还对不对"——
|
|
// 前两层都能通过而这一层坏掉:store 没把事件存进去、tab 切换把状态卸载了、
|
|
// 唯一的运行入口被改版顺手删了。
|
|
//
|
|
// 跑之前前后端都得起着:./scripts/dev.sh
|
|
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
|
|
|
|
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
|
|
const CHROME = process.env.CHROME_PATH
|
|
?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
|
const SHOTS = process.env.SHOT_DIR ?? ''
|
|
|
|
let failed = 0
|
|
const check = (name, cond, detail = '') => {
|
|
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
|
|
if (!cond) failed++
|
|
}
|
|
|
|
const browser = await chromium.launch({ executablePath: CHROME })
|
|
const ctx = await browser.newContext({ viewport: { width: 1500, height: 940 } })
|
|
|
|
/** 打开一页并收集真实报错(favicon 的 404 不算) */
|
|
async function visit(path) {
|
|
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())
|
|
})
|
|
await page.goto(`${WEB}${path}`, { waitUntil: 'networkidle' })
|
|
await page.waitForTimeout(700)
|
|
return { page, errors }
|
|
}
|
|
const shot = async (page, name) => {
|
|
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${name}.png` })
|
|
}
|
|
|
|
console.log('=== 画布助手栏 ===')
|
|
{
|
|
const { page, errors } = await visit('/studio')
|
|
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
|
|
|
const tabs = await page.locator('nav, [role=tablist], button').allInnerTexts()
|
|
const flat = tabs.join(' ')
|
|
check('右栏是「助手 / 属性」两个 tab', flat.includes('助手') && flat.includes('属性'))
|
|
check('不再有「时间线 / 成果 / 事件」的第二层嵌套',
|
|
!flat.includes('时间线') && !flat.includes('原始事件'))
|
|
|
|
// 改版最容易顺手删掉的两个东西
|
|
check('发起运行的入口还在', await page.getByText('试运行').count() > 0)
|
|
check('Copilot 输入框就在栏里(不再是弹窗)',
|
|
await page.locator('textarea[placeholder*="告诉它"], textarea[placeholder*="描述你要"]').count() > 0)
|
|
|
|
// 切到属性再切回来,输入框里的草稿不该没
|
|
const composer = page.locator('textarea[placeholder*="告诉它"], textarea[placeholder*="描述你要"]').first()
|
|
await composer.fill('测试草稿不要丢')
|
|
await page.getByRole('button', { name: '属性' }).click()
|
|
await page.waitForTimeout(200)
|
|
await page.getByRole('button', { name: '助手' }).click()
|
|
await page.waitForTimeout(200)
|
|
check('切走再切回来,输入的字还在', await composer.inputValue() === '测试草稿不要丢',
|
|
await composer.inputValue())
|
|
await composer.fill('')
|
|
|
|
const overflow = await page.evaluate(() =>
|
|
document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
|
check('页面不横向溢出', overflow <= 0, `${overflow}px`)
|
|
await shot(page, 'studio')
|
|
await page.close()
|
|
}
|
|
|
|
console.log('\n=== 运行历史 ===')
|
|
{
|
|
const { page, errors } = await visit('/runs')
|
|
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
|
|
|
const rows = page.locator('button').filter({ hasText: /\d{4}\/\d{1,2}\/\d{1,2}/ })
|
|
const n = await rows.count()
|
|
check('列出了运行记录', n > 0, `${n} 条`)
|
|
|
|
if (n > 0) {
|
|
// 列表原本每行只有"临时图 · 1.2s · 340 tok",十条长得一模一样
|
|
const texts = await rows.allInnerTexts()
|
|
const withSummary = texts.filter((t) => t.trim().split('\n').length >= 3).length
|
|
check('行里有内容摘要而不只是耗时和 token', withSummary > 0,
|
|
`${withSummary}/${texts.length} 行带摘要`)
|
|
|
|
await rows.first().click()
|
|
await page.waitForTimeout(900)
|
|
const body = await page.locator('body').innerText()
|
|
check('详情走的是可读视图,不是事件表',
|
|
!/\bnode\.(started|finished)\b/.test(body) && !/\brun\.started\b/.test(body))
|
|
check('详情有内容', body.length > 300, `${body.length} 字`)
|
|
|
|
// 原始事件不是常驻 tab,但必须还能看到
|
|
await page.locator('button[title*="原始事件"]').first().click()
|
|
await page.waitForTimeout(400)
|
|
const rawBody = await page.locator('body').innerText()
|
|
check('切得到原始事件', /node\.(started|finished)|run\.started/.test(rawBody))
|
|
await page.locator('button[title*="可读视图"]').first().click()
|
|
await page.waitForTimeout(300)
|
|
check('切得回来', !/\bnode\.started\b/.test(await page.locator('body').innerText()))
|
|
}
|
|
await shot(page, 'runs')
|
|
await page.close()
|
|
}
|
|
|
|
console.log('\n=== 问数据 ===')
|
|
{
|
|
const { page, errors } = await visit('/chat')
|
|
check('没有运行时报错', errors.length === 0, errors.join(' | '))
|
|
const body = await page.locator('body').innerText()
|
|
check('有空态或历史,不是白屏', body.length > 60, `${body.length} 字`)
|
|
check('有输入框', await page.locator('textarea').count() > 0)
|
|
const overflow = await page.evaluate(() =>
|
|
document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
|
check('页面不横向溢出', overflow <= 0, `${overflow}px`)
|
|
await shot(page, 'chat')
|
|
await page.close()
|
|
}
|
|
|
|
console.log('\n=== 三处讲的是同一个故事 ===')
|
|
{
|
|
// 同一次运行,运行页详情和 preview 里的 dense 渲染应该给出同一批步骤。
|
|
// 这是"全站一个翻译层"的实际含义——分两套的话用户没法判断哪个是真的。
|
|
const a = await ctx.newPage()
|
|
await a.goto(`${WEB}/preview.html?case=loop_approve`, { waitUntil: 'networkidle' })
|
|
await a.waitForTimeout(400)
|
|
const wide = await a.locator('body').innerText()
|
|
const asks = (wide.match(/这条公告可以发吗/g) ?? []).length
|
|
// 宽栏和窄栏各渲染一遍,所以是 3×2
|
|
check('宽窄两栏各三轮审批,一轮不少', asks === 6, `${asks} 处`)
|
|
check('决定折进了同一行而不是另起一行',
|
|
!/^你放行了$/m.test(wide) && !/^你驳回了$/m.test(wide))
|
|
await a.close()
|
|
}
|
|
|
|
await browser.close()
|
|
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 界面端到端全部通过')
|
|
process.exit(failed ? 1 : 0)
|