1056 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import {
Database,
Edit,
Loader2,
Plus,
RefreshCw,
Save,
Search,
Trash2,
Upload,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import type { ThreeDModelAliasEntry } from "@/lib/model3d/model-alias-config";
import { cn, formatBeijingTime } from "@/lib/utils";
import type {
AdminAiProvider,
LocalAiModel3dSectionsProps,
} from "../../_lib/types";
import {
LocalAiGroupHeading,
LocalAiSectionShell,
LocalAiSectionSummary,
} from "../local-ai-section-shell";
export function LocalAiModel3dSections({
tripo3dConfig,
setTripo3dConfig,
tripoProviders,
filteredTripoProviders,
enabledTripoProviderCount,
tripoProviderSearch,
setTripoProviderSearch,
tripoProviderBusyId,
worldlabs3dConfig,
setWorldlabs3dConfig,
worldlabsKeyPoolConfig,
setWorldlabsKeyPoolConfig,
worldlabsApiKeysDraft,
setWorldlabsApiKeysDraft,
worldlabsProviders,
filteredWorldlabsProviders,
enabledWorldlabsProviderCount,
worldlabsProviderSearch,
setWorldlabsProviderSearch,
worldlabsProviderBusyId,
threeDModelAliasConfig,
setThreeDModelAliasConfig,
kittycadCadConfig,
setKittycadCadConfig,
isTestingKittycadCad,
isLocalAiSectionExpanded,
toggleLocalAiSection,
getProviderDefaultBaseUrl,
handleEditTripoPoolProvider,
handleToggleTripoPoolProvider,
handleDeleteProvider,
handleSaveTripo3dConfig,
handleAddTripo3dPoolKey,
handleImportWorldlabs3dPoolKeys,
handleAddWorldlabs3dPoolKey,
handleEditWorldlabsPoolProvider,
handleToggleWorldlabsPoolProvider,
handleSaveWorldlabs3dConfig,
handleAddThreeDModelAliasEntry,
handleThreeDModelAliasEntryChange,
handleRemoveThreeDModelAliasEntry,
handleResetThreeDModelAliasConfig,
handleSaveThreeDModelAliasConfig,
handleTestKittycadCadConfig,
handleSaveKittycadCadConfig,
}: LocalAiModel3dSectionsProps) {
return (
<>
<LocalAiGroupHeading
description="集中管理 Tripo3D、World Labs、KittyCAD 以及前端显示名映射,方便核对 3D 链路是否完整。"
eyebrow="3D / CAD"
title="3D 生成、世界模型与 CAD"
/>
<LocalAiSectionShell
description={
<>
配置无限画布 3D 生成功能使用的 Tripo3D 接口,保存后写入数据库 AI
提供商配置。
</>
}
expanded={isLocalAiSectionExpanded("tripo3d")}
id="local-ai-section-tripo3d"
onToggle={() => toggleLocalAiSection("tripo3d")}
summary={
<LocalAiSectionSummary
items={[
{
label: tripo3dConfig.isEnabled ? "服务已启用" : "服务已关闭",
tone: tripo3dConfig.isEnabled ? "enabled" : "disabled",
},
{
label: `${tripoProviders.length} 个 Key`,
tone: tripoProviders.length > 0 ? "enabled" : "warning",
},
{
label: `${enabledTripoProviderCount} 个启用中`,
tone: enabledTripoProviderCount > 0 ? "enabled" : "warning",
},
]}
/>
}
title="Tripo3D API 配置"
>
<div className="space-y-2">
<Label htmlFor="tripo3dName">提供商名称</Label>
<Input
id="tripo3dName"
onChange={(e) =>
setTripo3dConfig({ ...tripo3dConfig, name: e.target.value })
}
placeholder="Tripo3D"
value={tripo3dConfig.name}
/>
</div>
<div className="space-y-2">
<Label htmlFor="tripo3dBaseUrl">API 地址</Label>
<Input
id="tripo3dBaseUrl"
onChange={(e) =>
setTripo3dConfig({
...tripo3dConfig,
baseUrl: e.target.value,
})
}
placeholder={getProviderDefaultBaseUrl("tripo-3d")}
value={tripo3dConfig.baseUrl}
/>
<div className="text-muted-foreground text-xs">
默认:{getProviderDefaultBaseUrl("tripo-3d")}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="tripo3dApiKey">
API Key {tripo3dConfig.providerId ? "(留空则不修改)" : "*"}
</Label>
<Input
id="tripo3dApiKey"
onChange={(e) =>
setTripo3dConfig({
...tripo3dConfig,
apiKey: e.target.value,
})
}
placeholder={
tripo3dConfig.providerId
? "留空则不修改当前 Key"
: "请输入 Tripo3D API Key"
}
type="password"
value={tripo3dConfig.apiKey}
/>
</div>
<div className="space-y-4 rounded-xl border p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="font-medium text-sm">Tripo3D Key 池</div>
<div className="text-muted-foreground text-xs">
当前共 {tripoProviders.length} 个 Key,已启用{" "}
{enabledTripoProviderCount} 个。生成任务会轮询使用可用 Key。
</div>
</div>
<div className="relative w-full md:max-w-xs">
<Search className="-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-4 w-4 text-muted-foreground" />
<Input
className="pl-9"
onChange={(e) => setTripoProviderSearch(e.target.value)}
placeholder="搜索名称 / Key / API 地址"
value={tripoProviderSearch}
/>
</div>
</div>
<div className="space-y-3">
{filteredTripoProviders.length ? (
filteredTripoProviders.map((provider: AdminAiProvider) => (
<div
className="flex flex-col gap-4 rounded-lg border p-4 lg:flex-row lg:items-center lg:justify-between"
key={provider.id}
>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium">{provider.name}</div>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs",
provider.isEnabled
? "bg-emerald-500/10 text-emerald-600"
: "bg-zinc-200 text-zinc-600"
)}
>
{provider.isEnabled ? "已启用" : "已禁用"}
</span>
</div>
<div className="font-mono text-muted-foreground text-xs">
Key: {provider.apiKey}
</div>
<div className="text-muted-foreground text-xs">
API: {provider.baseUrl}
</div>
<div className="text-muted-foreground text-xs">
创建时间: {formatBeijingTime(provider.createdAt)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => handleEditTripoPoolProvider(provider)}
size="sm"
variant="outline"
>
<Edit className="mr-1 h-4 w-4" />
编辑
</Button>
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
<Switch
checked={provider.isEnabled}
disabled={tripoProviderBusyId === provider.id}
onCheckedChange={(checked) =>
void handleToggleTripoPoolProvider(provider, checked)
}
/>
<span className="text-muted-foreground text-xs">
{provider.isEnabled ? "启用中" : "已停用"}
</span>
</div>
<Button
onClick={() => handleDeleteProvider(provider.id)}
size="sm"
variant="destructive"
>
<Trash2 className="mr-1 h-4 w-4" />
删除
</Button>
</div>
</div>
))
) : (
<div className="rounded-lg border border-dashed p-6 text-center text-muted-foreground text-sm">
{tripoProviders.length
? "没有匹配到符合筛选条件的 Tripo3D Key。"
: "当前还没有导入任何 Tripo3D Key。"}
</div>
)}
</div>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-sm">启用 Tripo3D</Label>
<div className="text-muted-foreground text-xs">
关闭后 3D 生成接口将不可用
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={tripo3dConfig.isEnabled}
onCheckedChange={(checked) =>
setTripo3dConfig({ ...tripo3dConfig, isEnabled: checked })
}
/>
<span className="min-w-[3rem] font-medium text-muted-foreground text-sm">
{tripo3dConfig.isEnabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={handleSaveTripo3dConfig}>
<Save className="mr-2 h-4 w-4" /> 保存 Tripo3D 配置
</Button>
<Button onClick={handleAddTripo3dPoolKey} variant="outline">
<Plus className="mr-2 h-4 w-4" /> 添加为新 Key
</Button>
</div>
</LocalAiSectionShell>
<LocalAiSectionShell
description={
<>
配置无限画布 3D 世界模式使用的 World Labs 接口,保存后写入数据库 AI
提供商配置。
</>
}
expanded={isLocalAiSectionExpanded("worldlabs3d")}
id="local-ai-section-worldlabs3d"
onToggle={() => toggleLocalAiSection("worldlabs3d")}
summary={
<LocalAiSectionSummary
items={[
{
label: worldlabs3dConfig.isEnabled
? "服务已启用"
: "服务已关闭",
tone: worldlabs3dConfig.isEnabled ? "enabled" : "disabled",
},
{
label: `${worldlabsProviders.length} 个 Key`,
tone: worldlabsProviders.length > 0 ? "enabled" : "warning",
},
{
label: `${enabledWorldlabsProviderCount} 个启用中`,
tone: enabledWorldlabsProviderCount > 0 ? "enabled" : "warning",
},
]}
/>
}
title="World Labs API 配置"
>
<div className="space-y-2">
<Label htmlFor="worldlabs3dName">提供商名称</Label>
<Input
id="worldlabs3dName"
onChange={(e) =>
setWorldlabs3dConfig({
...worldlabs3dConfig,
name: e.target.value,
})
}
placeholder="World Labs"
value={worldlabs3dConfig.name}
/>
</div>
<div className="space-y-2">
<Label htmlFor="worldlabs3dBaseUrl">API 地址</Label>
<Input
id="worldlabs3dBaseUrl"
onChange={(e) =>
setWorldlabs3dConfig({
...worldlabs3dConfig,
baseUrl: e.target.value,
})
}
placeholder={getProviderDefaultBaseUrl("worldlabs-3d")}
value={worldlabs3dConfig.baseUrl}
/>
<div className="text-muted-foreground text-xs">
默认:{getProviderDefaultBaseUrl("worldlabs-3d")}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="worldlabs3dApiKey">
API Key {worldlabs3dConfig.providerId ? "(留空则不修改)" : "*"}
</Label>
<Input
id="worldlabs3dApiKey"
onChange={(e) =>
setWorldlabs3dConfig({
...worldlabs3dConfig,
apiKey: e.target.value,
})
}
placeholder={
worldlabs3dConfig.providerId
? "留空则不修改当前 Key"
: "请输入 World Labs API Key"
}
type="password"
value={worldlabs3dConfig.apiKey}
/>
</div>
<div className="space-y-2">
<Label htmlFor="worldlabsKeyPoolImportToken">Key 池导入令牌</Label>
<Input
id="worldlabsKeyPoolImportToken"
onChange={(e) =>
setWorldlabsKeyPoolConfig((prev) => ({
...prev,
importToken: e.target.value,
}))
}
placeholder="供 /api/worldlabs/key-pool/import 调用的 Bearer Token"
type="password"
value={worldlabsKeyPoolConfig.importToken}
/>
<div className="text-muted-foreground text-xs">
用于 Python 脚本通过
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-[11px]">
/api/worldlabs/key-pool/import
</code>
批量导入新 Key;后台管理员在本页面导入时不需要额外填写令牌。
</div>
</div>
<div className="space-y-2 rounded-xl border border-dashed p-4">
<div className="flex flex-col gap-1 md:flex-row md:items-center md:justify-between">
<div>
<Label htmlFor="worldlabs3dApiKeysDraft">批量导入 Key</Label>
<div className="text-muted-foreground text-xs">
每行一个 World Labs API Key,导入时会自动跳过已存在或重复的
Key。
</div>
</div>
<Button
onClick={handleImportWorldlabs3dPoolKeys}
type="button"
variant="outline"
>
<Upload className="mr-2 h-4 w-4" />
批量导入
</Button>
</div>
<Textarea
id="worldlabs3dApiKeysDraft"
onChange={(e) => setWorldlabsApiKeysDraft(e.target.value)}
placeholder={"wlt_xxxxxxxxx\nwlt_yyyyyyyyy\nwlt_zzzzzzzzz"}
rows={5}
value={worldlabsApiKeysDraft}
/>
</div>
<div className="space-y-4 rounded-xl border p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="font-medium text-sm">Key 池明细</div>
<div className="text-muted-foreground text-xs">
当前共 {worldlabsProviders.length} 个 Key,已启用{" "}
{enabledWorldlabsProviderCount} 个。
</div>
</div>
<div className="relative w-full md:max-w-xs">
<Search className="-translate-y-1/2 pointer-events-none absolute top-1/2 left-3 h-4 w-4 text-muted-foreground" />
<Input
className="pl-9"
onChange={(e) => setWorldlabsProviderSearch(e.target.value)}
placeholder="搜索名称 / Key / API 地址"
value={worldlabsProviderSearch}
/>
</div>
</div>
<div className="space-y-3">
{filteredWorldlabsProviders.length ? (
filteredWorldlabsProviders.map((provider: AdminAiProvider) => (
<div
className="flex flex-col gap-4 rounded-lg border p-4 lg:flex-row lg:items-center lg:justify-between"
key={provider.id}
>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium">{provider.name}</div>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs",
provider.isEnabled
? "bg-emerald-500/10 text-emerald-600"
: "bg-zinc-200 text-zinc-600"
)}
>
{provider.isEnabled ? "已启用" : "已禁用"}
</span>
</div>
<div className="font-mono text-muted-foreground text-xs">
Key: {provider.apiKey}
</div>
<div className="text-muted-foreground text-xs">
API: {provider.baseUrl}
</div>
<div className="text-muted-foreground text-xs">
创建时间: {formatBeijingTime(provider.createdAt)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => handleEditWorldlabsPoolProvider(provider)}
size="sm"
variant="outline"
>
<Edit className="mr-1 h-4 w-4" />
编辑
</Button>
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
<Switch
checked={provider.isEnabled}
disabled={worldlabsProviderBusyId === provider.id}
onCheckedChange={(checked) =>
void handleToggleWorldlabsPoolProvider(
provider,
checked
)
}
/>
<span className="text-muted-foreground text-xs">
{provider.isEnabled ? "启用中" : "已停用"}
</span>
</div>
<Button
onClick={() => handleDeleteProvider(provider.id)}
size="sm"
variant="destructive"
>
<Trash2 className="mr-1 h-4 w-4" />
删除
</Button>
</div>
</div>
))
) : (
<div className="rounded-lg border border-dashed p-6 text-center text-muted-foreground text-sm">
{worldlabsProviders.length
? "没有匹配到符合筛选条件的 World Labs Key。"
: "当前还没有导入任何 World Labs Key。"}
</div>
)}
</div>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-sm">启用 World Labs</Label>
<div className="text-muted-foreground text-xs">
关闭后 3D 世界模式接口将不可用
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={worldlabs3dConfig.isEnabled}
onCheckedChange={(checked) =>
setWorldlabs3dConfig({
...worldlabs3dConfig,
isEnabled: checked,
})
}
/>
<span className="min-w-[3rem] font-medium text-muted-foreground text-sm">
{worldlabs3dConfig.isEnabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button onClick={handleSaveWorldlabs3dConfig}>
<Save className="mr-2 h-4 w-4" /> 保存 World Labs 配置
</Button>
<Button onClick={handleAddWorldlabs3dPoolKey} variant="outline">
<Plus className="mr-2 h-4 w-4" /> 添加为新 Key
</Button>
</div>
</LocalAiSectionShell>
<LocalAiSectionShell
description={
<>
配置 Tripo3D 与 World Labs 的前台展示名称,并控制 3D
生成附加选项是否显示。用户侧只会看到这里开放的模型和能力。
</>
}
expanded={isLocalAiSectionExpanded("3d-model-alias")}
id="local-ai-section-3d-model-alias"
onToggle={() => toggleLocalAiSection("3d-model-alias")}
summary={
<LocalAiSectionSummary
items={[
{ label: "前台展示名", tone: "neutral" },
{ label: "附加选项显隐", tone: "neutral" },
]}
/>
}
title="3D / 世界模型显示名映射"
>
<div className="rounded-xl border border-slate-200 bg-slate-50/70 p-4 text-muted-foreground text-sm">
前端提交任务时只会发送系统内部别名,真实模型 ID
只在服务端使用。你可以在这里自由改“展示名”和“真实模型
ID”的对应关系,避免用户看到上游模型名。
</div>
<div className="space-y-3 rounded-xl border p-4">
<div className="space-y-1">
<div className="font-medium text-base">3D 生成附加选项显隐</div>
<div className="text-muted-foreground text-sm">
关闭后,用户侧 3D
生成面板会隐藏对应开关;服务端也会忽略该附加选项,避免隐藏功能继续产生额外扣分。
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
{(
[
{
key: "pbr",
label: "PBR 材质",
description: "控制 3D 模型生成里的 PBR 材质开关。",
},
{
key: "quad",
label: "四边面优化",
description: "控制 3D 模型生成里的四边面优化开关。",
},
{
key: "smartLowPoly",
label: "智能低模",
description: "控制 3D 模型生成里的智能低模开关。",
},
] as const
).map((item) => (
<div
className="flex items-center justify-between gap-3 rounded-xl border bg-muted/10 px-3 py-3"
key={item.key}
>
<div className="space-y-0.5">
<div className="font-medium text-sm">{item.label}</div>
<div className="text-muted-foreground text-xs">
{item.description}
</div>
</div>
<Switch
checked={
threeDModelAliasConfig.featureVisibility?.[item.key] !==
false
}
onCheckedChange={(checked) =>
setThreeDModelAliasConfig((prev) => ({
...prev,
featureVisibility: {
...prev.featureVisibility,
[item.key]: checked,
},
}))
}
/>
</div>
))}
</div>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className="space-y-4 rounded-xl border p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<div className="font-medium text-base">Tripo3D 模型映射</div>
<div className="text-muted-foreground text-sm">
控制 3D 生成页面与模型节点显示的模型名称。
</div>
</div>
<Button
onClick={() => handleAddThreeDModelAliasEntry("tripoModels")}
size="sm"
type="button"
variant="outline"
>
<Plus className="mr-2 h-4 w-4" />
添加映射
</Button>
</div>
<div className="space-y-4">
{threeDModelAliasConfig.tripoModels.map(
(entry: ThreeDModelAliasEntry, index: number) => (
<div
className="space-y-4 rounded-xl border p-4"
key={entry.id}
>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="font-medium text-sm">
Tripo3D 映射 {index + 1}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() =>
handleThreeDModelAliasEntryChange(
"tripoModels",
index,
"isDefault",
true
)
}
size="sm"
type="button"
variant={entry.isDefault ? "default" : "outline"}
>
{entry.isDefault ? "默认映射" : "设为默认"}
</Button>
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
<Switch
checked={entry.enabled}
onCheckedChange={(checked) =>
handleThreeDModelAliasEntryChange(
"tripoModels",
index,
"enabled",
checked
)
}
/>
<span className="text-muted-foreground text-xs">
{entry.enabled ? "已启用" : "已停用"}
</span>
</div>
<Button
disabled={
threeDModelAliasConfig.tripoModels.length <= 1
}
onClick={() =>
handleRemoveThreeDModelAliasEntry(
"tripoModels",
index
)
}
size="icon"
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>前台显示名</Label>
<Input
onChange={(e) =>
handleThreeDModelAliasEntryChange(
"tripoModels",
index,
"displayName",
e.target.value
)
}
placeholder="例如:标准模型"
value={entry.displayName}
/>
</div>
<div className="space-y-2">
<Label>真实模型 ID</Label>
<Input
onChange={(e) =>
handleThreeDModelAliasEntryChange(
"tripoModels",
index,
"realModelId",
e.target.value
)
}
placeholder="例如:Turbo-v1.0-20250506"
value={entry.realModelId}
/>
</div>
</div>
<div className="space-y-2">
<Label>说明</Label>
<Input
onChange={(e) =>
handleThreeDModelAliasEntryChange(
"tripoModels",
index,
"description",
e.target.value
)
}
placeholder="可选,用于帮助管理员区分不同映射用途"
value={entry.description}
/>
</div>
</div>
)
)}
</div>
</div>
<div className="space-y-4 rounded-xl border p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<div className="font-medium text-base">World Labs 模型映射</div>
<div className="text-muted-foreground text-sm">
控制 3D 世界工作台与世界节点显示的质量名称。
</div>
</div>
<Button
onClick={() => handleAddThreeDModelAliasEntry("worldModels")}
size="sm"
type="button"
variant="outline"
>
<Plus className="mr-2 h-4 w-4" />
添加映射
</Button>
</div>
<div className="space-y-4">
{threeDModelAliasConfig.worldModels.map(
(entry: ThreeDModelAliasEntry, index: number) => (
<div
className="space-y-4 rounded-xl border p-4"
key={entry.id}
>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="font-medium text-sm">
World 映射 {index + 1}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() =>
handleThreeDModelAliasEntryChange(
"worldModels",
index,
"isDefault",
true
)
}
size="sm"
type="button"
variant={entry.isDefault ? "default" : "outline"}
>
{entry.isDefault ? "默认映射" : "设为默认"}
</Button>
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
<Switch
checked={entry.enabled}
onCheckedChange={(checked) =>
handleThreeDModelAliasEntryChange(
"worldModels",
index,
"enabled",
checked
)
}
/>
<span className="text-muted-foreground text-xs">
{entry.enabled ? "已启用" : "已停用"}
</span>
</div>
<Button
disabled={
threeDModelAliasConfig.worldModels.length <= 1
}
onClick={() =>
handleRemoveThreeDModelAliasEntry(
"worldModels",
index
)
}
size="icon"
type="button"
variant="ghost"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>前台显示名</Label>
<Input
onChange={(e) =>
handleThreeDModelAliasEntryChange(
"worldModels",
index,
"displayName",
e.target.value
)
}
placeholder="例如:标准质量"
value={entry.displayName}
/>
</div>
<div className="space-y-2">
<Label>真实模型 ID</Label>
<Input
onChange={(e) =>
handleThreeDModelAliasEntryChange(
"worldModels",
index,
"realModelId",
e.target.value
)
}
placeholder="例如:marble-1.1"
value={entry.realModelId}
/>
</div>
</div>
<div className="space-y-2">
<Label>说明</Label>
<Input
onChange={(e) =>
handleThreeDModelAliasEntryChange(
"worldModels",
index,
"description",
e.target.value
)
}
placeholder="可选,用于帮助管理员区分不同映射用途"
value={entry.description}
/>
</div>
</div>
)
)}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
onClick={handleResetThreeDModelAliasConfig}
type="button"
variant="outline"
>
<RefreshCw className="mr-2 h-4 w-4" />
恢复默认映射
</Button>
<Button onClick={handleSaveThreeDModelAliasConfig}>
<Save className="mr-2 h-4 w-4" /> 保存模型显示映射
</Button>
</div>
</LocalAiSectionShell>
<LocalAiSectionShell
description={
<>
配置官方 HiCAD 使用的 KittyCAD / Zoo 服务,保存后写入数据库 AI
提供商配置。
</>
}
expanded={isLocalAiSectionExpanded("kittycad-cad")}
id="local-ai-section-kittycad-cad"
onToggle={() => toggleLocalAiSection("kittycad-cad")}
summary={
<LocalAiSectionSummary
items={[
{
label: kittycadCadConfig.isEnabled
? "服务已启用"
: "服务已关闭",
tone: kittycadCadConfig.isEnabled ? "enabled" : "disabled",
},
{
label:
kittycadCadConfig.apiKey || kittycadCadConfig.providerId
? "Token 已配置"
: "Token 待配置",
tone:
kittycadCadConfig.apiKey || kittycadCadConfig.providerId
? "enabled"
: "warning",
},
]}
/>
}
title={<>KittyCAD CAD API 配置</>}
>
<div className="space-y-2">
<Label htmlFor="kittycadCadName">提供商名称</Label>
<Input
id="kittycadCadName"
onChange={(e) =>
setKittycadCadConfig({
...kittycadCadConfig,
name: e.target.value,
})
}
placeholder="KittyCAD CAD"
value={kittycadCadConfig.name}
/>
</div>
<div className="space-y-2">
<Label htmlFor="kittycadCadBaseUrl">API 地址</Label>
<Input
id="kittycadCadBaseUrl"
onChange={(e) =>
setKittycadCadConfig({
...kittycadCadConfig,
baseUrl: e.target.value,
})
}
placeholder={getProviderDefaultBaseUrl("kittycad-cad")}
value={kittycadCadConfig.baseUrl}
/>
<div className="text-muted-foreground text-xs">
默认:{getProviderDefaultBaseUrl("kittycad-cad")}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="kittycadCadApiKey">
API Token {kittycadCadConfig.providerId ? "(留空则不修改)" : "*"}
</Label>
<Input
id="kittycadCadApiKey"
onChange={(e) =>
setKittycadCadConfig({
...kittycadCadConfig,
apiKey: e.target.value,
})
}
placeholder={
kittycadCadConfig.providerId
? "留空则不修改当前 Token"
: "请输入 KittyCAD / Zoo API Token"
}
type="password"
value={kittycadCadConfig.apiKey}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-sm">启用 KittyCAD CAD</Label>
<div className="text-muted-foreground text-xs">
关闭后 HiCAD 将无法连接 Zoo 建模和 AI 助手服务
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={kittycadCadConfig.isEnabled}
onCheckedChange={(checked) =>
setKittycadCadConfig({
...kittycadCadConfig,
isEnabled: checked,
})
}
/>
<span className="min-w-[3rem] font-medium text-muted-foreground text-sm">
{kittycadCadConfig.isEnabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<div className="space-y-1 rounded-lg border border-orange-200 bg-orange-50 p-4 text-orange-800 text-xs">
<p>• 默认接入地址为 `https://api.zoo.dev`。</p>
<p>
• 建议填写 Zoo 官方 API
Token;如果你有自建兼容服务,也可以改成自定义地址。
</p>
<p>
• 测试连接只会校验当前账号和 Token,不会创建 CAD
任务,也不会消耗生成额度。
</p>
</div>
<div className="flex flex-wrap justify-end gap-3">
<Button
disabled={isTestingKittycadCad}
onClick={handleTestKittycadCadConfig}
variant="outline"
>
{isTestingKittycadCad ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Database className="mr-2 h-4 w-4" />
)}
测试连接
</Button>
<Button onClick={handleSaveKittycadCadConfig}>
<Save className="mr-2 h-4 w-4" /> 保存 KittyCAD CAD 配置
</Button>
</div>
</LocalAiSectionShell>
</>
);
}