Files
AgentLab/scripts/check-ui.mjs
T
ThearchyHelios dcadc0c48b 对话有上下文了,左边也能选会话
三条 run(d983c4e6 / 14103db5 / 08c3de1c)在用户看来是一次对话,系统里却
毫无关联。查下来不是哪里传漏了,是整层缺失:

- 每轮 base_graph=None 从零建图,Copilot 不知道上一轮问过什么
- 每轮 thread_id = run.id,三条独立的 checkpoint 线程
- 前端 turns 只活在 zustand 里,刷新页面就全没了——那三轮当时在界面上
  已经找不回来,只剩 runs 表里三条互不相干的记录
- 后端 13 张表里没有会话这个概念

加两张表(conversations / conversation_turns)。失败的轮次也记:只记成功的,
历史就成了一份美化过的记录,而人回来最想搞清楚的恰恰是上次卡在哪。

Copilot 的上下文带三样:前几轮问答、上一轮那张图(走 _slim 去掉坐标噪音)。
只给问答文字不够——"再按月份拆一下"会让模型重新设计一张图,实测经常接到另
一张表上,答案对不上前一轮。运行时另外注入 {{ input.history }},管的是指代:
用户问"它们平均多少",图里的 agent 得知道"它们"是上一轮那 12 家店。

左侧会话栏只在问数据页出现,可折叠、可改名删除,当前会话存 localStorage。
轮次按会话分桶缓存——切走再切回来要看得到原来的步骤,回源只拿得到问答。
历史轮次的执行过程不预加载,点「看执行过程」才取事件、走现成的 decodeRun。
画布右栏的 Copilot 依附工作流各有一条会话,连说两次"再加一个节点"能接上了
(base_graph 带的是图的结果,带不出"你刚才要我干什么"),但不进左侧列表。

过程中修掉的两个自己引入的坑:

- 第一版在列表为空时自动建一条会话。create 是异步的、StrictMode 把 effect
  跑两遍,于是每次进页面都留下两三条"新对话"。改成用户开口那一刻按需建
- 加了"在上一轮的图上改"这条指令之后,模型照改图协议只发 update_node,
  而问数据页这条路径的累加器从空图起步,操作全部落空,用户收到的是
  "图校验未通过:图是空的,先拖一个节点进来"(会话 f5db9809 的第二轮,
  而它的 explanation 证明上下文其实完全读懂了)。指令改成沿用 id 和 config
  但完整重发,另加一道兜底:空图且上一轮有图时把操作重放到那张图上

check-ui 多一节「会话」:切换、刷新还在、逛一圈不多出空会话。
2026-09-18 17:58:04 +08:00

254 lines
11 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-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 API = process.env.AGENTLAB_API ?? 'http://localhost:8000/api'
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 body = await page.locator('body').innerText()
check('不再有两层 tab 嵌套',
!body.includes('时间线') && !body.includes('原始事件'))
// 用户的原话:"没有一个很好的地方引导用户写问题"。空态下输入区就该是主体
check('空态给出了明确的邀请', body.includes('想让它做什么'))
check('说清楚它能够到什么', /\d+ 个工具/.test(body), body.match(/\d+ 个工具/)?.[0])
const examples = await page.locator('aside button').filter({ hasText: /。|,/ }).count()
check('例句是完整句子而不是截断的 chip', examples >= 2, `${examples} 条`)
const composer = page.locator('aside textarea').first()
check('输入框自动聚焦,不用先点一下',
await composer.evaluate((el) => el === document.activeElement))
// 改版最容易顺手删掉的东西:唯一的运行入口
check('发起运行的入口还在(已搬到工具栏)',
await page.getByRole('button', { name: /^运行/ }).count() > 0)
// 这是这次重构的核心承诺:属性是盖在助手上的一层,不是把它换掉。
// 切 tab 会卸载组件,草稿、滚动位置、展开状态全丢——那正是要修的问题
await composer.fill('测试草稿不要丢')
await page.locator('.react-flow__node').first().click()
await page.waitForTimeout(350)
const sheet = await page.locator('body').innerText()
check('选中节点滑出属性面板', sheet.includes('节点名称') || sheet.includes('ID:'))
check('面板上有明确的返回,让人知道底下还有东西',
await page.getByTitle('返回助手(Esc)').count() > 0)
await page.keyboard.press('Escape')
await page.waitForTimeout(350)
check('Esc 关得掉', await page.getByTitle('返回助手(Esc)').count() === 0)
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('/studio')
check('没有运行时报错', errors.length === 0, errors.join(' | '))
await page.getByRole('button', { name: /^变量/ }).click()
await page.waitForTimeout(700)
const rows = await page.locator('table tbody tr').count()
check('变量表列出了变量', rows > 0, `${rows} 行`)
const body = await page.locator('body').innerText()
check('列出的是可直接粘的写法', body.includes('{{ input.'), '')
// 抽屉是挤压式的:画布不能被压没,React Flow 容器高度归零会直接报错
const canvasH = await page.locator('.react-flow').first()
.evaluate((el) => el.getBoundingClientRect().height)
check('画布没有被压没', canvasH > 120, `${Math.round(canvasH)}px`)
await page.getByRole('button', { name: /^变量/ }).click()
await page.waitForTimeout(400)
// 补全:模板取不到值会静默渲染成空字符串,补全是从源头消灭这类错误。
// 默认那张图只有 输入→成果,没有带模板的字段,先从面板拖一个模型调用出来
await page.locator('text=模型调用').first().click()
await page.waitForTimeout(700)
// 必须限定在属性面板里:底下助手栏的 Copilot 输入框也是 aside textarea,
// 而它被这一层盖着,点它会一直超时
const field = page.locator('.sheet-in textarea').first()
await field.click()
await page.keyboard.type('{{')
await page.waitForTimeout(400)
const hasPanel = (await page.locator('body').innerText()).includes('⏎ 插入')
check('打 {{ 弹出候选', hasPanel)
if (hasPanel) {
await page.keyboard.press('Enter')
await page.waitForTimeout(350)
const v = await field.inputValue()
check('选中后插入成完整写法', /\{\{ \S+ \}\}/.test(v), v.slice(0, 40))
// 完全受控组件,插入后光标会被重置到末尾,必须自己放回去
await page.keyboard.type('X')
check('光标停在插入内容之后', (await field.inputValue()).endsWith('X'),
(await field.inputValue()).slice(-20))
}
await shot(page, 'variables')
await page.close()
}
console.log('\n=== 动效对前庭敏感者可关 ===')
{
// 这套界面里动的东西不少(边在流动、节点在脉冲、面板在滑)。
// 系统里关了动效还照播,对前庭敏感的人是实打实的难受
const page = await ctx.newPage()
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.goto(`${WEB}/studio`, { waitUntil: 'networkidle' })
await page.waitForTimeout(500)
const durations = await page.evaluate(() => {
const probe = document.createElement('div')
probe.className = 'rise-in'
document.body.appendChild(probe)
const d = getComputedStyle(probe).animationDuration
probe.remove()
return d
})
check('关掉动效后动画确实停了', parseFloat(durations) < 0.01, durations)
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=== 会话 ===')
{
// 对话以前只活在内存里,刷新就没了。这一组守的是它真的落了库:
// 列表能列出来、切过去内容跟着变、刷新还在。
//
// 最后一项守的是一个真踩过的坑:进页面时自动建一条空会话,而 create 是
// 异步的、StrictMode 又把 effect 跑两遍,于是每次访问都留下两三条"新对话"。
const before = await (await fetch(`${API}/conversations`)).json()
const { page, errors } = await visit('/chat')
check('没有运行时报错', errors.length === 0, errors.slice(0, 2).join(' | '))
check('有"新对话"按钮', await page.getByRole('button', { name: /新对话/ }).first().isVisible())
const seeded = before.find((c) => c.turn_count > 0)
if (seeded) {
await page.locator('aside button[title]').filter({ hasText: seeded.title }).first().click()
await page.waitForTimeout(700)
check('切过去能看到那次问的话', (await page.locator('main').innerText()).includes(seeded.title))
await page.reload({ waitUntil: 'networkidle' })
await page.waitForTimeout(900)
check('刷新后还在', (await page.locator('main').innerText()).includes(seeded.title))
} else {
check('(跳过:库里还没有带轮次的会话)', true)
}
const after = await (await fetch(`${API}/conversations`)).json()
check('逛一圈没有凭空多出空会话', after.length <= before.length,
`${before.length} → ${after.length}`)
await shot(page, 'conversations')
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)