feat: add agent debugging workspace

This commit is contained in:
hailin
2026-07-14 14:08:47 +08:00
parent 92b7af118e
commit d08dda723c
7 changed files with 417 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
import * as React from 'react'
import { ArrowLeft, Ban, Braces, Clock3, Coins, RotateCw } from 'lucide-react'
import { toast } from 'sonner'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppPage } from '@/components/app/AppPage'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { StatusBadge } from '@/features/ai-platform/status'
import { formatDateTime } from '@/lib/format'
import { cn } from '@/lib/utils'
import { cancelAgentRun, getAgentRun } from './api'
import type { AgentRunDetail } from './types'
function JsonBlock({ value }: { value: unknown }) {
return <pre className="max-h-[420px] overflow-auto rounded-md border border-border bg-muted/30 p-3 font-mono text-xs leading-5 text-foreground">{JSON.stringify(value, null, 2)}</pre>
}
export function AgentRunDetailPage({ publicId }: { publicId: string }) {
const [run, setRun] = React.useState<AgentRunDetail | null>(null)
const [selectedStepId, setSelectedStepId] = React.useState<number | null>(null)
const [error, setError] = React.useState('')
const load = React.useCallback(async () => {
try {
const next = await getAgentRun(publicId); setRun(next); setSelectedStepId((current) => current ?? next.steps[0]?.id ?? null); setError('')
} catch (reason) { setError(reason instanceof Error ? reason.message : '加载运行详情失败') }
}, [publicId])
React.useEffect(() => { void load() }, [load])
React.useEffect(() => {
if (!run || !['queued', 'running'].includes(run.status)) return
const timer = window.setTimeout(() => void load(), 1200)
return () => window.clearTimeout(timer)
}, [load, run])
async function cancel() { try { setRun(await cancelAgentRun(publicId)); toast.success('取消请求已提交') } catch (reason) { toast.error(reason instanceof Error ? reason.message : '取消失败') } }
if (error) return <AppPage><AppEmptyState title="无法打开运行详情" description={error} /></AppPage>
if (!run) return <AppPage><div className="h-72 animate-pulse rounded-md bg-muted" /></AppPage>
const selected = run.steps.find((step) => step.id === selectedStepId) ?? null
return <AppPage maxWidth="7xl">
<AppPageHeader title={run.agent_name} description={`运行 ${run.public_id} · 创建于 ${formatDateTime(run.created_at)}`} meta={<div className="flex gap-1"><StatusBadge status={run.status} />{run.sandbox ? <Badge variant="warning">草稿沙箱</Badge> : <Badge variant="secondary">发布 v{run.agent_version}</Badge>}</div>} actions={<><Button variant="outline" onClick={() => { window.location.hash = '/agent-runs' }}><ArrowLeft data-icon="inline-start" />返回</Button><Button variant="outline" onClick={() => void load()}><RotateCw data-icon="inline-start" />刷新</Button>{['queued', 'running'].includes(run.status) ? <Button variant="destructive" onClick={() => void cancel()}><Ban data-icon="inline-start" />尽力取消</Button> : null}</>} />
{run.error_message ? <Alert variant="destructive"><AlertTitle>{run.error_code || '运行失败'}</AlertTitle><AlertDescription>{run.error_message}</AlertDescription></Alert> : null}
<div className="grid gap-4 lg:grid-cols-[280px_minmax(0,1fr)_300px]">
<Card className="gap-0 py-0"><CardHeader className="border-b border-border py-4"><CardTitle className="text-sm">执行脊柱</CardTitle><CardDescription>{run.steps.length}/{run.total_steps} 个步骤已创建</CardDescription></CardHeader><CardContent className="max-h-[640px] overflow-auto p-2"><ol className="grid gap-1">{run.steps.map((step) => <li key={step.id}><button type="button" onClick={() => setSelectedStepId(step.id)} className={cn('grid w-full grid-cols-[28px_minmax(0,1fr)] gap-2 rounded-md p-2 text-left transition-colors hover:bg-muted', selectedStepId === step.id && 'bg-muted')}><span className={cn('flex size-7 items-center justify-center rounded-full border text-xs font-medium', step.status === 'succeeded' && 'border-success/40 bg-success/10 text-success', step.status === 'failed' && 'border-destructive/40 bg-destructive/10 text-destructive')}>{step.order}</span><span className="min-w-0"><span className="block truncate font-medium">{step.name}</span><span className="mt-1 flex items-center justify-between gap-2"><span className="font-mono text-xs text-muted-foreground">{step.code}</span><StatusBadge status={step.status} /></span></span></button>{step.order < run.steps.length ? <span className="ml-[21px] block h-3 border-l border-border" /> : null}</li>)}</ol></CardContent></Card>
<Card className="gap-0 py-0"><CardHeader className="border-b border-border py-4"><CardTitle className="text-sm">{selected ? `${selected.order}. ${selected.name}` : '运行输入与最终输出'}</CardTitle><CardDescription>{selected ? selected.code : '尚未生成步骤记录'}</CardDescription></CardHeader><CardContent className="p-4"><Tabs defaultValue="input"><TabsList><TabsTrigger value="input">输入</TabsTrigger><TabsTrigger value="output">输出</TabsTrigger><TabsTrigger value="attempts">调用尝试</TabsTrigger></TabsList><TabsContent value="input" className="pt-3"><JsonBlock value={selected?.input_payload ?? run.input_payload} /></TabsContent><TabsContent value="output" className="pt-3"><JsonBlock value={selected?.output_payload ?? run.final_output} /></TabsContent><TabsContent value="attempts" className="grid gap-3 pt-3">{selected?.attempts.length ? selected.attempts.map((attempt) => <Card key={attempt.id}><CardHeader><div className="flex items-center justify-between"><CardTitle className="text-sm">{attempt.attempt_type}</CardTitle><StatusBadge status={attempt.status} /></div><CardDescription>{attempt.input_tokens + attempt.output_tokens} tokens · {attempt.duration_ms} ms</CardDescription></CardHeader><CardContent className="grid gap-2">{attempt.validation_error ? <Alert variant="destructive"><AlertTitle>结构校验错误</AlertTitle><AlertDescription>{attempt.validation_error}</AlertDescription></Alert> : null}<JsonBlock value={attempt.raw_output} /></CardContent></Card>) : <AppEmptyState title="暂无调用尝试" description="排队步骤或尚未开始的步骤不会产生调用记录。" />}</TabsContent></Tabs></CardContent></Card>
<div className="grid content-start gap-4"><Card><CardHeader><CardTitle className="text-sm">运行指标</CardTitle></CardHeader><CardContent className="grid gap-3 text-sm"><Metric icon={Coins} label="输入 Token" value={String(run.input_tokens)} /><Metric icon={Coins} label="输出 Token" value={String(run.output_tokens)} /><Metric icon={Clock3} label="总耗时" value={`${run.duration_ms} ms`} />{selected ? <><Separator /><Metric icon={Coins} label="步骤 Token" value={String(selected.input_tokens + selected.output_tokens)} /><Metric icon={Clock3} label="步骤耗时" value={`${selected.duration_ms} ms`} /></> : null}</CardContent></Card><Card><CardHeader><CardTitle className="text-sm">配置快照</CardTitle><CardDescription>历史记录不受后续配置修改影响</CardDescription></CardHeader><CardContent><JsonBlock value={run.config_snapshot} /></CardContent></Card>{run.final_output !== null ? <Card><CardHeader><CardTitle className="flex items-center gap-2 text-sm"><Braces className="size-4" />最终结构化输出</CardTitle></CardHeader><CardContent><JsonBlock value={run.final_output} /></CardContent></Card> : null}</div>
</div>
</AppPage>
}
function Metric({ icon: Icon, label, value }: { icon: typeof Clock3; label: string; value: string }) {
return <div className="flex items-center justify-between gap-3"><span className="inline-flex items-center gap-2 text-muted-foreground"><Icon className="size-4" />{label}</span><span className="font-mono text-xs">{value}</span></div>
}