/** * aide — web UI JavaScript * * Handles: * - WebSocket connection & reconnect * - Streaming chat (text + tool indicators) * - Confirmation modal * - Pause/resume * - Shared helpers (status bar, nav) */ /* ── Web UI fetch marker ─────────────────────────────────────────────────── */ // Adds X-Requested-By: aide-ui to every /api/* call made by the web UI. // Combined with the session cookie this identifies legitimate web UI requests. // Swagger's built-in fetch does NOT add this header, so it must use the API key. (function () { const _orig = window.fetch.bind(window); window.fetch = function (url, opts) { const urlStr = typeof url === "string" ? url : (url && url.url) || ""; if (urlStr.startsWith("/api/")) { opts = opts ? { ...opts } : {}; opts.headers = { "X-Requested-By": "aide-ui", ...(opts.headers || {}) }; } return _orig(url, opts); }; })(); /* ── Global state ────────────────────────────────────────────────────────── */ let ws = null; let sessionId = null; // set lazily in initChat() after inline scripts run let reconnectDelay = 1000; let isGenerating = false; /* ── Utility ─────────────────────────────────────────────────────────────── */ function esc(str) { return String(str) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function formatBytes(n) { if (n < 1024) return n + " B"; if (n < 1048576) return (n / 1024).toFixed(1) + " KB"; return (n / 1048576).toFixed(1) + " MB"; } const _TZ = "Europe/Oslo"; function _dtParts(iso, opts) { const parts = {}; for (const p of new Intl.DateTimeFormat("en-GB", { ...opts, timeZone: _TZ }).formatToParts(new Date(iso))) parts[p.type] = p.value; return parts; } function formatDate(iso) { if (!iso) return "—"; try { const v = _dtParts(iso, { day:"2-digit", month:"2-digit", year:"numeric", hour:"2-digit", minute:"2-digit", second:"2-digit", hour12:false }); return `${v.day}.${v.month}.${v.year} ${v.hour}:${v.minute}:${v.second}`; } catch { return iso; } } function formatDateShort(iso) { if (!iso) return "—"; try { const v = _dtParts(iso, { day:"2-digit", month:"2-digit", year:"numeric" }); return `${v.day}.${v.month}.${v.year}`; } catch { return iso; } } /** Parse "dd.mm.yyyy" or "dd.mm.yyyy HH:MM" → ISO UTC string, or null if invalid. */ function parseEuDate(str, isEnd = false) { if (!str?.trim()) return null; const m = str.trim().match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:[T ](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/); if (!m) return null; const [, d, mo, y, h = "00", mi = "00", s] = m; const ss = s ?? (isEnd ? "59" : "00"); return `${y}-${mo.padStart(2,"0")}-${d.padStart(2,"0")}T${h.padStart(2,"0")}:${mi}:${ss}Z`; } /* ── Status bar (shared) ─────────────────────────────────────────────────── */ async function refreshStatus() { try { const r = await fetch("/api/status"); const data = await r.json(); const dot = document.getElementById("status-dot"); const label = document.getElementById("status-label"); if (dot && label) { if (data.paused) { dot.className = "status-dot paused"; label.textContent = "Paused"; } else { dot.className = "status-dot"; label.textContent = "Running"; } } // Update sidebar pause button const sidebarBtn = document.getElementById("pause-btn"); if (sidebarBtn) { sidebarBtn.classList.toggle("paused", data.paused); sidebarBtn.title = data.paused ? "Resume agent" : "Pause agent"; const icon = sidebarBtn.querySelector(".btn-icon"); if (icon) icon.textContent = data.paused ? "\u25b6" : "\u23f8"; const label = sidebarBtn.querySelector(".btn-label"); if (label) label.textContent = data.paused ? "Resume" : "Pause"; } // Update settings page pause button (has a different id to avoid duplicate) const settingsBtn = document.getElementById("settings-pause-btn"); if (settingsBtn) { settingsBtn.className = `btn ${data.paused ? "btn-ghost paused" : "btn-primary"}`; const icon = settingsBtn.querySelector(".btn-icon"); if (icon) icon.textContent = data.paused ? "▶" : "⏸"; const textNode = Array.from(settingsBtn.childNodes).find(n => n.nodeType === Node.TEXT_NODE && n.textContent.trim()); if (textNode) textNode.textContent = data.paused ? " Resume agent" : " Pause agent"; } } catch { /* ignore */ } } async function togglePause() { const pauseBtn = document.getElementById("pause-btn"); const isPaused = pauseBtn?.classList.contains("paused"); await fetch(isPaused ? "/api/resume" : "/api/pause", { method: "POST" }); await refreshStatus(); } /* ── Navigation (SPA) ───────────────────────────────────────────────────── */ // Snapshot of chat state while on another page let _chatSnapshot = null; // { html, sessionId } let _skipNextRestore = false; // true when snapshot was just restored — skip WS restore event let _cachedModels = null; // { models, default, capabilities } — cached from WS "models" message let _modelCapabilities = {}; // { "provider:model-id": { vision, tools, online } } function initNav() { _setActiveNav(location.pathname); // Intercept sidebar nav clicks — swap
without a full reload document.querySelectorAll(".nav-item[data-page]").forEach(el => { el.addEventListener("click", e => { const href = el.getAttribute("href"); if (!href || href.startsWith("http")) return; // external links: normal e.preventDefault(); navigateTo(href); }); }); // Browser back/forward window.addEventListener("popstate", e => { navigateTo(location.pathname, { pushState: false }); }); } function _setActiveNav(path) { document.querySelectorAll(".nav-item[data-page]").forEach(el => { const page = el.dataset.page; el.classList.toggle("active", page === path || (path === "/" && page === "/")); }); } async function navigateTo(url, { pushState = true } = {}) { // Snapshot chat state before leaving the chat page const chatMsgs = document.getElementById("chat-messages"); if (chatMsgs) { _chatSnapshot = { html: chatMsgs.innerHTML, sessionId }; } // Fetch the target page HTML and extract
content let html; try { const r = await fetch(url); html = await r.text(); } catch (err) { console.error("[aide] navigate fetch failed:", err); return; } const parser = new DOMParser(); const doc = parser.parseFromString(html, "text/html"); const newMain = doc.querySelector("main, #main"); if (!newMain) return; document.getElementById("main").innerHTML = newMain.innerHTML; document.title = doc.title || window.AGENT_NAME || "aide"; // Extract and run any inline