feat: add configurable ai wash workspace

This commit is contained in:
Your Name 2026-06-01 00:34:37 +08:00
parent de91bd396f
commit 1c7d005088
12 changed files with 2983 additions and 10 deletions

View File

@ -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>

View File

@ -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,

View File

@ -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;

View File

@ -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}

View 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 });
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View File

@ -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,
});

View File

@ -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);

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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
);
}