Spaces:
Running
Running
| '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<string> = 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<HTMLElement>(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<string>, 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<ApiSettings>): 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<ApiSettings>; | |
| 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<HistoryMetadata['storageModeUsed']>; | |
| 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<string>; | |
| }; | |
| 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<typeof useI18n>['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<typeof useI18n>['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 ( | |
| <div className='grid gap-2'> | |
| <p>{error.message}</p> | |
| {error.links.map((link) => ( | |
| <a | |
| className='border-destructive/45 text-destructive hover:bg-destructive/10 w-fit rounded-md border px-2 py-1 font-medium underline-offset-2 hover:underline' | |
| href={link.url} | |
| key={link.url} | |
| rel='noreferrer' | |
| target='_blank'> | |
| {link.label} | |
| </a> | |
| ))} | |
| </div> | |
| ); | |
| } | |
| 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>): 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<WorkbenchMode>('generate'); | |
| const [reuseContext, setReuseContext] = React.useState<WorkbenchReuseContext | null>(null); | |
| const [editReuseContext, setEditReuseContext] = React.useState<EditingReuseContext | null>(null); | |
| const [isPasswordRequiredByBackend, setIsPasswordRequiredByBackend] = React.useState<boolean | null>(null); | |
| const [clientPasswordHash, setClientPasswordHash] = React.useState<string | null>(null); | |
| const [isEntryAuthenticated, setIsEntryAuthenticated] = React.useState(false); | |
| const [isLoading, setIsLoading] = React.useState(false); | |
| const [isSendingToEdit, setIsSendingToEdit] = React.useState(false); | |
| const [error, setError] = React.useState<ApiErrorNotice | null>(null); | |
| const [generationFailureMessage, setGenerationFailureMessage] = React.useState<string | null>(null); | |
| const [failedBatchPrompts, setFailedBatchPrompts] = React.useState<string[]>([]); | |
| const [latestImageBatch, setLatestImageBatch] = React.useState<ApiImageResult[] | null>(null); | |
| const [activeResultSource, setActiveResultSource] = React.useState<HistoryMetadata | null>(null); | |
| const [completedGenerationCount, setCompletedGenerationCount] = React.useState<number | null>(null); | |
| const [imageOutputView, setImageOutputView] = React.useState<'grid' | number>('grid'); | |
| const [history, setHistory] = React.useState<HistoryMetadata[]>([]); | |
| const [inspirations, setInspirations] = React.useState<InspirationItem[]>([]); | |
| const hasLoadedStoredHistoryRef = React.useRef(false); | |
| const hasProcessedQueuedResultFeedbackDeletesRef = React.useRef(false); | |
| const hasProcessedQueuedResultFeedbackSyncsRef = React.useRef(false); | |
| const scheduledResultFeedbackSyncKeysRef = React.useRef<Set<string>>(new Set()); | |
| const scheduledResultFeedbackSyncPayloadsRef = React.useRef<Map<string, FeedbackSyncPayload>>(new Map()); | |
| const completedResultFeedbackSyncKeysRef = React.useRef<Set<string>>(new Set()); | |
| const scheduledResultFeedbackDeleteKeysRef = React.useRef<Set<string>>(new Set()); | |
| const scheduledResultFeedbackDeletePayloadsRef = React.useRef<Map<string, HistoryFeedbackDeletePayload>>(new Map()); | |
| const scheduleResultFeedbackDeletePayloadRef = React.useRef< | |
| ((payload: HistoryFeedbackDeletePayload) => void) | null | |
| >(null); | |
| const resultFeedbackRetryTimersRef = React.useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map()); | |
| const resultFeedbackSyncAbortControllersRef = React.useRef<Map<string, AbortController>>(new Map()); | |
| const resultFeedbackDeleteAbortControllersRef = React.useRef<Map<string, AbortController>>(new Map()); | |
| const blobUrlCacheRef = React.useRef<Map<string, string>>(new Map()); | |
| const [isPasswordDialogOpen, setIsPasswordDialogOpen] = React.useState(false); | |
| const [isApiSettingsDialogOpen, setIsApiSettingsDialogOpen] = React.useState(false); | |
| const [apiSettings, setApiSettings] = React.useState<ApiSettings>(emptyApiSettings); | |
| const [runtimeCapabilities, setRuntimeCapabilities] = React.useState<RuntimeCapabilities | null>(null); | |
| const [isRuntimeCapabilitiesLoading, setIsRuntimeCapabilitiesLoading] = React.useState(true); | |
| const [loadedWebuiImageRetentionState, setLoadedWebuiImageRetentionState] = | |
| React.useState<LoadedWebuiImageRetentionState | null>(null); | |
| const [passwordDialogContext, setPasswordDialogContext] = React.useState<'initial' | 'retry'>('initial'); | |
| const [lastApiCallArgs, setLastApiCallArgs] = React.useState<ApiCallRetryArgs | null>(null); | |
| const [skipDeleteConfirmation, setSkipDeleteConfirmation] = React.useState<boolean>(false); | |
| const [itemToDeleteConfirm, setItemToDeleteConfirm] = React.useState<HistoryMetadata | null>(null); | |
| const [dialogCheckboxStateSkipConfirm, setDialogCheckboxStateSkipConfirm] = React.useState<boolean>(false); | |
| const [openLogsSignal, setOpenLogsSignal] = React.useState(0); | |
| const [shareDialogOpen, setShareDialogOpen] = React.useState(false); | |
| const [shareTargetFilename, setShareTargetFilename] = React.useState<string | null>(null); | |
| const [shareTargetStorageMode, setShareTargetStorageMode] = React.useState<HistoryMetadata['storageModeUsed']>(); | |
| const [shareUrl, setShareUrl] = React.useState<string | null>(null); | |
| const [shareError, setShareError] = React.useState<string | null>(null); | |
| const [isCreatingShare, setIsCreatingShare] = React.useState(false); | |
| const [shareDialogSessionId, setShareDialogSessionId] = React.useState(0); | |
| const [isMobileCreationDrawerOpen, setIsMobileCreationDrawerOpen] = React.useState(false); | |
| const outputPanelRef = React.useRef<HTMLDivElement | null>(null); | |
| const mobileCreationDrawerCloseButtonRef = React.useRef<HTMLButtonElement | null>(null); | |
| const mobileCreationDrawerTriggerRef = React.useRef<HTMLButtonElement | null>(null); | |
| const mobileCreationDrawerFocusTargetRef = React.useRef<'trigger' | 'output' | null>(null); | |
| const pendingWorkbenchModeFocusRef = React.useRef<WorkbenchMode | null>(null); | |
| const apiSettingsDialogTriggerRef = React.useRef<HTMLButtonElement | null>(null); | |
| const sendingToEditRef = React.useRef(false); | |
| const mobileDrawerPointerStartRef = React.useRef<MobileDrawerPointerStart | null>(null); | |
| const mobileDrawerGestureHandledAtRef = React.useRef(0); | |
| const allDbImages = useLiveQuery<ImageRecord[] | undefined>(() => db.images.toArray(), []); | |
| const [editImageFiles, setEditImageFiles] = React.useState<File[]>([]); | |
| const [editSourceImagePreviewUrls, setEditSourceImagePreviewUrlsState] = React.useState<string[]>([]); | |
| const editSourceImagePreviewUrlsRef = React.useRef<string[]>([]); | |
| 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<EditingFormData['size']>('auto'); | |
| const [editCustomWidth, setEditCustomWidth] = React.useState<number>(1024); | |
| const [editCustomHeight, setEditCustomHeight] = React.useState<number>(1024); | |
| const [editQuality, setEditQuality] = React.useState<EditingFormData['quality']>('auto'); | |
| const [editOutputFormat, setEditOutputFormat] = React.useState<EditingFormData['output_format']>('webp'); | |
| const [editCompression, setEditCompression] = React.useState([100]); | |
| const [editModeration, setEditModeration] = React.useState<EditingFormData['moderation']>('auto'); | |
| const [editBrushSize, setEditBrushSize] = React.useState([20]); | |
| const [editShowMaskEditor, setEditShowMaskEditor] = React.useState(false); | |
| const [editGeneratedMaskFile, setEditGeneratedMaskFile] = React.useState<File | null>(null); | |
| const [editIsMaskSaved, setEditIsMaskSaved] = React.useState(false); | |
| const [editOriginalImageSize, setEditOriginalImageSize] = React.useState<{ width: number; height: number } | null>( | |
| null | |
| ); | |
| const [editDrawnPoints, setEditDrawnPoints] = React.useState<DrawnPoint[]>([]); | |
| const [editMaskPreviewUrl, setEditMaskPreviewUrl] = React.useState<string | null>(null); | |
| const [editImageBackend, setEditImageBackend] = React.useState<EditingFormData['image_backend']>( | |
| IMAGE_UPSTREAM_FORM_SERVER_DEFAULT | |
| ); | |
| const [editStreamingStrategy, setEditStreamingStrategy] = React.useState<EditingFormData['streaming_strategy']>( | |
| IMAGE_UPSTREAM_FORM_SERVER_DEFAULT | |
| ); | |
| const [editResponsesModel, setEditResponsesModel] = React.useState(''); | |
| const [editThinking, setEditThinking] = React.useState<EditingFormData['thinking']>( | |
| IMAGE_UPSTREAM_FORM_SERVER_DEFAULT | |
| ); | |
| const [editPromptOptimization, setEditPromptOptimization] = React.useState<EditingFormData['promptOptimization']>( | |
| IMAGE_UPSTREAM_FORM_SERVER_DEFAULT | |
| ); | |
| const [editForceWeb, setEditForceWeb] = React.useState(false); | |
| const [genModel, setGenModel] = React.useState<GenerationFormData['model']>('gpt-image-2'); | |
| const [genPrompt, setGenPrompt] = React.useState(''); | |
| const [genBatchPromptText, setGenBatchPromptText] = React.useState(''); | |
| const [genN, setGenN] = React.useState([1]); | |
| const [genSize, setGenSize] = React.useState<GenerationFormData['size']>('auto'); | |
| const [genCustomWidth, setGenCustomWidth] = React.useState<number>(1024); | |
| const [genCustomHeight, setGenCustomHeight] = React.useState<number>(1024); | |
| const [genQuality, setGenQuality] = React.useState<GenerationFormData['quality']>('high'); | |
| const [genOutputFormat, setGenOutputFormat] = React.useState<GenerationFormData['output_format']>('webp'); | |
| const [genCompression, setGenCompression] = React.useState([100]); | |
| const [genBackground, setGenBackground] = React.useState<GenerationFormData['background']>('auto'); | |
| const [genModeration, setGenModeration] = React.useState<GenerationFormData['moderation']>('auto'); | |
| const [genImageBackend, setGenImageBackend] = React.useState<GenerationFormData['image_backend']>( | |
| IMAGE_UPSTREAM_FORM_SERVER_DEFAULT | |
| ); | |
| const [genStreamingStrategy, setGenStreamingStrategy] = React.useState<GenerationFormData['streaming_strategy']>( | |
| IMAGE_UPSTREAM_FORM_SERVER_DEFAULT | |
| ); | |
| const [genResponsesModel, setGenResponsesModel] = React.useState(''); | |
| const [genThinking, setGenThinking] = React.useState<GenerationFormData['thinking']>( | |
| IMAGE_UPSTREAM_FORM_SERVER_DEFAULT | |
| ); | |
| const [genPromptOptimization, setGenPromptOptimization] = React.useState<GenerationFormData['promptOptimization']>( | |
| IMAGE_UPSTREAM_FORM_SERVER_DEFAULT | |
| ); | |
| const [genForceWeb, setGenForceWeb] = React.useState(false); | |
| const [editModel, setEditModel] = React.useState<EditingFormData['model']>('gpt-image-2'); | |
| // 流式状态,由生成和编辑模式共用。 | |
| const [streamMode, setStreamMode] = React.useState<ImageStreamMode>('auto'); | |
| const [partialImages, setPartialImages] = React.useState<PartialImagesCount>(1); | |
| const [enableParallelBatch, setEnableParallelBatch] = React.useState(false); | |
| const [activeRequestStreaming, setActiveRequestStreaming] = React.useState(false); | |
| // 流式预览图,存储流式过程中的局部图片 base64 data URL。 | |
| const [streamingPreviewImages, setStreamingPreviewImages] = React.useState<Map<number, string>>(new Map()); | |
| const [batchProgress, setBatchProgress] = React.useState<GenerationBatchProgress | null>(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<string>) => { | |
| 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<HTMLButtonElement>('[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<HTMLElement>) => { | |
| 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<HTMLElement>) => { | |
| 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<HTMLElement>) => { | |
| 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<HTMLButtonElement>('[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<PasswordVerificationResult> => { | |
| 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<boolean> => { | |
| 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<RuntimeCapabilities | null> => { | |
| 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<typeof readWebuiImageRetentionFilenames>; | |
| 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<typeof readWebuiImageFileOperationResults>; | |
| 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<ApiImageResult[]> => { | |
| 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<Uint8Array>; | |
| 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<void> => { | |
| 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<Blob> => { | |
| 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<boolean> => { | |
| 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<typeof readWebuiImageFileOperationResults>; | |
| 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<HTMLButtonElement>('[data-api-settings-trigger]')).find( | |
| (candidate) => candidate.getClientRects().length > 0 && !candidate.disabled | |
| ) ?? null | |
| ); | |
| }, []); | |
| const openApiSettingsDialog = (event: React.MouseEvent<HTMLButtonElement>) => { | |
| 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 ( | |
| <main className='studio-paper text-foreground min-h-screen overflow-x-hidden pb-[calc(var(--mobile-action-dock-height)+env(safe-area-inset-bottom))] [--mobile-action-dock-height:8.5rem] xl:h-dvh xl:pb-0'> | |
| <PasswordDialog | |
| isOpen={isPasswordDialogOpen} | |
| onOpenChange={setIsPasswordDialogOpen} | |
| onSave={handleSavePassword} | |
| title={passwordDialogContext === 'retry' ? t('password.required') : t('password.configure')} | |
| description={ | |
| passwordDialogContext === 'retry' | |
| ? t('password.retryDescription') | |
| : t('password.initialDescription') | |
| } | |
| /> | |
| <ApiSettingsDialog | |
| isOpen={isApiSettingsDialogOpen} | |
| onOpenChange={setIsApiSettingsDialogOpen} | |
| settings={apiSettings} | |
| onSave={handleSaveApiSettings} | |
| getReturnFocusTarget={getApiSettingsDialogFocusTarget} | |
| /> | |
| <ShareDialog | |
| key={shareDialogSessionId} | |
| open={shareDialogOpen} | |
| onOpenChange={setShareDialogOpen} | |
| isCreating={isCreatingShare} | |
| shareUrl={shareUrl} | |
| error={shareError} | |
| onCreate={handleCreateShare} | |
| /> | |
| {isMobileCreationDrawerOpen ? ( | |
| <p | |
| data-mobile-generation-activity-announcer | |
| role='status' | |
| aria-live='polite' | |
| aria-atomic='true' | |
| className='sr-only'> | |
| {announcedGenerationActivity | |
| ? `${announcedGenerationActivity.label} ${announcedGenerationActivity.detail}` | |
| : ''} | |
| </p> | |
| ) : null} | |
| {showEntryLock ? ( | |
| <div className='mx-auto flex min-h-screen w-full max-w-md flex-col items-center justify-center gap-6 px-4 text-center'> | |
| <div className='absolute top-3 right-4'> | |
| <LanguageSelector /> | |
| </div> | |
| <div className='bg-primary text-primary-foreground border-primary/20 flex size-14 items-center justify-center rounded-full border shadow-sm'> | |
| <Lock className='h-6 w-6' /> | |
| </div> | |
| <div className='space-y-2'> | |
| <h1 className='text-2xl font-semibold'>{t('password.required')}</h1> | |
| <p className='text-muted-foreground text-sm'>{t('password.entryDescription')}</p> | |
| </div> | |
| {error && ( | |
| <Alert | |
| variant='destructive' | |
| className='border-destructive/45 bg-destructive/10 text-destructive text-left'> | |
| <AlertTitle>{t('common.error')}</AlertTitle> | |
| <AlertDescription>{renderErrorDescription(error)}</AlertDescription> | |
| </Alert> | |
| )} | |
| <Button | |
| type='button' | |
| onClick={() => { | |
| setError(null); | |
| setPasswordDialogContext('initial'); | |
| setIsPasswordDialogOpen(true); | |
| }} | |
| className='px-6'> | |
| {t('password.unlock')} | |
| </Button> | |
| </div> | |
| ) : null} | |
| {!showEntryLock && isPasswordRequiredByBackend !== null ? ( | |
| <> | |
| <a | |
| href='#workbench-content' | |
| className='bg-background text-foreground focus-visible:ring-ring sr-only z-[70] rounded-md px-4 py-2 shadow-lg focus:not-sr-only focus:fixed focus:top-3 focus:left-3 focus-visible:ring-2 focus-visible:outline-none'> | |
| {t('app.skipToMainContent')} | |
| </a> | |
| {isMobileCreationDrawerOpen && ( | |
| <div | |
| aria-hidden='true' | |
| className='bg-foreground/25 fixed inset-0 z-40 lg:hidden' | |
| onClick={closeMobileCreationDrawer} | |
| /> | |
| )} | |
| <div className='mx-auto flex min-h-screen w-full max-w-[1880px] min-w-0 flex-col px-4 py-3 lg:px-6 lg:py-5 xl:h-full xl:min-h-0'> | |
| <header | |
| aria-hidden={isMobileCreationDrawerOpen} | |
| inert={isMobileCreationDrawerOpen} | |
| className='border-border/80 mb-4 flex shrink-0 flex-col gap-3 border-b pb-4 xl:flex-row xl:items-center xl:justify-between'> | |
| <div className='flex min-w-0 items-center gap-3'> | |
| <div className='flex min-w-0 flex-1 flex-wrap items-baseline gap-x-3 gap-y-1'> | |
| <h1 className='editorial-title shrink-0 text-2xl font-semibold tracking-normal sm:text-3xl'> | |
| {t('app.studioTitle')} | |
| </h1> | |
| <p className='text-muted-foreground min-w-0 text-sm'>{t('app.studioSubtitle')}</p> | |
| </div> | |
| <Button | |
| type='button' | |
| variant='outline' | |
| size='icon' | |
| onClick={openApiSettingsDialog} | |
| className='bg-card/80 ml-auto h-11 w-11 shrink-0 shadow-sm sm:hidden' | |
| data-api-settings-trigger | |
| aria-label={t('app.apiSettings')}> | |
| <Settings2 className='h-4 w-4' /> | |
| </Button> | |
| </div> | |
| <div className='flex min-w-0 flex-wrap items-center gap-2 sm:justify-end sm:gap-4'> | |
| <WorkbenchStatusStrip | |
| model={activeWorkbenchModel} | |
| routeLabel={activeRouteLabel} | |
| streamStatus={getStreamingStatusLabel(streamMode, t)} | |
| parallelBatchEnabled={activeParallelBatchVisible} | |
| requestSummaryLabel={activeRequestSummaryLabel} | |
| runtimeHealthStatus={activeRuntimeHealthStatus} | |
| channelRouting={runtimeCapabilities?.channelRouting ?? null} | |
| runtimeLastFailure={runtimeCapabilities?.streamingBatch.lastFailure ?? null} | |
| /> | |
| <LanguageSelector /> | |
| <div className='text-muted-foreground hidden items-center sm:flex'> | |
| <button | |
| type='button' | |
| onClick={openApiSettingsDialog} | |
| aria-label={t('app.apiSettings')} | |
| data-api-settings-trigger | |
| className='hover:bg-accent hover:text-foreground focus-visible:ring-ring flex h-11 w-11 items-center justify-center rounded-md transition-[background-color,color,box-shadow] focus-visible:ring-2 focus-visible:outline-none active:scale-[0.98] lg:h-9 lg:w-9'> | |
| <Settings2 className='h-4 w-4' /> | |
| </button> | |
| </div> | |
| </div> | |
| </header> | |
| <div | |
| id='workbench-content' | |
| tabIndex={-1} | |
| className='workbench-layout grid flex-1 grid-cols-1 gap-4 focus:outline-none lg:grid-cols-[minmax(360px,420px)_minmax(0,1fr)]'> | |
| <section | |
| id='mobile-creation-sheet' | |
| aria-label={t('app.creationControls')} | |
| role={isMobileCreationDrawerOpen ? 'dialog' : undefined} | |
| aria-modal={isMobileCreationDrawerOpen ? true : undefined} | |
| onKeyDown={handleMobileCreationDrawerKeyDown} | |
| className={`order-2 lg:static lg:order-1 lg:block lg:overflow-visible lg:p-0 lg:shadow-none xl:min-h-0 xl:overflow-hidden ${ | |
| isMobileCreationDrawerOpen | |
| ? 'border-border bg-card fixed inset-x-0 top-4 bottom-0 z-50 flex min-h-0 flex-col overflow-hidden rounded-t-lg border-t px-3 pt-3 pb-[calc(1rem+env(safe-area-inset-bottom))] shadow-[0_-16px_36px_rgba(15,23,42,0.16)] lg:max-h-none lg:rounded-none' | |
| : 'hidden min-h-[620px]' | |
| }`}> | |
| {isMobileCreationDrawerOpen && ( | |
| <div className='mobile-creation-sheet-handle z-10 mb-2 flex shrink-0 items-center justify-center lg:hidden'> | |
| <button | |
| type='button' | |
| className='flex h-11 w-24 touch-none items-center justify-center rounded-full select-none' | |
| onPointerDown={beginMobileCreationDrawerGesture} | |
| onPointerUp={finishMobileCreationDrawerGesture} | |
| onPointerCancel={cancelMobileCreationDrawerGesture} | |
| onClick={handleMobileCreationDrawerHandleClick} | |
| aria-label={t('ux.closeCreationSheet')} | |
| aria-controls='mobile-creation-sheet' | |
| aria-expanded={true}> | |
| <span className='bg-border h-1 w-10 rounded-full' aria-hidden='true' /> | |
| </button> | |
| <Button | |
| ref={mobileCreationDrawerCloseButtonRef} | |
| type='button' | |
| variant='outline' | |
| size='icon' | |
| onClick={closeMobileCreationDrawer} | |
| className='bg-card/95 absolute top-0 right-0 h-11 w-11 shadow-sm' | |
| aria-label={t('ux.closeCreationSheetButton')}> | |
| <X className='h-4 w-4' /> | |
| </Button> | |
| </div> | |
| )} | |
| <div | |
| aria-hidden={mode !== 'generate'} | |
| inert={mode !== 'generate'} | |
| className={ | |
| mode === 'generate' | |
| ? 'mobile-drawer-form-slot flex min-h-0 w-full flex-1 flex-col lg:h-full' | |
| : 'hidden' | |
| }> | |
| <GenerationForm | |
| onSubmit={handleApiCall} | |
| onSaveInspiration={handleSaveInspiration} | |
| canApplyRandomInspiration={hasRandomInspirationPrompt} | |
| onPickRandomInspiration={pickRandomInspirationPrompt} | |
| isLoading={isLoading || isSendingToEdit} | |
| showLoadingState={isLoading} | |
| isActive={mode === 'generate'} | |
| currentMode={workbenchMode} | |
| onModeChange={handleWorkbenchModeChange} | |
| reuseContext={reuseContext} | |
| onClearReuseContext={() => setReuseContext(null)} | |
| isPasswordRequiredByBackend={isPasswordRequiredByBackend} | |
| clientPasswordHash={clientPasswordHash} | |
| onOpenPasswordDialog={handleOpenPasswordDialog} | |
| model={genModel} | |
| setModel={setGenModel} | |
| prompt={genPrompt} | |
| setPrompt={setGenPrompt} | |
| batchPromptText={genBatchPromptText} | |
| setBatchPromptText={handleBatchPromptTextChange} | |
| failedBatchPrompts={failedBatchPrompts} | |
| canPauseBatch={canPausePromptBatch} | |
| isBatchPauseRequested={isBatchPauseRequested} | |
| onPauseBatch={handlePauseBatch} | |
| n={genN} | |
| setN={setGenN} | |
| size={genSize} | |
| setSize={setGenSize} | |
| customWidth={genCustomWidth} | |
| setCustomWidth={setGenCustomWidth} | |
| customHeight={genCustomHeight} | |
| setCustomHeight={setGenCustomHeight} | |
| quality={genQuality} | |
| setQuality={setGenQuality} | |
| outputFormat={genOutputFormat} | |
| setOutputFormat={setGenOutputFormat} | |
| compression={genCompression} | |
| setCompression={setGenCompression} | |
| background={genBackground} | |
| setBackground={setGenBackground} | |
| upstreamProfile={activeUpstreamProfile} | |
| upstreamProfileMixed={activeUpstreamProfileMixed} | |
| moderation={genModeration} | |
| setModeration={setGenModeration} | |
| streamMode={streamMode} | |
| setStreamMode={setStreamMode} | |
| allowStreamingBatch={streamingBatchEnabled} | |
| enableParallelBatch={enableParallelBatch} | |
| setEnableParallelBatch={setEnableParallelBatch} | |
| partialImages={partialImages} | |
| setPartialImages={setPartialImages} | |
| allowResponsesImageBackend={allowResponsesImageBackend} | |
| hasDefaultResponsesModel={hasDefaultResponsesModel} | |
| imageBackend={genImageBackend} | |
| setImageBackend={setGenImageBackend} | |
| streamingStrategy={genStreamingStrategy} | |
| defaultStreamingStrategy={defaultStreamingStrategy} | |
| setStreamingStrategy={setGenStreamingStrategy} | |
| responsesModel={genResponsesModel} | |
| setResponsesModel={setGenResponsesModel} | |
| thinking={genThinking} | |
| setThinking={setGenThinking} | |
| promptOptimization={genPromptOptimization} | |
| setPromptOptimization={setGenPromptOptimization} | |
| forceWeb={genForceWeb} | |
| setForceWeb={setGenForceWeb} | |
| requestSummaryLabel={activeRequestSummaryLabel} | |
| /> | |
| </div> | |
| <div | |
| aria-hidden={mode !== 'edit'} | |
| inert={mode !== 'edit'} | |
| className={ | |
| mode === 'edit' | |
| ? 'mobile-drawer-form-slot flex min-h-0 w-full flex-1 flex-col lg:h-full' | |
| : 'hidden' | |
| }> | |
| <EditingForm | |
| onSubmit={handleApiCall} | |
| onSaveInspiration={handleSaveInspiration} | |
| canApplyRandomInspiration={hasRandomInspirationPrompt} | |
| onPickRandomInspiration={pickRandomInspirationPrompt} | |
| isLoading={isLoading || isSendingToEdit} | |
| showLoadingState={isLoading} | |
| isActive={mode === 'edit'} | |
| currentMode={workbenchMode} | |
| onModeChange={handleWorkbenchModeChange} | |
| reuseContext={editReuseContext} | |
| onClearReuseContext={() => setEditReuseContext(null)} | |
| isPasswordRequiredByBackend={isPasswordRequiredByBackend} | |
| clientPasswordHash={clientPasswordHash} | |
| onOpenPasswordDialog={handleOpenPasswordDialog} | |
| editModel={editModel} | |
| setEditModel={setEditModel} | |
| imageFiles={editImageFiles} | |
| sourceImagePreviewUrls={editSourceImagePreviewUrls} | |
| setImageFiles={setEditImageFiles} | |
| setSourceImagePreviewUrls={(nextUrls) => { | |
| const resolvedUrls = | |
| typeof nextUrls === 'function' | |
| ? nextUrls(editSourceImagePreviewUrlsRef.current) | |
| : nextUrls; | |
| updateEditSourceImagePreviewUrls(resolvedUrls); | |
| }} | |
| maxImages={activeUpstreamProfile.upload.maxImages} | |
| editPrompt={editPrompt} | |
| setEditPrompt={setEditPrompt} | |
| editN={editN} | |
| setEditN={setEditN} | |
| editSize={editSize} | |
| setEditSize={setEditSize} | |
| editCustomWidth={editCustomWidth} | |
| setEditCustomWidth={setEditCustomWidth} | |
| editCustomHeight={editCustomHeight} | |
| setEditCustomHeight={setEditCustomHeight} | |
| editQuality={editQuality} | |
| setEditQuality={setEditQuality} | |
| editOutputFormat={editOutputFormat} | |
| setEditOutputFormat={setEditOutputFormat} | |
| editCompression={editCompression} | |
| setEditCompression={setEditCompression} | |
| upstreamProfile={activeUpstreamProfile} | |
| upstreamProfileMixed={activeUpstreamProfileMixed} | |
| editModeration={editModeration} | |
| setEditModeration={setEditModeration} | |
| editBrushSize={editBrushSize} | |
| setEditBrushSize={setEditBrushSize} | |
| editShowMaskEditor={editShowMaskEditor} | |
| setEditShowMaskEditor={setEditShowMaskEditor} | |
| editGeneratedMaskFile={editGeneratedMaskFile} | |
| setEditGeneratedMaskFile={setEditGeneratedMaskFile} | |
| editIsMaskSaved={editIsMaskSaved} | |
| setEditIsMaskSaved={setEditIsMaskSaved} | |
| editOriginalImageSize={editOriginalImageSize} | |
| setEditOriginalImageSize={setEditOriginalImageSize} | |
| editDrawnPoints={editDrawnPoints} | |
| setEditDrawnPoints={setEditDrawnPoints} | |
| editMaskPreviewUrl={editMaskPreviewUrl} | |
| setEditMaskPreviewUrl={setEditMaskPreviewUrl} | |
| streamMode={streamMode} | |
| setStreamMode={setStreamMode} | |
| allowStreamingBatch={streamingBatchEnabled} | |
| enableParallelBatch={enableParallelBatch} | |
| setEnableParallelBatch={setEnableParallelBatch} | |
| partialImages={partialImages} | |
| setPartialImages={setPartialImages} | |
| allowResponsesImageBackend={allowResponsesImageBackend} | |
| hasDefaultResponsesModel={hasDefaultResponsesModel} | |
| editImageBackend={editImageBackend} | |
| setEditImageBackend={setEditImageBackend} | |
| editStreamingStrategy={editStreamingStrategy} | |
| defaultStreamingStrategy={defaultStreamingStrategy} | |
| setEditStreamingStrategy={setEditStreamingStrategy} | |
| editResponsesModel={editResponsesModel} | |
| setEditResponsesModel={setEditResponsesModel} | |
| editThinking={editThinking} | |
| setEditThinking={setEditThinking} | |
| editPromptOptimization={editPromptOptimization} | |
| setEditPromptOptimization={setEditPromptOptimization} | |
| editForceWeb={editForceWeb} | |
| setEditForceWeb={setEditForceWeb} | |
| requestSummaryLabel={activeRequestSummaryLabel} | |
| /> | |
| </div> | |
| </section> | |
| <section | |
| ref={outputPanelRef} | |
| tabIndex={-1} | |
| aria-label={t('app.canvasPreview')} | |
| aria-hidden={isMobileCreationDrawerOpen} | |
| inert={isMobileCreationDrawerOpen} | |
| className='order-1 flex min-h-[380px] min-w-0 scroll-mt-4 flex-col sm:min-h-[460px] lg:order-2 xl:min-h-0'> | |
| {error && ( | |
| <Alert | |
| variant='destructive' | |
| className='border-destructive/45 bg-destructive/10 text-destructive mb-4'> | |
| <AlertTitle>{t('common.error')}</AlertTitle> | |
| <AlertDescription>{renderErrorDescription(error)}</AlertDescription> | |
| </Alert> | |
| )} | |
| <div className='min-h-0 shrink-0 xl:flex-1'> | |
| <ImageOutput | |
| imageBatch={latestImageBatch} | |
| viewMode={imageOutputView} | |
| onViewChange={setImageOutputView} | |
| altText={t('output.alt')} | |
| isLoading={isLoading || isSendingToEdit} | |
| onSendToEdit={handleSendToEdit} | |
| onDownloadImage={handleDownloadImage} | |
| onShareImage={handleOpenShareImage} | |
| onCreateVariant={handleCreateVariant} | |
| onReusePrompt={handleReuseCurrentPrompt} | |
| failureMessage={outputFailureMessage} | |
| onRetry={canRetryLastGeneration ? handleRetryLastGeneration : undefined} | |
| compareImage={historyCompareImage} | |
| canCreateVariant={canCreateResultVariant} | |
| canReusePrompt={canReuseResultPrompt} | |
| currentMode={mode} | |
| baseImagePreviewUrl={editSourceImagePreviewUrls[0] || null} | |
| streamingPreviewImages={streamingPreviewImages} | |
| isStreamingRequest={activeRequestStreaming} | |
| clientPasswordHash={clientPasswordHash} | |
| canOpenLogs={canOpenLogs} | |
| openLogsSignal={openLogsSignal} | |
| logClientRequestIds={activeLogClientRequestIds} | |
| logFilenames={activeLogFilenames} | |
| /> | |
| </div> | |
| <WorkbenchProDock | |
| outputFormat={mode === 'generate' ? genOutputFormat : editOutputFormat} | |
| onOutputFormatChange={ | |
| mode === 'generate' ? setGenOutputFormat : setEditOutputFormat | |
| } | |
| quality={mode === 'generate' ? genQuality : editQuality} | |
| onQualityChange={mode === 'generate' ? setGenQuality : setEditQuality} | |
| model={mode === 'generate' ? genModel : editModel} | |
| onModelChange={mode === 'generate' ? setGenModel : setEditModel} | |
| size={mode === 'generate' ? genSize : editSize} | |
| streamMode={streamMode} | |
| onStreamModeChange={setStreamMode} | |
| allowStreamingBatch={streamingBatchEnabled} | |
| enableParallelBatch={enableParallelBatch} | |
| onEnableParallelBatchChange={setEnableParallelBatch} | |
| parallelBatchTargetCount={ | |
| mode === 'generate' && workbenchMode === 'batch' | |
| ? readBatchPromptLines(genBatchPromptText).length | |
| : mode === 'generate' | |
| ? genN[0] | |
| : editN[0] | |
| } | |
| allowResponsesImageBackend={allowResponsesImageBackend} | |
| upstreamProfileMixed={activeUpstreamProfileMixed} | |
| hasDefaultResponsesModel={hasDefaultResponsesModel} | |
| imageBackend={mode === 'generate' ? genImageBackend : editImageBackend} | |
| onImageBackendChange={ | |
| mode === 'generate' ? setGenImageBackend : setEditImageBackend | |
| } | |
| streamingStrategy={ | |
| mode === 'generate' ? genStreamingStrategy : editStreamingStrategy | |
| } | |
| defaultStreamingStrategy={defaultStreamingStrategy} | |
| onStreamingStrategyChange={ | |
| mode === 'generate' ? setGenStreamingStrategy : setEditStreamingStrategy | |
| } | |
| responsesModel={mode === 'generate' ? genResponsesModel : editResponsesModel} | |
| onResponsesModelChange={ | |
| mode === 'generate' ? setGenResponsesModel : setEditResponsesModel | |
| } | |
| disabled={isLoading || isSendingToEdit} | |
| /> | |
| </section> | |
| <aside | |
| aria-label={t('history.title')} | |
| aria-hidden={isMobileCreationDrawerOpen} | |
| inert={isMobileCreationDrawerOpen} | |
| className={cn( | |
| 'workbench-history-column order-3 min-h-[17.5rem] min-w-0 lg:col-span-2', | |
| shouldExpandOutputStage && 'workbench-history-column--contained' | |
| )}> | |
| <HistoryPanel | |
| history={history} | |
| inspirations={inspirations} | |
| activityItems={generationActivityItems} | |
| isSendingToEdit={isLoading || isSendingToEdit} | |
| onSelectImage={handleHistorySelect} | |
| onApplyPrompt={handleApplyPrompt} | |
| onSaveInspiration={handleSaveInspiration} | |
| onSendHistoryToEdit={handleSendHistoryToEdit} | |
| onMarkResultFeedback={handleMarkResultFeedback} | |
| onUpdateResultFeedbackNote={handleUpdateResultFeedbackNote} | |
| onDeleteInspiration={handleDeleteInspiration} | |
| onDownloadHistoryItem={handleDownloadHistoryItem} | |
| onClearHistory={handleClearHistory} | |
| getImageSrc={getImageSrc} | |
| onDeleteItemRequest={handleRequestDeleteItem} | |
| itemPendingDeleteConfirmation={itemToDeleteConfirm} | |
| onConfirmDeletion={handleConfirmDeletion} | |
| onCancelDeletion={handleCancelDeletion} | |
| deletePreferenceDialogValue={dialogCheckboxStateSkipConfirm} | |
| onDeletePreferenceDialogChange={setDialogCheckboxStateSkipConfirm} | |
| cleanupEnabled={retentionControlsEnabled} | |
| permanentlySavedFilenames={permanentlySavedFilenames ?? emptyPermanentFilenames} | |
| onUpdatePermanentSave={updatePermanentSave} | |
| /> | |
| </aside> | |
| </div> | |
| </div> | |
| {!isMobileCreationDrawerOpen && ( | |
| <div className='bg-background border-border fixed right-0 bottom-0 left-0 z-40 min-h-[var(--mobile-action-dock-height)] border-t px-3 pt-2 pb-[calc(0.75rem+env(safe-area-inset-bottom))] shadow-[0_-12px_30px_rgba(15,23,42,0.1)] lg:hidden'> | |
| <div | |
| className='mx-auto mb-2 flex h-3 w-24 touch-none items-center justify-center rounded-full select-none' | |
| onPointerDown={beginMobileCreationDrawerGesture} | |
| onPointerUp={finishMobileCreationDrawerGesture} | |
| onPointerCancel={cancelMobileCreationDrawerGesture} | |
| onClick={handleMobileCreationDrawerHandleClick} | |
| aria-hidden='true'> | |
| <span className='bg-border h-1 w-10 rounded-full' aria-hidden='true' /> | |
| </div> | |
| <div className='mx-auto flex max-w-screen-sm items-stretch gap-2'> | |
| <Button | |
| ref={mobileCreationDrawerTriggerRef} | |
| type='button' | |
| variant='outline' | |
| size='icon' | |
| onClick={openMobileCreationDrawer} | |
| className='text-muted-foreground hover:text-foreground h-11 w-11 shrink-0' | |
| aria-label={t('ux.openCreationSheet')} | |
| aria-controls='mobile-creation-sheet' | |
| aria-expanded={false}> | |
| <PenLine className='h-4 w-4' /> | |
| </Button> | |
| <Button | |
| type='button' | |
| onClick={handleMobilePrimaryAction} | |
| disabled={mobilePrimaryDisabled} | |
| className='bg-primary text-primary-foreground hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground min-h-11 min-w-0 flex-1'> | |
| {(isLoading || isSendingToEdit) && ( | |
| <Loader2 className='mr-2 h-4 w-4 animate-spin' /> | |
| )} | |
| {mode === 'generate' | |
| ? isLoading | |
| ? t('generate.loading') | |
| : t('generate.submit') | |
| : isLoading | |
| ? t('edit.loading') | |
| : t('edit.submit')} | |
| </Button> | |
| {canPausePromptBatch && ( | |
| <Button | |
| type='button' | |
| variant='outline' | |
| size='icon' | |
| onClick={handlePauseBatch} | |
| disabled={isBatchPauseRequested} | |
| className='text-muted-foreground hover:text-foreground h-11 w-11 shrink-0' | |
| aria-label={ | |
| isBatchPauseRequested ? t('batch.pauseRequested') : t('batch.pause') | |
| }> | |
| {isBatchPauseRequested ? ( | |
| <Loader2 className='h-4 w-4 animate-spin' /> | |
| ) : ( | |
| <Pause className='h-4 w-4' /> | |
| )} | |
| </Button> | |
| )} | |
| <Button | |
| type='button' | |
| variant='outline' | |
| size='icon' | |
| onClick={scrollToOutput} | |
| className='text-muted-foreground hover:text-foreground h-11 w-11 shrink-0' | |
| aria-label={t('ux.jumpToResult')}> | |
| <ArrowUp className='h-4 w-4' /> | |
| </Button> | |
| {canOpenLogs && ( | |
| <Button | |
| type='button' | |
| variant='outline' | |
| size='icon' | |
| onClick={() => setOpenLogsSignal((value) => value + 1)} | |
| className='text-muted-foreground hover:text-foreground h-11 w-11 shrink-0' | |
| aria-label={t('logs.open')}> | |
| <Activity className='h-4 w-4' /> | |
| </Button> | |
| )} | |
| </div> | |
| {mobilePrimaryDisabledReason && ( | |
| <p className='text-muted-foreground mx-auto mt-2 max-w-screen-sm text-center text-xs leading-4'> | |
| {mobilePrimaryDisabledReason} | |
| </p> | |
| )} | |
| </div> | |
| )} | |
| </> | |
| ) : null} | |
| </main> | |
| ); | |
| } | |