Files
AgentLab/frontend/src/dev/preview.tsx
T
ThearchyHelios c1c13fad41 助手流组件:一份 Step,宽屏和 360px 窄栏共用
AssistantStream 是纯展示的,不连 store。这样画布右栏和问数据页能用同一个
组件,而不是"一个组件三种停靠模式"——后者要求同一个实例出现在三个 DOM
父节点里,React 里除非 portal 否则不成立,硬做就变成跨路由 remount,
输入草稿和展开状态全丢。差别只在 dense 一个开关。

渲染真实事件时发现的几个问题,顺手一起修了:

- 取数运行的最终成果是一个**被截断的双重转义 JSON**(后端按字符数硬切,
  切点落在 JSON 中间是常态),严格 JSON.parse 必然失败,于是这个产品最
  典型的一次运行,成果区呈现为满屏 \"attribute01\", \"attribute02\"。
  parseQueryResult 改为解析失败时截到最后一条完整记录再解析——78 列 1 行
  的表格,比一屏转义文本有用得多。并区分"查询撞了行数上限"和"预览被切断":
  前者该去调查询,后者该去看完整工件,混成一句"已截断"等于没说。

- 审批恢复过的运行有"开始执行"+"继续执行"两条生命周期,run.finished 只
  收了第一条(find 而非 forEach),"继续执行"永远转圈——明明已经跑完。

- 处理完的审批保留着 warn 级别,一直是橙色,看着像还有事没办。

- 一屏 0ms:那些纯赋值节点确实不花时间,把没信息量的数字摆出来反而把真正
  慢的那一步淹了。低于 10ms 不显示,同时空耗时给 undefined 而不是空串。

- output 里 {"result": ""} 很常见(图跑通但出口没接上),会渲染出一条什么
  都没有的分隔线。

验证:新增 scripts/check-stream.mjs,真浏览器跑真组件——5 类真实运行 ×
(无报错/无转义 JSON/无 0ms/不横向溢出)+ 展开看 SQL 和结果表 + 空态。
check-decode.mjs 补 11 条,两个脚本共 68 条断言全过。frontend/preview.html
是离线预览页,只有 dev server 加载(已确认不进 vite build 产物)。
2026-09-17 17:00:50 +08:00

116 lines
4.7 KiB
TypeScript
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.
import { useState } from 'react'
import { createRoot } from 'react-dom/client'
import { Database, MessageSquare } from 'lucide-react'
import { AssistantStream, StreamEmpty, type StreamTurn } from '../run/AssistantStream'
import { decodeRun } from '../run/decode'
import { ToastHost } from '../components/ui'
import fixtures from '../run/__tests__/fixtures.json'
import '../index.css'
/**
* AssistantStream 的离线预览。
*
* 只有 dev server 会加载(vite build 的 input 只有 index.html),所以它不进
* 生产包。存在的理由:要改这个组件的排版,不该每次都真跑一次图——真跑一次
* 意味着要连数据库、等模型、还得凑巧撞上人工介入分支才能看到审批卡长什么样。
* 这里用的是真实导出的事件,走的是同一个 decodeRun,只有事件来源是静态的。
*
* 打开 http://localhost:5273/preview.html
*/
type Bucket = keyof typeof fixtures
const CASES: { key: Bucket; question: string; phase: StreamTurn['phase']; status: string }[] = [
{ key: 'db', question: '这个库里有多少数据?挑最大的表说说', phase: 'done', status: '完成' },
{ key: 'think', question: '分析上季度华东区销量下滑的原因', phase: 'running', status: '正在执行…' },
{ key: 'human', question: '把这批客户的信用额度调高 10%', phase: 'waiting', status: '等待你的确认' },
{ key: 'issue', question: '出一份本月经营分析', phase: 'done', status: '完成' },
{ key: 'failed', question: '查一下不存在的那张表', phase: 'error', status: '' },
]
function buildTurns(): StreamTurn[] {
return CASES.map((c) => {
const events = (fixtures as any)[c.key] as any[]
const steps = decodeRun(events)
const finished = events.find((e) => e.type === 'run.finished')
const failed = events.find((e) => e.type === 'run.failed')
return {
id: c.key,
question: c.question,
phase: c.phase,
status: c.status,
steps,
thinking: c.phase === 'running'
? '先看一下 ANALYTICS 里有哪些和销量相关的视图,再按区域和月份聚合……'
: undefined,
output: finished?.data?.output ?? null,
error: failed ? String(failed.data?.error ?? '运行失败') : undefined,
runId: events[0]?.run_id,
}
})
}
function Preview() {
// ?case=db 只看一个用例,?theme=light 直接出浅色——都是为了截图可复现,
// 不用靠点按钮
const params = new URLSearchParams(location.search)
const only = params.get('case')
if (params.get('theme') === 'light') document.documentElement.dataset.theme = 'light'
const [all] = useState(buildTurns)
const turns = only ? all.filter((t) => t.id === only) : all
const [dense, setDense] = useState(false)
const [emptyState, setEmptyState] = useState(false)
const empty = (
<StreamEmpty
icon={<MessageSquare size={22} />}
title="问点什么"
hint="它会自己接数据源、写查询、跑完给结论——你不用碰画布"
/>
)
return (
<div className="flex h-full flex-col">
<header className="flex items-center gap-3 border-b px-4 py-2 text-[12px]">
<Database size={14} style={{ color: 'var(--accent)' }} />
<span className="font-semibold">AssistantStream 预览</span>
<span className="text-faint">真实事件 · 离线渲染</span>
<span className="flex-1" />
<label className="flex items-center gap-1.5 text-[11.5px] text-dim">
<input type="checkbox" checked={dense} onChange={(e) => setDense(e.target.checked)} />
dense(360px 窄栏)
</label>
<label className="flex items-center gap-1.5 text-[11.5px] text-dim">
<input type="checkbox" checked={emptyState}
onChange={(e) => setEmptyState(e.target.checked)} />
空态
</label>
<button className="btn btn-sm btn-ghost"
onClick={() => {
const el = document.documentElement
el.dataset.theme = el.dataset.theme === 'light' ? '' : 'light'
}}>
切主题
</button>
</header>
<div className="flex min-h-0 flex-1">
{/* 宽屏:问数据页的形态 */}
<div className="min-w-0 flex-1 border-r">
<AssistantStream turns={emptyState ? [] : turns} empty={empty} />
</div>
{/* 窄栏:画布右侧助手栏的形态。固定 360px,和真实右栏一致 */}
<div className="w-[360px] shrink-0 bg-panel">
<AssistantStream turns={emptyState ? [] : turns} dense empty={empty} />
</div>
</div>
</div>
)
}
createRoot(document.getElementById('root')!).render(
<ToastHost>
<Preview />
</ToastHost>,
)