import { createContext, useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode, } from 'react' import { Link } from 'react-router-dom' import { AlertCircle, AlertTriangle, ArrowDown, Brain, ChevronRight, CircleCheck, CircleDot, CornerDownRight, Database, Download, ExternalLink, FileCode, FileDown, GitBranch, Hand, Info, ListChecks, Play, Settings2, Scale, ShieldCheck, Sparkles, Table2, Terminal, UserX, Users, Wrench, XCircle, } from 'lucide-react' import clsx from 'clsx' import { api } from '../api/client' import { ErrorNotice, Modal, Skeleton, Spinner, StatusBadge, toast } from '../components/ui' import { humanizeError } from '../lib/errors' import { explainRunError } from '../lib/explain' import { formatClock, formatDuration, formatNumber, NONE, shortId } from '../lib/format' import { statusLabel } from '../lib/status' import { COPILOT_CONTEXT_TEXT, DUAL_CHECK_TEXT, EVIDENCE_TEXT, JUDGE_TEXT, MERGE_TEXT, RUN_SQL_CHECK_TEXT, evidenceTally, issuanceLabel, nodeTypeLabel, } from '../lib/terms' import { SqlCheckList } from './SqlChecks' import { ISSUANCE_HINT, childrenByExec, compactSteps, parseQueryResult, progressOf, splitIssuanceGaps, spread, teamVerdictOf, type Exec, type FixKind, type ResultTable as Table, type Step, type StepKind, type TeamMemberEx, type TeamRun, } from './decode' import { CODE_COLUMN, CopyChip, LEADING_ZERO, Markdown, type MarkSpec } from './Markdown' import { ClaimTally, EvidenceField, type EvidenceDocHandle } from './EvidenceDoc' import { EVIDENCE_STATE, claimProblems, docTally, evidenceFields, issuanceMarks, type EvidenceTally } from '../lib/evidence' import type { EvidenceDocData, IssuanceSqlCheck } from '../types' import { useRunClock } from './useRunClock' import { CatalogPatchCard } from './CatalogPatchCard' import type { CatalogPatch } from './catalogPatch' import type { ReviewResult } from '../types' /** * 助手流:一轮轮「你问什么 → 它做了什么 → 结果」。 * * 纯展示,不碰 store。画布右栏(360px)、问数据页(宽屏)、运行页用的是同一个 * 组件,差别只在 dense 这个开关和几个可选回调上——而不是做成"一个组件三种停靠 * 模式":那要求同一个组件实例出现在三个不同的 DOM 父节点里,React 里除非用 * portal 否则不成立,硬做就会变成跨路由 unmount/remount,草稿和展开状态全丢。 * * 步骤来自 decode.ts,这里只负责怎么画。三处读同一个解码结果,所以同一次运行 * 在哪儿看讲的都是同一个故事。 */ /** * 页面已经拆好的报错:问数据页的 Failure、记录页的 explainRunError。照原样画,不再 * 交给 humanizeError 翻一遍——已经是人话的「操作超时:查询超时…」再翻一遍,就成了 * 「操作超时 / 操作超时:查询超时…」 */ export interface TurnFailure { title: string reason?: string /** 怎么办 */ hint?: string /** 原文,收进技术细节 */ detail?: string /** 该去哪儿改(lib/explain 的 fix):给了就在报错里放直达入口 */ fix?: FixKind | 'rerun' /** fix 的站内地址(lib/explain 的 fixTo):直接打开要改的那一项,比笼统的「去工具库」准 */ fixTo?: string /** 先改再接着跑(lib/explain 的 fixFirst):入口写明要改什么 */ fixFirst?: boolean } export interface StreamTurn { id: string /** 用户说了什么。画布上的 Copilot 指令也走这个字段 */ question?: string phase: 'running' | 'waiting' | 'done' | 'error' /** 一句话进度,顶在最前面 */ status?: string steps: Step[] /** 还在流的思考(尚未成为 Step)。只有 Anthropic 系模型会有 */ thinking?: string output?: Record | null /** * 报错,三种给法: * - 一句原话:运行的失败按 lib/explain 讲清为什么、怎么办,别的按 humanizeError; * - {error, hint?, detail?}:原话 + 怎么办 + 原文(Copilot 的 error 操作); * - TurnFailure:页面已经拆好的,照原样画。 */ error?: string | { error: string; hint?: string; detail?: string } | TurnFailure /** * 这一轮查了几次库。从库里恢复的轮次没有事件,步骤里数不出来,页面手上有落库的 * 次数就传进来;步骤里数得出来时以步骤为准 */ queries?: number runId?: string /** * 成果出自哪次运行:成果里的报告点开片段时按它取证据链。不给就用 runId。运行页、画布 * 右栏的头上已经有运行号、不给 runId(给了会在轮次头再挂一个运行链接),就给它 */ outputRun?: string /** formal / exploratory。出具横幅要据此标注"不进正式归档" */ runClass?: string /** 这一轮建出来的图,可展开看、可放到画布 */ graph?: any graphNote?: string /** 这一条没有查库,答案是根据前几轮说的 */ noQuery?: boolean /** 复核结论:这次运行有什么不对,以及对这个答案意味着什么 */ review?: ReviewResult | null /** * 助手这一轮提出的数据目录修改(catalog_patch)。每条一张「建议更新数据目录」卡片,人点保存才写入; * 从库里恢复的轮次没有操作流,也就没有卡片 */ catalogPatches?: CatalogPatch[] /** 被复核重写之前的答案。改写是有损的,得能对照原件 */ rawOutput?: Record | null /** * 这一轮开始的时刻(毫秒时间戳)。有它,进行中的轮次头部才走实时计时;不给就从第一步 * 开始的时刻推。null 表示明确不要实时计时:问数据页刚从库里恢复、正在对账的轮次, * 事件可能先到一两条,那不是它开始跑了 */ startedAt?: number | null /** 跑完的轮次花了多久(毫秒)。不给就从「完成」那一行推 */ elapsedMs?: number /** * 头部要不要提醒。phase 只分得出跑没跑完;「图放上去了,但还有 2 处问题」 * 也是 done,头部却不该是一个安静的绿勾 */ tone?: 'warn' | 'failed' /** 头部徽标用哪个状态码(lib/status)。不给就按 phase:已取消、已中断这些 phase 表达不了 */ statusCode?: 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, } /** 步骤行共用的上下文:联动回调、时钟差、工件抽屉。一层层往下传 props 会穿过五六个组件 */ interface StreamCtx { dense: boolean onHover?: (nodeId: string | null) => void onFocus?: (nodeId: string) => void /** 打开这个节点的设置去改(编排页)。下一步是「去画布改」的行据此给直达入口;给了 field 就落到那一栏(args.sql) */ onOpen?: (nodeId: string, field?: string) => void activeNodeId?: string | null skewMs: number openArtifact: (id: string, title?: string) => void reduced: boolean /** 还没办完的点名请求:要看的那一行收在「展开前面的 N 条」里时,列表先展开 */ reveal?: { nodeId: string; seq: number } | null } const Ctx = createContext({ dense: false, skewMs: 0, openArtifact: () => undefined, reduced: false, }) /** 系统的「减少动效」。扫光和转圈在那种模式下停住,得换一种说法表示"在跑" */ function useReducedMotion(): boolean { const query = '(prefers-reduced-motion: reduce)' const [reduced, setReduced] = useState(() => typeof matchMedia === 'function' && matchMedia(query).matches) useEffect(() => { if (typeof matchMedia !== 'function') return const mq = matchMedia(query) const on = () => setReduced(mq.matches) mq.addEventListener('change', on) return () => mq.removeEventListener('change', on) }, []) return reduced } /** 一轮里的步骤数(含子步骤),跟随和「有几条新进展」都按它算 */ function countSteps(steps: Step[]): number { let n = 0 for (const s of steps) n += 1 + (s.children ? countSteps(s.children) : 0) return n } /** 离底部多近算"在底部"。比一行步骤略高,手指一抖不至于就断开跟随 */ const STICK_PX = 64 export function AssistantStream({ turns, dense = false, empty, approvalsFor, onOpenGraph, footer, renderTurnActions, onStepHover, onStepFocus, onStepOpen, activeNodeId, follow = true, landing, onFollowUp, clockSkewMs = 0, className, resetKey, reveal, onRevealed, }: { turns: StreamTurn[] /** 窄栏模式:更紧的排版、表格少列 */ dense?: boolean /** 没有任何一轮时显示什么 */ empty?: ReactNode /** 这一轮要不要插审批卡——插槽而不是内建,因为不同页面接流的方式不同 */ approvalsFor?: (turn: StreamTurn) => ReactNode onOpenGraph?: (graph: any, question?: string) => void footer?: ReactNode /** 每一轮结束之后的动作槽:重试、接着跑、让 Copilot 再修……由页面决定放什么 */ renderTurnActions?: (turn: StreamTurn, index: number) => ReactNode /** 编排页:步骤行悬停 / 点击时联动画布。问数据页没有画布,不传 */ onStepHover?: (nodeId: string | null) => void onStepFocus?: (nodeId: string) => void /** * 编排页:打开某个节点的设置。节点没绑工具、团队轮数不够这类要去画布上改的, * 报错和提醒行据此给「打开设置」;不传就只说该去哪儿改 */ onStepOpen?: (nodeId: string, field?: string) => void /** 画布上悬停的节点:对应的步骤行高亮 */ activeNodeId?: string | null /** 贴着底部时,新步骤到来自动滚到底。离开底部就不再拽人 */ follow?: boolean /** * 打开时停在哪。end:最新处(对话);summary:摘要——有失败停在第一处失败、 * 等人停在审批卡、否则停在顶部(回看历史运行)。不传时只有一轮且不在跑就按 summary */ landing?: 'end' | 'summary' /** 答案下面的追问标签点了之后做什么;不传就不显示追问 */ onFollowUp?: (text: string) => void /** 服务器与本机的时钟差(毫秒)。实时计时从事件 ts 起算,差了这个数会跑快或跑慢 */ clockSkewMs?: number className?: string /** * 换了它才算"换了一批轮次"、重新定位。不传就认第一轮的 id。在前面补出更早的轮次 * (助手栏展开「之前的 N 轮」)时第一轮变了,但人还在看同一段对话,得传一个不随之变的 */ resetKey?: string /** * 请这条流把某个节点最后一次执行摆到眼前、描一下边(画布上点了跑过的节点)。seq 变了才办; * 办完调 onRevealed,页面据此把请求清掉,免得重新挂载时又办一遍 */ reveal?: { nodeId: string; seq: number } | null onRevealed?: (seq: number) => void }) { const scrollRef = useRef(null) const pinned = useRef(true) /** 上一次跟随时看到的最后一轮:哪一轮、多少步、其余会变的东西 */ const seen = useRef({ id: '', n: 0, sig: '' }) const [fresh, setFresh] = useState(0) const [artifact, setArtifact] = useState<{ id: string; title?: string } | null>(null) const reduced = useReducedMotion() const last = turns[turns.length - 1] // 跟随和「N 条新进展」只看最后一轮、而且只数它还在进行时长出来的步骤。已经停下的轮次点开 // 「看执行过程」时步骤是按需取回的历史:以前也算进总数,没有任何运行在跑,底下却浮出 // 「24 条新进展」;贴着底时还被一把拽到底部,刚点开的执行过程被推出视口 const lastCount = useMemo(() => (last ? countSteps(last.steps) : 0), [last]) const lastLive = last?.phase === 'running' || last?.phase === 'waiting' // 除步骤数以外,变了就值得跟随的东西:新的一轮、相位、成果或报错出现 const signature = `${turns.length}|${last?.id}|${last?.phase}|${!!last?.output}|${!!last?.error}` const mode = landing ?? (turns.length === 1 && last?.phase !== 'running' ? 'summary' : 'end') // 换了一批轮次(切换会话、打开另一条运行)就当作第一次打开,重新定位 const identity = resetKey ?? turns[0]?.id ?? '' const onScroll = useCallback(() => { const el = scrollRef.current if (!el) return const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_PX if (atBottom === pinned.current) return pinned.current = atBottom // 离开底部时不用记基数:新进展是离开之后一条条累加的,贴底时它一直是 0 if (atBottom) setFresh(0) }, []) // 首次打开:直接定位,不做平滑滚动——从顶部平滑滚过六轮对话只是让人晕 useLayoutEffect(() => { const el = scrollRef.current if (!el || !turns.length) return setFresh(0) seen.current = { id: last?.id ?? '', n: lastCount, sig: signature } if (mode === 'end') { el.scrollTop = el.scrollHeight pinned.current = true return } const failed = el.querySelector('[data-step-status="failed"]') const approval = el.querySelector('[data-approval-slot]:not(:empty)') const target = failed ?? approval if (target) target.scrollIntoView({ block: 'center' }) else el.scrollTop = 0 pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < STICK_PX if (failed) return flash(failed, 'failed') // 只在换了一批轮次时重新定位;之后的变化交给下面的跟随 // eslint-disable-next-line react-hooks/exhaustive-deps }, [identity, turns.length === 0]) // 跟随:只在贴底时跟;离开底部就数一数来了几条新的,给一枚「跳到最新」 useLayoutEffect(() => { const prev = seen.current seen.current = { id: last?.id ?? '', n: lastCount, sig: signature } const el = scrollRef.current if (!el || !follow) return const grown = last?.id !== prev.id ? lastCount : lastLive ? Math.max(0, lastCount - prev.n) : 0 if (!grown && signature === prev.sig) return if (pinned.current) el.scrollTop = el.scrollHeight else if (grown) setFresh((f) => f + grown) // eslint-disable-next-line react-hooks/exhaustive-deps }, [signature, lastCount, follow]) // 点名要看某个节点(画布上点了它):滚过去就暂停跟随——人要看的是那一步,新进展来了 // 也不能把他拽回底部;他自己滚回底部再恢复(onScroll)。 // 等这一帧落定再滚:右栏刚从对话层切过来时,「打开时停在最新处」的定位(开发期 StrictMode // 还会把它重跑一遍)都得排在这一次前面,不然人刚被带到那一步又被拽回底部。不在清理里 // 取消,理由同 RunView:清理一跑请求就吞掉了;卸载了的话找不到东西自然不做 const revealed = useRef(0) const unflash = useRef<(() => void) | null>(null) // 长运行里那一行可能收在「展开前面的 N 条」里:StepList 从 Ctx 看到请求会先展开,这里多等 // 几帧再找。找到了才暂停跟随、描边、报办完;始终找不到就原地不动,不把人带走又什么都不描。 // 用 useEffect 不用 useLayoutEffect:开发期 StrictMode 把挂载时的 effect 重跑一遍是在 // passive 这一拍里做的,排在它后面起跳,重跑的「打开时停在最新处」和卸载时摘描边都已经过去 useEffect(() => { if (!reveal || reveal.seq === revealed.current) return revealed.current = reveal.seq const { nodeId, seq } = reveal let tries = 0 const attempt = () => { const el = scrollRef.current const target = el ? lastExecOf(el, nodeId) : null if (!el) return if (!target) { if (++tries < REVEAL_TRIES) requestAnimationFrame(attempt) return } pinned.current = false setFresh(0) target.scrollIntoView({ block: 'start', behavior: reduced ? 'auto' : 'smooth' }) unflash.current?.() unflash.current = flash(target, 'focus') onRevealed?.(seq) } requestAnimationFrame(attempt) // eslint-disable-next-line react-hooks/exhaustive-deps }, [reveal]) // 请求清掉(reveal 变回 null)时描边照常留满时长,只在卸载时摘 useEffect(() => () => unflash.current?.(), []) // 悬停联动画布:在滚动容器上统一认"指着哪个节点"。挂在每一行上的话,从节点行移进 // 它下面的查询行会先触发子行的 leave,把高亮清掉,人明明还指着这个节点 const hovered = useRef(null) const onPointerOver = onStepHover && ((e: ReactMouseEvent) => { const id = (e.target as HTMLElement).closest('[data-node-id]')?.dataset.nodeId ?? null if (id === hovered.current) return hovered.current = id onStepHover(id) }) const onPointerLeave = onStepHover && (() => { if (hovered.current == null) return hovered.current = null onStepHover(null) }) const jumpToLatest = () => { const el = scrollRef.current if (!el) return pinned.current = true setFresh(0) el.scrollTo({ top: el.scrollHeight, behavior: reduced ? 'auto' : 'smooth' }) } const ctx = useMemo(() => ({ dense, onHover: onStepHover, onFocus: onStepFocus, onOpen: onStepOpen, activeNodeId, skewMs: clockSkewMs, openArtifact: (id, title) => setArtifact({ id, title }), reduced, reveal, }), [dense, onStepHover, onStepFocus, onStepOpen, activeNodeId, clockSkewMs, reduced, reveal]) const rootClass = clsx('flex h-full min-h-0 flex-1 flex-col', className) if (!turns.length && empty) { return
{empty}{footer}
} return (
{/* 留白放在里面一层:滚动容器自己带 padding 的话,吸顶的轮次头会停在离顶边 一个 padding 的地方,上面露出一条滚过去的正文 */}
{turns.map((turn, i) => (
{renderTurnActions && {renderTurnActions(turn, i)}}
))}
{fresh > 0 && ( )}
{footer}
setArtifact(null)} />
) } /** * 节点最后一次执行的那一块:执行过多次的,是分轮折叠里最后那一轮(它总是列着的); * 只执行过一次的,是节点那一行本身。节点行里套着它自己的子步骤(同一个 data-node-id), * 取最外层那个 */ function lastExecOf(root: HTMLElement, nodeId: string): HTMLElement | null { const id = CSS.escape(nodeId) const row = [...root.querySelectorAll(`[data-step-status][data-node-id="${id}"]`)] .find((el) => !el.parentElement?.closest(`[data-node-id="${id}"]`)) if (!row) return null const execs = row.querySelectorAll(`[data-exec-groups="${id}"] [data-exec]`) return execs.length ? execs[execs.length - 1] : row } /** 空的动作槽不占位:页面对某些轮次什么都不放是常态 */ function TurnActions({ children }: { children: ReactNode }) { if (children == null || children === false) return null return
{children}
} /** * 定位到的那一行短暂描边一下,告诉人"是这里"。failed 用失败色(回看历史时落到 * 第一处失败),focus 用强调色(点名要看的那一轮)——一次普通的定位不能描成红框, * 那等于把好好的一步说成出了错。返回清理函数:换了目标或卸载时立刻摘掉,不留残框 */ function flash(el: HTMLElement, kind: 'failed' | 'focus'): () => void { el.dataset.flash = kind const clear = () => { if (el.dataset.flash === kind) delete el.dataset.flash } const t = setTimeout(clear, 1800) return () => { clearTimeout(t); clear() } } // ------------------------------------------------------------------------- // 一轮 // ------------------------------------------------------------------------- function TurnCard({ turn, last, approvals, onOpenGraph, onFollowUp }: { turn: StreamTurn; last: boolean; approvals?: ReactNode onOpenGraph?: (graph: any, question?: string) => void onFollowUp?: (text: string) => void }) { const { dense } = useContext(Ctx) // 报错已经在卡片顶上说了一遍(带原因、怎么办、技术细节),步骤列表末尾那条运行级的 // 红行(run.failed)不再重复。页面拆好的报错标题是改写过的,对不上原话,所以按来源认 const errText = typeof turn.error === 'string' ? turn.error : turn.error && 'error' in turn.error ? turn.error.error : undefined const steps = turn.phase === 'error' && turn.error ? turn.steps.filter((s) => !(s.kind === 'error' && (s.title === errText || s.id.startsWith('rf-')))) : turn.steps const plan = steps.filter((s) => s.stage === 'plan') // 「开始运行(6 个节点)」「完成」和轮次头说的是同一件事(状态、节点数、耗时), // 再列一遍只是噪音;失败时「开始运行」还会被画成红叉,像是出了第二个错 const exec = steps.filter((s) => s.stage !== 'plan' && !redundant(s)) const broken = turn.review?.severity === 'broken' return (
{turn.question && ( // 宽屏走聊天气泡(右对齐、实心),窄栏里气泡会挤成一条竖带, // 改用左侧强调条——同样能一眼看出"这句是你说的",但不吃宽度
{turn.question}
)}
{turn.phase === 'error' && turn.error && ( // 报错不截成一行:原因和怎么办要读得全,原文收进可展开的技术细节 )} {/* 还在流、但还没成为步骤的思考。解码器已经把思考并成了步骤行的话就不再 重复一遍——同一段话在灰框和步骤行里各出现一次 */} {turn.phase === 'running' && turn.thinking && !plan.some((s) => s.kind === 'think' && s.status === 'running') && } {plan.length > 0 && ( )} {exec.length > 0 && ( 0} defaultOpen={turn.phase !== 'done' || last} /> )}
{approvals}
{/* 没查库这件事必须说在结论前面。「涉及数据必须真查」是这条路径上 最硬的一条约定,放开它之后,用户得能一眼分清哪些结论背后真的 动了库、哪些只是在复述前面几轮 */} {turn.noQuery && (
本条未查询数据库,基于前几轮的结果作答
)} {/* 复核说明排在成果**上方**,和上面那条声明同一个位置。排在下面 等于让人读完整个结论、信了,才知道它是在什么条件下得出的 */} {turn.review && } {turn.output && ( )} {turn.rawOutput && (
复核改写过此答案,查看改写前的原文
)} {turn.catalogPatches?.map((p) => ( ))} {!!turn.graph?.nodes?.length && ( onOpenGraph(g, turn.question))} /> )}
) } /** * 轮次头:状态、阶段进度、实时计时。 * * 吸顶:长运行往下翻的时候,"现在到哪了、跑了多久"一直看得见——这就是回看 * 历史运行时那条迷你摘要。状态变化另放一个只有读屏器看得见的播报区:计时每 * 100ms 跳一次,放进 aria-live 会被一直念。 */ function TurnHead({ turn }: { turn: StreamTurn }) { const { dense, skewMs } = useContext(Ctx) const progress = useMemo(() => progressOf(turn.steps.filter((s) => s.stage !== 'plan')), [turn.steps]) const live = turn.phase === 'running' const now = useRunClock(live) const review = turn.review?.severity const code = turn.phase === 'running' ? 'running' : turn.phase === 'waiting' ? 'waiting' : turn.phase === 'error' ? 'failed' : 'succeeded' // 协作团队用完轮数、按降档交付:节点「完成」了,交出去的却是成员最后的原话 const teamShort = useMemo(() => !!findFirst(turn.steps, (s) => s.code === 'team_exhausted' || (!!s.team && teamVerdictOf(s.team)?.outcome === 'degraded')), [turn.steps]) // 跑完了但复核说结论不能用:头部不能还是一个安静的「完成」 const base = turn.status || statusLabel(turn.statusCode ?? code) const verdict = turn.phase === 'done' && review === 'broken' ? '结论不可用' : turn.phase === 'done' && review === 'degraded' ? '存在缺口' : turn.phase === 'done' && teamShort ? '协作团队未完成' : '' // 复核结论说的是答案,不是运行:运行确实跑完了,徽标和「已完成」照常,红 / 琥珀只 // 落在结论那几个字上。把完成的勾染成红色,形状说成功、颜色说失败,两头打架 const tone = turn.tone === 'failed' ? 'var(--st-failed)' : turn.tone === 'warn' ? 'var(--st-waiting)' : turn.phase === 'error' ? 'var(--st-failed)' : turn.phase === 'waiting' ? 'var(--st-waiting)' : undefined const verdictTone = verdict === '结论不可用' ? 'var(--st-failed)' : 'var(--st-waiting)' const badgeCode = turn.statusCode ?? (turn.tone === 'warn' && turn.phase === 'done' ? 'waiting' : turn.tone === 'failed' && turn.phase === 'done' ? 'failed' : code) const firstStart = useMemo(() => findFirst(turn.steps, (s) => s.startedAt != null)?.startedAt, [turn.steps]) const started = turn.startedAt === null ? undefined : turn.startedAt ?? firstStart // 「完成」那一行不再单独列出,它的「执行 1.2 s · 等人 3 分」挪到头上 const doneRow = turn.steps.find((s) => s.kind === 'lifecycle' && s.title === '完成') const took = turn.elapsedMs != null ? (turn.elapsedMs >= 10 ? formatDuration(turn.elapsedMs) : '') : doneRow?.meta ?? '' const elapsed = live && started != null ? Math.max(0, now - skewMs - started) : undefined // 从库里恢复的轮次没有事件,步骤里数不出查询:用页面带过来的落库次数 const counted = useMemo(() => countKind(turn.steps, 'query'), [turn.steps]) const queries = counted || turn.queries || 0 // 档位优先取成果里那份(带全了核对明细),失败的运行、成果被替换过的轮次退到出具那一行 const tierStep = useMemo(() => findFirst(turn.steps, (s) => s.kind === 'issuance' && !!s.tier)?.tier, [turn.steps]) const tier = typeof turn.output?._issuance?.tier === 'string' ? turn.output._issuance.tier as string : tierStep ?? '' const tags = [ progress.total ? `${progress.done}/${progress.total} 节点` : '', !live && queries ? `${queries} 次查询` : '', !live ? took : '', ].filter(Boolean) const fraction = progress.total ? Math.min(1, progress.done / progress.total) : undefined // 节点轨:图不大时一个节点一格,按状态着色——跑到哪、哪一步跳过了、卡在哪一眼看全。 // 只在跑着、等人时画;跑完了安静,出了事由步骤流里的红行说 const rail = useMemo(() => (progress.total && progress.total <= RAIL_MAX ? railOf(turn.steps) : null), [progress.total, turn.steps]) const showRail = (live || turn.phase === 'waiting') && !!rail && !!progress.total return ( // 吸顶之后正文从它下面滚过去,没有这道线的话头和正文糊成一片。没有步骤的轮次 // (只回了一句话)下面紧跟着成果区自己的分隔线,不再画第二道
0 || live) && 'border-b')} style={{ borderColor: 'var(--hairline, var(--border))' }}>
{base} {verdict && · {verdict}} {tags.length > 0 && ( {tags.join(' · ')} )} {tier && ( // 出具档位也挂在吸顶的头上:往下翻到报告中段时,"这份结论能不能当真"还看得见。 // 降档、不予出具带个警示形状,只靠颜色的话色弱的人读不出它和「完整出具」的差别 {tier !== 'formal' && } {issuanceLabel(tier)} )} {elapsed != null && ( {formatClock(elapsed)} )} {turn.runId && ( {shortId(turn.runId)} )} {`${base}${verdict ? `,${verdict}` : ''}${tier ? `,${issuanceLabel(tier)}` : ''}`}
{live && progress.current && (
正在:{progress.current.title}
)} {showRail && (
{Array.from({ length: progress.total! }, (_, i) => { const n = rail![i] // 还没轮到的画成空心格:一格一格数得清还剩几步。填成 --bg-hover 的话和栏底 // 只差 1.1:1,5 个节点跑到第 3 个看着像一共 4 格;填成未运行色又和「已跳过」撞色 return ( ) })}
)} {live && fraction != null && !showRail && ( // 节点进度压在头部的底线上:按不同节点数,不按执行次数,循环不会让它倒退或超过 100%
)}
) } /** 节点轨最多画几格。再多就是一条挤满细缝的条,不如一根进度条 */ const RAIL_MAX = 15 const RAIL_COLOR: Record = { running: 'var(--st-running)', waiting: 'var(--st-waiting)', failed: 'var(--st-failed)', done: 'var(--st-done)', skipped: 'var(--st-skipped)', cancelled: 'var(--st-cancelled)', suspended: 'var(--st-suspended, var(--st-waiting))', } /** 出现过的节点,按第一次出现的顺序;并行组里的几路各算一格 */ function railOf(steps: Step[]): { id: string; title: string; status: string }[] { const out: { id: string; title: string; status: string }[] = [] const seen = new Set() const walk = (list: Step[]) => { for (const s of list) { if (s.kind === 'node' && s.nodeId && s.stage !== 'plan' && !seen.has(s.nodeId)) { seen.add(s.nodeId) out.push({ id: s.nodeId, title: s.title.replace(/^跳过「(.+)」$/, '$1'), status: s.status ?? 'done' }) } else if (s.kind === 'branch' && !s.nodeId && s.children) walk(s.children) } } walk(steps) return out } function findFirst(steps: Step[], pred: (s: Step) => boolean): Step | undefined { for (const s of steps) { if (pred(s)) return s const hit = s.children && findFirst(s.children, pred) if (hit) return hit } return undefined } function countKind(steps: Step[], kind: StepKind): number { let n = 0 // 合并查询那一行也是 kind query(同样的展开区和下钻),但它没有查数据库:「N 次查询」和落库的查询次数对得上 for (const s of steps) { n += (s.kind === kind && !s.merge ? 1 : 0) + (s.children ? countKind(s.children, kind) : 0) } return n } /** * 一段步骤:标题可收起。问数据页一轮先建图再执行,两段各一个标题;执行开始后 * 「规划」自动收成一行「规划 · 6.4 s · 4 步」。 */ function StepSection({ label, steps, turn, defaultOpen, titled = true }: { label: string; steps: Step[]; turn: StreamTurn; defaultOpen: boolean; titled?: boolean }) { const [open, setOpen] = useState(defaultOpen) // 相位变化时跟着默认值走(执行开始时把规划收起来);用户手动点过就听用户的 const touched = useRef(false) useEffect(() => { if (!touched.current) setOpen(defaultOpen) }, [defaultOpen]) const n = countSteps(steps) const ms = steps.filter((s) => s.kind === 'lifecycle' && s.ms != null) .reduce((acc, s) => Math.max(acc, s.ms ?? 0), 0) const failed = steps.some(function bad(s): boolean { return s.status === 'failed' || (s.children?.some(bad) ?? false) }) if (!titled) { return
} return (
{open &&
}
) } /** * 不必单列的生命周期行:「开始运行」「完成」「已取消」和轮次头说的是同一件事。 * 「继续运行」带着从哪接着跑、谁发起的才留;老数据里光秃秃的一句,审批行上的 * 「→ 已放行」已经说过了,而且步骤按节点归并之后它的位置也对不上真实的先后 */ const redundant = (s: Step): boolean => s.kind === 'lifecycle' && (s.title.startsWith('开始运行') || s.title === '完成' || ((s.title === '已取消' || s.title === '继续运行') && !s.sub)) /** 这一轮一共跑了多久,给耗时细条当分母 */ function turnMs(turn: StreamTurn): number | undefined { const fin = turn.elapsedMs ?? turn.steps.find((s) => s.kind === 'lifecycle' && s.title === '完成')?.ms if (fin) return fin const sum = turn.steps.reduce((acc, s) => acc + (s.kind !== 'lifecycle' ? s.ms ?? 0 : 0), 0) return sum || undefined } /** * 这次运行有什么不对。 * * broken 用报警色:那意味着答案本身不可信,不是"仅供参考"。degraded 用普通 * 边框——它能用,只是有缺口。两者混成一个样子,用户就学会了一概忽略。 * 可信度相关的字一律不用最淡的那档灰:最需要被看到的「这条结论有问题」以前 * 恰恰是整页最淡的字。 */ function ReviewNote({ review }: { review: ReviewResult }) { const broken = review.severity === 'broken' const Icon = broken ? AlertTriangle : Info return (
{review.note}
{!!review.signals.length && (
检测到 {review.signals.length} 处异常
    {review.signals.map((s, i) => (
  • · {s.detail}
  • ))}
)}
) } /** 拆好的一处报错:发生了什么、为什么、怎么办、该去哪儿改、原文 */ interface Failure { title: string reason?: string action?: string raw?: string fix?: FixKind | 'rerun' fixTo?: string fixFirst?: boolean /** lib/explain 的 fixNode:该去改的是另一个节点(名字),比如整形节点失败、毛病在上游 */ fixNode?: string } /** 这一轮是不是一次运行。Copilot 建图、发起就失败的,不能按运行的失败去讲(「接着跑」无从谈起) */ const isRunTurn = (turn: StreamTurn): boolean => !!turn.runId || turn.steps.some((s) => s.stage !== 'plan') function failureOf(turn: StreamTurn): Failure | null { const e = turn.error if (!e) return null if (typeof e === 'object' && 'title' in e) { return { title: e.title, reason: e.reason, action: e.hint, raw: e.detail || undefined, fix: e.fix, fixTo: e.fixTo, fixFirst: e.fixFirst } } const text = typeof e === 'string' ? e : e.error const detail = typeof e === 'string' ? undefined : e.detail const hint = typeof e === 'string' ? undefined : e.hint // 运行的失败和记录页、问数据页读同一份「为什么 + 怎么办」:同一次失败三处说法不一, // 人就不知道该信哪句、该点哪个按钮 if (!hint && isRunTurn(turn)) { const x = explainRunError(text, detail) return { title: x.title, reason: x.reason, action: x.action, fix: x.fix, fixTo: x.fixTo, fixFirst: x.fixFirst, fixNode: x.fixNode, raw: x.raw && x.raw !== x.title ? x.raw : undefined } } const h = humanizeError(hint || detail ? { error: text, hint, detail } : text) return { title: h.title, reason: h.reason, action: h.action, raw: h.raw && h.raw !== h.title ? h.raw : undefined } } /** * 下一步的直达入口。要去画布上改的,编排页能直接打开那个节点的设置;别处只说该去哪儿, * 不装作能点。「接着跑」「重新运行」由页面自己放(renderTurnActions),这里不给 */ function FixAction({ fix, nodeId, label, to, first, field }: { fix?: FixKind | 'rerun'; nodeId?: string; label?: string /** lib/explain 的 fixTo:直达要改的那一项 */ to?: string /** lib/explain 的 fixFirst:先改好才接得下去,入口写明要改什么 */ first?: boolean /** 要改的那一栏(args.sql:调用工具节点里写死的 SQL)。给了就落到那一栏,入口写「打开「取数」的 SQL」 */ field?: string }) { const { onOpen } = useContext(Ctx) const cls = 'inline-flex shrink-0 items-center gap-1 rounded px-1 text-2xs text-[var(--accent)] underline-offset-2 transition-colors hover:bg-hover hover:underline' if (fix === 'canvas' && nodeId && onOpen) { const sql = field === 'args.sql' || field === 'sql' return ( ) } if (fix === 'settings') { return 去模型接入 } if (fix === 'catalog' && to) { // 助手参考的大库还没有数据目录:直达那个数据源的目录页去起草 return ( {COPILOT_CONTEXT_TEXT.toCatalog} ) } if (fix === 'tools') { // 数据源工具(db_query__ 这类)不在工具库里接入,在数据页 const data = !!to?.startsWith('/data') return ( {data ? : } {' '}{data ? '前往「数据」页接入' : first ? '去改参数定义' : '去工具库'} ) } return null } /** * 轮次级的报错。样子和 ErrorState 的 compact 版一致,但先把话讲清再画:运行的失败走 * lib/explain(模型没真调工具、团队轮数用完这类,给的是「去画布改」而不是「接着跑」), * 页面拆好的照原样,其余交给 humanizeError。出错的是哪个节点也写在这里,点一下画布取景 */ function TurnError({ turn }: { turn: StreamTurn }) { const { onFocus } = useContext(Ctx) const f = useMemo(() => failureOf(turn), [turn]) if (!f) return null // run.failed 那一行定位得到节点就用它(它在流里已经不单列了),否则第一个失败的节点 const rf = findFirst(turn.steps, (s) => s.kind === 'error' && s.id.startsWith('rf-') && !!s.nodeId) const node = findFirst(turn.steps, (s) => s.kind === 'node' && s.status === 'failed' && !!s.nodeId) const whereId = rf?.nodeId ?? node?.nodeId const whereLabel = rf?.sub?.replace(/^出错的节点:/, '') || node?.title // 该改的是另一个节点(上游写坏了 JSON):入口指到它;这一轮里找不到它就不给入口,不指错 const target = f.fixNode && f.fixNode !== whereLabel ? findFirst(turn.steps, (s) => s.kind === 'node' && s.title === f.fixNode && !!s.nodeId) : null const fixId = f.fixNode && f.fixNode !== whereLabel ? target?.nodeId : whereId const fixLabel = f.fixNode && f.fixNode !== whereLabel ? f.fixNode : whereLabel return (
{f.title}
{whereId && whereLabel && ( onFocus ? ( ) :
出错的节点:「{whereLabel}」
)} {f.reason &&
{f.reason}
} {f.action && (
{f.action}
)}
{f.raw &&
}
) } /** 思考的尾窗:只露最后几行并跟着滚,不然一大坨会把步骤挤到屏幕外。 */ function ThinkingTail({ text }: { text: string }) { const ref = useRef(null) useEffect(() => { if (ref.current) ref.current.scrollTop = ref.current.scrollHeight }, [text]) return (
{text}
) } // ------------------------------------------------------------------------- // 步骤 // ------------------------------------------------------------------------- /** 同一节点执行超过这么多次才按轮收起 */ const FOLD_FROM = 5 /** 一层超过这么多条时先只画一部分。1860 条事件的运行以前是 5970 个 DOM 节点 */ const LIST_CAP = 50 const LIST_HEAD = 20 /** 点名的那一行等展开之后再找,最多等这么多帧 */ const REVEAL_TRIES = 4 const notable = (s: Step): boolean => s.status === 'failed' || s.status === 'running' || s.status === 'waiting' || s.level === 'error' || s.level === 'warn' function StepList({ steps, depth, turnMs, fold = false }: { steps: Step[]; depth: number; turnMs?: number /** 已经做完的节点:只留出问题的和最后三条,其余收成一行 */ fold?: boolean }) { const [all, setAll] = useState(false) const rows = useMemo(() => compactSteps(steps), [steps]) const { reveal } = useContext(Ctx) // 收起的是前面那些平平无奇的:最新的几条(运行中正在发生的就在这里)和出了 // 问题的一律留着。反过来只画前 20 条的话,长运行跑到后面,正在跑的那一步反而 // 被收进「展开其余」里看不见 const keep = fold && rows.length > 6 ? 3 : rows.length > LIST_CAP ? LIST_HEAD : 0 const shown = !all && keep ? rows.filter((s, i) => notable(s) || i >= rows.length - keep) : rows const hidden = rows.length - shown.length // 画布上点名要看的节点正好收在前面那一截里:先展开,上面的定位下一帧就找得到它 useLayoutEffect(() => { if (!reveal || !hidden) return const kept = new Set(shown) if (rows.some((s) => !kept.has(s) && s.nodeId === reveal.nodeId)) setAll(true) // eslint-disable-next-line react-hooks/exhaustive-deps }, [reveal?.seq]) return (
0 && 'ml-[5px] border-l pl-2.5')} style={depth > 0 ? { borderColor: 'var(--hairline, var(--border))' } : undefined}> {hidden > 0 && ( )} {shown.map((step) => )}
) } /** 状态有自己剪影的就画剪影(全站同一套),其余的画这一步是什么 */ const BADGED = new Set(['running', 'waiting', 'failed', 'cancelled', 'suspended', 'skipped']) function StepRow({ step, depth, turnMs }: { step: Step; depth: number; turnMs?: number }) { if (depth === 0 && step.kind === 'lifecycle' && step.stage !== 'plan') return return } /** * 执行里的分段线:「继续运行 · 由 张工 发起」。它标的是这次运行在时间线上的一个 * 转折,不是一件做完或做砸的事——画成打勾或红叉就像多了一个步骤(失败的运行里 * 「继续运行」以前是一个红叉,读起来像续跑本身出了错) */ function PhaseMark({ step }: { step: Step }) { const line = { background: 'var(--hairline, var(--border))' } return (
{step.title} {step.sub && {step.sub}} {step.meta && {step.meta}}
) } function StepLine({ step, depth, turnMs }: { step: Step; depth: number; turnMs?: number }) { const { dense, onHover, onFocus, activeNodeId, openArtifact, reduced } = useContext(Ctx) const [open, setOpen] = useState(false) const Icon = ICONS[step.kind] ?? CircleDot const result = step.result const expandable = !!(step.detail || result || step.raw || step.artifact || step.checks?.length) const table = result ? parseQueryResult(result) : null const status = step.status ?? 'done' // 失败的节点:收着的时候就说为什么(和轮次顶上的报错同一份 lib/explain),展开再看原因、 // 怎么办和原话。以前这一行只有节点名和一个红叉,得点开才知道是没绑工具还是超时 const explained = useMemo(() => (step.kind === 'node' && status === 'failed' && step.detail ? explainRunError(step.detail, step.raw) : null), [step.kind, status, step.detail, step.raw]) const sub = step.sub ?? explained?.title // 失败要压过 kind:一条 lifecycle 跑挂了还画成打勾的"完成"图标,是把失败说成了成功 const failed = status === 'failed' || step.level === 'error' const running = status === 'running' const top = depth === 0 && (step.kind === 'node' || step.kind === 'lifecycle') const color = failed ? 'var(--st-failed)' : status === 'waiting' ? 'var(--st-waiting)' : step.level === 'warn' ? 'var(--st-waiting)' : status === 'skipped' || status === 'cancelled' ? 'var(--text-faint)' : status === 'suspended' ? 'var(--st-suspended, var(--warn))' : running ? 'var(--st-running)' // 顶层节点是主干,子步骤是它的过程——靠明度分层,不然一屏全是同一个灰 : top ? 'var(--text)' : 'var(--text-dim)' // 建图阶段加的节点是"放上去了",不是"跑完了":画成节点图标,不画完成的勾 const badge = failed ? 'failed' : BADGED.has(status) ? status : top && step.kind === 'node' && step.stage !== 'plan' ? 'done' : null const linked = !!step.nodeId && (!!onHover || !!onFocus) const active = !!step.nodeId && step.nodeId === activeNodeId const toggle = () => { if (expandable) setOpen((v) => !v) if (step.nodeId && onFocus) onFocus(step.nodeId) } return (
{step.next && ( // 下一步不收进展开区:模型没真调工具、团队轮数用完这种,人要的就是这一句
{step.next} {/* 要改的是另一个节点(指标的问题出在来源查询的 SQL 上):入口落到那个节点的那一栏 */}
)} {open && } {/* 泳道排在子步骤**前面**:先给一眼看清的形状(谁做了哪几轮、哪些是 同时进行的、各花多久),再往下是每一步的内容。反过来的话,得把 十几行步骤读完才知道这个团队是怎么分工的 */} {step.team && } {(step.execs?.length ?? 0) > 1 && step.children?.length ? : !!step.children?.length && ( )} {step.artifact && !open && step.kind === 'query' && ( )}
) } /** * 行尾:做完的写耗时(配一根按本轮总时长归一化的细条,慢在哪一眼能看出来), * 在跑的走实时计时。 */ function StepMeta({ step, depth, turnMs }: { step: Step; depth: number; turnMs?: number }) { const { dense, reduced } = useContext(Ctx) const running = step.status === 'running' const failed = step.status === 'failed' || step.level === 'error' const showBar = !running && depth === 0 && step.kind === 'node' && step.ms != null && turnMs != null && turnMs > 0 && step.ms >= 10 const share = showBar ? Math.min(1, step.ms! / turnMs!) : 0 return ( <> {showBar && ( )} {/* 「开始运行」那一行的秒表和轮次头部的是同一个数,不重复 */} {running && step.startedAt != null && step.kind !== 'lifecycle' ? : (step.meta || STOPPED[step.status ?? '']) && !(running && step.startedAt != null) && ( {step.meta || STOPPED[step.status ?? '']} )} {step.repeat && step.repeat.ms.length >= 3 && } ) } /** 被停下的步骤在行尾说一声停在哪种状态:灰色方块和"做完了"光看图标分不太开 */ const STOPPED: Record = { cancelled: statusLabel('cancelled', { short: true }), suspended: statusLabel('suspended', { short: true }), } /** * 进行中那一行的秒表。只有它订阅时钟,其余行不会跟着每 100ms 重画。 * 带时限的工具越过上限就换成提醒色、说破「已超出」:引擎马上会放弃等待,秒表一声 * 不吭地接着走,看着像还有指望 */ function LiveClock({ since, label, limitS }: { since: number; label?: string; limitS?: number }) { const { dense, skewMs } = useContext(Ctx) const now = useRunClock(true) const ms = Math.max(0, now - skewMs - since) const over = limitS != null && ms > limitS * 1000 return ( {over ? `已超出 ${formatNumber(limitS!)} 秒上限 · ` : label ? `${label} · ` : ''}{formatClock(ms)} ) } /** 一串耗时的静态折线。不动画:它说的是已经发生的分布,不是正在发生的事 */ function Sparkline({ values }: { values: number[] }) { const pts = values.length > 200 ? values.filter((_, i) => i % Math.ceil(values.length / 200) === 0) : values const max = Math.max(...pts, 1) const w = 48 const h = 12 const d = pts.map((v, i) => `${(i / Math.max(1, pts.length - 1)) * w},${h - (v / max) * (h - 1) - 0.5}`).join(' ') return ( ) } /** * 同一个节点执行了很多次:按轮折叠。 * * 默认只展开最后一轮和出了问题的那几轮,其余收成一行摘要(几轮、中位、最慢、 * 哪几轮有提醒),配一条每轮耗时的折线——最慢那一拍一眼就能看见。摘要里点名的 * 那几轮(最慢那一次、有提醒的几次)点一下就列出来,不用去一百多轮里翻。 */ function ExecGroups({ step, turnMs }: { step: Step; turnMs?: number }) { const groups = useMemo(() => childrenByExec(step), [step]) const [expanded, setExpanded] = useState>(() => new Set()) // 从摘要里点名要看的轮次:不展开全部也单独列出来 const [pinned, setPinned] = useState>(() => new Set()) const [all, setAll] = useState(false) // 展开全部时平平无奇的轮次分批列,每批 LIST_HEAD 轮 const [limit, setLimit] = useState(LIST_HEAD) const [reveal, setReveal] = useState<{ n: number; seq: number } | null>(null) const boxRef = useRef(null) const bad = (g: { exec: Exec; steps: Step[] }) => g.exec.status === 'failed' || g.steps.some((s) => notable(s) && s.status !== 'running') const lastN = groups[groups.length - 1]?.exec.n // 几轮而已(驳回两次再放行)就全摊开:分轮本身已经把边界画清楚了,收起来反而把 // 那两次驳回藏了。轮询、逐拍这种几十上百轮的才收 const few = groups.length <= FOLD_FROM const auto = (g: { exec: Exec; steps: Step[] }) => few || g.exec.n === lastN || g.exec.status === 'running' || g.exec.status === 'waiting' || g.exec.status === 'failed' || g.steps.some((s) => s.level === 'error' || s.status === 'failed') const ms = groups.map((g) => g.exec.ms ?? 0) const st = spread(ms) const slowest = st.maxAt != null ? groups[st.maxAt]?.exec.n : undefined const warned = groups.filter((g) => g.steps.some((s) => s.level === 'warn')).map((g) => g.exec.n) const quiet = groups.filter((g) => !auto(g)) const loose = quiet.filter((g) => !pinned.has(g.exec.n)) const paged = all && loose.length > LIST_CAP // 要紧的轮次和点名要看的一直在;其余的只有展开全部时才按先后分批列。没列出来的 // 那一截在它开始的地方留一行,说清还有多少、怎么继续看——不假装"已经全在上面了" const rows: ({ kind: 'exec'; g: { exec: Exec; steps: Step[] } } | { kind: 'more'; left: number })[] = [] let listed = 0 for (const g of groups) { const keep = auto(g) || pinned.has(g.exec.n) if (!keep && !all) continue if (!keep && paged && ++listed > limit) { if (listed === limit + 1) rows.push({ kind: 'more', left: loose.length - limit }) continue } rows.push({ kind: 'exec', g }) } const hiddenNow = groups.length - rows.filter((r) => r.kind === 'exec').length // 点名的轮次里本来就列着的(最后一轮、出错的)不用再钉,只需要滚过去 const own = (ns: number[]) => ns.filter((n) => { const g = groups.find((x) => x.exec.n === n) return !!g && !auto(g) }) const namedOn = (ns: number[]) => own(ns).length > 0 && own(ns).every((n) => pinned.has(n)) /** 点名看某几轮:列出来、展开,并把第一轮滚进视野。再点一次收回去 */ const toggleNamed = (ns: number[]) => { if (!ns.length) return const on = namedOn(ns) const flip = (prev: Set) => { const next = new Set(prev) for (const n of own(ns)) { if (on) next.delete(n); else next.add(n) } return next } setPinned(flip) setExpanded(flip) if (!on) setReveal((r) => ({ n: ns[0], seq: (r?.seq ?? 0) + 1 })) } useLayoutEffect(() => { if (!reveal) return const el = boxRef.current?.querySelector(`[data-exec="${reveal.n}"]`) if (!el) return el.scrollIntoView({ block: 'nearest' }) return flash(el, 'focus') }, [reveal]) return (
{quiet.length > 0 && (
{warned.length > 0 && ( )} {st.max != null && st.max >= 10 && slowest != null && ( {st.median != null && st.median >= 10 && 中位 {formatDuration(st.median)} · } )} {(st.max ?? 0) >= 10 && ms.length >= 3 && }
)} {rows.map((r) => { if (r.kind === 'more') { return (
还有 {r.left} 轮未列出
) } const { g } = r const open = auto(g) || expanded.has(g.exec.n) return (
{open && g.steps.length > 0 && }
) })}
) } /** * 失败节点的原话是不是已经被上面的说法原样说完了:原话的每一句(按句号、分号断开,去掉标点和 * 空白比)都出现在「一句话 + 为什么 + 怎么办」里。说完了就不在展开区再贴一遍——用户真实踩到的: * 「模板渲染出来的不是合法 JSON…」先按标题、怎么办各说一遍,底下又整段贴了一遍原话。 * 说法是改写过的(「模型鉴权没通过」之类)就还留着原话,它是唯一的原文 */ function saidAlready(detail: string, parts: (string | undefined)[]): boolean { const norm = (t: string) => t.replace(/[\s。..,,;;::!!??、]/g, '') const shown = norm(parts.filter(Boolean).join('')) const sentences = detail.split(/[。;;\n]/).map(norm).filter(Boolean) return !!shown && sentences.length > 0 && sentences.every((x) => shown.includes(x)) } /** 展开区:跑的是什么(SQL、代码、参数)+ 跑出了什么 + 技术细节 + 完整证据 */ function StepDetail({ step, table, explained }: { step: Step; table: Table | null /** 失败节点按 lib/explain 讲的为什么、怎么办(title 是收着时那一行已经说了的一句话) */ explained?: { title?: string; reason?: string; action?: string; fix?: FixKind | 'rerun'; fixTo?: string; fixFirst?: boolean; fixNode?: string } | null }) { const { openArtifact, dense } = useContext(Ctx) const pre = clsx('mono max-h-40 overflow-auto whitespace-pre-wrap rounded bg-bg px-2 py-1.5 leading-relaxed text-dim [overflow-wrap:anywhere]', dense ? 'text-[10.5px]' : 'text-2xs') const result = step.result const detail = step.detail && explained && saidAlready(step.detail, [explained.title, explained.reason, explained.action]) ? undefined : step.detail return (
{explained && (explained.reason || explained.action) && (
{explained.reason &&
{explained.reason}
} {explained.action && (
{explained.action} {/* 该改的是上游(fixNode)时这一行不给入口:它只认得自己这个节点,轮次顶上的报错会指到上游去 */}
)}
)} {step.merge && step.merge.inputs.length > 0 && ( // 合并查询:先说合并的是哪几个输入(合并 SQL 里的表名 → 上游节点、行数、数据源),再看合并 SQL 和结果
{MERGE_TEXT.inputs(step.merge.inputs.length)}
    {step.merge.inputs.map((i) => (
  • {i.alias} {i.label || i.nodeId || '—'} {[i.rows != null ? MERGE_TEXT.rows(i.rows) : '', i.source ? MERGE_TEXT.source(i.source) : ''] .filter(Boolean).join(' · ')}
  • ))}
)} {detail && (
{step.kind === 'query' && (
{step.merge ? MERGE_TEXT.sql : 'SQL'} {step.source && 数据源 {step.source}} step.detail ?? ''} />
)}
            {detail}
          
)} {!!step.checks?.length && ( // 对照数据目录的 SQL 检查:查询那一行是这次查询的检查结果,指标那一行是来源查询没通过的那几条
{RUN_SQL_CHECK_TEXT.toolChecks}
)} {table ? : result && result !== step.detail && (
            {result.slice(0, 2000)}
          
)} {step.artifact && !table && ( )} {step.raw && }
) } /** 技术细节:原始异常。给排查和转给维护者用,默认收着 */ function TechDetails({ raw }: { raw: string }) { const { dense } = useContext(Ctx) return (
技术细节
          {raw}
        
raw} />
) } // ------------------------------------------------------------------------- // 协作团队的泳道 // ------------------------------------------------------------------------- /** * 每个成员一行,每一轮一格。 * * 这个节点以前在界面上是一条扁平的步骤序列——能看出"谁回了什么",看不出 * "谁和谁是同时干的"。而一轮同时派几个人正是它相对单 agent 的全部优势, * 不画出来等于没有。 * * 条形的宽度按耗时归一化,所以一眼能看出谁是这一轮的瓶颈。说法和配色跟画布上 * 的协作矩阵是同一套:完成是 --st-done,进行中写「进行中」、不写「0 ms」, * 省下的时间要等那一轮的人都交回来才出现——之前刚开始就写「省下 0ms」,是在 * 报一个还没发生的收益。 */ function TeamLanes({ team }: { team: TeamRun }) { const { dense } = useContext(Ctx) const [openKey, setOpenKey] = useState(null) if (!team.rounds.length) return null // 所有格子共用一个时间标尺,否则"快的那格"和"慢的那格"画得一样长, // 瓶颈就看不出来了 const slowest = Math.max(1, ...team.rounds.flatMap((r) => r.members.map((m) => m.ms))) const parallelRounds = team.rounds.filter((r) => r.parallel > 1).length const liveRound = [...team.rounds].reverse().find((r) => r.members.some((m) => m.status === 'running')) const verdict = teamVerdictOf(team) // 一次都没派到的成员也占一行:团队「没做完」时,最该被看见的往往就是那个从没上过场的 const never = (verdict?.never ?? []).filter((n) => !team.members.includes(n)) const rounds = verdict?.rounds ?? team.rounds.length const ending = verdict?.outcome === 'failed' ? { text: `用完 ${rounds} 轮仍未完成`, color: 'var(--st-failed)' } : verdict?.outcome === 'degraded' ? { text: `用完 ${rounds} 轮仍未完成 · 按降档交付`, color: 'var(--st-waiting)' } : verdict?.closing && verdict.done ? { text: '轮数用完 · 调度者判定:已完成', color: 'var(--text-dim)' } : verdict?.closing && verdict.done === false ? { text: '轮数用完 · 调度者判定:未完成', color: 'var(--st-waiting)' } // 还在判定。运行在这时停了(取消、失败)团队会被收成 finished,就不再说「在判定」 : verdict?.closing && !team.finished ? { text: '轮数用完 · 调度者正在判定…', color: 'var(--st-running)' } : null const laneLabel = clsx('shrink-0 truncate text-2xs text-dim', dense ? 'w-14' : 'w-20') return (
{team.members.length + never.length} 名成员 · {team.rounds.length} 轮 {liveRound && ( 第 {liveRound.round + 1} 轮 · {liveRound.parallel > 1 ? `本轮 ${liveRound.parallel} 名成员并行中` : '进行中'} )} {team.savedMs > 0 && ( 并行节省 {formatDuration(team.savedMs)}({parallelRounds} 轮并行) )} {parallelRounds === 0 && team.finished && !verdict?.outcome && ( // 全程串行不是故障,但值得说一句——任务本来就互相依赖时它就该是串行的 全程串行 )}
{ending && ( // 怎么收的尾单独一行:只说「3 轮」的话,调度者说完成了和轮数用完硬停长得一样
{verdict?.outcome && } {ending.text} {verdict?.reason && :{verdict.reason}}
)}
{team.members.map((name) => (
{name}
{team.rounds.map((r) => { const m = r.members.find((x) => x.agent === name) const key = `${r.round}:${name}` const running = m?.status === 'running' // 被停下的(取消、服务重启)也不知道本来要多久:同样画满一格斜纹,只是灰的 const stopped = m?.status === 'cancelled' || m?.status === 'suspended' const bar = m?.status === 'done' ? 'var(--st-done)' : m?.status === 'failed' ? 'var(--st-failed)' : running ? 'var(--st-running)' : 'var(--text-faint)' const hatched = running || stopped const said = running ? '进行中' : stopped ? statusLabel(m!.status, { short: true }) : m?.status === 'failed' ? `失败 · ${formatDuration(m.ms)}` : formatDuration(m?.ms) return (
{m ? (
))} {never.map((name) => (
{name}
未分派:{rounds} 轮中调度者均未向该成员分派任务
))}
{team.rounds.map((r) => { const live = r.members.some((m) => m.status === 'running') // 没收齐就停下的一轮(取消、服务重启)不知道本来要多久:写怎么停的。以前写 // 「第1轮 · 0 ms」,有人先交回的话还写成先交回那人的耗时,像是这一轮跑完了 const stopped = r.members.find((m) => m.status === 'cancelled' || m.status === 'suspended') return (
{/* 窄栏放不下完整措辞,但"并2"这种缩写没人看得懂, 宁可只留轮次和耗时,并行与否看上面那行条形本来就一目了然 */} {`第${r.round + 1}轮`} {r.parallel > 1 && !dense ? ` · ${r.parallel} 名成员并行` : ''} {' · '}{live ? '进行中' : stopped ? statusLabel(stopped.status, { short: true }) : r.wallMs > 0 ? formatDuration(r.wallMs) : NONE}
) })}
{openKey && (() => { const [rd, name] = openKey.split(':') const m = team.rounds.find((r) => String(r.round) === rd) ?.members.find((x) => x.agent === name) as TeamMemberEx | undefined if (!m) return null return (
{name} · 第 {Number(rd) + 1} 轮 · {m.status === 'running' ? '进行中' : m.status === 'cancelled' || m.status === 'suspended' ? statusLabel(m.status, { short: true }) : m.status === 'failed' ? `失败 · ${formatDuration(m.ms)}` : formatDuration(m.ms)}
{m.instruction && (
任务:{m.instruction}
)} {m.error && (
失败原因:{m.error}
)} {m.result && (
                {m.result.slice(0, 1200)}
              
)}
) })()}
) } // ------------------------------------------------------------------------- // 查询结果、完整证据 // ------------------------------------------------------------------------- /** 值是不是数:数值列右对齐、等宽数字,小数点才对得齐。0 打头的串是编号,不算 */ const isNumeric = (v: unknown): boolean => typeof v === 'number' || (typeof v === 'string' && /^[-+]?\d[\d,]*(\.\d+)?%?$/.test(v.trim()) && !LEADING_ZERO.test(v.trim())) /** 这一列能不能当"量":列名不像编号 / 分组,值全是数 */ const isMetricColumn = (name: string, rows: unknown[][], j: number): boolean => !CODE_COLUMN.test(name) && rows.length > 0 && rows.every((r) => r[j] == null || isNumeric(r[j])) function toCsv(columns: string[], rows: unknown[][]): string { const cell = (v: unknown) => { const t = v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v) return /[",\n]/.test(t) ? `"${t.replace(/"/g, '""')}"` : t } return [columns, ...rows].map((r) => r.map(cell).join(',')).join('\n') } function download(name: string, text: string, type: string) { // 带 BOM:Excel 按 GBK 猜编码,不带的话中文列名全是乱码 const blob = new Blob([type.includes('csv') ? '\uFEFF' + text : text], { type }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = name a.click() setTimeout(() => URL.revokeObjectURL(url), 1000) } const fileSafe = (s: string) => s.replace(/[\\/:*?"<>|\s]+/g, '_').slice(0, 40) || '结果' /** * 查询结果画成表格。一屏 JSON 谁也看不出名堂,表格能一眼看到形状。 * * 证据面板的查询步骤也用它:highlight 标出被引用的行和格(行是 table.rows 的下标), * 加 data-highlight,颜色只用令牌;masked 列出被遮罩的列,那几列的值一律写「已遮罩」—— * 后端已经换过,这里再拦一道,老接口、别的入口回来的原值也不画出来 */ export function ResultTable({ table, artifact, title, full = false, highlight, masked }: { table: Table; artifact?: string; title?: string /** 完整证据里:不截列、行数放宽 */ full?: boolean /** cells 精确到格([行下标, 列名]):有它就只标这几格,没有就标 rows × cols */ highlight?: { rows: number[]; cols: string[]; cells?: [number, string][] | null } masked?: string[] }) { const { dense, openArtifact } = useContext(Ctx) const maxCols = full ? table.columns.length : dense ? 4 : 8 const maxRows = full ? 500 : 12 const cols = table.columns.slice(0, maxCols) const hiddenCols = table.columns.length - cols.length const rows = table.rows.slice(0, maxRows) const numeric = cols.map((c, j) => isMetricColumn(c, rows, j)) const hlCells = highlight?.cells ? new Set(highlight.cells.map(([r, c]) => `${r}\u0000${c}`)) : null const hlRows = new Set([...(highlight?.rows ?? []), ...(highlight?.cells ?? []).map(([r]) => r)]) const hlCols = new Set(highlight?.cols ?? []) const hidden = new Set(masked ?? []) return (
{cols.map((c, j) => ( ))} {hiddenCols > 0 && } {rows.map((row, i) => { const hit = hlRows.has(i) return ( {row.slice(0, maxCols).map((cell, j) => { const mark = hit && (hlCells ? hlCells.has(`${i}\u0000${cols[j]}`) : hlCols.has(cols[j])) const mask = hidden.has(cols[j]) return ( ) })} {hiddenCols > 0 && } ) })}
{c}+{hiddenCols}
{mask ? 已遮罩 : cell == null ? null : typeof cell === 'object' ? JSON.stringify(cell) : String(cell)} …
{[ // total:结果一共几行(预览只存了前几行时比 rows 多,比如合并查询 6 行只预览 5 行) rows.length < Math.max(table.total ?? 0, table.rows.length) ? `显示前 ${formatNumber(rows.length)} / ${formatNumber(Math.max(table.total ?? 0, table.rows.length))} 行` : `${formatNumber(table.rows.length)} 行`, hiddenCols > 0 ? `显示前 ${cols.length} / ${table.columns.length} 列` : null, ].filter(Boolean).join(' · ')} {/* 两种"不全"性质不同:一个是 guard 主动限的,一个是存预览时切的。 混成一句"已截断",用户没法判断该去调查询还是去看完整工件。 */} {table.truncated && ( 查询已达行数上限,数据库中还有更多数据 )} {table.clipped && (artifact ? ( ) : 此处仅为预览,实际返回的行数更多)} {artifact && !table.clipped && !full && ( )}
) } /** * 完整证据:按内容哈希取回工件(后端取回时复验哈希,对不上会报 409), * 表格全量显示、可导出,文本等宽显示、可复制。 * * 解码器早就给每一步记了工件 id,接口也一直在,界面从没渲染过——「每一步都有 * 工件、数字可回指」这个区别于普通问数工具的卖点,以前在界面上看不见。 * 导出给运行页的工件清单复用:id 为 null 时不渲染。证据面板打开查询快照时带上 masked, * 数据源设了遮罩的那几列同样写「已遮罩」。 */ export function ArtifactViewer({ id, title, masked, onClose }: { id: string | null; title?: string; masked?: string[]; onClose: () => void }) { const [state, setState] = useState<{ id: string; content?: unknown; error?: unknown } | null>(null) useEffect(() => { if (!id) return let alive = true setState({ id }) api.artifact(id).then( (res) => { if (alive) setState({ id, content: res.content }) }, (error) => { if (alive) setState({ id, error }) }, ) return () => { alive = false } }, [id]) const content = state?.id === id ? state?.content : undefined const error = state?.id === id ? state?.error : undefined const body = useMemo(() => (content === undefined ? null : unwrapArtifact(content)), [content]) return ( {title || '完整证据'} {id && 证据 {shortId(id, 8)}} {content !== undefined && ( 校验通过 )} }> {error ? : !body ? : (
{body.sql && ( // 证据得连着"问的是什么":只给结果表,读的人没法判断它是不是自己以为的那条查询
SQL {body.tool && {body.tool}} body.sql ?? ''} />
                  {body.sql}
                
)} {body.table ? : (
body.text} />
                    {body.text}
                  
)}
)}
) } /** * 工件里装的是什么。工具调用的快照是 {tool, args, result},查询的 result 还是一段 * JSON 字符串;节点产出是它自己的输出对象。认得出结果集就画成表,其余等宽显示 */ function unwrapArtifact(content: unknown): { table: Table | null; text: string; sql?: string; tool?: string } { let payload = content let sql: string | undefined let tool: string | undefined if (content && typeof content === 'object' && 'result' in content && ('tool' in content || 'args' in content)) { const snap = content as { tool?: unknown; args?: { sql?: unknown }; result?: unknown } payload = snap.result tool = typeof snap.tool === 'string' ? snap.tool : undefined sql = typeof snap.args?.sql === 'string' ? snap.args.sql : undefined } const table = typeof payload === 'string' ? parseQueryResult(payload) : payload && typeof payload === 'object' && Array.isArray((payload as any).columns) && Array.isArray((payload as any).rows) ? { columns: (payload as any).columns, rows: (payload as any).rows, truncated: !!(payload as any).truncated } : null const text = table ? '' : typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2) return { table, text, ...(sql ? { sql } : {}), ...(tool ? { tool } : {}) } } // ------------------------------------------------------------------------- // 成果 // ------------------------------------------------------------------------- function Output({ output, runClass, broken, question, onFollowUp, runId }: { output: Record; runClass?: string; broken?: boolean question?: string; onFollowUp?: (text: string) => void /** 这份成果出自哪次运行:带证据的字段点开片段时按它取证据链 */ runId?: string }) { const { dense } = useContext(Ctx) // _issuance 这类下划线开头的是内部字段,不是给人看的成果。 // 空值也要滤掉:output 里留一个 {"result": ""} 很常见(图跑通了但出口 // 没接上),它会渲染出一条什么都没有的分隔线——用户只会以为界面坏了 const entries = Object.entries(output) .filter(([k, v]) => !k.startsWith('_') && !isBlank(v)) const issuance = (output as any)._issuance const marks = useMemo(() => issuanceMarks(issuance), [issuance]) // 逐字等于报告撰写节点原文的字段:换成可逐段点开的文档,其余照旧。没有 _evidence 的 // 成果(旧运行、没有报告节点的图)一个字段都不会进这里,渲染和以前完全一样 const evidence = useMemo(() => evidenceFields(output), [output]) const firstEvidence = entries.find(([k, v]) => evidence.has(k) && typeof v === 'string')?.[0] // 第一份带证据的字段出自哪个报告节点:导出 HTML 时点名(服务端按它挑报告文档) const reportNode = firstEvidence ? evidence.get(firstEvidence)?.report : undefined const docRef = useRef(null) // 第一份报告文档取到之后,出具横幅多一行「N/N 数字有出处 · 无证据 M」 const [tallyDoc, setTallyDoc] = useState(null) const tally = useMemo(() => (tallyDoc ? docTally(tallyDoc) : null), [tallyDoc]) const banner = !!issuance?.tier const follow = useMemo(() => (onFollowUp ? followUpsOf(entries.map(([, v]) => v)) : []), // eslint-disable-next-line react-hooks/exhaustive-deps [output, onFollowUp]) if (!entries.length && !issuance) return null return (
{issuance && ( // 成果里有报告字段就告诉横幅:正文换成了报告文档,回指不上的数字不再是 Markdown 上的 // 虚线(列表序号里的画不了线),而是在报告的违规清单里。文档取到后再给计数和两个动作 docRef.current?.next() : undefined, onList: tallyDoc?.violations?.length ? () => docRef.current?.list() : undefined, } : undefined} /> )} {broken && ( // 复核判了不可信:答案照常给(可能有参考价值),但先说清楚它不能当结论。 // 不降透明度——那会让它更难读,而不是更不可信
以下内容不可作为结论使用
)} {/* 复制 / 导出贴着正文浮出,不贴着整块:上面有出具横幅时,贴整块的右上角会 正好压在横幅的「回指 N 个数字」上 */}
{entries.length > 0 && ( )}
{entries.map(([key, value]) => (
{entries.length > 1 && (
{key}
)} {evidence.has(key) && typeof value === 'string' ? ( ) : }
))}
{follow.length > 0 && onFollowUp && (
继续提问 {follow.map((f) => ( ))}
)}
) } /** 成果转成 Markdown:复制、导出都用它。表格按 Markdown 表格写,贴进周报就是表 */ function answerMarkdown(entries: [string, any][], question?: string, issuance?: any): string { const parts: string[] = [] if (question) parts.push(`> ${question.replace(/\n/g, '\n> ')}`) for (const [key, value] of entries) { if (entries.length > 1) parts.push(`### ${key}`) const table = typeof value === 'string' ? parseQueryResult(value) : value && typeof value === 'object' && Array.isArray(value.columns) && Array.isArray(value.rows) ? { columns: value.columns, rows: value.rows, truncated: false } : null if (table) { const esc = (v: unknown) => (v == null ? '' : String(v)).replace(/\|/g, '\\|').replace(/\n/g, ' ') parts.push([ `| ${table.columns.map(esc).join(' | ')} |`, `| ${table.columns.map(() => '---').join(' | ')} |`, ...table.rows.map((r: unknown[]) => `| ${r.map(esc).join(' | ')} |`), ].join('\n')) } else { parts.push(typeof value === 'string' ? value : '```json\n' + JSON.stringify(value, null, 2) + '\n```') } } if (issuance?.tier) { const cal = (issuance.calibers ?? []).map((c: any) => `${c.caliber} ${c.version}`).join('、') parts.push(`---\n出具档位:${issuanceLabel(issuance.tier)}${cal ? `(口径:${cal})` : ''}`) } return parts.join('\n\n') } /** 答案上的操作:复制、导出。悬停时浮出,键盘聚焦时也出来。 * * 成果带逐段证据(报告文档)时,导出从服务端拿自包含的 HTML:正文里的数字点得开口径、来源和计算方式, * 和网页上一样;Markdown 照旧保留一个小的(贴进周报的还是纯文本)。没有证据的成果(旧运行、没有报告节点 * 的图)照旧只导 Markdown。 */ function AnswerActions({ entries, question, issuance, runId, reportNode }: { entries: [string, any][]; question?: string; issuance?: any /** 这份成果出自哪次运行:带证据的报告从服务端导出成 HTML */ runId?: string /** 报告撰写节点的 id(成果 _evidence 标注的那份);没有时服务端自己挑成果标注的那一份 */ reportNode?: string }) { const md = () => answerMarkdown(entries, question, issuance) const [exporting, setExporting] = useState(false) const exportHtml = runId ? async () => { setExporting(true) try { const { blob, name } = await api.reportExport.download(runId, reportNode) const file = name ?? `${fileSafe(question ?? '报告')}-报告.html` saveBlob(file, blob) toast.ok(`已导出「${file}」`) } catch (e) { toast.error(e, { key: `report-export:${runId}` }) } finally { setExporting(false) } } : null return (
{exportHtml ? ( ) : null}
) } /** 存成文件(Blob):对象 URL 交给一个临时的链接点一下,稍后回收(同记录页可验证报告的下载写法) */ function saveBlob(name: string, blob: Blob) { const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = name a.click() setTimeout(() => URL.revokeObjectURL(url), 1000) } /** * 追问:从答案里的表格派生,不调模型。 * * 问数据的下一步往往就是「只看某一个」「换个维度比」,用户以前得自己想好措辞 * 再打一遍。拿不到表格就不给——凭空编的追问比没有更糟。 */ function followUpsOf(values: unknown[]): string[] { for (const v of values) { const table = typeof v === 'string' ? parseQueryResult(v) : v && typeof v === 'object' && Array.isArray((v as any).columns) && Array.isArray((v as any).rows) ? { columns: (v as any).columns as string[], rows: (v as any).rows as unknown[][], truncated: false } : null if (!table || !table.rows.length) continue const cat = table.columns.findIndex((_, j) => table.rows.every((r) => r[j] == null || !isNumeric(r[j]))) // 数值列里认得出是"量"的才拿来排序:编号、代码、分组也是数字,按它排没有意义 const metric = table.columns.findIndex((c, j) => isMetricColumn(c, table.rows, j)) const time = table.columns.findIndex((c) => /date|day|time|month|日期|时间|月/i.test(c)) // 只有一行时「只看某一个」「从高到低排」都是原样再问一遍 const several = table.rows.length >= 2 const out: string[] = [] if (several && cat >= 0 && table.rows[0][cat] != null) out.push(`只看「${String(table.rows[0][cat]).slice(0, 16)}」的明细`) if (time >= 0) out.push('按月拆分查看趋势') if (several && metric >= 0 && cat >= 0) out.push(`按 ${table.columns[metric]} 从高到低排序`) return out.slice(0, 3) } return [] } const TEXT_CAP = 3000 /** * 折叠长文本的切点。 * * 不能按字符硬切:模型的报告几乎总是 Markdown,切点一旦落在表格中间, * 前面的行渲染成表格、最后半行留成原始的 `| 1 | ThearchyHelios | …`, * 看上去就是一份被咬掉一口的报告——而它其实是完整的,只是被折叠了。 * * 所以只在行边界切;而且切点落进表格里时整块表格都不要,宁可少显示一段, * 也不要显示一张半截的表。 */ function cutForPreview(text: string, cap: number): string { const head = text.slice(0, cap) const atLine = head.lastIndexOf('\n') let cut = atLine > cap * 0.5 ? head.slice(0, atLine) : head // 收尾的连续表格行(含分隔行)一起去掉——只剩表头的表格比没有更难读 const lines = cut.split('\n') while (lines.length && /^\s*\|/.test(lines[lines.length - 1])) lines.pop() const withoutTable = lines.join('\n') // 整段都是表格时别把内容清空,那还不如原样折叠 if (withoutTable.trim()) cut = withoutTable return cut.trimEnd() } 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, marks, label }: { value: unknown; marks?: MarkSpec[]; label?: string }) { const { dense } = useContext(Ctx) 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 const shown = long && !full ? cutForPreview(text, TEXT_CAP) : text return (
{/* 模型几乎总是用 Markdown 组织回答。当纯文本显示的话,「1+1 等于 **2**」 就原样带着星号——它的结构全丢了。只有字符串才渲染:JSON.stringify 出来的东西按 Markdown 解会被 * 和 _ 搅乱 */} {typeof value === 'string' ? : (
{shown}
)} {long && (
{!full && ( // 光有一个"展开"按钮不够:用户看到的是一份读起来完整的报告, // 不会想到它下面还有。得先说"这里断了" …以下内容已折叠 )}
)}
) } /** * 出具声明里没通过 SQL 检查的查询(sql_checks):每条写是哪次查询、什么问题、影响了哪些引用,下一步写改来源查询的 SQL。 * 出具横幅和报告页顶部共用。颜色跟着档位:降档用提醒色,不予出具用失败色 */ export function IssuanceSqlChecks({ tier, checks }: { tier: string; checks: IssuanceSqlCheck[] }) { const tone = tier === 'withheld' ? 'var(--st-failed)' : 'var(--st-waiting)' return (
{RUN_SQL_CHECK_TEXT.issuanceTitle(tier)}
    {checks.map((c, i) => (
  • {c.query || RUN_SQL_CHECK_TEXT.issuanceQuery} {!!c.problems?.length && :{c.problems.join(';')}} {!!c.refs?.length && ({RUN_SQL_CHECK_TEXT.issuanceAffected(c.refs.join('、'))})}
  • ))}
{RUN_SQL_CHECK_TEXT.issuanceNext}
) } /** * 出具横幅里双盲复算、反方审稿报上来的一组缺口:failed「核查未通过」、unfinished「核查未完成」。排在「校验未全部完成」 * 上面,样子和它一样(同一种提醒色、小标题加圆点列表),一条都没有时不画 */ function IssuanceGaps({ kind, title, gaps }: { kind: 'failed' | 'unfinished'; title: string; gaps: string[] }) { if (!gaps.length) return null return (
{title}:
    {gaps.map((g) =>
  • · {g}
  • )}
) } const TIER_META: Record = { formal: { color: 'var(--st-done)', soft: 'var(--st-done-soft)', hint: ISSUANCE_HINT.formal }, degraded: { color: 'var(--st-waiting)', soft: 'var(--st-waiting-soft)', hint: ISSUANCE_HINT.degraded }, withheld: { color: 'var(--st-failed)', soft: 'var(--st-failed-soft)', hint: ISSUANCE_HINT.withheld }, } /** * 出具档位。全站唯一一份——画布助手栏、问数据页、运行详情都用它。 * * 数据取自 output._issuance 而不是 issuance 事件:事件里只有 tier / * missing_* / unmatched(计数),而 output 里那份还带着 matched_numbers、 * metrics_checked、calibers、unmatched_numbers[].token。拿事件当数据源, * 横幅会静默退化成"核对 0 个指标",比不显示更糟。 * * 降档的原因必须写在这里:以前只列缺指标和回指不上的数字,而「叙述模板渲染 * 为空」「指标集为空」这类校验根本没跑起来的缺口(gaps)一条都不显示——横幅 * 说「请对照下方声明」,下方什么都没有。缺口分几块:没通过 SQL 检查的单列在最前, * 双盲复算、反方审稿报上来的分「核查未通过」「核查未完成」,其余的归「校验未全部完成」。 * 运行详情的证据页、问数据的回答、画布右栏用的都是这一个横幅,三处说法一样。 */ export function IssuanceBanner({ issuance, runClass, evidence }: { issuance: any; runClass?: string /** * 成果里有报告文档时给:它的计数(文档取到之后才有)、跳到下一处无证据、打开违规清单。 * 给了它,正文里带证据的字段就不是 Markdown 了,「正文里已用虚线标出」不再成立 */ evidence?: { counts?: EvidenceTally; onNext?: () => void; onList?: () => void } }) { const tier = String(issuance?.tier ?? '') if (!tier) return null const meta = TIER_META[tier] ?? { color: 'var(--st-waiting)', soft: 'var(--st-waiting-soft)', hint: '' } const unmatched: any[] = issuance?.unmatched_numbers ?? [] // 没通过 SQL 检查的查询(出具声明的 sql_checks)单列一块、写在显眼处:数字都有出处,降档却是因为算数的查询本身有问题。 // 单列了的那几句(gap)不在其余缺口里重复 const sqlChecks: IssuanceSqlCheck[] = (Array.isArray(issuance?.sql_checks) ? issuance.sql_checks : []) .filter((x: unknown): x is IssuanceSqlCheck => !!x && typeof x === 'object') const sqlGaps = new Set(sqlChecks.map((x) => x.gap).filter(Boolean)) // 其余缺口里双盲复算、反方审稿报上来的另起两组:「核查未通过」(两路结果不一致、结论被推翻)、「核查未完成」(指标无法 // 复算),排在「校验未全部完成」上面,先后同时间线、证据页、成果节点的印章(分组见 decode.ts 的 splitIssuanceGaps)。 // 不分的话核查查出的问题读着像校验没做完,没核成的数又像被查出了错 const { failed: verifyFailed, unfinished: verifyUnfinished, other: gaps } = splitIssuanceGaps( (Array.isArray(issuance?.gaps) ? issuance.gaps.map(String) : []).filter((g: string) => !sqlGaps.has(g))) const calibers: any[] = issuance?.calibers ?? [] const matched = Number(issuance?.matched_numbers ?? 0) // 逐个数字的出处(matched[].caliber 形如「口径名 @ v2」):有它就能按卡数清楚, // 不必只在「只有一张卡」时才敢说数字来自哪 const perCaliber = new Map() for (const m of Array.isArray(issuance?.matched) ? issuance.matched : []) { if (m?.caliber) perCaliber.set(String(m.caliber), (perCaliber.get(String(m.caliber)) ?? 0) + 1) } const hitsOf = (c: any): number | undefined => perCaliber.get([c?.caliber, c?.version].filter(Boolean).join(' @ ')) // 数字之外另说的两样:可疑名字(按报告文档数,和文档自己的证据条同一个数)、没挂依据的结论句(只在要求挂依据时有) const counts = evidence?.counts const suspect = counts?.suspect ?? 0 const uncitedClaims = Number(issuance?.claims?.uncited_claims ?? 0) || 0 const numbers = counts ? evidenceTally(counts.cited, counts.total, counts.other) : '' const numbersOk = !counts?.none && !counts?.other // 数字都有出处只在完整出具时用绿色:降档时「10 个数字都有出处」画成绿的,读的人会以为一切正常 const numbersColor = !numbersOk ? 'var(--st-waiting)' : tier === 'formal' ? 'var(--st-done)' : 'var(--text-dim)' // 四期:结论句有判定(开了裁判,或探索运行里按需判过)时多一段「结论 4 句(支持 3 · 无证据 1)」。 // 没开裁判的文档里没挂依据的都数在这一段的「无证据」里,不再另起一句;开了裁判的文档里,没挂依据却送了 // 裁判的句子按判定数、预筛放掉的不数——出具照样按没挂依据计缺口(require_citation、judge 都是),这时 // 结论句那一段盖不全,照出具的数另起「没挂依据的结论句 N」,不能让花了档位的缺口从横幅上消失 const claims = counts?.claims?.total ? counts.claims : null const uncitedLine = uncitedClaims > (claims?.uncited ?? 0) return ( // data-issuance-banner:画布上成果节点的出具印章据此滚过来;tabIndex 让「去看出具」能把焦点带到这里 // outline-none 把 --tw-outline-style 设成了 none,data-[flash]:outline 读的就是它:得显式 // 写回 solid,带到这里的那一下描边才画得出来(以前焦点落上了、描边却是 none)
{issuanceLabel(tier)} {runClass === 'exploratory' && ( 探索运行 · 不进正式归档 )} 可追溯 {formatNumber(matched)} 个数字 · 核对 {formatNumber(issuance.metrics_checked ?? 0)} 个指标
{meta.hint &&
{meta.hint}
} {counts && (counts.total > 0 || counts.other > 0 || suspect > 0 || uncitedClaims > 0 || !!claims) && ( // 逐段证据的计数:和报告核对那一行、证据条同一种说法。可疑名字、没挂依据的结论句不是数字, // 各自另起一句。有无证据的数字、可疑名字、证据有问题(相矛盾、部分有依据、证据不足)的结论句时才给「定位下一处」—— // 结论句靠句末徽标定位;只是没挂依据的结论句没有徽标,定位不到
{numbers && ( {numbers} )} {suspect > 0 && ( {numbers ? '· ' : ''}{EVIDENCE_STATE.suspect.glyph} {EVIDENCE_TEXT.suspectTag} {formatNumber(suspect)} )} {claims && 0} />} {uncitedLine && ( {numbers || suspect > 0 || claims ? '· ' : ''}{EVIDENCE_TEXT.uncitedClaimsTag} {formatNumber(uncitedClaims)} )} {(counts.none > 0 || counts.other > 0 || suspect > 0 || claimProblems(claims) > 0) && evidence?.onNext && ( )}
)} {!!sqlChecks.length && } {!!gaps.length && (
{RUN_SQL_CHECK_TEXT.otherGaps}
    {gaps.map((g) =>
  • · {g}
  • )}
)} {!!issuance.missing_required?.length && (
缺少必需指标:{issuance.missing_required.join('、')}
)} {!!issuance.missing_expected?.length && (
缺少期望指标:{issuance.missing_expected.join('、')}(本期无数据)
)} {!!unmatched.length && (
{/* 带报告文档时正文是 EvidenceDoc:列表序号里的数字画不了线,逐条都在违规清单里; 文档还没取到(或取不到)时正文是不带标记的普通文本,什么都不许诺 */} {!evidence ? '无法追溯的数字(正文中已用虚线标出):' : evidence.onList ? EVIDENCE_TEXT.unmatchedInDoc : EVIDENCE_TEXT.unmatchedPlain} {unmatched.map((u: any, i: number) => ( {u?.token ?? String(u)} {u?.context && ({String(u.context).slice(0, 24)})} ))} {evidence?.onList && ( )}
)} {!!calibers.length && ( // 数字回指到哪张口径卡:按 matched[].caliber 逐张数(hitsOf 的 key 和后端 io.py 的 // 「口径名 @ 版本」同一种拼法,check-stream 盯着)。老后端没有逐个出处,只有一张卡时 // 回指上的数字必然来自它,可以直说
{calibers.map((c: any) => { const hits = hitsOf(c) return (
口径卡「{c.caliber}」{c.version} {calibers.length === 1 && matched > 0 && (hits == null || hits === matched) ? · 可追溯的 {matched} 个数字均来自此口径卡 : hits != null && · 可追溯 {hits} 个数字}
) })}
)}
) } /** 这一轮建出来的图:几步、每步是什么,可以放到画布上接着改 */ function GraphPeek({ graph, note, onOpen }: { graph: any; note?: string; onOpen?: (g: any) => void }) { const { dense } = useContext(Ctx) const [open, setOpen] = useState(false) const nodes = graph?.nodes ?? [] return (
{onOpen && ( )}
{open && (
{note && (
{note}
)} {nodes.map((n: any, i: number) => (
{i + 1} {nodeTypeLabel(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}
}
) }