ai-chatbot/components/canvas/EnhanceProWorkspace.tsx
Your Name 3100d10dc0
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
fix: restore light theme for realtime canvas enhance mode
2026-04-05 22:09:21 +08:00

1108 lines
37 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 { 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-border/60 bg-background text-foreground shadow-sm";
const SOLID_PANEL_CARD_CLASS =
"rounded-2xl border border-border/60 bg-card shadow-sm";
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-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-border/60 bg-background/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-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-border/60 bg-background/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-border/60 hover:border-foreground/20"
)}
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-border/60 border-l bg-[#fcfbf7]">
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto px-4 py-4 pt-6">
{isLoading ? (
<div
className={cn(
SOLID_PANEL_CARD_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="space-y-2">
<Label className="text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
增强预设
</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-muted-foreground">
{selectedPreset.description ||
`${selectedPreset.model} · ${getSubjectDetectionLabel(selectedPreset.subjectDetection)}`}
</div>
) : null}
</div>
<div className="space-y-2">
<Label
className="text-[11px] uppercase tracking-[0.08em] text-muted-foreground"
htmlFor="enhance-workspace-prompt"
>
增强提示词
</Label>
<Textarea
className={`${SOLID_PANEL_FIELD_CLASS} min-h-[82px] resize-none 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 className="space-y-2">
<div className="text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
基础参数
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label
className="text-foreground/80 text-xs"
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-foreground/80 text-xs"
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-foreground/80 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-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-foreground/80 text-xs"
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-foreground/80 text-xs">模式</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-foreground/80 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-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-foreground/80 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-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-foreground/80 text-xs"
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-foreground/80 text-xs"
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-muted-foreground">
增强开关
</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 ? (
<>
<Separator className="bg-border/60" />
<div className="space-y-2">
<div className="text-[11px] uppercase tracking-[0.08em] text-muted-foreground">
高级调节
</div>
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1.5">
<Label className="text-foreground/80 text-xs">创造力</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-foreground/80 text-xs">纹理</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-foreground/80 text-xs">细节</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-foreground/80 text-xs">锐化</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-foreground/80 text-xs">降噪</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-foreground/80 text-xs">脸部力度</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-foreground/80 text-xs">脸部创意</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-border/60 border-t bg-background/90 px-4 py-3">
<div className="mb-3 flex items-center justify-between text-muted-foreground text-xs">
<span>
{generatedImages.length > 0
? `已生成 ${generatedImages.length} 张候选结果`
: "生成后可直接发送回画布"}
</span>
{selectedGeneratedImage ? (
<span className="inline-flex items-center gap-1 text-emerald-600">
<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-primary text-primary-foreground hover:bg-primary/90"
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-border/60 bg-background px-4 text-foreground hover:bg-muted"
disabled={!selectedGeneratedImage}
onClick={handleApply}
type="button"
variant="outline"
>
<Send className="mr-2 h-4 w-4" />
发送到画布
</Button>
</div>
</div>
</div>
</div>
);
}
export default EnhanceProWorkspace;