Auto-Illustrator Bridge

Generates images, injects widgets, and manages the Tombstone Graveyard.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name        Auto-Illustrator Bridge
// @namespace   Violentmonkey Scripts
// @match       https://novelai.net/*
// @license       MIT
// @inject-into page
// @run-at      document-idle
// @grant       none
// @version     5.4.0
// @description Generates images, injects widgets, and manages the Tombstone Graveyard.
// ==/UserScript==

(function () {
    'use strict';
    console.log("[Bridge] v5.4.0 active. Initializing persistent scanner...");

    function getAuthToken() {
        try {
            const s = localStorage.getItem('session');
            if (s) { const p = JSON.parse(s); return "Bearer " + (p.token || p.auth_token || p.access_token); }
        } catch(_) {}
        return null;
    }

    const DB_NAME = 'nai_illustrator', DB_STORE = 'images';
    let db = null;

    async function openDB() {
        if (db) return db;
        return new Promise((res, rej) => {
            const req = indexedDB.open(DB_NAME, 1);
            req.onupgradeneeded = e => e.target.result.createObjectStore(DB_STORE, { keyPath: 'uuid' });
            req.onsuccess = e => { db = e.target.result; res(db); };
            req.onerror   = e => rej(e.target.error);
        });
    }
    
    async function dbSave(uuid, prompt, dataUrl, fp, fu, characters, settings) {
        const d = await openDB();
        return new Promise((res, rej) => {
            const tx = d.transaction(DB_STORE, 'readwrite');
            tx.objectStore(DB_STORE).put({ uuid, prompt, dataUrl, fp, fu, characters, settings, ts: Date.now() });
            tx.oncomplete = res; tx.onerror = e => rej(e.target.error);
        });
    }
    
    async function dbGetAll() {
        const d = await openDB();
        return new Promise((res, rej) => {
            const req = d.transaction(DB_STORE, 'readonly').objectStore(DB_STORE).getAll();
            req.onsuccess = e => res(e.target.result); req.onerror = e => rej(e.target.error);
        });
    }

    // --- TOMBSTONE GRAVEYARD LOGIC ---
    function isDeleted(uuid) {
        const d = JSON.parse(localStorage.getItem('nai_img_del') || '[]');
        return d.includes(uuid);
    }
    
    function markDeleted(uuid) {
        const d = JSON.parse(localStorage.getItem('nai_img_del') || '[]');
        if (!d.includes(uuid)) {
            d.push(uuid);
            localStorage.setItem('nai_img_del', JSON.stringify(d));
        }
    }

    const imgCache    = new Map();
    const activeCache = new Map();

    function buildRequestBody(fp, fu, characters, settings) {
        const charPos = (characters || []).filter(c => c.visuals).map(c => ({ char_caption: c.visuals, centers: [{ x: 0.5, y: 0.5 }] }));
        const charNeg = (characters || []).filter(c => c.uc).map(c => ({ char_caption: c.uc, centers: [{ x: 0.5, y: 0.5 }] }));
        return {
            input: fp, model: "nai-diffusion-4-5-full", action: "generate",
            parameters: {
                width: settings.width, height: settings.height, scale: settings.scale,
                sampler: "k_euler_ancestral", noise_schedule: "karras", steps: settings.steps, n_samples: 1,
                seed: Math.floor(Math.random() * 4294967295),
                params_version: 3, qualityToggle: false, uc: fu,
                characterPrompts: [], reference_image_multiple: [],
                reference_information_extracted_multiple: [], reference_strength_multiple: [],
                v4_prompt:          { caption: { base_caption: fp, char_captions: charPos }, use_coords: false, use_order: true },
                v4_negative_prompt: { caption: { base_caption: fu, char_captions: charNeg }, use_coords: false, use_order: true }
            }
        };
    }

    async function callGenerateAPI(body) {
        const auth = getAuthToken();
        if (!auth || auth === "Bearer undefined") throw new Error("No NovelAI auth token found.");
        
        const resp = await fetch("https://image.novelai.net/ai/generate-image-stream", {
            method: "POST",
            headers: { "Content-Type": "application/json", "Authorization": auth },
            body: JSON.stringify(body)
        });
        if (!resp.ok) throw new Error(`NovelAI API rejected request (${resp.status})`);

        const reader = resp.body.getReader(), dec = new TextDecoder();
        let buf = '', chunks = [], lastJpeg = null;
        
        while (true) {
            const { done, value } = await reader.read();
            if (done) break;
            buf += dec.decode(value, { stream: true });
            const lines = buf.split('\n'); buf = lines.pop();
            for (const line of lines) {
                if (line.startsWith('data:')) chunks.push(line.slice(5).trim());
                else if (line.trim() === '' && chunks.length) {
                    try { const ev = JSON.parse(chunks.join(''));
                    if (ev.image) lastJpeg = ev.image; } catch(_) {}
                    chunks = [];
                }
            }
        }
        if (!lastJpeg) throw new Error("No image data returned from AI.");
        return `data:image/jpeg;base64,${lastJpeg}`;
    }

    function openFullscreen(dataUrl) {
        const d = document.createElement('div');
        d.setAttribute('style', 'position:fixed;inset:0;width:100vw;height:100vh;background:rgba(0,0,0,0.95);display:flex;align-items:center;justify-content:center;cursor:pointer;z-index:99999;');
        d.addEventListener('click', () => d.remove());
        const fi = document.createElement('img');
        fi.src = dataUrl;
        fi.style.cssText = 'max-width:95vw;max-height:95vh;border-radius:6px;pointer-events:none;';
        d.appendChild(fi);
        document.body.appendChild(d);
    }

    function makeTextarea(parent, label, value, rows) {
        const lbl = document.createElement('p');
        lbl.style.cssText = 'color:rgba(255,255,255,0.5);font-size:12px;margin:12px 0 4px;';
        lbl.textContent = label;
        const ta = document.createElement('textarea');
        ta.value = value || '';
        ta.rows  = rows || 4;
        ta.style.cssText = 'width:100%;box-sizing:border-box;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.15);color:#fff;border-radius:6px;padding:8px;font-size:13px;resize:vertical;font-family:inherit;';
        parent.appendChild(lbl);
        parent.appendChild(ta);
        return ta;
    }

    function openPromptEditor(uuid) {
        const entry = activeCache.get(uuid) || imgCache.get(uuid);
        if (!entry) return;
        const { fp = '', fu = '', characters = [], settings } = entry;
        
        const dialog = document.createElement('div');
        dialog.setAttribute('style', 'position:fixed;inset:0;width:100vw;height:100vh;background:rgba(0,0,0,0.88);overflow-y:auto;padding:24px 16px 48px;box-sizing:border-box;z-index:99999;');
        dialog.addEventListener('click', e => { if (e.target === dialog) dialog.remove(); });

        const panel = document.createElement('div');
        panel.style.cssText = 'max-width:640px;margin:0 auto;background:#1a1a2e;border-radius:10px;padding:20px;';
        
        const hdr = document.createElement('div');
        hdr.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;';
        const ttl = document.createElement('span');
        ttl.style.cssText = 'color:#fff;font-size:15px;font-weight:bold;';
        ttl.textContent = '✏️ View / Edit Prompt';
        const cls = document.createElement('button');
        cls.textContent = '✕';
        cls.setAttribute('style', 'background:rgba(255,255,255,0.1);border:none;color:#fff;padding:4px 12px;border-radius:4px;cursor:pointer;font-size:15px;');
        cls.addEventListener('click', () => dialog.remove());
        hdr.appendChild(ttl); hdr.appendChild(cls);
        panel.appendChild(hdr);

        const taFp = makeTextarea(panel, 'Base Prompt  (quality tags + scene)', fp, 5);
        const taFu = makeTextarea(panel, 'Base Undesired Content', fu, 3);

        const charHeader = document.createElement('p');
        charHeader.style.cssText = 'color:rgba(255,255,255,0.5);font-size:12px;margin:16px 0 6px;';
        charHeader.textContent = `Characters (${characters.length} attached)`;
        panel.appendChild(charHeader);

        const charFields = characters.length ? characters.map((c, i) => {
            const sec = document.createElement('div');
            sec.style.cssText = 'margin-bottom:10px;padding:12px;border:1px solid rgba(255,255,255,0.12);border-radius:8px;';
            const secHdr = document.createElement('p');
            secHdr.style.cssText = 'color:rgba(255,255,255,0.75);font-size:13px;font-weight:bold;margin:0 0 4px;';
            secHdr.textContent = `Character: ${c.name || `#${i + 1}`}`;
            sec.appendChild(secHdr);
            const taV = makeTextarea(sec, 'Visuals  →  char_caption (positive)', c.visuals || '', 3);
            const taU = makeTextarea(sec, 'Undesired Content  →  char_caption (negative)', c.uc || '', 2);
            panel.appendChild(sec);
            return { taV, taU };
        }) : (() => {
            const note = document.createElement('p');
            note.style.cssText = 'color:rgba(255,255,255,0.3);font-size:12px;font-style:italic;padding:10px;border:1px dashed rgba(255,255,255,0.1);border-radius:6px;margin:0;';
            note.textContent = 'No lorebook entries matched. Keys must appear in the trigger prompt. Entries need [Visuals: ...] and [UC: ...] tags.';
            panel.appendChild(note);
            return [];
        })();

        const regenBtn = document.createElement('button');
        regenBtn.textContent = settings ? '🔄 Regenerate' : '🔄 Regenerate (unavailable — no settings in record)';
        regenBtn.disabled = !settings;
        regenBtn.setAttribute('style', `margin-top:18px;width:100%;padding:10px;background:${settings ? '#4f46e5' : '#333'};border:none;color:#fff;border-radius:6px;cursor:${settings ? 'pointer' : 'not-allowed'};font-size:14px;font-weight:bold;opacity:${settings ? '1' : '0.5'};`);
        
        regenBtn.addEventListener('click', async () => {
            if (!settings) return;
            regenBtn.disabled = true;
            regenBtn.textContent = '⏳ Generating…';
            try {
                const newFp    = taFp.value;
                const newFu    = taFu.value;
                const newChars = characters.map((c, i) => ({
                    name:    c.name,
                    visuals: charFields[i]?.taV.value ?? c.visuals,
                    uc:      charFields[i]?.taU.value ?? c.uc
                }));
                await regenImage(uuid, newFp, newFu, newChars, settings);
                dialog.remove();
            } catch(e) {
                console.error('[Bridge] Regen failed:', e);
                regenBtn.textContent = '❌ Failed — try again';
                regenBtn.disabled = false;
            }
        });
        panel.appendChild(regenBtn);

        dialog.appendChild(panel);
        document.body.appendChild(dialog);
    }

    async function regenImage(uuid, fp, fu, characters, settings) {
        console.log(`[Bridge] Regenerating UUID: ${uuid}`);
        const body    = buildRequestBody(fp, fu, characters, settings);
        const dataUrl = await callGenerateAPI(body);
        
        const entry = { dataUrl, fp, fu, characters: characters || [], settings };
        imgCache.set(uuid, entry);
        if (activeCache.has(uuid)) activeCache.set(uuid, entry);
        await dbSave(uuid, fp, dataUrl, fp, fu, characters, settings);

        const oldImg = document.querySelector(`[data-nai-uuid="${uuid}"]`);
        if (oldImg) {
            const newImg = oldImg.cloneNode(false);
            newImg.src = dataUrl;
            newImg.addEventListener('click', () => openFullscreen(dataUrl));
            oldImg.replaceWith(newImg);
            
            const wrap   = newImg.parentElement;
            const oldBtn = wrap?.querySelector('button[data-nai-edit]');
            if (oldBtn) {
                const newBtn = oldBtn.cloneNode(true);
                newBtn.addEventListener('click', e => { e.stopPropagation(); openPromptEditor(uuid); });
                oldBtn.replaceWith(newBtn);
            }
        } else {
            injectImage(uuid, dataUrl);
        }
    }

    function findPlaceholder(uuid) {
        const marker = `nai-img:${uuid}`;
        const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null);
        let node;
        while ((node = walker.nextNode())) {
            if (node.textContent.includes(marker)) {
                let el = node.parentElement;
                for (let i = 0; i < 5 && el; i++) {
                    if (el.childElementCount > 0 || el.offsetHeight > 0) return el;
                    el = el.parentElement;
                }
                return node.parentElement;
            }
        }
        return null;
    }
    
    function updatePlaceholderStatus(uuid, text, isError = false) {
        const container = findPlaceholder(uuid);
        if (!container) return;
        const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
        let n;
        while ((n = walker.nextNode())) {
            if (n.textContent.includes('Generating illustration') || n.textContent.includes('Bridge:')) {
                n.textContent = text;
                if (isError) n.parentElement.style.color = '#ff6b6b';
                
                // Add the Cancel button if it doesn't already exist
                if (!container.querySelector('.nai-cancel-btn')) {
                    const btn = document.createElement('button');
                    btn.className = 'nai-cancel-btn';
                    btn.textContent = '❌';
                    btn.title = 'Cancel & Delete Stuck Widget';
                    btn.style.cssText = 'margin-left:10px;background:rgba(255,50,50,0.3);border:none;border-radius:4px;color:#fff;cursor:pointer;padding:2px 8px;font-size:11px;';
                    btn.onclick = (e) => {
                        e.stopPropagation();
                        markDeleted(uuid); // Add to Tombstone Graveyard
                        container.style.display = 'none'; // Silence it instantly
                    };
                    n.parentElement.appendChild(btn);
                }
                break;
            }
        }
    }

    function injectImage(uuid, dataUrl) {
        const container = findPlaceholder(uuid);
        if (!container) return false;
        
        if (container.querySelector(`[data-nai-uuid="${uuid}"]`)) return true;

        const wrap = document.createElement('div');
        wrap.dataset.naiWrap = uuid;
        wrap.style.cssText = 'position:relative;display:block;text-align:center;';
        
        const img = document.createElement('img');
        img.src = dataUrl;
        img.dataset.naiUuid = uuid;
        img.style.cssText = 'max-width:100%;max-height:55vh;width:auto;height:auto;display:block;margin:0 auto;border-radius:6px;box-shadow:0 2px 12px rgba(0,0,0,0.5);cursor:pointer;';
        img.addEventListener('click', () => openFullscreen(dataUrl));

        const editBtn = document.createElement('button');
        editBtn.textContent = '✏️';
        editBtn.title = 'View / Edit Prompt';
        editBtn.dataset.naiEdit = uuid;
        editBtn.setAttribute('style', 'position:absolute;top:6px;right:6px;background:rgba(0,0,0,0.65);border:none;color:#fff;border-radius:4px;padding:3px 8px;cursor:pointer;font-size:13px;line-height:1;');
        editBtn.addEventListener('click', e => { e.stopPropagation(); openPromptEditor(uuid); });

        const dlBtn = document.createElement('button');
        dlBtn.textContent = '⬇️';
        dlBtn.title = 'Download Image';
        dlBtn.setAttribute('style', 'position:absolute;top:6px;right:40px;background:rgba(0,0,0,0.65);border:none;color:#fff;border-radius:4px;padding:3px 8px;cursor:pointer;font-size:13px;line-height:1;');
        dlBtn.addEventListener('click', e => { 
            e.stopPropagation();
            const a = Object.assign(document.createElement('a'), { href: dataUrl, download: `nai_${uuid}.jpg` });
            document.body.appendChild(a); a.click(); document.body.removeChild(a);
        });

        // The Graveyard Delete Button
        const delBtn = document.createElement('button');
        delBtn.textContent = '🗑️';
        delBtn.title = 'Delete Image';
        delBtn.setAttribute('style', 'position:absolute;top:6px;right:74px;background:rgba(0,0,0,0.65);border:none;color:#fff;border-radius:4px;padding:3px 8px;cursor:pointer;font-size:13px;line-height:1;');
        delBtn.addEventListener('click', async (e) => { 
            e.stopPropagation();
            if (confirm("Delete this image from the gallery and database?")) {
                wrap.style.display = 'none';
                imgCache.delete(uuid);
                activeCache.delete(uuid);
                markDeleted(uuid); // Add to Tombstone Graveyard
                try {
                    const d = await openDB();
                    d.transaction(DB_STORE, 'readwrite').objectStore(DB_STORE).delete(uuid);
                } catch(err) { console.error("Failed to delete from DB", err); }
            }
        });

        wrap.appendChild(img);
        wrap.appendChild(editBtn);
        wrap.appendChild(dlBtn);
        wrap.appendChild(delBtn);
        
        container.innerHTML = '';
        container.appendChild(wrap);
        
        const existing = imgCache.get(uuid);
        activeCache.set(uuid, existing ? { ...existing, dataUrl } : { dataUrl, fp: '', fu: '', characters: [], settings: null });
        return true;
    }

    const genQueue  = [];
    let   genActive = false;
    let currentlyGeneratingUuid = null;

    function queueGeneration(payload) {
        genQueue.push(payload);
        if (!genActive) drainQueue();
    }
    
    async function drainQueue() {
        if (genActive || !genQueue.length) return;
        genActive = true;
        while (genQueue.length) {
            const payload = genQueue.shift();
            currentlyGeneratingUuid = payload.uuid;
            await handleSignal(payload);
            currentlyGeneratingUuid = null;
        }
        genActive = false;
    }

    function scanDOMForPayloads() {
        const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null);
        let n;
        while ((n = walker.nextNode())) {
            const txt = n.textContent || "";
            if (txt.includes("nai-img:") && txt.includes("|||")) {
                const parts = txt.split('|||');
                const uuid = parts[0].split('nai-img:')[1].trim();
                
                // If it is in the Graveyard, silence it immediately!
                if (isDeleted(uuid)) {
                    let container = findPlaceholder(uuid);
                    if (container) container.style.display = 'none';
                    continue;
                }
                
                if (imgCache.has(uuid)) {
                    injectImage(uuid, imgCache.get(uuid).dataUrl);
                } else {
                    try {
                        const payloadStr = parts[1].trim().replace(/[\u200B-\u200D\uFEFF]/g, '');
                        const payload = JSON.parse(payloadStr);
                        if (uuid !== currentlyGeneratingUuid && !genQueue.find(q => q.uuid === uuid)) {
                            queueGeneration(payload);
                        }
                    } catch(e) { }
                }
            }
        }
    }

    const processedEls = new WeakSet();

    new MutationObserver((mutations) => {
        for (const m of mutations) {
            if (m.type === 'characterData') {
                const text = m.target.textContent || "";
                if (text.includes("nai-img:")) scanDOMForPayloads();
            } else {
                for (const node of m.addedNodes) {
                    const text = node.textContent || "";
                    if (text.includes("SHOW_GALLERY:")) checkAndCatch(node);
                    if (text.includes("nai-img:")) scanDOMForPayloads();
                }
            }
        }
    }).observe(document.body, { childList: true, subtree: true, characterData: true });

    setInterval(() => {
        document.querySelectorAll('.Toastify__toast, [role="alert"]').forEach(el => {
            if (!processedEls.has(el) && el.textContent.includes('SHOW_GALLERY:')) checkAndCatch(el);
        });
    }, 200);

    function checkAndCatch(el) {
        if (!el || processedEls.has(el)) return;
        processedEls.add(el);
        const text = el.textContent || "";
        if (!text.includes("SHOW_GALLERY:")) return;

        el.style.cssText += ";opacity:0!important;position:absolute!important;pointer-events:none!important;";
        const toast = el.closest('.Toastify__toast') || el.closest('[role="alert"]');
        if (toast) toast.style.cssText += ";opacity:0!important;position:absolute!important;pointer-events:none!important;";
        showGallery(); 
    }

    async function handleSignal(payload) {
        const { uuid, settings, characters, autoDownload } = payload;
        
        updatePlaceholderStatus(uuid, "Bridge: Requesting image from AI...");

        const fp = [settings.quality, payload.prompt].filter(Boolean).join(", ");
        const fu = settings.negative || '';
        
        try {
            const body    = buildRequestBody(fp, fu, characters, settings);
            const dataUrl = await callGenerateAPI(body);
            
            updatePlaceholderStatus(uuid, "Bridge: Image received! Injecting...");

            const entry = { dataUrl, fp, fu, characters: characters || [], settings };
            imgCache.set(uuid, entry);
            await dbSave(uuid, payload.prompt, dataUrl, fp, fu, characters, settings);
            
            if (autoDownload) {
                const a = Object.assign(document.createElement('a'), { href: dataUrl, download: `nai_${uuid}.jpg` });
                document.body.appendChild(a); a.click(); document.body.removeChild(a);
            }

            injectImage(uuid, dataUrl);
        } catch(e) { 
            console.error("[Bridge] Generation failed:", e); 
            updatePlaceholderStatus(uuid, `Bridge Error: ${e.message}`, true);
        }
    }

    function showGallery() {
        if (document.getElementById('nai-gallery-overlay')) return;

        const dialog = document.createElement('div');
        dialog.id = 'nai-gallery-overlay';
        dialog.setAttribute('style', [
            'position:fixed', 'inset:0', 'width:100vw', 'height:100vh',
            'background:rgba(0,0,0,0.92)', 'overflow-y:auto',
            'padding:16px', 'box-sizing:border-box', 'z-index:99999'
        ].join(';'));
        dialog.addEventListener('click', e => { if (e.target === dialog) dialog.remove(); });

        const hdr = document.createElement('div');
        hdr.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;position:sticky;top:0;background:rgba(0,0,0,0.92);z-index:1;padding-bottom:8px;';
        hdr.innerHTML = `<span style="color:#fff;font-size:16px;font-weight:bold">🖼 Story Gallery (${activeCache.size})</span>`;
        const cls = document.createElement('button');
        cls.textContent = '✕';
        cls.setAttribute('style', 'background:rgba(255,255,255,0.1);border:none;color:#fff;padding:4px 12px;border-radius:4px;cursor:pointer;font-size:15px;');
        cls.addEventListener('click', () => dialog.remove());
        hdr.appendChild(cls);
        dialog.appendChild(hdr);

        if (!activeCache.size) {
            const empty = document.createElement('p');
            empty.setAttribute('style', 'color:rgba(255,255,255,0.4);text-align:center;padding:40px 0;');
            empty.textContent = 'No images generated this session.';
            dialog.appendChild(empty);
        } else {
            const grid = document.createElement('div');
            grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:10px;padding-bottom:32px;';
            for (const [uuid, entry] of activeCache) {
                const { dataUrl } = entry;
                const cell = document.createElement('div');
                cell.style.cssText = 'position:relative;aspect-ratio:2/3;overflow:hidden;border-radius:6px;cursor:pointer;background:#111;';

                const thumb = document.createElement('img');
                thumb.src = dataUrl;
                thumb.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;';
                thumb.addEventListener('click', () => openFullscreen(dataUrl));

                const dl = document.createElement('button');
                dl.textContent = '⬇';
                dl.setAttribute('style', 'position:absolute;bottom:4px;right:4px;background:rgba(0,0,0,0.65);color:#fff;border:none;border-radius:4px;padding:2px 7px;cursor:pointer;font-size:13px;');
                dl.addEventListener('click', e => {
                    e.stopPropagation();
                    const a = Object.assign(document.createElement('a'), { href: dataUrl, download: `nai_${uuid}.jpg` });
                    document.body.appendChild(a); a.click(); document.body.removeChild(a);
                });
                
                const editBtn = document.createElement('button');
                editBtn.textContent = '✏️';
                editBtn.title = 'View / Edit Prompt';
                editBtn.setAttribute('style', 'position:absolute;top:4px;right:32px;background:rgba(0,0,0,0.65);color:#fff;border:none;border-radius:4px;padding:2px 7px;cursor:pointer;font-size:12px;');
                editBtn.addEventListener('click', e => {
                    e.stopPropagation();
                    dialog.remove(); 
                    openPromptEditor(uuid);
                });
                
                cell.appendChild(thumb); cell.appendChild(dl); cell.appendChild(editBtn);
                grid.appendChild(cell);
            }
            dialog.appendChild(grid);
        }

        document.body.appendChild(dialog);
    }

    async function restoreFromDB() {
        const records = await dbGetAll().catch(() => []);
        for (const rec of records) {
            const entry = {
                dataUrl:    rec.dataUrl,
                fp:         rec.fp         ?? rec.prompt ?? '',
                fu:         rec.fu         ?? '',
                characters: rec.characters ?? [],
                settings:   rec.settings   ?? null
            };
            imgCache.set(rec.uuid, entry);
        }
    }

    restoreFromDB().then(() => {
        setInterval(scanDOMForPayloads, 1000);
    });

})();