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

@@ -15,6 +15,13 @@ import { TicketCreatePage } from '@/features/tickets/TicketCreatePage'
import { TicketDetailPage } from '@/features/tickets/TicketDetailPage'
import { TicketListPage } from '@/features/tickets/TicketListPage'
import { WorkbenchPage } from '@/features/workbench/WorkbenchPage'
import { ModelProviderPage } from '@/features/modelhub/ModelProviderPage'
import { SkillListPage } from '@/features/skills/SkillListPage'
import { SkillDetailPage } from '@/features/skills/SkillDetailPage'
import { AgentListPage } from '@/features/agents/AgentListPage'
import { AgentDetailPage } from '@/features/agents/AgentDetailPage'
import { AgentRunListPage } from '@/features/agents/AgentRunListPage'
import { AgentRunDetailPage } from '@/features/agents/AgentRunDetailPage'
function getRoute() {
return window.location.hash.replace(/^#/, '') || '/'
@@ -57,6 +64,37 @@ export function Router() {
return <WorkbenchPage />
}
if (route === '/modelhub/providers') {
return <ModelProviderPage />
}
if (route === '/skills') {
return <SkillListPage />
}
const skillMatch = route.match(/^\/skills\/(\d+)$/)
if (skillMatch) {
return <SkillDetailPage skillId={Number(skillMatch[1])} />
}
if (route === '/agents') {
return <AgentListPage />
}
const agentMatch = route.match(/^\/agents\/(\d+)$/)
if (agentMatch) {
return <AgentDetailPage agentId={Number(agentMatch[1])} />
}
if (route === '/agent-runs') {
return <AgentRunListPage />
}
const runMatch = route.match(/^\/agent-runs\/([0-9a-f-]+)$/i)
if (runMatch) {
return <AgentRunDetailPage publicId={runMatch[1]} />
}
if (route === '/knowledge') {
return <KnowledgeBaseListPage />
}

View File

@@ -0,0 +1,103 @@
import * as React from 'react'
import { ArrowDown, ArrowLeft, ArrowUp, CopyPlus, Play, Plus, Save, Send, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppField } from '@/components/app/AppField'
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 { FieldGroup } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { JsonEditor, parseJsonObject } from '@/features/ai-platform/components/JsonEditor'
import { MobileEditGuard } from '@/features/ai-platform/components/MobileEditGuard'
import { SchemaInputEditor } from '@/features/ai-platform/components/SchemaInputEditor'
import { StatusBadge } from '@/features/ai-platform/status'
import { listDeployments } from '@/features/modelhub/api'
import type { ModelDeployment } from '@/features/modelhub/types'
import { getSkill, listSkills } from '@/features/skills/api'
import type { SkillVersion } from '@/features/skills/types'
import { formatDateTime } from '@/lib/format'
import { ensureAgentDraft, getAgent, publishAgent, rollbackAgent, startAgentRun, updateAgent, updateAgentDraft } from './api'
import type { AgentDetail, AgentVersion } from './types'
type EditableStep = { code: string; skill_version_id: string; mapping: string }
type SkillOption = SkillVersion & { definitionName: string }
export function AgentDetailPage({ agentId }: { agentId: number }) {
const [detail, setDetail] = React.useState<AgentDetail | null>(null)
const [draft, setDraft] = React.useState<AgentVersion | null>(null)
const [deployments, setDeployments] = React.useState<ModelDeployment[]>([])
const [skills, setSkills] = React.useState<SkillOption[]>([])
const [meta, setMeta] = React.useState({ name: '', description: '' })
const [modelId, setModelId] = React.useState('')
const [systemPrompt, setSystemPrompt] = React.useState('')
const [temperature, setTemperature] = React.useState('')
const [maxTokens, setMaxTokens] = React.useState('')
const [inputSchema, setInputSchema] = React.useState('{}')
const [exampleInput, setExampleInput] = React.useState('{}')
const [steps, setSteps] = React.useState<EditableStep[]>([])
const [testVersion, setTestVersion] = React.useState<string>('draft')
const [testInput, setTestInput] = React.useState('{}')
const [error, setError] = React.useState('')
const hydrateDraft = React.useCallback((version: AgentVersion | null) => {
setDraft(version)
if (!version) return
setModelId(String(version.model_deployment_id)); setSystemPrompt(version.system_prompt)
setTemperature(version.temperature_override === null ? '' : String(version.temperature_override))
setMaxTokens(version.max_output_tokens_override === null ? '' : String(version.max_output_tokens_override))
setInputSchema(JSON.stringify(version.input_schema, null, 2)); setExampleInput(JSON.stringify(version.example_input, null, 2)); setTestInput(JSON.stringify(version.example_input, null, 2))
setSteps(version.steps.map((step) => ({ code: step.code, skill_version_id: String(step.skill_version_id), mapping: JSON.stringify(step.input_mapping, null, 2) })))
}, [])
const load = React.useCallback(async () => {
try {
const [item, nextDeployments, definitions] = await Promise.all([getAgent(agentId), listDeployments(), listSkills()])
const skillDetails = await Promise.all(definitions.map((skill) => getSkill(skill.id)))
setDetail(item); setMeta({ name: item.name, description: item.description }); setDeployments(nextDeployments)
setSkills(skillDetails.flatMap((skill) => skill.versions.map((version) => ({ ...version, definitionName: skill.name }))))
hydrateDraft(item.versions.find((version) => version.status === 'draft') ?? null)
setTestVersion(item.draft_version ? 'draft' : String(item.active_version ?? ''))
setError('')
} catch (reason) { setError(reason instanceof Error ? reason.message : '加载 Agent 失败') }
}, [agentId, hydrateDraft])
React.useEffect(() => { void load() }, [load])
function updateStep(index: number, patch: Partial<EditableStep>) { setSteps((current) => current.map((step, i) => i === index ? { ...step, ...patch } : step)) }
function moveStep(index: number, direction: -1 | 1) { setSteps((current) => { const next = [...current]; const target = index + direction; if (target < 0 || target >= next.length) return current; [next[index], next[target]] = [next[target], next[index]]; return next }) }
async function save() {
try {
await Promise.all([
updateAgent(agentId, meta),
updateAgentDraft(agentId, { model_deployment_id: Number(modelId), system_prompt: systemPrompt, input_schema: parseJsonObject(inputSchema, 'Agent 输入 Schema'), example_input: parseJsonObject(exampleInput, '示例输入'), temperature_override: temperature === '' ? null : Number(temperature), max_output_tokens_override: maxTokens === '' ? null : Number(maxTokens), steps: steps.map((step) => ({ code: step.code, skill_version_id: Number(step.skill_version_id), input_mapping: parseJsonObject(step.mapping, `步骤 ${step.code} 输入映射`) })) })
])
toast.success('Agent 草稿已保存'); await load()
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '保存失败') }
}
async function createDraft() { try { await ensureAgentDraft(agentId); toast.success('已创建新草稿'); await load() } catch (reason) { toast.error(reason instanceof Error ? reason.message : '创建草稿失败') } }
async function publish() { try { await publishAgent(agentId); toast.success('Agent 版本已发布'); await load() } catch (reason) { toast.error(reason instanceof Error ? reason.message : '发布失败') } }
async function rollback(version: number) { try { await rollbackAgent(agentId, version); toast.success(`已回滚到 v${version}`); await load() } catch (reason) { toast.error(reason instanceof Error ? reason.message : '回滚失败') } }
async function run() { try { const result = await startAgentRun(agentId, { version: testVersion === 'draft' ? 'draft' : Number(testVersion), input: parseJsonObject(testInput, '测试输入') }); toast.success('Agent 运行已进入队列'); window.location.hash = `/agent-runs/${result.public_id}` } catch (reason) { toast.error(reason instanceof Error ? reason.message : '启动运行失败') } }
if (error) return <AppPage><AppEmptyState title="无法打开 Agent" description={error} /></AppPage>
if (!detail) return <AppPage><div className="h-64 animate-pulse rounded-md bg-muted" /></AppPage>
return <AppPage>
<AppPageHeader title={detail.name} description={`${detail.code} · 有序步骤执行,任一步失败后立即终止。`} meta={detail.active_version ? <Badge variant="success">当前 v{detail.active_version}</Badge> : <Badge variant="secondary">尚未发布</Badge>} actions={<><Button variant="outline" onClick={() => { window.location.hash = '/agents' }}><ArrowLeft data-icon="inline-start" />返回</Button>{draft ? <><Button variant="outline" onClick={() => void save()}><Save data-icon="inline-start" />保存草稿</Button><Button onClick={() => void publish()}><Send data-icon="inline-start" />发布 v{draft.version}</Button></> : <Button onClick={() => void createDraft()}><CopyPlus data-icon="inline-start" />创建新草稿</Button>}</>} />
<Tabs defaultValue="basic"><TabsList variant="line" className="max-w-full overflow-x-auto"><TabsTrigger value="basic">基础与模型</TabsTrigger><TabsTrigger value="steps">步骤</TabsTrigger><TabsTrigger value="schema">输入 Schema</TabsTrigger><TabsTrigger value="test">测试</TabsTrigger><TabsTrigger value="versions">版本</TabsTrigger></TabsList>
<TabsContent value="basic" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-5"><FieldGroup className="grid gap-3 md:grid-cols-2"><AppField label="名称"><Input value={meta.name} onChange={(e) => setMeta({ ...meta, name: e.target.value })} /></AppField><AppField label="描述"><Input value={meta.description} onChange={(e) => setMeta({ ...meta, description: e.target.value })} /></AppField><AppField label="模型"><Select value={modelId} onValueChange={setModelId}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent>{deployments.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.display_name}</SelectItem>)}</SelectContent></Select></AppField><AppField label="Temperature 覆盖" description="留空使用模型默认值"><Input type="number" min="0" max="2" step="0.1" value={temperature} onChange={(e) => setTemperature(e.target.value)} /></AppField><AppField label="最大输出 Token 覆盖" description="留空使用模型默认值"><Input type="number" min="1" max="32768" value={maxTokens} onChange={(e) => setMaxTokens(e.target.value)} /></AppField></FieldGroup><AppField label="系统 Prompt"><Textarea className="min-h-40" value={systemPrompt} onChange={(e) => setSystemPrompt(e.target.value)} /></AppField></div> : <AppEmptyState title="无可编辑草稿" />}</MobileEditGuard></TabsContent>
<TabsContent value="steps" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-3">{steps.map((step, index) => <Card key={`${index}-${step.code}`}><CardHeader className="flex-row items-center justify-between"><div><CardTitle className="text-sm">{index + 1}. {step.code}</CardTitle><CardDescription>输入映射后传递给 Skill</CardDescription></div><div className="flex gap-1"><Button variant="ghost" size="icon-sm" disabled={index === 0} onClick={() => moveStep(index, -1)}><ArrowUp /><span className="sr-only">上移</span></Button><Button variant="ghost" size="icon-sm" disabled={index === steps.length - 1} onClick={() => moveStep(index, 1)}><ArrowDown /><span className="sr-only">下移</span></Button><Button variant="ghost" size="icon-sm" disabled={steps.length === 1} onClick={() => setSteps((current) => current.filter((_, i) => i !== index))}><Trash2 /><span className="sr-only">删除</span></Button></div></CardHeader><CardContent className="grid gap-3 md:grid-cols-2"><AppField label="步骤代码"><Input value={step.code} onChange={(e) => updateStep(index, { code: e.target.value })} /></AppField><AppField label="Skill 版本"><Select value={step.skill_version_id} onValueChange={(value) => updateStep(index, { skill_version_id: value })}><SelectTrigger className="w-full"><SelectValue /></SelectTrigger><SelectContent>{skills.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.definitionName} · v{item.version}({item.status})</SelectItem>)}</SelectContent></Select></AppField><div className="md:col-span-2"><JsonEditor id={`agent-step-mapping-${index}`} label="输入映射" value={step.mapping} onChange={(value) => updateStep(index, { mapping: value })} rows={6} /></div></CardContent></Card>)}<Button variant="outline" disabled={steps.length >= 10} onClick={() => setSteps((current) => [...current, { code: `step_${current.length + 1}`, skill_version_id: String(skills[0]?.id ?? ''), mapping: '{}' }])}><Plus data-icon="inline-start" />添加步骤</Button></div> : <AppEmptyState title="无可编辑草稿" />}</MobileEditGuard></TabsContent>
<TabsContent value="schema" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-5 xl:grid-cols-2"><JsonEditor id="agent-input-schema" label="Agent 输入 JSON Schema" value={inputSchema} onChange={setInputSchema} /><JsonEditor id="agent-example-input" label="示例输入" value={exampleInput} onChange={setExampleInput} rows={10} /></div> : <AppEmptyState title="无可编辑草稿" />}</MobileEditGuard></TabsContent>
<TabsContent value="test" className="grid gap-4 pt-4"><Alert><Play /><AlertTitle>异步沙箱与发布版本测试</AlertTitle><AlertDescription>草稿运行会标记为沙箱;正式运行只能选择已发布版本。启动后跳转到运行轨迹并自动轮询。</AlertDescription></Alert><AppField label="运行版本"><Select value={testVersion} onValueChange={(value) => { setTestVersion(value); const selected = value === 'draft' ? draft : detail.versions.find((item) => String(item.version) === value); if (selected) setTestInput(JSON.stringify(selected.example_input, null, 2)) }}><SelectTrigger className="w-full md:w-80"><SelectValue /></SelectTrigger><SelectContent>{draft ? <SelectItem value="draft">草稿 v{draft.version}(沙箱)</SelectItem> : null}{detail.versions.filter((item) => item.status === 'published').map((item) => <SelectItem key={item.id} value={String(item.version)}>已发布 v{item.version}</SelectItem>)}</SelectContent></Select></AppField><SchemaInputEditor schema={(testVersion === 'draft' ? draft?.input_schema : detail.versions.find((item) => String(item.version) === testVersion)?.input_schema) ?? {}} value={testInput} onChange={setTestInput} /><div><Button onClick={() => void run()} disabled={!testVersion}><Play data-icon="inline-start" />启动异步运行</Button></div></TabsContent>
<TabsContent value="versions" className="grid gap-3 pt-4">{detail.versions.map((version) => <Card key={version.id}><CardHeader className="flex-row items-center justify-between"><div><CardTitle>版本 v{version.version}</CardTitle><CardDescription>{version.model_display_name} · {version.steps.length} 个步骤 · {version.published_at ? formatDateTime(version.published_at) : formatDateTime(version.updated_at)}</CardDescription></div><div className="flex items-center gap-2"><StatusBadge status={version.status} />{version.status === 'published' && detail.active_version !== version.version ? <Button variant="outline" size="sm" onClick={() => void rollback(version.version)}>回滚至此版本</Button> : null}</div></CardHeader></Card>)}</TabsContent>
</Tabs>
</AppPage>
}

View File

@@ -0,0 +1,77 @@
import * as React from 'react'
import { Plus, X } from 'lucide-react'
import { toast } from 'sonner'
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppField } from '@/components/app/AppField'
import { AppPage } from '@/components/app/AppPage'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { AppSection } from '@/components/app/AppSection'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { FieldGroup } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { MobileEditGuard } from '@/features/ai-platform/components/MobileEditGuard'
import { listDeployments } from '@/features/modelhub/api'
import type { ModelDeployment } from '@/features/modelhub/types'
import { getSkill, listSkills } from '@/features/skills/api'
import type { SkillVersion } from '@/features/skills/types'
import { formatDateTime } from '@/lib/format'
import { createAgent, listAgents } from './api'
import type { AgentDefinition } from './types'
type StepForm = { code: string; skill_version_id: string; mapping: string }
type SkillOption = SkillVersion & { definitionName: string }
export function AgentListPage() {
const [items, setItems] = React.useState<AgentDefinition[]>([])
const [deployments, setDeployments] = React.useState<ModelDeployment[]>([])
const [skills, setSkills] = React.useState<SkillOption[]>([])
const [form, setForm] = React.useState({ code: '', name: '', description: '', model_deployment_id: '' })
const [steps, setSteps] = React.useState<StepForm[]>([{ code: 'step_1', skill_version_id: '', mapping: '{\n "problem": "$input.problem"\n}' }])
const [error, setError] = React.useState('')
const load = React.useCallback(async () => {
try {
const [nextAgents, nextDeployments, definitions] = await Promise.all([listAgents(), listDeployments(), listSkills()])
const details = await Promise.all(definitions.map((item) => getSkill(item.id)))
const options = details.flatMap((item) => item.versions.map((version) => ({ ...version, definitionName: item.name })))
setItems(nextAgents); setDeployments(nextDeployments); setSkills(options)
setForm((current) => ({ ...current, model_deployment_id: current.model_deployment_id || String(nextDeployments[0]?.id ?? '') }))
setSteps((current) => current.map((step) => ({ ...step, skill_version_id: step.skill_version_id || String(options[0]?.id ?? '') })))
setError('')
} catch (reason) { setError(reason instanceof Error ? reason.message : '加载 Agent 失败') }
}, [])
React.useEffect(() => { void load() }, [load])
function updateStep(index: number, patch: Partial<StepForm>) { setSteps((current) => current.map((step, stepIndex) => stepIndex === index ? { ...step, ...patch } : step)) }
async function handleCreate(event: React.FormEvent) {
event.preventDefault()
try {
const created = await createAgent({ ...form, model_deployment_id: Number(form.model_deployment_id), system_prompt: '你是技术支持分析助手。必须返回符合当前 Skill 输出 Schema 的 JSON。', input_schema: { type: 'object', properties: { problem: { type: 'string' }, environment: { type: 'string' } }, required: ['problem'], additionalProperties: false }, example_input: { problem: '采集任务长时间没有更新', environment: '生产环境' }, steps: steps.map((step) => ({ code: step.code, skill_version_id: Number(step.skill_version_id), input_mapping: JSON.parse(step.mapping) as object })) })
toast.success('Agent 草稿已创建'); window.location.hash = `/agents/${created.id}`
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '创建失败') }
}
const columns: AppDataTableColumn<AgentDefinition>[] = [
{ key: 'name', header: 'Agent', cell: (item) => <div><p className="font-medium">{item.name}</p><p className="font-mono text-xs text-muted-foreground">{item.code}</p></div> },
{ key: 'description', header: '描述', cell: (item) => <span className="max-w-lg text-muted-foreground">{item.description || '-'}</span> },
{ key: 'version', header: '版本', cell: (item) => <div className="flex gap-1">{item.active_version ? <Badge variant="success">发布 v{item.active_version}</Badge> : <Badge variant="secondary">未发布</Badge>}{item.draft_version ? <Badge variant="warning">草稿 v{item.draft_version}</Badge> : null}</div> },
{ key: 'updated', header: '更新时间', cell: (item) => <span className="text-muted-foreground">{formatDateTime(item.updated_at)}</span> }
]
return <AppPage>
<AppPageHeader title="Agent 管理" description="把已定义的 Skill 组合为有序、失败即终止的执行流程。" actions={<Button variant="outline" onClick={() => { window.location.hash = '/agent-runs' }}>查看运行记录</Button>} />
<MobileEditGuard><AppSection title="新建 Agent 草稿" description="首版最多 10 个有序步骤,映射源仅允许 $input.* 与 $steps.&lt;code&gt;.output.*。"><form className="grid gap-4" onSubmit={handleCreate}>
<FieldGroup className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"><AppField label="代码" required><Input required pattern="[a-z][a-z0-9_]*" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} /></AppField><AppField label="名称" required><Input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></AppField><AppField label="描述"><Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></AppField><AppField label="模型" required><Select value={form.model_deployment_id} onValueChange={(value) => setForm({ ...form, model_deployment_id: value })}><SelectTrigger className="w-full"><SelectValue placeholder="选择模型" /></SelectTrigger><SelectContent>{deployments.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.display_name}</SelectItem>)}</SelectContent></Select></AppField></FieldGroup>
<div className="grid gap-3">{steps.map((step, index) => <Card key={index}><CardHeader className="flex-row items-center justify-between"><CardTitle className="text-sm">步骤 {index + 1}</CardTitle>{steps.length > 1 ? <Button type="button" variant="ghost" size="icon-sm" onClick={() => setSteps((current) => current.filter((_, i) => i !== index))}><X /><span className="sr-only">删除步骤</span></Button> : null}</CardHeader><CardContent><FieldGroup className="grid gap-3 md:grid-cols-[1fr_1.5fr_2fr]"><AppField label="步骤代码" required><Input required value={step.code} onChange={(e) => updateStep(index, { code: e.target.value })} /></AppField><AppField label="Skill 版本" required><Select value={step.skill_version_id} onValueChange={(value) => updateStep(index, { skill_version_id: value })}><SelectTrigger className="w-full"><SelectValue placeholder="选择 Skill" /></SelectTrigger><SelectContent>{skills.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.definitionName} · v{item.version}({item.status})</SelectItem>)}</SelectContent></Select></AppField><AppField label="输入映射 JSON" required><Input className="font-mono text-xs" value={step.mapping.replace(/\s+/g, ' ')} onChange={(e) => updateStep(index, { mapping: e.target.value })} /></AppField></FieldGroup></CardContent></Card>)}</div>
<div className="flex justify-between"><Button type="button" variant="outline" disabled={steps.length >= 10} onClick={() => setSteps((current) => [...current, { code: `step_${current.length + 1}`, skill_version_id: String(skills[0]?.id ?? ''), mapping: '{}' }])}><Plus data-icon="inline-start" />添加步骤</Button><Button type="submit" disabled={!deployments.length || !skills.length}><Plus data-icon="inline-start" />创建 Agent</Button></div>
</form></AppSection></MobileEditGuard>
{error ? <AppEmptyState title="无法加载 Agent" description={error} /> : <AppDataTable columns={columns} rows={items} getRowKey={(item) => item.id} emptyTitle="暂无 Agent" emptyDescription="请先准备模型配置和 Skill,再创建 Agent。" onRowClick={(item) => { window.location.hash = `/agents/${item.id}` }} />}
</AppPage>
}

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>
}

View File

@@ -0,0 +1,30 @@
import * as React from 'react'
import { RefreshCw } from 'lucide-react'
import { AppDataTable, type AppDataTableColumn } from '@/components/app/AppDataTable'
import { AppEmptyState } from '@/components/app/AppEmptyState'
import { AppPage } from '@/components/app/AppPage'
import { AppPageHeader } from '@/components/app/AppPageHeader'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { StatusBadge } from '@/features/ai-platform/status'
import { formatDateTime } from '@/lib/format'
import { listAgentRuns } from './api'
import type { AgentRunSummary } from './types'
export function AgentRunListPage() {
const [items, setItems] = React.useState<AgentRunSummary[]>([])
const [error, setError] = React.useState('')
const load = React.useCallback(async () => { try { setItems(await listAgentRuns()); setError('') } catch (reason) { setError(reason instanceof Error ? reason.message : '加载运行记录失败') } }, [])
React.useEffect(() => { void load() }, [load])
const columns: AppDataTableColumn<AgentRunSummary>[] = [
{ key: 'agent', header: 'Agent', cell: (item) => <div><p className="font-medium">{item.agent_name}</p><p className="font-mono text-xs text-muted-foreground">{item.public_id.slice(0, 8)}…</p></div> },
{ key: 'version', header: '版本', cell: (item) => <div className="flex gap-1"><Badge variant="outline">v{item.agent_version}</Badge>{item.sandbox ? <Badge variant="warning">沙箱</Badge> : <Badge variant="secondary">正式</Badge>}</div> },
{ key: 'status', header: '状态', cell: (item) => <StatusBadge status={item.status} /> },
{ key: 'progress', header: '当前步骤', cell: (item) => <span className="text-muted-foreground">{item.current_step || '-'} · {item.total_steps} 步</span> },
{ key: 'usage', header: 'Token / 耗时', cell: (item) => <span className="text-muted-foreground">{item.input_tokens + item.output_tokens} · {item.duration_ms} ms</span> },
{ key: 'created', header: '创建时间', cell: (item) => <span className="text-muted-foreground">{formatDateTime(item.created_at)}</span> }
]
return <AppPage><AppPageHeader title="Agent 运行记录" description="查看沙箱与正式版本的执行结果、Token、耗时和版本快照。" actions={<><Button variant="outline" onClick={() => { window.location.hash = '/agents' }}>Agent 管理</Button><Button variant="outline" onClick={() => void load()}><RefreshCw data-icon="inline-start" />刷新</Button></>} />{error ? <AppEmptyState title="无法加载运行记录" description={error} /> : <AppDataTable columns={columns} rows={items} getRowKey={(item) => item.public_id} emptyTitle="暂无运行记录" emptyDescription="从 Agent 详情的测试页启动一次运行。" onRowClick={(item) => { window.location.hash = `/agent-runs/${item.public_id}` }} />}</AppPage>
}

View File

@@ -0,0 +1,15 @@
import { apiRequest } from '@/lib/api'
import type { AgentDefinition, AgentDetail, AgentRunDetail, AgentRunSummary, AgentVersion } from './types'
export const listAgents = () => apiRequest<AgentDefinition[]>('/agents')
export const createAgent = (payload: object) => apiRequest<AgentDetail>('/agents', { method: 'POST', body: JSON.stringify(payload) })
export const getAgent = (id: number) => apiRequest<AgentDetail>(`/agents/${id}`)
export const updateAgent = (id: number, payload: object) => apiRequest<AgentDetail>(`/agents/${id}`, { method: 'PATCH', body: JSON.stringify(payload) })
export const ensureAgentDraft = (id: number) => apiRequest<AgentVersion>(`/agents/${id}/draft`, { method: 'POST' })
export const updateAgentDraft = (id: number, payload: object) => apiRequest<AgentVersion>(`/agents/${id}/draft`, { method: 'PATCH', body: JSON.stringify(payload) })
export const publishAgent = (id: number) => apiRequest<AgentVersion>(`/agents/${id}/publish`, { method: 'POST' })
export const rollbackAgent = (id: number, version: number) => apiRequest<AgentDetail>(`/agents/${id}/rollback/${version}`, { method: 'POST' })
export const startAgentRun = (id: number, payload: object) => apiRequest<AgentRunDetail>(`/agents/${id}/runs`, { method: 'POST', body: JSON.stringify(payload) })
export const listAgentRuns = () => apiRequest<AgentRunSummary[]>('/agent-runs')
export const getAgentRun = (id: string) => apiRequest<AgentRunDetail>(`/agent-runs/${id}`)
export const cancelAgentRun = (id: string) => apiRequest<AgentRunDetail>(`/agent-runs/${id}/cancel`, { method: 'POST' })

View File

@@ -0,0 +1,94 @@
export type AgentStep = {
id: number
code: string
order: number
skill_version_id: number
skill_name: string
skill_version: number
skill_status: string
input_mapping: Record<string, string>
}
export type AgentVersion = {
id: number
version: number
status: 'draft' | 'published' | 'archived'
model_deployment_id: number
model_display_name: string
system_prompt: string
input_schema: Record<string, unknown>
example_input: Record<string, unknown>
temperature_override: number | null
max_output_tokens_override: number | null
steps: AgentStep[]
created_at: string
updated_at: string
published_at: string | null
}
export type AgentDefinition = {
id: number
code: string
name: string
description: string
active_version: number | null
draft_version: number | null
updated_at: string
}
export type AgentDetail = AgentDefinition & { versions: AgentVersion[] }
export type AgentRunAttempt = {
id: number
attempt_type: 'initial' | 'transient_retry' | 'structure_repair'
status: string
raw_output: string
validation_error: string
input_tokens: number
output_tokens: number
duration_ms: number
created_at: string
}
export type AgentRunStep = {
id: number
code: string
name: string
order: number
status: string
input_payload: Record<string, unknown>
output_payload: unknown
input_tokens: number
output_tokens: number
duration_ms: number
error_code: string
error_message: string
attempts: AgentRunAttempt[]
}
export type AgentRunSummary = {
public_id: string
agent_id: number
agent_name: string
agent_version: number
status: string
sandbox: boolean
current_step: string
total_steps: number
input_tokens: number
output_tokens: number
duration_ms: number
error_code: string
error_message: string
created_at: string
started_at: string | null
finished_at: string | null
}
export type AgentRunDetail = AgentRunSummary & {
input_payload: Record<string, unknown>
final_output: unknown
config_snapshot: Record<string, unknown>
cancellation_requested_at: string | null
steps: AgentRunStep[]
}