ai-chatbot/components/canvas/AiWashWorkspace.tsx
2026-06-01 01:14:07 +08:00

1472 lines
57 KiB
TypeScript
Raw 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 {
Clock3,
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 { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
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 { buildImageProxyUrl } from "@/lib/image-delivery";
import { cn } from "@/lib/utils";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
const PANEL_CLASS =
"rounded-[28px] border border-[#e5e5e5] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_20px_40px_rgba(0,0,0,0.06)]";
const SUBTLE_PANEL_CLASS =
"rounded-[22px] border border-[#ececec] bg-[#fbfbfb] shadow-[inset_0_1px_0_rgba(255,255,255,0.95)]";
const PRIMARY_ACTION_BUTTON_CLASS =
"border border-[#111111] bg-[#111111] text-white shadow-[0_16px_30px_rgba(0,0,0,0.18)] transition hover:bg-black hover:border-black disabled:border-[#e5e5e5] disabled:bg-[#f1f1f1] disabled:text-[#9a9a9a] disabled:shadow-none";
const SECONDARY_ACTION_BUTTON_CLASS =
"border border-[#d9d9d9] bg-white text-[#111111] shadow-[0_0_0_1px_rgba(0,0,0,0.02),0_10px_22px_rgba(0,0,0,0.03)] transition hover:border-[#111111] hover:bg-[#fafafa] hover:text-black disabled:border-[#e5e5e5] disabled:bg-[#f7f7f7] disabled:text-[#a5a5a5] disabled:shadow-none";
const FIELD_CLASS =
"h-10 rounded-[14px] border-[#e5e5e5] bg-white text-[#111111] shadow-[inset_0_1px_0_rgba(255,255,255,0.95),0_1px_2px_rgba(0,0,0,0.04)]";
const SELECT_CONTENT_CLASS =
"border-[#e5e5e5] bg-white text-[#111111] shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_18px_38px_rgba(0,0,0,0.08)]";
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-2.5 bg-[#ededed] shadow-inner";
const SLIDER_RANGE_CLASS = "bg-[#111111]";
const SLIDER_THUMB_CLASS =
"h-4 w-4 border border-[#d4d4d4] bg-white shadow-[0_0_0_4px_rgba(0,0,0,0.08)]";
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 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 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 [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 primaryPreset = useMemo(
() => enabledPresets.find((preset) => preset.isDefault) || enabledPresets[0] || null,
[enabledPresets]
);
const uiControls = useMemo(
() =>
AI_WASH_CONTROL_ORDER.map(
(controlId) => getPresetControl(primaryPreset as AiWashPreset, controlId)
).filter(Boolean) as AiWashControl[],
[primaryPreset]
);
const compareSourceUrl = String(
historySourceOverride || sourceImageUrl || ""
).trim();
const resolvedHistoryScopeInput = useMemo(
() => String(historyScopeKey || sourceImageUrl || "").trim(),
[historyScopeKey, sourceImageUrl]
);
const resolvedHistoryScopeKey = useMemo(
() => createHistoryScopeKey(resolvedHistoryScopeInput),
[resolvedHistoryScopeInput]
);
const selectedResult = useMemo(
() =>
results.find(
(item) => item.presetId === selectedResultId && item.status === "succeeded"
) || null,
[results, selectedResultId]
);
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 shouldShowUserModelSelector = useMemo(
() => presetExecutionStates.some((item) => item.usesUserSelectableModel),
[presetExecutionStates]
);
const totalEstimatedCost = useMemo(
() => presetExecutionStates.reduce((sum, item) => sum + item.unitCost, 0),
[presetExecutionStates]
);
useEffect(() => {
setResults(createIdleResultCards(enabledPresets));
const defaultPreset =
enabledPresets.find((preset) => preset.isDefault) || enabledPresets[0];
setSelectedResultId(defaultPreset?.id || "");
setSelectedLevelIndexByControl(
AI_WASH_CONTROL_ORDER.reduce<Record<string, number>>((accumulator, controlId) => {
accumulator[controlId] =
getPresetControl(defaultPreset, controlId)?.defaultLevel ?? 0;
return accumulator;
}, {})
);
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));
setSelectedResultId(
(enabledPresets.find((preset) => preset.isDefault) || enabledPresets[0])?.id ||
""
);
}, [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;
}
const currentRunId = runIdRef.current + 1;
runIdRef.current = currentRunId;
setHistorySourceOverride("");
setIsSubmitting(true);
setLastErrorMessage("");
setResults(
enabledPresets.map((preset) => ({
presetId: preset.id,
title: preset.title,
description: preset.description,
prompt:
presetExecutionStates.find((item) => item.presetId === preset.id)
?.resolvedPrompt || preset.prompt,
modelId:
presetExecutionStates.find((item) => item.presetId === preset.id)
?.resolvedModel?.modelId ||
preset.modelId,
isDefault: preset.isDefault,
status: "submitting",
}))
);
setSelectedResultId(
(enabledPresets.find((preset) => preset.isDefault) || enabledPresets[0])?.id ||
""
);
setComparePositionById(
enabledPresets.reduce<Record<string, number>>((accumulator, preset) => {
accumulator[preset.id] = 50;
return accumulator;
}, {})
);
const startedAt = Date.now();
const taskResults = await Promise.all<AiWashTaskOutcome>(
presetExecutionStates.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: compareSourceUrl,
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: "",
}));
if (preset.isDefault || index === 0) {
setSelectedResultId((current) => current || 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(compareSourceUrl) || undefined,
items: successfulItems,
},
...currentEntries,
]);
toast.success(
failedItems.length > 0
? `AI洗图已完成 ${successfulItems.length} 组,另有 ${failedItems.length} 组失败`
: `AI洗图已完成,共生成 ${successfulItems.length} 组结果`
);
} 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,
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 handleLoadHistoryBatch = useCallback((entry: AiWashHistoryBatch) => {
setResults(
entry.items.map((item) => ({
...item,
status: "succeeded",
}))
);
setSelectedResultId(
(entry.items.find((item) => item.isDefault) || entry.items[0])?.presetId || ""
);
setHistorySourceOverride(entry.sourceImageUrl || "");
setComparePositionById(
entry.items.reduce<Record<string, number>>((accumulator, item) => {
accumulator[item.presetId] = 50;
return accumulator;
}, {})
);
setLastErrorMessage("");
toast.success("已加载历史 AI洗图记录");
}, []);
const handleDeleteHistoryBatch = useCallback(
(entryId: string) => {
persistHistoryBatches((currentEntries) =>
currentEntries.filter((entry) => entry.id !== entryId)
);
toast.success("已删除历史记录");
},
[persistHistoryBatches]
);
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.025]" />
<div className="relative z-10 flex h-full w-full pt-24">
<aside className="relative z-20 h-full w-[420px] max-w-[42vw] shrink-0 py-6 pl-6">
<div className={cn("h-full space-y-3 overflow-y-auto pb-3", 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">
<div className={cn(PANEL_CLASS, "space-y-3 p-3.5")}>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-[16px] border border-[#ececec] bg-white text-[#111111] shadow-[0_10px_22px_rgba(0,0,0,0.05)]">
<CanvasToolbarIcon
className="h-5 w-5"
fallback={Sparkles}
iconUrl={config.modeIconUrl}
/>
</div>
<div className="min-w-0">
<div className="truncate font-semibold text-[22px] text-[#111111]">
{config.modeLabel || "AI洗图"}
</div>
</div>
</div>
<div className="rounded-full border border-[#ebebeb] bg-white px-2.5 py-1 text-[11px] text-[#666666]">
{enabledPresets.length} 个预设
</div>
</div>
<Button
className={cn(
"h-12 w-full rounded-[20px] px-4 text-[15px] font-semibold",
PRIMARY_ACTION_BUTTON_CLASS
)}
disabled={
isSubmitting ||
!compareSourceUrl ||
enabledPresets.length === 0 ||
(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
? "正在洗图"
: totalEstimatedCost > 0
? `开始 AI洗图 · ${totalEstimatedCost} 积分`
: "开始 AI洗图"}
</Button>
</div>
<div className={cn(SUBTLE_PANEL_CLASS, "space-y-3 p-3.5")}>
<div className="flex items-center justify-between">
<div className="text-[15px] font-semibold text-[#111111]">
上传底图
</div>
<Button
className={cn(
"h-9 rounded-full px-3 text-xs font-medium",
SECONDARY_ACTION_BUTTON_CLASS
)}
onClick={() => onRequestAddImage?.()}
size="sm"
type="button"
variant="outline"
>
<Upload className="mr-1.5 h-3.5 w-3.5" />
{compareSourceUrl ? "更换图片" : "选择图片"}
</Button>
</div>
{compareSourceUrl ? (
<div className="overflow-hidden rounded-[18px] border border-[#ececec] bg-white">
<img
alt="AI洗图底图"
className="aspect-[16/10] w-full object-cover"
src={getDisplayImageUrl(compareSourceUrl, 720)}
/>
</div>
) : (
<button
className="flex aspect-[16/10] w-full items-center justify-center rounded-[18px] border border-dashed border-[#d9d9d9] bg-white text-center text-sm text-[#8a8a8a] transition hover:border-[#111111] hover:text-[#111111]"
onClick={() => onRequestAddImage?.()}
type="button"
>
先上传一张底图
</button>
)}
</div>
<div className={cn(SUBTLE_PANEL_CLASS, "space-y-2 p-3.5")}>
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[14px] border border-[#ececec] bg-white text-[#111111] shadow-[0_8px_18px_rgba(0,0,0,0.04)]">
<SlidersHorizontal className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="font-semibold text-[15px] text-[#111111]">
渲染目标
</div>
</div>
</div>
<div className="space-y-2">
{presetExecutionStates.map((item) => (
<div
className="rounded-[16px] border border-[#ececec] bg-white px-3 py-2.5"
key={item.preset.id}
>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0 truncate font-medium text-sm text-[#111111]">
{item.preset.title}
</div>
<span className="shrink-0 rounded-full border border-[#ebebeb] bg-[#fafafa] px-2 py-1 text-[11px] text-[#666666]">
{item.unitCost > 0 ? `${item.unitCost} 积分/张` : "未计费"}
</span>
</div>
<div className="mt-2 flex min-w-0 items-center gap-2 text-[11px] text-[#666666]">
<span className="min-w-0 truncate rounded-full border border-[#ebebeb] bg-[#fafafa] px-2 py-1">
{item.resolvedModel?.displayName ||
item.resolvedModel?.modelId ||
(item.usesUserSelectableModel
? "前台自选模型"
: "待配置模型")}
</span>
</div>
</div>
))}
</div>
</div>
<div className={cn(SUBTLE_PANEL_CLASS, "space-y-3 p-3.5")}>
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[14px] border border-[#ececec] bg-white text-[#111111] shadow-[0_8px_18px_rgba(0,0,0,0.04)]">
<Settings2 className="h-5 w-5" />
</div>
<div className="min-w-0">
<div className="font-semibold text-[15px] text-[#111111]">
参数设置
</div>
</div>
</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-[16px] border border-[#ececec] bg-white px-3.5 py-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.94)]"
key={control.id}
>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-sm font-medium text-[#222222]">
{control.label}
</div>
<div className="inline-flex min-w-[54px] justify-center rounded-full border border-[#e7e7e7] bg-white px-2.5 py-1 font-medium text-[11px] text-[#666666] shadow-[0_4px_12px_rgba(0,0,0,0.04)]">
{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="flex min-w-0 flex-1 flex-col overflow-hidden px-6 pb-6 pt-6">
<div className="grid min-h-0 flex-1 gap-5 xl:grid-cols-3">
{results.length > 0 ? (
results.map((result) => {
const hasImage = Boolean(result.imageUrl);
const comparePosition = comparePositionById[result.presetId] ?? 50;
const isSelected = selectedResultId === result.presetId && hasImage;
return (
<button
className={cn(
PANEL_CLASS,
"flex min-h-0 flex-col overflow-hidden p-4 text-left transition",
isSelected &&
"border-[#111111] shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_18px_36px_rgba(0,0,0,0.08)]"
)}
key={result.presetId}
onClick={() => hasImage && setSelectedResultId(result.presetId)}
type="button"
>
<div className="mb-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-[22px] font-semibold text-[#111111]">
{result.title}
</div>
<div className="mt-1 text-sm leading-6 text-[#666666]">
{result.description}
</div>
</div>
<div className="shrink-0 rounded-full border border-[#ebebeb] bg-[#fafafa] px-3 py-1 text-xs text-[#666666]">
{result.status === "idle"
? "待生成"
: result.status === "submitting"
? "提交中"
: result.status === "running"
? "生成中"
: result.status === "failed"
? "失败"
: "已完成"}
</div>
</div>
<div className="relative min-h-[360px] flex-1 overflow-hidden rounded-[22px] border border-[#ececec] bg-[#f7f7f7]">
{hasImage ? (
<>
<img
alt={result.title}
className="absolute inset-0 h-full w-full object-contain"
src={getDisplayImageUrl(result.imageUrl || "", 1400)}
/>
{compareSourceUrl ? (
<>
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
style={{
clipPath: `inset(0 ${100 - comparePosition}% 0 0)`,
}}
>
<img
alt={`${result.title} 原图对比`}
className="h-full w-full object-contain"
src={getDisplayImageUrl(compareSourceUrl, 1400)}
/>
</div>
<div
className="pointer-events-none absolute inset-y-0 w-px bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.2),0_0_16px_rgba(0,0,0,0.18)]"
style={{ left: `${comparePosition}%` }}
/>
<div
className="absolute inset-0 cursor-col-resize touch-none"
onPointerCancel={(event) =>
finishCompareDrag(result.presetId, event)
}
onPointerDown={(event) =>
handleComparePointerDown(result.presetId, event)
}
onPointerMove={(event) =>
handleComparePointerMove(result.presetId, event)
}
onPointerUp={(event) =>
finishCompareDrag(result.presetId, event)
}
>
<div
className="pointer-events-none absolute inset-y-0 w-12 -translate-x-1/2"
style={{ left: `${comparePosition}%` }}
>
<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-11 w-11 -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-4 w-4" />
</div>
</div>
</div>
<div className="pointer-events-none absolute left-3 top-3 rounded-full bg-black/65 px-2.5 py-1 text-[11px] text-white">
原图
</div>
<div className="pointer-events-none absolute right-3 top-3 rounded-full bg-black/65 px-2.5 py-1 text-[11px] text-white">
洗后
</div>
</>
) : null}
</>
) : (
<div className="flex h-full min-h-[360px] items-center justify-center bg-[linear-gradient(180deg,#ffffff,#f7f7f7)]">
<div className="px-6 text-center">
{result.status === "running" || result.status === "submitting" ? (
<>
<Loader2 className="mx-auto mb-3 h-8 w-8 animate-spin text-[#666666]" />
<div className="text-sm text-[#666666]">
{result.status === "submitting"
? "正在提交模型任务"
: "模型正在生成结果"}
</div>
</>
) : result.status === "failed" ? (
<div className="text-sm leading-6 text-[#9b2c2c]">
{result.error || "该预设生成失败"}
</div>
) : (
<>
<ImageIcon className="mx-auto mb-3 h-8 w-8 text-[#b2b2b2]" />
<div className="text-sm text-[#888888]">
生成后会在这里完整展示可拖动对比图
</div>
</>
)}
</div>
</div>
)}
</div>
<div className="mt-4 flex items-center justify-between gap-3 text-xs text-[#666666]">
<div className="flex flex-wrap items-center gap-2">
{result.modelId ? (
<span className="rounded-full border border-[#ebebeb] bg-[#fafafa] px-2.5 py-1">
{result.modelId}
</span>
) : null}
{isSelected ? (
<span className="rounded-full border border-[#111111] bg-white px-2.5 py-1 text-[#111111]">
当前发送目标
</span>
) : null}
</div>
{result.createdAt ? (
<span>
{new Date(result.createdAt).toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}
</span>
) : null}
</div>
</button>
);
})
) : (
<div className={cn(PANEL_CLASS, "col-span-full p-10 text-center")}>
<Sparkles className="mx-auto mb-4 h-8 w-8 text-[#8a8a8a]" />
<div className="text-lg font-medium text-[#111111]">AI洗图准备就绪</div>
<div className="mt-2 text-sm text-[#666666]">
上传底图后,系统会按管理员配置好的多个模式同时生成洗图结果。
</div>
</div>
)}
</div>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3">
<div className="rounded-full border border-[#e5e5e5] bg-white px-4 py-2 text-xs text-[#666666] shadow-[0_0_0_1px_rgba(0,0,0,0.02),0_10px_20px_rgba(0,0,0,0.03)]">
{selectedResult
? `已选中 ${selectedResult.title},可直接发送回画布`
: "请选择或生成一张洗图结果"}
</div>
<Button
className={cn(
"h-11 rounded-[18px] px-5 text-sm 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 className={cn(PANEL_CLASS, "mt-5 p-4")}>
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 items-center justify-center rounded-[16px] border border-[#ececec] bg-white shadow-[0_10px_22px_rgba(0,0,0,0.05)]">
<HistoryIcon className="h-5 w-5 text-[#111111]" />
</div>
<div>
<div className="text-lg font-semibold text-[#111111]">
历史洗图记录
</div>
<div className="text-sm text-[#666666]">
保留最近的 AI洗图批次,支持一键回看并继续发送结果到画布。
</div>
</div>
</div>
<div className="rounded-full border border-[#ebebeb] bg-[#fafafa] px-3 py-1 text-xs text-[#666666]">
最近 {visibleHistoryBatches.length} 条
</div>
</div>
{visibleHistoryBatches.length > 0 ? (
<div className="flex gap-3 overflow-x-auto pb-1">
{visibleHistoryBatches.map((entry) => {
const cover = entry.items[0];
return (
<div
className="min-w-[260px] max-w-[260px] rounded-[22px] border border-[#ececec] bg-[#fbfbfb] p-3"
key={entry.id}
>
<button
className="w-full text-left"
onClick={() => handleLoadHistoryBatch(entry)}
type="button"
>
<div className="overflow-hidden rounded-[18px] border border-[#ececec] bg-white">
<img
alt={cover?.title || "历史洗图结果"}
className="aspect-[4/3] w-full object-cover"
src={getDisplayImageUrl(
cover?.thumbnailUrl || cover?.imageUrl || "",
520
)}
/>
</div>
<div className="mt-3 flex items-start justify-between gap-3">
<div className="min-w-0">
<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>
</div>
</button>
<div className="mt-3 flex items-center justify-between">
<div className="flex flex-wrap items-center gap-2">
{cover?.modelId ? (
<span className="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-[22px] border border-dashed border-[#dcdcdc] bg-[#fafafa] px-5 py-8 text-center text-sm text-[#8a8a8a]">
还没有历史洗图记录,生成一次后会自动出现在这里。
</div>
)}
</div>
</main>
</div>
</div>
);
}