visual-journal / src /app /page.tsx
Visual Journal deploy
Deploy 5554830 to Docker Space
e4e0afe
Raw
History Blame Contribute Delete
202 kB
'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>
);
}