Implemented a new connection testing functionality in ComponentsModelsPage, allowing users to test API connectivity. Added state management for testing status and results, along with UI updates to display connection success or failure. Enhanced button interactions for improved user experience during testing.
659 lines
21 KiB
TypeScript
659 lines
21 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Brain,
|
||
CheckCircle2,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Eye,
|
||
EyeOff,
|
||
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 { useState } 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;
|
||
type: ModelType;
|
||
interfaceType: InterfaceType;
|
||
apiUrl: string;
|
||
apiKey: string;
|
||
};
|
||
|
||
const mockModels: ModelResource[] = [
|
||
{
|
||
id: "model_001",
|
||
name: "DeepSeek-V3",
|
||
type: "LLM",
|
||
interfaceType: "openai",
|
||
apiUrl: "https://api.deepseek.com/v1",
|
||
apiKey: "sk-deepseek-7f3a9c2e1b",
|
||
},
|
||
{
|
||
id: "model_002",
|
||
name: "Qwen-Max",
|
||
type: "LLM",
|
||
interfaceType: "openai",
|
||
apiUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||
apiKey: "sk-qwen-4d8e2a6f0c",
|
||
},
|
||
{
|
||
id: "model_003",
|
||
name: "讯飞语音识别",
|
||
type: "ASR",
|
||
interfaceType: "xfyun",
|
||
apiUrl: "https://iat-api.xfyun.cn/v2/iat",
|
||
apiKey: "xf-asr-9b1c3d5e7a",
|
||
},
|
||
{
|
||
id: "model_004",
|
||
name: "Paraformer 识别",
|
||
type: "ASR",
|
||
interfaceType: "dashscope",
|
||
apiUrl: "https://dashscope.aliyuncs.com/api/v1/services/audio/asr",
|
||
apiKey: "sk-paraformer-2e4f6a",
|
||
},
|
||
{
|
||
id: "model_005",
|
||
name: "讯飞语音合成",
|
||
type: "TTS",
|
||
interfaceType: "xfyun",
|
||
apiUrl: "https://tts-api.xfyun.cn/v2/tts",
|
||
apiKey: "xf-tts-6c8a0b2d4f",
|
||
},
|
||
{
|
||
id: "model_006",
|
||
name: "CosyVoice 合成",
|
||
type: "TTS",
|
||
interfaceType: "dashscope",
|
||
apiUrl: "https://dashscope.aliyuncs.com/api/v1/services/audio/tts",
|
||
apiKey: "sk-cosyvoice-1a3c5e",
|
||
},
|
||
{
|
||
id: "model_007",
|
||
name: "OpenAI TTS",
|
||
type: "TTS",
|
||
interfaceType: "openai",
|
||
apiUrl: "https://api.openai.com/v1/audio/speech",
|
||
apiKey: "sk-openai-tts-8f0a2c",
|
||
},
|
||
{
|
||
id: "model_008",
|
||
name: "GPT Realtime",
|
||
type: "Realtime",
|
||
interfaceType: "openai",
|
||
apiUrl: "https://api.openai.com/v1/realtime",
|
||
apiKey: "sk-realtime-3b5d7f9a1c",
|
||
},
|
||
{
|
||
id: "model_009",
|
||
name: "Gemini Live",
|
||
type: "Realtime",
|
||
interfaceType: "gemini",
|
||
apiUrl: "https://generativelanguage.googleapis.com/v1beta",
|
||
apiKey: "gm-live-5e7a9c1b3d",
|
||
},
|
||
{
|
||
id: "model_010",
|
||
name: "text-embedding-3",
|
||
type: "Embedding",
|
||
interfaceType: "openai",
|
||
apiUrl: "https://api.openai.com/v1/embeddings",
|
||
apiKey: "sk-embed-0c2e4a6f8b",
|
||
},
|
||
{
|
||
id: "model_011",
|
||
name: "Kimi-K2",
|
||
type: "LLM",
|
||
interfaceType: "openai",
|
||
apiUrl: "https://api.moonshot.cn/v1",
|
||
apiKey: "sk-kimi-7a9c1e3b5d",
|
||
},
|
||
{
|
||
id: "model_012",
|
||
name: "BGE Embedding",
|
||
type: "Embedding",
|
||
interfaceType: "openai",
|
||
apiUrl: "https://api.siliconflow.cn/v1/embeddings",
|
||
apiKey: "sk-bge-2d4f6a8c0e",
|
||
},
|
||
];
|
||
|
||
type ModelForm = {
|
||
name: string;
|
||
type: ModelType;
|
||
interfaceType: InterfaceType;
|
||
apiUrl: string;
|
||
apiKey: string;
|
||
};
|
||
|
||
const emptyForm: ModelForm = {
|
||
name: "",
|
||
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,
|
||
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.id, 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.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.id}</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="sm:max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle>{editingId ? "编辑模型" : "添加模型"}</DialogTitle>
|
||
<DialogDescription>
|
||
填写模型名称、资源类型、接口类型与访问凭证。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-4">
|
||
<label className="block">
|
||
<div className="mb-2 text-sm font-medium text-foreground">名称</div>
|
||
<Input
|
||
value={form.name}
|
||
autoFocus
|
||
onChange={(event) => updateForm("name", event.target.value)}
|
||
placeholder="请输入模型名称"
|
||
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||
/>
|
||
</label>
|
||
|
||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||
<div className="block">
|
||
<div className="mb-2 text-sm font-medium text-foreground">
|
||
资源类型
|
||
</div>
|
||
<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">
|
||
<div className="mb-2 text-sm font-medium text-foreground">
|
||
接口类型
|
||
</div>
|
||
<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>
|
||
|
||
<label className="block">
|
||
<div className="mb-2 text-sm font-medium text-foreground">
|
||
API URL
|
||
</div>
|
||
<Input
|
||
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"
|
||
/>
|
||
</label>
|
||
|
||
<label className="block">
|
||
<div className="mb-2 text-sm font-medium text-foreground">
|
||
API Key
|
||
</div>
|
||
<div className="relative">
|
||
<Input
|
||
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>
|
||
</label>
|
||
</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>
|
||
);
|
||
}
|