"use client"; import { ChevronDown, Clock3, Download, History as HistoryIcon, ImageIcon, Loader2, RefreshCcw, Send, Settings2, SlidersHorizontal, Sparkles, Trash2, Upload, } from "lucide-react"; import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import useSWR from "swr"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Slider } from "@/components/ui/slider"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { AI_WASH_CONTROL_ORDER, AI_WASH_DEFAULT_HISTORY_LIMIT, defaultAiWashConfig, getEnabledAiWashPresets, normalizeAiWashConfig, type AiWashConfig, type AiWashControl, type AiWashControlId, type AiWashControlLevel, type AiWashPreset, } from "@/lib/ai-wash/config"; import { downloadCanvasImageAsPng } from "@/lib/client/canvas-image-download"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import { cn } from "@/lib/utils"; import { imageGenerationService } from "@/services/canvas"; const fetcher = (url: string) => fetch(url).then((res) => res.json()); const PANEL_CLASS = "rounded-[22px] border border-[#ececec] bg-white shadow-[0_1px_2px_rgba(0,0,0,0.04),0_14px_30px_rgba(0,0,0,0.035)]"; const PRIMARY_ACTION_BUTTON_CLASS = "border border-[#111111] bg-[#111111] text-white shadow-[0_14px_28px_rgba(0,0,0,0.14)] transition hover:bg-black hover:border-black disabled:border-[#ededed] disabled:bg-[#f7f7f7] disabled:text-[#c8c8c8] disabled:shadow-none"; const SECONDARY_ACTION_BUTTON_CLASS = "border border-[#e3e3e3] bg-white text-[#111111] shadow-[0_1px_2px_rgba(0,0,0,0.04),0_8px_18px_rgba(0,0,0,0.025)] transition hover:border-[#111111] hover:bg-[#fafafa] hover:text-black disabled:border-[#eeeeee] disabled:bg-[#fafafa] disabled:text-[#cfcfcf] disabled:shadow-none"; const FIELD_CLASS = "h-10 rounded-[16px] border-[#e9e9e9] bg-white text-[#111111] shadow-[inset_0_1px_0_rgba(255,255,255,0.95),0_1px_2px_rgba(0,0,0,0.035)]"; const SELECT_CONTENT_CLASS = "border-[#ececec] bg-white text-[#111111] shadow-[0_1px_2px_rgba(0,0,0,0.04),0_18px_38px_rgba(0,0,0,0.06)]"; const SIDEBAR_SCROLL_CLASS = "pr-1.5 [scrollbar-width:thin] [scrollbar-color:#c8c8c8_transparent] [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-[#c8c8c8] hover:[&::-webkit-scrollbar-thumb]:bg-[#a8a8a8]"; const SLIDER_TRACK_CLASS = "h-1.5 bg-[#ededed] shadow-inner"; const SLIDER_RANGE_CLASS = "bg-[#111111]"; const SLIDER_THUMB_CLASS = "h-3.5 w-3.5 border border-[#d8d8d8] bg-white shadow-[0_0_0_3px_rgba(0,0,0,0.05)]"; const AI_WASH_HISTORY_STORAGE_KEY = "ai-chatbot:ai-wash-history:v1"; const AI_WASH_HISTORY_TOTAL_LIMIT = 24; const AI_WASH_TASK_POLL_INTERVAL_MS = 2500; const AI_WASH_TASK_POLL_LIMIT = 80; type AiWashResultStatus = | "idle" | "submitting" | "running" | "succeeded" | "failed"; type AiWashResultCard = { presetId: string; title: string; description: string; prompt: string; modelId: string; isDefault: boolean; status: AiWashResultStatus; imageUrl?: string; thumbnailUrl?: string; width?: number; height?: number; taskId?: string; error?: string; createdAt?: number; }; type AiWashHistoryBatch = { id: string; scopeKey: string; createdAt: number; sourceImageUrl?: string; items: AiWashResultCard[]; }; type AiWashTaskOutcome = | { presetId: string; status: "stale"; } | { presetId: string; status: "failed"; error: string; } | { presetId: string; status: "succeeded"; item: AiWashResultCard & { status: "succeeded" }; }; 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[]; }; interface AiWashWorkspaceProps { sourceImageUrl?: string | null; historyScopeKey?: string | null; onRequestAddImage?: () => void; onApplyResult: (payload: { imageUrl: string; sourceImageUrl?: string; prompt: string; title?: string; model?: string; width?: number; height?: number; }) => void; } function clampNumber(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } function sleep(ms: number) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } function hashValue(value: string) { let hash = 2_166_136_261; for (let index = 0; index < value.length; index += 1) { hash ^= value.charCodeAt(index); hash = Math.imul(hash, 16_777_619); } return (hash >>> 0).toString(36); } function shouldUploadAiWashReferenceImage(value: string) { return /^data:image\/[a-z0-9.+-]+;base64,/i.test(value) || value.startsWith("blob:"); } async function uploadAiWashReferenceImage(imageUrl: string) { const normalized = imageUrl.trim(); if (!shouldUploadAiWashReferenceImage(normalized)) { return normalized; } const response = await fetch(normalized); if (!response.ok) { throw new Error("读取洗图底图失败,请重新上传图片"); } const blob = await response.blob(); if (!blob.type.startsWith("image/")) { throw new Error("洗图底图格式无效,请上传图片文件"); } const extension = blob.type.includes("jpeg") ? "jpg" : blob.type.includes("webp") ? "webp" : blob.type.includes("png") ? "png" : "jpg"; const file = new File([blob], `ai-wash-reference-${Date.now()}.${extension}`, { type: blob.type || "image/jpeg", }); const uploadResult = await imageGenerationService.uploadImage(file); const uploadedUrl = uploadResult.data?.url || ""; if (!uploadResult.success || !uploadedUrl) { throw new Error(uploadResult.error || "洗图底图上传失败,请稍后重试"); } return uploadedUrl; } function createHistoryScopeKey(value?: string | null) { const raw = String(value || "").trim(); return raw ? `ai-wash-scope-${hashValue(raw)}` : "ai-wash-scope-global"; } function createIdleResultCards(presets: AiWashPreset[]): AiWashResultCard[] { return presets.map((preset) => ({ presetId: preset.id, title: preset.title, description: preset.description, prompt: preset.prompt, modelId: preset.modelId, isDefault: preset.isDefault, status: "idle", })); } function normalizeHistoryBatch(value: unknown): AiWashHistoryBatch | null { if (!value || typeof value !== "object") return null; const raw = value as Partial; const items = Array.isArray(raw.items) ? raw.items .map((item) => { if (!item || typeof item !== "object") return null; const typedItem = item as Partial; const imageUrl = String(typedItem.imageUrl || "").trim(); if (!imageUrl) return null; return { presetId: String(typedItem.presetId || "").trim() || hashValue(imageUrl), title: String(typedItem.title || "AI洗图结果").trim(), description: String(typedItem.description || "").trim(), prompt: String(typedItem.prompt || "").trim(), modelId: String(typedItem.modelId || "").trim(), isDefault: typedItem.isDefault === true, status: "succeeded" as const, imageUrl, thumbnailUrl: String(typedItem.thumbnailUrl || imageUrl).trim() || imageUrl, width: Number.isFinite(Number(typedItem.width)) ? Number(typedItem.width) : undefined, height: Number.isFinite(Number(typedItem.height)) ? Number(typedItem.height) : undefined, createdAt: Number.isFinite(Number(typedItem.createdAt)) ? Number(typedItem.createdAt) : undefined, }; }) .filter(Boolean) as AiWashResultCard[] : []; if (items.length === 0) return null; return { id: String(raw.id || "").trim() || `ai-wash-history-${hashValue(items[0].imageUrl || "")}`, scopeKey: String(raw.scopeKey || "").trim() || createHistoryScopeKey(), createdAt: Number.isFinite(Number(raw.createdAt)) ? Number(raw.createdAt) : Date.now(), sourceImageUrl: String(raw.sourceImageUrl || "").trim() || undefined, items, }; } function readPersistedHistory() { if (typeof window === "undefined") return [] as AiWashHistoryBatch[]; try { const raw = window.localStorage.getItem(AI_WASH_HISTORY_STORAGE_KEY); if (!raw) return []; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed .map((item) => normalizeHistoryBatch(item)) .filter(Boolean) as AiWashHistoryBatch[]; } catch { return []; } } function writePersistedHistory(entries: AiWashHistoryBatch[]) { if (typeof window === "undefined") return; window.localStorage.setItem( AI_WASH_HISTORY_STORAGE_KEY, JSON.stringify(entries.slice(0, AI_WASH_HISTORY_TOTAL_LIMIT)) ); } function getDisplayImageUrl(url: string, width: number) { const raw = String(url || "").trim(); if (!raw) return ""; if (raw.startsWith("data:")) return raw; return buildImageProxyUrl(raw, { width }); } function getPersistableSourceImageUrl(value?: string | null) { const raw = String(value || "").trim(); if (!raw) return ""; if (/^https?:\/\//i.test(raw)) return raw; return raw.length <= 120_000 ? raw : ""; } function getPresetControl( preset: AiWashPreset | null | undefined, controlId: AiWashControlId ): AiWashControl | null { if (!preset) return null; return preset.controls.find((control) => control.id === controlId) || null; } function getControlLevelByIndex( 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 getPresetDefaultLevelIndexByControl(preset?: AiWashPreset | null) { if (!preset) return {}; return AI_WASH_CONTROL_ORDER.reduce>( (accumulator, controlId) => { accumulator[controlId] = getPresetControl(preset, controlId)?.defaultLevel ?? 0; return accumulator; }, {} ); } function getAiWashStatusLabel(status?: AiWashResultStatus) { switch (status) { case "submitting": return "提交中"; case "running": return "生成中"; case "succeeded": return "已完成"; case "failed": return "失败"; case "idle": default: return "待生成"; } } function buildAiWashDownloadName(result: AiWashResultCard) { const createdAt = result.createdAt || Date.now(); return `ai-wash-${result.title || result.presetId || "result"}-${createdAt}`; } function composePresetPrompt(params: { preset: AiWashPreset; selectedLevelIndexByControl: Record; }) { const promptParts = [params.preset.prompt.trim()].filter(Boolean); for (const controlId of AI_WASH_CONTROL_ORDER) { const level = getControlLevelByIndex( getPresetControl(params.preset, controlId), params.selectedLevelIndexByControl[controlId] ?? 0 ); const fragment = String(level?.promptFragment || "").trim(); if (fragment) { promptParts.push(fragment); } } return promptParts.join("\n"); } function resolvePresetAdminModelId(params: { preset: AiWashPreset; selectedLevelIndexByControl: Record; }) { for (const controlId of AI_WASH_CONTROL_ORDER) { const level = getControlLevelByIndex( getPresetControl(params.preset, controlId), params.selectedLevelIndexByControl[controlId] ?? 0 ); const levelModelId = String(level?.modelId || "").trim(); if (levelModelId) return levelModelId; } return String(params.preset.modelId || "").trim(); } async function pollAiWashTask(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; }; if (task.status === "succeeded") { 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 (task.status === "failed") { throw new Error(task.error || "AI洗图任务失败"); } await sleep(AI_WASH_TASK_POLL_INTERVAL_MS); } throw new Error("AI洗图任务轮询超时,请稍后到历史记录查看"); } export function AiWashWorkspace({ sourceImageUrl, historyScopeKey, onRequestAddImage, onApplyResult, }: AiWashWorkspaceProps) { const [results, setResults] = useState([]); const [selectedResultId, setSelectedResultId] = useState(""); const [selectedUserModelId, setSelectedUserModelId] = useState(""); const [selectedLevelIndexByControl, setSelectedLevelIndexByControl] = useState>({}); const [historySourceOverride, setHistorySourceOverride] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); const [downloadingResultId, setDownloadingResultId] = useState( null ); const [isHistoryExpanded, setIsHistoryExpanded] = useState(false); const [lastErrorMessage, setLastErrorMessage] = useState(""); const [comparePositionById, setComparePositionById] = useState< Record >({}); const [persistedHistoryBatches, setPersistedHistoryBatches] = useState< AiWashHistoryBatch[] >(() => readPersistedHistory()); const dragStateRef = useRef<{ presetId: string; pointerId: number } | null>( null ); const runIdRef = useRef(0); const { data: configPayload, isLoading } = useSWR( "/api/ai-wash/config", fetcher ); const { data: modelOptionsPayload, isLoading: modelOptionsLoading } = useSWR("/api/ai-wash/model-options", fetcher); const config = useMemo( () => normalizeAiWashConfig(configPayload?.config || defaultAiWashConfig()), [configPayload?.config] ); const lockedModelOptions = useMemo( () => configPayload?.lockedModels || [], [configPayload?.lockedModels] ); const selectableModelOptions = useMemo( () => modelOptionsPayload?.models || [], [modelOptionsPayload?.models] ); const enabledPresets = useMemo( () => getEnabledAiWashPresets(config).filter((preset) => preset.enabled), [config] ); const compareSourceUrl = String( historySourceOverride || sourceImageUrl || "" ).trim(); const resolvedHistoryScopeInput = useMemo( () => String(historyScopeKey || sourceImageUrl || "").trim(), [historyScopeKey, sourceImageUrl] ); const resolvedHistoryScopeKey = useMemo( () => createHistoryScopeKey(resolvedHistoryScopeInput), [resolvedHistoryScopeInput] ); const selectedPreviewResult = useMemo( () => results.find((item) => item.presetId === selectedResultId) || results.find((item) => item.isDefault) || results[0] || null, [results, selectedResultId] ); const selectedResult = useMemo( () => selectedPreviewResult?.status === "succeeded" ? selectedPreviewResult : null, [selectedPreviewResult] ); const selectedPreset = useMemo( () => enabledPresets.find((preset) => preset.id === selectedResultId) || enabledPresets.find((preset) => preset.id === selectedPreviewResult?.presetId) || enabledPresets.find((preset) => preset.isDefault) || enabledPresets[0] || null, [enabledPresets, selectedPreviewResult?.presetId, selectedResultId] ); const uiControls = useMemo( () => AI_WASH_CONTROL_ORDER.map( (controlId) => getPresetControl(selectedPreset, controlId) ).filter(Boolean) as AiWashControl[], [selectedPreset] ); const visibleHistoryBatches = useMemo(() => { const scoped = persistedHistoryBatches .filter( (entry) => !resolvedHistoryScopeInput || entry.scopeKey === resolvedHistoryScopeKey ) .sort((left, right) => right.createdAt - left.createdAt); return scoped.slice(0, config.historyLimit || AI_WASH_DEFAULT_HISTORY_LIMIT); }, [ config.historyLimit, persistedHistoryBatches, resolvedHistoryScopeInput, resolvedHistoryScopeKey, ]); const modelOptionByRef = useMemo(() => { const map = new Map(); [...lockedModelOptions, ...selectableModelOptions].forEach((model) => { map.set(model.id, model); map.set(model.modelId, model); }); return map; }, [lockedModelOptions, selectableModelOptions]); const selectedUserModel = useMemo( () => modelOptionByRef.get(selectedUserModelId) || null, [modelOptionByRef, selectedUserModelId] ); const presetExecutionStates = useMemo( () => enabledPresets.map((preset) => { const adminModelRef = resolvePresetAdminModelId({ preset, selectedLevelIndexByControl, }); const effectiveModelRef = adminModelRef || String(selectedUserModelId || "").trim(); const resolvedModel = modelOptionByRef.get(effectiveModelRef) || selectedUserModel || null; const usesUserSelectableModel = !adminModelRef; const resolvedPrompt = composePresetPrompt({ preset, selectedLevelIndexByControl, }); return { presetId: preset.id, preset, usesUserSelectableModel, adminModelRef, effectiveModelRef, resolvedModel, resolvedPrompt, unitCost: Math.max(0, Number(resolvedModel?.unitCost || 0)), }; }), [enabledPresets, modelOptionByRef, selectedLevelIndexByControl, selectedUserModel, selectedUserModelId] ); const selectedExecutionState = useMemo( () => presetExecutionStates.find((item) => item.presetId === selectedResultId) || presetExecutionStates.find( (item) => item.presetId === selectedPreviewResult?.presetId ) || presetExecutionStates.find((item) => item.preset.isDefault) || presetExecutionStates[0] || null, [presetExecutionStates, selectedPreviewResult?.presetId, selectedResultId] ); const selectedPresetHasExecutableModel = Boolean( selectedExecutionState?.effectiveModelRef ); const shouldShowUserModelSelector = Boolean( !config.hideModelSelector && selectedExecutionState?.usesUserSelectableModel ); useEffect(() => { setResults(createIdleResultCards(enabledPresets)); const defaultPreset = enabledPresets.find((preset) => preset.isDefault) || enabledPresets[0]; setSelectedResultId(defaultPreset?.id || ""); setSelectedLevelIndexByControl(getPresetDefaultLevelIndexByControl(defaultPreset)); setComparePositionById( enabledPresets.reduce>((accumulator, preset) => { accumulator[preset.id] = 50; return accumulator; }, {}) ); }, [enabledPresets]); useEffect(() => { if (selectedUserModelId) return; const fallbackModelId = String(modelOptionsPayload?.defaultModelId || "").trim() || selectableModelOptions.find((model) => model.isDefault)?.id || selectableModelOptions[0]?.id || ""; if (fallbackModelId) { setSelectedUserModelId(fallbackModelId); } }, [modelOptionsPayload?.defaultModelId, selectableModelOptions, selectedUserModelId]); useEffect(() => { setLastErrorMessage(""); setHistorySourceOverride(""); setResults(createIdleResultCards(enabledPresets)); const defaultPreset = enabledPresets.find((preset) => preset.isDefault) || enabledPresets[0] || null; setSelectedResultId(defaultPreset?.id || ""); setSelectedLevelIndexByControl(getPresetDefaultLevelIndexByControl(defaultPreset)); }, [enabledPresets, sourceImageUrl]); const persistHistoryBatches = useCallback( (updater: (entries: AiWashHistoryBatch[]) => AiWashHistoryBatch[]) => { setPersistedHistoryBatches((currentEntries) => { const nextEntries = updater(currentEntries).slice( 0, AI_WASH_HISTORY_TOTAL_LIMIT ); writePersistedHistory(nextEntries); return nextEntries; }); }, [] ); const updateResultCard = useCallback( (presetId: string, updater: (current: AiWashResultCard) => AiWashResultCard) => { setResults((currentResults) => currentResults.map((item) => item.presetId === presetId ? updater(item) : item ) ); }, [] ); const updateComparePosition = useCallback( (presetId: string, event: ReactPointerEvent) => { const rect = event.currentTarget.getBoundingClientRect(); if (rect.width <= 0) return; const nextPosition = ((event.clientX - rect.left) / rect.width) * 100; setComparePositionById((current) => ({ ...current, [presetId]: clampNumber(nextPosition, 0, 100), })); }, [] ); const handleComparePointerDown = useCallback( (presetId: string, event: ReactPointerEvent) => { if (event.button !== 0) return; event.preventDefault(); event.stopPropagation(); dragStateRef.current = { presetId, pointerId: event.pointerId }; event.currentTarget.setPointerCapture(event.pointerId); updateComparePosition(presetId, event); }, [updateComparePosition] ); const handleComparePointerMove = useCallback( (presetId: string, event: ReactPointerEvent) => { if ( dragStateRef.current?.pointerId !== event.pointerId || dragStateRef.current?.presetId !== presetId ) { return; } event.preventDefault(); event.stopPropagation(); updateComparePosition(presetId, event); }, [updateComparePosition] ); const finishCompareDrag = useCallback( (presetId: string, event: ReactPointerEvent) => { if ( dragStateRef.current?.pointerId !== event.pointerId || dragStateRef.current?.presetId !== presetId ) { return; } event.preventDefault(); event.stopPropagation(); if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } dragStateRef.current = null; }, [] ); const handleSubmit = useCallback(async () => { if (!config.enabled) { toast.error("AI洗图当前未开启,请先在系统设置中启用"); return; } if (!compareSourceUrl) { toast.error("请先上传一张需要洗图的底图"); return; } if (enabledPresets.length === 0) { toast.error("当前没有可执行的 AI洗图预设,请联系管理员完成配置"); return; } if (shouldShowUserModelSelector && !selectedUserModel) { toast.error("请先选择一个可用的生图模型"); return; } if (!selectedExecutionState) { toast.error("请先选择一个 AI洗图增强预设"); return; } if (!selectedPresetHasExecutableModel) { toast.error("当前预设没有可用的 AI洗图 生图模型,请先在系统设置中配置模型"); return; } const currentRunId = runIdRef.current + 1; runIdRef.current = currentRunId; setHistorySourceOverride(""); setIsSubmitting(true); setLastErrorMessage(""); const activeExecutionState = selectedExecutionState; const activePreset = activeExecutionState.preset; setResults( enabledPresets.map((preset) => { const executionState = presetExecutionStates.find((item) => item.presetId === preset.id) || null; const isActivePreset = preset.id === activePreset.id; return { presetId: preset.id, title: preset.title, description: preset.description, prompt: executionState?.resolvedPrompt || preset.prompt, modelId: executionState?.resolvedModel?.modelId || executionState?.effectiveModelRef || preset.modelId, isDefault: preset.isDefault, status: isActivePreset ? ("submitting" as const) : ("idle" as const), }; }) ); setSelectedResultId(activePreset.id); setComparePositionById( (current) => ({ ...current, [activePreset.id]: 50, }) ); const startedAt = Date.now(); let requestReferenceImage = compareSourceUrl; try { if (shouldUploadAiWashReferenceImage(compareSourceUrl)) { toast.info("正在准备洗图底图..."); } requestReferenceImage = await uploadAiWashReferenceImage(compareSourceUrl); } catch (error) { const message = error instanceof Error ? error.message : "洗图底图上传失败,请稍后重试"; setLastErrorMessage(message); toast.error(message); setIsSubmitting(false); return; } const taskResults = await Promise.all( [activeExecutionState].map(async (executionState, index) => { const { preset } = executionState; try { if (!executionState.effectiveModelRef) { throw new Error(`${preset.title} 未配置可用模型`); } const submitResponse = await fetch("/api/ai-wash/generate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: executionState.resolvedPrompt, referenceImage: requestReferenceImage, selectedModelId: executionState.effectiveModelRef, imageSize: config.imageSize, promptExtend: config.promptExtend, title: preset.title, }), }); const submitPayload = await submitResponse.json().catch(() => ({})); if (!submitResponse.ok || submitPayload?.success !== true || !submitPayload?.taskId) { throw new Error(submitPayload?.error || `${preset.title} 提交失败`); } if (runIdRef.current !== currentRunId) { return { presetId: preset.id, status: "stale" as const }; } updateResultCard(preset.id, (current) => ({ ...current, status: "running", taskId: submitPayload.taskId, modelId: String(submitPayload.modelName || submitPayload.modelId || "").trim() || executionState.resolvedModel?.displayName || executionState.resolvedModel?.modelId || current.modelId, error: "", })); const completed = await pollAiWashTask(submitPayload.taskId); if (runIdRef.current !== currentRunId) { return { presetId: preset.id, status: "stale" as const }; } const createdAt = startedAt + index; updateResultCard(preset.id, (current) => ({ ...current, status: "succeeded", imageUrl: completed.imageUrl, thumbnailUrl: completed.thumbnailUrl, modelId: completed.modelId || executionState.resolvedModel?.displayName || executionState.resolvedModel?.modelId || current.modelId, prompt: completed.prompt || current.prompt, createdAt, error: "", })); setSelectedResultId(preset.id); return { presetId: preset.id, status: "succeeded" as const, item: { presetId: preset.id, title: preset.title, description: preset.description, prompt: completed.prompt || executionState.resolvedPrompt, modelId: completed.modelId || executionState.resolvedModel?.displayName || executionState.resolvedModel?.modelId || executionState.effectiveModelRef, isDefault: preset.isDefault, status: "succeeded" as const, imageUrl: completed.imageUrl, thumbnailUrl: completed.thumbnailUrl, createdAt, }, }; } catch (error) { const message = error instanceof Error ? error.message : `${preset.title} 生成失败`; updateResultCard(preset.id, (current) => ({ ...current, status: "failed", error: message, })); return { presetId: preset.id, status: "failed" as const, error: message, }; } }) ); if (runIdRef.current !== currentRunId) { setIsSubmitting(false); return; } const successfulItems = taskResults .filter( (item): item is Extract => item.status === "succeeded" ) .map((item) => item.item); const failedItems = taskResults.filter( (item): item is Extract => item.status === "failed" ); if (successfulItems.length > 0) { const selected = successfulItems.find((item) => item.isDefault) || successfulItems[0]; setSelectedResultId(selected?.presetId || ""); persistHistoryBatches((currentEntries) => [ { id: `ai-wash-history-${startedAt.toString(36)}`, scopeKey: resolvedHistoryScopeKey, createdAt: startedAt, sourceImageUrl: getPersistableSourceImageUrl(requestReferenceImage) || undefined, items: successfulItems, }, ...currentEntries, ]); toast.success( failedItems.length > 0 ? `AI洗图已完成 ${successfulItems.length} 组,另有 ${failedItems.length} 组失败` : `AI洗图已完成:${selected?.title || "当前预设"}` ); } else { const message = failedItems[0]?.error || "AI洗图失败"; setLastErrorMessage(message); toast.error(message); } setIsSubmitting(false); }, [ compareSourceUrl, config.enabled, config.imageSize, config.promptExtend, enabledPresets, presetExecutionStates, persistHistoryBatches, resolvedHistoryScopeKey, selectedExecutionState, selectedPresetHasExecutableModel, selectedUserModel, shouldShowUserModelSelector, updateResultCard, ]); const handleApply = useCallback(() => { if (!selectedResult?.imageUrl) { toast.error("请先选择一张洗图结果"); return; } onApplyResult({ imageUrl: selectedResult.imageUrl, sourceImageUrl: compareSourceUrl || undefined, prompt: selectedResult.prompt || "AI洗图", title: selectedResult.title || "AI洗图结果", model: selectedResult.modelId || undefined, width: selectedResult.width, height: selectedResult.height, }); }, [compareSourceUrl, onApplyResult, selectedResult]); const handleDownloadSelectedResult = useCallback(async () => { if (!selectedResult?.imageUrl) { toast.error("请先选择一张洗图结果"); return; } const downloadId = selectedResult.presetId || selectedResult.imageUrl; setDownloadingResultId(downloadId); try { await downloadCanvasImageAsPng({ url: selectedResult.imageUrl, name: buildAiWashDownloadName(selectedResult), candidates: [selectedResult.imageUrl], }); toast.success("图片已开始下载"); } catch (error) { toast.error(error instanceof Error ? error.message : "下载失败"); } finally { setDownloadingResultId((current) => current === downloadId ? null : current ); } }, [selectedResult]); const handleLoadHistoryBatch = useCallback((entry: AiWashHistoryBatch) => { const loadedPresetId = (entry.items.find((item) => item.isDefault) || entry.items[0])?.presetId || ""; const loadedPreset = enabledPresets.find((preset) => preset.id === loadedPresetId) || null; setResults( entry.items.map((item) => ({ ...item, status: "succeeded", })) ); setSelectedResultId(loadedPresetId); setSelectedLevelIndexByControl(getPresetDefaultLevelIndexByControl(loadedPreset)); setHistorySourceOverride(entry.sourceImageUrl || ""); setComparePositionById( entry.items.reduce>((accumulator, item) => { accumulator[item.presetId] = 50; return accumulator; }, {}) ); setLastErrorMessage(""); toast.success("已加载历史 AI洗图记录"); }, [enabledPresets]); const handleDeleteHistoryBatch = useCallback( (entryId: string) => { persistHistoryBatches((currentEntries) => currentEntries.filter((entry) => entry.id !== entryId) ); toast.success("已删除历史记录"); }, [persistHistoryBatches] ); const previewComparePosition = selectedPreviewResult ? comparePositionById[selectedPreviewResult.presetId] ?? 50 : 50; const previewHasImage = Boolean(selectedPreviewResult?.imageUrl); const previewIsPending = selectedPreviewResult?.status === "running" || selectedPreviewResult?.status === "submitting" || selectedPreviewResult?.status === "failed"; return (
{compareSourceUrl ? ( ) : null}
{selectedPreviewResult && previewHasImage ? (
{selectedPreviewResult.title} {compareSourceUrl ? ( <>
{`${selectedPreviewResult.title}
finishCompareDrag(selectedPreviewResult.presetId, event) } onPointerDown={(event) => handleComparePointerDown( selectedPreviewResult.presetId, event ) } onPointerMove={(event) => handleComparePointerMove( selectedPreviewResult.presetId, event ) } onPointerUp={(event) => finishCompareDrag(selectedPreviewResult.presetId, event) } >
原图
洗后
) : null}
) : compareSourceUrl ? (
AI洗图底图
原图
{previewIsPending ? (
{selectedPreviewResult.status === "running" || selectedPreviewResult.status === "submitting" ? ( <>
{selectedPreviewResult.status === "submitting" ? "正在提交模型任务" : "模型正在生成结果"}
结果完成后会在同一区域切换为可拖动对比图。
) : ( <>
该目标生成失败
{selectedPreviewResult.error || "请调整配置后重新生成。"}
)}
) : null}
) : (
选择本地图后即可开始 {config.modeLabel || "AI洗图"}
)}
生成后可直接发送回画布
{selectedResult?.imageUrl ? ( ) : null} {isHistoryExpanded ? (
{visibleHistoryBatches.length > 0 ? (
{visibleHistoryBatches.map((entry) => { const cover = entry.items[0]; return (
{cover?.modelId ? ( {cover.modelId} ) : null} {entry.sourceImageUrl ? ( 可对比 ) : null}
); })}
) : (
还没有历史洗图记录,生成一次后会自动出现在这里。
)}
) : null}
); }