340 lines
16 KiB
JavaScript
340 lines
16 KiB
JavaScript
(() => {
|
|
const actions = document.querySelector("[data-ai-runtime-actions]");
|
|
const state = document.querySelector("[data-runtime-state]");
|
|
const downloadStatus = document.querySelector("[data-download-status]");
|
|
const testForm = document.querySelector("[data-ai-test-form]");
|
|
const testOutput = document.querySelector("[data-ai-test-output]");
|
|
const testToolsNotice = document.querySelector("[data-ai-test-tools-notice]");
|
|
const gpuControl = document.querySelector("[data-gpu-control]");
|
|
const accessForm = document.querySelector("[data-ai-access-form]");
|
|
const runtimePrimary = document.querySelector("[data-runtime-primary]");
|
|
if (actions) {
|
|
const syncPrimary = (nextState) => {
|
|
if (!runtimePrimary || !window.LumiStateButton) return;
|
|
const normalized = String(nextState || state?.textContent || "").toLowerCase();
|
|
window.LumiStateButton.setState(runtimePrimary, normalized === "running" ? "running" : "idle");
|
|
};
|
|
actions.addEventListener("click", async (event) => {
|
|
const primaryButton = event.target.closest("[data-runtime-primary]");
|
|
const button = primaryButton || event.target.closest("[data-runtime-action]");
|
|
if (!button) return;
|
|
const action = primaryButton
|
|
? (String(state?.textContent || "").trim().toLowerCase() === "running" ? "restart" : "start")
|
|
: button.dataset.runtimeAction;
|
|
if (!action) return;
|
|
button.disabled = true;
|
|
if (primaryButton && window.LumiStateButton) {
|
|
window.LumiStateButton.setState(button, action === "restart" ? "restarting" : "starting", { busy: true });
|
|
}
|
|
try {
|
|
const response = await fetch(`/plugins/lumi_ai/runtime/${action}`, { method: "POST" });
|
|
const data = await response.json();
|
|
if (!response.ok) throw new Error(data.error || "Runtime action failed.");
|
|
if (data.state) state.textContent = data.state;
|
|
syncPrimary(data.state);
|
|
if (["self-test", "verify-runtime", "verify-model", "verify-gate-model"].includes(action)) {
|
|
const labels = { "self-test": "Runtime self-test passed.", "verify-runtime": "Runtime installation verified.", "verify-model": "Model verification passed.", "verify-gate-model": "Gate model verification passed." };
|
|
window.alert(labels[action]);
|
|
}
|
|
} catch (error) {
|
|
if (primaryButton && window.LumiStateButton) window.LumiStateButton.error(button);
|
|
window.alert(error.message);
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
document.querySelectorAll("[data-ai-download-form]").forEach((form) => {
|
|
form.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const button = form.querySelector("[data-ai-download-button]");
|
|
window.LumiStateButton?.setState(button, "loading", { busy: true });
|
|
try {
|
|
const response = await fetch(form.action, {
|
|
method: "POST",
|
|
headers: { "Accept": "application/json" },
|
|
body: new FormData(form)
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok) throw new Error(data.error || "Download failed to start.");
|
|
pollDownloads();
|
|
} catch (error) {
|
|
window.LumiStateButton?.error(button);
|
|
if (downloadStatus) {
|
|
downloadStatus.hidden = false;
|
|
downloadStatus.textContent = error.message;
|
|
}
|
|
}
|
|
});
|
|
});
|
|
const pollDownloads = async () => {
|
|
if (!downloadStatus) return;
|
|
try {
|
|
const response = await fetch("/plugins/lumi_ai/api/downloads");
|
|
if (!response.ok) return;
|
|
const jobs = Object.values(await response.json());
|
|
const active = jobs.filter((job) => !["complete", "error"].includes(job.state));
|
|
if (!jobs.length) return;
|
|
downloadStatus.hidden = false;
|
|
downloadStatus.textContent = jobs.map((job) => {
|
|
const percent = job.total ? Math.floor(job.downloaded / job.total * 100) : 0;
|
|
return `${job.id}: ${job.state}${job.total ? ` ${percent}%` : ""}${job.error ? ` - ${job.error}` : ""}`;
|
|
}).join(" | ");
|
|
jobs.forEach((job) => {
|
|
const button = document.querySelector(`[data-ai-download-button][data-download-id="${CSS.escape(job.id)}"]`);
|
|
if (!button || !window.LumiStateButton) return;
|
|
if (job.state === "complete") window.LumiStateButton.setState(button, "success");
|
|
else if (job.state === "error") window.LumiStateButton.setState(button, "error");
|
|
else window.LumiStateButton.setState(button, "loading", { busy: true });
|
|
const label = button.querySelector('[data-state-view="loading"] span:last-child');
|
|
if (label && job.total) label.textContent = `Downloading ${Math.floor(job.downloaded / job.total * 100)}%`;
|
|
});
|
|
if (active.length) window.setTimeout(pollDownloads, 1000);
|
|
} catch {}
|
|
};
|
|
pollDownloads();
|
|
if (testForm && testOutput) {
|
|
const updateTestToolsNotice = () => {
|
|
if (!testToolsNotice) return;
|
|
const enabled = testForm.elements.allow_tools?.checked === true;
|
|
testToolsNotice.textContent = enabled
|
|
? "Tools are enabled. This test uses normal discovery, prompt exposure, permission checks, and execution."
|
|
: "Tools are disabled for this test; this result will not exercise tool discovery or execution.";
|
|
};
|
|
testForm.elements.allow_tools?.addEventListener("change", updateTestToolsNotice);
|
|
updateTestToolsNotice();
|
|
testForm.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const form = new FormData(testForm);
|
|
testOutput.hidden = false;
|
|
testOutput.textContent = "Running...";
|
|
try {
|
|
const response = await fetch("/plugins/lumi_ai/assistant/test", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
role: form.get("role"),
|
|
origin: form.get("origin"),
|
|
message: form.get("message"),
|
|
allow_tools: form.get("allow_tools") === "on",
|
|
show_raw_prompt: form.get("show_raw_prompt") === "on",
|
|
show_raw_output: form.get("show_raw_output") === "on"
|
|
})
|
|
});
|
|
const data = await response.json();
|
|
if (!response.ok) throw new Error(data.error || "Test failed.");
|
|
if (form.get("show_raw_output") !== "on") delete data.raw_response;
|
|
testOutput.textContent = JSON.stringify(data, null, 2);
|
|
} catch (error) {
|
|
testOutput.textContent = error.message;
|
|
}
|
|
});
|
|
}
|
|
if (gpuControl) {
|
|
const model = document.querySelector("[data-gpu-model]");
|
|
const context = document.querySelector("[data-gpu-context]");
|
|
const workload = gpuControl.querySelector("[data-gpu-workload]");
|
|
const slider = gpuControl.querySelector("[data-gpu-slider]");
|
|
const value = gpuControl.querySelector("[data-gpu-value]");
|
|
const intentLabel = gpuControl.querySelector("[data-gpu-intent]");
|
|
const actualLabel = gpuControl.querySelector("[data-gpu-actual]");
|
|
const limit = gpuControl.querySelector("[data-gpu-limit]");
|
|
const backend = gpuControl.querySelector("[data-gpu-backend]");
|
|
const memory = gpuControl.querySelector("[data-gpu-memory]");
|
|
const totalVram = gpuControl.querySelector("[data-gpu-total-vram]");
|
|
const freeVram = gpuControl.querySelector("[data-gpu-free-vram]");
|
|
const externalVram = gpuControl.querySelector("[data-gpu-external-vram]");
|
|
const warning = gpuControl.querySelector("[data-gpu-warning]");
|
|
let maximum = Number.parseInt(limit.textContent.match(/\d+/)?.[0], 10) || 0;
|
|
let capacityTimer = null;
|
|
|
|
const formatBytes = (megabytes) => {
|
|
if (!megabytes) return "0 B";
|
|
return megabytes >= 1024 ? `${(megabytes / 1024).toFixed(1)} GB` : `${Math.round(megabytes)} MB`;
|
|
};
|
|
const clampNewIntent = () => {
|
|
const next = Math.max(0, Math.min(maximum, Number(workload.value) || 0));
|
|
workload.value = String(next);
|
|
value.textContent = `${next}% intent`;
|
|
intentLabel.textContent = `${next}%`;
|
|
return next;
|
|
};
|
|
const applyCapacity = (data) => {
|
|
maximum = Math.max(0, Math.min(100, Number(data.gpu_allocation_max_safe_percent) || 0));
|
|
const intent = Math.max(0, Math.min(100, Number(data.gpu_allocation_intent_percent) || 0));
|
|
const actual = Math.max(0, Math.min(maximum, Number(data.gpu_allocation_actual_percent) || 0));
|
|
slider.style.setProperty("--gpu-max", `${maximum}%`);
|
|
slider.style.setProperty("--gpu-actual", `${actual}%`);
|
|
slider.title = `Intended ${intent}%, actual ${actual}%, maximum safe ${maximum}%`;
|
|
limit.textContent = `Maximum safe: ${maximum}%`;
|
|
workload.value = String(intent);
|
|
value.textContent = `${intent}% intent`;
|
|
intentLabel.textContent = `${intent}%`;
|
|
actualLabel.textContent = `${actual}%`;
|
|
backend.textContent = String(data.backend || "cpu").toUpperCase();
|
|
memory.dataset.fullOffloadMb = String(Number(data.estimated_full_offload_mb) || 0);
|
|
memory.textContent = formatBytes(data.managed_model_vram_mb);
|
|
totalVram.textContent = formatBytes(data.total_vram_mb);
|
|
freeVram.textContent = formatBytes(data.free_vram_mb);
|
|
externalVram.textContent = formatBytes(data.external_vram_estimate_mb);
|
|
warning.hidden = !data.warning;
|
|
warning.textContent = data.warning || "";
|
|
};
|
|
const refreshCapacity = async () => {
|
|
const query = new URLSearchParams({
|
|
model_id: model.value,
|
|
context_size: context.value,
|
|
intent_percent: workload.value
|
|
});
|
|
try {
|
|
const response = await fetch(`${gpuControl.dataset.endpoint}?${query}`, { cache: "no-store" });
|
|
const data = await response.json();
|
|
if (!response.ok) throw new Error(data.error || "Capacity check failed.");
|
|
applyCapacity(data);
|
|
} catch (error) {
|
|
applyCapacity({ max_percent: 0, backend: "cpu", warning: error.message });
|
|
}
|
|
};
|
|
const scheduleCapacity = () => {
|
|
window.clearTimeout(capacityTimer);
|
|
capacityTimer = window.setTimeout(refreshCapacity, 250);
|
|
};
|
|
workload.addEventListener("input", () => {
|
|
const selected = clampNewIntent();
|
|
const actual = Math.min(selected, maximum);
|
|
slider.style.setProperty("--gpu-actual", `${actual}%`);
|
|
actualLabel.textContent = `${actual}%`;
|
|
memory.textContent = formatBytes(
|
|
(Number(memory.dataset.fullOffloadMb) || 0) * actual / 100
|
|
);
|
|
});
|
|
workload.addEventListener("change", refreshCapacity);
|
|
model.addEventListener("change", refreshCapacity);
|
|
context.addEventListener("input", scheduleCapacity);
|
|
context.addEventListener("change", refreshCapacity);
|
|
refreshCapacity();
|
|
}
|
|
if (accessForm) {
|
|
const search = accessForm.querySelector("[data-user-search]");
|
|
const userId = accessForm.querySelector("[data-user-id]");
|
|
const results = accessForm.querySelector("[data-user-results]");
|
|
const preview = accessForm.querySelector("[data-user-preview]");
|
|
const action = accessForm.querySelector("[data-access-action]");
|
|
const timeoutField = accessForm.querySelector("[data-timeout-field]");
|
|
let searchTimer = null;
|
|
|
|
const updateTimeoutVisibility = () => {
|
|
const visible = action.value === "timeout";
|
|
timeoutField.hidden = !visible;
|
|
const input = timeoutField.querySelector("input");
|
|
input.required = visible;
|
|
if (!visible) input.value = "";
|
|
};
|
|
const selectUser = (user) => {
|
|
userId.value = user.id;
|
|
search.value = user.username;
|
|
const identities = user.identities.map((identity) =>
|
|
`${identity.provider}: ${identity.display_name || identity.provider_user_id}`).join(" | ");
|
|
preview.textContent = `${user.username} | ${user.id}${identities ? ` | ${identities}` : ""}`;
|
|
preview.hidden = false;
|
|
results.hidden = true;
|
|
};
|
|
const renderUsers = (users) => {
|
|
results.replaceChildren();
|
|
for (const user of users) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "ai-user-result";
|
|
const identity = user.identities[0];
|
|
button.textContent = identity
|
|
? `${user.username} | ${identity.display_name || identity.provider_user_id} (${identity.provider})`
|
|
: `${user.username} | ${user.id}`;
|
|
button.addEventListener("click", () => selectUser(user));
|
|
results.append(button);
|
|
}
|
|
results.hidden = !users.length;
|
|
};
|
|
search.addEventListener("input", () => {
|
|
userId.value = "";
|
|
preview.hidden = true;
|
|
window.clearTimeout(searchTimer);
|
|
const query = search.value.trim();
|
|
if (query.length < 2) {
|
|
results.hidden = true;
|
|
return;
|
|
}
|
|
searchTimer = window.setTimeout(async () => {
|
|
try {
|
|
const response = await fetch(`/plugins/lumi_ai/api/users/search?q=${encodeURIComponent(query)}`, { cache: "no-store" });
|
|
const data = await response.json();
|
|
renderUsers(response.ok ? data.users || [] : []);
|
|
} catch {
|
|
renderUsers([]);
|
|
}
|
|
}, 180);
|
|
});
|
|
accessForm.addEventListener("submit", (event) => {
|
|
if (!userId.value) {
|
|
event.preventDefault();
|
|
search.setCustomValidity("Select a known Lumi user.");
|
|
search.reportValidity();
|
|
}
|
|
});
|
|
search.addEventListener("input", () => search.setCustomValidity(""));
|
|
action.addEventListener("change", updateTimeoutVisibility);
|
|
updateTimeoutVisibility();
|
|
}
|
|
const assistantDiagnostics = document.querySelector("[data-assistant-diagnostics]");
|
|
if (assistantDiagnostics) {
|
|
const status = assistantDiagnostics.querySelector("[data-assistant-status]");
|
|
const reason = assistantDiagnostics.querySelector("[data-assistant-reason]");
|
|
const conditions = assistantDiagnostics.querySelector("[data-assistant-conditions]");
|
|
const endpointStatus = assistantDiagnostics.querySelector("[data-panel-endpoint-status]");
|
|
const htmlLength = assistantDiagnostics.querySelector("[data-panel-html-length]");
|
|
const htmlError = assistantDiagnostics.querySelector("[data-panel-html-error]");
|
|
const mountError = assistantDiagnostics.querySelector("[data-panel-mount-error]");
|
|
const refreshDiagnostics = () => fetch(assistantDiagnostics.dataset.endpoint, { cache: "no-store" })
|
|
.then(async (response) => {
|
|
const data = await response.json();
|
|
if (!response.ok) throw new Error(data.error || "Diagnostic unavailable.");
|
|
status.textContent = data.conditions?.every((condition) => condition.passed) ? "Mounted" : data.available ? "Backend ready" : "Hidden";
|
|
reason.textContent = data.reason;
|
|
for (const condition of data.conditions || []) {
|
|
const row = conditions?.querySelector(`[data-condition="${condition.key}"] strong`);
|
|
if (!row) continue;
|
|
row.textContent = condition.passed ? "Pass" : "Fail";
|
|
row.className = condition.passed ? "pass" : "fail";
|
|
}
|
|
if (endpointStatus) endpointStatus.textContent = data.panel_endpoint_status || "Not requested";
|
|
if (htmlLength) htmlLength.textContent = data.panel_html_length || 0;
|
|
if (htmlError) htmlError.textContent = data.panel_html_error || "None";
|
|
if (mountError) mountError.textContent = data.mount_error || "None";
|
|
})
|
|
.catch((error) => {
|
|
status.textContent = "Unknown";
|
|
reason.textContent = error.message;
|
|
});
|
|
refreshDiagnostics();
|
|
window.setInterval(refreshDiagnostics, 5000);
|
|
}
|
|
const logContent = document.querySelector("[data-log-content]");
|
|
const logFilter = document.querySelector("[data-log-filter]");
|
|
const logCopy = document.querySelector("[data-log-copy]");
|
|
if (logContent && logFilter) {
|
|
const originalLines = logContent.textContent.split(/\r?\n/);
|
|
logFilter.addEventListener("input", () => {
|
|
const term = logFilter.value.trim().toLowerCase();
|
|
logContent.textContent = term
|
|
? originalLines.filter((line) => line.toLowerCase().includes(term)).join("\n")
|
|
: originalLines.join("\n");
|
|
});
|
|
logCopy?.addEventListener("click", async () => {
|
|
await navigator.clipboard.writeText(logContent.textContent);
|
|
const original = logCopy.textContent;
|
|
logCopy.textContent = "Copied";
|
|
window.setTimeout(() => { logCopy.textContent = original; }, 1200);
|
|
});
|
|
}
|
|
pollDownloads();
|
|
})();
|