mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 19:42:02 +00:00
265ba0c91d
两件事,一条心:把「网页上点数字看得到的效果」带出系统外。
① 报告导出:新增 GET /api/runs/{run_id}/report.html(app/report_export:build + 模板 + 浏览器端脚本)。
正文由服务端按报告文档的块、句、片段渲染成 HTML——不执行脚本也读得到整份报告(附件预览常常不执行脚本);
执行脚本后,带出处的数字、无证据的数字、可疑的表名字段名都点得开,面板是网页上证据面板同一个拼法的
出处链(api/evidence._chain):指标步骤带口径卡名与版本、原式、代入式、每个输入取自哪次查询的哪一格、
复算、双盲复算;查询步骤带 SQL 和被引用的行;数据源遮罩列照网页上的规矩写「已遮罩」。封存状态逐项标明;
没封存的运行照常导出(和网页上读报告一个道理)。同样的引用共用一条链,长报告不把整份快照抄几十遍。
入口在问数据答案和运行详情成果上的「导出」(带证据的报告给 HTML,Markdown 留一个小的;旧运行照旧只有
Markdown)——api.reportExport + AssistantStream 的 AnswerActions。
② 可验证报告(bundle)的数字详情补齐:点开数字原来只有出处和值,现在多一节口径卡——指标(名 + id)、
单位、原式、代入式、输入(取值 ← 路径、取自哪次查询的哪一行哪一列)、复算;输入指向的查询快照也随文件
附带(遮罩规则同嵌入那一套,build._card_input_queries),④ 引用链按「已核对的口径卡的输入」认它们
(哈希对不上的卡不认,免得替被改过的卡圆)。收件人不连服务也能看到每个数怎么算出来的。
测试:后端 tests/test_report_export.py(真跑运行导出,正文、出处链、遮罩、只读、404/409、共用链);
bundle_samples 的口径卡换成带原式/代入式/输入/复算的真实形状,另加 metric-only 样本页(只引用指标的
报告,快照从口径卡的输入这一条路附带);check-bundle 断言数字详情和这条引用链,加上篡改;新增
scripts/check-report-export.mjs(真导出断网点开、不执行脚本也读得到、成果上的导出按钮、旧答案没有
HTML 按钮、409 提示),挂进 check-all。README 补「导出两份」一节。全量 pytest 通过;check-bundle、
check-report-export、check-stream、check-evidence、check-chat 全部通过(check-runs 5 项失败为基线
上就有的沙箱数据形态问题,与本次无关)。
2599 lines
134 KiB
TypeScript
2599 lines
134 KiB
TypeScript
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<string, any> | 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<string, any> | null
|
||
/**
|
||
* 这一轮开始的时刻(毫秒时间戳)。有它,进行中的轮次头部才走实时计时;不给就从第一步
|
||
* 开始的时刻推。null 表示明确不要实时计时:问数据页刚从库里恢复、正在对账的轮次,
|
||
* 事件可能先到一两条,那不是它开始跑了
|
||
*/
|
||
startedAt?: number | null
|
||
/** 跑完的轮次花了多久(毫秒)。不给就从「完成」那一行推 */
|
||
elapsedMs?: number
|
||
/**
|
||
* 头部要不要提醒。phase 只分得出跑没跑完;「图放上去了,但还有 2 处问题」
|
||
* 也是 done,头部却不该是一个安静的绿勾
|
||
*/
|
||
tone?: 'warn' | 'failed'
|
||
/** 头部徽标用哪个状态码(lib/status)。不给就按 phase:已取消、已中断这些 phase 表达不了 */
|
||
statusCode?: string
|
||
}
|
||
|
||
const ICONS: Record<StepKind, typeof Wrench> = {
|
||
node: CircleDot, think: Brain, llm: Sparkles, query: Database, schema: Table2,
|
||
tool: Wrench, code: Terminal, branch: GitBranch, human: Hand,
|
||
issuance: FileCode, note: CircleDot, error: XCircle, lifecycle: CircleCheck,
|
||
}
|
||
|
||
/** 步骤行共用的上下文:联动回调、时钟差、工件抽屉。一层层往下传 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<StreamCtx>({
|
||
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<HTMLDivElement>(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<HTMLElement>('[data-step-status="failed"]')
|
||
const approval = el.querySelector<HTMLElement>('[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<string | null>(null)
|
||
const onPointerOver = onStepHover && ((e: ReactMouseEvent) => {
|
||
const id = (e.target as HTMLElement).closest<HTMLElement>('[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<StreamCtx>(() => ({
|
||
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 <div className={rootClass}>{empty}{footer}</div>
|
||
}
|
||
|
||
return (
|
||
<Ctx.Provider value={ctx}>
|
||
<div className={rootClass}>
|
||
<div className="relative min-h-0 flex-1">
|
||
<div ref={scrollRef} onScroll={onScroll} data-stream-scroll=""
|
||
onMouseOver={onPointerOver || undefined} onMouseLeave={onPointerLeave || undefined}
|
||
className="h-full overflow-y-auto overflow-x-hidden">
|
||
{/* 留白放在里面一层:滚动容器自己带 padding 的话,吸顶的轮次头会停在离顶边
|
||
一个 padding 的地方,上面露出一条滚过去的正文 */}
|
||
<div className={dense ? 'px-2.5 py-3' : 'px-4 py-4'}>
|
||
<div className={clsx('space-y-4', !dense && 'mx-auto max-w-3xl')}>
|
||
{turns.map((turn, i) => (
|
||
<div key={turn.id}>
|
||
<TurnCard turn={turn} last={i === turns.length - 1}
|
||
approvals={approvalsFor?.(turn)} onOpenGraph={onOpenGraph}
|
||
onFollowUp={i === turns.length - 1 ? onFollowUp : undefined} />
|
||
{renderTurnActions && <TurnActions>{renderTurnActions(turn, i)}</TurnActions>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{fresh > 0 && (
|
||
<button
|
||
type="button"
|
||
data-jump-latest=""
|
||
onClick={jumpToLatest}
|
||
className="fade-up absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1 rounded-full border px-2.5 py-1 text-2xs shadow-elev-2 transition-colors hover:bg-hover"
|
||
style={{ background: 'var(--bg-elev)', color: 'var(--text)' }}
|
||
>
|
||
<ArrowDown size={11} aria-hidden /> {fresh} 条新进展 · 跳到最新
|
||
</button>
|
||
)}
|
||
</div>
|
||
{footer}
|
||
</div>
|
||
<ArtifactViewer id={artifact?.id ?? null} title={artifact?.title} onClose={() => setArtifact(null)} />
|
||
</Ctx.Provider>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 节点最后一次执行的那一块:执行过多次的,是分轮折叠里最后那一轮(它总是列着的);
|
||
* 只执行过一次的,是节点那一行本身。节点行里套着它自己的子步骤(同一个 data-node-id),
|
||
* 取最外层那个
|
||
*/
|
||
function lastExecOf(root: HTMLElement, nodeId: string): HTMLElement | null {
|
||
const id = CSS.escape(nodeId)
|
||
const row = [...root.querySelectorAll<HTMLElement>(`[data-step-status][data-node-id="${id}"]`)]
|
||
.find((el) => !el.parentElement?.closest(`[data-node-id="${id}"]`))
|
||
if (!row) return null
|
||
const execs = row.querySelectorAll<HTMLElement>(`[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 <div className="mt-1.5 flex flex-wrap items-center gap-1.5">{children}</div>
|
||
}
|
||
|
||
/**
|
||
* 定位到的那一行短暂描边一下,告诉人"是这里"。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 (
|
||
<div className="fade-up" data-turn={turn.id}>
|
||
{turn.question && (
|
||
// 宽屏走聊天气泡(右对齐、实心),窄栏里气泡会挤成一条竖带,
|
||
// 改用左侧强调条——同样能一眼看出"这句是你说的",但不吃宽度
|
||
<div className={clsx('mb-2 flex', dense ? 'justify-start' : 'justify-end')}>
|
||
<div
|
||
className={clsx(
|
||
'whitespace-pre-wrap leading-relaxed [overflow-wrap:anywhere]',
|
||
dense
|
||
? 'w-full border-l-2 py-0.5 pl-2 text-[11.5px]'
|
||
: 'max-w-[80%] rounded-lg rounded-br-sm px-3 py-1.5 text-sm',
|
||
)}
|
||
style={dense
|
||
? { borderColor: 'var(--accent)', color: 'var(--text)' }
|
||
: { background: 'var(--accent-solid)', color: 'var(--on-accent)' }}
|
||
>
|
||
{turn.question}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="rounded-lg border bg-panel">
|
||
<TurnHead turn={turn} />
|
||
|
||
<div className={clsx(dense ? 'px-2.5 pb-2.5' : 'px-3 pb-3')}>
|
||
{turn.phase === 'error' && turn.error && (
|
||
// 报错不截成一行:原因和怎么办要读得全,原文收进可展开的技术细节
|
||
<TurnError turn={turn} />
|
||
)}
|
||
|
||
{/* 还在流、但还没成为步骤的思考。解码器已经把思考并成了步骤行的话就不再
|
||
重复一遍——同一段话在灰框和步骤行里各出现一次 */}
|
||
{turn.phase === 'running' && turn.thinking
|
||
&& !plan.some((s) => s.kind === 'think' && s.status === 'running')
|
||
&& <ThinkingTail text={turn.thinking} />}
|
||
|
||
{plan.length > 0 && (
|
||
<StepSection
|
||
label={exec.length ? '规划' : '过程'}
|
||
steps={plan}
|
||
// 开始运行之后规划那段自动收起:"建图时加的节点"和"执行时跑的节点"
|
||
// 同名同序地再出现一遍只是噪音
|
||
defaultOpen={exec.length ? false : turn.phase !== 'done' || last}
|
||
turn={turn}
|
||
/>
|
||
)}
|
||
{exec.length > 0 && (
|
||
<StepSection label="执行" steps={exec} turn={turn}
|
||
titled={plan.length > 0}
|
||
defaultOpen={turn.phase !== 'done' || last} />
|
||
)}
|
||
|
||
<div data-approval-slot="">{approvals}</div>
|
||
|
||
{/* 没查库这件事必须说在结论前面。「涉及数据必须真查」是这条路径上
|
||
最硬的一条约定,放开它之后,用户得能一眼分清哪些结论背后真的
|
||
动了库、哪些只是在复述前面几轮 */}
|
||
{turn.noQuery && (
|
||
<div className="mt-2 flex items-start gap-1.5 rounded border px-2 py-1.5 text-2xs leading-relaxed text-dim">
|
||
<Info size={11} className="mt-[2px] shrink-0" aria-hidden />
|
||
<span>本条未查询数据库,基于前几轮的结果作答</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 复核说明排在成果**上方**,和上面那条声明同一个位置。排在下面
|
||
等于让人读完整个结论、信了,才知道它是在什么条件下得出的 */}
|
||
{turn.review && <ReviewNote review={turn.review} />}
|
||
|
||
{turn.output && (
|
||
<Output output={turn.output} runClass={turn.runClass} broken={broken}
|
||
question={turn.question} onFollowUp={onFollowUp} runId={turn.outputRun ?? turn.runId} />
|
||
)}
|
||
|
||
{turn.rawOutput && (
|
||
<details className="mt-1.5">
|
||
<summary className="cursor-pointer text-2xs text-dim hover:text-fg">
|
||
复核改写过此答案,查看改写前的原文
|
||
</summary>
|
||
<div className="mt-1.5">
|
||
<Output output={turn.rawOutput} runId={turn.outputRun ?? turn.runId} />
|
||
</div>
|
||
</details>
|
||
)}
|
||
|
||
{turn.catalogPatches?.map((p) => (
|
||
<CatalogPatchCard key={p.key} patch={p} turnId={turn.id} dense={dense} />
|
||
))}
|
||
|
||
{!!turn.graph?.nodes?.length && (
|
||
<GraphPeek graph={turn.graph} note={turn.graphNote}
|
||
onOpen={onOpenGraph && ((g) => onOpenGraph(g, turn.question))} />
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 轮次头:状态、阶段进度、实时计时。
|
||
*
|
||
* 吸顶:长运行往下翻的时候,"现在到哪了、跑了多久"一直看得见——这就是回看
|
||
* 历史运行时那条迷你摘要。状态变化另放一个只有读屏器看得见的播报区:计时每
|
||
* 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 (
|
||
// 吸顶之后正文从它下面滚过去,没有这道线的话头和正文糊成一片。没有步骤的轮次
|
||
// (只回了一句话)下面紧跟着成果区自己的分隔线,不再画第二道
|
||
<div className={clsx('sticky top-0 z-[1] rounded-t-lg bg-panel', dense ? 'px-2.5 pt-2 pb-1.5' : 'px-3 pt-2.5 pb-2',
|
||
(turn.steps.length > 0 || live) && 'border-b')}
|
||
style={{ borderColor: 'var(--hairline, var(--border))' }}>
|
||
<div className={clsx('flex items-center gap-2', dense ? 'text-[11.5px]' : 'text-xs')}>
|
||
<StatusBadge status={badgeCode} size={13} decorative />
|
||
<span className={clsx('min-w-0 truncate font-medium', !tone && 'text-fg')}
|
||
style={tone ? { color: tone } : undefined} data-turn-status=""
|
||
title={verdict ? `${base} · ${verdict}` : base}>
|
||
{base}
|
||
{verdict && <span data-verdict="" style={{ color: verdictTone }}> · {verdict}</span>}
|
||
</span>
|
||
{tags.length > 0 && (
|
||
<span className="tnum min-w-0 shrink truncate text-2xs text-dim">{tags.join(' · ')}</span>
|
||
)}
|
||
{tier && (
|
||
// 出具档位也挂在吸顶的头上:往下翻到报告中段时,"这份结论能不能当真"还看得见。
|
||
// 降档、不予出具带个警示形状,只靠颜色的话色弱的人读不出它和「完整出具」的差别
|
||
<span data-head-tier={tier}
|
||
className="inline-flex shrink-0 items-center gap-0.5 rounded px-1 text-2xs font-medium"
|
||
title={TIER_META[tier]?.hint}
|
||
style={{ color: TIER_META[tier]?.color ?? 'var(--st-waiting)',
|
||
background: TIER_META[tier]?.soft ?? 'var(--st-waiting-soft)' }}>
|
||
{tier !== 'formal' && <AlertTriangle size={10} aria-hidden />}
|
||
{issuanceLabel(tier)}
|
||
</span>
|
||
)}
|
||
<span className="flex-1" />
|
||
{elapsed != null && (
|
||
<span className="mono tnum shrink-0 text-2xs" style={{ color: 'var(--st-running)' }}
|
||
title="已运行">
|
||
{formatClock(elapsed)}
|
||
</span>
|
||
)}
|
||
{turn.runId && (
|
||
<Link to={`/runs/${turn.runId}`} title="在运行记录中查看"
|
||
className="mono shrink-0 rounded px-1 text-2xs text-dim underline-offset-2 transition-colors hover:bg-hover hover:text-fg hover:underline">
|
||
{shortId(turn.runId)}
|
||
</Link>
|
||
)}
|
||
<span className="sr-only" role="status" aria-live="polite">
|
||
{`${base}${verdict ? `,${verdict}` : ''}${tier ? `,${issuanceLabel(tier)}` : ''}`}
|
||
</span>
|
||
</div>
|
||
{live && progress.current && (
|
||
<div className="mt-0.5 truncate pl-[21px] text-2xs text-dim" title={progress.current.title}>
|
||
正在:{progress.current.title}
|
||
</div>
|
||
)}
|
||
{showRail && (
|
||
<div className="mt-1.5 flex gap-[3px]" role="progressbar" aria-label="节点进度"
|
||
aria-valuemin={0} aria-valuemax={progress.total} aria-valuenow={progress.done}
|
||
data-node-rail="">
|
||
{Array.from({ length: progress.total! }, (_, i) => {
|
||
const n = rail![i]
|
||
// 还没轮到的画成空心格:一格一格数得清还剩几步。填成 --bg-hover 的话和栏底
|
||
// 只差 1.1:1,5 个节点跑到第 3 个看着像一共 4 格;填成未运行色又和「已跳过」撞色
|
||
return (
|
||
<span key={n?.id ?? `todo-${i}`} title={n ? `${n.title} · ${statusLabel(n.status)}` : '尚未开始'}
|
||
className="h-[3px] min-w-0 flex-1 rounded-full"
|
||
style={n ? { background: RAIL_COLOR[n.status] ?? 'var(--st-done)' }
|
||
: { boxShadow: 'inset 0 0 0 1px var(--st-idle)' }} />
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
{live && fraction != null && !showRail && (
|
||
// 节点进度压在头部的底线上:按不同节点数,不按执行次数,循环不会让它倒退或超过 100%
|
||
<div className="absolute inset-x-0 -bottom-px h-[2px] overflow-hidden"
|
||
role="progressbar" aria-valuemin={0} aria-valuemax={progress.total} aria-valuenow={progress.done}
|
||
aria-label="节点进度">
|
||
<div className="h-full origin-left transition-transform duration-300"
|
||
style={{ background: 'var(--st-running)', transform: `scaleX(${fraction})` }} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 节点轨最多画几格。再多就是一条挤满细缝的条,不如一根进度条 */
|
||
const RAIL_MAX = 15
|
||
|
||
const RAIL_COLOR: Record<string, string> = {
|
||
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<string>()
|
||
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 <div className="mt-1.5"><StepList steps={steps} depth={0} turnMs={turnMs(turn)} /></div>
|
||
}
|
||
return (
|
||
<div className="mt-1.5">
|
||
<button type="button"
|
||
className="flex w-full items-center gap-1.5 py-0.5 text-2xs text-dim transition-colors hover:text-fg"
|
||
aria-expanded={open}
|
||
onClick={() => { touched.current = true; setOpen((v) => !v) }}>
|
||
<ChevronRight size={11} aria-hidden className="shrink-0 transition-transform"
|
||
style={{ transform: open ? 'rotate(90deg)' : 'none' }} />
|
||
<span className="font-medium tracking-wide">{label}</span>
|
||
<span className="tnum">{[ms >= 10 ? formatDuration(ms) : '', `${n} 步`].filter(Boolean).join(' · ')}</span>
|
||
{failed && !open && <StatusBadge status="failed" size={10} decorative />}
|
||
<span className="ml-1 h-px flex-1" style={{ background: 'var(--hairline, var(--border))' }} />
|
||
</button>
|
||
{open && <div className="mt-0.5"><StepList steps={steps} depth={0} turnMs={turnMs(turn)} /></div>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 不必单列的生命周期行:「开始运行」「完成」「已取消」和轮次头说的是同一件事。
|
||
* 「继续运行」带着从哪接着跑、谁发起的才留;老数据里光秃秃的一句,审批行上的
|
||
* 「→ 已放行」已经说过了,而且步骤按节点归并之后它的位置也对不上真实的先后
|
||
*/
|
||
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 (
|
||
<div className="mt-2 rounded border px-2 py-1.5 text-2xs leading-relaxed"
|
||
style={{
|
||
borderColor: broken ? 'var(--st-failed)' : 'var(--border)',
|
||
color: broken ? 'var(--st-failed)' : 'var(--text-dim)',
|
||
background: broken ? 'var(--st-failed-soft)' : undefined,
|
||
}}>
|
||
<div className="flex items-start gap-1.5">
|
||
<Icon size={11} className="mt-[2px] shrink-0" aria-hidden />
|
||
<span className="whitespace-pre-wrap">{review.note}</span>
|
||
</div>
|
||
{!!review.signals.length && (
|
||
<details className="mt-1 pl-[17px]">
|
||
<summary className="cursor-pointer" style={{ color: 'var(--st-waiting)' }}>
|
||
检测到 {review.signals.length} 处异常
|
||
</summary>
|
||
<ul className="mt-1 space-y-0.5 text-dim">
|
||
{review.signals.map((s, i) => (
|
||
<li key={i}>· {s.detail}</li>
|
||
))}
|
||
</ul>
|
||
</details>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 拆好的一处报错:发生了什么、为什么、怎么办、该去哪儿改、原文 */
|
||
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 (
|
||
<button type="button" className={cls} data-fix="canvas" data-fix-node={nodeId} data-fix-field={field || undefined}
|
||
onClick={() => onOpen(nodeId, field || undefined)}>
|
||
<Settings2 size={10} aria-hidden /> {sql ? RUN_SQL_CHECK_TEXT.openSql(label) : `打开${label ? `「${label}」的` : ''}设置`}
|
||
</button>
|
||
)
|
||
}
|
||
if (fix === 'settings') {
|
||
return <Link to={to ?? '/settings/providers'} className={cls} data-fix="settings"><Settings2 size={10} aria-hidden /> 去模型接入</Link>
|
||
}
|
||
if (fix === 'catalog' && to) {
|
||
// 助手参考的大库还没有数据目录:直达那个数据源的目录页去起草
|
||
return (
|
||
<Link to={to} className={cls} data-fix="catalog"><Database size={10} aria-hidden /> {COPILOT_CONTEXT_TEXT.toCatalog}</Link>
|
||
)
|
||
}
|
||
if (fix === 'tools') {
|
||
// 数据源工具(db_query__ 这类)不在工具库里接入,在数据页
|
||
const data = !!to?.startsWith('/data')
|
||
return (
|
||
<Link to={to ?? '/tools'} className={cls} data-fix={data ? 'data' : 'tools'}>
|
||
{data ? <Database size={10} aria-hidden /> : <Wrench size={10} aria-hidden />}
|
||
{' '}{data ? '前往「数据」页接入' : first ? '去改参数定义' : '去工具库'}
|
||
</Link>
|
||
)
|
||
}
|
||
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 (
|
||
<div role="alert" data-turn-error=""
|
||
className="mt-2 flex items-start gap-2 rounded-lg border px-3 py-2 text-xs"
|
||
style={{ borderColor: 'color-mix(in srgb, var(--st-failed) 35%, var(--border))',
|
||
background: 'color-mix(in srgb, var(--st-failed) 6%, transparent)' }}>
|
||
<AlertCircle size={14} className="mt-0.5 shrink-0" style={{ color: 'var(--st-failed)' }} aria-hidden />
|
||
<div className="min-w-0 flex-1">
|
||
<div className="font-medium text-fg [overflow-wrap:anywhere]">{f.title}</div>
|
||
{whereId && whereLabel && (
|
||
onFocus
|
||
? (
|
||
<button type="button" className="mt-0.5 text-2xs text-dim underline-offset-2 hover:text-fg hover:underline"
|
||
title="在画布上定位这个节点" onClick={() => onFocus(whereId)}>
|
||
出错的节点:「{whereLabel}」
|
||
</button>
|
||
)
|
||
: <div className="mt-0.5 text-2xs text-dim">出错的节点:「{whereLabel}」</div>
|
||
)}
|
||
{f.reason && <div className="mt-0.5 leading-relaxed text-dim [overflow-wrap:anywhere]">{f.reason}</div>}
|
||
{f.action && (
|
||
<div className="mt-0.5 flex items-start gap-1 leading-relaxed text-dim">
|
||
<CornerDownRight size={11} className="mt-[3px] shrink-0" aria-hidden />
|
||
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">{f.action}</span>
|
||
</div>
|
||
)}
|
||
<div className="flex flex-wrap items-center gap-x-2">
|
||
<FixAction fix={f.fix} nodeId={fixId} label={fixLabel} to={f.fixTo} first={f.fixFirst} />
|
||
</div>
|
||
{f.raw && <div className="mt-1"><TechDetails raw={f.raw} /></div>}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 思考的尾窗:只露最后几行并跟着滚,不然一大坨会把步骤挤到屏幕外。 */
|
||
function ThinkingTail({ text }: { text: string }) {
|
||
const ref = useRef<HTMLDivElement>(null)
|
||
useEffect(() => {
|
||
if (ref.current) ref.current.scrollTop = ref.current.scrollHeight
|
||
}, [text])
|
||
return (
|
||
<div ref={ref}
|
||
className="mt-2 max-h-20 overflow-y-auto whitespace-pre-wrap rounded bg-bg px-2 py-1.5 text-2xs italic leading-relaxed text-dim">
|
||
{text}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// 步骤
|
||
// -------------------------------------------------------------------------
|
||
|
||
/** 同一节点执行超过这么多次才按轮收起 */
|
||
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 (
|
||
<div className={clsx(depth > 0 && 'ml-[5px] border-l pl-2.5')}
|
||
style={depth > 0 ? { borderColor: 'var(--hairline, var(--border))' } : undefined}>
|
||
{hidden > 0 && (
|
||
<button type="button"
|
||
className="flex items-center gap-1 py-0.5 pl-[18px] text-2xs text-dim transition-colors hover:text-fg"
|
||
onClick={() => setAll(true)}>
|
||
<ChevronRight size={10} aria-hidden />
|
||
展开前面的 {hidden} 条
|
||
</button>
|
||
)}
|
||
{shown.map((step) => <StepRow key={step.id} step={step} depth={depth} turnMs={turnMs} />)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 状态有自己剪影的就画剪影(全站同一套),其余的画这一步是什么 */
|
||
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 <PhaseMark step={step} />
|
||
return <StepLine step={step} depth={depth} turnMs={turnMs} />
|
||
}
|
||
|
||
/**
|
||
* 执行里的分段线:「继续运行 · 由 张工 发起」。它标的是这次运行在时间线上的一个
|
||
* 转折,不是一件做完或做砸的事——画成打勾或红叉就像多了一个步骤(失败的运行里
|
||
* 「继续运行」以前是一个红叉,读起来像续跑本身出了错)
|
||
*/
|
||
function PhaseMark({ step }: { step: Step }) {
|
||
const line = { background: 'var(--hairline, var(--border))' }
|
||
return (
|
||
<div className="flex items-center gap-1.5 py-1 text-2xs text-dim" data-phase-mark="">
|
||
<span className="h-px w-2 shrink-0" style={line} />
|
||
<Play size={9} aria-hidden className="shrink-0" fill="currentColor" />
|
||
<span className="shrink-0 font-medium">{step.title}</span>
|
||
{step.sub && <span className="min-w-0 truncate text-faint" title={step.sub}>{step.sub}</span>}
|
||
<span className="h-px min-w-3 flex-1" style={line} />
|
||
{step.meta && <span className="mono tnum shrink-0">{step.meta}</span>}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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 (
|
||
<div
|
||
className={clsx(
|
||
// scroll-mt:定位到这一行时别让吸顶的轮次头盖住它
|
||
'scroll-mt-14 rounded py-[3px] transition-colors data-[flash]:outline data-[flash]:outline-1 data-[flash]:outline-offset-1',
|
||
'data-[flash=failed]:outline-[color:var(--st-failed)] data-[flash=focus]:outline-[color:var(--accent)]',
|
||
active && 'bg-hover',
|
||
// 减少动效时扫光和转圈都停住,"在跑"改由左边一道强调色竖线说
|
||
running && reduced && 'border-l-2 pl-1.5',
|
||
)}
|
||
style={running && reduced ? { borderColor: 'var(--st-running)' } : undefined}
|
||
data-node-id={step.nodeId}
|
||
data-step-status={failed ? 'failed' : status}
|
||
data-step-code={step.code}
|
||
aria-busy={running || undefined}
|
||
>
|
||
<button
|
||
type="button"
|
||
className={clsx('flex w-full items-start gap-1.5 text-left',
|
||
(expandable || linked) && 'cursor-pointer hover:opacity-85')}
|
||
style={{ color }}
|
||
onClick={toggle}
|
||
disabled={!expandable && !(step.nodeId && onFocus)}
|
||
aria-expanded={expandable ? open : undefined}
|
||
title={linked ? '点击在画布上定位该节点' : undefined}
|
||
>
|
||
<span className={clsx('shrink-0', dense ? 'mt-[3px]' : 'mt-[4px]')}>
|
||
{badge
|
||
? <StatusBadge status={badge} size={11} decorative
|
||
style={badge === 'done' ? { color: 'var(--st-done)' } : undefined} />
|
||
: <Icon size={11} aria-hidden />}
|
||
</span>
|
||
<span className="min-w-0 flex-1">
|
||
<span className={clsx('block leading-relaxed [overflow-wrap:anywhere]',
|
||
dense ? 'text-[11px]' : 'text-xs',
|
||
step.kind === 'think' && 'italic',
|
||
(status === 'skipped' || status === 'cancelled') && 'text-faint',
|
||
// 一道光扫过文字。比转圈多说一件事——它在出东西,不只是在等
|
||
running && !reduced && !step.children?.length && 'shimmer')}>
|
||
{step.title}
|
||
</span>
|
||
{sub && (
|
||
// 副标题平时是淡色斜体的旁白(这次调用前在想什么、为什么跳过);出了事的行
|
||
// 它说的是为什么,用正体、跟着行的颜色,别让最要紧的那句成了最淡的字
|
||
<span className={clsx('block truncate text-2xs leading-snug',
|
||
failed || step.level === 'warn' ? 'not-italic' : 'italic text-faint')}
|
||
style={failed ? { color: 'var(--st-failed)' } : step.level === 'warn' ? { color: 'var(--text-dim)' } : undefined}
|
||
title={sub}>
|
||
{sub}
|
||
</span>
|
||
)}
|
||
</span>
|
||
<StepMeta step={step} depth={depth} turnMs={turnMs} />
|
||
{expandable && (
|
||
<ChevronRight size={10} aria-hidden className="mt-[3px] shrink-0 opacity-50"
|
||
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />
|
||
)}
|
||
</button>
|
||
|
||
{step.next && (
|
||
// 下一步不收进展开区:模型没真调工具、团队轮数用完这种,人要的就是这一句
|
||
<div data-step-next="" className="flex flex-wrap items-start gap-x-1.5 pl-[18px] text-2xs leading-snug text-dim">
|
||
<span className="flex min-w-0 flex-1 items-start gap-1">
|
||
<CornerDownRight size={10} className="mt-[2px] shrink-0" aria-hidden />
|
||
<span className="min-w-0 [overflow-wrap:anywhere]">{step.next}</span>
|
||
</span>
|
||
{/* 要改的是另一个节点(指标的问题出在来源查询的 SQL 上):入口落到那个节点的那一栏 */}
|
||
<FixAction fix={step.fix} nodeId={step.fixNode ?? step.nodeId} field={step.fixField}
|
||
label={step.fixNode ? step.fixLabel : undefined} to={step.fixTo} />
|
||
</div>
|
||
)}
|
||
|
||
{open && <StepDetail step={step} table={table} explained={explained} />}
|
||
|
||
{/* 泳道排在子步骤**前面**:先给一眼看清的形状(谁做了哪几轮、哪些是
|
||
同时进行的、各花多久),再往下是每一步的内容。反过来的话,得把
|
||
十几行步骤读完才知道这个团队是怎么分工的 */}
|
||
{step.team && <TeamLanes team={step.team} />}
|
||
|
||
{(step.execs?.length ?? 0) > 1 && step.children?.length
|
||
? <ExecGroups step={step} turnMs={turnMs} />
|
||
: !!step.children?.length && (
|
||
<StepList steps={step.children} depth={depth + 1} turnMs={turnMs}
|
||
fold={step.kind === 'node' && status === 'done'} />
|
||
)}
|
||
{step.artifact && !open && step.kind === 'query' && (
|
||
<button type="button"
|
||
className="ml-[18px] mt-0.5 flex items-center gap-1 text-2xs text-dim transition-colors hover:text-fg"
|
||
onClick={() => openArtifact(step.artifact!, step.title)}>
|
||
<ShieldCheck size={10} aria-hidden /> 完整结果 {shortId(step.artifact, 8)}
|
||
<ExternalLink size={9} aria-hidden />
|
||
</button>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 行尾:做完的写耗时(配一根按本轮总时长归一化的细条,慢在哪一眼能看出来),
|
||
* 在跑的走实时计时。
|
||
*/
|
||
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 && (
|
||
<span className={clsx('mt-[7px] h-[3px] shrink-0 overflow-hidden rounded-full', dense ? 'w-8' : 'w-12')}
|
||
style={{ background: 'var(--bg-hover)' }} aria-hidden>
|
||
<span className="block h-full origin-left rounded-full"
|
||
style={{
|
||
transform: `scaleX(${Math.max(0.04, share)})`,
|
||
background: failed ? 'var(--st-failed)' : 'color-mix(in srgb, var(--text-dim) 70%, transparent)',
|
||
}} />
|
||
</span>
|
||
)}
|
||
{/* 「开始运行」那一行的秒表和轮次头部的是同一个数,不重复 */}
|
||
{running && step.startedAt != null && step.kind !== 'lifecycle'
|
||
? <LiveClock since={step.startedAt} label={reduced ? '进行中' : undefined} limitS={step.limitS} />
|
||
: (step.meta || STOPPED[step.status ?? '']) && !(running && step.startedAt != null) && (
|
||
<span className={clsx('tnum mt-[1px] shrink-0 text-dim', dense ? 'text-[10px]' : 'text-2xs',
|
||
step.meta && 'mono')}>
|
||
{step.meta || STOPPED[step.status ?? '']}
|
||
</span>
|
||
)}
|
||
{step.repeat && step.repeat.ms.length >= 3 && <Sparkline values={step.repeat.ms} />}
|
||
</>
|
||
)
|
||
}
|
||
|
||
/** 被停下的步骤在行尾说一声停在哪种状态:灰色方块和"做完了"光看图标分不太开 */
|
||
const STOPPED: Record<string, string> = {
|
||
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 (
|
||
<span className={clsx('mono tnum mt-[1px] shrink-0', dense ? 'text-[10px]' : 'text-2xs')}
|
||
style={{ color: over ? 'var(--st-waiting)' : 'var(--st-running)' }}
|
||
{...(over ? { 'data-over-limit': '', title: `此步骤时限为 ${formatNumber(limitS!)} 秒,超时将停止等待` } : {})}>
|
||
{over ? `已超出 ${formatNumber(limitS!)} 秒上限 · ` : label ? `${label} · ` : ''}{formatClock(ms)}
|
||
</span>
|
||
)
|
||
}
|
||
|
||
/** 一串耗时的静态折线。不动画:它说的是已经发生的分布,不是正在发生的事 */
|
||
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 (
|
||
<svg width={w} height={h} className="mt-[2px] shrink-0" aria-hidden>
|
||
<polyline points={d} fill="none" stroke="var(--text-faint)" strokeWidth="1" strokeLinejoin="round" />
|
||
</svg>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 同一个节点执行了很多次:按轮折叠。
|
||
*
|
||
* 默认只展开最后一轮和出了问题的那几轮,其余收成一行摘要(几轮、中位、最慢、
|
||
* 哪几轮有提醒),配一条每轮耗时的折线——最慢那一拍一眼就能看见。摘要里点名的
|
||
* 那几轮(最慢那一次、有提醒的几次)点一下就列出来,不用去一百多轮里翻。
|
||
*/
|
||
function ExecGroups({ step, turnMs }: { step: Step; turnMs?: number }) {
|
||
const groups = useMemo(() => childrenByExec(step), [step])
|
||
const [expanded, setExpanded] = useState<Set<number>>(() => new Set())
|
||
// 从摘要里点名要看的轮次:不展开全部也单独列出来
|
||
const [pinned, setPinned] = useState<Set<number>>(() => 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<HTMLDivElement>(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<number>) => {
|
||
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<HTMLElement>(`[data-exec="${reveal.n}"]`)
|
||
if (!el) return
|
||
el.scrollIntoView({ block: 'nearest' })
|
||
return flash(el, 'focus')
|
||
}, [reveal])
|
||
|
||
return (
|
||
<div ref={boxRef} data-exec-groups={step.nodeId ?? ''} className="ml-[5px] border-l pl-2.5"
|
||
style={{ borderColor: 'var(--hairline, var(--border))' }}>
|
||
{quiet.length > 0 && (
|
||
<div className="flex items-start gap-1.5 py-[3px] text-2xs text-dim">
|
||
<span className="min-w-0 flex-1">
|
||
<span className="flex min-w-0 items-center gap-1">
|
||
<button type="button"
|
||
className="flex min-w-0 items-center gap-1.5 text-left transition-colors hover:text-fg"
|
||
aria-expanded={all}
|
||
onClick={() => {
|
||
// 收起就是字面意思:只剩要紧的那几轮,点名列出来的也收回去
|
||
if (all) { setPinned(new Set()); setLimit(LIST_HEAD) }
|
||
setAll((v) => !v)
|
||
}}>
|
||
<ChevronRight size={10} aria-hidden className="shrink-0 transition-transform"
|
||
style={{ transform: all ? 'rotate(90deg)' : 'none' }} />
|
||
<span className="truncate">{all ? '收起,只显示重点轮次' : `另外 ${hiddenNow} 轮`}</span>
|
||
</button>
|
||
{warned.length > 0 && (
|
||
<button type="button" aria-pressed={namedOn(warned)}
|
||
className="shrink-0 rounded px-0.5 underline-offset-2 transition-colors hover:bg-hover hover:underline"
|
||
style={{ color: 'var(--st-waiting)' }}
|
||
title={namedOn(warned) ? '收起这几轮' : '列出有提醒的轮次'}
|
||
onClick={() => toggleNamed(warned)}>
|
||
· {warned.length} 轮有提醒
|
||
</button>
|
||
)}
|
||
</span>
|
||
{st.max != null && st.max >= 10 && slowest != null && (
|
||
<span className="tnum flex min-w-0 items-center pl-[16px] text-faint">
|
||
{st.median != null && st.median >= 10 && <span className="shrink-0">中位 {formatDuration(st.median)} · </span>}
|
||
<button type="button" aria-pressed={namedOn([slowest])}
|
||
className="truncate rounded px-0.5 text-left underline-offset-2 transition-colors hover:bg-hover hover:text-fg hover:underline"
|
||
title={namedOn([slowest]) ? '收起这一轮' : '列出这一轮'}
|
||
onClick={() => toggleNamed([slowest])}>
|
||
最慢 {formatDuration(st.max)}(第 {slowest} 次)
|
||
</button>
|
||
</span>
|
||
)}
|
||
</span>
|
||
{(st.max ?? 0) >= 10 && ms.length >= 3 && <Sparkline values={ms} />}
|
||
</div>
|
||
)}
|
||
{rows.map((r) => {
|
||
if (r.kind === 'more') {
|
||
return (
|
||
<div key="more" data-exec-more="" className="flex flex-wrap items-center gap-x-2 py-0.5 pl-[18px] text-2xs text-dim">
|
||
<span className="tnum">还有 {r.left} 轮未列出</span>
|
||
<button type="button" className="rounded px-0.5 underline-offset-2 transition-colors hover:bg-hover hover:text-fg hover:underline"
|
||
onClick={() => setLimit((l) => l + LIST_HEAD)}>
|
||
再列 {Math.min(LIST_HEAD, r.left)} 轮
|
||
</button>
|
||
<button type="button" className="rounded px-0.5 underline-offset-2 transition-colors hover:bg-hover hover:text-fg hover:underline"
|
||
onClick={() => setLimit(Infinity)}>
|
||
全部列出
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
const { g } = r
|
||
const open = auto(g) || expanded.has(g.exec.n)
|
||
return (
|
||
<div key={g.exec.n} data-exec={g.exec.n}
|
||
className="scroll-mt-14 rounded data-[flash]:outline data-[flash]:outline-1 data-[flash]:outline-offset-1 data-[flash=focus]:outline-[color:var(--accent)]">
|
||
<button type="button"
|
||
className="flex w-full items-center gap-1.5 py-[2px] text-left text-2xs text-dim transition-colors hover:text-fg"
|
||
aria-expanded={open}
|
||
onClick={() => setExpanded((prev) => {
|
||
const next = new Set(prev)
|
||
if (next.has(g.exec.n)) next.delete(g.exec.n)
|
||
else next.add(g.exec.n)
|
||
return next
|
||
})}>
|
||
<StatusBadge status={g.exec.status === 'done' ? 'done' : g.exec.status} size={9} decorative
|
||
animate={g.exec.status === 'running'} />
|
||
<span className="tnum">第 {g.exec.n} 次</span>
|
||
{g.exec.iteration != null && g.exec.iteration !== g.exec.n && (
|
||
<span className="tnum text-faint">· 循环第 {g.exec.iteration} 轮</span>
|
||
)}
|
||
<span className="flex-1" />
|
||
{g.exec.ms != null && g.exec.ms >= 10 && (
|
||
<span className="mono tnum">{formatDuration(g.exec.ms)}</span>
|
||
)}
|
||
{bad(g) && <AlertTriangle size={10} aria-hidden style={{ color: 'var(--st-waiting)' }} />}
|
||
</button>
|
||
{open && g.steps.length > 0 && <StepList steps={g.steps} depth={1} turnMs={turnMs} />}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 失败节点的原话是不是已经被上面的说法原样说完了:原话的每一句(按句号、分号断开,去掉标点和
|
||
* 空白比)都出现在「一句话 + 为什么 + 怎么办」里。说完了就不在展开区再贴一遍——用户真实踩到的:
|
||
* 「模板渲染出来的不是合法 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 (
|
||
<div className="mb-1 mt-1 space-y-1.5 pl-[18px]">
|
||
{explained && (explained.reason || explained.action) && (
|
||
<div className="space-y-0.5 text-2xs leading-relaxed text-dim" data-step-explain="">
|
||
{explained.reason && <div className="[overflow-wrap:anywhere]">{explained.reason}</div>}
|
||
{explained.action && (
|
||
<div className="flex flex-wrap items-start gap-x-1.5">
|
||
<span className="flex min-w-0 flex-1 items-start gap-1">
|
||
<CornerDownRight size={10} className="mt-[3px] shrink-0" aria-hidden />
|
||
<span className="min-w-0 [overflow-wrap:anywhere]">{explained.action}</span>
|
||
</span>
|
||
{/* 该改的是上游(fixNode)时这一行不给入口:它只认得自己这个节点,轮次顶上的报错会指到上游去 */}
|
||
<FixAction fix={explained.fix} nodeId={explained.fixNode && explained.fixNode !== step.title ? undefined : step.nodeId}
|
||
label={step.title} to={explained.fixTo} first={explained.fixFirst} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{step.merge && step.merge.inputs.length > 0 && (
|
||
// 合并查询:先说合并的是哪几个输入(合并 SQL 里的表名 → 上游节点、行数、数据源),再看合并 SQL 和结果
|
||
<div className="text-2xs text-dim" data-step-merge="">
|
||
<div className="mb-0.5">{MERGE_TEXT.inputs(step.merge.inputs.length)}</div>
|
||
<ul className="space-y-0.5">
|
||
{step.merge.inputs.map((i) => (
|
||
<li key={i.alias} className="flex min-w-0 items-baseline gap-1.5" data-step-merge-input={i.alias}>
|
||
<span className="mono shrink-0 rounded bg-bg px-1 text-fg">{i.alias}</span>
|
||
<span className="min-w-0 truncate" title={i.nodeId}>{i.label || i.nodeId || '—'}</span>
|
||
<span className="shrink-0 text-faint">
|
||
{[i.rows != null ? MERGE_TEXT.rows(i.rows) : '', i.source ? MERGE_TEXT.source(i.source) : '']
|
||
.filter(Boolean).join(' · ')}
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
{detail && (
|
||
<div className="group/detail relative">
|
||
{step.kind === 'query' && (
|
||
<div className="mb-0.5 flex items-center gap-2 text-2xs text-dim">
|
||
<span className={step.merge ? undefined : 'mono'}>{step.merge ? MERGE_TEXT.sql : 'SQL'}</span>
|
||
{step.source && <span>数据源 {step.source}</span>}
|
||
<span className="flex-1" />
|
||
<CopyChip label={step.merge ? MERGE_TEXT.copySql : '复制 SQL'} text={() => step.detail ?? ''} />
|
||
</div>
|
||
)}
|
||
<pre className={pre}>
|
||
{detail}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
{!!step.checks?.length && (
|
||
// 对照数据目录的 SQL 检查:查询那一行是这次查询的检查结果,指标那一行是来源查询没通过的那几条
|
||
<div className="text-2xs" data-step-sql-checks="">
|
||
<div className="mb-0.5 text-dim">{RUN_SQL_CHECK_TEXT.toolChecks}</div>
|
||
<SqlCheckList checks={step.checks} />
|
||
</div>
|
||
)}
|
||
{table
|
||
? <ResultTable table={table} artifact={step.artifact} title={step.title} />
|
||
: result && result !== step.detail && (
|
||
<pre className={pre}>
|
||
{result.slice(0, 2000)}
|
||
</pre>
|
||
)}
|
||
{step.artifact && !table && (
|
||
<button type="button"
|
||
className="flex items-center gap-1 text-2xs text-dim transition-colors hover:text-fg"
|
||
onClick={() => openArtifact(step.artifact!, step.title)}>
|
||
<ShieldCheck size={10} aria-hidden /> 查看完整证据 {shortId(step.artifact, 8)}
|
||
<ExternalLink size={9} aria-hidden />
|
||
</button>
|
||
)}
|
||
{step.raw && <TechDetails raw={step.raw} />}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 技术细节:原始异常。给排查和转给维护者用,默认收着 */
|
||
function TechDetails({ raw }: { raw: string }) {
|
||
const { dense } = useContext(Ctx)
|
||
return (
|
||
<details className="group/raw">
|
||
<summary className="cursor-pointer text-2xs text-dim hover:text-fg">技术细节</summary>
|
||
<div className="mt-1 flex items-start gap-1">
|
||
<pre className={clsx('mono max-h-40 min-w-0 flex-1 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')}>
|
||
{raw}
|
||
</pre>
|
||
<CopyChip label="复制" text={() => raw} />
|
||
</div>
|
||
</details>
|
||
)
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// 协作团队的泳道
|
||
// -------------------------------------------------------------------------
|
||
|
||
/**
|
||
* 每个成员一行,每一轮一格。
|
||
*
|
||
* 这个节点以前在界面上是一条扁平的步骤序列——能看出"谁回了什么",看不出
|
||
* "谁和谁是同时干的"。而一轮同时派几个人正是它相对单 agent 的全部优势,
|
||
* 不画出来等于没有。
|
||
*
|
||
* 条形的宽度按耗时归一化,所以一眼能看出谁是这一轮的瓶颈。说法和配色跟画布上
|
||
* 的协作矩阵是同一套:完成是 --st-done,进行中写「进行中」、不写「0 ms」,
|
||
* 省下的时间要等那一轮的人都交回来才出现——之前刚开始就写「省下 0ms」,是在
|
||
* 报一个还没发生的收益。
|
||
*/
|
||
function TeamLanes({ team }: { team: TeamRun }) {
|
||
const { dense } = useContext(Ctx)
|
||
const [openKey, setOpenKey] = useState<string | null>(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 (
|
||
<div className="mt-2 rounded-lg border bg-bg p-2" data-team-lanes="">
|
||
<div className="mb-2 flex flex-wrap items-center gap-x-2.5 gap-y-1 text-2xs text-dim">
|
||
<span className="flex items-center gap-1">
|
||
<Users size={11} aria-hidden /> {team.members.length + never.length} 名成员 · {team.rounds.length} 轮
|
||
</span>
|
||
{liveRound && (
|
||
<span style={{ color: 'var(--st-running)' }}>
|
||
第 {liveRound.round + 1} 轮 · {liveRound.parallel > 1 ? `本轮 ${liveRound.parallel} 名成员并行中` : '进行中'}
|
||
</span>
|
||
)}
|
||
{team.savedMs > 0 && (
|
||
<span className="tnum" style={{ color: 'var(--st-done)' }}>
|
||
并行节省 {formatDuration(team.savedMs)}({parallelRounds} 轮并行)
|
||
</span>
|
||
)}
|
||
{parallelRounds === 0 && team.finished && !verdict?.outcome && (
|
||
// 全程串行不是故障,但值得说一句——任务本来就互相依赖时它就该是串行的
|
||
<span title="调度者每轮只分派一名成员:后一步依赖前一步的结论">全程串行</span>
|
||
)}
|
||
</div>
|
||
{ending && (
|
||
// 怎么收的尾单独一行:只说「3 轮」的话,调度者说完成了和轮数用完硬停长得一样
|
||
<div className="-mt-1 mb-2 flex items-start gap-1 text-2xs font-medium leading-snug" style={{ color: ending.color }}
|
||
data-team-ending={verdict?.outcome ?? (verdict?.done ? 'done' : verdict?.done === false ? 'undone' : 'judging')}>
|
||
{verdict?.outcome && <AlertTriangle size={11} className="mt-[1px] shrink-0" aria-hidden />}
|
||
<span className="min-w-0 [overflow-wrap:anywhere]">
|
||
{ending.text}
|
||
{verdict?.reason && <span className="font-normal text-dim">:{verdict.reason}</span>}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
<div className="space-y-1">
|
||
{team.members.map((name) => (
|
||
<div key={name} className="flex items-center gap-1.5">
|
||
<div className={clsx('shrink-0 truncate text-2xs text-dim', dense ? 'w-14' : 'w-20')}
|
||
title={name}>
|
||
{name}
|
||
</div>
|
||
<div className="flex min-w-0 flex-1 gap-1">
|
||
{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 (
|
||
<div key={r.round} className="min-w-0 flex-1 rounded-sm" style={{ background: 'var(--bg-hover)' }}>
|
||
{m ? (
|
||
<button
|
||
type="button"
|
||
className="block h-4 w-full rounded-sm text-left transition-opacity hover:opacity-80"
|
||
title={`第 ${r.round + 1} 轮 · ${m.instruction || '(无指令)'}`}
|
||
aria-label={`${name} 第 ${r.round + 1} 轮:${said}`}
|
||
onClick={() => setOpenKey(openKey === key ? null : key)}
|
||
style={{
|
||
// 还在跑的不知道要多久:画满一格的斜纹,不画成一根按比例的条
|
||
background: hatched
|
||
? `repeating-linear-gradient(135deg, color-mix(in srgb, ${bar} ${stopped ? 28 : 40}%, transparent) 0 4px, color-mix(in srgb, ${bar} ${stopped ? 10 : 18}%, transparent) 4px 8px)`
|
||
: bar,
|
||
// 宽度按耗时占比,但留一个下限,否则快的那格细到看不见
|
||
width: hatched ? '100%' : `${Math.max(18, (m.ms / slowest) * 100)}%`,
|
||
}}
|
||
/>
|
||
) : <div className="h-4" />}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{never.map((name) => (
|
||
<div key={`never-${name}`} className="flex items-center gap-1.5" data-never-dispatched={name}>
|
||
<div className={clsx(laneLabel, 'text-faint line-through decoration-dotted')} title={`${name}:未被分派任务`}>
|
||
{name}
|
||
</div>
|
||
<div className="flex h-4 min-w-0 flex-1 items-center gap-1 rounded-sm border border-dashed px-1.5 text-2xs text-dim"
|
||
style={{ borderColor: 'var(--border-strong, var(--border))' }}>
|
||
<UserX size={10} aria-hidden className="shrink-0" />
|
||
<span className="truncate">未分派:{rounds} 轮中调度者均未向该成员分派任务</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
<div className="flex items-center gap-1.5 pt-0.5">
|
||
<div className={clsx('shrink-0', dense ? 'w-14' : 'w-20')} />
|
||
<div className="flex min-w-0 flex-1 gap-1">
|
||
{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 (
|
||
<div key={r.round}
|
||
className={clsx('tnum min-w-0 flex-1 truncate text-center text-dim', dense ? 'text-[10px]' : 'text-2xs')}
|
||
title={r.reason || ''}>
|
||
{/* 窄栏放不下完整措辞,但"并2"这种缩写没人看得懂,
|
||
宁可只留轮次和耗时,并行与否看上面那行条形本来就一目了然 */}
|
||
{`第${r.round + 1}轮`}
|
||
{r.parallel > 1 && !dense ? ` · ${r.parallel} 名成员并行` : ''}
|
||
{' · '}{live ? '进行中'
|
||
: stopped ? statusLabel(stopped.status, { short: true })
|
||
: r.wallMs > 0 ? formatDuration(r.wallMs) : NONE}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{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 (
|
||
<div className="mt-2 rounded border bg-panel p-2 text-2xs leading-relaxed">
|
||
<div className="mb-1 text-dim">
|
||
{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)}
|
||
</div>
|
||
{m.instruction && (
|
||
<div className="mb-1.5 text-dim">任务:{m.instruction}</div>
|
||
)}
|
||
{m.error && (
|
||
<div className="mb-1.5 [overflow-wrap:anywhere]" style={{ color: 'var(--st-failed)' }}>失败原因:{m.error}</div>
|
||
)}
|
||
{m.result && (
|
||
<pre className="mono max-h-36 overflow-auto whitespace-pre-wrap text-dim">
|
||
{m.result.slice(0, 1200)}
|
||
</pre>
|
||
)}
|
||
</div>
|
||
)
|
||
})()}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// 查询结果、完整证据
|
||
// -------------------------------------------------------------------------
|
||
|
||
/** 值是不是数:数值列右对齐、等宽数字,小数点才对得齐。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 (
|
||
<div className="overflow-hidden rounded border">
|
||
<div className={clsx('overflow-x-auto', full && 'max-h-[60vh] overflow-y-auto')}>
|
||
<table className={clsx('w-full', dense && !full ? 'text-[10px]' : 'text-2xs')}>
|
||
<thead className={clsx(full && 'sticky top-0')}>
|
||
<tr className="border-b bg-elev">
|
||
{cols.map((c, j) => (
|
||
<th key={c} className={clsx('mono px-1.5 py-1 font-medium text-dim',
|
||
numeric[j] ? 'text-right' : 'text-left')}>{c}</th>
|
||
))}
|
||
{hiddenCols > 0 && <th className="px-1.5 py-1 text-dim">+{hiddenCols}</th>}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((row, i) => {
|
||
const hit = hlRows.has(i)
|
||
return (
|
||
<tr key={i} className="border-b last:border-0" data-highlight={hit ? 'row' : undefined}
|
||
style={hit ? { background: 'var(--st-done-soft)' } : undefined}>
|
||
{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 (
|
||
<td key={j} className={clsx('mono max-w-[160px] truncate px-1.5 py-1',
|
||
mark ? 'font-semibold' : 'text-dim', numeric[j] && 'tnum text-right')}
|
||
data-highlight={mark ? 'cell' : undefined} data-masked={mask ? '' : undefined}
|
||
style={mark ? { outline: '1px solid var(--st-done)', outlineOffset: -1, color: 'var(--text)' } : undefined}
|
||
title={mask ? '已遮罩' : cell == null ? '' : String(cell)}>
|
||
{mask ? <span className="font-sans italic text-faint">已遮罩</span>
|
||
: cell == null ? <span className="opacity-40">null</span>
|
||
: typeof cell === 'object' ? JSON.stringify(cell) : String(cell)}
|
||
</td>
|
||
)
|
||
})}
|
||
{hiddenCols > 0 && <td className="px-1.5 py-1 text-dim">…</td>}
|
||
</tr>
|
||
)
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 border-t px-1.5 py-1 text-2xs leading-relaxed text-dim">
|
||
<span className="tnum">
|
||
{[
|
||
// 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(' · ')}
|
||
</span>
|
||
{/* 两种"不全"性质不同:一个是 guard 主动限的,一个是存预览时切的。
|
||
混成一句"已截断",用户没法判断该去调查询还是去看完整工件。 */}
|
||
{table.truncated && (
|
||
<span style={{ color: 'var(--st-waiting)' }}>查询已达行数上限,数据库中还有更多数据</span>
|
||
)}
|
||
{table.clipped && (artifact
|
||
? (
|
||
<button type="button" className="underline decoration-dotted underline-offset-2 hover:text-fg"
|
||
onClick={() => openArtifact(artifact, title)}>
|
||
此处仅为预览,打开完整结果
|
||
</button>
|
||
)
|
||
: <span>此处仅为预览,实际返回的行数更多</span>)}
|
||
<span className="flex-1" />
|
||
<button type="button" className="inline-flex items-center gap-1 rounded px-1 transition-colors hover:bg-hover hover:text-fg"
|
||
title="导出为 CSV(Excel 可直接打开)"
|
||
onClick={() => download(`${fileSafe(title ?? '查询结果')}.csv`, toCsv(table.columns, hidden.size
|
||
? table.rows.map((r) => r.map((v, j) => (hidden.has(table.columns[j]) ? '已遮罩' : v))) : table.rows),
|
||
'text/csv;charset=utf-8')}>
|
||
<Download size={10} aria-hidden /> CSV
|
||
</button>
|
||
{artifact && !table.clipped && !full && (
|
||
<button type="button" className="inline-flex items-center gap-1 rounded px-1 transition-colors hover:bg-hover hover:text-fg"
|
||
onClick={() => openArtifact(artifact, title)}>
|
||
<ShieldCheck size={10} aria-hidden /> 完整结果
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 完整证据:按内容哈希取回工件(后端取回时复验哈希,对不上会报 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 (
|
||
<Modal open={!!id} onClose={onClose} width={920}
|
||
title={
|
||
<span className="flex min-w-0 items-center gap-2">
|
||
<span className="truncate">{title || '完整证据'}</span>
|
||
{id && <span className="mono shrink-0 text-2xs text-dim">证据 {shortId(id, 8)}</span>}
|
||
{content !== undefined && (
|
||
<span className="inline-flex shrink-0 items-center gap-1 text-2xs" style={{ color: 'var(--st-done)' }}
|
||
title="加载时已按内容校验值重新校验,与运行时记录一致">
|
||
<ShieldCheck size={11} aria-hidden /> 校验通过
|
||
</span>
|
||
)}
|
||
</span>
|
||
}>
|
||
{error ? <ErrorNotice error={error} />
|
||
: !body ? <Skeleton rows={6} />
|
||
: (
|
||
<div className="space-y-2" data-artifact="">
|
||
{body.sql && (
|
||
// 证据得连着"问的是什么":只给结果表,读的人没法判断它是不是自己以为的那条查询
|
||
<div>
|
||
<div className="mb-0.5 flex items-center gap-2 text-2xs text-dim">
|
||
<span className="mono">SQL</span>
|
||
{body.tool && <span className="mono">{body.tool}</span>}
|
||
<span className="flex-1" />
|
||
<CopyChip label="复制 SQL" text={() => body.sql ?? ''} />
|
||
</div>
|
||
<pre className="mono max-h-32 overflow-auto whitespace-pre-wrap rounded bg-bg px-2 py-1.5 text-2xs leading-relaxed text-dim [overflow-wrap:anywhere]">
|
||
{body.sql}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
{body.table ? <ResultTable table={body.table} full artifact={id ?? undefined} title={title} masked={masked} />
|
||
: (
|
||
<div>
|
||
<div className="mb-1 flex justify-end"><CopyChip label="复制全文" text={() => body.text} /></div>
|
||
<pre className="mono max-h-[60vh] overflow-auto whitespace-pre-wrap rounded bg-bg p-2 text-2xs leading-relaxed text-dim [overflow-wrap:anywhere]">
|
||
{body.text}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 工件里装的是什么。工具调用的快照是 {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<string, any>; 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<EvidenceDocHandle>(null)
|
||
// 第一份报告文档取到之后,出具横幅多一行「N/N 数字有出处 · 无证据 M」
|
||
const [tallyDoc, setTallyDoc] = useState<EvidenceDocData | null>(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 (
|
||
<div className="group/answer relative mt-2 space-y-2 border-t pt-2">
|
||
{issuance && (
|
||
// 成果里有报告字段就告诉横幅:正文换成了报告文档,回指不上的数字不再是 Markdown 上的
|
||
// 虚线(列表序号里的画不了线),而是在报告的违规清单里。文档取到后再给计数和两个动作
|
||
<IssuanceBanner issuance={issuance} runClass={runClass}
|
||
evidence={firstEvidence ? {
|
||
counts: tally ?? undefined,
|
||
onNext: tally ? () => docRef.current?.next() : undefined,
|
||
onList: tallyDoc?.violations?.length ? () => docRef.current?.list() : undefined,
|
||
} : undefined} />
|
||
)}
|
||
{broken && (
|
||
// 复核判了不可信:答案照常给(可能有参考价值),但先说清楚它不能当结论。
|
||
// 不降透明度——那会让它更难读,而不是更不可信
|
||
<div className="flex items-center gap-1.5 text-2xs font-medium" style={{ color: 'var(--st-failed)' }}>
|
||
<AlertTriangle size={11} aria-hidden /> 以下内容不可作为结论使用
|
||
</div>
|
||
)}
|
||
{/* 复制 / 导出贴着正文浮出,不贴着整块:上面有出具横幅时,贴整块的右上角会
|
||
正好压在横幅的「回指 N 个数字」上 */}
|
||
<div className={clsx('relative', broken && 'border-l-2 pl-2.5')}
|
||
style={broken ? { borderColor: 'var(--st-failed)' } : undefined}>
|
||
{entries.length > 0 && (
|
||
<AnswerActions entries={entries} question={question} issuance={issuance}
|
||
runId={firstEvidence ? runId : undefined} reportNode={reportNode} />
|
||
)}
|
||
<div className="space-y-2">
|
||
{entries.map(([key, value]) => (
|
||
<div key={key}>
|
||
{entries.length > 1 && (
|
||
<div className="mb-0.5 text-2xs font-semibold text-dim">{key}</div>
|
||
)}
|
||
{evidence.has(key) && typeof value === 'string'
|
||
? (
|
||
<EvidenceField
|
||
ref={key === firstEvidence ? docRef : undefined}
|
||
artifact={evidence.get(key)!.artifact} text={value} runId={runId} dense={dense} label={key}
|
||
tally={!banner} runClass={runClass}
|
||
onDoc={key === firstEvidence ? setTallyDoc : undefined}
|
||
/>
|
||
)
|
||
: <OutputValue value={value} marks={marks} label={key} />}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
{follow.length > 0 && onFollowUp && (
|
||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||
<span className="text-2xs text-dim">继续提问</span>
|
||
{follow.map((f) => (
|
||
<button key={f} type="button" className="chip transition-colors hover:bg-hover hover:text-fg"
|
||
onClick={() => onFollowUp(f)}>
|
||
{f}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 成果转成 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 (
|
||
<div className="absolute right-0 top-0 z-[1] flex items-center gap-0.5 rounded opacity-0 shadow-elev-1 transition-opacity focus-within:opacity-100 group-hover/answer:opacity-100"
|
||
style={{ background: 'var(--bg-panel)' }}>
|
||
<CopyChip label="复制" text={md} />
|
||
{exportHtml ? (
|
||
<button type="button"
|
||
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-2xs text-dim transition-colors hover:bg-hover hover:text-fg"
|
||
title="导出为 HTML 文件:数字点得开口径、来源和计算方式(和网页上一样)"
|
||
disabled={exporting}
|
||
onClick={() => void exportHtml()} data-answer-export="html">
|
||
{exporting ? <Spinner size={10} aria-hidden /> : <FileDown size={11} aria-hidden />} 导出
|
||
</button>
|
||
) : null}
|
||
<button type="button"
|
||
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-2xs text-dim transition-colors hover:bg-hover hover:text-fg"
|
||
title={exportHtml ? '导出为 Markdown 文件(纯文本,不带出处)' : '导出为 Markdown 文件'}
|
||
onClick={() => download(`${fileSafe(question ?? '答案')}.md`, md(), 'text/markdown;charset=utf-8')}
|
||
data-answer-export={exportHtml ? 'md' : undefined}>
|
||
{exportHtml ? 'MD' : (<><FileDown size={11} aria-hidden /> 导出</>)}
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 存成文件(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 <ResultTable table={table} title={label} />
|
||
|
||
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 (
|
||
<div>
|
||
{/* 模型几乎总是用 Markdown 组织回答。当纯文本显示的话,「1+1 等于 **2**」
|
||
就原样带着星号——它的结构全丢了。只有字符串才渲染:JSON.stringify
|
||
出来的东西按 Markdown 解会被 * 和 _ 搅乱 */}
|
||
{typeof value === 'string'
|
||
? <Markdown text={shown} dense={dense} marks={marks} />
|
||
: (
|
||
<pre className={clsx('mono overflow-x-auto whitespace-pre-wrap leading-relaxed',
|
||
dense ? 'text-[10.5px]' : 'text-xs')}>{shown}</pre>
|
||
)}
|
||
{long && (
|
||
<div className="mt-1 flex items-center gap-2 text-2xs">
|
||
{!full && (
|
||
// 光有一个"展开"按钮不够:用户看到的是一份读起来完整的报告,
|
||
// 不会想到它下面还有。得先说"这里断了"
|
||
<span className="text-dim">…以下内容已折叠</span>
|
||
)}
|
||
<button type="button" className="text-[var(--accent)] hover:underline"
|
||
onClick={() => setFull((v) => !v)}>
|
||
{full ? '收起' : `展开全部(${formatNumber(text.length)} 字)`}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 出具声明里没通过 SQL 检查的查询(sql_checks):每条写是哪次查询、什么问题、影响了哪些引用,下一步写改来源查询的 SQL。
|
||
* 出具横幅和报告页顶部共用。颜色跟着档位:降档用提醒色,不予出具用失败色
|
||
*/
|
||
export function IssuanceSqlChecks({ tier, checks }: { tier: string; checks: IssuanceSqlCheck[] }) {
|
||
const tone = tier === 'withheld' ? 'var(--st-failed)' : 'var(--st-waiting)'
|
||
return (
|
||
<div className="mt-1.5 rounded border px-2 py-1.5 text-2xs leading-relaxed" data-issuance-sql-checks={checks.length}
|
||
style={{ borderColor: `color-mix(in srgb, ${tone} 45%, var(--border))`, background: `color-mix(in srgb, ${tone} 6%, transparent)` }}>
|
||
<div className="flex items-center gap-1 font-medium" style={{ color: tone }}>
|
||
<AlertTriangle size={11} aria-hidden /> {RUN_SQL_CHECK_TEXT.issuanceTitle(tier)}
|
||
</div>
|
||
<ul className="mt-0.5 space-y-0.5">
|
||
{checks.map((c, i) => (
|
||
<li key={`${c.query ?? ''}:${i}`} className="[overflow-wrap:anywhere]" data-issuance-sql-check={c.node_id ?? ''}>
|
||
<span className="font-medium text-fg">{c.query || RUN_SQL_CHECK_TEXT.issuanceQuery}</span>
|
||
{!!c.problems?.length && <span className="text-fg">:{c.problems.join(';')}</span>}
|
||
{!!c.refs?.length && <span className="text-dim">({RUN_SQL_CHECK_TEXT.issuanceAffected(c.refs.join('、'))})</span>}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
<div className="mt-0.5 text-dim">{RUN_SQL_CHECK_TEXT.issuanceNext}</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* 出具横幅里双盲复算、反方审稿报上来的一组缺口:failed「核查未通过」、unfinished「核查未完成」。排在「校验未全部完成」
|
||
* 上面,样子和它一样(同一种提醒色、小标题加圆点列表),一条都没有时不画
|
||
*/
|
||
function IssuanceGaps({ kind, title, gaps }: { kind: 'failed' | 'unfinished'; title: string; gaps: string[] }) {
|
||
if (!gaps.length) return null
|
||
return (
|
||
<div className="mt-1.5 text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }} data-issuance-verify-gaps={kind}>
|
||
<div className="font-medium">{title}:</div>
|
||
<ul className="mt-0.5 space-y-0.5 pl-2 [overflow-wrap:anywhere]">
|
||
{gaps.map((g) => <li key={g}>· {g}</li>)}
|
||
</ul>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const TIER_META: Record<string, { color: string; soft: string; hint: string }> = {
|
||
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<string, number>()
|
||
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)
|
||
<div className="scroll-mt-14 rounded-lg border p-2 outline-none data-[flash]:outline data-[flash]:outline-solid data-[flash]:outline-2 data-[flash]:outline-offset-1 data-[flash=focus]:outline-[color:var(--accent)]"
|
||
style={{ borderColor: meta.color }} data-issuance-banner={tier} tabIndex={-1}>
|
||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||
<span className="inline-flex items-center gap-1 rounded px-1.5 py-px text-xs font-semibold" style={{ color: meta.color, background: meta.soft }}>
|
||
<Scale size={11} aria-hidden /> {issuanceLabel(tier)}
|
||
</span>
|
||
{runClass === 'exploratory' && (
|
||
<span className="chip" style={{ color: 'var(--st-waiting)' }}>探索运行 · 不进正式归档</span>
|
||
)}
|
||
<span className="tnum ml-auto text-2xs text-dim">
|
||
可追溯 {formatNumber(matched)} 个数字 · 核对 {formatNumber(issuance.metrics_checked ?? 0)} 个指标
|
||
</span>
|
||
</div>
|
||
{meta.hint && <div className="mt-1 text-2xs leading-relaxed text-dim">{meta.hint}</div>}
|
||
{counts && (counts.total > 0 || counts.other > 0 || suspect > 0 || uncitedClaims > 0 || !!claims) && (
|
||
// 逐段证据的计数:和报告核对那一行、证据条同一种说法。可疑名字、没挂依据的结论句不是数字,
|
||
// 各自另起一句。有无证据的数字、可疑名字、证据有问题(相矛盾、部分有依据、证据不足)的结论句时才给「定位下一处」——
|
||
// 结论句靠句末徽标定位;只是没挂依据的结论句没有徽标,定位不到
|
||
<div className="mt-1 flex flex-wrap items-center gap-2 text-2xs" data-evidence-line="">
|
||
{numbers && (
|
||
<span className="tnum" style={{ color: numbersColor }} data-evidence-line-numbers="">{numbers}</span>
|
||
)}
|
||
{suspect > 0 && (
|
||
<span className="tnum" style={{ color: 'var(--st-waiting)' }} data-evidence-line-suspect="">
|
||
{numbers ? '· ' : ''}{EVIDENCE_STATE.suspect.glyph} {EVIDENCE_TEXT.suspectTag} {formatNumber(suspect)}
|
||
</span>
|
||
)}
|
||
{claims && <ClaimTally claims={claims} lead={!!numbers || suspect > 0} />}
|
||
{uncitedLine && (
|
||
<span className="tnum" style={{ color: 'var(--st-waiting)' }} data-evidence-line-claims=""
|
||
title={claims ? JUDGE_TEXT.uncitedBesides : undefined}>
|
||
{numbers || suspect > 0 || claims ? '· ' : ''}{EVIDENCE_TEXT.uncitedClaimsTag} {formatNumber(uncitedClaims)}
|
||
</span>
|
||
)}
|
||
{(counts.none > 0 || counts.other > 0 || suspect > 0 || claimProblems(claims) > 0) && evidence?.onNext && (
|
||
<button type="button" className="btn btn-xs" data-evidence-next="" onClick={evidence.onNext}>
|
||
{EVIDENCE_TEXT.locateNext}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{!!sqlChecks.length && <IssuanceSqlChecks tier={tier} checks={sqlChecks} />}
|
||
<IssuanceGaps kind="failed" title={DUAL_CHECK_TEXT.verifyGaps.failed} gaps={verifyFailed} />
|
||
<IssuanceGaps kind="unfinished" title={DUAL_CHECK_TEXT.verifyGaps.unfinished} gaps={verifyUnfinished} />
|
||
{!!gaps.length && (
|
||
<div className="mt-1.5 text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }} data-issuance-gaps="">
|
||
<div className="font-medium">{RUN_SQL_CHECK_TEXT.otherGaps}</div>
|
||
<ul className="mt-0.5 space-y-0.5 pl-2">
|
||
{gaps.map((g) => <li key={g}>· {g}</li>)}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
{!!issuance.missing_required?.length && (
|
||
<div className="mt-1 text-2xs" style={{ color: 'var(--st-failed)' }}>
|
||
缺少必需指标:{issuance.missing_required.join('、')}
|
||
</div>
|
||
)}
|
||
{!!issuance.missing_expected?.length && (
|
||
<div className="mt-1 text-2xs" style={{ color: 'var(--st-waiting)' }}>
|
||
缺少期望指标:{issuance.missing_expected.join('、')}(本期无数据)
|
||
</div>
|
||
)}
|
||
{!!unmatched.length && (
|
||
<div className="mt-1 text-2xs leading-relaxed" style={{ color: 'var(--st-waiting)' }}>
|
||
{/* 带报告文档时正文是 EvidenceDoc:列表序号里的数字画不了线,逐条都在违规清单里;
|
||
文档还没取到(或取不到)时正文是不带标记的普通文本,什么都不许诺 */}
|
||
<span data-unmatched-note="">
|
||
{!evidence ? '无法追溯的数字(正文中已用虚线标出):'
|
||
: evidence.onList ? EVIDENCE_TEXT.unmatchedInDoc : EVIDENCE_TEXT.unmatchedPlain}
|
||
</span>
|
||
{unmatched.map((u: any, i: number) => (
|
||
<span key={i} className="mr-1.5 inline-block" title={u?.context ? `「${u.context}」` : undefined}>
|
||
<span className="mono tnum">{u?.token ?? String(u)}</span>
|
||
{u?.context && <span className="text-dim">({String(u.context).slice(0, 24)})</span>}
|
||
</span>
|
||
))}
|
||
{evidence?.onList && (
|
||
<button type="button" className="btn btn-xs btn-ghost align-middle" data-evidence-list=""
|
||
onClick={evidence.onList}>
|
||
<ListChecks size={11} aria-hidden /> {EVIDENCE_TEXT.violations}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
{!!calibers.length && (
|
||
// 数字回指到哪张口径卡:按 matched[].caliber 逐张数(hitsOf 的 key 和后端 io.py 的
|
||
// 「口径名 @ 版本」同一种拼法,check-stream 盯着)。老后端没有逐个出处,只有一张卡时
|
||
// 回指上的数字必然来自它,可以直说
|
||
<div className="mt-1.5 space-y-0.5 border-t pt-1.5 text-2xs text-dim">
|
||
{calibers.map((c: any) => {
|
||
const hits = hitsOf(c)
|
||
return (
|
||
<div key={c.node ?? c.caliber} className="flex items-center gap-1.5" data-caliber={c.caliber}>
|
||
<FileCode size={10} aria-hidden className="shrink-0" />
|
||
<span>口径卡「{c.caliber}」<span className="mono">{c.version}</span></span>
|
||
{calibers.length === 1 && matched > 0 && (hits == null || hits === matched)
|
||
? <span data-caliber-hits={matched} style={{ color: 'var(--st-done)' }}>· 可追溯的 {matched} 个数字均来自此口径卡</span>
|
||
: hits != null && <span data-caliber-hits={hits} className="tnum" style={{ color: 'var(--st-done)' }}>· 可追溯 {hits} 个数字</span>}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 这一轮建出来的图:几步、每步是什么,可以放到画布上接着改 */
|
||
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 (
|
||
<div className="mt-2 border-t pt-2">
|
||
<div className="flex items-center gap-2">
|
||
<button type="button" className="flex items-center gap-1 text-2xs text-dim hover:text-fg"
|
||
aria-expanded={open} onClick={() => setOpen((v) => !v)}>
|
||
<ChevronRight size={10} aria-hidden
|
||
style={{ transform: open ? 'rotate(90deg)' : 'none', transition: 'transform .15s' }} />
|
||
执行过程({nodes.length} 步)
|
||
</button>
|
||
<span className="flex-1" />
|
||
{onOpen && (
|
||
<button type="button" className="btn btn-sm btn-ghost text-2xs"
|
||
title="在画布中打开此工作流继续修改" onClick={() => onOpen(graph)}>
|
||
在画布里打开
|
||
</button>
|
||
)}
|
||
</div>
|
||
{open && (
|
||
<div className="mt-1.5 space-y-1">
|
||
{note && (
|
||
<div className={clsx('rounded bg-bg px-2 py-1.5 leading-relaxed text-dim',
|
||
dense ? 'text-[10.5px]' : 'text-xs')}>
|
||
{note}
|
||
</div>
|
||
)}
|
||
{nodes.map((n: any, i: number) => (
|
||
<div key={n.id ?? i} className="flex items-center gap-2 text-2xs text-dim">
|
||
<span className="mono tnum w-4 shrink-0 text-right">{i + 1}</span>
|
||
<span className="chip shrink-0">{nodeTypeLabel(n.type)}</span>
|
||
<span className="truncate">{n.data?.label || n.id}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** 供各页面复用的空态骨架。 */
|
||
export function StreamEmpty({ icon, title, hint, children }: {
|
||
icon: ReactNode; title: string; hint?: string; children?: ReactNode
|
||
}) {
|
||
return (
|
||
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
|
||
<div className="mb-2 text-faint">{icon}</div>
|
||
<div className="text-sm font-medium">{title}</div>
|
||
{hint && <div className="mt-1 max-w-sm text-xs leading-relaxed text-dim">{hint}</div>}
|
||
{children && <div className="mt-3 w-full max-w-md">{children}</div>}
|
||
</div>
|
||
)
|
||
}
|