792 lines
30 KiB
JavaScript
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 [
|
|
["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 = /(<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 });
|
|
})();
|