Files
oai-web/server/web/static/app.js
2026-04-08 12:43:24 +02:00

4998 lines
207 KiB
JavaScript

/**
* 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, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 <main> 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 <main> 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 <script> tags from the fetched page
// (e.g. window.SESSION_ID injected by chat.html — but only on a fresh visit)
if (!_chatSnapshot || url !== "/") {
doc.querySelectorAll("script:not([src])").forEach(s => {
if (s.textContent.trim()) {
const el = document.createElement("script");
el.textContent = s.textContent;
document.head.appendChild(el);
document.head.removeChild(el);
}
});
}
if (pushState) history.pushState({}, "", url);
_setActiveNav(url);
_initPage(url);
}
function _initPage(url) {
let path;
try { path = new URL(url, location.origin).pathname; } catch { path = url.split("?")[0]; }
if (path === "/" || path === "") { initChat(); return; }
if (path === "/agents") { initAgents(); return; }
if (path.startsWith("/agents/")) { initAgentDetail(); return; }
if (path === "/audit") { initAudit(); return; }
if (path === "/models") { initModels(); return; }
if (path === "/settings") { initSettings(); return; }
if (path === "/help") { initHelp(); return; }
if (path === "/files") { fileBrowserNavigate(""); return; }
}
/* ══════════════════════════════════════════════════════════════════════════
FILES PAGE
══════════════════════════════════════════════════════════════════════════ */
let _fbPath = "";
function _fbFmtSize(bytes) {
if (bytes === null || bytes === undefined) return "—";
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " MB";
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + " GB";
}
function _fbFmtDate(iso) {
if (!iso) return "—";
return formatDate(iso);
}
function _fbBuildBreadcrumb(path) {
const el = document.getElementById("file-breadcrumb");
if (!el) return;
el.innerHTML = "";
const root = document.createElement("span");
root.style.cssText = "cursor:pointer;color:var(--accent)";
root.textContent = "My Files";
root.onclick = function() { fileBrowserNavigate(""); };
el.appendChild(root);
if (!path) return;
const parts = path.split("/").filter(Boolean);
parts.forEach(function(part, i) {
const sep = document.createElement("span");
sep.textContent = " / ";
el.appendChild(sep);
const crumb = document.createElement("span");
const isLast = i === parts.length - 1;
if (isLast) {
crumb.style.color = "var(--text)";
crumb.textContent = part;
} else {
crumb.style.cssText = "cursor:pointer;color:var(--accent)";
crumb.textContent = part;
const targetPath = parts.slice(0, i + 1).join("/");
crumb.onclick = (function(tp) { return function() { fileBrowserNavigate(tp); }; })(targetPath);
}
el.appendChild(crumb);
});
}
async function fileBrowserNavigate(path) {
_fbPath = path || "";
const tbody = document.getElementById("file-tbody");
const tableWrap = document.getElementById("file-table-wrap");
const emptyEl = document.getElementById("file-empty");
const noFolder = document.getElementById("file-no-folder");
const zipBtn = document.getElementById("dl-zip-btn");
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-dim)">Loading\u2026</td></tr>';
tableWrap.style.display = "";
emptyEl.style.display = "none";
noFolder.style.display = "none";
const r = await fetch("/api/my/files?path=" + encodeURIComponent(_fbPath));
if (!r.ok) {
const d = await r.json().catch(function() { return {}; });
if (d.detail === "no_folder" || d.detail === "folder_missing") {
tableWrap.style.display = "none";
noFolder.style.display = "";
zipBtn.style.display = "none";
document.getElementById("file-breadcrumb").innerHTML = "";
} else {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--red)">' + esc(d.detail || "Error loading files") + "</td></tr>";
}
return;
}
const data = await r.json();
_fbBuildBreadcrumb(data.path);
zipBtn.style.display = "";
if (!data.entries.length) {
tableWrap.style.display = "none";
emptyEl.style.display = "";
return;
}
tableWrap.style.display = "";
emptyEl.style.display = "none";
tbody.innerHTML = "";
if (data.parent !== null && data.parent !== undefined) {
const tr = document.createElement("tr");
tr.style.cursor = "pointer";
tr.innerHTML = '<td style="color:var(--text-dim);font-size:16px;text-align:center">\u2191</td><td style="color:var(--text-dim)" colspan="3">..</td><td></td>';
tr.onclick = (function(p) { return function() { fileBrowserNavigate(p); }; })(data.parent);
tr.onmouseenter = function() { tr.style.background = "var(--bg2)"; };
tr.onmouseleave = function() { tr.style.background = ""; };
tbody.appendChild(tr);
}
for (const entry of data.entries) {
const tr = document.createElement("tr");
const iconTd = document.createElement("td");
iconTd.style.textAlign = "center";
iconTd.innerHTML = entry.is_dir
? '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="color:var(--yellow)"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>'
: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16" style="color:var(--text-dim)"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>';
const nameTd = document.createElement("td");
if (entry.is_dir) {
nameTd.style.cssText = "cursor:pointer;color:var(--accent)";
nameTd.textContent = entry.name;
nameTd.onclick = (function(p) { return function() { fileBrowserNavigate(p); }; })(entry.path);
} else {
nameTd.textContent = entry.name;
}
const sizeTd = document.createElement("td");
sizeTd.style.cssText = "text-align:right;color:var(--text-dim);font-size:12px";
sizeTd.textContent = _fbFmtSize(entry.size);
const dateTd = document.createElement("td");
dateTd.style.cssText = "color:var(--text-dim);font-size:12px";
dateTd.textContent = _fbFmtDate(entry.modified);
const actionTd = document.createElement("td");
actionTd.style.cssText = "text-align:right;display:flex;justify-content:flex-end;gap:6px;align-items:center";
const btn = document.createElement("button");
btn.className = "btn btn-ghost btn-small";
if (entry.is_dir) {
btn.title = "Download folder as ZIP";
btn.textContent = "\u2193 ZIP";
btn.onclick = (function(p) { return function(e) { e.stopPropagation(); fileBrowserDownloadZip(p); }; })(entry.path);
} else {
btn.title = "Download file";
btn.textContent = "\u2193 Download";
btn.onclick = (function(p) { return function() { fileBrowserDownloadFile(p); }; })(entry.path);
}
actionTd.appendChild(btn);
// Delete button — files only, protected names excluded
if (!entry.is_dir && !entry.name.startsWith("memory_") && !entry.name.startsWith("reasoning_")) {
const delBtn = document.createElement("button");
delBtn.className = "btn btn-ghost btn-small";
delBtn.title = "Delete file";
delBtn.textContent = "Delete";
delBtn.style.color = "var(--danger,#dc3c3c)";
delBtn.onclick = (function(p, n) { return function(e) { e.stopPropagation(); fileBrowserDeleteFile(p, n); }; })(entry.path, entry.name);
actionTd.appendChild(delBtn);
}
tr.appendChild(iconTd);
tr.appendChild(nameTd);
tr.appendChild(sizeTd);
tr.appendChild(dateTd);
tr.appendChild(actionTd);
if (entry.is_dir) {
tr.style.cursor = "pointer";
tr.onmouseenter = function() { tr.style.background = "var(--bg2)"; };
tr.onmouseleave = function() { tr.style.background = ""; };
tr.onclick = (function(p) { return function(e) { if (e.target.closest("button")) return; fileBrowserNavigate(p); }; })(entry.path);
}
tbody.appendChild(tr);
}
}
async function _fbFetch(url) {
const r = await fetch(url, { credentials: "same-origin" });
if (!r.ok) {
let detail = r.statusText;
try { detail = (await r.json()).detail || detail; } catch (ex) { /* ignore */ }
throw new Error(detail);
}
return r;
}
function _fbTriggerBlob(blob, filename) {
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = blobUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function() { URL.revokeObjectURL(blobUrl); }, 2000);
}
async function fileBrowserDownloadFile(path) {
try {
const r = await _fbFetch("/api/my/files/download?path=" + encodeURIComponent(path));
const blob = await r.blob();
_fbTriggerBlob(blob, path.split("/").pop() || "file");
} catch (e) {
alert("Download failed: " + e.message);
}
}
async function fileBrowserDownloadZip(path) {
const target = (path !== undefined) ? path : _fbPath;
try {
const r = await _fbFetch("/api/my/files/download-zip?path=" + encodeURIComponent(target || ""));
const blob = await r.blob();
const name = (target ? target.split("/").pop() : "files") + ".zip";
_fbTriggerBlob(blob, name);
} catch (e) {
alert("Download failed: " + e.message);
}
}
async function fileBrowserDeleteFile(path, name) {
if (!confirm("Delete \u201c" + name + "\u201d?\nThis cannot be undone.")) return;
try {
const r = await fetch("/api/my/files?path=" + encodeURIComponent(path), {
method: "DELETE",
credentials: "same-origin",
});
if (!r.ok) {
let detail = r.statusText;
try { detail = (await r.json()).detail || detail; } catch (ex) { /* ignore */ }
alert("Delete failed: " + detail);
return;
}
fileBrowserNavigate(_fbPath);
} catch (e) {
alert("Delete failed: " + e.message);
}
}
/* ══════════════════════════════════════════════════════════════════════════
CHAT PAGE
══════════════════════════════════════════════════════════════════════════ */
function initChat() {
if (!document.getElementById("chat-messages")) return;
// Restore snapshot if returning from another page
if (_chatSnapshot) {
const chatMsgs = document.getElementById("chat-messages");
chatMsgs.innerHTML = _chatSnapshot.html;
chatMsgs.scrollTop = chatMsgs.scrollHeight;
sessionId = _chatSnapshot.sessionId;
_chatSnapshot = null;
_skipNextRestore = true; // don't let WS restore overwrite the snapshot
} else {
// Restore persisted session so refreshes continue the same conversation.
// If the URL already has ?session=<id> (reopened from Chats page) use that
// and persist it. Otherwise fall back to localStorage, then server-generated.
const urlSession = new URLSearchParams(window.location.search).get("session");
if (urlSession) {
sessionId = urlSession;
localStorage.setItem("current_session_id", urlSession);
// Clean the URL without triggering a reload
history.replaceState(null, "", "/");
} else {
sessionId = localStorage.getItem("current_session_id") || window.SESSION_ID || null;
if (sessionId !== window.SESSION_ID) {
// Using a stored session — the WS restore event will render the history
}
}
}
// Connect WS only if not already open (survives navigation)
if (!ws || ws.readyState === WebSocket.CLOSED || ws.readyState === WebSocket.CLOSING) {
connectWS();
}
// Always re-attach listeners — DOM was replaced by navigation swap
const sendBtn = document.getElementById("send-btn");
const chatInput = document.getElementById("chat-input");
const clearBtn = document.getElementById("clear-btn");
sendBtn.addEventListener("click", sendMessage);
chatInput.addEventListener("keydown", e => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
clearBtn?.addEventListener("click", clearChat);
_initAttachHandler();
_pendingAttachments = [];
_renderAttachPreview();
// Auto-resize textarea
chatInput.addEventListener("input", () => {
chatInput.style.height = "auto";
chatInput.style.height = Math.min(chatInput.scrollHeight, 200) + "px";
});
// Re-populate model selector if WS already connected (SPA navigation)
if (_cachedModels) {
populateModelSelector(_cachedModels.models, _cachedModels.default);
}
// Model picker keyboard navigation
const mpSearch = document.getElementById("model-picker-search");
if (mpSearch) {
mpSearch.addEventListener("keydown", e => {
if (e.key === "Escape") { closeModelPicker(); return; }
const listEl = document.getElementById("model-picker-list");
const items = listEl ? Array.from(listEl.querySelectorAll(".mp-item")) : [];
if (!items.length) return;
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
items.forEach(it => it.classList.remove("mp-focused"));
_mpFocusIdx = e.key === "ArrowDown"
? Math.min(_mpFocusIdx + 1, items.length - 1)
: Math.max(_mpFocusIdx - 1, 0);
items[_mpFocusIdx].classList.add("mp-focused");
items[_mpFocusIdx].scrollIntoView({ block: "nearest" });
}
if (e.key === "Enter" && _mpFocusIdx >= 0) {
items[_mpFocusIdx]?.click();
}
});
}
refreshStatus();
setInterval(refreshStatus, 5000);
}
/* ── WebSocket ──────────────────────────────────────────────────────────── */
function connectWS() {
if (!sessionId) {
console.error("[aide] connectWS: sessionId is null — cannot connect");
return;
}
const proto = location.protocol === "https:" ? "wss" : "ws";
const url = `${proto}://${location.host}/ws/${sessionId}`;
console.log("[aide] Connecting WebSocket:", url);
ws = new WebSocket(url);
ws.onopen = () => {
reconnectDelay = 1000;
console.log("[aide] WebSocket connected");
setConnected(true);
};
ws.onclose = e => {
console.warn("[aide] WebSocket closed (code:", e.code, ")");
setConnected(false);
setTimeout(connectWS, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 15000);
};
ws.onerror = e => {
console.error("[aide] WebSocket error:", e);
ws.close();
};
ws.onmessage = e => {
const msg = JSON.parse(e.data);
if (msg.type === "models") {
_modelCapabilities = msg.capabilities || {};
_cachedModels = { models: msg.models, default: msg.default, capabilities: _modelCapabilities };
populateModelSelector(msg.models, msg.default);
} else if (msg.type === "restore") {
if (_skipNextRestore) { _skipNextRestore = false; }
else { restoreChat(msg); }
} else {
handleEvent(msg);
}
};
}
function setConnected(ok) {
const dot = document.getElementById("status-dot");
const label = document.getElementById("status-label");
if (dot) dot.className = ok ? "status-dot" : "status-dot offline";
if (label) label.textContent = ok ? "Connected" : "Disconnected";
}
/* ── Model selector helpers ──────────────────────────────────────────────── */
/**
* Parse a "provider:model" string into { provider, model, label }.
* Bare model strings (no prefix) return provider="".
*/
function _parseModel(m) {
const known = ["anthropic", "openai", "openrouter"];
const idx = m.indexOf(":");
if (idx > 0 && known.includes(m.slice(0, idx))) {
return { provider: m.slice(0, idx), model: m.slice(idx + 1), label: m.slice(idx + 1) };
}
return { provider: "", model: m, label: m };
}
// Canonical provider display names and sort order
const _PROVIDER_ORDER = ["anthropic", "openai", "openrouter"];
const _PROVIDER_LABELS = { anthropic: "Anthropic", openai: "OpenAI", openrouter: "OpenRouter" };
function _providerLabel(p) {
return _PROVIDER_LABELS[p] || (p ? p.charAt(0).toUpperCase() + p.slice(1) : "Other");
}
function _sortedProviders(groups) {
return [
..._PROVIDER_ORDER.filter(p => groups[p]),
...Object.keys(groups).filter(p => !_PROVIDER_ORDER.includes(p)).sort(),
];
}
/**
* Fill a <select> element with model options grouped by provider.
* currentValue can be "provider:model" or a bare model id (legacy).
* Returns the resolved value that was set.
*/
function _fillModelSelect(sel, models, currentValue) {
if (!sel) return currentValue;
sel.innerHTML = "";
// Group by provider
const groups = {};
for (const m of models) {
const { provider } = _parseModel(m);
const key = provider || "other";
(groups[key] = groups[key] || []).push(m);
}
const multiProvider = Object.keys(groups).length > 1;
for (const prov of _sortedProviders(groups)) {
const items = groups[prov];
let container = sel;
if (multiProvider) {
const grp = document.createElement("optgroup");
grp.label = _providerLabel(prov);
sel.appendChild(grp);
container = grp;
}
for (const m of items) {
const opt = document.createElement("option");
opt.value = m;
opt.textContent = _parseModel(m).label;
container.appendChild(opt);
}
}
// Try exact match first, then try prepending each provider prefix (legacy bare IDs)
sel.value = currentValue || "";
if (!sel.value && currentValue) {
const match = models.find(m => {
const { model } = _parseModel(m);
return model === currentValue;
});
if (match) sel.value = match;
}
return sel.value;
}
let _selectedChatModel = "";
function populateModelSelector(models, defaultModel) {
const saved = localStorage.getItem("preferred-model");
_selectedChatModel = (saved && models.includes(saved)) ? saved : defaultModel;
_updateModelBtn();
_updateAttachBtn();
_updateCapabilityBadges();
}
function _updateModelBtn() {
const btn = document.getElementById("model-btn");
if (!btn) return;
const { provider, label } = _parseModel(_selectedChatModel);
const text = label || _selectedChatModel || "Select model";
btn.textContent = provider ? `${_providerLabel(provider)} · ${text}` : text;
_updateAttachBtn();
_updateCapabilityBadges();
}
function _currentModelCaps() {
return _modelCapabilities[_selectedChatModel] || {};
}
function _updateAttachBtn() {
const btn = document.getElementById("attach-btn");
if (!btn) return;
btn.style.display = _currentModelCaps().vision ? "" : "none";
}
function _updateCapabilityBadges() {
const el = document.getElementById("model-caps");
if (!el) return;
const caps = _currentModelCaps();
const badges = [
caps.image_gen ? `<span title="Image generation" style="font-size:12px;opacity:0.7">🎨</span>` : "",
caps.vision ? `<span title="Vision" style="font-size:12px;opacity:0.7">👁</span>` : "",
caps.tools ? `<span title="Tool use" style="font-size:12px;opacity:0.7">🔧</span>` : "",
caps.online ? `<span title="Web access" style="font-size:12px;opacity:0.7">🌐</span>` : "",
].filter(Boolean).join("");
el.innerHTML = badges;
}
function getSelectedModel() {
return _selectedChatModel || null;
}
/* ── Model picker modal ──────────────────────────────────────────────────── */
let _mpFocusIdx = -1;
function openModelPicker() {
const modal = document.getElementById("model-picker-modal");
if (!modal || !_cachedModels) return;
_mpFocusIdx = -1;
modal.classList.remove("hidden");
const s = document.getElementById("model-picker-search");
if (s) { s.value = ""; s.focus(); }
_renderModelPicker("");
}
function closeModelPicker() {
document.getElementById("model-picker-modal")?.classList.add("hidden");
}
function _renderModelPicker(q) {
const listEl = document.getElementById("model-picker-list");
if (!listEl || !_cachedModels) return;
const ql = q.toLowerCase();
_mpFocusIdx = -1;
listEl.innerHTML = "";
const groups = {};
for (const m of _cachedModels.models) {
const { provider, label } = _parseModel(m);
if (ql && !m.toLowerCase().includes(ql) && !label.toLowerCase().includes(ql)) continue;
const key = provider || "other";
(groups[key] = groups[key] || []).push({ m, label });
}
if (!Object.keys(groups).length) {
listEl.innerHTML = '<div style="padding:16px;text-align:center;color:var(--text-dim);font-size:13px">No models found</div>';
return;
}
const orderedProvs = _sortedProviders(groups);
for (let i = 0; i < orderedProvs.length; i++) {
const prov = orderedProvs[i];
const items = groups[prov];
// Horizontal divider between sections
if (i > 0) {
const hr = document.createElement("div");
hr.style.cssText = "border-top:1px solid var(--border);margin:4px 0";
listEl.appendChild(hr);
}
// Section header
const gh = document.createElement("div");
gh.style.cssText = "padding:8px 14px 4px;font-size:10px;text-transform:uppercase;letter-spacing:0.8px;color:var(--text-dim);font-weight:600";
gh.textContent = _providerLabel(prov);
listEl.appendChild(gh);
for (const { m, label } of items) {
const item = document.createElement("div");
item.className = "mp-item" + (m === _selectedChatModel ? " mp-selected" : "");
item.textContent = label;
item.dataset.v = m;
item.addEventListener("click", () => {
_selectedChatModel = m;
localStorage.setItem("preferred-model", m);
_updateModelBtn();
closeModelPicker();
});
listEl.appendChild(item);
}
}
}
/* ── Event handling ─────────────────────────────────────────────────────── */
// Track in-progress assistant message and tool indicators by call_id
let currentAssistantDiv = null;
let currentBubble = null;
const toolDivs = {};
function handleEvent(msg) {
switch (msg.type) {
case "text":
appendText(msg.content);
break;
case "tool_start":
showToolIndicator(msg.call_id, msg.tool_name, msg.arguments);
break;
case "tool_done":
resolveToolIndicator(msg.call_id, msg.success, msg.result, msg.confirmed);
break;
case "image":
appendGeneratedImages(msg.data_urls);
break;
case "confirmation_required":
showConfirmModal(msg.call_id, msg.tool_name, msg.arguments, msg.description);
break;
case "done":
finishResponse(msg);
break;
case "error":
showError(msg.message);
finishGenerating();
break;
}
}
function restoreChat(msg) {
const container = document.getElementById("chat-messages");
if (!container) return;
// Persist this session so further refreshes keep the same conversation
if (msg.session_id) {
sessionId = msg.session_id;
localStorage.setItem("current_session_id", msg.session_id);
}
// Pre-select the model that was used in this conversation
if (msg.model) {
localStorage.setItem("preferred-model", msg.model);
const btn = document.getElementById("model-btn");
if (btn) btn.textContent = msg.model;
}
// Clear the default greeting
container.innerHTML = "";
// Add a subtle "restored" marker
const marker = document.createElement("div");
marker.style.cssText = "text-align:center;color:var(--text-dim);font-size:11px;padding:8px 0 4px;";
marker.textContent = msg.title ? `${msg.title}` : "— Restored conversation —";
container.appendChild(marker);
// Render each turn
for (const turn of (msg.messages || [])) {
if (turn.role === "user") {
const div = document.createElement("div");
div.className = "message user";
div.innerHTML = `<div class="message-bubble">${esc(turn.text)}</div>`;
container.appendChild(div);
} else if (turn.role === "assistant") {
const div = document.createElement("div");
div.className = "message assistant";
const bubble = document.createElement("div");
bubble.className = "message-bubble";
bubble.textContent = turn.text;
div.appendChild(bubble);
container.appendChild(div);
}
}
container.scrollTop = container.scrollHeight;
}
function appendText(content) {
if (!currentAssistantDiv) {
currentAssistantDiv = createAssistantMessage();
currentBubble = currentAssistantDiv.querySelector(".message-bubble");
}
// Append raw text; simple pre-wrap handles newlines
currentBubble.textContent += content;
scrollToBottom();
}
function showToolIndicator(callId, toolName, args) {
// Commit any in-progress text message first
if (currentAssistantDiv) {
currentAssistantDiv = null;
currentBubble = null;
}
const el = document.createElement("div");
el.className = "tool-indicator";
el.id = `tool-${callId}`;
const argStr = Object.entries(args || {})
.slice(0, 3)
.map(([k, v]) => `${k}: ${String(v).slice(0, 60)}`)
.join(", ");
el.innerHTML = `
<span class="dot"></span>
<span class="tool-label"><strong>${esc(toolName)}</strong>${argStr ? " — " + esc(argStr) : ""}</span>
`;
document.getElementById("chat-messages").appendChild(el);
toolDivs[callId] = el;
scrollToBottom();
}
function resolveToolIndicator(callId, success, result, confirmed) {
const el = toolDivs[callId];
if (!el) return;
el.classList.add(success ? "done" : "error");
const label = el.querySelector(".tool-label");
const suffix = confirmed ? " ✓" : (success ? "" : " ✗");
if (result) {
label.innerHTML += ` <span class="result">→ ${esc(String(result).slice(0, 120))}${suffix}</span>`;
}
}
function finishResponse(msg) {
currentAssistantDiv = null;
currentBubble = null;
finishGenerating();
// Append usage badge
if (msg.usage) {
const meta = document.createElement("div");
meta.className = "message-meta";
meta.textContent = `${msg.usage.input}${msg.usage.output} tokens • ${msg.tool_calls_made} tool call${msg.tool_calls_made !== 1 ? "s" : ""}`;
document.getElementById("chat-messages").appendChild(meta);
}
}
function showError(message) {
const el = document.createElement("div");
el.className = "tool-indicator error";
el.innerHTML = `<span class="dot"></span><span>Error: ${esc(message)}</span>`;
document.getElementById("chat-messages").appendChild(el);
scrollToBottom();
}
/* ── Message DOM helpers ─────────────────────────────────────────────────── */
function createUserMessage(text, imageDataUrls) {
const wrap = document.createElement("div");
wrap.className = "message user";
let html = "";
if (imageDataUrls && imageDataUrls.length) {
const imgs = imageDataUrls.map(u =>
`<img src="${esc(u)}" style="max-height:120px;max-width:200px;border-radius:4px;border:1px solid var(--border);display:block">`
).join("");
html += `<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:${text ? "6px" : "0"}">${imgs}</div>`;
}
if (text) html += `<div>${esc(text)}</div>`;
wrap.innerHTML = `<div class="message-bubble">${html}</div>`;
document.getElementById("chat-messages").appendChild(wrap);
return wrap;
}
function appendGeneratedImages(dataUrls) {
if (!dataUrls || !dataUrls.length) return;
// Ensure an assistant bubble exists
if (!currentAssistantDiv) {
currentAssistantDiv = createAssistantMessage();
currentBubble = currentAssistantDiv.querySelector(".message-bubble");
}
const wrap = document.createElement("div");
wrap.style.cssText = "display:flex;flex-wrap:wrap;gap:8px;margin-top:8px";
for (const url of dataUrls) {
const imgWrap = document.createElement("div");
imgWrap.style.cssText = "position:relative;display:inline-block";
const img = document.createElement("img");
img.src = url;
img.style.cssText = "max-width:100%;border-radius:6px;display:block;cursor:zoom-in";
img.title = "Click to open full size";
img.onclick = () => window.open(url, "_blank");
imgWrap.appendChild(img);
// Download button overlay
const dlBtn = document.createElement("a");
dlBtn.href = url;
dlBtn.download = `generated-${Date.now()}.png`;
dlBtn.style.cssText = "position:absolute;bottom:6px;right:6px;background:rgba(0,0,0,0.6);color:#fff;border-radius:4px;padding:3px 8px;font-size:11px;text-decoration:none";
dlBtn.textContent = "↓ Save";
imgWrap.appendChild(dlBtn);
wrap.appendChild(imgWrap);
}
currentBubble.appendChild(wrap);
scrollToBottom();
}
function createAssistantMessage() {
const wrap = document.createElement("div");
wrap.className = "message assistant";
wrap.innerHTML = `<div class="message-bubble"></div>`;
document.getElementById("chat-messages").appendChild(wrap);
scrollToBottom();
return wrap;
}
function scrollToBottom() {
const el = document.getElementById("chat-messages");
el.scrollTop = el.scrollHeight;
}
/* ── File attachments ────────────────────────────────────────────────────── */
let _pendingAttachments = []; // [{ name, media_type, data, dataUrl }]
const _ALLOWED_TYPES = new Set([
"image/jpeg", "image/png", "image/gif", "image/webp", "image/avif",
"application/pdf",
]);
async function _addFile(file) {
if (!file || !_ALLOWED_TYPES.has(file.type)) return;
const dataUrl = await new Promise((res, rej) => {
const r = new FileReader();
r.onload = e => res(e.target.result);
r.onerror = rej;
r.readAsDataURL(file);
});
const base64 = dataUrl.split(",")[1];
_pendingAttachments.push({ name: file.name || "attachment", media_type: file.type, data: base64, dataUrl });
_renderAttachPreview();
}
function _initAttachHandler() {
const input = document.getElementById("img-file-input");
if (!input) return;
input.addEventListener("change", async () => {
for (const file of input.files) await _addFile(file);
input.value = "";
});
// Paste images from clipboard into the chat input
const chatInput = document.getElementById("chat-input");
chatInput?.addEventListener("paste", async e => {
const items = e.clipboardData?.items || [];
for (const item of items) {
if (item.type.startsWith("image/")) {
e.preventDefault();
const file = item.getAsFile();
if (file) await _addFile(file);
}
}
});
}
function _renderAttachPreview() {
const strip = document.getElementById("attach-preview");
if (!strip) return;
if (!_pendingAttachments.length) {
strip.style.display = "none";
strip.innerHTML = "";
return;
}
strip.style.display = "flex";
strip.innerHTML = "";
_pendingAttachments.forEach((att, idx) => {
const wrap = document.createElement("div");
wrap.style.cssText = "position:relative;display:inline-flex;align-items:center";
const rm = document.createElement("button");
rm.textContent = "\u2715";
rm.style.cssText = "position:absolute;top:-4px;right:-4px;background:var(--bg2);border:1px solid var(--border);border-radius:50%;width:16px;height:16px;font-size:9px;cursor:pointer;display:flex;align-items:center;justify-content:center;padding:0;color:var(--text-dim);z-index:1";
rm.onclick = function() { _pendingAttachments.splice(idx, 1); _renderAttachPreview(); };
if (att.media_type.startsWith("image/")) {
const img = document.createElement("img");
img.src = att.dataUrl;
img.style.cssText = "height:56px;width:56px;object-fit:cover;border-radius:4px;border:1px solid var(--border)";
img.title = att.name;
wrap.appendChild(img);
} else {
// Non-image (PDF etc.) — show a file chip
const chip = document.createElement("div");
chip.title = att.name;
chip.style.cssText = "display:flex;align-items:center;gap:6px;height:56px;padding:0 12px;border-radius:4px;border:1px solid var(--border);background:var(--bg2);font-size:12px;color:var(--text-dim);max-width:160px";
const icon = document.createElement("span");
icon.style.cssText = "flex-shrink:0;font-size:18px";
icon.textContent = "\ud83d\udcc4"; // 📄
const label = document.createElement("span");
label.style.cssText = "overflow:hidden;text-overflow:ellipsis;white-space:nowrap";
label.textContent = att.name;
chip.appendChild(icon);
chip.appendChild(label);
wrap.appendChild(chip);
}
wrap.appendChild(rm);
strip.appendChild(wrap);
});
}
/* ── Send ────────────────────────────────────────────────────────────────── */
function sendMessage() {
if (isGenerating) return;
const input = document.getElementById("chat-input");
const text = input.value.trim();
if (!text && !_pendingAttachments.length) return;
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.error("[aide] sendMessage: WebSocket not open. State:", ws ? ws.readyState : "null (ws is null)");
return;
}
// Persist session so page refreshes continue the same conversation
if (sessionId) localStorage.setItem("current_session_id", sessionId);
const attachments = _pendingAttachments.length ? _pendingAttachments.map(a => ({ media_type: a.media_type, data: a.data })) : null;
createUserMessage(text, _pendingAttachments.map(a => a.dataUrl));
_pendingAttachments = [];
_renderAttachPreview();
input.value = "";
input.style.height = "auto";
const model = getSelectedModel();
const payload = { type: "message", content: text, model };
if (attachments) payload.attachments = attachments;
ws.send(JSON.stringify(payload));
startGenerating();
scrollToBottom();
}
function clearChat() {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "clear" }));
}
// Generate a fresh session for the next conversation
localStorage.removeItem("current_session_id");
sessionId = window.SESSION_ID || crypto.randomUUID();
document.getElementById("chat-messages").innerHTML = '<div class="message assistant"><div class="message-bubble">Hello. What can I help you with?</div></div>';
Object.keys(toolDivs).forEach(k => delete toolDivs[k]);
currentAssistantDiv = null;
currentBubble = null;
_pendingAttachments = [];
_renderAttachPreview();
}
function startGenerating() {
isGenerating = true;
document.getElementById("send-btn").disabled = true;
// Show typing indicator
const el = document.createElement("div");
el.id = "typing-indicator";
el.className = "message assistant";
el.innerHTML = `<div class="message-bubble"><div class="typing"><span></span><span></span><span></span></div></div>`;
document.getElementById("chat-messages").appendChild(el);
scrollToBottom();
}
function finishGenerating() {
isGenerating = false;
document.getElementById("send-btn").disabled = false;
document.getElementById("typing-indicator")?.remove();
}
/* ── Confirmation modal ─────────────────────────────────────────────────── */
let pendingCallId = null;
function showConfirmModal(callId, toolName, args, description) {
pendingCallId = callId;
document.getElementById("confirm-tool-name").textContent = toolName;
document.getElementById("confirm-description").textContent = description || JSON.stringify(args, null, 2);
document.getElementById("confirm-modal").classList.remove("hidden");
}
function respondConfirm(approved) {
if (!pendingCallId) return;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "confirm", approved, call_id: pendingCallId }));
}
pendingCallId = null;
document.getElementById("confirm-modal").classList.add("hidden");
}
/* ══════════════════════════════════════════════════════════════════════════
AUDIT PAGE
══════════════════════════════════════════════════════════════════════════ */
let _auditTaskId = ""; // set when navigating from run detail
function initAudit() {
if (!document.getElementById("audit-table")) return;
// Pre-filter by task_id if passed in URL (e.g. from agent run detail link)
const urlParams = new URLSearchParams(window.location.search);
_auditTaskId = urlParams.get("task_id") || "";
if (_auditTaskId) {
// Clear all other filters so they don't interfere with the task_id filter
["filter-start","filter-end","filter-tool","filter-session"].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = "";
});
const cb = document.getElementById("filter-confirmed");
if (cb) cb.checked = false;
const banner = document.getElementById("audit-task-filter-banner");
if (banner) {
banner.textContent = `Showing entries for run ${_auditTaskId.slice(0, 8)}`;
banner.style.display = "";
}
}
loadAudit();
document.getElementById("filter-btn")?.addEventListener("click", loadAudit);
document.getElementById("filter-reset")?.addEventListener("click", () => {
["filter-start","filter-end","filter-tool","filter-session"].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = "";
});
document.getElementById("filter-confirmed").checked = false;
_auditTaskId = "";
const banner = document.getElementById("audit-task-filter-banner");
if (banner) banner.style.display = "none";
loadAudit();
});
}
let auditPage = 1;
async function loadAudit(page) {
if (page) auditPage = page;
const params = new URLSearchParams();
const start = document.getElementById("filter-start")?.value;
const end = document.getElementById("filter-end")?.value;
const tool = document.getElementById("filter-tool")?.value;
const session = document.getElementById("filter-session")?.value;
const confirmed = document.getElementById("filter-confirmed")?.checked;
const startISO = parseEuDate(start);
const endISO = parseEuDate(end, true);
if (startISO) params.set("start", startISO);
if (endISO) params.set("end", endISO);
if (tool) params.set("tool_name", tool);
if (session) params.set("session_id", session);
if (_auditTaskId) params.set("task_id", _auditTaskId);
if (confirmed) params.set("confirmed_only", "true");
params.set("page", auditPage);
params.set("per_page", 50);
const r = await fetch("/api/audit?" + params);
const data = await r.json();
renderAuditTable(data);
}
function renderAuditTable(data) {
const tbody = document.querySelector("#audit-table tbody");
tbody.innerHTML = "";
for (const e of data.entries) {
const tr = document.createElement("tr");
tr.style.cursor = "pointer";
tr.title = "Click for details";
tr.innerHTML = `
<td>${esc(formatDate(e.timestamp))}</td>
<td><code>${esc(e.tool_name)}</code></td>
<td class="args-cell">${esc(JSON.stringify(e.arguments).slice(0, 80))}</td>
<td>${esc((e.result_summary || "").slice(0, 80))}</td>
<td>${e.confirmed ? '<span class="badge badge-green">✓</span>' : '<span class="badge badge-red">✗</span>'}</td>
<td><code>${esc((e.session_id || "").slice(0,8))}</code></td>
`;
tr.addEventListener("click", () => openAuditDetail(e));
tbody.appendChild(tr);
}
// Pagination
const pag = document.getElementById("audit-pagination");
if (!pag) return;
pag.innerHTML = "";
for (let p = 1; p <= data.pages; p++) {
const btn = document.createElement("button");
btn.className = "page-btn" + (p === data.page ? " active" : "");
btn.textContent = p;
btn.onclick = () => loadAudit(p);
pag.appendChild(btn);
}
}
function openAuditDetail(e) {
const modal = document.getElementById("audit-detail-modal");
if (!modal) return;
document.getElementById("audit-detail-tool").textContent = e.tool_name;
document.getElementById("audit-detail-ts").textContent = formatDate(e.timestamp);
document.getElementById("audit-detail-session").textContent = e.session_id || "—";
document.getElementById("audit-detail-task").textContent = e.task_id || "—";
document.getElementById("audit-detail-confirmed").innerHTML = e.confirmed
? '<span class="badge badge-green">Yes</span>'
: '<span class="badge badge-red">No</span>';
let _args = e.arguments;
if (typeof _args === "string") { try { _args = JSON.parse(_args); } catch {} }
document.getElementById("audit-detail-args").textContent =
_args != null ? JSON.stringify(_args, null, 2) : "—";
document.getElementById("audit-detail-result").textContent = e.result_summary || "—";
modal.style.display = "flex";
}
function closeAuditDetail() {
const modal = document.getElementById("audit-detail-modal");
if (modal) modal.style.display = "none";
}
/* ══════════════════════════════════════════════════════════════════════════
SETTINGS PAGE
══════════════════════════════════════════════════════════════════════════ */
function switchSettingsTab(name) {
["general", "whitelists", "credentials", "inbox", "emailaccounts", "telegram", "system", "brain", "mcp", "security", "branding", "mfa"].forEach(t => {
const pane = document.getElementById(`spane-${t}`);
const btn = document.getElementById(`stab-${t}`);
if (pane) pane.style.display = t === name ? "" : "none";
if (btn) btn.classList.toggle("active", t === name);
});
if (name === "inbox") { loadInboxStatus(); }
if (name === "emailaccounts") { loadEmailAccounts(); }
if (name === "brain") { loadBrainKey("brain-mcp-key", "brain-mcp-cmd"); loadBrainAutoApprove(); }
if (name === "mfa") { loadTheme(); loadMyProfile(); loadMfaStatus(); loadDataFolder(); }
}
/* ── API Key management ──────────────────────────────────────────────────── */
async function loadApiKeyStatus() {
const r = await fetch("/api/settings/api-key");
if (!r.ok) return;
const d = await r.json();
const statusEl = document.getElementById("apikey-status");
const revokeBtn = document.getElementById("apikey-revoke-btn");
const regenBtn = document.getElementById("apikey-regen-btn");
if (!statusEl) return;
if (d.configured) {
const when = d.created_at ? " (generated " + formatDate(d.created_at) + ")" : "";
statusEl.innerHTML = `<span style="color:var(--green)">&#10003; Key configured</span><span style="color:var(--text-dim);font-size:11px">${when}</span>`;
if (revokeBtn) revokeBtn.style.display = "";
if (regenBtn) regenBtn.style.display = "";
} else {
statusEl.innerHTML = `<span style="color:var(--text-dim)">No key configured - API is open (home-network mode)</span>`;
if (revokeBtn) revokeBtn.style.display = "none";
if (regenBtn) regenBtn.style.display = "none";
}
}
async function generateApiKey(isRegen) {
if (isRegen && !confirm("This will invalidate the current key. All existing external clients will lose access. Continue?")) return;
const r = await fetch("/api/settings/api-key", { method: "POST" });
if (!r.ok) { showFlash("Error generating key"); return; }
const d = await r.json();
// Show the one-time reveal - key is NOT stored anywhere; copy it for external tools
const box = document.getElementById("apikey-reveal-box");
const keyEl = document.getElementById("apikey-reveal-value");
if (box && keyEl) {
keyEl.textContent = d.key;
box.style.display = "";
}
showFlash(isRegen ? "Key regenerated - copy it for external tools!" : "Key generated - copy it for external tools!");
loadApiKeyStatus();
}
async function revokeApiKey() {
if (!confirm("Revoke the API key? All external clients will lose access immediately.")) return;
const r = await fetch("/api/settings/api-key", { method: "DELETE" });
if (!r.ok) { showFlash("Error revoking key"); return; }
const box = document.getElementById("apikey-reveal-box");
if (box) box.style.display = "none";
showFlash("Key revoked");
loadApiKeyStatus();
}
function copyApiKey() {
const val = document.getElementById("apikey-reveal-value")?.textContent;
if (!val) return;
navigator.clipboard.writeText(val).then(() => showFlash("Copied!"));
}
async function loadProviderKeys() {
const r = await fetch("/api/settings/provider-keys");
if (!r.ok) return;
const d = await r.json();
const notSet = '<span style="color:var(--text-dim)">Not set — seeded from .env on startup if present</span>';
const isSet = '<span style="color:var(--green)">&#10003; Key configured</span>';
const antEl = document.getElementById("provider-key-anthropic-status");
const orEl = document.getElementById("provider-key-openrouter-status");
const oaiEl = document.getElementById("provider-key-openai-status");
if (antEl) antEl.innerHTML = d.anthropic_set ? isSet : notSet;
if (orEl) orEl.innerHTML = d.openrouter_set ? isSet : notSet;
if (oaiEl) oaiEl.innerHTML = d.openai_set ? isSet : notSet;
}
async function saveProviderKey(provider) {
const val = document.getElementById(`provider-key-${provider}-input`)?.value.trim();
if (!val) { showFlash("Enter a key first."); return; }
const r = await fetch("/api/settings/provider-keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, key: val }),
});
if (r.ok) {
document.getElementById(`provider-key-${provider}-input`).value = "";
showFlash(`${provider} key saved.`);
loadProviderKeys();
} else {
const d = await r.json().catch(() => ({}));
showFlash(d.detail || "Error saving key.");
}
}
async function clearProviderKey(provider) {
if (!confirm(`Clear the ${provider} API key? This may break agent runs that rely on it.`)) return;
const r = await fetch(`/api/settings/provider-keys/${provider}`, { method: "DELETE" });
if (r.ok) { showFlash(`${provider} key cleared.`); loadProviderKeys(); }
else showFlash("Error clearing key.");
}
async function loadUsersBaseFolder() {
const el = document.getElementById("users-base-folder");
if (!el) return;
const r = await fetch("/api/settings/users-base-folder");
if (!r.ok) return;
const d = await r.json();
el.value = d.path || "";
}
async function saveUsersBaseFolder() {
const val = document.getElementById("users-base-folder")?.value.trim();
if (val === undefined) return;
const r = await fetch("/api/settings/users-base-folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: val }),
});
if (r.ok) {
showFlash(val ? "Users base folder saved." : "Users base folder cleared.");
} else {
const d = await r.json().catch(() => ({}));
showFlash(d.detail || "Error saving.", true);
}
}
async function clearUsersBaseFolder() {
document.getElementById("users-base-folder").value = "";
await saveUsersBaseFolder();
}
function switchUserTab(name) {
document.querySelectorAll("[id^='ustab-']").forEach(b => b.classList.remove("active"));
document.querySelectorAll("[id^='uspane-']").forEach(p => { p.style.display = "none"; });
const tab = document.getElementById("ustab-" + name);
const pane = document.getElementById("uspane-" + name);
if (tab) tab.classList.add("active");
if (pane) pane.style.display = "";
if (name === "mcp") loadMyMcpServers();
if (name === "inbox") { loadMyInboxConfig(); loadMyInboxTriggers(); }
if (name === "emailaccounts") { loadEmailAccounts(); }
if (name === "caldav") { loadMyCaldavConfig(); }
if (name === "telegram") { loadMyTelegramConfig(); loadMyTelegramWhitelist(); loadMyTelegramTriggers(); }
if (name === "brain") { loadBrainKey("ubrain-mcp-key", "ubrain-mcp-cmd"); loadBrainAutoApprove(); }
if (name === "mfa") { loadTheme(); loadMyProfile(); loadMfaStatus(); loadDataFolder(); }
}
async function loadMyProviderKeys() {
const r = await fetch("/api/my/provider-keys");
if (!r.ok) return;
const d = await r.json();
const antEl = document.getElementById("my-apikey-anthropic-status");
const orEl = document.getElementById("my-apikey-openrouter-status");
const oaiEl = document.getElementById("my-apikey-openai-status");
const noteEl = document.getElementById("my-api-keys-tier-note");
if (antEl) antEl.innerHTML = d.anthropic_set
? '<span style="color:var(--green)">&#10003; Your own key is set</span>'
: (d.anthropic_blocked ? '<span style="color:var(--yellow)">Anthropic disabled — set your own key to unlock</span>'
: '<span style="color:var(--text-dim)">Not set</span>');
if (orEl) orEl.innerHTML = d.openrouter_set
? '<span style="color:var(--green)">&#10003; Your own key is set</span>'
: (d.openrouter_free_only ? '<span style="color:var(--yellow)">Using system default (free models only — set your own key for full access)</span>'
: '<span style="color:var(--text-dim)">Not set</span>');
if (oaiEl) oaiEl.innerHTML = d.openai_set
? '<span style="color:var(--green)">&#10003; Your own key is set</span>'
: '<span style="color:var(--text-dim)">Not set — required to use OpenAI models</span>';
_renderAccessNote(noteEl, d);
}
async function saveMyProviderKey(provider) {
const val = document.getElementById(`my-apikey-${provider}-input`)?.value.trim();
if (!val) { showFlash("Enter a key first."); return; }
const r = await fetch("/api/my/provider-keys", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, key: val }),
});
if (r.ok) {
document.getElementById(`my-apikey-${provider}-input`).value = "";
showFlash(`${provider} key saved.`);
loadMyProviderKeys();
} else {
const d = await r.json().catch(() => ({}));
showFlash(d.detail || "Error saving key.");
}
}
async function clearMyProviderKey(provider) {
const r = await fetch(`/api/my/provider-keys/${provider}`, { method: "DELETE" });
if (r.ok) { showFlash(`${provider} key cleared.`); loadMyProviderKeys(); }
else showFlash("Error clearing key.");
}
async function loadMyPersonality() {
const r = await fetch("/api/my/personality");
if (!r.ok) return;
const d = await r.json();
const soulEl = document.getElementById("my-personality-soul");
const userEl = document.getElementById("my-personality-user");
if (soulEl) soulEl.value = d.soul || "";
if (userEl) userEl.value = d.user_context || "";
}
async function saveMyPersonality() {
const soul = document.getElementById("my-personality-soul")?.value ?? null;
const user_context = document.getElementById("my-personality-user")?.value ?? null;
const r = await fetch("/api/my/personality", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ soul, user_context }),
});
if (r.ok) showFlash("Personality saved.");
else { const d = await r.json().catch(() => ({})); showFlash(d.detail || "Error saving."); }
}
async function clearMyPersonality() {
const r = await fetch("/api/my/personality", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ soul: "", user_context: "" }),
});
if (r.ok) {
document.getElementById("my-personality-soul").value = "";
document.getElementById("my-personality-user").value = "";
showFlash("Personality reset to system defaults.");
}
}
async function dismissNag() {
document.getElementById("nag-banner")?.remove();
await fetch("/api/my/personality/dismiss-nag", { method: "POST" });
}
// ── My MCP Servers ────────────────────────────────────────────────────────────
let _myMcpServerId = null;
async function loadMyMcpServers() {
const r = await fetch("/api/my/mcp-servers");
if (!r.ok) return;
const servers = await r.json();
const tbody = document.getElementById("my-mcp-tbody");
if (!tbody) return;
if (!servers.length) {
tbody.innerHTML = '<tr><td colspan="5" style="color:var(--text-dim)">No personal MCP servers configured.</td></tr>';
return;
}
tbody.innerHTML = servers.map(s => `
<tr>
<td>${s.name}</td>
<td style="font-size:12px;color:var(--text-dim)">${s.url}</td>
<td>${s.transport}</td>
<td>${s.enabled ? '<span style="color:var(--green)">enabled</span>' : '<span style="color:var(--text-dim)">disabled</span>'}</td>
<td style="text-align:right">
<button class="btn btn-small btn-ghost" onclick="editMyMcpServer('${s.id}')">Edit</button>
<button class="btn btn-small btn-ghost" onclick="toggleMyMcpServer('${s.id}')">${s.enabled ? 'Disable' : 'Enable'}</button>
<button class="btn btn-small btn-danger" onclick="deleteMyMcpServer('${s.id}')">Delete</button>
</td>
</tr>`).join("");
}
function openMyMcpModal(serverId) {
_myMcpServerId = serverId || null;
const modal = document.getElementById("my-mcp-modal");
if (!modal) return;
if (!serverId) {
document.getElementById("my-mcp-name").value = "";
document.getElementById("my-mcp-url").value = "";
document.getElementById("my-mcp-transport").value = "sse";
document.getElementById("my-mcp-apikey").value = "";
document.getElementById("my-mcp-modal-title").textContent = "Add MCP Server";
}
modal.classList.remove("hidden");
}
async function editMyMcpServer(id) {
const r = await fetch(`/api/my/mcp-servers/${id}`);
if (!r.ok) return;
const s = await r.json();
_myMcpServerId = id;
document.getElementById("my-mcp-name").value = s.name;
document.getElementById("my-mcp-url").value = s.url;
document.getElementById("my-mcp-transport").value = s.transport;
document.getElementById("my-mcp-apikey").value = "";
document.getElementById("my-mcp-modal-title").textContent = "Edit MCP Server";
document.getElementById("my-mcp-modal").classList.remove("hidden");
}
async function saveMyMcpServer() {
const body = {
name: document.getElementById("my-mcp-name").value.trim(),
url: document.getElementById("my-mcp-url").value.trim(),
transport: document.getElementById("my-mcp-transport").value,
api_key: document.getElementById("my-mcp-apikey").value.trim() || null,
enabled: true,
};
if (!body.name || !body.url) { showFlash("Name and URL are required."); return; }
const method = _myMcpServerId ? "PUT" : "POST";
const url = _myMcpServerId ? `/api/my/mcp-servers/${_myMcpServerId}` : "/api/my/mcp-servers";
const r = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
if (r.ok) {
closeMyMcpModal();
showFlash("MCP server saved.");
loadMyMcpServers();
} else {
const d = await r.json().catch(() => ({}));
showFlash(d.detail || "Error saving server.");
}
}
async function toggleMyMcpServer(id) {
const r = await fetch(`/api/my/mcp-servers/${id}/toggle`, { method: "POST" });
if (r.ok) { showFlash("Server updated."); loadMyMcpServers(); }
else showFlash("Error toggling server.");
}
async function deleteMyMcpServer(id) {
if (!confirm("Delete this MCP server?")) return;
const r = await fetch(`/api/my/mcp-servers/${id}`, { method: "DELETE" });
if (r.ok) { showFlash("Server deleted."); loadMyMcpServers(); }
else showFlash("Error deleting server.");
}
function closeMyMcpModal() {
document.getElementById("my-mcp-modal")?.classList.add("hidden");
_myMcpServerId = null;
}
// ── My Inbox ──────────────────────────────────────────────────────────────────
let _myInboxTriggerId = null;
async function loadMyInboxConfig() {
const r = await fetch("/api/my/inbox/config");
if (!r.ok) return;
const d = await r.json();
const fields = ["imap_host","imap_port","imap_username","smtp_host","smtp_port","smtp_username"];
for (const f of fields) {
const el = document.getElementById(`my-inbox-${f.replace("_","-")}`);
if (el) el.value = d[f] || "";
}
// Load status
const sr = await fetch("/api/my/inbox/status");
const s = sr.ok ? await sr.json() : {};
const statusEl = document.getElementById("my-inbox-status");
if (statusEl) {
if (!s.configured) statusEl.innerHTML = '<span style="color:var(--text-dim)">Not configured</span>';
else if (s.connected) statusEl.innerHTML = '<span style="color:var(--green)">Connected</span>';
else statusEl.innerHTML = `<span style="color:var(--yellow)">Disconnected${s.error ? ': ' + s.error : ''}</span>`;
}
}
async function loadMyInboxTriggers() {
const r = await fetch("/api/my/inbox/triggers");
if (!r.ok) return;
const triggers = await r.json();
const tbody = document.getElementById("my-inbox-triggers-tbody");
if (!tbody) return;
if (!triggers.length) {
tbody.innerHTML = '<tr><td colspan="4" style="color:var(--text-dim)">No triggers configured.</td></tr>';
return;
}
tbody.innerHTML = triggers.map(t => `
<tr>
<td><code>${t.trigger_word}</code></td>
<td>${t.agent_name || t.agent_id}</td>
<td>${t.enabled ? '<span style="color:var(--green)">enabled</span>' : '<span style="color:var(--text-dim)">disabled</span>'}</td>
<td style="text-align:right">
<button class="btn btn-small btn-ghost" onclick="editMyInboxTrigger('${t.id}','${t.trigger_word}','${t.agent_id}','${t.description||''}')">Edit</button>
<button class="btn btn-small btn-ghost" onclick="toggleMyInboxTrigger('${t.id}')">${t.enabled?'Disable':'Enable'}</button>
<button class="btn btn-small btn-danger" onclick="deleteMyInboxTrigger('${t.id}')">Delete</button>
</td>
</tr>`).join("");
}
async function saveMyInboxConfig() {
const body = {};
const fields = ["imap_host","imap_port","imap_username","imap_password","smtp_host","smtp_port","smtp_username","smtp_password"];
for (const f of fields) {
const el = document.getElementById(`my-inbox-${f.replace(/_/g,"-")}`);
if (el && el.value.trim()) body[f] = el.value.trim();
}
const r = await fetch("/api/my/inbox/config", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(body) });
if (r.ok) {
await fetch("/api/my/inbox/reconnect", { method:"POST" });
showFlash("Inbox config saved. Connecting…");
setTimeout(loadMyInboxConfig, 2000);
} else showFlash("Error saving inbox config.");
}
function openMyInboxTriggerModal(id, word, agentId, desc) {
_myInboxTriggerId = id || null;
document.getElementById("my-inbox-trigger-id").value = id || "";
document.getElementById("my-inbox-trigger-word").value = word || "";
document.getElementById("my-inbox-trigger-desc").value = desc || "";
document.getElementById("my-inbox-trigger-modal-title").textContent = id ? "Edit Trigger" : "Add Inbox Trigger";
_populateAgentSelect("my-inbox-trigger-agent", agentId);
document.getElementById("my-inbox-trigger-modal").classList.remove("hidden");
}
function editMyInboxTrigger(id, word, agentId, desc) { openMyInboxTriggerModal(id, word, agentId, desc); }
function closeMyInboxTriggerModal() { document.getElementById("my-inbox-trigger-modal")?.classList.add("hidden"); _myInboxTriggerId = null; }
async function saveMyInboxTrigger() {
const body = {
trigger_word: document.getElementById("my-inbox-trigger-word").value.trim(),
agent_id: document.getElementById("my-inbox-trigger-agent").value,
description: document.getElementById("my-inbox-trigger-desc").value.trim(),
enabled: true,
};
if (!body.trigger_word || !body.agent_id) { showFlash("Trigger word and agent are required."); return; }
const method = _myInboxTriggerId ? "PUT" : "POST";
const url = _myInboxTriggerId ? `/api/my/inbox/triggers/${_myInboxTriggerId}` : "/api/my/inbox/triggers";
const r = await fetch(url, { method, headers:{"Content-Type":"application/json"}, body:JSON.stringify(body) });
if (r.ok) { closeMyInboxTriggerModal(); showFlash("Trigger saved."); loadMyInboxTriggers(); }
else { const d = await r.json().catch(()=>({})); showFlash(d.detail || "Error saving trigger."); }
}
async function toggleMyInboxTrigger(id) {
const r = await fetch(`/api/my/inbox/triggers/${id}/toggle`, { method:"POST" });
if (r.ok) { showFlash("Trigger updated."); loadMyInboxTriggers(); }
}
async function deleteMyInboxTrigger(id) {
if (!confirm("Delete this trigger?")) return;
const r = await fetch(`/api/my/inbox/triggers/${id}`, { method:"DELETE" });
if (r.ok) { showFlash("Trigger deleted."); loadMyInboxTriggers(); }
}
// ── My Telegram ───────────────────────────────────────────────────────────────
let _myTelegramTriggerId = null;
async function loadMyTelegramConfig() {
const r = await fetch("/api/my/telegram/config");
if (!r.ok) return;
const d = await r.json();
const sr = await fetch("/api/my/telegram/status");
const s = sr.ok ? await sr.json() : {};
const statusEl = document.getElementById("my-telegram-status");
if (statusEl) {
if (!d.bot_token_set) statusEl.innerHTML = '<span style="color:var(--text-dim)">No bot configured</span>';
else if (s.running) statusEl.innerHTML = '<span style="color:var(--green)">Bot running</span>';
else statusEl.innerHTML = `<span style="color:var(--yellow)">Bot stopped${s.error ? ': ' + s.error : ''}</span>`;
}
}
async function loadMyTelegramWhitelist() {
const r = await fetch("/api/my/telegram-whitelist");
if (!r.ok) return;
const list = await r.json();
const tbody = document.getElementById("my-telegram-whitelist-tbody");
if (!tbody) return;
if (!list.length) { tbody.innerHTML = '<tr><td colspan="3" style="color:var(--text-dim)">No whitelisted chats.</td></tr>'; return; }
tbody.innerHTML = list.map(e => `
<tr>
<td>${e.chat_id}</td>
<td>${e.label || ''}</td>
<td style="text-align:right"><button class="btn btn-small btn-danger" onclick="removeMyTelegramWhitelist('${e.chat_id}')">Remove</button></td>
</tr>`).join("");
}
async function loadMyTelegramTriggers() {
const r = await fetch("/api/my/telegram-triggers");
if (!r.ok) return;
const triggers = await r.json();
const tbody = document.getElementById("my-telegram-triggers-tbody");
if (!tbody) return;
if (!triggers.length) { tbody.innerHTML = '<tr><td colspan="4" style="color:var(--text-dim)">No triggers configured.</td></tr>'; return; }
tbody.innerHTML = triggers.map(t => `
<tr>
<td><code>${t.trigger_word}</code></td>
<td>${t.agent_name || t.agent_id}</td>
<td>${t.enabled ? '<span style="color:var(--green)">enabled</span>' : '<span style="color:var(--text-dim)">disabled</span>'}</td>
<td style="text-align:right">
<button class="btn btn-small btn-ghost" onclick="editMyTelegramTrigger('${t.id}','${t.trigger_word}','${t.agent_id}','${t.description||''}')">Edit</button>
<button class="btn btn-small btn-ghost" onclick="toggleMyTelegramTrigger('${t.id}')">${t.enabled?'Disable':'Enable'}</button>
<button class="btn btn-small btn-danger" onclick="deleteMyTelegramTrigger('${t.id}')">Delete</button>
</td>
</tr>`).join("");
}
async function saveMyTelegramConfig() {
const token = document.getElementById("my-telegram-bot-token")?.value.trim();
if (!token) { showFlash("Enter a bot token first."); return; }
const r = await fetch("/api/my/telegram/config", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ bot_token: token }) });
if (r.ok) {
document.getElementById("my-telegram-bot-token").value = "";
await fetch("/api/my/telegram/reconnect", { method:"POST" });
showFlash("Telegram bot saved. Starting…");
setTimeout(loadMyTelegramConfig, 2000);
} else showFlash("Error saving Telegram config.");
}
async function deleteMyTelegramConfig() {
if (!confirm("Remove your Telegram bot configuration?")) return;
const r = await fetch("/api/my/telegram/config", { method:"DELETE" });
if (r.ok) { showFlash("Telegram bot removed."); loadMyTelegramConfig(); }
}
async function addMyTelegramWhitelist() {
const chatId = document.getElementById("my-tg-chat-id")?.value.trim();
const label = document.getElementById("my-tg-chat-label")?.value.trim() || "";
if (!chatId) { showFlash("Enter a chat ID."); return; }
const r = await fetch("/api/my/telegram-whitelist", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({ chat_id: chatId, label }) });
if (r.ok) {
document.getElementById("my-tg-chat-id").value = "";
document.getElementById("my-tg-chat-label").value = "";
showFlash("Chat ID whitelisted.");
loadMyTelegramWhitelist();
} else showFlash("Error adding chat ID.");
}
async function removeMyTelegramWhitelist(chatId) {
if (!confirm(`Remove chat ID ${chatId} from your whitelist?`)) return;
const r = await fetch(`/api/my/telegram-whitelist/${chatId}`, { method:"DELETE" });
if (r.ok) { showFlash("Chat ID removed."); loadMyTelegramWhitelist(); }
}
function openMyTelegramTriggerModal(id, word, agentId, desc) {
_myTelegramTriggerId = id || null;
document.getElementById("my-telegram-trigger-id").value = id || "";
document.getElementById("my-telegram-trigger-word").value = word || "";
document.getElementById("my-telegram-trigger-desc").value = desc || "";
document.getElementById("my-telegram-trigger-modal-title").textContent = id ? "Edit Trigger" : "Add Telegram Trigger";
_populateAgentSelect("my-telegram-trigger-agent", agentId);
document.getElementById("my-telegram-trigger-modal").classList.remove("hidden");
}
function editMyTelegramTrigger(id, word, agentId, desc) { openMyTelegramTriggerModal(id, word, agentId, desc); }
function closeMyTelegramTriggerModal() { document.getElementById("my-telegram-trigger-modal")?.classList.add("hidden"); _myTelegramTriggerId = null; }
async function saveMyTelegramTrigger() {
const body = {
trigger_word: document.getElementById("my-telegram-trigger-word").value.trim(),
agent_id: document.getElementById("my-telegram-trigger-agent").value,
description: document.getElementById("my-telegram-trigger-desc").value.trim(),
enabled: true,
};
if (!body.trigger_word || !body.agent_id) { showFlash("Trigger word and agent are required."); return; }
const method = _myTelegramTriggerId ? "PUT" : "POST";
const url = _myTelegramTriggerId ? `/api/my/telegram-triggers/${_myTelegramTriggerId}` : "/api/my/telegram-triggers";
const r = await fetch(url, { method, headers:{"Content-Type":"application/json"}, body:JSON.stringify(body) });
if (r.ok) { closeMyTelegramTriggerModal(); showFlash("Trigger saved."); loadMyTelegramTriggers(); }
else { const d = await r.json().catch(()=>({})); showFlash(d.detail || "Error saving trigger."); }
}
async function toggleMyTelegramTrigger(id) {
const r = await fetch(`/api/my/telegram-triggers/${id}/toggle`, { method:"POST" });
if (r.ok) { showFlash("Trigger updated."); loadMyTelegramTriggers(); }
}
async function deleteMyTelegramTrigger(id) {
if (!confirm("Delete this trigger?")) return;
const r = await fetch(`/api/my/telegram-triggers/${id}`, { method:"DELETE" });
if (r.ok) { showFlash("Trigger deleted."); loadMyTelegramTriggers(); }
}
// Helper: populate an agent <select> element
async function _populateAgentSelect(selectId, selectedId) {
const sel = document.getElementById(selectId);
if (!sel) return;
try {
const r = await fetch("/api/agents");
const agents = r.ok ? await r.json() : [];
sel.innerHTML = agents.map(a =>
`<option value="${a.id}"${a.id === selectedId ? " selected" : ""}>${a.name}</option>`
).join("");
} catch (e) {}
}
// ── Email Handling Accounts ────────────────────────────────────────────────────
let _emailAccounts = [];
let _editingAccountId = null;
async function loadEmailAccounts() {
const tbody = document.getElementById("email-accounts-tbody");
if (!tbody) return;
const r = await fetch("/api/my/email-accounts");
if (!r.ok) { tbody.innerHTML = '<tr><td colspan="5" style="color:var(--danger)">Error loading accounts</td></tr>'; return; }
_emailAccounts = await r.json();
if (!_emailAccounts.length) {
tbody.innerHTML = '<tr><td colspan="5" style="color:var(--text-dim)">No handling accounts configured.</td></tr>';
return;
}
tbody.innerHTML = _emailAccounts.map(a => `
<tr>
<td>${esc(a.label)}</td>
<td style="color:var(--text-dim);font-size:12px">${esc(a.imap_username)}</td>
<td>
<span style="font-size:11px;padding:2px 8px;border-radius:20px;background:var(--bg2);color:${a.enabled ? 'var(--green)' : 'var(--text-dim)'}">${a.enabled ? 'enabled' : 'disabled'}</span>
${a.paused ? '<span style="font-size:11px;padding:2px 8px;border-radius:20px;background:var(--bg2);color:var(--yellow,#e5a) ;margin-left:4px">paused</span>' : ''}
</td>
<td style="font-size:12px;color:var(--text-dim)">${esc(a.agent_model || '—')}</td>
<td style="text-align:right">
<button class="btn btn-ghost btn-small" onclick="editEmailAccount('${a.id}')">Edit</button>
<button class="btn btn-ghost btn-small" onclick="toggleEmailAccount('${a.id}')">${a.enabled ? 'Disable' : 'Enable'}</button>
<button class="btn btn-danger btn-small" onclick="deleteEmailAccount('${a.id}')">Delete</button>
</td>
</tr>
`).join("");
}
function openEmailAccountModal(id) {
_editingAccountId = id || null;
const acct = id ? _emailAccounts.find(a => a.id === id) : null;
const modal = document.getElementById("email-account-modal");
if (!modal) return;
document.getElementById("eam-title").textContent = id ? "Edit Handling Account" : "Add Handling Account";
document.getElementById("eam-label").value = acct?.label || "";
document.getElementById("eam-imap-host").value = acct?.imap_host || "";
document.getElementById("eam-imap-port").value = acct?.imap_port || "993";
document.getElementById("eam-imap-username").value = acct?.imap_username || "";
document.getElementById("eam-imap-password").value = "";
document.getElementById("eam-initial-load-limit").value = acct?.initial_load_limit || "200";
document.getElementById("eam-folders-display").textContent = (acct?.monitored_folders || ["INBOX"]).join(", ");
document.getElementById("eam-folders-hidden").value = JSON.stringify(acct?.monitored_folders || ["INBOX"]);
document.getElementById("eam-agent-prompt").value = acct?.agent_prompt || "";
_populateEamModelSelect(acct?.agent_model);
_loadEamExtraTools(acct?.extra_tools || [], acct?.telegram_chat_id || "", acct?.telegram_keyword || "");
// Keyword field — show only on edit (create sets it after Telegram checkbox)
const kwRow = document.getElementById("eam-keyword-row");
const kwInput = document.getElementById("eam-telegram-keyword");
const kwPreview = document.getElementById("eam-keyword-preview");
if (kwRow && kwInput) {
kwInput.value = acct?.telegram_keyword || "";
if (kwPreview) kwPreview.textContent = acct?.telegram_keyword || "keyword";
kwRow.style.display = (acct?.extra_tools || []).includes("telegram") ? "" : "none";
}
// Pause/resume button — edit only
const pauseRow = document.getElementById("eam-pause-row");
const pauseBtn = document.getElementById("eam-pause-btn");
const pauseStatus = document.getElementById("eam-pause-status");
if (pauseRow) {
if (id) {
pauseRow.style.display = "";
const paused = acct?.paused;
if (pauseBtn) pauseBtn.textContent = paused ? "Resume listener" : "Pause listener";
if (pauseStatus) pauseStatus.textContent = paused ? "Listener is paused" : "Listener is running";
} else {
pauseRow.style.display = "none";
}
}
modal.style.display = "flex";
}
async function _loadEamExtraTools(currentSelected, currentChatId, currentKeyword) {
const area = document.getElementById("eam-extra-tools-area");
if (!area) return;
area.innerHTML = '<span style="font-size:12px;color:var(--text-dim)">Loading…</span>';
const [toolsRes, chatsRes] = await Promise.all([
fetch("/api/my/email-accounts/available-extra-tools"),
fetch("/api/my/telegram/whitelisted-chats"),
]);
if (!toolsRes.ok) { area.innerHTML = '<span style="font-size:12px;color:var(--text-dim)">No notification tools configured.</span>'; return; }
const tools = await toolsRes.json();
const chats = chatsRes.ok ? await chatsRes.json() : [];
if (!tools.length) {
area.innerHTML = '<span style="font-size:12px;color:var(--text-dim)">No notification tools configured. Add Telegram or Pushover in Settings → Credentials.</span>';
return;
}
area.innerHTML = tools.map(t => {
let extra = "";
if (t.id === "telegram") {
const chatOptions = chats.map(c =>
`<option value="${esc(c.chat_id)}" ${c.chat_id === currentChatId ? "selected" : ""}>${esc(c.label)}</option>`
).join("");
const noChats = !chats.length ? `<option value="">No whitelisted chats — add one in Settings → Telegram</option>` : "";
extra = `
<div id="eam-telegram-chat-row" style="margin-top:6px;display:${currentSelected.includes("telegram") ? "block" : "none"}">
<label style="font-size:12px;color:var(--text-dim);display:block;margin-bottom:4px">Send notifications to chat:</label>
<select id="eam-telegram-chat-id" class="form-input" style="font-size:12px;padding:4px 8px">
${noChats || chatOptions}
</select>
</div>`;
}
return `
<div>
<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer;font-size:13px">
<input type="checkbox" id="eam-extra-${esc(t.id)}" value="${esc(t.id)}"
${currentSelected.includes(t.id) ? "checked" : ""}
style="margin-top:2px;accent-color:var(--accent)"
onchange="_onEamExtraToolChange('${esc(t.id)}')">
<span>
<span style="font-weight:500;color:var(--text)">${esc(t.label)}</span>
<span style="color:var(--text-dim);font-size:12px;display:block">${esc(t.description)}</span>
</span>
</label>
${extra}
</div>`;
}).join("");
}
function _onEamExtraToolChange(toolId) {
if (toolId === "telegram") {
const checked = document.getElementById("eam-extra-telegram")?.checked;
const chatRow = document.getElementById("eam-telegram-chat-row");
if (chatRow) chatRow.style.display = checked ? "block" : "none";
const kwRow = document.getElementById("eam-keyword-row");
if (kwRow) kwRow.style.display = checked ? "" : "none";
}
}
function editEmailAccount(id) { openEmailAccountModal(id); }
function closeEmailAccountModal() {
const modal = document.getElementById("email-account-modal");
if (modal) modal.style.display = "none";
}
async function _populateEamModelSelect(selectedModel) {
const sel = document.getElementById("eam-agent-model");
if (!sel) return;
sel.innerHTML = '<option value="">Loading…</option>';
const r = await fetch("/api/models");
const d = r.ok ? await r.json() : {};
const models = d.models || [];
const chosen = selectedModel || d.default || "";
_fillModelSelect(sel, models, chosen);
}
async function loadEamFolders() {
const btn = document.getElementById("eam-load-folders-btn");
if (btn) btn.textContent = "Loading…";
let r;
const host = document.getElementById("eam-imap-host").value.trim();
const port = document.getElementById("eam-imap-port").value.trim() || "993";
const username = document.getElementById("eam-imap-username").value.trim();
const password = document.getElementById("eam-imap-password").value;
if (_editingAccountId && !password) {
// Use saved DB credentials — no new password entered
r = await fetch(`/api/my/email-accounts/${_editingAccountId}/list-folders`, { method: "POST" });
} else {
if (!host || !username || !password) {
if (btn) btn.textContent = "Load folders";
showFlash("Enter IMAP host, username and password first.");
return;
}
r = await fetch("/api/my/email-accounts/list-folders-preview", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ imap_host: host, imap_port: port, imap_username: username, imap_password: password }),
});
}
if (btn) btn.textContent = "Load folders";
if (!r.ok) { const e = await r.json().catch(() => ({})); showFlash("Failed to load folders: " + (e.detail || r.status)); return; }
const d = await r.json();
const folders = d.folders || [];
const currentSelected = JSON.parse(document.getElementById("eam-folders-hidden").value || '["INBOX"]');
const container = document.getElementById("eam-folders-checklist");
if (!container) return;
container.innerHTML = folders.map(f => `
<label style="display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer;padding:2px 0">
<input type="checkbox" value="${esc(f)}" ${currentSelected.includes(f) ? "checked" : ""}
onchange="_updateFolderSelection()"> ${esc(f)}
</label>
`).join("");
container.style.display = "";
}
function _updateFolderSelection() {
const checks = document.querySelectorAll("#eam-folders-checklist input[type=checkbox]:checked");
const selected = Array.from(checks).map(c => c.value);
document.getElementById("eam-folders-hidden").value = JSON.stringify(selected.length ? selected : ["INBOX"]);
document.getElementById("eam-folders-display").textContent = selected.join(", ") || "INBOX";
}
async function saveEmailAccount() {
const label = document.getElementById("eam-label").value.trim();
const imap_host = document.getElementById("eam-imap-host").value.trim();
const imap_port = document.getElementById("eam-imap-port").value.trim() || "993";
const imap_username = document.getElementById("eam-imap-username").value.trim();
const imap_password = document.getElementById("eam-imap-password").value;
const agent_model = document.getElementById("eam-agent-model").value;
const agent_prompt = document.getElementById("eam-agent-prompt").value.trim();
const initial_load_limit = parseInt(document.getElementById("eam-initial-load-limit").value || "200");
const monitored_folders = JSON.parse(document.getElementById("eam-folders-hidden").value || '["INBOX"]');
const extra_tools = Array.from(document.querySelectorAll("#eam-extra-tools-area input[type=checkbox]:checked")).map(c => c.value);
const telegram_chat_id = extra_tools.includes("telegram")
? (document.getElementById("eam-telegram-chat-id")?.value || "")
: "";
const telegram_keyword = extra_tools.includes("telegram")
? (document.getElementById("eam-telegram-keyword")?.value || "").trim()
: "";
if (!label || !imap_host || !imap_username) { showFlash("Label, IMAP host and username are required."); return; }
if (!_editingAccountId && !imap_password) { showFlash("Password is required for new accounts."); return; }
if (!agent_model) { showFlash("Please select a model for the handling agent."); return; }
if (extra_tools.includes("telegram") && !telegram_chat_id) { showFlash("Please select a Telegram chat to send notifications to."); return; }
if (extra_tools.includes("telegram") && !telegram_keyword) { showFlash("Please enter a reply keyword for Telegram (e.g. 'work')."); return; }
const payload = { label, imap_host, imap_port, imap_username, imap_password,
agent_model, agent_prompt, initial_load_limit, monitored_folders, extra_tools,
telegram_chat_id, telegram_keyword };
const url = _editingAccountId ? `/api/my/email-accounts/${_editingAccountId}` : "/api/my/email-accounts";
const method = _editingAccountId ? "PUT" : "POST";
const r = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
if (!r.ok) { const e = await r.json().catch(() => ({})); showFlash("Error: " + (e.detail || r.status)); return; }
closeEmailAccountModal();
showFlash("Account saved.");
loadEmailAccounts();
}
async function toggleEmailAccount(id) {
const r = await fetch(`/api/my/email-accounts/${id}/toggle`, { method: "POST" });
if (r.ok) { showFlash("Account updated."); loadEmailAccounts(); }
}
async function deleteEmailAccount(id) {
if (!confirm("Delete this handling account?")) return;
const r = await fetch(`/api/my/email-accounts/${id}`, { method: "DELETE" });
if (r.ok) { showFlash("Account deleted."); loadEmailAccounts(); }
}
async function togglePauseEmailAccount() {
if (!_editingAccountId) return;
const acct = _emailAccounts.find(a => a.id === _editingAccountId);
const paused = acct?.paused;
const action = paused ? "resume" : "pause";
const r = await fetch(`/api/my/email-accounts/${_editingAccountId}/${action}`, { method: "POST" });
if (!r.ok) { showFlash("Failed to " + action + " account."); return; }
showFlash(`Listener ${action}d.`);
await loadEmailAccounts();
// Refresh button state
const updatedAcct = _emailAccounts.find(a => a.id === _editingAccountId);
const btn = document.getElementById("eam-pause-btn");
const status = document.getElementById("eam-pause-status");
if (btn) btn.textContent = updatedAcct?.paused ? "Resume listener" : "Pause listener";
if (status) status.textContent = updatedAcct?.paused ? "Listener is paused" : "Listener is running";
}
async function loadDataFolder() {
const hint = document.getElementById("data-folder-hint");
if (!hint) return;
const r = await fetch("/api/my/data-folder");
if (!r.ok) { hint.textContent = "Could not load folder info."; return; }
const d = await r.json();
hint.textContent = d.data_folder
? `Your folder: ${d.data_folder}`
: "Not provisioned — ask your administrator to set system:users_base_folder in Credentials.";
}
// ── Per-user CalDAV ───────────────────────────────────────────────────────────
async function loadMyCaldavConfig() {
const r = await fetch("/api/my/caldav/config");
if (!r.ok) return;
const d = await r.json();
const el = id => document.getElementById(id);
if (el("my-caldav-url")) el("my-caldav-url").value = d.url || "";
if (el("my-caldav-username")) el("my-caldav-username").value = d.username || "";
if (el("my-caldav-password")) el("my-caldav-password").placeholder = d.password_set ? "••••••••" : "Enter password";
if (el("my-caldav-calendar-name")) el("my-caldav-calendar-name").value = d.calendar_name || "";
}
async function saveMyCaldavConfig() {
const el = id => document.getElementById(id);
const payload = {
url: el("my-caldav-url")?.value.trim() || "",
username: el("my-caldav-username")?.value.trim() || "",
password: el("my-caldav-password")?.value || "",
calendar_name: el("my-caldav-calendar-name")?.value.trim() || "",
};
const r = await fetch("/api/my/caldav/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload) });
if (r.ok) { showFlash("CalDAV settings saved."); loadMyCaldavConfig(); }
else { showFlash("Error saving CalDAV settings."); }
}
async function testMyCaldavConfig() {
const btn = document.getElementById("caldav-test-btn");
const res = document.getElementById("caldav-test-result");
if (btn) btn.textContent = "Testing…";
const r = await fetch("/api/my/caldav/test", { method: "POST" });
if (btn) btn.textContent = "Test connection";
const d = r.ok ? await r.json() : { success: false, message: "Request failed" };
if (res) {
res.textContent = d.message;
res.style.color = d.success ? "var(--green)" : "var(--danger)";
}
}
async function deleteMyCaldavConfig() {
if (!confirm("Clear your personal CalDAV settings? The system CalDAV will be used instead.")) return;
const r = await fetch("/api/my/caldav/config", { method: "DELETE" });
if (r.ok) { showFlash("CalDAV settings cleared."); loadMyCaldavConfig(); }
}
function togglePasswordVisibility(inputId, btn) {
const input = document.getElementById(inputId);
if (!input) return;
if (input.type === "password") { input.type = "text"; btn.textContent = "Hide"; }
else { input.type = "password"; btn.textContent = "Show"; }
}
function initUserSettings() {
const tab = new URLSearchParams(window.location.search).get("tab") || "apikeys";
switchUserTab(tab);
loadMyProviderKeys();
loadMyPersonality();
loadMyMcpServers();
}
function initSettings() {
// Branch: admin settings vs user settings
if (document.getElementById("uspane-apikeys")) {
initUserSettings();
return;
}
if (!document.getElementById("limits-form")) return;
loadApiKeyStatus();
loadProviderKeys();
loadUsersBaseFolder();
loadLimits();
loadDefaultModels();
loadProxyTrust();
loadAuditRetention();
loadBrainStatus();
loadMcpServers();
loadSecuritySettings();
loadLockouts();
loadBrandingSettings();
loadEmailWhitelist();
loadWebWhitelist();
loadFilesystemWhitelist();
reloadCredList();
loadInboxStatus();
loadInboxTriggers();
initInboxForm();
loadTelegramStatus();
loadTelegramWhitelist();
loadTelegramTriggers();
loadTgDefaultAgent();
initTelegramConfigForm();
loadSystemPrompts();
document.getElementById("telegram-whitelist-form")?.addEventListener("submit", async e => {
e.preventDefault();
const chatId = document.getElementById("tg-wl-chatid").value.trim();
const label = document.getElementById("tg-wl-label").value.trim();
if (!chatId) return;
const r = await fetch("/api/telegram-whitelist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chat_id: chatId, label }),
});
if (r.ok) {
document.getElementById("tg-wl-chatid").value = "";
document.getElementById("tg-wl-label").value = "";
showFlash("Added ✓");
loadTelegramWhitelist();
} else {
const d = await r.json().catch(() => ({}));
alert(d.detail || "Error adding chat ID");
}
});
document.getElementById("proxy-trust-form")?.addEventListener("submit", async e => {
e.preventDefault();
const ips = document.getElementById("proxy-trust-ips").value.trim();
if (!ips) return;
const r = await fetch("/api/settings/proxy-trust", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ trusted_ips: ips }),
});
if (r.ok) showFlash("Saved ✓ — restart the server for this to take effect");
else showFlash("Error saving proxy trust setting");
});
document.getElementById("limits-form")?.addEventListener("submit", async e => {
e.preventDefault();
const tc = parseInt(document.getElementById("lim-tool-calls").value, 10);
const rph = parseInt(document.getElementById("lim-runs-per-hour").value, 10);
const body = {};
if (!isNaN(tc)) body.max_tool_calls = tc;
if (!isNaN(rph)) body.max_autonomous_runs_per_hour = rph;
const r = await fetch("/api/settings/limits", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (r.ok) showFlash("Limits saved ✓");
else showFlash("Error saving limits");
});
document.getElementById("email-whitelist-form")?.addEventListener("submit", async e => {
e.preventDefault();
await addEmail();
});
document.getElementById("web-whitelist-form")?.addEventListener("submit", async e => {
e.preventDefault();
await addWebDomain();
});
document.getElementById("fs-whitelist-form")?.addEventListener("submit", async e => {
e.preventDefault();
await addFilesystemPath();
});
document.getElementById("brain-capture-form")?.addEventListener("submit", async e => {
e.preventDefault();
const content = document.getElementById("brain-capture-text").value.trim();
if (!content) return;
const btn = e.target.querySelector("button[type=submit]");
btn.disabled = true;
btn.textContent = "Capturing…";
const r = await fetch("/api/brain/capture", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
btn.disabled = false;
btn.textContent = "Capture";
if (r.ok) {
const d = await r.json();
document.getElementById("brain-capture-text").value = "";
document.getElementById("brain-capture-result").textContent = d.confirmation || "Captured ✓";
setTimeout(() => { const el = document.getElementById("brain-capture-result"); if (el) el.textContent = ""; }, 5000);
loadBrainStatus();
} else {
showFlash("Error capturing thought");
}
});
refreshStatus();
}
async function loadProxyTrust() {
try {
const r = await fetch("/api/settings/proxy-trust");
const data = await r.json();
const el = document.getElementById("proxy-trust-ips");
if (el) el.value = data.trusted_ips;
} catch { /* ignore */ }
}
async function loadBrainStatus() {
const badge = document.getElementById("brain-status-badge");
const statsEl = document.getElementById("brain-stats");
if (!badge) return;
try {
const r = await fetch("/api/brain/status");
const data = await r.json();
if (!data.connected) {
badge.textContent = "Disconnected";
badge.style.background = "var(--red)";
badge.style.color = "#fff";
if (statsEl) statsEl.innerHTML = `<div style="color:var(--text-dim);font-size:13px">${data.error || "Brain DB not available"}</div>`;
_renderBrainRows([], false);
return;
}
badge.textContent = "Connected";
badge.style.background = "var(--green)";
badge.style.color = "#000";
// Stats cards
const s = data.stats;
const byType = (s.by_type || []).map(t => `${t.type}: ${t.count}`).join(" · ") || "—";
const lastSeen = s.most_recent ? formatDate(s.most_recent) : "—";
if (statsEl) statsEl.innerHTML = `
<div class="meta-card"><div class="meta-label">Total thoughts</div><div class="meta-value">${s.total.toLocaleString()}</div></div>
<div class="meta-card"><div class="meta-label">By type</div><div class="meta-value" style="font-size:12px">${esc(byType)}</div></div>
<div class="meta-card"><div class="meta-label">Most recent</div><div class="meta-value" style="font-size:12px">${esc(lastSeen)}</div></div>
`;
_renderBrainRows(data.recent, false);
} catch (e) {
if (badge) { badge.textContent = "Error"; badge.style.background = "var(--red)"; badge.style.color = "#fff"; }
}
}
function _renderBrainRows(thoughts, isSearch) {
const tbody = document.querySelector("#brain-recent-table tbody");
const heading = document.getElementById("brain-table-heading");
if (!tbody) return;
if (heading) heading.textContent = isSearch ? "Search Results" : "Recent Thoughts";
tbody.innerHTML = "";
if (!thoughts.length) {
const msg = isSearch ? "No matching thoughts" : "No thoughts yet";
tbody.innerHTML = `<tr><td colspan="3" style="text-align:center;color:var(--text-dim)">${msg}</td></tr>`;
return;
}
for (const t of thoughts) {
const type = t.metadata?.type || "—";
const date = formatDate(t.created_at);
const preview = t.content.length > 120 ? t.content.slice(0, 120) + "…" : t.content;
const tr = document.createElement("tr");
tr.style.cursor = "pointer";
tr.title = "Click to view full thought";
let simBadge = "";
if (t.similarity != null) {
const pct = Math.round(t.similarity * 100);
simBadge = `<span style="margin-left:6px;font-size:10px;color:var(--text-dim)">${pct}%</span>`;
}
tr.innerHTML = `
<td style="font-size:12px;max-width:400px;white-space:pre-wrap;word-break:break-word">${esc(preview)}${simBadge}</td>
<td><span class="badge badge-blue" style="font-size:11px">${esc(type)}</span></td>
<td style="font-size:12px;color:var(--text-dim)">${esc(date)}</td>
`;
tr.addEventListener("click", () => openBrainThoughtModal(t));
tbody.appendChild(tr);
}
}
let _brainSearchTimer = null;
function onBrainSearchInput(val) {
const clearBtn = document.getElementById("brain-search-clear");
if (clearBtn) clearBtn.style.display = val.trim() ? "" : "none";
clearTimeout(_brainSearchTimer);
if (!val.trim()) { loadBrainStatus(); return; }
_brainSearchTimer = setTimeout(() => runBrainSearch(val.trim()), 400);
}
async function runBrainSearch(q) {
const tbody = document.querySelector("#brain-recent-table tbody");
if (tbody) tbody.innerHTML = `<tr><td colspan="3" style="text-align:center;color:var(--text-dim)">Searching…</td></tr>`;
if (document.getElementById("brain-table-heading")) document.getElementById("brain-table-heading").textContent = "Search Results";
try {
const r = await fetch(`/api/brain/search?q=${encodeURIComponent(q)}&limit=20`);
if (!r.ok) { throw new Error(await r.text()); }
const data = await r.json();
_renderBrainRows(data.results, true);
} catch (e) {
if (tbody) tbody.innerHTML = `<tr><td colspan="3" style="text-align:center;color:var(--red)">Search failed</td></tr>`;
}
}
function clearBrainSearch() {
const inp = document.getElementById("brain-search-input");
const clearBtn = document.getElementById("brain-search-clear");
if (inp) inp.value = "";
if (clearBtn) clearBtn.style.display = "none";
loadBrainStatus();
}
function openBrainThoughtModal(t) {
document.getElementById("btm-content").textContent = t.content;
document.getElementById("btm-type").textContent = t.metadata?.type || "—";
document.getElementById("btm-date").textContent = formatDate(t.created_at);
const simEl = document.getElementById("btm-sim");
if (t.similarity != null) {
simEl.textContent = `${Math.round(t.similarity * 100)}% match`;
simEl.style.display = "";
} else {
simEl.textContent = "";
simEl.style.display = "none";
}
const tagsEl = document.getElementById("btm-tags");
const tags = t.metadata?.tags || [];
tagsEl.style.display = tags.length ? "" : "none";
tagsEl.innerHTML = tags.map(tag => `<span class="badge" style="font-size:11px;background:var(--bg2);color:var(--text-dim)">${esc(tag)}</span>`).join("");
const modal = document.getElementById("brain-thought-modal");
modal.style.display = "flex";
modal.onclick = e => { if (e.target === modal) closeBrainThoughtModal(); };
}
function closeBrainThoughtModal() {
document.getElementById("brain-thought-modal").style.display = "none";
}
async function loadBrainKey(inputId = "brain-mcp-key", cmdId = "brain-mcp-cmd") {
const input = document.getElementById(inputId);
const cmdEl = document.getElementById(cmdId);
if (!input) return;
const r = await fetch("/api/my/brain/key");
if (!r.ok) return;
const d = await r.json();
input.value = d.key;
if (cmdEl) cmdEl.textContent =
`claude mcp add --transport http brain https://jarvis.oai.pm/brain-mcp/sse --header "x-brain-key: ${d.key}"`;
}
function toggleBrainKeyVisibility(btn, inputId = "brain-mcp-key") {
const input = document.getElementById(inputId);
if (!input) return;
if (input.type === "password") { input.type = "text"; btn.textContent = "Hide"; }
else { input.type = "password"; btn.textContent = "Show"; }
}
async function copyBrainKey(inputId = "brain-mcp-key") {
const input = document.getElementById(inputId);
if (!input) return;
await navigator.clipboard.writeText(input.value);
showFlash("Key copied to clipboard");
}
async function regenerateBrainKey(inputId = "brain-mcp-key", cmdId = "brain-mcp-cmd") {
if (!confirm("Regenerate your brain MCP key? Your existing MCP connections will stop working until you update them.")) return;
const r = await fetch("/api/my/brain/key/regenerate", { method: "POST" });
if (!r.ok) { showFlash("Failed to regenerate key"); return; }
const d = await r.json();
const input = document.getElementById(inputId);
const cmdEl = document.getElementById(cmdId);
if (input) input.value = d.key;
if (cmdEl) cmdEl.textContent =
`claude mcp add --transport http brain https://jarvis.oai.pm/brain-mcp/sse --header "x-brain-key: ${d.key}"`;
showFlash("New key generated — update your MCP clients");
}
async function loadBrainAutoApprove() {
try {
const r = await fetch("/api/my/brain-settings");
if (!r.ok) return;
const d = await r.json();
const tog1 = document.getElementById("brain-auto-approve-toggle");
const tog2 = document.getElementById("ubrain-auto-approve-toggle");
if (tog1) tog1.checked = !!d.brain_auto_approve;
if (tog2) tog2.checked = !!d.brain_auto_approve;
} catch { /* ignore */ }
}
async function saveBrainAutoApprove(enabled) {
try {
const r = await fetch("/api/my/brain-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brain_auto_approve: enabled }),
});
if (!r.ok) { showFlash("Failed to save setting"); return; }
// Show flash next to whichever pane is visible
const flashEl = document.getElementById("brain-auto-approve-flash") || document.getElementById("ubrain-auto-approve-flash");
if (flashEl) {
flashEl.textContent = enabled ? "Auto-approve enabled" : "Auto-approve disabled";
flashEl.style.display = "block";
setTimeout(() => { flashEl.style.display = "none"; }, 3000);
}
} catch { showFlash("Failed to save setting"); }
}
async function loadLimits() {
try {
const r = await fetch("/api/settings/limits");
const data = await r.json();
const tcEl = document.getElementById("lim-tool-calls");
const rphEl = document.getElementById("lim-runs-per-hour");
const defaultsEl = document.getElementById("limits-defaults");
if (tcEl) tcEl.value = data.max_tool_calls;
if (rphEl) rphEl.value = data.max_autonomous_runs_per_hour;
if (defaultsEl) {
defaultsEl.textContent =
`.env defaults: max_tool_calls=${data.defaults.max_tool_calls}, ` +
`max_autonomous_runs_per_hour=${data.defaults.max_autonomous_runs_per_hour}`;
}
} catch { /* ignore */ }
}
async function loadDefaultModels() {
const defSel = document.getElementById("dm-default");
const freeSel = document.getElementById("dm-free");
if (!defSel || !freeSel) return;
// Fetch all models and free-only model info in parallel
const [modelsResp, infoResp, savedResp] = await Promise.all([
fetch("/api/models"),
fetch("/api/models/info"),
fetch("/api/settings/default-models"),
]);
const allModels = modelsResp.ok ? (await modelsResp.json()).models || [] : [];
const modelInfo = infoResp.ok ? await infoResp.json() : [];
const saved = savedResp.ok ? await savedResp.json() : {};
// Free OpenRouter models: openrouter:* with prompt_per_1m == 0 and completion_per_1m == 0
const freeModels = modelInfo
.filter(m => m.id?.startsWith("openrouter:") &&
m.pricing?.prompt_per_1m === 0 &&
m.pricing?.completion_per_1m === 0)
.map(m => m.id);
// Helper: prepend a blank "use default" option after _fillModelSelect clears the select
const fillWithBlank = (sel, models, currentVal) => {
_fillModelSelect(sel, models, currentVal);
const blank = document.createElement("option");
blank.value = "";
blank.textContent = "(use .env / first available)";
sel.insertBefore(blank, sel.firstChild);
if (!currentVal) sel.value = "";
};
fillWithBlank(defSel, allModels, saved.default_model || "");
fillWithBlank(freeSel, freeModels, saved.free_tier_model || "");
const form = document.getElementById("default-models-form");
if (form && !form._bound) {
form._bound = true;
form.addEventListener("submit", async e => {
e.preventDefault();
const r = await fetch("/api/settings/default-models", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
default_model: defSel.value || "",
free_tier_model: freeSel.value || "",
}),
});
if (r.ok) showFlash("Default models saved ✓");
else showFlash("Error saving default models");
});
}
}
async function loadAuditRetention() {
try {
const r = await fetch("/api/settings/audit-retention");
const data = await r.json();
const sel = document.getElementById("audit-retention-days");
if (sel) sel.value = data.days;
} catch { /* ignore */ }
const form = document.getElementById("audit-retention-form");
if (form) form.addEventListener("submit", async e => {
e.preventDefault();
const days = parseInt(document.getElementById("audit-retention-days").value, 10);
const r = await fetch("/api/settings/audit-retention", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ days }),
});
if (r.ok) showFlash(days > 0 ? `Retention set to ${days} days ✓` : "Retention disabled (keep forever) ✓");
});
}
async function clearAuditLog(olderThanDays) {
const msg = olderThanDays > 0
? `Delete all audit entries older than ${olderThanDays} days?`
: "Delete the ENTIRE audit log? This cannot be undone.";
if (!confirm(msg)) return;
const r = await fetch(`/api/audit?older_than_days=${olderThanDays}`, { method: "DELETE" });
const data = await r.json();
showFlash(`Deleted ${data.deleted} entries ✓`);
if (document.getElementById("audit-table")) loadAudit();
}
function toggleCredVisibility() {
const input = document.getElementById("cred-modal-value");
if (input) input.type = input.type === "password" ? "text" : "password";
}
async function deleteCred(key) {
if (!confirm(`Delete credential "${key}"?`)) return;
const r = await fetch(`/api/credentials/${encodeURIComponent(key)}`, { method: "DELETE" });
if (r.ok) {
showFlash("Deleted ✓");
await reloadCredList();
} else {
const d = await r.json();
alert(d.detail || "Error");
}
}
const _SYSTEM_CRED_KEYS = new Set([
"system:paused", "system:max_tool_calls", "system:max_autonomous_runs_per_hour"
]);
const _CRED_LABELS = {
// AI providers
"anthropic_api_key": "Anthropic API Key",
"openrouter_api_key": "OpenRouter API Key",
// CalDAV
"caldav_url": "CalDAV URL",
"caldav_username": "CalDAV Username",
"caldav_password": "CalDAV Password",
"caldav_calendar_name": "Calendar Name",
// Mailcow (legacy keys)
"mailcow_host": "Mailcow Host",
"mailcow_username": "Mailcow Username",
"mailcow_password": "Mailcow Password",
"mailcow_imap_host": "IMAP Host",
"mailcow_smtp_host": "SMTP Host",
"mailcow_smtp_port": "SMTP Port",
// Pushover
"pushover_user_key": "Pushover User Key",
"pushover_app_token": "Pushover App Token",
// Inbox
"inbox:imap_host": "IMAP Host",
"inbox:imap_username": "IMAP Username",
"inbox:imap_password": "IMAP Password",
"inbox:smtp_host": "SMTP Host",
"inbox:smtp_port": "SMTP Port",
"inbox:smtp_username": "SMTP Username",
"inbox:smtp_password": "SMTP Password",
// Telegram
"telegram:bot_token": "Bot Token",
"telegram:default_agent_id": "Default Agent",
// 2nd Brain
"brain:mcp_key": "MCP Key",
// System — runtime limits
"system:max_tool_calls": "Max Tool Calls",
"system:max_autonomous_runs_per_hour": "Max Autonomous Runs / Hour",
"system:paused": "Agent Paused",
"system:trusted_proxy_ips": "Trusted Proxy IPs",
"system:audit_retention_days": "Audit Log Retention (days)",
// System — branding
"system:brand_name": "Brand Name",
"system:brand_logo_filename": "Brand Logo",
// System — security
"system:security_sanitize_enhanced": "Enhanced Sanitization",
"system:security_canary_enabled": "Canary Token Enabled",
"system:canary_token": "Canary Token Value",
"system:canary_rotated_at": "Canary Token Last Rotated",
"system:security_llm_screen_enabled": "LLM Content Screening",
"system:security_llm_screen_model": "LLM Screening Model",
"system:security_llm_screen_block": "LLM Screening Block Mode",
"system:security_output_validation_enabled": "Output Validation",
"system:security_truncation_enabled": "Content Truncation",
"system:security_max_web_chars": "Max Web Content (chars)",
"system:security_max_email_chars": "Max Email Content (chars)",
"system:security_max_file_chars": "Max File Content (chars)",
"system:security_max_subject_chars": "Max Subject (chars)",
};
const _CRED_PREFIX_LABELS = {
"inbox": "Inbox", "telegram": "Telegram", "system": "System",
"brain": "2nd Brain", "caldav": "CalDAV",
};
const _ACRONYMS = new Set(["api", "url", "imap", "smtp", "mcp", "ip", "id"]);
function _credLabel(key) {
if (_CRED_LABELS[key]) return _CRED_LABELS[key];
// Fallback: split prefix:suffix, humanize
const colonIdx = key.indexOf(":");
let prefix = "", name = key;
if (colonIdx !== -1) {
prefix = _CRED_PREFIX_LABELS[key.slice(0, colonIdx)] || _titleCase(key.slice(0, colonIdx));
name = key.slice(colonIdx + 1);
}
const humanName = name.split("_").map(w => _ACRONYMS.has(w) ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
return prefix ? `${prefix}${humanName}` : humanName;
}
function _titleCase(str) {
return str.split("_").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
}
// The exact set of credentials managed on the Credentials tab.
// Keys owned by Inbox, Telegram, Brain, Security, Branding, or API tabs are excluded.
const _CREDENTIALS_TAB_KEYS = [
{ key: "mailcow_host", label: "Mailcow Host", usedBy: "CalDAV", note: "SOGo/CalDAV hostname, e.g. example.com" },
{ key: "mailcow_username", label: "Mailcow Username", usedBy: "CalDAV, Email", note: "Full email address, e.g. you@example.com" },
{ key: "mailcow_password", label: "Mailcow Password", usedBy: "CalDAV, Email", note: "Account or app-specific password" },
{ key: "caldav_calendar_name", label: "Calendar Name", usedBy: "CalDAV", note: "Optional; omit to use first calendar found" },
{ key: "mailcow_imap_host", label: "IMAP Host", usedBy: "Email", note: "IMAP hostname if different from mailcow_host" },
{ key: "mailcow_smtp_host", label: "SMTP Host", usedBy: "Email", note: "SMTP hostname if different from mailcow_host" },
{ key: "mailcow_smtp_port", label: "SMTP Port", usedBy: "Email", note: "SMTP port (default: 465)" },
{ key: "pushover_user_key", label: "Pushover User Key", usedBy: "Pushover", note: "Your Pushover user key (from pushover.net dashboard)" },
{ key: "pushover_app_token", label: "Pushover App Token", usedBy: "Pushover", note: "Application API token from pushover.net" },
{ key: "system:trusted_proxy_ips", label: "Trusted Proxy IPs", usedBy: "Network", note: "Reverse proxy IP(s) - managed via Settings - General; requires restart" },
];
const _KNOWN_CRED_KEYS = new Set(_CREDENTIALS_TAB_KEYS.map(d => d.key));
async function reloadCredList() {
const r = await fetch("/api/credentials");
if (!r.ok) return;
const rows = await r.json();
const stored = new Map(rows.map(row => [typeof row === "string" ? row : row.key, row]));
const list = document.getElementById("cred-list");
if (!list) return;
list.innerHTML = "";
for (const def of _CREDENTIALS_TAB_KEYS) {
const k = def.key;
const isSet = stored.has(k);
const row = stored.get(k) || {};
const desc = row.description || "";
const tr = document.createElement("tr");
// Key column
const tdKey = document.createElement("td");
tdKey.innerHTML = `<span style="font-weight:500">${esc(def.label)}</span><br>` +
`<code style="font-size:11px;color:var(--text-dim)">${esc(k)}</code><br>` +
`<span style="font-size:11px;color:var(--text-dim)">${esc(def.note)}</span>`;
// Used by column
const tdUsed = document.createElement("td");
tdUsed.style.cssText = "color:var(--text-dim);font-size:12px;white-space:nowrap";
tdUsed.textContent = def.usedBy;
// Status column
const tdStatus = document.createElement("td");
tdStatus.style.cssText = "font-size:12px";
tdStatus.innerHTML = isSet
? `<span style="color:var(--green)">&#10003; Set</span>`
: `<span style="color:var(--text-dim)">Not set</span>`;
// Actions column
const tdAct = document.createElement("td");
if (k === "system:trusted_proxy_ips") {
const span = document.createElement("span");
span.style.cssText = "color:var(--text-dim);font-size:12px";
span.textContent = "via General tab";
tdAct.appendChild(span);
} else {
const editBtn = document.createElement("button");
editBtn.className = "btn btn-ghost";
editBtn.style.cssText = "padding:4px 10px;font-size:12px;margin-right:6px";
editBtn.textContent = isSet ? "Edit" : "Set";
editBtn.addEventListener("click", () => openCredModal(k, desc));
tdAct.appendChild(editBtn);
if (isSet) {
const delBtn = document.createElement("button");
delBtn.className = "btn btn-danger";
delBtn.style.cssText = "padding:4px 10px;font-size:12px";
delBtn.textContent = "Delete";
delBtn.addEventListener("click", () => deleteCred(k));
tdAct.appendChild(delBtn);
}
}
tr.appendChild(tdKey);
tr.appendChild(tdUsed);
tr.appendChild(tdStatus);
tr.appendChild(tdAct);
list.appendChild(tr);
}
}
function _isSensitiveCredKey(key) {
return /password|token|secret/i.test(key);
}
async function openCredModal(key = null, desc = "") {
const modal = document.getElementById("cred-modal");
const title = document.getElementById("cred-modal-title");
const keySelect = document.getElementById("cred-modal-key-select");
const custGroup = document.getElementById("cred-modal-custom-group");
const custInput = document.getElementById("cred-modal-key-custom");
const valInput = document.getElementById("cred-modal-value");
const descInput = document.getElementById("cred-modal-desc");
valInput.value = "";
valInput.type = "password";
descInput.value = desc;
if (key) {
title.textContent = "Edit Credential";
keySelect.disabled = true;
custInput.disabled = true;
if (_KNOWN_CRED_KEYS.has(key)) {
keySelect.value = key;
custGroup.style.display = "none";
} else {
keySelect.value = "__custom__";
custInput.value = key;
custGroup.style.display = "";
}
// Pre-fill existing value — always fetch so the eye button works
try {
const r = await fetch(`/api/credentials/${encodeURIComponent(key)}`);
if (r.ok) {
const d = await r.json();
valInput.value = d.value ?? "";
}
} catch { /* leave blank */ }
} else {
title.textContent = "Add Credential";
keySelect.disabled = false;
keySelect.value = "";
custInput.disabled = false;
custInput.value = "";
custGroup.style.display = "none";
descInput.value = "";
}
modal.classList.remove("hidden");
}
function closeCredModal() {
document.getElementById("cred-modal").classList.add("hidden");
}
function onCredKeySelectChange() {
const sel = document.getElementById("cred-modal-key-select");
const grp = document.getElementById("cred-modal-custom-group");
if (grp) grp.style.display = sel.value === "__custom__" ? "" : "none";
}
async function saveCredModal() {
const keySelect = document.getElementById("cred-modal-key-select");
const custInput = document.getElementById("cred-modal-key-custom");
const valInput = document.getElementById("cred-modal-value");
const descInput = document.getElementById("cred-modal-desc");
const key = keySelect.value === "__custom__"
? custInput.value.trim()
: keySelect.value.trim();
if (!key) { alert("Please select or enter a credential name."); return; }
const value = valInput.value;
if (!value) { alert("Please enter a value."); return; }
const r = await fetch("/api/credentials", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, value, description: descInput.value.trim() }),
});
if (r.ok) {
closeCredModal();
showFlash("Saved ✓");
await reloadCredList();
} else {
const d = await r.json().catch(() => ({}));
alert(d.detail || "Error saving credential");
}
}
/* ══════════════════════════════════════════════════════════════════════════
MCP SERVERS SETTINGS
══════════════════════════════════════════════════════════════════════════ */
async function loadMcpServers() {
const tbody = document.querySelector("#mcp-server-table tbody");
if (!tbody) return;
try {
const r = await fetch("/api/mcp-servers");
const servers = await r.json();
if (!servers.length) {
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center;color:var(--text-dim)">No MCP servers configured yet.</td></tr>`;
return;
}
tbody.innerHTML = "";
for (const s of servers) {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${esc(s.name)}</td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(s.url)}</td>
<td>${esc(s.transport)}</td>
<td id="mcp-tool-count-${s.id}">—</td>
<td><span style="color:${s.enabled ? "var(--green)" : "var(--text-dim)"}">${s.enabled ? "Enabled" : "Disabled"}</span></td>
<td style="display:flex;gap:6px;flex-wrap:wrap">
<button class="btn btn-ghost" style="padding:3px 8px;font-size:12px" onclick="refreshMcpServer('${s.id}')">Refresh</button>
<button class="btn btn-ghost" style="padding:3px 8px;font-size:12px" onclick="editMcpServer('${s.id}')">Edit</button>
<button class="btn btn-ghost" style="padding:3px 8px;font-size:12px" onclick="toggleMcpServer('${s.id}')">${s.enabled ? "Disable" : "Enable"}</button>
<button class="btn btn-danger" style="padding:3px 8px;font-size:12px" onclick="deleteMcpServer('${s.id}', '${esc(s.name)}')">Delete</button>
</td>`;
tbody.appendChild(tr);
}
} catch (e) {
tbody.innerHTML = `<tr><td colspan="6" style="color:var(--red)">${esc(String(e))}</td></tr>`;
}
}
function showMcpModal(server = null) {
document.getElementById("mcp-modal-title").textContent = server ? "Edit MCP Server" : "Add MCP Server";
document.getElementById("mcp-modal-id").value = server ? server.id : "";
document.getElementById("mcp-name").value = server ? server.name : "";
document.getElementById("mcp-url").value = server ? server.url : "";
document.getElementById("mcp-transport").value = server ? server.transport : "sse";
document.getElementById("mcp-api-key").value = ""; // never pre-fill secrets
document.getElementById("mcp-headers").value = "";
document.getElementById("mcp-enabled").checked = server ? server.enabled : true;
document.getElementById("mcp-modal").classList.remove("hidden");
}
function closeMcpModal() {
document.getElementById("mcp-modal").classList.add("hidden");
}
async function editMcpServer(id) {
const r = await fetch(`/api/mcp-servers/${id}`);
const server = await r.json();
showMcpModal(server);
}
async function saveMcpServer() {
const id = document.getElementById("mcp-modal-id").value;
const headersRaw = document.getElementById("mcp-headers").value.trim();
let headers = null;
if (headersRaw) {
try { headers = JSON.parse(headersRaw); } catch { alert("Extra headers must be valid JSON"); return; }
}
const body = {
name: document.getElementById("mcp-name").value.trim(),
url: document.getElementById("mcp-url").value.trim(),
transport: document.getElementById("mcp-transport").value,
api_key: document.getElementById("mcp-api-key").value,
headers,
enabled: document.getElementById("mcp-enabled").checked,
};
if (!body.name || !body.url) { alert("Name and URL are required"); return; }
const url = id ? `/api/mcp-servers/${id}` : "/api/mcp-servers";
const method = id ? "PUT" : "POST";
try {
const r = await fetch(url, { method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
if (r.ok) {
closeMcpModal();
showFlash("Saved — discovering tools in background…");
setTimeout(loadMcpServers, 1500);
} else {
const d = await r.json().catch(() => ({}));
alert(d.detail || `Error saving MCP server (HTTP ${r.status})`);
}
} catch (e) {
alert(`Network error: ${e.message}`);
}
}
async function deleteMcpServer(id, name) {
if (!confirm(`Delete MCP server "${name}"?`)) return;
const r = await fetch(`/api/mcp-servers/${id}`, { method: "DELETE" });
if (r.ok) { showFlash("Deleted ✓"); loadMcpServers(); }
}
async function toggleMcpServer(id) {
await fetch(`/api/mcp-servers/${id}/toggle`, { method: "POST" });
loadMcpServers();
}
async function refreshMcpServer(id) {
const countEl = document.getElementById(`mcp-tool-count-${id}`);
if (countEl) countEl.textContent = "…";
const r = await fetch(`/api/mcp-servers/${id}/refresh`, { method: "POST" });
if (r.ok) {
const d = await r.json();
if (countEl) countEl.textContent = d.tool_count;
showFlash(`Refreshed — ${d.tool_count} tool(s) found ✓`);
} else {
if (countEl) countEl.textContent = "error";
showFlash("Refresh failed");
}
}
/* ══════════════════════════════════════════════════════════════════════════
SECURITY SETTINGS
══════════════════════════════════════════════════════════════════════════ */
// ── Branding ──────────────────────────────────────────────────────────────────
async function loadBrandingSettings() {
try {
const r = await fetch("/api/settings/branding");
const d = await r.json();
const nameInput = document.getElementById("brand-name-input");
if (nameInput) nameInput.value = d.brand_name || "";
_updateLogoPreview(d.has_custom_logo, d.logo_filename);
} catch { /* ignore */ }
}
function _updateLogoPreview(hasCustom, filename) {
const img = document.getElementById("brand-logo-preview");
if (!img) return;
img.src = hasCustom && filename ? `/static/${filename}?t=${Date.now()}` : "/static/logo.png";
const resetBtn = document.getElementById("brand-logo-reset-btn");
if (resetBtn) resetBtn.style.display = hasCustom ? "" : "none";
}
async function saveBrandingName(e) {
e.preventDefault();
const name = document.getElementById("brand-name-input")?.value.trim() || "";
const r = await fetch("/api/settings/branding", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brand_name: name }),
});
if (r.ok) {
showFlash("Branding saved — reload to see sidebar update");
} else {
showFlash("Save failed");
}
}
async function uploadBrandLogo() {
const fileInput = document.getElementById("brand-logo-file");
if (!fileInput?.files?.length) return;
const fd = new FormData();
fd.append("file", fileInput.files[0]);
const r = await fetch("/api/settings/branding/logo/upload", { method: "POST", body: fd });
if (r.ok) {
const d = await r.json();
_updateLogoPreview(true, d.filename);
showFlash("Logo uploaded — reload to see sidebar update");
} else {
const d = await r.json().catch(() => ({}));
showFlash("Upload failed: " + (d.detail || "unknown error"));
}
fileInput.value = "";
}
async function resetBrandLogo() {
const r = await fetch("/api/settings/branding/logo", { method: "DELETE" });
if (r.ok) {
_updateLogoPreview(false);
showFlash("Logo reset — reload to see sidebar update");
}
}
async function loadSecuritySettings() {
const form = document.getElementById("security-form");
if (!form) return;
try {
const r = await fetch("/api/settings/security");
const data = await r.json();
const setChk = (id, val) => { const el = document.getElementById(id); if (el) el.checked = !!val; };
const setNum = (id, val) => { const el = document.getElementById(id); if (el) el.value = val; };
setChk("sec-sanitize-enhanced", data.sanitize_enhanced);
setChk("sec-truncation-enabled", data.truncation_enabled);
setChk("sec-output-validation", data.output_validation_enabled);
setChk("sec-canary-enabled", data.canary_enabled);
setNum("sec-max-web-chars", data.max_web_chars);
setNum("sec-max-email-chars", data.max_email_chars);
setNum("sec-max-file-chars", data.max_file_chars);
setNum("sec-max-subject-chars", data.max_subject_chars);
setChk("sec-llm-screen-enabled", data.llm_screen_enabled);
const mdEl = document.getElementById("sec-llm-screen-model");
if (mdEl && data.llm_screen_model) mdEl.value = data.llm_screen_model;
const modeEl = document.getElementById("sec-llm-screen-mode");
if (modeEl) modeEl.value = data.llm_screen_block ? "block" : "flag";
} catch { /* ignore */ }
form.addEventListener("submit", async e => {
e.preventDefault();
const getChk = id => { const el = document.getElementById(id); return el ? el.checked : null; };
const getNum = id => { const el = document.getElementById(id); return el ? parseInt(el.value, 10) : null; };
const body = {
sanitize_enhanced: getChk("sec-sanitize-enhanced"),
truncation_enabled: getChk("sec-truncation-enabled"),
output_validation_enabled: getChk("sec-output-validation"),
canary_enabled: getChk("sec-canary-enabled"),
max_web_chars: getNum("sec-max-web-chars"),
max_email_chars: getNum("sec-max-email-chars"),
max_file_chars: getNum("sec-max-file-chars"),
max_subject_chars: getNum("sec-max-subject-chars"),
llm_screen_enabled: getChk("sec-llm-screen-enabled"),
llm_screen_model: (() => { const el = document.getElementById("sec-llm-screen-model"); return el ? el.value : null; })(),
llm_screen_block: (() => { const el = document.getElementById("sec-llm-screen-mode"); return el ? el.value === "block" : null; })(),
};
// Remove null/NaN values
for (const k of Object.keys(body)) {
if (body[k] === null || (typeof body[k] === "number" && isNaN(body[k]))) delete body[k];
}
const r = await fetch("/api/settings/security", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (r.ok) showFlash("Security settings saved ✓");
else showFlash("Error saving security settings");
});
}
async function loadLockouts() {
const container = document.getElementById("lockout-list");
if (!container) return;
try {
const r = await fetch("/api/settings/login-lockouts");
const list = await r.json();
if (!list.length) {
container.innerHTML = "<span style='color:var(--text-dim);font-size:13px'>No locked IPs.</span>";
return;
}
const now = Date.now() / 1000;
container.innerHTML = list.map(item => {
const badge = item.type === "permanent"
? "<span style='background:var(--danger,#dc3c3c);color:#fff;border-radius:3px;padding:1px 6px;font-size:11px'>PERMANENT</span>"
: "<span style='background:var(--yellow,#e0a632);color:#000;border-radius:3px;padding:1px 6px;font-size:11px'>30 min</span>";
const since = item.locked_at ? formatDate(new Date(item.locked_at * 1000).toISOString()) : "—";
const until = item.locked_until ? formatDate(new Date(item.locked_until * 1000).toISOString()) : "—";
return `<div style="display:flex;align-items:center;gap:10px;padding:6px 0;border-bottom:1px solid var(--border);font-size:13px">
<code style="flex:1">${item.ip}</code>
${badge}
<span style="color:var(--text-dim)">since ${since}</span>
${item.type === "temporary" ? `<span style="color:var(--text-dim)">until ${until}</span>` : ""}
<button class="btn btn-ghost btn-small" style="color:var(--accent)" onclick="unlockIp(${JSON.stringify(item.ip)})">Unlock</button>
</div>`;
}).join("");
} catch(e) {
container.innerHTML = "<span style='color:var(--danger)'>Failed to load lockouts.</span>";
}
}
async function unlockIp(ip) {
const r = await fetch("/api/settings/login-lockouts/" + encodeURIComponent(ip), { method: "DELETE" });
if (r.ok) { showFlash("Unlocked " + ip); loadLockouts(); }
else showFlash("Failed to unlock " + ip);
}
async function unlockAllIps() {
if (!confirm("Unlock all locked IPs?")) return;
const r = await fetch("/api/settings/login-lockouts", { method: "DELETE" });
if (r.ok) { const d = await r.json(); showFlash("Unlocked " + d.unlocked + " IP(s)"); loadLockouts(); }
else showFlash("Failed to unlock all");
}
/* ══════════════════════════════════════════════════════════════════════════
INBOX SETTINGS
══════════════════════════════════════════════════════════════════════════ */
async function loadInboxStatus() {
const badge = document.getElementById("inbox-status-badge");
const btn = document.getElementById("inbox-action-btn");
if (!badge) return;
try {
const r = await fetch("/api/inbox/status");
const raw = await r.json();
const d = raw.global || raw; // route wraps in {global: {...}, all: [...]}
if (!d.configured) {
badge.textContent = "Not configured";
badge.style.cssText = "font-size:12px;padding:2px 10px;border-radius:20px;background:var(--bg2);color:var(--text-dim)";
if (btn) { btn.textContent = "Reconnect"; btn.onclick = reconnectInbox; }
} else if (d.connected) {
badge.textContent = "Connected";
badge.style.cssText = "font-size:12px;padding:2px 10px;border-radius:20px;background:rgba(80,200,120,0.15);color:var(--green)";
if (btn) { btn.textContent = "Disconnect"; btn.onclick = disconnectInbox; }
} else {
badge.textContent = d.error ? `Error: ${d.error}` : "Disconnected";
badge.style.cssText = "font-size:12px;padding:2px 10px;border-radius:20px;background:rgba(220,80,80,0.15);color:var(--danger,#e05)";
if (btn) { btn.textContent = "Reconnect"; btn.onclick = reconnectInbox; }
}
} catch { /* ignore */ }
}
async function reconnectInbox() {
await fetch("/api/inbox/reconnect", { method: "POST" });
showFlash("Reconnecting…");
setTimeout(loadInboxStatus, 2000);
}
async function disconnectInbox() {
await fetch("/api/inbox/disconnect", { method: "POST" });
showFlash("Disconnected");
setTimeout(loadInboxStatus, 500);
}
function initInboxForm() {
const form = document.getElementById("inbox-config-form");
if (!form) return;
// Pre-fill non-sensitive fields
const nonSecret = [
["inbox:imap_host", "inbox-imap-host"],
["inbox:imap_username","inbox-imap-user"],
["inbox:smtp_host", "inbox-smtp-host"],
["inbox:smtp_port", "inbox-smtp-port"],
["inbox:smtp_username","inbox-smtp-user"],
];
nonSecret.forEach(async ([key, elId]) => {
const el = document.getElementById(elId);
if (!el) return;
try {
const r = await fetch(`/api/credentials/${encodeURIComponent(key)}`);
if (r.ok) { const d = await r.json(); el.value = d.value; }
} catch { /* leave blank */ }
});
form.addEventListener("submit", async e => {
e.preventDefault();
const fields = [
["inbox:imap_host", document.getElementById("inbox-imap-host").value.trim()],
["inbox:imap_username", document.getElementById("inbox-imap-user").value.trim()],
["inbox:imap_password", document.getElementById("inbox-imap-pass").value],
["inbox:smtp_host", document.getElementById("inbox-smtp-host").value.trim()],
["inbox:smtp_port", document.getElementById("inbox-smtp-port").value.trim()],
["inbox:smtp_username", document.getElementById("inbox-smtp-user").value.trim()],
["inbox:smtp_password", document.getElementById("inbox-smtp-pass").value],
];
for (const [key, value] of fields) {
if (!value) continue; // skip blanks — don't overwrite existing secrets
await fetch("/api/credentials", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, value }),
});
}
await fetch("/api/inbox/reconnect", { method: "POST" });
showFlash("Saved & reconnecting ✓");
setTimeout(loadInboxStatus, 2000);
});
}
async function loadInboxTriggers() {
const tbody = document.getElementById("inbox-trigger-list");
if (!tbody) return;
const r = await fetch("/api/inbox-triggers");
const triggers = await r.json();
tbody.innerHTML = "";
if (!triggers.length) {
tbody.innerHTML = "<tr><td colspan='5' style='text-align:center;color:var(--text-dim)'>No trigger rules yet</td></tr>";
return;
}
for (const t of triggers) {
const tr = document.createElement("tr");
const statusLabel = t.enabled
? "<span style='color:var(--green);font-size:12px'>Enabled</span>"
: "<span style='color:var(--text-dim);font-size:12px'>Disabled</span>";
tr.innerHTML = `
<td><code>${esc(t.trigger_word)}</code></td>
<td>${esc(t.agent_name || t.agent_id)}</td>
<td style="color:var(--text-dim)">${esc(t.description || "—")}</td>
<td>${statusLabel}</td>
<td></td>
`;
const tdAct = tr.querySelector("td:last-child");
const editBtn = document.createElement("button");
editBtn.className = "btn btn-ghost";
editBtn.style.cssText = "padding:4px 10px;font-size:12px;margin-right:6px";
editBtn.textContent = "Edit";
editBtn.addEventListener("click", () => openTriggerModal(t));
const toggleBtn = document.createElement("button");
toggleBtn.className = "btn btn-ghost";
toggleBtn.style.cssText = "padding:4px 10px;font-size:12px;margin-right:6px";
toggleBtn.textContent = t.enabled ? "Disable" : "Enable";
toggleBtn.addEventListener("click", async () => {
await fetch(`/api/inbox-triggers/${t.id}/toggle`, { method: "POST" });
loadInboxTriggers();
});
const delBtn = document.createElement("button");
delBtn.className = "btn btn-danger";
delBtn.style.cssText = "padding:4px 10px;font-size:12px";
delBtn.textContent = "Delete";
delBtn.addEventListener("click", async () => {
if (!confirm(`Delete trigger rule "${t.trigger_word}"?`)) return;
await fetch(`/api/inbox-triggers/${t.id}`, { method: "DELETE" });
loadInboxTriggers();
});
tdAct.appendChild(editBtn);
tdAct.appendChild(toggleBtn);
tdAct.appendChild(delBtn);
tbody.appendChild(tr);
}
}
async function openTriggerModal(trigger = null) {
const modal = document.getElementById("trigger-modal");
const title = document.getElementById("trigger-modal-title");
const idInput = document.getElementById("trigger-modal-id");
const wordEl = document.getElementById("trigger-modal-word");
const descEl = document.getElementById("trigger-modal-desc");
const enEl = document.getElementById("trigger-modal-enabled");
const agentSel = document.getElementById("trigger-modal-agent");
title.textContent = trigger ? "Edit Trigger Rule" : "Add Trigger Rule";
idInput.value = trigger ? trigger.id : "";
wordEl.value = trigger ? trigger.trigger_word : "";
descEl.value = trigger ? (trigger.description || "") : "";
enEl.checked = trigger ? !!trigger.enabled : true;
// Populate agent dropdown
agentSel.innerHTML = "<option value=''>Loading…</option>";
try {
const r = await fetch("/api/agents");
const agents = await r.json();
agentSel.innerHTML = agents.length
? agents.map(a => `<option value="${esc(a.id)}">${esc(a.name)}</option>`).join("")
: "<option value=''>No agents defined</option>";
if (trigger) agentSel.value = trigger.agent_id;
} catch {
agentSel.innerHTML = "<option value=''>Error loading agents</option>";
}
modal.classList.remove("hidden");
}
function closeTriggerModal() {
document.getElementById("trigger-modal").classList.add("hidden");
}
async function saveTrigger() {
const id = document.getElementById("trigger-modal-id").value;
const word = document.getElementById("trigger-modal-word").value.trim();
const agentId = document.getElementById("trigger-modal-agent").value;
const desc = document.getElementById("trigger-modal-desc").value.trim();
const enabled = document.getElementById("trigger-modal-enabled").checked;
if (!word) { alert("Please enter a trigger word."); return; }
if (!agentId) { alert("Please select an agent."); return; }
const body = { trigger_word: word, agent_id: agentId, description: desc, enabled };
const url = id ? `/api/inbox-triggers/${id}` : "/api/inbox-triggers";
const method = id ? "PUT" : "POST";
const r = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (r.ok) {
closeTriggerModal();
showFlash("Saved ✓");
loadInboxTriggers();
} else {
const d = await r.json().catch(() => ({}));
alert(d.detail || "Error saving trigger rule");
}
}
/* ── end inbox ─────────────────────────────────────────────────────────── */
/* ── Telegram ──────────────────────────────────────────────────────────── */
async function loadTelegramStatus() {
const badge = document.getElementById("telegram-status-badge");
const btn = document.getElementById("telegram-action-btn");
if (!badge) return;
const r = await fetch("/api/telegram/status");
if (!r.ok) return;
const s = await r.json();
if (!s.configured) {
badge.textContent = "Not configured";
badge.style.background = "var(--bg2)"; badge.style.color = "var(--text-dim)";
if (btn) { btn.textContent = "Reconnect"; btn.onclick = reconnectTelegram; }
} else if (s.running) {
badge.textContent = "Connected";
badge.style.background = "rgba(72,199,142,0.15)"; badge.style.color = "var(--green)";
if (btn) { btn.textContent = "Disconnect"; btn.onclick = disconnectTelegram; }
} else {
badge.textContent = "Offline";
badge.style.background = "rgba(255,100,100,0.12)"; badge.style.color = "#ff6b6b";
if (btn) { btn.textContent = "Reconnect"; btn.onclick = reconnectTelegram; }
}
}
async function reconnectTelegram() {
await fetch("/api/telegram/reconnect", { method: "POST" });
setTimeout(loadTelegramStatus, 2000);
}
async function disconnectTelegram() {
await fetch("/api/telegram/disconnect", { method: "POST" });
setTimeout(loadTelegramStatus, 500);
}
function initTelegramConfigForm() {
const form = document.getElementById("telegram-config-form");
if (!form) return;
form.addEventListener("submit", async e => {
e.preventDefault();
const token = document.getElementById("telegram-bot-token").value.trim();
if (token) {
await fetch("/api/credentials", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key: "telegram:bot_token", value: token, description: "Telegram Bot API token" }),
});
}
await fetch("/api/telegram/reconnect", { method: "POST" });
document.getElementById("telegram-bot-token").value = "";
showFlash("Saved & reconnecting…");
setTimeout(loadTelegramStatus, 2000);
});
}
async function loadTelegramWhitelist() {
const tbody = document.getElementById("telegram-whitelist-list");
if (!tbody) return;
const r = await fetch("/api/telegram-whitelist");
const rows = await r.json();
tbody.innerHTML = "";
if (!rows.length) {
tbody.innerHTML = "<tr><td colspan='3' style='text-align:center;color:var(--text-dim)'>No chat IDs whitelisted</td></tr>";
return;
}
for (const row of rows) {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><code>${esc(row.chat_id)}</code></td>
<td>${esc(row.label || "")}</td>
<td><button class="btn btn-danger" style="padding:4px 10px;font-size:12px"
onclick="removeTelegramWhitelist('${esc(row.chat_id)}')">Remove</button></td>
`;
tbody.appendChild(tr);
}
}
async function removeTelegramWhitelist(chatId) {
if (!confirm(`Remove chat ID ${chatId} from whitelist?`)) return;
await fetch(`/api/telegram-whitelist/${encodeURIComponent(chatId)}`, { method: "DELETE" });
loadTelegramWhitelist();
}
async function loadTelegramTriggers() {
const tbody = document.getElementById("telegram-trigger-list");
if (!tbody) return;
const r = await fetch("/api/telegram-triggers");
const rows = await r.json();
tbody.innerHTML = "";
if (!rows.length) {
tbody.innerHTML = "<tr><td colspan='5' style='text-align:center;color:var(--text-dim)'>No trigger rules</td></tr>";
return;
}
for (const t of rows) {
const tr = document.createElement("tr");
const statusLabel = t.enabled ? "Enabled" : "Disabled";
const statusColor = t.enabled ? "var(--green)" : "var(--text-dim)";
const editBtn = document.createElement("button");
const toggleBtn = document.createElement("button");
const deleteBtn = document.createElement("button");
editBtn.className = "btn btn-ghost"; editBtn.style.cssText = "padding:4px 10px;font-size:12px;margin-right:4px";
toggleBtn.className = "btn btn-ghost"; toggleBtn.style.cssText = "padding:4px 10px;font-size:12px;margin-right:4px";
deleteBtn.className = "btn btn-danger"; deleteBtn.style.cssText = "padding:4px 10px;font-size:12px";
editBtn.textContent = "Edit";
toggleBtn.textContent = t.enabled ? "Disable" : "Enable";
deleteBtn.textContent = "Delete";
editBtn.addEventListener("click", () => openTgTriggerModal(t));
toggleBtn.addEventListener("click", async () => {
await fetch(`/api/telegram-triggers/${t.id}/toggle`, { method: "POST" });
loadTelegramTriggers();
});
deleteBtn.addEventListener("click", async () => {
if (!confirm("Delete this trigger rule?")) return;
await fetch(`/api/telegram-triggers/${t.id}`, { method: "DELETE" });
loadTelegramTriggers();
});
tr.innerHTML = `
<td><code>${esc(t.trigger_word)}</code></td>
<td>${esc(t.agent_name || t.agent_id)}</td>
<td style="color:var(--text-dim)">${esc(t.description || "")}</td>
<td style="color:${statusColor}">${statusLabel}</td>
<td></td>
`;
const actionCell = tr.querySelector("td:last-child");
actionCell.appendChild(editBtn);
actionCell.appendChild(toggleBtn);
actionCell.appendChild(deleteBtn);
tbody.appendChild(tr);
}
}
async function openTgTriggerModal(trigger = null) {
const r = await fetch("/api/agents");
const agents = await r.json();
const sel = document.getElementById("tg-trigger-modal-agent");
sel.innerHTML = '<option value="">— select agent —</option>';
for (const a of agents) {
const opt = document.createElement("option");
opt.value = a.id; opt.textContent = a.name;
if (trigger && trigger.agent_id === a.id) opt.selected = true;
sel.appendChild(opt);
}
document.getElementById("tg-trigger-modal-id").value = trigger ? trigger.id : "";
document.getElementById("tg-trigger-modal-word").value = trigger ? trigger.trigger_word : "";
document.getElementById("tg-trigger-modal-desc").value = trigger ? trigger.description : "";
document.getElementById("tg-trigger-modal-enabled").checked = trigger ? !!trigger.enabled : true;
document.getElementById("tg-trigger-modal-title").textContent = trigger ? "Edit Telegram Trigger" : "Add Telegram Trigger";
document.getElementById("tg-trigger-modal").classList.remove("hidden");
}
function closeTgTriggerModal() {
document.getElementById("tg-trigger-modal").classList.add("hidden");
}
async function saveTgTrigger() {
const id = document.getElementById("tg-trigger-modal-id").value;
const word = document.getElementById("tg-trigger-modal-word").value.trim();
const agentId = document.getElementById("tg-trigger-modal-agent").value;
const desc = document.getElementById("tg-trigger-modal-desc").value.trim();
const enabled = document.getElementById("tg-trigger-modal-enabled").checked;
if (!word) { alert("Please enter a trigger word."); return; }
if (!agentId) { alert("Please select an agent."); return; }
const body = { trigger_word: word, agent_id: agentId, description: desc, enabled };
const url = id ? `/api/telegram-triggers/${id}` : "/api/telegram-triggers";
const method = id ? "PUT" : "POST";
const r = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (r.ok) {
closeTgTriggerModal();
showFlash("Saved ✓");
loadTelegramTriggers();
} else {
const d = await r.json().catch(() => ({}));
alert(d.detail || "Error saving trigger rule");
}
}
async function loadTgDefaultAgent() {
const sel = document.getElementById("tg-default-agent");
if (!sel) return;
const [agentsR, defaultR] = await Promise.all([
fetch("/api/agents"),
fetch("/api/telegram/default-agent"),
]);
const agents = await agentsR.json();
const { agent_id: current } = await defaultR.json();
sel.innerHTML = '<option value="">— none (drop unmatched messages) —</option>';
for (const a of agents) {
const opt = document.createElement("option");
opt.value = a.id;
opt.textContent = a.name;
if (a.id === current) opt.selected = true;
sel.appendChild(opt);
}
}
async function saveTgDefaultAgent() {
const agent_id = document.getElementById("tg-default-agent").value;
const r = await fetch("/api/telegram/default-agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_id }),
});
if (r.ok) showFlash(agent_id ? "Default agent saved ✓" : "Default agent cleared ✓");
else showFlash("Error saving default agent");
}
/* ── end telegram ──────────────────────────────────────────────────────── */
/* ── System prompt (SOUL.md / USER.md) ─────────────────────────────────── */
async function loadSystemPrompts() {
const [soulR, userR] = await Promise.all([
fetch("/api/system-prompt/soul"),
fetch("/api/system-prompt/user"),
]);
const { content: soul } = await soulR.json();
const { content: user } = await userR.json();
const soulEl = document.getElementById("sp-soul");
const userEl = document.getElementById("sp-user");
if (soulEl) soulEl.value = soul;
if (userEl) userEl.value = user;
}
async function saveSystemPrompt(key) {
const el = document.getElementById(`sp-${key}`);
if (!el) return;
const r = await fetch(`/api/system-prompt/${key}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: el.value }),
});
if (r.ok) showFlash("Saved ✓");
else showFlash("Error saving");
}
/* ── end system prompt ──────────────────────────────────────────────────── */
async function loadEmailWhitelist() {
const r = await fetch("/api/email-whitelist");
const entries = await r.json();
const tbody = document.getElementById("email-whitelist-list");
if (!tbody) return;
tbody.innerHTML = "";
if (!entries.length) {
tbody.innerHTML = "<tr><td colspan='3' style='text-align:center;color:var(--text-dim)'>No addresses whitelisted</td></tr>";
return;
}
for (const e of entries) {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><code>${esc(e.email)}</code></td>
<td>${e.daily_limit === 0 ? "Unlimited" : esc(String(e.daily_limit))}</td>
<td><button class="btn btn-danger" style="padding:4px 10px;font-size:12px"
onclick="deleteEmail('${esc(e.email)}')">Remove</button></td>
`;
tbody.appendChild(tr);
}
}
async function addEmail() {
const email = document.getElementById("ew-email").value.trim();
const limit = parseInt(document.getElementById("ew-limit").value || "0", 10);
if (!email) return;
const r = await fetch("/api/email-whitelist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, daily_limit: isNaN(limit) ? 0 : limit }),
});
if (r.ok) {
document.getElementById("ew-email").value = "";
document.getElementById("ew-limit").value = "0";
showFlash("Added ✓");
await loadEmailWhitelist();
} else {
const d = await r.json();
alert(d.detail || "Error adding email");
}
}
async function deleteEmail(email) {
if (!confirm(`Remove "${email}" from whitelist?`)) return;
const r = await fetch(`/api/email-whitelist/${encodeURIComponent(email)}`, { method: "DELETE" });
if (r.ok) {
showFlash("Removed ✓");
await loadEmailWhitelist();
} else {
const d = await r.json();
alert(d.detail || "Error");
}
}
/* ── Filesystem whitelist ─────────────────────────────────────────────────── */
async function loadFilesystemWhitelist() {
const r = await fetch("/api/filesystem-whitelist");
const entries = await r.json();
const tbody = document.getElementById("fs-whitelist-list");
if (!tbody) return;
tbody.innerHTML = "";
if (!entries.length) {
tbody.innerHTML = "<tr><td colspan='3' style='text-align:center;color:var(--text-dim)'>No directories configured</td></tr>";
return;
}
for (const e of entries) {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><code style="font-size:12px">${esc(e.path)}</code></td>
<td style="color:var(--text-dim);font-size:12px">${esc(e.note || "")}</td>
<td><button class="btn btn-danger" style="padding:4px 10px;font-size:12px"
onclick="deleteFilesystemPath('${esc(e.path)}')">Remove</button></td>
`;
tbody.appendChild(tr);
}
}
async function addFilesystemPath() {
const path = document.getElementById("fs-path").value.trim();
const note = document.getElementById("fs-note").value.trim();
if (!path) return;
const r = await fetch("/api/filesystem-whitelist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path, note }),
});
if (r.ok) {
document.getElementById("fs-path").value = "";
document.getElementById("fs-note").value = "";
showFlash("Added ✓");
await loadFilesystemWhitelist();
} else {
const d = await r.json();
alert(d.detail || "Error adding path");
}
}
async function deleteFilesystemPath(path) {
if (!confirm(`Remove "${path}" from filesystem sandbox?`)) return;
const r = await fetch(`/api/filesystem-whitelist/${encodeURIComponent(path)}`, { method: "DELETE" });
if (r.ok) {
showFlash("Removed ✓");
await loadFilesystemWhitelist();
} else {
const d = await r.json();
alert(d.detail || "Error");
}
}
/* ── Filesystem browser modal ─────────────────────────────────────────────── */
let _fsBrowserPath = "/";
async function openFsBrowser() {
document.getElementById("fs-browser-modal").classList.remove("hidden");
await navigateFsBrowser(_fsBrowserPath);
}
function closeFsBrowser() {
document.getElementById("fs-browser-modal").classList.add("hidden");
}
async function navigateFsBrowser(path) {
_fsBrowserPath = path;
document.getElementById("fs-breadcrumb").textContent = path;
const list = document.getElementById("fs-browser-list");
list.innerHTML = `<div style="padding:12px;color:var(--text-dim);font-size:13px">Loading…</div>`;
const r = await fetch(`/api/filesystem-browser?path=${encodeURIComponent(path)}`);
if (!r.ok) {
list.innerHTML = `<div style="padding:12px;color:var(--text-dim);font-size:13px">Cannot read directory</div>`;
return;
}
const data = await r.json();
list.innerHTML = "";
// Up button
if (data.parent) {
const up = document.createElement("div");
up.style.cssText = "padding:8px 14px;cursor:pointer;font-size:13px;border-bottom:1px solid var(--border);color:var(--text-dim)";
up.textContent = "↑ ..";
up.onclick = () => navigateFsBrowser(data.parent);
up.onmouseenter = () => up.style.background = "var(--bg2)";
up.onmouseleave = () => up.style.background = "";
list.appendChild(up);
}
if (!data.entries.length) {
const empty = document.createElement("div");
empty.style.cssText = "padding:12px 14px;font-size:13px;color:var(--text-dim)";
empty.textContent = "No subdirectories";
list.appendChild(empty);
return;
}
for (const entry of data.entries) {
const row = document.createElement("div");
row.style.cssText = "padding:8px 14px;cursor:pointer;font-size:13px;display:flex;align-items:center;gap:8px";
row.innerHTML = `<span style="color:var(--text-dim)">📁</span><span>${esc(entry.name)}</span>`;
row.onclick = () => navigateFsBrowser(entry.path);
row.onmouseenter = () => row.style.background = "var(--bg2)";
row.onmouseleave = () => row.style.background = "";
list.appendChild(row);
}
}
function selectFsPath() {
document.getElementById("fs-path").value = _fsBrowserPath;
closeFsBrowser();
}
async function loadWebWhitelist() {
const r = await fetch("/api/web-whitelist");
const entries = await r.json();
const tbody = document.getElementById("web-whitelist-list");
if (!tbody) return;
tbody.innerHTML = "";
if (!entries.length) {
tbody.innerHTML = "<tr><td colspan='3' style='text-align:center;color:var(--text-dim)'>No domains whitelisted</td></tr>";
return;
}
for (const e of entries) {
const tr = document.createElement("tr");
tr.innerHTML = `
<td><code>${esc(e.domain)}</code></td>
<td style="color:var(--text-dim);font-size:12px">${esc(e.note || "")}</td>
<td><button class="btn btn-danger" style="padding:4px 10px;font-size:12px"
onclick="deleteWebDomain('${esc(e.domain)}')">Remove</button></td>
`;
tbody.appendChild(tr);
}
}
async function addWebDomain() {
const domain = document.getElementById("ww-domain").value.trim();
const note = document.getElementById("ww-note").value.trim();
if (!domain) return;
const r = await fetch("/api/web-whitelist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain, note }),
});
if (r.ok) {
document.getElementById("ww-domain").value = "";
document.getElementById("ww-note").value = "";
showFlash("Added ✓");
await loadWebWhitelist();
} else {
const d = await r.json();
alert(d.detail || "Error adding domain");
}
}
async function deleteWebDomain(domain) {
if (!confirm(`Remove "${domain}" from whitelist?`)) return;
const r = await fetch(`/api/web-whitelist/${encodeURIComponent(domain)}`, { method: "DELETE" });
if (r.ok) {
showFlash("Removed ✓");
await loadWebWhitelist();
} else {
const d = await r.json();
alert(d.detail || "Error");
}
}
let _flashTimer = null;
function showFlash(msg) {
const el = document.getElementById("flash-msg");
if (!el) return;
if (_flashTimer) { clearTimeout(_flashTimer); _flashTimer = null; }
el.textContent = msg;
el.style.opacity = "1";
_flashTimer = setTimeout(() => { el.style.opacity = "0"; _flashTimer = null; }, 3000);
}
/* ══════════════════════════════════════════════════════════════════════════
TASKS PAGE
══════════════════════════════════════════════════════════════════════════ */
let _availableModels = []; // populated from /api/models on pages that need it
/* ── Cron preview ────────────────────────────────────────────────────────── */
function cronLabel(expr) {
if (!expr) return "";
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return "";
const [min, hr, dom, mon, dow] = parts;
const days = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
const pad = n => String(n).padStart(2, "0");
const isNum = s => /^\d+$/.test(s);
// Interval patterns — must come before the "daily" catch-all
if (min.startsWith("*/") && hr === "*" && dom === "*" && mon === "*" && dow === "*")
return `Every ${min.slice(2)} min`;
if (hr.startsWith("*/") && isNum(min) && dom === "*" && mon === "*" && dow === "*")
return `Every ${hr.slice(2)} hr`;
// Fixed-time patterns — only when hr and min are plain numbers
if (dom === "*" && mon === "*" && dow === "*" && isNum(hr) && isNum(min))
return `Daily at ${pad(hr)}:${pad(min)}`;
if (dom === "*" && mon === "*" && /^\d$/.test(dow) && isNum(hr) && isNum(min))
return `${days[+dow]} at ${pad(hr)}:${pad(min)}`;
return "";
}
/* ══════════════════════════════════════════════════════════════════════════
AGENTS PAGE
══════════════════════════════════════════════════════════════════════════ */
function initAgents() {
if (!document.getElementById("agents-container")) return;
_loadAvailableModels().then(() => {
loadAgents();
loadAgentRuns();
});
}
async function _loadAvailableModels() {
try {
const r = await fetch("/api/models");
const data = await r.json();
_availableModels = data.models || [];
} catch (e) {
_availableModels = [];
}
}
function switchTab(tab) {
document.getElementById("pane-agents").style.display = tab === "agents" ? "" : "none";
document.getElementById("pane-status").style.display = tab === "status" ? "" : "none";
document.getElementById("tab-agents").classList.toggle("active", tab === "agents");
document.getElementById("tab-status").classList.toggle("active", tab === "status");
}
async function loadAgents() {
const r = await fetch("/api/agents");
const agents = await r.json();
const tbody = document.querySelector("#agents-table tbody");
tbody.innerHTML = "";
if (!agents.length) {
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center;color:var(--text-dim)">
No agents yet — click <strong>+ New Agent</strong> to create one.</td></tr>`;
return;
}
for (const a of agents) {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>
<a href="/agents/${a.id}" style="color:var(--accent);text-decoration:none"
onclick="event.preventDefault();navigateTo('/agents/${a.id}')">${esc(a.name)}</a>
${a.description ? `<div style="font-size:11px;color:var(--text-dim)">${esc(a.description)}</div>` : ""}
${a.parent_agent_id ? `<div style="font-size:10px;color:var(--text-dim)">sub-agent</div>` : ""}
</td>
<td><code style="font-size:11px">${esc(_parseModel(a.model).label || a.model)}</code>
${_parseModel(a.model).provider ? `<div style="font-size:10px;color:var(--text-dim)">${esc(_parseModel(a.model).provider)}</div>` : ""}
</td>
<td><code>${esc(a.schedule || "—")}</code>
<div style="font-size:11px;color:var(--text-dim)">${cronLabel(a.schedule)}</div>
</td>
<td style="text-align:center">${a.can_create_subagents ? "✓" : "—"}</td>
<td>
<button class="btn btn-ghost" style="padding:3px 10px;font-size:12px"
onclick="toggleAgent('${a.id}', ${a.enabled})">
${a.enabled ? "Enabled" : "Disabled"}
</button>
</td>
<td style="font-size:12px;color:var(--text-dim)">${a.last_run_at ? formatDateShort(a.last_run_at) : "—"}</td>
<td>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button class="btn btn-ghost" style="padding:3px 10px;font-size:12px"
onclick="editAgent('${a.id}')">Edit</button>
<button class="btn btn-primary" style="padding:3px 10px;font-size:12px"
onclick="runAgent('${a.id}')">▶ Run</button>
<button class="btn btn-danger" style="padding:3px 10px;font-size:12px"
onclick="deleteAgent('${a.id}', '${esc(a.name)}')">Delete</button>
</div>
</td>
`;
tbody.appendChild(tr);
}
}
async function loadAgentRuns() {
const since = document.getElementById("status-since")?.value || "7d";
const r = await fetch(`/api/agent-runs?since=${since}`);
const runs = await r.json();
const tbody = document.querySelector("#runs-table tbody");
tbody.innerHTML = "";
if (!runs.length) {
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center;color:var(--text-dim)">No runs in this period</td></tr>`;
document.getElementById("runs-totals").textContent = "";
return;
}
let totalIn = 0, totalOut = 0;
for (const run of runs) {
totalIn += run.input_tokens || 0;
totalOut += run.output_tokens || 0;
const started = run.started_at ? new Date(run.started_at) : null;
const ended = run.ended_at ? new Date(run.ended_at) : null;
const dur = (started && ended)
? Math.round((ended - started) / 1000) + "s"
: run.status === "running" ? "running…" : "—";
const statusClass = run.status === "success" ? "badge-green"
: run.status === "error" ? "badge-red"
: run.status === "stopped" ? "badge-red"
: run.status === "running" ? "badge-blue" : "";
const tr = document.createElement("tr");
tr.innerHTML = `
<td>
<a href="/agents/${run.agent_id}" style="color:var(--accent);text-decoration:none"
onclick="event.preventDefault();navigateTo('/agents/${run.agent_id}')">${esc(run.agent_name || run.agent_id.slice(0,8))}</a>
</td>
<td style="font-size:12px">${formatDate(run.started_at)}</td>
<td style="font-size:12px">${esc(dur)}</td>
<td>${statusClass ? `<span class="badge ${statusClass}">${esc(run.status)}</span>` : esc(run.status)}</td>
<td style="font-size:12px;text-align:right">${(run.input_tokens || 0).toLocaleString()}</td>
<td style="font-size:12px;text-align:right">${(run.output_tokens || 0).toLocaleString()}</td>
<td>
${run.status === "running"
? `<button class="btn btn-danger" style="padding:3px 10px;font-size:12px"
onclick="stopRun('${run.id}')">■ Stop</button>`
: "—"}
</td>
`;
tbody.appendChild(tr);
}
document.getElementById("runs-totals").textContent =
`Total: ${totalIn.toLocaleString()} input + ${totalOut.toLocaleString()} output tokens`;
}
/* ── Agent modal ─────────────────────────────────────────────────────────── */
function setPromptMode(mode) {
document.getElementById("a-prompt-mode").value = mode;
document.querySelectorAll(".pm-btn").forEach(b => b.classList.toggle("active", b.dataset.mode === mode));
const hints = {
combined: "Agent prompt takes priority, followed by the standard system prompt.",
system_only: "Standard system prompt only — agent prompt used as the task message.",
agent_only: "Agent prompt replaces the standard system prompt entirely.",
};
document.getElementById("a-pm-hint").textContent = hints[mode] || "";
}
async function showAgentModal(agent, template = null) {
const prefill = template || agent;
document.getElementById("agent-modal-title").textContent = template ? `New Agent from Template` : (agent ? "Edit Agent" : "New Agent");
document.getElementById("a-id").value = agent ? agent.id : "";
document.getElementById("a-name").value = prefill ? (prefill.name || "") : "";
document.getElementById("a-desc").value = prefill ? (prefill.description || "") : "";
document.getElementById("a-prompt").value = prefill ? (prefill.prompt || "") : "";
document.getElementById("a-schedule").value = prefill ? (prefill.schedule || prefill.suggested_schedule || "") : "";
document.getElementById("a-subagents").checked = agent ? !!agent.can_create_subagents : false;
document.getElementById("a-enabled").checked = agent ? !!agent.enabled : true;
setPromptMode(prefill?.prompt_mode || "combined");
const mtcEl = document.getElementById("a-max-tool-calls");
if (mtcEl) mtcEl.value = (agent && agent.max_tool_calls) ? agent.max_tool_calls : "";
updateAgentCronPreview(document.getElementById("a-schedule").value);
updateMtcHint();
const modelSel = document.getElementById("a-model");
if (modelSel) {
const defaultModel = prefill?.model || _availableModels[0] || "";
_fillModelSelect(modelSel, _availableModels, defaultModel);
}
// Load tool list dynamically
// allowed_tools may be a parsed array (from API) or a JSON string (from templates)
const _raw = prefill ? (prefill.allowed_tools ?? prefill.suggested_tools ?? []) : [];
const agentTools = Array.isArray(_raw) ? _raw : (typeof _raw === "string" ? JSON.parse(_raw) : []);
await _renderToolCheckboxes(agentTools);
document.getElementById("agent-modal").classList.remove("hidden");
}
async function _renderToolCheckboxes(checkedTools = []) {
const container = document.getElementById("agent-tool-list");
if (!container) return;
container.innerHTML = `<span style="color:var(--text-dim);font-size:12px">Loading…</span>`;
let tools = [];
try {
const r = await fetch("/api/tools");
tools = await r.json();
} catch { /* fallback to empty */ }
if (tools.length === 0) {
container.innerHTML = `<span style="color:var(--text-dim);font-size:12px">No tools available</span>`;
return;
}
// Group: native tools first, then MCP servers (one checkbox per server)
const native = tools.filter(t => !t.is_mcp);
const mcp = tools.filter(t => t.is_mcp);
// Collapse MCP tools into per-server entries
const mcpServers = {};
for (const t of mcp) {
const serverSlug = t.name.split("__")[1];
if (!mcpServers[serverSlug]) mcpServers[serverSlug] = { displayName: t.server_display_name || serverSlug, tools: [] };
mcpServers[serverSlug].tools.push(t.name);
}
container.innerHTML = "";
// Native tools
for (const t of native) {
const lbl = document.createElement("label");
lbl.style.cssText = "display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.className = "agent-tool-cb";
cb.value = t.name;
cb.checked = checkedTools.includes(t.name);
lbl.appendChild(cb);
lbl.appendChild(document.createTextNode(t.name));
container.appendChild(lbl);
}
// MCP servers — one checkbox per server
if (Object.keys(mcpServers).length) {
const sep = document.createElement("div");
sep.style.cssText = "width:100%;font-size:10px;color:var(--accent);text-transform:uppercase;letter-spacing:.05em;padding-top:4px";
sep.textContent = "MCP Servers";
container.appendChild(sep);
for (const [serverSlug, { displayName, tools: toolNames }] of Object.entries(mcpServers)) {
const serverKey = `mcp__${serverSlug}`;
const isChecked = checkedTools.includes(serverKey) ||
toolNames.some(n => checkedTools.includes(n));
const lbl = document.createElement("label");
lbl.style.cssText = "display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13px";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.className = "agent-tool-cb";
cb.value = serverKey;
cb.checked = isChecked;
lbl.appendChild(cb);
lbl.appendChild(document.createTextNode(displayName));
container.appendChild(lbl);
}
}
}
function updateMtcHint() {
const el = document.getElementById("a-max-tool-calls");
const hint = document.getElementById("a-mtc-hint");
if (!el || !hint) return;
hint.textContent = el.value ? `(overrides system default)` : `(system default)`;
}
function closeAgentModal() {
document.getElementById("agent-modal").classList.add("hidden");
}
async function editAgent(id) {
const r = await fetch(`/api/agents/${id}`);
const agent = await r.json();
showAgentModal(agent);
}
async function saveAgent() {
const id = document.getElementById("a-id").value;
const mtcRaw = document.getElementById("a-max-tool-calls")?.value;
const mtcVal = mtcRaw ? parseInt(mtcRaw, 10) : null;
const body = {
name: document.getElementById("a-name").value.trim(),
description: document.getElementById("a-desc").value.trim(),
prompt: document.getElementById("a-prompt").value.trim(),
model: document.getElementById("a-model").value,
schedule: document.getElementById("a-schedule").value.trim() || null,
can_create_subagents: document.getElementById("a-subagents").checked,
enabled: document.getElementById("a-enabled").checked,
max_tool_calls: (mtcVal && mtcVal > 0) ? mtcVal : null,
prompt_mode: document.getElementById("a-prompt-mode").value || "combined",
allowed_tools: [...document.querySelectorAll(".agent-tool-cb:checked")].map(cb => cb.value),
};
const promptRequired = body.prompt_mode !== "system_only";
if (!body.name || !body.model || (promptRequired && !body.prompt)) {
alert(promptRequired ? "Name, prompt, and model are required." : "Name and model are required.");
return;
}
const url = id ? `/api/agents/${id}` : "/api/agents";
const method = id ? "PUT" : "POST";
const r = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!r.ok) {
const d = await r.json();
alert("Error: " + (d.detail || r.statusText));
return;
}
closeAgentModal();
loadAgents();
}
async function toggleAgent(id, currentlyEnabled) {
await fetch(`/api/agents/${id}/toggle`, { method: "POST" });
loadAgents();
}
async function deleteAgent(id, name) {
if (!confirm(`Delete agent "${name}"?`)) return;
await fetch(`/api/agents/${id}`, { method: "DELETE" });
loadAgents();
}
async function runAgent(id) {
document.getElementById("agent-run-output").textContent = "Starting agent run…";
document.getElementById("agent-run-modal").classList.remove("hidden");
const r = await fetch(`/api/agents/${id}/run`, { method: "POST" });
const data = await r.json();
document.getElementById("agent-run-output").textContent =
data.run ? `Run started: ${data.run.id}` : JSON.stringify(data, null, 2);
loadAgents();
loadAgentRuns();
}
function closeAgentRunModal() {
document.getElementById("agent-run-modal").classList.add("hidden");
}
async function showTemplatesModal() {
document.getElementById("templates-modal").classList.remove("hidden");
const container = document.getElementById("templates-list");
container.innerHTML = `<div style="color:var(--text-dim);font-size:13px">Loading…</div>`;
const r = await fetch("/api/agent-templates");
const templates = await r.json();
const categoryColors = { productivity: "#6c8ef5", brain: "#b07aee", utility: "#4caf7d" };
container.innerHTML = "";
for (const t of templates) {
const color = categoryColors[t.category] || "var(--accent)";
const toolBadges = (t.suggested_tools || [])
.map(tool => `<span class="badge" style="background:rgba(108,142,245,0.12);color:var(--accent);font-size:10px">${tool}</span>`)
.join(" ");
const card = document.createElement("div");
card.style.cssText = "background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:16px;display:flex;flex-direction:column;gap:8px;cursor:pointer";
card.innerHTML = `
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px">
<div style="font-weight:600;font-size:14px">${esc(t.name)}</div>
<span style="font-size:10px;background:rgba(0,0,0,.25);color:${color};padding:2px 7px;border-radius:10px;white-space:nowrap">${esc(t.category)}</span>
</div>
<div style="font-size:12px;color:var(--text-dim);line-height:1.5">${esc(t.description)}</div>
<div style="display:flex;flex-wrap:wrap;gap:4px">${toolBadges}</div>
${t.suggested_schedule ? `<div style="font-size:11px;color:var(--text-dim)">Schedule: <code>${t.suggested_schedule}</code></div>` : ""}
<button class="btn btn-primary" style="margin-top:4px;font-size:12px" onclick="installTemplate('${t.id}')">Use this template</button>`;
container.appendChild(card);
}
}
function closeTemplatesModal() {
document.getElementById("templates-modal").classList.add("hidden");
}
async function installTemplate(id) {
const r = await fetch(`/api/agent-templates/${id}`);
const template = await r.json();
closeTemplatesModal();
await showAgentModal(null, template);
}
async function stopRun(runId) {
await fetch(`/api/agent-runs/${runId}/stop`, { method: "POST" });
loadAgentRuns();
}
function updateAgentCronPreview(val) {
const el = document.getElementById("a-cron-preview");
if (el) el.textContent = cronLabel(val);
}
async function editCurrentAgent() {
const agentId = window.AGENT_ID;
if (!agentId) return;
const r = await fetch(`/api/agents/${agentId}`);
const agent = await r.json();
showAgentModal(agent);
}
async function runCurrentAgent() {
const agentId = window.AGENT_ID;
if (!agentId) return;
await fetch(`/api/agents/${agentId}/run`, { method: "POST" });
loadAgentDetail();
}
async function stopAgentRun(runId) {
const r = await fetch(`/api/agent-runs/${runId}/stop`, { method: "POST" });
if (r.ok) loadAgentDetail();
else alert("Could not stop run — it may have already finished.");
}
async function saveAgentAndReload() {
await saveAgent();
loadAgentDetail();
}
/* ══════════════════════════════════════════════════════════════════════════
AGENT DETAIL PAGE
══════════════════════════════════════════════════════════════════════════ */
function initAgentDetail() {
if (!document.getElementById("agent-detail-container")) return;
_loadAvailableModels().then(() => loadAgentDetail());
}
async function loadAgentDetail() {
const agentId = window.AGENT_ID;
if (!agentId) return;
const [agentR, runsR] = await Promise.all([
fetch(`/api/agents/${agentId}`),
fetch(`/api/agents/${agentId}/runs`),
]);
const agent = await agentR.json();
const runs = await runsR.json();
// Fill metadata
document.getElementById("d-name").textContent = agent.name;
document.getElementById("d-model").textContent = _parseModel(agent.model).label || agent.model;
document.getElementById("d-schedule").textContent = agent.schedule || "Manual only";
document.getElementById("d-subagents").textContent = agent.can_create_subagents ? "Yes" : "No";
document.getElementById("d-created-by").textContent = agent.created_by;
document.getElementById("d-created-at").textContent = formatDate(agent.created_at);
document.getElementById("d-prompt").textContent = agent.prompt;
document.getElementById("d-description").textContent = agent.description || "—";
// Token totals
const totalIn = runs.reduce((s, r) => s + (r.input_tokens || 0), 0);
const totalOut = runs.reduce((s, r) => s + (r.output_tokens || 0), 0);
document.getElementById("d-total-tokens").textContent =
`${totalIn.toLocaleString()} input + ${totalOut.toLocaleString()} output tokens across ${runs.length} run${runs.length !== 1 ? "s" : ""}`;
// Run history
const tbody = document.querySelector("#detail-runs-table tbody");
tbody.innerHTML = "";
if (!runs.length) {
tbody.innerHTML = `<tr><td colspan="7" style="text-align:center;color:var(--text-dim)">No runs yet</td></tr>`;
return;
}
for (const run of runs) {
const started = run.started_at ? new Date(run.started_at) : null;
const ended = run.ended_at ? new Date(run.ended_at) : null;
const dur = (started && ended) ? Math.round((ended - started) / 1000) + "s" : "—";
const statusClass = run.status === "success" ? "badge-green"
: run.status === "error" ? "badge-red"
: run.status === "stopped" ? "badge-red"
: run.status === "running" ? "badge-blue" : "";
const stopBtn = run.status === "running"
? `<button class="btn btn-ghost" style="padding:2px 10px;font-size:12px;color:var(--red)"
onclick="event.stopPropagation();stopAgentRun('${esc(run.id)}')">Stop</button>`
: `<button class="btn btn-ghost" style="padding:2px 10px;font-size:12px"
onclick="event.stopPropagation();showRunDetail('${esc(run.id)}')">Detail</button>`;
const tr = document.createElement("tr");
tr.style.cursor = "pointer";
tr.onclick = () => showRunDetail(run.id);
tr.innerHTML = `
<td><code style="font-size:11px">${esc(run.id.slice(0, 8))}</code></td>
<td style="font-size:12px">${formatDate(run.started_at)}</td>
<td style="font-size:12px">${esc(dur)}</td>
<td>${statusClass ? `<span class="badge ${statusClass}">${esc(run.status)}</span>` : esc(run.status)}</td>
<td style="font-size:12px;text-align:right">${(run.input_tokens || 0).toLocaleString()}</td>
<td style="font-size:12px;text-align:right">${(run.output_tokens || 0).toLocaleString()}</td>
<td>${stopBtn}</td>
`;
tbody.appendChild(tr);
}
}
async function showRunDetail(runId) {
const modal = document.getElementById("run-detail-modal");
if (!modal) return;
document.getElementById("rd-result").textContent = "Loading…";
document.getElementById("rd-error").style.display = "none";
document.getElementById("rd-status-badge").textContent = "";
document.getElementById("rd-meta").textContent = "";
modal.style.display = "flex";
const r = await fetch(`/api/agent-runs/${runId}`);
if (!r.ok) { document.getElementById("rd-result").textContent = "Failed to load run details."; return; }
const run = await r.json();
const statusClass = run.status === "success" ? "badge-green"
: run.status === "error" || run.status === "stopped" ? "badge-red"
: "badge-blue";
const badge = document.getElementById("rd-status-badge");
badge.className = `badge ${statusClass}`;
badge.textContent = run.status;
const started = run.started_at ? new Date(run.started_at) : null;
const ended = run.ended_at ? new Date(run.ended_at) : null;
const dur = (started && ended) ? Math.round((ended - started) / 1000) + "s" : "";
document.getElementById("rd-meta").textContent =
formatDate(run.started_at) + (dur ? ` · ${dur}` : "");
if (run.error) {
const errEl = document.getElementById("rd-error");
errEl.textContent = run.error;
errEl.style.display = "";
}
document.getElementById("rd-result").textContent = run.result || "(no result text)";
const link = document.getElementById("rd-audit-link");
link.href = `/audit?task_id=${encodeURIComponent(runId)}`;
}
function closeRunDetail() {
const modal = document.getElementById("run-detail-modal");
if (modal) modal.style.display = "none";
}
/* ══════════════════════════════════════════════════════════════════════════
MODELS PAGE
══════════════════════════════════════════════════════════════════════════ */
let _allModels = [];
let _modelFilter = "all";
let _modelSort = { key: null, dir: 1 }; // key: "input"|"output"|null, dir: 1=asc -1=desc
function sortModels(key) {
if (_modelSort.key === key) {
_modelSort.dir *= -1;
} else {
_modelSort.key = key;
_modelSort.dir = 1;
}
// Update header indicators
["input", "output"].forEach(k => {
const th = document.getElementById(`th-${k}`);
if (!th) return;
const base = k === "input" ? "Input / 1M" : "Output / 1M";
th.textContent = k === _modelSort.key
? base + (_modelSort.dir === 1 ? " ▲" : " ▼")
: base;
});
filterModels();
}
async function initModels() {
if (!document.getElementById("models-container")) return;
// Build filter chips
const filtersEl = document.getElementById("models-filters");
const filters = [
{ key: "all", label: "All" },
{ key: "image_gen", label: "🎨 Image Gen" },
{ key: "vision", label: "👁 Vision" },
{ key: "tools", label: "🔧 Tools" },
{ key: "online", label: "🌐 Online" },
];
filtersEl.innerHTML = filters.map(f => `
<button class="btn ${f.key === _modelFilter ? "btn-primary" : "btn-ghost"}"
style="font-size:12px;padding:5px 12px"
onclick="setModelFilter('${f.key}')"
id="model-filter-${f.key}">${f.label}</button>
`).join("");
// Fetch model info + access tier in parallel
const tbody = document.querySelector("#models-table tbody");
try {
const [infoResp, accessResp] = await Promise.all([
fetch("/api/models/info"),
fetch("/api/models"),
]);
_allModels = await infoResp.json();
const accessData = await accessResp.json();
_renderAccessNote(document.getElementById("models-access-note"), accessData.access || {});
} catch (e) {
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center;color:var(--red)">Failed to load models: ${esc(String(e))}</td></tr>`;
return;
}
filterModels();
}
function _renderAccessNote(el, access) {
if (!el) return;
const notes = [];
if (access.anthropic_blocked)
notes.push("Anthropic models are unavailable — add your own Anthropic API key in <a href='/settings'>Settings → API Keys</a> to unlock them.");
if (access.openrouter_free_only)
notes.push("Only free OpenRouter models are shown — add your own OpenRouter API key in <a href='/settings'>Settings → API Keys</a> for full access.");
if (notes.length) {
el.innerHTML = `<strong style="color:var(--yellow)">⚠ Model Access Restrictions</strong><br>${notes.join("<br>")}`;
el.style.display = "";
} else {
el.style.display = "none";
}
}
function setModelFilter(key) {
_modelFilter = key;
// Update chip styles
["all", "image_gen", "vision", "tools", "online"].forEach(k => {
const btn = document.getElementById(`model-filter-${k}`);
if (!btn) return;
btn.className = `btn ${k === key ? "btn-primary" : "btn-ghost"}`;
btn.style.cssText = "font-size:12px;padding:5px 12px";
});
filterModels();
}
function filterModels() {
const searchEl = document.getElementById("models-search");
const q = searchEl ? searchEl.value.toLowerCase() : "";
const visible = _allModels.filter(m => {
const matchSearch = !q
|| m.id.toLowerCase().includes(q)
|| (m.name || "").toLowerCase().includes(q)
|| (m.bare_id || "").toLowerCase().includes(q);
const caps = m.capabilities || {};
const matchFilter = _modelFilter === "all"
|| (_modelFilter === "image_gen" && caps.image_gen)
|| (_modelFilter === "vision" && caps.vision)
|| (_modelFilter === "tools" && caps.tools)
|| (_modelFilter === "online" && caps.online);
return matchSearch && matchFilter;
});
// Sort
if (_modelSort.key) {
const priceKey = _modelSort.key === "input" ? "prompt_per_1m" : "completion_per_1m";
visible.sort((a, b) => {
const av = a.pricing?.[priceKey] ?? Infinity;
const bv = b.pricing?.[priceKey] ?? Infinity;
return (av - bv) * _modelSort.dir;
});
}
const countEl = document.getElementById("models-count");
if (countEl) {
countEl.textContent = `Showing ${visible.length} of ${_allModels.length} models`;
}
const tbody = document.querySelector("#models-table tbody");
if (!tbody) return;
if (visible.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center;color:var(--text-dim)">No models match your filter.</td></tr>`;
return;
}
tbody.innerHTML = "";
for (const m of visible) {
const tr = document.createElement("tr");
tr.style.cursor = "pointer";
tr.onclick = () => showModelModal(m.id);
const providerBadge = m.provider === "anthropic"
? `<span class="badge" style="background:rgba(255,160,80,0.15);color:#e8a060;font-size:10px">anthropic</span>`
: `<span class="badge" style="background:rgba(108,142,245,0.12);color:var(--accent);font-size:10px">openrouter</span>`;
const ctx = m.context_length ? _fmtCtx(m.context_length) : "—";
const inp = _fmtPrice(m.pricing?.prompt_per_1m);
const out = _fmtPrice(m.pricing?.completion_per_1m);
const caps = m.capabilities || {};
const capBadges = [
caps.image_gen ? `<span class="badge" style="background:rgba(255,165,0,0.12);color:#e8a030;font-size:10px">🎨 Image Gen</span>` : "",
caps.vision ? `<span class="badge" style="background:rgba(108,142,245,0.12);color:var(--accent);font-size:10px">👁 Vision</span>` : "",
caps.tools ? `<span class="badge" style="background:rgba(160,100,245,0.15);color:#b07aee;font-size:10px">🔧 Tools</span>` : "",
caps.online ? `<span class="badge" style="background:rgba(76,175,125,0.15);color:var(--green);font-size:10px">🌐 Online</span>` : "",
].filter(Boolean).join(" ");
tr.innerHTML = `
<td>
<div style="display:flex;align-items:center;gap:8px">
${providerBadge}
<div>
<div style="font-weight:500">${esc(m.name || m.bare_id)}</div>
<div style="font-size:11px;color:var(--text-dim);font-family:var(--mono)">${esc(m.bare_id)}</div>
</div>
</div>
</td>
<td style="color:var(--text-dim);font-size:12px">${esc(ctx)}</td>
<td style="font-size:12px">${inp}</td>
<td style="font-size:12px">${out}</td>
<td style="white-space:nowrap">${capBadges || '<span style="color:var(--text-dim);font-size:11px">—</span>'}</td>
`;
tbody.appendChild(tr);
}
}
function _fmtCtx(n) {
if (!n) return "—";
if (n >= 1_000_000) return (n / 1_000_000).toFixed(0) + "M";
if (n >= 1000) return Math.round(n / 1000) + "K";
return String(n);
}
function _fmtPrice(v) {
if (v === null || v === undefined) return '<span style="color:var(--text-dim)">—</span>';
if (v === 0) return '<span style="color:var(--green)">free</span>';
return `$${v.toFixed(2)}`;
}
function showModelModal(modelId) {
const m = _allModels.find(x => x.id === modelId);
if (!m) return;
const caps = m.capabilities || {};
const capBadges = [
caps.image_gen ? `<span class="badge" style="background:rgba(255,165,0,0.12);color:#e8a030">🎨 Image Gen</span>` : "",
caps.vision ? `<span class="badge" style="background:rgba(108,142,245,0.12);color:var(--accent)">👁 Vision</span>` : "",
caps.tools ? `<span class="badge" style="background:rgba(160,100,245,0.15);color:#b07aee">🔧 Tools</span>` : "",
caps.online ? `<span class="badge" style="background:rgba(76,175,125,0.15);color:var(--green)">🌐 Online</span>` : "",
].filter(Boolean).join(" ");
const providerLabel = m.provider === "anthropic"
? `<span class="badge" style="background:rgba(255,160,80,0.15);color:#e8a060">anthropic</span>`
: `<span class="badge" style="background:rgba(108,142,245,0.12);color:var(--accent)">openrouter</span>`;
const arch = m.architecture || {};
const el = document.getElementById("model-modal-content");
el.innerHTML = `
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px">
${providerLabel}
<h2 style="font-size:18px;font-weight:600">${esc(m.name || m.bare_id)}</h2>
</div>
<div style="margin-bottom:16px">
<div style="font-size:11px;color:var(--text-dim);margin-bottom:4px;text-transform:uppercase;letter-spacing:0.5px">Model ID</div>
<div style="font-family:var(--mono);font-size:12px;background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:8px 10px;display:flex;align-items:center;justify-content:space-between;gap:8px">
<span>${esc(m.id)}</span>
<button class="btn btn-ghost" style="padding:3px 8px;font-size:11px" onclick="navigator.clipboard.writeText('${esc(m.id)}');this.textContent='Copied!'">Copy</button>
</div>
</div>
${m.description ? `
<div style="margin-bottom:16px">
<div style="font-size:11px;color:var(--text-dim);margin-bottom:4px;text-transform:uppercase;letter-spacing:0.5px">Description</div>
<div style="font-size:13px;line-height:1.6;color:var(--text)">${esc(m.description)}</div>
</div>` : ""}
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:16px">
<div class="meta-card">
<div class="meta-label">Context window</div>
<div class="meta-value">${_fmtCtx(m.context_length) || "—"}</div>
</div>
<div class="meta-card">
<div class="meta-label">Input / 1M tokens</div>
<div class="meta-value">${m.pricing?.prompt_per_1m != null ? (m.pricing.prompt_per_1m === 0 ? "Free" : "$" + m.pricing.prompt_per_1m.toFixed(2)) : "—"}</div>
</div>
<div class="meta-card">
<div class="meta-label">Output / 1M tokens</div>
<div class="meta-value">${m.pricing?.completion_per_1m != null ? (m.pricing.completion_per_1m === 0 ? "Free" : "$" + m.pricing.completion_per_1m.toFixed(2)) : "—"}</div>
</div>
</div>
${capBadges ? `
<div style="margin-bottom:16px">
<div style="font-size:11px;color:var(--text-dim);margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px">Capabilities</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">${capBadges}</div>
</div>` : ""}
${(arch.tokenizer || arch.modality) ? `
<div>
<div style="font-size:11px;color:var(--text-dim);margin-bottom:8px;text-transform:uppercase;letter-spacing:0.5px">Architecture</div>
<div style="display:flex;gap:16px;flex-wrap:wrap;font-size:12px">
${arch.tokenizer ? `<span style="color:var(--text-dim)">Tokenizer: <span style="color:var(--text)">${esc(arch.tokenizer)}</span></span>` : ""}
${arch.modality ? `<span style="color:var(--text-dim)">Modality: <span style="color:var(--text)">${esc(arch.modality)}</span></span>` : ""}
</div>
</div>` : ""}
`;
document.getElementById("model-modal").classList.remove("hidden");
}
function closeModelModal() {
document.getElementById("model-modal").classList.add("hidden");
}
/* ══════════════════════════════════════════════════════════════════════════
Help page
══════════════════════════════════════════════════════════════════════════ */
let _helpObserver = null;
function initHelp() {
if (!document.querySelector(".help-content")) return;
// Disconnect any previous observer (SPA re-navigation)
if (_helpObserver) { _helpObserver.disconnect(); _helpObserver = null; }
const tocLinks = document.querySelectorAll(".toc-list a[href^='#']");
const sections = document.querySelectorAll("section[data-section]");
// IntersectionObserver — highlight TOC link for the section most in view
_helpObserver = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const id = entry.target.id;
tocLinks.forEach(a => {
a.classList.toggle("toc-active", a.getAttribute("href") === "#" + id);
});
});
}, { rootMargin: "-20% 0px -70% 0px", threshold: 0 });
sections.forEach(s => _helpObserver.observe(s));
// Smooth-scroll anchor clicks within the help content
tocLinks.forEach(a => {
a.addEventListener("click", e => {
e.preventDefault();
const target = document.querySelector(a.getAttribute("href"));
if (target) target.scrollIntoView({ behavior: "smooth" });
});
});
}
function helpSearch(query) {
const q = query.trim().toLowerCase();
const sections = document.querySelectorAll(".help-content section[data-section]");
let visibleCount = 0;
sections.forEach(s => {
const matches = !q || s.textContent.toLowerCase().includes(q);
s.style.display = matches ? "" : "none";
if (matches) visibleCount++;
});
// Sync TOC link visibility
document.querySelectorAll(".toc-list a[href^='#']").forEach(a => {
const id = a.getAttribute("href").slice(1);
const section = document.getElementById(id);
const visible = !q || !section || section.style.display !== "none";
a.closest("li").style.display = visible ? "" : "none";
});
// Show/hide no-results message
let noResults = document.getElementById("help-no-results");
if (!noResults) {
noResults = document.createElement("p");
noResults.id = "help-no-results";
noResults.style.cssText = "color:var(--text-dim);font-size:14px;padding:32px 0;text-align:center";
noResults.textContent = "No matching sections.";
document.querySelector(".help-content")?.appendChild(noResults);
}
noResults.style.display = (q && visibleCount === 0) ? "" : "none";
// Scroll content back to top so hidden sections are visibly gone
if (q) document.querySelector(".help-content")?.scrollTo({ top: 0 });
}
/* ── Theme picker ────────────────────────────────────────────────────────── */
async function loadTheme() {
const container = document.getElementById("theme-picker");
if (!container) return;
const r = await fetch("/api/my/theme");
if (!r.ok) { container.innerHTML = '<span style="color:var(--red)">Failed to load themes.</span>'; return; }
const { active, themes } = await r.json();
container.innerHTML = themes.map(t => `
<button type="button" onclick="setTheme('${esc(t.id)}')"
title="${esc(t.label)}"
style="display:flex;flex-direction:column;align-items:center;gap:6px;background:none;border:none;cursor:pointer;padding:4px">
<span style="
display:block;width:56px;height:36px;border-radius:8px;
background:${esc(t.preview.bg)};
border:2px solid ${t.id === active ? 'var(--accent)' : 'transparent'};
box-shadow:0 0 0 1px rgba(255,255,255,0.08);
position:relative;overflow:hidden;
transition:border-color 0.15s;
" id="theme-swatch-${esc(t.id)}">
<span style="
position:absolute;bottom:4px;right:4px;
width:16px;height:16px;border-radius:50%;
background:${esc(t.preview.accent)};
"></span>
</span>
<span style="font-size:11px;color:${t.id === active ? 'var(--accent)' : 'var(--text-dim)'};white-space:nowrap"
id="theme-label-${esc(t.id)}">${esc(t.label)}</span>
</button>
`).join("");
}
async function setTheme(themeId) {
const r = await fetch("/api/my/theme", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme_id: themeId }),
});
if (!r.ok) { showFlash("Failed to save theme."); return; }
// Reload page so server-injected theme CSS takes effect
location.reload();
}
/* ── Profile (account info + password + MFA) ─────────────────────────────── */
async function loadMyProfile() {
const r = await fetch("/api/my/profile");
if (!r.ok) return;
const d = await r.json();
const u = document.getElementById("profile-username");
const e = document.getElementById("profile-email");
const n = document.getElementById("profile-display-name");
if (u) u.value = d.username || "";
if (e) e.value = d.email || "";
if (n) n.value = d.display_name || "";
}
async function saveMyProfile() {
const errEl = document.getElementById("profile-save-error");
if (errEl) errEl.style.display = "none";
const display_name = document.getElementById("profile-display-name")?.value.trim() || "";
const r = await fetch("/api/my/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ display_name }),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
if (errEl) { errEl.textContent = d.detail || "Failed to save."; errEl.style.display = ""; }
return;
}
showFlash("Profile saved.");
}
async function changeMyProfilePassword() {
const errEl = document.getElementById("prof-pw-error");
errEl.style.display = "none";
const current = document.getElementById("prof-pw-current").value;
const pw = document.getElementById("prof-pw-new").value;
const pw2 = document.getElementById("prof-pw-new2").value;
if (pw.length < 8) { errEl.textContent = "Password must be at least 8 characters."; errEl.style.display = ""; return; }
if (pw !== pw2) { errEl.textContent = "Passwords do not match."; errEl.style.display = ""; return; }
const r = await fetch("/api/users/me/password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current_password: current, new_password: pw }),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
errEl.textContent = d.detail || "Failed to change password.";
errEl.style.display = "";
return;
}
document.getElementById("prof-pw-current").value = "";
document.getElementById("prof-pw-new").value = "";
document.getElementById("prof-pw-new2").value = "";
showFlash("Password changed.");
}
/* ── MFA (TOTP) management ───────────────────────────────────────────────── */
async function loadMfaStatus() {
const area = document.getElementById("mfa-status-area");
if (!area) return;
const r = await fetch("/api/my/mfa/status");
if (!r.ok) { area.innerHTML = '<span style="color:var(--red)">Failed to load MFA status.</span>'; return; }
const { enabled } = await r.json();
if (enabled) {
area.innerHTML = `
<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px">
<span class="badge badge-green" style="font-size:13px;padding:4px 10px">&#10003; MFA is active on your account</span>
</div>
<button class="btn btn-danger" onclick="showMfaDisableForm()">Disable MFA</button>
<div id="mfa-disable-form" style="display:none;margin-top:16px;max-width:360px">
<div class="form-group">
<label>Current password</label>
<input type="password" id="mfa-dis-pw" class="form-input" placeholder="Your password">
</div>
<div class="form-group">
<label>Current authenticator code</label>
<input type="text" id="mfa-dis-code" class="form-input" inputmode="numeric" maxlength="6" placeholder="000000">
</div>
<div id="mfa-dis-error" style="display:none;color:var(--red);font-size:12px;margin-bottom:10px"></div>
<div style="display:flex;gap:8px">
<button class="btn btn-ghost" onclick="document.getElementById('mfa-disable-form').style.display='none'">Cancel</button>
<button class="btn btn-danger" onclick="confirmMfaDisable()">Confirm Disable</button>
</div>
</div>`;
} else {
area.innerHTML = `
<p style="color:var(--text-dim);font-size:13px;margin-bottom:16px">MFA is not enabled on your account.</p>
<button class="btn btn-primary" onclick="beginMfaSetup()">Enable MFA</button>
<div id="mfa-setup-area" style="display:none;margin-top:20px"></div>`;
}
}
function showMfaDisableForm() {
const f = document.getElementById("mfa-disable-form");
if (f) { f.style.display = ""; document.getElementById("mfa-dis-pw").focus(); }
}
async function beginMfaSetup() {
const area = document.getElementById("mfa-setup-area");
if (!area) return;
area.innerHTML = '<span style="color:var(--text-dim)">Generating…</span>';
area.style.display = "";
const r = await fetch("/api/my/mfa/setup/begin", { method: "POST" });
if (!r.ok) { area.innerHTML = '<span style="color:var(--red)">Failed to start setup.</span>'; return; }
const { qr, secret } = await r.json();
area.innerHTML = `
<p style="font-size:13px;color:var(--text);margin-bottom:12px">
Scan this QR code with your authenticator app, then enter the 6-digit code below to confirm.
</p>
<img src="${qr}" alt="QR code" style="width:180px;height:180px;display:block;margin-bottom:12px;border-radius:var(--radius)">
<p style="font-size:11px;color:var(--text-dim);margin-bottom:12px">
Manual entry key: <code style="font-size:11px;background:var(--bg3);padding:2px 6px;border-radius:4px">${esc(secret)}</code>
</p>
<div class="form-group" style="max-width:240px">
<label>Verification code</label>
<input type="text" id="mfa-confirm-code" class="form-input" inputmode="numeric" maxlength="6" placeholder="000000" autofocus>
</div>
<div id="mfa-confirm-error" style="display:none;color:var(--red);font-size:12px;margin-bottom:10px"></div>
<button class="btn btn-primary" onclick="confirmMfaSetup()">Verify and Enable</button>`;
}
async function confirmMfaSetup() {
const code = (document.getElementById("mfa-confirm-code")?.value || "").trim();
const errEl = document.getElementById("mfa-confirm-error");
if (errEl) errEl.style.display = "none";
const r = await fetch("/api/my/mfa/setup/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code }),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
if (errEl) { errEl.textContent = d.detail || "Invalid code. Try again."; errEl.style.display = ""; }
return;
}
showFlash("MFA enabled successfully.");
loadMfaStatus();
}
async function confirmMfaDisable() {
const pw = document.getElementById("mfa-dis-pw")?.value || "";
const code = (document.getElementById("mfa-dis-code")?.value || "").trim();
const errEl = document.getElementById("mfa-dis-error");
if (errEl) errEl.style.display = "none";
const r = await fetch("/api/my/mfa/disable", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: pw, code }),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
if (errEl) { errEl.textContent = d.detail || "Failed to disable MFA."; errEl.style.display = ""; }
return;
}
showFlash("MFA disabled.");
loadMfaStatus();
}
async function clearUserMfa(userId, username) {
if (!confirm(`Clear MFA for user "${username}"? They will be able to log in with password only.`)) return;
const r = await fetch(`/api/users/${userId}/mfa`, { method: "DELETE" });
if (!r.ok) { alert("Failed to clear MFA."); return; }
showFlash(`MFA cleared for ${username}.`);
loadUsers();
}
/* ── Bootstrap ───────────────────────────────────────────────────────────── */
document.addEventListener("DOMContentLoaded", () => {
initNav();
_initPage(location.pathname);
});