2917 lines
118 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 {
ArrowDown,
ArrowUp,
Check,
Layers,
Loader2,
Menu,
Move3D,
Plus,
RefreshCw,
Save,
Sparkles,
Trash2,
Upload,
} from "lucide-react";
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
AI_WASH_CONTROL_ORDER,
type AiWashControlId,
} from "@/lib/ai-wash/config";
import {
ENHANCE_PRO_KREA_DEFAULT_BASE_URL,
ENHANCE_PRO_KREA_DEFAULT_MODEL,
ENHANCE_PRO_KREA_MODELS,
type EnhanceProKreaModel,
type EnhanceProPreset,
} from "@/lib/enhance-pro/config";
import { cn } from "@/lib/utils";
import type {
EnhanceProKeyRow,
LocalAiImagePipelineSectionsProps,
} from "../../_lib/types";
import {
LocalAiGroupHeading,
LocalAiSectionShell,
LocalAiSectionSummary,
} from "../local-ai-section-shell";
export function LocalAiImagePipelineSections({
modelsData,
rmbgConfig,
setRmbgConfig,
enhanceConfig,
setEnhanceConfig,
enhanceProConfig,
setEnhanceProConfig,
enhanceProApiKeysDraft,
setEnhanceProApiKeysDraft,
enhanceProApiKeysDraftCount,
draftEnhanceProKeyRows,
savedEnhanceProKeyRows,
enhanceProPresetEditorMode,
setEnhanceProPresetEditorMode,
selectedEnhanceProPresetId,
setSelectedEnhanceProPresetId,
selectedEnhanceProPreset,
selectedEnhanceProPresetIndex,
draggingEnhanceProPresetId,
aiWashConfig,
setAiWashConfig,
aiWashModeIconUploading,
extendConfig,
setExtendConfig,
watermarkRemoveConfig,
setWatermarkRemoveConfig,
angleEditConfig,
setAngleEditConfig,
pointsSystemConfig,
updatePointsCostField,
layerSplitConfig,
setLayerSplitConfig,
isLocalAiSectionExpanded,
toggleLocalAiSection,
renderEnhanceProPresetEditor,
getEnhanceProPresetSummary,
handleSaveRmbgConfig,
handleSaveEnhanceConfig,
handleSaveEnhanceProConfig,
handleAddEnhanceProPoolKey,
handleDeleteEnhanceProPoolKey,
handleToggleEnhanceProModelVisible,
handleAddEnhanceProPreset,
handleResetEnhanceProPresets,
handleEnhanceProPresetDragStart,
handleEnhanceProPresetDragEnd,
handleEnhanceProPresetDrop,
handleMoveEnhanceProPreset,
handleAiWashModeIconUpload,
handleSaveAiWashConfig,
handleSaveExtendConfig,
handleSaveWatermarkRemoveConfig,
handleSaveAngleEditConfig,
handleSaveLayerSplitConfig,
}: LocalAiImagePipelineSectionsProps) {
const availableImageModels = (modelsData?.models || []).filter(
(model) => model.supportsImageGen && model.isEnabled && !model.isHidden
);
const getAiWashControlToneCopy = (controlId: AiWashControlId) => {
if (controlId === "creativity") {
return "每档可以单独指定模型;如果留空,前台才会开放模型下拉选择。";
}
if (controlId === "detail") {
return "每档可以补充默认提示词,决定细节精修的强弱。";
}
return "每档可以补充默认提示词,决定光影、氛围与空间塑造。";
};
return (
<>
{/* 抠图API配置 */}
<LocalAiGroupHeading
description="把抠图、增强、拓展、去水印、角度编辑和图层拆分归到一组,按用户处理链路顺序阅读。"
eyebrow="Image Pipeline"
title="图像处理能力"
/>
<LocalAiSectionShell
description={
<>
配置背景移除API,用于画图标注功能中的抠图处理。支持自部署RMBG、PhotoRoom和AOS云服务。
</>
}
expanded={isLocalAiSectionExpanded("rmbg")}
id="local-ai-section-rmbg"
onToggle={() => toggleLocalAiSection("rmbg")}
summary={
<LocalAiSectionSummary
items={[
{
label: `当前 ${
rmbgConfig.provider === "photoroom"
? "PhotoRoom"
: rmbgConfig.provider === "aoscdn"
? "AOS"
: "RMBG"
}`,
tone: "neutral",
},
{
label:
rmbgConfig.apiKey ||
rmbgConfig.photoroomApiKey ||
rmbgConfig.aosApiKey
? "Key 已配置"
: "Key 待配置",
tone:
rmbgConfig.apiKey ||
rmbgConfig.photoroomApiKey ||
rmbgConfig.aosApiKey
? "enabled"
: "warning",
},
]}
/>
}
title="抠图API配置"
>
{/* 服务提供商选择 */}
<div className="space-y-3">
<Label>抠图服务提供商</Label>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
rmbgConfig.provider === "rmbg"
? "border-primary bg-accent ring-1 ring-primary"
: ""
}`}
onClick={() => setRmbgConfig({ ...rmbgConfig, provider: "rmbg" })}
>
<div
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-primary ${
rmbgConfig.provider === "rmbg"
? "bg-primary text-primary-foreground"
: "bg-transparent"
}`}
>
{rmbgConfig.provider === "rmbg" && (
<Check className="h-3 w-3" />
)}
</div>
<div className="space-y-1">
<p className="font-medium text-sm leading-none">
RMBG-2.0 (自部署)
</p>
</div>
</div>
<div
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
rmbgConfig.provider === "photoroom"
? "border-primary bg-accent ring-1 ring-primary"
: ""
}`}
onClick={() =>
setRmbgConfig({ ...rmbgConfig, provider: "photoroom" })
}
>
<div
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-primary ${
rmbgConfig.provider === "photoroom"
? "bg-primary text-primary-foreground"
: "bg-transparent"
}`}
>
{rmbgConfig.provider === "photoroom" && (
<Check className="h-3 w-3" />
)}
</div>
<div className="space-y-1">
<p className="font-medium text-sm leading-none">
PhotoRoom (云服务)
</p>
</div>
</div>
<div
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
rmbgConfig.provider === "aoscdn"
? "border-primary bg-accent ring-1 ring-primary"
: ""
}`}
onClick={() =>
setRmbgConfig({ ...rmbgConfig, provider: "aoscdn" })
}
>
<div
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-primary ${
rmbgConfig.provider === "aoscdn"
? "bg-primary text-primary-foreground"
: "bg-transparent"
}`}
>
{rmbgConfig.provider === "aoscdn" && (
<Check className="h-3 w-3" />
)}
</div>
<div className="space-y-1">
<p className="font-medium text-sm leading-none">
AOS Segmentation
</p>
</div>
</div>
</div>
</div>
<Separator />
{/* RMBG 配置 */}
{rmbgConfig.provider === "rmbg" && (
<>
<div className="font-medium text-muted-foreground text-sm">
RMBG-2.0 配置
</div>
<div className="space-y-2">
<Label htmlFor="rmbgApiUrl">API地址 *</Label>
<Input
id="rmbgApiUrl"
onChange={(e) =>
setRmbgConfig({ ...rmbgConfig, apiUrl: e.target.value })
}
placeholder="http://156.225.27.52:8000"
value={rmbgConfig.apiUrl}
/>
<div className="text-muted-foreground text-xs">
请输入完整的API地址,例如: http://156.225.27.52:8000
</div>
</div>
<div className="space-y-2">
<Label htmlFor="rmbgApiKey">API密钥(可选)</Label>
<Input
id="rmbgApiKey"
onChange={(e) =>
setRmbgConfig({ ...rmbgConfig, apiKey: e.target.value })
}
placeholder="如果API需要密钥,请在此输入"
type="password"
value={rmbgConfig.apiKey}
/>
</div>
<div className="space-y-2 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="font-medium text-blue-900 text-sm">
RMBG-2.0 说明
</div>
<div className="space-y-1 text-blue-800 text-xs">
<p>• 需要自行部署RMBG-2.0服务</p>
<p>• 系统会自动调用 /rmbg/base64 接口进行抠图处理</p>
<p>• 支持的图片格式:JPG, PNG, JPEG等常见格式</p>
</div>
</div>
</>
)}
{/* PhotoRoom 配置 */}
{rmbgConfig.provider === "photoroom" && (
<>
<div className="font-medium text-muted-foreground text-sm">
PhotoRoom 配置
</div>
<div className="space-y-2">
<Label htmlFor="photoroomApiKey">API密钥 *</Label>
<Input
id="photoroomApiKey"
onChange={(e) =>
setRmbgConfig({
...rmbgConfig,
photoroomApiKey: e.target.value,
})
}
placeholder="输入PhotoRoom API密钥"
type="password"
value={rmbgConfig.photoroomApiKey}
/>
<div className="text-muted-foreground text-xs">
在 PhotoRoom 开发者平台获取API密钥
</div>
</div>
<div className="space-y-2 rounded-lg border border-green-200 bg-green-50 p-4">
<div className="font-medium text-green-900 text-sm">
PhotoRoom 说明
</div>
<div className="space-y-1 text-green-800 text-xs">
<p>• 使用 PhotoRoom 云服务进行高清抠图</p>
<p>• 支持 HD 高清背景移除,效果更好</p>
<p>• API文档:https://www.photoroom.com/api</p>
<p>• 按调用次数计费,请注意用量</p>
</div>
</div>
</>
)}
{/* AOS 配置 */}
{rmbgConfig.provider === "aoscdn" && (
<>
<div className="font-medium text-muted-foreground text-sm">
AOS Segmentation 配置
</div>
<div className="space-y-2">
<Label htmlFor="aosApiKey">API密钥 *</Label>
<Input
id="aosApiKey"
onChange={(e) =>
setRmbgConfig({
...rmbgConfig,
aosApiKey: e.target.value,
})
}
placeholder="输入 AOS X-API-KEY"
type="password"
value={rmbgConfig.aosApiKey}
/>
</div>
<div className="space-y-2">
<Label htmlFor="aosApiBaseUrl">API地址</Label>
<Input
id="aosApiBaseUrl"
onChange={(e) =>
setRmbgConfig({
...rmbgConfig,
aosApiBaseUrl: e.target.value,
})
}
placeholder="https://techsz.aoscdn.com"
value={rmbgConfig.aosApiBaseUrl}
/>
<div className="text-muted-foreground text-xs">
默认使用官方地址,可按需替换。
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>前景类型</Label>
<Select
onValueChange={(
value: "auto" | "person" | "object" | "stamp"
) =>
setRmbgConfig({
...rmbgConfig,
aosForegroundType: value,
})
}
value={rmbgConfig.aosForegroundType}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">自动识别</SelectItem>
<SelectItem value="person">人物</SelectItem>
<SelectItem value="object">物体</SelectItem>
<SelectItem value="stamp">图章</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>输出格式</Label>
<Select
onValueChange={(value: "png" | "jpg") =>
setRmbgConfig({ ...rmbgConfig, aosFormat: value })
}
value={rmbgConfig.aosFormat}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="png">PNG(透明背景)</SelectItem>
<SelectItem value="jpg">JPG(白底)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-sm">裁剪到主体边缘</Label>
<div className="text-muted-foreground text-xs">
关闭时保持原图尺寸,开启时按主体区域裁剪。
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={rmbgConfig.aosCrop}
onCheckedChange={(checked) =>
setRmbgConfig({ ...rmbgConfig, aosCrop: checked })
}
/>
<span className="min-w-[3rem] font-medium text-muted-foreground text-sm">
{rmbgConfig.aosCrop ? "已开启" : "已关闭"}
</span>
</div>
</div>
<div className="space-y-2 rounded-lg border border-indigo-200 bg-indigo-50 p-4">
<div className="font-medium text-indigo-900 text-sm">
AOS 说明
</div>
<div className="space-y-1 text-indigo-800 text-xs">
<p>• 使用异步任务模式(1秒轮询,最多30秒)</p>
<p>• 接口返回链接有效期1小时,系统会自动转存到你的存储桶</p>
<p>• 官方限制:图片最大20MB,分辨率最大4096x4096</p>
</div>
</div>
</>
)}
<Button onClick={handleSaveRmbgConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
</Button>
</LocalAiSectionShell>
{/* 变清晰API配置 */}
<LocalAiSectionShell
description={
<>
配置图片变清晰API,用于画图标注功能中的图片增强处理。支持主API和备用API切换。
</>
}
expanded={isLocalAiSectionExpanded("enhance")}
id="local-ai-section-enhance"
onToggle={() => toggleLocalAiSection("enhance")}
summary={
<LocalAiSectionSummary
items={[
{
label: enhanceConfig.useBackupApi
? "备用 Real-ESRGAN"
: "主 API",
tone: "neutral",
},
{
label:
enhanceConfig.apiKey || enhanceConfig.backupApiKey
? "Key 已配置"
: "Key 待配置",
tone:
enhanceConfig.apiKey || enhanceConfig.backupApiKey
? "enabled"
: "warning",
},
]}
/>
}
title="变清晰API配置"
>
{/* API切换开关 */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label className="text-base">使用备用API (Real-ESRGAN)</Label>
<div className="text-muted-foreground text-sm">
启用后将使用Real-ESRGAN本地模型进行图片增强,效果更好
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={enhanceConfig.useBackupApi}
onCheckedChange={(checked) =>
setEnhanceConfig({
...enhanceConfig,
useBackupApi: checked,
})
}
/>
<span className="min-w-[3rem] font-medium text-muted-foreground text-sm">
{enhanceConfig.useBackupApi ? "已开启" : "已关闭"}
</span>
</div>
</div>
{/* 主API配置 */}
{!enhanceConfig.useBackupApi && (
<>
<Separator />
<div className="font-medium text-muted-foreground text-sm">
主API配置 (第三方云服务)
</div>
<div className="space-y-2">
<Label htmlFor="enhanceApiUrl">API地址 *</Label>
<Input
id="enhanceApiUrl"
onChange={(e) =>
setEnhanceConfig({
...enhanceConfig,
apiUrl: e.target.value,
})
}
placeholder="https://techsz.aoscdn.com"
value={enhanceConfig.apiUrl}
/>
<div className="text-muted-foreground text-xs">
请输入完整的API地址,例如: https://techsz.aoscdn.com
</div>
</div>
<div className="space-y-2">
<Label htmlFor="enhanceApiKey">API密钥 *</Label>
<Input
id="enhanceApiKey"
onChange={(e) =>
setEnhanceConfig({
...enhanceConfig,
apiKey: e.target.value,
})
}
placeholder="请输入API密钥"
type="password"
value={enhanceConfig.apiKey}
/>
<div className="text-muted-foreground text-xs">
API密钥是必需的,请从服务提供商获取
</div>
</div>
<div className="space-y-2">
<Label htmlFor="enhanceType">修复类型</Label>
<Select
onValueChange={(value) =>
setEnhanceConfig({ ...enhanceConfig, type: value })
}
value={enhanceConfig.type}
>
<SelectTrigger id="enhanceType">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="clean">通用放大清晰化</SelectItem>
<SelectItem value="face">人像放大清晰化</SelectItem>
</SelectContent>
</Select>
<div className="text-muted-foreground text-xs">
选择图片类型:通用或人像(人像模式最多同时处理10个人)
</div>
</div>
<div className="space-y-2">
<Label htmlFor="enhanceScaleFactor">放大倍数(可选)</Label>
<Select
onValueChange={(value) =>
setEnhanceConfig({
...enhanceConfig,
scaleFactor: value,
})
}
value={enhanceConfig.scaleFactor}
>
<SelectTrigger id="enhanceScaleFactor">
<SelectValue placeholder="自动缩放" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">自动缩放</SelectItem>
<SelectItem value="1">不放大</SelectItem>
<SelectItem value="2">2倍放大</SelectItem>
<SelectItem value="4">4倍放大</SelectItem>
</SelectContent>
</Select>
<div className="text-muted-foreground text-xs">
选择放大倍数:自动(根据图片尺寸智能选择)、不放大、2倍或4倍
</div>
</div>
<div className="space-y-2 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="font-medium text-blue-900 text-sm">
主API文档说明
</div>
<div className="space-y-1 text-blue-800 text-xs">
<p>
• API地址应指向服务根地址,例如: https://techsz.aoscdn.com
</p>
<p>
• 系统会自动调用 /api/tasks/visual/scale 接口进行变清晰处理
</p>
<p>• 支持的图片格式:JPG, JPEG, PNG, BMP, WEBP, TIFF等</p>
<p>
• 图片大小:不超过50MB,不限制分辨率(建议按业务需求控制尺寸)
</p>
<p>• 返回的链接有效期为1小时,系统会自动下载并存储到S3</p>
</div>
</div>
</>
)}
{/* 备用API配置 (Real-ESRGAN) */}
{enhanceConfig.useBackupApi && (
<>
<Separator />
<div className="font-medium text-muted-foreground text-sm">
备用API配置 (Real-ESRGAN 本地模型)
</div>
<div className="space-y-2">
<Label htmlFor="backupApiUrl">API地址 *</Label>
<Input
id="backupApiUrl"
onChange={(e) =>
setEnhanceConfig({
...enhanceConfig,
backupApiUrl: e.target.value,
})
}
placeholder="http://156.225.27.52:5233"
value={enhanceConfig.backupApiUrl}
/>
<div className="text-muted-foreground text-xs">
Real-ESRGAN API服务地址,默认: http://156.225.27.52:5233
</div>
</div>
<div className="space-y-2">
<Label htmlFor="backupApiKey">API密钥 *</Label>
<Input
id="backupApiKey"
onChange={(e) =>
setEnhanceConfig({
...enhanceConfig,
backupApiKey: e.target.value,
})
}
placeholder="请输入Real-ESRGAN API密钥"
type="password"
value={enhanceConfig.backupApiKey}
/>
<div className="text-muted-foreground text-xs">
API密钥用于认证,请从管理员处获取
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="backupScale">放大倍数</Label>
<Select
onValueChange={(value) =>
setEnhanceConfig({
...enhanceConfig,
backupScale: value,
})
}
value={enhanceConfig.backupScale}
>
<SelectTrigger id="backupScale">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="2">2倍放大</SelectItem>
<SelectItem value="4">4倍放大(推荐)</SelectItem>
<SelectItem value="8">8倍放大</SelectItem>
</SelectContent>
</Select>
<div className="text-muted-foreground text-xs">
推荐使用4倍放大,效果最佳
</div>
</div>
<div className="space-y-2">
<Label htmlFor="backupModel">模型选择</Label>
<Select
onValueChange={(value) =>
setEnhanceConfig({
...enhanceConfig,
backupModel: value,
})
}
value={enhanceConfig.backupModel}
>
<SelectTrigger id="backupModel">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="RealESRGAN_x2plus">
RealESRGAN x2plus (2倍)
</SelectItem>
<SelectItem value="RealESRGAN_x4plus">
RealESRGAN x4plus (推荐)
</SelectItem>
<SelectItem value="RealESRGAN_x8plus">
RealESRGAN x8plus (8倍)
</SelectItem>
<SelectItem value="RealESRGAN_animevideov3">
动漫视频模型 (4倍)
</SelectItem>
</SelectContent>
</Select>
<div className="text-muted-foreground text-xs">
不同模型适用于不同场景
</div>
</div>
</div>
<div className="space-y-2 rounded-lg border border-green-200 bg-green-50 p-4">
<div className="font-medium text-green-900 text-sm">
Real-ESRGAN API说明
</div>
<div className="space-y-1 text-green-800 text-xs">
<p>• 基于Real-ESRGAN深度学习模型,图像超分辨率效果更好</p>
<p>• 使用URL方式上传图片,处理后自动保存到S3存储</p>
<p>• 支持2x、4x、8x放大倍数</p>
<p>• 处理时间:通常1-10秒,取决于图片大小</p>
<p>• 支持格式:JPG, PNG, JPEG等常见图片格式</p>
<p>• 文件限制:最大50MB</p>
</div>
</div>
</>
)}
<Button onClick={handleSaveEnhanceConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
</Button>
</LocalAiSectionShell>
<LocalAiSectionShell
description={
<>配置 Krea Topaz Generative,用于无限画布中的“图像增强 Pro”能力。</>
}
expanded={isLocalAiSectionExpanded("enhance-pro")}
id="local-ai-section-enhance-pro"
onToggle={() => toggleLocalAiSection("enhance-pro")}
summary={
<LocalAiSectionSummary
items={[
{
label: enhanceProConfig.enabled ? "功能启用" : "功能关闭",
tone: enhanceProConfig.enabled ? "enabled" : "disabled",
},
{
label: `${enhanceProConfig.apiKeys.length} 个 Key`,
tone:
enhanceProConfig.apiKey || enhanceProConfig.apiKeys.length > 0
? "enabled"
: "warning",
},
{
label:
enhanceProConfig.defaultModel ||
ENHANCE_PRO_KREA_DEFAULT_MODEL,
tone: "neutral",
},
]}
/>
}
title="图像增强 Pro 配置"
>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label className="text-base">启用图像增强 Pro</Label>
<div className="text-muted-foreground text-sm">
关闭后无限画布仍保留普通“高清增强”,但不再开放 Krea Topaz
Generative 参数面板。
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={enhanceProConfig.enabled}
onCheckedChange={(checked) =>
setEnhanceProConfig((prev) => ({
...prev,
enabled: checked,
}))
}
/>
<span className="min-w-[3rem] font-medium text-muted-foreground text-sm">
{enhanceProConfig.enabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="enhanceProProviderName">显示名称</Label>
<Input
id="enhanceProProviderName"
onChange={(e) =>
setEnhanceProConfig((prev) => ({
...prev,
providerName: e.target.value,
}))
}
placeholder="Krea Topaz Generative"
value={enhanceProConfig.providerName}
/>
</div>
<div className="space-y-2">
<Label htmlFor="enhanceProBaseUrl">API地址</Label>
<Input
id="enhanceProBaseUrl"
onChange={(e) =>
setEnhanceProConfig((prev) => ({
...prev,
baseUrl: e.target.value,
}))
}
placeholder={ENHANCE_PRO_KREA_DEFAULT_BASE_URL}
value={enhanceProConfig.baseUrl}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="enhanceProApiKey">API Key</Label>
<div className="flex flex-col gap-2 md:flex-row">
<Input
id="enhanceProApiKey"
onChange={(e) =>
setEnhanceProConfig((prev) => ({
...prev,
apiKey: e.target.value,
}))
}
placeholder="请输入 Krea Bearer Token"
type="password"
value={enhanceProConfig.apiKey}
/>
<Button
onClick={handleAddEnhanceProPoolKey}
type="button"
variant="outline"
>
<Plus className="mr-2 h-4 w-4" />
添加为新 Key
</Button>
</div>
<div className="text-muted-foreground text-xs">
和 Tripo3D 一样,单个 Key
可以直接加入池;批量导入仍支持一次粘贴多行。
</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>
<div className="font-medium text-sm">Krea Key 池</div>
<div className="text-muted-foreground text-xs">
当前共 {enhanceProConfig.apiKeys.length} 个 Key
{enhanceProApiKeysDraft.trim()
? `,本次保存会替换为 ${enhanceProApiKeysDraftCount} 个 Key`
: ",留空保存不会覆盖已保存池"}
。增强 Pro 任务会轮询使用可用 Key。
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
disabled={!enhanceProApiKeysDraft.trim()}
onClick={() => setEnhanceProApiKeysDraft("")}
size="sm"
type="button"
variant="ghost"
>
清空输入
</Button>
<Button
disabled={enhanceProConfig.apiKeys.length === 0}
onClick={() => {
setEnhanceProConfig((prev) => ({
...prev,
apiKeys: [],
}));
setEnhanceProApiKeysDraft("");
}}
size="sm"
type="button"
variant="outline"
>
清空已保存 Key 池
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="enhanceProApiKeys">批量导入 Key</Label>
<Textarea
id="enhanceProApiKeys"
onChange={(e) => setEnhanceProApiKeysDraft(e.target.value)}
placeholder="为安全起见不回显已保存 Key;每行一个新 Krea Bearer Token,保存后会替换当前 Key 池。留空保存则保持当前池不变。"
rows={4}
value={enhanceProApiKeysDraft}
/>
<div className="text-muted-foreground text-xs">
异步查询会绑定回创建任务时使用的 Key,避免轮询切换 Key
后查不到任务。
</div>
</div>
<div className="space-y-3">
{draftEnhanceProKeyRows.length ? (
<div className="rounded-lg border border-emerald-200 bg-emerald-50/70 p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="font-medium text-emerald-900 text-sm">
待保存 Key 预览
</div>
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-emerald-700 text-xs">
{draftEnhanceProKeyRows.length} 个
</span>
</div>
<div className="space-y-2">
{draftEnhanceProKeyRows.map((item: EnhanceProKeyRow) => (
<div
className="flex flex-col gap-2 rounded-lg border bg-background p-3 lg:flex-row lg:items-center lg:justify-between"
key={item.id}
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium">{item.name}</div>
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-emerald-600 text-xs">
待保存
</span>
</div>
<div className="font-mono text-muted-foreground text-xs">
Key: {item.maskedKey}
</div>
<div className="text-muted-foreground text-xs">
API:{" "}
{enhanceProConfig.baseUrl ||
ENHANCE_PRO_KREA_DEFAULT_BASE_URL}
</div>
</div>
</div>
))}
</div>
</div>
) : null}
{savedEnhanceProKeyRows.length ? (
savedEnhanceProKeyRows.map((item: EnhanceProKeyRow) => (
<div
className="flex flex-col gap-4 rounded-lg border p-4 lg:flex-row lg:items-center lg:justify-between"
key={item.id}
>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium">{item.name}</div>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs",
enhanceProConfig.enabled
? "bg-emerald-500/10 text-emerald-600"
: "bg-zinc-200 text-zinc-600"
)}
>
{enhanceProConfig.enabled ? "已启用" : "已禁用"}
</span>
</div>
<div className="font-mono text-muted-foreground text-xs">
Key: {item.maskedKey}
</div>
<div className="text-muted-foreground text-xs">
API:{" "}
{enhanceProConfig.baseUrl ||
ENHANCE_PRO_KREA_DEFAULT_BASE_URL}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() => {
if (typeof item.index === "number") {
handleDeleteEnhanceProPoolKey(item.index);
}
}}
size="sm"
type="button"
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">
当前还没有导入任何 Krea Key。
</div>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="enhanceProDefaultModel">默认模型</Label>
<Select
onValueChange={(value) =>
setEnhanceProConfig((prev) => ({
...prev,
defaultModel: value as EnhanceProKreaModel,
}))
}
value={enhanceProConfig.defaultModel}
>
<SelectTrigger id="enhanceProDefaultModel">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ENHANCE_PRO_KREA_MODELS.map((model: EnhanceProKreaModel) => {
const isHidden =
enhanceProConfig.hiddenModels.includes(model);
return (
<SelectItem disabled={isHidden} key={model} value={model}>
{model}
{isHidden ? "(已隐藏)" : ""}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="enhanceProDefaultOutputFormat">默认输出格式</Label>
<Select
onValueChange={(value) =>
setEnhanceProConfig((prev) => ({
...prev,
defaultOutputFormat: value as "png" | "jpg" | "webp",
}))
}
value={enhanceProConfig.defaultOutputFormat}
>
<SelectTrigger id="enhanceProDefaultOutputFormat">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="jpg">JPG</SelectItem>
<SelectItem value="png">PNG</SelectItem>
<SelectItem value="webp">WEBP</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-3 rounded-lg border p-4">
<div className="space-y-1">
<Label className="text-sm">前台可见模型</Label>
<div className="text-muted-foreground text-xs">
关闭后用户端不会展示该模型,旧客户端继续请求时服务端也会直接拒绝,适合临时屏蔽异常模型。
</div>
</div>
<div className="grid gap-3 md:grid-cols-4">
{ENHANCE_PRO_KREA_MODELS.map((model: EnhanceProKreaModel) => {
const isVisible = !enhanceProConfig.hiddenModels.includes(model);
const visibleCount = ENHANCE_PRO_KREA_MODELS.filter(
(item: EnhanceProKreaModel) =>
!enhanceProConfig.hiddenModels.includes(item)
).length;
return (
<div
className="flex items-center justify-between rounded-lg border px-3 py-2"
key={model}
>
<div className="space-y-0.5">
<div className="font-medium text-sm">{model}</div>
<div className="text-muted-foreground text-xs">
{isVisible ? "用户可选" : "已隐藏"}
</div>
</div>
<Switch
checked={isVisible}
disabled={isVisible && visibleCount <= 1}
onCheckedChange={(checked) =>
handleToggleEnhanceProModelVisible(model, checked)
}
/>
</div>
);
})}
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="enhanceProMaxBatchSize">最大候选图数量</Label>
<Select
onValueChange={(value) => {
const nextMaxBatchSize = Number.parseInt(value, 10) || 1;
setEnhanceProConfig((prev) => ({
...prev,
maxBatchSize: nextMaxBatchSize,
presets: prev.presets.map((preset: EnhanceProPreset) => ({
...preset,
batchSize: Math.min(
Math.max(1, preset.batchSize || 1),
nextMaxBatchSize
),
})),
}));
}}
value={String(enhanceProConfig.maxBatchSize)}
>
<SelectTrigger id="enhanceProMaxBatchSize">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">1 张</SelectItem>
<SelectItem value="2">2 张</SelectItem>
<SelectItem value="3">3 张</SelectItem>
<SelectItem value="4">4 张</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="enhanceProMaxUpscaleFactor">最大放大倍数</Label>
<Input
id="enhanceProMaxUpscaleFactor"
max="32"
min="1"
onChange={(e) => {
const nextMaxUpscaleFactor = Math.min(
32,
Math.max(1, Number.parseFloat(e.target.value) || 4)
);
setEnhanceProConfig((prev) => ({
...prev,
maxUpscaleFactor: nextMaxUpscaleFactor,
presets: prev.presets.map((preset: EnhanceProPreset) => ({
...preset,
imageScalingFactor: Math.min(
Math.max(1, preset.imageScalingFactor || 1),
nextMaxUpscaleFactor
),
})),
}));
}}
step="0.5"
type="number"
value={enhanceProConfig.maxUpscaleFactor}
/>
<p className="text-muted-foreground text-xs">
默认建议 2-4 倍;需要时仍可放宽到 Krea 支持的更高倍数。
</p>
</div>
<div className="space-y-2">
<Label htmlFor="enhanceProMaxOutputEdge">最大输出长边</Label>
<Input
id="enhanceProMaxOutputEdge"
max="16384"
min="512"
onChange={(e) =>
setEnhanceProConfig((prev) => ({
...prev,
maxOutputEdge: Number.parseInt(e.target.value, 10) || 4096,
}))
}
step="1"
type="number"
value={enhanceProConfig.maxOutputEdge}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5 pr-4">
<Label className="text-sm">允许返回多张候选图</Label>
<div className="text-muted-foreground text-xs">
开启后用户可在画布里选择 1-4
张候选结果,系统会按数量生成多个新节点。
</div>
</div>
<Switch
checked={enhanceProConfig.allowBatchSize}
onCheckedChange={(checked) =>
setEnhanceProConfig((prev) => ({
...prev,
allowBatchSize: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5 pr-4">
<Label className="text-sm">开放高级参数</Label>
<div className="text-muted-foreground text-xs">
控制是否在画布对话框中展示 creativity、texture、denoise
等高级调节项。
</div>
</div>
<Switch
checked={enhanceProConfig.enableAdvancedControls}
onCheckedChange={(checked) =>
setEnhanceProConfig((prev) => ({
...prev,
enableAdvancedControls: checked,
}))
}
/>
</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">
<Label className="text-base">增强预设</Label>
<div className="text-muted-foreground text-sm">
管理员可以预设一组适合不同场景的默认参数,画布用户打开“图像增强
Pro”时可直接选择。
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
onClick={handleResetEnhanceProPresets}
size="sm"
type="button"
variant="outline"
>
<RefreshCw className="mr-2 h-4 w-4" />
恢复默认预设
</Button>
<Button
onClick={handleAddEnhanceProPreset}
size="sm"
type="button"
variant="outline"
>
<Plus className="mr-2 h-4 w-4" />
添加预设
</Button>
</div>
</div>
<Tabs
className="space-y-4"
onValueChange={(value) =>
setEnhanceProPresetEditorMode(value as "simple" | "advanced")
}
value={enhanceProPresetEditorMode}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="simple">简化模式</TabsTrigger>
<TabsTrigger value="advanced">完整模式</TabsTrigger>
</TabsList>
<TabsContent className="space-y-4" value="simple">
<div className="grid gap-4 xl:grid-cols-[280px_minmax(0,1fr)]">
<div className="space-y-3 rounded-xl border bg-slate-50/60 p-3">
<div className="space-y-1">
<div className="font-medium text-sm">预设列表</div>
<div className="text-muted-foreground text-xs">
拖拽、上下移动或点击卡片切换当前编辑预设。
</div>
</div>
<div className="space-y-2">
{enhanceProConfig.presets.map(
(preset: EnhanceProPreset, index: number) => {
const isSelected =
preset.id === selectedEnhanceProPresetId;
const isDragging =
draggingEnhanceProPresetId === preset.id;
return (
<div
className={`flex items-center gap-2 rounded-xl border p-2 transition ${
isSelected
? "border-sky-400 bg-sky-50"
: "border-slate-200 bg-white"
} ${isDragging ? "opacity-60" : ""}`}
draggable={enhanceProConfig.presets.length > 1}
key={preset.id}
onDragEnd={handleEnhanceProPresetDragEnd}
onDragOver={(event) => event.preventDefault()}
onDragStart={(event) =>
handleEnhanceProPresetDragStart(event, preset.id)
}
onDrop={(event) =>
handleEnhanceProPresetDrop(event, preset.id)
}
>
<div className="rounded-lg border bg-white p-2 text-slate-500">
<Menu className="h-4 w-4" />
</div>
<button
className="min-w-0 flex-1 text-left"
onClick={() =>
setSelectedEnhanceProPresetId(preset.id)
}
type="button"
>
<div className="flex items-start justify-between gap-2">
<span className="min-w-0 flex-1 truncate font-medium text-sm">
{preset.name || `预设 ${index + 1}`}
</span>
{preset.isDefault ? (
<span className="shrink-0 rounded-full bg-slate-900 px-2 py-0.5 text-[11px] text-white">
默认
</span>
) : null}
</div>
<div className="mt-1 truncate text-muted-foreground text-xs">
{preset.description ||
getEnhanceProPresetSummary(preset)}
</div>
</button>
<div className="flex items-center gap-1">
<Button
disabled={index === 0}
onClick={() =>
handleMoveEnhanceProPreset(index, index - 1)
}
size="icon"
type="button"
variant="ghost"
>
<ArrowUp className="h-4 w-4" />
</Button>
<Button
disabled={
index === enhanceProConfig.presets.length - 1
}
onClick={() =>
handleMoveEnhanceProPreset(index, index + 1)
}
size="icon"
type="button"
variant="ghost"
>
<ArrowDown className="h-4 w-4" />
</Button>
</div>
</div>
);
}
)}
</div>
</div>
<div className="space-y-3">
<div className="rounded-xl border border-dashed p-3 text-muted-foreground text-sm">
当前只展开一个预设,适合日常配置和快速调参。
</div>
{selectedEnhanceProPreset ? (
renderEnhanceProPresetEditor(
selectedEnhanceProPreset,
selectedEnhanceProPresetIndex
)
) : (
<div className="rounded-xl border border-dashed p-6 text-muted-foreground text-sm">
暂无可编辑预设,请先添加一个预设。
</div>
)}
</div>
</div>
</TabsContent>
<TabsContent className="space-y-4" value="advanced">
<div className="rounded-xl border border-dashed p-3 text-muted-foreground text-sm">
完整模式会一次展开全部预设,适合集中对比和批量调整。
</div>
<div className="space-y-4">
{enhanceProConfig.presets.map(
(preset: EnhanceProPreset, index: number) =>
renderEnhanceProPresetEditor(preset, index, true)
)}
</div>
</TabsContent>
</Tabs>
</div>
<div className="space-y-2 rounded-lg border border-amber-200 bg-amber-50 p-4">
<div className="font-medium text-amber-900 text-sm">
Krea Topaz Generative 说明
</div>
<div className="space-y-1 text-amber-800 text-xs">
<p>
• 使用异步 Job
模式,服务端会自动短轮询并在完成后转存到你的存储桶。
</p>
<p>
• 无限画布将向用户开放 width / height / model / prompt / subject
detection / face enhancement / upscaling 等参数。
</p>
<p>• 如果启用多候选结果,用户一次可回收多张增强图到画布。</p>
<p>• 建议控制最大输出长边与候选图数量,避免时延和成本过高。</p>
</div>
</div>
<Button onClick={handleSaveEnhanceProConfig}>
<Save className="mr-2 h-4 w-4" /> 保存图像增强 Pro 配置
</Button>
</LocalAiSectionShell>
<LocalAiSectionShell
description={
<>
配置无限画布“AI洗图”子页面使用的生图模型与提示词。前台会基于同一张原图并行生成多组洗图结果,并支持拖动对比。
</>
}
expanded={isLocalAiSectionExpanded("ai-wash")}
id="local-ai-section-ai-wash"
onToggle={() => toggleLocalAiSection("ai-wash")}
summary={
<LocalAiSectionSummary
items={[
{
label: aiWashConfig.enabled ? "功能启用" : "功能关闭",
tone: aiWashConfig.enabled ? "enabled" : "disabled",
},
{
label: `${aiWashConfig.presets.filter((preset) => preset.enabled).length} 个启用预设`,
tone:
aiWashConfig.presets.some(
(preset) =>
preset.enabled && preset.modelId.trim() && preset.prompt.trim()
)
? "enabled"
: "warning",
},
{
label: aiWashConfig.modeLabel || "AI洗图",
tone: "neutral",
},
{
label: aiWashConfig.hideModelSelector
? "前台隐藏模型"
: "前台可选模型",
tone: aiWashConfig.hideModelSelector ? "enabled" : "neutral",
},
]}
/>
}
title="AI洗图配置"
>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label className="text-base">启用 AI洗图</Label>
<div className="text-muted-foreground text-sm">
开启后,图像增强模式顶部会新增一个“AI洗图”入口,用户可上传底图并批量对比洗图结果。
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={aiWashConfig.enabled}
onCheckedChange={(checked) =>
setAiWashConfig((prev) => ({
...prev,
enabled: checked,
}))
}
/>
<span className="min-w-[3rem] font-medium text-muted-foreground text-sm">
{aiWashConfig.enabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="space-y-3 rounded-xl border bg-muted/10 p-4 md:col-span-2">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="font-medium text-sm">前台入口名称与图标</div>
<div className="text-muted-foreground text-xs">
会同步到图像增强顶部的 AI洗图模式按钮,也会用于洗图工作区标题。
</div>
</div>
<div className="flex items-center gap-1">
<Button
className="h-8"
disabled={aiWashModeIconUploading}
onClick={() =>
document.getElementById("ai-wash-mode-icon-upload")?.click()
}
size="sm"
type="button"
variant="outline"
>
{aiWashModeIconUploading ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
上传中
</>
) : (
<>
<Upload className="mr-1.5 h-3.5 w-3.5" />
上传 SVG
</>
)}
</Button>
{aiWashConfig.modeIconUrl ? (
<Button
className="h-8 px-2 text-muted-foreground"
onClick={() =>
setAiWashConfig((prev) => ({
...prev,
modeIconUrl: "",
}))
}
size="sm"
type="button"
variant="ghost"
>
清除
</Button>
) : null}
</div>
<input
accept=".svg,image/svg+xml"
className="hidden"
id="ai-wash-mode-icon-upload"
onChange={async (event) => {
const file = event.target.files?.[0];
if (file) {
await handleAiWashModeIconUpload(file);
}
event.target.value = "";
}}
type="file"
/>
</div>
<div className="grid gap-3 lg:grid-cols-[1.1fr_0.9fr]">
<div className="space-y-2">
<Label htmlFor="aiWashModeLabel">前台入口名称</Label>
<Input
id="aiWashModeLabel"
onChange={(event) =>
setAiWashConfig((prev) => ({
...prev,
modeLabel: event.target.value,
}))
}
placeholder="AI洗图"
value={aiWashConfig.modeLabel}
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground text-xs">图标预览</Label>
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex h-10 items-center gap-2 rounded-[18px] bg-[#111111] px-3 font-medium text-white text-xs">
<CanvasToolbarIcon
active
className="h-4 w-4"
fallback={Sparkles}
iconUrl={aiWashConfig.modeIconUrl}
/>
{aiWashConfig.modeLabel || "AI洗图"}
</div>
<div className="inline-flex h-10 items-center gap-2 rounded-[18px] border bg-white px-3 font-medium text-[#666666] text-xs">
<CanvasToolbarIcon
className="h-4 w-4"
fallback={Sparkles}
iconUrl={aiWashConfig.modeIconUrl}
/>
{aiWashConfig.modeLabel || "AI洗图"}
</div>
</div>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="aiWashImageSize">输出档位</Label>
<Select
onValueChange={(value) =>
setAiWashConfig((prev) => ({
...prev,
imageSize: value,
}))
}
value={aiWashConfig.imageSize}
>
<SelectTrigger id="aiWashImageSize">
<SelectValue placeholder="选择出图档位" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1K">1K</SelectItem>
<SelectItem value="2K">2K</SelectItem>
<SelectItem value="3K">3K</SelectItem>
<SelectItem value="4K">4K</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="aiWashHistoryLimit">历史展示条数</Label>
<Input
id="aiWashHistoryLimit"
min={1}
max={24}
onChange={(event) =>
setAiWashConfig((prev) => ({
...prev,
historyLimit: Number(event.target.value || 8),
}))
}
type="number"
value={aiWashConfig.historyLimit}
/>
</div>
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
<div>
<div className="font-medium text-sm">启用提示词扩写</div>
<div className="text-muted-foreground text-xs">
会将预设提示词交给生图供应商做额外 prompt enhance。
</div>
</div>
<Switch
checked={aiWashConfig.promptExtend}
onCheckedChange={(checked) =>
setAiWashConfig((prev) => ({
...prev,
promptExtend: checked,
}))
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border px-4 py-3">
<div>
<div className="font-medium text-sm">隐藏前台生图模型选择</div>
<div className="text-muted-foreground text-xs">
开启后,AI洗图页面不再显示生图模型下拉框,直接使用后台预设或默认模型。
</div>
</div>
<Switch
checked={aiWashConfig.hideModelSelector}
onCheckedChange={(checked) =>
setAiWashConfig((prev) => ({
...prev,
hideModelSelector: checked,
}))
}
/>
</div>
</div>
<div className="space-y-4 rounded-xl border p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="font-medium text-sm">洗图预设</div>
<div className="text-muted-foreground text-xs">
前台会横向展示启用中的预设结果。建议至少配置 2 到 3 个不同风格的结果卡位。
</div>
</div>
<div className="rounded-full border px-2.5 py-1 text-xs text-muted-foreground">
共 {aiWashConfig.presets.length} 个预设
</div>
</div>
<div className="space-y-4">
{aiWashConfig.presets.map((preset, index) => (
<div className="rounded-xl border p-4" key={preset.id || `ai-wash-${index}`}>
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<div className="font-medium text-sm">{preset.title || `预设 ${index + 1}`}</div>
<div className="text-muted-foreground text-xs">
这个卡位会在前台结果区展示成一张独立的可拖动对比卡片。
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
onClick={() =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) => ({
...item,
isDefault: itemIndex === index,
})),
}))
}
size="sm"
type="button"
variant={preset.isDefault ? "default" : "outline"}
>
{preset.isDefault ? "默认卡位" : "设为默认"}
</Button>
<div className="flex items-center gap-2 rounded-full border px-3 py-1.5">
<Switch
checked={preset.enabled}
onCheckedChange={(checked) =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) =>
itemIndex === index
? { ...item, enabled: checked }
: item
),
}))
}
/>
<span className="text-sm text-muted-foreground">
{preset.enabled ? "启用" : "停用"}
</span>
</div>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div className="space-y-2">
<Label htmlFor={`aiWashPresetTitle-${index}`}>预设标题</Label>
<Input
id={`aiWashPresetTitle-${index}`}
onChange={(event) =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) =>
itemIndex === index
? { ...item, title: event.target.value }
: item
),
}))
}
placeholder="例如:质感提升(细节更精准)"
value={preset.title}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`aiWashPresetModel-${index}`}>
固定模型(留空则前台可选)
</Label>
<Select
onValueChange={(value) =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) =>
itemIndex === index
? {
...item,
modelId: value === "__none__" ? "" : value,
}
: item
),
}))
}
value={preset.modelId || "__none__"}
>
<SelectTrigger id={`aiWashPresetModel-${index}`}>
<SelectValue placeholder="留空时前台允许用户选择模型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">前台用户自行选择</SelectItem>
{availableImageModels.length > 0 ? (
availableImageModels.map((model) => (
<SelectItem key={model.id} value={model.modelId}>
{model.displayName || model.modelId}
</SelectItem>
))
) : (
<SelectItem disabled value="__empty__">
暂无可用生图模型,请先到 AI 模型里启用
</SelectItem>
)}
</SelectContent>
</Select>
<div className="text-muted-foreground text-xs">
如果这里指定了模型,前台默认不再展示生图模型下拉;创造力档位里若再指定模型,会以档位模型优先。
</div>
</div>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-[0.9fr_1.1fr]">
<div className="space-y-2">
<Label htmlFor={`aiWashPresetDescription-${index}`}>结果说明</Label>
<Textarea
id={`aiWashPresetDescription-${index}`}
onChange={(event) =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) =>
itemIndex === index
? { ...item, description: event.target.value }
: item
),
}))
}
placeholder="用于前台解释这一张结果图更偏什么风格。"
rows={3}
value={preset.description}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`aiWashPresetPrompt-${index}`}>洗图提示词</Label>
<Textarea
id={`aiWashPresetPrompt-${index}`}
onChange={(event) =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) =>
itemIndex === index
? { ...item, prompt: event.target.value }
: item
),
}))
}
placeholder="描述这一张卡位的洗图方向,例如细节强化、材质提升、AI 渲染化。"
rows={6}
value={preset.prompt}
/>
</div>
</div>
<div className="mt-4 space-y-4 rounded-xl border border-dashed p-4">
<div>
<div className="font-medium text-sm">三段调节条</div>
<div className="text-muted-foreground text-xs">
前台每个模式都会显示这 3 个滑条。每一档都能单独配置,数量也可以继续扩展,不限死 3 档。
</div>
</div>
<div className="space-y-4">
{AI_WASH_CONTROL_ORDER.map((controlId) => {
const control = preset.controls.find(
(item) => item.id === controlId
);
if (!control) return null;
return (
<div className="rounded-xl border p-4" key={`${preset.id}-${control.id}`}>
<div className="mb-4 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-1">
<div className="font-medium text-sm">
{control.label || control.id}
</div>
<div className="text-muted-foreground text-xs">
{control.helperText || getAiWashControlToneCopy(control.id)}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="rounded-full border px-2.5 py-1 text-xs text-muted-foreground">
当前 {control.levels.length} 档
</div>
<Button
onClick={() =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) => {
if (itemIndex !== index) return item;
return {
...item,
controls: item.controls.map((currentControl) => {
if (currentControl.id !== control.id) {
return currentControl;
}
return {
...currentControl,
levels: [
...currentControl.levels,
{
id: `${currentControl.id}-level-${currentControl.levels.length + 1}`,
label: String(currentControl.levels.length + 1),
modelId: "",
promptFragment: "",
},
],
};
}),
};
}),
}))
}
size="sm"
type="button"
variant="outline"
>
<Plus className="mr-1 h-3.5 w-3.5" />
增加档位
</Button>
<Button
disabled={control.levels.length <= 1}
onClick={() =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) => {
if (itemIndex !== index) return item;
return {
...item,
controls: item.controls.map((currentControl) => {
if (currentControl.id !== control.id) {
return currentControl;
}
const nextLevels = currentControl.levels.slice(0, -1);
return {
...currentControl,
levels: nextLevels,
defaultLevel: Math.min(
currentControl.defaultLevel,
Math.max(0, nextLevels.length - 1)
),
};
}),
};
}),
}))
}
size="sm"
type="button"
variant="ghost"
>
删除最后一档
</Button>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-[280px_1fr]">
<div className="space-y-2">
<Label htmlFor={`aiWashControlLabel-${index}-${control.id}`}>
前台显示名
</Label>
<Input
id={`aiWashControlLabel-${index}-${control.id}`}
onChange={(event) =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) => {
if (itemIndex !== index) return item;
return {
...item,
controls: item.controls.map((currentControl) =>
currentControl.id === control.id
? {
...currentControl,
label: event.target.value,
}
: currentControl
),
};
}),
}))
}
value={control.label}
/>
<Textarea
onChange={(event) =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) => {
if (itemIndex !== index) return item;
return {
...item,
controls: item.controls.map((currentControl) =>
currentControl.id === control.id
? {
...currentControl,
helperText: event.target.value,
}
: currentControl
),
};
}),
}))
}
placeholder={getAiWashControlToneCopy(control.id)}
rows={4}
value={control.helperText}
/>
</div>
<div className="space-y-3">
{control.levels.map((level, levelIndex) => (
<div className="rounded-lg border bg-muted/20 p-3" key={level.id}>
<div className="mb-3 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<span className="rounded-full border bg-background px-2.5 py-1 text-xs font-medium">
档位 {levelIndex + 1}
</span>
<span className="text-muted-foreground text-xs">
前台滑块会把这个标签显示成当前档位值
</span>
</div>
<div className="flex items-center gap-2">
<Label
className="text-muted-foreground text-xs"
htmlFor={`aiWashControlDefault-${index}-${control.id}-${levelIndex}`}
>
默认
</Label>
<Switch
checked={control.defaultLevel === levelIndex}
onCheckedChange={(checked) => {
if (!checked) return;
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) => {
if (itemIndex !== index) return item;
return {
...item,
controls: item.controls.map((currentControl) =>
currentControl.id === control.id
? {
...currentControl,
defaultLevel: levelIndex,
}
: currentControl
),
};
}),
}));
}}
/>
</div>
</div>
<div
className={cn(
"grid gap-3",
control.kind === "model"
? "lg:grid-cols-[150px_280px_1fr]"
: "lg:grid-cols-[150px_1fr]"
)}
>
<div className="space-y-2">
<Label
htmlFor={`aiWashLevelLabel-${index}-${control.id}-${levelIndex}`}
>
档位标签
</Label>
<Input
id={`aiWashLevelLabel-${index}-${control.id}-${levelIndex}`}
onChange={(event) =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) => {
if (itemIndex !== index) return item;
return {
...item,
controls: item.controls.map((currentControl) => {
if (currentControl.id !== control.id) {
return currentControl;
}
return {
...currentControl,
levels: currentControl.levels.map(
(currentLevel, currentLevelIndex) =>
currentLevelIndex === levelIndex
? {
...currentLevel,
label: event.target.value,
}
: currentLevel
),
};
}),
};
}),
}))
}
value={level.label}
/>
</div>
{control.kind === "model" ? (
<div className="space-y-2">
<Label
htmlFor={`aiWashLevelModel-${index}-${control.id}-${levelIndex}`}
>
指定模型
</Label>
<Select
onValueChange={(value) =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) => {
if (itemIndex !== index) return item;
return {
...item,
controls: item.controls.map((currentControl) => {
if (currentControl.id !== control.id) {
return currentControl;
}
return {
...currentControl,
levels: currentControl.levels.map(
(currentLevel, currentLevelIndex) =>
currentLevelIndex === levelIndex
? {
...currentLevel,
modelId:
value === "__none__"
? ""
: value,
}
: currentLevel
),
};
}),
};
}),
}))
}
value={level.modelId || "__none__"}
>
<SelectTrigger
id={`aiWashLevelModel-${index}-${control.id}-${levelIndex}`}
>
<SelectValue placeholder="留空时走固定模型或前台用户选择" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">
不指定,允许下游继承
</SelectItem>
{availableImageModels.length > 0 ? (
availableImageModels.map((model) => (
<SelectItem
key={model.id}
value={model.modelId}
>
{model.displayName || model.modelId}
</SelectItem>
))
) : (
<SelectItem disabled value="__empty__">
暂无可用生图模型
</SelectItem>
)}
</SelectContent>
</Select>
</div>
) : null}
<div className="space-y-2">
<Label
htmlFor={`aiWashLevelPrompt-${index}-${control.id}-${levelIndex}`}
>
追加提示词
</Label>
<Textarea
id={`aiWashLevelPrompt-${index}-${control.id}-${levelIndex}`}
onChange={(event) =>
setAiWashConfig((prev) => ({
...prev,
presets: prev.presets.map((item, itemIndex) => {
if (itemIndex !== index) return item;
return {
...item,
controls: item.controls.map((currentControl) => {
if (currentControl.id !== control.id) {
return currentControl;
}
return {
...currentControl,
levels: currentControl.levels.map(
(currentLevel, currentLevelIndex) =>
currentLevelIndex === levelIndex
? {
...currentLevel,
promptFragment:
event.target.value,
}
: currentLevel
),
};
}),
};
}),
}))
}
placeholder={
control.kind === "model"
? "可选:这一档除了切换模型,还可以额外加一段提示词。"
: "例如:强化微小纹理、让边缘更锐利、增强体积光层次。"
}
rows={4}
value={level.promptFragment}
/>
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
))}
</div>
</div>
<Button onClick={handleSaveAiWashConfig}>
<Save className="mr-2 h-4 w-4" /> 保存 AI洗图配置
</Button>
</LocalAiSectionShell>
{/* 图像拓展API配置 */}
<LocalAiSectionShell
description={
<>
配置阿里云Dashscope图像拓展API,用于画图标注功能中的图像智能扩展处理
</>
}
expanded={isLocalAiSectionExpanded("extend")}
id="local-ai-section-extend"
onToggle={() => toggleLocalAiSection("extend")}
summary={
<LocalAiSectionSummary
items={[
{
label: extendConfig.apiKey ? "Key 已配置" : "Key 待配置",
tone: extendConfig.apiKey ? "enabled" : "warning",
},
{
label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`,
tone: "neutral",
},
]}
/>
}
title="图像拓展API配置"
>
<div className="space-y-2">
<Label htmlFor="extendApiKey">API密钥 *</Label>
<Input
id="extendApiKey"
onChange={(e) =>
setExtendConfig({ ...extendConfig, apiKey: e.target.value })
}
placeholder="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
type="password"
value={extendConfig.apiKey}
/>
<div className="text-muted-foreground text-xs">
请输入阿里云Dashscope API密钥,例如:
sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="extendXScale">X轴放大倍数 *</Label>
<Input
id="extendXScale"
max="2.0"
min="1.0"
onChange={(e) =>
setExtendConfig({
...extendConfig,
xScale: e.target.value,
})
}
placeholder="1.5"
step="0.1"
type="number"
value={extendConfig.xScale}
/>
<div className="text-muted-foreground text-xs">
X轴放大倍数(1.0-2.0),推荐1.5
</div>
</div>
<div className="space-y-2">
<Label htmlFor="extendYScale">Y轴放大倍数 *</Label>
<Input
id="extendYScale"
max="2.0"
min="1.0"
onChange={(e) =>
setExtendConfig({
...extendConfig,
yScale: e.target.value,
})
}
placeholder="1.5"
step="0.1"
type="number"
value={extendConfig.yScale}
/>
<div className="text-muted-foreground text-xs">
Y轴放大倍数(1.0-2.0),推荐1.5
</div>
</div>
</div>
<div className="space-y-2 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="font-medium text-blue-900 text-sm">API文档说明</div>
<div className="space-y-1 text-blue-800 text-xs">
<p>• 使用阿里云Dashscope图像拓展API(image-out-painting模型)</p>
<p>
• API端点:
https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/out-painting
</p>
<p>• 支持的图片格式:JPG, PNG, WebP等常见格式</p>
<p>• 最大图片大小:4MB</p>
<p>• 处理时间:约5-15秒/张</p>
<p>• 放大倍数范围:1.0-2.0(推荐1.5倍等比例扩展)</p>
</div>
</div>
<Button onClick={handleSaveExtendConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
</Button>
</LocalAiSectionShell>
{/* 去水印API配置 */}
<LocalAiSectionShell
description={<>配置PicWish去水印API,用于自动去除图片中的水印</>}
expanded={isLocalAiSectionExpanded("watermark-remove")}
id="local-ai-section-watermark-remove"
onToggle={() => toggleLocalAiSection("watermark-remove")}
summary={
<LocalAiSectionSummary
items={[
{
label: watermarkRemoveConfig.apiKey
? "Key 已配置"
: "Key 待配置",
tone: watermarkRemoveConfig.apiKey ? "enabled" : "warning",
},
{ label: "PicWish", tone: "neutral" },
]}
/>
}
title={
<span className="flex items-center gap-2">
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path d="M12 2.69l5.66 5.66a8 8 0 1 1-11.31 0z" />
<line x1="4" x2="20" y1="4" y2="20" />
</svg>
去水印API配置
</span>
}
>
<div className="space-y-2">
<Label htmlFor="watermarkRemoveApiKey">API密钥 *</Label>
<Input
id="watermarkRemoveApiKey"
onChange={(e) =>
setWatermarkRemoveConfig({
...watermarkRemoveConfig,
apiKey: e.target.value,
})
}
placeholder="输入PicWish API密钥"
type="password"
value={watermarkRemoveConfig.apiKey}
/>
<div className="text-muted-foreground text-xs">
在 PicWish 开发者平台获取API密钥
</div>
</div>
<div className="space-y-2 rounded-lg border border-purple-200 bg-purple-50 p-4">
<div className="font-medium text-purple-900 text-sm">
PicWish API 说明
</div>
<div className="space-y-1 text-purple-800 text-xs">
<p>• 使用 PicWish 云服务进行智能去水印</p>
<p>• 支持各种类型的水印去除(文字、图片水印)</p>
<p>• API文档:https://picwish.com/developer</p>
<p>• 处理时间:约10-30秒/张</p>
</div>
</div>
<Button onClick={handleSaveWatermarkRemoveConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
</Button>
</LocalAiSectionShell>
{/* 角度编辑API配置 */}
<LocalAiSectionShell
description={
<>配置 fal.ai 多角度图像编辑 API,用于对同一图像生成不同视角的变体</>
}
expanded={isLocalAiSectionExpanded("angle-edit")}
id="local-ai-section-angle-edit"
onToggle={() => toggleLocalAiSection("angle-edit")}
summary={
<LocalAiSectionSummary
items={[
{
label: angleEditConfig.enabled ? "功能启用" : "功能关闭",
tone: angleEditConfig.enabled ? "enabled" : "disabled",
},
{
label: angleEditConfig.falApiKey ? "Key 已配置" : "Key 待配置",
tone: angleEditConfig.falApiKey ? "enabled" : "warning",
},
{
label: `${angleEditConfig.inferenceSteps} steps`,
tone: "neutral",
},
{
label: `${pointsSystemConfig.creditCostPerImageAngleEdit || 0} 积分/次`,
tone:
pointsSystemConfig.creditCostPerImageAngleEdit > 0
? "neutral"
: "disabled",
},
]}
/>
}
title={
<span className="flex items-center gap-2">
<Move3D className="h-5 w-5" />
角度编辑API配置
</span>
}
>
{/* 启用开关 */}
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>启用角度编辑功能</Label>
<div className="text-muted-foreground text-xs">
开启后用户可在图片编辑器中使用角度编辑功能
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={angleEditConfig.enabled}
onCheckedChange={(checked) =>
setAngleEditConfig({
...angleEditConfig,
enabled: checked,
})
}
/>
<span className="min-w-[3rem] font-medium text-muted-foreground text-sm">
{angleEditConfig.enabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<Separator />
{/* API Key */}
<div className="space-y-2">
<Label htmlFor="falApiKey">
fal.ai API Key <span className="text-destructive">*</span>
</Label>
<Input
disabled={!angleEditConfig.enabled}
id="falApiKey"
onChange={(e) =>
setAngleEditConfig({
...angleEditConfig,
falApiKey: e.target.value,
})
}
placeholder="fal_xxxxxxxxxxxxx"
type="password"
value={angleEditConfig.falApiKey}
/>
<div className="text-muted-foreground text-xs">
从{" "}
<a
className="underline hover:text-primary"
href="https://fal.ai/dashboard/keys"
rel="noopener noreferrer"
target="_blank"
>
fal.ai Dashboard
</a>{" "}
获取 API Key
</div>
</div>
<Separator />
{/* 积分消耗 */}
<div className="space-y-2">
<Label htmlFor="angleEditCreditCost">积分消耗</Label>
<Input
id="angleEditCreditCost"
min={0}
onChange={(e) =>
updatePointsCostField(
"creditCostPerImageAngleEdit",
e.target.value
)
}
type="number"
value={pointsSystemConfig.creditCostPerImageAngleEdit}
/>
<div className="text-muted-foreground text-xs">
每次成功生成多角度图片后扣除的积分;填写 0 表示免费。保存本配置时会同步保存到全站积分设置。
</div>
</div>
<Separator />
{/* LoRA 强度 */}
<div className="space-y-3">
<div className="flex justify-between">
<Label>默认变换强度</Label>
<span className="text-muted-foreground text-sm">
{(angleEditConfig.loraScale * 100).toFixed(0)}%
</span>
</div>
<Input
className="w-full"
disabled={!angleEditConfig.enabled}
max={1.0}
min={0.5}
onChange={(e) =>
setAngleEditConfig({
...angleEditConfig,
loraScale: Number.parseFloat(e.target.value),
})
}
step={0.05}
type="range"
value={angleEditConfig.loraScale}
/>
<div className="text-muted-foreground text-xs">
值越高角度变化越明显,值越低保留原图特征越多(推荐 85%-95%)
</div>
</div>
<Separator />
{/* 高级设置 */}
<div className="space-y-4">
<Label className="font-medium text-base">高级设置</Label>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="inferenceSteps">推理步数</Label>
<Input
disabled={!angleEditConfig.enabled}
id="inferenceSteps"
max={50}
min={10}
onChange={(e) =>
setAngleEditConfig({
...angleEditConfig,
inferenceSteps: Number.parseInt(e.target.value) || 28,
})
}
type="number"
value={angleEditConfig.inferenceSteps}
/>
</div>
<div className="space-y-2">
<Label htmlFor="guidanceScale">引导系数</Label>
<Input
disabled={!angleEditConfig.enabled}
id="guidanceScale"
max={10}
min={1}
onChange={(e) =>
setAngleEditConfig({
...angleEditConfig,
guidanceScale: Number.parseFloat(e.target.value) || 4.5,
})
}
step={0.5}
type="number"
value={angleEditConfig.guidanceScale}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="maxImageSize">最大图片(MB)</Label>
<Input
disabled={!angleEditConfig.enabled}
id="maxImageSize"
max={50}
min={1}
onChange={(e) =>
setAngleEditConfig({
...angleEditConfig,
maxImageSize: Number.parseInt(e.target.value) || 10,
})
}
type="number"
value={angleEditConfig.maxImageSize}
/>
</div>
<div className="space-y-2">
<Label htmlFor="timeout">超时时间(秒)</Label>
<Input
disabled={!angleEditConfig.enabled}
id="timeout"
max={300}
min={30}
onChange={(e) =>
setAngleEditConfig({
...angleEditConfig,
timeout: Number.parseInt(e.target.value) || 120,
})
}
type="number"
value={angleEditConfig.timeout}
/>
</div>
</div>
</div>
<div className="space-y-2 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="font-medium text-blue-900 text-sm">功能说明</div>
<div className="space-y-1 text-blue-800 text-xs">
<p>• 使用 Qwen-Image-Edit-2511-Multiple-Angles-LoRA 模型</p>
<p>• 支持 96 种相机姿态(4 仰角 × 8 方位角 × 3 距离)</p>
<p>• 可生成正面、侧面、俯视、仰视等多角度图像</p>
<p>• 处理时间:约 10-30 秒/张</p>
<p>• 费用:约 $0.02-0.05/张(fal.ai 按量计费)</p>
</div>
</div>
<Button onClick={handleSaveAngleEditConfig}>
<Save className="mr-2 h-4 w-4" /> 保存配置
</Button>
</LocalAiSectionShell>
<LocalAiSectionShell
description={
<>
配置 Replicate 上的 Qwen-Image-Layered
模型,用于将单张图片拆分为多个可编辑图层并回写到无限画布。
</>
}
expanded={isLocalAiSectionExpanded("layer-split")}
id="local-ai-section-layer-split"
onToggle={() => toggleLocalAiSection("layer-split")}
summary={
<LocalAiSectionSummary
items={[
{
label: layerSplitConfig.enabled ? "功能启用" : "功能关闭",
tone: layerSplitConfig.enabled ? "enabled" : "disabled",
},
{
label: layerSplitConfig.replicateApiToken
? "Token 已配置"
: "Token 待配置",
tone: layerSplitConfig.replicateApiToken
? "enabled"
: "warning",
},
{ label: `${layerSplitConfig.layers} 图层`, tone: "neutral" },
]}
/>
}
title={
<span className="flex items-center gap-2">
<Layers className="h-5 w-5" />
图层拆分API配置
</span>
}
>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>启用图层拆分功能</Label>
<div className="text-muted-foreground text-xs">
开启后,用户在无限画布中选中图片可使用“图层拆分”工具。
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={layerSplitConfig.enabled}
onCheckedChange={(checked) =>
setLayerSplitConfig({
...layerSplitConfig,
enabled: checked,
})
}
/>
<span className="min-w-[3rem] font-medium text-muted-foreground text-sm">
{layerSplitConfig.enabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="layerSplitReplicateApiToken">
Replicate API Token <span className="text-destructive">*</span>
</Label>
<Input
disabled={!layerSplitConfig.enabled}
id="layerSplitReplicateApiToken"
onChange={(e) =>
setLayerSplitConfig({
...layerSplitConfig,
replicateApiToken: e.target.value,
})
}
placeholder="r8_xxxxxxxxxxxxx"
type="password"
value={layerSplitConfig.replicateApiToken}
/>
</div>
<div className="space-y-2">
<Label htmlFor="layerSplitReplicateModel">Replicate 模型</Label>
<Input
disabled={!layerSplitConfig.enabled}
id="layerSplitReplicateModel"
onChange={(e) =>
setLayerSplitConfig({
...layerSplitConfig,
replicateModel: e.target.value,
})
}
placeholder="qwen/qwen-image-layered"
value={layerSplitConfig.replicateModel}
/>
</div>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="space-y-2">
<Label htmlFor="layerSplitLayers">默认层数</Label>
<Input
disabled={!layerSplitConfig.enabled}
id="layerSplitLayers"
max={12}
min={2}
onChange={(e) =>
setLayerSplitConfig({
...layerSplitConfig,
layers: Math.max(
2,
Math.min(12, Number(e.target.value) || 4)
),
})
}
type="number"
value={layerSplitConfig.layers}
/>
</div>
<div className="space-y-2">
<Label htmlFor="layerSplitResolution">分辨率</Label>
<Input
disabled={!layerSplitConfig.enabled}
id="layerSplitResolution"
max={1536}
min={256}
onChange={(e) =>
setLayerSplitConfig({
...layerSplitConfig,
resolution: Math.max(
256,
Math.min(1536, Number(e.target.value) || 640)
),
})
}
step={64}
type="number"
value={layerSplitConfig.resolution}
/>
</div>
<div className="space-y-2">
<Label htmlFor="layerSplitCfg">true_cfg_scale</Label>
<Input
disabled={!layerSplitConfig.enabled}
id="layerSplitCfg"
max={12}
min={1}
onChange={(e) =>
setLayerSplitConfig({
...layerSplitConfig,
trueCfgScale: Math.max(
1,
Math.min(12, Number(e.target.value) || 4)
),
})
}
step={0.5}
type="number"
value={layerSplitConfig.trueCfgScale}
/>
</div>
<div className="space-y-2">
<Label htmlFor="layerSplitSteps">推理步数</Label>
<Input
disabled={!layerSplitConfig.enabled}
id="layerSplitSteps"
max={80}
min={1}
onChange={(e) =>
setLayerSplitConfig({
...layerSplitConfig,
numInferenceSteps: Math.max(
1,
Math.min(80, Number(e.target.value) || 30)
),
})
}
type="number"
value={layerSplitConfig.numInferenceSteps}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-sm">cfg_normalize</Label>
<div className="text-muted-foreground text-xs">
保持前景/背景透明边缘更稳定
</div>
</div>
<Switch
checked={layerSplitConfig.cfgNormalize}
disabled={!layerSplitConfig.enabled}
onCheckedChange={(checked) =>
setLayerSplitConfig({
...layerSplitConfig,
cfgNormalize: checked,
})
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-sm">use_en_prompt</Label>
<div className="text-muted-foreground text-xs">
使用英文提示增强兼容性
</div>
</div>
<Switch
checked={layerSplitConfig.useEnPrompt}
disabled={!layerSplitConfig.enabled}
onCheckedChange={(checked) =>
setLayerSplitConfig({
...layerSplitConfig,
useEnPrompt: checked,
})
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="layerSplitTimeout">超时时间(毫秒)</Label>
<Input
disabled={!layerSplitConfig.enabled}
id="layerSplitTimeout"
max={900_000}
min={30_000}
onChange={(e) =>
setLayerSplitConfig({
...layerSplitConfig,
timeoutMs: Math.max(
30_000,
Math.min(900_000, Number(e.target.value) || 300_000)
),
})
}
step={1000}
type="number"
value={layerSplitConfig.timeoutMs}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="layerSplitNegativePrompt">负向提示词(可选)</Label>
<Textarea
disabled={!layerSplitConfig.enabled}
id="layerSplitNegativePrompt"
onChange={(e) =>
setLayerSplitConfig({
...layerSplitConfig,
negativePrompt: e.target.value,
})
}
placeholder="例如:text, watermark, logo, artifacts"
rows={3}
value={layerSplitConfig.negativePrompt}
/>
</div>
<div className="space-y-2 rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="font-medium text-blue-900 text-sm">
Qwen-Image-Layered 说明
</div>
<div className="space-y-1 text-blue-800 text-xs">
<p>• 用途:将单张图片拆成多个独立图层(RGBA layer)</p>
<p>• 平台:Replicate 托管推理,无需自建 GPU 服务器</p>
<p>• 默认模型:qwen/qwen-image-layered</p>
<p>
• 使用后会将每个图层转存到你的存储桶,并在无限画布中新增图片节点
</p>
</div>
</div>
<Button onClick={handleSaveLayerSplitConfig}>
<Save className="mr-2 h-4 w-4" /> 保存图层拆分配置
</Button>
</LocalAiSectionShell>
</>
);
}