feat: add agent debugging workspace
This commit is contained in:
@@ -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 />
|
||||
}
|
||||
|
||||
103
frontend/src/features/agents/AgentDetailPage.tsx
Normal file
103
frontend/src/features/agents/AgentDetailPage.tsx
Normal 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>
|
||||
}
|
||||
77
frontend/src/features/agents/AgentListPage.tsx
Normal file
77
frontend/src/features/agents/AgentListPage.tsx
Normal 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.<code>.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>
|
||||
}
|
||||
60
frontend/src/features/agents/AgentRunDetailPage.tsx
Normal file
60
frontend/src/features/agents/AgentRunDetailPage.tsx
Normal 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>
|
||||
}
|
||||
30
frontend/src/features/agents/AgentRunListPage.tsx
Normal file
30
frontend/src/features/agents/AgentRunListPage.tsx
Normal 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>
|
||||
}
|
||||
15
frontend/src/features/agents/api.ts
Normal file
15
frontend/src/features/agents/api.ts
Normal 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' })
|
||||
94
frontend/src/features/agents/types.ts
Normal file
94
frontend/src/features/agents/types.ts
Normal 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[]
|
||||
}
|
||||
Reference in New Issue
Block a user