3069 lines
125 KiB
TypeScript
3069 lines
125 KiB
TypeScript
"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,
|
||
canvasLocalEditConfig,
|
||
setCanvasLocalEditConfig,
|
||
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,
|
||
handleSaveCanvasLocalEditConfig,
|
||
handleSaveExtendConfig,
|
||
handleSaveWatermarkRemoveConfig,
|
||
handleSaveAngleEditConfig,
|
||
handleSaveLayerSplitConfig,
|
||
}: LocalAiImagePipelineSectionsProps) {
|
||
const availableImageModels = (modelsData?.models || []).filter(
|
||
(model) => model.supportsImageGen && model.isEnabled
|
||
);
|
||
const formatAiWashModelOptionLabel = (model: (typeof availableImageModels)[number]) =>
|
||
`${model.displayName || model.modelId}${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.provider === "vip98k"
|
||
? "服务商 Key 已配置"
|
||
: rmbgConfig.apiKey ||
|
||
rmbgConfig.photoroomApiKey ||
|
||
rmbgConfig.aosApiKey
|
||
? "Key 已配置"
|
||
: "Key 待配置",
|
||
tone:
|
||
rmbgConfig.provider === "vip98k" ||
|
||
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-4">
|
||
<div
|
||
className={`relative flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-all hover:bg-accent ${
|
||
rmbgConfig.provider === "vip98k"
|
||
? "border-primary bg-accent ring-1 ring-primary"
|
||
: ""
|
||
}`}
|
||
onClick={() => setRmbgConfig({ ...rmbgConfig, provider: "vip98k" })}
|
||
>
|
||
<div
|
||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-primary ${
|
||
rmbgConfig.provider === "vip98k"
|
||
? "bg-primary text-primary-foreground"
|
||
: "bg-transparent"
|
||
}`}
|
||
>
|
||
{rmbgConfig.provider === "vip98k" && <Check className="h-3 w-3" />}
|
||
</div>
|
||
<p className="font-medium text-sm leading-none">VIP98K 统一服务</p>
|
||
</div>
|
||
<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 />
|
||
|
||
{rmbgConfig.provider === "vip98k" && (
|
||
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||
使用 VIP98K 的 remove-background 模型。API Key 从“AI 模型设置”中的
|
||
VIP98K 服务商读取,不会在此重复保存。
|
||
</div>
|
||
)}
|
||
|
||
{/* 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={
|
||
<>
|
||
配置图片变清晰服务,用于画图标注功能中的图片增强处理。
|
||
</>
|
||
}
|
||
expanded={isLocalAiSectionExpanded("enhance")}
|
||
id="local-ai-section-enhance"
|
||
onToggle={() => toggleLocalAiSection("enhance")}
|
||
summary={
|
||
<LocalAiSectionSummary
|
||
items={[
|
||
{
|
||
label: enhanceConfig.provider === "vip98k"
|
||
? "VIP98K"
|
||
: enhanceConfig.useBackupApi
|
||
? "备用 Real-ESRGAN"
|
||
: "主 API",
|
||
tone: "neutral",
|
||
},
|
||
{
|
||
label:
|
||
enhanceConfig.provider === "vip98k" ||
|
||
enhanceConfig.apiKey || enhanceConfig.backupApiKey
|
||
? "Key 已配置"
|
||
: "Key 待配置",
|
||
tone:
|
||
enhanceConfig.provider === "vip98k" ||
|
||
enhanceConfig.apiKey || enhanceConfig.backupApiKey
|
||
? "enabled"
|
||
: "warning",
|
||
},
|
||
]}
|
||
/>
|
||
}
|
||
title="变清晰API配置"
|
||
>
|
||
{enhanceConfig.provider === "vip98k" ? (
|
||
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||
使用 VIP98K 的 enhance 模型,默认 4 倍增强。API Key 从“AI 模型设置”中的
|
||
VIP98K 服务商读取。
|
||
</div>
|
||
) : (
|
||
<>
|
||
{/* 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>
|
||
• 图片大小:不超过20MB,不限制分辨率(建议按业务需求控制尺寸)
|
||
</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>• 文件限制:最大20MB</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("canvas-local-edit")}
|
||
id="local-ai-section-canvas-local-edit"
|
||
onToggle={() => toggleLocalAiSection("canvas-local-edit")}
|
||
summary={
|
||
<LocalAiSectionSummary
|
||
items={[
|
||
{
|
||
label: canvasLocalEditConfig.modelId
|
||
? "普通局部编辑已指定"
|
||
: "普通局部编辑继承默认",
|
||
tone: canvasLocalEditConfig.modelId ? "enabled" : "neutral",
|
||
},
|
||
{
|
||
label: canvasLocalEditConfig.scaleAdjustModelId
|
||
? "比例调整已指定"
|
||
: "比例调整兼容旧默认",
|
||
tone: canvasLocalEditConfig.scaleAdjustModelId
|
||
? "enabled"
|
||
: "neutral",
|
||
},
|
||
]}
|
||
/>
|
||
}
|
||
title="局部编辑模型配置"
|
||
>
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="canvasLocalEditModel">普通局部编辑默认模型</Label>
|
||
<Select
|
||
onValueChange={(value) =>
|
||
setCanvasLocalEditConfig((prev) => ({
|
||
...prev,
|
||
modelId: value === "__none__" ? "" : value,
|
||
}))
|
||
}
|
||
value={canvasLocalEditConfig.modelId || "__none__"}
|
||
>
|
||
<SelectTrigger id="canvasLocalEditModel">
|
||
<SelectValue placeholder="不指定时使用当前模型或系统默认" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="__none__">不指定,沿用当前模型/系统默认</SelectItem>
|
||
{availableImageModels.length > 0 ? (
|
||
availableImageModels.map((model) => (
|
||
<SelectItem key={model.id} value={model.modelId}>
|
||
{formatAiWashModelOptionLabel(model)}
|
||
</SelectItem>
|
||
))
|
||
) : (
|
||
<SelectItem disabled value="__empty__">
|
||
暂无可用生图模型,请先到 AI 模型里启用
|
||
</SelectItem>
|
||
)}
|
||
</SelectContent>
|
||
</Select>
|
||
<div className="text-muted-foreground text-xs">
|
||
适用于手动画框并填写提示词的局部编辑。
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label htmlFor="canvasScaleAdjustModel">比例调整/目标框模型</Label>
|
||
<Select
|
||
onValueChange={(value) =>
|
||
setCanvasLocalEditConfig((prev) => ({
|
||
...prev,
|
||
scaleAdjustModelId: value === "__none__" ? "" : value,
|
||
}))
|
||
}
|
||
value={canvasLocalEditConfig.scaleAdjustModelId || "__none__"}
|
||
>
|
||
<SelectTrigger id="canvasScaleAdjustModel">
|
||
<SelectValue placeholder="不指定时兼容旧 AI详情默认模型" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="__none__">不指定,兼容旧 AI详情默认模型</SelectItem>
|
||
{availableImageModels.length > 0 ? (
|
||
availableImageModels.map((model) => (
|
||
<SelectItem key={model.id} value={model.modelId}>
|
||
{formatAiWashModelOptionLabel(model)}
|
||
</SelectItem>
|
||
))
|
||
) : (
|
||
<SelectItem disabled value="__empty__">
|
||
暂无可用生图模型,请先到 AI 模型里启用
|
||
</SelectItem>
|
||
)}
|
||
</SelectContent>
|
||
</Select>
|
||
<div className="text-muted-foreground text-xs">
|
||
适用于截图中“局部编辑/比例”按钮,把商品缩放到青色目标框的流程。
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Button onClick={handleSaveCanvasLocalEditConfig}>
|
||
<Save className="mr-2 h-4 w-4" /> 保存局部编辑模型配置
|
||
</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}>
|
||
{formatAiWashModelOptionLabel(model)}
|
||
</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}
|
||
>
|
||
{formatAiWashModelOptionLabel(model)}
|
||
</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={
|
||
<>
|
||
配置图像拓展服务,用于画图标注功能中的图像智能扩展处理
|
||
</>
|
||
}
|
||
expanded={isLocalAiSectionExpanded("extend")}
|
||
id="local-ai-section-extend"
|
||
onToggle={() => toggleLocalAiSection("extend")}
|
||
summary={
|
||
<LocalAiSectionSummary
|
||
items={[
|
||
{
|
||
label:
|
||
extendConfig.provider === "vip98k"
|
||
? "VIP98K 服务商"
|
||
: "阿里云服务商",
|
||
tone: "enabled",
|
||
},
|
||
{
|
||
label: `${extendConfig.xScale}x / ${extendConfig.yScale}x`,
|
||
tone: "neutral",
|
||
},
|
||
]}
|
||
/>
|
||
}
|
||
title="图像拓展API配置"
|
||
>
|
||
{extendConfig.provider === "vip98k" ? (
|
||
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||
使用 VIP98K 的 image-expansion 模型。API Key 从“AI 模型设置”中的
|
||
VIP98K 服务商读取,仅在这里维护扩展比例。
|
||
</div>
|
||
) : (
|
||
<div className="rounded-lg border bg-muted/40 p-4 text-sm">
|
||
使用阿里云 DashScope 的 image-out-painting 模型。API Key
|
||
从“AI 模型设置”中已启用的阿里云官方生图服务商读取,
|
||
仅在这里维护拓展比例。
|
||
</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>• 最大图片大小:10MB</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>
|
||
</>
|
||
);
|
||
}
|