diff --git a/frontend/src/components/app/AppTraceTimeline.tsx b/frontend/src/components/app/AppTraceTimeline.tsx
index 2518a6f..9a02ff9 100644
--- a/frontend/src/components/app/AppTraceTimeline.tsx
+++ b/frontend/src/components/app/AppTraceTimeline.tsx
@@ -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 (
{items.map((item) => {
@@ -32,12 +46,12 @@ export function AppTraceTimeline({ items }: { items: AgentStep[] }) {
{item.step_name}
-
{typeLabels[item.step_type]}
+
{item.step_type ? typeLabels[item.step_type] : 'Skill'}
{status.label}
-
输入:{item.input_summary}
-
输出:{item.output_summary}
-
耗时 {item.latency_ms} ms
+ {item.input_summary ?
输入:{item.input_summary}
: null}
+ {item.output_summary ?
输出:{item.output_summary}
: null}
+ {item.latency_ms !== undefined ?
耗时 {item.latency_ms} ms
: null}
)
diff --git a/frontend/src/features/ai-platform/ai-platform-components.test.tsx b/frontend/src/features/ai-platform/ai-platform-components.test.tsx
new file mode 100644
index 0000000..aced224
--- /dev/null
+++ b/frontend/src/features/ai-platform/ai-platform-components.test.tsx
@@ -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()
+ 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()
+ fireEvent.change(screen.getByLabelText('problem'), { target: { value: '新问题' } })
+ expect(onChange).toHaveBeenCalledWith('{\n "problem": "新问题"\n}')
+ })
+
+ it('运行轨迹组件不依赖 prototype 类型', () => {
+ render()
+ expect(screen.getByText('问题结构化')).toBeInTheDocument()
+ expect(screen.getByText('执行中')).toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/features/ai-platform/components/JsonEditor.tsx b/frontend/src/features/ai-platform/components/JsonEditor.tsx
new file mode 100644
index 0000000..8f2ca6a
--- /dev/null
+++ b/frontend/src/features/ai-platform/components/JsonEditor.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+ {message?.kind === 'success' ? (
+
+ {message.text}
+
+ ) : null}
+
+ {message?.kind === 'error' ? (
+
+ JSON 解析失败
+ {message.text}
+
+ ) : null}
+
+ )
+}
+
+export function parseJsonObject(value: string, label: string): Record {
+ const parsed = JSON.parse(value) as unknown
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ throw new Error(`${label}必须是 JSON 对象`)
+ }
+ return parsed as Record
+}
diff --git a/frontend/src/features/ai-platform/components/MobileEditGuard.tsx b/frontend/src/features/ai-platform/components/MobileEditGuard.tsx
new file mode 100644
index 0000000..12b03e0
--- /dev/null
+++ b/frontend/src/features/ai-platform/components/MobileEditGuard.tsx
@@ -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 (
+
+
+ 请使用桌面端配置
+ 移动端支持查看列表和运行详情,不支持 Schema、步骤和版本编辑。
+
+ )
+}
diff --git a/frontend/src/features/ai-platform/components/SchemaInputEditor.tsx b/frontend/src/features/ai-platform/components/SchemaInputEditor.tsx
new file mode 100644
index 0000000..50b1490
--- /dev/null
+++ b/frontend/src/features/ai-platform/components/SchemaInputEditor.tsx
@@ -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; value: string; onChange: (value: string) => void }) {
+ const properties = (schema.properties ?? {}) as Record
+ 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 表单JSON
+ {Object.entries(properties).map(([key, property]) => {
+ const fieldId = `schema-input-${key}`
+ if (property.type === 'boolean') return setField(key, checked)} />
+ return {
+ 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)
+ }} />
+ })}
+
+
+}
diff --git a/frontend/src/features/ai-platform/status.tsx b/frontend/src/features/ai-platform/status.tsx
new file mode 100644
index 0000000..7abc857
--- /dev/null
+++ b/frontend/src/features/ai-platform/status.tsx
@@ -0,0 +1,20 @@
+import { Badge } from '@/components/ui/badge'
+
+const labels: Record = {
+ 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 {labels[status] ?? status}
+}
diff --git a/frontend/src/features/modelhub/ModelProviderPage.tsx b/frontend/src/features/modelhub/ModelProviderPage.tsx
new file mode 100644
index 0000000..f7f9331
--- /dev/null
+++ b/frontend/src/features/modelhub/ModelProviderPage.tsx
@@ -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([])
+ const [deployments, setDeployments] = React.useState([])
+ 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(null)
+ const [editingDeployment, setEditingDeployment] = React.useState(null)
+ const [testing, setTesting] = React.useState(null)
+ const [prompt, setPrompt] = React.useState('请只回复:模型连接正常')
+ const [testResult, setTestResult] = React.useState(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('#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[] = [
+ { key: 'name', header: '渠道', cell: (item) => {item.name}
{item.base_url}
},
+ { key: 'credential', header: '凭证', cell: (item) => {item.credential_set ? item.credential_masked : '未配置'} },
+ { key: 'timeout', header: '超时', cell: (item) => `${item.default_timeout_seconds} 秒` },
+ { key: 'status', header: '状态', cell: (item) => {item.enabled ? '已启用' : '已停用'} },
+ { key: 'updated', header: '更新时间', cell: (item) => {formatDateTime(item.updated_at)} },
+ { key: 'action', header: '操作', cell: (item) => }
+ ]
+ const deploymentColumns: AppDataTableColumn[] = [
+ { key: 'name', header: '模型', cell: (item) => {item.display_name}
{item.model_name}
},
+ { key: 'provider', header: '渠道', cell: (item) => item.provider_name },
+ { key: 'params', header: '默认参数', cell: (item) => T {item.default_temperature} · {item.max_output_tokens} tokens },
+ { key: 'status', header: '状态', cell: (item) => {item.enabled ? '可用' : '停用'} },
+ { key: 'action', header: '操作', cell: (item) => }
+ ]
+
+ return (
+
+ void load()}>刷新} />
+ {error ? : null}
+ {loading ?
: null}
+ {!loading && !error ? <>
+
+
+
+
+
+ item.id} emptyTitle="暂无模型渠道" emptyDescription="先配置真实渠道,再创建模型配置。" />
+
+
+
+
+
+ item.id} emptyTitle="暂无模型配置" />
+ > : null}
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/modelhub/api.ts b/frontend/src/features/modelhub/api.ts
new file mode 100644
index 0000000..ec3babe
--- /dev/null
+++ b/frontend/src/features/modelhub/api.ts
@@ -0,0 +1,11 @@
+import { apiRequest } from '@/lib/api'
+import type { ModelDeployment, ModelInvocation, ModelProvider } from './types'
+
+export const listProviders = () => apiRequest('/modelhub/providers')
+export const createProvider = (payload: object) => apiRequest('/modelhub/providers', { method: 'POST', body: JSON.stringify(payload) })
+export const updateProvider = (id: number, payload: object) => apiRequest(`/modelhub/providers/${id}`, { method: 'PATCH', body: JSON.stringify(payload) })
+export const listDeployments = () => apiRequest('/modelhub/deployments')
+export const createDeployment = (payload: object) => apiRequest('/modelhub/deployments', { method: 'POST', body: JSON.stringify(payload) })
+export const updateDeployment = (id: number, payload: object) => apiRequest(`/modelhub/deployments/${id}`, { method: 'PATCH', body: JSON.stringify(payload) })
+export const testDeployment = (id: number, prompt: string) => apiRequest(`/modelhub/deployments/${id}/test`, { method: 'POST', body: JSON.stringify({ prompt }) })
+export const getModelTest = (id: string) => apiRequest(`/modelhub/model-tests/${id}`)
diff --git a/frontend/src/features/modelhub/types.ts b/frontend/src/features/modelhub/types.ts
new file mode 100644
index 0000000..2440a3b
--- /dev/null
+++ b/frontend/src/features/modelhub/types.ts
@@ -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
+ 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
+}
diff --git a/frontend/src/features/skills/SkillDetailPage.tsx b/frontend/src/features/skills/SkillDetailPage.tsx
new file mode 100644
index 0000000..879bb3b
--- /dev/null
+++ b/frontend/src/features/skills/SkillDetailPage.tsx
@@ -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(null)
+ const [draft, setDraft] = React.useState(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
+ if (!detail) return
+ return
+ 当前 v{detail.active_version} : 尚未发布} actions={<>{draft ? <>> : }>} />
+ 草稿编辑版本记录
+ {draft ? : }
+ {detail.versions.map((version) => 版本 v{version.version}{version.published_at ? `发布于 ${formatDateTime(version.published_at)}` : `更新于 ${formatDateTime(version.updated_at)}`}
{version.status === 'published' && detail.active_version !== version.version ? : null}
{version.prompt_template}
)}
+
+
+}
diff --git a/frontend/src/features/skills/SkillListPage.tsx b/frontend/src/features/skills/SkillListPage.tsx
new file mode 100644
index 0000000..23a8f7a
--- /dev/null
+++ b/frontend/src/features/skills/SkillListPage.tsx
@@ -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([])
+ 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[] = [
+ { key: 'name', header: 'Skill', cell: (item) => },
+ { key: 'description', header: '描述', cell: (item) => {item.description || '-'} },
+ { key: 'version', header: '版本', cell: (item) => {item.active_version ? 已发布 v{item.active_version} : 未发布}{item.draft_version ? 草稿 v{item.draft_version} : null}
},
+ { key: 'updated', header: '更新时间', cell: (item) => {formatDateTime(item.updated_at)} }
+ ]
+
+ return
+
+
+ {error ? : item.id} emptyTitle="暂无 Skill" emptyDescription="先创建一个 Skill 草稿。" onRowClick={(item) => { window.location.hash = `/skills/${item.id}` }} />}
+
+}
diff --git a/frontend/src/features/skills/api.ts b/frontend/src/features/skills/api.ts
new file mode 100644
index 0000000..6457c33
--- /dev/null
+++ b/frontend/src/features/skills/api.ts
@@ -0,0 +1,11 @@
+import { apiRequest } from '@/lib/api'
+import type { SkillDefinition, SkillDetail, SkillVersion } from './types'
+
+export const listSkills = () => apiRequest('/skills')
+export const createSkill = (payload: object) => apiRequest('/skills', { method: 'POST', body: JSON.stringify(payload) })
+export const getSkill = (id: number) => apiRequest(`/skills/${id}`)
+export const updateSkill = (id: number, payload: object) => apiRequest(`/skills/${id}`, { method: 'PATCH', body: JSON.stringify(payload) })
+export const ensureSkillDraft = (id: number) => apiRequest(`/skills/${id}/draft`, { method: 'POST' })
+export const updateSkillDraft = (id: number, payload: object) => apiRequest(`/skills/${id}/draft`, { method: 'PATCH', body: JSON.stringify(payload) })
+export const publishSkill = (id: number) => apiRequest(`/skills/${id}/publish`, { method: 'POST' })
+export const rollbackSkill = (id: number, version: number) => apiRequest(`/skills/${id}/rollback/${version}`, { method: 'POST' })
diff --git a/frontend/src/features/skills/types.ts b/frontend/src/features/skills/types.ts
new file mode 100644
index 0000000..fefdb20
--- /dev/null
+++ b/frontend/src/features/skills/types.ts
@@ -0,0 +1,24 @@
+export type SkillVersion = {
+ id: number
+ version: number
+ status: 'draft' | 'published' | 'archived'
+ prompt_template: string
+ input_schema: Record
+ output_schema: Record
+ example_input: Record
+ 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[] }
diff --git a/frontend/src/lib/api.test.ts b/frontend/src/lib/api.test.ts
new file mode 100644
index 0000000..e7ceede
--- /dev/null
+++ b/frontend/src/lib/api.test.ts
@@ -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)
+ })
+
+ 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 }])
+ })
+})
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 258bab8..8bce4dc 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -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(path: string, init?: RequestInit): Promise