feat: add canvas ai wash quick panel

This commit is contained in:
Your Name 2026-06-04 13:09:42 +08:00
parent b12d61b371
commit 9da001e3ee
2 changed files with 862 additions and 18 deletions

View File

@ -3439,6 +3439,10 @@ export function RealtimeCanvasDialog({
"h-9 w-9 rounded-[16px] border border-transparent text-[#666666] transition hover:border-[#e5e5e5] hover:bg-[#fafafa] hover:text-[#111111] dark:text-white/58 dark:hover:border-white/12 dark:hover:bg-white/10 dark:hover:text-white";
const compactToolButtonActiveClass =
"border-[#111111] bg-[#111111] text-white shadow-[0_10px_20px_rgba(0,0,0,0.12)] hover:bg-black hover:text-white dark:border-white dark:bg-white dark:text-[#111111] dark:hover:bg-white/90 dark:hover:text-[#111111]";
const modeSwitchIconSlotClass =
"inline-flex h-4 w-4 shrink-0 items-center justify-center leading-none [&>*]:!h-3.5 [&>*]:!w-3.5 [&>img]:object-contain";
const modeSwitchIconClass =
"h-3.5 w-3.5 max-h-3.5 max-w-3.5 object-contain transition group-hover:opacity-100";
const currentModeLabel =
brushMode === "select"
? "鼠标"
@ -3513,12 +3517,14 @@ export function RealtimeCanvasDialog({
onClick={() => handleWorkspaceModeChange("realtime")}
type="button"
>
<CanvasToolbarIcon
active={workspaceMode === "realtime"}
className="h-3.5 w-3.5 transition group-hover:opacity-100"
fallback={Paintbrush}
item={realtimeModeRealtimeItem}
/>
<span className={modeSwitchIconSlotClass}>
<CanvasToolbarIcon
active={workspaceMode === "realtime"}
className={modeSwitchIconClass}
fallback={Paintbrush}
item={realtimeModeRealtimeItem}
/>
</span>
<span className="max-w-[9rem] truncate">
{realtimeModeRealtimeItem.label}
</span>
@ -3534,12 +3540,14 @@ export function RealtimeCanvasDialog({
onClick={() => handleWorkspaceModeChange("enhance-pro")}
type="button"
>
<CanvasToolbarIcon
active={workspaceMode === "enhance-pro"}
className="h-3.5 w-3.5 transition group-hover:opacity-100"
fallback={Wand2}
item={realtimeModeEnhanceItem}
/>
<span className={modeSwitchIconSlotClass}>
<CanvasToolbarIcon
active={workspaceMode === "enhance-pro"}
className={modeSwitchIconClass}
fallback={Wand2}
item={realtimeModeEnhanceItem}
/>
</span>
<span className="max-w-[9rem] truncate">
{realtimeModeEnhanceItem.label}
</span>
@ -3556,12 +3564,14 @@ export function RealtimeCanvasDialog({
onClick={() => handleWorkspaceModeChange("ai-wash")}
type="button"
>
<CanvasToolbarIcon
active={workspaceMode === "ai-wash"}
className="h-3.5 w-3.5 transition group-hover:opacity-100"
fallback={Sparkles}
iconUrl={aiWashModeIconUrl}
/>
<span className={modeSwitchIconSlotClass}>
<CanvasToolbarIcon
active={workspaceMode === "ai-wash"}
className={modeSwitchIconClass}
fallback={Sparkles}
iconUrl={aiWashModeIconUrl}
/>
</span>
<span className="max-w-[9rem] truncate">{aiWashModeLabel}</span>
</button>
) : null}

View File

@ -132,6 +132,13 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Textarea } from "@/components/ui/textarea";
import {
@ -167,6 +174,18 @@ import {
isCanvasToolbarItemEnabled,
type CanvasToolbarConfig,
} from "@/lib/canvas-toolbar-config";
import {
AI_WASH_CONTROL_ORDER,
AI_WASH_DEFAULT_MODE_LABEL,
defaultAiWashConfig,
getEnabledAiWashPresets,
normalizeAiWashConfig,
type AiWashConfig,
type AiWashControl,
type AiWashControlId,
type AiWashControlLevel,
type AiWashPreset,
} from "@/lib/ai-wash/config";
import { isCanvasImageUploadPending } from "@/lib/canvas/local-upload-state";
import { cn, fetcher } from "@/lib/utils";
import type {
@ -222,6 +241,7 @@ interface LayerSplitBounds {
}
const AI_CLOUD_IMAGE_ACTIONS = new Set([
"ai-wash",
"rmbg",
"watermark",
"enhance",
@ -235,6 +255,41 @@ const AI_CLOUD_IMAGE_ACTIONS = new Set([
"generate-3d-world",
]);
type AiWashResultStatus =
| "idle"
| "submitting"
| "running"
| "succeeded"
| "failed";
type AiWashModelOption = {
id: string;
modelId: string;
displayName: string;
avatarUrl?: string | null;
providerName?: string;
providerType?: string;
isDefault?: boolean;
unitCost: number;
creditDiscountLabel?: string | null;
availabilityStatus?: "available" | "maintenance" | "testing";
availabilityLabel?: string | null;
selectable?: boolean;
disabledReason?: string | null;
};
type AiWashConfigResponse = {
success?: boolean;
config?: AiWashConfig;
lockedModels?: AiWashModelOption[];
};
type AiWashModelOptionsResponse = {
success?: boolean;
defaultModelId?: string | null;
models?: AiWashModelOption[];
};
type AngleEditPanelMode = "subject" | "camera";
type FlipRotateEditAction = "horizontal" | "vertical" | "reset";
type CropInteractionState = {
@ -449,6 +504,8 @@ const CHAT_ATTACHMENT_MAX_UPLOAD_BYTES = 30 * 1024 * 1024;
const CHAT_ATTACHMENT_MAX_DATA_URL_LENGTH = 42 * 1024 * 1024;
const CANVAS_3D_POLL_INTERVAL_MS = 2000;
const CANVAS_3D_MAX_POLL_ATTEMPTS = 360;
const AI_WASH_TASK_POLL_INTERVAL_MS = 2500;
const AI_WASH_TASK_POLL_LIMIT = 80;
const DEFAULT_CANVAS_TRIPO_MODEL = getDefaultThreeDModelAliasId(
DEFAULT_PUBLIC_THREED_MODEL_ALIAS_CONFIG.tripoModels
);
@ -459,6 +516,118 @@ const DEFAULT_CANVAS_WORLD_MODEL = getDefaultThreeDModelAliasId(
const sleep = (ms: number) =>
new Promise((resolve) => window.setTimeout(resolve, ms));
function getAiWashPresetControl(
preset: AiWashPreset | null | undefined,
controlId: AiWashControlId
): AiWashControl | null {
if (!preset) return null;
return preset.controls.find((control) => control.id === controlId) || null;
}
function getAiWashControlLevelByIndex(
control: AiWashControl | null,
selectedIndex: number
): AiWashControlLevel | null {
if (!control || control.levels.length === 0) return null;
const clampedIndex = Math.max(
0,
Math.min(control.levels.length - 1, Math.floor(selectedIndex))
);
return control.levels[clampedIndex] || control.levels[0] || null;
}
function composeAiWashQuickPrompt(params: {
preset: AiWashPreset;
selectedLevelIndexByControl: Record<string, number>;
}) {
const promptParts = [params.preset.prompt.trim()].filter(Boolean);
for (const controlId of AI_WASH_CONTROL_ORDER) {
const level = getAiWashControlLevelByIndex(
getAiWashPresetControl(params.preset, controlId),
params.selectedLevelIndexByControl[controlId] ?? 0
);
const fragment = String(level?.promptFragment || "").trim();
if (fragment) {
promptParts.push(fragment);
}
}
return promptParts.join("\n");
}
function resolveAiWashQuickAdminModelId(params: {
preset: AiWashPreset;
selectedLevelIndexByControl: Record<string, number>;
}) {
for (const controlId of AI_WASH_CONTROL_ORDER) {
const level = getAiWashControlLevelByIndex(
getAiWashPresetControl(params.preset, controlId),
params.selectedLevelIndexByControl[controlId] ?? 0
);
const levelModelId = String(level?.modelId || "").trim();
if (levelModelId) return levelModelId;
}
return String(params.preset.modelId || "").trim();
}
function getAiWashStatusLabel(status?: AiWashResultStatus) {
switch (status) {
case "submitting":
return "提交中";
case "running":
return "生成中";
case "succeeded":
return "已完成";
case "failed":
return "失败";
case "idle":
default:
return "待生成";
}
}
async function pollAiWashQuickTask(taskId: string) {
for (let attempt = 0; attempt < AI_WASH_TASK_POLL_LIMIT; attempt += 1) {
const response = await fetch(
`/api/canvas/generate/image?taskId=${encodeURIComponent(taskId)}`
);
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success !== true || !payload?.task) {
throw new Error(payload?.error || "查询 AI洗图任务失败");
}
const task = payload.task as {
status: string;
error?: string | null;
result?: {
images?: Array<{ thumbnailUrl?: string; originalUrl?: string }>;
model?: string;
modelRef?: string;
prompt?: string;
} | null;
};
const status = String(task.status || "").toLowerCase();
if (status === "succeeded" || status === "completed") {
const image = task.result?.images?.[0];
if (!image?.originalUrl) {
throw new Error("AI洗图已完成,但没有拿到结果图片");
}
return {
imageUrl: image.originalUrl,
thumbnailUrl: image.thumbnailUrl || image.originalUrl,
modelId: String(task.result?.model || task.result?.modelRef || "").trim(),
prompt: String(task.result?.prompt || "").trim(),
};
}
if (["failed", "cancelled", "expired", "banned"].includes(status)) {
throw new Error(task.error || "AI洗图任务失败");
}
await sleep(AI_WASH_TASK_POLL_INTERVAL_MS);
}
throw new Error("AI洗图任务轮询超时,请稍后重试");
}
const isTerminal3DFailureStatus = (status?: string | null) =>
["failed", "banned", "expired", "cancelled"].includes(String(status || ""));
@ -850,6 +1019,30 @@ export function ImageNode({
? routeParams.id
: "";
const data = node.data as ImageNodeData;
const { data: aiWashConfigPayload, isLoading: aiWashConfigLoading } =
useSWR<AiWashConfigResponse>("/api/ai-wash/config", fetcher);
const aiWashQuickConfig = useMemo(
() =>
normalizeAiWashConfig(
aiWashConfigPayload?.config || defaultAiWashConfig()
),
[aiWashConfigPayload?.config]
);
const aiWashQuickPresets = useMemo(
() => getEnabledAiWashPresets(aiWashQuickConfig).filter((preset) => preset.enabled),
[aiWashQuickConfig]
);
const imageAiWashQuickEnabled =
aiWashQuickConfig.enabled && aiWashQuickPresets.length > 0;
const aiWashQuickModeLabel =
aiWashQuickConfig.modeLabel || AI_WASH_DEFAULT_MODE_LABEL;
const {
data: aiWashModelOptionsPayload,
isLoading: aiWashModelOptionsLoading,
} = useSWR<AiWashModelOptionsResponse>(
aiWashQuickConfig.enabled ? "/api/ai-wash/model-options" : null,
fetcher
);
const imageNodeMoreItem = getCanvasToolbarItem(canvasToolbarConfig, "imageNodeMore");
const imageAddToChatItem = getCanvasToolbarItem(canvasToolbarConfig, "imageAddToChat");
const imagePickObjectItem = getCanvasToolbarItem(canvasToolbarConfig, "imagePickObject");
@ -1029,6 +1222,7 @@ export function ImageNode({
imageMultiAngleEnabled ||
imageGenerate3dModelEnabled ||
imageGenerate3dWorldEnabled ||
imageAiWashQuickEnabled ||
imageLayerSplitEnabled;
const hasSelectedImageSecondaryToolbarActions =
hasSelectedImageMoreMenu || imageDownloadEnabled;
@ -1162,6 +1356,15 @@ export function ImageNode({
const [isAddingToChat, setIsAddingToChat] = useState(false);
const [isCompareMode, setIsCompareMode] = useState(false);
const [compareSplitPercent, setCompareSplitPercent] = useState(50);
const [aiWashQuickPanelOpen, setAiWashQuickPanelOpen] = useState(false);
const [aiWashQuickPresetId, setAiWashQuickPresetId] = useState("");
const [aiWashQuickModelId, setAiWashQuickModelId] = useState("");
const [aiWashQuickLevels, setAiWashQuickLevels] = useState<
Record<string, number>
>({});
const [isSubmittingAiWashQuick, setIsSubmittingAiWashQuick] = useState(false);
const [aiWashQuickStatus, setAiWashQuickStatus] =
useState<AiWashResultStatus>("idle");
const [toolbarSize, setToolbarSize] = useState({ width: 0, height: 0 });
const [boxCutoutToolbarSize, setBoxCutoutToolbarSize] = useState({
width: 0,
@ -1207,12 +1410,128 @@ export function ImageNode({
const hasActiveImageAdjustments = !isDefaultImageAdjustments(
activeImageAdjustments
);
const selectedAiWashQuickPreset = useMemo(
() =>
aiWashQuickPresets.find((preset) => preset.id === aiWashQuickPresetId) ||
aiWashQuickPresets.find((preset) => preset.isDefault) ||
aiWashQuickPresets[0] ||
null,
[aiWashQuickPresetId, aiWashQuickPresets]
);
const aiWashQuickControls = useMemo(
() =>
AI_WASH_CONTROL_ORDER.map((controlId) =>
getAiWashPresetControl(selectedAiWashQuickPreset, controlId)
).filter(Boolean) as AiWashControl[],
[selectedAiWashQuickPreset]
);
const aiWashQuickSelectableModels = useMemo(
() => aiWashModelOptionsPayload?.models || [],
[aiWashModelOptionsPayload?.models]
);
const aiWashQuickLockedModels = useMemo(
() => aiWashConfigPayload?.lockedModels || [],
[aiWashConfigPayload?.lockedModels]
);
const aiWashQuickModelOptionByRef = useMemo(() => {
const map = new Map<string, AiWashModelOption>();
[...aiWashQuickLockedModels, ...aiWashQuickSelectableModels].forEach((model) => {
map.set(model.id, model);
map.set(model.modelId, model);
});
return map;
}, [aiWashQuickLockedModels, aiWashQuickSelectableModels]);
const aiWashQuickAdminModelRef = useMemo(
() =>
selectedAiWashQuickPreset
? resolveAiWashQuickAdminModelId({
preset: selectedAiWashQuickPreset,
selectedLevelIndexByControl: aiWashQuickLevels,
})
: "",
[aiWashQuickLevels, selectedAiWashQuickPreset]
);
const shouldShowAiWashQuickModelSelector = !aiWashQuickAdminModelRef;
const effectiveAiWashQuickModelRef =
aiWashQuickAdminModelRef || String(aiWashQuickModelId || "").trim();
const selectedAiWashQuickModel = useMemo(
() =>
aiWashQuickModelOptionByRef.get(effectiveAiWashQuickModelRef) ||
aiWashQuickModelOptionByRef.get(aiWashQuickModelId) ||
null,
[aiWashQuickModelId, aiWashQuickModelOptionByRef, effectiveAiWashQuickModelRef]
);
const aiWashQuickPrompt = useMemo(
() =>
selectedAiWashQuickPreset
? composeAiWashQuickPrompt({
preset: selectedAiWashQuickPreset,
selectedLevelIndexByControl: aiWashQuickLevels,
})
: "",
[aiWashQuickLevels, selectedAiWashQuickPreset]
);
useEffect(() => {
if (!batchProcessingEnabled) {
setSceneFusionDialogOpen(false);
}
}, [batchProcessingEnabled]);
useEffect(() => {
if (aiWashQuickPresets.length === 0) {
setAiWashQuickPresetId("");
return;
}
setAiWashQuickPresetId((current) => {
if (aiWashQuickPresets.some((preset) => preset.id === current)) {
return current;
}
return (
aiWashQuickPresets.find((preset) => preset.isDefault)?.id ||
aiWashQuickPresets[0]?.id ||
""
);
});
}, [aiWashQuickPresets]);
useEffect(() => {
if (!selectedAiWashQuickPreset) {
setAiWashQuickLevels({});
return;
}
setAiWashQuickLevels(
AI_WASH_CONTROL_ORDER.reduce<Record<string, number>>(
(accumulator, controlId) => {
accumulator[controlId] =
getAiWashPresetControl(selectedAiWashQuickPreset, controlId)
?.defaultLevel ?? 0;
return accumulator;
},
{}
)
);
setAiWashQuickStatus("idle");
}, [selectedAiWashQuickPreset]);
useEffect(() => {
const fallbackModelId =
String(aiWashModelOptionsPayload?.defaultModelId || "").trim() ||
aiWashQuickSelectableModels.find((model) => model.isDefault)?.id ||
aiWashQuickSelectableModels[0]?.id ||
"";
setAiWashQuickModelId((current) => {
if (
current &&
aiWashQuickSelectableModels.some((model) => model.id === current)
) {
return current;
}
return fallbackModelId;
});
}, [aiWashModelOptionsPayload?.defaultModelId, aiWashQuickSelectableModels]);
useEffect(() => {
if (!imageAiWashQuickEnabled || !isSelected) {
setAiWashQuickPanelOpen(false);
}
}, [imageAiWashQuickEnabled, isSelected]);
useEffect(() => {
if (!imageAdjustPanelOpen) return;
setImageAdjustmentsDraft(storedImageAdjustments);
@ -4013,6 +4332,44 @@ export function ImageNode({
return { top: 0, left: 0, maxWidth: 480 };
};
const getAiWashQuickPanelPosition = (
anchor: { top: number; left: number; maxWidth: number }
) => {
if (typeof window === "undefined") {
return { top: 0, left: 0, width: 360, maxHeight: 520 };
}
const panelWidth = 360;
const panelHeight = 540;
const gap = 10;
const toolbarHeight = Math.max(
42,
(toolbarSize.height || 60) * FLOATING_TOOLBAR_SCALE
);
const { viewportPadding, safeBottom, safeLeft, safeRight, maxTop } =
getFloatingViewportBounds(panelHeight);
const width = Math.min(panelWidth, Math.max(260, safeRight - safeLeft));
let top = anchor.top + toolbarHeight + gap;
if (top + panelHeight > safeBottom) {
top = anchor.top - panelHeight - gap;
}
top = Math.min(Math.max(top, viewportPadding), maxTop);
const preferredLeft = anchor.left;
const left = Math.min(
Math.max(preferredLeft, safeLeft),
Math.max(safeLeft, safeRight - width)
);
return {
top,
left,
width,
maxHeight: Math.max(260, Math.min(panelHeight, safeBottom - top)),
};
};
const getBoxCutoutToolbarPosition = () => {
if (containerRef.current && typeof window !== "undefined") {
const rect = containerRef.current.getBoundingClientRect();
@ -5204,6 +5561,195 @@ export function ImageNode({
}
}
const handleSubmitAiWashQuick = useCallback(async () => {
if (!data.src) {
toast.error("请先上传图片");
return;
}
if (isCanvasImageUploadPending(node) || !isHttpImageUrl(data.src)) {
toast.error("图片仍在本地,上传完成后再进行 AI洗图");
return;
}
if (!aiWashQuickConfig.enabled) {
toast.error("AI洗图当前未开启,请先在系统设置中启用");
return;
}
if (!selectedAiWashQuickPreset) {
toast.error("当前没有可执行的 AI洗图预设,请联系管理员完成配置");
return;
}
if (!effectiveAiWashQuickModelRef) {
toast.error("请先选择一个可用的生图模型");
return;
}
if (isProcessing || isSubmittingAiWashQuick) {
toast.info("正在处理中,请稍候...");
return;
}
setImageAdjustPanelOpen(false);
setCropPanelOpen(false);
setCropInteraction(null);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setTextEditPanelOpen(false);
setNotePanelOpen(false);
resetBoxCutoutState();
resetLocalEditState();
setIsProcessing(true);
setIsSubmittingAiWashQuick(true);
setAiWashQuickStatus("submitting");
const actionName = aiWashQuickModeLabel || "AI洗图";
const resultTitle = `${displayTitle}-${selectedAiWashQuickPreset.title}`;
const newNodeId = addNode({
type: "image",
position: {
x: node.position.x + node.size.width + 20,
y: node.position.y,
},
size: { width: node.size.width, height: node.size.height },
data: {
src: "",
status: "generating",
prompt: `${actionName}处理中...`,
title: resultTitle,
originalSrc: data.src,
metadata: {
editMode: "ai-wash",
sourceNodeId: node.id,
aiWashPresetId: selectedAiWashQuickPreset.id,
aiWashPresetTitle: selectedAiWashQuickPreset.title,
aiWashLevels: aiWashQuickLevels,
},
},
connections: [node.id],
});
selectNode(newNodeId);
try {
const submitResponse = await fetch("/api/ai-wash/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
prompt: aiWashQuickPrompt,
referenceImage: data.src,
selectedModelId: effectiveAiWashQuickModelRef,
imageSize: aiWashQuickConfig.imageSize,
promptExtend: aiWashQuickConfig.promptExtend,
title: selectedAiWashQuickPreset.title,
}),
});
const submitPayload = await submitResponse.json().catch(() => ({}));
if (
!submitResponse.ok ||
submitPayload?.success !== true ||
!submitPayload?.taskId
) {
throw new Error(submitPayload?.error || "AI洗图提交失败");
}
setAiWashQuickStatus("running");
updateNode(newNodeId, {
data: {
src: "",
status: "generating",
prompt: aiWashQuickPrompt || `${actionName}处理中...`,
title: resultTitle,
originalSrc: data.src,
metadata: {
editMode: "ai-wash",
sourceNodeId: node.id,
taskId: submitPayload.taskId,
aiWashPresetId: selectedAiWashQuickPreset.id,
aiWashPresetTitle: selectedAiWashQuickPreset.title,
aiWashLevels: aiWashQuickLevels,
aiWashImageSize: aiWashQuickConfig.imageSize,
},
},
});
const completed = await pollAiWashQuickTask(submitPayload.taskId);
const resolvedModelLabel =
completed.modelId ||
selectedAiWashQuickModel?.displayName ||
selectedAiWashQuickModel?.modelId ||
effectiveAiWashQuickModelRef;
updateNode(newNodeId, {
data: {
src: completed.imageUrl,
status: "completed",
prompt: completed.prompt || aiWashQuickPrompt || `${actionName}结果`,
title: resultTitle,
originalSrc: data.src,
model: resolvedModelLabel,
metadata: {
editMode: "ai-wash",
sourceNodeId: node.id,
taskId: submitPayload.taskId,
thumbnailUrl: completed.thumbnailUrl,
aiWashPresetId: selectedAiWashQuickPreset.id,
aiWashPresetTitle: selectedAiWashQuickPreset.title,
aiWashLevels: aiWashQuickLevels,
aiWashImageSize: aiWashQuickConfig.imageSize,
modelId: resolvedModelLabel,
},
},
});
setAiWashQuickStatus("succeeded");
setAiWashQuickPanelOpen(false);
toast.success("AI洗图结果已生成到画布");
} catch (error) {
const message = error instanceof Error ? error.message : "AI洗图失败";
console.error("[ImageNode] AI洗图快捷生成失败:", error);
updateNode(newNodeId, {
data: {
src: "",
status: "failed",
prompt: `${actionName}失败`,
title: `${resultTitle}-失败`,
originalSrc: data.src,
metadata: {
editMode: "ai-wash",
sourceNodeId: node.id,
error: message,
aiWashPresetId: selectedAiWashQuickPreset.id,
aiWashPresetTitle: selectedAiWashQuickPreset.title,
aiWashLevels: aiWashQuickLevels,
},
},
});
setAiWashQuickStatus("failed");
toast.error(message);
} finally {
setIsProcessing(false);
setIsSubmittingAiWashQuick(false);
}
}, [
addNode,
aiWashQuickConfig.enabled,
aiWashQuickConfig.imageSize,
aiWashQuickConfig.promptExtend,
aiWashQuickLevels,
aiWashQuickModeLabel,
aiWashQuickPrompt,
data.src,
displayTitle,
effectiveAiWashQuickModelRef,
isProcessing,
isSubmittingAiWashQuick,
node,
resetBoxCutoutState,
resetLocalEditState,
selectNode,
selectedAiWashQuickModel?.displayName,
selectedAiWashQuickModel?.modelId,
selectedAiWashQuickPreset,
updateNode,
]);
// 处理图片编辑操作 - 集成我们现有的 API
const handleAction = async (action: string) => {
if (!data.src) {
@ -5221,6 +5767,20 @@ export function ImageNode({
toast.info("正在处理中,请稍候...");
return;
}
if (action === "ai-wash") {
setCropPanelOpen(false);
setCropInteraction(null);
setImageAdjustPanelOpen(false);
setFlipRotatePanelOpen(false);
setAnglePanelOpen(false);
setTextEditPanelOpen(false);
setNotePanelOpen(false);
resetBoxCutoutState();
resetLocalEditState();
setAiWashQuickPanelOpen((current) => !current);
return;
}
setAiWashQuickPanelOpen(false);
if (action !== "local-edit") {
resetLocalEditState();
}
@ -6897,6 +7457,7 @@ export function ImageNode({
const cropPanelPosition = getCropPanelPosition();
const imageAdjustPanelPosition = getImageAdjustPanelPosition();
const toolbarPosition = getToolbarPosition();
const aiWashQuickPanelPosition = getAiWashQuickPanelPosition(toolbarPosition);
const boxCutoutToolbarPosition = getBoxCutoutToolbarPosition();
const flipRotatePanelPosition = getFlipRotatePanelPosition();
const anglePanelPosition = getAnglePanelPosition();
@ -8076,6 +8637,252 @@ export function ImageNode({
document.body
)}
{aiWashQuickPanelOpen &&
imageAiWashQuickEnabled &&
isSelected &&
!isMultiSelected &&
data.src &&
!isEditingTitle &&
!sceneFusionDialogOpen &&
!promptDialogOpen &&
!threedGenerationDialogMode &&
!notePanelOpen &&
!boxCutoutMode &&
!localEditMode &&
!cropPanelOpen &&
typeof document !== "undefined" &&
createPortal(
<div
className="fixed z-[10000] rounded-[28px] border border-slate-200 bg-white/96 p-3.5 text-slate-950 shadow-[0_20px_54px_rgba(15,23,42,0.16)] backdrop-blur-xl dark:border-white/12 dark:bg-slate-950/94 dark:text-white"
data-canvas-no-drag="true"
onClick={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
style={{
top: aiWashQuickPanelPosition.top,
left: aiWashQuickPanelPosition.left,
width: aiWashQuickPanelPosition.width,
maxHeight: aiWashQuickPanelPosition.maxHeight,
overflowY: "auto",
scrollbarWidth: "thin",
}}
>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[16px] border border-slate-200 bg-white shadow-[0_8px_18px_rgba(15,23,42,0.06)] dark:border-white/12 dark:bg-white/8">
<CanvasToolbarIcon
className="h-5 w-5"
fallback={Sparkles}
iconUrl={aiWashQuickConfig.modeIconUrl}
/>
</span>
<div className="min-w-0">
<div className="truncate font-semibold text-[16px] tracking-[-0.03em]">
{aiWashQuickModeLabel}
</div>
<div className="mt-0.5 text-[12px] text-slate-500 dark:text-white/52">
选中图片快捷洗图
</div>
</div>
</div>
<button
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-slate-200 bg-white text-slate-500 transition hover:border-slate-950 hover:text-slate-950 dark:border-white/12 dark:bg-white/8 dark:text-white/60 dark:hover:border-white dark:hover:text-white"
onClick={() => setAiWashQuickPanelOpen(false)}
title="关闭"
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
{aiWashConfigLoading ? (
<div className="mb-3 flex items-center gap-2 rounded-[18px] border border-slate-200 bg-slate-50 px-3 py-2 text-[13px] text-slate-500 dark:border-white/10 dark:bg-white/6 dark:text-white/58">
<Loader2 className="h-4 w-4 animate-spin" />
正在加载 AI洗图 配置...
</div>
) : null}
<div className="space-y-3">
<div className="rounded-[22px] border border-slate-200 bg-slate-50/70 p-3 dark:border-white/10 dark:bg-white/[0.04]">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-[14px] font-semibold">
<SlidersHorizontal className="h-4 w-4" />
渲染目标
</div>
<span className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-[11px] text-slate-500 dark:border-white/10 dark:bg-white/8 dark:text-white/58">
{getAiWashStatusLabel(aiWashQuickStatus)}
</span>
</div>
<div className="space-y-2">
{aiWashQuickPresets.map((preset) => {
const isActive = selectedAiWashQuickPreset?.id === preset.id;
return (
<button
className={cn(
"w-full rounded-[16px] border px-3 py-2.5 text-left transition",
isActive
? "border-slate-950 bg-slate-950 text-white shadow-[0_10px_22px_rgba(15,23,42,0.16)] dark:border-white dark:bg-white dark:text-slate-950"
: "border-slate-200 bg-white text-slate-600 hover:border-slate-950 hover:text-slate-950 dark:border-white/10 dark:bg-white/7 dark:text-white/64 dark:hover:border-white dark:hover:text-white"
)}
key={preset.id}
onClick={() => {
setAiWashQuickPresetId(preset.id);
setAiWashQuickStatus("idle");
}}
type="button"
>
<div className="truncate font-semibold text-[13px]">
{preset.title}
</div>
{preset.description ? (
<div
className={cn(
"mt-1 line-clamp-2 text-[12px] leading-5",
isActive ? "text-white/72 dark:text-slate-700" : "text-slate-500 dark:text-white/46"
)}
>
{preset.description}
</div>
) : null}
</button>
);
})}
</div>
</div>
<div className="rounded-[22px] border border-slate-200 bg-white p-3 dark:border-white/10 dark:bg-white/[0.04]">
<div className="mb-2 text-[14px] font-semibold">参数设置</div>
{shouldShowAiWashQuickModelSelector ? (
<div className="mb-3 space-y-1.5">
<Label className="text-[12px] text-slate-500 dark:text-white/54">
生图模型
</Label>
<Select
onValueChange={setAiWashQuickModelId}
value={selectedAiWashQuickModel?.id || aiWashQuickModelId}
>
<SelectTrigger className="h-10 rounded-[15px] border-slate-200 bg-white text-[13px] shadow-[0_1px_2px_rgba(15,23,42,0.04)] dark:border-white/10 dark:bg-white/8">
<SelectValue placeholder="选择生图模型" />
</SelectTrigger>
<SelectContent className="border-slate-200 bg-white text-slate-950 dark:border-white/10 dark:bg-slate-950 dark:text-white">
{aiWashQuickSelectableModels.map((model) => (
<SelectItem
disabled={model.selectable === false}
key={model.id}
value={model.id}
>
<span className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate">
{model.displayName}
</span>
<span className="shrink-0 text-slate-500 text-xs dark:text-white/52">
{model.unitCost > 0
? `${model.unitCost} 积分/张`
: "未计费"}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-[12px] leading-5 text-slate-500 dark:text-white/50">
{selectedAiWashQuickModel
? `当前模型:${selectedAiWashQuickModel.displayName} · ${
selectedAiWashQuickModel.unitCost > 0
? `${selectedAiWashQuickModel.unitCost} 积分/张`
: "未设置积分"
}`
: aiWashModelOptionsLoading
? "正在加载可选模型..."
: "没有可选模型"}
</div>
</div>
) : selectedAiWashQuickModel ? (
<div className="mb-3 rounded-[15px] border border-slate-200 bg-slate-50 px-3 py-2 text-[12px] text-slate-500 dark:border-white/10 dark:bg-white/6 dark:text-white/52">
管理员已锁定模型:{selectedAiWashQuickModel.displayName}
{selectedAiWashQuickModel.unitCost > 0
? ` · ${selectedAiWashQuickModel.unitCost} 积分/张`
: ""}
</div>
) : null}
<div className="space-y-2.5">
{aiWashQuickControls.map((control) => {
const selectedIndex =
aiWashQuickLevels[control.id] ?? control.defaultLevel;
const activeLevel =
getAiWashControlLevelByIndex(control, selectedIndex) ||
control.levels[0] ||
null;
return (
<div
className="rounded-[16px] border border-slate-200 bg-slate-50/70 px-3 py-2.5 dark:border-white/10 dark:bg-white/[0.04]"
key={control.id}
>
<div className="mb-2 flex items-center justify-between gap-2">
<div className="text-[13px] font-semibold">
{control.label}
</div>
<span className="min-w-9 rounded-full border border-slate-200 bg-white px-2 py-0.5 text-center text-[11px] text-slate-600 dark:border-white/10 dark:bg-white/8 dark:text-white/64">
{activeLevel?.label || selectedIndex + 1}
</span>
</div>
<Slider
className="w-full"
max={Math.max(0, control.levels.length - 1)}
min={0}
onValueChange={([next]) =>
setAiWashQuickLevels((current) => ({
...current,
[control.id]: Math.round(next ?? 0),
}))
}
rangeClassName="bg-slate-950 dark:bg-white"
step={1}
thumbClassName="h-3.5 w-3.5 border border-slate-300 bg-white shadow-[0_0_0_3px_rgba(15,23,42,0.06)]"
trackClassName="h-1.5 bg-slate-200 dark:bg-white/14"
value={[selectedIndex]}
/>
</div>
);
})}
</div>
</div>
{aiWashQuickPrompt ? (
<div className="max-h-20 overflow-hidden rounded-[18px] border border-slate-200 bg-slate-50 px-3 py-2 text-[12px] leading-5 text-slate-500 dark:border-white/10 dark:bg-white/[0.04] dark:text-white/48">
{aiWashQuickPrompt}
</div>
) : null}
<Button
className="h-11 w-full rounded-[18px] bg-slate-950 text-[14px] font-semibold text-white shadow-[0_12px_26px_rgba(15,23,42,0.16)] hover:bg-slate-900 hover:text-white disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none dark:bg-white dark:text-slate-950 dark:hover:bg-slate-100 dark:disabled:bg-white/12 dark:disabled:text-white/30"
disabled={
isSubmittingAiWashQuick ||
!selectedAiWashQuickPreset ||
!effectiveAiWashQuickModelRef
}
onClick={() => void handleSubmitAiWashQuick()}
type="button"
>
{isSubmittingAiWashQuick ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{isSubmittingAiWashQuick
? "正在 AI洗图"
: `开始 ${aiWashQuickModeLabel}${
selectedAiWashQuickModel?.unitCost
? ` · ${selectedAiWashQuickModel.unitCost} 积分`
: ""
}`}
</Button>
</div>
</div>,
document.body
)}
{isSelected &&
!isMultiSelected &&
data.src &&
@ -8221,6 +9028,33 @@ export function ImageNode({
</Button>
) : null}
{imageAiWashQuickEnabled ? (
<Button
className={cn(
"h-10 rounded-xl px-3 font-medium text-[13px] transition",
aiWashQuickPanelOpen
? "bg-slate-950 text-white hover:bg-slate-900 hover:text-white dark:bg-white dark:text-slate-950 dark:hover:bg-slate-100"
: "text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"
)}
disabled={isProcessing || isSubmittingAiWashQuick}
onClick={() => handleAction("ai-wash")}
title={aiWashQuickModeLabel}
variant="ghost"
>
{isSubmittingAiWashQuick ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<CanvasToolbarIcon
active={aiWashQuickPanelOpen}
className="mr-1.5 h-4 w-4"
fallback={Sparkles}
iconUrl={aiWashQuickConfig.modeIconUrl}
/>
)}
{aiWashQuickModeLabel}
</Button>
) : null}
{imageRemoveWatermarkEnabled ? (
<Button
className="h-10 rounded-xl px-3 font-medium text-[13px] text-slate-700 hover:bg-slate-100 hover:text-slate-900 dark:text-slate-100 dark:hover:bg-zinc-800"