4998 lines
207 KiB
JavaScript
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, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function formatBytes(n) {
|
|
if (n < 1024) return n + " B";
|
|
if (n < 1048576) return (n / 1024).toFixed(1) + " KB";
|
|
return (n / 1048576).toFixed(1) + " MB";
|
|
}
|
|
|
|
const _TZ = "Europe/Oslo";
|
|
|
|
function _dtParts(iso, opts) {
|
|
const parts = {};
|
|
for (const p of new Intl.DateTimeFormat("en-GB", { ...opts, timeZone: _TZ }).formatToParts(new Date(iso)))
|
|
parts[p.type] = p.value;
|
|
return parts;
|
|
}
|
|
|
|
function formatDate(iso) {
|
|
if (!iso) return "—";
|
|
try {
|
|
const v = _dtParts(iso, { day:"2-digit", month:"2-digit", year:"numeric",
|
|
hour:"2-digit", minute:"2-digit", second:"2-digit", hour12:false });
|
|
return `${v.day}.${v.month}.${v.year} ${v.hour}:${v.minute}:${v.second}`;
|
|
} catch { return iso; }
|
|
}
|
|
function formatDateShort(iso) {
|
|
if (!iso) return "—";
|
|
try {
|
|
const v = _dtParts(iso, { day:"2-digit", month:"2-digit", year:"numeric" });
|
|
return `${v.day}.${v.month}.${v.year}`;
|
|
} catch { return iso; }
|
|
}
|
|
|
|
/** Parse "dd.mm.yyyy" or "dd.mm.yyyy HH:MM" → ISO UTC string, or null if invalid. */
|
|
function parseEuDate(str, isEnd = false) {
|
|
if (!str?.trim()) return null;
|
|
const m = str.trim().match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:[T ](\d{1,2}):(\d{2})(?::(\d{2}))?)?$/);
|
|
if (!m) return null;
|
|
const [, d, mo, y, h = "00", mi = "00", s] = m;
|
|
const ss = s ?? (isEnd ? "59" : "00");
|
|
return `${y}-${mo.padStart(2,"0")}-${d.padStart(2,"0")}T${h.padStart(2,"0")}:${mi}:${ss}Z`;
|
|
}
|
|
|
|
/* ── Status bar (shared) ─────────────────────────────────────────────────── */
|
|
|
|
async function refreshStatus() {
|
|
try {
|
|
const r = await fetch("/api/status");
|
|
const data = await r.json();
|
|
const dot = document.getElementById("status-dot");
|
|
const label = document.getElementById("status-label");
|
|
|
|
if (dot && label) {
|
|
if (data.paused) {
|
|
dot.className = "status-dot paused";
|
|
label.textContent = "Paused";
|
|
} else {
|
|
dot.className = "status-dot";
|
|
label.textContent = "Running";
|
|
}
|
|
}
|
|
|
|
// Update sidebar pause button
|
|
const sidebarBtn = document.getElementById("pause-btn");
|
|
if (sidebarBtn) {
|
|
sidebarBtn.classList.toggle("paused", data.paused);
|
|
sidebarBtn.title = data.paused ? "Resume agent" : "Pause agent";
|
|
const icon = sidebarBtn.querySelector(".btn-icon");
|
|
if (icon) icon.textContent = data.paused ? "\u25b6" : "\u23f8";
|
|
const label = sidebarBtn.querySelector(".btn-label");
|
|
if (label) label.textContent = data.paused ? "Resume" : "Pause";
|
|
}
|
|
// Update settings page pause button (has a different id to avoid duplicate)
|
|
const settingsBtn = document.getElementById("settings-pause-btn");
|
|
if (settingsBtn) {
|
|
settingsBtn.className = `btn ${data.paused ? "btn-ghost paused" : "btn-primary"}`;
|
|
const icon = settingsBtn.querySelector(".btn-icon");
|
|
if (icon) icon.textContent = data.paused ? "▶" : "⏸";
|
|
const textNode = Array.from(settingsBtn.childNodes).find(n => n.nodeType === Node.TEXT_NODE && n.textContent.trim());
|
|
if (textNode) textNode.textContent = data.paused ? " Resume agent" : " Pause agent";
|
|
}
|
|
} catch { /* ignore */ }
|
|
}
|
|
|
|
async function togglePause() {
|
|
const pauseBtn = document.getElementById("pause-btn");
|
|
const isPaused = pauseBtn?.classList.contains("paused");
|
|
await fetch(isPaused ? "/api/resume" : "/api/pause", { method: "POST" });
|
|
await refreshStatus();
|
|
}
|
|
|
|
/* ── Navigation (SPA) ───────────────────────────────────────────────────── */
|
|
|
|
// Snapshot of chat state while on another page
|
|
let _chatSnapshot = null; // { html, sessionId }
|
|
let _skipNextRestore = false; // true when snapshot was just restored — skip WS restore event
|
|
let _cachedModels = null; // { models, default, capabilities } — cached from WS "models" message
|
|
let _modelCapabilities = {}; // { "provider:model-id": { vision, tools, online } }
|
|
|
|
function initNav() {
|
|
_setActiveNav(location.pathname);
|
|
|
|
// Intercept sidebar nav clicks — swap <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)">✓ 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)">✓ 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)">✓ 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)">✓ 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)">✓ 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)">✓ 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">✓ 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);
|
|
});
|