Lumi/plugins/lumi_ai/public/tool-manager.js
2026-06-14 04:18:36 +02:00

523 lines
22 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]");
const settingsModal = document.querySelector("[data-ai-tool-settings-modal]");
const settingsTitle = settingsModal?.querySelector("[data-ai-tool-settings-title]");
const settingsForm = settingsModal?.querySelector("[data-ai-tool-settings-form]");
const settingsFields = settingsModal?.querySelector("[data-ai-tool-settings-fields]");
const settingsSave = settingsModal?.querySelector("[data-ai-tool-settings-save]");
const diagnostics = modal?.querySelector("[data-ai-tool-diagnostics]");
const diagnosticRole = diagnostics?.querySelector("[data-ai-tool-diagnostic-role]");
const diagnosticOrigin = diagnostics?.querySelector("[data-ai-tool-diagnostic-origin]");
const diagnosticRefresh = diagnostics?.querySelector("[data-ai-tool-diagnostic-refresh]");
const diagnosticResults = diagnostics?.querySelector("[data-ai-tool-diagnostic-results]");
const promptPreview = diagnostics?.querySelector("[data-ai-tool-prompt-preview]");
if (!openButton || !modal || !list || !source || !readmeModal || !readmeTitle || !readmeBody ||
!settingsModal || !settingsTitle || !settingsForm || !settingsFields || !settingsSave ||
!diagnostics || !diagnosticRole || !diagnosticOrigin || !diagnosticResults || !promptPreview) return;
let loading = false;
let activeSettingsTool = null;
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 settings = button("Settings", "subtle");
settings.disabled = !tool.has_settings;
settings.title = tool.has_settings ? "" : "This tool does not expose configurable settings.";
settings.addEventListener("click", () => openSettings(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, settings, 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("Registered definitions", joinValue(tool.registered_tools)),
detail("Install status", tool.local_error || tool.remote_error || (tool.remote_missing ? "Installed locally; missing from remote repository." : "Ready"))
);
return details;
};
const loadDiagnostics = async () => {
diagnosticResults.replaceChildren(message("Evaluating registered tools..."));
promptPreview.textContent = "Loading...";
diagnosticRefresh.disabled = true;
try {
const query = new URLSearchParams({
role: diagnosticRole.value,
origin: diagnosticOrigin.value
});
const response = await fetch(`/plugins/lumi_ai/api/tools-diagnostics?${query}`, {
cache: "no-store",
headers: { Accept: "application/json" }
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "Tool diagnostics are unavailable.");
renderDiagnostics(payload);
} catch (error) {
diagnosticResults.replaceChildren(message(error.message, true));
promptPreview.textContent = "(unavailable)";
} finally {
diagnosticRefresh.disabled = false;
}
};
const renderDiagnostics = (payload) => {
diagnosticResults.replaceChildren();
if (!payload.plugins?.length) {
diagnosticResults.append(message("No installed AI tool plugins were discovered."));
}
for (const plugin of payload.plugins || []) {
const row = document.createElement("div");
row.className = `ai-tool-diagnostic-row ${plugin.prompt_exposed ? "exposed" : "hidden"}`;
const decisions = (plugin.decisions || []).map((decision) =>
`${decision.tool.tool_id}: ${decision.exposed ? "exposed" : decision.reason}`
).join(" | ");
row.textContent = [
plugin.tool_id,
`state=${plugin.state}`,
`enabled=${plugin.enabled}`,
`registered=${(plugin.registered_tools || []).join(", ") || "none"}`,
plugin.prompt_exposed ? "prompt=exposed" : `prompt=hidden (${plugin.hidden_reason || "unknown"})`,
plugin.message || "",
decisions
].filter(Boolean).join(" · ");
diagnosticResults.append(row);
}
promptPreview.textContent = payload.prompt_preview || "ALLOWED TOOLS:\n(none)";
};
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 openSettings = async (tool) => {
activeSettingsTool = tool;
settingsTitle.textContent = `${tool.display_name || tool.tool_id} settings`;
settingsFields.replaceChildren(message("Loading settings..."));
setOpen(settingsModal, true);
try {
const response = await fetch(`/plugins/lumi_ai/api/tools/${encodeURIComponent(tool.tool_id)}/settings`, {
cache: "no-store",
headers: { Accept: "application/json" }
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "Unable to load tool settings.");
renderSettings(payload);
} catch (error) {
settingsFields.replaceChildren(message(error.message, true));
}
};
const renderSettings = (payload) => {
settingsFields.replaceChildren();
for (const [key, field] of Object.entries(payload.schema || {})) {
const wrapper = document.createElement("div");
wrapper.className = `ai-tool-setting${["string_list", "multi_select"].includes(field.type) ? " wide" : ""}`;
const label = document.createElement("label");
label.textContent = field.label || key;
const control = settingsControl(key, field, payload.values?.[key], payload.configured_secrets?.[key]);
label.htmlFor = control.id || "";
wrapper.append(label, control);
if (field.description) {
const hint = document.createElement("p");
hint.className = "hint";
hint.textContent = field.description;
wrapper.append(hint);
}
settingsFields.append(wrapper);
}
};
const settingsControl = (key, field, value, configuredSecret) => {
if (field.type === "boolean") {
const input = document.createElement("input");
input.type = "checkbox";
input.name = key;
input.id = `ai-tool-setting-${key}`;
input.checked = value === true;
return input;
}
if (field.type === "multi_select") {
const group = document.createElement("div");
group.className = "check-grid";
group.dataset.settingName = key;
for (const option of field.options || []) {
const label = document.createElement("label");
const input = document.createElement("input");
input.type = "checkbox";
input.value = option;
input.checked = Array.isArray(value) && value.includes(option);
label.append(input, document.createTextNode(option));
group.append(label);
}
return group;
}
if (field.type === "string_list") {
const textarea = document.createElement("textarea");
textarea.name = key;
textarea.id = `ai-tool-setting-${key}`;
textarea.rows = field.rows || 3;
textarea.value = Array.isArray(value) ? value.join("\n") : "";
return textarea;
}
if (field.type === "enum") {
const select = document.createElement("select");
select.name = key;
select.id = `ai-tool-setting-${key}`;
for (const option of field.options || []) {
const item = document.createElement("option");
item.value = option;
item.textContent = option;
item.selected = option === value;
select.append(item);
}
return select;
}
const input = document.createElement("input");
input.name = key;
input.id = `ai-tool-setting-${key}`;
input.type = field.secret ? "password" : ["integer", "number"].includes(field.type) ? "number" : "text";
if (field.minimum != null) input.min = field.minimum;
if (field.maximum != null) input.max = field.maximum;
if (field.type === "number") input.step = "any";
input.value = field.secret ? "" : value ?? "";
if (field.secret && configuredSecret) input.placeholder = "Configured; leave blank to keep";
return input;
};
settingsForm.addEventListener("submit", async (event) => {
event.preventDefault();
if (!activeSettingsTool) return;
const values = {};
for (const [key, field] of Object.entries(activeSettingsTool.settings_schema || {})) {
if (field.type === "multi_select") {
const group = [...settingsFields.querySelectorAll("[data-setting-name]")]
.find((element) => element.dataset.settingName === key);
values[key] = [...(group?.querySelectorAll("input:checked") || [])].map((input) => input.value);
} else {
const input = [...settingsFields.querySelectorAll("[name]")]
.find((element) => element.name === key);
values[key] = field.type === "boolean" ? Boolean(input?.checked) : input?.value;
}
}
settingsSave.disabled = true;
settingsSave.textContent = "Saving...";
try {
const response = await fetch(`/plugins/lumi_ai/api/tools/${encodeURIComponent(activeSettingsTool.tool_id)}/settings`, {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ values })
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "Unable to save tool settings.");
setOpen(settingsModal, false);
await loadTools(false);
} catch (error) {
window.alert(error.message);
} finally {
settingsSave.disabled = false;
settingsSave.textContent = "Save settings";
}
});
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));
diagnosticRefresh?.addEventListener("click", loadDiagnostics);
diagnostics.addEventListener("toggle", () => {
if (diagnostics.open) loadDiagnostics();
});
diagnosticRole.addEventListener("change", () => diagnostics.open && loadDiagnostics());
diagnosticOrigin.addEventListener("change", () => diagnostics.open && loadDiagnostics());
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)));
settingsModal.querySelectorAll("[data-ai-tool-settings-close]").forEach((control) => control.addEventListener("click", () => setOpen(settingsModal, false)));
for (const target of [modal, readmeModal, settingsModal]) {
target.addEventListener("click", (event) => {
if (event.target === target) setOpen(target, false);
});
}
})();