Lumi/plugins/lumi_ai/public/assistant.js
2026-06-12 11:54:46 +02:00

547 lines
20 KiB
JavaScript

(() => {
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 = /(<a\b[^>]*\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 (/^<a\b/i.test(token)) {
const parts = token.match(/^<a\b[^>]*\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 });
})();