ai-chatbot/components/canvas/EnhanceProWorkspace.tsx
Your Name 9c8228b697
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: compact enhance pro workspace layout
2026-04-05 21:59:05 +08:00

1122 lines
38 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,
Wand2,
} 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 { Separator } from "@/components/ui/separator";
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 SOLID_PANEL_FIELD_CLASS = "border-white/10 bg-[#1a1b22] text-white";
const SOLID_PANEL_CARD_CLASS = "rounded-2xl border border-white/10 bg-[#17181f]";
const SOLID_PANEL_CHIP_CLASS = "rounded-full border border-white/10 bg-[#1b1c24]";
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)
: "原图";
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-[#09090b] text-white">
<div className="relative flex min-w-0 flex-1 items-center justify-center overflow-hidden bg-[#050506]">
{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-white/10 bg-[#101114] px-3 py-1.5 text-white/80 text-xs">
<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-white/25" />
<div className="font-semibold text-lg text-white">
还没有可增强的图片
</div>
<div className="text-sm text-white/55 leading-6">
请先在主画布中选中一张图片,再从底部工具栏进入“图像增强 Pro”,
或者先在实时画布模式里生成一张结果后再切回来。
</div>
</div>
)}
{sidePreviewItems.length > 0 ? (
<div className="absolute right-6 bottom-6 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-white/10 bg-[#101114] px-3 py-3 shadow-2xl">
{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-400 shadow-[0_0_0_1px_rgba(56,189,248,0.35)]"
: "border-white/10 hover:border-white/35"
)}
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="flex h-full w-[420px] max-w-[42vw] shrink-0 flex-col border-white/10 border-l bg-[#111217]">
<div className="border-white/10 border-b px-4 py-4 pr-24">
<div className="flex items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-semibold text-base text-white">
<Wand2 className="h-4.5 w-4.5 text-sky-300" />
图像增强模式
</div>
</div>
<div
className={cn(
SOLID_PANEL_CHIP_CLASS,
"px-2.5 py-1 text-[11px] text-white/70"
)}
>
{selectedPreset?.model || config.defaultModel}
</div>
</div>
</div>
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4">
{isLoading ? (
<div className={cn(SOLID_PANEL_CARD_CLASS, "flex items-center gap-2 px-4 py-3 text-sm text-white/60")}>
<Loader2 className="h-4 w-4 animate-spin" />
正在加载增强配置...
</div>
) : null}
{!config.enabled && !isLoading ? (
<div className="rounded-2xl border border-amber-400/20 bg-amber-500/10 px-4 py-3 text-amber-100 text-sm leading-6">
当前未启用图像增强 Pro。请先在系统设置的“本地AI设置”里完成 Krea
配置。
</div>
) : null}
{lastErrorMessage ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-rose-100 text-sm leading-6">
{lastErrorMessage}
</div>
) : null}
<div className="space-y-2">
<Label className="text-[11px] uppercase tracking-[0.08em] text-white/65">
增强预设
</Label>
<Select onValueChange={handleSelectPreset} value={selectedPresetId}>
<SelectTrigger className={`${SOLID_PANEL_FIELD_CLASS} h-10`}>
<SelectValue placeholder="选择一个增强预设" />
</SelectTrigger>
<SelectContent>
{presets.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
{preset.name}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedPreset ? (
<div className="line-clamp-2 text-[11px] leading-5 text-white/45">
{selectedPreset.description ||
`${selectedPreset.model} · ${getSubjectDetectionLabel(selectedPreset.subjectDetection)}`}
</div>
) : null}
</div>
<div className="space-y-2">
<Label
className="text-[11px] uppercase tracking-[0.08em] text-white/65"
htmlFor="enhance-workspace-prompt"
>
增强提示词
</Label>
<Textarea
className={`${SOLID_PANEL_FIELD_CLASS} min-h-[82px] resize-none placeholder:text-white/30`}
id="enhance-workspace-prompt"
onChange={(event) =>
setFormState((prev) => ({
...prev,
prompt: event.target.value,
}))
}
placeholder="例如:保持主体不变,提升画质层次、纹理和局部细节,让结果更高级。"
rows={3}
value={formState.prompt}
/>
</div>
<div className="space-y-2">
<div className="text-[11px] uppercase tracking-[0.08em] text-white/65">
基础参数
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label
className="text-xs text-white/70"
htmlFor="enhance-workspace-width"
>
宽度
</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-10`}
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-xs text-white/70"
htmlFor="enhance-workspace-height"
>
高度
</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-10`}
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-xs text-white/70">候选</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-10`}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{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-xs text-white/70"
htmlFor="enhance-workspace-seed"
>
Seed
</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-10`}
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-xs text-white/70">模式</Label>
<Select
onValueChange={(value) =>
setFormState((prev) => ({
...prev,
model: value as FormState["model"],
}))
}
value={formState.model}
>
<SelectTrigger className={`${SOLID_PANEL_FIELD_CLASS} h-10`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<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-xs text-white/70">格式</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-10`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<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-xs text-white/70">区域</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-10`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<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-xs text-white/70"
htmlFor="enhance-workspace-seed"
>
Seed
</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-10`}
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-xs text-white/70"
htmlFor="enhance-workspace-scale"
>
放大
</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-10`}
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="space-y-2">
<div className="text-[11px] uppercase tracking-[0.08em] text-white/65">
增强开关
</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-sm text-white">人脸增强</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-sm text-white">启用放大</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-sm text-white">裁剪铺满</span>
<Switch
checked={formState.crop_to_fill}
onCheckedChange={(checked) =>
setFormState((prev) => ({ ...prev, crop_to_fill: checked }))
}
/>
</div>
</div>
</div>
{config.enableAdvancedControls ? (
<>
<Separator className="bg-white/10" />
<div className="space-y-2">
<div className="text-[11px] uppercase tracking-[0.08em] text-white/65">
高级调节
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label className="text-xs text-white/70">创造力</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-10`}
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-xs text-white/70">纹理</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-10`}
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-xs text-white/70">细节</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-10`}
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-xs text-white/70">锐化</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-10`}
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-xs text-white/70">降噪</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-10`}
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-xs text-white/70">脸部力度</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-10`}
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-xs text-white/70">脸部创意</Label>
<Input
className={`${SOLID_PANEL_FIELD_CLASS} h-10`}
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 className="border-white/10 border-t px-4 py-3">
<div className="mb-3 flex items-center justify-between text-white/45 text-xs">
<span>
{generatedImages.length > 0
? `已生成 ${generatedImages.length} 张候选结果`
: "生成后可直接发送回画布"}
</span>
{selectedGeneratedImage ? (
<span className="inline-flex items-center gap-1 text-emerald-300">
<CheckCircle2 className="h-3.5 w-3.5" />
已选中 {activePreviewLabel}
</span>
) : null}
</div>
<div className="flex items-center gap-2">
<Button
className="h-10 flex-1 rounded-2xl bg-[#0f62fe] text-white hover:bg-[#0a58e0]"
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" />
)}
开始增强 Pro
</Button>
<Button
className="h-10 rounded-2xl border-white/10 bg-[#1a1b22] px-4 text-white hover:bg-[#23242c] hover:text-white"
disabled={!selectedGeneratedImage}
onClick={handleApply}
type="button"
variant="outline"
>
<Send className="mr-2 h-4 w-4" />
发送到画布
</Button>
</div>
</div>
</div>
</div>
);
}
export default EnhanceProWorkspace;