- 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>
|
|
);
|
|
}
|