ai-chatbot/components/canvas/AiWashWorkspace.tsx
134 1a9888d825 fix: harden canvas image generation flows
(cherry picked from commit 78b0da908c4a0c22bd7eb66b5a661c07fcecb4fc)
2026-07-27 14:41:08 +00:00

1603 lines
62 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<AiWashHistoryBatch>;
const items = Array.isArray(raw.items)
? raw.items
.map((item) => {
if (!item || typeof item !== "object") return null;
const typedItem = item as Partial<AiWashResultCard>;
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<Record<string, number>>(
(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<string, number>;
}) {
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<string, number>;
}) {
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<AiWashResultCard[]>([]);
const [selectedResultId, setSelectedResultId] = useState("");
const [selectedUserModelId, setSelectedUserModelId] = useState("");
const [selectedLevelIndexByControl, setSelectedLevelIndexByControl] =
useState<Record<string, number>>({});
const [historySourceOverride, setHistorySourceOverride] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [downloadingResultId, setDownloadingResultId] = useState<string | null>(
null
);
const [isHistoryExpanded, setIsHistoryExpanded] = useState(false);
const [lastErrorMessage, setLastErrorMessage] = useState("");
const [comparePositionById, setComparePositionById] = useState<
Record<string, number>
>({});
const [persistedHistoryBatches, setPersistedHistoryBatches] = useState<
AiWashHistoryBatch[]
>(() => readPersistedHistory());
const dragStateRef = useRef<{ presetId: string; pointerId: number } | null>(
null
);
const runIdRef = useRef(0);
const { data: configPayload, isLoading } = useSWR<AiWashConfigResponse>(
"/api/ai-wash/config",
fetcher
);
const { data: modelOptionsPayload, isLoading: modelOptionsLoading } =
useSWR<AiWashModelOptionsResponse>("/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<string, AiWashModelOption>();
[...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 &&
selectedExecutionState?.resolvedModel &&
selectedExecutionState.resolvedModel.selectable !== false
);
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<Record<string, number>>((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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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(
selectedExecutionState?.resolvedModel?.disabledReason ||
"当前预设没有可用的 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<AiWashTaskOutcome>(
[activeExecutionState].map(async (executionState, index) => {
const { preset } = executionState;
try {
if (!executionState.effectiveModelRef) {
throw new Error(`${preset.title} 未配置可用模型`);
}
if (
!executionState.resolvedModel ||
executionState.resolvedModel.selectable === false
) {
throw new Error(
executionState.resolvedModel?.disabledReason ||
`${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.resolvedModel.id ||
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<AiWashTaskOutcome, { status: "succeeded" }> =>
item.status === "succeeded"
)
.map((item) => item.item);
const failedItems = taskResults.filter(
(item): item is Extract<AiWashTaskOutcome, { status: "failed" }> =>
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<Record<string, number>>((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 (
<div className="relative flex h-full w-full bg-[#ffffff] text-[#111111]">
<div className="pointer-events-none absolute inset-0 bg-[#ffffff]" />
<div className="pattern-grid-lg pointer-events-none absolute inset-0 opacity-[0.018]" />
<div className="relative z-10 flex h-full w-full pt-[86px]">
<aside className="relative z-20 order-2 -mt-12 h-[calc(100%+3rem)] w-[404px] max-w-[36vw] shrink-0 pb-5 pr-5">
<div
className={cn(
"h-full space-y-3.5 overflow-y-auto rounded-[30px] border border-[#ececec] bg-white/78 p-3.5 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_20px_54px_rgba(0,0,0,0.055)] backdrop-blur-xl",
SIDEBAR_SCROLL_CLASS
)}
>
{isLoading ? (
<div
className={cn(
PANEL_CLASS,
"flex items-center gap-2 px-4 py-3 text-[#666666] text-sm"
)}
>
<Loader2 className="h-4 w-4 animate-spin" />
正在加载 AI洗图 配置...
</div>
) : null}
{!config.enabled && !isLoading ? (
<div className="rounded-[24px] border border-[#e5e5e5] bg-white px-4 py-3 text-[#333333] text-sm leading-6 shadow-[0_10px_24px_rgba(0,0,0,0.04)]">
当前未启用 AI洗图。请先在系统设置的“本地AI设置”里开启并配置预设。
</div>
) : null}
{lastErrorMessage ? (
<div className="rounded-[24px] border border-[#f0d4d4] bg-[#fff7f7] px-4 py-3 text-[#9b2c2c] text-sm leading-6 shadow-[0_10px_24px_rgba(0,0,0,0.04)]">
{lastErrorMessage}
</div>
) : null}
<div className="space-y-3.5">
<div className="rounded-[26px] border border-[#ededed] bg-[#fbfbfb]/82 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.9)]">
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[18px] border border-[#ececec] bg-white text-[#111111] shadow-[0_8px_18px_rgba(0,0,0,0.045)]">
<SlidersHorizontal className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="inline-flex rounded-full border border-[#e7e7e7] bg-white px-2.5 py-0.5 font-semibold text-[10px] text-[#777777] tracking-[0.18em]">
STUDIO SETUP
</div>
<div className="mt-1.5 font-semibold text-[18px] text-[#111111] tracking-[-0.03em]">
设置
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="rounded-full border border-[#e6e6e6] bg-white px-3 py-1 text-xs text-[#666666]">
Redefine
</span>
<ChevronDown className="h-4 w-4 rotate-180 text-[#666666]" />
</div>
</div>
</div>
<div className="rounded-[24px] border border-[#ededed] bg-[#fbfbfb]/84 p-3.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.95)]">
<div className="mb-2.5 font-semibold text-[16px] text-[#111111] tracking-[-0.02em]">
增强预设
</div>
<div className="space-y-2">
{presetExecutionStates.map((item) => {
const targetResult = results.find(
(result) => result.presetId === item.preset.id
);
const isActive =
selectedResultId === item.preset.id ||
(!selectedResultId &&
selectedPreviewResult?.presetId === item.preset.id);
return (
<button
aria-pressed={isActive}
className={cn(
"w-full rounded-[17px] border px-3.5 py-2.5 text-left transition",
isActive
? "border-[#111111] bg-white text-[#111111] shadow-[0_8px_18px_rgba(0,0,0,0.055)]"
: "border-[#e9e9e9] bg-white/92 text-[#666666] hover:border-[#d6d6d6] hover:text-[#111111]"
)}
key={item.preset.id}
onClick={() => {
setSelectedResultId(item.preset.id);
setSelectedLevelIndexByControl(
getPresetDefaultLevelIndexByControl(item.preset)
);
}}
type="button"
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate font-medium text-[14px]">
{item.preset.title}
</div>
<span
className={cn(
"shrink-0 rounded-full border px-2 py-0.5 text-[10px]",
isActive
? "border-[#111111] bg-[#111111] text-white"
: "border-[#ebebeb] bg-[#fafafa] text-[#666666]"
)}
>
{getAiWashStatusLabel(targetResult?.status)}
</span>
</div>
</button>
);
})}
</div>
<div className="mt-3 max-h-24 overflow-hidden rounded-[17px] border border-[#ececec] bg-white/92 px-3.5 py-2.5 text-[13px] leading-6 text-[#666666] shadow-[0_1px_2px_rgba(0,0,0,0.025)]">
{selectedExecutionState?.resolvedPrompt ||
"选择预设后会使用管理员配置的提示词进行 AI洗图。"}
</div>
</div>
<div className="rounded-[24px] border border-[#ededed] bg-[#fbfbfb]/84 p-3.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.95)]">
<div className="mb-3 flex min-w-0 items-center justify-between gap-3">
<div className="font-semibold text-[16px] text-[#111111] tracking-[-0.02em]">
参数设置
</div>
<Settings2 className="h-4 w-4 text-[#777777]" />
</div>
{shouldShowUserModelSelector ? (
<div className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="text-[12px] font-medium text-[#666666]">
生图模型
</div>
{selectedUserModel?.creditDiscountLabel ? (
<span className="rounded-full bg-emerald-50 px-2 py-1 text-[11px] text-emerald-700">
{selectedUserModel.creditDiscountLabel}
</span>
) : null}
</div>
<Select
onValueChange={setSelectedUserModelId}
value={selectedUserModel?.id || selectedUserModelId}
>
<SelectTrigger className={FIELD_CLASS}>
<SelectValue placeholder="选择生图模型" />
</SelectTrigger>
<SelectContent className={SELECT_CONTENT_CLASS}>
{selectableModelOptions.map((model) => (
<SelectItem
disabled={model.selectable === false}
key={model.id}
value={model.id}
>
<div className="flex w-full min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate">
{model.displayName}
</span>
<span className="shrink-0 text-[#777777] text-xs">
{model.unitCost > 0
? `${model.unitCost} 积分/张`
: "未计费"}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="text-[12px] leading-5 text-[#666666]">
{selectedUserModel
? `当前模型:${selectedUserModel.displayName} · ${
selectedUserModel.unitCost > 0
? `${selectedUserModel.unitCost} 积分/张`
: "未设置积分"
}`
: modelOptionsLoading
? "正在加载可选模型..."
: "没有可选模型"}
</div>
</div>
) : (
null
)}
<div className="space-y-3">
{uiControls.map((control) => {
const selectedIndex =
selectedLevelIndexByControl[control.id] ?? control.defaultLevel;
const activeLevel =
getControlLevelByIndex(control, selectedIndex) ||
control.levels[0] ||
null;
return (
<div
className="rounded-[18px] border border-[#ececec] bg-white/94 px-3.5 py-3 shadow-[0_1px_2px_rgba(0,0,0,0.025)]"
key={control.id}
>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-[13px] font-medium text-[#222222]">
{control.label}
</div>
<div className="inline-flex min-w-[42px] justify-center rounded-full border border-[#e7e7e7] bg-white px-2 py-0.5 font-medium text-[11px] text-[#666666] shadow-[0_1px_2px_rgba(0,0,0,0.035)]">
{activeLevel?.label || selectedIndex + 1}
</div>
</div>
<Slider
className="w-full"
max={Math.max(0, control.levels.length - 1)}
min={0}
onValueChange={([next]) =>
setSelectedLevelIndexByControl((current) => ({
...current,
[control.id]: Math.round(next ?? 0),
}))
}
rangeClassName={SLIDER_RANGE_CLASS}
step={1}
thumbClassName={SLIDER_THUMB_CLASS}
trackClassName={SLIDER_TRACK_CLASS}
value={[selectedIndex]}
/>
</div>
);
})}
</div>
</div>
</div>
</div>
</aside>
<main className="order-1 relative min-w-0 flex-1 overflow-hidden px-7 pb-6 pt-5">
<div className="relative h-full w-full">
{compareSourceUrl ? (
<button
className={cn(
"absolute right-4 top-2 z-20 inline-flex h-9 items-center rounded-full px-3.5 text-[13px] font-medium",
SECONDARY_ACTION_BUTTON_CLASS
)}
onClick={() => onRequestAddImage?.()}
type="button"
>
<Upload className="mr-2 h-4 w-4" />
更换图片
</button>
) : null}
<div className="absolute inset-0 flex items-center justify-center px-8 pb-36 pt-6">
{selectedPreviewResult && previewHasImage ? (
<div className="relative max-h-full max-w-full overflow-hidden rounded-[22px] shadow-[0_16px_44px_rgba(0,0,0,0.07)]">
<img
alt={selectedPreviewResult.title}
className="block max-h-[calc(100vh-284px)] max-w-[min(62vw,1080px)] object-contain"
src={getDisplayImageUrl(selectedPreviewResult.imageUrl || "", 1800)}
/>
{compareSourceUrl ? (
<>
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
style={{
clipPath: `inset(0 ${100 - previewComparePosition}% 0 0)`,
}}
>
<img
alt={`${selectedPreviewResult.title} 原图对比`}
className="h-full w-full object-contain"
src={getDisplayImageUrl(compareSourceUrl, 1800)}
/>
</div>
<div
className="pointer-events-none absolute inset-y-0 w-px bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.22),0_0_20px_rgba(0,0,0,0.18)]"
style={{ left: `${previewComparePosition}%` }}
/>
<div
className="absolute inset-0 cursor-col-resize touch-none"
onPointerCancel={(event) =>
finishCompareDrag(selectedPreviewResult.presetId, event)
}
onPointerDown={(event) =>
handleComparePointerDown(
selectedPreviewResult.presetId,
event
)
}
onPointerMove={(event) =>
handleComparePointerMove(
selectedPreviewResult.presetId,
event
)
}
onPointerUp={(event) =>
finishCompareDrag(selectedPreviewResult.presetId, event)
}
>
<div
className="pointer-events-none absolute inset-y-0 w-12 -translate-x-1/2"
style={{ left: `${previewComparePosition}%` }}
>
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-white" />
<div className="absolute left-1/2 top-1/2 flex h-10 w-10 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-white bg-black/70 text-white shadow-[0_12px_24px_rgba(0,0,0,0.2)]">
<RefreshCcw className="h-3.5 w-3.5" />
</div>
</div>
</div>
<div className="pointer-events-none absolute left-4 top-4 rounded-full bg-black/58 px-2.5 py-1 text-[11px] text-white">
原图
</div>
<div className="pointer-events-none absolute right-4 top-4 rounded-full bg-black/58 px-2.5 py-1 text-[11px] text-white">
洗后
</div>
</>
) : null}
</div>
) : compareSourceUrl ? (
<div className="relative max-h-full max-w-full overflow-hidden rounded-[22px] shadow-[0_16px_44px_rgba(0,0,0,0.07)]">
<img
alt="AI洗图底图"
className="block max-h-[calc(100vh-284px)] max-w-[min(62vw,1080px)] object-contain"
src={getDisplayImageUrl(compareSourceUrl, 1800)}
/>
<div className="pointer-events-none absolute left-4 top-4 rounded-full bg-black/58 px-2.5 py-1 text-[11px] text-white">
原图
</div>
{previewIsPending ? (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/78 px-6 text-center backdrop-blur-[2px]">
<div className="max-w-sm rounded-[22px] border border-[#ececec] bg-white/92 px-5 py-4 shadow-[0_14px_34px_rgba(0,0,0,0.07)]">
{selectedPreviewResult.status === "running" ||
selectedPreviewResult.status === "submitting" ? (
<>
<Loader2 className="mx-auto mb-3 h-8 w-8 animate-spin text-[#666666]" />
<div className="text-base font-medium text-[#111111]">
{selectedPreviewResult.status === "submitting"
? "正在提交模型任务"
: "模型正在生成结果"}
</div>
<div className="mt-2 text-sm leading-6 text-[#777777]">
结果完成后会在同一区域切换为可拖动对比图。
</div>
</>
) : (
<>
<div className="text-base font-medium text-[#9b2c2c]">
该目标生成失败
</div>
<div className="mt-2 text-sm leading-6 text-[#9b2c2c]">
{selectedPreviewResult.error || "请调整配置后重新生成。"}
</div>
</>
)}
</div>
</div>
) : null}
</div>
) : (
<div className="text-center">
<button
className="inline-flex h-12 items-center rounded-[18px] bg-[#111111] px-6 text-[15px] font-semibold text-white shadow-[0_12px_28px_rgba(0,0,0,0.12)] transition hover:-translate-y-0.5 hover:bg-black"
onClick={() => onRequestAddImage?.()}
type="button"
>
<ImageIcon className="mr-2.5 h-4 w-4" />
添加图片
</button>
<div className="mt-2.5 text-[13px] text-[#777777]">
选择本地图后即可开始 {config.modeLabel || "AI洗图"}
</div>
</div>
)}
</div>
<div className="absolute bottom-2 left-1/2 z-30 flex -translate-x-1/2 flex-col items-center gap-2">
<div className="rounded-full border border-[#e7e7e7] bg-white/90 px-4 py-1.5 text-[12px] text-[#666666] shadow-[0_8px_18px_rgba(0,0,0,0.04)] backdrop-blur">
生成后可直接发送回画布
</div>
<div className="flex items-center gap-2 rounded-[30px] border border-[#e7e7e7] bg-white/86 p-2 shadow-[0_14px_36px_rgba(0,0,0,0.06)] backdrop-blur-xl">
<Button
className={cn(
"h-12 min-w-[190px] rounded-[24px] px-6 text-[14px] font-semibold",
PRIMARY_ACTION_BUTTON_CLASS
)}
disabled={
isSubmitting ||
!compareSourceUrl ||
enabledPresets.length === 0 ||
!selectedPresetHasExecutableModel ||
(shouldShowUserModelSelector && !selectedUserModel)
}
onClick={handleSubmit}
type="button"
>
{isSubmitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
{isSubmitting ? "正在洗图" : "开始 AI洗图"}
</Button>
<Button
className={cn(
"h-12 min-w-[190px] rounded-[24px] px-6 text-[14px] font-medium",
SECONDARY_ACTION_BUTTON_CLASS
)}
disabled={!selectedResult?.imageUrl}
onClick={handleApply}
type="button"
variant="outline"
>
<Send className="mr-2 h-4 w-4" />
发送到画布
</Button>
</div>
</div>
{selectedResult?.imageUrl ? (
<button
className="absolute bottom-2 right-2 z-30 inline-flex h-10 items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/92 px-4 text-xs font-medium text-[#333333] shadow-[0_10px_24px_rgba(0,0,0,0.07)] backdrop-blur transition hover:border-[#111111] hover:text-[#111111] disabled:cursor-not-allowed disabled:opacity-55"
disabled={Boolean(downloadingResultId)}
onClick={() => {
void handleDownloadSelectedResult();
}}
type="button"
>
{downloadingResultId ===
(selectedResult.presetId || selectedResult.imageUrl) ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
下载
</button>
) : null}
<button
className="absolute bottom-2 left-2 z-30 inline-flex h-10 items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/90 px-4 text-xs text-[#666666] shadow-[0_10px_24px_rgba(0,0,0,0.06)] backdrop-blur transition hover:border-[#111111] hover:text-[#111111]"
onClick={() => setIsHistoryExpanded((current) => !current)}
type="button"
>
<HistoryIcon className="h-4 w-4" />
历史 {visibleHistoryBatches.length}
<ChevronDown
className={cn(
"h-3.5 w-3.5 transition-transform",
isHistoryExpanded && "rotate-180"
)}
/>
</button>
{isHistoryExpanded ? (
<div className="absolute bottom-16 left-2 z-40 w-[380px] rounded-[28px] border border-[#e5e5e5] bg-white/94 p-3 shadow-[0_24px_60px_rgba(0,0,0,0.12)] backdrop-blur-xl">
{visibleHistoryBatches.length > 0 ? (
<div className="flex max-h-[360px] flex-col gap-3 overflow-y-auto pr-1">
{visibleHistoryBatches.map((entry) => {
const cover = entry.items[0];
return (
<div
className="rounded-[20px] border border-[#ececec] bg-[#fbfbfb] p-3"
key={entry.id}
>
<button
className="flex w-full items-center gap-3 text-left"
onClick={() => handleLoadHistoryBatch(entry)}
type="button"
>
<div className="h-16 w-20 shrink-0 overflow-hidden rounded-[14px] border border-[#ececec] bg-white">
<img
alt={cover?.title || "历史洗图结果"}
className="h-full w-full object-cover"
src={getDisplayImageUrl(
cover?.thumbnailUrl || cover?.imageUrl || "",
360
)}
/>
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-sm text-[#111111]">
{cover?.title || "AI洗图结果"}
</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-[#666666]">
<Clock3 className="h-3.5 w-3.5" />
{new Date(entry.createdAt).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</div>
</div>
<div className="rounded-full border border-[#ebebeb] bg-white px-2 py-1 text-[11px] text-[#666666]">
{entry.items.length} 张
</div>
</button>
<div className="mt-2 flex items-center justify-between">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{cover?.modelId ? (
<span className="max-w-[240px] truncate rounded-full border border-[#ebebeb] bg-white px-2 py-1 text-[11px] text-[#666666]">
{cover.modelId}
</span>
) : null}
{entry.sourceImageUrl ? (
<span className="rounded-full border border-[#dcebdd] bg-[#f4fbf5] px-2 py-1 text-[11px] text-[#2f6f3b]">
可对比
</span>
) : null}
</div>
<button
className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-[#ececec] bg-white text-[#666666] transition hover:border-[#111111] hover:text-[#111111]"
onClick={(event) => {
event.stopPropagation();
handleDeleteHistoryBatch(entry.id);
}}
title="删除历史记录"
type="button"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
) : (
<div className="rounded-[20px] border border-dashed border-[#dcdcdc] bg-[#fafafa] px-5 py-8 text-center text-sm text-[#8a8a8a]">
还没有历史洗图记录,生成一次后会自动出现在这里。
</div>
)}
</div>
) : null}
</div>
</main>
</div>
</div>
);
}