mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 00:21:58 +00:00
助手流组件:一份 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:
@@ -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>
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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
@@ -49,6 +49,9 @@ export interface Step {
|
|||||||
nodeId?: string
|
nodeId?: string
|
||||||
/** 工件 id,可下钻到完整证据 */
|
/** 工件 id,可下钻到完整证据 */
|
||||||
artifact?: string
|
artifact?: string
|
||||||
|
/** 工具/查询的返回。和 detail 分开:查询的 detail 是 SQL 原文,
|
||||||
|
* 两者要同时展示(问什么 + 查到什么),合成一个字段就只能二选一 */
|
||||||
|
result?: string
|
||||||
children?: Step[]
|
children?: Step[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,34 +77,129 @@ const SILENT = new Set(['usage', 'node.skipped'])
|
|||||||
const num = (v: unknown): number | undefined =>
|
const num = (v: unknown): number | undefined =>
|
||||||
typeof v === 'number' && Number.isFinite(v) ? v : 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 {
|
export function formatDuration(ms?: number): string {
|
||||||
if (ms == null) return ''
|
if (ms == null) return ''
|
||||||
|
// 低于 10ms 不显示。一屏全是"0ms"看着像每步都被精确计时了,实际上只是
|
||||||
|
// 这些步骤(输入、成果这类纯赋值节点)根本没花时间——把没有信息量的数字
|
||||||
|
// 摆出来,反而把真正慢的那一步淹没了
|
||||||
|
if (ms < 10) return ''
|
||||||
if (ms < 1000) return `${Math.round(ms)}ms`
|
if (ms < 1000) return `${Math.round(ms)}ms`
|
||||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
|
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
|
||||||
return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}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。 */
|
/** 从 db_query 的结果预览里抠出行数——用户关心的是"查到多少",不是 JSON。 */
|
||||||
function rowCountOf(preview: string): number | undefined {
|
function rowCountOf(preview: string): number | undefined {
|
||||||
const m = preview.match(/"row_count":\s*(\d+)/)
|
const m = preview.match(/"row_count":\s*(\d+)/)
|
||||||
return m ? Number(m[1]) : undefined
|
return m ? Number(m[1]) : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把查询结果预览转成小表格能用的形状;解析不了就算了,原样展示。 */
|
export interface ResultTable {
|
||||||
export function parseQueryResult(preview: string):
|
columns: string[]
|
||||||
{ columns: string[]; rows: unknown[][]; truncated: boolean } | null {
|
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 {
|
try {
|
||||||
const raw = preview.trim().startsWith('"') ? JSON.parse(preview) : preview
|
const data = JSON.parse(text)
|
||||||
const data = typeof raw === 'string' ? JSON.parse(raw) : raw
|
|
||||||
if (Array.isArray(data?.columns) && Array.isArray(data?.rows)) {
|
if (Array.isArray(data?.columns) && Array.isArray(data?.rows)) {
|
||||||
return { columns: data.columns, rows: data.rows, truncated: !!data.truncated }
|
return { columns: data.columns, rows: data.rows, truncated: !!data.truncated }
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* 不是标准查询结果,当普通文本 */
|
/* 交给调用方决定要不要修 */
|
||||||
}
|
}
|
||||||
return null
|
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 {
|
function toolStep(seq: number, tool: string, args: Record<string, any>): Step {
|
||||||
const base = { id: `tool-${seq}`, seq, status: 'running' as StepStatus }
|
const base = { id: `tool-${seq}`, seq, status: 'running' as StepStatus }
|
||||||
// 数据库工具单独认:它是这个产品最主要的取数方式,"调用 db_query__warehouse"
|
// 数据库工具单独认:它是这个产品最主要的取数方式,"调用 db_query__warehouse"
|
||||||
@@ -162,11 +260,14 @@ export function decodeRun(events: RunEvent[]): Step[] {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if (resumed) {
|
if (resumed) {
|
||||||
// 恢复意味着之前那条"等你确认"已经过去了
|
// 恢复意味着之前那条"等你确认"已经过去了。level 也要一起清:
|
||||||
out.forEach((s) => { if (s.status === 'waiting') s.status = 'done' })
|
// 留着 warn 的话,那条已经处理完的审批会永远是橙色的,看上去
|
||||||
nodeSteps.forEach((s) => {
|
// 像还有什么没解决
|
||||||
s.children?.forEach((c) => { if (c.status === 'waiting') c.status = 'done' })
|
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({
|
out.push({
|
||||||
id: `s-${seq}`, seq, kind: 'lifecycle', status: 'running',
|
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
|
const step = nodeId ? nodeSteps.get(nodeId) : undefined
|
||||||
if (step) {
|
if (step) {
|
||||||
step.status = 'done'
|
step.status = 'done'
|
||||||
step.meta = formatDuration(num(d.duration_ms))
|
step.meta = dur(num(d.duration_ms))
|
||||||
step.artifact = d.artifact || step.artifact
|
step.artifact = d.artifact || step.artifact
|
||||||
// 空对象/空串不是"详情",显示出来只是噪音
|
// 空对象/空串不是"详情",显示出来只是噪音
|
||||||
const raw = d.preview
|
const raw = d.preview
|
||||||
@@ -224,7 +325,7 @@ export function decodeRun(events: RunEvent[]): Step[] {
|
|||||||
step.status = 'failed'
|
step.status = 'failed'
|
||||||
step.level = 'error'
|
step.level = 'error'
|
||||||
step.detail = String(d.error ?? '')
|
step.detail = String(d.error ?? '')
|
||||||
step.meta = formatDuration(num(d.duration_ms))
|
step.meta = dur(num(d.duration_ms))
|
||||||
} else {
|
} else {
|
||||||
push({ id: `e-${seq}`, seq, kind: 'error', level: 'error',
|
push({ id: `e-${seq}`, seq, kind: 'error', level: 'error',
|
||||||
title: String(d.error ?? '这一步失败了'), nodeId }, nodeId)
|
title: String(d.error ?? '这一步失败了'), nodeId }, nodeId)
|
||||||
@@ -245,7 +346,7 @@ export function decodeRun(events: RunEvent[]): Step[] {
|
|||||||
step.status = 'done'
|
step.status = 'done'
|
||||||
// 不给 token 数和美元——那是账单视角,不是"它干了什么"。
|
// 不给 token 数和美元——那是账单视角,不是"它干了什么"。
|
||||||
// 成本在运行详情的用量区单独看。
|
// 成本在运行详情的用量区单独看。
|
||||||
step.meta = formatDuration(num(d.duration_ms))
|
step.meta = dur(num(d.duration_ms))
|
||||||
push(step, nodeId)
|
push(step, nodeId)
|
||||||
pendingLlm.delete(nodeId ?? '_')
|
pendingLlm.delete(nodeId ?? '_')
|
||||||
}
|
}
|
||||||
@@ -291,12 +392,12 @@ export function decodeRun(events: RunEvent[]): Step[] {
|
|||||||
rows != null ? `${rows} 行` : '',
|
rows != null ? `${rows} 行` : '',
|
||||||
formatDuration(num(d.duration_ms)),
|
formatDuration(num(d.duration_ms)),
|
||||||
].filter(Boolean)
|
].filter(Boolean)
|
||||||
step.meta = parts.join(' · ')
|
step.meta = parts.join(' · ') || undefined
|
||||||
step.artifact = d.artifact
|
step.artifact = d.artifact
|
||||||
if (step.kind === 'query' || step.kind === 'schema' || failed) {
|
if (step.kind === 'query' || step.kind === 'schema' || failed) {
|
||||||
// 查询保留 SQL 作为 detail,结果另挂;失败时结果就是错误原因
|
// 查询保留 SQL 作为 detail,结果另挂;失败时结果就是错误原因
|
||||||
step.detail = failed ? `${step.detail ?? ''}\n\n${preview}`.trim() : step.detail
|
step.detail = failed ? `${step.detail ?? ''}\n\n${preview}`.trim() : step.detail
|
||||||
;(step as any).result = preview
|
step.result = preview
|
||||||
} else {
|
} else {
|
||||||
step.detail = preview.slice(0, 4000)
|
step.detail = preview.slice(0, 4000)
|
||||||
}
|
}
|
||||||
@@ -321,7 +422,7 @@ export function decodeRun(events: RunEvent[]): Step[] {
|
|||||||
if (step) {
|
if (step) {
|
||||||
step.status = d.ok ? 'done' : 'failed'
|
step.status = d.ok ? 'done' : 'failed'
|
||||||
if (!d.ok) step.level = 'error'
|
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)
|
const body = [d.stdout, d.stderr].filter(Boolean).join('\n').slice(0, 4000)
|
||||||
step.detail = body || (d.ok ? '(无输出)' : `退出码 ${d.exit_code}`)
|
step.detail = body || (d.ok ? '(无输出)' : `退出码 ${d.exit_code}`)
|
||||||
pendingTools.delete(`sandbox-${nodeId ?? seq}`)
|
pendingTools.delete(`sandbox-${nodeId ?? seq}`)
|
||||||
@@ -403,7 +504,7 @@ export function decodeRun(events: RunEvent[]): Step[] {
|
|||||||
case 'agent.step.end':
|
case 'agent.step.end':
|
||||||
push({
|
push({
|
||||||
id: `ae-${seq}`, seq, kind: 'note', nodeId, status: 'done',
|
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),
|
detail: String(d.preview ?? '').slice(0, 2000),
|
||||||
}, nodeId)
|
}, nodeId)
|
||||||
break
|
break
|
||||||
@@ -426,7 +527,7 @@ export function decodeRun(events: RunEvent[]): Step[] {
|
|||||||
// 更明白,只会让人以为出了两个错
|
// 更明白,只会让人以为出了两个错
|
||||||
const dup = out.some((s) => s.status === 'failed' &&
|
const dup = out.some((s) => s.status === 'failed' &&
|
||||||
(s.detail === msg || s.title === msg))
|
(s.detail === msg || s.title === msg))
|
||||||
out.forEach((s) => { if (s.kind === 'lifecycle' && s.status === 'running') s.status = 'failed' })
|
closeLifecycles(out, 'failed')
|
||||||
if (!dup) {
|
if (!dup) {
|
||||||
out.push({
|
out.push({
|
||||||
id: `rf-${seq}`, seq, kind: 'error', level: 'error', status: 'failed', title: msg,
|
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':
|
case 'run.cancelled':
|
||||||
|
closeLifecycles(out, 'failed')
|
||||||
out.push({ id: `rc-${seq}`, seq, kind: 'lifecycle', status: 'failed', title: '已取消' })
|
out.push({ id: `rc-${seq}`, seq, kind: 'lifecycle', status: 'failed', title: '已取消' })
|
||||||
break
|
break
|
||||||
|
|
||||||
case 'run.finished': {
|
case 'run.finished': {
|
||||||
// 开头那条"开始执行"要收尾,否则跑完了还挂着一个转圈的图标
|
// 开头那条"开始执行"要收尾,否则跑完了还挂着一个转圈的图标。
|
||||||
const opener = out.find((s) => s.kind === 'lifecycle' && s.status === 'running')
|
// 注意是全部而不是第一条:审批恢复过的运行有"开始执行"+"继续执行"
|
||||||
if (opener) opener.status = 'done'
|
// 两条,只收第一条会让"继续执行"永远转圈——明明已经完成了。
|
||||||
|
closeLifecycles(out, 'done')
|
||||||
out.push({
|
out.push({
|
||||||
id: `rd-${seq}`, seq, kind: 'lifecycle', status: 'done',
|
id: `rd-${seq}`, seq, kind: 'lifecycle', status: 'done',
|
||||||
title: '完成', meta: formatDuration(num(d.duration_ms)),
|
title: '完成', meta: dur(num(d.duration_ms)),
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,10 +37,36 @@ console.log('=== 数据库查询 ===')
|
|||||||
check('查询步骤说人话,不是 db_query__warehouse(...)',
|
check('查询步骤说人话,不是 db_query__warehouse(...)',
|
||||||
!!q && q.title.includes('查询数据') && !q.title.includes('db_query__'), q?.title)
|
!!q && q.title.includes('查询数据') && !q.title.includes('db_query__'), q?.title)
|
||||||
check('SQL 原文留在详情里可展开', !!q?.detail?.includes('SELECT'))
|
check('SQL 原文留在详情里可展开', !!q?.detail?.includes('SELECT'))
|
||||||
|
check('查询结果和 SQL 分开存,不互相覆盖', !!q?.result && q.result !== q.detail)
|
||||||
check('工具调用挂在节点下,不是平铺',
|
check('工具调用挂在节点下,不是平铺',
|
||||||
steps.some((s) => s.kind === 'node' && s.children?.some((c) => c.kind === 'query')))
|
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=== 人工审批(含重放)===')
|
console.log('\n=== 人工审批(含重放)===')
|
||||||
{
|
{
|
||||||
const steps = mod.decodeRun(fixtures.human)
|
const steps = mod.decodeRun(fixtures.human)
|
||||||
@@ -57,6 +83,13 @@ console.log('\n=== 人工审批(含重放)===')
|
|||||||
check('节点不因重放而重复', new Set(nodeIds).size === nodeIds.length,
|
check('节点不因重放而重复', new Set(nodeIds).size === nodeIds.length,
|
||||||
nodeIds.join(','))
|
nodeIds.join(','))
|
||||||
check('审批结果有交代', all.some((s) => s.title.includes('放行') || s.title.includes('驳回')))
|
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=== 出具判定 ===')
|
console.log('\n=== 出具判定 ===')
|
||||||
@@ -89,6 +122,12 @@ console.log('\n=== 通用规则 ===')
|
|||||||
!all.some((s) => /^(node|run|llm|tool)\./.test(s.title)),
|
!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(','))
|
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)))
|
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 操作流 ===')
|
console.log('\n=== Copilot 操作流 ===')
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user