(() => { const instances = new WeakMap(); const HISTORY_LIMIT = 40; const REQUEST_HISTORY_LIMIT = 12; const MIN_HEIGHT = 180; function mount(root) { if (!root || instances.has(root)) return; const endpoint = root.dataset.endpoint; const userId = root.dataset.userId || "anonymous"; const panel = root.querySelector("[data-lumi-ai-panel]"); const toggle = root.querySelector("[data-lumi-ai-toggle]"); const close = root.querySelector("[data-lumi-ai-close]"); const clear = root.querySelector("[data-lumi-ai-clear]"); const resizeHandle = root.querySelector("[data-lumi-ai-resize]"); const state = root.querySelector("[data-lumi-ai-state]"); const status = root.querySelector("[data-lumi-ai-status]"); const messages = root.querySelector("[data-lumi-ai-messages]"); const form = root.querySelector("[data-lumi-ai-form]"); const input = form?.querySelector("textarea"); const submit = form?.querySelector("[data-lumi-ai-submit]"); if (!endpoint || !panel || !toggle || !close || !clear || !resizeHandle || !state || !status || !messages || !form || !input || !submit) return; const listeners = new AbortController(); const requests = new Set(); const overlayRoot = document.createElement("div"); overlayRoot.className = "lumi-ai-overlay-root"; overlayRoot.dataset.lumiAiOverlayRoot = ""; document.body.append(overlayRoot); overlayRoot.append(panel); const storageKey = `lumi_ai.chat.${userId}`; const stateKey = `lumi_ai.panel.${userId}`; let conversation = loadJson(storageKey, []); let panelState = loadJson(stateKey, {}); let statusTimer = null; const trackedFetch = async (url, options = {}) => { const controller = new AbortController(); requests.add(controller); try { return await fetch(url, { ...options, signal: controller.signal }); } finally { requests.delete(controller); } }; const persistConversation = () => { conversation = conversation.slice(-HISTORY_LIMIT); saveJson(storageKey, conversation); }; const persistPanelState = (next = {}) => { panelState = { ...panelState, ...next }; saveJson(stateKey, panelState); }; const panelHeight = () => { const fallback = Math.max(MIN_HEIGHT, Math.round(window.innerHeight / 6)); const stored = Number(panelState.height); return Number.isFinite(stored) && stored >= MIN_HEIGHT ? stored : fallback; }; const positionPanel = (height = panelHeight()) => { const viewportHeight = window.innerHeight; const footerRect = document.querySelector(".site-footer")?.getBoundingClientRect(); const bottomLimit = footerRect && footerRect.top < viewportHeight && footerRect.bottom > 0 ? Math.max(MIN_HEIGHT + 8, footerRect.top - 8) : viewportHeight - 8; const maximum = Math.max(MIN_HEIGHT, bottomLimit - 8); const clampedHeight = Math.min(maximum, Math.max(MIN_HEIGHT, height)); const top = Math.max(8, bottomLimit - clampedHeight); panel.style.setProperty("--lumi-ai-top", `${top}px`); panel.style.height = `${clampedHeight}px`; return clampedHeight; }; const setOpen = (open, persist = true) => { if (open) positionPanel(); panel.classList.toggle("open", open); panel.setAttribute("aria-hidden", String(!open)); toggle.setAttribute("aria-expanded", String(open)); if (persist) persistPanelState({ open }); if (open) { input.focus(); messages.scrollTop = messages.scrollHeight; } }; const addMessage = (text, type, confirmation = null, links = [], persist = true) => { const item = document.createElement("div"); item.className = `lumi-ai-message ${type}`; if (type === "assistant") renderMarkdown(item, text); else { const body = document.createElement("div"); body.textContent = text; body.style.whiteSpace = "pre-wrap"; item.append(body); } appendLinks(item, links); if (confirmation) appendConfirmation(item, confirmation); messages.append(item); messages.scrollTop = messages.scrollHeight; if (persist && ["user", "assistant"].includes(type)) { conversation.push({ role: type, content: String(text || ""), links: safeStoredLinks(links) }); persistConversation(); } }; const addPending = () => { const item = document.createElement("div"); item.className = "lumi-ai-message assistant pending"; item.setAttribute("role", "status"); const spinner = document.createElement("span"); spinner.className = "lumi-ai-spinner"; spinner.setAttribute("aria-hidden", "true"); const label = document.createElement("span"); label.textContent = "Queued for Lumi Assistant..."; item.append(spinner, label); messages.append(item); messages.scrollTop = messages.scrollHeight; const processingTimer = window.setTimeout(() => { if (item.isConnected) label.textContent = "Lumi Assistant is processing..."; }, 350); return { remove() { window.clearTimeout(processingTimer); item.remove(); } }; }; const addError = (text, retry = null) => { const item = document.createElement("div"); item.className = "lumi-ai-message assistant error"; item.setAttribute("role", "alert"); const body = document.createElement("div"); body.textContent = text || "Lumi Assistant could not complete the request."; item.append(body); if (typeof retry === "function") { const button = document.createElement("button"); button.type = "button"; button.className = "lumi-ai-retry"; button.textContent = "Retry"; button.addEventListener("click", () => { item.remove(); retry(); }, { once: true, signal: listeners.signal }); item.append(button); } messages.append(item); messages.scrollTop = messages.scrollHeight; }; const appendConfirmation = (item, confirmation) => { const actions = document.createElement("div"); actions.className = "lumi-ai-confirm"; for (const [label, route] of [["Confirm", "confirm"], ["Cancel", "cancel"]]) { const button = document.createElement("button"); button.type = "button"; button.textContent = label; button.addEventListener("click", async () => { button.disabled = true; try { const response = await trackedFetch(`${endpoint}/assistant/${route}`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: confirmation.id }) }); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Action failed."); addMessage(route === "confirm" ? "Confirmed and completed." : "Cancelled.", "assistant"); actions.remove(); } catch (error) { if (error.name !== "AbortError") addMessage(error.message, "assistant error", null, [], false); } }, { signal: listeners.signal }); actions.append(button); } item.append(actions); }; const restoreConversation = () => { messages.replaceChildren(); for (const entry of conversation.slice(-HISTORY_LIMIT)) { if (!["user", "assistant"].includes(entry?.role) || !entry.content) continue; addMessage(entry.content, entry.role, null, entry.links || [], false); } if (!messages.childElementCount) { addMessage( "Ask about Lumi Bot, its WebUI, plugins, settings, streams, moderation, or community systems.", "assistant", null, [], false ); } }; const refreshStatus = async () => { try { const response = await trackedFetch(`${endpoint}/api/status`); const data = await response.json(); const ready = response.ok && data.enabled && data.runtime?.healthy; state.className = `lumi-ai-state ${ready ? "ready" : "error"}`; status.textContent = ready ? `${data.model_id} ready` : "Runtime unavailable"; } catch (error) { if (error.name !== "AbortError") { state.className = "lumi-ai-state error"; status.textContent = "Status unavailable"; } } }; toggle.addEventListener("click", () => setOpen(!panel.classList.contains("open")), { signal: listeners.signal }); close.addEventListener("click", () => setOpen(false), { signal: listeners.signal }); clear.addEventListener("click", () => { conversation = []; persistConversation(); restoreConversation(); }, { signal: listeners.signal }); resizeHandle.addEventListener("pointerdown", (event) => { event.preventDefault(); resizeHandle.setPointerCapture(event.pointerId); const startY = event.clientY; const startHeight = panel.getBoundingClientRect().height; const move = (moveEvent) => { const nextHeight = positionPanel(startHeight + startY - moveEvent.clientY); persistPanelState({ height: Math.round(nextHeight) }); }; const stop = () => { resizeHandle.removeEventListener("pointermove", move); resizeHandle.removeEventListener("pointerup", stop); resizeHandle.removeEventListener("pointercancel", stop); }; resizeHandle.addEventListener("pointermove", move); resizeHandle.addEventListener("pointerup", stop); resizeHandle.addEventListener("pointercancel", stop); }, { signal: listeners.signal }); const sendMessage = async (message, history, addUserMessage = true) => { if (addUserMessage) addMessage(message, "user"); input.disabled = true; submit.disabled = true; messages.setAttribute("aria-busy", "true"); const pending = addPending(); try { const response = await trackedFetch(`${endpoint}/assistant/message`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message, history }) }); const data = await readResponseJson(response); if (!response.ok) { const error = new Error(data.error || `Request failed (${response.status}).`); error.status = response.status; throw error; } addMessage(data.text, "assistant", data.confirmation, data.links); } catch (error) { if (error.name !== "AbortError") { const retrySafe = ![400, 401, 403].includes(error.status); addError(error.message, retrySafe ? () => sendMessage(message, history, false) : null); } } finally { pending.remove(); if (root.isConnected) { input.disabled = false; submit.disabled = false; messages.setAttribute("aria-busy", "false"); input.focus(); } } }; form.addEventListener("submit", async (event) => { event.preventDefault(); const message = input.value.trim(); if (!message || input.disabled) return; const history = conversation.slice(-REQUEST_HISTORY_LIMIT).map((entry) => ({ role: entry.role, content: entry.content })); input.value = ""; await sendMessage(message, history); }, { signal: listeners.signal }); input.addEventListener("keydown", (event) => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); form.requestSubmit(); } }, { signal: listeners.signal }); messages.addEventListener("wheel", (event) => { event.stopPropagation(); const atTop = messages.scrollTop <= 0; const atBottom = Math.ceil(messages.scrollTop + messages.clientHeight) >= messages.scrollHeight; if ((event.deltaY < 0 && atTop) || (event.deltaY > 0 && atBottom)) event.preventDefault(); }, { passive: false, signal: listeners.signal }); window.addEventListener("resize", () => { if (panel.classList.contains("open")) { const height = positionPanel(); if (height !== panelState.height) persistPanelState({ height: Math.round(height) }); } }, { signal: listeners.signal }); restoreConversation(); positionPanel(); setOpen(panelState.open === true, false); refreshStatus(); statusTimer = window.setInterval(refreshStatus, 15000); instances.set(root, { destroy() { listeners.abort(); for (const request of requests) request.abort(); requests.clear(); if (statusTimer) window.clearInterval(statusTimer); overlayRoot.remove(); instances.delete(root); } }); } function renderMarkdown(container, value) { const lines = String(value || "").replace(/\r\n/g, "\n").split("\n"); let paragraph = []; let list = null; let listType = null; const flushParagraph = () => { if (!paragraph.length) return; const p = document.createElement("p"); appendInlineMarkdown(p, paragraph.join("\n")); container.append(p); paragraph = []; }; const flushList = () => { if (list) container.append(list); list = null; listType = null; }; for (let index = 0; index < lines.length; index += 1) { const fence = lines[index].match(/^```([a-z0-9_+-]*)\s*$/i); if (fence) { flushParagraph(); flushList(); const code = []; index += 1; while (index < lines.length && !/^```\s*$/.test(lines[index])) { code.push(lines[index]); index += 1; } container.append(createCodeBlock(code.join("\n"), fence[1] || "text")); continue; } const item = lines[index].match(/^\s*[-*]\s+(.+)$/); const orderedItem = lines[index].match(/^\s*\d+\.\s+(.+)$/); if (item || orderedItem) { flushParagraph(); const nextListType = orderedItem ? "ol" : "ul"; if (list && listType !== nextListType) flushList(); if (!list) { list = document.createElement(nextListType); listType = nextListType; } const li = document.createElement("li"); appendInlineMarkdown(li, (item || orderedItem)[1]); list.append(li); continue; } const heading = lines[index].match(/^(#{1,3})\s+(.+)$/); if (heading) { flushParagraph(); flushList(); const element = document.createElement(`h${heading[1].length}`); appendInlineMarkdown(element, heading[2]); container.append(element); continue; } const quote = lines[index].match(/^>\s?(.+)$/); if (quote) { flushParagraph(); flushList(); const blockquote = document.createElement("blockquote"); appendInlineMarkdown(blockquote, quote[1]); container.append(blockquote); continue; } if (!lines[index].trim()) { flushParagraph(); flushList(); continue; } flushList(); paragraph.push(lines[index]); } flushParagraph(); flushList(); } function appendInlineMarkdown(parent, value) { const pattern = /(]*\bhref\s*=\s*(["'])(.*?)\2[^>]*>(.*?)<\/a>|`[^`\n]+`|\*\*[^*]+\*\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\))/gi; let offset = 0; for (const match of String(value).matchAll(pattern)) { parent.append(document.createTextNode(value.slice(offset, match.index))); const token = match[0]; if (token.startsWith("`")) { const code = document.createElement("code"); code.textContent = token.slice(1, -1); parent.append(code); } else if (token.startsWith("**")) { const strong = document.createElement("strong"); strong.textContent = token.slice(2, -2); parent.append(strong); } else if (token.startsWith("_")) { const emphasis = document.createElement("em"); emphasis.textContent = token.slice(1, -1); parent.append(emphasis); } else if (/^]*\bhref\s*=\s*(["'])(.*?)\1[^>]*>(.*?)<\/a>$/i); const anchor = safeAnchor(parts?.[2], stripHtml(parts?.[3])); parent.append(anchor || document.createTextNode(stripHtml(parts?.[3]) || token)); } else { const parts = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/); const anchor = safeAnchor(parts?.[2], parts?.[1]); parent.append(anchor || document.createTextNode(parts?.[1] || token)); } offset = match.index + token.length; } parent.append(document.createTextNode(value.slice(offset))); } function createCodeBlock(codeValue, language) { const wrapper = document.createElement("div"); wrapper.className = "lumi-ai-code"; const header = document.createElement("div"); header.className = "lumi-ai-code-header"; const label = document.createElement("span"); label.textContent = language; const copy = document.createElement("button"); copy.type = "button"; copy.className = "lumi-ai-code-copy"; copy.textContent = "Copy"; copy.title = "Copy code"; copy.addEventListener("click", async () => { try { await copyText(codeValue); copy.textContent = "Copied"; } catch { copy.textContent = "Copy failed"; } window.setTimeout(() => { copy.textContent = "Copy"; }, 1200); }); const pre = document.createElement("pre"); const code = document.createElement("code"); code.className = `language-${language}`; code.textContent = codeValue; pre.append(code); header.append(label, copy); wrapper.append(header, pre); return wrapper; } function appendLinks(item, links) { if (!Array.isArray(links) || !links.length) return; const linkList = document.createElement("div"); linkList.className = "lumi-ai-links"; for (const link of links) { const anchor = safeAnchor(link?.href, link?.label); if (anchor && ![...item.querySelectorAll("a")].some((existing) => existing.href === anchor.href)) { linkList.append(anchor); } } if (linkList.childElementCount) item.append(linkList); } function safeAnchor(href, label) { if (!href || !label) return null; try { const parsed = new URL(href, window.location.origin); if (!["http:", "https:"].includes(parsed.protocol)) return null; const anchor = document.createElement("a"); anchor.href = parsed.href; anchor.textContent = label; if (parsed.origin !== window.location.origin) { anchor.target = "_blank"; anchor.rel = "noopener noreferrer"; } return anchor; } catch { return null; } } function safeStoredLinks(links) { return (Array.isArray(links) ? links : []).slice(0, 8).map((link) => ({ href: String(link?.href || "").slice(0, 2000), label: String(link?.label || "").slice(0, 200) })).filter((link) => link.href && link.label); } function loadJson(key, fallback) { try { const value = JSON.parse(window.localStorage.getItem(key)); return value && typeof value === "object" ? value : fallback; } catch { return fallback; } } function saveJson(key, value) { try { window.localStorage.setItem(key, JSON.stringify(value)); } catch {} } async function readResponseJson(response) { try { return await response.json(); } catch { return {}; } } async function copyText(value) { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(value); return; } const textarea = document.createElement("textarea"); textarea.value = value; textarea.setAttribute("readonly", ""); textarea.style.position = "fixed"; textarea.style.opacity = "0"; document.body.append(textarea); textarea.select(); const copied = document.execCommand?.("copy"); textarea.remove(); if (!copied) throw new Error("Clipboard access is unavailable."); } function stripHtml(value) { return String(value || "").replace(/<[^>]*>/g, ""); } function unmount(root) { instances.get(root)?.destroy(); } window.LumiAssistantPanels?.register("lumi_ai", { mount, unmount }); })();