UI 重构:Copilot 进侧栏,三个页面共用一条助手流

用户的原话是「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 个测试全过。
This commit is contained in:
ThearchyHelios
2026-09-17 18:29:16 +08:00
parent c1c13fad41
commit f0fd63f0b9
18 changed files with 3001 additions and 1012 deletions
+51 -1
View File
@@ -92,6 +92,32 @@ console.log('\n=== 人工审批(含重放)===')
!all.some((s) => s.status === 'done' && s.level === 'warn'))
}
console.log('\n=== 循环里的多轮审批 ===')
{
// 真实运行:驳回 → 改写 → 再驳回(带备注)→ 改写 → 放行。三轮,每轮都有
// 重放。按内容去重必然出错——三轮的 title 一模一样,和重放无法区分。
// 这条只有拿真实运行才测得出来:构造样本不会长成这样。
const steps = mod.decodeRun(fixtures.loop_approve)
const all = flatten(steps)
const asks = all.filter((s) => s.kind === 'human')
check('三轮审批就是三条,不多不少', asks.length === 3, `${asks.length} 条`)
check('每条都带着你的决定', asks.every((s) => /你(放行|驳回)了/.test(s.title)),
asks.map((s) => s.title).join(' | '))
check('两次驳回一次放行,顺序没错',
asks.filter((s) => s.title.includes('驳回')).length === 2
&& asks[2]?.title.includes('放行'))
// detail 里是被审的草稿全文,打出来会刷屏——只报哪一轮带了备注
check('备注跟着那一轮走', asks.some((s) => s.detail?.includes('不高级')),
asks.map((s, i) => (s.detail?.includes('你的备注') ? `#${i + 1} 有备注` : '')).filter(Boolean).join(' '))
check('没有落单的"你放行了/你驳回了"孤行',
!all.some((s) => s.title === '你放行了' || s.title === '你驳回了'))
check('重放不制造重复节点', (() => {
const ids = steps.filter((s) => s.kind === 'node').map((s) => s.nodeId)
return new Set(ids).size === ids.length
})())
check('跑完了没有还在转的行', !all.some((s) => s.status === 'running'))
}
console.log('\n=== 出具判定 ===')
{
const steps = mod.decodeRun(fixtures.issue)
@@ -142,13 +168,37 @@ console.log('\n=== Copilot 操作流 ===')
{ op: 'add_edge', edge: { source: 'a', target: 'q' } },
{ op: 'done', explanation: '完成' },
]
const mid = mod.decodeCopilot(ops.slice(0, 2))
check('心跳不堆行', mid.filter((s) => s.kind === 'lifecycle' && s.status === 'running').length === 1)
const steps = mod.decodeCopilot(ops)
check('心跳不堆行', steps.filter((s) => s.kind === 'lifecycle' && s.status === 'running').length === 1)
check('连续思考并成一条', steps.filter((s) => s.kind === 'think').length === 1,
`${steps.filter((s) => s.kind === 'think').length} 条`)
check('思考全文保留在详情', steps.find((s) => s.kind === 'think')?.detail?.includes('先看表结构'))
check('连线不单独成行', !steps.some((s) => s.title.includes('edge')))
check('节点用标签不用 id', steps.some((s) => s.kind === 'node' && s.title === '取数'))
// 生成完了那条"正在理解需求…"还在转圈的话,看上去像卡住了
check('生成结束后没有还在转的行', !steps.some((s) => s.status === 'running'),
steps.filter((s) => s.status === 'running').map((s) => s.title).join(','))
// 改图场景:这一轮加了 1 个节点,但整张图有 4 个。说"共 1 步"是错的
const edited = mod.decodeCopilot([
...ops,
{ op: 'final', graph: { nodes: [{ id: 'a' }, { id: 'q' }, { id: 'b' }, { id: 'c' }] } },
])
const tail = edited[edited.length - 1]
check('改图时说清"加了几步"和"整张图几步"',
tail.title.includes('加了 1 步') && tail.title.includes('共 4 步'), tail.title)
const built = mod.decodeCopilot([
{ op: 'add_node', node: { id: 'a', type: 'input' } },
{ op: 'add_node', node: { id: 'b', type: 'output' } },
{ op: 'done', explanation: '' },
{ op: 'final', graph: { nodes: [{ id: 'a' }, { id: 'b' }] } },
])
check('从零新建时不啰嗦,只说共几步',
built[built.length - 1].title === '流程搭好了,共 2 步',
built[built.length - 1].title)
}
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过')
+1 -1
View File
@@ -12,7 +12,7 @@ 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'
const CASES = ['db', 'think', 'human', 'issue', 'failed']
const CASES = ['db', 'think', 'human', 'issue', 'failed', 'loop_approve']
let failed = 0
const check = (name, cond, detail = '') => {
+142
View File
@@ -0,0 +1,142 @@
// 三个页面的端到端检查:真浏览器、真后端、真数据。
//
// 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)