Generates images, injects widgets, and manages the Tombstone Graveyard.
// ==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);
});
})();