Lumi/plugins/lumi_ai/public/assistant.js
2026-06-16 08:30:40 +02:00

792 lines
30 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]");
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 [
["strict_correction", "Strict correction"],
["instruction_based", "Instruction-based guidance"]
]) {
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 = /(<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 });
})();