1603 lines
62 KiB
TypeScript
1603 lines
62 KiB
TypeScript
"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>
|
||
);
|
||
}
|