feat: add model and skill admin pages
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
85
frontend/src/features/ai-platform/components/JsonEditor.tsx
Normal file
85
frontend/src/features/ai-platform/components/JsonEditor.tsx
Normal 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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
20
frontend/src/features/ai-platform/status.tsx
Normal file
20
frontend/src/features/ai-platform/status.tsx
Normal 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>
|
||||
}
|
||||
192
frontend/src/features/modelhub/ModelProviderPage.tsx
Normal file
192
frontend/src/features/modelhub/ModelProviderPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
11
frontend/src/features/modelhub/api.ts
Normal file
11
frontend/src/features/modelhub/api.ts
Normal 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}`)
|
||||
40
frontend/src/features/modelhub/types.ts
Normal file
40
frontend/src/features/modelhub/types.ts
Normal 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
|
||||
}
|
||||
84
frontend/src/features/skills/SkillDetailPage.tsx
Normal file
84
frontend/src/features/skills/SkillDetailPage.tsx
Normal 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>
|
||||
}
|
||||
57
frontend/src/features/skills/SkillListPage.tsx
Normal file
57
frontend/src/features/skills/SkillListPage.tsx
Normal 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>
|
||||
}
|
||||
11
frontend/src/features/skills/api.ts
Normal file
11
frontend/src/features/skills/api.ts
Normal 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' })
|
||||
24
frontend/src/features/skills/types.ts
Normal file
24
frontend/src/features/skills/types.ts
Normal 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[] }
|
||||
17
frontend/src/lib/api.test.ts
Normal file
17
frontend/src/lib/api.test.ts
Normal 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 }])
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user