visual-journal / src /components /generation-form.tsx
Visual Journal deploy
Deploy 5554830 to Docker Space
e4e0afe
Raw
History Blame Contribute Delete
97.4 kB
'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<React.SetStateAction<GenerationFormData['model']>>;
prompt: string;
setPrompt: React.Dispatch<React.SetStateAction<string>>;
batchPromptText: string;
setBatchPromptText: React.Dispatch<React.SetStateAction<string>>;
failedBatchPrompts?: string[];
canPauseBatch?: boolean;
isBatchPauseRequested?: boolean;
onPauseBatch?: () => void;
n: number[];
setN: React.Dispatch<React.SetStateAction<number[]>>;
size: GenerationFormData['size'];
setSize: React.Dispatch<React.SetStateAction<GenerationFormData['size']>>;
customWidth: number;
setCustomWidth: React.Dispatch<React.SetStateAction<number>>;
customHeight: number;
setCustomHeight: React.Dispatch<React.SetStateAction<number>>;
quality: GenerationFormData['quality'];
setQuality: React.Dispatch<React.SetStateAction<GenerationFormData['quality']>>;
outputFormat: GenerationFormData['output_format'];
setOutputFormat: React.Dispatch<React.SetStateAction<GenerationFormData['output_format']>>;
compression: number[];
setCompression: React.Dispatch<React.SetStateAction<number[]>>;
background: GenerationFormData['background'];
setBackground: React.Dispatch<React.SetStateAction<GenerationFormData['background']>>;
upstreamProfile: ImageUpstreamProfile;
upstreamProfileMixed?: boolean;
moderation: GenerationFormData['moderation'];
setModeration: React.Dispatch<React.SetStateAction<GenerationFormData['moderation']>>;
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;
imageBackend: GenerationFormData['image_backend'];
setImageBackend: React.Dispatch<React.SetStateAction<GenerationFormData['image_backend']>>;
streamingStrategy: GenerationFormData['streaming_strategy'];
defaultStreamingStrategy: ImageStreamingStrategy;
setStreamingStrategy: React.Dispatch<React.SetStateAction<GenerationFormData['streaming_strategy']>>;
responsesModel: string;
setResponsesModel: React.Dispatch<React.SetStateAction<string>>;
thinking: GenerationFormData['thinking'];
setThinking: React.Dispatch<React.SetStateAction<GenerationFormData['thinking']>>;
promptOptimization: GenerationFormData['promptOptimization'];
setPromptOptimization: React.Dispatch<React.SetStateAction<GenerationFormData['promptOptimization']>>;
forceWeb: boolean;
setForceWeb: React.Dispatch<React.SetStateAction<boolean>>;
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 | 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;
}
}
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 = (
<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:flex-row 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]]:gap-1 [&_[data-slot=radio-group-item-content]]:overflow-hidden lg:[&_[data-slot=radio-group-item-content]]:gap-1'>
<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 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<AdvancedTab>(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 (
<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-2.5 overflow-y-auto p-4 pb-4 lg:px-5 lg:py-3 2xl:space-y-2.5'>
<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:min-h-7 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 className='space-y-1.5 lg:space-y-1'>
{!isBatchMode && (
<>
<div className='flex items-center'>
<Label htmlFor='prompt' className='text-foreground text-sm font-medium'>
{t('workbench.promptTitle')}
</Label>
{prompt.trim() && (
<button
type='button'
onClick={() => setPrompt('')}
disabled={isLoading}
className='text-muted-foreground hover:text-foreground focus-visible:ring-ring -mr-2 ml-auto min-h-11 min-w-11 rounded-md px-2 text-xs transition-[color,box-shadow] focus-visible:ring-2 focus-visible:outline-none disabled:opacity-50 lg:mr-0 lg:min-h-7 lg:min-w-0 lg:px-0'>
{t('common.clear')}
</button>
)}
</div>
<div className='relative'>
<Textarea
id='prompt'
name='prompt'
placeholder={t('form.promptPlaceholder')}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
required
disabled={isLoading}
className='bg-muted/45 min-h-[104px] rounded-md px-4 py-3 pb-8 leading-6 shadow-inner lg:min-h-[92px]'
/>
<span className='text-muted-foreground ui-stat pointer-events-none absolute bottom-3 left-4 text-xs'>
{prompt.trim().length} / 1000
</span>
</div>
</>
)}
{recommendStreaming && !isBatchMode && (
<p className='text-xs text-amber-700 dark:text-amber-300'>
{t('streaming.highResolutionRecommendation')}
</p>
)}
{isReuseMode && (
<div className='border-primary/25 bg-primary/5 rounded-md border px-3 py-2 text-xs leading-5 shadow-sm'>
{reuseContext ? (
<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.appliedTitle')}</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>
</div>
) : (
<p className='text-muted-foreground'>{t('mode.reuseHint')}</p>
)}
</div>
)}
{isBatchMode && (
<div className='border-border/75 bg-background/65 space-y-2 rounded-md border p-3 shadow-sm'>
<div className='flex items-center justify-between gap-3'>
<Label htmlFor='batch-prompt-list'>{t('batch.promptList')}</Label>
<span className='text-muted-foreground ui-stat shrink-0 text-xs'>
{t('batch.promptCount', { count: batchPrompts.length })}
</span>
</div>
<Textarea
id='batch-prompt-list'
name='batchPrompts'
value={batchPromptText}
onChange={(event) => setBatchPromptText(event.target.value)}
disabled={isLoading}
className='bg-muted/45 min-h-[132px] rounded-md px-3 py-2 leading-6 shadow-inner'
placeholder={t('batch.promptPlaceholder')}
/>
{hasFailedBatchPrompts && (
<div className='border-destructive/25 bg-destructive/5 text-muted-foreground flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-xs leading-5'>
<span>
{t('batch.failedReuseHint', {
count: failedBatchPrompts.length
})}
</span>
<Button
type='button'
variant='outline'
size='sm'
onClick={reuseFailedBatchPrompts}
disabled={isLoading}
className='bg-background/80 min-h-11 px-3 text-xs lg:h-7 lg:min-h-0 lg:px-2'>
<RotateCcw className='mr-1.5 h-3.5 w-3.5' />
{t('batch.reuseFailed')}
</Button>
</div>
)}
<div
id='batch-task-summary'
className='border-border/60 grid gap-2 border-t pt-2 text-xs leading-5 sm:grid-cols-2'>
<div className='flex gap-2'>
<ListChecks className='text-muted-foreground mt-0.5 h-3.5 w-3.5 shrink-0' />
<div className='min-w-0'>
<div className='text-foreground font-medium'>{t('batch.summaryTitle')}</div>
<p className='text-muted-foreground'>{t('batch.summaryPerLine')}</p>
</div>
</div>
<div className='flex gap-2'>
<RotateCcw className='text-muted-foreground mt-0.5 h-3.5 w-3.5 shrink-0' />
<div className='min-w-0'>
<div className='text-foreground font-medium'>
{t('batch.summaryFailureTitle')}
</div>
<p className='text-muted-foreground'>{t('batch.summaryFailureDetail')}</p>
</div>
</div>
<p className='text-muted-foreground sm:col-span-2'>{t('batch.summaryProgress')}</p>
</div>
</div>
)}
</div>
<div className='border-border/70 space-y-2 border-t pt-2'>
<div className='text-foreground block text-sm leading-none font-medium select-none'>
{t('workbench.basicSettings')}
</div>
<div className={compactSettingRowClass}>
<div className={compactSettingLabelClass}>{t('form.size')}</div>
<RadioGroup
value={size}
onValueChange={(value) => setSize(value as GenerationFormData['size'])}
disabled={isLoading}
name='size'
aria-label={t('form.size')}
className='grid grid-cols-2 gap-1.5'>
{sizePresetOptions.map((option) => (
<RadioItemWithIcon
key={option.value}
value={option.value}
id={`size-${option.value}`}
label={getSizePresetLabel(option.value, t)}
Icon={getSizePresetIcon(option.value)}
disabled={isLoading}
tooltip={getPresetTooltip(option.value, model)}
/>
))}
</RadioGroup>
</div>
{isGptImage2 && size === '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='custom-width' className='text-muted-foreground text-xs'>
{t('form.width')}
</Label>
<Input
id='custom-width'
name='customWidth'
type='number'
inputMode='numeric'
min={usesPositiveIntegerCustomSize ? 1 : 16}
max={usesPositiveIntegerCustomSize ? undefined : 3840}
step={usesPositiveIntegerCustomSize ? 1 : 16}
value={customWidth}
onChange={(e) => setCustomWidth(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='custom-height' className='text-muted-foreground text-xs'>
{t('form.height')}
</Label>
<Input
id='custom-height'
name='customHeight'
type='number'
inputMode='numeric'
min={usesPositiveIntegerCustomSize ? 1 : 16}
max={usesPositiveIntegerCustomSize ? undefined : 3840}
step={usesPositiveIntegerCustomSize ? 1 : 16}
value={customHeight}
onChange={(e) => setCustomHeight(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: customPixels.toLocaleString(locale),
percent: ((customPixels / 8_294_400) * 100).toFixed(1),
ratio: customRatio
})}
</p>
{customSizeError && <p className='text-destructive text-xs'>{customSizeError}</p>}
<p className='text-muted-foreground text-xs'>{t('form.customConstraints')}</p>
</div>
)}
<div className={compactSettingRowClass}>
<div className={cn(compactSettingLabelClass, 'ui-stat')}>
{isBatchMode
? t('form.batchNumberOfImages', { count: batchPrompts.length })
: t('form.numberOfImages', { count: n[0] })}
</div>
{isBatchMode ? (
<div className='border-border bg-muted/25 text-muted-foreground rounded-md border px-3 py-2 text-xs leading-5'>
{t('mode.batchHint')}
</div>
) : (
<RadioGroup
value={String(n[0])}
onValueChange={(value) => setN([Number(value)])}
disabled={isLoading}
name='n'
aria-label={t('form.numberOfImages', { count: n[0] })}
className='grid grid-cols-4 gap-1.5'>
{generationCountOptions.map((value) => (
<RadioItemWithIcon
key={value}
value={String(value)}
id={`n-${value}`}
label={String(value)}
Icon={value === 1 ? Tally1 : value === 2 ? Tally2 : Tally3}
disabled={isLoading}
/>
))}
</RadioGroup>
)}
</div>
<div className={compactSettingRowClass}>
<div className={compactSettingLabelClass}>{t('form.quality')}</div>
<RadioGroup
value={quality}
onValueChange={(value) => setQuality(value as GenerationFormData['quality'])}
disabled={isLoading}
name='quality'
aria-label={t('form.quality')}
className='grid grid-cols-3 gap-1.5'>
<RadioItemWithIcon
value='medium'
id='quality-medium-quick'
label={t('common.standard')}
Icon={Tally1}
disabled={isLoading}
/>
<RadioItemWithIcon
value='high'
id='quality-high-quick'
label={t('common.high')}
Icon={Tally2}
disabled={isLoading}
/>
<RadioItemWithIcon
value='auto'
id='quality-auto-quick'
label={t('common.auto')}
Icon={Sparkles}
disabled={isLoading}
/>
</RadioGroup>
</div>
<div className={compactSettingRowClass}>
<div className={compactSettingLabelClass}>{t('form.outputFormat')}</div>
<RadioGroup
value={outputFormat}
onValueChange={(value) => setOutputFormat(value as GenerationFormData['output_format'])}
disabled={isLoading}
name='output_format'
aria-label={t('form.outputFormat')}
className='grid grid-cols-3 gap-1.5'>
<RadioItemWithIcon
value='jpeg'
id='format-jpeg-quick'
label={t('common.jpeg')}
Icon={FileImage}
disabled={isLoading}
/>
<RadioItemWithIcon
value='png'
id='format-png-quick'
label={t('common.png')}
Icon={FileImage}
disabled={isLoading}
/>
<RadioItemWithIcon
value='webp'
id='format-webp-quick'
label={t('common.webp')}
Icon={FileImage}
disabled={isLoading}
/>
</RadioGroup>
</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='generation-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'>
{isAdvancedOpen ? t('ux.professionalMode') : t('ux.easyMode')}
</span>
<span className='text-muted-foreground block text-xs leading-4 font-normal'>
{isAdvancedOpen ? advancedSummary : t('ux.easyModeSummary')}
</span>
</span>
</span>
<ChevronDown
className={`h-4 w-4 shrink-0 transition-transform ${isAdvancedOpen ? 'rotate-180' : ''}`}
/>
</button>
{isAdvancedOpen && (
<div id='generation-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'>
<Label htmlFor='model-select'>{t('form.model')}</Label>
<Select
value={model}
onValueChange={(value) =>
setModel(value as GenerationFormData['model'])
}
disabled={isLoading}
name='model'>
<SelectTrigger id='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='image-backend-select'>{t('upstream.backend')}</Label>
<Select
value={imageBackend}
onValueChange={(value) =>
setImageBackend(value as GenerationFormData['image_backend'])
}
disabled={isLoading}
name='image_backend'>
<SelectTrigger
id='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='streaming-strategy-select'>
{t('upstream.streamingStrategy')}
</Label>
<Select
value={streamingStrategy}
onValueChange={(value) =>
setStreamingStrategy(
value as GenerationFormData['streaming_strategy']
)
}
disabled={isLoading}
name='image_streaming_strategy'>
<SelectTrigger
id='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,
streamingStrategy: 'openai-sse',
allowResponsesImageBackend
})
}>
{t('upstream.strategyOpenAiSse')}
</SelectItem>
<SelectItem
value='newapi-keepalive-sse'
disabled={
!isImageUpstreamStreamingStrategySelectable({
imageBackend,
streamingStrategy: 'newapi-keepalive-sse',
allowResponsesImageBackend
})
}>
{t('upstream.strategyKeepaliveSse')}
</SelectItem>
<SelectItem
value='responses-sse'
disabled={
!isImageUpstreamStreamingStrategySelectable({
imageBackend,
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: imageBackend,
streamingStrategy,
defaultStreamingStrategy,
allowResponsesImageBackend,
serverProfileMixed: upstreamProfileMixed
}).map((key) => (
<p key={key}>{t(key)}</p>
))}
</div>
{imageBackend === '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='responses-model-input'>
{t('upstream.gptModel')}
</Label>
<Input
id='responses-model-input'
name='responsesModel'
value={responsesModel}
onChange={(event) => setResponsesModel(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='thinking-select'>
{t('upstream.thinking')}
</Label>
<Select
value={thinking}
onValueChange={(value) =>
setThinking(value as GenerationFormData['thinking'])
}
disabled={isLoading}
name='thinking'>
<SelectTrigger
id='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='prompt-optimization-select'>
{t('upstream.promptOptimization')}
</Label>
<Select
value={promptOptimization}
onValueChange={(value) =>
setPromptOptimization(
value as GenerationFormData['promptOptimization']
)
}
disabled={isLoading}
name='promptOptimization'>
<SelectTrigger
id='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>
)}
{imageBackend === '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={forceWeb ? 'true' : 'false'}
onValueChange={(value) => setForceWeb(value === 'true')}
disabled={isLoading}
name='force_web'
aria-label={t('upstream.forceWeb')}
className='grid grid-cols-2 gap-2'>
<RadioItemWithIcon
value='false'
id='force-web-false'
label={t('upstream.serverDefault')}
Icon={Sparkles}
disabled={isLoading}
/>
<RadioItemWithIcon
value='true'
id='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='stream-mode-select'>{t('streaming.mode')}</Label>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Select
value={streamMode}
onValueChange={(value) =>
setStreamMode(value as ImageStreamMode)
}
disabled={isLoading || streamingDisabledByStrategy}
name='stream_mode'>
<SelectTrigger
id='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 && n[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='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='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='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='partial_images'
aria-label={t('streaming.previewImages')}
className='grid grid-cols-5 gap-2'>
{partialImageOptions.map((value) => (
<RadioItemWithIcon
key={value}
value={String(value)}
id={`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={quality}
onValueChange={(value) =>
setQuality(value as GenerationFormData['quality'])
}
disabled={isLoading}
name='quality'
aria-label={t('form.quality')}
className='grid grid-cols-2 gap-2 sm:grid-cols-4'>
<RadioItemWithIcon
value='auto'
id='quality-auto'
label={t('common.auto')}
Icon={Sparkles}
disabled={isLoading}
/>
<RadioItemWithIcon
value='low'
id='quality-low'
label={t('common.low')}
Icon={Tally1}
disabled={isLoading}
/>
<RadioItemWithIcon
value='medium'
id='quality-medium'
label={t('common.medium')}
Icon={Tally2}
disabled={isLoading}
/>
<RadioItemWithIcon
value='high'
id='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.background')}
</div>
<RadioGroup
value={background}
onValueChange={(value) =>
setBackground(value as GenerationFormData['background'])
}
disabled={isLoading}
name='background'
aria-label={t('form.background')}
className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
<RadioItemWithIcon
value='auto'
id='bg-auto'
label={t('common.auto')}
Icon={Sparkles}
disabled={isLoading}
/>
<RadioItemWithIcon
value='opaque'
id='bg-opaque'
label={t('form.backgroundOpaque')}
Icon={BrickWall}
disabled={isLoading}
/>
<RadioItemWithIcon
value='transparent'
id='bg-transparent'
label={t('form.backgroundTransparent')}
Icon={Eraser}
disabled={
isLoading ||
!upstreamProfile.gptImage2.allowTransparentBackground
}
/>
</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={outputFormat}
onValueChange={(value) =>
setOutputFormat(value as GenerationFormData['output_format'])
}
disabled={isLoading}
name='output_format'
aria-label={t('form.outputFormat')}
className='grid grid-cols-1 gap-2 sm:grid-cols-3'>
<RadioItemWithIcon
value='png'
id='format-png'
label={t('common.png')}
Icon={FileImage}
disabled={isLoading}
/>
<RadioItemWithIcon
value='jpeg'
id='format-jpeg'
label={t('common.jpeg')}
Icon={FileImage}
disabled={isLoading}
/>
<RadioItemWithIcon
value='webp'
id='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: compression[0] })}
</div>
<Slider
id='compression-slider'
name='output_compression'
thumbLabel={t('form.compression', { value: compression[0] })}
min={0}
max={100}
step={1}
value={compression}
onValueChange={setCompression}
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={moderation}
onValueChange={(value) =>
setModeration(value as GenerationFormData['moderation'])
}
disabled={isLoading}
name='moderation'
aria-label={t('form.moderation')}
className='grid grid-cols-2 gap-2'>
<RadioItemWithIcon
value='auto'
id='mod-auto'
label={t('common.auto')}
Icon={ShieldCheck}
disabled={isLoading}
/>
<RadioItemWithIcon
value='low'
id='mod-low'
label={t('common.low')}
Icon={ShieldAlert}
disabled={isLoading}
/>
</RadioGroup>
</div>
</TabsContent>
</Tabs>
</div>
)}
</div>
</CardContent>
<CardFooter className='border-border bg-card flex shrink-0 border-t p-3'>
<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'>
{model}
</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>
)}
<div className='grid gap-2 sm:grid-cols-[1fr_auto]'>
<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 border-0 shadow-sm lg:min-h-9'>
{showLoadingState && <Loader2 className='h-4 w-4 animate-spin' />}
{showLoadingState ? t('generate.loading') : t('generate.submit')}
</Button>
{canPauseBatch && (
<Button
type='button'
variant='outline'
onClick={() => onPauseBatch?.()}
disabled={isBatchPauseRequested}
title={t('batch.pauseHint')}
className='bg-background/80 min-h-10 gap-2 px-3'>
{isBatchPauseRequested ? (
<Loader2 className='h-4 w-4 animate-spin' />
) : (
<Pause className='h-4 w-4' />
)}
<span className='whitespace-nowrap'>
{isBatchPauseRequested ? t('batch.pauseRequested') : t('batch.pause')}
</span>
</Button>
)}
</div>
{canPauseBatch && (
<p className='text-muted-foreground text-center text-xs'>{t('batch.pauseHint')}</p>
)}
<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-3 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={footerPromptTarget.isEmpty || isLoading}
onClick={() => onSaveInspiration(footerPromptTarget.value)}>
<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-3 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;
if (currentMode === 'batch') {
setBatchPromptText(nextPrompt);
return;
}
setPrompt(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>
);
}