(() => { 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]"); const cooldown = form?.querySelector("[data-lumi-ai-cooldown]"); 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; let cooldownTimer = null; let cooldownUntil = 0; let requestInFlight = false; 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 maximum = Math.max(MIN_HEIGHT, window.innerHeight - 96); const clampedHeight = Math.min(maximum, Math.max(MIN_HEIGHT, height)); 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, feedbackContext = null) => { 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); if (type === "assistant" && feedbackContext) appendFeedback(item, feedbackContext); 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..."; const progress = document.createElement("div"); progress.className = "lumi-ai-pending-progress"; progress.append(label); const controls = document.createElement("div"); controls.className = "lumi-ai-timeout-controls"; controls.hidden = true; const details = document.createElement("div"); details.className = "lumi-ai-timeout-details"; details.hidden = true; const buttons = {}; for (const [key, text] of [ ["continue", "Continue waiting"], ["cancel", "Cancel"], ["details", "Details"] ]) { const button = document.createElement("button"); button.type = "button"; button.textContent = text; button.dataset.timeoutAction = key; buttons[key] = button; controls.append(button); } progress.append(controls, details); item.append(spinner, progress); messages.append(item); messages.scrollTop = messages.scrollHeight; let latestJob = null; return { setStage(stage, job = null) { latestJob = job || latestJob; const labels = { queued: "Queued for Lumi Assistant...", deterministic: "Checking verified answers...", gating: "Routing with the lightweight gate...", gate: "Routing with the lightweight gate...", main_model_loading: "Loading the main model...", prompt_eval: "Evaluating the prompt...", generating: "Main model is generating...", formatting: "Formatting the reply...", done: "Reply complete.", cancelled: "Request cancelled." }; const baseLabel = labels[stage] || "Lumi Assistant is processing..."; const elapsed = latestJob?.elapsed_ms ? ` · ${formatElapsed(latestJob.elapsed_ms)}` : ""; const budget = Number(latestJob?.details?.max_output_tokens_used || latestJob?.details?.max_output_tokens) || 0; const budgetText = budget && ["prompt_eval", "generating"].includes(stage) ? ` · budget ${budget} tokens` : ""; label.textContent = `${baseLabel}${elapsed}${budgetText}`; if (!details.hidden && latestJob) this.updateDetails(latestJob); }, showSoftTimeout(actions) { controls.hidden = false; buttons.continue.onclick = actions.continueWaiting; buttons.cancel.onclick = actions.cancel; buttons.details.onclick = () => { details.hidden = !details.hidden; if (!details.hidden && latestJob) this.updateDetails(latestJob); }; updateCooldown(); }, hideSoftTimeout() { controls.hidden = true; details.hidden = true; }, updateDetails(job) { latestJob = job; const jobDetails = job.details || {}; const generated = Number(jobDetails.generated_tokens) || 0; details.textContent = [ `Stage: ${job.stage || "unknown"}`, `Elapsed: ${formatElapsed(job.elapsed_ms)}`, `Generated tokens: ${generated || "not reported"}`, `Job alive: ${job.still_running ? "yes" : "no"}` ].join(" | "); }, remove() { item.remove(); } }; }; const cooldownSeconds = () => Math.max(0, Math.ceil((cooldownUntil - Date.now()) / 1000)); const updateCooldown = () => { const seconds = cooldownSeconds(); const active = seconds > 0; submit.disabled = requestInFlight || active; submit.title = active ? `Retry available in ${seconds}s` : "Send"; if (cooldown) { cooldown.hidden = !active; cooldown.textContent = active ? `Retry available in ${seconds}s` : ""; } for (const button of messages.querySelectorAll(".lumi-ai-retry[data-cooldown]")) { button.disabled = active; button.textContent = active ? `Retry in ${seconds}s` : "Retry"; } if (!active && cooldownTimer) { window.clearInterval(cooldownTimer); cooldownTimer = null; } }; const beginCooldown = (seconds) => { const duration = Math.max(1, Number(seconds) || 1); cooldownUntil = Math.max(cooldownUntil, Date.now() + duration * 1000); if (!cooldownTimer) cooldownTimer = window.setInterval(updateCooldown, 1000); updateCooldown(); }; const addError = (text, retry = null, retryAfterSeconds = 0) => { 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"; if (retryAfterSeconds > 0) button.dataset.cooldown = "true"; button.addEventListener("click", () => { if (cooldownSeconds() > 0) return; item.remove(); retry(); }, { once: true, signal: listeners.signal }); item.append(button); updateCooldown(); } 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 appendFeedback = (item, context) => { const controls = document.createElement("form"); controls.className = "lumi-ai-feedback"; controls.setAttribute("aria-label", "Rate this Lumi Assistant reply"); const select = document.createElement("select"); select.setAttribute("aria-label", "Feedback tag"); for (const tag of [ "good", "bad", "wrong_link", "hallucinated", "too_generic", "unsafe", "should_clarify", "bad_code", "wrong_scope", "wrong_tool_usage" ]) { const option = document.createElement("option"); option.value = tag; option.textContent = tag.replaceAll("_", " "); select.append(option); } const kind = document.createElement("select"); kind.setAttribute("aria-label", "Feedback type"); for (const [value, label] of [ ["instruction_based", "Instruction-based guidance"], ["strict_correction", "Strict correction"] ]) { const option = document.createElement("option"); option.value = value; option.textContent = label; kind.append(option); } const correction = document.createElement("input"); correction.maxLength = 16000; correction.placeholder = "Correction or instruction for future replies"; correction.setAttribute("aria-label", "Correction or instruction"); const submitFeedback = document.createElement("button"); submitFeedback.type = "submit"; submitFeedback.textContent = "Send feedback"; controls.append(select, kind, correction, submitFeedback); controls.addEventListener("submit", async (event) => { event.preventDefault(); submitFeedback.disabled = true; try { const response = await trackedFetch(`${endpoint}/assistant/feedback`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...context, feedback_tag: select.value, feedback_kind: kind.value, optional_correction: correction.value.trim() }) }); const data = await readResponseJson(response); if (!response.ok) throw new Error(data.error || "Feedback could not be saved."); controls.replaceChildren(document.createTextNode("Feedback saved.")); } catch (error) { submitFeedback.disabled = false; submitFeedback.textContent = error.message || "Try again"; } }, { signal: listeners.signal }); item.append(controls); }; 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; const cold = response.ok && data.enabled && data.runtime?.runtime_installed && data.runtime?.model_downloaded && data.runtime?.state === "stopped"; state.className = `lumi-ai-state ${ready ? "ready" : cold ? "warming" : "error"}`; status.textContent = ready ? `Main ready · gate ${data.gate?.healthy ? "ready" : "fallback"}` : cold ? `Main loads on request · gate ${data.gate?.healthy ? "ready" : "starting"}` : "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"); requestInFlight = true; 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; error.retryAfterSeconds = Number(data.retry_after_seconds) || 0; throw error; } const result = response.status === 202 && data.status_url ? await pollAssistantJob(data, pending) : data; addMessage(result.text, "assistant", result.confirmation, result.links, true, result.feedback_context); } catch (error) { if (error.name === "AbortError") { if (root.isConnected) { addError("Assistant request was cancelled.", () => sendMessage(message, history, false)); } } else { const retrySafe = ![400, 401, 403].includes(error.status); if (error.status === 429 && error.retryAfterSeconds > 0) { beginCooldown(error.retryAfterSeconds); } addError( error.message, retrySafe ? () => sendMessage(message, history, false) : null, error.retryAfterSeconds ); } } finally { pending.remove(); requestInFlight = false; if (root.isConnected) { input.disabled = false; updateCooldown(); messages.setAttribute("aria-busy", "false"); input.focus(); } } }; const pollAssistantJob = async (jobRequest, pending) => { const softTimeoutMs = Math.max(5000, Number(jobRequest.ui_soft_timeout_ms) || 45000); let nextSoftTimeoutAt = Date.now() + softTimeoutMs; let softTimeoutReported = false; let requestedAction = null; while (root.isConnected) { if (requestedAction === "continue") { pending.hideSoftTimeout(); nextSoftTimeoutAt = Date.now() + softTimeoutMs; requestedAction = null; } else if (requestedAction === "cancel") { requestedAction = null; const cancelResponse = await trackedFetch(jobRequest.cancel_url, { method: "POST" }); const cancelled = await readResponseJson(cancelResponse); if (!cancelResponse.ok) { const error = new Error(cancelled.error || "Could not cancel the running request."); error.status = cancelResponse.status; throw error; } } const response = await trackedFetch(jobRequest.status_url, { cache: "no-store" }); const job = await readResponseJson(response); if (!response.ok) { const error = new Error(job.error || `Request status failed (${response.status}).`); error.status = response.status; throw error; } pending.setStage(job.stage, job); pending.updateDetails(job); if (job.state === "complete") return job.result || {}; if (["error", "cancelled"].includes(job.state)) { const error = new Error(job.error || "Lumi Assistant could not complete the request."); if (job.state === "cancelled") error.name = "AbortError"; error.status = job.retry_after_seconds ? 429 : 503; error.retryAfterSeconds = Number(job.retry_after_seconds) || 0; throw error; } if (Date.now() >= nextSoftTimeoutAt) { pending.showSoftTimeout({ continueWaiting: () => { requestedAction = "continue"; }, cancel: () => { requestedAction = "cancel"; } }); if (!softTimeoutReported && jobRequest.soft_timeout_url) { softTimeoutReported = true; trackedFetch(jobRequest.soft_timeout_url, { method: "POST" }).catch(() => {}); } nextSoftTimeoutAt = Number.POSITIVE_INFINITY; } await new Promise((resolve) => window.setTimeout(resolve, 750)); } throw Object.assign(new Error("Assistant panel closed."), { name: "AbortError" }); }; form.addEventListener("submit", async (event) => { event.preventDefault(); const message = input.value.trim(); if (!message || input.disabled || cooldownSeconds() > 0) 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); if (cooldownTimer) window.clearInterval(cooldownTimer); 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 formatElapsed(milliseconds) { const seconds = Math.max(0, Math.floor((Number(milliseconds) || 0) / 1000)); const minutes = Math.floor(seconds / 60); return minutes ? `${minutes}m ${seconds % 60}s` : `${seconds}s`; } 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 }); })();