"use client"; import { CheckCircle2, ChevronDown, ChevronUp, Copy, Eye, EyeOff, Loader2, MoreHorizontal, Pencil, Plug, Plus, Trash2, XCircle, } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { DataList } from "@/components/ui/data-list"; import { PageHeader } from "@/components/ui/page-header"; import { FilterPills } from "@/components/ui/filter-pills"; import { SearchInput } from "@/components/ui/search-input"; import { ListToolbar } from "@/components/ui/list-toolbar"; import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { interfaceDefinitionsApi, modelResourcesApi, type InterfaceDefinition, type InterfaceField, type ModelResource, type ModelType, } from "@/lib/api"; const capabilities: ModelType[] = [ "LLM", "ASR", "TTS", "Realtime", "Embedding", "Agent", ]; const capabilityFilters = ["全部", ...capabilities] as const; type ResourceDraft = { name: string; capability: ModelType; interfaceType: string; values: Record; secrets: Record; supportImageInput: boolean; }; const emptyDraft: ResourceDraft = { name: "", capability: "LLM", interfaceType: "", values: {}, secrets: {}, supportImageInput: false, }; function defaults(definition: InterfaceDefinition): Record { return Object.fromEntries( definition.fieldSchema.fields .filter((field) => field.group === "values" && field.default !== undefined) .map((field) => [field.key, field.default]), ); } function formatUpdatedAt(value?: string | null): string { if (!value) return "—"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "—"; return date.toLocaleString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); } // 列表按更新时间排序:newest=最近更新在前(倒叙,默认) / oldest=最早更新在前 type SortOrder = "newest" | "oldest"; // 缺失/非法时间戳视为最早(0),用于排序 function updatedAtValue(value?: string | null): number { const time = value ? new Date(value).getTime() : NaN; return Number.isNaN(time) ? 0 : time; } function fieldValue(draft: ResourceDraft, field: InterfaceField): unknown { return field.group === "secrets" ? draft.secrets[field.key] : draft.values[field.key]; } function secretMask( masks: Record, field: InterfaceField, ): string { if (field.group !== "secrets") return ""; const value = masks[field.key]; return typeof value === "string" && value.includes("****") ? value : ""; } function extraBodyText(values: Record): string { const value = values.extraBody; if (!value || typeof value !== "object" || Array.isArray(value)) return ""; return JSON.stringify(value, null, 2); } function parseExtraBody(text: string): | { ok: true; value: Record | null } | { ok: false; error: string } { const trimmed = text.trim(); if (!trimmed) return { ok: true, value: null }; try { const parsed = JSON.parse(trimmed) as unknown; if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { return { ok: false, error: "Extra Body 必须是 JSON 对象" }; } return { ok: true, value: parsed as Record }; } catch { return { ok: false, error: "Extra Body 不是合法 JSON" }; } } function valuesWithExtraBody( values: Record, extraBody: Record | null, ): Record { const next = { ...values }; delete next.extraBody; if (extraBody) next.extraBody = extraBody; return next; } function isSelectableDefinition(definition: InterfaceDefinition): boolean { return ( definition.capability !== "LLM" || definition.interfaceType === "openai-llm" ); } export function ComponentsModelsPage() { const [resources, setResources] = useState([]); const [definitions, setDefinitions] = useState([]); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(""); const [search, setSearch] = useState(""); const [filter, setFilter] = useState("全部"); const [sortOrder, setSortOrder] = useState("newest"); const [currentPage, setCurrentPage] = useState(1); const [dialogOpen, setDialogOpen] = useState(false); const [editingId, setEditingId] = useState(null); const [draft, setDraft] = useState(emptyDraft); const [storedSecretMasks, setStoredSecretMasks] = useState>({}); const [extraBodyDraft, setExtraBodyDraft] = useState(""); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(""); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState<{ ok: boolean; message: string; } | null>(null); const [deletingId, setDeletingId] = useState(null); const [duplicatingId, setDuplicatingId] = useState(null); const load = useCallback(async () => { setLoading(true); setLoadError(""); try { const [nextResources, nextDefinitions] = await Promise.all([ modelResourcesApi.list(), interfaceDefinitionsApi.list(), ]); setResources(nextResources); setDefinitions(nextDefinitions); } catch (error) { setLoadError(error instanceof Error ? error.message : "加载失败"); } finally { setLoading(false); } }, []); useEffect(() => { // Initial external API synchronization. // eslint-disable-next-line react-hooks/set-state-in-effect void load(); }, [load]); const selectableDefinitions = definitions.filter(isSelectableDefinition); const availableDefinitions = selectableDefinitions.filter( (definition) => definition.capability === draft.capability, ); const selectedDefinition = definitions.find( (definition) => definition.interfaceType === draft.interfaceType, ); const connectionFields = selectedDefinition?.fieldSchema.fields.filter( (field) => field.group === "secrets" || field.required, ) ?? []; const optionalFields = selectedDefinition?.fieldSchema.fields.filter( (field) => field.group === "values" && !field.required, ) ?? []; const filtered = useMemo(() => { const keyword = search.trim().toLowerCase(); return resources.filter((resource) => { if (filter !== "全部" && resource.capability !== filter) return false; return ( !keyword || [ resource.name, resource.interfaceType, String(resource.values.modelId ?? ""), ] .join(" ") .toLowerCase() .includes(keyword) ); }); }, [resources, search, filter]); // 按更新时间排序(以原始时间戳为准);id 作为稳定的次级排序键 const sorted = useMemo(() => { return [...filtered].sort((a, b) => { const diff = updatedAtValue(b.updatedAt) - updatedAtValue(a.updatedAt); if (diff !== 0) return sortOrder === "newest" ? diff : -diff; return a.id.localeCompare(b.id); }); }, [filtered, sortOrder]); const pageSize = 5; const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize)); const safeCurrentPage = Math.min(currentPage, totalPages); const pageStart = (safeCurrentPage - 1) * pageSize; const pageEnd = pageStart + pageSize; const paginatedResources = sorted.slice(pageStart, pageEnd); function changeFilter(nextFilter: ModelType | "全部") { setFilter(nextFilter); setCurrentPage(1); } function changeSearch(value: string) { setSearch(value); setCurrentPage(1); } function changeSort(order: SortOrder) { setSortOrder(order); setCurrentPage(1); } function toggleSortOrder() { changeSort(sortOrder === "newest" ? "oldest" : "newest"); } function selectDefinition(interfaceType: string) { const definition = definitions.find( (item) => item.interfaceType === interfaceType, ); setDraft((previous) => ({ ...previous, interfaceType, values: definition ? defaults(definition) : {}, secrets: {}, })); setStoredSecretMasks({}); setExtraBodyDraft(""); setTestResult(null); } function selectCapability(capability: ModelType) { const first = selectableDefinitions.find( (definition) => definition.capability === capability, ); setDraft((previous) => ({ ...previous, capability, interfaceType: first?.interfaceType ?? "", values: first ? defaults(first) : {}, secrets: {}, supportImageInput: capability === "LLM" ? previous.supportImageInput : false, })); setStoredSecretMasks({}); setExtraBodyDraft(""); setTestResult(null); } function openCreate() { const first = selectableDefinitions.find( (definition) => definition.capability === "LLM", ); setEditingId(null); setDraft({ ...emptyDraft, interfaceType: first?.interfaceType ?? "", values: first ? defaults(first) : {}, secrets: {}, supportImageInput: false, }); setStoredSecretMasks({}); setExtraBodyDraft(""); setSaveError(""); setTestResult(null); setDialogOpen(true); } function openEdit(resource: ModelResource) { setEditingId(resource.id); setDraft({ name: resource.name, capability: resource.capability, interfaceType: resource.interfaceType, values: resource.values, secrets: {}, supportImageInput: resource.supportImageInput, }); setStoredSecretMasks(resource.secrets); setExtraBodyDraft(extraBodyText(resource.values)); setSaveError(""); setTestResult(null); setDialogOpen(true); } function updateField(field: InterfaceField, value: unknown) { const target = field.group === "secrets" ? "secrets" : "values"; setDraft((previous) => ({ ...previous, [target]: { ...previous[target], [field.key]: value }, })); setTestResult(null); } const parsedExtraBody = parseExtraBody(extraBodyDraft); const extraBodyError = parsedExtraBody.ok ? "" : parsedExtraBody.error; const canSave = Boolean( draft.name.trim() && selectedDefinition && !extraBodyError && selectedDefinition.fieldSchema.fields.every((field) => { if (!field.required) return true; const value = field.group === "secrets" && secretMask(storedSecretMasks, field) ? secretMask(storedSecretMasks, field) : fieldValue(draft, field); return value !== undefined && value !== null && value !== ""; }), ); function payload() { const extraBody = parsedExtraBody.ok ? parsedExtraBody.value : null; return { name: draft.name.trim(), interfaceType: draft.interfaceType, values: draft.capability === "LLM" ? valuesWithExtraBody(draft.values, extraBody) : draft.values, secrets: editingId ? { ...storedSecretMasks, ...draft.secrets } : draft.secrets, supportImageInput: draft.capability === "LLM" ? draft.supportImageInput : false, enabled: editingId ? resources.find((resource) => resource.id === editingId)?.enabled ?? true : true, isDefault: editingId ? resources.find((resource) => resource.id === editingId)?.isDefault ?? false : false, }; } async function testConnection() { if (!canSave) return; setTesting(true); setTestResult(null); try { const result = await modelResourcesApi.test(payload(), editingId ?? undefined); setTestResult({ ok: result.ok, message: [ result.message, result.latencyMs !== null ? `${result.latencyMs}ms` : "", result.detail, ] .filter(Boolean) .join(" · "), }); } catch (error) { setTestResult({ ok: false, message: error instanceof Error ? error.message : "测试连接失败", }); } finally { setTesting(false); } } async function save() { if (!canSave) return; setSaving(true); setSaveError(""); const body = payload(); try { if (editingId) { await modelResourcesApi.update(editingId, body); } else { await modelResourcesApi.create(body); } setDialogOpen(false); await load(); } catch (error) { setSaveError(error instanceof Error ? error.message : "保存失败"); } finally { setSaving(false); } } async function remove(id: string) { setDeletingId(id); try { await modelResourcesApi.remove(id); await load(); } catch (error) { setLoadError(error instanceof Error ? error.message : "删除失败"); } finally { setDeletingId(null); } } async function duplicate(id: string) { setDuplicatingId(id); try { await modelResourcesApi.duplicate(id); await load(); } catch (error) { setLoadError(error instanceof Error ? error.message : "复制失败"); } finally { setDuplicatingId(null); } } return (
添加模型 } />
} search={ } /> rows={paginatedResources} rowKey={(resource) => resource.id} loading={loading} error={loadError || null} onRetry={() => void load()} empty={{ title: resources.length === 0 ? "暂无模型资源" : "未找到匹配的模型资源", description: resources.length === 0 ? "点击右上角「添加模型」开始。" : "请调整关键词或筛选条件后再试。", }} pagination={{ page: safeCurrentPage, totalPages, onPageChange: setCurrentPage, summary: filtered.length === 0 ? "没有数据" : `显示 ${pageStart + 1}-${Math.min(pageEnd, filtered.length)} / 共 ${filtered.length} 个模型资源`, }} columns={[ { key: "name", header: "模型名称", width: "md:w-[360px]", cell: (resource) => ( <>
{resource.name} {resource.supportImageInput && ( )}
{String( resource.values.modelId ?? resource.values.apiUrl ?? "", )}
), }, { key: "capability", header: "能力", width: "md:w-[128px]", cell: (resource) => ( {resource.capability} ), }, { key: "interfaceType", header: "接口类型", width: "md:w-[220px]", cell: (resource) => ( {resource.interfaceType} ), }, { key: "updatedAt", width: "md:w-[176px]", header: ( ), cellClassName: "whitespace-nowrap tabular-nums text-muted-foreground", cell: (resource) => formatUpdatedAt(resource.updatedAt), }, { key: "actions", header: "操作", align: "right", cellClassName: "flex justify-end gap-2", cell: (resource) => ( <> { event.preventDefault(); void duplicate(resource.id); }} > {duplicatingId === resource.id ? ( ) : ( )} 复制 { event.preventDefault(); void remove(resource.id); }} > {deletingId === resource.id ? ( ) : ( )} 删除 ), }, ]} />
{editingId ? "编辑模型资源" : "添加模型资源"} 选择具体接口类型后,系统会从接口定义生成需要填写的字段。修改能力类型会自动取消不再兼容的助手绑定。
setDraft((previous) => ({ ...previous, name: event.target.value, })) } />
{draft.capability === "LLM" && (
支持图片输入 setDraft((previous) => ({ ...previous, supportImageInput: checked, })) } />
)}
{connectionFields.map((field) => ( updateField(field, value)} /> ))}
{draft.capability === "LLM" && (