- Introduce new fields for voice, speed, and language in the AssistantConfig and ProviderCredential models to support TTS and ASR configurations. - Update the database schema and seeding script to accommodate the new fields, ensuring backward compatibility. - Implement credential testing endpoints and logic to validate OpenAI-compatible credentials, enhancing user experience and reliability. - Modify frontend components to include new fields in the credential forms and improve connection testing feedback. - Refactor related services and API interactions to support the new credential testing feature.
973 lines
33 KiB
TypeScript
973 lines
33 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
Brain,
|
||
CheckCircle2,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Copy,
|
||
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 { useCallback, useEffect, useState, type ReactNode } from "react";
|
||
import {
|
||
credentialsApi,
|
||
type Credential,
|
||
type InterfaceType,
|
||
type ModelType,
|
||
} from "@/lib/api";
|
||
|
||
/** 各资源类型可选的接口类型 */
|
||
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"];
|
||
|
||
// 列表项类型直接复用 API 契约(camelCase,api_key 已打码)
|
||
type ModelResource = Credential;
|
||
|
||
type ModelForm = {
|
||
name: string;
|
||
modelId: string;
|
||
type: ModelType;
|
||
interfaceType: InterfaceType;
|
||
apiUrl: string;
|
||
apiKey: string;
|
||
voice: string;
|
||
speed: number;
|
||
language: string;
|
||
};
|
||
|
||
const emptyForm: ModelForm = {
|
||
name: "",
|
||
modelId: "",
|
||
type: "LLM",
|
||
interfaceType: "openai",
|
||
apiUrl: "",
|
||
apiKey: "",
|
||
voice: "",
|
||
speed: 1,
|
||
language: "",
|
||
};
|
||
|
||
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 [models, setModels] = useState<ModelResource[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [loadError, setLoadError] = useState<string | null>(null);
|
||
|
||
const [dialogOpen, setDialogOpen] = useState(false);
|
||
const [editingId, setEditingId] = useState<string | null>(null);
|
||
// 编辑时记住原记录,保存时回填 isDefault 等未在表单暴露的字段
|
||
const [editingModel, setEditingModel] = useState<ModelResource | null>(null);
|
||
const [form, setForm] = useState<ModelForm>(emptyForm);
|
||
const [showKey, setShowKey] = useState(false);
|
||
const [saving, setSaving] = useState(false);
|
||
const [saveError, setSaveError] = useState<string | null>(null);
|
||
const [deletingId, setDeletingId] = useState<string | null>(null);
|
||
const [duplicatingId, setDuplicatingId] = useState<string | null>(null);
|
||
|
||
const [testing, setTesting] = useState(false);
|
||
const [testResult, setTestResult] = useState<"idle" | "ok" | "fail">("idle");
|
||
const [testMessage, setTestMessage] = useState("");
|
||
|
||
const loadModels = useCallback(async () => {
|
||
setLoading(true);
|
||
setLoadError(null);
|
||
try {
|
||
setModels(await credentialsApi.list());
|
||
} catch (error) {
|
||
setLoadError(error instanceof Error ? error.message : "加载失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
// 挂载时拉取列表:这是与后端(外部系统)的同步,loadModels 内的 setLoading 属预期
|
||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||
void loadModels();
|
||
}, [loadModels]);
|
||
|
||
function updateForm<K extends keyof ModelForm>(key: K, value: ModelForm[K]) {
|
||
setForm((prev) => ({ ...prev, [key]: value }));
|
||
// 任何配置变更后,旧的测试结果不再可信,重置为待测状态
|
||
setTestResult("idle");
|
||
setTestMessage("");
|
||
}
|
||
|
||
async function handleTestConnection() {
|
||
setTesting(true);
|
||
setTestResult("idle");
|
||
setTestMessage("");
|
||
try {
|
||
const result = await credentialsApi.test(
|
||
{
|
||
modelId: form.modelId.trim(),
|
||
type: form.type,
|
||
interfaceType: form.interfaceType,
|
||
apiUrl: form.apiUrl.trim(),
|
||
apiKey: form.apiKey,
|
||
voice: form.voice.trim(),
|
||
speed: form.speed,
|
||
language: form.language.trim(),
|
||
},
|
||
editingId ?? undefined,
|
||
);
|
||
setTestResult(result.ok ? "ok" : "fail");
|
||
setTestMessage(
|
||
[
|
||
result.message,
|
||
result.latencyMs !== null ? `${result.latencyMs}ms` : "",
|
||
result.detail,
|
||
]
|
||
.filter(Boolean)
|
||
.join(" · "),
|
||
);
|
||
} catch (error) {
|
||
setTestResult("fail");
|
||
setTestMessage(error instanceof Error ? error.message : "测试连接失败");
|
||
} finally {
|
||
setTesting(false);
|
||
}
|
||
}
|
||
|
||
function handleTypeChange(type: ModelType) {
|
||
const options = interfaceOptionsByType[type];
|
||
setForm((prev) => ({
|
||
...prev,
|
||
type,
|
||
// 资源类型变化时,若当前接口类型不在新类型的可选项内则重置为首项
|
||
interfaceType: options.includes(prev.interfaceType)
|
||
? prev.interfaceType
|
||
: options[0],
|
||
voice: type === "TTS" ? prev.voice : "",
|
||
speed: type === "TTS" ? prev.speed : 1,
|
||
language: type === "ASR" ? prev.language : "",
|
||
}));
|
||
setTestResult("idle");
|
||
setTestMessage("");
|
||
}
|
||
|
||
function openCreate() {
|
||
setEditingId(null);
|
||
setEditingModel(null);
|
||
setForm(emptyForm);
|
||
setShowKey(false);
|
||
setTestResult("idle");
|
||
setTestMessage("");
|
||
setSaveError(null);
|
||
setDialogOpen(true);
|
||
}
|
||
|
||
function openEdit(model: ModelResource) {
|
||
setEditingId(model.id);
|
||
setEditingModel(model);
|
||
setForm({
|
||
name: model.name,
|
||
modelId: model.modelId,
|
||
type: model.type,
|
||
interfaceType: model.interfaceType,
|
||
apiUrl: model.apiUrl,
|
||
// 编辑时不把打码占位符放入输入框;空值写回后端表示保留旧 key
|
||
apiKey: "",
|
||
voice: model.voice,
|
||
speed: model.speed,
|
||
language: model.language,
|
||
});
|
||
setShowKey(false);
|
||
setTestResult("idle");
|
||
setTestMessage("");
|
||
setSaveError(null);
|
||
setDialogOpen(true);
|
||
}
|
||
|
||
async function handleSave() {
|
||
setSaving(true);
|
||
setSaveError(null);
|
||
const payload = {
|
||
name: form.name.trim(),
|
||
modelId: form.modelId.trim(),
|
||
type: form.type,
|
||
interfaceType: form.interfaceType,
|
||
apiUrl: form.apiUrl.trim(),
|
||
apiKey: form.apiKey,
|
||
voice: form.voice.trim(),
|
||
speed: form.speed,
|
||
language: form.language.trim(),
|
||
// 表单未暴露 isDefault,编辑时沿用原值,新建默认 false
|
||
isDefault: editingModel?.isDefault ?? false,
|
||
};
|
||
try {
|
||
if (editingId) {
|
||
await credentialsApi.update(editingId, payload);
|
||
} else {
|
||
await credentialsApi.create(payload);
|
||
}
|
||
setDialogOpen(false);
|
||
await loadModels();
|
||
} catch (error) {
|
||
setSaveError(error instanceof Error ? error.message : "保存失败");
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function handleDelete(id: string) {
|
||
setDeletingId(id);
|
||
try {
|
||
await credentialsApi.remove(id);
|
||
await loadModels();
|
||
} catch (error) {
|
||
setLoadError(error instanceof Error ? error.message : "删除失败");
|
||
} finally {
|
||
setDeletingId(null);
|
||
}
|
||
}
|
||
|
||
// 服务端整行复制(含真 key,密钥不经浏览器)
|
||
async function handleDuplicate(id: string) {
|
||
setDuplicatingId(id);
|
||
try {
|
||
await credentialsApi.duplicate(id);
|
||
await loadModels();
|
||
} catch (error) {
|
||
setLoadError(error instanceof Error ? error.message : "复制失败");
|
||
} finally {
|
||
setDuplicatingId(null);
|
||
}
|
||
}
|
||
|
||
const filteredModels = models.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 hasStoredApiKey = Boolean(editingId && editingModel?.apiKey);
|
||
const hasRequiredTypeOptions =
|
||
form.type !== "TTS" || Boolean(form.voice.trim() && form.speed > 0);
|
||
const canSave =
|
||
form.name.trim() &&
|
||
form.modelId.trim() &&
|
||
form.apiUrl.trim() &&
|
||
hasRequiredTypeOptions;
|
||
const canTest = Boolean(
|
||
form.modelId.trim() &&
|
||
form.apiUrl.trim() &&
|
||
hasRequiredTypeOptions &&
|
||
(form.apiKey.trim() || editingModel?.apiKey),
|
||
);
|
||
|
||
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
|
||
className="rounded-lg"
|
||
disabled={duplicatingId === model.id}
|
||
onSelect={(event) => {
|
||
event.preventDefault();
|
||
void handleDuplicate(model.id);
|
||
}}
|
||
>
|
||
{duplicatingId === model.id ? (
|
||
<Loader2 size={14} className="animate-spin" />
|
||
) : (
|
||
<Copy size={14} />
|
||
)}
|
||
复制
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
variant="destructive"
|
||
className="rounded-lg"
|
||
disabled={deletingId === model.id}
|
||
onSelect={(event) => {
|
||
event.preventDefault();
|
||
void handleDelete(model.id);
|
||
}}
|
||
>
|
||
{deletingId === model.id ? (
|
||
<Loader2 size={14} className="animate-spin" />
|
||
) : (
|
||
<Trash2 size={14} />
|
||
)}
|
||
删除
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{loading && (
|
||
<div className="flex items-center justify-center gap-2 px-5 py-12 text-sm text-muted-foreground">
|
||
<Loader2 size={16} className="animate-spin" />
|
||
正在加载模型列表…
|
||
</div>
|
||
)}
|
||
|
||
{!loading && loadError && (
|
||
<div className="px-5 py-12 text-center">
|
||
<div className="font-medium text-destructive">
|
||
加载失败
|
||
</div>
|
||
<div className="mt-2 text-sm text-muted-foreground">
|
||
{loadError}
|
||
</div>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="mt-4 border-hairline-strong text-muted-foreground hover:text-foreground"
|
||
onClick={() => void loadModels()}
|
||
>
|
||
重试
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{!loading && !loadError && filteredModels.length === 0 && (
|
||
<div className="px-5 py-12 text-center">
|
||
<div className="font-medium text-foreground">
|
||
{models.length === 0 ? "暂无模型资源" : "未找到匹配的模型"}
|
||
</div>
|
||
<div className="mt-2 text-sm text-muted-foreground">
|
||
{models.length === 0
|
||
? "点击右上角「添加模型」创建第一个资源。"
|
||
: "请调整关键词或筛选条件后再试。"}
|
||
</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>
|
||
|
||
{form.type === "TTS" && (
|
||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||
<div className="block">
|
||
<FieldLabel
|
||
htmlFor="model-voice"
|
||
hint={{
|
||
description:
|
||
"调用语音合成接口时使用的音色标识,需与服务商支持的 voice 一致。",
|
||
example: "alloy、中文女",
|
||
}}
|
||
>
|
||
Voice
|
||
</FieldLabel>
|
||
<Input
|
||
id="model-voice"
|
||
value={form.voice}
|
||
onChange={(event) => updateForm("voice", event.target.value)}
|
||
placeholder="例如 alloy"
|
||
className="border-hairline-strong bg-background text-foreground placeholder:text-muted-soft"
|
||
/>
|
||
</div>
|
||
<div className="block">
|
||
<FieldLabel
|
||
htmlFor="model-speed"
|
||
hint={{
|
||
description: "语音合成的播放速度,1 表示正常速度。",
|
||
example: "0.8、1、1.2",
|
||
}}
|
||
>
|
||
Speed
|
||
</FieldLabel>
|
||
<Input
|
||
id="model-speed"
|
||
type="number"
|
||
min="0.25"
|
||
max="4"
|
||
step="0.1"
|
||
value={form.speed}
|
||
onChange={(event) =>
|
||
updateForm("speed", Number(event.target.value) || 1)
|
||
}
|
||
className="border-hairline-strong bg-background text-foreground"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{form.type === "ASR" && (
|
||
<div className="block">
|
||
<FieldLabel
|
||
htmlFor="model-language"
|
||
hint={{
|
||
description:
|
||
"语音识别的语言代码,留空时由模型自动判断或使用服务商默认值。",
|
||
example: "zh、en",
|
||
}}
|
||
>
|
||
Language
|
||
</FieldLabel>
|
||
<Input
|
||
id="model-language"
|
||
value={form.language}
|
||
onChange={(event) => updateForm("language", event.target.value)}
|
||
placeholder="例如 zh,留空则自动识别"
|
||
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>
|
||
{hasStoredApiKey && (
|
||
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||
<span>当前密钥</span>
|
||
<code className="rounded-md bg-surface-strong px-2 py-1 font-mono text-foreground">
|
||
{editingModel?.apiKey}
|
||
</code>
|
||
</div>
|
||
)}
|
||
<div className="relative">
|
||
<Input
|
||
id="model-api-key"
|
||
value={form.apiKey}
|
||
type={showKey ? "text" : "password"}
|
||
onChange={(event) => updateForm("apiKey", event.target.value)}
|
||
placeholder={
|
||
hasStoredApiKey
|
||
? "已配置,留空则保持不变"
|
||
: "请输入 API Key"
|
||
}
|
||
autoComplete="new-password"
|
||
className="border-hairline-strong bg-background pr-10 text-foreground placeholder:text-muted-soft"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowKey((value) => !value)}
|
||
disabled={!form.apiKey}
|
||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-soft transition-colors hover:text-foreground disabled:cursor-not-allowed disabled:opacity-40"
|
||
aria-label={showKey ? "隐藏 API Key" : "显示 API Key"}
|
||
>
|
||
{showKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
||
</button>
|
||
</div>
|
||
{hasStoredApiKey && (
|
||
<p className="mt-2 text-xs leading-5 text-muted-foreground">
|
||
仅显示当前密钥首尾用于识别。留空可保持原密钥,输入新值将覆盖原密钥。
|
||
</p>
|
||
)}
|
||
</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={!canTest || 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} />
|
||
{testMessage}
|
||
</span>
|
||
)}
|
||
{testResult === "fail" && (
|
||
<span className="flex items-center gap-1.5 text-xs text-destructive">
|
||
<XCircle size={14} />
|
||
{testMessage}
|
||
</span>
|
||
)}
|
||
{saveError && (
|
||
<span className="flex items-center gap-1.5 text-xs text-destructive">
|
||
<XCircle size={14} />
|
||
{saveError}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
className="border-hairline-strong text-muted-foreground hover:text-foreground"
|
||
disabled={saving}
|
||
onClick={() => setDialogOpen(false)}
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
className="gap-2"
|
||
disabled={!canSave || saving}
|
||
onClick={() => void handleSave()}
|
||
>
|
||
{saving ? (
|
||
<Loader2 size={16} className="animate-spin" />
|
||
) : (
|
||
<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>
|
||
);
|
||
}
|