助手流组件:一份 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 产物)。
This commit is contained in:
ThearchyHelios
2026-09-17 17:00:50 +08:00
parent ca6e672dfa
commit c1c13fad41
6 changed files with 833 additions and 23 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AssistantStream 预览</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/dev/preview.tsx"></script>
</body>
</html>
+115
View File
@@ -0,0 +1,115 @@
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>,
)
+461
View File
@@ -0,0 +1,461 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import {
Brain, ChevronRight, CircleCheck, CircleDot, Database,
FileCode, GitBranch, Hand, Sparkles, Table2, Terminal, Wrench, XCircle,
} from 'lucide-react'
import clsx from 'clsx'
import { Spinner } from '../components/ui'
import {
parseQueryResult, type ResultTable as Table, type Step, type StepKind,
} from './decode'
/**
* 助手流:一轮轮「你问什么 → 它做了什么 → 结果」。
*
* 纯展示,不碰 store。画布右栏(360px)和问数据页(宽屏)用的是同一个组件,
* 差别只在 dense 这个开关上——而不是做成"一个组件三种停靠模式":那要求同一个
* 组件实例出现在三个不同的 DOM 父节点里,React 里除非用 portal 否则不成立,
* 硬做就会变成跨路由 unmount/remount,输入框草稿和展开状态全丢。
*
* 步骤来自 decode.ts,这里只负责怎么画。两边读同一个解码结果,所以同一次运行
* 在画布和问数据页讲的是同一个故事。
*/
export interface StreamTurn {
id: string
/** 用户说了什么。画布上的 Copilot 指令也走这个字段 */
question?: string
phase: 'running' | 'waiting' | 'done' | 'error'
/** 一句话进度,顶在最前面 */
status?: string
steps: Step[]
/** 还在流的思考(尚未成为 Step)。只有 Anthropic 系模型会有 */
thinking?: string
output?: Record<string, any> | null
error?: string
runId?: string
/** 这一轮建出来的图,可展开看、可放到画布 */
graph?: any
graphNote?: string
}
const ICONS: Record<StepKind, typeof Wrench> = {
node: CircleDot, think: Brain, llm: Sparkles, query: Database, schema: Table2,
tool: Wrench, code: Terminal, branch: GitBranch, human: Hand,
issuance: FileCode, note: CircleDot, error: XCircle, lifecycle: CircleCheck,
}
export function AssistantStream({
turns, dense = false, empty, approvalsFor, onOpenGraph, footer,
}: {
turns: StreamTurn[]
/** 窄栏模式:更紧的排版、表格少列 */
dense?: boolean
/** 没有任何一轮时显示什么 */
empty?: ReactNode
/** 这一轮要不要插审批卡——插槽而不是内建,因为不同页面接流的方式不同 */
approvalsFor?: (turn: StreamTurn) => ReactNode
onOpenGraph?: (graph: any) => void
footer?: ReactNode
}) {
const bottomRef = useRef<HTMLDivElement>(null)
const lastPhase = turns[turns.length - 1]?.phase
const lastCount = turns[turns.length - 1]?.steps.length
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [turns.length, lastPhase, lastCount])
if (!turns.length && empty) {
return <div className="flex h-full flex-col">{empty}{footer}</div>
}
return (
<div className="flex h-full flex-col">
<div className={clsx('min-h-0 flex-1 overflow-y-auto', dense ? 'px-2.5 py-3' : 'px-4 py-4')}>
<div className={clsx('space-y-4', !dense && 'mx-auto max-w-3xl')}>
{turns.map((turn) => (
<TurnCard key={turn.id} turn={turn} dense={dense}
approvals={approvalsFor?.(turn)} onOpenGraph={onOpenGraph} />
))}
</div>
<div ref={bottomRef} />
</div>
{footer}
</div>
)
}
function TurnCard({ turn, dense, approvals, onOpenGraph }: {
turn: StreamTurn; dense: boolean; approvals?: ReactNode
onOpenGraph?: (graph: any) => void
}) {
const running = turn.phase === 'running'
return (
<div className="fade-up">
{turn.question && (
// 宽屏走聊天气泡(右对齐、实心),窄栏里气泡会挤成一条竖带,
// 改用左侧强调条——同样能一眼看出"这句是你说的",但不吃宽度
<div className={clsx('mb-2 flex', dense ? 'justify-start' : 'justify-end')}>
<div
className={clsx(
'leading-relaxed',
dense
? 'w-full border-l-2 py-0.5 pl-2 text-[11.5px]'
: 'max-w-[80%] rounded-lg rounded-br-sm px-3 py-1.5 text-[12.5px]',
)}
style={dense
? { borderColor: 'var(--accent)', color: 'var(--text)' }
: { background: 'var(--accent)', color: '#fff' }}
>
{turn.question}
</div>
</div>
)}
<div className={clsx('rounded-lg border bg-panel', dense ? 'p-2.5' : 'p-3')}>
<div className="flex items-center gap-2 text-[11.5px]">
{running && <Spinner size={12} />}
{turn.phase === 'waiting' && <Hand size={12} style={{ color: 'var(--warn)' }} />}
{turn.phase === 'error' && <XCircle size={12} style={{ color: 'var(--err)' }} />}
<span className={clsx('min-w-0 flex-1 truncate',
turn.phase === 'error' && 'text-[var(--err)]')}>
{turn.phase === 'error' ? turn.error : turn.status}
</span>
{turn.runId && (
<span className="mono shrink-0 text-[10px] text-faint">#{turn.runId.slice(0, 6)}</span>
)}
</div>
{/* 还在流的思考。这是等待期间唯一能看的东西——但只有 Anthropic 系
模型会产出,所以它是增强,主干是下面的步骤 */}
{running && turn.thinking && <ThinkingTail text={turn.thinking} />}
{!!turn.steps.length && (
<div className="mt-2">
<StepList steps={turn.steps} dense={dense} />
</div>
)}
{approvals}
{turn.output && <Output output={turn.output} dense={dense} />}
{turn.graph && (
<GraphPeek graph={turn.graph} note={turn.graphNote}
dense={dense} onOpen={onOpenGraph} />
)}
</div>
</div>
)
}
/** 思考的尾窗:只露最后几行并跟着滚,不然一大坨会把步骤挤到屏幕外。 */
function ThinkingTail({ text }: { text: string }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight
}, [text])
return (
<div ref={ref}
className="mt-2 max-h-20 overflow-y-auto whitespace-pre-wrap rounded bg-bg px-2 py-1.5 text-[10.5px] leading-relaxed text-faint">
{text}
</div>
)
}
function StepList({ steps, dense, depth = 0 }: {
steps: Step[]; dense: boolean; depth?: number
}) {
return (
<div className={clsx(depth > 0 && 'ml-3 border-l pl-2.5')}>
{steps.map((step) => <StepRow key={step.id} step={step} dense={dense} depth={depth} />)}
</div>
)
}
function StepRow({ step, dense, depth }: { step: Step; dense: boolean; depth: number }) {
const [open, setOpen] = useState(false)
const Icon = ICONS[step.kind] ?? CircleDot
const result = step.result
const expandable = !!(step.detail || result)
const table = result ? parseQueryResult(result) : null
// 失败要压过 kind:一条 lifecycle 跑挂了还画成打勾的"完成"图标,
// 是把失败说成了成功
const failed = step.status === 'failed' || step.level === 'error'
const Glyph = failed ? XCircle : Icon
const color = failed ? 'var(--err)'
: step.level === 'warn' ? 'var(--warn)'
: step.kind === 'think' ? 'var(--text-faint)'
: step.status === 'running' ? 'var(--accent)'
// 顶层节点是主干,子步骤是它的过程——靠明度分层,不然一屏全是同一个灰
: depth === 0 && (step.kind === 'node' || step.kind === 'lifecycle') ? 'var(--text)'
: 'var(--text-dim)'
return (
<div className="py-[3px]">
<button
className={clsx('flex w-full items-start gap-1.5 text-left',
expandable && 'hover:opacity-80')}
style={{ color }}
onClick={() => expandable && setOpen((v) => !v)}
disabled={!expandable}
>
<span className="mt-[2px] shrink-0">
{step.status === 'running'
? <Spinner size={10} />
: <Glyph size={11} />}
</span>
<span className={clsx('min-w-0 flex-1 leading-relaxed',
dense ? 'text-[11px]' : 'text-[11.5px]',
step.kind === 'think' && 'italic')}>
{step.title}
</span>
{step.meta && (
<span className="mono mt-[1px] shrink-0 text-[9.5px] text-faint">{step.meta}</span>
)}
{expandable && (
<ChevronRight size={10} className="mt-[2px] shrink-0 opacity-50"
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />
)}
</button>
{open && (
<div className="mb-1 mt-1 space-y-1.5 pl-[18px]">
{step.detail && (
<pre className="mono max-h-40 overflow-auto whitespace-pre-wrap rounded bg-bg px-2 py-1.5 text-[10px] leading-relaxed text-dim">
{step.detail}
</pre>
)}
{table
? <ResultTable table={table} dense={dense} />
: result && (
<pre className="mono max-h-40 overflow-auto whitespace-pre-wrap rounded bg-bg px-2 py-1.5 text-[10px] leading-relaxed text-dim">
{result.slice(0, 2000)}
</pre>
)}
</div>
)}
{!!step.children?.length && (
<StepList steps={step.children} dense={dense} depth={depth + 1} />
)}
</div>
)
}
/** 查询结果画成表格。一屏 JSON 谁也看不出名堂,表格能一眼看到形状。 */
function ResultTable({ table, dense }: { table: Table; dense: boolean }) {
const maxCols = dense ? 4 : 8
const maxRows = 12
const cols = table.columns.slice(0, maxCols)
const hiddenCols = table.columns.length - cols.length
const rows = table.rows.slice(0, maxRows)
return (
<div className="overflow-x-auto rounded border">
<table className="w-full text-[10px]">
<thead>
<tr className="border-b bg-elev">
{cols.map((c) => (
<th key={c} className="mono px-1.5 py-1 text-left font-medium text-faint">{c}</th>
))}
{hiddenCols > 0 && <th className="px-1.5 py-1 text-faint">+{hiddenCols}</th>}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} className="border-b last:border-0">
{row.slice(0, maxCols).map((cell, j) => (
<td key={j} className="mono max-w-[140px] truncate px-1.5 py-1 text-dim"
title={cell == null ? '' : String(cell)}>
{cell == null ? <span className="opacity-40">null</span> : String(cell)}
</td>
))}
{hiddenCols > 0 && <td className="px-1.5 py-1 text-faint">…</td>}
</tr>
))}
</tbody>
</table>
<div className="border-t px-1.5 py-1 text-[9.5px] leading-relaxed text-faint">
{[
rows.length < table.rows.length
? `显示前 ${rows.length} / ${table.rows.length} 行`
: `${table.rows.length} 行`,
hiddenCols > 0 ? `显示前 ${cols.length} / ${table.columns.length} 列` : null,
// 两种"不全"性质不同:一个是 guard 主动限的,一个是存预览时切的。
// 混成一句"已截断",用户没法判断该去调查询还是去看完整工件。
table.truncated ? '查询撞了行数上限,库里还有更多' : null,
table.clipped ? '这里只是预览,实际取回的行数更多' : null,
].filter(Boolean).join(' · ')}
</div>
</div>
)
}
function Output({ output, dense }: { output: Record<string, any>; dense: boolean }) {
// _issuance 这类下划线开头的是内部字段,不是给人看的成果。
// 空值也要滤掉:output 里留一个 {"result": ""} 很常见(图跑通了但出口
// 没接上),它会渲染出一条什么都没有的分隔线——用户只会以为界面坏了
const entries = Object.entries(output)
.filter(([k, v]) => !k.startsWith('_') && !isBlank(v))
const issuance = (output as any)._issuance
if (!entries.length && !issuance) return null
return (
<div className="mt-2 space-y-2 border-t pt-2">
{issuance && <IssuanceTag issuance={issuance} />}
{entries.map(([key, value]) => (
<div key={key}>
{entries.length > 1 && (
<div className="mb-0.5 text-[10.5px] font-semibold text-faint">{key}</div>
)}
<OutputValue value={value} dense={dense} />
</div>
))}
</div>
)
}
const TEXT_CAP = 3000
function isBlank(v: unknown): boolean {
if (v == null) return true
if (typeof v === 'string') return !v.trim()
if (Array.isArray(v)) return !v.length
if (typeof v === 'object') return !Object.keys(v as object).length
return false
}
/**
* 一个成果值该怎么显示。
*
* 取数流程的最终产物十有八九就是结果集本身,而它在 output 里是一个
* JSON 字符串(还常常是被再编码一层的)。直接当文本贴出来就是满屏
* `\"attribute01\", \"attribute02\"`——技术上没错,但没人能从里面看出
* "查到了什么"。能认出结果集就画成表格,认不出才退回文本。
*/
function OutputValue({ value, dense }: { value: unknown; dense: boolean }) {
const [full, setFull] = useState(false)
const table = typeof value === 'string'
? parseQueryResult(value)
: (value && typeof value === 'object'
&& Array.isArray((value as any).columns) && Array.isArray((value as any).rows)
? { columns: (value as any).columns, rows: (value as any).rows,
truncated: !!(value as any).truncated }
: null)
if (table) return <ResultTable table={table} dense={dense} />
const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2)
// 不设上限的话,一次没走查询工具、直接把几千行塞进 output 的运行会
// 把整条流卡住——列表本来就没做虚拟化
const long = text.length > TEXT_CAP
return (
<div className={clsx('whitespace-pre-wrap leading-relaxed',
dense ? 'text-[11.5px]' : 'text-[12.5px]')}>
{long && !full ? text.slice(0, TEXT_CAP) : text}
{long && (
<button className="ml-1 text-[10.5px] text-[var(--accent)] hover:underline"
onClick={() => setFull((v) => !v)}>
{full ? '收起' : `…展开全部(${text.length} 字)`}
</button>
)}
</div>
)
}
/**
* 出具档位。
*
* 数据取自 output._issuance 而不是 issuance 事件——事件里只有 tier /
* missing_* / unmatched(计数),而 output 里那份还带着 matched_numbers、
* metrics_checked、unmatched_numbers[].token。拿事件当数据源,横幅会静默
* 退化成"核对 0 个指标",比不显示更糟。
*/
function IssuanceTag({ issuance }: { issuance: any }) {
const tier = String(issuance?.tier ?? '')
const label = { formal: '正式出具', degraded: '降档出具', withheld: '不予出具' }[tier] ?? tier
if (!tier) return null
const color = tier === 'formal' ? 'var(--ok)' : tier === 'withheld' ? 'var(--err)' : 'var(--warn)'
const unmatched: any[] = issuance?.unmatched_numbers ?? []
const gaps: string[] = issuance?.gaps ?? []
return (
<div className="rounded border px-2 py-1.5 text-[10.5px] leading-relaxed"
style={{ borderColor: color, color }}>
<span className="font-semibold">{label}</span>
{issuance?.metrics_checked != null && (
<span className="ml-2 text-faint">
核对 {issuance.metrics_checked} 个指标 · 回指 {issuance.matched_numbers ?? 0} 个数字
</span>
)}
{!!unmatched.length && (
<div className="mt-1">
无法回指:{unmatched.slice(0, 6).map((u) => u.token ?? u).join('、')}
</div>
)}
{!!gaps.length && <div className="mt-1">{gaps.join(';')}</div>}
</div>
)
}
function GraphPeek({ graph, note, dense, onOpen }: {
graph: any; note?: string; dense: boolean; onOpen?: (g: any) => void
}) {
const [open, setOpen] = useState(false)
const nodes = graph?.nodes ?? []
return (
<div className="mt-2 border-t pt-2">
<div className="flex items-center gap-2">
<button className="flex items-center gap-1 text-[10.5px] text-faint hover:text-dim"
onClick={() => setOpen((v) => !v)}>
<ChevronRight size={10}
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />
它是怎么做的({nodes.length} 步)
</button>
<span className="flex-1" />
{onOpen && (
<button className="btn btn-sm btn-ghost text-[10.5px]"
title="把这张图放到画布上继续改" onClick={() => onOpen(graph)}>
在画布里打开
</button>
)}
</div>
{open && (
<div className="mt-1.5 space-y-1">
{note && (
<div className={clsx('rounded bg-bg px-2 py-1.5 leading-relaxed text-dim',
dense ? 'text-[10.5px]' : 'text-[11px]')}>
{note}
</div>
)}
{nodes.map((n: any, i: number) => (
<div key={n.id ?? i} className="flex items-center gap-2 text-[10.5px] text-faint">
<span className="mono w-4 shrink-0 text-right">{i + 1}</span>
<span className="chip shrink-0">{n.type}</span>
<span className="truncate">{n.data?.label || n.id}</span>
</div>
))}
</div>
)}
</div>
)
}
/** 供各页面复用的空态骨架。 */
export function StreamEmpty({ icon, title, hint, children }: {
icon: ReactNode; title: string; hint?: string; children?: ReactNode
}) {
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
<div className="mb-2 text-faint">{icon}</div>
<div className="text-[13px] font-medium">{title}</div>
{hint && <div className="mt-1 max-w-sm text-[11.5px] leading-relaxed text-faint">{hint}</div>}
{children && <div className="mt-3 w-full max-w-md">{children}</div>}
</div>
)
}
+126 -23
View File
@@ -49,6 +49,9 @@ export interface Step {
nodeId?: string
/** 工件 id,可下钻到完整证据 */
artifact?: string
/** 工具/查询的返回。和 detail 分开:查询的 detail 是 SQL 原文,
* 两者要同时展示(问什么 + 查到什么),合成一个字段就只能二选一 */
result?: string
children?: Step[]
}
@@ -74,34 +77,129 @@ const SILENT = new Set(['usage', 'node.skipped'])
const num = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) ? v : undefined
/**
* 耗时,没有可显示内容时给 undefined 而不是空串。
*
* 差别不是洁癖:Step.meta 是可选字段,赋成 `''` 之后"没测到耗时"和
* "测到了但不值一提"就分不开了,任何想据此判断的地方都得先猜。
*/
const dur = (ms?: number): string | undefined => formatDuration(ms) || undefined
export function formatDuration(ms?: number): string {
if (ms == null) return ''
// 低于 10ms 不显示。一屏全是"0ms"看着像每步都被精确计时了,实际上只是
// 这些步骤(输入、成果这类纯赋值节点)根本没花时间——把没有信息量的数字
// 摆出来,反而把真正慢的那一步淹没了
if (ms < 10) return ''
if (ms < 1000) return `${Math.round(ms)}ms`
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`
}
/** run 到终态时,所有还挂着"进行中"的生命周期行都要收尾。 */
function closeLifecycles(out: Step[], status: StepStatus) {
out.forEach((s) => {
if (s.kind === 'lifecycle' && s.status === 'running') s.status = status
})
}
/** 从 db_query 的结果预览里抠出行数——用户关心的是"查到多少",不是 JSON。 */
function rowCountOf(preview: string): number | undefined {
const m = preview.match(/"row_count":\s*(\d+)/)
return m ? Number(m[1]) : undefined
}
/** 把查询结果预览转成小表格能用的形状;解析不了就算了,原样展示。 */
export function parseQueryResult(preview: string):
{ columns: string[]; rows: unknown[][]; truncated: boolean } | null {
export interface ResultTable {
columns: string[]
rows: unknown[][]
/** 查询本身撞了行数上限——数据库里还有更多,是 guard 没让它全取回来 */
truncated: boolean
/** 这份预览被按字符数切断了——取回来的行数比这里显示的多,只是没存下来 */
clipped?: boolean
}
/**
* 把查询结果预览转成小表格能用的形状;解析不了就算了,原样展示。
*
* 必须容忍**被截断的 JSON**。后端的预览是按字符数硬切的(成果字段 2000、
* 工具结果 4000),切点落在 JSON 中间是常态而不是例外——严格 JSON.parse
* 对这类值一律失败,于是最典型的一次取数运行,最终成果会以满屏
* `\"attribute01\", \"attribute02\"` 的形式呈现。那不是"降级展示",那是
* 什么都没展示。所以解析失败时回退到"截到最后一条完整记录"再解析。
*/
export function parseQueryResult(preview: string): ResultTable | null {
const text = preview.trim()
if (!text) return null
// 值常常被 JSON 编码过一层(字符串里套字符串),截断后连外层引号都收不了口
const unwrapped = text.startsWith('"') ? unescapeJsonString(text) : text
if (!unwrapped.includes('"columns"')) return null
const direct = tryTable(unwrapped)
if (direct) return direct
const repaired = cutAtLastCompleteRow(unwrapped)
if (repaired) {
const table = tryTable(repaired)
if (table) return { ...table, clipped: true }
}
return null
}
function tryTable(text: string): ResultTable | null {
try {
const raw = preview.trim().startsWith('"') ? JSON.parse(preview) : preview
const data = typeof raw === 'string' ? JSON.parse(raw) : raw
const data = JSON.parse(text)
if (Array.isArray(data?.columns) && Array.isArray(data?.rows)) {
return { columns: data.columns, rows: data.rows, truncated: !!data.truncated }
}
} catch {
/* 不是标准查询结果,当普通文本 */
/* 交给调用方决定要不要修 */
}
return null
}
/** 去掉外层 JSON 字符串的引号和转义。截断的串 JSON.parse 不了,只能手工拆。 */
function unescapeJsonString(text: string): string {
const body = text.slice(1)
return body.replace(/\\(u[0-9a-fA-F]{4}|.)/g, (_, c: string) => {
if (c[0] === 'u') return String.fromCharCode(parseInt(c.slice(1), 16))
return { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f' }[c] ?? c
})
}
/**
* 把截断的 `{"columns":[...],"rows":[[...],[...],[...`
* 砍到最后一条完整记录后补上收尾括号。
*/
function cutAtLastCompleteRow(text: string): string | null {
const start = text.indexOf('"rows"')
if (start < 0) return null
const open = text.indexOf('[', start)
if (open < 0) return null
let depth = 0
let inString = false
let escaped = false
let lastRowEnd = -1
for (let i = open; i < text.length; i += 1) {
const ch = text[i]
if (escaped) { escaped = false; continue }
if (ch === '\\') { escaped = true; continue }
if (ch === '"') { inString = !inString; continue }
if (inString) continue
if (ch === '[' || ch === '{') depth += 1
else if (ch === ']' || ch === '}') {
depth -= 1
// depth 回到 1 表示一条记录刚闭合(0 是 rows 数组本身)
if (depth === 1) lastRowEnd = i + 1
else if (depth === 0) return text.slice(0, i + 1) + '}' // rows 其实是完整的
}
}
if (lastRowEnd < 0) return null
return text.slice(0, lastRowEnd) + ']}'
}
function toolStep(seq: number, tool: string, args: Record<string, any>): Step {
const base = { id: `tool-${seq}`, seq, status: 'running' as StepStatus }
// 数据库工具单独认:它是这个产品最主要的取数方式,"调用 db_query__warehouse"
@@ -162,11 +260,14 @@ export function decodeRun(events: RunEvent[]): Step[] {
break
}
if (resumed) {
// 恢复意味着之前那条"等你确认"已经过去了
out.forEach((s) => { if (s.status === 'waiting') s.status = 'done' })
nodeSteps.forEach((s) => {
s.children?.forEach((c) => { if (c.status === 'waiting') c.status = 'done' })
})
// 恢复意味着之前那条"等你确认"已经过去了。level 也要一起清:
// 留着 warn 的话,那条已经处理完的审批会永远是橙色的,看上去
// 像还有什么没解决
const settle = (s: Step) => {
if (s.status === 'waiting') { s.status = 'done'; s.level = undefined }
}
out.forEach(settle)
nodeSteps.forEach((s) => s.children?.forEach(settle))
}
out.push({
id: `s-${seq}`, seq, kind: 'lifecycle', status: 'running',
@@ -204,7 +305,7 @@ export function decodeRun(events: RunEvent[]): Step[] {
const step = nodeId ? nodeSteps.get(nodeId) : undefined
if (step) {
step.status = 'done'
step.meta = formatDuration(num(d.duration_ms))
step.meta = dur(num(d.duration_ms))
step.artifact = d.artifact || step.artifact
// 空对象/空串不是"详情",显示出来只是噪音
const raw = d.preview
@@ -224,7 +325,7 @@ export function decodeRun(events: RunEvent[]): Step[] {
step.status = 'failed'
step.level = 'error'
step.detail = String(d.error ?? '')
step.meta = formatDuration(num(d.duration_ms))
step.meta = dur(num(d.duration_ms))
} else {
push({ id: `e-${seq}`, seq, kind: 'error', level: 'error',
title: String(d.error ?? '这一步失败了'), nodeId }, nodeId)
@@ -245,7 +346,7 @@ export function decodeRun(events: RunEvent[]): Step[] {
step.status = 'done'
// 不给 token 数和美元——那是账单视角,不是"它干了什么"。
// 成本在运行详情的用量区单独看。
step.meta = formatDuration(num(d.duration_ms))
step.meta = dur(num(d.duration_ms))
push(step, nodeId)
pendingLlm.delete(nodeId ?? '_')
}
@@ -291,12 +392,12 @@ export function decodeRun(events: RunEvent[]): Step[] {
rows != null ? `${rows} 行` : '',
formatDuration(num(d.duration_ms)),
].filter(Boolean)
step.meta = parts.join(' · ')
step.meta = parts.join(' · ') || undefined
step.artifact = d.artifact
if (step.kind === 'query' || step.kind === 'schema' || failed) {
// 查询保留 SQL 作为 detail,结果另挂;失败时结果就是错误原因
step.detail = failed ? `${step.detail ?? ''}\n\n${preview}`.trim() : step.detail
;(step as any).result = preview
step.result = preview
} else {
step.detail = preview.slice(0, 4000)
}
@@ -321,7 +422,7 @@ export function decodeRun(events: RunEvent[]): Step[] {
if (step) {
step.status = d.ok ? 'done' : 'failed'
if (!d.ok) step.level = 'error'
step.meta = formatDuration(num(d.duration_ms))
step.meta = dur(num(d.duration_ms))
const body = [d.stdout, d.stderr].filter(Boolean).join('\n').slice(0, 4000)
step.detail = body || (d.ok ? '(无输出)' : `退出码 ${d.exit_code}`)
pendingTools.delete(`sandbox-${nodeId ?? seq}`)
@@ -403,7 +504,7 @@ export function decodeRun(events: RunEvent[]): Step[] {
case 'agent.step.end':
push({
id: `ae-${seq}`, seq, kind: 'note', nodeId, status: 'done',
title: `${d.agent} 回复`, meta: formatDuration(num(d.duration_ms)),
title: `${d.agent} 回复`, meta: dur(num(d.duration_ms)),
detail: String(d.preview ?? '').slice(0, 2000),
}, nodeId)
break
@@ -426,7 +527,7 @@ export function decodeRun(events: RunEvent[]): Step[] {
// 更明白,只会让人以为出了两个错
const dup = out.some((s) => s.status === 'failed' &&
(s.detail === msg || s.title === msg))
out.forEach((s) => { if (s.kind === 'lifecycle' && s.status === 'running') s.status = 'failed' })
closeLifecycles(out, 'failed')
if (!dup) {
out.push({
id: `rf-${seq}`, seq, kind: 'error', level: 'error', status: 'failed', title: msg,
@@ -436,16 +537,18 @@ export function decodeRun(events: RunEvent[]): Step[] {
}
case 'run.cancelled':
closeLifecycles(out, 'failed')
out.push({ id: `rc-${seq}`, seq, kind: 'lifecycle', status: 'failed', title: '已取消' })
break
case 'run.finished': {
// 开头那条"开始执行"要收尾,否则跑完了还挂着一个转圈的图标
const opener = out.find((s) => s.kind === 'lifecycle' && s.status === 'running')
if (opener) opener.status = 'done'
// 开头那条"开始执行"要收尾,否则跑完了还挂着一个转圈的图标。
// 注意是全部而不是第一条:审批恢复过的运行有"开始执行"+"继续执行"
// 两条,只收第一条会让"继续执行"永远转圈——明明已经完成了。
closeLifecycles(out, 'done')
out.push({
id: `rd-${seq}`, seq, kind: 'lifecycle', status: 'done',
title: '完成', meta: formatDuration(num(d.duration_ms)),
title: '完成', meta: dur(num(d.duration_ms)),
})
break
}
+39
View File
@@ -37,10 +37,36 @@ console.log('=== 数据库查询 ===')
check('查询步骤说人话,不是 db_query__warehouse(...)',
!!q && q.title.includes('查询数据') && !q.title.includes('db_query__'), q?.title)
check('SQL 原文留在详情里可展开', !!q?.detail?.includes('SELECT'))
check('查询结果和 SQL 分开存,不互相覆盖', !!q?.result && q.result !== q.detail)
check('工具调用挂在节点下,不是平铺',
steps.some((s) => s.kind === 'node' && s.children?.some((c) => c.kind === 'query')))
}
console.log('\n=== 被截断的结果集 ===')
{
// 后端按字符数硬切预览,切点落在 JSON 中间是常态。严格 JSON.parse 一律失败,
// 于是最典型的一次取数运行,成果会变成满屏 \"attribute01\"。这几条守的就是它。
const fin = fixtures.db.find((e) => e.type === 'run.finished')
const raw = Object.values(fin.data.output).find((v) => typeof v === 'string')
check('样本确实是坏 JSON(不然这组断言等于没测)', (() => {
try { JSON.parse(raw); return false } catch { return true }
})(), `${raw.length} 字`)
const t = mod.parseQueryResult(raw)
check('截断的结果集仍能解析出表格', !!t)
check('列名完整', Array.isArray(t?.columns) && t.columns.length > 10, `${t?.columns?.length} 列`)
check('至少救回一条完整记录', (t?.rows?.length ?? 0) >= 1, `${t?.rows?.length} 行`)
check('每行长度和列数对得上', t?.rows?.every((r) => r.length === t.columns.length))
check('标出这是被切断的预览,而不是查询上限', t?.clipped === true && !t?.truncated)
// 完整的结果集不该被误判成截断
const whole = JSON.stringify({ columns: ['a', 'b'], rows: [[1, 2], [3, 4]] })
const w = mod.parseQueryResult(whole)
check('完整结果集不标截断', w?.rows.length === 2 && !w.clipped)
check('普通文本不会被硬认成表格', mod.parseQueryResult('查到 3 条记录') === null)
check('空串不报错', mod.parseQueryResult('') === null)
}
console.log('\n=== 人工审批(含重放)===')
{
const steps = mod.decodeRun(fixtures.human)
@@ -57,6 +83,13 @@ console.log('\n=== 人工审批(含重放)===')
check('节点不因重放而重复', new Set(nodeIds).size === nodeIds.length,
nodeIds.join(','))
check('审批结果有交代', all.some((s) => s.title.includes('放行') || s.title.includes('驳回')))
// 恢复过的运行有"开始执行"+"继续执行"两条生命周期。只收第一条的话,
// "继续执行"会永远转圈——明明整条已经跑完了
check('恢复过的运行不会留下转圈的行',
!all.some((s) => s.status === 'running'),
all.filter((s) => s.status === 'running').map((s) => s.title).join(','))
check('处理完的审批不再是待办色',
!all.some((s) => s.status === 'done' && s.level === 'warn'))
}
console.log('\n=== 出具判定 ===')
@@ -89,6 +122,12 @@ console.log('\n=== 通用规则 ===')
!all.some((s) => /^(node|run|llm|tool)\./.test(s.title)),
all.filter((s) => /^(node|run|llm|tool)\./.test(s.title)).map(s=>s.title).join(','))
check('耗时是人类单位', all.filter((s) => s.meta).every((s) => /(\d+ms|\d+\.\d+s|\d+m\d+s|\d+ 行)/.test(s.meta)))
// 一屏"0ms"看着像每步都被精确计时,实际只是这些步骤没花时间,
// 反而把真正慢的那一步淹了
// 用词边界,不然 "130ms" 里的 "0ms" 会误判
check('不显示 0ms 这种没信息量的耗时', !all.some((s) => /\b0ms\b/.test(s.meta ?? '')),
all.filter((s) => /\b0ms\b/.test(s.meta ?? '')).map((s) => s.title).join(','))
check('没有空 meta 占位', !all.some((s) => s.meta === ''))
}
console.log('\n=== Copilot 操作流 ===')
+80
View File
@@ -0,0 +1,80 @@
// AssistantStream 的渲染回归检查。
//
// 解码器的检查(check-decode.mjs)守的是"翻译对不对",这里守的是"画出来
// 对不对"——两者能各自通过而合起来是坏的:Step 里字段都对,组件却把它渲染成
// 一屏转义 JSON,或者在 360px 窄栏里把页面撑得横向滚动。
//
// 用真浏览器跑真组件,数据还是 fixtures.json 里那批真实事件。
// 跑之前前端得起着:./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'
const CASES = ['db', 'think', 'human', 'issue', 'failed']
let failed = 0
const check = (name, cond, detail = '') => {
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
if (!cond) failed++
}
const browser = await chromium.launch({ executablePath: CHROME })
for (const kind of CASES) {
console.log(`\n=== ${kind} ===`)
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
const errors = []
page.on('pageerror', (e) => errors.push(e.message))
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()) })
await page.goto(`${WEB}/preview.html?case=${kind}`, { waitUntil: 'networkidle' })
await page.waitForTimeout(300)
const text = await page.locator('body').innerText()
// 404 是 favicon,和组件无关
const real = errors.filter((e) => !e.includes('404'))
check('没有运行时报错', real.length === 0, real.join(' | '))
check('有内容渲染出来', text.length > 40, `${text.length} 字`)
check('没有把转义 JSON 直接吐出来', !text.includes('\\"columns\\"'))
check('没有 0ms 噪音', !/\b0ms\b/.test(text))
check('没有裸事件名', !/\b(node|run|llm|tool)\.(started|finished|start|end)\b/.test(text))
// 360px 窄栏是画布右栏的真实宽度。这里溢出,右栏就会横向滚动
const overflow = await page.evaluate(() => {
const el = document.documentElement
return el.scrollWidth - el.clientWidth
})
check('页面不横向溢出', overflow <= 0, `${overflow}px`)
await page.close()
}
console.log('\n=== 展开交互 ===')
{
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
await page.goto(`${WEB}/preview.html?case=db`, { waitUntil: 'networkidle' })
await page.waitForTimeout(300)
const before = await page.locator('body').innerText()
check('收起时不显示 SQL', !before.includes('SELECT'))
await page.locator('button', { hasText: '在 bi 上查询数据' }).first().click()
await page.waitForTimeout(250)
const after = await page.locator('body').innerText()
check('展开后能看到 SQL 原文', after.includes('SELECT') && after.includes('ANALYTICS'))
check('展开后能看到结果表', after.includes('factory_code'))
await page.close()
}
console.log('\n=== 空态 ===')
{
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } })
await page.goto(`${WEB}/preview.html?case=__none__`, { waitUntil: 'networkidle' })
await page.waitForTimeout(250)
const text = await page.locator('body').innerText()
check('没有内容时给的是空态而不是白屏', text.includes('问点什么'))
await page.close()
}
await browser.close()
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 助手流渲染全部通过')
process.exit(failed ? 1 : 0)