ai-chatbot/components/canvas/EnhanceProWorkspace.tsx
Your Name b8a59fe2a4
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: harden enhance pro task charging
2026-04-27 18:38:42 +08:00

1473 lines
54 KiB
TypeScript

"use client";
import {
CheckCircle2,
ChevronDown,
ImageIcon,
Loader2,
ScanFace,
Send,
Settings2,
SlidersHorizontal,
Sparkles,
} from "lucide-react";
import {
type PointerEvent as ReactPointerEvent,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import useSWR from "swr";
import { Button } from "@/components/ui/button";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Slider } from "@/components/ui/slider";
import { Textarea } from "@/components/ui/textarea";
import {
defaultEnhanceProPresets,
type EnhanceProKreaModel,
type EnhanceProOutputFormat,
type EnhanceProPreset,
type EnhanceProSubjectDetection,
} from "@/lib/enhance-pro/config";
import { buildImageProxyUrl } from "@/lib/image-delivery";
import {
calculateEnhanceProPointsEstimate,
defaultPointsSystemConfig,
type EnhanceProPointsPricingConfig,
} from "@/lib/points/config";
import { cn } from "@/lib/utils";
import {
type EnhanceProImageResult,
type EnhanceProRequestParams,
imageGenerationService,
} from "@/services/canvas";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
function createEnhanceProClientRequestId() {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `enhance-pro-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function shouldPreserveEnhanceProRequestId(message: string) {
return /任务.*已提交|避免重复提交|稍后重试查看结果|failed to fetch|network/i.test(
String(message || "")
);
}
function buildEnhanceProRequestSignature(params: EnhanceProRequestParams) {
return JSON.stringify({
imageUrl: params.imageUrl || null,
image_base64: params.image_base64 || null,
mimeType: params.mimeType || null,
prompt: params.prompt || "",
width: params.width,
height: params.height,
batchSize: params.batchSize || 1,
seed: params.seed || 0,
output_format: params.output_format || null,
subject_detection: params.subject_detection || null,
face_enhancement: params.face_enhancement === true,
face_enhancement_creativity: params.face_enhancement_creativity ?? null,
face_enhancement_strength: params.face_enhancement_strength ?? null,
crop_to_fill: params.crop_to_fill === true,
model: params.model || null,
upscaling_activated: params.upscaling_activated === true,
image_scaling_factor: params.image_scaling_factor ?? 1,
creativity: params.creativity ?? null,
texture: params.texture ?? null,
sharpen: params.sharpen ?? null,
denoise: params.denoise ?? null,
detail: params.detail ?? null,
});
}
type EnhanceProClientConfig = {
enabled: boolean;
providerName: string;
baseUrl: string;
defaultModel: EnhanceProKreaModel;
defaultOutputFormat: EnhanceProOutputFormat;
allowBatchSize: boolean;
maxBatchSize: number;
maxUpscaleFactor: number;
maxOutputEdge: number;
enableAdvancedControls: boolean;
presets: EnhanceProPreset[];
};
type EnhanceProClientPointsConfig = {
enabled: boolean;
blockOnInsufficient: boolean;
enhanceProPricing: EnhanceProPointsPricingConfig;
};
type FormState = {
prompt: string;
width: number;
height: number;
batchSize: number;
seed: number;
output_format: EnhanceProOutputFormat;
subject_detection: EnhanceProSubjectDetection;
face_enhancement: boolean;
face_enhancement_creativity: number;
face_enhancement_strength: number;
crop_to_fill: boolean;
model: EnhanceProKreaModel;
upscaling_activated: boolean;
image_scaling_factor: number;
creativity: number;
texture: number;
sharpen: number;
denoise: number;
detail: number;
};
const FALLBACK_CONFIG: EnhanceProClientConfig = {
enabled: false,
providerName: "Krea Topaz Generative",
baseUrl: "https://api.krea.ai",
defaultModel: "Redefine",
defaultOutputFormat: "jpg",
allowBatchSize: true,
maxBatchSize: 4,
maxUpscaleFactor: 4,
maxOutputEdge: 4096,
enableAdvancedControls: true,
presets: defaultEnhanceProPresets(),
};
const FALLBACK_POINTS_CONFIG: EnhanceProClientPointsConfig = {
enabled: false,
blockOnInsufficient: true,
enhanceProPricing: defaultPointsSystemConfig.enhanceProPricing,
};
const PANEL_SHELL_CLASS =
"pointer-events-auto rounded-[28px] border border-[#e5e5e5] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_20px_40px_rgba(0,0,0,0.06)]";
const SOLID_PANEL_FIELD_CLASS =
"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 SOLID_PANEL_CARD_CLASS =
"rounded-[22px] border border-[#ececec] bg-white shadow-[inset_0_1px_0_rgba(255,255,255,0.95),0_10px_22px_rgba(0,0,0,0.04)]";
const PANEL_SECTION_TITLE_CLASS =
"font-semibold text-[15px] tracking-[-0.04em] text-[#111111]";
const PANEL_SECTION_NOTE_CLASS =
"text-[12px] leading-5 text-[#666666]";
const SOLID_PANEL_SELECT_CONTENT_CLASS =
"border-[#e5e5e5] bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_18px_38px_rgba(0,0,0,0.08)]";
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 COMPACT_FIELD_CLASS = `${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[16px] text-sm`;
const COMPACT_LABEL_CLASS =
"font-medium text-[#666666] text-[11px] leading-4 tracking-[0.01em]";
const COMPACT_TOGGLE_CARD_CLASS =
"flex items-center justify-between gap-2.5 px-3.5 py-3";
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 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 SIDEBAR_SECTION_CARD_CLASS =
"rounded-[28px] border border-[#e5e5e5] bg-white p-3.5 shadow-[0_18px_36px_rgba(0,0,0,0.05)]";
const SECTION_EYEBROW_CLASS =
"inline-flex items-center gap-1.5 rounded-full border border-[#ebebeb] bg-[#fafafa] px-2.5 py-1 font-semibold text-[10px] tracking-[0.14em] text-[#666666] uppercase";
const INNER_PANEL_CLASS =
"rounded-[22px] border border-[#efefef] bg-[#fbfbfb] p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.92)]";
function clampNumber(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function fitDimensions(width: number, height: number, maxOutputEdge: number) {
const safeWidth = Math.max(1, Math.round(width || 1024));
const safeHeight = Math.max(1, Math.round(height || 1024));
const maxEdge = Math.max(
512,
Math.round(maxOutputEdge || FALLBACK_CONFIG.maxOutputEdge)
);
const currentMax = Math.max(safeWidth, safeHeight);
if (currentMax <= maxEdge) {
return { width: safeWidth, height: safeHeight };
}
const scale = maxEdge / currentMax;
return {
width: Math.max(1, Math.round(safeWidth * scale)),
height: Math.max(1, Math.round(safeHeight * scale)),
};
}
function createDefaultFormState(
config: EnhanceProClientConfig,
width: number,
height: number
): FormState {
const size = fitDimensions(width, height, config.maxOutputEdge);
return {
prompt: "",
width: size.width,
height: size.height,
batchSize: 1,
seed: 1337,
output_format: config.defaultOutputFormat,
subject_detection: "All",
face_enhancement: false,
face_enhancement_creativity: 0.5,
face_enhancement_strength: 0.5,
crop_to_fill: false,
model: config.defaultModel,
upscaling_activated: false,
image_scaling_factor: 1,
creativity: 3,
texture: 3,
sharpen: 0.5,
denoise: 0.5,
detail: 0.5,
};
}
function applyPresetToForm(
config: EnhanceProClientConfig,
width: number,
height: number,
preset?: EnhanceProPreset
): FormState {
const baseState = createDefaultFormState(config, width, height);
if (!preset) {
return baseState;
}
return {
...baseState,
prompt: preset.prompt,
batchSize: config.allowBatchSize
? clampNumber(Math.round(preset.batchSize || 1), 1, config.maxBatchSize)
: 1,
seed: Number.isFinite(Number(preset.seed))
? Math.round(Number(preset.seed))
: baseState.seed,
output_format: preset.outputFormat,
subject_detection: preset.subjectDetection,
face_enhancement: preset.faceEnhancement,
face_enhancement_creativity: clampNumber(
preset.faceEnhancementCreativity,
0,
1
),
face_enhancement_strength: clampNumber(
preset.faceEnhancementStrength,
0,
1
),
crop_to_fill: preset.cropToFill,
model: preset.model,
upscaling_activated: preset.upscalingActivated,
image_scaling_factor: clampNumber(
preset.imageScalingFactor,
1,
config.maxUpscaleFactor
),
creativity: clampNumber(Math.round(preset.creativity), 1, 6),
texture: clampNumber(Math.round(preset.texture), 1, 5),
sharpen: clampNumber(preset.sharpen, 0, 1),
denoise: clampNumber(preset.denoise, 0, 1),
detail: clampNumber(preset.detail, 0, 1),
};
}
function getDisplayImageUrl(url?: string | null, width = 1400) {
const raw = String(url || "").trim();
if (!raw) return "";
return /^https?:\/\//i.test(raw)
? buildImageProxyUrl(raw, {
width,
quality: 88,
format: "webp",
})
: raw;
}
function getSubjectDetectionLabel(
subjectDetection: EnhanceProSubjectDetection
) {
if (subjectDetection === "Foreground") return "前景";
if (subjectDetection === "Background") return "背景";
return "全部";
}
function getActiveResultLabel(index: number) {
return `结果 ${index + 1}`;
}
type ParameterSliderRowProps = {
label: string;
value: number;
min: number;
max: number;
step: number;
onChange: (value: number) => void;
formatValue?: (value: number) => string;
disabled?: boolean;
};
function ParameterSliderRow({
label,
value,
min,
max,
step,
onChange,
formatValue,
disabled = false,
}: ParameterSliderRowProps) {
const displayValue = formatValue ? formatValue(value) : String(value);
return (
<div
className={cn(
"space-y-3 rounded-[20px] border border-[#efefef] bg-white px-3.5 py-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.94)]",
disabled && "opacity-50"
)}
>
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-[12px] text-[#222222] tracking-[-0.01em]">
{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)]">
{displayValue}
</div>
</div>
<Slider
className="w-full"
disabled={disabled}
max={max}
min={min}
onValueChange={([next]) => onChange(next ?? value)}
rangeClassName={SLIDER_RANGE_CLASS}
step={step}
thumbClassName={SLIDER_THUMB_CLASS}
trackClassName={SLIDER_TRACK_CLASS}
value={[value]}
/>
</div>
);
}
function buildEnhanceSourcePayload(
sourceImageUrl?: string | null
): Pick<
EnhanceProRequestParams,
"imageUrl" | "image_base64" | "mimeType"
> | null {
const raw = String(sourceImageUrl || "").trim();
if (!raw) return null;
const dataUrlMatch = raw.match(/^data:([^;]+);base64,(.+)$/);
if (dataUrlMatch) {
return {
image_base64: dataUrlMatch[2],
mimeType: dataUrlMatch[1] || "image/png",
};
}
if (/^https?:\/\//i.test(raw)) {
return {
imageUrl: raw,
};
}
return null;
}
interface EnhanceProWorkspaceProps {
sourceImageUrl?: string | null;
onRequestAddImage?: () => void;
onApplyResult: (payload: {
imageUrl: string;
sourceImageUrl?: string;
prompt: string;
title?: string;
model?: string;
width?: number;
height?: number;
}) => void;
}
export function EnhanceProWorkspace({
sourceImageUrl,
onRequestAddImage,
onApplyResult,
}: EnhanceProWorkspaceProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [naturalSize, setNaturalSize] = useState({ width: 1024, height: 1024 });
const [selectedPresetId, setSelectedPresetId] = useState("");
const [selectedResultIndex, setSelectedResultIndex] = useState<number>(-1);
const [formState, setFormState] = useState<FormState>(() =>
createDefaultFormState(FALLBACK_CONFIG, 1024, 1024)
);
const [generatedImages, setGeneratedImages] = useState<
EnhanceProImageResult[]
>([]);
const [lastErrorMessage, setLastErrorMessage] = useState("");
const [settingsOpen, setSettingsOpen] = useState(true);
const [adjustmentsOpen, setAdjustmentsOpen] = useState(true);
const [faceOpen, setFaceOpen] = useState(true);
const [comparePosition, setComparePosition] = useState(50);
const compareDragPointerIdRef = useRef<number | null>(null);
const requestIdRef = useRef<string | null>(null);
const requestSignatureRef = useRef<string | null>(null);
const { data: configPayload, isLoading } = useSWR<{
success?: boolean;
config?: EnhanceProClientConfig;
pointsConfig?: EnhanceProClientPointsConfig;
}>("/api/enhance/pro/config", fetcher);
const config = useMemo(
() => configPayload?.config || FALLBACK_CONFIG,
[configPayload?.config]
);
const pointsConfig = useMemo(
() => configPayload?.pointsConfig || FALLBACK_POINTS_CONFIG,
[configPayload?.pointsConfig]
);
const presets = useMemo(
() => (config.presets.length ? config.presets : FALLBACK_CONFIG.presets),
[config.presets]
);
const selectedPreset = useMemo(
() => presets.find((item) => item.id === selectedPresetId) || null,
[presets, selectedPresetId]
);
const selectedGeneratedImage =
selectedResultIndex >= 0
? generatedImages[selectedResultIndex] || null
: null;
const activePreviewUrl =
selectedGeneratedImage?.originalUrl || sourceImageUrl || "";
const showComparisonPreview = Boolean(
selectedGeneratedImage?.originalUrl && sourceImageUrl
);
const activePreviewLabel =
selectedGeneratedImage && selectedResultIndex >= 0
? getActiveResultLabel(selectedResultIndex)
: "原图";
const actionHint = selectedGeneratedImage
? `已选中 ${activePreviewLabel}`
: generatedImages.length > 0
? `已生成 ${generatedImages.length} 张候选结果`
: "生成后可直接发送回画布";
const pointsEstimate = useMemo(
() =>
calculateEnhanceProPointsEstimate(
{
...defaultPointsSystemConfig,
enabled: pointsConfig.enabled,
blockOnInsufficient: pointsConfig.blockOnInsufficient,
enhanceProPricing: pointsConfig.enhanceProPricing,
},
{
width: formState.width,
height: formState.height,
batchSize: formState.batchSize,
model: formState.model,
upscalingActivated: formState.upscaling_activated,
scaleFactor: formState.image_scaling_factor,
}
),
[
formState.batchSize,
formState.height,
formState.image_scaling_factor,
formState.model,
formState.upscaling_activated,
formState.width,
pointsConfig,
]
);
useEffect(() => {
const imageSource = String(sourceImageUrl || "").trim();
if (!imageSource) {
setNaturalSize({ width: 1024, height: 1024 });
return;
}
const image = new window.Image();
image.onload = () => {
setNaturalSize({
width: image.naturalWidth || 1024,
height: image.naturalHeight || 1024,
});
};
image.onerror = () => {
setNaturalSize({ width: 1024, height: 1024 });
};
image.src = imageSource;
}, [sourceImageUrl]);
useEffect(() => {
const defaultPreset =
presets.find((preset) => preset.isDefault) || presets[0];
setSelectedPresetId(defaultPreset?.id || "");
setFormState(
applyPresetToForm(
config,
naturalSize.width,
naturalSize.height,
defaultPreset
)
);
}, [config, naturalSize.height, naturalSize.width, presets]);
useEffect(() => {
setGeneratedImages([]);
setSelectedResultIndex(-1);
setLastErrorMessage("");
}, [sourceImageUrl]);
useEffect(() => {
setComparePosition(50);
}, [selectedResultIndex]);
useEffect(() => {
if (formState.face_enhancement) {
setFaceOpen(true);
}
}, [formState.face_enhancement]);
const handleNumericField = <K extends keyof FormState>(
key: K,
nextValue: number
) => {
setFormState((prev) => ({
...prev,
[key]: nextValue,
}));
};
const handleSelectPreset = (presetId: string) => {
const preset = presets.find((item) => item.id === presetId);
setSelectedPresetId(presetId);
setFormState(
applyPresetToForm(config, naturalSize.width, naturalSize.height, preset)
);
};
const handleSubmit = async () => {
if (!config.enabled) {
toast.error("图像增强 Pro 当前未开启,请先在系统设置中启用");
return;
}
const sourcePayload = buildEnhanceSourcePayload(sourceImageUrl);
if (!sourcePayload) {
toast.error(
"当前没有可用于增强的图片,请先选择画布图片或切回实时模式准备一张结果图"
);
return;
}
setIsSubmitting(true);
setLastErrorMessage("");
const requestParams: EnhanceProRequestParams = {
...sourcePayload,
prompt: formState.prompt.trim() || "图像增强 Pro",
width: clampNumber(
Math.round(formState.width),
1,
config.maxOutputEdge
),
height: clampNumber(
Math.round(formState.height),
1,
config.maxOutputEdge
),
batchSize: config.allowBatchSize
? clampNumber(Math.round(formState.batchSize), 1, config.maxBatchSize)
: 1,
seed: Math.round(formState.seed),
output_format: formState.output_format,
subject_detection: formState.subject_detection,
face_enhancement: formState.face_enhancement,
face_enhancement_creativity: clampNumber(
formState.face_enhancement_creativity,
0,
1
),
face_enhancement_strength: clampNumber(
formState.face_enhancement_strength,
0,
1
),
crop_to_fill: formState.crop_to_fill,
model: formState.model,
upscaling_activated: formState.upscaling_activated,
image_scaling_factor: clampNumber(
formState.image_scaling_factor,
1,
config.maxUpscaleFactor
),
creativity: clampNumber(Math.round(formState.creativity), 1, 6),
texture: clampNumber(Math.round(formState.texture), 1, 5),
sharpen: clampNumber(formState.sharpen, 0, 1),
denoise: clampNumber(formState.denoise, 0, 1),
detail: clampNumber(formState.detail, 0, 1),
};
const requestSignature = buildEnhanceProRequestSignature(requestParams);
const clientRequestId =
requestIdRef.current && requestSignatureRef.current === requestSignature
? requestIdRef.current
: createEnhanceProClientRequestId();
requestIdRef.current = clientRequestId;
requestSignatureRef.current = requestSignature;
try {
const result = await imageGenerationService.enhanceImagePro({
...requestParams,
clientRequestId,
});
if (!result.success || !result.data?.images?.length) {
throw new Error(result.error || "图像增强 Pro 失败");
}
setGeneratedImages(result.data.images);
setSelectedResultIndex(0);
requestIdRef.current = null;
requestSignatureRef.current = null;
toast.success(
`图像增强 Pro 完成,已生成 ${result.data.images.length} 张结果`
);
} catch (error) {
const message =
error instanceof Error ? error.message : "图像增强 Pro 失败";
if (!shouldPreserveEnhanceProRequestId(message)) {
requestIdRef.current = null;
requestSignatureRef.current = null;
}
setLastErrorMessage(message);
toast.error(message);
} finally {
setIsSubmitting(false);
}
};
const handleApply = () => {
if (!selectedGeneratedImage?.originalUrl) {
toast.error("请先生成结果并选择一张增强图");
return;
}
onApplyResult({
imageUrl: selectedGeneratedImage.originalUrl,
sourceImageUrl: sourceImageUrl || undefined,
prompt: formState.prompt.trim() || "图像增强 Pro",
title: "图像增强 Pro 结果",
model: selectedPreset?.model || config.defaultModel,
width: selectedGeneratedImage.width,
height: selectedGeneratedImage.height,
});
};
const sidePreviewItems = useMemo(() => {
const items: Array<{
key: string;
label: string;
url: string;
isSource?: boolean;
}> = [];
if (generatedImages.length > 0 && sourceImageUrl) {
items.push({
key: "source",
label: "原图",
url: sourceImageUrl,
isSource: true,
});
}
generatedImages.forEach((image, index) => {
items.push({
key: `result-${index}`,
label: getActiveResultLabel(index),
url: image.thumbnailUrl || image.originalUrl,
});
});
return items;
}, [generatedImages, sourceImageUrl]);
const updateComparePositionFromPointer = (
event: ReactPointerEvent<HTMLDivElement>
) => {
const rect = event.currentTarget.getBoundingClientRect();
if (rect.width <= 0) return;
const nextPosition = ((event.clientX - rect.left) / rect.width) * 100;
setComparePosition(clampNumber(nextPosition, 0, 100));
};
const handleComparePointerDown = (
event: ReactPointerEvent<HTMLDivElement>
) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
compareDragPointerIdRef.current = event.pointerId;
event.currentTarget.setPointerCapture(event.pointerId);
updateComparePositionFromPointer(event);
};
const handleComparePointerMove = (
event: ReactPointerEvent<HTMLDivElement>
) => {
if (compareDragPointerIdRef.current !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
updateComparePositionFromPointer(event);
};
const finishCompareDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
if (compareDragPointerIdRef.current !== event.pointerId) return;
event.preventDefault();
event.stopPropagation();
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
compareDragPointerIdRef.current = null;
};
return (
<div className="relative flex h-full w-full bg-[#ffffff] text-[#111111]">
<div className="relative flex min-w-0 flex-1 items-center justify-center overflow-hidden px-6 pb-28 pt-24">
<div className="pointer-events-none absolute inset-0 bg-[#ffffff]" />
<div className="pattern-grid-lg pointer-events-none absolute inset-0 opacity-[0.025] [mask-image:radial-gradient(circle_at_center,black_38%,transparent_82%)]" />
{activePreviewUrl ? (
<div className="relative flex h-full w-full min-w-0 select-none items-center justify-center overflow-hidden">
<div className="relative flex h-full w-full min-w-0 items-center justify-center overflow-hidden px-4 py-4">
<div className="relative h-full w-full max-w-[min(1320px,calc(100vw-30rem))] overflow-hidden">
<img
alt={activePreviewLabel}
className="pointer-events-none absolute inset-0 h-full w-full object-contain transition-transform duration-75"
src={getDisplayImageUrl(activePreviewUrl, 1800)}
/>
{showComparisonPreview && sourceImageUrl ? (
<>
<div
className="pointer-events-none absolute inset-0 overflow-hidden"
style={{
clipPath: `inset(0 ${100 - comparePosition}% 0 0)`,
}}
>
<img
alt="原图对比"
className="h-full w-full object-contain"
src={getDisplayImageUrl(sourceImageUrl, 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.32),0_0_18px_rgba(0,0,0,0.35)]"
style={{ left: `${comparePosition}%` }}
/>
<div
className="pointer-events-auto absolute inset-0 cursor-col-resize touch-none"
data-enhance-pro-compare-control="true"
onClick={(event) => event.stopPropagation()}
onPointerCancel={finishCompareDrag}
onPointerDown={handleComparePointerDown}
onPointerMove={handleComparePointerMove}
onPointerUp={finishCompareDrag}
>
<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 shadow-[0_0_0_1px_rgba(0,0,0,0.25),0_0_18px_rgba(0,0,0,0.3)]" />
<div className="-translate-x-1/2 -translate-y-1/2 absolute top-1/2 left-1/2 flex h-9 w-9 items-center justify-center rounded-full border border-white/80 bg-black/70 text-white shadow-[0_12px_24px_rgba(0,0,0,0.28)] backdrop-blur-sm">
<SlidersHorizontal className="h-4 w-4" />
</div>
</div>
</div>
</>
) : null}
{showComparisonPreview ? (
<>
<div className="pointer-events-none absolute top-3 left-3 rounded-full bg-black/65 px-2.5 py-1 font-medium text-[11px] text-white backdrop-blur-sm">
原图
</div>
<div className="pointer-events-none absolute top-3 right-3 rounded-full bg-black/65 px-2.5 py-1 font-medium text-[11px] text-white backdrop-blur-sm">
增强后
</div>
</>
) : null}
</div>
</div>
<div className="pointer-events-none absolute top-6 left-6 flex items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/96 px-3.5 py-1.5 text-[#666666] text-xs shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_10px_24px_rgba(0,0,0,0.04)] backdrop-blur-sm">
<ImageIcon className="h-3.5 w-3.5" />
<span>{activePreviewLabel}</span>
</div>
<div className="pointer-events-none absolute top-6 right-8 hidden items-center gap-2 rounded-full border border-[#e5e5e5] bg-white/96 px-3 py-1.5 text-[#666666] text-xs shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_10px_24px_rgba(0,0,0,0.04)] lg:inline-flex">
图片固定显示
</div>
</div>
) : (
<div className="relative flex flex-col items-center justify-center gap-3 px-6 text-center">
<Button
className={cn(
"h-16 rounded-[22px] px-10 text-[15px] font-semibold tracking-[-0.02em] sm:h-20 sm:px-12 sm:text-lg",
PRIMARY_ACTION_BUTTON_CLASS
)}
disabled={!onRequestAddImage}
onClick={onRequestAddImage}
type="button"
>
<ImageIcon className="mr-2 h-5 w-5 sm:h-6 sm:w-6" />
添加图片
</Button>
<div className="text-[#666666] text-[13px] leading-6">
选择本地图片后即可开始图像增强 Pro
</div>
</div>
)}
{generatedImages.length > 0 && sidePreviewItems.length > 0 ? (
<div className="absolute right-6 bottom-28 left-6 flex items-center justify-center">
<div className="flex max-w-[min(720px,calc(100vw-5rem))] items-center gap-2 rounded-[24px] border border-[#e5e5e5] bg-white/96 px-3 py-3 shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_20px_40px_rgba(0,0,0,0.05)] backdrop-blur-sm">
{sidePreviewItems.map((item, index) => {
const isActive = item.isSource
? selectedResultIndex < 0
: selectedResultIndex === index - 1;
return (
<button
className={cn(
"group flex h-16 w-16 shrink-0 overflow-hidden rounded-2xl border transition",
isActive
? "border-[#111111] shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_10px_20px_rgba(0,0,0,0.08)]"
: "border-[#e5e5e5] hover:shadow-sm"
)}
key={item.key}
onClick={() =>
setSelectedResultIndex(item.isSource ? -1 : index - 1)
}
title={item.label}
type="button"
>
<img
alt={item.label}
className="h-full w-full object-cover"
src={getDisplayImageUrl(item.url, 220)}
/>
</button>
);
})}
</div>
</div>
) : null}
<div className="pointer-events-none absolute bottom-6 left-1/2 z-30 flex w-fit max-w-[calc(100%-2rem)] -translate-x-1/2 flex-col items-center gap-2">
<div className="pointer-events-auto rounded-full border border-[#e5e5e5] bg-white/96 px-4 py-1.5 text-[#666666] text-[11px] shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_12px_24px_rgba(0,0,0,0.04)] backdrop-blur-xl">
<span className="inline-flex items-center gap-1.5">
{selectedGeneratedImage ? (
<CheckCircle2 className="h-3.5 w-3.5 text-[#111111]" />
) : null}
{actionHint}
</span>
</div>
{pointsEstimate.chargeable ? (
<div className="pointer-events-auto rounded-full border border-[#e5e5e5] bg-white/96 px-4 py-1.5 text-[#111111] text-[11px] shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_12px_24px_rgba(0,0,0,0.04)] backdrop-blur-xl">
预计消耗 {pointsEstimate.cost} 积分
{pointsEstimate.chargeMode === "resolution_bucket" && pointsEstimate.bucketName
? ` · ${pointsEstimate.bucketName}`
: ""}
{pointsEstimate.chargeMode === "scale_factor"
? ` · ${pointsEstimate.scaleFactor}x`
: ""}
</div>
) : null}
<div className="pointer-events-auto inline-flex items-center justify-center gap-2 rounded-[32px] border border-[#e5e5e5] bg-white p-2 shadow-[0_0_0_1px_rgba(0,0,0,0.03),0_18px_38px_rgba(0,0,0,0.08)] backdrop-blur-xl">
<Button
className={cn(
"h-11 min-w-[214px] rounded-[24px] px-5 text-sm font-medium tracking-[-0.01em]",
PRIMARY_ACTION_BUTTON_CLASS
)}
disabled={isSubmitting || !sourceImageUrl}
onClick={handleSubmit}
type="button"
>
{isSubmitting ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Sparkles className="mr-2 h-4 w-4" />
)}
开始增强
</Button>
<Button
className={cn(
"h-11 min-w-[214px] rounded-[24px] px-5 text-sm font-medium tracking-[-0.01em]",
SECONDARY_ACTION_BUTTON_CLASS
)}
disabled={!selectedGeneratedImage}
onClick={handleApply}
type="button"
variant="outline"
>
<Send className="mr-2 h-4 w-4" />
发送到画布
</Button>
</div>
</div>
</div>
<div className="relative z-20 h-full w-[420px] max-w-[42vw] shrink-0 py-6 pr-6">
<div className={cn("h-full space-y-3 overflow-y-auto pb-3", SIDEBAR_SCROLL_CLASS)}>
{isLoading ? (
<div
className={cn(
PANEL_SHELL_CLASS,
"flex items-center gap-2 px-4 py-3 text-[#666666] text-sm"
)}
>
<Loader2 className="h-4 w-4 animate-spin" />
正在加载增强配置...
</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)]">
当前未启用图像增强 Pro。请先在系统设置的“本地AI设置”里完成 Krea
配置。
</div>
) : null}
{lastErrorMessage ? (
<div className="rounded-[24px] border border-[#e5e5e5] bg-[#fafafa] px-4 py-3 text-[#333333] text-sm leading-6 shadow-[0_10px_24px_rgba(0,0,0,0.04)]">
{lastErrorMessage}
</div>
) : null}
<Collapsible
className={cn(
PANEL_SHELL_CLASS,
SIDEBAR_SECTION_CARD_CLASS,
"px-3.5 py-3.5"
)}
onOpenChange={setSettingsOpen}
open={settingsOpen}
>
<CollapsibleTrigger asChild>
<button
className="flex w-full items-center justify-between gap-3 rounded-[24px] border border-[#efefef] bg-[#fafafa] px-3.5 py-3 text-left transition hover:bg-white"
type="button"
>
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[18px] border border-[#ececec] bg-white text-[#111111] shadow-[0_10px_22px_rgba(0,0,0,0.05)]">
<Settings2 className="h-5 w-5" />
</div>
<div className="min-w-0 space-y-1">
<div className={SECTION_EYEBROW_CLASS}>Studio Setup</div>
<div className={PANEL_SECTION_TITLE_CLASS}>设置</div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="hidden rounded-full border border-[#ebebeb] bg-white px-2.5 py-1 text-[11px] text-[#666666] sm:inline-flex">
{formState.model}
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-[#666666] transition-transform",
settingsOpen && "rotate-180"
)}
/>
</div>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-3">
<div className={cn(INNER_PANEL_CLASS, "space-y-2")}>
<Label className={PANEL_SECTION_TITLE_CLASS}>增强预设</Label>
<Select onValueChange={handleSelectPreset} value={selectedPresetId}>
<SelectTrigger className={COMPACT_FIELD_CLASS}>
<SelectValue placeholder="选择一个增强预设" />
</SelectTrigger>
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
{presets.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
{preset.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedPreset ? (
<div className="rounded-[18px] border border-[#ececec] bg-white px-3 py-2.5">
<div className={cn(PANEL_SECTION_NOTE_CLASS, "line-clamp-2")}>
{selectedPreset.description ||
`${selectedPreset.model} · ${getSubjectDetectionLabel(selectedPreset.subjectDetection)}`}
</div>
</div>
) : null}
</div>
<div className={cn(INNER_PANEL_CLASS, "space-y-2")}>
<Label
className={PANEL_SECTION_TITLE_CLASS}
htmlFor="enhance-workspace-prompt"
>
增强提示词
</Label>
<Textarea
className={`${SOLID_PANEL_FIELD_CLASS} min-h-[84px] resize-none rounded-[20px] bg-white text-sm placeholder:text-[#a5a8b5]`}
id="enhance-workspace-prompt"
onChange={(event) =>
setFormState((prev) => ({
...prev,
prompt: event.target.value,
}))
}
placeholder="例如:保持主体不变,提升画质层次、纹理和局部细节,让结果更高级。"
rows={2}
value={formState.prompt}
/>
</div>
<div className={cn(INNER_PANEL_CLASS, "grid grid-cols-4 gap-2.5")}>
<div className="space-y-1.5">
<Label
className={COMPACT_LABEL_CLASS}
htmlFor="enhance-workspace-width"
>
宽度
</Label>
<Input
className={COMPACT_FIELD_CLASS}
id="enhance-workspace-width"
max={config.maxOutputEdge}
min="1"
onChange={(event) =>
handleNumericField(
"width",
clampNumber(
Number.parseInt(event.target.value, 10) || 1,
1,
config.maxOutputEdge
)
)
}
type="number"
value={formState.width}
/>
</div>
<div className="space-y-1.5">
<Label
className={COMPACT_LABEL_CLASS}
htmlFor="enhance-workspace-height"
>
高度
</Label>
<Input
className={COMPACT_FIELD_CLASS}
id="enhance-workspace-height"
max={config.maxOutputEdge}
min="1"
onChange={(event) =>
handleNumericField(
"height",
clampNumber(
Number.parseInt(event.target.value, 10) || 1,
1,
config.maxOutputEdge
)
)
}
type="number"
value={formState.height}
/>
</div>
{config.allowBatchSize ? (
<div className="space-y-1.5">
<Label className={COMPACT_LABEL_CLASS}>候选</Label>
<Select
onValueChange={(value) =>
handleNumericField(
"batchSize",
clampNumber(
Number.parseInt(value, 10) || 1,
1,
config.maxBatchSize
)
)
}
value={String(formState.batchSize)}
>
<SelectTrigger className={COMPACT_FIELD_CLASS}>
<SelectValue />
</SelectTrigger>
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
{Array.from(
{ length: config.maxBatchSize },
(_, index) => index + 1
).map((item) => (
<SelectItem key={item} value={String(item)}>
{item} 张
</SelectItem>
))}
</SelectContent>
</Select>
</div>
) : null}
<div className="space-y-1.5">
<Label className={COMPACT_LABEL_CLASS}>模式</Label>
<Select
onValueChange={(value) =>
setFormState((prev) => ({
...prev,
model: value as FormState["model"],
}))
}
value={formState.model}
>
<SelectTrigger className={COMPACT_FIELD_CLASS}>
<SelectValue />
</SelectTrigger>
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
<SelectItem value="Redefine">Redefine</SelectItem>
<SelectItem value="Recovery">Recovery</SelectItem>
<SelectItem value="Recovery V2">Recovery V2</SelectItem>
<SelectItem value="Reimagine">Reimagine</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className={COMPACT_LABEL_CLASS}>格式</Label>
<Select
onValueChange={(value) =>
setFormState((prev) => ({
...prev,
output_format: value as FormState["output_format"],
}))
}
value={formState.output_format}
>
<SelectTrigger className={COMPACT_FIELD_CLASS}>
<SelectValue />
</SelectTrigger>
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
<SelectItem value="jpg">JPG</SelectItem>
<SelectItem value="png">PNG</SelectItem>
<SelectItem value="webp">WEBP</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className={COMPACT_LABEL_CLASS}>区域</Label>
<Select
onValueChange={(value) =>
setFormState((prev) => ({
...prev,
subject_detection:
value as FormState["subject_detection"],
}))
}
value={formState.subject_detection}
>
<SelectTrigger className={COMPACT_FIELD_CLASS}>
<SelectValue />
</SelectTrigger>
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
<SelectItem value="All">全部</SelectItem>
<SelectItem value="Foreground">前景</SelectItem>
<SelectItem value="Background">背景</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label
className={COMPACT_LABEL_CLASS}
htmlFor="enhance-workspace-seed"
>
Seed
</Label>
<Input
className={COMPACT_FIELD_CLASS}
id="enhance-workspace-seed"
onChange={(event) =>
handleNumericField(
"seed",
Number.parseInt(event.target.value, 10) || 1337
)
}
type="number"
value={formState.seed}
/>
</div>
<div className="space-y-1.5">
<Label
className={COMPACT_LABEL_CLASS}
htmlFor="enhance-workspace-scale"
>
放大
</Label>
<Input
className={COMPACT_FIELD_CLASS}
id="enhance-workspace-scale"
max={config.maxUpscaleFactor}
min="1"
onChange={(event) =>
handleNumericField(
"image_scaling_factor",
clampNumber(
Number.parseFloat(event.target.value) || 1,
1,
config.maxUpscaleFactor
)
)
}
step="0.25"
type="number"
value={formState.image_scaling_factor}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2.5">
<div
className={cn(
SOLID_PANEL_CARD_CLASS,
COMPACT_TOGGLE_CARD_CLASS,
"bg-white"
)}
>
<span className="text-[#111111] text-xs">启用放大</span>
<Switch
checked={formState.upscaling_activated}
onCheckedChange={(checked) =>
setFormState((prev) => ({
...prev,
upscaling_activated: checked,
}))
}
/>
</div>
<div
className={cn(
SOLID_PANEL_CARD_CLASS,
COMPACT_TOGGLE_CARD_CLASS,
"bg-white"
)}
>
<span className="text-[#111111] text-xs">裁剪铺满</span>
<Switch
checked={formState.crop_to_fill}
onCheckedChange={(checked) =>
setFormState((prev) => ({
...prev,
crop_to_fill: checked,
}))
}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
{config.enableAdvancedControls ? (
<Collapsible
className={cn(
PANEL_SHELL_CLASS,
SIDEBAR_SECTION_CARD_CLASS,
"px-3.5 py-3.5"
)}
onOpenChange={setAdjustmentsOpen}
open={adjustmentsOpen}
>
<CollapsibleTrigger asChild>
<button
className="flex w-full items-center justify-between gap-3 rounded-[24px] border border-[#efefef] bg-[#fafafa] px-3.5 py-3 text-left transition hover:bg-white"
type="button"
>
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[18px] border border-[#ececec] bg-white text-[#111111] shadow-[0_10px_22px_rgba(0,0,0,0.05)]">
<SlidersHorizontal className="h-5 w-5" />
</div>
<div className="min-w-0 space-y-1">
<div className={SECTION_EYEBROW_CLASS}>Fine Tune</div>
<div className={PANEL_SECTION_TITLE_CLASS}>调节</div>
</div>
</div>
<ChevronDown
className={cn(
"h-4 w-4 text-[#666666] transition-transform",
adjustmentsOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-4">
<ParameterSliderRow
label="锐化"
max={1}
min={0}
onChange={(value) =>
handleNumericField("sharpen", clampNumber(value, 0, 1))
}
step={0.01}
value={formState.sharpen}
formatValue={(value) => `${Math.round(value * 100)}%`}
/>
<ParameterSliderRow
label="降噪"
max={1}
min={0}
onChange={(value) =>
handleNumericField("denoise", clampNumber(value, 0, 1))
}
step={0.01}
value={formState.denoise}
formatValue={(value) => `${Math.round(value * 100)}%`}
/>
<ParameterSliderRow
label="细节"
max={1}
min={0}
onChange={(value) =>
handleNumericField("detail", clampNumber(value, 0, 1))
}
step={0.01}
value={formState.detail}
formatValue={(value) => `${Math.round(value * 100)}%`}
/>
<ParameterSliderRow
label="创造力"
max={6}
min={1}
onChange={(value) =>
handleNumericField(
"creativity",
clampNumber(Math.round(value), 1, 6)
)
}
step={1}
value={formState.creativity}
formatValue={(value) => String(Math.round(value))}
/>
<ParameterSliderRow
label="纹理"
max={5}
min={1}
onChange={(value) =>
handleNumericField(
"texture",
clampNumber(Math.round(value), 1, 5)
)
}
step={1}
value={formState.texture}
formatValue={(value) => String(Math.round(value))}
/>
</CollapsibleContent>
</Collapsible>
) : null}
<Collapsible
className={cn(
PANEL_SHELL_CLASS,
SIDEBAR_SECTION_CARD_CLASS,
"px-3.5 py-3.5"
)}
onOpenChange={setFaceOpen}
open={faceOpen}
>
<div className="flex items-center justify-between gap-3">
<CollapsibleTrigger asChild>
<button
className="flex min-w-0 flex-1 items-center justify-between gap-3 rounded-[24px] border border-[#efefef] bg-[#fafafa] px-3.5 py-3 text-left transition hover:bg-white"
type="button"
>
<div className="flex min-w-0 items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-[18px] border border-[#ececec] bg-white text-[#111111] shadow-[0_10px_22px_rgba(0,0,0,0.05)]">
<ScanFace className="h-5 w-5" />
</div>
<div className="min-w-0 space-y-1">
<div className={SECTION_EYEBROW_CLASS}>Face Boost</div>
<div className={PANEL_SECTION_TITLE_CLASS}>面部增强</div>
</div>
</div>
<ChevronDown
className={cn(
"h-4 w-4 shrink-0 text-[#666666] transition-transform",
faceOpen && "rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
<Switch
checked={formState.face_enhancement}
onCheckedChange={(checked) =>
setFormState((prev) => ({
...prev,
face_enhancement: checked,
}))
}
/>
</div>
<CollapsibleContent className="mt-3 space-y-4">
<ParameterSliderRow
disabled={!formState.face_enhancement}
label="脸部力度"
max={1}
min={0}
onChange={(value) =>
handleNumericField(
"face_enhancement_strength",
clampNumber(value, 0, 1)
)
}
step={0.01}
value={formState.face_enhancement_strength}
formatValue={(value) => `${Math.round(value * 100)}%`}
/>
<ParameterSliderRow
disabled={!formState.face_enhancement}
label="脸部创意"
max={1}
min={0}
onChange={(value) =>
handleNumericField(
"face_enhancement_creativity",
clampNumber(value, 0, 1)
)
}
step={0.01}
value={formState.face_enhancement_creativity}
formatValue={(value) => `${Math.round(value * 100)}%`}
/>
</CollapsibleContent>
</Collapsible>
</div>
</div>
</div>
);
}
export default EnhanceProWorkspace;