diff --git "a/src/components/generation-form.tsx" "b/src/components/generation-form.tsx" --- "a/src/components/generation-form.tsx" +++ "b/src/components/generation-form.tsx" @@ -1,25 +1,41 @@ '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 { readBatchPromptLines } from '@/lib/batch-prompts'; import type { GptImageModel } from '@/lib/cost-utils'; import { useI18n } from '@/lib/i18n'; -import type { ImageUpstreamFormBackend, ImageUpstreamFormStreamingStrategy } from '@/lib/image-upstream-form'; -import { getPresetDimensions, getPresetTooltip, validateGptImage2Size } from '@/lib/size-utils'; -import type { SizePreset } from '@/lib/size-utils'; +import type { + ImageUpstreamFormBackend, + ImageUpstreamFormPromptOptimization, + ImageUpstreamFormStreamingStrategy, + ImageUpstreamFormThinking +} from '@/lib/image-upstream-form'; +import { + getImageUpstreamRouteImpactKeys, + isImageUpstreamStreamingStrategySelectable, + resolveImageUpstreamEffectiveStreamingStrategy +} from '@/lib/image-upstream-form'; import { shouldRecommendImageStreaming, + type ImageStreamMode, type ImageStreamingStrategy } from '@/lib/image-upstream-strategy'; +import { getPresetDimensions, 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 { Square, RectangleHorizontal, @@ -39,7 +55,13 @@ import { Lock, LockOpen, HelpCircle, - SquareDashed + SquareDashed, + WandSparkles, + Globe2, + ListChecks, + Pause, + RotateCcw, + Bookmark } from 'lucide-react'; import * as React from 'react'; @@ -58,13 +80,29 @@ export type GenerationFormData = { image_backend: ImageUpstreamFormBackend; streaming_strategy: ImageUpstreamFormStreamingStrategy; responsesModel: string; + thinking: ImageUpstreamFormThinking; + promptOptimization: ImageUpstreamFormPromptOptimization; + forceWeb: boolean; + enableParallelBatch: boolean; + batchPrompts?: string[]; +}; + +export type WorkbenchReuseContext = { + sourceLabel: string; + restoredFields: string[]; + promptPreview: string; }; type GenerationFormProps = { onSubmit: (data: GenerationFormData) => void; + onSaveInspiration: (prompt: string) => void; + canApplyRandomInspiration: boolean; + onPickRandomInspiration: () => string; isLoading: boolean; - currentMode: 'generate' | 'edit'; - onModeChange: (mode: 'generate' | 'edit') => void; + currentMode: WorkbenchMode; + onModeChange: (mode: WorkbenchMode) => void; + reuseContext: WorkbenchReuseContext | null; + onClearReuseContext: () => void; isPasswordRequiredByBackend: boolean | null; clientPasswordHash: string | null; onOpenPasswordDialog: () => void; @@ -72,6 +110,12 @@ type GenerationFormProps = { setModel: React.Dispatch>; prompt: string; setPrompt: React.Dispatch>; + batchPromptText: string; + setBatchPromptText: React.Dispatch>; + failedBatchPrompts?: string[]; + canPauseBatch?: boolean; + isBatchPauseRequested?: boolean; + onPauseBatch?: () => void; n: number[]; setN: React.Dispatch>; size: GenerationFormData['size']; @@ -90,19 +134,51 @@ type GenerationFormProps = { setBackground: React.Dispatch>; moderation: GenerationFormData['moderation']; setModeration: 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; imageBackend: GenerationFormData['image_backend']; setImageBackend: React.Dispatch>; streamingStrategy: GenerationFormData['streaming_strategy']; + defaultStreamingStrategy: ImageStreamingStrategy; setStreamingStrategy: React.Dispatch>; responsesModel: string; setResponsesModel: React.Dispatch>; + thinking: GenerationFormData['thinking']; + setThinking: React.Dispatch>; + promptOptimization: GenerationFormData['promptOptimization']; + setPromptOptimization: React.Dispatch>; + forceWeb: boolean; + setForceWeb: React.Dispatch>; + estimatedCostLabel: string; + defaultAdvancedOpen?: boolean; + defaultAdvancedTab?: AdvancedTab; }; +type AdvancedTab = 'output' | 'model' | 'stream' | 'route'; + +const compactSettingRowClass = + 'space-y-1.5 lg:grid lg:grid-cols-[3.4rem_minmax(0,1fr)] lg:items-center lg:gap-1.5 lg:space-y-0'; +const compactSettingLabelClass = 'text-muted-foreground text-xs lg:pt-0.5'; + +export function resolveGenerationFooterPromptTarget(input: { + currentMode: WorkbenchMode; + prompt: string; + batchPromptText: string; +}): { value: string; isEmpty: boolean } { + const value = input.currentMode === 'batch' ? input.batchPromptText : input.prompt; + return { + value, + isEmpty: value.trim().length === 0 + }; +} + const RadioItemWithIcon = ({ value, id, @@ -124,9 +200,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-7 lg:flex-row lg:px-1 lg:text-[11px] 2xl:px-1.5 2xl:text-xs [&_[data-slot=radio-group-indicator]]:hidden [&_[data-slot=radio-group-item-content]]:gap-1 lg:[&_[data-slot=radio-group-item-content]]:gap-0.5 2xl:[&_[data-slot=radio-group-item-content]]:gap-1'> + + {label} ); @@ -155,11 +231,47 @@ function readConcreteSize(input: { return Number.isFinite(width) && Number.isFinite(height) ? { width, height } : null; } +function getBackendLabel(backend: GenerationFormData['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: GenerationFormData['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: GenerationFormData['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: GenerationFormData['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 GenerationForm({ onSubmit, + onSaveInspiration, + canApplyRandomInspiration, + onPickRandomInspiration, isLoading, currentMode, onModeChange, + reuseContext, + onClearReuseContext, isPasswordRequiredByBackend, clientPasswordHash, onOpenPasswordDialog, @@ -167,6 +279,12 @@ export function GenerationForm({ setModel, prompt, setPrompt, + batchPromptText, + setBatchPromptText, + failedBatchPrompts = [], + canPauseBatch = false, + isBatchPauseRequested = false, + onPauseBatch, n, setN, size, @@ -185,17 +303,31 @@ export function GenerationForm({ setBackground, moderation, setModeration, - enableStreaming, - setEnableStreaming, + streamMode, + setStreamMode, allowStreamingBatch, + enableParallelBatch, + setEnableParallelBatch, partialImages, setPartialImages, + allowResponsesImageBackend, + hasDefaultResponsesModel, imageBackend, setImageBackend, streamingStrategy, + defaultStreamingStrategy, setStreamingStrategy, responsesModel, - setResponsesModel + setResponsesModel, + thinking, + setThinking, + promptOptimization, + setPromptOptimization, + forceWeb, + setForceWeb, + estimatedCostLabel, + defaultAdvancedOpen = false, + defaultAdvancedTab = 'output' }: GenerationFormProps) { const { locale, t } = useI18n(); const showCompression = outputFormat === 'jpeg' || outputFormat === 'webp'; @@ -214,37 +346,77 @@ export function GenerationForm({ ? null : t(customSizeValidation.reasonKey, customSizeValidation.values); - const streamingDisabledByCount = n[0] > 1 && !allowStreamingBatch; - const effectiveStreamingStrategy: ImageStreamingStrategy = - streamingStrategy === 'server-default' ? 'auto' : streamingStrategy; + const effectiveStreamingStrategy = resolveImageUpstreamEffectiveStreamingStrategy({ + streamingStrategy, + defaultStreamingStrategy + }); const streamingDisabledByStrategy = effectiveStreamingStrategy === 'off'; const concreteSize = readConcreteSize({ size, model, customWidth, customHeight }); - const recommendStreaming = - !streamingDisabledByCount && - Boolean( - concreteSize && - shouldRecommendImageStreaming({ - streamingStrategy: effectiveStreamingStrategy, - quality, - width: concreteSize.width, - height: concreteSize.height, - streamEnabled: enableStreaming - }) - ); - const [isAdvancedOpen, setIsAdvancedOpen] = React.useState(false); + const recommendStreaming = Boolean( + concreteSize && + shouldRecommendImageStreaming({ + streamingStrategy: effectiveStreamingStrategy, + quality, + width: concreteSize.width, + height: concreteSize.height, + streamEnabled: streamMode !== 'non_stream' + }) + ); + const [isAdvancedOpen, setIsAdvancedOpen] = React.useState(defaultAdvancedOpen); + const [advancedTab, setAdvancedTab] = React.useState(defaultAdvancedTab); + const isBatchMode = currentMode === 'batch'; + const isReuseMode = currentMode === 'reuse'; + const batchPrompts = React.useMemo(() => readBatchPromptLines(batchPromptText), [batchPromptText]); + const parallelBatchTargetCount = isBatchMode ? batchPrompts.length : n[0]; + const parallelBatchToggle = resolveStreamingBatchToggleState({ + allowStreamingBatch, + userEnabled: enableParallelBatch, + targetCount: parallelBatchTargetCount, + streamMode, + streamingStrategy: effectiveStreamingStrategy + }); + const canEnableParallelBatch = parallelBatchToggle.canEnable; + const parallelBatchChecked = parallelBatchToggle.checked; + const parallelBatchUnavailableKey = parallelBatchToggle.unavailableReasonKey; + const hasFailedBatchPrompts = isBatchMode && failedBatchPrompts.length > 0; + const requiresResponsesModel = + imageBackend === 'responses-image-generation' && !hasDefaultResponsesModel && !responsesModel.trim(); const submitDisabledReason = React.useMemo(() => { if (isLoading) return ''; - if (!prompt.trim()) return t('ux.disabledPrompt'); + if (isBatchMode && batchPrompts.length === 0) return t('ux.disabledBatchPrompts'); + if (!isBatchMode && !prompt.trim()) return t('ux.disabledPrompt'); + if (requiresResponsesModel) return t('upstream.responsesModelRequired'); if (customSizeInvalid) return customSizeError || t('ux.disabledCustomSize'); return ''; - }, [customSizeError, customSizeInvalid, isLoading, prompt, t]); + }, [ + batchPrompts.length, + customSizeError, + customSizeInvalid, + isBatchMode, + isLoading, + prompt, + requiresResponsesModel, + t + ]); + const advancedSummary = [ + `${t('form.quality')}: ${getQualityLabel(quality, t)}`, + `${t('form.outputFormat')}: ${getOutputFormatLabel(outputFormat, t)}`, + getBackendLabel(imageBackend, t) + ].join(', '); + const streamModeLabel = getStreamModeLabel(streamMode, t); + const streamStatusLabel = getStreamingStatusLabel(streamMode, t); + const workbenchBackendLabel = getWorkbenchBackendLabel(imageBackend, t); + const footerPromptTarget = resolveGenerationFooterPromptTarget({ + currentMode, + prompt, + batchPromptText + }); - // 未显式开启批量流式分发时,n > 1 会禁用流式输出。 React.useEffect(() => { - if ((streamingDisabledByCount || streamingDisabledByStrategy) && enableStreaming) { - setEnableStreaming(false); + if (streamingDisabledByStrategy && streamMode !== 'non_stream') { + setStreamMode('non_stream'); } - }, [streamingDisabledByCount, streamingDisabledByStrategy, enableStreaming, setEnableStreaming]); + }, [streamingDisabledByStrategy, streamMode, setStreamMode]); // custom 仅对 gpt-image-2 有效,切换到旧模型时重置。 React.useEffect(() => { @@ -265,7 +437,7 @@ export function GenerationForm({ return; } const formData: GenerationFormData = { - prompt, + prompt: isBatchMode && batchPrompts.length > 0 ? batchPrompts[0] : prompt, n: n[0], size, customWidth, @@ -277,183 +449,245 @@ export function GenerationForm({ model, image_backend: imageBackend, streaming_strategy: streamingStrategy, - responsesModel + responsesModel, + thinking, + promptOptimization, + forceWeb, + enableParallelBatch: parallelBatchChecked }; + if (isBatchMode) { + formData.batchPrompts = batchPrompts; + } if (showCompression) { formData.output_compression = compression[0]; } onSubmit(formData); }; + const reuseFailedBatchPrompts = () => { + setBatchPromptText(failedBatchPrompts.join('\n')); + }; + return ( - - -
+ + + + +
+
- {t('generate.title')} + + {t('workbench.creationSheet')} + {isPasswordRequiredByBackend && ( )}
- {t('generate.description')} -
- - -
- -
- -
- - -
- - setEnableStreaming(!!checked)} - disabled={isLoading || streamingDisabledByCount || streamingDisabledByStrategy} - className='data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground' - /> - -
- -
- -