diff --git a/components/canvas/RealtimeCanvasDialog.tsx b/components/canvas/RealtimeCanvasDialog.tsx index 2c984d979..b71ef1ad0 100644 --- a/components/canvas/RealtimeCanvasDialog.tsx +++ b/components/canvas/RealtimeCanvasDialog.tsx @@ -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" > - + + + {realtimeModeRealtimeItem.label} @@ -3534,12 +3540,14 @@ export function RealtimeCanvasDialog({ onClick={() => handleWorkspaceModeChange("enhance-pro")} type="button" > - + + + {realtimeModeEnhanceItem.label} @@ -3556,12 +3564,14 @@ export function RealtimeCanvasDialog({ onClick={() => handleWorkspaceModeChange("ai-wash")} type="button" > - + + + {aiWashModeLabel} ) : null} diff --git a/components/canvas/nodes/ImageNode.tsx b/components/canvas/nodes/ImageNode.tsx index 503d1fa90..89049be1e 100644 --- a/components/canvas/nodes/ImageNode.tsx +++ b/components/canvas/nodes/ImageNode.tsx @@ -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; +}) { + 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; +}) { + 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("/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( + 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 + >({}); + const [isSubmittingAiWashQuick, setIsSubmittingAiWashQuick] = useState(false); + const [aiWashQuickStatus, setAiWashQuickStatus] = + useState("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(); + [...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>( + (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( +
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", + }} + > +
+
+ + + +
+
+ {aiWashQuickModeLabel} +
+
+ 选中图片快捷洗图 +
+
+
+ +
+ + {aiWashConfigLoading ? ( +
+ + 正在加载 AI洗图 配置... +
+ ) : null} + +
+
+
+
+ + 渲染目标 +
+ + {getAiWashStatusLabel(aiWashQuickStatus)} + +
+
+ {aiWashQuickPresets.map((preset) => { + const isActive = selectedAiWashQuickPreset?.id === preset.id; + return ( + + ); + })} +
+
+ +
+
参数设置
+ {shouldShowAiWashQuickModelSelector ? ( +
+ + +
+ {selectedAiWashQuickModel + ? `当前模型:${selectedAiWashQuickModel.displayName} · ${ + selectedAiWashQuickModel.unitCost > 0 + ? `${selectedAiWashQuickModel.unitCost} 积分/张` + : "未设置积分" + }` + : aiWashModelOptionsLoading + ? "正在加载可选模型..." + : "没有可选模型"} +
+
+ ) : selectedAiWashQuickModel ? ( +
+ 管理员已锁定模型:{selectedAiWashQuickModel.displayName} + {selectedAiWashQuickModel.unitCost > 0 + ? ` · ${selectedAiWashQuickModel.unitCost} 积分/张` + : ""} +
+ ) : null} + +
+ {aiWashQuickControls.map((control) => { + const selectedIndex = + aiWashQuickLevels[control.id] ?? control.defaultLevel; + const activeLevel = + getAiWashControlLevelByIndex(control, selectedIndex) || + control.levels[0] || + null; + return ( +
+
+
+ {control.label} +
+ + {activeLevel?.label || selectedIndex + 1} + +
+ + 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]} + /> +
+ ); + })} +
+
+ + {aiWashQuickPrompt ? ( +
+ {aiWashQuickPrompt} +
+ ) : null} + + +
+
, + document.body + )} + {isSelected && !isMultiSelected && data.src && @@ -8221,6 +9028,33 @@ export function ImageNode({ ) : null} + {imageAiWashQuickEnabled ? ( + + ) : null} + {imageRemoveWatermarkEnabled ? (