Lumi/plugins/lumi_ai/public/tool-manager.js
2026-06-13 20:28:06 +02:00

318 lines
13 KiB
JavaScript

(() => {
const openButton = document.querySelector("[data-ai-tools-open]");
const modal = document.querySelector("[data-ai-tools-modal]");
const list = modal?.querySelector("[data-ai-tools-list]");
const source = modal?.querySelector("[data-ai-tools-source]");
const refresh = modal?.querySelector("[data-ai-tools-refresh]");
const readmeModal = document.querySelector("[data-ai-tool-readme-modal]");
const readmeTitle = readmeModal?.querySelector("[data-ai-tool-readme-title]");
const readmeBody = readmeModal?.querySelector("[data-ai-tool-readme]");
if (!openButton || !modal || !list || !source || !readmeModal || !readmeTitle || !readmeBody) return;
let loading = false;
const setOpen = (target, open) => {
target.classList.toggle("is-open", open);
target.setAttribute("aria-hidden", String(!open));
};
const loadTools = async (force = false) => {
if (loading) return;
loading = true;
list.replaceChildren(message("Loading AI tool plugins..."));
try {
const response = await fetch(`/plugins/lumi_ai/api/tools${force ? "?refresh=1" : ""}`, {
cache: "no-store",
headers: { Accept: "application/json" }
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "Unable to load AI tools.");
source.textContent = `${payload.repository} · ${payload.branch} · checked ${payload.checked_at ? new Date(payload.checked_at).toLocaleString() : "never"}${payload.cached ? " · cached" : ""}${payload.stale ? " · stale" : ""}`;
source.classList.toggle("error", Boolean(payload.error));
if (payload.error) source.title = payload.error;
renderTools(payload.tools || []);
} catch (error) {
list.replaceChildren(message(error.message, true));
} finally {
loading = false;
}
};
const renderTools = (tools) => {
list.replaceChildren();
if (!tools.length) {
list.append(message("No local or remote Lumi AI tool plugins were found."));
return;
}
for (const tool of tools) list.append(renderTool(tool));
};
const renderTool = (tool) => {
const row = document.createElement("article");
row.className = "ai-tool-row";
const summary = document.createElement("div");
summary.className = "ai-tool-summary";
const identity = document.createElement("div");
identity.className = "ai-tool-identity";
const name = document.createElement("strong");
name.textContent = tool.display_name || tool.tool_id;
const id = document.createElement("span");
id.textContent = tool.tool_id;
identity.append(name, id);
const versions = document.createElement("div");
versions.className = "ai-tool-versions";
versions.append(
badge(tool.installed ? "Installed" : "Remote", tool.installed ? "installed" : ""),
badge(tool.enabled ? "Enabled" : "Disabled", tool.enabled ? "installed" : ""),
textPair("Local", tool.local_version || "-"),
textPair("Remote", tool.remote_version || "-"),
badge(tool.remote_missing ? "Remote missing" : tool.update_available ? "Update available" : "Current", tool.update_available ? "warning" : "")
);
const scope = document.createElement("div");
scope.className = "ai-tool-scope";
scope.textContent = `${tool.primary_type || "general"} · ${tool.primary_scope || "unspecified"}`;
const actions = document.createElement("div");
actions.className = "ai-tool-actions";
const expand = button("Details", "subtle");
const details = renderDetails(tool);
expand.addEventListener("click", () => {
details.hidden = !details.hidden;
expand.textContent = details.hidden ? "Details" : "Hide details";
});
const inspect = button("Inspect", "subtle");
inspect.addEventListener("click", () => inspectReadme(tool));
const enable = button(tool.enabled ? "Disable" : "Enable", tool.enabled ? "subtle" : "");
enable.disabled = tool.installed && !tool.local_valid;
enable.addEventListener("click", () => runAction(tool, tool.enabled ? "disable" : "enable", enable));
const update = button("Update", tool.update_available ? "update" : "subtle");
update.disabled = !tool.update_enabled;
update.title = !tool.installed ? "Install the tool before updating." : tool.remote_missing ? "This tool is missing remotely." : "";
update.addEventListener("click", () => runAction(tool, "update", update));
actions.append(expand, inspect, enable, update);
if (tool.installed) actions.append(deleteForm(tool));
summary.append(identity, versions, scope, actions);
row.append(summary, details);
return row;
};
const renderDetails = (tool) => {
const details = document.createElement("div");
details.className = "ai-tool-details";
details.hidden = true;
details.append(
detail("Description", tool.description || "No description."),
detail("Capabilities", joinValue(tool.capabilities)),
detail("Limitations", joinValue(tool.limitations)),
detail("Scope", joinValue(tool.scope)),
detail("Permissions", joinValue(tool.permissions)),
detail("Dependencies", joinValue(tool.dependencies)),
detail("Required plugins", joinValue(tool.required_plugins)),
detail("Required platforms", joinValue(tool.required_platforms)),
detail("Dependency availability", dependencyStatus(tool.dependency_status)),
detail("Risk / confirmation", `${tool.risk_level || "sensitive"} / ${tool.confirmation_required === false ? "not required" : "required"}`),
detail("Runtime", `${tool.runtime_state || "unknown"}${tool.runtime_message ? `: ${tool.runtime_message}` : ""}`),
detail("Install status", tool.local_error || tool.remote_error || (tool.remote_missing ? "Installed locally; missing from remote repository." : "Ready"))
);
return details;
};
const runAction = async (tool, action, control) => {
control.disabled = true;
const original = control.textContent;
control.textContent = action === "enable" ? "Enabling..." : action === "disable" ? "Disabling..." : "Updating...";
try {
const response = await fetch(`/plugins/lumi_ai/tools/${encodeURIComponent(tool.tool_id)}/${action}`, {
method: "POST",
headers: { Accept: "application/json" }
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || `${action} failed.`);
await loadTools(false);
} catch (error) {
window.alert(error.message);
control.disabled = false;
control.textContent = original;
}
};
const deleteForm = (tool) => {
const form = document.createElement("form");
form.method = "post";
form.action = `/plugins/lumi_ai/tools/${encodeURIComponent(tool.tool_id)}/delete`;
form.dataset.confirmMode = "modal";
form.dataset.confirmTitle = `Delete ${tool.display_name || tool.tool_id}?`;
form.dataset.confirmText = "The installed AI tool files will be removed. Shared Lumi AI data and unrelated plugins are not affected.";
const remove = button("Delete", "danger");
remove.type = "submit";
form.append(remove);
return form;
};
const inspectReadme = async (tool) => {
readmeTitle.textContent = `${tool.display_name || tool.tool_id} documentation`;
readmeBody.replaceChildren(message("Loading readme.md..."));
setOpen(readmeModal, true);
try {
const response = await fetch(`/plugins/lumi_ai/api/tools/${encodeURIComponent(tool.tool_id)}/readme`, {
cache: "no-store",
headers: { Accept: "application/json" }
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "Unable to load readme.md.");
renderMarkdown(readmeBody, payload.markdown);
} catch (error) {
readmeBody.replaceChildren(message(error.message, true));
}
};
const renderMarkdown = (container, markdown) => {
container.replaceChildren();
const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n");
let paragraph = [];
const flush = () => {
if (!paragraph.length) return;
const p = document.createElement("p");
appendInline(p, paragraph.join(" "));
container.append(p);
paragraph = [];
};
for (let index = 0; index < lines.length; index += 1) {
const fence = lines[index].match(/^```([a-z0-9_+-]*)/i);
if (fence) {
flush();
const code = [];
for (index += 1; index < lines.length && !/^```/.test(lines[index]); index += 1) code.push(lines[index]);
const pre = document.createElement("pre");
const element = document.createElement("code");
element.textContent = code.join("\n");
element.className = `language-${fence[1] || "text"}`;
pre.append(element);
container.append(pre);
continue;
}
const heading = lines[index].match(/^(#{1,4})\s+(.+)/);
if (heading) {
flush();
const element = document.createElement(`h${heading[1].length}`);
appendInline(element, heading[2]);
container.append(element);
} else if (/^\s*[-*]\s+/.test(lines[index])) {
flush();
const list = container.lastElementChild?.tagName === "UL" ? container.lastElementChild : document.createElement("ul");
const item = document.createElement("li");
appendInline(item, lines[index].replace(/^\s*[-*]\s+/, ""));
list.append(item);
if (!list.isConnected) container.append(list);
} else if (!lines[index].trim()) {
flush();
} else {
paragraph.push(lines[index]);
}
}
flush();
};
const appendInline = (parent, value) => {
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\[[^\]]+\]\([^)]+\))/g;
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 {
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(String(value).slice(offset)));
};
const safeAnchor = (href, label) => {
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;
}
};
const detail = (label, value) => {
const item = document.createElement("div");
const term = document.createElement("strong");
const body = document.createElement("span");
term.textContent = label;
body.textContent = value || "-";
item.append(term, body);
return item;
};
const dependencyStatus = (value) => {
const blocking = Array.isArray(value?.blocking) ? value.blocking : [];
const optional = Array.isArray(value?.optional) ? value.optional : [];
if (!blocking.length && !optional.length) return "Available";
return [
blocking.length ? `Blocking: ${blocking.join("; ")}` : "",
optional.length ? `Optional: ${optional.join("; ")}` : ""
].filter(Boolean).join(" | ");
};
const joinValue = (value) => Array.isArray(value) ? value.join(", ") || "-" : value && typeof value === "object" ? JSON.stringify(value) : String(value || "-");
const badge = (text, className = "") => {
const element = document.createElement("span");
element.className = `ai-tag ${className}`.trim();
element.textContent = text;
return element;
};
const textPair = (label, value) => {
const element = document.createElement("span");
element.className = "ai-tool-version";
element.textContent = `${label} ${value}`;
return element;
};
const button = (text, className = "") => {
const element = document.createElement("button");
element.type = "button";
element.className = `button ${className}`.trim();
element.textContent = text;
return element;
};
const message = (text, error = false) => {
const element = document.createElement("div");
element.className = `callout${error ? " danger" : ""}`;
element.textContent = text;
return element;
};
openButton.addEventListener("click", () => {
setOpen(modal, true);
loadTools(false);
});
refresh?.addEventListener("click", () => loadTools(true));
modal.querySelectorAll("[data-ai-tools-close]").forEach((control) => control.addEventListener("click", () => setOpen(modal, false)));
readmeModal.querySelectorAll("[data-ai-tool-readme-close]").forEach((control) => control.addEventListener("click", () => setOpen(readmeModal, false)));
for (const target of [modal, readmeModal]) {
target.addEventListener("click", (event) => {
if (event.target === target) setOpen(target, false);
});
}
})();