Initial commit: AI Video Assistant fullstack platform.
Add pipecat-based backend with WebRTC/WS voice routes, Next.js frontend, and Docker Compose orchestration. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
794
frontend/src/components/pages/ComponentsModelsPage.tsx
Normal file
794
frontend/src/components/pages/ComponentsModelsPage.tsx
Normal file
@@ -0,0 +1,794 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user