const modelsList = document.getElementById("models-list"); const downloadsList = document.getElementById("downloads-list"); const refreshModels = document.getElementById("refresh-models"); const refreshDownloads = document.getElementById("refresh-downloads"); const form = document.getElementById("download-form"); const errorEl = document.getElementById("download-error"); const statusEl = document.getElementById("switch-status"); const configStatusEl = document.getElementById("config-status"); const configForm = document.getElementById("config-form"); const refreshConfig = document.getElementById("refresh-config"); const warmupPromptEl = document.getElementById("warmup-prompt"); const refreshLogs = document.getElementById("refresh-logs"); const logsOutput = document.getElementById("logs-output"); const logsStatus = document.getElementById("logs-status"); const themeToggle = document.getElementById("theme-toggle"); const applyTheme = (theme) => { document.documentElement.setAttribute("data-theme", theme); themeToggle.textContent = theme === "dark" ? "Light" : "Dark"; themeToggle.setAttribute("aria-pressed", theme === "dark" ? "true" : "false"); }; const savedTheme = localStorage.getItem("theme") || "light"; applyTheme(savedTheme); themeToggle.addEventListener("click", () => { const next = document.documentElement.getAttribute("data-theme") === "dark" ? "light" : "dark"; localStorage.setItem("theme", next); applyTheme(next); }); const cfgFields = { ctx_size: document.getElementById("cfg-ctx-size"), n_gpu_layers: document.getElementById("cfg-n-gpu-layers"), tensor_split: document.getElementById("cfg-tensor-split"), split_mode: document.getElementById("cfg-split-mode"), cache_type_k: document.getElementById("cfg-cache-type-k"), cache_type_v: document.getElementById("cfg-cache-type-v"), flash_attn: document.getElementById("cfg-flash-attn"), temp: document.getElementById("cfg-temp"), top_k: document.getElementById("cfg-top-k"), top_p: document.getElementById("cfg-top-p"), repeat_penalty: document.getElementById("cfg-repeat-penalty"), repeat_last_n: document.getElementById("cfg-repeat-last-n"), frequency_penalty: document.getElementById("cfg-frequency-penalty"), presence_penalty: document.getElementById("cfg-presence-penalty"), }; const extraArgsEl = document.getElementById("cfg-extra-args"); const fmtBytes = (bytes) => { if (!bytes && bytes !== 0) return "-"; const units = ["B", "KB", "MB", "GB", "TB"]; let idx = 0; let value = bytes; while (value >= 1024 && idx < units.length - 1) { value /= 1024; idx += 1; } return `${value.toFixed(1)} ${units[idx]}`; }; const setStatus = (message, type) => { statusEl.textContent = message || ""; statusEl.className = "status"; if (type) { statusEl.classList.add(type); } }; const setConfigStatus = (message, type) => { configStatusEl.textContent = message || ""; configStatusEl.className = "status"; if (type) { configStatusEl.classList.add(type); } }; async function loadModels() { const res = await fetch("/ui/api/models"); const data = await res.json(); modelsList.innerHTML = ""; const activeModel = data.active_model; data.models.forEach((model) => { const li = document.createElement("li"); if (model.active) { li.classList.add("active"); } const row = document.createElement("div"); row.className = "model-row"; const name = document.createElement("span"); name.textContent = `${model.id} (${fmtBytes(model.size)})`; const actions = document.createElement("div"); if (model.active) { const badge = document.createElement("span"); badge.className = "badge"; badge.textContent = "Active"; actions.appendChild(badge); } else { const button = document.createElement("button"); button.className = "ghost"; button.textContent = "Switch"; button.onclick = async () => { setStatus(`Switching to ${model.id}...`); const warmupPrompt = warmupPromptEl.value.trim(); const res = await fetch("/ui/api/switch-model", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model_id: model.id, warmup_prompt: warmupPrompt }), }); const payload = await res.json(); if (!res.ok) { setStatus(payload.detail || "Switch failed.", "error"); return; } warmupPromptEl.value = ""; setStatus(`Active model: ${model.id}`, "ok"); await loadModels(); }; actions.appendChild(button); } row.appendChild(name); row.appendChild(actions); li.appendChild(row); modelsList.appendChild(li); }); if (activeModel) { setStatus(`Active model: ${activeModel}`, "ok"); } } async function loadDownloads() { const res = await fetch("/ui/api/downloads"); const data = await res.json(); downloadsList.innerHTML = ""; const entries = Object.values(data.downloads || {}); if (!entries.length) { downloadsList.innerHTML = "
No active downloads.
"; return; } entries.forEach((download) => { const card = document.createElement("div"); card.className = "download-card"; const title = document.createElement("strong"); title.textContent = download.filename; const meta = document.createElement("div"); const percent = download.bytes_total ? Math.round((download.bytes_downloaded / download.bytes_total) * 100) : 0; meta.textContent = `${download.status} ยท ${fmtBytes(download.bytes_downloaded)} / ${fmtBytes(download.bytes_total)}`; const progress = document.createElement("div"); progress.className = "progress"; const bar = document.createElement("span"); bar.style.width = `${Math.min(percent, 100)}%`; progress.appendChild(bar); const actions = document.createElement("div"); if (download.status === "downloading" || download.status === "queued") { const cancel = document.createElement("button"); cancel.className = "ghost"; cancel.textContent = "Cancel"; cancel.onclick = async () => { await fetch(`/ui/api/downloads/${download.download_id}`, { method: "DELETE" }); await loadDownloads(); }; actions.appendChild(cancel); } card.appendChild(title); card.appendChild(meta); card.appendChild(progress); card.appendChild(actions); downloadsList.appendChild(card); }); } async function loadConfig() { const res = await fetch("/ui/api/llamacpp-config"); const data = await res.json(); Object.entries(cfgFields).forEach(([key, el]) => { el.value = data.params?.[key] || ""; }); extraArgsEl.value = data.extra_args || ""; if (data.active_model) { setConfigStatus(`Active model: ${data.active_model}`, "ok"); } } async function loadLogs() { const res = await fetch("/ui/api/llamacpp-logs"); if (!res.ok) { logsStatus.textContent = "Unavailable"; return; } const data = await res.json(); logsOutput.textContent = data.logs || ""; logsStatus.textContent = data.logs ? "Snapshot" : "Empty"; } form.addEventListener("submit", async (event) => { event.preventDefault(); errorEl.textContent = ""; const url = document.getElementById("model-url").value.trim(); const filename = document.getElementById("model-filename").value.trim(); if (!url) { errorEl.textContent = "URL is required."; return; } const payload = { url }; if (filename) payload.filename = filename; const res = await fetch("/ui/api/downloads", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!res.ok) { const err = await res.json(); errorEl.textContent = err.detail || "Failed to start download."; return; } document.getElementById("model-url").value = ""; document.getElementById("model-filename").value = ""; await loadDownloads(); }); configForm.addEventListener("submit", async (event) => { event.preventDefault(); setConfigStatus("Applying parameters..."); const params = {}; Object.entries(cfgFields).forEach(([key, el]) => { if (el.value.trim()) { params[key] = el.value.trim(); } }); const warmupPrompt = warmupPromptEl.value.trim(); const res = await fetch("/ui/api/llamacpp-config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ params, extra_args: extraArgsEl.value.trim(), warmup_prompt: warmupPrompt }), }); const payload = await res.json(); if (!res.ok) { setConfigStatus(payload.detail || "Update failed.", "error"); return; } setConfigStatus("Parameters updated.", "ok"); warmupPromptEl.value = ""; }); refreshModels.addEventListener("click", loadModels); refreshDownloads.addEventListener("click", loadDownloads); refreshConfig.addEventListener("click", loadConfig); refreshLogs.addEventListener("click", loadLogs); loadModels(); loadDownloads(); loadConfig(); loadLogs(); const eventSource = new EventSource("/ui/api/events"); eventSource.onmessage = async (event) => { const payload = JSON.parse(event.data); if (payload.type === "download_progress" || payload.type === "download_completed" || payload.type === "download_status") { await loadDownloads(); } if (payload.type === "active_model") { await loadModels(); await loadConfig(); } if (payload.type === "model_switched") { setStatus(`Active model: ${payload.model_id}`, "ok"); await loadModels(); await loadConfig(); } if (payload.type === "model_switch_failed") { setStatus(payload.error || "Model switch failed.", "error"); } if (payload.type === "llamacpp_config_updated") { await loadConfig(); } }; const logsSource = new EventSource("/ui/api/llamacpp-logs/stream"); logsSource.onopen = () => { logsStatus.textContent = "Streaming"; }; logsSource.onmessage = (event) => { const payload = JSON.parse(event.data); if (payload.type !== "logs") { return; } const lines = payload.lines || []; if (!lines.length) return; const current = logsOutput.textContent.split("\n").filter((line) => line.length); const merged = current.concat(lines).slice(-400); logsOutput.textContent = merged.join("\n"); logsOutput.scrollTop = logsOutput.scrollHeight; logsStatus.textContent = "Streaming"; }; logsSource.onerror = () => { logsStatus.textContent = "Disconnected"; };