Implement server-side duplication for both assistants and credentials, allowing users to create copies with unique IDs and modified names. Update the respective API routes and frontend components to handle duplication requests, ensuring sensitive information is securely managed. Enhance the AssistantPage and ComponentsModelsPage to support this new feature, including loading and error handling for the duplication process.
832 lines
28 KiB
TypeScript
832 lines
28 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;
|
||
};
|
||
|
||
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 [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 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");
|
||
}
|
||
|
||
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);
|
||
setEditingModel(null);
|
||
setForm(emptyForm);
|
||
setShowKey(false);
|
||
setTestResult("idle");
|
||
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: model.apiKey,
|
||
});
|
||
setShowKey(false);
|
||
setTestResult("idle");
|
||
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,
|
||
// 表单未暴露 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 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
|
||
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>
|
||
|
||
<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>
|
||
)}
|
||
{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>
|
||
);
|
||
}
|