diff --git "a/src/components/editing-form.tsx" "b/src/components/editing-form.tsx" --- "a/src/components/editing-form.tsx" +++ "b/src/components/editing-form.tsx" @@ -1,22 +1,37 @@ 'use client'; import { ModeToggle } from '@/components/mode-toggle'; +import type { WorkbenchMode } from '@/components/mode-toggle'; import { Button } from '@/components/ui/button'; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; +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 type { + ImageUpstreamFormBackend, + ImageUpstreamFormPromptOptimization, + ImageUpstreamFormStreamingStrategy, + ImageUpstreamFormThinking +} from '@/lib/image-upstream-form'; +import { + getImageUpstreamRouteImpactKeys, + isImageUpstreamStreamingStrategySelectable, + resolveImageUpstreamEffectiveStreamingStrategy +} from '@/lib/image-upstream-form'; +import type { ImageStreamMode, ImageStreamingStrategy } from '@/lib/image-upstream-strategy'; import { getPresetTooltip, validateGptImage2Size } 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 { - Upload, Eraser, Save, Square, @@ -36,7 +51,11 @@ import { LockOpen, HelpCircle, SquareDashed, - Info + FileImage, + WandSparkles, + Globe2, + ShieldCheck, + ShieldAlert } from 'lucide-react'; import Image from 'next/image'; import * as React from 'react'; @@ -54,16 +73,28 @@ export type EditingFormData = { 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; isLoading: boolean; - currentMode: 'generate' | 'edit'; - onModeChange: (mode: 'generate' | 'edit') => void; + currentMode: WorkbenchMode; + onModeChange: (mode: WorkbenchMode) => void; + reuseContext: EditingReuseContext | null; + onClearReuseContext: () => void; isPasswordRequiredByBackend: boolean | null; clientPasswordHash: string | null; onOpenPasswordDialog: () => void; @@ -86,6 +117,12 @@ type EditingFormProps = { setEditCustomHeight: React.Dispatch>; editQuality: EditingFormData['quality']; setEditQuality: React.Dispatch>; + editOutputFormat: EditingFormData['output_format']; + setEditOutputFormat: React.Dispatch>; + editCompression: number[]; + setEditCompression: React.Dispatch>; + editModeration: EditingFormData['moderation']; + setEditModeration: React.Dispatch>; editBrushSize: number[]; setEditBrushSize: React.Dispatch>; editShowMaskEditor: boolean; @@ -100,13 +137,41 @@ type EditingFormProps = { setEditDrawnPoints: React.Dispatch>; editMaskPreviewUrl: string | null; setEditMaskPreviewUrl: React.Dispatch>; - enableStreaming: boolean; - setEnableStreaming: React.Dispatch>; + streamMode: ImageStreamMode; + setStreamMode: React.Dispatch>; allowStreamingBatch: boolean; + enableParallelBatch: boolean; + setEnableParallelBatch: React.Dispatch>; partialImages: 1 | 2 | 3; setPartialImages: React.Dispatch>; + allowResponsesImageBackend: boolean; + hasDefaultResponsesModel: boolean; + editImageBackend: EditingFormData['image_backend']; + setEditImageBackend: React.Dispatch>; + editStreamingStrategy: EditingFormData['streaming_strategy']; + defaultStreamingStrategy: ImageStreamingStrategy; + setEditStreamingStrategy: React.Dispatch>; + editResponsesModel: string; + setEditResponsesModel: React.Dispatch>; + editThinking: EditingFormData['thinking']; + setEditThinking: React.Dispatch>; + editPromptOptimization: EditingFormData['promptOptimization']; + setEditPromptOptimization: React.Dispatch>; + editForceWeb: boolean; + setEditForceWeb: React.Dispatch>; + estimatedCostLabel: string; + initialAdvancedOpen?: boolean; + initialAdvancedTab?: AdvancedTab; +}; + +export type EditingReuseContext = { + sourceLabel: string; + restoredFields: string[]; + promptPreview: string; }; +type AdvancedTab = 'output' | 'model' | 'stream' | 'route'; + const RadioItemWithIcon = ({ value, id, @@ -128,9 +193,9 @@ const RadioItemWithIcon = ({ id={id} disabled={disabled} aria-label={label} - className='flex aspect-auto h-auto min-h-10 w-full items-center justify-start gap-2 rounded-md border-border px-3 py-2 text-sm text-muted-foreground shadow-none transition-[background-color,border-color,color,box-shadow,transform] enabled:motion-safe:hover:-translate-y-0.5 enabled:motion-safe:hover:scale-100 enabled:motion-safe:active:scale-100 enabled:hover:border-foreground/20 enabled:hover:bg-accent enabled:hover:text-accent-foreground enabled:active:translate-y-0 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground [&_[data-slot=radio-group-indicator]]:hidden'> - - {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-8 [&_[data-slot=radio-group-indicator]]:hidden'> + + {label} ); @@ -144,11 +209,44 @@ const RadioItemWithIcon = ({ ); }; +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'); +} + +function getQualityLabel(quality: EditingFormData['quality'], t: (key: string) => string): string { + if (quality === 'low') return t('common.low'); + if (quality === 'medium') return t('common.medium'); + if (quality === 'high') return t('common.high'); + return t('common.auto'); +} + +function getOutputFormatLabel(format: EditingFormData['output_format'], t: (key: string) => string): string { + if (format === 'jpeg') return t('common.jpeg'); + if (format === 'webp') return t('common.webp'); + return t('common.png'); +} + export function EditingForm({ onSubmit, isLoading, currentMode, onModeChange, + reuseContext, + onClearReuseContext, isPasswordRequiredByBackend, clientPasswordHash, onOpenPasswordDialog, @@ -171,6 +269,12 @@ export function EditingForm({ setEditCustomHeight, editQuality, setEditQuality, + editOutputFormat, + setEditOutputFormat, + editCompression, + setEditCompression, + editModeration, + setEditModeration, editBrushSize, setEditBrushSize, editShowMaskEditor, @@ -185,11 +289,31 @@ export function EditingForm({ setEditDrawnPoints, editMaskPreviewUrl, setEditMaskPreviewUrl, - enableStreaming, - setEnableStreaming, + streamMode, + setStreamMode, allowStreamingBatch, + enableParallelBatch, + setEnableParallelBatch, partialImages, - setPartialImages + setPartialImages, + allowResponsesImageBackend, + hasDefaultResponsesModel, + editImageBackend, + setEditImageBackend, + editStreamingStrategy, + defaultStreamingStrategy, + setEditStreamingStrategy, + editResponsesModel, + setEditResponsesModel, + editThinking, + setEditThinking, + editPromptOptimization, + setEditPromptOptimization, + editForceWeb, + setEditForceWeb, + estimatedCostLabel, + initialAdvancedOpen = false, + initialAdvancedTab = 'output' }: EditingFormProps) { const { locale, t } = useI18n(); const [firstImagePreviewUrl, setFirstImagePreviewUrl] = React.useState(null); @@ -210,16 +334,35 @@ export function EditingForm({ const editCustomSizeError = customSizeValidation.valid ? null : t(customSizeValidation.reasonKey, customSizeValidation.values); - - const streamingDisabledByCount = editN[0] > 1 && !allowStreamingBatch; - const [isAdvancedOpen, setIsAdvancedOpen] = React.useState(false); + 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(initialAdvancedTab); + const requiresResponsesModel = + editImageBackend === 'responses-image-generation' && !hasDefaultResponsesModel && !editResponsesModel.trim(); const submitDisabledReason = React.useMemo(() => { if (isLoading) return ''; - if (!editPrompt.trim()) return t('ux.disabledPrompt'); if (imageFiles.length === 0) return t('ux.disabledSourceImage'); + 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 ''; }, [ @@ -231,15 +374,17 @@ export function EditingForm({ editPrompt, imageFiles.length, isLoading, + requiresResponsesModel, t ]); - - // 未显式开启批量流式分发时,editN > 1 会禁用流式输出。 - React.useEffect(() => { - if (streamingDisabledByCount && enableStreaming) { - setEnableStreaming(false); - } - }, [streamingDisabledByCount, enableStreaming, setEnableStreaming]); + const advancedSummary = [ + `${t('form.quality')}: ${getQualityLabel(editQuality, t)}`, + `${t('form.outputFormat')}: ${getOutputFormatLabel(editOutputFormat, t)}`, + getBackendLabel(editImageBackend, t) + ].join(', '); + const streamModeLabel = getStreamModeLabel(streamMode, t); + const streamStatusLabel = getStreamingStatusLabel(streamMode, t); + const workbenchBackendLabel = getWorkbenchBackendLabel(editImageBackend, t); // custom 仅对 gpt-image-2 有效,切换到旧模型时重置。 React.useEffect(() => { @@ -248,11 +393,22 @@ export function EditingForm({ } }, [isGptImage2, editSize, setEditSize]); + React.useEffect(() => { + if (streamingDisabledByStrategy && streamMode !== 'non_stream') { + setStreamMode('non_stream'); + } + }, [streamingDisabledByStrategy, streamMode, setStreamMode]); + const canvasRef = React.useRef(null); const visualFeedbackCanvasRef = React.useRef(null); const isDrawing = React.useRef(false); const lastPos = React.useRef<{ x: number; y: number } | null>(null); + const imageInputRef = React.useRef(null); const maskInputRef = React.useRef(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) { @@ -265,7 +421,10 @@ export function EditingForm({ }, [editOriginalImageSize]); React.useEffect(() => { + let cancelled = false; + queueMicrotask(() => { + if (cancelled) return; setEditGeneratedMaskFile(null); setEditIsMaskSaved(false); setEditOriginalImageSize(null); @@ -274,26 +433,35 @@ export function EditingForm({ setEditMaskPreviewUrl(null); }); - if (imageFiles.length > 0 && sourceImagePreviewUrls.length > 0) { + if (primaryImagePreviewUrl) { const img = new window.Image(); img.onload = () => { + if (cancelled) return; setEditOriginalImageSize({ width: img.width, height: img.height }); }; - img.src = sourceImagePreviewUrls[0]; + img.src = primaryImagePreviewUrl; queueMicrotask(() => { - setFirstImagePreviewUrl(sourceImagePreviewUrls[0]); + if (!cancelled) { + setFirstImagePreviewUrl(primaryImagePreviewUrl); + } }); } else { queueMicrotask(() => { - setEditShowMaskEditor(false); + if (!cancelled) { + setEditShowMaskEditor(false); + } }); } + + return () => { + cancelled = true; + }; }, [ - imageFiles, - sourceImagePreviewUrls, + primaryImagePreviewUrl, setEditGeneratedMaskFile, setEditIsMaskSaved, setEditOriginalImageSize, + setFirstImagePreviewUrl, setEditDrawnPoints, setEditMaskPreviewUrl, setEditShowMaskEditor @@ -555,137 +723,129 @@ export function EditingForm({ customWidth: editCustomWidth, customHeight: editCustomHeight, quality: editQuality, + output_format: editOutputFormat, + ...(showCompression ? { output_compression: editCompression[0] } : {}), + moderation: editModeration, imageFiles: imageFiles, maskFile: editGeneratedMaskFile, - model: editModel + model: editModel, + image_backend: editImageBackend, + streaming_strategy: editStreamingStrategy, + responsesModel: editResponsesModel, + thinking: editThinking, + promptOptimization: editPromptOptimization, + forceWeb: editForceWeb, + enableParallelBatch: parallelBatchChecked }; onSubmit(formData); }; - const displayFileNames = (files: File[]) => { - if (files.length === 0) return t('edit.noFile'); - if (files.length === 1) return files[0].name; - return t('edit.filesSelected', { count: files.length }); - }; - return ( - - -
-
- {t('edit.title')} - {isPasswordRequiredByBackend && ( - - )} -
- {t('edit.description')} -
+ +
- -
- -
- - {isGptImage2 && ( - - - - - {t('edit.fidelityHint')} - + +
+
+ + {t('workbench.creationSheet')} + + {isPasswordRequiredByBackend && ( + )} - -
- - setEnableStreaming(!!checked)} - disabled={isLoading || streamingDisabledByCount} - className='data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground' - /> - - -
- - {streamingDisabledByCount - ? t('streaming.disabledByCount') - : allowStreamingBatch && editN[0] > 1 - ? t('streaming.batchDescription') - : t('streaming.description')} - -
- -
- -