feat: add model and skill admin pages

This commit is contained in:
hailin
2026-07-14 14:08:04 +08:00
parent 9d4fae58bd
commit 92b7af118e
15 changed files with 669 additions and 9 deletions

View File

@@ -1,12 +1,26 @@
import type * as React from 'react'
import { CheckCircle2, CircleAlert, Clock3 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import type { AgentStep } from '@/features/prototype/types'
export type TraceTimelineItem = {
id: React.Key
step_name: string
step_type?: 'model' | 'retrieval' | 'skill' | 'tool' | 'approval'
status: 'success' | 'succeeded' | 'failed' | 'waiting' | 'queued' | 'running' | 'cancelled'
input_summary?: string
output_summary?: string
latency_ms?: number
}
const statusConfig = {
success: { label: '成功', variant: 'success' },
failed: { label: '失败', variant: 'danger' },
waiting: { label: '等待确认', variant: 'warning' }
waiting: { label: '等待确认', variant: 'warning' },
succeeded: { label: '成功', variant: 'success' },
queued: { label: '排队中', variant: 'secondary' },
running: { label: '执行中', variant: 'info' },
cancelled: { label: '已取消', variant: 'secondary' }
} as const
const typeLabels = {
@@ -17,7 +31,7 @@ const typeLabels = {
approval: '人工确认'
} as const
export function AppTraceTimeline({ items }: { items: AgentStep[] }) {
export function AppTraceTimeline({ items }: { items: TraceTimelineItem[] }) {
return (
<ol className="grid gap-3">
{items.map((item) => {
@@ -32,12 +46,12 @@ export function AppTraceTimeline({ items }: { items: AgentStep[] }) {
<div className="grid gap-2">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium text-foreground">{item.step_name}</p>
<Badge variant="outline">{typeLabels[item.step_type]}</Badge>
<Badge variant="outline">{item.step_type ? typeLabels[item.step_type] : 'Skill'}</Badge>
<Badge variant={status.variant}>{status.label}</Badge>
</div>
<p className="text-sm text-muted-foreground">输入:{item.input_summary}</p>
<p className="text-sm text-foreground">输出:{item.output_summary}</p>
<p className="text-xs text-muted-foreground">耗时 {item.latency_ms} ms</p>
{item.input_summary ? <p className="text-sm text-muted-foreground">输入:{item.input_summary}</p> : null}
{item.output_summary ? <p className="text-sm text-foreground">输出:{item.output_summary}</p> : null}
{item.latency_ms !== undefined ? <p className="text-xs text-muted-foreground">耗时 {item.latency_ms} ms</p> : null}
</div>
</li>
)

View File

@@ -0,0 +1,32 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AppTraceTimeline } from '@/components/app/AppTraceTimeline'
import { JsonEditor, parseJsonObject } from './components/JsonEditor'
import { SchemaInputEditor } from './components/SchemaInputEditor'
afterEach(cleanup)
describe('AI 平台编辑组件', () => {
it('格式化并校验 JSON,同时拒绝非对象输入', () => {
const onChange = vi.fn()
render(<JsonEditor id="schema" label="输入 Schema" value={'{"type":"object"}'} onChange={onChange} />)
fireEvent.click(screen.getByRole('button', { name: '格式化' }))
expect(onChange).toHaveBeenCalledWith('{\n "type": "object"\n}')
expect(screen.getByText('JSON 语法有效')).toBeInTheDocument()
expect(() => parseJsonObject('[]', '输入')).toThrow('输入必须是 JSON 对象')
})
it('根据简单 Schema 生成表单并同步 JSON', () => {
const onChange = vi.fn()
render(<SchemaInputEditor schema={{ type: 'object', properties: { problem: { type: 'string' } } }} value={'{"problem":"旧值"}'} onChange={onChange} />)
fireEvent.change(screen.getByLabelText('problem'), { target: { value: '新问题' } })
expect(onChange).toHaveBeenCalledWith('{\n "problem": "新问题"\n}')
})
it('运行轨迹组件不依赖 prototype 类型', () => {
render(<AppTraceTimeline items={[{ id: 1, step_name: '问题结构化', status: 'running', latency_ms: 12 }]} />)
expect(screen.getByText('问题结构化')).toBeInTheDocument()
expect(screen.getByText('执行中')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,85 @@
import * as React from 'react'
import { Braces, CheckCircle2, WandSparkles } from 'lucide-react'
import { AppField } from '@/components/app/AppField'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
type JsonEditorProps = {
id: string
label: string
value: string
onChange: (value: string) => void
description?: string
rows?: number
}
export function JsonEditor({ id, label, value, onChange, description, rows = 12 }: JsonEditorProps) {
const [message, setMessage] = React.useState<{ kind: 'success' | 'error'; text: string } | null>(null)
function parse() {
try {
const parsed = JSON.parse(value) as unknown
setMessage({ kind: 'success', text: 'JSON 语法有效' })
return parsed
} catch (reason) {
setMessage({ kind: 'error', text: reason instanceof Error ? reason.message : 'JSON 语法无效' })
return null
}
}
function format() {
const parsed = parse()
if (parsed !== null) onChange(JSON.stringify(parsed, null, 2))
}
return (
<div className="grid gap-2">
<AppField label={label} htmlFor={id} description={description} invalid={message?.kind === 'error'}>
<Textarea
id={id}
rows={rows}
spellCheck={false}
className="min-h-48 resize-y font-mono text-xs leading-5"
value={value}
onChange={(event) => {
onChange(event.target.value)
setMessage(null)
}}
/>
</AppField>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex gap-2">
<Button type="button" variant="outline" size="sm" onClick={format}>
<WandSparkles data-icon="inline-start" />
格式化
</Button>
<Button type="button" variant="outline" size="sm" onClick={parse}>
<Braces data-icon="inline-start" />
校验语法
</Button>
</div>
{message?.kind === 'success' ? (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<CheckCircle2 className="size-3.5" />{message.text}
</span>
) : null}
</div>
{message?.kind === 'error' ? (
<Alert variant="destructive">
<AlertTitle>JSON 解析失败</AlertTitle>
<AlertDescription className="font-mono text-xs">{message.text}</AlertDescription>
</Alert>
) : null}
</div>
)
}
export function parseJsonObject(value: string, label: string): Record<string, unknown> {
const parsed = JSON.parse(value) as unknown
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${label}必须是 JSON 对象`)
}
return parsed as Record<string, unknown>
}

View File

@@ -0,0 +1,16 @@
import { Monitor } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { useIsMobile } from '@/hooks/use-mobile'
export function MobileEditGuard({ children }: { children: React.ReactNode }) {
const mobile = useIsMobile()
if (!mobile) return children
return (
<Alert>
<Monitor />
<AlertTitle>请使用桌面端配置</AlertTitle>
<AlertDescription>移动端支持查看列表和运行详情,不支持 Schema、步骤和版本编辑。</AlertDescription>
</Alert>
)
}

View File

@@ -0,0 +1,32 @@
import * as React from 'react'
import { AppField } from '@/components/app/AppField'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { JsonEditor, parseJsonObject } from './JsonEditor'
type PropertySchema = { type?: string; enum?: unknown[]; items?: { type?: string } }
export function SchemaInputEditor({ schema, value, onChange }: { schema: Record<string, unknown>; value: string; onChange: (value: string) => void }) {
const properties = (schema.properties ?? {}) as Record<string, PropertySchema>
const simple = Object.values(properties).every((item) => ['string', 'number', 'integer', 'boolean', 'array'].includes(item.type ?? '') && (item.type !== 'array' || item.items?.type === 'string'))
const current = React.useMemo(() => {
try { return parseJsonObject(value, '测试输入') } catch { return {} }
}, [value])
function setField(key: string, next: unknown) { onChange(JSON.stringify({ ...current, [key]: next }, null, 2)) }
return <Tabs defaultValue={simple ? 'form' : 'json'}><TabsList><TabsTrigger value="form" disabled={!simple}>表单</TabsTrigger><TabsTrigger value="json">JSON</TabsTrigger></TabsList>
<TabsContent value="form" className="grid gap-3 pt-3 md:grid-cols-2">{Object.entries(properties).map(([key, property]) => {
const fieldId = `schema-input-${key}`
if (property.type === 'boolean') return <AppField key={key} label={key} htmlFor={fieldId}><div className="flex h-8 items-center"><Switch id={fieldId} checked={Boolean(current[key])} onCheckedChange={(checked) => setField(key, checked)} /></div></AppField>
return <AppField key={key} label={key} htmlFor={fieldId}><Input id={fieldId} type={['number', 'integer'].includes(property.type ?? '') ? 'number' : 'text'} value={Array.isArray(current[key]) ? current[key].join(', ') : String(current[key] ?? '')} onChange={(event) => {
const raw = event.target.value
const next = property.type === 'array' ? raw.split(',').map((item) => item.trim()).filter(Boolean) : ['number', 'integer'].includes(property.type ?? '') ? Number(raw) : raw
setField(key, next)
}} /></AppField>
})}</TabsContent>
<TabsContent value="json" className="pt-3"><JsonEditor id="agent-test-input" label="测试输入" value={value} onChange={onChange} rows={10} /></TabsContent>
</Tabs>
}

View File

@@ -0,0 +1,20 @@
import { Badge } from '@/components/ui/badge'
const labels: Record<string, string> = {
draft: '草稿', published: '已发布', archived: '已归档',
queued: '排队中', running: '执行中', succeeded: '成功', failed: '失败',
cancelled: '已取消', timed_out: '已超时'
}
export function StatusBadge({ status }: { status: string }) {
const variant = status === 'succeeded' || status === 'published'
? 'success'
: status === 'failed' || status === 'timed_out'
? 'danger'
: status === 'running'
? 'info'
: status === 'draft'
? 'warning'
: 'secondary'
return <Badge variant={variant}>{labels[status] ?? status}</Badge>
}

View File

@@ -0,0 +1,192 @@
import * as React from 'react'
import { Activity, KeyRound, Pencil, Plus, RefreshCw, Settings2 } 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 { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { MobileEditGuard } from '@/features/ai-platform/components/MobileEditGuard'
import { StatusBadge } from '@/features/ai-platform/status'
import { formatDateTime } from '@/lib/format'
import { createDeployment, createProvider, getModelTest, listDeployments, listProviders, testDeployment, updateDeployment, updateProvider } from './api'
import type { ModelDeployment, ModelInvocation, ModelProvider } from './types'
const providerInitial = { name: '', base_url: '', api_key: '', default_timeout_seconds: '60', enabled: true }
const deploymentInitial = { provider_id: '', display_name: '', model_name: '', default_temperature: '0.2', max_output_tokens: '2048' }
export function ModelProviderPage() {
const [providers, setProviders] = React.useState<ModelProvider[]>([])
const [deployments, setDeployments] = React.useState<ModelDeployment[]>([])
const [loading, setLoading] = React.useState(true)
const [error, setError] = React.useState('')
const [providerForm, setProviderForm] = React.useState(providerInitial)
const [deploymentForm, setDeploymentForm] = React.useState(deploymentInitial)
const [editing, setEditing] = React.useState<ModelProvider | null>(null)
const [editingDeployment, setEditingDeployment] = React.useState<ModelDeployment | null>(null)
const [testing, setTesting] = React.useState<ModelDeployment | null>(null)
const [prompt, setPrompt] = React.useState('请只回复:模型连接正常')
const [testResult, setTestResult] = React.useState<ModelInvocation | null>(null)
const load = React.useCallback(async () => {
try {
const [nextProviders, nextDeployments] = await Promise.all([listProviders(), listDeployments()])
setProviders(nextProviders)
setDeployments(nextDeployments)
setDeploymentForm((current) => ({ ...current, provider_id: current.provider_id || String(nextProviders[0]?.id ?? '') }))
setError('')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '加载模型配置失败')
} finally {
setLoading(false)
}
}, [])
React.useEffect(() => { void load() }, [load])
React.useEffect(() => {
if (!testResult || !['queued', 'running'].includes(testResult.status)) return
const timer = window.setTimeout(async () => {
try { setTestResult(await getModelTest(testResult.public_id)) } catch (reason) { setError(reason instanceof Error ? reason.message : '查询测试结果失败') }
}, 1200)
return () => window.clearTimeout(timer)
}, [testResult])
async function handleCreateProvider(event: React.FormEvent) {
event.preventDefault()
try {
await createProvider({ ...providerForm, default_timeout_seconds: Number(providerForm.default_timeout_seconds) })
setProviderForm(providerInitial)
toast.success('模型渠道已创建')
await load()
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '创建失败') }
}
async function handleUpdateProvider() {
if (!editing) return
try {
const keyInput = document.querySelector<HTMLInputElement>('#edit-provider-api-key')?.value ?? ''
await updateProvider(editing.id, { name: editing.name, base_url: editing.base_url, default_timeout_seconds: editing.default_timeout_seconds, enabled: editing.enabled, api_key: keyInput })
setEditing(null)
toast.success('渠道配置已保存')
await load()
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '保存失败') }
}
async function handleCreateDeployment(event: React.FormEvent) {
event.preventDefault()
try {
await createDeployment({ ...deploymentForm, provider_id: Number(deploymentForm.provider_id), default_temperature: Number(deploymentForm.default_temperature), max_output_tokens: Number(deploymentForm.max_output_tokens), enabled: true })
setDeploymentForm((current) => ({ ...deploymentInitial, provider_id: current.provider_id }))
toast.success('模型配置已创建')
await load()
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '创建失败') }
}
async function handleUpdateDeployment() {
if (!editingDeployment) return
try {
await updateDeployment(editingDeployment.id, {
display_name: editingDeployment.display_name,
model_name: editingDeployment.model_name,
default_temperature: editingDeployment.default_temperature,
max_output_tokens: editingDeployment.max_output_tokens,
enabled: editingDeployment.enabled
})
setEditingDeployment(null)
toast.success('模型配置已保存')
await load()
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '保存失败') }
}
async function handleTest() {
if (!testing) return
try { setTestResult(await testDeployment(testing.id, prompt)) } catch (reason) { toast.error(reason instanceof Error ? reason.message : '测试启动失败') }
}
const providerColumns: AppDataTableColumn<ModelProvider>[] = [
{ key: 'name', header: '渠道', cell: (item) => <div><p className="font-medium">{item.name}</p><p className="text-xs text-muted-foreground">{item.base_url}</p></div> },
{ key: 'credential', header: '凭证', cell: (item) => <span className="inline-flex items-center gap-1 text-sm text-muted-foreground"><KeyRound className="size-3.5" />{item.credential_set ? item.credential_masked : '未配置'}</span> },
{ key: 'timeout', header: '超时', cell: (item) => `${item.default_timeout_seconds} 秒` },
{ key: 'status', header: '状态', cell: (item) => <Badge variant={item.enabled ? 'success' : 'secondary'}>{item.enabled ? '已启用' : '已停用'}</Badge> },
{ key: 'updated', header: '更新时间', cell: (item) => <span className="text-muted-foreground">{formatDateTime(item.updated_at)}</span> },
{ key: 'action', header: '操作', cell: (item) => <Button variant="ghost" size="sm" onClick={() => setEditing({ ...item })}><Pencil data-icon="inline-start" />编辑</Button> }
]
const deploymentColumns: AppDataTableColumn<ModelDeployment>[] = [
{ key: 'name', header: '模型', cell: (item) => <div><p className="font-medium">{item.display_name}</p><p className="text-xs text-muted-foreground">{item.model_name}</p></div> },
{ key: 'provider', header: '渠道', cell: (item) => item.provider_name },
{ key: 'params', header: '默认参数', cell: (item) => <span className="text-muted-foreground">T {item.default_temperature} · {item.max_output_tokens} tokens</span> },
{ key: 'status', header: '状态', cell: (item) => <Badge variant={item.enabled ? 'success' : 'secondary'}>{item.enabled ? '可用' : '停用'}</Badge> },
{ key: 'action', header: '操作', cell: (item) => <div className="flex gap-1"><Button variant="ghost" size="sm" onClick={() => setEditingDeployment({ ...item })}><Settings2 data-icon="inline-start" />配置</Button><Button variant="outline" size="sm" onClick={() => { setTesting(item); setTestResult(null) }}><Activity data-icon="inline-start" />连通测试</Button></div> }
]
return (
<AppPage>
<AppPageHeader title="模型渠道" description="管理 OpenAI-compatible 渠道、凭证与文本模型。凭证只显示脱敏结果。" actions={<Button variant="outline" onClick={() => void load()}><RefreshCw data-icon="inline-start" />刷新</Button>} />
{error ? <AppEmptyState title="无法加载模型配置" description={error} /> : null}
{loading ? <div className="grid gap-3"><Skeleton className="h-32" /><Skeleton className="h-56" /></div> : null}
{!loading && !error ? <>
<MobileEditGuard>
<AppSection title="添加渠道" description="主密钥缺失时,后端会拒绝保存 API Key。">
<form onSubmit={handleCreateProvider}><FieldGroup className="grid gap-3 md:grid-cols-2 xl:grid-cols-5 xl:items-end">
<AppField label="渠道名称" htmlFor="provider-name" required><Input id="provider-name" required value={providerForm.name} onChange={(e) => setProviderForm({ ...providerForm, name: e.target.value })} placeholder="内部 OpenAI" /></AppField>
<AppField label="Base URL" htmlFor="provider-url" required><Input id="provider-url" required value={providerForm.base_url} onChange={(e) => setProviderForm({ ...providerForm, base_url: e.target.value })} placeholder="https://.../v1" /></AppField>
<AppField label="API Key" htmlFor="provider-key" required><Input id="provider-key" required type="password" value={providerForm.api_key} onChange={(e) => setProviderForm({ ...providerForm, api_key: e.target.value })} /></AppField>
<AppField label="超时(秒)" htmlFor="provider-timeout"><Input id="provider-timeout" type="number" min="1" max="300" value={providerForm.default_timeout_seconds} onChange={(e) => setProviderForm({ ...providerForm, default_timeout_seconds: e.target.value })} /></AppField>
<Button type="submit"><Plus data-icon="inline-start" />创建渠道</Button>
</FieldGroup></form>
</AppSection>
</MobileEditGuard>
<AppDataTable columns={providerColumns} rows={providers} getRowKey={(item) => item.id} emptyTitle="暂无模型渠道" emptyDescription="先配置真实渠道,再创建模型配置。" />
<MobileEditGuard>
<AppSection title="添加模型配置">
<form onSubmit={handleCreateDeployment}><FieldGroup className="grid gap-3 md:grid-cols-2 xl:grid-cols-6 xl:items-end">
<AppField label="渠道" required><Select value={deploymentForm.provider_id} onValueChange={(value) => setDeploymentForm({ ...deploymentForm, provider_id: value })}><SelectTrigger className="w-full"><SelectValue placeholder="选择渠道" /></SelectTrigger><SelectContent>{providers.map((item) => <SelectItem key={item.id} value={String(item.id)}>{item.name}</SelectItem>)}</SelectContent></Select></AppField>
<AppField label="显示名称" htmlFor="deployment-display" required><Input id="deployment-display" required value={deploymentForm.display_name} onChange={(e) => setDeploymentForm({ ...deploymentForm, display_name: e.target.value })} /></AppField>
<AppField label="模型名" htmlFor="deployment-model" required><Input id="deployment-model" required value={deploymentForm.model_name} onChange={(e) => setDeploymentForm({ ...deploymentForm, model_name: e.target.value })} /></AppField>
<AppField label="Temperature" htmlFor="deployment-temperature"><Input id="deployment-temperature" type="number" min="0" max="2" step="0.1" value={deploymentForm.default_temperature} onChange={(e) => setDeploymentForm({ ...deploymentForm, default_temperature: e.target.value })} /></AppField>
<AppField label="最大输出" htmlFor="deployment-tokens"><Input id="deployment-tokens" type="number" min="1" max="32768" value={deploymentForm.max_output_tokens} onChange={(e) => setDeploymentForm({ ...deploymentForm, max_output_tokens: e.target.value })} /></AppField>
<Button type="submit" disabled={!providers.length}><Plus data-icon="inline-start" />创建模型</Button>
</FieldGroup></form>
</AppSection>
</MobileEditGuard>
<AppDataTable columns={deploymentColumns} rows={deployments} getRowKey={(item) => item.id} emptyTitle="暂无模型配置" />
</> : null}
<Dialog open={Boolean(editing)} onOpenChange={(open) => !open && setEditing(null)}><DialogContent><DialogHeader><DialogTitle>编辑模型渠道</DialogTitle></DialogHeader>{editing ? <FieldGroup>
<AppField label="渠道名称"><Input value={editing.name} onChange={(e) => setEditing({ ...editing, name: e.target.value })} /></AppField>
<AppField label="Base URL"><Input value={editing.base_url} onChange={(e) => setEditing({ ...editing, base_url: e.target.value })} /></AppField>
<AppField label="替换 API Key" description="留空表示不替换"><Input id="edit-provider-api-key" type="password" placeholder={editing.credential_masked || '输入新密钥'} /></AppField>
<AppField label="默认超时"><Input type="number" min="1" max="300" value={editing.default_timeout_seconds} onChange={(e) => setEditing({ ...editing, default_timeout_seconds: Number(e.target.value) })} /></AppField>
<AppField label="启用状态"><div className="flex h-8 items-center gap-2"><Switch checked={editing.enabled} onCheckedChange={(checked) => setEditing({ ...editing, enabled: checked })} /><span className="text-sm text-muted-foreground">{editing.enabled ? '已启用' : '已停用'}</span></div></AppField>
</FieldGroup> : null}<DialogFooter><Button variant="outline" onClick={() => setEditing(null)}>取消</Button><Button onClick={() => void handleUpdateProvider()}>保存</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={Boolean(editingDeployment)} onOpenChange={(open) => !open && setEditingDeployment(null)}><DialogContent><DialogHeader><DialogTitle>编辑模型配置</DialogTitle></DialogHeader>{editingDeployment ? <FieldGroup>
<AppField label="显示名称"><Input value={editingDeployment.display_name} onChange={(e) => setEditingDeployment({ ...editingDeployment, display_name: e.target.value })} /></AppField>
<AppField label="模型名"><Input value={editingDeployment.model_name} onChange={(e) => setEditingDeployment({ ...editingDeployment, model_name: e.target.value })} /></AppField>
<AppField label="Temperature"><Input type="number" min="0" max="2" step="0.1" value={editingDeployment.default_temperature} onChange={(e) => setEditingDeployment({ ...editingDeployment, default_temperature: Number(e.target.value) })} /></AppField>
<AppField label="最大输出 Token"><Input type="number" min="1" max="32768" value={editingDeployment.max_output_tokens} onChange={(e) => setEditingDeployment({ ...editingDeployment, max_output_tokens: Number(e.target.value) })} /></AppField>
<AppField label="启用状态"><div className="flex h-8 items-center gap-2"><Switch checked={editingDeployment.enabled} onCheckedChange={(checked) => setEditingDeployment({ ...editingDeployment, enabled: checked })} /><span className="text-sm text-muted-foreground">{editingDeployment.enabled ? '已启用' : '已停用'}</span></div></AppField>
</FieldGroup> : null}<DialogFooter><Button variant="outline" onClick={() => setEditingDeployment(null)}>取消</Button><Button onClick={() => void handleUpdateDeployment()}>保存</Button></DialogFooter></DialogContent></Dialog>
<Dialog open={Boolean(testing)} onOpenChange={(open) => !open && setTesting(null)}><DialogContent className="sm:max-w-2xl"><DialogHeader><DialogTitle>模型连通测试{testing ? `:${testing.display_name}` : ''}</DialogTitle></DialogHeader>
<AppField label="测试提示词" htmlFor="model-test-prompt"><Textarea id="model-test-prompt" value={prompt} onChange={(e) => setPrompt(e.target.value)} /></AppField>
{testResult ? <Alert><Activity /><AlertTitle className="flex items-center gap-2">测试状态 <StatusBadge status={testResult.status} /></AlertTitle><AlertDescription className="grid gap-1"><span>{testResult.output_text || testResult.error_message || '任务处理中…'}</span><span className="text-xs">Token:{testResult.input_tokens ?? '渠道未提供'} / {testResult.output_tokens ?? '渠道未提供'} · {testResult.duration_ms} ms · 重试 {testResult.retry_count}</span></AlertDescription></Alert> : null}
<DialogFooter><Button variant="outline" onClick={() => setTesting(null)}>关闭</Button><Button onClick={() => void handleTest()} disabled={testResult ? ['queued', 'running'].includes(testResult.status) : false}><Activity data-icon="inline-start" />开始测试</Button></DialogFooter>
</DialogContent></Dialog>
</AppPage>
)
}

View File

@@ -0,0 +1,11 @@
import { apiRequest } from '@/lib/api'
import type { ModelDeployment, ModelInvocation, ModelProvider } from './types'
export const listProviders = () => apiRequest<ModelProvider[]>('/modelhub/providers')
export const createProvider = (payload: object) => apiRequest<ModelProvider>('/modelhub/providers', { method: 'POST', body: JSON.stringify(payload) })
export const updateProvider = (id: number, payload: object) => apiRequest<ModelProvider>(`/modelhub/providers/${id}`, { method: 'PATCH', body: JSON.stringify(payload) })
export const listDeployments = () => apiRequest<ModelDeployment[]>('/modelhub/deployments')
export const createDeployment = (payload: object) => apiRequest<ModelDeployment>('/modelhub/deployments', { method: 'POST', body: JSON.stringify(payload) })
export const updateDeployment = (id: number, payload: object) => apiRequest<ModelDeployment>(`/modelhub/deployments/${id}`, { method: 'PATCH', body: JSON.stringify(payload) })
export const testDeployment = (id: number, prompt: string) => apiRequest<ModelInvocation>(`/modelhub/deployments/${id}/test`, { method: 'POST', body: JSON.stringify({ prompt }) })
export const getModelTest = (id: string) => apiRequest<ModelInvocation>(`/modelhub/model-tests/${id}`)

View File

@@ -0,0 +1,40 @@
export type ModelProvider = {
id: number
name: string
base_url: string
enabled: boolean
default_timeout_seconds: number
credential_set: boolean
credential_masked: string
created_at: string
updated_at: string
}
export type ModelDeployment = {
id: number
provider_id: number
provider_name: string
display_name: string
model_name: string
capability: string
default_temperature: number
max_output_tokens: number
enabled: boolean
}
export type ModelInvocation = {
public_id: string
deployment_id: number | null
status: 'queued' | 'running' | 'succeeded' | 'failed'
model_snapshot: Record<string, unknown>
output_text: string
input_tokens: number | null
output_tokens: number | null
duration_ms: number
retry_count: number
error_code: string
error_message: string
created_at: string
started_at: string | null
finished_at: string | null
}

View File

@@ -0,0 +1,84 @@
import * as React from 'react'
import { ArrowLeft, CopyPlus, Save, Send } 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 { AppSection } from '@/components/app/AppSection'
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 { 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 { StatusBadge } from '@/features/ai-platform/status'
import { formatDateTime } from '@/lib/format'
import { ensureSkillDraft, getSkill, publishSkill, rollbackSkill, updateSkill, updateSkillDraft } from './api'
import type { SkillDetail, SkillVersion } from './types'
export function SkillDetailPage({ skillId }: { skillId: number }) {
const [detail, setDetail] = React.useState<SkillDetail | null>(null)
const [draft, setDraft] = React.useState<SkillVersion | null>(null)
const [meta, setMeta] = React.useState({ name: '', description: '' })
const [prompt, setPrompt] = React.useState('')
const [inputSchema, setInputSchema] = React.useState('{}')
const [outputSchema, setOutputSchema] = React.useState('{}')
const [exampleInput, setExampleInput] = React.useState('{}')
const [error, setError] = React.useState('')
const load = React.useCallback(async () => {
try {
const item = await getSkill(skillId)
setDetail(item); setMeta({ name: item.name, description: item.description })
const nextDraft = item.versions.find((version) => version.status === 'draft') ?? null
setDraft(nextDraft)
if (nextDraft) {
setPrompt(nextDraft.prompt_template)
setInputSchema(JSON.stringify(nextDraft.input_schema, null, 2))
setOutputSchema(JSON.stringify(nextDraft.output_schema, null, 2))
setExampleInput(JSON.stringify(nextDraft.example_input, null, 2))
}
setError('')
} catch (reason) { setError(reason instanceof Error ? reason.message : '加载 Skill 失败') }
}, [skillId])
React.useEffect(() => { void load() }, [load])
async function createDraft() {
try { await ensureSkillDraft(skillId); toast.success('已从当前发布版本创建新草稿'); await load() } catch (reason) { toast.error(reason instanceof Error ? reason.message : '创建草稿失败') }
}
async function save() {
try {
await Promise.all([
updateSkill(skillId, meta),
updateSkillDraft(skillId, { prompt_template: prompt, input_schema: parseJsonObject(inputSchema, '输入 Schema'), output_schema: parseJsonObject(outputSchema, '输出 Schema'), example_input: parseJsonObject(exampleInput, '示例输入') })
])
toast.success('Skill 草稿已保存并通过校验'); await load()
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '保存失败') }
}
async function publish() {
try { await publishSkill(skillId); toast.success('Skill 版本已发布'); await load() } catch (reason) { toast.error(reason instanceof Error ? reason.message : '发布失败') }
}
async function rollback(version: number) {
try { await rollbackSkill(skillId, version); toast.success(`已回滚到 v${version}`); await load() } catch (reason) { toast.error(reason instanceof Error ? reason.message : '回滚失败') }
}
if (error) return <AppPage><AppEmptyState title="无法打开 Skill" description={error} /></AppPage>
if (!detail) return <AppPage><div className="h-52 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 = '/skills' }}><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="editor"><TabsList variant="line"><TabsTrigger value="editor">草稿编辑</TabsTrigger><TabsTrigger value="versions">版本记录</TabsTrigger></TabsList>
<TabsContent value="editor" className="pt-4"><MobileEditGuard>{draft ? <div className="grid gap-5">
<AppSection title={`基础信息 · 草稿 v${draft.version}`}><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></FieldGroup></AppSection>
<AppSection title="Prompt 模板" description="仅允许 {{ input.* }} 变量,不支持表达式、循环或代码执行。"><AppField label="模板" htmlFor="skill-prompt"><Textarea id="skill-prompt" className="min-h-48 font-mono text-xs leading-5" value={prompt} onChange={(e) => setPrompt(e.target.value)} /></AppField></AppSection>
<div className="grid gap-5 xl:grid-cols-2"><JsonEditor id="skill-input-schema" label="输入 JSON Schema" value={inputSchema} onChange={setInputSchema} /><JsonEditor id="skill-output-schema" label="输出 JSON Schema" value={outputSchema} onChange={setOutputSchema} /><JsonEditor id="skill-example-input" label="示例输入" value={exampleInput} onChange={setExampleInput} rows={8} /></div>
</div> : <AppEmptyState title="当前没有可编辑草稿" description="从当前发布版本克隆一个新草稿后再编辑。" />}</MobileEditGuard></TabsContent>
<TabsContent value="versions" className="grid gap-3 pt-4">{detail.versions.map((version) => <Card key={version.id}><CardHeader><div className="flex items-center justify-between gap-3"><div><CardTitle>版本 v{version.version}</CardTitle><CardDescription>{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></div></CardHeader><CardContent><p className="line-clamp-3 whitespace-pre-wrap font-mono text-xs text-muted-foreground">{version.prompt_template}</p></CardContent></Card>)}</TabsContent>
</Tabs>
</AppPage>
}

View File

@@ -0,0 +1,57 @@
import * as React from 'react'
import { Plus } 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 { FieldGroup } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import { MobileEditGuard } from '@/features/ai-platform/components/MobileEditGuard'
import { formatDateTime } from '@/lib/format'
import { createSkill, listSkills } from './api'
import type { SkillDefinition } from './types'
export function SkillListPage() {
const [items, setItems] = React.useState<SkillDefinition[]>([])
const [form, setForm] = React.useState({ code: '', name: '', description: '' })
const [error, setError] = React.useState('')
const load = React.useCallback(async () => {
try { setItems(await listSkills()); setError('') } catch (reason) { setError(reason instanceof Error ? reason.message : '加载 Skill 失败') }
}, [])
React.useEffect(() => { void load() }, [load])
async function handleCreate(event: React.FormEvent) {
event.preventDefault()
try {
const created = await createSkill({ ...form, prompt_template: '请根据以下输入返回 JSON:\n{{ input.problem }}', input_schema: { type: 'object', properties: { problem: { type: 'string' } }, required: ['problem'], additionalProperties: false }, output_schema: { type: 'object' }, example_input: { problem: '示例问题' } })
toast.success('Skill 草稿已创建')
window.location.hash = `/skills/${created.id}`
} catch (reason) { toast.error(reason instanceof Error ? reason.message : '创建失败') }
}
const columns: AppDataTableColumn<SkillDefinition>[] = [
{ key: 'name', header: 'Skill', 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="line-clamp-2 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="Skill 管理" description="以受限模板和 JSON Schema 定义可复用的模型步骤。" />
<MobileEditGuard><AppSection title="新建 Skill" description="创建后进入草稿编辑器完成 Prompt、输入和输出契约。"><form onSubmit={handleCreate}><FieldGroup className="grid gap-3 md:grid-cols-[1fr_1fr_2fr_auto] md:items-end">
<AppField label="代码" htmlFor="skill-code" required><Input id="skill-code" required pattern="[a-z][a-z0-9_]*" value={form.code} onChange={(e) => setForm({ ...form, code: e.target.value })} placeholder="problem_structuring" /></AppField>
<AppField label="名称" htmlFor="skill-name" required><Input id="skill-name" required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></AppField>
<AppField label="描述" htmlFor="skill-description"><Input id="skill-description" value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></AppField>
<Button type="submit"><Plus data-icon="inline-start" />创建 Skill</Button>
</FieldGroup></form></AppSection></MobileEditGuard>
{error ? <AppEmptyState title="无法加载 Skill" description={error} /> : <AppDataTable columns={columns} rows={items} getRowKey={(item) => item.id} emptyTitle="暂无 Skill" emptyDescription="先创建一个 Skill 草稿。" onRowClick={(item) => { window.location.hash = `/skills/${item.id}` }} />}
</AppPage>
}

View File

@@ -0,0 +1,11 @@
import { apiRequest } from '@/lib/api'
import type { SkillDefinition, SkillDetail, SkillVersion } from './types'
export const listSkills = () => apiRequest<SkillDefinition[]>('/skills')
export const createSkill = (payload: object) => apiRequest<SkillDetail>('/skills', { method: 'POST', body: JSON.stringify(payload) })
export const getSkill = (id: number) => apiRequest<SkillDetail>(`/skills/${id}`)
export const updateSkill = (id: number, payload: object) => apiRequest<SkillDetail>(`/skills/${id}`, { method: 'PATCH', body: JSON.stringify(payload) })
export const ensureSkillDraft = (id: number) => apiRequest<SkillVersion>(`/skills/${id}/draft`, { method: 'POST' })
export const updateSkillDraft = (id: number, payload: object) => apiRequest<SkillVersion>(`/skills/${id}/draft`, { method: 'PATCH', body: JSON.stringify(payload) })
export const publishSkill = (id: number) => apiRequest<SkillVersion>(`/skills/${id}/publish`, { method: 'POST' })
export const rollbackSkill = (id: number, version: number) => apiRequest<SkillDetail>(`/skills/${id}/rollback/${version}`, { method: 'POST' })

View File

@@ -0,0 +1,24 @@
export type SkillVersion = {
id: number
version: number
status: 'draft' | 'published' | 'archived'
prompt_template: string
input_schema: Record<string, unknown>
output_schema: Record<string, unknown>
example_input: Record<string, unknown>
created_at: string
updated_at: string
published_at: string | null
}
export type SkillDefinition = {
id: number
code: string
name: string
description: string
active_version: number | null
draft_version: number | null
updated_at: string
}
export type SkillDetail = SkillDefinition & { versions: SkillVersion[] }

View File

@@ -0,0 +1,17 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiError, apiRequest } from './api'
describe('apiRequest', () => {
afterEach(() => vi.unstubAllGlobals())
it('优先展示后端 detail 并保留状态码', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify({ detail: '无 AI 管理权限' }), { status: 403, headers: { 'content-type': 'application/json' } })))
await expect(apiRequest('/skills')).rejects.toMatchObject({ status: 403, message: '无 AI 管理权限' } satisfies Partial<ApiError>)
})
it('成功时解析 JSON', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(JSON.stringify([{ id: 1 }]), { status: 200, headers: { 'content-type': 'application/json' } })))
await expect(apiRequest('/skills')).resolves.toEqual([{ id: 1 }])
})
})

View File

@@ -2,13 +2,38 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000
let csrfToken: string | null = null
export class ApiError extends Error {
status: number
payload: unknown
constructor(status: number, message: string, payload: unknown) {
super(message)
this.name = 'ApiError'
this.status = status
this.payload = payload
}
}
async function readError(response: Response) {
let payload: unknown
try {
payload = await response.json()
} catch {
payload = null
}
const detail = payload && typeof payload === 'object' && 'detail' in payload
? String((payload as { detail: unknown }).detail)
: `API 请求失败(${response.status})`
return new ApiError(response.status, detail, payload)
}
async function getCsrfToken() {
if (!csrfToken) {
const response = await fetch(`${API_BASE_URL}/accounts/auth/csrf`, {
credentials: 'include'
})
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`)
throw await readError(response)
}
const data = (await response.json()) as { csrfToken: string }
csrfToken = data.csrfToken
@@ -33,7 +58,7 @@ export async function apiRequest<T>(path: string, init?: RequestInit): Promise<T
})
if (!response.ok) {
throw new Error(`API request failed: ${response.status}`)
throw await readError(response)
}
if (response.status === 204) {