Spaces:
Running
Running
| 'use client'; | |
| import { ModeToggle } from '@/components/mode-toggle'; | |
| import type { WorkbenchMode } from '@/components/mode-toggle'; | |
| import { Button } from '@/components/ui/button'; | |
| import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; | |
| import { Checkbox } from '@/components/ui/checkbox'; | |
| import { Input } from '@/components/ui/input'; | |
| import { Label } from '@/components/ui/label'; | |
| import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; | |
| import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; | |
| import { Slider } from '@/components/ui/slider'; | |
| import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; | |
| import { Textarea } from '@/components/ui/textarea'; | |
| import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; | |
| import type { GptImageModel } from '@/lib/cost-utils'; | |
| import { useI18n } from '@/lib/i18n'; | |
| import { getImageOutputFormatLabel, getImageQualityLabel } from '@/lib/image-display-labels'; | |
| import type { | |
| ImageUpstreamFormBackend, | |
| ImageUpstreamFormPromptOptimization, | |
| ImageUpstreamFormStreamingStrategy, | |
| ImageUpstreamFormThinking | |
| } from '@/lib/image-upstream-form'; | |
| import { | |
| getImageUpstreamRouteImpactKeys, | |
| isImageUpstreamStreamingStrategySelectable, | |
| resolveImageUpstreamEffectiveStreamingStrategy | |
| } from '@/lib/image-upstream-form'; | |
| import { | |
| buildIntegerRangeOptions, | |
| clampIntegerToRange, | |
| getPartialImagesRangeForBackend, | |
| type ImageUpstreamProfile, | |
| type PartialImagesCount | |
| } from '@/lib/image-upstream-profile'; | |
| import type { ImageStreamMode, ImageStreamingStrategy } from '@/lib/image-upstream-strategy'; | |
| import { | |
| getPresetTooltip, | |
| getSizePresetOptions, | |
| validateGptImage2Size, | |
| validatePositiveIntegerImageSize | |
| } from '@/lib/size-utils'; | |
| import type { SizePreset } from '@/lib/size-utils'; | |
| import { resolveStreamingBatchToggleState } from '@/lib/streaming-batch'; | |
| import { getStreamingStatusLabel } from '@/lib/streaming-status-label'; | |
| import { | |
| Bookmark, | |
| Eraser, | |
| Save, | |
| Square, | |
| RectangleHorizontal, | |
| RectangleVertical, | |
| Sparkles, | |
| SlidersHorizontal, | |
| ChevronDown, | |
| CircleOff, | |
| Tally1, | |
| Tally2, | |
| Tally3, | |
| Tally4, | |
| Loader2, | |
| X, | |
| ScanEye, | |
| UploadCloud, | |
| Lock, | |
| LockOpen, | |
| HelpCircle, | |
| SquareDashed, | |
| FileImage, | |
| WandSparkles, | |
| Globe2, | |
| ShieldCheck, | |
| ShieldAlert | |
| } from 'lucide-react'; | |
| import Image from 'next/image'; | |
| import * as React from 'react'; | |
| type DrawnPoint = { | |
| x: number; | |
| y: number; | |
| size: number; | |
| }; | |
| export type EditingFormData = { | |
| prompt: string; | |
| n: number; | |
| size: SizePreset; | |
| customWidth: number; | |
| customHeight: number; | |
| quality: 'low' | 'medium' | 'high' | 'auto'; | |
| output_format: 'png' | 'jpeg' | 'webp'; | |
| output_compression?: number; | |
| moderation: 'low' | 'auto'; | |
| imageFiles: File[]; | |
| maskFile: File | null; | |
| model: GptImageModel; | |
| image_backend: ImageUpstreamFormBackend; | |
| streaming_strategy: ImageUpstreamFormStreamingStrategy; | |
| responsesModel: string; | |
| thinking: ImageUpstreamFormThinking; | |
| promptOptimization: ImageUpstreamFormPromptOptimization; | |
| forceWeb: boolean; | |
| enableParallelBatch: boolean; | |
| }; | |
| type EditingFormProps = { | |
| onSubmit: (data: EditingFormData) => void; | |
| onSaveInspiration: (prompt: string) => void; | |
| canApplyRandomInspiration: boolean; | |
| onPickRandomInspiration: () => string; | |
| isLoading: boolean; | |
| showLoadingState?: boolean; | |
| isActive: boolean; | |
| currentMode: WorkbenchMode; | |
| onModeChange: (mode: WorkbenchMode) => void; | |
| reuseContext: EditingReuseContext | null; | |
| onClearReuseContext: () => void; | |
| isPasswordRequiredByBackend: boolean | null; | |
| clientPasswordHash: string | null; | |
| onOpenPasswordDialog: () => void; | |
| editModel: EditingFormData['model']; | |
| setEditModel: React.Dispatch<React.SetStateAction<EditingFormData['model']>>; | |
| imageFiles: File[]; | |
| sourceImagePreviewUrls: string[]; | |
| setImageFiles: React.Dispatch<React.SetStateAction<File[]>>; | |
| setSourceImagePreviewUrls: React.Dispatch<React.SetStateAction<string[]>>; | |
| maxImages: number; | |
| editPrompt: string; | |
| setEditPrompt: React.Dispatch<React.SetStateAction<string>>; | |
| editN: number[]; | |
| setEditN: React.Dispatch<React.SetStateAction<number[]>>; | |
| editSize: EditingFormData['size']; | |
| setEditSize: React.Dispatch<React.SetStateAction<EditingFormData['size']>>; | |
| editCustomWidth: number; | |
| setEditCustomWidth: React.Dispatch<React.SetStateAction<number>>; | |
| editCustomHeight: number; | |
| setEditCustomHeight: React.Dispatch<React.SetStateAction<number>>; | |
| editQuality: EditingFormData['quality']; | |
| setEditQuality: React.Dispatch<React.SetStateAction<EditingFormData['quality']>>; | |
| editOutputFormat: EditingFormData['output_format']; | |
| setEditOutputFormat: React.Dispatch<React.SetStateAction<EditingFormData['output_format']>>; | |
| editCompression: number[]; | |
| setEditCompression: React.Dispatch<React.SetStateAction<number[]>>; | |
| upstreamProfile: ImageUpstreamProfile; | |
| upstreamProfileMixed?: boolean; | |
| editModeration: EditingFormData['moderation']; | |
| setEditModeration: React.Dispatch<React.SetStateAction<EditingFormData['moderation']>>; | |
| editBrushSize: number[]; | |
| setEditBrushSize: React.Dispatch<React.SetStateAction<number[]>>; | |
| editShowMaskEditor: boolean; | |
| setEditShowMaskEditor: React.Dispatch<React.SetStateAction<boolean>>; | |
| editGeneratedMaskFile: File | null; | |
| setEditGeneratedMaskFile: React.Dispatch<React.SetStateAction<File | null>>; | |
| editIsMaskSaved: boolean; | |
| setEditIsMaskSaved: React.Dispatch<React.SetStateAction<boolean>>; | |
| editOriginalImageSize: { width: number; height: number } | null; | |
| setEditOriginalImageSize: React.Dispatch<React.SetStateAction<{ width: number; height: number } | null>>; | |
| editDrawnPoints: DrawnPoint[]; | |
| setEditDrawnPoints: React.Dispatch<React.SetStateAction<DrawnPoint[]>>; | |
| editMaskPreviewUrl: string | null; | |
| setEditMaskPreviewUrl: React.Dispatch<React.SetStateAction<string | null>>; | |
| streamMode: ImageStreamMode; | |
| setStreamMode: React.Dispatch<React.SetStateAction<ImageStreamMode>>; | |
| allowStreamingBatch: boolean; | |
| enableParallelBatch: boolean; | |
| setEnableParallelBatch: React.Dispatch<React.SetStateAction<boolean>>; | |
| partialImages: PartialImagesCount; | |
| setPartialImages: React.Dispatch<React.SetStateAction<PartialImagesCount>>; | |
| allowResponsesImageBackend: boolean; | |
| hasDefaultResponsesModel: boolean; | |
| editImageBackend: EditingFormData['image_backend']; | |
| setEditImageBackend: React.Dispatch<React.SetStateAction<EditingFormData['image_backend']>>; | |
| editStreamingStrategy: EditingFormData['streaming_strategy']; | |
| defaultStreamingStrategy: ImageStreamingStrategy; | |
| setEditStreamingStrategy: React.Dispatch<React.SetStateAction<EditingFormData['streaming_strategy']>>; | |
| editResponsesModel: string; | |
| setEditResponsesModel: React.Dispatch<React.SetStateAction<string>>; | |
| editThinking: EditingFormData['thinking']; | |
| setEditThinking: React.Dispatch<React.SetStateAction<EditingFormData['thinking']>>; | |
| editPromptOptimization: EditingFormData['promptOptimization']; | |
| setEditPromptOptimization: React.Dispatch<React.SetStateAction<EditingFormData['promptOptimization']>>; | |
| editForceWeb: boolean; | |
| setEditForceWeb: React.Dispatch<React.SetStateAction<boolean>>; | |
| requestSummaryLabel: string; | |
| initialAdvancedOpen?: boolean; | |
| initialAdvancedTab?: AdvancedTab; | |
| }; | |
| export type EditingReuseContext = { | |
| sourceLabel: string; | |
| restoredFields: string[]; | |
| promptPreview: string; | |
| }; | |
| type AdvancedTab = 'output' | 'model' | 'stream' | 'route'; | |
| const RadioItemWithIcon = ({ | |
| value, | |
| id, | |
| label, | |
| Icon, | |
| disabled = false, | |
| tooltip | |
| }: { | |
| value: string; | |
| id: string; | |
| label: string; | |
| Icon: React.ElementType; | |
| disabled?: boolean; | |
| tooltip?: React.ReactNode; | |
| }) => { | |
| const item = ( | |
| <RadioGroupItem | |
| value={value} | |
| id={id} | |
| disabled={disabled} | |
| aria-label={label} | |
| className='border-border bg-background/58 text-muted-foreground enabled:hover:border-primary/25 enabled:hover:bg-accent/45 enabled:hover:text-foreground data-[state=checked]:border-primary/55 data-[state=checked]:bg-primary/10 data-[state=checked]:text-primary flex aspect-auto h-auto min-h-11 w-full flex-col items-center justify-center gap-0.5 rounded-md px-1 py-1 text-xs shadow-none transition-[background-color,border-color,color,box-shadow,transform] enabled:active:translate-y-0 enabled:motion-safe:hover:-translate-y-0.5 enabled:motion-safe:hover:scale-100 enabled:motion-safe:active:scale-100 lg:min-h-9 lg:px-1.5 lg:text-xs [&_[data-slot=radio-group-indicator]]:hidden [&_[data-slot=radio-group-item-content]]:max-w-full [&_[data-slot=radio-group-item-content]]:min-w-0 [&_[data-slot=radio-group-item-content]]:justify-center [&_[data-slot=radio-group-item-content]]:overflow-hidden'> | |
| <Icon className='h-3 w-3 shrink-0 text-current opacity-50 lg:hidden' /> | |
| <span className='max-w-full min-w-0 text-center leading-4 break-words whitespace-normal'>{label}</span> | |
| </RadioGroupItem> | |
| ); | |
| if (!tooltip) return item; | |
| return ( | |
| <Tooltip> | |
| <TooltipTrigger asChild>{item}</TooltipTrigger> | |
| <TooltipContent>{tooltip}</TooltipContent> | |
| </Tooltip> | |
| ); | |
| }; | |
| function getSizePresetLabel( | |
| preset: SizePreset, | |
| t: (key: string, values?: Record<string, string | number>) => string | |
| ): string { | |
| switch (preset) { | |
| case 'auto': | |
| return t('common.auto'); | |
| case 'custom': | |
| return t('common.custom'); | |
| case 'square': | |
| return t('common.square'); | |
| case 'landscape': | |
| return t('common.landscape'); | |
| case 'portrait': | |
| return t('common.portrait'); | |
| case 'square-1k': | |
| return '1K'; | |
| case 'square-2k': | |
| return '2K'; | |
| case 'square-4k': | |
| return '4K'; | |
| case 'wide-4k': | |
| return '16:9'; | |
| case 'tall-4k': | |
| return '9:16'; | |
| } | |
| } | |
| function getSizePresetIcon(preset: SizePreset): React.ElementType { | |
| switch (preset) { | |
| case 'auto': | |
| return Sparkles; | |
| case 'custom': | |
| return SquareDashed; | |
| case 'landscape': | |
| case 'wide-4k': | |
| return RectangleHorizontal; | |
| case 'portrait': | |
| case 'tall-4k': | |
| return RectangleVertical; | |
| default: | |
| return Square; | |
| } | |
| } | |
| function getBackendLabel(backend: EditingFormData['image_backend'], t: (key: string) => string): string { | |
| if (backend === 'images-api') return t('upstream.backendImages'); | |
| if (backend === 'responses-image-generation') return t('upstream.backendResponses'); | |
| return t('upstream.serverDefault'); | |
| } | |
| function getWorkbenchBackendLabel(backend: EditingFormData['image_backend'], t: (key: string) => string): string { | |
| if (backend === 'images-api') return t('upstream.backendImages'); | |
| if (backend === 'responses-image-generation') return t('upstream.backendResponses'); | |
| return t('upstream.workbenchDefaultRoute'); | |
| } | |
| function getStreamModeLabel(streamMode: ImageStreamMode, t: (key: string) => string): string { | |
| if (streamMode === 'stream') return t('streaming.modeStream'); | |
| if (streamMode === 'non_stream') return t('streaming.modeNonStream'); | |
| return t('streaming.modeAuto'); | |
| } | |
| export function EditingForm({ | |
| onSubmit, | |
| onSaveInspiration, | |
| canApplyRandomInspiration, | |
| onPickRandomInspiration, | |
| isLoading, | |
| showLoadingState = isLoading, | |
| isActive, | |
| currentMode, | |
| onModeChange, | |
| reuseContext, | |
| onClearReuseContext, | |
| isPasswordRequiredByBackend, | |
| clientPasswordHash, | |
| onOpenPasswordDialog, | |
| editModel, | |
| setEditModel, | |
| imageFiles, | |
| sourceImagePreviewUrls, | |
| setImageFiles, | |
| setSourceImagePreviewUrls, | |
| maxImages, | |
| editPrompt, | |
| setEditPrompt, | |
| editN, | |
| setEditN, | |
| editSize, | |
| setEditSize, | |
| editCustomWidth, | |
| setEditCustomWidth, | |
| editCustomHeight, | |
| setEditCustomHeight, | |
| editQuality, | |
| setEditQuality, | |
| editOutputFormat, | |
| setEditOutputFormat, | |
| editCompression, | |
| setEditCompression, | |
| upstreamProfile, | |
| upstreamProfileMixed = false, | |
| editModeration, | |
| setEditModeration, | |
| editBrushSize, | |
| setEditBrushSize, | |
| editShowMaskEditor, | |
| setEditShowMaskEditor, | |
| editGeneratedMaskFile, | |
| setEditGeneratedMaskFile, | |
| editIsMaskSaved, | |
| setEditIsMaskSaved, | |
| editOriginalImageSize, | |
| setEditOriginalImageSize, | |
| editDrawnPoints, | |
| setEditDrawnPoints, | |
| editMaskPreviewUrl, | |
| setEditMaskPreviewUrl, | |
| streamMode, | |
| setStreamMode, | |
| allowStreamingBatch, | |
| enableParallelBatch, | |
| setEnableParallelBatch, | |
| partialImages, | |
| setPartialImages, | |
| allowResponsesImageBackend, | |
| hasDefaultResponsesModel, | |
| editImageBackend, | |
| setEditImageBackend, | |
| editStreamingStrategy, | |
| defaultStreamingStrategy, | |
| setEditStreamingStrategy, | |
| editResponsesModel, | |
| setEditResponsesModel, | |
| editThinking, | |
| setEditThinking, | |
| editPromptOptimization, | |
| setEditPromptOptimization, | |
| editForceWeb, | |
| setEditForceWeb, | |
| requestSummaryLabel, | |
| initialAdvancedOpen = false, | |
| initialAdvancedTab = 'output' | |
| }: EditingFormProps) { | |
| const { locale, t } = useI18n(); | |
| const [firstImagePreviewUrl, setFirstImagePreviewUrl] = React.useState<string | null>(null); | |
| const isGptImage2 = editModel === 'gpt-image-2'; | |
| const usesPositiveIntegerCustomSize = upstreamProfile.gptImage2.sizePolicy === 'positive-integer'; | |
| const customSizeValidation = | |
| editSize === 'custom' | |
| ? usesPositiveIntegerCustomSize | |
| ? validatePositiveIntegerImageSize(editCustomWidth, editCustomHeight) | |
| : validateGptImage2Size(editCustomWidth, editCustomHeight) | |
| : { valid: true as const }; | |
| const customSizeInvalid = editSize === 'custom' && !customSizeValidation.valid; | |
| const editCustomPixels = editCustomWidth * editCustomHeight; | |
| const editCustomRatio = | |
| editCustomWidth > 0 && editCustomHeight > 0 | |
| ? t('form.ratio', { | |
| ratio: ( | |
| Math.max(editCustomWidth, editCustomHeight) / Math.min(editCustomWidth, editCustomHeight) | |
| ).toFixed(2) | |
| }) | |
| : t('form.noRatio'); | |
| const editCustomSizeError = customSizeValidation.valid | |
| ? null | |
| : t(customSizeValidation.reasonKey, customSizeValidation.values); | |
| const showCompression = editOutputFormat === 'jpeg' || editOutputFormat === 'webp'; | |
| const effectiveStreamingStrategy = resolveImageUpstreamEffectiveStreamingStrategy({ | |
| streamingStrategy: editStreamingStrategy, | |
| defaultStreamingStrategy | |
| }); | |
| const streamingDisabledByStrategy = effectiveStreamingStrategy === 'off'; | |
| const parallelBatchToggle = resolveStreamingBatchToggleState({ | |
| allowStreamingBatch, | |
| userEnabled: enableParallelBatch, | |
| targetCount: editN[0], | |
| streamMode, | |
| streamingStrategy: effectiveStreamingStrategy | |
| }); | |
| const canEnableParallelBatch = parallelBatchToggle.canEnable; | |
| const parallelBatchChecked = parallelBatchToggle.checked; | |
| const parallelBatchUnavailableKey = parallelBatchToggle.unavailableReasonKey; | |
| const [isAdvancedOpen, setIsAdvancedOpen] = React.useState(initialAdvancedOpen); | |
| const [advancedTab, setAdvancedTab] = React.useState<AdvancedTab>(initialAdvancedTab); | |
| const requiresResponsesModel = | |
| editImageBackend === 'responses-image-generation' && !hasDefaultResponsesModel && !editResponsesModel.trim(); | |
| const submitDisabledReason = React.useMemo(() => { | |
| if (isLoading) return ''; | |
| if (imageFiles.length === 0) return t('ux.disabledSourceImage'); | |
| if (imageFiles.length > maxImages) return t('alert.maxImages', { count: maxImages }); | |
| if (!editPrompt.trim()) return t('ux.disabledPrompt'); | |
| if (editDrawnPoints.length > 0 && !editGeneratedMaskFile && !editIsMaskSaved) { | |
| return t('ux.disabledUnsavedMask'); | |
| } | |
| if (requiresResponsesModel) return t('upstream.responsesModelRequired'); | |
| if (customSizeInvalid) return editCustomSizeError || t('ux.disabledCustomSize'); | |
| return ''; | |
| }, [ | |
| customSizeInvalid, | |
| editCustomSizeError, | |
| editDrawnPoints.length, | |
| editGeneratedMaskFile, | |
| editIsMaskSaved, | |
| editPrompt, | |
| imageFiles.length, | |
| isLoading, | |
| maxImages, | |
| requiresResponsesModel, | |
| t | |
| ]); | |
| const advancedSummary = [ | |
| `${t('form.quality')}: ${getImageQualityLabel(editQuality, t)}`, | |
| `${t('form.outputFormat')}: ${getImageOutputFormatLabel(editOutputFormat, t)}`, | |
| getBackendLabel(editImageBackend, t) | |
| ].join(', '); | |
| const streamModeLabel = getStreamModeLabel(streamMode, t); | |
| const streamStatusLabel = getStreamingStatusLabel(streamMode, t); | |
| const workbenchBackendLabel = getWorkbenchBackendLabel(editImageBackend, t); | |
| const editSizePresetOptions = getSizePresetOptions({ model: editModel, upstreamProfile }); | |
| const partialImagesRange = React.useMemo( | |
| () => getPartialImagesRangeForBackend(upstreamProfile, editImageBackend), | |
| [editImageBackend, upstreamProfile] | |
| ); | |
| const partialImageOptions = buildIntegerRangeOptions(partialImagesRange) as PartialImagesCount[]; | |
| // custom 仅对 gpt-image-2 有效,切换到旧模型时重置。 | |
| React.useEffect(() => { | |
| if (isActive && !isGptImage2 && editSize === 'custom') { | |
| setEditSize('auto'); | |
| } | |
| }, [editSize, isActive, isGptImage2, setEditSize]); | |
| React.useEffect(() => { | |
| if (isActive && streamingDisabledByStrategy && streamMode !== 'non_stream') { | |
| setStreamMode('non_stream'); | |
| } | |
| }, [isActive, streamingDisabledByStrategy, streamMode, setStreamMode]); | |
| React.useEffect(() => { | |
| if (isActive && (partialImages < partialImagesRange.min || partialImages > partialImagesRange.max)) { | |
| setPartialImages(clampIntegerToRange(partialImages, partialImagesRange) as PartialImagesCount); | |
| } | |
| }, [isActive, partialImages, partialImagesRange, setPartialImages]); | |
| React.useEffect(() => { | |
| if (isActive && (editN[0] < upstreamProfile.editCount.min || editN[0] > upstreamProfile.editCount.max)) { | |
| setEditN([Math.min(upstreamProfile.editCount.max, Math.max(upstreamProfile.editCount.min, editN[0]))]); | |
| } | |
| }, [editN, isActive, setEditN, upstreamProfile.editCount.max, upstreamProfile.editCount.min]); | |
| const canvasRef = React.useRef<HTMLCanvasElement>(null); | |
| const visualFeedbackCanvasRef = React.useRef<HTMLCanvasElement | null>(null); | |
| const isDrawing = React.useRef(false); | |
| const lastPos = React.useRef<{ x: number; y: number } | null>(null); | |
| const imageInputRef = React.useRef<HTMLInputElement>(null); | |
| const maskInputRef = React.useRef<HTMLInputElement>(null); | |
| const primaryImagePreviewUrl = sourceImagePreviewUrls[0] ?? null; | |
| const hasSourceImages = imageFiles.length > 0; | |
| const hasSourcePreviews = sourceImagePreviewUrls.length > 0; | |
| const canAddSourceImage = !isLoading && imageFiles.length < maxImages; | |
| React.useEffect(() => { | |
| if (editOriginalImageSize) { | |
| if (!visualFeedbackCanvasRef.current) { | |
| visualFeedbackCanvasRef.current = document.createElement('canvas'); | |
| } | |
| visualFeedbackCanvasRef.current.width = editOriginalImageSize.width; | |
| visualFeedbackCanvasRef.current.height = editOriginalImageSize.height; | |
| } | |
| }, [editOriginalImageSize]); | |
| React.useEffect(() => { | |
| let cancelled = false; | |
| queueMicrotask(() => { | |
| if (cancelled) return; | |
| setEditGeneratedMaskFile(null); | |
| setEditIsMaskSaved(false); | |
| setEditOriginalImageSize(null); | |
| setFirstImagePreviewUrl(null); | |
| setEditDrawnPoints([]); | |
| setEditMaskPreviewUrl(null); | |
| }); | |
| if (primaryImagePreviewUrl) { | |
| const img = new window.Image(); | |
| img.onload = () => { | |
| if (cancelled) return; | |
| setEditOriginalImageSize({ width: img.width, height: img.height }); | |
| }; | |
| img.src = primaryImagePreviewUrl; | |
| queueMicrotask(() => { | |
| if (!cancelled) { | |
| setFirstImagePreviewUrl(primaryImagePreviewUrl); | |
| } | |
| }); | |
| } else { | |
| queueMicrotask(() => { | |
| if (!cancelled) { | |
| setEditShowMaskEditor(false); | |
| } | |
| }); | |
| } | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, [ | |
| primaryImagePreviewUrl, | |
| setEditGeneratedMaskFile, | |
| setEditIsMaskSaved, | |
| setEditOriginalImageSize, | |
| setFirstImagePreviewUrl, | |
| setEditDrawnPoints, | |
| setEditMaskPreviewUrl, | |
| setEditShowMaskEditor | |
| ]); | |
| React.useEffect(() => { | |
| const displayCtx = canvasRef.current?.getContext('2d'); | |
| const displayCanvas = canvasRef.current; | |
| const feedbackCanvas = visualFeedbackCanvasRef.current; | |
| if (!displayCtx || !displayCanvas || !feedbackCanvas || !editOriginalImageSize) return; | |
| const feedbackCtx = feedbackCanvas.getContext('2d'); | |
| if (!feedbackCtx) return; | |
| feedbackCtx.clearRect(0, 0, feedbackCanvas.width, feedbackCanvas.height); | |
| feedbackCtx.fillStyle = 'red'; | |
| editDrawnPoints.forEach((point) => { | |
| feedbackCtx.beginPath(); | |
| feedbackCtx.arc(point.x, point.y, point.size, 0, Math.PI * 2); | |
| feedbackCtx.fill(); | |
| }); | |
| displayCtx.clearRect(0, 0, displayCanvas.width, displayCanvas.height); | |
| displayCtx.save(); | |
| displayCtx.globalAlpha = 0.5; | |
| displayCtx.drawImage(feedbackCanvas, 0, 0, displayCanvas.width, displayCanvas.height); | |
| displayCtx.restore(); | |
| }, [editDrawnPoints, editOriginalImageSize]); | |
| const getMousePos = (e: React.MouseEvent | React.TouchEvent) => { | |
| const canvas = canvasRef.current; | |
| if (!canvas) return null; | |
| const rect = canvas.getBoundingClientRect(); | |
| const scaleX = canvas.width / rect.width; | |
| const scaleY = canvas.height / rect.height; | |
| const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; | |
| const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; | |
| return { | |
| x: (clientX - rect.left) * scaleX, | |
| y: (clientY - rect.top) * scaleY | |
| }; | |
| }; | |
| const addPoint = (x: number, y: number) => { | |
| setEditDrawnPoints((prevPoints) => [...prevPoints, { x, y, size: editBrushSize[0] }]); | |
| setEditIsMaskSaved(false); | |
| setEditMaskPreviewUrl(null); | |
| }; | |
| const startDrawing = (e: React.MouseEvent | React.TouchEvent) => { | |
| e.preventDefault(); | |
| isDrawing.current = true; | |
| const currentPos = getMousePos(e); | |
| if (!currentPos) return; | |
| lastPos.current = currentPos; | |
| addPoint(currentPos.x, currentPos.y); | |
| }; | |
| const drawLine = (e: React.MouseEvent | React.TouchEvent) => { | |
| if (!isDrawing.current) return; | |
| e.preventDefault(); | |
| const currentPos = getMousePos(e); | |
| if (!currentPos || !lastPos.current) return; | |
| const dist = Math.hypot(currentPos.x - lastPos.current.x, currentPos.y - lastPos.current.y); | |
| const angle = Math.atan2(currentPos.y - lastPos.current.y, currentPos.x - lastPos.current.x); | |
| const step = Math.max(1, editBrushSize[0] / 4); | |
| for (let i = step; i < dist; i += step) { | |
| const x = lastPos.current.x + Math.cos(angle) * i; | |
| const y = lastPos.current.y + Math.sin(angle) * i; | |
| addPoint(x, y); | |
| } | |
| addPoint(currentPos.x, currentPos.y); | |
| lastPos.current = currentPos; | |
| }; | |
| const drawMaskStroke = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number) => { | |
| ctx.fillStyle = 'black'; | |
| ctx.beginPath(); | |
| ctx.arc(x, y, size, 0, Math.PI * 2); | |
| ctx.fill(); | |
| }; | |
| const stopDrawing = () => { | |
| isDrawing.current = false; | |
| lastPos.current = null; | |
| }; | |
| const handleClearMask = () => { | |
| setEditDrawnPoints([]); | |
| setEditGeneratedMaskFile(null); | |
| setEditIsMaskSaved(false); | |
| setEditMaskPreviewUrl(null); | |
| }; | |
| const generateAndSaveMask = () => { | |
| if (!editOriginalImageSize || editDrawnPoints.length === 0) { | |
| setEditGeneratedMaskFile(null); | |
| setEditIsMaskSaved(false); | |
| setEditMaskPreviewUrl(null); | |
| return; | |
| } | |
| const offscreenCanvas = document.createElement('canvas'); | |
| offscreenCanvas.width = editOriginalImageSize.width; | |
| offscreenCanvas.height = editOriginalImageSize.height; | |
| const offscreenCtx = offscreenCanvas.getContext('2d'); | |
| if (!offscreenCtx) return; | |
| offscreenCtx.fillStyle = '#000000'; | |
| offscreenCtx.fillRect(0, 0, offscreenCanvas.width, offscreenCanvas.height); | |
| offscreenCtx.globalCompositeOperation = 'destination-out'; | |
| editDrawnPoints.forEach((point) => { | |
| drawMaskStroke(offscreenCtx, point.x, point.y, point.size); | |
| }); | |
| try { | |
| const dataUrl = offscreenCanvas.toDataURL('image/png'); | |
| setEditMaskPreviewUrl(dataUrl); | |
| } catch (e) { | |
| console.error('生成遮罩预览 data URL 失败:', e); | |
| setEditMaskPreviewUrl(null); | |
| } | |
| offscreenCanvas.toBlob((blob) => { | |
| if (blob) { | |
| const maskFile = new File([blob], 'generated-mask.png', { type: 'image/png' }); | |
| setEditGeneratedMaskFile(maskFile); | |
| setEditIsMaskSaved(true); | |
| } else { | |
| console.error('生成遮罩 blob 失败。'); | |
| setEditIsMaskSaved(false); | |
| setEditMaskPreviewUrl(null); | |
| } | |
| }, 'image/png'); | |
| }; | |
| const handleImageFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { | |
| if (event.target.files) { | |
| const newFiles = Array.from(event.target.files); | |
| const totalFiles = imageFiles.length + newFiles.length; | |
| if (totalFiles > maxImages) { | |
| alert(t('alert.maxImages', { count: maxImages })); | |
| const allowedNewFiles = newFiles.slice(0, maxImages - imageFiles.length); | |
| if (allowedNewFiles.length === 0) { | |
| event.target.value = ''; | |
| return; | |
| } | |
| newFiles.splice(allowedNewFiles.length); | |
| } | |
| setImageFiles((prevFiles) => [...prevFiles, ...newFiles]); | |
| const newFilePromises = newFiles.map((file) => { | |
| return new Promise<string>((resolve, reject) => { | |
| const reader = new FileReader(); | |
| reader.onloadend = () => resolve(reader.result as string); | |
| reader.onerror = reject; | |
| reader.readAsDataURL(file); | |
| }); | |
| }); | |
| Promise.all(newFilePromises) | |
| .then((newUrls) => { | |
| setSourceImagePreviewUrls((prevUrls) => [...prevUrls, ...newUrls]); | |
| }) | |
| .catch((error) => { | |
| console.error('读取新图片文件失败:', error); | |
| }); | |
| event.target.value = ''; | |
| } | |
| }; | |
| const handleRemoveImage = (indexToRemove: number) => { | |
| setImageFiles((prevFiles) => prevFiles.filter((_, index) => index !== indexToRemove)); | |
| setSourceImagePreviewUrls((prevUrls) => prevUrls.filter((_, index) => index !== indexToRemove)); | |
| }; | |
| const handleMaskFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = event.target.files?.[0]; | |
| if (!file || !editOriginalImageSize) { | |
| event.target.value = ''; | |
| return; | |
| } | |
| if (file.type !== 'image/png') { | |
| alert(t('alert.maskInvalidType')); | |
| event.target.value = ''; | |
| return; | |
| } | |
| const reader = new FileReader(); | |
| const img = new window.Image(); | |
| const objectUrl = URL.createObjectURL(file); | |
| img.onload = () => { | |
| if (img.width !== editOriginalImageSize.width || img.height !== editOriginalImageSize.height) { | |
| alert( | |
| t('alert.maskDimensionMismatch', { | |
| actual: `${img.width}x${img.height}`, | |
| expected: `${editOriginalImageSize.width}x${editOriginalImageSize.height}` | |
| }) | |
| ); | |
| URL.revokeObjectURL(objectUrl); | |
| event.target.value = ''; | |
| return; | |
| } | |
| setEditGeneratedMaskFile(file); | |
| setEditIsMaskSaved(true); | |
| setEditDrawnPoints([]); | |
| reader.onloadend = () => { | |
| setEditMaskPreviewUrl(reader.result as string); | |
| URL.revokeObjectURL(objectUrl); | |
| }; | |
| reader.onerror = () => { | |
| console.error('读取遮罩预览文件失败。'); | |
| setEditMaskPreviewUrl(null); | |
| URL.revokeObjectURL(objectUrl); | |
| }; | |
| reader.readAsDataURL(file); | |
| event.target.value = ''; | |
| }; | |
| img.onerror = () => { | |
| alert(t('alert.maskLoadFailed')); | |
| URL.revokeObjectURL(objectUrl); | |
| event.target.value = ''; | |
| }; | |
| img.src = objectUrl; | |
| }; | |
| const handleSubmit = () => { | |
| if (imageFiles.length === 0) { | |
| alert(t('alert.editNoImage')); | |
| return; | |
| } | |
| if (imageFiles.length > maxImages) { | |
| alert(t('alert.maxImages', { count: maxImages })); | |
| return; | |
| } | |
| if (editDrawnPoints.length > 0 && !editGeneratedMaskFile && !editIsMaskSaved) { | |
| alert(t('alert.saveMaskBeforeSubmit')); | |
| return; | |
| } | |
| if (customSizeInvalid) { | |
| return; | |
| } | |
| if (partialImages < partialImagesRange.min || partialImages > partialImagesRange.max) { | |
| setPartialImages(clampIntegerToRange(partialImages, partialImagesRange) as PartialImagesCount); | |
| return; | |
| } | |
| if (editN[0] < upstreamProfile.editCount.min || editN[0] > upstreamProfile.editCount.max) { | |
| setEditN([clampIntegerToRange(editN[0], upstreamProfile.editCount)]); | |
| return; | |
| } | |
| const formData: EditingFormData = { | |
| prompt: editPrompt, | |
| n: editN[0], | |
| size: editSize, | |
| customWidth: editCustomWidth, | |
| customHeight: editCustomHeight, | |
| quality: editQuality, | |
| output_format: editOutputFormat, | |
| ...(showCompression ? { output_compression: editCompression[0] } : {}), | |
| moderation: editModeration, | |
| imageFiles: imageFiles, | |
| maskFile: editGeneratedMaskFile, | |
| model: editModel, | |
| image_backend: editImageBackend, | |
| streaming_strategy: editStreamingStrategy, | |
| responsesModel: editResponsesModel, | |
| thinking: editThinking, | |
| promptOptimization: editPromptOptimization, | |
| forceWeb: editForceWeb, | |
| enableParallelBatch: parallelBatchChecked | |
| }; | |
| onSubmit(formData); | |
| }; | |
| return ( | |
| <Card className='mobile-drawer-form-card workbench-panel text-card-foreground border-border flex min-h-0 w-full flex-1 flex-col gap-0 overflow-hidden rounded-lg border py-0 lg:h-full'> | |
| <CardHeader className='border-border/70 shrink-0 border-b px-3 pt-2 !pb-2'> | |
| <ModeToggle currentMode={currentMode} onModeChange={onModeChange} /> | |
| </CardHeader> | |
| <div className='mobile-drawer-form-frame flex min-h-0 flex-1 flex-col overflow-hidden'> | |
| <CardContent className='mobile-drawer-form-content literary-scrollbar min-h-0 flex-1 space-y-3 overflow-y-auto p-4 pb-4 lg:px-5 lg:py-3'> | |
| <div className='space-y-1'> | |
| <div className='flex items-center'> | |
| <CardTitle className='editorial-title py-0.5 text-xl font-semibold'> | |
| {t('workbench.creationSheet')} | |
| </CardTitle> | |
| {isPasswordRequiredByBackend && ( | |
| <Button | |
| variant='ghost' | |
| size='sm' | |
| onClick={onOpenPasswordDialog} | |
| className='text-muted-foreground hover:text-foreground ml-auto min-h-11 min-w-11 px-2 lg:h-7 lg:min-h-0 lg:min-w-0' | |
| aria-label={t('password.configure')}> | |
| {clientPasswordHash ? ( | |
| <Lock className='h-4 w-4' /> | |
| ) : ( | |
| <LockOpen className='h-4 w-4' /> | |
| )} | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| {reuseContext && ( | |
| <div className='border-primary/25 bg-primary/5 rounded-md border px-3 py-2 text-xs leading-5 shadow-sm'> | |
| <div className='space-y-2'> | |
| <div className='flex items-start justify-between gap-3'> | |
| <div className='min-w-0'> | |
| <p className='text-foreground font-medium'>{t('reuse.editAppliedTitle')}</p> | |
| <p className='text-muted-foreground truncate'>{reuseContext.sourceLabel}</p> | |
| </div> | |
| <button | |
| type='button' | |
| onClick={onClearReuseContext} | |
| disabled={isLoading} | |
| className='text-muted-foreground hover:text-foreground focus-visible:ring-ring -mr-2 min-h-11 shrink-0 rounded-md px-2 transition-[color,box-shadow] focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50 lg:mr-0 lg:min-h-7 lg:px-0'> | |
| {t('common.clear')} | |
| </button> | |
| </div> | |
| <div className='flex flex-wrap gap-1'> | |
| {reuseContext.restoredFields.map((field) => ( | |
| <span | |
| key={field} | |
| className='border-primary/20 bg-background/70 text-primary rounded-full border px-2 py-0.5'> | |
| {field} | |
| </span> | |
| ))} | |
| </div> | |
| <p className='text-muted-foreground max-h-10 overflow-hidden break-words'> | |
| {reuseContext.promptPreview} | |
| </p> | |
| <p className='text-muted-foreground'>{t('reuse.editMutableHint')}</p> | |
| </div> | |
| </div> | |
| )} | |
| <div className='border-border/80 space-y-3 border-b border-dashed pb-4'> | |
| <div className='flex items-start justify-between gap-3'> | |
| <div className='space-y-1'> | |
| <div | |
| id='source-image-upload-label' | |
| className='text-foreground flex items-center gap-2 text-sm leading-none font-medium select-none'> | |
| {t('edit.referenceTitle')} | |
| </div> | |
| <p | |
| id='source-image-upload-description' | |
| className='text-muted-foreground text-xs leading-5'> | |
| {t('edit.referenceHint', { count: maxImages })} | |
| </p> | |
| </div> | |
| <span className='border-primary/20 bg-primary/10 text-primary ui-stat rounded-full border px-2 py-1 text-xs'> | |
| {t('edit.referenceCount', { count: imageFiles.length, max: maxImages })} | |
| </span> | |
| </div> | |
| <button | |
| type='button' | |
| onClick={() => imageInputRef.current?.click()} | |
| disabled={!canAddSourceImage} | |
| aria-label={t('edit.referenceAction')} | |
| aria-describedby='source-image-upload-description' | |
| className={`group hover:border-primary/35 focus-visible:ring-ring border-input bg-muted/35 hover:bg-muted/55 flex w-full cursor-pointer rounded-md border border-dashed text-center shadow-inner transition-[background-color,border-color,box-shadow,transform] focus-visible:ring-2 focus-visible:outline-none enabled:active:scale-[0.995] disabled:cursor-not-allowed disabled:opacity-60 ${ | |
| hasSourceImages | |
| ? 'min-h-11 flex-row items-center justify-start gap-2 px-3 py-2 text-left' | |
| : 'min-h-[116px] flex-col items-center justify-center gap-2 px-4 py-4' | |
| }`}> | |
| <span | |
| className={`border-primary/20 bg-background/80 text-primary flex items-center justify-center rounded-md border shadow-sm transition-transform group-hover:-translate-y-0.5 ${ | |
| hasSourceImages ? 'h-8 w-8 shrink-0' : 'h-10 w-10' | |
| }`}> | |
| <UploadCloud className='h-5 w-5' /> | |
| </span> | |
| <span className={hasSourceImages ? 'min-w-0' : ''}> | |
| <span className='text-foreground block text-sm font-medium'> | |
| {hasSourceImages ? t('edit.referenceAddMore') : t('edit.referenceAction')} | |
| </span> | |
| <span className='text-muted-foreground block max-w-[18rem] text-xs leading-5'> | |
| {hasSourceImages ? t('edit.referenceReady') : t('edit.referenceEmpty')} | |
| </span> | |
| </span> | |
| </button> | |
| <Input | |
| ref={imageInputRef} | |
| id='image-files-input' | |
| name='sourceImages' | |
| type='file' | |
| accept='image/png, image/jpeg, image/webp' | |
| multiple | |
| onChange={handleImageFileChange} | |
| disabled={isLoading || imageFiles.length >= maxImages} | |
| className='hidden' | |
| /> | |
| {!hasSourceImages && ( | |
| <div className='rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800 dark:border-amber-900 dark:bg-amber-950/35 dark:text-amber-200'> | |
| {t('edit.referenceRequiredNote')} | |
| </div> | |
| )} | |
| {hasSourceImages && !hasSourcePreviews && ( | |
| <div className='border-border/70 bg-background/60 text-muted-foreground rounded-md border px-3 py-2 text-xs leading-5'> | |
| {t('edit.referencePreparing')} | |
| </div> | |
| )} | |
| {hasSourcePreviews && ( | |
| <div className='flex space-x-2 overflow-x-auto pt-1.5'> | |
| {sourceImagePreviewUrls.map((url, index) => ( | |
| <div | |
| key={url} | |
| className='group border-border bg-card relative shrink-0 rounded-sm border p-1 shadow-[0_6px_14px_rgb(15,23,42,0.1)]'> | |
| <Image | |
| src={url} | |
| alt={t('edit.sourcePreview', { index: index + 1 })} | |
| width={96} | |
| height={96} | |
| className='image-edge h-24 w-24 rounded-[3px] object-cover' | |
| unoptimized | |
| /> | |
| <Button | |
| type='button' | |
| variant='secondary' | |
| size='icon' | |
| className='border-background/80 absolute top-1.5 right-1.5 min-h-9 min-w-9 rounded-full border bg-slate-800/80 p-0 text-white opacity-100 shadow-sm transition-opacity hover:bg-slate-900 hover:text-white sm:min-h-7 sm:min-w-7 sm:opacity-0 sm:group-focus-within:opacity-100 sm:group-hover:opacity-100' | |
| onClick={() => handleRemoveImage(index)} | |
| aria-label={t('edit.removeImage', { index: index + 1 })}> | |
| <X className='h-3 w-3' /> | |
| </Button> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| <div className='border-border/80 space-y-1.5 border-b border-dashed pb-4'> | |
| <Label htmlFor='edit-prompt'>{t('edit.instructionTitle')}</Label> | |
| <div className='relative'> | |
| <Textarea | |
| id='edit-prompt' | |
| name='editPrompt' | |
| placeholder={t('form.editPromptPlaceholder')} | |
| value={editPrompt} | |
| onChange={(e) => setEditPrompt(e.target.value)} | |
| required | |
| disabled={isLoading} | |
| className='bg-muted/45 min-h-[118px] rounded-md px-4 py-3 pb-9 leading-7 shadow-inner' | |
| /> | |
| <span className='text-muted-foreground ui-stat pointer-events-none absolute bottom-3 left-4 text-xs'> | |
| {editPrompt.trim().length} / 1000 | |
| </span> | |
| </div> | |
| </div> | |
| <div className='border-border bg-background space-y-3 rounded-md border p-3'> | |
| <div className='text-foreground block text-sm leading-none font-medium select-none'> | |
| {t('edit.mask')} | |
| </div> | |
| <Button | |
| type='button' | |
| variant='outline' | |
| size='sm' | |
| onClick={() => setEditShowMaskEditor(!editShowMaskEditor)} | |
| disabled={isLoading || !editOriginalImageSize} | |
| className='min-h-9 w-full justify-start px-3'> | |
| {editShowMaskEditor | |
| ? t('edit.closeMaskEditor') | |
| : editGeneratedMaskFile | |
| ? t('edit.editSavedMask') | |
| : t('edit.createMask')} | |
| {editIsMaskSaved && !editShowMaskEditor && ( | |
| <span className='ml-auto text-xs text-emerald-600 dark:text-emerald-400'> | |
| ({t('edit.maskAppliedStatus')}) | |
| </span> | |
| )} | |
| <ScanEye className='mt-0.5' /> | |
| </Button> | |
| {editShowMaskEditor && firstImagePreviewUrl && editOriginalImageSize && ( | |
| <div className='bg-muted/20 border-border space-y-3 rounded-md border p-3'> | |
| <p className='text-muted-foreground text-xs'>{t('edit.drawMaskHint')}</p> | |
| <div | |
| className='border-border image-edge relative mx-auto w-full overflow-hidden rounded border' | |
| style={{ | |
| maxWidth: `min(100%, ${editOriginalImageSize.width}px)`, | |
| aspectRatio: `${editOriginalImageSize.width} / ${editOriginalImageSize.height}` | |
| }}> | |
| <Image | |
| src={firstImagePreviewUrl} | |
| alt={t('edit.imagePreviewForMasking')} | |
| width={editOriginalImageSize.width} | |
| height={editOriginalImageSize.height} | |
| className='block h-auto w-full' | |
| unoptimized | |
| /> | |
| <canvas | |
| ref={canvasRef} | |
| width={editOriginalImageSize.width} | |
| height={editOriginalImageSize.height} | |
| className='absolute top-0 left-0 h-full w-full cursor-crosshair' | |
| onMouseDown={startDrawing} | |
| onMouseMove={drawLine} | |
| onMouseUp={stopDrawing} | |
| onMouseLeave={stopDrawing} | |
| onTouchStart={startDrawing} | |
| onTouchMove={drawLine} | |
| onTouchEnd={stopDrawing} | |
| /> | |
| </div> | |
| <div className='grid grid-cols-1 gap-4 pt-2'> | |
| <div className='space-y-2'> | |
| <div className='text-foreground flex items-center gap-2 text-sm leading-none font-medium select-none'> | |
| <span className='ui-stat'> | |
| {t('edit.brushSize', { value: editBrushSize[0] })} | |
| </span> | |
| </div> | |
| <Slider | |
| id='brush-size-slider' | |
| name='brush-size' | |
| thumbLabel={t('edit.brushSizeLabel')} | |
| min={5} | |
| max={100} | |
| step={1} | |
| value={editBrushSize} | |
| onValueChange={setEditBrushSize} | |
| disabled={isLoading} | |
| className='mt-1' | |
| /> | |
| </div> | |
| </div> | |
| <div className='flex flex-col items-stretch gap-2 pt-3 sm:flex-row sm:items-center sm:justify-between'> | |
| <Button | |
| type='button' | |
| variant='outline' | |
| size='sm' | |
| onClick={() => maskInputRef.current?.click()} | |
| disabled={isLoading || !editOriginalImageSize} | |
| className='w-full sm:mr-auto sm:w-auto'> | |
| <UploadCloud className='mr-1.5 h-4 w-4' /> {t('edit.uploadMask')} | |
| </Button> | |
| <Input | |
| ref={maskInputRef} | |
| id='mask-file-input' | |
| name='maskFile' | |
| type='file' | |
| accept='image/png' | |
| onChange={handleMaskFileChange} | |
| className='hidden' | |
| /> | |
| <div className='flex flex-col gap-2 sm:flex-row'> | |
| <Button | |
| type='button' | |
| variant='outline' | |
| size='sm' | |
| onClick={handleClearMask} | |
| disabled={isLoading} | |
| className='w-full sm:w-auto'> | |
| <Eraser className='mr-1.5 h-4 w-4' /> {t('edit.clearMask')} | |
| </Button> | |
| <Button | |
| type='button' | |
| variant='default' | |
| size='sm' | |
| onClick={generateAndSaveMask} | |
| disabled={isLoading || editDrawnPoints.length === 0} | |
| className='w-full sm:w-auto'> | |
| <Save className='mr-1.5 h-4 w-4' /> {t('edit.saveMask')} | |
| </Button> | |
| </div> | |
| </div> | |
| {editMaskPreviewUrl && ( | |
| <div className='border-border mt-3 border-t pt-3 text-center'> | |
| <div className='text-foreground mb-1.5 block text-sm leading-none font-medium select-none'> | |
| {t('edit.maskPreview')} | |
| </div> | |
| <div className='border-border bg-background inline-block rounded border p-1'> | |
| <Image | |
| src={editMaskPreviewUrl} | |
| alt={t('edit.generatedMaskPreviewAlt')} | |
| width={editOriginalImageSize.width} | |
| height={editOriginalImageSize.height} | |
| className='block max-w-full' | |
| style={{ width: 'auto', height: '134px' }} | |
| unoptimized | |
| /> | |
| </div> | |
| </div> | |
| )} | |
| {editIsMaskSaved && !editMaskPreviewUrl && ( | |
| <p className='pt-1 text-center text-xs text-yellow-600 dark:text-yellow-400'> | |
| {t('edit.maskPreviewLoading')} | |
| </p> | |
| )} | |
| {editIsMaskSaved && editMaskPreviewUrl && ( | |
| <p className='pt-1 text-center text-xs text-emerald-600 dark:text-emerald-400'> | |
| {t('edit.maskSaved')} | |
| </p> | |
| )} | |
| </div> | |
| )} | |
| {!editShowMaskEditor && editGeneratedMaskFile && ( | |
| <p className='pt-1 text-xs text-emerald-600 dark:text-emerald-400'> | |
| {t('edit.maskApplied', { name: editGeneratedMaskFile.name })} | |
| </p> | |
| )} | |
| </div> | |
| <div className='border-border bg-background space-y-3 rounded-md border p-3'> | |
| <div className='text-foreground block text-sm leading-none font-medium select-none'> | |
| {t('form.size')} | |
| </div> | |
| <RadioGroup | |
| value={editSize} | |
| onValueChange={(value) => setEditSize(value as EditingFormData['size'])} | |
| disabled={isLoading} | |
| name='edit-size' | |
| aria-label={t('form.size')} | |
| className='grid grid-cols-2 gap-2 sm:grid-cols-3'> | |
| {editSizePresetOptions.map((option) => ( | |
| <RadioItemWithIcon | |
| key={option.value} | |
| value={option.value} | |
| id={`edit-size-${option.value}`} | |
| label={getSizePresetLabel(option.value, t)} | |
| Icon={getSizePresetIcon(option.value)} | |
| disabled={isLoading} | |
| tooltip={getPresetTooltip(option.value, editModel)} | |
| /> | |
| ))} | |
| </RadioGroup> | |
| {isGptImage2 && editSize === 'custom' && ( | |
| <div className='bg-muted/30 border-border space-y-2 rounded-md border p-3'> | |
| <div className='flex items-center gap-3'> | |
| <div className='flex-1 space-y-1'> | |
| <Label htmlFor='edit-custom-width' className='text-muted-foreground text-xs'> | |
| {t('form.width')} | |
| </Label> | |
| <Input | |
| id='edit-custom-width' | |
| name='editCustomWidth' | |
| type='number' | |
| inputMode='numeric' | |
| min={usesPositiveIntegerCustomSize ? 1 : 16} | |
| max={usesPositiveIntegerCustomSize ? undefined : 3840} | |
| step={usesPositiveIntegerCustomSize ? 1 : 16} | |
| value={editCustomWidth} | |
| onChange={(e) => setEditCustomWidth(parseInt(e.target.value, 10) || 0)} | |
| disabled={isLoading} | |
| /> | |
| </div> | |
| <span className='text-muted-foreground pt-5'>x</span> | |
| <div className='flex-1 space-y-1'> | |
| <Label htmlFor='edit-custom-height' className='text-muted-foreground text-xs'> | |
| {t('form.height')} | |
| </Label> | |
| <Input | |
| id='edit-custom-height' | |
| name='editCustomHeight' | |
| type='number' | |
| inputMode='numeric' | |
| min={usesPositiveIntegerCustomSize ? 1 : 16} | |
| max={usesPositiveIntegerCustomSize ? undefined : 3840} | |
| step={usesPositiveIntegerCustomSize ? 1 : 16} | |
| value={editCustomHeight} | |
| onChange={(e) => setEditCustomHeight(parseInt(e.target.value, 10) || 0)} | |
| disabled={isLoading} | |
| /> | |
| </div> | |
| </div> | |
| <p className='text-muted-foreground ui-stat text-xs'> | |
| {t(usesPositiveIntegerCustomSize ? 'form.pixelsMeta' : 'form.pixelsMetaOfMaximum', { | |
| pixels: editCustomPixels.toLocaleString(locale), | |
| percent: ((editCustomPixels / 8_294_400) * 100).toFixed(1), | |
| ratio: editCustomRatio | |
| })} | |
| </p> | |
| {editCustomSizeError && ( | |
| <p className='text-destructive text-xs'>{editCustomSizeError}</p> | |
| )} | |
| <p className='text-muted-foreground text-xs'>{t('form.customConstraints')}</p> | |
| </div> | |
| )} | |
| </div> | |
| <div className='border-border bg-muted/20 rounded-md border'> | |
| <button | |
| type='button' | |
| onClick={() => setIsAdvancedOpen((open) => !open)} | |
| className='text-muted-foreground hover:bg-accent hover:text-accent-foreground focus-visible:ring-ring flex w-full cursor-pointer items-center justify-between gap-3 px-3 py-3 text-left text-sm font-medium transition-[background-color,color,transform] focus-visible:ring-2 focus-visible:outline-none active:scale-[0.995]' | |
| aria-expanded={isAdvancedOpen} | |
| aria-controls='editing-advanced-panel'> | |
| <span className='flex min-w-0 items-center gap-2'> | |
| <SlidersHorizontal className='h-4 w-4 shrink-0' /> | |
| <span className='min-w-0'> | |
| <span className='text-foreground block'>{t('ux.professionalMode')}</span> | |
| <span className='text-muted-foreground block text-xs leading-4 font-normal'> | |
| {advancedSummary} | |
| </span> | |
| </span> | |
| </span> | |
| <ChevronDown | |
| className={`h-4 w-4 shrink-0 transition-transform ${isAdvancedOpen ? 'rotate-180' : ''}`} | |
| /> | |
| </button> | |
| {isAdvancedOpen && ( | |
| <div id='editing-advanced-panel' className='border-border border-t p-3'> | |
| <Tabs | |
| value={advancedTab} | |
| onValueChange={(value) => setAdvancedTab(value as AdvancedTab)} | |
| className='gap-3'> | |
| <TabsList className='grid h-auto w-full grid-cols-4 rounded-md'> | |
| <TabsTrigger value='output' className='min-h-11 lg:min-h-9'> | |
| {t('ux.output')} | |
| </TabsTrigger> | |
| <TabsTrigger value='model' className='min-h-11 lg:min-h-9'> | |
| {t('ux.modelRoute')} | |
| </TabsTrigger> | |
| <TabsTrigger value='stream' className='min-h-11 lg:min-h-9'> | |
| {t('ux.streaming')} | |
| </TabsTrigger> | |
| <TabsTrigger value='route' className='min-h-11 lg:min-h-9'> | |
| {t('ux.route')} | |
| </TabsTrigger> | |
| </TabsList> | |
| <TabsContent value='model' className='space-y-5'> | |
| <div className='space-y-1.5'> | |
| <div className='flex items-center justify-between gap-2'> | |
| <Label htmlFor='edit-model-select'>{t('form.model')}</Label> | |
| {isGptImage2 && ( | |
| <Tooltip> | |
| <TooltipTrigger asChild> | |
| <button | |
| type='button' | |
| className='text-muted-foreground hover:bg-accent hover:text-foreground active:bg-accent/80 focus-visible:ring-ring -my-2 inline-flex h-9 w-9 cursor-help items-center justify-center rounded-sm transition-[background-color,color,transform] focus-visible:ring-2 focus-visible:outline-none active:scale-95' | |
| aria-label={t('edit.fidelityHint')}> | |
| <HelpCircle className='h-4 w-4' /> | |
| </button> | |
| </TooltipTrigger> | |
| <TooltipContent className='max-w-[280px]'> | |
| {t('edit.fidelityHint')} | |
| </TooltipContent> | |
| </Tooltip> | |
| )} | |
| </div> | |
| <Select | |
| value={editModel} | |
| onValueChange={(value) => | |
| setEditModel(value as EditingFormData['model']) | |
| } | |
| disabled={isLoading} | |
| name='edit-model'> | |
| <SelectTrigger | |
| id='edit-model-select' | |
| className='min-h-11 w-full lg:min-h-9'> | |
| <SelectValue placeholder={t('form.selectModel')} /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value='gpt-image-2'>gpt-image-2</SelectItem> | |
| <SelectItem value='gpt-image-1.5'>gpt-image-1.5</SelectItem> | |
| <SelectItem value='gpt-image-1'>gpt-image-1</SelectItem> | |
| <SelectItem value='gpt-image-1-mini'>gpt-image-1-mini</SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| </TabsContent> | |
| <TabsContent value='route' className='space-y-5'> | |
| <div className='grid gap-3 sm:grid-cols-2'> | |
| <div className='space-y-1.5'> | |
| <Label htmlFor='edit-image-backend-select'> | |
| {t('upstream.backend')} | |
| </Label> | |
| <Select | |
| value={editImageBackend} | |
| onValueChange={(value) => | |
| setEditImageBackend(value as EditingFormData['image_backend']) | |
| } | |
| disabled={isLoading} | |
| name='edit-image_backend'> | |
| <SelectTrigger | |
| id='edit-image-backend-select' | |
| className='min-h-11 lg:min-h-9'> | |
| <SelectValue /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value='images-api'> | |
| {t('upstream.backendImages')} | |
| </SelectItem> | |
| <SelectItem | |
| value='responses-image-generation' | |
| disabled={!allowResponsesImageBackend}> | |
| {t('upstream.backendResponses')} | |
| </SelectItem> | |
| <SelectItem value='server-default'> | |
| {t('upstream.serverDefault')} | |
| </SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| <div className='space-y-1.5'> | |
| <Label htmlFor='edit-streaming-strategy-select'> | |
| {t('upstream.streamingStrategy')} | |
| </Label> | |
| <Select | |
| value={editStreamingStrategy} | |
| onValueChange={(value) => | |
| setEditStreamingStrategy( | |
| value as EditingFormData['streaming_strategy'] | |
| ) | |
| } | |
| disabled={isLoading} | |
| name='edit-image_streaming_strategy'> | |
| <SelectTrigger | |
| id='edit-streaming-strategy-select' | |
| className='min-h-11 lg:min-h-9'> | |
| <SelectValue /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value='auto'> | |
| {t('upstream.strategyAuto')} | |
| </SelectItem> | |
| <SelectItem value='off'>{t('upstream.strategyOff')}</SelectItem> | |
| <SelectItem | |
| value='openai-sse' | |
| disabled={ | |
| !isImageUpstreamStreamingStrategySelectable({ | |
| imageBackend: editImageBackend, | |
| streamingStrategy: 'openai-sse', | |
| allowResponsesImageBackend | |
| }) | |
| }> | |
| {t('upstream.strategyOpenAiSse')} | |
| </SelectItem> | |
| <SelectItem | |
| value='newapi-keepalive-sse' | |
| disabled={ | |
| !isImageUpstreamStreamingStrategySelectable({ | |
| imageBackend: editImageBackend, | |
| streamingStrategy: 'newapi-keepalive-sse', | |
| allowResponsesImageBackend | |
| }) | |
| }> | |
| {t('upstream.strategyKeepaliveSse')} | |
| </SelectItem> | |
| <SelectItem | |
| value='responses-sse' | |
| disabled={ | |
| !isImageUpstreamStreamingStrategySelectable({ | |
| imageBackend: editImageBackend, | |
| streamingStrategy: 'responses-sse', | |
| allowResponsesImageBackend | |
| }) | |
| }> | |
| {t('upstream.strategyResponsesSse')} | |
| </SelectItem> | |
| <SelectItem value='force-sse'> | |
| {t('upstream.strategyForceSse')} | |
| </SelectItem> | |
| <SelectItem value='server-default'> | |
| {t('upstream.serverDefault')} | |
| </SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| </div> | |
| <div className='border-border bg-muted/20 text-muted-foreground space-y-1.5 rounded-md border p-3 text-xs leading-5'> | |
| <div className='text-foreground flex items-center gap-2 text-sm leading-none font-medium select-none'> | |
| <ShieldAlert className='text-muted-foreground h-4 w-4' /> | |
| {t('upstream.routeImpactTitle')} | |
| </div> | |
| {getImageUpstreamRouteImpactKeys({ | |
| backend: editImageBackend, | |
| streamingStrategy: editStreamingStrategy, | |
| defaultStreamingStrategy, | |
| allowResponsesImageBackend, | |
| serverProfileMixed: upstreamProfileMixed | |
| }).map((key) => ( | |
| <p key={key}>{t(key)}</p> | |
| ))} | |
| </div> | |
| {editImageBackend === 'responses-image-generation' && ( | |
| <div className='border-border bg-muted/20 space-y-3 rounded-md border p-3'> | |
| <div className='text-foreground flex items-center gap-2 text-sm leading-none font-medium select-none'> | |
| <WandSparkles className='text-muted-foreground h-4 w-4' /> | |
| {t('upstream.compatibilityParams')} | |
| </div> | |
| <div className='grid gap-3 sm:grid-cols-2'> | |
| <div className='space-y-1.5 sm:col-span-2'> | |
| <Label htmlFor='edit-responses-model-input'> | |
| {t('upstream.gptModel')} | |
| </Label> | |
| <Input | |
| id='edit-responses-model-input' | |
| name='editResponsesModel' | |
| value={editResponsesModel} | |
| onChange={(event) => | |
| setEditResponsesModel(event.target.value) | |
| } | |
| disabled={isLoading} | |
| autoComplete='off' | |
| spellCheck={false} | |
| placeholder='OPENAI_RESPONSES_API_MODEL' | |
| /> | |
| {requiresResponsesModel && ( | |
| <p className='text-destructive text-xs'> | |
| {t('upstream.responsesModelRequired')} | |
| </p> | |
| )} | |
| </div> | |
| <div className='space-y-1.5'> | |
| <Label htmlFor='edit-thinking-select'> | |
| {t('upstream.thinking')} | |
| </Label> | |
| <Select | |
| value={editThinking} | |
| onValueChange={(value) => | |
| setEditThinking(value as EditingFormData['thinking']) | |
| } | |
| disabled={isLoading} | |
| name='edit-thinking'> | |
| <SelectTrigger | |
| id='edit-thinking-select' | |
| className='min-h-11 lg:min-h-9'> | |
| <SelectValue /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value='server-default'> | |
| {t('upstream.serverDefault')} | |
| </SelectItem> | |
| <SelectItem value='none'> | |
| {t('upstream.thinkingNone')} | |
| </SelectItem> | |
| <SelectItem value='minimal'> | |
| {t('upstream.thinkingMinimal')} | |
| </SelectItem> | |
| <SelectItem value='low'> | |
| {t('upstream.thinkingLow')} | |
| </SelectItem> | |
| <SelectItem value='medium'> | |
| {t('upstream.thinkingMedium')} | |
| </SelectItem> | |
| <SelectItem value='high'> | |
| {t('upstream.thinkingHigh')} | |
| </SelectItem> | |
| <SelectItem value='xhigh'> | |
| {t('upstream.thinkingXhigh')} | |
| </SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| <div className='space-y-1.5'> | |
| <Label htmlFor='edit-prompt-optimization-select'> | |
| {t('upstream.promptOptimization')} | |
| </Label> | |
| <Select | |
| value={editPromptOptimization} | |
| onValueChange={(value) => | |
| setEditPromptOptimization( | |
| value as EditingFormData['promptOptimization'] | |
| ) | |
| } | |
| disabled={isLoading} | |
| name='edit-promptOptimization'> | |
| <SelectTrigger | |
| id='edit-prompt-optimization-select' | |
| className='min-h-11 lg:min-h-9'> | |
| <SelectValue /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value='server-default'> | |
| {t('upstream.serverDefault')} | |
| </SelectItem> | |
| <SelectItem value='on'> | |
| {t('common.enabled')} | |
| </SelectItem> | |
| <SelectItem value='off'> | |
| {t('common.disabled')} | |
| </SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {editImageBackend === 'images-api' && ( | |
| <div className='border-border bg-muted/20 space-y-3 rounded-md border p-3'> | |
| <div className='text-foreground flex items-center gap-2 text-sm leading-none font-medium select-none'> | |
| <Globe2 className='text-muted-foreground h-4 w-4' /> | |
| {t('upstream.compatibilityParams')} | |
| </div> | |
| <RadioGroup | |
| value={editForceWeb ? 'true' : 'false'} | |
| onValueChange={(value) => setEditForceWeb(value === 'true')} | |
| disabled={isLoading} | |
| name='edit-force_web' | |
| aria-label={t('upstream.forceWeb')} | |
| className='grid grid-cols-2 gap-2'> | |
| <RadioItemWithIcon | |
| value='false' | |
| id='edit-force-web-false' | |
| label={t('upstream.serverDefault')} | |
| Icon={Sparkles} | |
| disabled={isLoading} | |
| /> | |
| <RadioItemWithIcon | |
| value='true' | |
| id='edit-force-web-true' | |
| label={t('upstream.forceWeb')} | |
| Icon={Globe2} | |
| disabled={isLoading} | |
| tooltip={t('upstream.forceWebHint')} | |
| /> | |
| </RadioGroup> | |
| </div> | |
| )} | |
| </TabsContent> | |
| <TabsContent value='stream' className='space-y-5'> | |
| <div className='space-y-1.5'> | |
| <Label htmlFor='edit-stream-mode-select'>{t('streaming.mode')}</Label> | |
| <Tooltip> | |
| <TooltipTrigger asChild> | |
| <div> | |
| <Select | |
| value={streamMode} | |
| onValueChange={(value) => | |
| setStreamMode(value as ImageStreamMode) | |
| } | |
| disabled={isLoading || streamingDisabledByStrategy} | |
| name='edit-stream_mode'> | |
| <SelectTrigger | |
| id='edit-stream-mode-select' | |
| disabled={isLoading || streamingDisabledByStrategy} | |
| className='min-h-11 w-full lg:min-h-9'> | |
| <SelectValue /> | |
| </SelectTrigger> | |
| <SelectContent> | |
| <SelectItem value='auto'> | |
| {t('streaming.modeAuto')} | |
| </SelectItem> | |
| <SelectItem value='stream'> | |
| {t('streaming.modeStream')} | |
| </SelectItem> | |
| <SelectItem value='non_stream'> | |
| {t('streaming.modeNonStream')} | |
| </SelectItem> | |
| </SelectContent> | |
| </Select> | |
| </div> | |
| </TooltipTrigger> | |
| <TooltipContent className='max-w-[250px]'> | |
| {streamingDisabledByStrategy | |
| ? t('streaming.disabledByStrategy') | |
| : allowStreamingBatch && | |
| editN[0] > 1 && | |
| streamMode !== 'non_stream' | |
| ? t('streaming.batchDescription') | |
| : t('streaming.description')} | |
| </TooltipContent> | |
| </Tooltip> | |
| </div> | |
| <div className='border-border bg-muted/20 flex items-start gap-3 rounded-md border p-3'> | |
| <label | |
| htmlFor='edit-parallel-batch-enabled' | |
| className='-m-2 flex min-h-11 min-w-11 cursor-pointer items-start justify-center p-2 has-disabled:cursor-not-allowed'> | |
| <Checkbox | |
| id='edit-parallel-batch-enabled' | |
| checked={parallelBatchChecked} | |
| onCheckedChange={(value) => setEnableParallelBatch(value === true)} | |
| disabled={isLoading || !canEnableParallelBatch} | |
| aria-label={t('streaming.parallelBatch')} | |
| className='mt-0.5' | |
| /> | |
| </label> | |
| <div className='grid gap-1'> | |
| <Label | |
| htmlFor='edit-parallel-batch-enabled' | |
| className='text-foreground text-sm leading-none font-medium'> | |
| {t('streaming.parallelBatch')} | |
| </Label> | |
| <p className='text-muted-foreground text-xs leading-5'> | |
| {canEnableParallelBatch | |
| ? t('streaming.parallelBatchDescription') | |
| : t(parallelBatchUnavailableKey)} | |
| </p> | |
| </div> | |
| </div> | |
| {streamMode !== 'non_stream' && ( | |
| <div className='space-y-3'> | |
| <div className='flex items-center gap-2'> | |
| <div className='text-foreground flex items-center gap-2 text-sm leading-none font-medium select-none'> | |
| {t('streaming.previewImages')} | |
| </div> | |
| <Tooltip> | |
| <TooltipTrigger asChild> | |
| <button | |
| type='button' | |
| className='text-muted-foreground hover:bg-accent hover:text-foreground active:bg-accent/80 focus-visible:ring-ring -my-2 inline-flex h-11 w-11 cursor-help items-center justify-center rounded-sm transition-[background-color,color,transform] focus-visible:ring-2 focus-visible:outline-none active:scale-95 lg:h-9 lg:w-9' | |
| aria-label={t('streaming.costHint')}> | |
| <HelpCircle className='h-4 w-4' /> | |
| </button> | |
| </TooltipTrigger> | |
| <TooltipContent className='max-w-[250px]'> | |
| {t('streaming.costHint')} | |
| </TooltipContent> | |
| </Tooltip> | |
| </div> | |
| <RadioGroup | |
| value={String(partialImages)} | |
| onValueChange={(value) => | |
| setPartialImages(Number(value) as PartialImagesCount) | |
| } | |
| disabled={isLoading} | |
| name='edit-partial_images' | |
| aria-label={t('streaming.previewImages')} | |
| className='grid grid-cols-5 gap-2'> | |
| {partialImageOptions.map((value) => ( | |
| <RadioItemWithIcon | |
| key={value} | |
| value={String(value)} | |
| id={`edit-partial-${value}`} | |
| label={String(value)} | |
| Icon={ | |
| value === 0 | |
| ? CircleOff | |
| : value === 1 | |
| ? Tally1 | |
| : value === 2 | |
| ? Tally2 | |
| : value === 3 | |
| ? Tally3 | |
| : Tally4 | |
| } | |
| disabled={isLoading} | |
| /> | |
| ))} | |
| </RadioGroup> | |
| </div> | |
| )} | |
| {streamMode === 'non_stream' && ( | |
| <div className='border-border bg-muted/20 text-muted-foreground rounded-md border border-dashed p-3 text-sm'> | |
| {streamModeLabel} | |
| </div> | |
| )} | |
| </TabsContent> | |
| <TabsContent value='output' className='space-y-5'> | |
| <div className='space-y-3'> | |
| <div className='text-foreground block text-sm leading-none font-medium select-none'> | |
| {t('form.quality')} | |
| </div> | |
| <RadioGroup | |
| value={editQuality} | |
| onValueChange={(value) => | |
| setEditQuality(value as EditingFormData['quality']) | |
| } | |
| disabled={isLoading} | |
| name='edit-quality' | |
| aria-label={t('form.quality')} | |
| className='grid grid-cols-2 gap-2 sm:grid-cols-4'> | |
| <RadioItemWithIcon | |
| value='auto' | |
| id='edit-quality-auto' | |
| label={t('common.auto')} | |
| Icon={Sparkles} | |
| disabled={isLoading} | |
| /> | |
| <RadioItemWithIcon | |
| value='low' | |
| id='edit-quality-low' | |
| label={t('common.low')} | |
| Icon={Tally1} | |
| disabled={isLoading} | |
| /> | |
| <RadioItemWithIcon | |
| value='medium' | |
| id='edit-quality-medium' | |
| label={t('common.medium')} | |
| Icon={Tally2} | |
| disabled={isLoading} | |
| /> | |
| <RadioItemWithIcon | |
| value='high' | |
| id='edit-quality-high' | |
| label={t('common.high')} | |
| Icon={Tally3} | |
| disabled={isLoading} | |
| /> | |
| </RadioGroup> | |
| </div> | |
| <div className='space-y-3'> | |
| <div className='text-foreground block text-sm leading-none font-medium select-none'> | |
| {t('form.outputFormat')} | |
| </div> | |
| <RadioGroup | |
| value={editOutputFormat} | |
| onValueChange={(value) => | |
| setEditOutputFormat(value as EditingFormData['output_format']) | |
| } | |
| disabled={isLoading} | |
| name='edit-output_format' | |
| aria-label={t('form.outputFormat')} | |
| className='grid grid-cols-1 gap-2 sm:grid-cols-3'> | |
| <RadioItemWithIcon | |
| value='png' | |
| id='edit-format-png' | |
| label={t('common.png')} | |
| Icon={FileImage} | |
| disabled={isLoading} | |
| /> | |
| <RadioItemWithIcon | |
| value='jpeg' | |
| id='edit-format-jpeg' | |
| label={t('common.jpeg')} | |
| Icon={FileImage} | |
| disabled={isLoading} | |
| /> | |
| <RadioItemWithIcon | |
| value='webp' | |
| id='edit-format-webp' | |
| label={t('common.webp')} | |
| Icon={FileImage} | |
| disabled={isLoading} | |
| /> | |
| </RadioGroup> | |
| </div> | |
| {showCompression && ( | |
| <div className='space-y-2 pt-2 transition-opacity duration-300'> | |
| <div className='text-foreground flex items-center gap-2 text-sm leading-none font-medium select-none'> | |
| {t('form.compression', { value: editCompression[0] })} | |
| </div> | |
| <Slider | |
| id='edit-compression-slider' | |
| name='edit-output_compression' | |
| thumbLabel={t('form.compression', { value: editCompression[0] })} | |
| min={0} | |
| max={100} | |
| step={1} | |
| value={editCompression} | |
| onValueChange={setEditCompression} | |
| disabled={isLoading} | |
| className='mt-3' | |
| /> | |
| </div> | |
| )} | |
| <div className='space-y-3'> | |
| <div className='text-foreground block text-sm leading-none font-medium select-none'> | |
| {t('form.moderation')} | |
| </div> | |
| <RadioGroup | |
| value={editModeration} | |
| onValueChange={(value) => | |
| setEditModeration(value as EditingFormData['moderation']) | |
| } | |
| disabled={isLoading} | |
| name='edit-moderation' | |
| aria-label={t('form.moderation')} | |
| className='grid grid-cols-2 gap-2'> | |
| <RadioItemWithIcon | |
| value='auto' | |
| id='edit-mod-auto' | |
| label={t('common.auto')} | |
| Icon={ShieldCheck} | |
| disabled={isLoading} | |
| /> | |
| <RadioItemWithIcon | |
| value='low' | |
| id='edit-mod-low' | |
| label={t('common.low')} | |
| Icon={ShieldAlert} | |
| disabled={isLoading} | |
| /> | |
| </RadioGroup> | |
| </div> | |
| <div className='space-y-2'> | |
| <div className='text-foreground flex items-center gap-2 text-sm leading-none font-medium select-none'> | |
| {t('form.numberOfImages', { count: editN[0] })} | |
| </div> | |
| <Slider | |
| id='edit-n-slider' | |
| name='edit-n' | |
| thumbLabel={t('form.numberOfImages', { count: editN[0] })} | |
| min={1} | |
| max={upstreamProfile.editCount.max} | |
| step={1} | |
| value={editN} | |
| onValueChange={setEditN} | |
| disabled={isLoading} | |
| className='mt-3' | |
| /> | |
| </div> | |
| </TabsContent> | |
| </Tabs> | |
| </div> | |
| )} | |
| </div> | |
| </CardContent> | |
| <CardFooter className='border-border bg-card flex shrink-0 border-t p-4'> | |
| <div className='w-full space-y-2'> | |
| <div className='grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-1.5 text-xs'> | |
| <span className='border-border bg-muted/45 text-muted-foreground inline-flex min-w-0 items-center justify-center rounded-md border px-2 py-1 text-center leading-4 break-words whitespace-normal'> | |
| {editModel} | |
| </span> | |
| <span className='border-border bg-muted/45 text-muted-foreground inline-flex min-w-0 items-center justify-center rounded-md border px-2 py-1 text-center leading-4 break-words whitespace-normal'> | |
| {workbenchBackendLabel} | |
| </span> | |
| <span className='border-border bg-muted/45 text-muted-foreground inline-flex min-w-0 items-center justify-center rounded-md border px-2 py-1 text-center leading-4 break-words whitespace-normal'> | |
| {streamStatusLabel} | |
| </span> | |
| {parallelBatchChecked && ( | |
| <span className='inline-flex min-w-0 items-center justify-center rounded-md border border-emerald-200 bg-emerald-50 px-2 py-1 text-center leading-4 break-words whitespace-normal text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-300'> | |
| {t('streaming.parallelBatchEnabled')} | |
| </span> | |
| )} | |
| <span className='border-primary/20 bg-primary/10 text-primary col-span-2 inline-flex min-w-0 items-center justify-center rounded-md border px-2 py-1 text-center leading-4 break-words whitespace-normal'> | |
| {requestSummaryLabel} | |
| </span> | |
| </div> | |
| {submitDisabledReason && ( | |
| <p className='text-muted-foreground text-center text-xs'>{submitDisabledReason}</p> | |
| )} | |
| <Button | |
| type='button' | |
| onClick={handleSubmit} | |
| disabled={isLoading || !!submitDisabledReason} | |
| className='bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground flex min-h-11 w-full items-center justify-center gap-2 rounded-md border-0 shadow-sm lg:min-h-9'> | |
| {showLoadingState && <Loader2 className='h-4 w-4 animate-spin' />} | |
| {showLoadingState ? t('edit.loading') : t('edit.submit')} | |
| </Button> | |
| <div className='grid grid-cols-2 gap-2'> | |
| <button | |
| type='button' | |
| className='border-border bg-background/70 text-muted-foreground hover:border-primary/25 hover:bg-accent/45 hover:text-foreground focus-visible:ring-ring flex min-h-11 min-w-0 items-center justify-center gap-2 rounded-md border px-2 text-xs transition-[background-color,border-color,color,box-shadow,transform] focus-visible:ring-2 focus-visible:outline-none enabled:hover:-translate-y-0.5 enabled:active:translate-y-0 disabled:opacity-50 lg:min-h-9' | |
| disabled={!editPrompt.trim() || isLoading} | |
| onClick={() => onSaveInspiration(editPrompt)}> | |
| <Bookmark className='h-3.5 w-3.5 shrink-0' aria-hidden='true' /> | |
| <span className='min-w-0 whitespace-normal'>{t('workbench.saveInspiration')}</span> | |
| </button> | |
| <button | |
| type='button' | |
| className='border-border bg-background/70 text-muted-foreground hover:border-primary/25 hover:bg-accent/45 hover:text-foreground focus-visible:ring-ring flex min-h-11 min-w-0 items-center justify-center gap-2 rounded-md border px-2 text-xs transition-[background-color,border-color,color,box-shadow,transform] focus-visible:ring-2 focus-visible:outline-none enabled:hover:-translate-y-0.5 enabled:active:translate-y-0 disabled:opacity-50 lg:min-h-9' | |
| disabled={isLoading || !canApplyRandomInspiration} | |
| onClick={() => { | |
| const nextPrompt = onPickRandomInspiration().trim(); | |
| if (!nextPrompt) return; | |
| setEditPrompt(nextPrompt); | |
| }}> | |
| <WandSparkles className='h-3.5 w-3.5 shrink-0' aria-hidden='true' /> | |
| <span className='min-w-0 whitespace-normal'>{t('workbench.randomInspiration')}</span> | |
| </button> | |
| </div> | |
| </div> | |
| </CardFooter> | |
| </div> | |
| </Card> | |
| ); | |
| } | |