mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:11:59 +00:00
c1c13fad41
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 产物)。
116 lines
4.7 KiB
TypeScript
116 lines
4.7 KiB
TypeScript
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>,
|
||
)
|