ai-chatbot/components/canvas/EnhanceProWorkspace.tsx
Your Name df1b1a017e
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
style: unify realtime canvas control colors
2026-04-05 23:00:08 +08:00

1125 lines
39 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

"use client";
import {
CheckCircle2,
ImageIcon,
Loader2,
Send,
Sparkles,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import useSWR from "swr";
import { Button } from "@/components/ui/button";
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 { 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 { cn } from "@/lib/utils";
import {
type EnhanceProImageResult,
type EnhanceProRequestParams,
imageGenerationService,
} from "@/services/canvas";
const fetcher = (url: string) => fetch(url).then((res) => res.json());
type EnhanceProClientConfig = {
enabled: boolean;
providerName: string;
baseUrl: string;
defaultModel: EnhanceProKreaModel;
defaultOutputFormat: EnhanceProOutputFormat;
allowBatchSize: boolean;
maxBatchSize: number;
maxOutputEdge: number;
enableAdvancedControls: boolean;
presets: EnhanceProPreset[];
};
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,
maxOutputEdge: 4096,
enableAdvancedControls: true,
presets: defaultEnhanceProPresets(),
};
const PANEL_SHELL_CLASS =
"pointer-events-auto rounded-[28px] border border-[#e7e1d4] bg-white/88 shadow-[0_20px_50px_rgba(15,23,42,0.08)] backdrop-blur-xl";
const SOLID_PANEL_FIELD_CLASS =
"border-[#e7e1d4] bg-[#fffdfa] text-foreground shadow-[0_8px_20px_rgba(15,23,42,0.05)]";
const SOLID_PANEL_CARD_CLASS =
"rounded-[22px] border border-[#e7e1d4] bg-[#fffdfa] shadow-[0_8px_20px_rgba(15,23,42,0.05)]";
const PANEL_SECTION_TITLE_CLASS = "font-semibold text-[13px] text-slate-700";
const PANEL_SECTION_NOTE_CLASS = "text-[11px] leading-5 text-slate-500";
const SOLID_PANEL_SELECT_CONTENT_CLASS =
"border-[#e7e1d4] bg-[#fffdfa] shadow-[0_16px_34px_rgba(15,23,42,0.08)]";
const PRIMARY_ACTION_BUTTON_CLASS =
"border border-[#2563eb] bg-[#2563eb] text-white shadow-[0_10px_22px_rgba(37,99,235,0.18)] transition hover:border-[#1d4ed8] hover:bg-[#1d4ed8] disabled:border-[#e7e1d4] disabled:bg-slate-200 disabled:text-slate-500 disabled:shadow-none";
const SECONDARY_ACTION_BUTTON_CLASS =
"border border-[#d8e4ff] bg-[#eef4ff] text-[#1d4ed8] shadow-[0_8px_20px_rgba(59,130,246,0.10)] transition hover:bg-[#e4eeff] hover:text-[#1e40af] disabled:border-[#e7e1d4] disabled:bg-slate-100 disabled:text-slate-400 disabled:shadow-none";
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, 32),
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}`;
}
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;
onApplyResult: (payload: {
imageUrl: string;
prompt: string;
title?: string;
model?: string;
width?: number;
height?: number;
}) => void;
}
export function EnhanceProWorkspace({
sourceImageUrl,
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 { data: configPayload, isLoading } = useSWR<{
success?: boolean;
config?: EnhanceProClientConfig;
}>("/api/enhance/pro/config", fetcher);
const config = useMemo(
() => configPayload?.config || FALLBACK_CONFIG,
[configPayload?.config]
);
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 activePreviewLabel =
selectedGeneratedImage && selectedResultIndex >= 0
? getActiveResultLabel(selectedResultIndex)
: "原图";
const actionHint = selectedGeneratedImage
? `已选中 ${activePreviewLabel}`
: generatedImages.length > 0
? `已生成 ${generatedImages.length} 张候选结果`
: "生成后可直接发送回画布";
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]);
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("");
try {
const result = await imageGenerationService.enhanceImagePro({
...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,
32
),
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),
});
if (!result.success || !result.data?.images?.length) {
throw new Error(result.error || "图像增强 Pro 失败");
}
setGeneratedImages(result.data.images);
setSelectedResultIndex(0);
toast.success(
`图像增强 Pro 完成,已生成 ${result.data.images.length} 张结果`
);
} catch (error) {
const message =
error instanceof Error ? error.message : "图像增强 Pro 失败";
setLastErrorMessage(message);
toast.error(message);
} finally {
setIsSubmitting(false);
}
};
const handleApply = () => {
if (!selectedGeneratedImage?.originalUrl) {
toast.error("请先生成结果并选择一张增强图");
return;
}
onApplyResult({
imageUrl: selectedGeneratedImage.originalUrl,
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 (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]);
return (
<div className="relative flex h-full w-full bg-background text-foreground">
<div className="relative flex min-w-0 flex-1 items-center justify-center overflow-hidden bg-muted/20">
{activePreviewUrl ? (
<>
<img
alt={activePreviewLabel}
className="max-h-full max-w-full object-contain"
src={getDisplayImageUrl(activePreviewUrl, 1800)}
/>
<div className="pointer-events-none absolute top-6 left-6 flex items-center gap-2 rounded-full border border-[#e7e1d4] bg-[#fffdfa]/95 px-3 py-1.5 text-muted-foreground text-xs shadow-sm backdrop-blur-sm">
<ImageIcon className="h-3.5 w-3.5" />
<span>{activePreviewLabel}</span>
</div>
</>
) : (
<div className="flex max-w-md flex-col items-center gap-3 px-6 text-center">
<Sparkles className="h-10 w-10 text-muted-foreground/35" />
<div className="font-semibold text-foreground text-lg">
还没有可增强的图片
</div>
<div className="text-muted-foreground text-sm leading-6">
请先在主画布中选中一张图片,再从底部工具栏进入“图像增强 Pro”,
或者先在实时画布模式里生成一张结果后再切回来。
</div>
</div>
)}
{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-[#e7e1d4] bg-[#fffdfa]/95 px-3 py-3 shadow-xl 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-sky-500 shadow-[0_0_0_1px_rgba(14,165,233,0.18)]"
: "border-[#e7e1d4] 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>
<div className="pointer-events-none absolute top-20 right-6 bottom-6 z-20 w-[420px] max-w-[42vw]">
<div className="h-full space-y-4 overflow-y-auto pr-1 pb-6">
{isLoading ? (
<div
className={cn(
PANEL_SHELL_CLASS,
"flex items-center gap-2 px-4 py-3 text-muted-foreground text-sm"
)}
>
<Loader2 className="h-4 w-4 animate-spin" />
正在加载增强配置...
</div>
) : null}
{!config.enabled && !isLoading ? (
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-amber-800 text-sm leading-6">
当前未启用图像增强 Pro。请先在系统设置的“本地AI设置”里完成 Krea
配置。
</div>
) : null}
{lastErrorMessage ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-rose-700 text-sm leading-6">
{lastErrorMessage}
</div>
) : null}
<div className={cn(PANEL_SHELL_CLASS, "space-y-4 px-4 py-4")}>
<div className="space-y-2">
<Label className={PANEL_SECTION_TITLE_CLASS}>增强预设</Label>
<Select onValueChange={handleSelectPreset} value={selectedPresetId}>
<SelectTrigger
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
>
<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={cn(PANEL_SECTION_NOTE_CLASS, "line-clamp-2")}>
{selectedPreset.description ||
`${selectedPreset.model} · ${getSubjectDetectionLabel(selectedPreset.subjectDetection)}`}
</div>
) : null}
</div>
<div className="space-y-2">
<Label
className={PANEL_SECTION_TITLE_CLASS}
htmlFor="enhance-workspace-prompt"
>
增强提示词
</Label>
<Textarea
className={`${SOLID_PANEL_FIELD_CLASS} min-h-[92px] resize-none rounded-[22px] placeholder:text-muted-foreground/70`}
id="enhance-workspace-prompt"
onChange={(event) =>
setFormState((prev) => ({
...prev,
prompt: event.target.value,
}))
}
placeholder="例如:保持主体不变,提升画质层次、纹理和局部细节,让结果更高级。"
rows={3}
value={formState.prompt}
/>
</div>
</div>
<div className={cn(PANEL_SHELL_CLASS, "space-y-3 px-4 py-4")}>
<div className={PANEL_SECTION_TITLE_CLASS}>基础参数</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label
className="text-slate-600 text-xs"
htmlFor="enhance-workspace-width"
>
宽度
</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
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="text-slate-600 text-xs"
htmlFor="enhance-workspace-height"
>
高度
</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
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="text-slate-600 text-xs">候选</Label>
<Select
onValueChange={(value) =>
handleNumericField(
"batchSize",
clampNumber(
Number.parseInt(value, 10) || 1,
1,
config.maxBatchSize
)
)
}
value={String(formState.batchSize)}
>
<SelectTrigger
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
>
<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>
) : (
<div className="space-y-1.5">
<Label
className="text-slate-600 text-xs"
htmlFor="enhance-workspace-seed"
>
Seed
</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
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="text-slate-600 text-xs">模式</Label>
<Select
onValueChange={(value) =>
setFormState((prev) => ({
...prev,
model: value as FormState["model"],
}))
}
value={formState.model}
>
<SelectTrigger
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
>
<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="text-slate-600 text-xs">格式</Label>
<Select
onValueChange={(value) =>
setFormState((prev) => ({
...prev,
output_format: value as FormState["output_format"],
}))
}
value={formState.output_format}
>
<SelectTrigger
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
>
<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="text-slate-600 text-xs">区域</Label>
<Select
onValueChange={(value) =>
setFormState((prev) => ({
...prev,
subject_detection: value as FormState["subject_detection"],
}))
}
value={formState.subject_detection}
>
<SelectTrigger
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
>
<SelectValue />
</SelectTrigger>
<SelectContent className={SOLID_PANEL_SELECT_CONTENT_CLASS}>
<SelectItem value="All">全部</SelectItem>
<SelectItem value="Foreground">前景</SelectItem>
<SelectItem value="Background">背景</SelectItem>
</SelectContent>
</Select>
</div>
{config.allowBatchSize ? (
<div className="space-y-1.5">
<Label
className="text-slate-600 text-xs"
htmlFor="enhance-workspace-seed"
>
Seed
</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
id="enhance-workspace-seed"
onChange={(event) =>
handleNumericField(
"seed",
Number.parseInt(event.target.value, 10) || 1337
)
}
type="number"
value={formState.seed}
/>
</div>
) : null}
<div className="space-y-1.5">
<Label
className="text-slate-600 text-xs"
htmlFor="enhance-workspace-scale"
>
放大
</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
id="enhance-workspace-scale"
max="32"
min="1"
onChange={(event) =>
handleNumericField(
"image_scaling_factor",
clampNumber(
Number.parseFloat(event.target.value) || 1,
1,
32
)
)
}
step="0.5"
type="number"
value={formState.image_scaling_factor}
/>
</div>
</div>
</div>
<div className={cn(PANEL_SHELL_CLASS, "space-y-3 px-4 py-4")}>
<div className={PANEL_SECTION_TITLE_CLASS}>增强开关</div>
<div className="grid grid-cols-3 gap-3">
<div
className={cn(
SOLID_PANEL_CARD_CLASS,
"flex items-center justify-between gap-3 px-3 py-3"
)}
>
<span className="text-foreground text-sm">人脸增强</span>
<Switch
checked={formState.face_enhancement}
onCheckedChange={(checked) =>
setFormState((prev) => ({
...prev,
face_enhancement: checked,
}))
}
/>
</div>
<div
className={cn(
SOLID_PANEL_CARD_CLASS,
"flex items-center justify-between gap-3 px-3 py-3"
)}
>
<span className="text-foreground text-sm">启用放大</span>
<Switch
checked={formState.upscaling_activated}
onCheckedChange={(checked) =>
setFormState((prev) => ({
...prev,
upscaling_activated: checked,
}))
}
/>
</div>
<div
className={cn(
SOLID_PANEL_CARD_CLASS,
"flex items-center justify-between gap-3 px-3 py-3"
)}
>
<span className="text-foreground text-sm">裁剪铺满</span>
<Switch
checked={formState.crop_to_fill}
onCheckedChange={(checked) =>
setFormState((prev) => ({ ...prev, crop_to_fill: checked }))
}
/>
</div>
</div>
</div>
{config.enableAdvancedControls ? (
<div className={cn(PANEL_SHELL_CLASS, "space-y-3 px-4 py-4")}>
<div className="flex items-center justify-between gap-3">
<div className={PANEL_SECTION_TITLE_CLASS}>高级调节</div>
<div className={PANEL_SECTION_NOTE_CLASS}>细修参数</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label className="text-slate-600 text-xs">创造力</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
max="6"
min="1"
onChange={(event) =>
handleNumericField(
"creativity",
clampNumber(
Number.parseInt(event.target.value, 10) || 3,
1,
6
)
)
}
type="number"
value={formState.creativity}
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-600 text-xs">纹理</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
max="5"
min="1"
onChange={(event) =>
handleNumericField(
"texture",
clampNumber(
Number.parseInt(event.target.value, 10) || 3,
1,
5
)
)
}
type="number"
value={formState.texture}
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-600 text-xs">细节</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
max="1"
min="0"
onChange={(event) =>
handleNumericField(
"detail",
clampNumber(
Number.parseFloat(event.target.value) || 0,
0,
1
)
)
}
step="0.1"
type="number"
value={formState.detail}
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-600 text-xs">锐化</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
max="1"
min="0"
onChange={(event) =>
handleNumericField(
"sharpen",
clampNumber(
Number.parseFloat(event.target.value) || 0,
0,
1
)
)
}
step="0.1"
type="number"
value={formState.sharpen}
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-600 text-xs">降噪</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
max="1"
min="0"
onChange={(event) =>
handleNumericField(
"denoise",
clampNumber(
Number.parseFloat(event.target.value) || 0,
0,
1
)
)
}
step="0.1"
type="number"
value={formState.denoise}
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-600 text-xs">脸部力度</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
max="1"
min="0"
onChange={(event) =>
handleNumericField(
"face_enhancement_strength",
clampNumber(
Number.parseFloat(event.target.value) || 0,
0,
1
)
)
}
step="0.1"
type="number"
value={formState.face_enhancement_strength}
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-600 text-xs">脸部创意</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-11 rounded-[18px]`}
max="1"
min="0"
onChange={(event) =>
handleNumericField(
"face_enhancement_creativity",
clampNumber(
Number.parseFloat(event.target.value) || 0,
0,
1
)
)
}
step="0.1"
type="number"
value={formState.face_enhancement_creativity}
/>
</div>
</div>
</div>
) : null}
</div>
</div>
<div className="pointer-events-none absolute bottom-6 left-1/2 z-30 flex w-fit max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-col items-center gap-2">
<div className="pointer-events-auto rounded-full border border-[#e7e1d4] bg-[#fffdfa]/95 px-4 py-1.5 text-muted-foreground text-xs shadow-lg backdrop-blur-xl">
<span className="inline-flex items-center gap-1.5">
{selectedGeneratedImage ? (
<CheckCircle2 className="h-3.5 w-3.5 text-emerald-600" />
) : null}
{actionHint}
</span>
</div>
<div className="pointer-events-auto inline-flex items-center justify-center gap-3 rounded-full border border-[#e7e1d4] bg-[#fffdfa]/95 p-2 shadow-2xl backdrop-blur-xl">
<Button
className={cn(
"h-11 min-w-[220px] rounded-full px-6",
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-[220px] rounded-full px-6",
SECONDARY_ACTION_BUTTON_CLASS
)}
disabled={!selectedGeneratedImage}
onClick={handleApply}
type="button"
variant="outline"
>
<Send className="mr-2 h-4 w-4" />
发送到画布
</Button>
</div>
</div>
</div>
);
}
export default EnhanceProWorkspace;