Auto-Illustrator Bridge

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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);
    });

})();