'use client'; import { ApiSettingsDialog, type ApiSettings } from '@/components/api-settings-dialog'; import { EditingForm, type EditingFormData, type EditingReuseContext } from '@/components/editing-form'; import { GenerationForm, type GenerationFormData, type WorkbenchReuseContext } from '@/components/generation-form'; import { HistoryPanel, type InspirationItem, type PromptApplySource } from '@/components/history-panel'; import { ImageOutput } from '@/components/image-output'; import { LanguageSelector } from '@/components/language-selector'; import type { WorkbenchMode } from '@/components/mode-toggle'; import { PasswordDialog } from '@/components/password-dialog'; import { ShareDialog, type ShareDialogValues } from '@/components/share-dialog'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { WorkbenchProDock } from '@/components/workbench-pro-dock'; import { WorkbenchStatusStrip } from '@/components/workbench-status-strip'; import { buildApiErrorNotice, buildBatchPartialFailureMessage, buildUserFacingApiErrorMessage, type ApiErrorNotice } from '@/lib/api-error-guidance'; import { formatBatchPromptHistory, readBatchPromptLines } from '@/lib/batch-prompts'; import { db, type ImageRecord } from '@/lib/db'; import { hasReachedEditSourceImageLimit } from '@/lib/edit-source-limits'; import { advanceGenerationBatchProgress, buildGenerationActivityItems, collectFailedBatchPrompts, countCompletedBatchResults, selectAnnouncedGenerationActivity, type GenerationBatchProgress } from '@/lib/generation-activity'; import { resolveHistoryCompareImage } from '@/lib/history-compare'; import { buildHistoryFeedbackDeletePayload, buildHistoryFeedbackDeleteTargets, buildHistoryFeedbackSyncPayload, buildHistoryFeedbackSyncInputs, parseHistoryFeedbackDeleteQueue, parseHistoryFeedbackSyncQueue, pruneHistoryFeedbackDeleteQueueForSyncQueue, removeHistoryFeedbackDeleteQueuePayload, removeHistoryFeedbackDeleteQueueTargets, removeHistoryFeedbackSyncQueueItem, removeHistoryFeedbackSyncQueueTargets, removeHistoryFeedbackSyncQueueTargetsForDelete, serializeHistoryFeedbackDeleteQueue, serializeHistoryFeedbackSyncQueue, shouldFeedbackDeleteClearSync, shouldFeedbackSyncClearDelete, shouldReplaceHistoryFeedbackDeletePayload, upsertHistoryFeedbackDeleteQueue, upsertHistoryFeedbackSyncQueue, type HistoryFeedbackDeletePayload, type HistoryFeedbackTarget, type HistoryFeedbackSyncInput, type HistoryFeedbackSyncPayload } from '@/lib/history-feedback-sync'; import { buildCompletedHistoryEntry, buildFailedHistoryEntry, buildHistoryGenerationFormData, readHistoryImageCountSelection, readHistorySizeSelection, resolveHistoryImageClientRequestId, uniqueStrings, type HistoryMetadata, type RequestMode, type ResultFeedbackValue, updateHistoryResultFeedback } from '@/lib/history-metadata'; import { useI18n } from '@/lib/i18n'; import { IMAGE_UPSTREAM_FORM_SERVER_DEFAULT, appendImageUpstreamOverrideFields, normalizeImageUpstreamRuntimeFields, shouldAllowResponsesImageBackend, shouldAllowResponsesHistoryRoute, shouldBlockResponsesRequestWithoutModel, shouldBlockExplicitResponsesRequest, type ImageUpstreamFormBackend } from '@/lib/image-upstream-form'; import { IMAGE_UPSTREAM_PROFILES, summarizeImageUpstreamProfile, type ImageUpstreamProfile, type ImageUpstreamProfileId, type PartialImagesCount } from '@/lib/image-upstream-profile'; import type { ImageStreamMode, ImageStreamingStrategy } from '@/lib/image-upstream-strategy'; import { resolveMobileCreationSheetGesture } from '@/lib/mobile-creation-sheet-gesture'; import { resolveMobilePrimaryDisabledReason } from '@/lib/mobile-primary-action-state'; import { hasPreservedDisplayedAuthError, isPagePasswordAuthErrorCode } from '@/lib/page-password-auth'; import { resolveRuntimeHealthStatus, type RuntimeHealthStatus } from '@/lib/runtime-health-status'; import { sha256Hex } from '@/lib/sha256'; import { createImageShareFromBlob } from '@/lib/share-client'; import { getPresetDimensions, validateGptImage2Size, validatePositiveIntegerImageSize } from '@/lib/size-utils'; import { applyStreamingClientEvent, BatchPausedError, buildStreamingBatchJobs, canUseStreamingBatchTransport, resolveStreamingBatchCapacity, resolveStreamingBatchToggleState, scheduleStreamingBatch, shouldUseStreamingBatch, type ApiImageResponseItem, type StreamingBatchJob, type StreamingClientEvent, type StreamingClientState } from '@/lib/streaming-batch'; import { getStreamingStatusLabel } from '@/lib/streaming-status-label'; import type { ActualCostDetails } from '@/lib/upstream-cost/resolve'; import { cn } from '@/lib/utils'; import { mergeWebuiImageRetentionResults, readWebuiImageFileOperationResults, readWebuiImageRetentionFilenames, type WebuiImageRetentionAction } from '@/lib/webui-image-retention-client'; import { createZipBlob } from '@/lib/zip-export'; import { useLiveQuery } from 'dexie-react-hooks'; import { Activity, ArrowUp, Loader2, Lock, Pause, PenLine, Settings2, X } from 'lucide-react'; import * as React from 'react'; type DrawnPoint = { x: number; y: number; size: number; }; type MobileDrawerPointerStart = { x: number; y: number; }; const apiSettingsLocalStorageKey = 'openaiImageApiSettings'; const inspirationsLocalStorageKey = 'openaiImageInspirations'; const resultFeedbackSyncQueueLocalStorageKey = 'openaiImageResultFeedbackSyncQueue'; const resultFeedbackDeleteQueueLocalStorageKey = 'openaiImageResultFeedbackDeleteQueue'; const emptyApiSettings: ApiSettings = { apiKey: '', baseUrl: '' }; const sseEventDelimiterPattern = /\r?\n\r?\n/; const feedbackApiTargetBatchSize = 20; const resultFeedbackSyncQueueMaxItems = 100; const resultFeedbackDeleteQueueMaxItems = 100; const completedResultFeedbackSyncKeyMaxItems = 1000; const resultFeedbackSyncMaxAttempts = 3; const resultFeedbackSyncRetryDelaysMs = [1000, 3000] as const; const resultFeedbackDeleteMaxAttempts = 3; const resultFeedbackDeleteRetryDelaysMs = [1000, 3000] as const; const resultFeedbackRequestTimeoutMs = 10_000; const emptyPermanentFilenames: ReadonlySet = new Set(); const mobileCreationDrawerFocusableSelector = 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [contenteditable="true"], [tabindex]:not([tabindex="-1"])'; const mobileCreationDrawerPortalSelector = '[data-slot="select-content"], [role="listbox"], [role="menu"], [role="dialog"]:not(#mobile-creation-sheet)'; type ApiCallRetryArgs = [ GenerationFormData | EditingFormData, RequestMode, ImageStreamMode, PartialImagesCount, boolean ]; type PasswordVerificationResult = 'valid' | 'invalid' | 'unavailable'; type FeedbackSyncInput = HistoryFeedbackSyncInput; type FeedbackSyncPayload = HistoryFeedbackSyncPayload; type FeedbackSyncScheduleOptions = { pruneQueuedTargets?: boolean; }; type StoredApiSettingsReadResult = { settings: ApiSettings; shouldPersist: boolean; shouldRemove: boolean; }; function createFeedbackRequestSignal(signal: AbortSignal): { signal: AbortSignal; cleanup: () => void } { const controller = new AbortController(); const timeoutId = window.setTimeout(() => controller.abort(), resultFeedbackRequestTimeoutMs); const abortFromCaller = () => controller.abort(); if (signal.aborted) { abortFromCaller(); } else { signal.addEventListener('abort', abortFromCaller, { once: true }); } return { signal: controller.signal, cleanup: () => { window.clearTimeout(timeoutId); signal.removeEventListener('abort', abortFromCaller); } }; } function getWorkbenchRouteLabel(backend: ImageUpstreamFormBackend, 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 getMobileCreationDrawerFocusableElements(root: ParentNode): HTMLElement[] { return Array.from(root.querySelectorAll(mobileCreationDrawerFocusableSelector)).filter( (element) => element.getClientRects().length > 0 && !element.closest('[aria-hidden="true"], [inert]') ); } function haveHistoryFeedbackTargetOverlap(left: HistoryFeedbackTarget[], right: HistoryFeedbackTarget[]): boolean { const rightIds = new Set(right.map((target) => target.id)); return left.some((target) => rightIds.has(target.id)); } function rememberCompletedResultFeedbackSyncKey(keys: Set, key: string): void { keys.delete(key); keys.add(key); while (keys.size > completedResultFeedbackSyncKeyMaxItems) { const oldestKey = keys.values().next().value; if (typeof oldestKey !== 'string') return; keys.delete(oldestKey); } } function createClientRequestId(): string { if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { return `web-${crypto.randomUUID()}`; } return `web-${Date.now()}-${Math.random().toString(16).slice(2)}`; } function readLocalStorageValue(key: string): string | null { if (typeof window === 'undefined') return null; return window.localStorage.getItem(key); } function readStoredHistory(): HistoryMetadata[] { const storedHistory = readLocalStorageValue('openaiImageHistory'); if (!storedHistory) return []; try { const parsedHistory: unknown = JSON.parse(storedHistory); if (Array.isArray(parsedHistory)) return parsedHistory as HistoryMetadata[]; console.warn('localStorage 中发现无效历史记录数据。'); window.localStorage.removeItem('openaiImageHistory'); } catch (e) { console.error('加载或解析 localStorage 历史记录失败:', e); window.localStorage.removeItem('openaiImageHistory'); } return []; } function normalizeStoredApiSettings(settings: Partial): StoredApiSettingsReadResult { const apiKey = typeof settings.apiKey === 'string' ? settings.apiKey.trim() : ''; const baseUrl = typeof settings.baseUrl === 'string' ? settings.baseUrl.trim() : ''; if (!apiKey && !baseUrl) { return { settings: emptyApiSettings, shouldPersist: false, shouldRemove: true }; } if (!apiKey && baseUrl) { return { settings: emptyApiSettings, shouldPersist: false, shouldRemove: true }; } const normalizedSettings: ApiSettings = { apiKey, baseUrl }; return { settings: normalizedSettings, shouldPersist: normalizedSettings.apiKey !== settings.apiKey || normalizedSettings.baseUrl !== settings.baseUrl, shouldRemove: false }; } function readStoredApiSettings(): StoredApiSettingsReadResult { const storedApiSettings = readLocalStorageValue(apiSettingsLocalStorageKey); if (!storedApiSettings) { return { settings: emptyApiSettings, shouldPersist: false, shouldRemove: false }; } try { const parsedSettings = JSON.parse(storedApiSettings) as Partial; return normalizeStoredApiSettings(parsedSettings); } catch (error) { console.error('从 localStorage 加载 API 设置失败:', error); window.localStorage.removeItem(apiSettingsLocalStorageKey); return { settings: emptyApiSettings, shouldPersist: false, shouldRemove: false }; } } type StoredInspirationsReadResult = { items: InspirationItem[]; shouldPersist: boolean; shouldRemove: boolean; }; function readStoredInspirations(): StoredInspirationsReadResult { const storedInspirations = readLocalStorageValue(inspirationsLocalStorageKey); if (!storedInspirations) { return { items: [], shouldPersist: false, shouldRemove: false }; } try { const parsedInspirations: unknown = JSON.parse(storedInspirations); if (!Array.isArray(parsedInspirations)) { return { items: [], shouldPersist: false, shouldRemove: true }; } const validInspirations = parsedInspirations.filter( (item): item is InspirationItem => item !== null && typeof item === 'object' && typeof (item as InspirationItem).id === 'number' && typeof (item as InspirationItem).prompt === 'string' && typeof (item as InspirationItem).createdAt === 'number' ); const migratedInspirations = validInspirations.filter((item) => !(item.id < 0 && item.createdAt === 0)); return { items: migratedInspirations, shouldPersist: migratedInspirations.length !== parsedInspirations.length, shouldRemove: false }; } catch (error) { console.error('加载或解析灵感相册失败:', error); return { items: [], shouldPersist: false, shouldRemove: true }; } } function readStoredDeletePreference(): boolean { return readLocalStorageValue('imageGenSkipDeleteConfirm') === 'true'; } function getMimeTypeFromFormat(format: string): string { if (format === 'jpeg') return 'image/jpeg'; if (format === 'webp') return 'image/webp'; return 'image/png'; } const explicitModeClient = process.env.NEXT_PUBLIC_IMAGE_STORAGE_MODE; const vercelEnvClient = process.env.NEXT_PUBLIC_VERCEL_ENV; const isOnVercelClient = vercelEnvClient === 'production' || vercelEnvClient === 'preview'; let effectiveStorageModeClient: 'fs' | 'indexeddb'; if (explicitModeClient === 'fs') { effectiveStorageModeClient = 'fs'; } else if (explicitModeClient === 'indexeddb') { effectiveStorageModeClient = 'indexeddb'; } else if (isOnVercelClient) { effectiveStorageModeClient = 'indexeddb'; } else { effectiveStorageModeClient = 'fs'; } type ApiImageResult = { path: string; filename: string; clientRequestId?: string; storageMode?: HistoryMetadata['storageModeUsed']; }; type ImageStorageMode = NonNullable; type ApiUsage = { input_tokens_details?: { text_tokens?: number; image_tokens?: number; }; output_tokens?: number; }; type RuntimeCapabilities = { streaming?: { defaultBackend?: 'images-api' | 'responses-image-generation'; defaultMode?: ImageStreamMode; defaultStrategy?: ImageStreamingStrategy; }; streamingBatch: { enabled: boolean; recommendedConcurrency: number; requestCredentialConcurrency: number; channelCount?: number; healthyChannelCount?: number; unhealthyChannelCount?: number; lastFailure?: { at: number; scope: 'credential' | 'channel'; status?: number; code?: string; requestId?: string; requestMode?: string; }; }; responsesImageBackend?: { enabled: boolean; mode?: 'experimental'; requiredEnv?: string[]; optionalEnv?: string[]; hasDefaultModel?: boolean; missingEnv?: string[]; }; channelRouting?: { effectiveRequestModes?: string[]; requestModeHealth?: Array<{ mode: string; configuredCredentialCount: number; healthyCredentialCount: number; configuredChannelCount: number; healthyChannelCount: number; }>; effectiveRequestModesByChannel?: Array<{ channelId: string; requestModes: string[]; }>; }; upstreamProfile?: { activeProfile: ImageUpstreamProfileId; serverProfile: ImageUpstreamProfileId; serverProfileMixed: boolean; requestProfile: ImageUpstreamProfileId; activeConstraints?: ImageUpstreamProfile; }; webuiImageCleanup?: { enabled: boolean; retentionDays?: number; intervalMs?: number; }; }; type LoadedWebuiImageRetentionState = { scopeKey: string; filenames: Set; }; class ApiRequestError extends Error { readonly status?: number; readonly preserveDisplayedError: boolean; constructor(message: string, status?: number, options: { preserveDisplayedError?: boolean } = {}) { super(message); this.name = 'ApiRequestError'; this.status = status; this.preserveDisplayedError = options.preserveDisplayedError === true; } } function getLocalizedImageRequestError(t: ReturnType['t'], status: number | undefined): string { if (status === 400) return t('error.invalidRequest'); if (status === undefined) return t('error.streaming'); return t('error.apiFailed', { status }); } function summarizeApiError( error: unknown, fallbackMessage: string, t: ReturnType['t'] ): { message: string; status?: number } { if (error instanceof ApiRequestError) { return { message: error.message, status: error.status }; } if (error instanceof BatchPausedError) { return { message: t('batch.pausedFailure') }; } return { message: fallbackMessage }; } function renderErrorDescription(error: ApiErrorNotice): React.ReactNode { return (

{error.message}

{error.links.map((link) => ( {link.label} ))}
); } function mergeUsageValues(usages: unknown[]): ApiUsage | undefined { const merged: ApiUsage = { input_tokens_details: { text_tokens: 0, image_tokens: 0 }, output_tokens: 0 }; let hasUsage = false; usages.forEach((usage) => { if (!usage || typeof usage !== 'object') return; const candidate = usage as ApiUsage; const textTokens = candidate.input_tokens_details?.text_tokens; const imageTokens = candidate.input_tokens_details?.image_tokens; const outputTokens = candidate.output_tokens; if (typeof textTokens === 'number') { merged.input_tokens_details!.text_tokens! += textTokens; hasUsage = true; } if (typeof imageTokens === 'number') { merged.input_tokens_details!.image_tokens! += imageTokens; hasUsage = true; } if (typeof outputTokens === 'number') { merged.output_tokens! += outputTokens; hasUsage = true; } }); return hasUsage ? merged : undefined; } function mergeActualCostValues(costs: Array): ActualCostDetails | undefined { const present = costs.filter((cost): cost is ActualCostDetails => cost !== undefined); if (present.length === 0) return undefined; const actualCosts = present.filter((cost) => cost.source === 'new-api-log-token'); if (actualCosts.length === present.length) { const actualQuota = actualCosts.reduce((sum, cost) => sum + (cost.actualQuota ?? 0), 0); const actualAmount = actualCosts.reduce((sum, cost) => sum + (cost.actualAmount ?? 0), 0); return { actualAmount: Math.round(actualAmount * 1_000_000) / 1_000_000, actualQuota, currency: 'usd-equivalent', source: 'new-api-log-token', confidence: actualCosts.every((cost) => cost.confidence === 'high') ? 'high' : 'low', upstreamProvider: 'new-api', reason: actualCosts.length > 1 ? `已汇总 ${actualCosts.length} 个子请求的实际扣费。` : undefined }; } return { currency: 'usd-equivalent', source: 'unavailable', confidence: 'low', upstreamProvider: 'new-api', reason: '批量请求中存在未匹配到实际扣费的子请求,未将估算值标记为实际扣费。' }; } export default function HomePage() { const { locale, t } = useI18n(); const createErrorNotice = React.useCallback((message: string) => buildApiErrorNotice(message), []); const [mode, setMode] = React.useState<'generate' | 'edit'>('generate'); const [workbenchMode, setWorkbenchMode] = React.useState('generate'); const [reuseContext, setReuseContext] = React.useState(null); const [editReuseContext, setEditReuseContext] = React.useState(null); const [isPasswordRequiredByBackend, setIsPasswordRequiredByBackend] = React.useState(null); const [clientPasswordHash, setClientPasswordHash] = React.useState(null); const [isEntryAuthenticated, setIsEntryAuthenticated] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false); const [isSendingToEdit, setIsSendingToEdit] = React.useState(false); const [error, setError] = React.useState(null); const [generationFailureMessage, setGenerationFailureMessage] = React.useState(null); const [failedBatchPrompts, setFailedBatchPrompts] = React.useState([]); const [latestImageBatch, setLatestImageBatch] = React.useState(null); const [activeResultSource, setActiveResultSource] = React.useState(null); const [completedGenerationCount, setCompletedGenerationCount] = React.useState(null); const [imageOutputView, setImageOutputView] = React.useState<'grid' | number>('grid'); const [history, setHistory] = React.useState([]); const [inspirations, setInspirations] = React.useState([]); const hasLoadedStoredHistoryRef = React.useRef(false); const hasProcessedQueuedResultFeedbackDeletesRef = React.useRef(false); const hasProcessedQueuedResultFeedbackSyncsRef = React.useRef(false); const scheduledResultFeedbackSyncKeysRef = React.useRef>(new Set()); const scheduledResultFeedbackSyncPayloadsRef = React.useRef>(new Map()); const completedResultFeedbackSyncKeysRef = React.useRef>(new Set()); const scheduledResultFeedbackDeleteKeysRef = React.useRef>(new Set()); const scheduledResultFeedbackDeletePayloadsRef = React.useRef>(new Map()); const scheduleResultFeedbackDeletePayloadRef = React.useRef< ((payload: HistoryFeedbackDeletePayload) => void) | null >(null); const resultFeedbackRetryTimersRef = React.useRef>>(new Map()); const resultFeedbackSyncAbortControllersRef = React.useRef>(new Map()); const resultFeedbackDeleteAbortControllersRef = React.useRef>(new Map()); const blobUrlCacheRef = React.useRef>(new Map()); const [isPasswordDialogOpen, setIsPasswordDialogOpen] = React.useState(false); const [isApiSettingsDialogOpen, setIsApiSettingsDialogOpen] = React.useState(false); const [apiSettings, setApiSettings] = React.useState(emptyApiSettings); const [runtimeCapabilities, setRuntimeCapabilities] = React.useState(null); const [isRuntimeCapabilitiesLoading, setIsRuntimeCapabilitiesLoading] = React.useState(true); const [loadedWebuiImageRetentionState, setLoadedWebuiImageRetentionState] = React.useState(null); const [passwordDialogContext, setPasswordDialogContext] = React.useState<'initial' | 'retry'>('initial'); const [lastApiCallArgs, setLastApiCallArgs] = React.useState(null); const [skipDeleteConfirmation, setSkipDeleteConfirmation] = React.useState(false); const [itemToDeleteConfirm, setItemToDeleteConfirm] = React.useState(null); const [dialogCheckboxStateSkipConfirm, setDialogCheckboxStateSkipConfirm] = React.useState(false); const [openLogsSignal, setOpenLogsSignal] = React.useState(0); const [shareDialogOpen, setShareDialogOpen] = React.useState(false); const [shareTargetFilename, setShareTargetFilename] = React.useState(null); const [shareTargetStorageMode, setShareTargetStorageMode] = React.useState(); const [shareUrl, setShareUrl] = React.useState(null); const [shareError, setShareError] = React.useState(null); const [isCreatingShare, setIsCreatingShare] = React.useState(false); const [shareDialogSessionId, setShareDialogSessionId] = React.useState(0); const [isMobileCreationDrawerOpen, setIsMobileCreationDrawerOpen] = React.useState(false); const outputPanelRef = React.useRef(null); const mobileCreationDrawerCloseButtonRef = React.useRef(null); const mobileCreationDrawerTriggerRef = React.useRef(null); const mobileCreationDrawerFocusTargetRef = React.useRef<'trigger' | 'output' | null>(null); const pendingWorkbenchModeFocusRef = React.useRef(null); const apiSettingsDialogTriggerRef = React.useRef(null); const sendingToEditRef = React.useRef(false); const mobileDrawerPointerStartRef = React.useRef(null); const mobileDrawerGestureHandledAtRef = React.useRef(0); const allDbImages = useLiveQuery(() => db.images.toArray(), []); const [editImageFiles, setEditImageFiles] = React.useState([]); const [editSourceImagePreviewUrls, setEditSourceImagePreviewUrlsState] = React.useState([]); const editSourceImagePreviewUrlsRef = React.useRef([]); const updateEditSourceImagePreviewUrls = React.useCallback((nextUrls: string[]) => { const nextUrlSet = new Set(nextUrls); editSourceImagePreviewUrlsRef.current.forEach((url) => { if (!nextUrlSet.has(url)) { URL.revokeObjectURL(url); } }); editSourceImagePreviewUrlsRef.current = nextUrls; setEditSourceImagePreviewUrlsState(nextUrls); }, []); const [editPrompt, setEditPrompt] = React.useState(''); const [editN, setEditN] = React.useState([1]); const [editSize, setEditSize] = React.useState('auto'); const [editCustomWidth, setEditCustomWidth] = React.useState(1024); const [editCustomHeight, setEditCustomHeight] = React.useState(1024); const [editQuality, setEditQuality] = React.useState('auto'); const [editOutputFormat, setEditOutputFormat] = React.useState('webp'); const [editCompression, setEditCompression] = React.useState([100]); const [editModeration, setEditModeration] = React.useState('auto'); const [editBrushSize, setEditBrushSize] = React.useState([20]); const [editShowMaskEditor, setEditShowMaskEditor] = React.useState(false); const [editGeneratedMaskFile, setEditGeneratedMaskFile] = React.useState(null); const [editIsMaskSaved, setEditIsMaskSaved] = React.useState(false); const [editOriginalImageSize, setEditOriginalImageSize] = React.useState<{ width: number; height: number } | null>( null ); const [editDrawnPoints, setEditDrawnPoints] = React.useState([]); const [editMaskPreviewUrl, setEditMaskPreviewUrl] = React.useState(null); const [editImageBackend, setEditImageBackend] = React.useState( IMAGE_UPSTREAM_FORM_SERVER_DEFAULT ); const [editStreamingStrategy, setEditStreamingStrategy] = React.useState( IMAGE_UPSTREAM_FORM_SERVER_DEFAULT ); const [editResponsesModel, setEditResponsesModel] = React.useState(''); const [editThinking, setEditThinking] = React.useState( IMAGE_UPSTREAM_FORM_SERVER_DEFAULT ); const [editPromptOptimization, setEditPromptOptimization] = React.useState( IMAGE_UPSTREAM_FORM_SERVER_DEFAULT ); const [editForceWeb, setEditForceWeb] = React.useState(false); const [genModel, setGenModel] = React.useState('gpt-image-2'); const [genPrompt, setGenPrompt] = React.useState(''); const [genBatchPromptText, setGenBatchPromptText] = React.useState(''); const [genN, setGenN] = React.useState([1]); const [genSize, setGenSize] = React.useState('auto'); const [genCustomWidth, setGenCustomWidth] = React.useState(1024); const [genCustomHeight, setGenCustomHeight] = React.useState(1024); const [genQuality, setGenQuality] = React.useState('high'); const [genOutputFormat, setGenOutputFormat] = React.useState('webp'); const [genCompression, setGenCompression] = React.useState([100]); const [genBackground, setGenBackground] = React.useState('auto'); const [genModeration, setGenModeration] = React.useState('auto'); const [genImageBackend, setGenImageBackend] = React.useState( IMAGE_UPSTREAM_FORM_SERVER_DEFAULT ); const [genStreamingStrategy, setGenStreamingStrategy] = React.useState( IMAGE_UPSTREAM_FORM_SERVER_DEFAULT ); const [genResponsesModel, setGenResponsesModel] = React.useState(''); const [genThinking, setGenThinking] = React.useState( IMAGE_UPSTREAM_FORM_SERVER_DEFAULT ); const [genPromptOptimization, setGenPromptOptimization] = React.useState( IMAGE_UPSTREAM_FORM_SERVER_DEFAULT ); const [genForceWeb, setGenForceWeb] = React.useState(false); const [editModel, setEditModel] = React.useState('gpt-image-2'); // 流式状态,由生成和编辑模式共用。 const [streamMode, setStreamMode] = React.useState('auto'); const [partialImages, setPartialImages] = React.useState(1); const [enableParallelBatch, setEnableParallelBatch] = React.useState(false); const [activeRequestStreaming, setActiveRequestStreaming] = React.useState(false); // 流式预览图,存储流式过程中的局部图片 base64 data URL。 const [streamingPreviewImages, setStreamingPreviewImages] = React.useState>(new Map()); const [batchProgress, setBatchProgress] = React.useState(null); const [isBatchPauseRequested, setIsBatchPauseRequested] = React.useState(false); const batchPauseRequestedRef = React.useRef(false); const cleanupEnabled = runtimeCapabilities?.webuiImageCleanup?.enabled === true; const retentionScopeKey = cleanupEnabled && isPasswordRequiredByBackend !== null && (!isPasswordRequiredByBackend || isEntryAuthenticated) ? `webui-image-retention:${isPasswordRequiredByBackend ? 'authenticated' : 'public'}` : null; const permanentlySavedFilenames = loadedWebuiImageRetentionState?.scopeKey === retentionScopeKey ? loadedWebuiImageRetentionState.filenames : null; const retentionControlsEnabled = cleanupEnabled && permanentlySavedFilenames !== null; const defaultStreamingStrategy = runtimeCapabilities?.streaming?.defaultStrategy ?? 'auto'; const activeUpstreamProfileSummary = summarizeImageUpstreamProfile({ requestApiBaseUrl: apiSettings.baseUrl, serverProfileIds: runtimeCapabilities?.upstreamProfile ? [runtimeCapabilities.upstreamProfile.serverProfile] : [] }); const hasRequestApiKey = apiSettings.apiKey.trim().length > 0; const hasRequestApiOverride = hasRequestApiKey; const activeUpstreamProfile = hasRequestApiOverride ? activeUpstreamProfileSummary.activeConstraints : runtimeCapabilities?.upstreamProfile?.activeConstraints || IMAGE_UPSTREAM_PROFILES['openai-compatible']; const activeUpstreamProfileMixed = !hasRequestApiOverride && runtimeCapabilities?.upstreamProfile?.serverProfileMixed === true; const allowResponsesImageBackend = shouldAllowResponsesImageBackend({ runtimeCapabilities, hasRequestApiOverride }); const allowResponsesHistoryRoute = shouldAllowResponsesHistoryRoute({ runtimeCapabilitiesAvailable: runtimeCapabilities !== null, allowResponsesImageBackend }); const hasDefaultResponsesModel = runtimeCapabilities?.responsesImageBackend?.hasDefaultModel === true; const streamingBatchCapacity = resolveStreamingBatchCapacity({ featureEnabled: runtimeCapabilities?.streamingBatch.enabled === true, hasRequestApiKey: apiSettings.apiKey.trim().length > 0, requestCredentialConcurrency: runtimeCapabilities?.streamingBatch.requestCredentialConcurrency ?? 1, serverRecommendedConcurrency: runtimeCapabilities?.streamingBatch.recommendedConcurrency ?? 0 }); const streamingBatchEnabled = streamingBatchCapacity.enabled; const isPromptBatchMode = mode === 'generate' && workbenchMode === 'batch'; const currentPrompt = mode === 'generate' && workbenchMode === 'batch' ? genBatchPromptText : mode === 'generate' ? genPrompt : editPrompt; const hasEditSourceImage = editImageFiles.length > 0; const maxEditSourceImages = activeUpstreamProfile.upload.maxImages; const usesPositiveIntegerCustomSize = activeUpstreamProfile.gptImage2.sizePolicy === 'positive-integer'; const currentGenerateSizeValidation = genSize === 'custom' ? usesPositiveIntegerCustomSize ? validatePositiveIntegerImageSize(genCustomWidth, genCustomHeight) : validateGptImage2Size(genCustomWidth, genCustomHeight) : { valid: true as const }; const currentEditSizeValidation = editSize === 'custom' ? usesPositiveIntegerCustomSize ? validatePositiveIntegerImageSize(editCustomWidth, editCustomHeight) : validateGptImage2Size(editCustomWidth, editCustomHeight) : { valid: true as const }; const canOpenLogs = isPasswordRequiredByBackend === true && !!clientPasswordHash; const usesEditControls = workbenchMode === 'edit'; const activeWorkbenchModel = usesEditControls ? editModel : genModel; const activeWorkbenchBackend = usesEditControls ? editImageBackend : genImageBackend; const activeWorkbenchStreamingStrategy = usesEditControls ? editStreamingStrategy : genStreamingStrategy; const activeEffectiveStreamingStrategy = activeWorkbenchStreamingStrategy === IMAGE_UPSTREAM_FORM_SERVER_DEFAULT ? defaultStreamingStrategy : activeWorkbenchStreamingStrategy; const activeRouteLabel = getWorkbenchRouteLabel(activeWorkbenchBackend, t); const activeRuntimeHealthStatus: RuntimeHealthStatus = resolveRuntimeHealthStatus({ runtimeCapabilities, isRuntimeCapabilitiesLoading, hasRequestApiOverride, imageBackend: activeWorkbenchBackend, hasRequestResponsesModel: (usesEditControls ? editResponsesModel : genResponsesModel).trim().length > 0, streamingStrategy: activeEffectiveStreamingStrategy, streamMode }); const activeRequestedImageCount = mode === 'generate' && workbenchMode === 'batch' ? readBatchPromptLines(genBatchPromptText).length : mode === 'generate' ? genN[0] : editN[0]; const activeRequestSummaryLabel = t('workbench.requestSummary', { count: activeRequestedImageCount }); const activeParallelBatchVisible = resolveStreamingBatchToggleState({ allowStreamingBatch: streamingBatchEnabled, userEnabled: enableParallelBatch, targetCount: activeRequestedImageCount, streamMode, streamingStrategy: activeEffectiveStreamingStrategy }).checked; const hasRandomInspirationPrompt = inspirations.some((item) => item.prompt.trim().length > 0); const pickRandomInspirationPrompt = React.useCallback(() => { const savedPrompts = inspirations.map((item) => item.prompt.trim()).filter((prompt) => prompt.length > 0); if (savedPrompts.length === 0) return ''; return savedPrompts[Math.floor(Math.random() * savedPrompts.length)] ?? ''; }, [inspirations]); React.useEffect(() => { if (allowResponsesImageBackend || runtimeCapabilities === null) return; let cancelled = false; queueMicrotask(() => { if (cancelled) return; setGenImageBackend((current) => current === 'responses-image-generation' ? IMAGE_UPSTREAM_FORM_SERVER_DEFAULT : current ); setEditImageBackend((current) => current === 'responses-image-generation' ? IMAGE_UPSTREAM_FORM_SERVER_DEFAULT : current ); setGenStreamingStrategy((current) => current === 'responses-sse' ? IMAGE_UPSTREAM_FORM_SERVER_DEFAULT : current ); setEditStreamingStrategy((current) => current === 'responses-sse' ? IMAGE_UPSTREAM_FORM_SERVER_DEFAULT : current ); setGenResponsesModel(''); setEditResponsesModel(''); setGenThinking(IMAGE_UPSTREAM_FORM_SERVER_DEFAULT); setEditThinking(IMAGE_UPSTREAM_FORM_SERVER_DEFAULT); setGenPromptOptimization(IMAGE_UPSTREAM_FORM_SERVER_DEFAULT); setEditPromptOptimization(IMAGE_UPSTREAM_FORM_SERVER_DEFAULT); }); return () => { cancelled = true; }; }, [allowResponsesImageBackend, runtimeCapabilities]); React.useEffect(() => { let cancelled = false; queueMicrotask(() => { if (cancelled) return; if ( partialImages < activeUpstreamProfile.partialImages.min || partialImages > activeUpstreamProfile.partialImages.max ) { setPartialImages( Math.min( activeUpstreamProfile.partialImages.max, Math.max(activeUpstreamProfile.partialImages.min, partialImages) ) as PartialImagesCount ); } if (genBackground === 'transparent' && !activeUpstreamProfile.gptImage2.allowTransparentBackground) { setGenBackground('auto'); } setGenN((current) => current[0] < activeUpstreamProfile.generateCount.min || current[0] > activeUpstreamProfile.generateCount.max ? [ Math.min( activeUpstreamProfile.generateCount.max, Math.max(activeUpstreamProfile.generateCount.min, current[0]) ) ] : current ); setEditN((current) => current[0] < activeUpstreamProfile.editCount.min || current[0] > activeUpstreamProfile.editCount.max ? [ Math.min( activeUpstreamProfile.editCount.max, Math.max(activeUpstreamProfile.editCount.min, current[0]) ) ] : current ); }); return () => { cancelled = true; }; }, [activeUpstreamProfile, editN, genBackground, genN, partialImages]); const activeLogClientRequestIds = React.useMemo(() => { if (!latestImageBatch || latestImageBatch.length === 0) return []; if (typeof imageOutputView === 'number') { return uniqueStrings([latestImageBatch[imageOutputView]?.clientRequestId]); } return uniqueStrings(latestImageBatch.map((image) => image.clientRequestId)); }, [imageOutputView, latestImageBatch]); const activeLogFilenames = React.useMemo(() => { if (!latestImageBatch || latestImageBatch.length === 0) return []; if (typeof imageOutputView === 'number') { return uniqueStrings([latestImageBatch[imageOutputView]?.filename]); } return uniqueStrings(latestImageBatch.map((image) => image.filename)); }, [imageOutputView, latestImageBatch]); const generationActivityItems = React.useMemo( () => buildGenerationActivityItems({ isLoading, isSendingToEdit, mode, streamingPreviewCount: streamingPreviewImages.size, errorMessage: error?.message, completedGenerationCount, batchProgress, t }), [ batchProgress, completedGenerationCount, error?.message, isLoading, isSendingToEdit, mode, streamingPreviewImages.size, t ] ); const announcedGenerationActivity = selectAnnouncedGenerationActivity(generationActivityItems); const mobilePrimaryDisabledReason = resolveMobilePrimaryDisabledReason({ isLoading, isSendingToEdit, mode, isBatchMode: workbenchMode === 'batch', prompt: currentPrompt, batchPromptCount: workbenchMode === 'batch' ? readBatchPromptLines(genBatchPromptText).length : 0, hasEditSourceImage, hasUnsavedMask: editDrawnPoints.length > 0 && !editGeneratedMaskFile && !editIsMaskSaved, imageBackend: mode === 'generate' ? genImageBackend : editImageBackend, responsesModel: mode === 'generate' ? genResponsesModel : editResponsesModel, hasDefaultResponsesModel, generateSizeValidation: currentGenerateSizeValidation, editSizeValidation: currentEditSizeValidation, t }); const mobilePrimaryDisabled = isLoading || isSendingToEdit || Boolean(mobilePrimaryDisabledReason); const currentResultPrompt = activeResultSource?.prompt.trim() || currentPrompt.trim(); const canCreateResultVariant = !isLoading && Boolean(latestImageBatch) && Boolean(currentResultPrompt); const canReuseResultPrompt = Boolean(currentResultPrompt); const canPausePromptBatch = isLoading && isPromptBatchMode && Boolean(batchProgress); const handleBatchPromptTextChange = React.useCallback((nextText: React.SetStateAction) => { setGenBatchPromptText(nextText); setFailedBatchPrompts([]); }, []); const handlePauseBatch = React.useCallback(() => { batchPauseRequestedRef.current = true; setIsBatchPauseRequested(true); }, []); const handleWorkbenchModeChange = React.useCallback( (nextMode: WorkbenchMode) => { if ( typeof document !== 'undefined' && document.activeElement instanceof HTMLButtonElement && document.activeElement.matches('[data-workbench-mode]') ) { pendingWorkbenchModeFocusRef.current = nextMode; } setWorkbenchMode(nextMode); if (nextMode !== 'reuse') { setReuseContext(null); } if (nextMode !== 'edit') { setEditReuseContext(null); } if (nextMode === 'edit') { setMode('edit'); return; } if (nextMode === 'batch') { setGenN([1]); setGenBatchPromptText((current) => (current.trim() ? current : genPrompt)); } setMode('generate'); }, [genPrompt] ); React.useEffect(() => { const focusMode = pendingWorkbenchModeFocusRef.current; if (!focusMode || workbenchMode !== focusMode || typeof document === 'undefined') return; pendingWorkbenchModeFocusRef.current = null; const tab = Array.from(document.querySelectorAll('[data-workbench-mode]')).find( (candidate) => candidate.dataset.workbenchMode === focusMode && candidate.getClientRects().length > 0 && !candidate.closest('[aria-hidden="true"], [inert]') ); if (tab) { tab.focus(); } }, [mode, workbenchMode]); const scrollToOutput = React.useCallback(() => { const outputTop = outputPanelRef.current?.getBoundingClientRect().top; if (typeof outputTop !== 'number') return; window.scrollTo({ top: window.scrollY + outputTop - 12, behavior: 'smooth' }); }, []); const blurActiveMobileTrigger = React.useCallback(() => { if (typeof document === 'undefined') return; if (document.activeElement instanceof HTMLElement) { document.activeElement.blur(); } }, []); const openMobileCreationDrawer = React.useCallback(() => { blurActiveMobileTrigger(); setIsMobileCreationDrawerOpen(true); }, [blurActiveMobileTrigger]); const closeMobileCreationDrawer = React.useCallback(() => { mobileCreationDrawerFocusTargetRef.current = 'trigger'; blurActiveMobileTrigger(); setIsMobileCreationDrawerOpen(false); }, [blurActiveMobileTrigger]); const closeMobileCreationDrawerAfterSubmit = React.useCallback(() => { if (!isMobileCreationDrawerOpen) { mobileCreationDrawerFocusTargetRef.current = null; blurActiveMobileTrigger(); outputPanelRef.current?.focus(); return; } mobileCreationDrawerFocusTargetRef.current = 'output'; blurActiveMobileTrigger(); setIsMobileCreationDrawerOpen(false); }, [blurActiveMobileTrigger, isMobileCreationDrawerOpen]); const toggleMobileCreationDrawer = React.useCallback(() => { if (isMobileCreationDrawerOpen) { closeMobileCreationDrawer(); } else { openMobileCreationDrawer(); } }, [closeMobileCreationDrawer, isMobileCreationDrawerOpen, openMobileCreationDrawer]); const beginMobileCreationDrawerGesture = React.useCallback((event: React.PointerEvent) => { if (event.pointerType === 'mouse' && event.button !== 0) return; event.currentTarget.setPointerCapture(event.pointerId); mobileDrawerPointerStartRef.current = { x: event.clientX, y: event.clientY }; }, []); const finishMobileCreationDrawerGesture = React.useCallback( (event: React.PointerEvent) => { const start = mobileDrawerPointerStartRef.current; mobileDrawerPointerStartRef.current = null; if (!start) return; const gesture = resolveMobileCreationSheetGesture({ startX: start.x, startY: start.y, currentX: event.clientX, currentY: event.clientY }); if (gesture === 'open') { mobileDrawerGestureHandledAtRef.current = Date.now(); openMobileCreationDrawer(); } else if (gesture === 'close') { mobileDrawerGestureHandledAtRef.current = Date.now(); closeMobileCreationDrawer(); } }, [closeMobileCreationDrawer, openMobileCreationDrawer] ); const cancelMobileCreationDrawerGesture = React.useCallback(() => { mobileDrawerPointerStartRef.current = null; }, []); const handleMobileCreationDrawerHandleClick = React.useCallback(() => { if (Date.now() - mobileDrawerGestureHandledAtRef.current < 500) { mobileDrawerGestureHandledAtRef.current = 0; return; } toggleMobileCreationDrawer(); }, [toggleMobileCreationDrawer]); const handleMobileCreationDrawerKeyDown = React.useCallback( (event: React.KeyboardEvent) => { if (!isMobileCreationDrawerOpen) return; if (event.key !== 'Tab') return; const focusableElements = getMobileCreationDrawerFocusableElements(event.currentTarget); const firstElement = focusableElements[0]; const lastElement = focusableElements.at(-1); if (!firstElement || !lastElement) return; if (event.shiftKey && document.activeElement === firstElement) { event.preventDefault(); lastElement.focus(); } else if (!event.shiftKey && document.activeElement === lastElement) { event.preventDefault(); firstElement.focus(); } }, [isMobileCreationDrawerOpen] ); React.useEffect(() => { if (!isMobileCreationDrawerOpen || typeof document === 'undefined') return; const mobileCreationDrawer = document.getElementById('mobile-creation-sheet'); if (!mobileCreationDrawer) return; const keepFocusInMobileCreationDrawer = (event: FocusEvent) => { const target = event.target; if (!(target instanceof HTMLElement)) return; if (mobileCreationDrawer.contains(target) || target.closest(mobileCreationDrawerPortalSelector)) return; getMobileCreationDrawerFocusableElements(mobileCreationDrawer)[0]?.focus(); }; document.addEventListener('focusin', keepFocusInMobileCreationDrawer); return () => document.removeEventListener('focusin', keepFocusInMobileCreationDrawer); }, [isMobileCreationDrawerOpen]); React.useEffect(() => { if (!isMobileCreationDrawerOpen || typeof document === 'undefined') return; const previousBodyOverflow = document.body.style.overflow; const previousHtmlOverflow = document.documentElement.style.overflow; document.body.style.overflow = 'hidden'; document.documentElement.style.overflow = 'hidden'; return () => { document.body.style.overflow = previousBodyOverflow; document.documentElement.style.overflow = previousHtmlOverflow; }; }, [isMobileCreationDrawerOpen]); React.useEffect(() => { if (!isMobileCreationDrawerOpen || typeof document === 'undefined') return; const closeDrawerOnEscape = (event: KeyboardEvent) => { if (event.key !== 'Escape') return; if (event.target instanceof Element && event.target.closest(mobileCreationDrawerPortalSelector)) { return; } event.preventDefault(); closeMobileCreationDrawer(); }; document.addEventListener('keydown', closeDrawerOnEscape); return () => document.removeEventListener('keydown', closeDrawerOnEscape); }, [closeMobileCreationDrawer, isMobileCreationDrawerOpen]); React.useEffect(() => { if (!isMobileCreationDrawerOpen) return; requestAnimationFrame(() => { mobileCreationDrawerCloseButtonRef.current?.focus(); }); }, [isMobileCreationDrawerOpen]); React.useEffect(() => { if (isMobileCreationDrawerOpen || typeof window === 'undefined') return; const focusTarget = mobileCreationDrawerFocusTargetRef.current; if (!focusTarget) return; mobileCreationDrawerFocusTargetRef.current = null; if (focusTarget === 'trigger') { const trigger = mobileCreationDrawerTriggerRef.current ?? document.querySelector('[aria-controls="mobile-creation-sheet"]'); trigger?.focus(); } else { outputPanelRef.current?.focus(); } }, [isMobileCreationDrawerOpen]); React.useEffect(() => { if (!isMobileCreationDrawerOpen || typeof window === 'undefined') return; const desktopMediaQuery = window.matchMedia('(min-width: 1024px)'); const closeDrawerOnDesktop = () => { if (desktopMediaQuery.matches) { mobileCreationDrawerFocusTargetRef.current = null; setIsMobileCreationDrawerOpen(false); } }; closeDrawerOnDesktop(); desktopMediaQuery.addEventListener('change', closeDrawerOnDesktop); window.addEventListener('resize', closeDrawerOnDesktop); return () => { desktopMediaQuery.removeEventListener('change', closeDrawerOnDesktop); window.removeEventListener('resize', closeDrawerOnDesktop); }; }, [isMobileCreationDrawerOpen]); const getImageSrc = React.useCallback( (filename: string): string | undefined => { const cached = blobUrlCacheRef.current.get(filename); if (cached) return cached; const record = allDbImages?.find((img) => img.filename === filename); if (record?.blob) { const url = URL.createObjectURL(record.blob); blobUrlCacheRef.current.set(filename, url); return url; } return undefined; }, [allDbImages] ); const historyCompareImage = React.useMemo( () => resolveHistoryCompareImage({ history, currentFilenames: latestImageBatch?.map((image) => image.filename) ?? [], getIndexedDbImageSrc: getImageSrc }), [getImageSrc, history, latestImageBatch] ); React.useEffect(() => { const cache = blobUrlCacheRef.current; return () => { cache.forEach((url) => URL.revokeObjectURL(url)); cache.clear(); }; }, []); React.useEffect(() => { queueMicrotask(() => { setHistory(readStoredHistory()); const storedInspirations = readStoredInspirations(); setInspirations(storedInspirations.items); if (storedInspirations.shouldRemove) { window.localStorage.removeItem(inspirationsLocalStorageKey); } else if (storedInspirations.shouldPersist) { window.localStorage.setItem(inspirationsLocalStorageKey, JSON.stringify(storedInspirations.items)); } hasLoadedStoredHistoryRef.current = true; }); }, []); React.useEffect(() => { editSourceImagePreviewUrlsRef.current = editSourceImagePreviewUrls; }, [editSourceImagePreviewUrls]); const verifyEntryPasswordHash = React.useCallback( async (passwordHash: string): Promise => { try { const response = await fetch('/api/auth-verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ passwordHash }) }); if (response.ok) { return 'valid'; } if (response.status === 401) { try { const result = (await response.json()) as { code?: string }; return isPagePasswordAuthErrorCode(result.code) ? 'invalid' : 'unavailable'; } catch { return 'unavailable'; } } return 'unavailable'; } catch (error) { console.error('验证入口访问码失败:', error); return 'unavailable'; } }, [] ); const promptForExpiredPassword = React.useCallback(() => { localStorage.removeItem('clientPasswordHash'); setClientPasswordHash(null); setIsEntryAuthenticated(false); setPasswordDialogContext('retry'); setIsPasswordDialogOpen(true); setError(createErrorNotice(t('error.passwordExpired'))); }, [createErrorNotice, t]); const refreshImageAccessCookie = React.useCallback( async (passwordHash = clientPasswordHash): Promise => { if (!isPasswordRequiredByBackend) { return true; } if (!passwordHash) { promptForExpiredPassword(); return false; } const verificationResult = await verifyEntryPasswordHash(passwordHash); if (verificationResult === 'valid') { setIsEntryAuthenticated(true); return true; } if (verificationResult === 'unavailable') { setError(createErrorNotice(t('error.authVerifyUnavailable'))); return false; } promptForExpiredPassword(); return false; }, [ clientPasswordHash, createErrorNotice, isPasswordRequiredByBackend, promptForExpiredPassword, t, verifyEntryPasswordHash ] ); React.useEffect(() => { const fetchAuthStatus = async () => { try { const response = await fetch('/api/auth-status'); if (!response.ok) { throw new Error('获取鉴权状态失败'); } const data = await response.json(); const passwordRequired = Boolean(data.passwordRequired); setIsPasswordRequiredByBackend(passwordRequired); const storedPasswordHash = readLocalStorageValue('clientPasswordHash'); if (!passwordRequired) { setClientPasswordHash(storedPasswordHash); setIsEntryAuthenticated(true); return; } if (storedPasswordHash) { const storedVerificationResult = await verifyEntryPasswordHash(storedPasswordHash); if (storedVerificationResult === 'valid') { setClientPasswordHash(storedPasswordHash); setIsEntryAuthenticated(true); return; } if (storedVerificationResult === 'unavailable') { setClientPasswordHash(storedPasswordHash); setPasswordDialogContext('retry'); setIsEntryAuthenticated(false); setError(createErrorNotice(t('error.authVerifyUnavailable'))); return; } } localStorage.removeItem('clientPasswordHash'); setClientPasswordHash(null); setPasswordDialogContext('initial'); setIsEntryAuthenticated(false); } catch (error) { console.error('获取鉴权状态失败:', error); setIsPasswordRequiredByBackend(false); setIsEntryAuthenticated(true); } }; fetchAuthStatus(); queueMicrotask(() => { const storedApiSettings = readStoredApiSettings(); setApiSettings(storedApiSettings.settings); if (storedApiSettings.shouldRemove) { window.localStorage.removeItem(apiSettingsLocalStorageKey); } else if (storedApiSettings.shouldPersist) { window.localStorage.setItem(apiSettingsLocalStorageKey, JSON.stringify(storedApiSettings.settings)); } }); }, [createErrorNotice, t, verifyEntryPasswordHash]); const refreshRuntimeCapabilities = React.useCallback(async (): Promise => { setIsRuntimeCapabilitiesLoading(true); try { const response = await fetch('/api/runtime-capabilities'); if (!response.ok) { throw new Error('获取运行时能力失败'); } const data = (await response.json()) as RuntimeCapabilities; if (data === null || typeof data !== 'object') { throw new Error('获取运行时能力失败'); } setRuntimeCapabilities(data); return data; } catch (error) { console.error('获取运行时能力失败:', error); setRuntimeCapabilities(null); return null; } finally { setIsRuntimeCapabilitiesLoading(false); } }, []); React.useEffect(() => { queueMicrotask(() => { refreshRuntimeCapabilities(); }); }, [refreshRuntimeCapabilities]); React.useEffect(() => { if (!retentionScopeKey) return; const controller = new AbortController(); const loadPermanentlySavedFilenames = async () => { try { const response = await fetch('/api/image-retention', { signal: controller.signal }); const body: unknown = await response.json().catch(() => null); if (!response.ok) { if (!controller.signal.aborted) { setError( (current) => current ?? createErrorNotice( response.status === 401 ? t('error.unauthorized') : t('retention.loadFailed') ) ); } return; } let filenames: ReturnType; try { filenames = readWebuiImageRetentionFilenames(body); } catch { throw new Error(t('retention.loadFailed')); } if (!controller.signal.aborted) { setLoadedWebuiImageRetentionState({ scopeKey: retentionScopeKey, filenames: new Set(filenames) }); } } catch (error) { if (controller.signal.aborted) return; console.error('读取自动清理保护状态失败:', error); setError((current) => current ?? createErrorNotice(t('retention.loadFailed'))); } }; void loadPermanentlySavedFilenames(); return () => controller.abort(); }, [createErrorNotice, retentionScopeKey, t]); const updatePermanentSave = React.useCallback( async (action: WebuiImageRetentionAction, filenames: string[]) => { if (!retentionScopeKey) { throw new Error(t('retention.updateFailed')); } let response: Response; try { response = await fetch('/api/image-retention', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, filenames, ...(clientPasswordHash ? { passwordHash: clientPasswordHash } : {}) }) }); } catch { throw new Error(t('retention.updateFailed')); } let body: unknown; try { body = await response.json(); } catch { throw new Error(t('retention.updateFailed')); } if (!response.ok && response.status !== 207) { throw new Error(response.status === 401 ? t('error.unauthorized') : t('retention.updateFailed')); } let results: ReturnType; try { results = readWebuiImageFileOperationResults(body); } catch { throw new Error(t('retention.updateFailed')); } setLoadedWebuiImageRetentionState((current) => current?.scopeKey === retentionScopeKey ? { ...current, filenames: mergeWebuiImageRetentionResults(current.filenames, action, results) } : current ); if (results.some((result) => !result.success)) { throw new Error(t('retention.partialUpdateFailed')); } }, [clientPasswordHash, retentionScopeKey, t] ); React.useEffect(() => { if (!hasLoadedStoredHistoryRef.current) return; try { localStorage.setItem('openaiImageHistory', JSON.stringify(history)); } catch (e) { console.error('保存历史记录到 localStorage 失败:', e); } }, [history]); React.useEffect(() => { if (!hasLoadedStoredHistoryRef.current) return; try { localStorage.setItem(inspirationsLocalStorageKey, JSON.stringify(inspirations)); } catch (e) { console.error('保存灵感相册到 localStorage 失败:', e); } }, [inspirations]); React.useEffect(() => { return () => { editSourceImagePreviewUrlsRef.current.forEach((url) => URL.revokeObjectURL(url)); editSourceImagePreviewUrlsRef.current = []; }; }, []); React.useEffect(() => { queueMicrotask(() => { setSkipDeleteConfirmation(readStoredDeletePreference()); }); }, []); React.useEffect(() => { localStorage.setItem('imageGenSkipDeleteConfirm', String(skipDeleteConfirmation)); }, [skipDeleteConfirmation]); React.useEffect(() => { const handlePaste = (event: ClipboardEvent) => { if (mode !== 'edit' || !event.clipboardData) { return; } if ( hasReachedEditSourceImageLimit({ currentCount: editImageFiles.length, maxImages: maxEditSourceImages }) ) { alert(t('alert.pasteMaxImages', { count: maxEditSourceImages })); return; } const items = event.clipboardData.items; for (let i = 0; i < items.length; i++) { if (items[i].type.indexOf('image') !== -1) { const file = items[i].getAsFile(); if (file) { event.preventDefault(); const previewUrl = URL.createObjectURL(file); setEditImageFiles((prevFiles) => [...prevFiles, file]); updateEditSourceImagePreviewUrls([...editSourceImagePreviewUrlsRef.current, previewUrl]); break; } } } }; window.addEventListener('paste', handlePaste); return () => { window.removeEventListener('paste', handlePaste); }; }, [mode, editImageFiles.length, maxEditSourceImages, t, updateEditSourceImagePreviewUrls]); const handleSavePassword = async (password: string) => { if (!password.trim()) { setError(createErrorNotice(t('password.empty'))); return; } try { const hash = await sha256Hex(password); const passwordVerificationResult = isPasswordRequiredByBackend ? await verifyEntryPasswordHash(hash) : 'valid'; if (passwordVerificationResult === 'unavailable') { setError(createErrorNotice(t('error.authVerifyUnavailable'))); return; } if (passwordVerificationResult === 'invalid') { localStorage.removeItem('clientPasswordHash'); setClientPasswordHash(null); setIsEntryAuthenticated(false); setError(createErrorNotice(t('error.unauthorized'))); setIsPasswordDialogOpen(true); return; } localStorage.setItem('clientPasswordHash', hash); setClientPasswordHash(hash); setIsEntryAuthenticated(true); setError(null); setIsPasswordDialogOpen(false); if (passwordDialogContext === 'retry' && lastApiCallArgs) { const retryArgs = lastApiCallArgs; setLastApiCallArgs(null); await handleApiCall(...retryArgs, hash); } } catch (e) { console.error('计算访问码哈希失败:', e); setError(createErrorNotice(t('password.hashError'))); } }; const handleOpenPasswordDialog = () => { setPasswordDialogContext('initial'); setIsPasswordDialogOpen(true); }; const handleSaveApiSettings = (settings: ApiSettings) => { if (settings.baseUrl && !settings.apiKey) { throw new Error(t('api.urlPairRequired')); } setApiSettings(settings); if (settings.apiKey || settings.baseUrl) { localStorage.setItem(apiSettingsLocalStorageKey, JSON.stringify(settings)); } else { localStorage.removeItem(apiSettingsLocalStorageKey); } }; const materializeImages = React.useCallback( async (images: ApiImageResponseItem[]): Promise => { if (effectiveStorageModeClient === 'indexeddb') { const indexedDbImages = await Promise.all( images.map(async (img) => { if (!img.b64_json) { throw new ApiRequestError(t('error.imageMissingBase64', { filename: img.filename })); } const byteCharacters = atob(img.b64_json); const byteNumbers = new Array(byteCharacters.length); for (let i = 0; i < byteCharacters.length; i++) { byteNumbers[i] = byteCharacters.charCodeAt(i); } const byteArray = new Uint8Array(byteNumbers); const blob = new Blob([byteArray], { type: getMimeTypeFromFormat(img.output_format) }); await db.images.put({ filename: img.filename, blob }); const existingUrl = blobUrlCacheRef.current.get(img.filename); if (existingUrl) { URL.revokeObjectURL(existingUrl); } const blobUrl = URL.createObjectURL(blob); blobUrlCacheRef.current.set(img.filename, blobUrl); return { filename: img.filename, path: blobUrl, ...(img.clientRequestId ? { clientRequestId: img.clientRequestId } : {}) }; }) ); return indexedDbImages; } const fsImages = images .filter((img) => !!img.path) .map((img) => ({ path: img.path!, filename: img.filename, ...(img.clientRequestId ? { clientRequestId: img.clientRequestId } : {}) })); if (fsImages.length !== images.length) { throw new ApiRequestError(t('error.apiOmittedPaths')); } return fsImages; }, [t] ); const commitCompletedImages = React.useCallback( async ( images: ApiImageResponseItem[], usage: unknown, actualCost: ActualCostDetails | undefined, durationMsValue: number, formData: GenerationFormData | EditingFormData, requestMode: RequestMode, clearStreaming = false, promptOverride?: string ) => { if (images.length === 0) { throw new ApiRequestError(t('error.noImages')); } const processedImages = await materializeImages(images); const processedImagesWithStorage = processedImages.map((image) => ({ ...image, storageMode: effectiveStorageModeClient })); setLatestImageBatch(processedImagesWithStorage); setCompletedGenerationCount(processedImages.length); setImageOutputView(processedImages.length > 1 ? 'grid' : 0); if (clearStreaming) { setStreamingPreviewImages(new Map()); } const historyEntry = buildCompletedHistoryEntry({ images, usage, actualCost, durationMs: durationMsValue, formData, requestMode, storageMode: effectiveStorageModeClient, promptOverride }); setActiveResultSource(historyEntry); setHistory((prevHistory) => [historyEntry, ...prevHistory]); }, [materializeImages, t] ); const buildApiFormData = React.useCallback( ( formData: GenerationFormData | EditingFormData, requestMode: RequestMode, options: { forceSingleImage?: boolean; streamMode: ImageStreamMode; partialImages: PartialImagesCount; passwordHash?: string | null; } ) => { const apiFormData = new FormData(); const effectivePasswordHash = options.passwordHash ?? clientPasswordHash; if (isPasswordRequiredByBackend && effectivePasswordHash) { apiFormData.append('passwordHash', effectivePasswordHash); } else if (isPasswordRequiredByBackend && !effectivePasswordHash) { throw new ApiRequestError(t('error.passwordRequired')); } apiFormData.append('mode', requestMode); if (apiSettings.apiKey) { apiFormData.append('apiKey', apiSettings.apiKey); } if (apiSettings.baseUrl && apiSettings.apiKey) { apiFormData.append('apiBaseUrl', apiSettings.baseUrl); } apiFormData.append('stream_mode', options.streamMode); if (options.streamMode !== 'non_stream') { apiFormData.append('partial_images', options.partialImages.toString()); } apiFormData.append('clientRequestId', createClientRequestId()); if (requestMode === 'generate') { const genData = formData as GenerationFormData; apiFormData.append('model', genData.model); apiFormData.append('prompt', genData.prompt); apiFormData.append('n', options.forceSingleImage ? '1' : genData.n.toString()); const genSizeToSend = genData.size === 'custom' ? `${genData.customWidth}x${genData.customHeight}` : (getPresetDimensions(genData.size, genData.model) ?? genData.size); apiFormData.append('size', genSizeToSend); apiFormData.append('quality', genData.quality); apiFormData.append('output_format', genData.output_format); if ( (genData.output_format === 'jpeg' || genData.output_format === 'webp') && genData.output_compression !== undefined ) { apiFormData.append('output_compression', genData.output_compression.toString()); } apiFormData.append('background', genData.background); apiFormData.append('moderation', genData.moderation); appendImageUpstreamOverrideFields(apiFormData, { imageBackend: genData.image_backend, streamingStrategy: genData.streaming_strategy, responsesModel: genData.responsesModel, thinking: genData.thinking, promptOptimization: genData.promptOptimization, forceWeb: genData.forceWeb }); } else { const editData = formData as EditingFormData; apiFormData.append('model', editData.model); apiFormData.append('prompt', editData.prompt); apiFormData.append('n', options.forceSingleImage ? '1' : editData.n.toString()); const editSizeToSend = editData.size === 'custom' ? `${editData.customWidth}x${editData.customHeight}` : (getPresetDimensions(editData.size, editData.model) ?? editData.size); apiFormData.append('size', editSizeToSend); apiFormData.append('quality', editData.quality); apiFormData.append('output_format', editData.output_format); if ( (editData.output_format === 'jpeg' || editData.output_format === 'webp') && editData.output_compression !== undefined ) { apiFormData.append('output_compression', editData.output_compression.toString()); } apiFormData.append('moderation', editData.moderation); appendImageUpstreamOverrideFields(apiFormData, { imageBackend: editData.image_backend, streamingStrategy: editData.streaming_strategy, responsesModel: editData.responsesModel, thinking: editData.thinking, promptOptimization: editData.promptOptimization, forceWeb: editData.forceWeb }); editData.imageFiles.forEach((file, index) => { apiFormData.append(`image_${index}`, file, file.name); }); if (editData.maskFile) { apiFormData.append('mask', editData.maskFile, editData.maskFile.name); } } return apiFormData; }, [apiSettings.apiKey, apiSettings.baseUrl, clientPasswordHash, isPasswordRequiredByBackend, t] ); const executeImageRequest = React.useCallback( async ( apiFormData: FormData, options: { previewIndexOffset?: number; retryFormData?: GenerationFormData | EditingFormData; retryMode?: RequestMode; retryStreamMode?: ImageStreamMode; retryPartialImages?: PartialImagesCount; retryEnableParallelBatch?: boolean; } = {} ): Promise<{ images: ApiImageResponseItem[]; usage: unknown; actualCost?: ActualCostDetails }> => { const formClientRequestId = String(apiFormData.get('clientRequestId') || ''); let response: Response; try { response = await fetch('/api/images', { method: 'POST', body: apiFormData }); } catch { throw new ApiRequestError(t('error.networkRequest')); } const contentType = response.headers.get('content-type'); if (contentType?.includes('text/event-stream')) { if (!response.body) { throw new ApiRequestError(t('error.responseBodyNull')); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let streamingState: StreamingClientState = { completedImages: [] }; const processSseEvent = async (rawEvent: string) => { const dataLines = rawEvent .split(/\r?\n/) .filter((line) => line.startsWith('data: ')) .map((line) => line.slice(6)); if (dataLines.length === 0) return; let event: StreamingClientEvent; try { event = JSON.parse(dataLines.join('\n')) as StreamingClientEvent; } catch { throw new ApiRequestError(t('error.streaming')); } if (event.type === 'partial_image') { const imageIndex = options.previewIndexOffset ?? event.index ?? 0; const dataUrl = `data:image/png;base64,${event.b64_json}`; setStreamingPreviewImages((prev) => { const newMap = new Map(prev); newMap.set(imageIndex, dataUrl); return newMap; }); } else if (event.type === 'error') { throw new ApiRequestError(getLocalizedImageRequestError(t, event.status), event.status); } else if (event.type === 'completed' || event.type === 'done') { try { streamingState = applyStreamingClientEvent(streamingState, event); } catch { throw new ApiRequestError(t('error.streaming')); } } }; while (true) { let chunk: ReadableStreamReadResult; try { chunk = await reader.read(); } catch { throw new ApiRequestError(t('error.streaming')); } const { done, value } = chunk; if (done) break; buffer += decoder.decode(value, { stream: true }); const events = buffer.split(sseEventDelimiterPattern); buffer = events.pop() || ''; for (const eventText of events) { await processSseEvent(eventText); } } const remainingEvent = buffer.trim(); if (remainingEvent) { await processSseEvent(remainingEvent); } return { images: streamingState.completedImages.map((image) => ({ ...image, ...(image.clientRequestId || !formClientRequestId ? {} : { clientRequestId: formClientRequestId }) })), usage: streamingState.usage, actualCost: streamingState.actualCost ?? undefined }; } let result: { error?: string; code?: string; images?: ApiImageResponseItem[]; usage?: unknown; actualCost?: ActualCostDetails; clientRequestId?: string; }; try { result = await response.json(); } catch (error) { if (!response.ok) { throw new ApiRequestError(getLocalizedImageRequestError(t, response.status), response.status); } throw new ApiRequestError(t('error.unexpected'), response.status); } if (!response.ok) { if ( response.status === 401 && isPasswordRequiredByBackend && isPagePasswordAuthErrorCode(result.code) ) { if ( options.retryFormData && options.retryMode && options.retryStreamMode !== undefined && options.retryPartialImages !== undefined && options.retryEnableParallelBatch !== undefined ) { setLastApiCallArgs([ options.retryFormData, options.retryMode, options.retryStreamMode, options.retryPartialImages, options.retryEnableParallelBatch ]); } promptForExpiredPassword(); throw new ApiRequestError(t('error.passwordExpired'), 401, { preserveDisplayedError: true }); } throw new ApiRequestError(getLocalizedImageRequestError(t, response.status), response.status); } return { images: (result.images || []).map((image) => ({ ...image, ...(image.clientRequestId || !(result.clientRequestId || formClientRequestId) ? {} : { clientRequestId: result.clientRequestId || formClientRequestId }) })), usage: result.usage, actualCost: result.actualCost }; }, [isPasswordRequiredByBackend, promptForExpiredPassword, t] ); async function handleApiCall( formData: GenerationFormData | EditingFormData, requestMode: RequestMode = mode, requestStreamMode: ImageStreamMode = streamMode, requestPartialImages: PartialImagesCount = partialImages, requestEnableParallelBatch = formData.enableParallelBatch, requestPasswordHash: string | null = clientPasswordHash ) { const startTime = Date.now(); let durationMs = 0; let shouldKeepRetryArgs = true; let effectiveFormData: GenerationFormData | EditingFormData = formData; setIsLoading(true); setActiveRequestStreaming(requestStreamMode !== 'non_stream'); setError(null); setGenerationFailureMessage(null); setFailedBatchPrompts([]); setLastApiCallArgs(null); setLatestImageBatch(null); setActiveResultSource(null); setCompletedGenerationCount(null); setImageOutputView('grid'); setStreamingPreviewImages(new Map()); setBatchProgress(null); batchPauseRequestedRef.current = false; setIsBatchPauseRequested(false); if (typeof window !== 'undefined' && window.matchMedia('(max-width: 1023px)').matches) { closeMobileCreationDrawerAfterSubmit(); window.setTimeout(scrollToOutput, 80); } try { const latestRuntimeCapabilities = await refreshRuntimeCapabilities(); const currentStreamingBatchCapacity = resolveStreamingBatchCapacity({ featureEnabled: latestRuntimeCapabilities?.streamingBatch.enabled === true, hasRequestApiKey: apiSettings.apiKey.trim().length > 0, requestCredentialConcurrency: latestRuntimeCapabilities?.streamingBatch.requestCredentialConcurrency ?? 1, serverRecommendedConcurrency: latestRuntimeCapabilities?.streamingBatch.recommendedConcurrency ?? 0 }); if (isPasswordRequiredByBackend) { const verificationPasswordHash = requestPasswordHash; if (!verificationPasswordHash) { const message = t('error.passwordRequired'); setError(createErrorNotice(message)); setGenerationFailureMessage(message); setPasswordDialogContext('initial'); setIsPasswordDialogOpen(true); return; } const verificationResult = await verifyEntryPasswordHash(verificationPasswordHash); if (verificationResult === 'valid') { setIsEntryAuthenticated(true); } else if (verificationResult === 'unavailable') { const message = t('error.authVerifyUnavailable'); setError(createErrorNotice(message)); setGenerationFailureMessage(message); return; } else { const message = t('error.passwordExpired'); promptForExpiredPassword(); setGenerationFailureMessage(message); return; } } const allowRuntimeResponsesImageBackend = shouldAllowResponsesImageBackend({ runtimeCapabilities: latestRuntimeCapabilities, hasRequestApiOverride }); if ( shouldBlockExplicitResponsesRequest({ imageBackend: formData.image_backend, allowResponsesImageBackend: allowRuntimeResponsesImageBackend }) ) { throw new ApiRequestError( latestRuntimeCapabilities === null ? t('upstream.responsesRuntimeUnavailable') : t('upstream.backendResponsesUnavailable') ); } const runtimeFormData = normalizeImageUpstreamRuntimeFields(formData, { allowResponsesImageBackend: allowRuntimeResponsesImageBackend }); if ( shouldBlockResponsesRequestWithoutModel({ imageBackend: runtimeFormData.image_backend, responsesModel: runtimeFormData.responsesModel, hasDefaultResponsesModel: latestRuntimeCapabilities?.responsesImageBackend?.hasDefaultModel === true }) ) { throw new ApiRequestError(t('upstream.responsesModelRequired')); } const promptBatch = requestMode === 'generate' ? ((runtimeFormData as GenerationFormData).batchPrompts ?? []) .map((prompt) => prompt.trim()) .filter((prompt) => prompt.length > 0) : []; const isPromptBatch = promptBatch.length > 1; const historyPromptOverride = requestMode === 'generate' && promptBatch.length > 0 ? formatBatchPromptHistory(promptBatch) : undefined; const imageCount = isPromptBatch ? promptBatch.length : requestMode === 'generate' ? (runtimeFormData as GenerationFormData).n : (runtimeFormData as EditingFormData).n; const requestStreamingStrategy = requestMode === 'generate' ? (runtimeFormData as GenerationFormData).streaming_strategy : (runtimeFormData as EditingFormData).streaming_strategy; const runtimeDefaultStreamingStrategy = latestRuntimeCapabilities?.streaming?.defaultStrategy ?? defaultStreamingStrategy; const effectiveRequestStreamingStrategy = requestStreamingStrategy === IMAGE_UPSTREAM_FORM_SERVER_DEFAULT ? runtimeDefaultStreamingStrategy : requestStreamingStrategy; const normalizedEnableParallelBatch = shouldUseStreamingBatch({ enabled: currentStreamingBatchCapacity.enabled, userEnabled: requestEnableParallelBatch, streaming: canUseStreamingBatchTransport({ streamMode: requestStreamMode, streamingStrategy: effectiveRequestStreamingStrategy }), imageCount }); effectiveFormData = { ...runtimeFormData, enableParallelBatch: normalizedEnableParallelBatch }; setLastApiCallArgs([ effectiveFormData, requestMode, requestStreamMode, requestPartialImages, normalizedEnableParallelBatch ]); const executeImageRequestForCurrentOptions = async ( options: { forceSingleImage: boolean; previewIndexOffset?: number; promptOverride?: string } = { forceSingleImage: false } ) => { const requestFormData = requestMode === 'generate' && options.promptOverride ? { ...(effectiveFormData as GenerationFormData), prompt: options.promptOverride, n: 1 } : effectiveFormData; return executeImageRequest( buildApiFormData(requestFormData, requestMode, { forceSingleImage: options.forceSingleImage, streamMode: requestStreamMode, partialImages: requestPartialImages, passwordHash: requestPasswordHash }), { previewIndexOffset: options.previewIndexOffset, retryFormData: effectiveFormData, retryMode: requestMode, retryStreamMode: requestStreamMode, retryPartialImages: requestPartialImages, retryEnableParallelBatch: normalizedEnableParallelBatch } ); }; if (isPromptBatch) { const jobs = buildStreamingBatchJobs(promptBatch.length); setBatchProgress({ completed: 0, failed: 0, total: jobs.length }); const batchResults = await scheduleStreamingBatch(jobs, { concurrency: normalizedEnableParallelBatch ? currentStreamingBatchCapacity.concurrency : 1, runJob: async (job: StreamingBatchJob) => { try { const result = await executeImageRequestForCurrentOptions({ forceSingleImage: true, previewIndexOffset: job.outputIndex, promptOverride: promptBatch[job.outputIndex] }); setBatchProgress((current) => advanceGenerationBatchProgress(current, jobs.length, false)); return result; } catch (error) { setBatchProgress((current) => advanceGenerationBatchProgress(current, jobs.length, true)); throw error; } }, shouldPause: () => batchPauseRequestedRef.current }); const errors = batchResults.filter((result): result is Error => result instanceof Error); const successes = batchResults.filter( ( result ): result is { images: ApiImageResponseItem[]; usage: unknown; actualCost?: ActualCostDetails } => !(result instanceof Error) ); if (errors.some(hasPreservedDisplayedAuthError)) { return; } const failedPromptBatch = collectFailedBatchPrompts(promptBatch, batchResults); if (errors.some((batchError) => batchError instanceof BatchPausedError)) { setBatchProgress((current) => ({ completed: countCompletedBatchResults(batchResults), failed: failedPromptBatch.length, total: current?.total ?? jobs.length })); } if (successes.length === 0) { setFailedBatchPrompts(failedPromptBatch); throw errors[0] || new ApiRequestError(t('error.noImages')); } const images = successes.flatMap((result) => result.images); const usage = mergeUsageValues(successes.map((result) => result.usage)); const actualCost = mergeActualCostValues(successes.map((result) => result.actualCost)); durationMs = Date.now() - startTime; if (errors.length > 0) { setFailedBatchPrompts(failedPromptBatch); } await commitCompletedImages( images, usage, actualCost, durationMs, effectiveFormData, requestMode, true, historyPromptOverride ); shouldKeepRetryArgs = false; if (errors.length > 0) { await refreshRuntimeCapabilities(); setError( createErrorNotice( buildBatchPartialFailureMessage({ failed: errors.length, total: jobs.length, errors: errors.map((error) => summarizeApiError(error, t('error.unexpected'), t)), t }) ) ); } else { setFailedBatchPrompts([]); } return; } if (normalizedEnableParallelBatch) { const jobs = buildStreamingBatchJobs(imageCount); const batchResults = await scheduleStreamingBatch(jobs, { concurrency: currentStreamingBatchCapacity.concurrency, runJob: async (job: StreamingBatchJob) => { return executeImageRequestForCurrentOptions({ forceSingleImage: true, previewIndexOffset: job.outputIndex }); } }); const errors = batchResults.filter((result): result is Error => result instanceof Error); const successes = batchResults.filter( ( result ): result is { images: ApiImageResponseItem[]; usage: unknown; actualCost?: ActualCostDetails } => !(result instanceof Error) ); if (errors.some(hasPreservedDisplayedAuthError)) { return; } if (successes.length === 0) { throw errors[0] || new ApiRequestError(t('error.noImages')); } const images = successes.flatMap((result) => result.images); const usage = mergeUsageValues(successes.map((result) => result.usage)); const actualCost = mergeActualCostValues(successes.map((result) => result.actualCost)); durationMs = Date.now() - startTime; await commitCompletedImages( images, usage, actualCost, durationMs, effectiveFormData, requestMode, true ); shouldKeepRetryArgs = false; if (errors.length > 0) { await refreshRuntimeCapabilities(); setError( createErrorNotice( buildBatchPartialFailureMessage({ failed: errors.length, total: jobs.length, errors: errors.map((error) => summarizeApiError(error, t('error.unexpected'), t)), t }) ) ); } return; } const result = await executeImageRequestForCurrentOptions(); durationMs = Date.now() - startTime; await commitCompletedImages( result.images || [], result.usage, result.actualCost, durationMs, effectiveFormData, requestMode, false, historyPromptOverride ); shouldKeepRetryArgs = false; } catch (err: unknown) { durationMs = Date.now() - startTime; console.error(`API 调用在 ${durationMs}ms 后失败:`, err); if (hasPreservedDisplayedAuthError(err)) { setGenerationFailureMessage(t('error.passwordExpired')); setLatestImageBatch(null); setStreamingPreviewImages(new Map()); return; } const errorSummary = summarizeApiError(err, t('error.unexpected'), t); const message = buildUserFacingApiErrorMessage({ ...errorSummary, t }); setError(createErrorNotice(message)); setGenerationFailureMessage(message); setLatestImageBatch(null); setHistory((prevHistory) => [ buildFailedHistoryEntry({ message, durationMs, formData: effectiveFormData, requestMode, storageMode: effectiveStorageModeClient }), ...prevHistory ]); setStreamingPreviewImages(new Map()); await refreshRuntimeCapabilities(); } finally { if (durationMs === 0) durationMs = Date.now() - startTime; if (!shouldKeepRetryArgs) { setLastApiCallArgs(null); } setActiveRequestStreaming(false); setIsLoading(false); } } function handleMobilePrimaryAction() { if (mode === 'generate') { const batchPrompts = workbenchMode === 'batch' ? readBatchPromptLines(genBatchPromptText) : undefined; const formData: GenerationFormData = { prompt: batchPrompts && batchPrompts.length > 0 ? batchPrompts[0] : genPrompt, n: genN[0], size: genSize, customWidth: genCustomWidth, customHeight: genCustomHeight, quality: genQuality, output_format: genOutputFormat, ...(genOutputFormat === 'jpeg' || genOutputFormat === 'webp' ? { output_compression: genCompression[0] } : {}), background: genBackground, moderation: genModeration, model: genModel, image_backend: genImageBackend, streaming_strategy: genStreamingStrategy, responsesModel: genResponsesModel, thinking: genThinking, promptOptimization: genPromptOptimization, forceWeb: genForceWeb, enableParallelBatch, ...(batchPrompts ? { batchPrompts } : {}) }; void handleApiCall(formData); return; } const formData: EditingFormData = { prompt: editPrompt, n: editN[0], size: editSize, customWidth: editCustomWidth, customHeight: editCustomHeight, quality: editQuality, output_format: editOutputFormat, ...(editOutputFormat === 'jpeg' || editOutputFormat === 'webp' ? { output_compression: editCompression[0] } : {}), moderation: editModeration, imageFiles: editImageFiles, maskFile: editGeneratedMaskFile, model: editModel, image_backend: editImageBackend, streaming_strategy: editStreamingStrategy, responsesModel: editResponsesModel, thinking: editThinking, promptOptimization: editPromptOptimization, forceWeb: editForceWeb, enableParallelBatch }; void handleApiCall(formData); } const buildCurrentGenerationFallbackFormData = React.useCallback((): GenerationFormData => { return { prompt: genPrompt, n: genN[0], size: genSize, customWidth: genCustomWidth, customHeight: genCustomHeight, quality: genQuality, output_format: genOutputFormat, ...(genOutputFormat === 'jpeg' || genOutputFormat === 'webp' ? { output_compression: genCompression[0] } : {}), background: genBackground, moderation: genModeration, model: genModel, image_backend: genImageBackend, streaming_strategy: genStreamingStrategy, responsesModel: genResponsesModel, thinking: genThinking, promptOptimization: genPromptOptimization, forceWeb: genForceWeb, enableParallelBatch }; }, [ enableParallelBatch, genBackground, genCompression, genCustomHeight, genCustomWidth, genForceWeb, genImageBackend, genModel, genModeration, genN, genOutputFormat, genPrompt, genPromptOptimization, genQuality, genResponsesModel, genSize, genStreamingStrategy, genThinking ]); const applyHistoryGenerationFormData = React.useCallback( (formData: GenerationFormData, item: HistoryMetadata) => { const normalizedFormData = normalizeImageUpstreamRuntimeFields(formData, { allowResponsesImageBackend: allowResponsesHistoryRoute }); const trimmedPrompt = normalizedFormData.prompt.trim(); const restoredFields = [t('reuse.fieldPrompt')]; setGenPrompt(trimmedPrompt || normalizedFormData.prompt); setGenModel(normalizedFormData.model); setGenSize(normalizedFormData.size); setGenCustomWidth(normalizedFormData.customWidth); setGenCustomHeight(normalizedFormData.customHeight); setGenQuality(normalizedFormData.quality); setGenBackground(normalizedFormData.background); setGenModeration(normalizedFormData.moderation); setGenOutputFormat(normalizedFormData.output_format); setGenImageBackend(normalizedFormData.image_backend); setGenStreamingStrategy(normalizedFormData.streaming_strategy); setGenResponsesModel(normalizedFormData.responsesModel); setGenThinking(normalizedFormData.thinking); setGenPromptOptimization(normalizedFormData.promptOptimization); setGenForceWeb(normalizedFormData.forceWeb); setEnableParallelBatch(normalizedFormData.enableParallelBatch); if (normalizedFormData.output_compression !== undefined) { setGenCompression([normalizedFormData.output_compression]); } restoredFields.push( t('reuse.fieldModel'), t('reuse.fieldSize'), t('reuse.fieldQuality'), t('reuse.fieldBackground'), t('reuse.fieldModeration'), t('reuse.fieldFormat'), t('reuse.fieldRoute') ); if (normalizedFormData.batchPrompts && normalizedFormData.batchPrompts.length > 1) { setGenBatchPromptText(normalizedFormData.batchPrompts.join('\n')); setGenN([1]); setWorkbenchMode('batch'); } else { setGenBatchPromptText(''); setGenN([normalizedFormData.n]); restoredFields.push(t('reuse.fieldCount')); setWorkbenchMode('reuse'); } setReuseContext({ sourceLabel: t('reuse.sourceHistory', { time: new Date(item.timestamp).toLocaleString(locale) }), restoredFields: Array.from(new Set(restoredFields)), promptPreview: trimmedPrompt || t('history.noPrompt') }); setMode('generate'); return normalizedFormData; }, [allowResponsesHistoryRoute, locale, t] ); function handleCreateVariant() { if (activeResultSource) { const formData = buildHistoryGenerationFormData( activeResultSource, buildCurrentGenerationFallbackFormData() ); const normalizedFormData = applyHistoryGenerationFormData(formData, activeResultSource); void handleApiCall(normalizedFormData, 'generate'); return; } handleMobilePrimaryAction(); } function handleReuseCurrentPrompt() { if (activeResultSource) { const formData = buildHistoryGenerationFormData( activeResultSource, buildCurrentGenerationFallbackFormData() ); applyHistoryGenerationFormData(formData, activeResultSource); return; } const promptToReuse = mode === 'edit' && editPrompt.trim() ? editPrompt : currentPrompt; const trimmedPrompt = promptToReuse.trim(); if (trimmedPrompt) { setGenPrompt(trimmedPrompt); setReuseContext({ sourceLabel: t('reuse.sourceCurrent'), restoredFields: [t('reuse.fieldPrompt')], promptPreview: trimmedPrompt }); } setWorkbenchMode('reuse'); setMode('generate'); } const handleHistorySelect = React.useCallback( async (item: HistoryMetadata) => { const originalStorageMode = item.storageModeUsed || 'fs'; if (originalStorageMode === 'fs' && !(await refreshImageAccessCookie())) { return; } setCompletedGenerationCount(null); const selectedBatchPromises = item.images.map(async (imgInfo, imageIndex) => { let path: string | undefined; if (originalStorageMode === 'indexeddb') { path = getImageSrc(imgInfo.filename); } else { path = `/api/image/${imgInfo.filename}`; } const clientRequestId = resolveHistoryImageClientRequestId(item, imageIndex); if (path) { return { path, filename: imgInfo.filename, storageMode: originalStorageMode, ...(clientRequestId ? { clientRequestId } : {}) }; } else { console.warn( `Could not get image source for history item: ${imgInfo.filename} (mode: ${originalStorageMode})` ); setError(createErrorNotice(t('error.historyImageLoad', { filename: imgInfo.filename }))); return null; } }); Promise.all(selectedBatchPromises).then((resolvedBatch) => { const validImages = resolvedBatch.filter(Boolean) as ApiImageResult[]; if (validImages.length !== item.images.length) { setError(createErrorNotice(t('error.historySomeMissing'))); } else { setError(null); } setLatestImageBatch(validImages.length > 0 ? validImages : null); setActiveResultSource(validImages.length > 0 ? item : null); setImageOutputView(validImages.length > 1 ? 'grid' : 0); }); }, [createErrorNotice, getImageSrc, refreshImageAccessCookie, t] ); const handleApplyPrompt = React.useCallback( (prompt: string, source: PromptApplySource) => { const trimmedPrompt = prompt.trim(); const restoredFields = [t('reuse.fieldPrompt')]; if (source.type === 'history') { const formData = buildHistoryGenerationFormData(source.item, buildCurrentGenerationFallbackFormData()); applyHistoryGenerationFormData(formData, source.item); return; } else { setGenPrompt(trimmedPrompt || prompt); setReuseContext({ sourceLabel: t('reuse.sourceInspiration', { title: source.title }), restoredFields, promptPreview: trimmedPrompt || t('history.noPrompt') }); } setWorkbenchMode('reuse'); setMode('generate'); }, [applyHistoryGenerationFormData, buildCurrentGenerationFallbackFormData, t] ); const handleSaveInspiration = React.useCallback((prompt: string) => { const trimmedPrompt = prompt.trim(); if (!trimmedPrompt) return; setInspirations((current) => { const existing = current.find((item) => item.prompt === trimmedPrompt); if (existing) { return [existing, ...current.filter((item) => item.id !== existing.id)]; } return [{ id: Date.now(), prompt: trimmedPrompt, createdAt: Date.now() }, ...current].slice(0, 24); }); }, []); const readQueuedResultFeedbackSyncs = React.useCallback((): FeedbackSyncPayload[] => { try { return parseHistoryFeedbackSyncQueue(readLocalStorageValue(resultFeedbackSyncQueueLocalStorageKey)); } catch (error) { console.error('读取结果反馈补偿队列失败。', error); return []; } }, []); const writeQueuedResultFeedbackSyncs = React.useCallback((queue: FeedbackSyncPayload[]) => { try { if (queue.length === 0) { window.localStorage.removeItem(resultFeedbackSyncQueueLocalStorageKey); return; } window.localStorage.setItem( resultFeedbackSyncQueueLocalStorageKey, serializeHistoryFeedbackSyncQueue(queue) ); } catch (error) { console.error('写入结果反馈补偿队列失败。', error); } }, []); const upsertQueuedResultFeedbackSync = React.useCallback( (payload: FeedbackSyncPayload) => { writeQueuedResultFeedbackSyncs( upsertHistoryFeedbackSyncQueue( readQueuedResultFeedbackSyncs(), payload, resultFeedbackSyncQueueMaxItems ) ); }, [readQueuedResultFeedbackSyncs, writeQueuedResultFeedbackSyncs] ); const removeQueuedResultFeedbackSync = React.useCallback( (key: string) => { writeQueuedResultFeedbackSyncs(removeHistoryFeedbackSyncQueueItem(readQueuedResultFeedbackSyncs(), key)); }, [readQueuedResultFeedbackSyncs, writeQueuedResultFeedbackSyncs] ); const clearScheduledResultFeedbackSync = React.useCallback((key: string) => { const timer = resultFeedbackRetryTimersRef.current.get(key); if (timer) { clearTimeout(timer); resultFeedbackRetryTimersRef.current.delete(key); } const controller = resultFeedbackSyncAbortControllersRef.current.get(key); if (controller) { controller.abort(); resultFeedbackSyncAbortControllersRef.current.delete(key); } scheduledResultFeedbackSyncKeysRef.current.delete(key); scheduledResultFeedbackSyncPayloadsRef.current.delete(key); }, []); const clearOverlappingResultFeedbackSyncs = React.useCallback( (targets: HistoryFeedbackTarget[]) => { scheduledResultFeedbackSyncPayloadsRef.current.forEach((scheduledPayload, scheduledKey) => { if (!haveHistoryFeedbackTargetOverlap(scheduledPayload.targets, targets)) return; clearScheduledResultFeedbackSync(scheduledKey); }); }, [clearScheduledResultFeedbackSync] ); const clearResultFeedbackSyncsForDelete = React.useCallback( (payload: HistoryFeedbackDeletePayload) => { scheduledResultFeedbackSyncPayloadsRef.current.forEach((scheduledPayload, scheduledKey) => { if (!shouldFeedbackDeleteClearSync(payload, scheduledPayload)) return; clearScheduledResultFeedbackSync(scheduledKey); }); }, [clearScheduledResultFeedbackSync] ); const clearScheduledResultFeedbackDelete = React.useCallback((deleteKey: string) => { const retryTimerKey = `delete:${deleteKey}`; const timer = resultFeedbackRetryTimersRef.current.get(retryTimerKey); if (timer) { clearTimeout(timer); resultFeedbackRetryTimersRef.current.delete(retryTimerKey); } const controller = resultFeedbackDeleteAbortControllersRef.current.get(retryTimerKey); if (controller) { controller.abort(); resultFeedbackDeleteAbortControllersRef.current.delete(retryTimerKey); } scheduledResultFeedbackDeleteKeysRef.current.delete(deleteKey); scheduledResultFeedbackDeletePayloadsRef.current.delete(deleteKey); }, []); const clearResultFeedbackDeletesForSync = React.useCallback( (payload: FeedbackSyncPayload, clearLegacyDeletes: boolean) => { const remainingPayloads: HistoryFeedbackDeletePayload[] = []; scheduledResultFeedbackDeletePayloadsRef.current.forEach((scheduledPayload, scheduledKey) => { const shouldClear = scheduledPayload.deletedAt === undefined ? clearLegacyDeletes && haveHistoryFeedbackTargetOverlap(scheduledPayload.targets, payload.targets) : shouldFeedbackSyncClearDelete(payload, scheduledPayload); if (!shouldClear) return; remainingPayloads.push(...removeHistoryFeedbackDeleteQueueTargets([scheduledPayload], payload.targets)); clearScheduledResultFeedbackDelete(scheduledKey); }); return remainingPayloads; }, [clearScheduledResultFeedbackDelete] ); const readQueuedResultFeedbackDeletes = React.useCallback((): HistoryFeedbackDeletePayload[] => { try { return parseHistoryFeedbackDeleteQueue(readLocalStorageValue(resultFeedbackDeleteQueueLocalStorageKey)); } catch (error) { console.error('读取结果反馈删除补偿队列失败。', error); return []; } }, []); const writeQueuedResultFeedbackDeletes = React.useCallback((queue: HistoryFeedbackDeletePayload[]) => { try { if (queue.length === 0) { window.localStorage.removeItem(resultFeedbackDeleteQueueLocalStorageKey); return; } window.localStorage.setItem( resultFeedbackDeleteQueueLocalStorageKey, serializeHistoryFeedbackDeleteQueue(queue) ); } catch (error) { console.error('写入结果反馈删除补偿队列失败。', error); } }, []); const upsertQueuedResultFeedbackDeletes = React.useCallback( (payload: HistoryFeedbackDeletePayload) => { writeQueuedResultFeedbackDeletes( upsertHistoryFeedbackDeleteQueue( readQueuedResultFeedbackDeletes(), payload, resultFeedbackDeleteQueueMaxItems ) ); }, [readQueuedResultFeedbackDeletes, writeQueuedResultFeedbackDeletes] ); const removeQueuedResultFeedbackDeletePayload = React.useCallback( (payload: HistoryFeedbackDeletePayload) => { writeQueuedResultFeedbackDeletes( removeHistoryFeedbackDeleteQueuePayload(readQueuedResultFeedbackDeletes(), payload) ); }, [readQueuedResultFeedbackDeletes, writeQueuedResultFeedbackDeletes] ); const syncResultFeedback = React.useCallback(async (payload: FeedbackSyncPayload, signal: AbortSignal) => { const hasNoteInput = Object.prototype.hasOwnProperty.call(payload, 'note'); const requestSignal = createFeedbackRequestSignal(signal); try { const response = await fetch('/api/feedback', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, signal: requestSignal.signal, body: JSON.stringify({ targets: payload.targets, value: payload.value, updatedAt: new Date(payload.updatedAt).toISOString(), ...(hasNoteInput ? { note: payload.note ?? '' } : {}) }) }); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(text || `反馈同步失败,状态码 ${response.status}`); } } finally { requestSignal.cleanup(); } }, []); const scheduleResultFeedbackSyncPayload = React.useCallback( (payload: FeedbackSyncPayload, options: FeedbackSyncScheduleOptions = {}) => { const syncKey = payload.key; if (completedResultFeedbackSyncKeysRef.current.has(syncKey)) return; if (scheduledResultFeedbackSyncKeysRef.current.has(syncKey)) return; const queuedDeletes = readQueuedResultFeedbackDeletes(); const clearLegacyDeletes = options.pruneQueuedTargets === true; const isBlockedByDelete = (deletePayload: HistoryFeedbackDeletePayload) => { if (deletePayload.deletedAt === undefined) { return ( !clearLegacyDeletes && haveHistoryFeedbackTargetOverlap(deletePayload.targets, payload.targets) ); } return shouldFeedbackDeleteClearSync(deletePayload, payload); }; const hasBlockingDelete = Array.from(scheduledResultFeedbackDeletePayloadsRef.current.values()).some(isBlockedByDelete) || queuedDeletes.some(isBlockedByDelete); if (hasBlockingDelete) { const nextSyncQueue = queuedDeletes.reduce( (currentQueue, deletePayload) => isBlockedByDelete(deletePayload) ? removeHistoryFeedbackSyncQueueTargetsForDelete(currentQueue, deletePayload) : currentQueue, readQueuedResultFeedbackSyncs() ); writeQueuedResultFeedbackSyncs(nextSyncQueue); return; } const remainingDeleteTargets = clearResultFeedbackDeletesForSync(payload, clearLegacyDeletes); writeQueuedResultFeedbackDeletes( clearLegacyDeletes ? removeHistoryFeedbackDeleteQueueTargets(queuedDeletes, payload.targets) : pruneHistoryFeedbackDeleteQueueForSyncQueue(queuedDeletes, [payload]) ); for (const remainingDeletePayload of remainingDeleteTargets) { scheduleResultFeedbackDeletePayloadRef.current?.(remainingDeletePayload); } if (options.pruneQueuedTargets) { writeQueuedResultFeedbackSyncs( removeHistoryFeedbackSyncQueueTargets(readQueuedResultFeedbackSyncs(), payload.targets) ); clearOverlappingResultFeedbackSyncs(payload.targets); } scheduledResultFeedbackSyncKeysRef.current.add(syncKey); scheduledResultFeedbackSyncPayloadsRef.current.set(syncKey, payload); const runAttempt = async (attempt: number): Promise => { const controller = new AbortController(); resultFeedbackSyncAbortControllersRef.current.set(syncKey, controller); try { await syncResultFeedback(payload, controller.signal); rememberCompletedResultFeedbackSyncKey(completedResultFeedbackSyncKeysRef.current, syncKey); removeQueuedResultFeedbackSync(syncKey); resultFeedbackSyncAbortControllersRef.current.delete(syncKey); scheduledResultFeedbackSyncPayloadsRef.current.delete(syncKey); scheduledResultFeedbackSyncKeysRef.current.delete(syncKey); } catch (syncError) { resultFeedbackSyncAbortControllersRef.current.delete(syncKey); if (controller.signal.aborted) return; upsertQueuedResultFeedbackSync(payload); if (attempt < resultFeedbackSyncMaxAttempts) { const delayMs = resultFeedbackSyncRetryDelaysMs[ Math.min(attempt - 1, resultFeedbackSyncRetryDelaysMs.length - 1) ]; console.warn('结果反馈同步到服务端失败,将重试。', { attempt, nextAttempt: attempt + 1, delayMs, syncError }); const timer = setTimeout(() => { resultFeedbackRetryTimersRef.current.delete(syncKey); void runAttempt(attempt + 1); }, delayMs); resultFeedbackRetryTimersRef.current.set(syncKey, timer); return; } scheduledResultFeedbackSyncPayloadsRef.current.delete(syncKey); scheduledResultFeedbackSyncKeysRef.current.delete(syncKey); console.error('结果反馈同步到服务端失败。', syncError); setError(createErrorNotice(t('error.resultFeedbackSync'))); } }; void runAttempt(1); }, [ clearOverlappingResultFeedbackSyncs, clearResultFeedbackDeletesForSync, createErrorNotice, readQueuedResultFeedbackDeletes, readQueuedResultFeedbackSyncs, removeQueuedResultFeedbackSync, syncResultFeedback, t, upsertQueuedResultFeedbackSync, writeQueuedResultFeedbackDeletes, writeQueuedResultFeedbackSyncs ] ); const scheduleResultFeedbackSync = React.useCallback( (input: FeedbackSyncInput) => { const payload = buildHistoryFeedbackSyncPayload(input); if (!payload) { console.warn('结果反馈仅写入本地历史:缺少可同步的 clientRequestId。', { timestamp: input.item.timestamp }); return; } scheduleResultFeedbackSyncPayload(payload, { pruneQueuedTargets: true }); }, [scheduleResultFeedbackSyncPayload] ); const deleteResultFeedbackPayload = React.useCallback( async (payload: HistoryFeedbackDeletePayload, signal: AbortSignal) => { for (let index = 0; index < payload.targets.length; index += feedbackApiTargetBatchSize) { const chunk = payload.targets.slice(index, index + feedbackApiTargetBatchSize); if (chunk.length === 0) continue; const requestSignal = createFeedbackRequestSignal(signal); try { const response = await fetch('/api/feedback', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, signal: requestSignal.signal, body: JSON.stringify({ targets: chunk, ...(payload.deletedAt !== undefined ? { deletedAt: new Date(payload.deletedAt).toISOString() } : {}) }) }); if (!response.ok) { const text = await response.text().catch(() => ''); throw new Error(text || `反馈删除同步失败,状态码 ${response.status}`); } } finally { requestSignal.cleanup(); } } }, [] ); const scheduleResultFeedbackDeletePayload = React.useCallback( (payload: HistoryFeedbackDeletePayload) => { if (payload.targets.length === 0) return; const deleteKey = payload.key; const scheduledPayload = scheduledResultFeedbackDeletePayloadsRef.current.get(deleteKey); if (scheduledPayload) { if (!shouldReplaceHistoryFeedbackDeletePayload(scheduledPayload, payload)) return; clearScheduledResultFeedbackDelete(deleteKey); } clearResultFeedbackSyncsForDelete(payload); writeQueuedResultFeedbackSyncs( removeHistoryFeedbackSyncQueueTargetsForDelete(readQueuedResultFeedbackSyncs(), payload) ); upsertQueuedResultFeedbackDeletes(payload); scheduledResultFeedbackDeleteKeysRef.current.add(deleteKey); scheduledResultFeedbackDeletePayloadsRef.current.set(deleteKey, payload); const retryTimerKey = `delete:${deleteKey}`; const runDeleteAttempt = (attempt: number) => { const controller = new AbortController(); resultFeedbackDeleteAbortControllersRef.current.set(retryTimerKey, controller); void deleteResultFeedbackPayload(payload, controller.signal) .then(() => { if (resultFeedbackDeleteAbortControllersRef.current.get(retryTimerKey) === controller) { resultFeedbackDeleteAbortControllersRef.current.delete(retryTimerKey); } if (scheduledResultFeedbackDeletePayloadsRef.current.get(deleteKey) !== payload) return; removeQueuedResultFeedbackDeletePayload(payload); scheduledResultFeedbackDeleteKeysRef.current.delete(deleteKey); scheduledResultFeedbackDeletePayloadsRef.current.delete(deleteKey); }) .catch((deleteError) => { if (resultFeedbackDeleteAbortControllersRef.current.get(retryTimerKey) === controller) { resultFeedbackDeleteAbortControllersRef.current.delete(retryTimerKey); } if (controller.signal.aborted) return; if (scheduledResultFeedbackDeletePayloadsRef.current.get(deleteKey) !== payload) return; if (attempt < resultFeedbackDeleteMaxAttempts) { const delayMs = resultFeedbackDeleteRetryDelaysMs[attempt - 1] ?? resultFeedbackDeleteRetryDelaysMs[resultFeedbackDeleteRetryDelaysMs.length - 1]; const retryTimer = setTimeout(() => { resultFeedbackRetryTimersRef.current.delete(retryTimerKey); runDeleteAttempt(attempt + 1); }, delayMs); resultFeedbackRetryTimersRef.current.set(retryTimerKey, retryTimer); console.warn('结果反馈从服务端状态删除失败,将重试。', { attempt, nextAttempt: attempt + 1, delayMs, deleteError }); return; } resultFeedbackRetryTimersRef.current.delete(retryTimerKey); scheduledResultFeedbackDeleteKeysRef.current.delete(deleteKey); scheduledResultFeedbackDeletePayloadsRef.current.delete(deleteKey); console.error('结果反馈从服务端状态删除失败。', deleteError); setError(createErrorNotice(t('error.resultFeedbackDeleteSync'))); }); }; runDeleteAttempt(1); }, [ createErrorNotice, clearScheduledResultFeedbackDelete, clearResultFeedbackSyncsForDelete, deleteResultFeedbackPayload, readQueuedResultFeedbackSyncs, removeQueuedResultFeedbackDeletePayload, t, upsertQueuedResultFeedbackDeletes, writeQueuedResultFeedbackSyncs ] ); const scheduleResultFeedbackDeleteTargets = React.useCallback( (targets: HistoryFeedbackTarget[]) => { const payload = buildHistoryFeedbackDeletePayload(targets, Date.now()); if (payload) scheduleResultFeedbackDeletePayload(payload); }, [scheduleResultFeedbackDeletePayload] ); React.useEffect(() => { scheduleResultFeedbackDeletePayloadRef.current = scheduleResultFeedbackDeletePayload; return () => { if (scheduleResultFeedbackDeletePayloadRef.current === scheduleResultFeedbackDeletePayload) { scheduleResultFeedbackDeletePayloadRef.current = null; } }; }, [scheduleResultFeedbackDeletePayload]); React.useEffect(() => { if (hasProcessedQueuedResultFeedbackDeletesRef.current) return; hasProcessedQueuedResultFeedbackDeletesRef.current = true; const queued = readQueuedResultFeedbackDeletes(); if (queued.length === 0) return; const pruned = pruneHistoryFeedbackDeleteQueueForSyncQueue(queued, readQueuedResultFeedbackSyncs()); if (pruned.length !== queued.length) { writeQueuedResultFeedbackDeletes(pruned); } if (pruned.length === 0) return; pruned.forEach(scheduleResultFeedbackDeletePayload); }, [ readQueuedResultFeedbackDeletes, readQueuedResultFeedbackSyncs, scheduleResultFeedbackDeletePayload, writeQueuedResultFeedbackDeletes ]); React.useEffect(() => { if (hasProcessedQueuedResultFeedbackSyncsRef.current) return; hasProcessedQueuedResultFeedbackSyncsRef.current = true; const queued = readQueuedResultFeedbackSyncs(); queued.forEach((payload) => scheduleResultFeedbackSyncPayload(payload)); }, [readQueuedResultFeedbackSyncs, scheduleResultFeedbackSyncPayload]); React.useEffect(() => { if (!hasLoadedStoredHistoryRef.current) return; buildHistoryFeedbackSyncInputs(history).forEach(scheduleResultFeedbackSync); }, [history, scheduleResultFeedbackSync]); React.useEffect(() => { const timers = resultFeedbackRetryTimersRef.current; const syncControllers = resultFeedbackSyncAbortControllersRef.current; const deleteControllers = resultFeedbackDeleteAbortControllersRef.current; const scheduledSyncKeys = scheduledResultFeedbackSyncKeysRef.current; const scheduledSyncPayloads = scheduledResultFeedbackSyncPayloadsRef.current; const scheduledDeleteKeys = scheduledResultFeedbackDeleteKeysRef.current; const scheduledDeletePayloads = scheduledResultFeedbackDeletePayloadsRef.current; return () => { timers.forEach((timer) => clearTimeout(timer)); timers.clear(); syncControllers.forEach((controller) => controller.abort()); syncControllers.clear(); deleteControllers.forEach((controller) => controller.abort()); deleteControllers.clear(); scheduledSyncKeys.clear(); scheduledSyncPayloads.clear(); scheduledDeleteKeys.clear(); scheduledDeletePayloads.clear(); }; }, []); const handleMarkResultFeedback = React.useCallback( (item: HistoryMetadata, value: ResultFeedbackValue) => { const updatedAt = Date.now(); const currentFeedback = history.find((historyItem) => historyItem.timestamp === item.timestamp)?.resultFeedback ?? item.resultFeedback; const currentLocalNote = currentFeedback?.note; setHistory((current) => updateHistoryResultFeedback({ history: current, timestamp: item.timestamp, value, updatedAt }) ); setActiveResultSource((current) => current?.timestamp === item.timestamp ? { ...current, resultFeedback: { value, updatedAt, ...(currentLocalNote ? { note: currentLocalNote } : {}) } } : current ); scheduleResultFeedbackSync({ item, value, updatedAt, ...(currentLocalNote ? { note: currentLocalNote } : {}) }); }, [history, scheduleResultFeedbackSync] ); const handleUpdateResultFeedbackNote = React.useCallback( (item: HistoryMetadata, note: string) => { if (!item.resultFeedback) return; const resultFeedbackValue = item.resultFeedback.value; const updatedAt = Date.now(); setHistory((current) => updateHistoryResultFeedback({ history: current, timestamp: item.timestamp, value: resultFeedbackValue, updatedAt, note }) ); setActiveResultSource((current) => current?.timestamp === item.timestamp ? { ...current, resultFeedback: { value: resultFeedbackValue, updatedAt, ...(note ? { note } : {}) } } : current ); scheduleResultFeedbackSync({ item, value: resultFeedbackValue, updatedAt, ...(note ? { note } : {}) }); }, [scheduleResultFeedbackSync] ); const handleDeleteInspiration = React.useCallback((id: number) => { setInspirations((current) => current.filter((item) => item.id !== id)); }, []); const handleClearHistory = React.useCallback(async () => { const confirmationMessage = effectiveStorageModeClient === 'indexeddb' ? t('confirm.clearHistoryIndexedDb') : t('confirm.clearHistoryFs'); if (window.confirm(confirmationMessage)) { const feedbackDeleteTargets = buildHistoryFeedbackDeleteTargets(history); setHistory([]); setLatestImageBatch(null); setActiveResultSource(null); setCompletedGenerationCount(null); setImageOutputView('grid'); setError(null); try { localStorage.removeItem('openaiImageHistory'); if (effectiveStorageModeClient === 'indexeddb') { await db.images.clear(); blobUrlCacheRef.current.forEach((url) => URL.revokeObjectURL(url)); blobUrlCacheRef.current.clear(); } scheduleResultFeedbackDeleteTargets(feedbackDeleteTargets); } catch (e) { console.error('清空历史记录失败:', e); setError(createErrorNotice(t('error.clearHistory'))); } } }, [createErrorNotice, history, scheduleResultFeedbackDeleteTargets, t]); const resolveImageBlob = React.useCallback( async (filename: string, storageMode: ImageStorageMode = effectiveStorageModeClient): Promise => { if (storageMode === 'indexeddb') { const record = allDbImages?.find((img) => img.filename === filename); if (!record?.blob) { throw new ApiRequestError(t('error.imageNotFoundDb', { filename })); } return record.blob; } if (!(await refreshImageAccessCookie())) { throw new ApiRequestError(t('error.imageAccessRefreshFailed')); } let response: Response; try { response = await fetch(`/api/image/${filename}`); } catch { throw new ApiRequestError(t('error.retrieveImage', { filename })); } if (!response.ok) { throw new ApiRequestError(t('error.fetchImage', { status: response.status })); } try { return await response.blob(); } catch { throw new ApiRequestError(t('error.retrieveImage', { filename })); } }, [allDbImages, refreshImageAccessCookie, t] ); const handleDownloadImage = React.useCallback( async (filename: string, storageMode?: HistoryMetadata['storageModeUsed']) => { try { const blob = await resolveImageBlob(filename, storageMode); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 150); } catch (error) { setError( createErrorNotice( error instanceof ApiRequestError ? error.message : t('error.retrieveImage', { filename }) ) ); } }, [createErrorNotice, resolveImageBlob, t] ); const handleDownloadHistoryItem = React.useCallback( async (item: HistoryMetadata) => { try { const storageMode = item.storageModeUsed ?? 'fs'; const files = await Promise.all( item.images.map(async (image, index) => ({ name: `${String(index + 1).padStart(2, '0')}-${image.filename}`, blob: await resolveImageBlob(image.filename, storageMode), modifiedAt: new Date(item.timestamp) })) ); const zipBlob = await createZipBlob(files); const url = URL.createObjectURL(zipBlob); const link = document.createElement('a'); link.href = url; link.download = `visual-journal-history-${item.timestamp}.zip`; document.body.appendChild(link); link.click(); link.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 150); } catch (error) { setError( createErrorNotice(error instanceof ApiRequestError ? error.message : t('error.downloadHistory')) ); } }, [createErrorNotice, resolveImageBlob, t] ); const handleOpenShareImage = React.useCallback( (filename: string, storageMode?: HistoryMetadata['storageModeUsed']) => { setShareTargetFilename(filename); setShareTargetStorageMode(storageMode); setShareUrl(null); setShareError(null); setShareDialogSessionId((current) => current + 1); setShareDialogOpen(true); }, [] ); const handleCreateShare = React.useCallback( async (values: ShareDialogValues) => { if (!shareTargetFilename) return; setIsCreatingShare(true); setShareError(null); try { const blob = await resolveImageBlob(shareTargetFilename, shareTargetStorageMode); const result = await createImageShareFromBlob({ filename: shareTargetFilename, blob, values, accessRefreshErrorMessage: t('error.imageAccessRefreshFailed'), createFailedMessage: t('share.createFailed'), refreshImageAccessCookie, pageUrl: window.location.href }); setShareUrl(result.url); } catch (error) { setShareError(error instanceof Error ? error.message : t('share.createFailed')); } finally { setIsCreatingShare(false); } }, [refreshImageAccessCookie, resolveImageBlob, shareTargetFilename, shareTargetStorageMode, t] ); const handleSendToEdit = async ( filename: string, storageMode: HistoryMetadata['storageModeUsed'] = effectiveStorageModeClient ): Promise => { if (isLoading || sendingToEditRef.current) return false; sendingToEditRef.current = true; const sourceStorageMode = storageMode || 'fs'; const alreadyExists = editImageFiles.some((file) => file.name === filename); if (mode === 'edit' && alreadyExists) { sendingToEditRef.current = false; setIsSendingToEdit(false); return true; } if ( mode === 'edit' && hasReachedEditSourceImageLimit({ currentCount: editImageFiles.length, maxImages: maxEditSourceImages }) ) { setError(createErrorNotice(t('error.maxEditImages', { count: maxEditSourceImages }))); sendingToEditRef.current = false; setIsSendingToEdit(false); return false; } setCompletedGenerationCount(null); setStreamingPreviewImages(new Map()); setBatchProgress(null); setIsSendingToEdit(true); setError(null); try { let blob: Blob | undefined; let mimeType: string = 'image/png'; if (sourceStorageMode === 'indexeddb') { const record = allDbImages?.find((img) => img.filename === filename); if (record?.blob) { blob = record.blob; mimeType = blob.type || mimeType; } else { throw new ApiRequestError(t('error.imageNotFoundDb', { filename })); } } else { if (!(await refreshImageAccessCookie())) { return false; } let response: Response; try { response = await fetch(`/api/image/${filename}`); } catch { throw new ApiRequestError(t('error.sendToEdit')); } if (response.status === 401 && isPasswordRequiredByBackend) { let result: { code?: string } = {}; try { result = (await response.json()) as { code?: string }; } catch { result = {}; } if (isPagePasswordAuthErrorCode(result.code)) { promptForExpiredPassword(); } else { setError(createErrorNotice(t('error.authVerifyUnavailable'))); } return false; } if (!response.ok) { throw new ApiRequestError(t('error.fetchImage', { status: response.status })); } try { blob = await response.blob(); } catch { throw new ApiRequestError(t('error.sendToEdit')); } mimeType = response.headers.get('Content-Type') || mimeType; } if (!blob) { throw new ApiRequestError(t('error.retrieveImage', { filename })); } const newFile = new File([blob], filename, { type: mimeType }); const newPreviewUrl = URL.createObjectURL(blob); setEditImageFiles([newFile]); updateEditSourceImagePreviewUrls([newPreviewUrl]); setEditReuseContext(null); if (mode === 'generate') { setMode('edit'); setWorkbenchMode('edit'); } return true; } catch (err: unknown) { console.error('发送图片到编辑模式失败:', err); const errorMessage = err instanceof ApiRequestError ? err.message : t('error.sendToEdit'); setError(createErrorNotice(errorMessage)); return false; } finally { sendingToEditRef.current = false; setIsSendingToEdit(false); } }; const handleSendHistoryToEdit = async (item: HistoryMetadata) => { const firstImage = item.images[0]; if (!firstImage) { setError(createErrorNotice(t('error.historyMissingImage'))); return; } const sent = await handleSendToEdit(firstImage.filename, item.storageModeUsed || 'fs'); if (!sent) return; const nextModel = item.model ?? editModel; const normalizedRouteFields = normalizeImageUpstreamRuntimeFields( { image_backend: item.image_backend ?? editImageBackend, streaming_strategy: item.streaming_strategy ?? editStreamingStrategy, responsesModel: item.responsesModel ?? editResponsesModel, thinking: item.thinking ?? editThinking, promptOptimization: item.promptOptimization ?? editPromptOptimization }, { allowResponsesImageBackend: allowResponsesHistoryRoute } ); const sizeSelection = readHistorySizeSelection(item, nextModel); const restoredFields = [t('reuse.fieldReferenceImage'), t('reuse.fieldPrompt')]; setEditPrompt(item.prompt); setEditModel(nextModel); setEditSize(sizeSelection.size); if (typeof sizeSelection.customWidth === 'number') { setEditCustomWidth(sizeSelection.customWidth); } if (typeof sizeSelection.customHeight === 'number') { setEditCustomHeight(sizeSelection.customHeight); } setEditQuality(item.quality); setEditModeration(item.moderation); setEnableParallelBatch(item.enableParallelBatch === true); setEditImageBackend(normalizedRouteFields.image_backend); setEditStreamingStrategy(normalizedRouteFields.streaming_strategy); setEditResponsesModel(normalizedRouteFields.responsesModel); setEditThinking(normalizedRouteFields.thinking); setEditPromptOptimization(normalizedRouteFields.promptOptimization); setEditForceWeb(item.forceWeb === true); const imageCount = readHistoryImageCountSelection(item.images.length); if (imageCount !== null) { setEditN([imageCount]); restoredFields.push(t('reuse.fieldCount')); } restoredFields.push( t('reuse.fieldModel'), t('reuse.fieldQuality'), t('reuse.fieldModeration'), t('reuse.fieldRoute') ); if (sizeSelection.restored) { restoredFields.push(t('reuse.fieldSize')); } if (item.output_format) { setEditOutputFormat(item.output_format); restoredFields.push(t('reuse.fieldFormat')); } setEditReuseContext({ sourceLabel: t('reuse.sourceHistory', { time: new Date(item.timestamp).toLocaleString(locale) }), restoredFields: Array.from(new Set(restoredFields)), promptPreview: item.prompt.trim() || t('history.noPrompt') }); setMode('edit'); setWorkbenchMode('edit'); }; const executeDeleteItem = React.useCallback( async (item: HistoryMetadata) => { if (!item) return; setError(null); const { images: imagesInEntry, timestamp } = item; const storageModeUsed = item.storageModeUsed ?? 'fs'; const filenamesToDelete = imagesInEntry.map((img) => img.filename); try { if (storageModeUsed === 'indexeddb') { await db.images.where('filename').anyOf(filenamesToDelete).delete(); filenamesToDelete.forEach((fn) => { const url = blobUrlCacheRef.current.get(fn); if (url) URL.revokeObjectURL(url); blobUrlCacheRef.current.delete(fn); }); } else if (storageModeUsed === 'fs') { const apiPayload: { filenames: string[]; passwordHash?: string } = { filenames: filenamesToDelete }; if (isPasswordRequiredByBackend && clientPasswordHash) { apiPayload.passwordHash = clientPasswordHash; } let response: Response; try { response = await fetch('/api/image-delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(apiPayload) }); } catch { throw new ApiRequestError(t('error.deleteUnexpected')); } let result: unknown; try { result = await response.json(); } catch { throw new ApiRequestError(t('error.deleteUnexpected')); } if (!response.ok) { throw new ApiRequestError( response.status === 401 ? t('error.unauthorized') : t('error.deleteUnexpected') ); } let deletionResults: ReturnType; try { deletionResults = readWebuiImageFileOperationResults(result); } catch { throw new ApiRequestError(t('error.deleteUnexpected')); } setLoadedWebuiImageRetentionState((current) => current?.scopeKey === retentionScopeKey ? { ...current, filenames: mergeWebuiImageRetentionResults( current.filenames, 'release', deletionResults ) } : current ); const unavailableFilenames = new Set( deletionResults .filter( (resultItem) => resultItem.success || resultItem.fileDeleted === true || resultItem.fileAbsent === true ) .map((resultItem) => resultItem.filename) ); const remainingImages = imagesInEntry.filter((image) => !unavailableFilenames.has(image.filename)); if (remainingImages.length !== imagesInEntry.length) { setHistory((prevHistory) => remainingImages.length === 0 ? prevHistory.filter((historyItem) => historyItem.timestamp !== timestamp) : prevHistory.map((historyItem) => historyItem.timestamp === timestamp ? { ...historyItem, images: remainingImages } : historyItem ) ); setLatestImageBatch((prev) => { if (!prev) return prev; const remainingBatch = prev.filter((image) => !unavailableFilenames.has(image.filename)); return remainingBatch.length > 0 ? remainingBatch : null; }); setActiveResultSource((current) => current?.timestamp !== timestamp ? current : remainingImages.length === 0 ? null : { ...current, images: remainingImages } ); if (remainingImages.length === 0) { scheduleResultFeedbackDeleteTargets(buildHistoryFeedbackDeleteTargets([item])); } } if (deletionResults.some((resultItem) => !resultItem.success)) { setError(createErrorNotice(t('error.deletePartial'))); } return; } setHistory((prevHistory) => prevHistory.filter((h) => h.timestamp !== timestamp)); setLatestImageBatch((prev) => prev && prev.some((img) => filenamesToDelete.includes(img.filename)) ? null : prev ); setActiveResultSource((current) => (current?.timestamp === timestamp ? null : current)); scheduleResultFeedbackDeleteTargets(buildHistoryFeedbackDeleteTargets([item])); } catch (e: unknown) { console.error('删除条目失败:', e); setError(createErrorNotice(e instanceof ApiRequestError ? e.message : t('error.deleteUnexpected'))); } finally { setItemToDeleteConfirm(null); } }, [ clientPasswordHash, createErrorNotice, isPasswordRequiredByBackend, retentionScopeKey, scheduleResultFeedbackDeleteTargets, t ] ); const handleRequestDeleteItem = React.useCallback( (item: HistoryMetadata) => { if (!skipDeleteConfirmation) { setDialogCheckboxStateSkipConfirm(skipDeleteConfirmation); setItemToDeleteConfirm(item); } else { executeDeleteItem(item); } }, [skipDeleteConfirmation, executeDeleteItem] ); const handleConfirmDeletion = React.useCallback(() => { if (itemToDeleteConfirm) { executeDeleteItem(itemToDeleteConfirm); setSkipDeleteConfirmation(dialogCheckboxStateSkipConfirm); } }, [itemToDeleteConfirm, executeDeleteItem, dialogCheckboxStateSkipConfirm]); const handleCancelDeletion = React.useCallback(() => { setItemToDeleteConfirm(null); }, []); const showEntryLock = isPasswordRequiredByBackend === true && !isEntryAuthenticated; const getApiSettingsDialogFocusTarget = React.useCallback(() => { const trigger = apiSettingsDialogTriggerRef.current; if (trigger && trigger.getClientRects().length > 0 && !trigger.disabled) return trigger; if (typeof document === 'undefined') return null; return ( Array.from(document.querySelectorAll('[data-api-settings-trigger]')).find( (candidate) => candidate.getClientRects().length > 0 && !candidate.disabled ) ?? null ); }, []); const openApiSettingsDialog = (event: React.MouseEvent) => { apiSettingsDialogTriggerRef.current = event.currentTarget; setIsApiSettingsDialogOpen(true); }; const outputFailureMessage = !isLoading && !isSendingToEdit && !latestImageBatch && error?.message === generationFailureMessage ? generationFailureMessage : null; const shouldExpandOutputStage = isLoading || isSendingToEdit || Boolean(latestImageBatch) || Boolean(outputFailureMessage) || (mode === 'edit' && Boolean(editSourceImagePreviewUrls[0])); const canRetryLastGeneration = Boolean(lastApiCallArgs) && !isLoading && !isSendingToEdit; function handleRetryLastGeneration() { if (!lastApiCallArgs) return; void handleApiCall(...lastApiCallArgs, clientPasswordHash); } return (
{isMobileCreationDrawerOpen ? (

{announcedGenerationActivity ? `${announcedGenerationActivity.label} ${announcedGenerationActivity.detail}` : ''}

) : null} {showEntryLock ? (

{t('password.required')}

{t('password.entryDescription')}

{error && ( {t('common.error')} {renderErrorDescription(error)} )}
) : null} {!showEntryLock && isPasswordRequiredByBackend !== null ? ( <> {t('app.skipToMainContent')} {isMobileCreationDrawerOpen && (
); }