'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 { readBatchPromptLines } from '@/lib/batch-prompts'; import type { GptImageModel } from '@/lib/cost-utils'; import { useI18n } from '@/lib/i18n'; import { getImageOutputFormatLabel, getImageQualityLabel } from '@/lib/image-display-labels'; import { shouldRecommendImageStreaming } from '@/lib/image-streaming-recommendation'; 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, type ImageStreamingStrategy } from '@/lib/image-upstream-strategy'; import { getPresetDimensions, 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 { cn } from '@/lib/utils'; import { Square, RectangleHorizontal, RectangleVertical, Sparkles, SlidersHorizontal, ChevronDown, Eraser, ShieldCheck, ShieldAlert, FileImage, CircleOff, Tally1, Tally2, Tally3, Tally4, Loader2, BrickWall, Lock, LockOpen, HelpCircle, SquareDashed, WandSparkles, Globe2, ListChecks, Pause, RotateCcw, Bookmark } from 'lucide-react'; import * as React from 'react'; export type GenerationFormData = { prompt: string; n: number; size: SizePreset; customWidth: number; customHeight: number; quality: 'low' | 'medium' | 'high' | 'auto'; output_format: 'png' | 'jpeg' | 'webp'; output_compression?: number; background: 'transparent' | 'opaque' | 'auto'; moderation: 'low' | 'auto'; model: GptImageModel; 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; showLoadingState?: boolean; isActive: boolean; currentMode: WorkbenchMode; onModeChange: (mode: WorkbenchMode) => void; reuseContext: WorkbenchReuseContext | null; onClearReuseContext: () => void; isPasswordRequiredByBackend: boolean | null; clientPasswordHash: string | null; onOpenPasswordDialog: () => void; model: GenerationFormData['model']; 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']; setSize: React.Dispatch>; customWidth: number; setCustomWidth: React.Dispatch>; customHeight: number; setCustomHeight: React.Dispatch>; quality: GenerationFormData['quality']; setQuality: React.Dispatch>; outputFormat: GenerationFormData['output_format']; setOutputFormat: React.Dispatch>; compression: number[]; setCompression: React.Dispatch>; background: GenerationFormData['background']; setBackground: React.Dispatch>; upstreamProfile: ImageUpstreamProfile; upstreamProfileMixed?: boolean; moderation: GenerationFormData['moderation']; setModeration: React.Dispatch>; streamMode: ImageStreamMode; setStreamMode: React.Dispatch>; allowStreamingBatch: boolean; enableParallelBatch: boolean; setEnableParallelBatch: React.Dispatch>; partialImages: PartialImagesCount; 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>; requestSummaryLabel: 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'; function getSizePresetLabel( preset: SizePreset, t: (key: string, values?: Record) => 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; } } 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, label, Icon, disabled = false, tooltip }: { value: string; id: string; label: string; Icon: React.ElementType; disabled?: boolean; tooltip?: React.ReactNode; }) => { const item = ( {label} ); if (!tooltip) return item; return ( {item} {tooltip} ); }; function readConcreteSize(input: { size: SizePreset; model: GptImageModel; customWidth: number; customHeight: number; }): { width: number; height: number } | null { if (input.size === 'custom') { return { width: input.customWidth, height: input.customHeight }; } const preset = getPresetDimensions(input.size, input.model); if (!preset) return null; const [width, height] = preset.split('x').map(Number); 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'); } export function GenerationForm({ onSubmit, onSaveInspiration, canApplyRandomInspiration, onPickRandomInspiration, isLoading, showLoadingState = isLoading, isActive, currentMode, onModeChange, reuseContext, onClearReuseContext, isPasswordRequiredByBackend, clientPasswordHash, onOpenPasswordDialog, model, setModel, prompt, setPrompt, batchPromptText, setBatchPromptText, failedBatchPrompts = [], canPauseBatch = false, isBatchPauseRequested = false, onPauseBatch, n, setN, size, setSize, customWidth, setCustomWidth, customHeight, setCustomHeight, quality, setQuality, outputFormat, setOutputFormat, compression, setCompression, background, setBackground, upstreamProfile, upstreamProfileMixed = false, moderation, setModeration, streamMode, setStreamMode, allowStreamingBatch, enableParallelBatch, setEnableParallelBatch, partialImages, setPartialImages, allowResponsesImageBackend, hasDefaultResponsesModel, imageBackend, setImageBackend, streamingStrategy, defaultStreamingStrategy, setStreamingStrategy, responsesModel, setResponsesModel, thinking, setThinking, promptOptimization, setPromptOptimization, forceWeb, setForceWeb, requestSummaryLabel, defaultAdvancedOpen = false, defaultAdvancedTab = 'output' }: GenerationFormProps) { const { locale, t } = useI18n(); const showCompression = outputFormat === 'jpeg' || outputFormat === 'webp'; const isGptImage2 = model === 'gpt-image-2'; const usesPositiveIntegerCustomSize = upstreamProfile.gptImage2.sizePolicy === 'positive-integer'; const customSizeValidation = size === 'custom' ? usesPositiveIntegerCustomSize ? validatePositiveIntegerImageSize(customWidth, customHeight) : validateGptImage2Size(customWidth, customHeight) : { valid: true as const }; const customSizeInvalid = size === 'custom' && !customSizeValidation.valid; const customPixels = customWidth * customHeight; const customRatio = customWidth > 0 && customHeight > 0 ? t('form.ratio', { ratio: (Math.max(customWidth, customHeight) / Math.min(customWidth, customHeight)).toFixed(2) }) : t('form.noRatio'); const customSizeError = customSizeValidation.valid ? null : t(customSizeValidation.reasonKey, customSizeValidation.values); const effectiveStreamingStrategy = resolveImageUpstreamEffectiveStreamingStrategy({ streamingStrategy, defaultStreamingStrategy }); const streamingDisabledByStrategy = effectiveStreamingStrategy === 'off'; const concreteSize = readConcreteSize({ size, model, customWidth, customHeight }); const recommendStreaming = Boolean( isActive && 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 (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 ''; }, [ batchPrompts.length, customSizeError, customSizeInvalid, isBatchMode, isLoading, prompt, requiresResponsesModel, t ]); const advancedSummary = [ `${t('form.quality')}: ${getImageQualityLabel(quality, t)}`, `${t('form.outputFormat')}: ${getImageOutputFormatLabel(outputFormat, t)}`, getBackendLabel(imageBackend, t) ].join(', '); const streamModeLabel = getStreamModeLabel(streamMode, t); const streamStatusLabel = getStreamingStatusLabel(streamMode, t); const workbenchBackendLabel = getWorkbenchBackendLabel(imageBackend, t); const sizePresetOptions = getSizePresetOptions({ model, upstreamProfile }); const partialImagesRange = React.useMemo( () => getPartialImagesRangeForBackend(upstreamProfile, imageBackend), [imageBackend, upstreamProfile] ); const partialImageOptions = buildIntegerRangeOptions(partialImagesRange) as PartialImagesCount[]; const generationCountOptions = buildIntegerRangeOptions(upstreamProfile.generateCount); const footerPromptTarget = resolveGenerationFooterPromptTarget({ currentMode, prompt, batchPromptText }); React.useEffect(() => { if (isActive && (partialImages < partialImagesRange.min || partialImages > partialImagesRange.max)) { setPartialImages(clampIntegerToRange(partialImages, partialImagesRange) as PartialImagesCount); } if (isActive && (n[0] < upstreamProfile.generateCount.min || n[0] > upstreamProfile.generateCount.max)) { setN([clampIntegerToRange(n[0], upstreamProfile.generateCount)]); } if (isActive && background === 'transparent' && !upstreamProfile.gptImage2.allowTransparentBackground) { setBackground('auto'); } }, [ background, isActive, n, partialImages, partialImagesRange, setBackground, setN, setPartialImages, upstreamProfile.generateCount, upstreamProfile.gptImage2.allowTransparentBackground ]); React.useEffect(() => { if (isActive && streamingDisabledByStrategy && streamMode !== 'non_stream') { setStreamMode('non_stream'); } }, [isActive, streamingDisabledByStrategy, streamMode, setStreamMode]); // custom 仅对 gpt-image-2 有效,切换到旧模型时重置。 React.useEffect(() => { if (isActive && !isGptImage2 && size === 'custom') { setSize('auto'); } }, [isActive, isGptImage2, setSize, size]); const handleSubmit = () => { if (customSizeInvalid) { return; } if (partialImages < partialImagesRange.min || partialImages > partialImagesRange.max) { setPartialImages(clampIntegerToRange(partialImages, partialImagesRange) as PartialImagesCount); return; } if (n[0] < upstreamProfile.generateCount.min || n[0] > upstreamProfile.generateCount.max) { setN([clampIntegerToRange(n[0], upstreamProfile.generateCount)]); return; } if (background === 'transparent' && !upstreamProfile.gptImage2.allowTransparentBackground) { setBackground('auto'); return; } const formData: GenerationFormData = { prompt: isBatchMode && batchPrompts.length > 0 ? batchPrompts[0] : prompt, n: n[0], size, customWidth, customHeight, quality, output_format: outputFormat, background, moderation, model, image_backend: imageBackend, streaming_strategy: streamingStrategy, 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('workbench.creationSheet')} {isPasswordRequiredByBackend && ( )}
{!isBatchMode && ( <>
{prompt.trim() && ( )}