795 lines
25 KiB
TypeScript
795 lines
25 KiB
TypeScript
"use client";
|
|||
|
|
|
||
|
|
import {
|
||
|
|
Brain,
|
||
|
|
CheckCircle2,
|
||
|
|
ChevronLeft,
|
||
|
|
ChevronRight,
|
||
|
|
Eye,
|
||
|
|
EyeOff,
|
||
|
|
HelpCircle,
|
||
|
|
Loader2,
|
||
|
|
MoreHorizontal,
|
||
|
|
Pencil,
|
||
|
|
Plug,
|
||
|
|
Plus,
|
||
|
|
Search,
|
||
|
|
Trash2,
|
||
|
|
XCircle,
|
||
|
|
} from "lucide-react";
|
||
|
|
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import {
|
||
|
|
DropdownMenu,
|
||
|
|
DropdownMenuContent,
|
||
|
|
DropdownMenuItem,
|
||
|
|
DropdownMenuTrigger,
|
||
|
|
} from "@/components/ui/dropdown-menu";
|
||
|
|
import {
|
||
|
|
Select,
|
||
|
|
SelectContent,
|
||
|
|
SelectItem,
|
||
|
|
SelectTrigger,
|
||
|
|
SelectValue,
|
||
|
|
} from "@/components/ui/select";
|
||
|
|
import {
|
||
|
|
Dialog,
|
||
|
|
DialogContent,
|
||
|
|
DialogDescription,
|
||
|
|
DialogFooter,
|
||
|
|
DialogHeader,
|
||
|
|
DialogTitle,
|
||
|
|
} from "@/components/ui/dialog";
|
||
|
|
import {
|
||
|
|
Popover,
|
||
|
|
PopoverContent,
|
||
|
|
PopoverTrigger,
|
||
|
|
} from "@/components/ui/popover";
|
||
|
|
import { useState, type ReactNode } from "react";
|
||
|
|
|
||
|
|
type ModelType = "LLM" | "ASR" | "TTS" | "Realtime" | "Embedding";
|
||
|
|
|
||
|
|
type InterfaceType = "openai" | "xfyun" | "dashscope" | "gemini";
|
||
|
|
|
||
|
|
/** 各资源类型可选的接口类型 */
|
||
|
|
const interfaceOptionsByType: Record<ModelType, InterfaceType[]> = {
|
||
|
|
LLM: ["openai"],
|
||
|
|
ASR: ["openai", "xfyun", "dashscope"],
|
||
|
|
TTS: ["openai", "xfyun", "dashscope"],
|
||
|
|
Realtime: ["openai", "gemini"],
|
||
|
|
Embedding: ["openai"],
|
||
|
|
};
|
||
|
|
|
||
|
|
const modelTypes: ModelType[] = ["LLM", "ASR", "TTS", "Realtime", "Embedding"];
|
||
|
|
|
||
|
|
type ModelResource = {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
modelId: string;
|
||
|
|
type: ModelType;
|
||
|
|
interfaceType: InterfaceType;
|
||
|
|
apiUrl: string;
|
||
|
|
apiKey: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
const mockModels: ModelResource[] = [
|
||
|
|
{
|
||
|
|
id: "model_001",
|
||
|
|
name: "DeepSeek-V3",
|
||
|
|
modelId: "deepseek-chat",
|
||
|
|
type: "LLM",
|
||
|
|
interfaceType: "openai",
|
||
|
|
apiUrl: "https://api.deepseek.com/v1",
|
||
|
|
apiKey: "sk-deepseek-7f3a9c2e1b",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "model_002",
|
||
|
|
name: "Qwen-Max",
|
||
|
|
modelId: "qwen-max",
|
||
|
|
type: "LLM",
|
||
|
|
interfaceType: "openai",
|
||
|
|
apiUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||
|
|
apiKey: "sk-qwen-4d8e2a6f0c",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "model_003",
|
||
|
|
name: "讯飞语音识别",
|
||
|
|
modelId: "iat",
|
||
|
|
type: "ASR",
|
||
|
|
interfaceType: "xfyun",
|
||
|
|
apiUrl: "https://iat-api.xfyun.cn/v2/iat",
|
||
|
|
apiKey: "xf-asr-9b1c3d5e7a",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "model_004",
|
||
|
|
name: "Paraformer 识别",
|
||
|
|
modelId: "paraformer-realtime-v2",
|
||
|
|
type: "ASR",
|
||
|
|
interfaceType: "dashscope",
|
||
|
|
apiUrl: "https://dashscope.aliyuncs.com/api/v1/services/audio/asr",
|
||
|
|
apiKey: "sk-paraformer-2e4f6a",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "model_005",
|
||
|
|
name: "讯飞语音合成",
|
||
|
|
modelId: "tts",
|
||
|
|
type: "TTS",
|
||
|
|
interfaceType: "xfyun",
|
||
|
|
apiUrl: "https://tts-api.xfyun.cn/v2/tts",
|
||
|
|
apiKey: "xf-tts-6c8a0b2d4f",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "model_006",
|
||
|
|
name: "CosyVoice 合成",
|
||
|
|
modelId: "cosyvoice-v1",
|
||
|
|
type: "TTS",
|
||
|
|
interfaceType: "dashscope",
|
||
|
|
apiUrl: "https://dashscope.aliyuncs.com/api/v1/services/audio/tts",
|
||
|
|
apiKey: "sk-cosyvoice-1a3c5e",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "model_007",
|
||
|
|
name: "OpenAI TTS",
|
||
|
|
modelId: "tts-1",
|
||
|
|
type: "TTS",
|
||
|
|
interfaceType: "openai",
|
||
|
|
apiUrl: "https://api.openai.com/v1/audio/speech",
|
||
|
|
apiKey: "sk-openai-tts-8f0a2c",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "model_008",
|
||
|
|
name: "GPT Realtime",
|
||
|
|
modelId: "gpt-4o-realtime-preview",
|
||
|
|
type: "Realtime",
|
||
|
|
interfaceType: "openai",
|
||
|
|
apiUrl: "https://api.openai.com/v1/realtime",
|
||
|
|
apiKey: "sk-realtime-3b5d7f9a1c",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "model_009",
|
||
|
|
name: "Gemini Live",
|
||
|
|
modelId: "gemini-2.0-flash-live",
|
||
|
|
type: "Realtime",
|
||
|
|
interfaceType: "gemini",
|
||
|
|
apiUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||
|
|
apiKey: "gm-live-5e7a9c1b3d",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "model_010",
|
||
|
|
name: "text-embedding-3",
|
||
|
|
modelId: "text-embedding-3-small",
|
||
|
|
type: "Embedding",
|
||
|
|
interfaceType: "openai",
|
||
|
|
apiUrl: "https://api.openai.com/v1/embeddings",
|
||
|
|
apiKey: "sk-embed-0c2e4a6f8b",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "model_011",
|
||
|
|
name: "Kimi-K2",
|
||
|
|
modelId: "moonshot-v1-8k",
|
||
|
|
type: "LLM",
|
||
|
|
interfaceType: "openai",
|
||
|
|
apiUrl: "https://api.moonshot.cn/v1",
|
||
|
|
apiKey: "sk-kimi-7a9c1e3b5d",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
id: "model_012",
|
||
|
|
name: "BGE Embedding",
|
||
|
|
modelId: "bge-m3",
|
||
|
|
type: "Embedding",
|
||
|
|
interfaceType: "openai",
|
||
|
|
apiUrl: "https://api.siliconflow.cn/v1/embeddings",
|
||
|
|
apiKey: "sk-bge-2d4f6a8c0e",
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
type ModelForm = {
|
||
|
|
name: string;
|
||
|
|
modelId: string;
|
||
|
|
type: ModelType;
|
||
|
|
interfaceType: InterfaceType;
|
||
|
|
apiUrl: string;
|
||
|
|
apiKey: string;
|
||
|
|
};
|
||
|
|
|
||
|
|
const emptyForm: ModelForm = {
|
||
|
|
name: "",
|
||
|
|
modelId: "",
|
||
|
|
type: "LLM",
|
||
|
|
interfaceType: "openai",
|
||
|
|
apiUrl: "",
|
||
|
|
apiKey: "",
|
||
|
|
};
|
||
|
|
|
||
|
|
type TypeFilter = "全部" | ModelType;
|
||
|
|
|
||
|
|
const typeFilters: TypeFilter[] = ["全部", ...modelTypes];
|
||
|
|
|
||
|
|
export function ComponentsModelsPage() {
|
||
|
|
const [searchQuery, setSearchQuery] = useState("");
|
||
|
|
const [typeFilter, setTypeFilter] = useState<TypeFilter>("全部");
|
||
|
|
const [currentPage, setCurrentPage] = useState(1);
|
||
|
|
|
||
|
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||
|
|
const [form, setForm] = useState<ModelForm>(emptyForm);
|
||
|
|
const [showKey, setShowKey] = useState(false);
|
||
|
|
|
||
|
|
const [testing, setTesting] = useState(false);
|
||
|
|
const [testResult, setTestResult] = useState<"idle" | "ok" | "fail">("idle");
|
||
|
|
|
||
|
|
function updateForm<K extends keyof ModelForm>(key: K, value: ModelForm[K]) {
|
||
|
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||
|
|
// 任何配置变更后,旧的测试结果不再可信,重置为待测状态
|
||
|
|
setTestResult("idle");
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleTestConnection() {
|
||
|
|
setTesting(true);
|
||
|
|
setTestResult("idle");
|
||
|
|
try {
|
||
|
|
// TODO: 接入真实疎通接口(按 form.interfaceType 区分调用方式)
|
||
|
|
await new Promise((resolve) => setTimeout(resolve, 900));
|
||
|
|
const reachable = Boolean(form.apiUrl.trim() && form.apiKey.trim());
|
||
|
|
setTestResult(reachable ? "ok" : "fail");
|
||
|
|
} catch {
|
||
|
|
setTestResult("fail");
|
||
|
|
} finally {
|
||
|
|
setTesting(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleTypeChange(type: ModelType) {
|
||
|
|
const options = interfaceOptionsByType[type];
|
||
|
|
setForm((prev) => ({
|
||
|
|
...prev,
|
||
|
|
type,
|
||
|
|
// 资源类型变化时,若当前接口类型不在新类型的可选项内则重置为首项
|
||
|
|
interfaceType: options.includes(prev.interfaceType)
|
||
|
|
? prev.interfaceType
|
||
|
|
: options[0],
|
||
|
|
}));
|
||
|
|
setTestResult("idle");
|
||
|
|
}
|
||
|
|
|
||
|
|
function openCreate() {
|
||
|
|
setEditingId(null);
|
||
|
|
setForm(emptyForm);
|
||
|
|
setShowKey(false);
|
||
|
|
setTestResult("idle");
|
||
|
|
setDialogOpen(true);
|
||
|
|
}
|
||
|
|
|
||
|
|
function openEdit(model: ModelResource) {
|
||
|
|
setEditingId(model.id);
|
||
|
|
setForm({
|
||
|
|
name: model.name,
|
||
|
|
modelId: model.modelId,
|
||
|
|
type: model.type,
|
||
|
|
interfaceType: model.interfaceType,
|
||
|
|
apiUrl: model.apiUrl,
|
||
|
|
apiKey: model.apiKey,
|
||
|
|
});
|
||
|
|
setShowKey(false);
|
||
|
|
setTestResult("idle");
|
||
|
|
setDialogOpen(true);
|
||
|
|
}
|
||
|
|
|
||
|
|
const filteredModels = mockModels.filter((model) => {
|
||
|
|
if (typeFilter !== "全部" && model.type !== typeFilter) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
const keyword = searchQuery.trim().toLowerCase();
|
||
|
|
if (!keyword) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
return [model.name, model.modelId, model.type, model.interfaceType, model.apiUrl]
|
||
|
|
.join(" ")
|
||
|
|
.toLowerCase()
|
||
|
|
.includes(keyword);
|
||
|
|
});
|
||
|
|
|
||
|
|
const pageSize = 5;
|
||
|
|
const totalPages = Math.max(1, Math.ceil(filteredModels.length / pageSize));
|
||
|
|
const safeCurrentPage = Math.min(currentPage, totalPages);
|
||
|
|
const pageStart = (safeCurrentPage - 1) * pageSize;
|
||
|
|
const pageEnd = pageStart + pageSize;
|
||
|
|
const paginatedModels = filteredModels.slice(pageStart, pageEnd);
|
||
|
|
|
||
|
|
function handleSearchChange(value: string) {
|
||
|
|
setSearchQuery(value);
|
||
|
|
setCurrentPage(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
function handleFilterChange(filter: TypeFilter) {
|
||
|
|
setTypeFilter(filter);
|
||
|
|
setCurrentPage(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
const interfaceOptions = interfaceOptionsByType[form.type];
|
||
|
|
const canSave =
|
||
|
|
form.name.trim() && form.modelId.trim() && form.apiUrl.trim();
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="mx-auto flex w-full max-w-[1440px] flex-col gap-8">
|
||
|
|
<div className="flex flex-col items-start justify-between gap-5 sm:flex-row sm:gap-6">
|
||
|
|
<div>
|
||
|
|
<h1 className="font-display display-lg text-ink">模型资源</h1>
|
||
|
|
<p className="mt-3 max-w-2xl text-[15px] leading-7 text-muted-foreground">
|
||
|
|
统一管理 LLM、ASR、TTS、Realtime 与 Embedding 模型,配置各自的接口类型与访问凭证。
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Button
|
||
|
|
size="lg"
|
||
|
|
className="w-full shrink-0 gap-2 sm:w-auto"
|
||
|
|
onClick={openCreate}
|
||
|
|
>
|
||
|
|
<Plus size={16} />
|
||
|
|
添加模型
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<section className="rounded-2xl border border-hairline bg-card p-6 shadow-sm">
|
||
|
|
<div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||
|
|
<div className="flex flex-wrap items-center gap-2">
|
||
|
|
{typeFilters.map((filter) => (
|
||
|
|
<Button
|
||
|
|
key={filter}
|
||
|
|
variant={filter === typeFilter ? "default" : "outline"}
|
||
|
|
size="sm"
|
||
|
|
className={
|
||
|
|
filter === typeFilter
|
||
|
|
? "rounded-full"
|
||
|
|
: "rounded-full border-hairline-strong text-muted-foreground hover:text-foreground"
|
||
|
|
}
|
||
|
|
onClick={() => handleFilterChange(filter)}
|
||
|
|
>
|
||
|
|
{filter}
|
||
|
|
</Button>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="relative w-full lg:w-[320px]">
|
||
|
|
<Search
|
||
|
|
size={15}
|
||
|
|
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-soft"
|
||
|
|
/>
|
||
|
|
<Input
|
||
|
|
value={searchQuery}
|
||
|
|
onChange={(event) => handleSearchChange(event.target.value)}
|
||
|
|
className="h-10 border-hairline-strong bg-background pl-9 text-sm text-foreground placeholder:text-muted-soft"
|
||
|
|
placeholder="搜索模型名称、接口类型或 URL..."
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="overflow-hidden rounded-xl border border-hairline">
|
||
|
|
<div className="hidden items-center gap-4 bg-surface-strong/60 px-5 py-3 md:flex">
|
||
|
|
<div className="caption-label flex-1 text-muted-soft">模型名称</div>
|
||
|
|
<div className="caption-label w-[110px] text-muted-soft">
|
||
|
|
资源类型
|
||
|
|
</div>
|
||
|
|
<div className="caption-label w-[110px] text-muted-soft">
|
||
|
|
接口类型
|
||
|
|
</div>
|
||
|
|
<div className="caption-label w-[260px] text-muted-soft">
|
||
|
|
API URL
|
||
|
|
</div>
|
||
|
|
<div className="caption-label w-[116px] text-right text-muted-soft">
|
||
|
|
操作
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="divide-y divide-hairline">
|
||
|
|
{paginatedModels.map((model) => (
|
||
|
|
<div
|
||
|
|
key={model.id}
|
||
|
|
className="flex flex-col gap-3 px-5 py-4 text-sm transition-colors hover:bg-surface-strong/40 md:flex-row md:items-center md:gap-4"
|
||
|
|
>
|
||
|
|
<div className="min-w-0 flex-1">
|
||
|
|
<div className="truncate font-medium text-foreground">
|
||
|
|
{model.name}
|
||
|
|
</div>
|
||
|
|
<div className="mt-1 text-xs text-muted-soft">
|
||
|
|
{model.modelId}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="md:w-[110px]">
|
||
|
|
<Badge
|
||
|
|
variant="secondary"
|
||
|
|
className="h-6 bg-surface-strong px-3 text-muted-foreground"
|
||
|
|
>
|
||
|
|
{model.type}
|
||
|
|
</Badge>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="md:w-[110px]">
|
||
|
|
<Badge
|
||
|
|
variant="secondary"
|
||
|
|
className="h-6 bg-surface-strong px-3 text-muted-foreground"
|
||
|
|
>
|
||
|
|
{model.interfaceType}
|
||
|
|
</Badge>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="truncate text-muted-foreground md:w-[260px]">
|
||
|
|
{model.apiUrl}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex justify-end gap-2 md:w-[116px]">
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
className="gap-1.5 border-hairline-strong text-xs text-muted-foreground hover:text-foreground"
|
||
|
|
onClick={() => openEdit(model)}
|
||
|
|
>
|
||
|
|
<Pencil size={14} />
|
||
|
|
编辑
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
<DropdownMenu>
|
||
|
|
<DropdownMenuTrigger asChild>
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="icon-sm"
|
||
|
|
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||
|
|
aria-label={`${model.name} 更多操作`}
|
||
|
|
>
|
||
|
|
<MoreHorizontal size={15} />
|
||
|
|
</Button>
|
||
|
|
</DropdownMenuTrigger>
|
||
|
|
<DropdownMenuContent
|
||
|
|
align="end"
|
||
|
|
className="w-32 min-w-32 rounded-xl border border-hairline bg-popover p-1"
|
||
|
|
>
|
||
|
|
<DropdownMenuItem variant="destructive" className="rounded-lg">
|
||
|
|
<Trash2 size={14} />
|
||
|
|
删除
|
||
|
|
</DropdownMenuItem>
|
||
|
|
</DropdownMenuContent>
|
||
|
|
</DropdownMenu>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
|
||
|
|
{filteredModels.length === 0 && (
|
||
|
|
<div className="px-5 py-12 text-center">
|
||
|
|
<div className="font-medium text-foreground">
|
||
|
|
未找到匹配的模型
|
||
|
|
</div>
|
||
|
|
<div className="mt-2 text-sm text-muted-foreground">
|
||
|
|
请调整关键词或筛选条件后再试。
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="mt-5 flex flex-col gap-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
|
||
|
|
<div>
|
||
|
|
{filteredModels.length === 0
|
||
|
|
? "没有数据"
|
||
|
|
: `显示 ${pageStart + 1}-${Math.min(pageEnd, filteredModels.length)} / 共 ${filteredModels.length} 个模型`}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="icon-sm"
|
||
|
|
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||
|
|
disabled={safeCurrentPage <= 1}
|
||
|
|
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
|
||
|
|
aria-label="上一页"
|
||
|
|
>
|
||
|
|
<ChevronLeft size={15} />
|
||
|
|
</Button>
|
||
|
|
|
||
|
|
{Array.from({ length: totalPages }, (_, index) => index + 1).map(
|
||
|
|
(page) => (
|
||
|
|
<Button
|
||
|
|
key={page}
|
||
|
|
variant={page === safeCurrentPage ? "default" : "outline"}
|
||
|
|
size="sm"
|
||
|
|
className={[
|
||
|
|
"h-8 min-w-8 px-2",
|
||
|
|
page === safeCurrentPage
|
||
|
|
? ""
|
||
|
|
: "border-hairline-strong text-muted-foreground hover:text-foreground",
|
||
|
|
].join(" ")}
|
||
|
|
onClick={() => setCurrentPage(page)}
|
||
|
|
>
|
||
|
|
{page}
|
||
|
|
</Button>
|
||
|
|
),
|
||
|
|
)}
|
||
|
|
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="icon-sm"
|
||
|
|
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||
|
|
disabled={safeCurrentPage >= totalPages}
|
||
|
|
onClick={() =>
|
||
|
|
setCurrentPage((page) => Math.min(totalPages, page + 1))
|
||
|
|
}
|
||
|
|
aria-label="下一页"
|
||
|
|
>
|
||
|
|
<ChevronRight size={15} />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||
|
|
<DialogContent className="max-h-[calc(100vh-3rem)] overflow-y-auto sm:max-w-2xl">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle>{editingId ? "编辑模型" : "添加模型"}</DialogTitle>
|
||
|
|
<DialogDescription>
|
||
|
|
填写资源名称、模型 ID、资源类型、接口类型与访问凭证。
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
<div className="space-y-5">
|
||
|
|
<div className="block">
|
||
|
|
<FieldLabel
|
||
|
|
htmlFor="model-name"
|
||
|
|
hint={{
|
||
|
|
description:
|
||
|
|
"在控制台中展示的资源别名,便于识别和管理,可与模型 ID 不同。",
|
||
|
|
example: "DeepSeek-V3、Qwen-Max",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
资源名称
|
||
|
|
</FieldLabel>
|
||
|
|
<Input
|
||
|
|
id="model-name"
|
||
|
|
value={form.name}
|
||
|
|
autoFocus
|
||
|
|
onChange={(event) => updateForm("name", event.target.value)}
|
||
|
|
placeholder="请输入资源名称"
|
||
|
|
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="block">
|
||
|
|
<FieldLabel
|
||
|
|
htmlFor="model-id"
|
||
|
|
hint={{
|
||
|
|
description:
|
||
|
|
"调用 API 时传入的模型标识,需与服务商文档中的名称完全一致。",
|
||
|
|
example: "deepseek-chat、qwen-max、paraformer-realtime-v2",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
模型 ID
|
||
|
|
</FieldLabel>
|
||
|
|
<Input
|
||
|
|
id="model-id"
|
||
|
|
value={form.modelId}
|
||
|
|
onChange={(event) =>
|
||
|
|
updateForm("modelId", event.target.value)
|
||
|
|
}
|
||
|
|
placeholder="例如 deepseek-chat"
|
||
|
|
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||
|
|
<div className="block">
|
||
|
|
<FieldLabel
|
||
|
|
hint={{
|
||
|
|
description:
|
||
|
|
"模型在语音管线中的能力分类,决定可选的接口类型与后续编排用途。",
|
||
|
|
example: "LLM、ASR、TTS、Realtime、Embedding",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
资源类型
|
||
|
|
</FieldLabel>
|
||
|
|
<Select
|
||
|
|
value={form.type}
|
||
|
|
onValueChange={(value) => handleTypeChange(value as ModelType)}
|
||
|
|
>
|
||
|
|
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
|
||
|
|
<SelectValue placeholder="请选择资源类型" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent className="border-hairline bg-popover text-popover-foreground">
|
||
|
|
{modelTypes.map((type) => (
|
||
|
|
<SelectItem key={type} value={type}>
|
||
|
|
{type}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="block">
|
||
|
|
<FieldLabel
|
||
|
|
hint={{
|
||
|
|
description:
|
||
|
|
"服务商 API 的协议或适配器类型,需与所选资源类型匹配。",
|
||
|
|
example: "openai、xfyun、dashscope、gemini",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
接口类型
|
||
|
|
</FieldLabel>
|
||
|
|
<Select
|
||
|
|
value={form.interfaceType}
|
||
|
|
onValueChange={(value) =>
|
||
|
|
updateForm("interfaceType", value as InterfaceType)
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<SelectTrigger className="w-full border-hairline-strong bg-background text-foreground">
|
||
|
|
<SelectValue placeholder="请选择接口类型" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent className="border-hairline bg-popover text-popover-foreground">
|
||
|
|
{interfaceOptions.map((item) => (
|
||
|
|
<SelectItem key={item} value={item}>
|
||
|
|
{item}
|
||
|
|
</SelectItem>
|
||
|
|
))}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="block">
|
||
|
|
<FieldLabel
|
||
|
|
htmlFor="model-api-url"
|
||
|
|
hint={{
|
||
|
|
description:
|
||
|
|
"模型服务的 Base URL 或接口根地址,通常以 /v1 结尾,不含具体路径参数。",
|
||
|
|
example: "https://api.deepseek.com/v1",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
API URL
|
||
|
|
</FieldLabel>
|
||
|
|
<Input
|
||
|
|
id="model-api-url"
|
||
|
|
value={form.apiUrl}
|
||
|
|
onChange={(event) => updateForm("apiUrl", event.target.value)}
|
||
|
|
placeholder="https://api.example.com/v1"
|
||
|
|
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="block">
|
||
|
|
<FieldLabel
|
||
|
|
htmlFor="model-api-key"
|
||
|
|
hint={{
|
||
|
|
description:
|
||
|
|
"访问模型服务的鉴权密钥,由服务商控制台生成,请妥善保管勿泄露。",
|
||
|
|
example: "sk-xxxxxxxx",
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
API Key
|
||
|
|
</FieldLabel>
|
||
|
|
<div className="relative">
|
||
|
|
<Input
|
||
|
|
id="model-api-key"
|
||
|
|
value={form.apiKey}
|
||
|
|
type={showKey ? "text" : "password"}
|
||
|
|
onChange={(event) => updateForm("apiKey", event.target.value)}
|
||
|
|
placeholder="请输入 API Key"
|
||
|
|
className="border-hairline-strong bg-background pr-10 text-foreground placeholder:text-muted-soft"
|
||
|
|
/>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={() => setShowKey((value) => !value)}
|
||
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-soft transition-colors hover:text-foreground"
|
||
|
|
aria-label={showKey ? "隐藏 API Key" : "显示 API Key"}
|
||
|
|
>
|
||
|
|
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DialogFooter className="sm:items-center sm:justify-between">
|
||
|
|
<div className="flex items-center gap-3">
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
className="gap-2 border-hairline-strong text-muted-foreground hover:text-foreground"
|
||
|
|
disabled={!canSave || testing}
|
||
|
|
onClick={handleTestConnection}
|
||
|
|
>
|
||
|
|
{testing ? (
|
||
|
|
<Loader2 size={14} className="animate-spin" />
|
||
|
|
) : (
|
||
|
|
<Plug size={14} />
|
||
|
|
)}
|
||
|
|
测试连接
|
||
|
|
</Button>
|
||
|
|
{testResult === "ok" && (
|
||
|
|
<span className="flex items-center gap-1.5 text-xs text-emerald-500">
|
||
|
|
<CheckCircle2 size={14} />
|
||
|
|
连接成功
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
{testResult === "fail" && (
|
||
|
|
<span className="flex items-center gap-1.5 text-xs text-destructive">
|
||
|
|
<XCircle size={14} />
|
||
|
|
连接失败
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||
|
|
onClick={() => setDialogOpen(false)}
|
||
|
|
>
|
||
|
|
取消
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
className="gap-2"
|
||
|
|
disabled={!canSave}
|
||
|
|
onClick={() => setDialogOpen(false)}
|
||
|
|
>
|
||
|
|
<Brain size={16} />
|
||
|
|
{editingId ? "保存" : "添加"}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function FieldLabel({
|
||
|
|
htmlFor,
|
||
|
|
children,
|
||
|
|
hint,
|
||
|
|
}: {
|
||
|
|
htmlFor?: string;
|
||
|
|
children: ReactNode;
|
||
|
|
hint: { description: string; example?: string };
|
||
|
|
}) {
|
||
|
|
return (
|
||
|
|
<div className="mb-2 flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||
|
|
<label htmlFor={htmlFor}>{children}</label>
|
||
|
|
<HelpHint {...hint} />
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function HelpHint({
|
||
|
|
description,
|
||
|
|
example,
|
||
|
|
}: {
|
||
|
|
description: string;
|
||
|
|
example?: string;
|
||
|
|
}) {
|
||
|
|
return (
|
||
|
|
<Popover>
|
||
|
|
<PopoverTrigger asChild>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
aria-label="查看说明"
|
||
|
|
className="flex h-5 w-5 items-center justify-center rounded-full text-muted-soft transition-colors hover:bg-surface-strong hover:text-foreground"
|
||
|
|
>
|
||
|
|
<HelpCircle size={14} />
|
||
|
|
</button>
|
||
|
|
</PopoverTrigger>
|
||
|
|
<PopoverContent
|
||
|
|
align="start"
|
||
|
|
className="w-72 space-y-2 text-sm leading-6 text-muted-foreground"
|
||
|
|
>
|
||
|
|
<p>{description}</p>
|
||
|
|
{example && (
|
||
|
|
<p className="text-xs leading-5 text-muted-soft">
|
||
|
|
<span className="font-medium text-muted-foreground">填写样例:</span>
|
||
|
|
{example}
|
||
|
|
</p>
|
||
|
|
)}
|
||
|
|
</PopoverContent>
|
||
|
|
</Popover>
|
||
|
|
);
|
||
|
|
}
|