415 lines
14 KiB
TypeScript
415 lines
14 KiB
TypeScript
'use client'
|
||
|
||
import { useState } from 'react'
|
||
import useSWR from 'swr'
|
||
import {
|
||
Plus,
|
||
Loader2,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Pencil,
|
||
Trash2,
|
||
} from 'lucide-react'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import { Switch } from '@/components/ui/switch'
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from '@/components/ui/table'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogFooter,
|
||
DialogDescription,
|
||
} from '@/components/ui/dialog'
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from '@/components/ui/select'
|
||
import { TableSkeleton } from '@/components/ui/skeleton'
|
||
import { ErrorBanner, EmptyState } from '@/components/ui/state'
|
||
import { api } from '@/lib/api-client'
|
||
import { ApiRequestError } from '@/lib/api-client'
|
||
import { formatNumber } from '@/lib/utils'
|
||
import type { Model, Provider } from '@/lib/types'
|
||
|
||
const PAGE_SIZE = 20
|
||
|
||
interface ModelForm {
|
||
provider_id: string
|
||
model_id: string
|
||
alias: string
|
||
context_window: string
|
||
max_output_tokens: string
|
||
supports_streaming: boolean
|
||
supports_vision: boolean
|
||
enabled: boolean
|
||
pricing_input: string
|
||
pricing_output: string
|
||
}
|
||
|
||
const emptyForm: ModelForm = {
|
||
provider_id: '',
|
||
model_id: '',
|
||
alias: '',
|
||
context_window: '4096',
|
||
max_output_tokens: '4096',
|
||
supports_streaming: true,
|
||
supports_vision: false,
|
||
enabled: true,
|
||
pricing_input: '',
|
||
pricing_output: '',
|
||
}
|
||
|
||
export default function ModelsPage() {
|
||
const [page, setPage] = useState(1)
|
||
const [providerFilter, setProviderFilter] = useState<string>('all')
|
||
const [error, setError] = useState('')
|
||
|
||
// SWR for models list
|
||
const { data, isLoading, mutate } = useSWR(
|
||
['models', page, providerFilter],
|
||
() => {
|
||
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||
if (providerFilter !== 'all') params.provider_id = providerFilter
|
||
return api.models.list(params)
|
||
}
|
||
)
|
||
const models = data?.items ?? []
|
||
const total = data?.total ?? 0
|
||
|
||
// SWR for providers list (dropdown)
|
||
const { data: providersData } = useSWR(
|
||
['providers.all'],
|
||
() => api.providers.list({ page: 1, page_size: 100 })
|
||
)
|
||
const providers = providersData?.items ?? []
|
||
|
||
// Dialog
|
||
const [dialogOpen, setDialogOpen] = useState(false)
|
||
const [editTarget, setEditTarget] = useState<Model | null>(null)
|
||
const [form, setForm] = useState<ModelForm>(emptyForm)
|
||
const [saving, setSaving] = useState(false)
|
||
|
||
// 删除
|
||
const [deleteTarget, setDeleteTarget] = useState<Model | null>(null)
|
||
const [deleting, setDeleting] = useState(false)
|
||
|
||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||
|
||
const providerMap = new Map(providers.map((p) => [p.id, p.display_name || p.name]))
|
||
|
||
function openCreateDialog() {
|
||
setEditTarget(null)
|
||
setForm(emptyForm)
|
||
setDialogOpen(true)
|
||
}
|
||
|
||
function openEditDialog(model: Model) {
|
||
setEditTarget(model)
|
||
setForm({
|
||
provider_id: model.provider_id,
|
||
model_id: model.model_id,
|
||
alias: model.alias,
|
||
context_window: model.context_window.toString(),
|
||
max_output_tokens: model.max_output_tokens.toString(),
|
||
supports_streaming: model.supports_streaming,
|
||
supports_vision: model.supports_vision,
|
||
enabled: model.enabled,
|
||
pricing_input: model.pricing_input.toString(),
|
||
pricing_output: model.pricing_output.toString(),
|
||
})
|
||
setDialogOpen(true)
|
||
}
|
||
|
||
async function handleSave() {
|
||
if (!form.model_id.trim() || !form.provider_id) return
|
||
setSaving(true)
|
||
try {
|
||
const payload = {
|
||
provider_id: form.provider_id,
|
||
model_id: form.model_id.trim(),
|
||
alias: form.alias.trim(),
|
||
context_window: parseInt(form.context_window, 10) || 4096,
|
||
max_output_tokens: parseInt(form.max_output_tokens, 10) || 4096,
|
||
supports_streaming: form.supports_streaming,
|
||
supports_vision: form.supports_vision,
|
||
enabled: form.enabled,
|
||
pricing_input: parseFloat(form.pricing_input) || 0,
|
||
pricing_output: parseFloat(form.pricing_output) || 0,
|
||
}
|
||
if (editTarget) {
|
||
await api.models.update(editTarget.id, payload)
|
||
} else {
|
||
await api.models.create(payload)
|
||
}
|
||
setDialogOpen(false)
|
||
mutate()
|
||
} catch (err) {
|
||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
async function handleDelete() {
|
||
if (!deleteTarget) return
|
||
setDeleting(true)
|
||
try {
|
||
await api.models.delete(deleteTarget.id)
|
||
setDeleteTarget(null)
|
||
mutate()
|
||
} catch (err) {
|
||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||
} finally {
|
||
setDeleting(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<Select value={providerFilter} onValueChange={(v) => { setProviderFilter(v); setPage(1) }}>
|
||
<SelectTrigger className="w-[200px]">
|
||
<SelectValue placeholder="按服务商筛选" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">全部服务商</SelectItem>
|
||
{providers.map((p) => (
|
||
<SelectItem key={p.id} value={p.id}>
|
||
{p.display_name || p.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button onClick={openCreateDialog}>
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
新建模型
|
||
</Button>
|
||
</div>
|
||
|
||
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
|
||
|
||
{isLoading ? (
|
||
<TableSkeleton rows={8} cols={9} hasToolbar={false} />
|
||
) : error ? null : models.length === 0 ? (
|
||
<EmptyState />
|
||
) : (
|
||
<>
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>模型 ID</TableHead>
|
||
<TableHead>别名</TableHead>
|
||
<TableHead>服务商</TableHead>
|
||
<TableHead>上下文窗口</TableHead>
|
||
<TableHead>最大输出</TableHead>
|
||
<TableHead>流式</TableHead>
|
||
<TableHead>视觉</TableHead>
|
||
<TableHead>启用</TableHead>
|
||
<TableHead className="text-right">操作</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{models.map((m) => (
|
||
<TableRow key={m.id}>
|
||
<TableCell className="font-mono text-sm">{m.model_id}</TableCell>
|
||
<TableCell>{m.alias || '-'}</TableCell>
|
||
<TableCell className="text-muted-foreground">
|
||
{providerMap.get(m.provider_id) || m.provider_id.slice(0, 8)}
|
||
</TableCell>
|
||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||
{formatNumber(m.context_window)}
|
||
</TableCell>
|
||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||
{formatNumber(m.max_output_tokens)}
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant={m.supports_streaming ? 'success' : 'secondary'}>
|
||
{m.supports_streaming ? '是' : '否'}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant={m.supports_vision ? 'success' : 'secondary'}>
|
||
{m.supports_vision ? '是' : '否'}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant={m.enabled ? 'success' : 'destructive'}>
|
||
{m.enabled ? '启用' : '禁用'}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<div className="flex items-center justify-end gap-1">
|
||
<Button variant="ghost" size="icon" onClick={() => openEditDialog(m)} title="编辑">
|
||
<Pencil className="h-4 w-4" />
|
||
</Button>
|
||
<Button variant="ghost" size="icon" onClick={() => setDeleteTarget(m)} title="删除">
|
||
<Trash2 className="h-4 w-4 text-destructive" />
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
|
||
<div className="flex items-center justify-between text-sm">
|
||
<p className="text-muted-foreground">
|
||
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||
</p>
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||
上一页
|
||
</Button>
|
||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||
下一页
|
||
<ChevronRight className="h-4 w-4 ml-1" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* 创建/编辑 Dialog */}
|
||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||
<DialogContent className="max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle>{editTarget ? '编辑模型' : '新建模型'}</DialogTitle>
|
||
<DialogDescription>
|
||
{editTarget ? '修改模型配置' : '添加新的 AI 模型'}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin pr-1">
|
||
<div className="space-y-2">
|
||
<Label>服务商 *</Label>
|
||
<Select value={form.provider_id} onValueChange={(v) => setForm({ ...form, provider_id: v })} disabled={!!editTarget}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="选择服务商" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{providers.map((p) => (
|
||
<SelectItem key={p.id} value={p.id}>
|
||
{p.display_name || p.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>模型 ID *</Label>
|
||
<Input
|
||
value={form.model_id}
|
||
onChange={(e) => setForm({ ...form, model_id: e.target.value })}
|
||
placeholder="gpt-4o"
|
||
disabled={!!editTarget}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>别名</Label>
|
||
<Input
|
||
value={form.alias}
|
||
onChange={(e) => setForm({ ...form, alias: e.target.value })}
|
||
placeholder="GPT-4o"
|
||
/>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label>上下文窗口</Label>
|
||
<Input
|
||
type="number"
|
||
value={form.context_window}
|
||
onChange={(e) => setForm({ ...form, context_window: e.target.value })}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>最大输出 Tokens</Label>
|
||
<Input
|
||
type="number"
|
||
value={form.max_output_tokens}
|
||
onChange={(e) => setForm({ ...form, max_output_tokens: e.target.value })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label>Input 定价 ($/1M tokens)</Label>
|
||
<Input
|
||
type="number"
|
||
step="0.01"
|
||
value={form.pricing_input}
|
||
onChange={(e) => setForm({ ...form, pricing_input: e.target.value })}
|
||
placeholder="0"
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>Output 定价 ($/1M tokens)</Label>
|
||
<Input
|
||
type="number"
|
||
step="0.01"
|
||
value={form.pricing_output}
|
||
onChange={(e) => setForm({ ...form, pricing_output: e.target.value })}
|
||
placeholder="0"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-6">
|
||
<div className="flex items-center gap-2">
|
||
<Switch checked={form.supports_streaming} onCheckedChange={(v) => setForm({ ...form, supports_streaming: v })} />
|
||
<Label>流式</Label>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Switch checked={form.supports_vision} onCheckedChange={(v) => setForm({ ...form, supports_vision: v })} />
|
||
<Label>视觉</Label>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<Switch checked={form.enabled} onCheckedChange={(v) => setForm({ ...form, enabled: v })} />
|
||
<Label>启用</Label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||
<Button onClick={handleSave} disabled={saving || !form.model_id.trim() || !form.provider_id}>
|
||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
保存
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* 删除确认 */}
|
||
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>确认删除</DialogTitle>
|
||
<DialogDescription>
|
||
确定要删除模型 "{deleteTarget?.alias || deleteTarget?.model_id}" 吗?此操作不可撤销。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
|
||
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
删除
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
)
|
||
}
|