Files
AgentLab/frontend/src/run/AssistantPanel.tsx
T

925 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import {
Ban, ChevronLeft, ChevronRight, Code2, Crosshair, Hand, History, MessageSquarePlus, Play, Square,
Undo2, Wand2, Wrench,
} from 'lucide-react'
import clsx from 'clsx'
import { useCatalog } from '../store/catalog'
import { useStudio, type CopilotTurn } from '../store/studio'
import { revealField, useSheet } from '../canvas/InspectorSheet'
import { fieldOfIssue } from '../canvas/issues'
import { sourceHandles } from '../canvas/nodeDefs'
import { splitShrunk } from '../canvas/copilotMerge'
import { confirmDialog, IconButton, StatusBadge, toast } from '../components/ui'
import { formatCost, formatLapse, formatOffset, formatSpan, formatTokens, shortId, NONE } from '../lib/format'
import { statusLabel } from '../lib/status'
import { runClassLabel, SQL_CHECK_TEXT } from '../lib/terms'
import { sortSqlChecks } from '../lib/sqlcheck'
import type { RunUsage, ToolChange } from '../types'
import { AssistantStream, type StreamTurn } from './AssistantStream'
import { Composer } from './Composer'
import { SqlCheckBody } from './SqlChecks'
import { EvidenceHostContext, type EvidenceHost } from './evidenceHost'
import {
COPILOT_PHASE_TEXT, copilotOutcome, decodeCopilot, decodeRun, exitLabels, issueLine, issueSqlCheck, SELF_CHECK_ROUNDS, type CopilotIssue,
type CopilotOutcome,
} from './decode'
import { catalogPatchesOf } from './catalogPatch'
import { ApprovalCard } from './RunPanel'
import { useRunGlance } from './RunHud'
import { isSettled, lastEventAt, liveAt, project, type RunPhase, type Trace } from './trace'
import { useRunClock } from './useRunClock'
/**
* 画布右栏。
*
* 一个常驻的家(和 Copilot 说话),两层临时盖上来的东西(节点属性、运行过程)。
* 三者不是平级的 tab——tab 互斥,会把下面那层卸载掉,草稿和滚动位置全丢。
*
* 运行为什么不留在对话流里:
*
* 之前把 Copilot 建图和跑图排进了同一条时间轴,理由是"它们是一件事的两半"。
* 那个说法站不住。它们在时间上先后发生,但不是同一种东西——最直接的证据是
* 运行那一轮的 question 只能填 undefined:一个没有提问的"对话轮次",本来就
* 不是对话。
*
* 更实际的问题是输入框:它贴在运行结果下面,暗示你可以回复这次运行。但你
* 不能——在那儿打字改的是画布上的图,不是回答运行。这是个假的可供性,
* 而且两者的生命周期也对不上:Copilot 的轮次会累积,运行永远只有最新一次。
*
* 所以运行有自己的一层,没有输入框;对话那一层留一条进度条,点一下过去。
*/
export function AssistantPanel() {
const run = useStudio((s) => s.run)
const [view, setView] = useState<'chat' | 'run'>('chat')
const [reveal, setReveal] = useState<Reveal | null>(null)
// 请求办完就清掉(onRevealed),seq 不能跟着归零:运行层那头记着办过的 seq,归零后下一个
// 请求会撞上旧号被当成办过了
const seq = useRef(0)
const rootRef = useRef<HTMLDivElement>(null)
// 发起一次新运行就把这一层推到前面——那是此刻唯一在发生的事
useEffect(() => {
if (run?.id) setView('run')
setReveal(null)
}, [run?.id])
// 画布上的胶囊「去审批」、成果节点的出具印章、点一个跑过的节点,要把人带到右栏的审批卡 /
// 出具横幅 / 那个节点的步骤。右栏停在对话层时这些根本不在页面上,以前只能退回一句 toast
// 或者干脆没反应——所以由右栏自己听、自己切过去,并 preventDefault 告诉派发方「接手了」。
// 右栏收起时它还挂着(只是藏起来),那时不接:切过去也没人看得见,让派发方自己兜底
useEffect(() => {
const listen = (kind: Reveal['kind']) => (e: Event) => {
const detail = (e as CustomEvent<{ runId?: string; nodeId?: string }>).detail
const s = useStudio.getState()
const root = rootRef.current
if (!s.run || (detail?.runId && detail.runId !== s.run.id)) return
if (kind === 'step' && !detail?.nodeId) return
if (!root || !root.offsetWidth || getComputedStyle(root).visibility !== 'visible') return
e.preventDefault()
// 属性面板盖在右栏上:先让开,要看的东西在它下面。点跑过的节点来的只收起面板、
// 不清选中:画布上「…的配置」入口跟着选中走,派发方先选中再派发时清掉就没了
if (kind === 'step') useSheet.setState({ open: null })
else s.select(null)
setView('run')
setReveal({ kind, seq: ++seq.current, ...(kind === 'step' ? { nodeId: detail!.nodeId } : {}) })
}
const approval = listen('approval')
const issuance = listen('issuance')
const step = listen('step')
window.addEventListener('agentlab:goto-approval', approval)
window.addEventListener('agentlab:goto-issuance', issuance)
window.addEventListener('agentlab:reveal-step', step)
return () => {
window.removeEventListener('agentlab:goto-approval', approval)
window.removeEventListener('agentlab:goto-issuance', issuance)
window.removeEventListener('agentlab:reveal-step', step)
}
}, [])
return (
// data-assistant-panel:画布上的出具印章据此判断「点了有人接」,右栏在对话层时它照样能点
<div ref={rootRef} data-assistant-panel="" className="relative flex h-full min-h-0 flex-col">
{view === 'run' && run
? <RunView onBack={() => setView('chat')} reveal={reveal} onRevealed={() => setReveal(null)} />
: <ChatView hasRun={!!run} onOpenRun={() => setView('run')} />}
</div>
)
}
/**
* 别处请右栏摆到眼前的东西:审批卡、出具横幅、某个节点最后一次执行的步骤。seq 让同一个
* 请求连着来两次也会再做一遍。办完就清掉:请求要是留着,回到对话层再点运行条打开运行层
* 时它会再办一遍——又滚走、又抢焦点、又描一次边
*/
interface Reveal {
kind: 'approval' | 'issuance' | 'step'
seq: number
nodeId?: string
}
// -------------------------------------------------------------------------
// 对话:和 Copilot 说话的地方
// -------------------------------------------------------------------------
/**
* 一轮 Copilot 的卡片标题。只看 phase 的话,只回了一句话、自查没修好、少放了
* 一步,全都写「流程已更新到画布」——而这几种的下一步完全不同
*/
function copilotTurn(
t: CopilotTurn, live: { lastOp: string; phase: string; repairing?: number },
labelOf: (id: string) => string | undefined,
): StreamTurn {
const running = t.phase === 'running'
const out = copilotOutcome(t.ops, running)
const found = t.ops.find((o) => o.op === 'check' && o.status === 'repairing')
const foundN = Array.isArray(found?.issues) ? found!.issues.length : 0
const reply = out.reply ?? t.reply
const errorOp = out.error
const unchanged = t.outcome === 'unchanged'
const steps = decodeCopilot(t.ops, { context: 'canvas', unchanged, labelOf })
// 模型改提示词时漏写了 tools、把节点的工具抹掉了:图照样能跑,只是什么都查不到。
// 按要求删的不算:只有这一轮没让删的才在卡头上提醒
const { unasked } = toolLossOf(out)
// 回执说清改了什么:「流程已更新」不说改了哪几处,改坏了都不知道从哪看起
const d = t.diff
const changed = d ? [
d.added.length ? `新增 ${d.added.length}` : '',
d.changed.length ? `修改 ${d.changed.length}` : '',
d.removed.length ? `删除 ${d.removed.length}` : '',
].filter(Boolean).join(' · ') : ''
const updated = changed ? `已更新画布(${changed})` : '已更新画布'
let status = ''
let tone: StreamTurn['tone']
let statusCode: string | undefined
if (running) {
status = live.lastOp
|| (live.phase === 'repairing' && out.repairing
? `${COPILOT_PHASE_TEXT.repairing}(第 ${out.repairing}/${SELF_CHECK_ROUNDS} 轮)`
: COPILOT_PHASE_TEXT[live.phase] ?? '正在生成…')
} else if (t.outcome === 'reverted') {
status = '已撤回,画布已恢复到本轮之前'
statusCode = 'cancelled'
} else if (t.phase === 'error' || errorOp) {
status = '本轮未能完成'
} else if (reply && out.kind !== 'built') {
status = '已回答(未修改画布)'
} else if (out.kind === 'built') {
if (out.check?.status === 'failed') {
status = unchanged
? `画布未修改,仍有 ${out.check.issues.length} 处问题待处理`
: `已应用到画布,仍有 ${out.check.issues.length} 处问题待处理`
tone = 'warn'
} else if (out.skipped.length) {
// 模型只加了认不出类型的节点、全被跳过时画布一处没变:说「已放到画布」是在报一个没发生的改动
status = unchanged
? `画布未修改:有 ${out.skipped.length} 个步骤未能添加`
: `已应用到画布,但有 ${out.skipped.length} 个步骤未能添加`
tone = 'warn'
} else if (unasked.length) {
// 360px 的卡头放不下长句:要紧的「没让删」放前面,逐项说明在下面的工具清单里
status = `${updated} · 未经要求移除了 ${unasked.length} 处工具`
tone = 'warn'
} else if (unchanged) {
// 收到了 final,但一处都没变:说「已更新画布」是在报一个没发生的改动
status = '已检查,画布无需修改'
} else if (out.check?.status === 'passed' && out.check.repaired) {
// 修了几处只有「交回去改」那一条带着数;没有它(一次就修好)时不硬凑「几 处」
status = `${updated} · ${foundN ? `自查发现 ${foundN} 处问题,已自动修正` : '自查发现的问题已自动修正'}`
} else if (out.check?.status === 'passed') {
status = `${updated} · 自查通过`
} else {
status = updated
}
} else {
// 流正常收尾,却既没有交回图也没有回话(停止了,或者模型什么都没给)
status = '未修改画布'
statusCode = 'cancelled'
}
return {
id: t.id,
question: t.instruction,
phase: running ? 'running' : t.phase === 'error' || errorOp ? 'error' : 'done',
status,
tone,
statusCode,
steps,
output: reply ? { 回答: reply } : t.explanation ? { 说明: t.explanation } : null,
catalogPatches: catalogPatchesOf(t.ops),
error: errorOp
? { error: errorOp.message, hint: errorOp.hint, detail: errorOp.raw }
: t.error || undefined,
startedAt: t.startedAt,
}
}
/** 这一轮变少了的工具,分「没让删」和「按要求」两组:和改图回执(toast)同一条规则 */
const toolLossOf = (out: CopilotOutcome) =>
splitShrunk(out.toolChanges, out.dropped.map((w) => ({ node_id: w.nodeId, field: w.field })))
/**
* 画布上的节点名。自查问题、「去掉了」这些行只带节点 id;不订阅 nodes(拖一帧就要重解码
* 整条对话),取的是解码那一刻的名字
*/
const canvasLabel = (id: string): string | undefined =>
useStudio.getState().nodes.find((n) => n.id === id)?.data.label || undefined
function ChatView({ hasRun, onOpenRun }: { hasRun: boolean; onOpenRun: () => void }) {
const copilotTurns = useStudio((s) => s.copilotTurns)
const copilot = useStudio((s) => s.copilot)
const retryCopilot = useStudio((s) => s.retryCopilot)
const repairWithCopilot = useStudio((s) => s.repairWithCopilot)
const undoCopilotTurn = useStudio((s) => s.undoCopilotTurn)
const newCopilotConversation = useStudio((s) => s.newCopilotConversation)
const memory = useStudio((s) => s.copilotMemory)
const [showPast, setShowPast] = useState(false)
const turns = useMemo<StreamTurn[]>(() => copilotTurns.map((t) => copilotTurn(t, copilot, canvasLabel)),
[copilotTurns, copilot])
// 打开这张图之前就有的那几轮:模型下一条指令照样会带上它们。面板上一片空白、
// 模型却记着之前的事,它引用「刚才那个团队」时用户无从理解
const settledHere = copilotTurns.filter((t) => t.phase !== 'running').length
const earlier = memory.past.slice(0, Math.max(0, memory.past.length - settledHere))
const pastTurns = useMemo<StreamTurn[]>(() => earlier.map((t) => ({
id: `past-${t.id}`,
question: t.question,
phase: t.status === 'error' ? 'error' : 'done',
status: t.status === 'error' ? '本轮未能完成' : t.graph ? '已修改画布' : '已回答',
steps: [],
output: t.answer ? { 回答: t.answer } : t.explanation ? { 说明: t.explanation } : null,
error: t.status === 'error' ? t.error || undefined : undefined,
})), [earlier])
const shown = showPast ? [...pastTurns, ...turns] : turns
const streamRef = useRef<HTMLDivElement>(null)
// 展开之前的轮次:它们补在最上面,视野不动的话人什么也看不到,像是没点上。回到
// 顶上从补出来的第一轮往下读(不按那一轮的位置滚:它刚挂上、还在淡入位移,量出来差几像素)
useLayoutEffect(() => {
if (!showPast) return
const el = streamRef.current?.querySelector<HTMLElement>('[data-stream-scroll]')
if (el) el.scrollTop = 0
}, [showPast])
const empty = !copilotTurns.length && !earlier.length
/**
* 定位一条自查问题:和问题面板同一个落点、同一套推断(绑工具的问题落到工具选择器上)。
* 知道落在哪一项配置上(循环条件、第 2 个成员的工具)就打开检查器、翻到那一栏并把光标
* 放进去;落不到的只打开、取景
*/
const locateIssue = (x: CopilotIssue) => {
if (!x.nodeId) return
const node = useStudio.getState().nodes.find((n) => n.id === x.nodeId)
const at = node ? fieldOfIssue({ level: 'error', node_id: x.nodeId, message: x.message, field: x.field }, node) : null
revealField(x.nodeId, at, { focus: !!at })
}
/** 被去掉的工具:光标落在那个节点(或成员)工具选择器的「添加」上,回车就能加回去 */
const locateTools = (c: ToolChange) => revealField(c.node_id, c.field, { focus: true })
/** 一轮之后的动作:定位问题节点、让助手再修、撤销这次生成、重试 */
const actions = (turn: StreamTurn, i: number) => {
if (i !== shown.length - 1 || turn.phase === 'running') return null
const source = copilotTurns.find((t) => t.id === turn.id)
if (!source) return null
const out = copilotOutcome(source.ops, false, canvasLabel)
if (turn.phase === 'error') {
return (
<button type="button" className="btn btn-xs" onClick={() => retryCopilot()}>
<Wand2 size={11} aria-hidden /> 用同一句话重试
</button>
)
}
const issues = out.check?.status === 'failed' ? out.check.issues : []
// 对照数据目录的 SQL 检查:不挡运行的提醒、提示(错误交回模型改过,没改好的在上面那组)
const sqlNotes = sortSqlChecks(out.sqlChecks)
const { unasked, asked } = toolLossOf(out)
// 画布一处没变(unchanged)就没有可撤的:给了按钮,撤掉的是这一轮之前的那一步
const undo = source.outcome === 'applied'
|| (out.kind === 'built' && source.outcome !== 'reverted' && source.outcome !== 'unchanged')
if (!issues.length && !out.skipped.length && !unasked.length && !asked.length && !sqlNotes.length) {
return undo ? (
<button type="button" className="btn btn-xs btn-ghost" onClick={() => undoCopilotTurn(source.id)}>
<Undo2 size={11} aria-hidden /> 撤销这次生成
</button>
) : null
}
// 只是工具被抹掉了:让助手再修不管用(自查是通过的),撤回这一轮才是一步到位的补救
const repairable = issues.length > 0 || out.skipped.length > 0
return (
<div className="w-full space-y-1.5">
{/* 没修好的问题直接摊开,不折叠:这是此刻唯一要做的事 */}
{issues.length > 0 && <IssueList issues={issues} onLocate={locateIssue} />}
{sqlNotes.length > 0 && (
<IssueList issues={sqlNotes} onLocate={locateIssue} title={SQL_CHECK_TEXT.title}
plain={sqlNotes.every((x) => x.level === 'info')} />
)}
{(unasked.length > 0 || asked.length > 0) && <ToolLossList unasked={unasked} asked={asked} onLocate={locateTools} />}
<div className="flex flex-wrap items-center gap-1.5">
{repairable && (
<button type="button" className="btn btn-xs btn-primary"
onClick={() => repairWithCopilot(source.id)}>
<Wand2 size={11} aria-hidden /> {issues.length ? '让助手继续修正' : '让助手补全'}
</button>
)}
{undo && (
// 只有按要求删的:这一轮做的就是人要的,撤销退成次要
<button type="button" className={clsx('btn btn-xs', repairable || !unasked.length ? 'btn-ghost' : 'btn-primary')}
onClick={() => undoCopilotTurn(source.id)}>
<Undo2 size={11} aria-hidden /> 撤销这次生成
</button>
)}
</div>
</div>
)
}
return (
<>
{!empty && (
<div className="flex shrink-0 items-center gap-2 border-b px-2.5 py-1.5">
<span className="text-2xs font-semibold">助手</span>
{memory.turns > 0 && (
<span className="chip tnum text-2xs"
title={`下一条指令会附带最近 ${memory.turns} 轮对话和上一轮的工作流,模型据此理解「刚才那个」指的是什么`}>
参考前 {memory.turns} 轮
</span>
)}
<span className="flex-1" />
<IconButton
label="开始新对话(不影响画布,旧对话保留)"
onClick={() => void newCopilotConversation()}
>
<MessageSquarePlus size={12} aria-hidden />
</IconButton>
</div>
)}
{empty ? (
// 没说过话时输入框就是这一栏的主体,竖直居中
<div className="flex min-h-0 flex-1 flex-col justify-center overflow-y-auto py-6">
<Composer hero />
</div>
) : (
<>
{earlier.length > 0 && (
<button type="button"
className="flex shrink-0 items-center gap-1.5 border-b px-2.5 py-1 text-left text-2xs text-dim transition-colors hover:bg-hover hover:text-fg"
aria-expanded={showPast}
onClick={() => setShowPast((v) => !v)}>
<History size={11} aria-hidden />
{showPast ? '收起之前的对话' : `之前的 ${earlier.length} 轮(模型仍会参考)`}
<ChevronRight size={10} aria-hidden className="ml-auto transition-transform"
style={{ transform: showPast ? 'rotate(90deg)' : 'none' }} />
</button>
)}
<div ref={streamRef} className="min-h-0 flex-1">
{/* 定位只认这张图上的对话:在前面补出之前的轮次不算换了一批,不再被拽到底 */}
<AssistantStream turns={shown} dense landing="end" renderTurnActions={actions}
resetKey={turns[0]?.id ?? ''}
empty={<div className="flex-1" />} />
</div>
<Composer hero={false} />
</>
)}
{hasRun && <RunStrip onClick={onOpenRun} />}
</>
)
}
/**
* 自查没修好的问题,逐条带「定位」。问题落在哪个节点上认得出来才给;写节点名,认不出才写 id。
* 对照数据目录的 SQL 检查另画级别、中文规则名、涉及的表和列、SQL 片段(和证据面板同一套),「定位」落到 SQL 那一项。
* title 有值时是不挡运行的那一组(SQL 检查的提醒、提示):全是提示时不用提醒色
*/
function IssueList({ issues, onLocate, title, plain = false }: {
issues: CopilotIssue[]; onLocate: (x: CopilotIssue) => void; title?: string; plain?: boolean
}) {
return (
<div data-issue-list={title ? 'sql' : ''} className="rounded border px-2 py-1.5 text-2xs leading-relaxed"
style={plain ? undefined : { borderColor: 'color-mix(in srgb, var(--st-waiting) 45%, var(--border))', background: 'var(--st-waiting-soft)' }}>
{title && <div className="mb-1 font-medium text-fg">{title}</div>}
<ul className="space-y-1">
{issues.map((x, i) => {
const sql = issueSqlCheck(x)
return (
<li key={i} className="flex items-start gap-1.5" data-issue-node={x.nodeId} data-issue-field={x.field} data-issue-code={x.code}>
<div className="min-w-0 flex-1 [overflow-wrap:anywhere]" title={issueLine(x)}>
{x.nodeId && <span className={clsx('text-dim', !x.label && 'mono')}>「{x.label ?? x.nodeId}」</span>}
{sql ? <div className="mt-0.5"><SqlCheckBody check={sql} /></div> : x.message}
</div>
{x.nodeId && (
<button type="button"
className="inline-flex shrink-0 items-center gap-0.5 rounded px-1 text-dim transition-colors hover:bg-hover hover:text-fg"
title={x.field ? '打开该节点的设置,定位到出问题的配置项' : '在画布上定位这个节点'}
onClick={() => onLocate(x)}>
<Crosshair size={10} aria-hidden /> 定位
</button>
)}
</li>
)
})}
</ul>
</div>
)
}
/**
* 这一轮被去掉的工具,逐个节点列出来:去掉了什么、还剩什么。「修改 1」说不出节点的
* 查库工具被整个抹掉了,要到运行结果不对才会发现。分两组,和改图回执一样:这一轮没让删的
* 在前、提醒色;按要求删的另列一组、不催——混在一起,按要求删的也像是改漏了,要紧的那句被冲淡
*/
function ToolLossList({ unasked, asked, onLocate }: {
unasked: ToolChange[]; asked: ToolChange[]; onLocate: (c: ToolChange) => void
}) {
const group = (kind: 'unasked' | 'asked', changes: ToolChange[]) => {
const warn = kind === 'unasked'
return (
<div data-tool-loss={kind} className="space-y-0.5 rounded border px-2 py-1.5 text-2xs leading-relaxed"
style={warn ? { borderColor: 'color-mix(in srgb, var(--st-waiting) 45%, var(--border))', background: 'var(--st-waiting-soft)' }
: undefined}>
<div className={warn ? 'font-medium text-fg' : 'text-dim'}>
{warn ? '本轮未要求删除以下工具,请确认是否误删:' : '按要求去掉的:'}
</div>
<ul className="space-y-0.5">
{changes.map((c) => (
<li key={`${c.node_id}|${c.member ?? ''}|${c.field}`} className="flex items-start gap-1.5">
<Wrench size={10} className="mt-[3px] shrink-0" style={{ color: warn ? 'var(--st-waiting)' : 'var(--text-faint)' }} aria-hidden />
<span className="min-w-0 flex-1 [overflow-wrap:anywhere]">
「{c.label}」{c.member ? `的成员「${c.member}」` : ''}去掉了{' '}
<span className="mono">{c.removed.join('、')}</span>
{c.after.length ? `,剩余 ${c.after.join('、')}` : ',当前没有任何工具'}
</span>
{c.node_id && (
<button type="button"
className="inline-flex shrink-0 items-center gap-0.5 rounded px-1 text-dim transition-colors hover:bg-hover hover:text-fg"
title="打开该节点的设置,并定位到工具配置项的「添加」"
onClick={() => onLocate(c)}>
<Crosshair size={10} aria-hidden /> 定位
</button>
)}
</li>
))}
</ul>
</div>
)
}
return (
<div data-tool-changes="" className="space-y-1">
{unasked.length > 0 && group('unasked', unasked)}
{asked.length > 0 && group('asked', asked)}
</div>
)
}
/**
* 对话下面那条运行进度。
*
* 运行搬去自己那一层之后,这条是它在对话视图里留下的唯一痕迹——没有它,
* 点了运行再切回对话,这次运行在眼前就彻底没了。相位、那一句话和计时读
* useRunGlance,和画布上的胶囊同一份:以前这里自己解码步骤、自己起名,同一时刻
* 胶囊写「停在『主管审批』」、这里写「等待审批」,计时也各走各的。
*/
function RunStrip({ onClick }: { onClick: () => void }) {
const run = useStudio((s) => s.run)
const g = useRunGlance(true)
if (!run) return null
const phase = g.phase
const waiting = phase === 'waiting'
const alert = phase === 'failed' || phase === 'suspended'
const ticking = phase === 'running' || phase === 'queued'
return (
<button
type="button"
data-run-strip={phase}
className="flex w-full shrink-0 items-center gap-2 border-t px-2.5 py-1.5 text-left text-2xs transition-colors hover:bg-hover"
style={waiting ? { background: 'var(--st-waiting-soft)' } : undefined}
title="查看本次运行的完整过程" onClick={onClick}
>
{phase === 'idle'
? <Play size={10} className="shrink-0 text-faint" fill="currentColor" aria-hidden />
: <StatusBadge status={phase} size={11} decorative />}
<span className="min-w-0 flex-1 truncate">
<span className={clsx('font-medium', !waiting && !alert && 'text-fg')}
style={waiting ? { color: 'var(--st-waiting)' } : alert ? { color: 'var(--st-failed)' } : undefined}>
{phase === 'idle' ? '运行' : g.label}
</span>
{g.headline && phase !== 'idle' && <span className="text-dim"> · {g.headline}</span>}
{waiting && <span style={{ color: 'var(--st-waiting)' }}> · 去处理</span>}
</span>
{ticking && (
// 从开始算的时刻,和画布上胶囊的读数同一种写法:跨天写「9 天 00:38:26」
<span className="mono tnum shrink-0" style={{ color: 'var(--st-running)' }}>{formatOffset(g.elapsedMs)}</span>
)}
<ChevronRight size={11} className="shrink-0 text-faint" aria-hidden />
</button>
)
}
// -------------------------------------------------------------------------
// 运行:过程和结果,没有输入框
// -------------------------------------------------------------------------
const STREAM_PHASE: Record<RunPhase, StreamTurn['phase']> = {
idle: 'running', queued: 'running', running: 'running', waiting: 'waiting',
succeeded: 'done', failed: 'error', cancelled: 'done', suspended: 'done',
}
const finite = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
/**
* 停下来之后「执行」写多久。新后端的 usage.active_ms 是各段执行之和;老数据没有它,而
* duration_ms 只记了审批恢复后的最后一段——同屏的胶囊、航迹按事件算出一分多钟,栏头和
* 底栏写「22 ms」。所以退到航迹(和航迹坞同一个投影),连事件都没有才用 duration_ms
*/
function stoppedActiveMs(usage: RunUsage | undefined, trace: Trace): number | undefined {
return finite(usage?.active_ms)
?? (trace.startedAt != null ? project(trace, lastEventAt(trace)).activeMs : undefined)
?? finite(usage?.duration_ms)
}
/**
* 画布上分支、循环出口的说明。签名只取这两类节点的出口配置:拖动、选中节点都会换掉
* nodes 数组,不能因此把整条时间线重新解码一遍
*/
function useExitLabels() {
const sig = useStudio((s) => JSON.stringify(s.nodes
.filter((n) => n.data.nodeType === 'branch' || n.data.nodeType === 'loop')
.map((n) => [n.id, n.data.nodeType, n.data.nodeType === 'branch' ? n.data.config?.cases ?? [] : null])))
return useMemo(() => exitLabels(
(JSON.parse(sig) as [string, string, unknown][]).map(([id, type, cases]) => ({ id, type, config: { cases } })),
sourceHandles,
), [sig])
}
/**
* 画布右栏里的证据面板能做的两件事(evidenceHost):
* - 点开片段时把证据路径交给血缘层(setLineage):产出证据的节点(查询、口径卡、检索)实线描边、
* 报告虚线描边,两者之间的走线提亮。画布上已经没有的节点不交(对不到任何卡片)。面板关掉、换走时只
* 收回自己画的那一条——悬停变量画的血缘不去动它
* - 面板里的节点名点一下:选中并对准画布上的节点,和步骤行的「在画布上看」同一个动作
*/
function useCanvasEvidenceHost(): EvidenceHost {
const select = useStudio((s) => s.select)
const focusNode = useStudio((s) => s.focusNode)
const drawn = useRef<string | null>(null)
return useMemo<EvidenceHost>(() => {
const exists = (id: string) => useStudio.getState().nodes.some((n) => n.id === id)
return {
onTrace: (trace) => {
const s = useStudio.getState()
if (trace) {
drawn.current = trace.label
s.setLineage({ var: trace.label, producers: trace.producers.filter(exists), consumers: trace.consumers.filter(exists) })
} else if (drawn.current && s.lineage?.var === drawn.current) {
drawn.current = null
s.setLineage(null)
}
},
onNode: (id) => { select(id); focusNode(id) },
nodeLabel: (id) => {
const node = useStudio.getState().nodes.find((n) => n.id === id)
return node ? node.data.label || id : undefined
},
}
}, [select, focusNode])
}
function RunView({ onBack, reveal, onRevealed }: {
onBack: () => void; reveal: Reveal | null; onRevealed: () => void
}) {
const run = useStudio((s) => s.run)!
const events = useStudio((s) => s.events)
const phase = useStudio((s) => s.runPhase)
const trace = useStudio((s) => s.trace)
const attachRun = useStudio((s) => s.attachRun)
const stopRun = useStudio((s) => s.stopRun)
const setHoveredNode = useStudio((s) => s.setHoveredNode)
const focusNode = useStudio((s) => s.focusNode)
const select = useStudio((s) => s.select)
const hoveredNodeId = useStudio((s) => s.hoveredNodeId)
const approvals = useCatalog((s) => s.approvals)
const refreshApprovals = useCatalog((s) => s.refreshApprovals)
// 运行已经收尾了就不再挂卡:审批列表 4 秒才轮询一次,别处批过的卡会在跑完的运行上多挂几秒
const pending = isSettled(phase) ? []
: approvals.filter((a) => a.run_id === run.id && a.status === 'pending')
const [raw, setRaw] = useState(false)
const [stopping, setStopping] = useState(false)
const bodyRef = useRef<HTMLDivElement>(null)
const exitLabelOf = useExitLabels()
const evidenceHost = useCanvasEvidenceHost()
const turns = useMemo<StreamTurn[]>(() => {
const finished = [...events].reverse().find((e) => e.type === 'run.finished')
const failedEvent = [...events].reverse().find((e) => e.type === 'run.failed')
// 结局以 runPhase 为准,连同对账查到的状态一起喂给解码器:服务被强杀、
// 事件停在半路的运行,右栏的行也得收住,不能一直转
const steps = decodeRun(events, { status: run.status }, { exitLabelOf })
const output = run.output && Object.keys(run.output).length ? run.output : finished?.data?.output
return [{
id: run.id,
// 运行没有"提问"——它的输入是工具栏那张表单。硬塞一个问题气泡
// 只会让它看起来像一次对话,而它不是
question: undefined,
phase: STREAM_PHASE[phase],
status: statusLabel(phase === 'idle' ? 'queued' : phase),
statusCode: phase === 'idle' ? 'queued' : phase,
tone: phase === 'suspended' ? 'warn' : undefined,
steps,
output: phase === 'succeeded' || phase === 'failed' ? output ?? null : null,
// 原始异常(run.failed.data.detail)收进可展开的技术细节,人话在上面
error: phase === 'failed'
? { error: String(failedEvent?.data?.error ?? run.error ?? '运行失败'),
...(failedEvent?.data?.detail ? { detail: String(failedEvent.data.detail) } : {}) }
: undefined,
// #runId 已经在栏头上了(可点,去运行记录),卡片里不再重复;成果里的报告取证据链照样要它
runClass: run.run_class,
outputRun: run.id,
startedAt: trace.startedAt,
// 栏头只在不跑的时候写它;跑着的时候耗时细条按各步自己的耗时比
elapsedMs: phase === 'waiting' || isSettled(phase) ? stoppedActiveMs(run.usage, trace) : undefined,
}]
}, [run, events, phase, trace, exitLabelOf])
// 停下来等人的那一刻就去取审批卡。列表 4 秒才轮询一次:头上已经写着「等待审批」,
// 通过/驳回却要晚几秒才出来
const hasCard = pending.length > 0
const [looked, setLooked] = useState<string | null>(null)
useEffect(() => {
if (phase !== 'waiting' || hasCard) return
let alive = true
void refreshApprovals().finally(() => { if (alive) setLooked(run.id) })
return () => { alive = false }
}, [phase, hasCard, run.id, refreshApprovals])
// 画布上选中节点时不在这里滚动定位:选中即滑入的属性面板整个盖住这一栏,滚了也
// 看不见,反倒让藏在下面的时间线离开底部、断了跟随——关掉属性面板回来,人看到
// 的不是原处而是一枚「跳到最新」。联动靠悬停(activeNodeId)和点行取景
const stop = async () => {
setStopping(true)
try {
await stopRun()
} catch (e) {
// 409 多半是它其实早就不在跑了(服务重启过、刚好结束)。store 已经对过账,
// 这里说清楚发生了什么,不能静默吞掉
toast.error(e)
} finally {
setStopping(false)
}
}
/**
* 放弃停在审批上(或服务重启时挂起)的运行。它已经不在执行,没有「停止」可言;后端
* 把它直接收成已取消,待审批一并关闭。关掉的审批找不回来,所以先问一句——和画布
* 胶囊上的「放弃」同一套说法
*/
const abandon = async () => {
const ok = await confirmDialog({
title: '放弃这次运行?',
body: phase === 'suspended'
? '运行在服务重启时已挂起。放弃后将记为已取消,且无法从断点继续运行。'
: '运行正在等待审批。放弃后将记为已取消,且无法继续运行。',
consequences: [
...(pending.length ? [`本次运行的 ${pending.length} 条待审批将一并关闭,操作记录中会写明放弃人`] : []),
'已完成节点的结果仍保留在记录中',
],
confirmLabel: '放弃这次运行',
danger: true,
})
if (ok) await stop()
}
const reducedMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
const toApproval = () => {
const slot = bodyRef.current?.querySelector<HTMLElement>('[data-approval-slot]:not(:empty)')
if (!slot) return
const card = slot.querySelector<HTMLElement>('[data-approval]') ?? slot
card.scrollIntoView({ block: 'center', behavior: reducedMotion() ? 'auto' : 'smooth' })
card.querySelector<HTMLElement>('textarea, input, button')?.focus({ preventScroll: true })
// 和胶囊找到卡片时同一个描边:人从画布那头被带过来,得知道「是这张」
card.classList.add('sf-flash')
setTimeout(() => card.classList.remove('sf-flash'), 1600)
}
const toIssuance = () => {
const banner = bodyRef.current?.querySelector<HTMLElement>('[data-issuance-banner]')
if (!banner) return
banner.scrollIntoView({ block: 'start', behavior: reducedMotion() ? 'auto' : 'smooth' })
banner.focus({ preventScroll: true })
banner.dataset.flash = 'focus'
setTimeout(() => { if (banner.dataset.flash === 'focus') delete banner.dataset.flash }, 1600)
}
// 别处请这一层把审批卡 / 出具横幅摆到眼前(胶囊的「去审批」、成果节点的出具印章)。
// 卡片跟着审批列表来,刚停下时可能还在路上:等取回来(或确认没有)再办,别滚到空处
// 节点的步骤交给助手流自己滚:滚过去要暂停跟随,贴底跟随的开关在它手里
const revealed = useRef(0)
useEffect(() => {
if (!reveal || reveal.seq === revealed.current) return
if (raw) { setRaw(false); return }
if (reveal.kind === 'step') return
if (reveal.kind === 'approval' && !hasCard && phase === 'waiting' && looked !== run.id) return
revealed.current = reveal.seq
// 等这一帧的布局落定:运行层刚挂上时,助手流自己也在做打开时的定位。不在清理里
// 取消——紧跟着来一条事件就会把这次请求吞掉;卸载了的话两个函数找不到东西自然不做
requestAnimationFrame(reveal.kind === 'approval' ? toApproval : toIssuance)
onRevealed()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [reveal, raw, hasCard, phase, looked, run.id, turns])
const stepReveal = useMemo(() => (reveal?.kind === 'step' && reveal.nodeId && !raw
? { nodeId: reveal.nodeId, seq: reveal.seq } : null), [reveal, raw])
const live = phase === 'running' || phase === 'queued'
// 停在审批上、服务重启时挂起的:不在执行,给的是「放弃」而不是「停止」
const parked = phase === 'waiting' || phase === 'suspended'
return (
<div className="sheet-in flex h-full min-h-0 flex-col">
<div className="flex shrink-0 items-center gap-1.5 border-b px-2 py-1.5">
<button
type="button"
className="flex items-center gap-0.5 rounded-md px-1 py-1 text-dim transition-colors hover:bg-hover hover:text-fg"
title="回到和助手的对话" onClick={onBack}
>
<ChevronLeft size={14} aria-hidden />
<span className="text-2xs">助手</span>
</button>
<span className="mx-0.5 h-3.5 w-px shrink-0" style={{ background: 'var(--border)' }} />
<span className="text-2xs font-semibold">运行</span>
<Link to={`/runs/${run.id}`} title="在运行记录中查看"
className="mono text-2xs text-dim underline-offset-2 hover:text-fg hover:underline">
{shortId(run.id)}
</Link>
<span className="flex-1" />
{phase === 'waiting' && pending.length > 0 && (
// 等人时首要的是此刻真正该做的事;「放弃」是次一级的,收成一枚图标
<button type="button" className="btn btn-xs" style={{ color: 'var(--st-waiting)' }} onClick={toApproval}>
<Hand size={11} aria-hidden /> 去审批
</button>
)}
{parked && (
<IconButton label="放弃这次运行" variant="danger" disabled={stopping} onClick={() => void abandon()}>
<Ban size={12} aria-hidden />
</IconButton>
)}
{live && (
<IconButton label="停止这次运行" variant="danger" disabled={stopping} onClick={() => void stop()}>
<Square size={11} fill="currentColor" aria-hidden />
</IconButton>
)}
{/* 原始事件是排查用的开发者视图,不该占一个常驻 tab——但也不能删,
翻译层出问题时它是唯一能对照的东西 */}
<IconButton
label={raw ? '回到可读视图' : `看原始事件(${events.length} 条)`}
className={raw ? 'text-[var(--accent)]' : undefined}
aria-pressed={raw}
onClick={() => setRaw((v) => !v)}
>
<Code2 size={12} aria-hidden />
</IconButton>
</div>
<div ref={bodyRef} className="min-h-0 flex-1">
{raw ? <RawEvents /> : (
// 成果里的报告点开片段时:证据路径画到画布上,面板里的节点名点一下就对准那个节点
<EvidenceHostContext.Provider value={evidenceHost}>
<AssistantStream
turns={turns}
dense
clockSkewMs={trace.skewMs ?? 0}
onStepHover={setHoveredNode}
onStepFocus={focusNode}
// 没绑工具、轮数不够这种要去画布上改的:直接打开那个节点的设置,镜头也带过去。
// 给了栏位(指标的问题出在来源查询的 SQL 上,field 是 args.sql)就落到那一栏
onStepOpen={(id, field) => {
if (field) revealField(id, field, { focus: true })
else { select(id); focusNode(id) }
}}
activeNodeId={hoveredNodeId}
reveal={stepReveal}
onRevealed={onRevealed}
// 审批卡插在它所属的那一轮里,而不是浮在整栏顶上:
// 要确认的那件事和确认按钮之间不该隔着整条执行过程
approvalsFor={() => (pending.length ? (
<div className="fade-up mt-2 overflow-hidden rounded-lg border"
style={{ borderColor: 'var(--warn)' }}>
{pending.map((a) => (
<ApprovalCard key={a.id} approval={a} showWorkflow={false}
onResolved={(runId) => attachRun(runId)} />
))}
</div>
) : phase === 'waiting' ? (
<div className="mt-2 rounded-lg border border-dashed px-2.5 py-2 text-2xs text-dim"
style={{ borderColor: 'var(--st-waiting)' }}>
{looked === run.id ? (
<>
未找到本次运行的待审批卡,可能已在别处处理 ·{' '}
<Link to={`/runs/${run.id}`} className="underline underline-offset-2 hover:text-fg">查看运行记录</Link>
</>
) : '正在加载审批卡…'}
</div>
) : null)}
/>
</EvidenceHostContext.Provider>
)}
</div>
<RunFooter />
</div>
)
}
/**
* 底栏:运行级别、执行时长、等人时长、tokens、成本。
*
* 以前读的是 POST /runs 返回的 queued 快照,跑完也是空的。运行中读实时累加的
* usageLive,前面标「≥」——agent 和协作成员的模型调用以前不发 llm.end,
* 老后端上这个数会偏少,不能装作是总数;到终态换成后端累计的 run.usage。
* 执行和等人分开写:审批停了三分钟的运行,混成一个数哪个都不对。
*/
function RunFooter() {
const run = useStudio((s) => s.run)!
const phase = useStudio((s) => s.runPhase)
const trace = useStudio((s) => s.trace)
const usageLive = useStudio((s) => s.usageLive)
const settled = isSettled(phase)
const ticking = !settled && phase !== 'idle' && trace.startedAt != null
const now = useRunClock(ticking)
const view = trace.startedAt != null ? project(trace, liveAt(trace, now)) : undefined
const u = run.usage ?? {}
const num = finite
const activeMs = settled ? stoppedActiveMs(u, trace) : view?.activeMs
const waitMs = settled ? num(u.wait_ms) ?? view?.waitMs : view?.waitMs
const finalTokens = num(u.total_tokens)
?? (num(u.input_tokens) != null || num(u.output_tokens) != null
? (num(u.input_tokens) ?? 0) + (num(u.output_tokens) ?? 0) : undefined)
const liveTokens = usageLive.tokensIn + usageLive.tokensOut
const tokens = settled ? finalTokens ?? (liveTokens || undefined) : liveTokens
const cost = settled ? num(u.cost_usd) ?? (usageLive.costUsd || undefined) : usageLive.costUsd
const approx = !settled && phase !== 'idle'
return (
// 360px 的栏里放得下一行就不折:等人时五样数都在,11px 会把成本挤到第二行
<div className="tnum flex shrink-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 border-t px-2.5 py-1 text-[10.5px] text-dim"
aria-label="这次运行的用量">
<span className="chip" style={run.run_class === 'formal'
? { color: 'var(--st-done)', borderColor: 'var(--st-done)' } : undefined}>
{runClassLabel(run.run_class ?? 'exploratory', run.version)}
</span>
<span className="flex-1" />
<span title="执行时长:各段执行时长之和,不含等待审批的时间">
执行 <span className="mono text-fg">
{activeMs == null ? NONE : ticking ? formatLapse(activeMs) : formatSpan(activeMs)}
</span>
</span>
{(waitMs ?? 0) > 0 && (
<span title="等待审批的总时长" style={phase === 'waiting' ? { color: 'var(--st-waiting)' } : undefined}>
等待审批 <span className="mono">{ticking ? formatLapse(waitMs!) : formatSpan(waitMs!)}</span>
</span>
)}
<span title={approx ? '运行中实时累加,可能偏少;运行结束后以服务端统计为准' : '服务端统计'}>
<span className="mono">{approx && tokens ? '≥' : ''}{tokens ? formatTokens(tokens, { compact: true }) : `${NONE} token`}</span>
</span>
<span className="mono">{approx && cost ? '≥' : ''}{cost != null && (cost > 0 || settled) ? formatCost(cost) : NONE}</span>
</div>
)
}
/** 原始事件。翻译层出问题时用来对照,平时收着。和步骤流一样只在贴底时跟随 */
function RawEvents() {
const events = useStudio((s) => s.events)
const ref = useRef<HTMLDivElement>(null)
const pinned = useRef(true)
useEffect(() => {
const el = ref.current
if (el && pinned.current) el.scrollTop = el.scrollHeight
}, [events.length])
if (!events.length) {
return (
<div className="flex h-full items-center justify-center text-2xs text-dim">
暂无事件
</div>
)
}
return (
<div ref={ref} className="h-full overflow-y-auto p-2"
onScroll={(e) => {
const el = e.currentTarget
pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 64
}}>
{events.map((e) => (
<div key={e.seq} className="mono border-b px-1 py-1 text-[10px] leading-relaxed last:border-0">
<span className="text-dim">#{e.seq}</span>{' '}
<span className="text-[var(--accent)]">{e.type}</span>{' '}
{e.node_id && <span className="text-dim">{e.node_id}</span>}
<div className="break-all text-dim">{JSON.stringify(e.data).slice(0, 400)}</div>
</div>
))}
</div>
)
}