feat: add configurable ai wash workspace
This commit is contained in:
parent
de91bd396f
commit
1c7d005088
@ -5,13 +5,17 @@ import {
|
||||
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";
|
||||
@ -26,6 +30,10 @@ 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,
|
||||
@ -45,6 +53,7 @@ import {
|
||||
} from "../local-ai-section-shell";
|
||||
|
||||
export function LocalAiImagePipelineSections({
|
||||
modelsData,
|
||||
rmbgConfig,
|
||||
setRmbgConfig,
|
||||
enhanceConfig,
|
||||
@ -63,6 +72,9 @@ export function LocalAiImagePipelineSections({
|
||||
selectedEnhanceProPreset,
|
||||
selectedEnhanceProPresetIndex,
|
||||
draggingEnhanceProPresetId,
|
||||
aiWashConfig,
|
||||
setAiWashConfig,
|
||||
aiWashModeIconUploading,
|
||||
extendConfig,
|
||||
setExtendConfig,
|
||||
watermarkRemoveConfig,
|
||||
@ -87,11 +99,26 @@ export function LocalAiImagePipelineSections({
|
||||
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配置 */}
|
||||
@ -1385,8 +1412,775 @@ export function LocalAiImagePipelineSections({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSaveEnhanceProConfig}>
|
||||
<Save className="mr-2 h-4 w-4" /> 保存图像增强 Pro 配置
|
||||
<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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
}
|
||||
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>
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
@ -12,6 +12,7 @@ export const DEFAULT_LOCAL_AI_EXPANDED_SECTIONS: Record<string, boolean> = {
|
||||
rmbg: false,
|
||||
enhance: false,
|
||||
"enhance-pro": false,
|
||||
"ai-wash": false,
|
||||
extend: false,
|
||||
"watermark-remove": false,
|
||||
"angle-edit": false,
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { Dispatch, DragEvent, ReactNode, SetStateAction } from "react";
|
||||
import type { CanvasImageTaskQueueConfig } from "@/lib/canvas/image-task-config";
|
||||
import type { AiWashConfig } from "@/lib/ai-wash/config";
|
||||
import type {
|
||||
EnhanceProKreaConfig,
|
||||
EnhanceProKreaModel,
|
||||
@ -297,6 +298,7 @@ export type LayerSplitConfig = {
|
||||
};
|
||||
|
||||
export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
|
||||
modelsData?: AdminModelsData;
|
||||
rmbgConfig: RmbgConfig;
|
||||
setRmbgConfig: Dispatch<SetStateAction<RmbgConfig>>;
|
||||
enhanceConfig: EnhanceConfig;
|
||||
@ -317,6 +319,9 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
|
||||
selectedEnhanceProPreset: EnhanceProPreset | null | undefined;
|
||||
selectedEnhanceProPresetIndex: number;
|
||||
draggingEnhanceProPresetId: string;
|
||||
aiWashConfig: AiWashConfig;
|
||||
setAiWashConfig: Dispatch<SetStateAction<AiWashConfig>>;
|
||||
aiWashModeIconUploading: boolean;
|
||||
extendConfig: ExtendConfig;
|
||||
setExtendConfig: Dispatch<SetStateAction<ExtendConfig>>;
|
||||
watermarkRemoveConfig: WatermarkRemoveConfig;
|
||||
@ -352,6 +357,8 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & {
|
||||
targetPresetId: string
|
||||
) => void;
|
||||
handleMoveEnhanceProPreset: (fromIndex: number, toIndex: number) => void;
|
||||
handleAiWashModeIconUpload: (file: File) => Promise<void>;
|
||||
handleSaveAiWashConfig: AsyncAction;
|
||||
handleSaveExtendConfig: AsyncAction;
|
||||
handleSaveWatermarkRemoveConfig: AsyncAction;
|
||||
handleSaveAngleEditConfig: AsyncAction;
|
||||
|
||||
@ -197,6 +197,11 @@ import {
|
||||
normalizeCanvasToolbarConfig,
|
||||
} from "@/lib/canvas-toolbar-config";
|
||||
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
|
||||
import {
|
||||
AI_WASH_CONFIG_KEY,
|
||||
defaultAiWashConfig,
|
||||
normalizeAiWashConfig,
|
||||
} from "@/lib/ai-wash/config";
|
||||
import {
|
||||
defaultEnhanceProKreaConfig,
|
||||
defaultEnhanceProPresets,
|
||||
@ -1808,6 +1813,9 @@ export function AdminSettingsClient() {
|
||||
`/api/admin/settings?key=${ENHANCE_PRO_KREA_SETTING_KEY}`,
|
||||
fetcher
|
||||
);
|
||||
const { data: aiWashConfigData, mutate: mutateAiWashConfig } = useSWR<{
|
||||
setting?: { value?: unknown };
|
||||
}>(`/api/admin/settings?key=${AI_WASH_CONFIG_KEY}`, fetcher);
|
||||
const {
|
||||
data: canvasImageTaskQueueConfigData,
|
||||
mutate: mutateCanvasImageTaskQueueConfig,
|
||||
@ -2459,6 +2467,7 @@ export function AdminSettingsClient() {
|
||||
useState("");
|
||||
const [draggingEnhanceProPresetId, setDraggingEnhanceProPresetId] =
|
||||
useState("");
|
||||
const [aiWashConfig, setAiWashConfig] = useState(defaultAiWashConfig());
|
||||
|
||||
// 图像拓展API配置状态
|
||||
const [extendConfig, setExtendConfig] = useState({
|
||||
@ -2695,6 +2704,11 @@ export function AdminSettingsClient() {
|
||||
setEnhanceProApiKeysDraft("");
|
||||
}, [enhanceProConfigData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!aiWashConfigData) return;
|
||||
setAiWashConfig(normalizeAiWashConfig(aiWashConfigData.setting?.value));
|
||||
}, [aiWashConfigData]);
|
||||
|
||||
const enhanceProApiKeysDraftCount = useMemo(
|
||||
() =>
|
||||
normalizeEnhanceProKreaConfig({
|
||||
@ -3271,6 +3285,8 @@ export function AdminSettingsClient() {
|
||||
>(null);
|
||||
const [realtimeModeIconUploading, setRealtimeModeIconUploading] =
|
||||
useState<RealtimeCanvasModeIconKey | null>(null);
|
||||
const [aiWashModeIconUploading, setAiWashModeIconUploading] =
|
||||
useState(false);
|
||||
const [canvasToolbarIconUploading, setCanvasToolbarIconUploading] =
|
||||
useState<CanvasToolbarItemKey | null>(null);
|
||||
const [lovartHomeSidebarIconUploading, setLovartHomeSidebarIconUploading] =
|
||||
@ -3628,6 +3644,44 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAiWashModeIconUpload = async (file: File) => {
|
||||
const isSvg =
|
||||
file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg");
|
||||
if (!isSvg) {
|
||||
toast.error("请上传 SVG 图标文件");
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 512 * 1024) {
|
||||
toast.error("SVG 图标不能超过 512KB");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setAiWashModeIconUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("purpose", "admin-svg-icon");
|
||||
|
||||
const response = await fetchUploadWithRetry(formData);
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result?.url) {
|
||||
throw new Error(result?.error || "上传失败");
|
||||
}
|
||||
|
||||
setAiWashConfig((prev) => ({
|
||||
...prev,
|
||||
modeIconUrl: result.url,
|
||||
}));
|
||||
toast.success("AI洗图图标上传成功,请保存配置后生效");
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || "AI洗图图标上传失败");
|
||||
} finally {
|
||||
setAiWashModeIconUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateCanvasToolbarItem = (
|
||||
key: CanvasToolbarItemKey,
|
||||
updates: Partial<{ label: string; iconUrl: string; enabled: boolean }>
|
||||
@ -4915,6 +4969,52 @@ export function AdminSettingsClient() {
|
||||
toast.success("Krea Key 已加入池,保存配置后生效");
|
||||
};
|
||||
|
||||
const handleSaveAiWashConfig = async () => {
|
||||
try {
|
||||
const normalizedConfig = normalizeAiWashConfig(aiWashConfig);
|
||||
const enabledPresetCount = normalizedConfig.presets.filter(
|
||||
(preset) => preset.enabled
|
||||
).length;
|
||||
|
||||
if (normalizedConfig.enabled && enabledPresetCount === 0) {
|
||||
toast.error("请至少启用一个 AI洗图预设");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedConfig.enabled &&
|
||||
normalizedConfig.presets.some(
|
||||
(preset) => preset.enabled && !preset.prompt.trim()
|
||||
)
|
||||
) {
|
||||
toast.error("启用中的 AI洗图预设至少需要配置基础提示词");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch("/api/admin/settings", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
key: AI_WASH_CONFIG_KEY,
|
||||
value: normalizedConfig,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
String(payload?.message || payload?.error || "保存失败")
|
||||
);
|
||||
}
|
||||
|
||||
mutateAiWashConfig();
|
||||
toast.success("AI洗图配置保存成功");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "保存 AI洗图配置失败"
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEnhanceProPoolKey = (index: number) => {
|
||||
setEnhanceProConfig((prev) => ({
|
||||
...prev,
|
||||
@ -8087,6 +8187,7 @@ export function AdminSettingsClient() {
|
||||
: "",
|
||||
enhanceConfig.apiKey || enhanceConfig.backupApiKey ? "增强" : "",
|
||||
enhanceProConfig.enabled ? "Pro" : "",
|
||||
aiWashConfig.enabled ? "AI洗图" : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" / ") || "等待配置处理能力",
|
||||
@ -8096,7 +8197,8 @@ export function AdminSettingsClient() {
|
||||
rmbgConfig.aosApiKey ||
|
||||
enhanceConfig.apiKey ||
|
||||
enhanceConfig.backupApiKey ||
|
||||
enhanceProConfig.enabled
|
||||
enhanceProConfig.enabled ||
|
||||
aiWashConfig.enabled
|
||||
? "部分可用"
|
||||
: "待配置",
|
||||
tone:
|
||||
@ -8105,7 +8207,8 @@ export function AdminSettingsClient() {
|
||||
rmbgConfig.aosApiKey ||
|
||||
enhanceConfig.apiKey ||
|
||||
enhanceConfig.backupApiKey ||
|
||||
enhanceProConfig.enabled
|
||||
enhanceProConfig.enabled ||
|
||||
aiWashConfig.enabled
|
||||
? "enabled"
|
||||
: "warning",
|
||||
icon: Scissors,
|
||||
@ -8188,6 +8291,12 @@ export function AdminSettingsClient() {
|
||||
group: "图像处理",
|
||||
tone: enhanceProConfig.enabled ? "enabled" : "disabled",
|
||||
},
|
||||
{
|
||||
key: "ai-wash",
|
||||
label: "AI洗图",
|
||||
group: "图像处理",
|
||||
tone: aiWashConfig.enabled ? "enabled" : "disabled",
|
||||
},
|
||||
{
|
||||
key: "extend",
|
||||
label: "图像拓展",
|
||||
@ -17442,6 +17551,8 @@ export function AdminSettingsClient() {
|
||||
|
||||
<LocalAiImagePipelineSections
|
||||
angleEditConfig={angleEditConfig}
|
||||
aiWashConfig={aiWashConfig}
|
||||
aiWashModeIconUploading={aiWashModeIconUploading}
|
||||
draftEnhanceProKeyRows={draftEnhanceProKeyRows}
|
||||
draggingEnhanceProPresetId={draggingEnhanceProPresetId}
|
||||
enhanceConfig={enhanceConfig}
|
||||
@ -17453,6 +17564,7 @@ export function AdminSettingsClient() {
|
||||
getEnhanceProPresetSummary={getEnhanceProPresetSummary}
|
||||
handleAddEnhanceProPoolKey={handleAddEnhanceProPoolKey}
|
||||
handleAddEnhanceProPreset={handleAddEnhanceProPreset}
|
||||
handleAiWashModeIconUpload={handleAiWashModeIconUpload}
|
||||
handleDeleteEnhanceProPoolKey={handleDeleteEnhanceProPoolKey}
|
||||
handleEnhanceProPresetDragEnd={handleEnhanceProPresetDragEnd}
|
||||
handleEnhanceProPresetDragStart={handleEnhanceProPresetDragStart}
|
||||
@ -17460,6 +17572,7 @@ export function AdminSettingsClient() {
|
||||
handleMoveEnhanceProPreset={handleMoveEnhanceProPreset}
|
||||
handleResetEnhanceProPresets={handleResetEnhanceProPresets}
|
||||
handleSaveAngleEditConfig={handleSaveAngleEditConfig}
|
||||
handleSaveAiWashConfig={handleSaveAiWashConfig}
|
||||
handleSaveEnhanceConfig={handleSaveEnhanceConfig}
|
||||
handleSaveEnhanceProConfig={handleSaveEnhanceProConfig}
|
||||
handleSaveExtendConfig={handleSaveExtendConfig}
|
||||
@ -17471,6 +17584,7 @@ export function AdminSettingsClient() {
|
||||
}
|
||||
isLocalAiSectionExpanded={isLocalAiSectionExpanded}
|
||||
layerSplitConfig={layerSplitConfig}
|
||||
modelsData={modelsData}
|
||||
renderEnhanceProPresetEditor={renderEnhanceProPresetEditor}
|
||||
rmbgConfig={rmbgConfig}
|
||||
savedEnhanceProKeyRows={savedEnhanceProKeyRows}
|
||||
@ -17478,6 +17592,7 @@ export function AdminSettingsClient() {
|
||||
selectedEnhanceProPresetId={selectedEnhanceProPresetId}
|
||||
selectedEnhanceProPresetIndex={selectedEnhanceProPresetIndex}
|
||||
setAngleEditConfig={setAngleEditConfig}
|
||||
setAiWashConfig={setAiWashConfig}
|
||||
setEnhanceConfig={setEnhanceConfig}
|
||||
setEnhanceProApiKeysDraft={setEnhanceProApiKeysDraft}
|
||||
setEnhanceProConfig={setEnhanceProConfig}
|
||||
|
||||
58
app/api/ai-wash/config/route.ts
Normal file
58
app/api/ai-wash/config/route.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
import {
|
||||
AI_WASH_CONFIG_KEY,
|
||||
normalizeAiWashConfig,
|
||||
sanitizeAiWashConfigForClient,
|
||||
} from "@/lib/ai-wash/config";
|
||||
import {
|
||||
findStyleProImageModelByRef,
|
||||
getStyleProImageModelEntries,
|
||||
toPublicStyleProModelOption,
|
||||
} from "@/lib/style-pro/model-options";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
|
||||
const logger = createLogger("ai-wash-config-api");
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权,请先登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const setting = await getSystemSetting({ key: AI_WASH_CONFIG_KEY });
|
||||
const config = normalizeAiWashConfig(setting?.value);
|
||||
const lockedModelRefs = Array.from(
|
||||
new Set(
|
||||
config.presets
|
||||
.flatMap((preset) => [
|
||||
preset.modelId,
|
||||
...preset.controls.flatMap((control) =>
|
||||
control.levels.map((level) => level.modelId)
|
||||
),
|
||||
])
|
||||
.map((item) => String(item || "").trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
const modelEntries =
|
||||
lockedModelRefs.length > 0
|
||||
? await getStyleProImageModelEntries({ includeHidden: true })
|
||||
: [];
|
||||
const lockedModels = lockedModelRefs
|
||||
.map((modelRef) => findStyleProImageModelByRef(modelEntries, modelRef))
|
||||
.filter(Boolean)
|
||||
.map((entry) => toPublicStyleProModelOption(entry!));
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
config: sanitizeAiWashConfigForClient(config),
|
||||
lockedModels,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("config_fetch_failed", error);
|
||||
return NextResponse.json({ error: "获取 AI洗图 配置失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
133
app/api/ai-wash/generate/route.ts
Normal file
133
app/api/ai-wash/generate/route.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
createCanvasImageTask,
|
||||
enqueueCanvasImageTask,
|
||||
getCanvasImageTaskForUser,
|
||||
} from "@/lib/canvas/image-task-queue";
|
||||
import { type CanvasGenerateImageRequest } from "@/lib/canvas/generate-image";
|
||||
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
|
||||
import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge";
|
||||
import {
|
||||
findStyleProImageModelByRef,
|
||||
getStyleProImageModelEntries,
|
||||
} from "@/lib/style-pro/model-options";
|
||||
|
||||
const requestSchema = z.object({
|
||||
prompt: z.string().min(1).max(5000),
|
||||
referenceImage: z.string().min(1).max(200000),
|
||||
selectedModelId: z.string().min(1).max(128),
|
||||
imageSize: z.string().min(1).max(16).optional(),
|
||||
promptExtend: z.boolean().optional(),
|
||||
aspectRatio: z.string().min(1).max(16).optional(),
|
||||
title: z.string().max(128).optional(),
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const maxDuration = 120;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = requestSchema.parse(await request.json());
|
||||
const imageModelEntries = await getStyleProImageModelEntries({
|
||||
includeHidden: true,
|
||||
});
|
||||
const selectedModel = findStyleProImageModelByRef(
|
||||
imageModelEntries,
|
||||
body.selectedModelId
|
||||
);
|
||||
|
||||
if (!selectedModel) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "所选 AI洗图 生图模型不可用" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const imageSize = body.imageSize?.trim() || "2K";
|
||||
const unitCost = getModelGenerationCreditCost(selectedModel.model as any, "image", {
|
||||
imageSize,
|
||||
});
|
||||
const pointsCharge = await prepareFeaturePointsCharge({
|
||||
userId: session.user.id,
|
||||
unitCost,
|
||||
reason: "ai_wash_generation",
|
||||
modelId: selectedModel.model.id,
|
||||
note: `AI洗图消耗积分(${body.title || selectedModel.model.displayName})`,
|
||||
});
|
||||
|
||||
if (!pointsCharge.ok) {
|
||||
return NextResponse.json(pointsCharge.payload, {
|
||||
status: pointsCharge.status,
|
||||
});
|
||||
}
|
||||
|
||||
const payload: CanvasGenerateImageRequest = {
|
||||
prompt: body.prompt.trim(),
|
||||
model: selectedModel.model.id,
|
||||
referenceImage: body.referenceImage.trim(),
|
||||
imageSize,
|
||||
imageCount: 1,
|
||||
promptExtend: body.promptExtend === true,
|
||||
aspectRatio: body.aspectRatio?.trim() || "auto",
|
||||
ownerKey: session.user.id,
|
||||
debugLabel: "ai-wash",
|
||||
debugMeta: {
|
||||
selectedModelId: selectedModel.model.id,
|
||||
selectedModelName: selectedModel.model.displayName,
|
||||
title: body.title || "",
|
||||
},
|
||||
};
|
||||
|
||||
const task = await createCanvasImageTask({
|
||||
userId: session.user.id,
|
||||
request: payload as unknown as Record<string, unknown>,
|
||||
});
|
||||
await enqueueCanvasImageTask(task.id);
|
||||
const currentTask = await getCanvasImageTaskForUser({
|
||||
taskId: task.id,
|
||||
userId: session.user.id,
|
||||
});
|
||||
|
||||
if (pointsCharge.chargeable) {
|
||||
try {
|
||||
await pointsCharge.consume({
|
||||
note: `AI洗图消耗积分(${body.title || selectedModel.model.displayName} / ${task.id})`,
|
||||
});
|
||||
} catch (pointsError) {
|
||||
console.error("[AI Wash] Failed to consume points:", pointsError);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
taskId: task.id,
|
||||
status: currentTask?.status || task.status,
|
||||
queueStatus: currentTask?.queueStatus,
|
||||
unitCost,
|
||||
modelId: selectedModel.model.id,
|
||||
modelName: selectedModel.model.displayName,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[AI Wash Generate] Failed:", error);
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error.issues[0]?.message || "请求参数错误" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ success: false, error: error instanceof Error ? error.message : "AI洗图失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
38
app/api/ai-wash/model-options/route.ts
Normal file
38
app/api/ai-wash/model-options/route.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import {
|
||||
getStyleProImageModelDisplayEntries,
|
||||
toPublicStyleProModelOption,
|
||||
} from "@/lib/style-pro/model-options";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "未登录" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const entries = await getStyleProImageModelDisplayEntries({
|
||||
includeHidden: false,
|
||||
});
|
||||
const defaultEntry =
|
||||
entries.find((entry) => entry.model.isDefault) || entries[0] || null;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
defaultModelId: defaultEntry?.model.id || null,
|
||||
models: entries.map(toPublicStyleProModelOption),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[AI Wash Model Options] Failed:", error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: "获取 AI洗图 生图模型失败" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -129,6 +129,10 @@ import {
|
||||
ENHANCE_PRO_KREA_SETTING_KEY,
|
||||
normalizeEnhanceProKreaConfig,
|
||||
} from "@/lib/enhance-pro/config";
|
||||
import {
|
||||
AI_WASH_CONFIG_KEY,
|
||||
normalizeAiWashConfig,
|
||||
} from "@/lib/ai-wash/config";
|
||||
import { STYLE_PRO_CONFIG_KEY, normalizeStyleProConfig } from "@/lib/style-pro/config";
|
||||
import { withTimeout } from "@/lib/public-api/performance";
|
||||
import type { AgentCategoryTreeConfig } from "@/types/agent";
|
||||
@ -205,12 +209,16 @@ async function readCanvasFeatureAvailability(valueByKey: Map<string, unknown>) {
|
||||
]);
|
||||
|
||||
return {
|
||||
aiWashIconUrl: normalizeAiWashConfig(valueByKey.get(AI_WASH_CONFIG_KEY))
|
||||
.modeIconUrl,
|
||||
angleEditEnabled: normalizeAngleEditEnabled(
|
||||
valueByKey.get(ANGLE_EDIT_CONFIG_KEY),
|
||||
),
|
||||
enhanceProEnabled: normalizeEnhanceProKreaConfig(
|
||||
valueByKey.get(ENHANCE_PRO_KREA_SETTING_KEY),
|
||||
).enabled,
|
||||
aiWashEnabled: normalizeAiWashConfig(valueByKey.get(AI_WASH_CONFIG_KEY)).enabled,
|
||||
aiWashLabel: normalizeAiWashConfig(valueByKey.get(AI_WASH_CONFIG_KEY)).modeLabel,
|
||||
tripo3dEnabled: enabledTripoProviders.length > 0,
|
||||
worldlabs3dEnabled: enabledWorldlabsProviders.length > 0,
|
||||
};
|
||||
@ -691,6 +699,7 @@ async function getScopedPublicSiteSettings(
|
||||
VIDEO_CANVAS_ENABLED_KEY,
|
||||
VIDEO_CANVAS_SIDEBAR_ENABLED_KEY,
|
||||
ENHANCE_PRO_KREA_SETTING_KEY,
|
||||
AI_WASH_CONFIG_KEY,
|
||||
STYLE_PRO_CONFIG_KEY,
|
||||
AGENT_WORKFLOW_UI_CONFIG_KEY,
|
||||
AGENT_WORKFLOW_SHOWCASE_CONFIG_KEY,
|
||||
@ -730,6 +739,9 @@ async function getScopedPublicSiteSettings(
|
||||
),
|
||||
angleEditEnabled: canvasFeatureAvailability.angleEditEnabled,
|
||||
enhanceProEnabled: canvasFeatureAvailability.enhanceProEnabled,
|
||||
aiWashEnabled: canvasFeatureAvailability.aiWashEnabled,
|
||||
aiWashLabel: canvasFeatureAvailability.aiWashLabel,
|
||||
aiWashIconUrl: canvasFeatureAvailability.aiWashIconUrl,
|
||||
tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled,
|
||||
worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled,
|
||||
agentWorkflowUiConfig: normalizeAgentWorkflowUiConfig(
|
||||
@ -774,6 +786,7 @@ async function getScopedPublicSiteSettings(
|
||||
VIDEO_CANVAS_ENABLED_KEY,
|
||||
VIDEO_CANVAS_SIDEBAR_ENABLED_KEY,
|
||||
ENHANCE_PRO_KREA_SETTING_KEY,
|
||||
AI_WASH_CONFIG_KEY,
|
||||
STYLE_PRO_CONFIG_KEY,
|
||||
]);
|
||||
const canvasFeatureAvailability = await readCanvasFeatureAvailability(valueByKey);
|
||||
@ -810,6 +823,9 @@ async function getScopedPublicSiteSettings(
|
||||
),
|
||||
angleEditEnabled: canvasFeatureAvailability.angleEditEnabled,
|
||||
enhanceProEnabled: canvasFeatureAvailability.enhanceProEnabled,
|
||||
aiWashEnabled: canvasFeatureAvailability.aiWashEnabled,
|
||||
aiWashLabel: canvasFeatureAvailability.aiWashLabel,
|
||||
aiWashIconUrl: canvasFeatureAvailability.aiWashIconUrl,
|
||||
tripo3dEnabled: canvasFeatureAvailability.tripo3dEnabled,
|
||||
worldlabs3dEnabled: canvasFeatureAvailability.worldlabs3dEnabled,
|
||||
});
|
||||
|
||||
@ -12,8 +12,13 @@ export default function CanvasRealtimePage() {
|
||||
const routeParams = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const id = String(routeParams?.id || "");
|
||||
const initialMode =
|
||||
searchParams?.get("mode") === "enhance-pro" ? "enhance-pro" : "realtime";
|
||||
const initialMode: "realtime" | "enhance-pro" | "ai-wash" = (() => {
|
||||
const mode = String(searchParams?.get("mode") || "").trim();
|
||||
if (mode === "enhance-pro" || mode === "ai-wash") {
|
||||
return mode;
|
||||
}
|
||||
return "realtime";
|
||||
})();
|
||||
|
||||
const viewport = useCanvasStore(canvasSelectors.viewport);
|
||||
const selectedNodeIds = useCanvasStore(canvasSelectors.selectedNodeIds);
|
||||
|
||||
1429
components/canvas/AiWashWorkspace.tsx
Normal file
1429
components/canvas/AiWashWorkspace.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -34,6 +34,7 @@ import {
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
||||
import { AiWashWorkspace } from "@/components/canvas/AiWashWorkspace";
|
||||
import { EnhanceProWorkspace } from "@/components/canvas/EnhanceProWorkspace";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@ -58,6 +59,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { AI_WASH_DEFAULT_MODE_LABEL } from "@/lib/ai-wash/config";
|
||||
import {
|
||||
defaultCanvasToolbarConfig,
|
||||
getCanvasToolbarItem,
|
||||
@ -92,7 +94,7 @@ type RealtimeConfig = {
|
||||
};
|
||||
|
||||
type RealtimeStatus = "idle" | "connecting" | "live" | "error";
|
||||
type RealtimeWorkspaceMode = "realtime" | "enhance-pro";
|
||||
type RealtimeWorkspaceMode = "realtime" | "enhance-pro" | "ai-wash";
|
||||
type RealtimeGenerationMode = "manual" | "auto";
|
||||
type RealtimeAspectRatio = "1:1" | "4:3" | "2:3";
|
||||
type RealtimeCanvasModeIconConfig = {
|
||||
@ -905,6 +907,11 @@ export function RealtimeCanvasDialog({
|
||||
const [refreshToken, setRefreshToken] = useState(0);
|
||||
const [isLayerSplitEnabled, setIsLayerSplitEnabled] = useState(false);
|
||||
const [isEnhanceProEnabled, setIsEnhanceProEnabled] = useState(false);
|
||||
const [isAiWashEnabled, setIsAiWashEnabled] = useState(false);
|
||||
const [aiWashModeLabel, setAiWashModeLabel] = useState(
|
||||
AI_WASH_DEFAULT_MODE_LABEL
|
||||
);
|
||||
const [aiWashModeIconUrl, setAiWashModeIconUrl] = useState("");
|
||||
const [hasResolvedCanvasSettings, setHasResolvedCanvasSettings] =
|
||||
useState(false);
|
||||
const [modeIconConfig, setModeIconConfig] =
|
||||
@ -1096,6 +1103,9 @@ export function RealtimeCanvasDialog({
|
||||
let cancelled = false;
|
||||
setHasResolvedCanvasSettings(false);
|
||||
setIsEnhanceProEnabled(false);
|
||||
setIsAiWashEnabled(false);
|
||||
setAiWashModeLabel(AI_WASH_DEFAULT_MODE_LABEL);
|
||||
setAiWashModeIconUrl("");
|
||||
|
||||
fetch(`/api/site-settings?scope=canvas&t=${Date.now()}`)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
@ -1103,6 +1113,17 @@ export function RealtimeCanvasDialog({
|
||||
if (!data || cancelled) return;
|
||||
setIsLayerSplitEnabled(data.layerSplitEnabled === true);
|
||||
setIsEnhanceProEnabled(data.enhanceProEnabled === true);
|
||||
setIsAiWashEnabled(data.aiWashEnabled === true);
|
||||
setAiWashModeLabel(
|
||||
typeof data.aiWashLabel === "string" && data.aiWashLabel.trim()
|
||||
? data.aiWashLabel.trim().slice(0, 32)
|
||||
: AI_WASH_DEFAULT_MODE_LABEL
|
||||
);
|
||||
setAiWashModeIconUrl(
|
||||
typeof data.aiWashIconUrl === "string"
|
||||
? data.aiWashIconUrl.trim()
|
||||
: ""
|
||||
);
|
||||
setCanvasToolbarConfig(
|
||||
normalizeCanvasToolbarConfig(data.canvasToolbarConfig)
|
||||
);
|
||||
@ -1156,8 +1177,18 @@ export function RealtimeCanvasDialog({
|
||||
}
|
||||
if (workspaceMode === "enhance-pro" && !isEnhanceProEnabled) {
|
||||
setWorkspaceMode("realtime");
|
||||
return;
|
||||
}
|
||||
}, [hasResolvedCanvasSettings, isEnhanceProEnabled, open, workspaceMode]);
|
||||
if (workspaceMode === "ai-wash" && !isAiWashEnabled) {
|
||||
setWorkspaceMode("realtime");
|
||||
}
|
||||
}, [
|
||||
hasResolvedCanvasSettings,
|
||||
isAiWashEnabled,
|
||||
isEnhanceProEnabled,
|
||||
open,
|
||||
workspaceMode,
|
||||
]);
|
||||
|
||||
const pushMetric = useCallback((event: any) => {
|
||||
metricsQueueRef.current.push(event);
|
||||
@ -3127,14 +3158,18 @@ export function RealtimeCanvasDialog({
|
||||
if (files.length === 0) return;
|
||||
|
||||
try {
|
||||
if (workspaceMode === "enhance-pro") {
|
||||
if (workspaceMode === "enhance-pro" || workspaceMode === "ai-wash") {
|
||||
const file = files[0];
|
||||
if (!file?.type.startsWith("image/")) {
|
||||
throw new Error("请上传图片文件");
|
||||
}
|
||||
const dataUrl = await fileToDataUrl(file);
|
||||
setEnhanceUploadedSourceImageUrl(dataUrl);
|
||||
toast.success("已选择用于图像增强 Pro 的图片");
|
||||
toast.success(
|
||||
workspaceMode === "ai-wash"
|
||||
? "已选择用于 AI洗图 的图片"
|
||||
: "已选择用于图像增强 Pro 的图片"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -3324,6 +3359,10 @@ export function RealtimeCanvasDialog({
|
||||
toast.error("图像增强 Pro 当前未开启,请先在系统设置中启用");
|
||||
return;
|
||||
}
|
||||
if (nextMode === "ai-wash" && !isAiWashEnabled) {
|
||||
toast.error("AI洗图当前未开启,请先在系统设置中启用");
|
||||
return;
|
||||
}
|
||||
if (workspaceMode === "realtime") {
|
||||
captureDrawingLayerSnapshot();
|
||||
}
|
||||
@ -3336,6 +3375,7 @@ export function RealtimeCanvasDialog({
|
||||
cancelPendingRealtimeFlush,
|
||||
captureDrawingLayerSnapshot,
|
||||
closeBrushSettingPopovers,
|
||||
isAiWashEnabled,
|
||||
isEnhanceProEnabled,
|
||||
resetRealtimeInteractionState,
|
||||
workspaceMode,
|
||||
@ -3505,6 +3545,26 @@ export function RealtimeCanvasDialog({
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
{isAiWashEnabled ? (
|
||||
<button
|
||||
className={cn(
|
||||
"group inline-flex h-full min-w-0 items-center gap-2 rounded-[18px] px-4 text-[14px] font-medium tracking-[-0.02em] transition",
|
||||
workspaceMode === "ai-wash"
|
||||
? softActionButtonClass
|
||||
: "border border-transparent bg-transparent text-[#666666] hover:bg-white hover:text-[#111111] dark:text-white/58 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
)}
|
||||
onClick={() => handleWorkspaceModeChange("ai-wash")}
|
||||
type="button"
|
||||
>
|
||||
<CanvasToolbarIcon
|
||||
active={workspaceMode === "ai-wash"}
|
||||
className="h-4 w-4 transition group-hover:opacity-100"
|
||||
fallback={Sparkles}
|
||||
iconUrl={aiWashModeIconUrl}
|
||||
/>
|
||||
<span className="max-w-[9rem] truncate">{aiWashModeLabel}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -3694,6 +3754,13 @@ export function RealtimeCanvasDialog({
|
||||
onRequestAddImage={() => uploadInputRef.current?.click()}
|
||||
sourceImageUrl={enhanceSourceImageUrl}
|
||||
/>
|
||||
) : workspaceMode === "ai-wash" ? (
|
||||
<AiWashWorkspace
|
||||
historyScopeKey={enhanceHistoryScopeKey}
|
||||
onApplyResult={handleApplyEnhanceResult}
|
||||
onRequestAddImage={() => uploadInputRef.current?.click()}
|
||||
sourceImageUrl={enhanceSourceImageUrl}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Main Canvas Area - Split View */}
|
||||
|
||||
310
lib/ai-wash/config.ts
Normal file
310
lib/ai-wash/config.ts
Normal file
@ -0,0 +1,310 @@
|
||||
export const AI_WASH_CONFIG_KEY = "ai_wash_config";
|
||||
export const AI_WASH_DEFAULT_MODE_LABEL = "AI洗图";
|
||||
export const AI_WASH_DEFAULT_IMAGE_SIZE = "2K";
|
||||
export const AI_WASH_DEFAULT_HISTORY_LIMIT = 8;
|
||||
export const AI_WASH_CONTROL_ORDER = [
|
||||
"creativity",
|
||||
"detail",
|
||||
"lighting",
|
||||
] as const;
|
||||
|
||||
export type AiWashControlId = (typeof AI_WASH_CONTROL_ORDER)[number];
|
||||
export type AiWashControlKind = "model" | "prompt";
|
||||
|
||||
export type AiWashControlLevel = {
|
||||
id: string;
|
||||
label: string;
|
||||
modelId: string;
|
||||
promptFragment: string;
|
||||
};
|
||||
|
||||
export type AiWashControl = {
|
||||
id: AiWashControlId;
|
||||
label: string;
|
||||
helperText: string;
|
||||
kind: AiWashControlKind;
|
||||
levels: AiWashControlLevel[];
|
||||
defaultLevel: number;
|
||||
};
|
||||
|
||||
export type AiWashPreset = {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
modelId: string;
|
||||
enabled: boolean;
|
||||
isDefault: boolean;
|
||||
controls: AiWashControl[];
|
||||
};
|
||||
|
||||
export type AiWashConfig = {
|
||||
enabled: boolean;
|
||||
modeLabel: string;
|
||||
modeIconUrl: string;
|
||||
imageSize: string;
|
||||
promptExtend: boolean;
|
||||
historyLimit: number;
|
||||
presets: AiWashPreset[];
|
||||
};
|
||||
|
||||
function normalizeText(value: unknown, maxLength: number) {
|
||||
return String(value || "").trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function createPresetId(title: string, index: number) {
|
||||
const normalized = String(title || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
|
||||
return normalized || `ai-wash-preset-${index + 1}`;
|
||||
}
|
||||
|
||||
function createLevelId(controlId: AiWashControlId, index: number) {
|
||||
return `${controlId}-level-${index + 1}`;
|
||||
}
|
||||
|
||||
function createDefaultControl(
|
||||
controlId: AiWashControlId,
|
||||
overrides?: Partial<AiWashControl>
|
||||
): AiWashControl {
|
||||
const defaults: Record<
|
||||
AiWashControlId,
|
||||
Pick<AiWashControl, "label" | "helperText" | "kind">
|
||||
> = {
|
||||
creativity: {
|
||||
label: "创造力",
|
||||
helperText: "每一档都可以单独指定生图模型,也可以补充少量提示词片段。",
|
||||
kind: "model",
|
||||
},
|
||||
detail: {
|
||||
label: "细节",
|
||||
helperText: "每一档都可以补充默认提示词,控制画面的细节强弱与精修程度。",
|
||||
kind: "prompt",
|
||||
},
|
||||
lighting: {
|
||||
label: "光塑",
|
||||
helperText: "每一档都可以补充默认提示词,控制光影层次、氛围和空间塑造。",
|
||||
kind: "prompt",
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
id: controlId,
|
||||
label: overrides?.label || defaults[controlId].label,
|
||||
helperText: overrides?.helperText || defaults[controlId].helperText,
|
||||
kind: overrides?.kind || defaults[controlId].kind,
|
||||
defaultLevel:
|
||||
typeof overrides?.defaultLevel === "number"
|
||||
? Math.max(0, Math.min(23, Math.floor(overrides.defaultLevel)))
|
||||
: 1,
|
||||
levels:
|
||||
overrides?.levels && overrides.levels.length > 0
|
||||
? overrides.levels
|
||||
: Array.from({ length: 3 }, (_, index) => ({
|
||||
id: createLevelId(controlId, index),
|
||||
label: String(index + 1),
|
||||
modelId: "",
|
||||
promptFragment: "",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeControlLevel(
|
||||
controlId: AiWashControlId,
|
||||
value: unknown,
|
||||
index: number
|
||||
): AiWashControlLevel {
|
||||
const raw =
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
id: normalizeText(raw.id, 64) || createLevelId(controlId, index),
|
||||
label: normalizeText(raw.label, 24) || String(index + 1),
|
||||
modelId: normalizeText(raw.modelId, 128),
|
||||
promptFragment: normalizeText(raw.promptFragment, 1000),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeControl(
|
||||
controlId: AiWashControlId,
|
||||
value: unknown,
|
||||
fallback?: AiWashControl
|
||||
): AiWashControl {
|
||||
const base = fallback || createDefaultControl(controlId);
|
||||
const raw =
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
const levelsInput = Array.isArray(raw.levels) ? raw.levels : base.levels;
|
||||
const levels = levelsInput
|
||||
.map((level, index) => normalizeControlLevel(controlId, level, index))
|
||||
.slice(0, 24);
|
||||
|
||||
const defaultLevel = Number.isFinite(Number(raw.defaultLevel))
|
||||
? Number(raw.defaultLevel)
|
||||
: base.defaultLevel;
|
||||
|
||||
return {
|
||||
id: controlId,
|
||||
label: normalizeText(raw.label, 24) || base.label,
|
||||
helperText: normalizeText(raw.helperText, 200) || base.helperText,
|
||||
kind:
|
||||
raw.kind === "model" || raw.kind === "prompt" ? raw.kind : base.kind,
|
||||
levels: levels.length > 0 ? levels : base.levels,
|
||||
defaultLevel: Math.max(0, Math.min(levels.length - 1, Math.floor(defaultLevel))),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeControls(value: unknown): AiWashControl[] {
|
||||
const rawList = Array.isArray(value) ? value : [];
|
||||
|
||||
return AI_WASH_CONTROL_ORDER.map((controlId) => {
|
||||
const matched =
|
||||
rawList.find((item) => {
|
||||
const raw =
|
||||
item && typeof item === "object" && !Array.isArray(item)
|
||||
? (item as Record<string, unknown>)
|
||||
: {};
|
||||
return raw.id === controlId;
|
||||
}) || null;
|
||||
|
||||
return normalizeControl(controlId, matched, createDefaultControl(controlId));
|
||||
});
|
||||
}
|
||||
|
||||
function normalizePreset(
|
||||
value: unknown,
|
||||
index: number,
|
||||
fallback: AiWashPreset
|
||||
): AiWashPreset {
|
||||
const raw =
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
const title = normalizeText(raw.title, 48) || fallback.title;
|
||||
const description =
|
||||
normalizeText(raw.description, 200) || fallback.description;
|
||||
const prompt = normalizeText(raw.prompt, 4000) || fallback.prompt;
|
||||
|
||||
return {
|
||||
id: normalizeText(raw.id, 64) || createPresetId(title, index),
|
||||
title,
|
||||
description,
|
||||
prompt,
|
||||
modelId: normalizeText(raw.modelId, 128),
|
||||
enabled: raw.enabled !== false,
|
||||
isDefault: raw.isDefault === true,
|
||||
controls: normalizeControls(raw.controls),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureDefaultPreset(presets: AiWashPreset[]) {
|
||||
if (presets.length === 0) return presets;
|
||||
if (presets.some((preset) => preset.isDefault && preset.enabled)) {
|
||||
return presets;
|
||||
}
|
||||
return presets.map((preset, index) => ({
|
||||
...preset,
|
||||
isDefault: index === 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function defaultControlsForPreset(): AiWashControl[] {
|
||||
return AI_WASH_CONTROL_ORDER.map((controlId) => createDefaultControl(controlId));
|
||||
}
|
||||
|
||||
export function defaultAiWashConfig(): AiWashConfig {
|
||||
return {
|
||||
enabled: false,
|
||||
modeLabel: AI_WASH_DEFAULT_MODE_LABEL,
|
||||
modeIconUrl: "",
|
||||
imageSize: AI_WASH_DEFAULT_IMAGE_SIZE,
|
||||
promptExtend: false,
|
||||
historyLimit: AI_WASH_DEFAULT_HISTORY_LIMIT,
|
||||
presets: [
|
||||
{
|
||||
id: "texture-precise",
|
||||
title: "质感提升(细节更精准)",
|
||||
description: "保留原图结构,强化细节精度、材质质感和光影层级。",
|
||||
prompt:
|
||||
"保持原图主体、构图、色彩关系和空间透视基本不变,重点提升材质纹理、细节锐度、光影层次和整体清晰度,让画面更精致、更干净、更高级。",
|
||||
modelId: "",
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
controls: defaultControlsForPreset(),
|
||||
},
|
||||
{
|
||||
id: "texture-expressive",
|
||||
title: "质感提升(细节更发散)",
|
||||
description: "允许模型更积极地重建纹理和氛围,适合需要更强视觉表现的场景。",
|
||||
prompt:
|
||||
"在保留主体识别和原始构图的基础上,允许更积极地重建纹理、材质和环境氛围,提升视觉张力与高级感,让结果更具设计感和表现力。",
|
||||
modelId: "",
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
controls: defaultControlsForPreset(),
|
||||
},
|
||||
{
|
||||
id: "ai-render",
|
||||
title: "AI模型渲染(类似渲染器)",
|
||||
description: "转成更接近商业渲染图的质感,适合家居、空间、产品展示。",
|
||||
prompt:
|
||||
"将原图重塑为更接近专业商业渲染图的视觉效果,提升光影控制、材质真实度、环境反射和空间氛围,保持主体结构与镜头视角稳定。",
|
||||
modelId: "",
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
controls: defaultControlsForPreset(),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeAiWashConfig(value: unknown): AiWashConfig {
|
||||
const fallback = defaultAiWashConfig();
|
||||
const raw =
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
const presetsInput = Array.isArray(raw.presets) ? raw.presets : fallback.presets;
|
||||
const presets = ensureDefaultPreset(
|
||||
presetsInput
|
||||
.map((preset, index) =>
|
||||
normalizePreset(
|
||||
preset,
|
||||
index,
|
||||
fallback.presets[index] || fallback.presets[0]
|
||||
)
|
||||
)
|
||||
.slice(0, 6)
|
||||
);
|
||||
|
||||
return {
|
||||
enabled: raw.enabled === true,
|
||||
modeLabel: normalizeText(raw.modeLabel, 32) || AI_WASH_DEFAULT_MODE_LABEL,
|
||||
modeIconUrl: normalizeText(raw.modeIconUrl, 2000),
|
||||
imageSize: normalizeText(raw.imageSize, 16) || AI_WASH_DEFAULT_IMAGE_SIZE,
|
||||
promptExtend: raw.promptExtend === true,
|
||||
historyLimit: Math.max(
|
||||
1,
|
||||
Math.min(24, Number(raw.historyLimit) || AI_WASH_DEFAULT_HISTORY_LIMIT)
|
||||
),
|
||||
presets,
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeAiWashConfigForClient(
|
||||
config: AiWashConfig
|
||||
): AiWashConfig {
|
||||
return normalizeAiWashConfig(config);
|
||||
}
|
||||
|
||||
export function getEnabledAiWashPresets(config: AiWashConfig) {
|
||||
return config.presets.filter(
|
||||
(preset) => preset.enabled && preset.prompt.trim().length > 0
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user