Files
AgentLab/scripts/check-guards.mjs
T
ThearchyHelios e10c4f23fc 界面与交互全面翻修(前两波):运行态有了 HUD 和时间轴,终态会收尾,全站令牌、组件、文案统一
来自 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 项旧断言(按旧日期格式找运行行),第三波更新。
2026-09-26 23:11:31 +08:00

127 lines
7.0 KiB
JavaScript

// 几道"坏了就是整页事故"的护栏:输入法回车、未知节点类型、页面级错误边界、
// 全局快捷键不抢输入框、未知地址不是白板。
//
// 出事时的样子都很重:中文用户每选一个词就把半句话发去建图、跑图;
// Copilot 编出一个不存在的节点类型,整站白屏、没保存的编辑一起丢;任何组件渲染
// 抛错,React 卸掉整棵树,连导航栏都没了;外壳的快捷键一旦抢了输入框,问题里
// 打个问号就弹出一层面板。而 decode / stream / ui 三层检查对它们一个字都不会
// 说——它们喂的都是合法数据。
//
// 探针拦掉所有非 GET 的 /api 请求:不写库、不调模型、不花钱,随时可以跑。
// 往画布 store 里注入节点走的是 dev 构建挂在 window.__studio 上的那一份。
// 跑之前前端得起着:./scripts/dev.sh
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
// 不用 playwright install:它既下不动也会动到已有缓存。系统 Chrome 就够了。
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 browser = await chromium.launch({ executablePath: CHROME })
const page = await browser.newPage({ viewport: { width: 1280, height: 860 } })
const writes = []
await page.route('**/api/**', (route) => {
const r = route.request()
if (r.method() === 'GET') return route.continue()
writes.push(`${r.method()} ${new URL(r.url()).pathname}`)
return route.abort()
})
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
// 等到页面上出现某段文字为止。只在"应该发生"的断言上用;"不应该发生"的
// 只能等一个窗口——那是它的本性,不是偷懒
const shows = (text) => page.getByText(text).first().waitFor({ timeout: 8000 }).then(() => true, () => false)
console.log('=== 问数据页:输入法组字时的回车 ===')
await page.goto(`${WEB}/chat`, { waitUntil: 'networkidle' })
const box = page.locator('textarea').first()
await box.fill('帮我查一下本周')
const fire = (init) => box.evaluate((el, init) => el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init })), init)
writes.length = 0
await fire({ key: 'Enter', isComposing: true })
await page.waitForTimeout(400)
check('isComposing 的回车不发送', writes.length === 0, writes.join(', '))
await fire({ key: 'Enter', keyCode: 229 })
await page.waitForTimeout(400)
check('Safari 式 keyCode=229 的回车不发送', writes.length === 0, writes.join(', '))
check('草稿还在输入框里', (await box.inputValue()) === '帮我查一下本周')
const sent = page.waitForRequest((r) => r.method() !== 'GET' && r.url().includes('/api/'), { timeout: 8000 })
.then(() => true, () => false)
await fire({ key: 'Enter' })
await sent
check('普通回车照常发送(请求被探针拦下)', writes.length > 0, writes.join(', '))
console.log('\n=== 画布:未知节点类型不再白屏 ===')
await page.goto(`${WEB}/studio`, { waitUntil: 'networkidle' })
// 画布会自己跳到第一张图,等它真的把节点装进 store 再注入
await page.waitForFunction(() => window.__studio?.getState().nodes.length > 0, null, { timeout: 8000 })
await page.evaluate(() => {
const s = window.__studio.getState()
window.__studio.setState({ nodes: [...s.nodes, { id: 'ghost', type: 'card', position: { x: 60, y: 60 },
data: { nodeType: 'no_such_type', label: '幽灵节点', config: {} } }] })
window.__studio.getState().select('ghost')
})
check('属性面板说清楚是未知类型', await shows('不认识的节点类型'))
check('画布还在(没有白屏)', (await page.locator('body').innerText()).includes('幽灵节点'))
check('没有未捕获的运行时错误', errors.length === 0, errors[0] ?? '')
console.log('\n=== 错误边界:渲染真出错时只坏这一页 ===')
errors.length = 0
await page.evaluate(() => {
const s = window.__studio.getState()
window.__studio.setState({ nodes: [...s.nodes, { id: 'broken', type: 'card', position: { x: 0, y: 0 }, data: null }] })
})
check('出错时给出错误页而不是白屏', await shows('这一页出错了'))
const after = await page.locator('body').innerText()
check('导航栏还在', after.includes('问数据') && after.includes('编排'))
await page.evaluate(() => {
const s = window.__studio.getState()
window.__studio.setState({ nodes: s.nodes.filter((n) => n.id !== 'broken' && n.id !== 'ghost') })
})
await page.getByText('问数据', { exact: true }).first().click()
await page.waitForURL(/\/chat/)
check('换一页就恢复', await page.getByText('这一页出错了').waitFor({ state: 'detached', timeout: 8000 })
.then(() => true, () => false))
console.log('\n=== 全局快捷键:不抢输入框 ===')
// 外壳在 window 的捕获阶段听键盘,比谁都先拿到。判错一次,问题框里打个问号就
// 弹出一层面板、⌥3 直接把人带离写了一半的问题
await page.goto(`${WEB}/chat`, { waitUntil: 'networkidle' })
const draft = page.locator('textarea').first()
await draft.fill('本月出勤率')
await draft.press('?')
await page.waitForTimeout(300)
check('输入框里的 ? 是问号,不弹快捷键说明', (await draft.inputValue()).endsWith('?')
&& (await page.getByRole('dialog', { name: /键盘快捷键/ }).count()) === 0, await draft.inputValue())
await draft.press('Alt+Digit3')
await page.waitForTimeout(300)
check('输入框里的 ⌥3 不切页', new URL(page.url()).pathname.startsWith('/chat'), page.url())
const mod = process.platform === 'darwin' ? { metaKey: true } : { ctrlKey: true }
await draft.evaluate((el, mod) => el.dispatchEvent(new KeyboardEvent('keydown',
{ bubbles: true, cancelable: true, key: 'k', code: 'KeyK', isComposing: true, ...mod })), mod)
await page.waitForTimeout(300)
check('输入法组字时的 ⌘K 不开命令面板', (await page.getByRole('dialog', { name: '命令面板' }).count()) === 0)
await draft.press(process.platform === 'darwin' ? 'Meta+KeyK' : 'Control+KeyK')
check('⌘K 在输入框里照样能开命令面板', await page.getByRole('dialog', { name: '命令面板' }).waitFor({ timeout: 3000 })
.then(() => true, () => false))
await page.keyboard.press('Escape')
await page.waitForTimeout(200)
check('Esc 关掉面板,草稿还在、焦点回到输入框',
(await draft.inputValue()).startsWith('本月出勤率') && await draft.evaluate((el) => el === document.activeElement))
console.log('\n=== 未知地址不是白板 ===')
await page.goto(`${WEB}/no-such-page`, { waitUntil: 'networkidle' })
check('说清楚「这个地址不存在」', await shows('这个地址不存在'))
check('导航栏还在', (await page.locator('nav').innerText()).includes('问数据'))
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 护栏全部通过')
process.exit(failed ? 1 : 0)