import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react'; import { Upload, Mic, Settings2, X, Play, MoreHorizontal, Wand2, Download, RefreshCw, Trash2, Gamepad2, Pause, History, Lock, Unlock, Zap, Grid2X2, Image, Sliders, ChevronRight, ChevronDown, Maximize2, Info, Film, ArrowUp, Loader2, Sparkles } from 'lucide-react'; import { upscaleImage } from './enhance/upscaleApi'; import { resolveFileUrl } from './resolveFileUrl'; import { getCurrentUserId, registerUserScopedKey, userScopedKey, } from './lib/userScopedStorage'; // Register the gallery base key so AuthGate can namespace / clear it per user. registerUserScopedKey('homepilot_imagine_items'); /** * Resolve the localStorage key the Imagine gallery should read/write for the * current authenticated user. Falls back to an anonymous bucket when no * user is active — this keeps SSR/boot paths safe and prevents any user's * gallery from being written to the un-scoped global slot. */ function imagineItemsKey() { const uid = getCurrentUserId(); return uid ? userScopedKey('homepilot_imagine_items', uid) : 'homepilot_imagine_items:user:anon'; } // ----------------------------------------------------------------------------- // Helpers // ----------------------------------------------------------------------------- // Explicit generous deadline for generation requests. Cold-start // model loads + busy ComfyUI queues can legitimately take several // minutes; the browser's default fetch behaviour (either no // timeout or a very long one) leaves the UI in limbo. 10 minutes // matches the backend's COMFY_POLL_MAX_S so the two sides agree // on "when does this request really count as failed". const POSTJSON_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; async function postJson(baseUrl, path, body, apiKey, opts) { const url = `${baseUrl.replace(/\/+$/, '')}${path.startsWith('/') ? path : `/${path}`}`; // Compose a deadline-based AbortSignal with an optional caller // signal so a user-clicked Cancel also aborts. Deadline fires // only if the backend genuinely never responds within the // generous window above — matches best-practice long-running // API patterns (AWS Bedrock async, Replicate, OpenAI Images). const controller = new AbortController(); const timeoutMs = opts?.timeoutMs ?? POSTJSON_DEFAULT_TIMEOUT_MS; const deadlineTimer = window.setTimeout(() => controller.abort(), timeoutMs); const callerSignal = opts?.signal; if (callerSignal) { if (callerSignal.aborted) controller.abort(); else callerSignal.addEventListener('abort', () => controller.abort()); } try { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(apiKey ? { 'x-api-key': apiKey } : {}), }, credentials: 'include', body: JSON.stringify(body), signal: controller.signal, }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`HTTP ${res.status} ${res.statusText}${text ? `: ${text}` : ''}`); } return (await res.json()); } finally { window.clearTimeout(deadlineTimer); } } async function deleteJson(baseUrl, path, body, apiKey) { const url = `${baseUrl.replace(/\/+$/, '')}${path.startsWith('/') ? path : `/${path}`}`; const res = await fetch(url, { method: 'DELETE', headers: { 'Content-Type': 'application/json', ...(apiKey ? { 'x-api-key': apiKey } : {}), }, credentials: 'include', body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`HTTP ${res.status} ${res.statusText}${text ? `: ${text}` : ''}`); } return (await res.json()); } function uid() { return `${Date.now()}-${Math.random().toString(16).slice(2)}`; } const ASPECT_RATIOS = [ { label: '1:1', previewW: 24, previewH: 24, genW: 1024, genH: 1024 }, { label: '4:3', previewW: 32, previewH: 24, genW: 1152, genH: 864 }, { label: '3:4', previewW: 24, previewH: 32, genW: 864, genH: 1152 }, { label: '16:9', previewW: 42, previewH: 24, genW: 1344, genH: 768 }, { label: '9:16', previewW: 24, previewH: 42, genW: 768, genH: 1344 }, ]; // ----------------------------------------------------------------------------- // Component // ----------------------------------------------------------------------------- export default function ImagineView(props) { const authKey = (props.apiKey || '').trim(); const [prompt, setPrompt] = useState(''); // Load items from localStorage on mount. // Scoped per authenticated user so switching accounts never surfaces // another user's gallery thumbnails (whose /files/ URLs would 403/404 // and produce the ghost-card bug from issue: cross-account UI leak). const [items, setItems] = useState(() => { try { const lsKey = imagineItemsKey(); let stored = localStorage.getItem(lsKey); // One-time migration: if the user has no scoped gallery yet but the // legacy global slot still holds data, adopt it into the scoped slot. // This preserves galleries for the currently-logged-in user when the // fix is first deployed; the global slot is then cleared so future // logins don't inherit anyone's data. if (!stored) { const legacy = localStorage.getItem('homepilot_imagine_items'); if (legacy && getCurrentUserId()) { localStorage.setItem(lsKey, legacy); localStorage.removeItem('homepilot_imagine_items'); stored = legacy; } } if (stored) { const parsed = JSON.parse(stored); if (Array.isArray(parsed)) { // Thresholds for handling stale processing items: // - IN_FLIGHT_THRESHOLD: If processing item is older than this, generation likely // completed but state update was lost due to tab switch (component unmount). // Image generation typically takes 3-5 seconds, so 15s is safe. // - STALE_THRESHOLD: If processing item is older than this, app was closed during // generation - mark as definitively failed. const IN_FLIGHT_THRESHOLD = 15 * 1000; // 15 seconds - likely completed but lost track const STALE_THRESHOLD = 10 * 60 * 1000; // 10 minutes - definitively failed const now = Date.now(); return parsed.map((item) => { // Migrate: old items without status get 'done' if they have url if (!item.status) { return { ...item, status: item.url ? 'done' : 'failed' }; } // Handle processing items based on age if (item.status === 'processing') { const age = now - item.createdAt; // Very old items (app was closed) - mark as failed if (age > STALE_THRESHOLD) { return { ...item, status: 'failed', error: 'Generation interrupted - please retry' }; } // Medium-age items (tab was switched) - likely completed but we lost track // Mark as failed since we can't verify the result without backend check if (age > IN_FLIGHT_THRESHOLD) { return { ...item, status: 'failed', error: 'Generation may have completed - check your gallery or retry' }; } } // Keep recent processing items as-is (still in-flight) return item; }); } } } catch (error) { console.error('Failed to load imagine items from localStorage:', error); } return []; }); const [isGenerating, setIsGenerating] = useState(false); const [upscalingId, setUpscalingId] = useState(null); // Selection state for Lightbox (Grok-style detail view) const [selectedImage, setSelectedImage] = useState(null); const [showDetails, setShowDetails] = useState(false); // Immersive mode: details hidden by default const [lightboxPrompt, setLightboxPrompt] = useState(''); // Editable prompt in lightbox const [isRegenerating, setIsRegenerating] = useState(false); // Regenerating from lightbox const [regenProgress, setRegenProgress] = useState(null); // Progress 0-100 during regen const [regenAbortController, setRegenAbortController] = useState(null); // For cancellation // Fullscreen + navigation (additive) const lightboxRef = useRef(null); const [isFullscreen, setIsFullscreen] = useState(false); const [fullscreenStretch, setFullscreenStretch] = useState(false); // false = fit (contain), true = fill (cover/crop) // Track selected index for arrow navigation const selectedIndex = useMemo(() => { if (!selectedImage) return -1; return items.findIndex((it) => it.id === selectedImage.id); }, [items, selectedImage]); // Detect newly completed images for slideshow const prevItemsRef = useRef(items); const [aspect, setAspect] = useState('1:1'); const [showAspectPanel, setShowAspectPanel] = useState(false); const [compatibleAspectRatios, setCompatibleAspectRatios] = useState([]); const [defaultAspectRatio, setDefaultAspectRatio] = useState('1:1'); // Number of images to generate per request (1, 2, or 4 like Grok) const [numImages, setNumImages] = useState(1); // Game Mode state const [gameMode, setGameMode] = useState(false); const [useGlobalLLMForVariations, setUseGlobalLLMForVariations] = useState(false); const [gameSessionId, setGameSessionId] = useState(null); const [gameStrength, setGameStrength] = useState(0.65); const [spicyStrength, setSpicyStrength] = useState(0.3); // Spicy strength (only when nsfwMode + gameMode) const [gameLocks, setGameLocks] = useState({ lock_world: true, lock_style: true, lock_subject_type: true, lock_main_character: false, lock_palette: false, lock_time_of_day: false, }); const [isAutoGenerating, setIsAutoGenerating] = useState(false); const [showGamePanel, setShowGamePanel] = useState(false); const [gameVariations, setGameVariations] = useState([]); const [showVariationHistory, setShowVariationHistory] = useState(false); const autoGenerateRef = useRef(false); // Advanced Settings Panel state const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const [advancedMode, setAdvancedMode] = useState(false); const [customSteps, setCustomSteps] = useState(30); const [customCfg, setCustomCfg] = useState(5.5); const [customDenoise, setCustomDenoise] = useState(0.55); const [seedLock, setSeedLock] = useState(false); const [customSeed, setCustomSeed] = useState(0); const [useControlNet, setUseControlNet] = useState(false); const [cnStrength, setCnStrength] = useState(1.0); // Resolution override state (preset-based, additive — only dims change) const [resolutionTiers, setResolutionTiers] = useState([]); const [selectedResTier, setSelectedResTier] = useState(''); // empty = current preset (no override) const [resolutionMode, setResolutionMode] = useState('auto'); // auto = preset default, override = show grid const [presetResolutions, setPresetResolutions] = useState({}); // { aspect: { preset: {w,h} } } const [presetUiLabels, setPresetUiLabels] = useState({}); // { preset: { label } } // Reference Image state (for img2img similar generation) const referenceInputRef = useRef(null); const promptInputRef = useRef(null); const [referenceUrl, setReferenceUrl] = useState(null); const [referenceStrength, setReferenceStrength] = useState(0.35); // 0=very similar, 1=more creative const [isUploadingReference, setIsUploadingReference] = useState(false); // Ref for auto-scrolling to top when new images are added (Grok-style) const gridStartRef = useRef(null); // Save items to localStorage whenever they change (user-scoped slot). useEffect(() => { try { localStorage.setItem(imagineItemsKey(), JSON.stringify(items)); } catch (error) { console.error('Failed to save imagine items to localStorage:', error); } }, [items]); // Fail-closed rendering: when a gallery image fails to load (403/404 from // cross-account or deleted asset URLs), we drop the item from the current // view AND persist the drop so the ghost card doesn't re-appear on next // mount. This complements server-side ownership checks on /files/. const [brokenIds, setBrokenIds] = useState(() => new Set()); const markImageBroken = useCallback((id) => { setBrokenIds((prev) => { if (prev.has(id)) return prev; const next = new Set(prev); next.add(id); return next; }); setItems((prev) => prev.filter((it) => it.id !== id)); }, []); // On mount, periodically check localStorage for items that completed while unmounted // This handles the race condition where API completes after component remounts useEffect(() => { const processingItems = items.filter(item => item.status === 'processing'); if (processingItems.length === 0) return; // Check localStorage for updates and sync state const syncFromLocalStorage = () => { try { const stored = localStorage.getItem(imagineItemsKey()); if (!stored) return false; const storedItems = JSON.parse(stored); const storedMap = new Map(storedItems.map(item => [item.id, item])); let hasUpdates = false; setItems(prev => { const updated = prev.map(item => { if (item.status !== 'processing') return item; // Check if this item was completed in localStorage (by another instance) const storedItem = storedMap.get(item.id); if (storedItem && storedItem.status === 'done' && storedItem.url) { console.log('[Imagine] Synced completed item from localStorage:', item.id); hasUpdates = true; return storedItem; } // Check if this processing item is now too old const age = Date.now() - item.createdAt; if (age > 15 * 1000) { hasUpdates = true; return { ...item, status: 'failed', error: 'Generation may have completed - check your gallery or retry' }; } // Continue progress simulation return { ...item, progress: Math.min(90, (item.progress || 0) + Math.random() * 5 + 1) }; }); return updated; }); return hasUpdates; } catch (e) { console.error('[Imagine] Failed to sync from localStorage:', e); return false; } }; // Run sync check periodically const progressInterval = setInterval(() => { syncFromLocalStorage(); }, 500); // Also run immediately on mount syncFromLocalStorage(); return () => clearInterval(progressInterval); // Only run on mount (empty deps) - items will be stale but that's intentional // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Scroll to top when entering Imagine to avoid "empty space above" if previously scrolled useEffect(() => { gridStartRef.current?.scrollIntoView({ block: 'start' }); }, []); // Initialize lightbox prompt when selecting an image useEffect(() => { if (selectedImage) { setLightboxPrompt(selectedImage.prompt); } }, [selectedImage]); // PATCH B — Track fullscreen state useEffect(() => { const onFsChange = () => { setIsFullscreen(Boolean(document.fullscreenElement)); }; document.addEventListener("fullscreenchange", onFsChange); return () => document.removeEventListener("fullscreenchange", onFsChange); }, []); // PATCH C — Keyboard navigation (ArrowLeft/Right + Esc) useEffect(() => { if (!selectedImage) return; const onKeyDown = (e) => { // Don't hijack typing in inputs const tag = e.target?.tagName?.toLowerCase(); if (tag === "input" || tag === "textarea") return; if (e.key === "Escape") { setSelectedImage(null); setShowDetails(false); if (document.fullscreenElement) { document.exitFullscreen().catch(() => { }); } return; } if (e.key === "ArrowLeft") { e.preventDefault(); const i = selectedIndex; if (i >= 0 && i < items.length - 1) { // Items are newest-first; ArrowLeft = older setSelectedImage(items[i + 1]); } } if (e.key === "ArrowRight") { e.preventDefault(); const i = selectedIndex; if (i > 0) { // ArrowRight = newer setSelectedImage(items[i - 1]); } } }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [selectedImage, selectedIndex, items]); // PATCH D — Slideshow auto-advance (Game Mode + Auto + Fullscreen) useEffect(() => { if (!selectedImage) { prevItemsRef.current = items; return; } if (!(gameMode && isAutoGenerating && isFullscreen)) { prevItemsRef.current = items; return; } const prev = prevItemsRef.current; prevItemsRef.current = items; // Find newest DONE image now vs before const newestNow = items.find((it) => it.status === "done" && it.url); if (!newestNow) return; const newestPrev = prev.find((it) => it.status === "done" && it.url); // If there was no previous done, or newest changed -> advance if (!newestPrev || newestPrev.id !== newestNow.id) { setSelectedImage(newestNow); } }, [items, selectedImage, gameMode, isAutoGenerating, isFullscreen]); // Helper function to get preview dimensions for aspect ratio const getPreviewDimension = (ratioId, dim) => { const previewMap = { '1:1': { width: 24, height: 24 }, '4:3': { width: 32, height: 24 }, '3:4': { width: 24, height: 32 }, '16:9': { width: 42, height: 24 }, '9:16': { width: 24, height: 42 }, }; return previewMap[ratioId]?.[dim] ?? 24; }; // Fetch compatible aspect ratios when model changes useEffect(() => { const fetchImagePresets = async () => { try { const base = props.backendUrl.replace(/\/+$/, ''); // Detect model architecture from model filename const model = props.modelImages || ''; let arch = 'sdxl'; // default const lowerModel = model.toLowerCase(); if (lowerModel.includes('flux') && lowerModel.includes('schnell')) { arch = 'flux_schnell'; } else if (lowerModel.includes('flux')) { arch = 'flux_dev'; } else if (lowerModel.includes('sdxl') || lowerModel.includes('xl') || lowerModel.includes('pony')) { arch = 'sdxl'; } else if (lowerModel.includes('sd15') || lowerModel.includes('dreamshaper') || lowerModel.includes('realistic')) { arch = 'sd15'; } const url = `${base}/image-presets?model=${arch}&preset=${props.imgPreset || 'med'}`; const res = await fetch(url, { headers: authKey ? { 'x-api-key': authKey } : undefined, }); if (res.ok) { const data = await res.json(); if (data.compatible_aspect_ratios && Array.isArray(data.compatible_aspect_ratios)) { const mappedRatios = data.compatible_aspect_ratios.map((ar) => ({ id: ar.id, label: ar.label?.replace(/\s*\([^)]*\)/g, '') || ar.id, previewW: getPreviewDimension(ar.id, 'width'), previewH: getPreviewDimension(ar.id, 'height'), dimensions: ar.dimensions, })); setCompatibleAspectRatios(mappedRatios); // Set default aspect ratio from API if (data.default_aspect_ratio) { setDefaultAspectRatio(data.default_aspect_ratio); // Only change current aspect if it's not in the compatible list if (!mappedRatios.find(r => r.id === aspect)) { setAspect(data.default_aspect_ratio); } } } // Store preset resolutions for resolution grid in Advanced Controls if (data.preset_resolutions) { setPresetResolutions(data.preset_resolutions); } if (data.preset_ui) { setPresetUiLabels(data.preset_ui); } } } catch (err) { console.error('[Imagine] Failed to fetch image presets:', err); // Fallback to default aspect ratios const fallbackRatios = ASPECT_RATIOS.map(r => ({ id: r.label, label: r.label, previewW: r.previewW, previewH: r.previewH, dimensions: { width: r.genW, height: r.genH }, })); setCompatibleAspectRatios(fallbackRatios); } }; fetchImagePresets(); }, [props.modelImages, props.imgPreset, props.backendUrl, authKey]); // Recompute resolution tiers when aspect ratio changes // Uses preset_resolutions from the API (computed from model_config.get_model_settings) useEffect(() => { const arPresets = presetResolutions[aspect]; if (!arPresets || Object.keys(arPresets).length === 0) { setResolutionTiers([]); return; } const tierOrder = ['low', 'med', 'high', 'ultra']; const mapped = tierOrder .filter((tid) => arPresets[tid]?.width && arPresets[tid]?.height) .map((tid) => ({ id: tid, label: presetUiLabels[tid]?.label || tid, width: arPresets[tid].width, height: arPresets[tid].height, })); setResolutionTiers(mapped); // If current selection is no longer valid, reset to auto if (resolutionMode === 'override' && selectedResTier && !mapped.find((t) => t.id === selectedResTier)) { setSelectedResTier(''); setResolutionMode('auto'); } }, [aspect, presetResolutions, presetUiLabels]); // eslint-disable-line react-hooks/exhaustive-deps const aspectObj = useMemo(() => { // Prefer compatible ratios from API, fallback to static list if (compatibleAspectRatios.length > 0) { const found = compatibleAspectRatios.find((a) => a.id === aspect); if (found) { return { label: found.id, previewW: found.previewW, previewH: found.previewH, genW: found.dimensions?.width || 1024, genH: found.dimensions?.height || 1024, }; } } return ASPECT_RATIOS.find((a) => a.label === aspect) || ASPECT_RATIOS[0]; }, [aspect, compatibleAspectRatios]); // Upload reference image handler const handleUploadReference = useCallback(async (file) => { setIsUploadingReference(true); try { const base = props.backendUrl.replace(/\/+$/, ''); const url = `${base}/upload`; const fd = new FormData(); fd.append('file', file); const res = await fetch(url, { method: 'POST', headers: authKey ? { 'x-api-key': authKey } : undefined, body: fd, }); if (!res.ok) { const text = await res.text().catch(() => `HTTP ${res.status}`); throw new Error(text); } const data = await res.json(); // Backend typically returns { url: "http://.../files/..." } const imageUrl = data?.url || data?.file_url || data?.media_url; if (!imageUrl) { throw new Error('Upload succeeded but no URL returned'); } setReferenceUrl(imageUrl); } catch (err) { console.error('Reference upload failed:', err); alert(`Failed to upload reference: ${err.message || err}`); } finally { setIsUploadingReference(false); } }, [props.backendUrl, authKey]); const handleGenerate = useCallback(async (overridePrompt) => { const t = (overridePrompt || prompt).trim(); // Allow empty prompt when reference image is uploaded if (!t && !referenceUrl) return; if (isGenerating) return; // Use default prompt for reference-only generation const effectivePrompt = t || 'similar image'; setIsGenerating(true); setShowAspectPanel(false); setShowGamePanel(false); setShowAdvancedSettings(false); // Create placeholder items IMMEDIATELY (persists across tab switches) const placeholderIds = []; const now = Date.now(); for (let i = 0; i < numImages; i++) { placeholderIds.push(`${now}-${Math.random().toString(16).slice(2)}-${i}`); } const placeholders = placeholderIds.map(id => ({ id, status: 'processing', progress: 0, createdAt: now, prompt: effectivePrompt, width: props.imgWidth, height: props.imgHeight, })); // Add placeholders to items (will be persisted to localStorage) setItems((prev) => [...placeholders, ...prev]); // Only clear prompt if not in auto-generate mode if (!autoGenerateRef.current) { setPrompt(''); } // Auto-scroll to show new placeholders setTimeout(() => { gridStartRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 100); // Simulate progress while waiting for API const progressInterval = setInterval(() => { setItems(prev => prev.map(item => placeholderIds.includes(item.id) && item.status === 'processing' ? { ...item, progress: Math.min(90, (item.progress || 0) + Math.random() * 8 + 2) } : item)); }, 500); try { // IMPORTANT: Send aspect ratio to backend instead of hardcoded dimensions. // The backend's Dynamic Preset System will calculate correct dimensions // based on the model architecture (SD1.5 vs SDXL vs Flux). // Only send explicit width/height if user set them in settings. const hasExplicitDimensions = props.imgWidth && props.imgWidth > 0 && props.imgHeight && props.imgHeight > 0; // Resolution tier override: only apply when user explicitly chose Override mode const resTierOverride = resolutionMode === 'override' && selectedResTier ? resolutionTiers.find((t) => t.id === selectedResTier) : null; // Map 'comfyui' provider to 'ollama' for prompt refinement // ComfyUI is used automatically for actual image generation const llmProvider = props.providerImages === 'comfyui' ? 'ollama' : props.providerImages; // Determine the LLM settings for prompt refinement and Game Mode // Use chat provider settings if available, otherwise fall back to ollama defaults const chatProvider = props.providerChat || 'ollama'; const chatBaseUrl = props.baseUrlChat || 'http://localhost:11434'; const chatModel = props.modelChat || 'llama3:8b'; const requestBody = { message: /^\s*(imagine|generate|create|draw|make)\b/i.test(effectivePrompt) ? effectivePrompt : `imagine ${effectivePrompt}`, mode: 'imagine', // Provider override fields for image generation provider: llmProvider, provider_base_url: props.baseUrlImages || undefined, provider_model: props.modelImages || undefined, // LLM settings for prompt refinement and Game Mode variation generation // These are separate from the image provider settings ollama_base_url: chatProvider === 'ollama' ? chatBaseUrl : undefined, ollama_model: chatProvider === 'ollama' ? chatModel : undefined, llm_base_url: chatProvider !== 'ollama' ? chatBaseUrl : undefined, llm_model: chatProvider !== 'ollama' ? chatModel : undefined, // Image generation params // Send aspect ratio for backend to calculate model-appropriate dimensions // Only send explicit width/height if user set them in settings (not from aspect picker) imgAspectRatio: aspect, // e.g., "16:9", "1:1", etc. // Resolution override: tier selection > explicit props > auto from preset imgWidth: resTierOverride ? resTierOverride.width : hasExplicitDimensions ? props.imgWidth : undefined, imgHeight: resTierOverride ? resTierOverride.height : hasExplicitDimensions ? props.imgHeight : undefined, imgResolutionOverride: resTierOverride ? true : undefined, // Use custom settings if advanced mode is enabled, otherwise use props imgSteps: advancedMode ? customSteps : props.imgSteps, imgCfg: advancedMode ? customCfg : props.imgCfg, imgSeed: advancedMode && seedLock ? customSeed : props.imgSeed, imgDenoise: advancedMode ? customDenoise : undefined, imgPreset: props.imgPreset || 'med', // Send preset for architecture-aware settings imgModel: props.modelImages, nsfwMode: props.nsfwMode, promptRefinement: props.promptRefinement ?? true, imgBatchSize: numImages, // ControlNet settings useControlNet: advancedMode ? useControlNet : undefined, cnStrength: advancedMode && useControlNet ? cnStrength : undefined, // Reference image for img2img (similar image generation) imgReference: referenceUrl ?? undefined, imgRefStrength: referenceUrl ? referenceStrength : undefined, }; // Add Game Mode parameters if enabled if (gameMode) { requestBody.gameMode = true; requestBody.gameSessionId = gameSessionId; requestBody.gameStrength = gameStrength; requestBody.gameLocks = gameLocks; requestBody.gameUseGlobalLLMForVariations = useGlobalLLMForVariations; // Only send spicyStrength if nsfwMode is also enabled if (props.nsfwMode) { requestBody.gameSpicyStrength = spicyStrength; } } // Log the aspect ratio being sent for debugging console.log('[Imagine] Generating with aspect ratio:', aspect, '| Request body imgAspectRatio:', requestBody.imgAspectRatio); const data = await postJson(props.backendUrl, '/chat', requestBody, authKey); const urls = data?.media?.images || []; if (urls.length === 0) { throw new Error(data?.message || data?.text || 'No images returned by backend.'); } // Handle Game Mode response if (gameMode && data?.media?.game) { const gameData = data.media.game; if (gameData.session_id) { setGameSessionId(gameData.session_id); } if (gameData.variation_prompt) { setGameVariations((prev) => [ { prompt: gameData.variation_prompt, tags: gameData.tags || {}, timestamp: Date.now(), }, ...prev, ].slice(0, 50)); } } const now = Date.now(); // Use the actual refined/final prompt that was sent to the image model // This is the real prompt that generated the image, so users can reproduce it // Priority: final_prompt (refined) > variation_prompt (game mode) > original user input const finalPrompt = data?.media?.final_prompt || (gameMode && data?.media?.game?.variation_prompt) || t; clearInterval(progressInterval); // Extract generation parameters for reproducibility const seeds = data?.media?.seeds || (data?.media?.seed ? [data.media.seed] : []); const genWidth = data?.media?.width; const genHeight = data?.media?.height; const genSteps = data?.media?.steps; const genCfg = data?.media?.cfg; const genModel = data?.media?.model; // Build completed items const completedItemsMap = new Map(); placeholderIds.forEach((placeholderId, index) => { const imageUrl = urls[index]; const placeholder = placeholders[index]; if (imageUrl) { completedItemsMap.set(placeholderId, { ...placeholder, status: 'done', progress: 100, url: imageUrl, prompt: finalPrompt, seed: seeds[index] ?? seeds[0], width: genWidth, height: genHeight, steps: genSteps, cfg: genCfg, model: genModel, }); } else { completedItemsMap.set(placeholderId, { ...placeholder, status: 'failed', error: 'No image returned', }); } }); // CRITICAL: Directly persist to localStorage BEFORE setItems // This ensures the result is saved even if component unmounts between // API response and React's state update try { const lsKey = imagineItemsKey(); const stored = localStorage.getItem(lsKey); const currentItems = stored ? JSON.parse(stored) : []; const updatedItems = currentItems.map(item => { const completed = completedItemsMap.get(item.id); return completed || item; }).slice(0, 100); localStorage.setItem(lsKey, JSON.stringify(updatedItems)); console.log('[Imagine] Persisted completed images to localStorage:', Array.from(completedItemsMap.keys())); } catch (e) { console.error('[Imagine] Failed to persist to localStorage:', e); } // Update React state (may not process if component unmounted, but localStorage is already updated) setItems(prev => prev.map(item => { const completed = completedItemsMap.get(item.id); return completed || item; }).slice(0, 100)); } catch (err) { clearInterval(progressInterval); console.error('Generation failed:', err); // Build failed items const failedItemsMap = new Map(); placeholders.forEach(placeholder => { failedItemsMap.set(placeholder.id, { ...placeholder, status: 'failed', error: err.message || 'Generation failed' }); }); // CRITICAL: Directly persist failure to localStorage try { const lsKey = imagineItemsKey(); const stored = localStorage.getItem(lsKey); const currentItems = stored ? JSON.parse(stored) : []; const updatedItems = currentItems.map(item => { const failed = failedItemsMap.get(item.id); return failed || item; }); localStorage.setItem(lsKey, JSON.stringify(updatedItems)); } catch (e) { console.error('Failed to persist to localStorage:', e); } // Update React state setItems(prev => prev.map(item => { const failed = failedItemsMap.get(item.id); return failed || item; })); // Stop auto-generation on error autoGenerateRef.current = false; setIsAutoGenerating(false); } finally { setIsGenerating(false); } }, [prompt, isGenerating, aspect, props, authKey, gameMode, useGlobalLLMForVariations, gameSessionId, gameStrength, spicyStrength, gameLocks, numImages, advancedMode, customSteps, customCfg, customDenoise, seedLock, customSeed, useControlNet, cnStrength, referenceUrl, referenceStrength, selectedResTier, resolutionTiers]); // Auto-generation loop for Game Mode useEffect(() => { if (isAutoGenerating && gameMode && !isGenerating && prompt.trim()) { autoGenerateRef.current = true; const timeoutId = setTimeout(() => { if (autoGenerateRef.current) { handleGenerate(prompt); } }, 500); // Small delay between generations return () => clearTimeout(timeoutId); } }, [isAutoGenerating, gameMode, isGenerating, prompt, handleGenerate]); const toggleAutoGenerate = () => { if (isAutoGenerating) { autoGenerateRef.current = false; setIsAutoGenerating(false); } else { if (!prompt.trim()) { alert('Please enter a base prompt first'); return; } autoGenerateRef.current = true; setIsAutoGenerating(true); handleGenerate(prompt); } }; const resetGameSession = () => { setGameSessionId(null); setGameVariations([]); }; const handleDelete = async (item, e) => { if (e) { e.stopPropagation(); } // Confirm deletion if (!confirm('Delete this image? This will remove it from your gallery and database.')) { return; } try { // Only delete from backend if item has a URL (completed items) // Failed/processing items only exist locally if (item.url) { await deleteJson(props.backendUrl, '/media/image', { image_url: item.url }, authKey); } // Remove from local state (and localStorage via useEffect) setItems((prev) => prev.filter((i) => i.id !== item.id)); // Close lightbox if this image was selected if (selectedImage?.id === item.id) { setSelectedImage(null); } } catch (err) { alert(`Failed to delete image: ${err.message || err}`); } }; // Grok-style in-place regeneration: keeps lightbox open, shows progress overlay const handleRegenerateInPlace = useCallback(async () => { if (!selectedImage || !lightboxPrompt.trim() || isRegenerating) return; const abortController = new AbortController(); setRegenAbortController(abortController); setIsRegenerating(true); setRegenProgress(0); // Simulate progress while waiting for sync API const progressInterval = setInterval(() => { setRegenProgress(prev => { if (prev === null) return 0; if (prev >= 90) return prev; return Math.min(90, prev + Math.random() * 10 + 3); }); }, 400); try { const requestBody = { message: lightboxPrompt, mode: 'imagine', numImages: 1, imgWidth: selectedImage.width || 1024, imgHeight: selectedImage.height || 1024, ...(advancedMode && { imgSteps: customSteps, imgCfg: customCfg, ...(seedLock && { imgSeed: customSeed }), }), provider: props.providerImages === 'comfyui' ? 'ollama' : props.providerImages, provider_base_url: props.baseUrlImages || undefined, provider_model: props.modelImages || undefined, ollama_base_url: props.providerChat === 'ollama' ? props.baseUrlChat : undefined, ollama_model: props.providerChat === 'ollama' ? props.modelChat : undefined, nsfwMode: props.nsfwMode, promptRefinement: props.promptRefinement ?? true, }; const data = await postJson(props.backendUrl, '/chat', requestBody, authKey); if (abortController.signal.aborted) return; if (!data.media?.images?.[0]) { throw new Error(data.message || data.text || 'No image returned'); } setRegenProgress(100); const newItem = { id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, status: 'done', url: data.media.images[0], createdAt: Date.now(), prompt: data.media.final_prompt || lightboxPrompt, seed: data.media.seed || data.media.seeds?.[0], width: data.media.width || selectedImage.width, height: data.media.height || selectedImage.height, steps: data.media.steps || (advancedMode ? customSteps : undefined), cfg: data.media.cfg || (advancedMode ? customCfg : undefined), model: data.media.model || props.modelImages, }; // Update selected image in-place (Grok behavior) setSelectedImage(newItem); setLightboxPrompt(newItem.prompt); // Also prepend to items list setItems(prev => [newItem, ...prev]); await new Promise(r => setTimeout(r, 300)); } catch (err) { if (abortController.signal.aborted) return; console.error('Regeneration failed:', err); alert(`Regeneration failed: ${err.message || err}`); } finally { clearInterval(progressInterval); setIsRegenerating(false); setRegenProgress(null); setRegenAbortController(null); } }, [selectedImage, lightboxPrompt, isRegenerating, advancedMode, customSteps, customCfg, seedLock, customSeed, props, authKey]); // Cancel in-place regeneration const handleCancelRegeneration = useCallback(() => { if (regenAbortController) { regenAbortController.abort(); setIsRegenerating(false); setRegenProgress(null); setRegenAbortController(null); } }, [regenAbortController]); const handleUpscale = async (item, e) => { if (e) { e.stopPropagation(); } if (upscalingId) { alert('Already upscaling an image. Please wait.'); return; } try { if (!item.url) { alert('Cannot upscale: image URL not available'); return; } setUpscalingId(item.id); const result = await upscaleImage({ backendUrl: props.backendUrl, apiKey: authKey, imageUrl: item.url, scale: 2, model: '4x-UltraSharp.pth', }); const upscaledUrl = result?.media?.images?.[0]; if (upscaledUrl) { // Add upscaled image to gallery (non-destructive - keeps original) const newItem = { id: `upscale-${Date.now()}-${Math.random().toString(16).slice(2)}`, status: 'done', url: upscaledUrl, createdAt: Date.now(), prompt: `[Upscaled 2x] ${item.prompt}`, seed: item.seed, width: (item.width ?? 1024) * 2, height: (item.height ?? 1024) * 2, steps: item.steps, cfg: item.cfg, model: item.model, }; setItems((prev) => [newItem, ...prev]); } else { alert('Upscale completed but no image was returned.'); } } catch (err) { alert(`Failed to upscale image: ${err.message || err}`); } finally { setUpscalingId(null); } }; return (
Override resolution to test what works best on your GPU. Lower = faster, less VRAM.
{selectedImage.prompt}