84 lines
7.2 KiB
TypeScript
84 lines
7.2 KiB
TypeScript
import * as React from 'react'
|
|
import { 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 header={<AppPageHeader title="Skill 详情" />}><AppEmptyState title="无法打开 Skill" description={error} /></AppPage>
|
|
if (!detail) return <AppPage header={<AppPageHeader title="Skill 详情" />}><div className="h-52 animate-pulse rounded-md bg-muted" /></AppPage>
|
|
return <AppPage header={<AppPageHeader title={detail.name} breadcrumbLabel={detail.code} description={`${detail.code} · 发布版本不可变,编辑操作始终写入草稿。`} meta={detail.active_version ? <Badge variant="success">当前 v{detail.active_version}</Badge> : <Badge variant="secondary">尚未发布</Badge>} actions={<>{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>
|
|
}
|