From c1c13fad410079f487ff2e2c1a425db9a548a89a Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Thu, 17 Sep 2026 17:00:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A9=E6=89=8B=E6=B5=81=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=EF=BC=9A=E4=B8=80=E4=BB=BD=20Step=EF=BC=8C=E5=AE=BD=E5=B1=8F?= =?UTF-8?q?=E5=92=8C=20360px=20=E7=AA=84=E6=A0=8F=E5=85=B1=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 产物)。 --- frontend/preview.html | 12 + frontend/src/dev/preview.tsx | 115 +++++++ frontend/src/run/AssistantStream.tsx | 461 +++++++++++++++++++++++++++ frontend/src/run/decode.ts | 149 +++++++-- scripts/check-decode.mjs | 39 +++ scripts/check-stream.mjs | 80 +++++ 6 files changed, 833 insertions(+), 23 deletions(-) create mode 100644 frontend/preview.html create mode 100644 frontend/src/dev/preview.tsx create mode 100644 frontend/src/run/AssistantStream.tsx create mode 100644 scripts/check-stream.mjs diff --git a/frontend/preview.html b/frontend/preview.html new file mode 100644 index 00000000..3d2cccab --- /dev/null +++ b/frontend/preview.html @@ -0,0 +1,12 @@ + + + + + + AssistantStream 预览 + + +
+ + + diff --git a/frontend/src/dev/preview.tsx b/frontend/src/dev/preview.tsx new file mode 100644 index 00000000..154baedf --- /dev/null +++ b/frontend/src/dev/preview.tsx @@ -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 = ( + } + title="问点什么" + hint="它会自己接数据源、写查询、跑完给结论——你不用碰画布" + /> + ) + + return ( +
+
+ + AssistantStream 预览 + 真实事件 · 离线渲染 + + + + +
+ +
+ {/* 宽屏:问数据页的形态 */} +
+ +
+ {/* 窄栏:画布右侧助手栏的形态。固定 360px,和真实右栏一致 */} +
+ +
+
+
+ ) +} + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/run/AssistantStream.tsx b/frontend/src/run/AssistantStream.tsx new file mode 100644 index 00000000..5eece94e --- /dev/null +++ b/frontend/src/run/AssistantStream.tsx @@ -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 | null + error?: string + runId?: string + /** 这一轮建出来的图,可展开看、可放到画布 */ + graph?: any + graphNote?: string +} + +const ICONS: Record = { + 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(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
{empty}{footer}
+ } + + return ( +
+
+
+ {turns.map((turn) => ( + + ))} +
+
+
+ {footer} +
+ ) +} + +function TurnCard({ turn, dense, approvals, onOpenGraph }: { + turn: StreamTurn; dense: boolean; approvals?: ReactNode + onOpenGraph?: (graph: any) => void +}) { + const running = turn.phase === 'running' + + return ( +
+ {turn.question && ( + // 宽屏走聊天气泡(右对齐、实心),窄栏里气泡会挤成一条竖带, + // 改用左侧强调条——同样能一眼看出"这句是你说的",但不吃宽度 +
+
+ {turn.question} +
+
+ )} + +
+
+ {running && } + {turn.phase === 'waiting' && } + {turn.phase === 'error' && } + + {turn.phase === 'error' ? turn.error : turn.status} + + {turn.runId && ( + #{turn.runId.slice(0, 6)} + )} +
+ + {/* 还在流的思考。这是等待期间唯一能看的东西——但只有 Anthropic 系 + 模型会产出,所以它是增强,主干是下面的步骤 */} + {running && turn.thinking && } + + {!!turn.steps.length && ( +
+ +
+ )} + + {approvals} + + {turn.output && } + + {turn.graph && ( + + )} +
+
+ ) +} + +/** 思考的尾窗:只露最后几行并跟着滚,不然一大坨会把步骤挤到屏幕外。 */ +function ThinkingTail({ text }: { text: string }) { + const ref = useRef(null) + useEffect(() => { + if (ref.current) ref.current.scrollTop = ref.current.scrollHeight + }, [text]) + return ( +
+ {text} +
+ ) +} + +function StepList({ steps, dense, depth = 0 }: { + steps: Step[]; dense: boolean; depth?: number +}) { + return ( +
0 && 'ml-3 border-l pl-2.5')}> + {steps.map((step) => )} +
+ ) +} + +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 ( +
+ + + {open && ( +
+ {step.detail && ( +
+              {step.detail}
+            
+ )} + {table + ? + : result && ( +
+                {result.slice(0, 2000)}
+              
+ )} +
+ )} + + {!!step.children?.length && ( + + )} +
+ ) +} + +/** 查询结果画成表格。一屏 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 ( +
+ + + + {cols.map((c) => ( + + ))} + {hiddenCols > 0 && } + + + + {rows.map((row, i) => ( + + {row.slice(0, maxCols).map((cell, j) => ( + + ))} + {hiddenCols > 0 && } + + ))} + +
{c}+{hiddenCols}
+ {cell == null ? null : String(cell)} + …
+
+ {[ + 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(' · ')} +
+
+ ) +} + +function Output({ output, dense }: { output: Record; 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 ( +
+ {issuance && } + {entries.map(([key, value]) => ( +
+ {entries.length > 1 && ( +
{key}
+ )} + +
+ ))} +
+ ) +} + +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 + + const text = typeof value === 'string' ? value : JSON.stringify(value, null, 2) + // 不设上限的话,一次没走查询工具、直接把几千行塞进 output 的运行会 + // 把整条流卡住——列表本来就没做虚拟化 + const long = text.length > TEXT_CAP + return ( +
+ {long && !full ? text.slice(0, TEXT_CAP) : text} + {long && ( + + )} +
+ ) +} + +/** + * 出具档位。 + * + * 数据取自 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 ( +
+ {label} + {issuance?.metrics_checked != null && ( + + 核对 {issuance.metrics_checked} 个指标 · 回指 {issuance.matched_numbers ?? 0} 个数字 + + )} + {!!unmatched.length && ( +
+ 无法回指:{unmatched.slice(0, 6).map((u) => u.token ?? u).join('、')} +
+ )} + {!!gaps.length &&
{gaps.join(';')}
} +
+ ) +} + +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 ( +
+
+ + + {onOpen && ( + + )} +
+ {open && ( +
+ {note && ( +
+ {note} +
+ )} + {nodes.map((n: any, i: number) => ( +
+ {i + 1} + {n.type} + {n.data?.label || n.id} +
+ ))} +
+ )} +
+ ) +} + +/** 供各页面复用的空态骨架。 */ +export function StreamEmpty({ icon, title, hint, children }: { + icon: ReactNode; title: string; hint?: string; children?: ReactNode +}) { + return ( +
+
{icon}
+
{title}
+ {hint &&
{hint}
} + {children &&
{children}
} +
+ ) +} diff --git a/frontend/src/run/decode.ts b/frontend/src/run/decode.ts index 9856f347..9d4165ca 100644 --- a/frontend/src/run/decode.ts +++ b/frontend/src/run/decode.ts @@ -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): 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 } diff --git a/scripts/check-decode.mjs b/scripts/check-decode.mjs index 300ec0e0..bfabe3f8 100644 --- a/scripts/check-decode.mjs +++ b/scripts/check-decode.mjs @@ -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 操作流 ===') diff --git a/scripts/check-stream.mjs b/scripts/check-stream.mjs new file mode 100644 index 00000000..70532931 --- /dev/null +++ b/scripts/check-stream.mjs @@ -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)