61 lines
8.3 KiB
TypeScript
61 lines
8.3 KiB
TypeScript
import * as React from 'react'
|
||
import { 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 header={<AppPageHeader title="运行记录详情" />}><AppEmptyState title="无法打开运行详情" description={error} /></AppPage>
|
||
if (!run) return <AppPage header={<AppPageHeader title="运行记录详情" />}><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" header={<AppPageHeader title={run.agent_name} breadcrumbLabel={run.public_id} 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={() => 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}</>} />}>
|
||
{['queued', 'running'].includes(run.status) ? <Alert><RotateCw className="animate-spin" /><AlertTitle>{run.progress_message || 'Agent 正在运行'}</AlertTitle><AlertDescription>当前步骤:{run.current_step || '等待调度'}{run.retry_count ? `;已重试 ${run.retry_count} 次` : ''}</AlertDescription></Alert> : 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} · ${selected.model_display_name}(${selected.model_capability})` : '尚未生成步骤记录'}</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>
|
||
}
|