Birthright Awaits

Your D&D 3.5e Birthright Campaign Setting assistant, powered by Mistral AI.

  • Rules, spells, feats, and class information
  • Birthright domains, bloodlines, and realm magic
  • Create and manage character sheets
  • Anuirean, Brecht, Khinasi, Rjurik, and Vos lore
  • Search the reference library directly
  • English Dialect Translation
Welsh
Australian
Scottish
Irish
Cockney
Pirate
Southern US
Jamaican

🔒 Login

Enter your email to save conversations. Admins get extra site controls.

📖 Reference Search

Search the reference library for rules, lore, spells, and more.

⚂ Dice Roller

Select a die and roll!

History

⚙ Admin Panel

DM Context & Persona
House Rules
Import from URL

Title
Content (paste text or fetched above)

Imported Content
  • Loading...

Upload Full Book (Proper Indexing)

Indexes an entire book with the same search quality as the built-in library. Accepts PDF (text-layer), .txt, or .md. Max 20MB for PDFs.

Indexed Books
  • Loading...

Describe a D&D feature to add to the site — the AI will generate and deploy it. Only D&D-related features are allowed. Changes appear after reloading the page.

Ctrl+Enter to submit, or click Build


Active Features
  • Loading...

Manage Users

    --fa4bb2d056bec58c369fb1877e06e808dfaae30205a37ea4fc75fcc53809 --408eee63bae1c46d85ae134c5056dec784c2e7ade88dd20be33e102933e8 --84297b87f3b5fe63284e531ed8b7ec4d64a6d238f10bf041e73b284127af --b19f72efcff17e2609cfd1a47f78a5555f65d1150c4f3592923736191bff --fff5bdf244cc5a25cc2ac86fb28c8901b4846691b2ee3668c74210fb2861 --74733b0848aaeecccdbd55cf7f7bbbea4cb48a821c98c61238b64ab1ec90 --e639aaedc7b39e16b899f31fe4545294c16430c0f142b9fa4a3ac49c33f7 --42c7dbd123680a3078ed7d537c45ba8307b902fbbd0721e8fcd1bfc6e448 --f369bbef2cbf9bd48289b6aa72c9feb40c79c43c49f6a55837442711e96e --2daa03b02fd1434d5cbf314630524fdbb83ee7de251658027ae279fa392d Content-Disposition: form-data; name="worker.js"; filename="worker.js" var __defProp = Object.defineProperty; var __name = (target, value) => __defProp(target, "name", { value, configurable: true }); // src/worker.js import HTML from "./c3cf0fb0e36bafacbb7ac8bf36dc8ac44627bb23-index.html"; var ALLOWED_ORIGINS = [ "https://jdungeon.bnbnerd.com", "https://cerilia.bnbnerd.com", "http://localhost:8787", "http://localhost:8788", "http://127.0.0.1:8787" ]; function corsHeaders(request) { const origin = request.headers.get("Origin") || ""; const allowed = ALLOWED_ORIGINS.find((o) => origin === o) || (origin.startsWith("http://localhost:") ? origin : null); return { "Access-Control-Allow-Origin": allowed || ALLOWED_ORIGINS[0], "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization, Cf-Access-Jwt-Assertion, X-User-Email", "Access-Control-Allow-Credentials": "true", "Access-Control-Max-Age": "86400" }; } __name(corsHeaders, "corsHeaders"); function jsonResponse(data, status = 200, request = null) { const headers = { "Content-Type": "application/json", ...request ? corsHeaders(request) : {} }; return new Response(JSON.stringify(data), { status, headers }); } __name(jsonResponse, "jsonResponse"); var STOPWORDS = /* @__PURE__ */ new Set([ "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "for", "of", "with", "by", "from", "is", "it", "its", "are", "was", "were", "be", "been", "being", "have", "has", "had", "do", "does", "did", "will", "would", "could", "should", "may", "might", "shall", "can", "not", "no", "nor", "so", "if", "then", "than", "that", "this", "these", "those", "what", "which", "who", "whom", "when", "where", "how", "all", "each", "every", "both", "few", "more", "most", "other", "some", "such", "only", "own", "same", "too", "very", "just", "because", "as", "until", "while", "about", "between", "through", "during", "before", "after", "above", "below", "up", "down", "out", "off", "over", "under", "again", "further", "once", "here", "there", "also", "into", "he", "she", "they", "them", "his", "her", "my", "your", "our", "their", "me", "him", "us", "we", "you", "i", "any", "page", "see", "table" ]); var BOOST_TERMS = /* @__PURE__ */ new Set([ "birthright", "cerilia", "anuirean", "anuire", "brecht", "khinasi", "rjurik", "vos", "vosgaard", "domain", "regent", "regency", "bloodline", "bloodmark", "blooded", "bloodtheft", "blood", "realm", "province", "holding", "law", "temple", "guild", "source", "awnsheghlien", "awnshegh", "ehrshegh", "ehrsheghlien", "shadow", "deismaar", "azrai", "anduiras", "reynir", "brenna", "masela", "vorynn", "basaia" ]); function tokenize(text) { return text.toLowerCase().replace(/[^a-z0-9'\-]/g, " ").split(/\s+/).filter((t) => t.length > 1 && !STOPWORDS.has(t)); } __name(tokenize, "tokenize"); function getUserEmail(request) { const jwt = request.headers.get("Cf-Access-Jwt-Assertion"); if (jwt) { try { const parts = jwt.split("."); if (parts.length === 3) { const payload = JSON.parse(atob(parts[1])); if (payload.email) return payload.email.toLowerCase(); } } catch (e) { } } const devEmail = request.headers.get("X-User-Email"); if (devEmail) return devEmail.toLowerCase(); const cookies = request.headers.get("Cookie") || ""; const jdCookie = cookies.split(";").find((c) => c.trim().startsWith("jd_email=")); if (jdCookie) { try { const v = decodeURIComponent(jdCookie.split("=")[1].trim()); if (v && v.includes("@")) return v.toLowerCase(); } catch (e) {} } const cfAuth = cookies.split(";").find((c) => c.trim().startsWith("CF_Authorization=")); if (cfAuth) { try { const token = cfAuth.split("=")[1].trim(); const parts = token.split("."); if (parts.length === 3) { const payload = JSON.parse(atob(parts[1])); if (payload.email) return payload.email.toLowerCase(); } } catch (e) { } } return null; } __name(getUserEmail, "getUserEmail"); function getUserRole(email) { // SOURCE OF TRUTH for jdungeon + cerilia admin lists. // cerilia.bnbnerd.com authorizes via this Worker's /api/whoami endpoint // — it does NOT keep its own copy. Add/remove admins here only. if (!email) return "guest"; const superadmins = ["isaacbcole@gmail.com", "isaac@bnbnerd.com"]; const admins = ["jonah@hoosiervacations.com"]; if (superadmins.includes(email)) return "superadmin"; if (admins.includes(email)) return "admin"; return "user"; } __name(getUserRole, "getUserRole"); async function searchChunks(query, env, topK = 8) { const tokens = tokenize(query); if (tokens.length === 0) return []; const shardLetters = /* @__PURE__ */ new Set(); for (const token of tokens) { const firstChar = token[0]; if (firstChar >= "a" && firstChar <= "z") { shardLetters.add(firstChar); } else if (firstChar >= "0" && firstChar <= "9") { shardLetters.add("0"); } } const shardPromises = Array.from(shardLetters).map( (letter) => env.CHUNKS.get(`IDX:${letter}`, { type: "json" }).then((data) => ({ letter, data })) ); const shards = await Promise.all(shardPromises); const chunkScores = /* @__PURE__ */ new Map(); for (const { data } of shards) { if (!data) continue; for (const token of tokens) { const entries = data[token]; if (!entries) continue; const boost = BOOST_TERMS.has(token) ? 2 : 1; for (const entry of entries) { const current = chunkScores.get(entry.id) || { score: 0, terms: 0 }; current.score += entry.score * boost; current.terms += 1; chunkScores.set(entry.id, current); } } } if (chunkScores.size === 0) return []; const totalQueryTerms = tokens.length; const scored = []; for (const [id, { score, terms }] of chunkScores) { const coordBoost = terms / totalQueryTerms; scored.push({ id, score: score * (0.5 + 0.5 * coordBoost) }); } scored.sort((a, b) => b.score - a.score); const topChunks = scored.slice(0, topK); const batchSize = 50; const batchIds = /* @__PURE__ */ new Set(); for (const chunk of topChunks) { batchIds.add(Math.floor(chunk.id / batchSize)); } const batchPromises = Array.from(batchIds).map( (batchId) => env.CHUNKS.get(`CHUNKS:${batchId}`, { type: "json" }).then((data) => ({ batchId, data })) ); const batches = await Promise.all(batchPromises); const chunkLookup = /* @__PURE__ */ new Map(); for (const { batchId, data } of batches) { if (!data) continue; for (const chunk of data) { chunkLookup.set(chunk.id, chunk); } } const maxScore = topChunks[0]?.score || 1; const results = []; for (const { id, score } of topChunks) { const chunk = chunkLookup.get(id); if (chunk) { results.push({ id: chunk.id, book_name: chunk.book_name, text: chunk.text, score: score / maxScore // normalize to 0..1 }); } } return results; } __name(searchChunks, "searchChunks"); async function getSession(env, sessionId) { if (!sessionId) return null; const data = await env.CHUNKS.get(`SESSION:${sessionId}`, { type: "json" }); return data; } __name(getSession, "getSession"); async function saveSession(env, session) { await env.CHUNKS.put(`SESSION:${session.id}`, JSON.stringify(session)); } __name(saveSession, "saveSession"); async function getUserSessions(env, email) { if (!email) return []; const data = await env.CHUNKS.get(`USERSESSIONS:${email}`, { type: "json" }); return data || []; } __name(getUserSessions, "getUserSessions"); async function saveUserSessions(env, email, sessions) { await env.CHUNKS.put(`USERSESSIONS:${email}`, JSON.stringify(sessions)); } __name(saveUserSessions, "saveUserSessions"); async function getUserList(env) { const data = await env.CHUNKS.get("USERLIST", { type: "json" }); return data || [ { email: "isaacbcole@gmail.com" }, { email: "isaac@bnbnerd.com" }, { email: "jonah@hoosiervacations.com" } ]; } __name(getUserList, "getUserList"); async function saveUserList(env, users) { await env.CHUNKS.put("USERLIST", JSON.stringify(users)); } __name(saveUserList, "saveUserList"); function generateId() { return crypto.randomUUID(); } __name(generateId, "generateId"); var SYSTEM_PROMPT_TEMPLATE = `You are JDungeon, an expert assistant for the Birthright Campaign Setting (BRCS) and AD&D/D&D 3.5e rules. You have access to the following reference material. {dm_context} Answer using ONLY the reference material provided. If the answer isn't in the references, say "I don't have that information in my source books." Always cite which source book your answer comes from using [Source: Book Name] format. Keep answers focused and useful for tabletop gameplay. If asked about character building, provide specific stats, feats, and abilities. Reference Material: {chunks}`; var DIALECT_INSTRUCTIONS = { "welsh": ` IMPORTANT: Respond in a Welsh English dialect. Use Welsh speech patterns, expressions, and cadence. Say things like "look you", "there's lovely", "tidy", "mun", etc.`, "australian": '\n\nIMPORTANT: Respond in an Australian English dialect. Use Aussie slang and expressions like "mate", "reckon", "no worries", "crikey", "fair dinkum", etc.', "scottish": '\n\nIMPORTANT: Respond in a Scottish English dialect. Use Scottish expressions like "aye", "wee", "bonnie", "cannae", "dinnae", etc.', "irish": '\n\nIMPORTANT: Respond in an Irish English dialect. Use Irish expressions like "grand", "sure", "craic", "fierce", "deadly", etc.', "cockney": '\n\nIMPORTANT: Respond in a Cockney English dialect. Use Cockney rhyming slang and expressions like "blimey", "cor", "guv", etc.', "pirate": '\n\nIMPORTANT: Respond in a pirate dialect. Use pirate expressions like "arrr", "ye", "matey", "shiver me timbers", "avast", etc.', "southern-us": ` IMPORTANT: Respond in a Southern US English dialect. Use Southern expressions like "y'all", "fixin' to", "bless your heart", "reckon", etc.`, "jamaican": '\n\nIMPORTANT: Respond in a Jamaican English dialect. Use Jamaican patois expressions like "mon", "irie", "wa gwaan", "mi", etc.' }; async function callBedrock(messages, env) { const response = await fetch(`${env.BEDROCK_BASE_URL}/chat/completions`, { method: "POST", headers: { "Authorization": `Bearer ${env.BEDROCK_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ model: env.BEDROCK_MODEL, messages, max_tokens: 2e3, temperature: 0.3 }) }); if (!response.ok) { const text = await response.text(); throw new Error(`Bedrock API error ${response.status}: ${text}`); } const data = await response.json(); return data.choices?.[0]?.message?.content || "No response generated."; } __name(callBedrock, "callBedrock"); async function handleChat(request, env) { const body = await request.json(); const { message, session_id, dialect } = body; const email = getUserEmail(request) || body.user_email || "anonymous"; if (!message || typeof message !== "string") { return jsonResponse({ error: "Message is required" }, 400, request); } const [mainChunks, adminBookChunks] = await Promise.all([ searchChunks(message, env, 6), searchAdminBooks(message, env, 4) ]); const allResults = [...mainChunks, ...adminBookChunks]; const chunkTexts = allResults.map(c => `--- [${c.book_name}] ---\n${c.text}`).join("\n\n"); const adminSettings = await env.CHUNKS.get('ADMIN:settings', {type:'json'}) || {}; const dmContext = adminSettings.dm_context ? `\nDM Context & House Rules:\n${adminSettings.dm_context}\n` : ''; const importText = await searchAdminImports(message, env); const allChunks = [chunkTexts, importText].filter(Boolean).join('\n\n'); let systemPrompt = SYSTEM_PROMPT_TEMPLATE.replace('{dm_context}', dmContext).replace('{chunks}', allChunks || 'No reference material found.'); if (dialect && dialect !== "none" && DIALECT_INSTRUCTIONS[dialect]) { systemPrompt += DIALECT_INSTRUCTIONS[dialect]; } let session = null; let sessionId = session_id; if (sessionId) { session = await getSession(env, sessionId); } if (!session) { sessionId = generateId(); session = { id: sessionId, email, title: message.substring(0, 50) + (message.length > 50 ? "..." : ""), messages: [], created: Date.now() }; } const historyMessages = session.messages.slice(-6).map((m) => ({ role: m.role, content: m.content })); const llmMessages = [ { role: "system", content: systemPrompt }, ...historyMessages, { role: "user", content: message } ]; let responseText; try { responseText = await callBedrock(llmMessages, env); } catch (e) { console.error("Bedrock error:", e); return jsonResponse({ error: "AI service temporarily unavailable. Please try again.", session_id: sessionId }, 502, request); } session.messages.push({ role: "user", content: message, ts: Date.now() }); session.messages.push({ role: "assistant", content: responseText, ts: Date.now() }); if (session.messages.length > 40) { session.messages = session.messages.slice(-40); } await saveSession(env, session); const userSessions = await getUserSessions(env, email); const existingIdx = userSessions.findIndex((s) => s.id === sessionId); const sessionEntry = { id: sessionId, title: session.title, updated: Date.now() }; if (existingIdx >= 0) { userSessions[existingIdx] = sessionEntry; } else { userSessions.unshift(sessionEntry); } if (userSessions.length > 20) userSessions.length = 20; await saveUserSessions(env, email, userSessions); const sources = chunks.slice(0, 5).map((c) => ({ book_name: c.book_name, score: c.score })); return jsonResponse({ response: responseText, session_id: sessionId, sources, sessions: userSessions }, 200, request); } __name(handleChat, "handleChat"); async function handleSearch(request, env) { const url = new URL(request.url); const query = url.searchParams.get("q"); if (!query) { return jsonResponse({ error: "Query parameter q is required" }, 400, request); } const results = await searchChunks(query, env, 10); return jsonResponse({ query, results: results.map((r) => ({ book_name: r.book_name, text: r.text, score: r.score })) }, 200, request); } __name(handleSearch, "handleSearch"); async function handleGetSessions(request, env) { const email = getUserEmail(request); if (!email) return jsonResponse({ sessions: [] }, 200, request); const sessions = await getUserSessions(env, email); return jsonResponse({ sessions }, 200, request); } __name(handleGetSessions, "handleGetSessions"); async function handleGetSessionMessages(request, env, sessionId) { const session = await getSession(env, sessionId); if (!session) { return jsonResponse({ error: "Session not found" }, 404, request); } return jsonResponse({ messages: session.messages.map((m) => ({ role: m.role, content: m.content })) }, 200, request); } __name(handleGetSessionMessages, "handleGetSessionMessages"); async function handleGetUsers(request, env) { const email = getUserEmail(request); const role = getUserRole(email); if (role !== "superadmin" && role !== "admin") { return jsonResponse({ error: "Forbidden" }, 403, request); } const users = await getUserList(env); return jsonResponse({ users }, 200, request); } __name(handleGetUsers, "handleGetUsers"); async function handleDownloadCerilia(request, env, size) { const email = getUserEmail(request); if (!email) { const home = new URL("/", request.url); home.searchParams.set("from", "cerilia-dl"); return Response.redirect(home.toString(), 302); } const key = size === "full" ? "cerilia-full.jpg" : "cerilia-high.jpg"; if (!env.DOWNLOADS) { return new Response("Downloads bucket not bound.", { status: 500 }); } const obj = await env.DOWNLOADS.get(key); if (!obj) { return new Response("File not found in bucket.", { status: 404 }); } return new Response(obj.body, { headers: { "Content-Type": "image/jpeg", "Content-Disposition": `attachment; filename="${key}"`, "Cache-Control": "private, max-age=3600" } }); } __name(handleDownloadCerilia, "handleDownloadCerilia"); async function handleAddUser(request, env) { const callerEmail = getUserEmail(request); const callerRole = getUserRole(callerEmail); if (callerRole !== "superadmin" && callerRole !== "admin") { return jsonResponse({ error: "Forbidden" }, 403, request); } const body = await request.json(); const newEmail = body.email?.toLowerCase()?.trim(); if (!newEmail || !newEmail.includes("@")) { return jsonResponse({ error: "Valid email required" }, 400, request); } const users = await getUserList(env); if (!users.find((u) => u.email === newEmail)) { users.push({ email: newEmail }); await saveUserList(env, users); } return jsonResponse({ users }, 200, request); } __name(handleAddUser, "handleAddUser"); async function handleDeleteUser(request, env) { const callerEmail = getUserEmail(request); const callerRole = getUserRole(callerEmail); if (callerRole !== "superadmin") { return jsonResponse({ error: "Forbidden" }, 403, request); } const body = await request.json(); const targetEmail = body.email?.toLowerCase()?.trim(); if (!targetEmail) { return jsonResponse({ error: "Email required" }, 400, request); } let users = await getUserList(env); users = users.filter((u) => u.email !== targetEmail); await saveUserList(env, users); return jsonResponse({ users }, 200, request); } __name(handleDeleteUser, "handleDeleteUser"); async function handleHealth(request) { return jsonResponse({ status: "ok", service: "jdungeon-api", ts: Date.now() }, 200, request); } __name(handleHealth, "handleHealth"); async function searchAdminImports(query, env) { const list = await env.CHUNKS.get('ADMIN:imports', {type:'json'}) || []; if (!list.length) return ''; const terms = new Set(tokenize(query)); if (!terms.size) return ''; const fetches = list.slice(0,20).map(item => env.CHUNKS.get(`ADMIN:import:${item.id}`, {type:'json'})); const docs = await Promise.all(fetches); const scored = docs.filter(Boolean).map(doc => { const docTerms = tokenize(doc.content); const score = docTerms.filter(t => terms.has(t)).length; return {title: doc.title, content: doc.content, score}; }).filter(d => d.score > 0).sort((a,b) => b.score - a.score).slice(0,3); return scored.map(d => `--- [${d.title}] ---\n${d.content.slice(0,1200)}`).join('\n\n'); } __name(searchAdminImports, "searchAdminImports"); async function handleAdminSettings(request, env) { const email = getUserEmail(request); const role = getUserRole(email); if (role !== 'superadmin' && role !== 'admin') return jsonResponse({error:'Forbidden'}, 403, request); if (request.method === 'GET') { const s = await env.CHUNKS.get('ADMIN:settings', {type:'json'}) || {}; return jsonResponse({settings:s}, 200, request); } const body = await request.json(); const settings = { dm_context: (body.dm_context||'').slice(0,3000), house_rules: (body.house_rules||'').slice(0,2000), updated_by: email, updated_at: Date.now() }; await env.CHUNKS.put('ADMIN:settings', JSON.stringify(settings)); return jsonResponse({ok:true, settings}, 200, request); } __name(handleAdminSettings, "handleAdminSettings"); async function handleAdminImports(request, env) { const email = getUserEmail(request); const role = getUserRole(email); if (role !== 'superadmin' && role !== 'admin') return jsonResponse({error:'Forbidden'}, 403, request); if (request.method === 'GET') { const list = await env.CHUNKS.get('ADMIN:imports', {type:'json'}) || []; return jsonResponse({imports:list}, 200, request); } if (request.method === 'DELETE') { const body = await request.json(); const {id} = body; if (!id) return jsonResponse({error:'id required'}, 400, request); await env.CHUNKS.delete(`ADMIN:import:${id}`); let list = await env.CHUNKS.get('ADMIN:imports', {type:'json'}) || []; list = list.filter(i => i.id !== id); await env.CHUNKS.put('ADMIN:imports', JSON.stringify(list)); return jsonResponse({ok:true}, 200, request); } // POST const body = await request.json(); const {title, content} = body; if (!title||!content) return jsonResponse({error:'title and content required'}, 400, request); const id = crypto.randomUUID(); await env.CHUNKS.put(`ADMIN:import:${id}`, JSON.stringify({ id, title: title.slice(0,100), content: content.slice(0,30000), added_by: email, added_at: Date.now() })); let list = await env.CHUNKS.get('ADMIN:imports', {type:'json'}) || []; list.unshift({id, title: title.slice(0,100), added_at: Date.now(), added_by: email}); if (list.length > 30) list.length = 30; await env.CHUNKS.put('ADMIN:imports', JSON.stringify(list)); return jsonResponse({ok:true, id}, 200, request); } __name(handleAdminImports, "handleAdminImports"); async function handleFetchUrl(request, env) { const email = getUserEmail(request); const role = getUserRole(email); if (role !== 'superadmin' && role !== 'admin') return jsonResponse({error:'Forbidden'}, 403, request); const {url} = await request.json(); if (!url) return jsonResponse({error:'url required'}, 400, request); try { const resp = await fetch(url, {headers:{'User-Agent':'JDungeon/1.0'}, redirect:'follow'}); if (!resp.ok) return jsonResponse({error:`Fetch failed: ${resp.status}`}, 400, request); const ct = resp.headers.get('content-type') || ''; if (ct.includes('pdf') || ct.includes('octet-stream')) return jsonResponse({error:'PDF/binary files must be pasted as text'}, 400, request); let text = await resp.text(); if (ct.includes('html')) { text = text.replace(//gi,'') .replace(//gi,'') .replace(/<[^>]+>/g,' ').replace(/\s+/g,' ').trim(); } return jsonResponse({ok:true, text: text.slice(0,30000)}, 200, request); } catch(e) { return jsonResponse({error: e.message}, 400, request); } } __name(handleFetchUrl, "handleFetchUrl"); var SITE_BUILDER_PROMPT = `You are the JDungeon site builder. Your job is to add D&D features to the JDungeon website. RULE: Only add D&D/tabletop RPG features. Refuse anything unrelated. Allowed: spell trackers, initiative order, condition reference, monster lookup, dice tables, character stat blocks, etc. WEBSITE CSS VARIABLES: --bg-dark #0d0d14, --bg-panel #13131f, --bg-card #1a1a2e, --border #2a2a44, --accent #c9a84c (gold), --text #d4d0c8, --text-dim #8a8678, --red #b44, --font-heading (Palatino serif), --font-main (system UI) OVERLAY PATTERN:

    TITLE

    CONTENT
    FOOTER BUTTON PATTERN: In your JS, add a button to admin-extra-btns like this: (function(){var b=document.createElement('button');b.className='footer-btn';b.textContent='ICON LABEL';b.onclick=function(){document.getElementById('UNIQUE-overlay').classList.add('active');};var e=document.getElementById('admin-extra-btns');if(e)e.appendChild(b);})(); RULES FOR CODE: - Use unique IDs to avoid conflicts (prefix with 'jd-' + short feature name) - CSS must scope styles carefully to avoid breaking existing UI - No external network requests in JS - Keep it functional and D&D-useful - panel-body can use inline styles for layout Respond with ONLY valid JSON (no markdown fences): {"allowed":true,"description":"What was added","css":"/* css or empty */","html":"","js":"// js or empty"} If not D&D related: {"allowed":false,"description":"Why refused"}`; function extractSiteJson(text) { const start = text.indexOf('{'); const end = text.lastIndexOf('}'); if (start === -1 || end === -1 || end <= start) return null; try { return JSON.parse(text.slice(start, end + 1)); } catch(e) { return null; } } __name(extractSiteJson, "extractSiteJson"); async function handleSiteChat(request, env) { const email = getUserEmail(request); const role = getUserRole(email); if (role !== 'superadmin' && role !== 'admin') return jsonResponse({error:'Forbidden'}, 403, request); const {message} = await request.json(); if (!message) return jsonResponse({error:'message required'}, 400, request); let raw; try { raw = await callBedrock([ {role:'system', content: SITE_BUILDER_PROMPT}, {role:'user', content: message} ], env); } catch(e) { return jsonResponse({error:'AI service unavailable'}, 502, request); } const result = extractSiteJson(raw); if (!result) return jsonResponse({error:'AI returned invalid response', raw: raw.slice(0,300)}, 500, request); if (!result.allowed) return jsonResponse({ok:true, allowed:false, description: result.description||'Not D&D related.'}, 200, request); const id = crypto.randomUUID(); const feature = { id, description: (result.description||'').slice(0,200), css: (result.css||'').slice(0,10000), html: (result.html||'').slice(0,20000), js: (result.js||'').slice(0,20000), added_by: email, added_at: Date.now() }; const features = await env.CHUNKS.get('ADMIN:site_features', {type:'json'}) || []; features.push(feature); if (features.length > 20) features.shift(); await env.CHUNKS.put('ADMIN:site_features', JSON.stringify(features)); return jsonResponse({ok:true, allowed:true, description: result.description, id}, 200, request); } __name(handleSiteChat, "handleSiteChat"); async function handleSiteFeatures(request, env) { const email = getUserEmail(request); const role = getUserRole(email); if (role !== 'superadmin' && role !== 'admin') return jsonResponse({error:'Forbidden'}, 403, request); if (request.method === 'GET') { const features = await env.CHUNKS.get('ADMIN:site_features', {type:'json'}) || []; return jsonResponse({features: features.map(f => ({id:f.id, description:f.description, added_at:f.added_at, added_by:f.added_by}))}, 200, request); } if (request.method === 'DELETE') { const {id} = await request.json(); if (id === '__all__') { await env.CHUNKS.put('ADMIN:site_features', JSON.stringify([])); return jsonResponse({ok:true}, 200, request); } if (!id) return jsonResponse({error:'id required'}, 400, request); let features = await env.CHUNKS.get('ADMIN:site_features', {type:'json'}) || []; features = features.filter(f => f.id !== id); await env.CHUNKS.put('ADMIN:site_features', JSON.stringify(features)); return jsonResponse({ok:true}, 200, request); } return jsonResponse({error:'Method not allowed'}, 405, request); } __name(handleSiteFeatures, "handleSiteFeatures"); function arrayBufferToBase64(buffer) { const bytes = new Uint8Array(buffer); let binary = ''; const CHUNK = 8192; for (let i = 0; i < bytes.length; i += CHUNK) { binary += String.fromCharCode(...bytes.subarray(i, Math.min(i + CHUNK, bytes.length))); } return btoa(binary); } __name(arrayBufferToBase64, "arrayBufferToBase64"); function chunkText(text, wordsPerChunk = 500) { const words = text.split(/\s+/).filter(w => w.length > 0); const chunks = []; const overlap = 50; for (let i = 0; i < words.length; i += wordsPerChunk - overlap) { const slice = words.slice(i, i + wordsPerChunk).join(' '); if (slice.split(/\s+/).length >= 20) chunks.push(slice); } return chunks; } __name(chunkText, "chunkText"); async function indexBookChunks(env, title, chunks) { const countStr = await env.CHUNKS.get('ADMIN_BOOK_CHUNK_TOTAL') || '0'; const startId = parseInt(countStr); const localIdx = {}; const batchSize = 50; const batchMap = {}; chunks.forEach((text, i) => { const id = startId + i; const batchId = Math.floor(id / batchSize); if (!batchMap[batchId]) batchMap[batchId] = []; batchMap[batchId].push({id, book_name: title, text}); const tokens = tokenize(text); const termCounts = {}; for (const t of tokens) termCounts[t] = (termCounts[t] || 0) + 1; const total = tokens.length || 1; for (const [term, count] of Object.entries(termCounts)) { const score = count / total; const letter = (term[0] >= 'a' && term[0] <= 'z') ? term[0] : '0'; if (!localIdx[letter]) localIdx[letter] = {}; if (!localIdx[letter][term]) localIdx[letter][term] = []; localIdx[letter][term].push({id, score}); } }); const lettersNeeded = Object.keys(localIdx); const shardReads = lettersNeeded.map(l => env.CHUNKS.get(`ADMIN_BOOK_IDX:${l}`, {type:'json'}).then(d => ({l, d: d || {}})) ); const existingShards = await Promise.all(shardReads); const shardMap = {}; for (const {l, d} of existingShards) shardMap[l] = d; for (const [letter, terms] of Object.entries(localIdx)) { const shard = shardMap[letter]; for (const [term, entries] of Object.entries(terms)) { if (!shard[term]) shard[term] = []; shard[term].push(...entries); } } const writes = [ ...Object.entries(shardMap).map(([l, shard]) => env.CHUNKS.put(`ADMIN_BOOK_IDX:${l}`, JSON.stringify(shard)) ), ...Object.entries(batchMap).map(([batchId, newChunks]) => env.CHUNKS.get(`ADMIN_BOOK_CHUNKS:${batchId}`, {type:'json'}).then(existing => env.CHUNKS.put(`ADMIN_BOOK_CHUNKS:${batchId}`, JSON.stringify([...(existing||[]), ...newChunks])) ) ), env.CHUNKS.put('ADMIN_BOOK_CHUNK_TOTAL', String(startId + chunks.length)) ]; await Promise.all(writes); const bookList = await env.CHUNKS.get('ADMIN_BOOK_LIST', {type:'json'}) || []; bookList.push({id: crypto.randomUUID(), title, chunk_count: chunks.length, added_at: Date.now()}); await env.CHUNKS.put('ADMIN_BOOK_LIST', JSON.stringify(bookList)); return {chunks: chunks.length, startId}; } __name(indexBookChunks, "indexBookChunks"); async function searchAdminBooks(query, env, topK = 4) { const bookList = await env.CHUNKS.get('ADMIN_BOOK_LIST', {type:'json'}) || []; if (!bookList.length) return []; const tokens = tokenize(query); if (!tokens.length) return []; const shardLetters = new Set(); for (const t of tokens) shardLetters.add(t[0] >= 'a' && t[0] <= 'z' ? t[0] : '0'); const shards = await Promise.all(Array.from(shardLetters).map(l => env.CHUNKS.get(`ADMIN_BOOK_IDX:${l}`, {type:'json'}).then(d => ({l, d})) )); const chunkScores = new Map(); for (const {d} of shards) { if (!d) continue; for (const token of tokens) { const entries = d[token]; if (!entries) continue; const boost = BOOST_TERMS.has(token) ? 2 : 1; for (const entry of entries) { const cur = chunkScores.get(entry.id) || {score:0, terms:0}; cur.score += entry.score * boost; cur.terms += 1; chunkScores.set(entry.id, cur); } } } if (!chunkScores.size) return []; const totalQ = tokens.length; const scored = Array.from(chunkScores.entries()).map(([id, {score, terms}]) => ({ id, score: score * (0.5 + 0.5 * terms / totalQ) })).sort((a,b) => b.score - a.score).slice(0, topK); const batchSize = 50; const batchIds = new Set(scored.map(c => Math.floor(c.id / batchSize))); const batches = await Promise.all(Array.from(batchIds).map(bid => env.CHUNKS.get(`ADMIN_BOOK_CHUNKS:${bid}`, {type:'json'}).then(d => d || []) )); const lookup = new Map(); for (const batch of batches) for (const c of batch) lookup.set(c.id, c); const maxScore = scored[0]?.score || 1; return scored.map(({id, score}) => { const c = lookup.get(id); return c ? {id:c.id, book_name:c.book_name, text:c.text, score:score/maxScore} : null; }).filter(Boolean); } __name(searchAdminBooks, "searchAdminBooks"); async function handleExtractPdf(request, env) { const email = getUserEmail(request); const role = getUserRole(email); if (role !== 'superadmin' && role !== 'admin') return jsonResponse({error:'Forbidden'}, 403, request); if (!env.GEMINI_KEY) return jsonResponse({error:'Gemini API key not configured'}, 500, request); const formData = await request.formData(); const file = formData.get('file'); if (!file) return jsonResponse({error:'No file provided'}, 400, request); const bytes = await file.arrayBuffer(); if (bytes.byteLength > 20 * 1024 * 1024) return jsonResponse({error:'File too large (max 20MB for PDFs). For larger files, convert to .txt first.'}, 400, request); const base64 = arrayBufferToBase64(bytes); const mimeType = file.type || 'application/pdf'; const geminiResp = await fetch( `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${env.GEMINI_KEY}`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ contents: [{parts: [ {inline_data: {mime_type: mimeType, data: base64}}, {text: 'Extract all text from this document. Output only the plain text, preserving paragraph breaks with blank lines. No commentary, no formatting markers, no headers — just the raw extracted text.'} ]}], generationConfig: {temperature: 0}, thinkingConfig: {thinkingBudget: 0} }) } ); if (!geminiResp.ok) { const err = await geminiResp.text(); return jsonResponse({error:`Gemini error ${geminiResp.status}`, detail:err.slice(0,300)}, 502, request); } const gd = await geminiResp.json(); const text = gd.candidates?.[0]?.content?.parts?.[0]?.text || ''; if (!text) return jsonResponse({error:'Gemini returned no text — PDF may be image-only (scanned). Convert to text first.'}, 500, request); return jsonResponse({ok:true, text, chars:text.length}, 200, request); } __name(handleExtractPdf, "handleExtractPdf"); async function handleIndexBook(request, env) { const email = getUserEmail(request); const role = getUserRole(email); if (role !== 'superadmin' && role !== 'admin') return jsonResponse({error:'Forbidden'}, 403, request); const {title, text} = await request.json(); if (!title || !text) return jsonResponse({error:'title and text required'}, 400, request); const chunks = chunkText(text, 500); if (!chunks.length) return jsonResponse({error:'No content found in text'}, 400, request); const result = await indexBookChunks(env, title, chunks); return jsonResponse({ok:true, chunks:result.chunks, title}, 200, request); } __name(handleIndexBook, "handleIndexBook"); async function handleAdminBooks(request, env) { const email = getUserEmail(request); const role = getUserRole(email); if (role !== 'superadmin' && role !== 'admin') return jsonResponse({error:'Forbidden'}, 403, request); const list = await env.CHUNKS.get('ADMIN_BOOK_LIST', {type:'json'}) || []; return jsonResponse({books: list}, 200, request); } __name(handleAdminBooks, "handleAdminBooks"); async function handleWhoami(request, env) { const email = getUserEmail(request); const role = getUserRole(email); return jsonResponse({email: email || null, role}, 200, request); } __name(handleWhoami, "handleWhoami"); async function serveHTML(request, env) { const email = getUserEmail(request) || "Guest"; const role = getUserRole(email); const name = email.split("@")[0] || "Guest"; let html = HTML; html = html.replace("__CF_USER_EMAIL__", email); html = html.replace("__CF_USER_NAME__", name); html = html.replace("__CF_USER_ROLE__", role); try { const features = await env.CHUNKS.get('ADMIN:site_features', {type:'json'}) || []; if (features.length > 0) { const css = features.map(f => f.css||'').filter(Boolean).join('\n'); const parts = features.map(f => f.html||'').filter(Boolean).join('\n'); const js = features.map(f => f.js||'').filter(Boolean).join('\n'); if (css) html = html.replace('', `/* Admin Features */\n${css}\n`); if (parts) html = html.replace('', `\n${parts}\n`); if (js) html = html.replace('', `\n