// ── Channel icons & labels for grouping ───────────────────── const CHANNEL_META = { webui: { icon: "language", label: "Web UI" }, telegram: { icon: "send", label: "Telegram" }, discord: { icon: "forum", label: "Discord" }, slack: { icon: "tag", label: "Slack" }, api: { icon: "api", label: "API" }, cli: { icon: "terminal", label: "CLI" }, heartbeat: { icon: "favorite", label: "Heartbeat" }, cron: { icon: "schedule", label: "Cron" }, _default: { icon: "chat_bubble", label: "Other" } }; const RECENT_COUNT = 4; const _channelCollapsed = {}; function _extractChannel(key) { const idx = key.indexOf(":"); return idx > 0 ? key.substring(0, idx).toLowerCase() : "_default"; } function _channelInfo(ch) { return CHANNEL_META[ch] || { icon: CHANNEL_META._default.icon, label: ch.charAt(0).toUpperCase() + ch.slice(1) }; } function _sessionKeyTail(key) { const rawKey = key || ""; const idx = rawKey.indexOf(":"); return idx >= 0 ? rawKey.substring(idx + 1) : rawKey; } function _escapeRegExp(text) { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function _cleanSessionTitle(name, sessionKey) { const rawName = (name || "").trim(); const rawKey = (sessionKey || "").trim(); const fallback = _sessionKeyTail(rawKey).trim(); if (!rawName) return fallback; if (!rawKey.includes(":")) return rawName; const channel = _extractChannel(rawKey); const channelLabel = _channelInfo(channel).label; const prefixes = Array.from(new Set([ channel, channelLabel, channelLabel.replace(/\s+/g, "") ].filter(Boolean))); let cleaned = rawName; prefixes.forEach((prefix) => { cleaned = cleaned.replace(new RegExp(`^${_escapeRegExp(prefix)}(?:_|:)\\s*`, "i"), ""); }); cleaned = cleaned.trim(); return cleaned || fallback || rawName; } function _getSessionChannelLabel(sessionKey) { const rawKey = (sessionKey || "").trim(); if (!rawKey.includes(":")) return ""; return _channelInfo(_extractChannel(rawKey)).label; } function _appendHistoryAttachment(container, file) { if (!file) return; if (file.type && file.type.startsWith("image/")) { const img = document.createElement("img"); img.src = file.url; img.onclick = () => window.open(file.url, "_blank"); container.appendChild(img); return; } const link = buildFileAttachmentLink(file, () => { downloadAttachment(file.url, file.name || "attachment"); }); container.appendChild(link); } function _isCurrentSessionLoad(loadSeq, sessionId) { return state.sessionLoadSeq === loadSeq && state.sessionId === sessionId; } function _clearOAuthPoll(scope) { const polls = state.oauthPolls || (state.oauthPolls = {}); if (!polls[scope]) return; clearInterval(polls[scope]); delete polls[scope]; } function _clearOAuthPollsByPrefix(prefix) { const polls = state.oauthPolls || {}; Object.keys(polls).forEach((scope) => { if (!prefix || scope.startsWith(prefix)) { _clearOAuthPoll(scope); } }); } function _clearAllOAuthPolls() { _clearOAuthPollsByPrefix(""); } window.clearAllOAuthPolls = _clearAllOAuthPolls; function _startOAuthJobPoll(scope, jobId, onUpdate) { _clearOAuthPoll(scope); const polls = state.oauthPolls || (state.oauthPolls = {}); let inFlight = false; polls[scope] = setInterval(async () => { if (inFlight) return; inFlight = true; try { const r2 = await authFetch("/api/oauth/job/" + jobId); const payload = await r2.json(); if (!payload.job) return; if (await onUpdate(payload.job)) { _clearOAuthPoll(scope); } } catch (_) { // Keep polling until the flow finishes or is explicitly cleaned up. } finally { inFlight = false; } }, 2000); } async function _loadContextModalContent() { const contentEl = $("context-content"); if (!contentEl) return; if (!state.sessionId) { contentEl.innerHTML = "
No active session
"; return; } const sessionId = state.sessionId; contentEl.innerHTML = `
Loading context...
`; try { const res = await authFetch(`/api/context?session_id=${encodeURIComponent(sessionId)}`); const data = await res.json(); if (!state.contextModalOpen || state.sessionId !== sessionId) return; const t = data.tokens || {}; const tokenCard = buildTokenCard(t); contentEl.innerHTML = tokenCard + renderMarkdown(data.context); enhanceCodeBlocks(contentEl); updateTokenBadge(t); } catch (e) { if (!state.contextModalOpen || state.sessionId !== sessionId) return; contentEl.innerHTML = "Error loading context."; } } function _buildSessionEl(sess) { const el = document.createElement("div"); el.className = "history-item"; el.dataset.sessionKey = sess.key; if (sess.key === state.sessionId) el.classList.add("active"); const date = new Date(sess.created_at).toLocaleDateString(); const time = new Date(sess.updated_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const name = sess.nickname || sess.key; const displayName = _cleanSessionTitle(name, sess.key); const channel = _extractChannel(sess.key); const channelLabel = _channelInfo(channel).label; const safeKey = encodeURIComponent(sess.key); const safeName = escapeHtml(displayName); const safeChannelLabel = escapeHtml(channelLabel); // Skip empty channels but otherwise render badged tag const channelTag = channelLabel ? `${safeChannelLabel}` : ""; el.innerHTML = `
${safeName}
${channelTag}
${date} ${time}
`; const infoEl = el.querySelector(".session-info"); infoEl.addEventListener("click", () => selectSession(sess.key, infoEl)); el.querySelector(".btn-session-menu").addEventListener("click", (e) => toggleSessionMenu(e, e.currentTarget, sess.key)); el.querySelector(".rename-action").addEventListener("click", () => renameSessionPrompt(sess.key, displayName)); el.querySelector(".archive-action").addEventListener("click", () => archiveSession(sess.key)); el.querySelector(".delete-action").addEventListener("click", () => deleteSession(sess.key)); return el; } function _toggleChannelGroup(ch, headerEl) { _channelCollapsed[ch] = !_channelCollapsed[ch]; const items = headerEl.nextElementSibling; if (_channelCollapsed[ch]) { headerEl.classList.add("collapsed"); items.classList.add("collapsed"); } else { headerEl.classList.remove("collapsed"); items.classList.remove("collapsed"); items.style.maxHeight = items.scrollHeight + "px"; } } async function loadHistory() { const list = $("history-list"); try { const res = await authFetch("/api/sessions"); const data = await res.json(); list.innerHTML = ""; if (!data.sessions || data.sessions.length === 0) { list.innerHTML = `
No past sessions
`; return; } const sessions = data.sessions; const visibleSessions = sessions.slice(0, RECENT_COUNT); const remaining = sessions.slice(RECENT_COUNT); visibleSessions.forEach(s => list.appendChild(_buildSessionEl(s))); if (remaining.length > 0) { const moreBtn = document.createElement("button"); moreBtn.className = "btn-show-more"; moreBtn.innerHTML = `expand_more Show ${remaining.length} more`; moreBtn.onclick = () => { remaining.forEach(s => list.insertBefore(_buildSessionEl(s), moreBtn)); moreBtn.remove(); }; list.appendChild(moreBtn); } } catch (e) { list.innerHTML = `
Error loading history
`; } } const _autoCollapsed = JSON.parse(localStorage.getItem("autoCollapsed") || "{}"); function _saveAutoCollapsed() { localStorage.setItem("autoCollapsed", JSON.stringify(_autoCollapsed)); } function _toggleAutoSection(key, headerEl) { _autoCollapsed[key] = !_autoCollapsed[key]; const items = headerEl.nextElementSibling; if (_autoCollapsed[key]) { headerEl.classList.add("collapsed"); items.classList.add("collapsed"); } else { headerEl.classList.remove("collapsed"); items.classList.remove("collapsed"); items.style.maxHeight = items.scrollHeight + "px"; } _saveAutoCollapsed(); } function _formatSchedule(s) { if (s.kind === "cron") { const tz = s.tz ? ` (${s.tz})` : ""; return `cron: ${s.expr}${tz}`; } if (s.kind === "every" && s.everyMs) { const ms = s.everyMs; if (ms % 3600000 === 0) return `every ${ms / 3600000}h`; if (ms % 60000 === 0) return `every ${ms / 60000}m`; if (ms % 1000 === 0) return `every ${ms / 1000}s`; return `every ${ms}ms`; } if (s.kind === "at" && s.atMs) { return new Date(s.atMs).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); } return s.kind; } function _timeAgo(ms) { if (!ms) return ""; const sec = Math.floor((Date.now() - ms) / 1000); if (sec < 60) return "just now"; if (sec < 3600) return `${Math.floor(sec / 60)}m ago`; if (sec < 86400) return `${Math.floor(sec / 3600)}h ago`; return `${Math.floor(sec / 86400)}d ago`; } function _cronStatusClass(job) { if (!job.enabled) return "st-disabled"; if (job.state.lastStatus === "error") return "st-error"; if (job.state.lastStatus === "ok") return "st-ok"; return "st-pending"; } async function loadCronSection() { const list = $("cron-list"); const count = $("cron-count"); try { const res = await authFetch("/api/cron/jobs"); const data = await res.json(); const jobs = data.jobs || []; count.textContent = jobs.length; if (jobs.length === 0) { list.innerHTML = `
No scheduled jobs
`; return; } list.innerHTML = ""; for (const job of jobs) { const row = document.createElement("div"); row.className = "auto-row"; const stCls = _cronStatusClass(job); const meta = job.state.lastRunAtMs ? _timeAgo(job.state.lastRunAtMs) : _formatSchedule(job.schedule); const safeName = escapeHtml(job.name || job.payload.message.slice(0, 30)); row.innerHTML = `
${safeName}
${escapeHtml(meta)}
`; row.querySelector(".btn-auto-trigger").addEventListener("click", async (e) => { const btn = e.currentTarget; btn.disabled = true; btn.textContent = "…"; try { await authFetch(`/api/cron/jobs/${encodeURIComponent(job.id)}/trigger`, { method: "POST" }); } catch (_) { } await loadCronSection(); }); list.appendChild(row); } } catch (e) { list.innerHTML = `
Error loading jobs
`; } } async function loadHeartbeatSection() { const list = $("heartbeat-list"); const badge = $("heartbeat-badge"); try { const res = await authFetch("/api/heartbeat/status"); const data = await res.json(); if (!data.reachable) { badge.className = "automation-badge badge-off"; badge.textContent = "offline"; list.innerHTML = `
Gateway unreachable
`; return; } if (!data.enabled) { badge.className = "automation-badge badge-off"; badge.textContent = "off"; list.innerHTML = `
Heartbeat disabled
`; return; } badge.className = "automation-badge " + (data.last_error ? "badge-error" : (data.running ? "badge-ok" : "badge-off")); badge.textContent = data.last_error ? "error" : (data.running ? "active" : "idle"); let info = `
`; info += `Interval: ${data.interval_min}min
`; if (data.session_key) info += `Session: ${escapeHtml(data.session_key)}
`; if (data.profile_id) info += `Profile: ${escapeHtml(data.profile_id)}
`; if (data.targets && Object.keys(data.targets).length) info += `Targets: ${escapeHtml(Object.entries(data.targets).map(([channel, target]) => `${channel}:${target}`).join(", "))}
`; if (data.last_check_ms) info += `Last check: ${_timeAgo(data.last_check_ms)} — ${data.last_action || "?"}
`; if (data.last_run_ms) info += `Last run: ${_timeAgo(data.last_run_ms)}
`; if (data.last_error) info += `Error: ${escapeHtml(data.last_error)}
`; info += `File: ${data.heartbeat_file_exists ? `HEARTBEAT.md` : "missing"}`; info += `
`; info += `
`; list.innerHTML = info; $("btn-hb-trigger").addEventListener("click", async (e) => { const btn = e.currentTarget; btn.disabled = true; btn.textContent = "…"; try { await authFetch("/api/heartbeat/trigger", { method: "POST" }); } catch (_) { } await loadHeartbeatSection(); }); } catch (e) { badge.className = "automation-badge badge-off"; badge.textContent = ""; list.innerHTML = `
Error loading status
`; } } function initAutomationSections() { const cronHeader = $("cron-header"); const hbHeader = $("heartbeat-header"); if (!state.automationInitialized) { if (cronHeader) { cronHeader.addEventListener("click", () => _toggleAutoSection("cron", cronHeader)); if (_autoCollapsed["cron"]) { cronHeader.classList.add("collapsed"); $("cron-list").classList.add("collapsed"); } } if (hbHeader) { hbHeader.addEventListener("click", () => _toggleAutoSection("heartbeat", hbHeader)); if (_autoCollapsed["heartbeat"]) { hbHeader.classList.add("collapsed"); $("heartbeat-list").classList.add("collapsed"); } } state.automationInitialized = true; } loadCronSection(); loadHeartbeatSection(); } window.toggleSessionMenu = function (event, btn, key) { event.stopPropagation(); const safeKey = encodeURIComponent(key); const dropdown = document.querySelector(`.session-dropdown[data-session-key="${safeKey}"]`); const isActive = dropdown && dropdown.classList.contains("active"); document.querySelectorAll(".session-dropdown").forEach(d => { d.classList.remove("active"); d.style.top = ""; d.style.bottom = ""; d.style.marginBottom = ""; }); document.querySelectorAll(".btn-session-menu").forEach(b => b.classList.remove("active")); if (!isActive && dropdown) { dropdown.classList.add("active"); btn.classList.add("active"); const container = dropdown.closest('.history-section'); if (container) { const containerRect = container.getBoundingClientRect(); const rect = dropdown.getBoundingClientRect(); if (rect.bottom > containerRect.bottom) { dropdown.style.top = "auto"; dropdown.style.bottom = "100%"; dropdown.style.marginBottom = "4px"; } } } }; window.renameSessionPrompt = async function (key, currentName) { const newName = await shibaDialog("prompt", "Rename Session", "Enter new name for session:", { defaultValue: currentName, confirmText: "Rename" }); if (newName && newName !== currentName) { renameSession(key, newName); } }; async function renameSession(key, nickname) { try { const res = await authFetch(`/api/sessions/${encodeURIComponent(key)}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nickname }) }); if (res.ok) { if (key === state.sessionId) { setSessionLabel(nickname || key); } await loadHistory(); } } catch (e) { console.error("Rename error:", e); } } async function autoTitleSession() { if (!state.sessionId) return; const firstUser = chatHistory.querySelector(".message-group.user .message-bubble"); if (!firstUser) return; const text = firstUser.textContent?.trim(); if (!text) return; let title = text .replace(/\n+/g, " ") .replace(/\s+/g, " ") .trim(); if (title.length > 45) title = title.slice(0, 42) + "..."; try { const res = await authFetch(`/api/sessions/${encodeURIComponent(state.sessionId)}`); if (!res.ok) return; const data = await res.json(); if (data.nickname) return; } catch (e) { return; } renameSession(state.sessionId, title); } async function shibaDialog(type, title, message, { confirmText = "Confirm", danger = false, defaultValue = "" } = {}) { return new Promise(resolve => { const backdrop = document.getElementById("confirm-dialog"); const msgEl = document.getElementById("confirm-message"); const okBtn = document.getElementById("confirm-ok"); const cancelBtn = document.getElementById("confirm-cancel"); document.getElementById("confirm-title").textContent = title; msgEl.textContent = message ?? ""; let inputEl = null; if (type === "prompt") { inputEl = document.createElement("input"); inputEl.type = "text"; inputEl.className = "form-input"; inputEl.style.marginTop = "16px"; inputEl.style.width = "100%"; inputEl.style.fontSize = "14px"; inputEl.style.padding = "10px"; inputEl.value = defaultValue; msgEl.appendChild(inputEl); } okBtn.textContent = confirmText; okBtn.className = danger ? "btn-danger" : "btn-primary"; cancelBtn.style.display = (type === "alert") ? "none" : ""; function cleanup(result) { backdrop.classList.remove("active"); okBtn.removeEventListener("click", onOk); cancelBtn.removeEventListener("click", onCancel); backdrop.removeEventListener("click", onBackdrop); if (inputEl) inputEl.removeEventListener("keydown", onKeydown); resolve(result); } function onOk() { if (type === "prompt") cleanup(inputEl.value); else cleanup(true); } function onCancel() { cleanup(type === "prompt" ? null : false); } function onBackdrop(e) { if (e.target === backdrop) onCancel(); } function onKeydown(e) { if (e.key === "Enter") onOk(); if (e.key === "Escape") onCancel(); } okBtn.addEventListener("click", onOk); cancelBtn.addEventListener("click", onCancel); backdrop.addEventListener("click", onBackdrop); if (inputEl) { inputEl.addEventListener("keydown", onKeydown); setTimeout(() => inputEl.focus(), 50); } else { setTimeout(() => okBtn.focus(), 50); } backdrop.classList.add("active"); }); } function removeSessionFromUI(key) { const safeKey = encodeURIComponent(key); const dropdown = document.querySelector(`.session-dropdown[data-session-key="${safeKey}"]`); if (!dropdown) return; const item = dropdown.closest(".history-item"); if (item) { item.style.transition = "opacity 0.2s, transform 0.2s"; item.style.opacity = "0"; item.style.transform = "translateX(-20px)"; setTimeout(() => item.remove(), 200); } } window.deleteSession = async function (key) { const ok = await shibaDialog("confirm", "Delete Session", "This session will be permanently deleted.", { confirmText: "Delete", danger: true }); if (!ok) return; removeSessionFromUI(key); if (state.sessionId === key) realtime.emit("new_session"); try { await authFetch(`/api/sessions/${encodeURIComponent(key)}`, { method: "DELETE" }); } catch (e) { console.error("Delete error:", e); } }; window.archiveSession = async function (key) { const ok = await shibaDialog("confirm", "Archive Session", "This session will run the same consolidation flow as /new and then be removed.", { confirmText: "Archive" }); if (!ok) return; removeSessionFromUI(key); if (state.sessionId === key) realtime.emit("new_session"); try { await authFetch(`/api/sessions/${encodeURIComponent(key)}/archive`, { method: "POST" }); } catch (e) { console.error("Archive error:", e); } }; document.addEventListener("click", () => { document.querySelectorAll(".session-dropdown").forEach(d => { d.classList.remove("active"); d.style.top = ""; d.style.bottom = ""; d.style.marginBottom = ""; }); document.querySelectorAll(".btn-session-menu").forEach(b => b.classList.remove("active")); }); async function loadSession(sessionId) { if (state.processing) { state.processing = false; setWorkingState(false); updateSendButton(); clearTimeout(state._typingBubbleTimeout); hideTypingBubble(); hideThinking(); } const loadSeq = (state.sessionLoadSeq || 0) + 1; state.sessionLoadSeq = loadSeq; state.sessionId = sessionId; localStorage.setItem("shiba_session_id", sessionId); document.querySelectorAll(".history-item").forEach(el => el.classList.remove("active")); const items = $("history-list").children; const encodedId = encodeURIComponent(sessionId); for (let el of items) { try { const dropdown = el.querySelector('.session-dropdown'); if (dropdown && dropdown.dataset && dropdown.dataset.sessionKey === encodedId) { el.classList.add('active'); } } catch (e) { if (el.textContent && el.textContent.includes(sessionId)) el.classList.add("active"); } } try { const res = await authFetch(`/api/sessions/${encodeURIComponent(sessionId)}`); const data = await res.json(); if (!_isCurrentSessionLoad(loadSeq, sessionId)) return; console.debug("[SHIBA] loadSession:", sessionId, "messages:", data.messages?.length || 0); setSessionLabel(data.nickname || sessionId); state.profileId = data.profile_id || "default"; if (typeof window.syncProfileSelection === "function") { await window.syncProfileSelection(state.profileId); if (!_isCurrentSessionLoad(loadSeq, sessionId)) return; } if (!_isCurrentSessionLoad(loadSeq, sessionId)) return; if (typeof updateModelSelectorDisplay === "function") { updateModelSelectorDisplay(data.model || ""); } chatHistory.innerHTML = ""; state.messageCount = 0; Object.values(state.processGroups).forEach(pg => { if (pg && pg.timer) clearInterval(pg.timer); }); state.processGroups = {}; const messages = Array.isArray(data.messages) ? data.messages : []; if (messages.length > 0) { activateChat(); try { refreshTokenBadge(); } catch (e) { /* ignore */ } let turnSteps = []; let turnId = 0; let pgCount = 0; let lastUserContent = null; const fragment = document.createDocumentFragment(); for (const msg of messages) { if (!_isCurrentSessionLoad(loadSeq, sessionId)) return; if (!msg || !msg.role) continue; if (msg.role === "user") { if (msg.metadata && msg.metadata.hidden) continue; if (!msg.content || msg.content === lastUserContent) continue; lastUserContent = msg.content; const hasExeSteps = turnSteps.some(s => s.badge === "EXE"); if (turnSteps.length > 0 && hasExeSteps) { renderProcessGroupFromHistory(turnId, turnSteps, fragment); pgCount++; } turnSteps = []; turnId++; const group = createMessageGroup("user", fragment); const bubble = document.createElement("div"); bubble.className = "message-bubble"; if (msg.content) { bubble.innerHTML = renderMarkdown(msg.content); enhanceCodeBlocks(bubble); } const attachments = msg.metadata?.attachments || []; attachments.forEach(file => { _appendHistoryAttachment(bubble, file); }); group.querySelector(".message-content").appendChild(bubble); if (msg.timestamp) addTimestamp(group, msg.timestamp); fragment.appendChild(group); } else if (msg.role === "assistant") { const hasTc = msg.tool_calls && msg.tool_calls.length > 0; const hasContent = !!msg.content; const hasReasoning = !!msg.reasoning_content; if (hasReasoning) { const preview = (msg.reasoning_content?.slice?.(0, 120)) || ""; turnSteps.push({ badge: "GEN", text: preview }); } if (hasTc) { for (const tc of msg.tool_calls) { const fn = tc.function?.name || "tool"; let args = ""; try { const raw = tc.function?.arguments; if (raw) { const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; const vals = Object.values(parsed); if (vals.length > 0) { const preview = String(vals[0]).replace(/\n/g, " "); args = `("${truncate(preview, 60)}")`; } } } catch { /* ignore parse errors */ } turnSteps.push({ badge: "EXE", text: fn + args }); } } if (hasContent && !hasTc) { const hasExeSteps = turnSteps.some(s => s.badge === "EXE"); if (turnSteps.length > 0 && hasExeSteps) { renderProcessGroupFromHistory(turnId, turnSteps, fragment); pgCount++; } turnSteps = []; const group = createMessageGroup("agent", fragment); const bubble = document.createElement("div"); bubble.className = "message-bubble"; bubble.innerHTML = renderMarkdown(msg.content); enhanceCodeBlocks(bubble); const attachments = msg.metadata?.attachments || []; attachments.forEach(file => { _appendHistoryAttachment(bubble, file); }); group.querySelector(".message-content").appendChild(bubble); if (msg.timestamp) addTimestamp(group, msg.timestamp); fragment.appendChild(group); } else if (!hasContent && !hasTc && turnSteps.length > 0) { renderProcessGroupFromHistory(turnId, turnSteps, fragment); pgCount++; turnSteps = []; } } else if (msg.role === "tool") { } } if (turnSteps.length > 0 && turnSteps.some(s => s.badge === "EXE")) { renderProcessGroupFromHistory(turnId, turnSteps, fragment); pgCount++; } if (!_isCurrentSessionLoad(loadSeq, sessionId)) return; chatHistory.appendChild(fragment); console.debug("[SHIBA] loadSession rendered:", pgCount, "process groups,", chatHistory.querySelectorAll(".process-group").length, "in DOM"); scrollToBottom(); } else { chatHistory.classList.remove("active"); welcomeScreen.style.display = ""; } } catch (e) { if (_isCurrentSessionLoad(loadSeq, sessionId)) { console.debug("[SHIBA] Error loading session:", e); } } finally { if (realtime.connected && _isCurrentSessionLoad(loadSeq, sessionId)) { realtime.emit("switch_session", { session_id: sessionId }); } } } window.openModal = async function (id) { const modal = $(id); if (!modal) return; modal.classList.add("active"); if (typeof window.closeSidebarOnMobile === "function") { window.closeSidebarOnMobile(); } if (id === "context-modal") { state.contextModalOpen = true; await _loadContextModalContent(); } else if (id === "settings-modal") { $("settings-loading").style.display = "flex"; document.querySelectorAll(".settings-panel").forEach(p => p.style.display = "none"); try { const res = await authFetch("/api/settings"); const cfg = await res.json(); if (cfg.error) throw cfg.error; window._shibaConfig = cfg; populateSettings(cfg); $("settings-loading").style.display = "none"; let startTab = "agent"; try { startTab = localStorage.getItem("shibaclaw_settings_tab") || "agent"; } catch (e) { } switchSettingsTab(startTab); } catch (e) { $("settings-loading").innerHTML = `error Failed to load settings`; } } else if (id === "fs-modal") { await loadFs(state.currentFsPath || "."); if (state.fsOpenTarget) { const target = state.fsOpenTarget; state.fsOpenTarget = null; openFileEditor(target, target.split(/[\\/\\]/).pop()); } } else if (id === "changelog-modal") { const contentEl = $("changelog-content"); contentEl.innerHTML = '
Fetching release notes...
'; try { const version = $("sidebar-version").textContent.replace("v", "").trim(); const hasResolvedVersion = version && version !== "loading..."; let releaseUrl = hasResolvedVersion ? `https://api.github.com/repos/RikyZ90/ShibaClaw/releases/tags/v${version}` : "https://api.github.com/repos/RikyZ90/ShibaClaw/releases/latest"; let res = await fetch(releaseUrl); if (!res.ok && hasResolvedVersion) { // fallback to latest res = await fetch("https://api.github.com/repos/RikyZ90/ShibaClaw/releases/latest"); } if (res.ok) { const data = await res.json(); // Show github button const btn = $("changelog-github-btn"); if (btn && data.html_url) { btn.href = data.html_url; btn.style.display = "inline-flex"; } if (data.body) { contentEl.innerHTML = renderMarkdown(data.body); } else { contentEl.innerHTML = '
No release notes available.
'; } } else { throw new Error("Could not fetch release notes."); } } catch (e) { console.error("Changelog fetch error:", e); contentEl.innerHTML = `
Failed to load release notes. Please check your connection or visit GitHub.
`; } } }; window.openChangelog = function () { openModal("changelog-modal"); }; window.openHeartbeatFile = function (event) { if (event && event.preventDefault) event.preventDefault(); const filePath = "HEARTBEAT.md"; const dir = filePath.includes("/") ? filePath.replace(/\\/g, "/").split("/").slice(0, -1).join("/") : "."; state.currentFsPath = dir || "."; state.fsOpenTarget = filePath; openModal("fs-modal"); }; window.closeModal = function (id) { const modal = $(id); if (!modal) return; if (id === "context-modal") { state.contextModalOpen = false; } if (id === "settings-modal") { _clearOAuthPollsByPrefix("settings:"); } if (id === "onboard-modal") { _clearOAuthPollsByPrefix("onboard:"); } modal.classList.remove("active"); }; window.openOnboardFromSettings = function () { closeModal("settings-modal"); openOnboardWizard(); }; window.switchSettingsTab = function (tab) { document.querySelectorAll(".settings-sidebar-item").forEach(t => t.classList.remove("active")); const sidebarEl = document.querySelector(`.settings-sidebar-item[data-tab="${tab}"]`); if (sidebarEl) sidebarEl.classList.add("active"); document.querySelectorAll(".settings-tab").forEach(t => t.classList.remove("active")); const tabEl = document.querySelector(`.settings-tab[data-tab="${tab}"]`); if (tabEl) tabEl.classList.add("active"); document.querySelectorAll(".settings-panel").forEach(p => p.style.display = "none"); const panel = $("panel-" + tab); if (panel) panel.style.display = "block"; if (tab !== "oauth") _clearOAuthPollsByPrefix("settings:"); if (tab === "oauth") loadOAuthPanel(); if (tab === "update") loadUpdatePanel(); if (tab === "skills") loadSkillsPanel(); if (tab === "heartbeat") loadHeartbeatSettingsPanel(); try { localStorage.setItem("shibaclaw_settings_tab", tab); } catch (e) { } }; /* ── Skills panel ── */ window._skillsData = []; window._skillsPinnedList = []; window._skillsMaxPinned = 5; async function loadSkillsPanel() { const listEl = document.getElementById("skills-list"); try { const res = await authFetch("/api/skills"); if (!res.ok) { if (listEl) listEl.innerHTML = '
Failed to load skills (HTTP ' + res.status + ')
'; return; } const data = await res.json(); window._skillsData = data.skills || []; window._skillsPinnedList = data.pinned_skills || []; window._skillsMaxPinned = data.max_pinned_skills || 5; renderSkillsPanel(); } catch (e) { console.error("loadSkillsPanel", e); if (listEl) listEl.innerHTML = '
Error loading skills
'; } } function renderSkillsPanel() { const skills = window._skillsData; const pinned = window._skillsPinnedList; var alwaysActive = skills.filter(function (s) { return s.always || pinned.includes(s.name); }); var alwaysNames = alwaysActive.map(function (s) { return s.name; }); var counter = document.getElementById("skills-pin-counter"); if (counter) counter.textContent = alwaysActive.length + " / " + window._skillsMaxPinned; var pinnedList = document.getElementById("skills-pinned-list"); if (pinnedList) { if (alwaysActive.length === 0) { pinnedList.innerHTML = 'No always-active skills'; } else { pinnedList.innerHTML = alwaysActive.map(function (s) { var canUnpin = !s.always; var closeBtn = canUnpin ? ' close' : ' lock'; return '' + escHtml(s.name) + closeBtn + ''; }).join(""); } } var listEl = document.getElementById("skills-list"); if (!listEl) return; var q = ((document.getElementById("skills-search") || {}).value || "").toLowerCase(); var filtered = q ? skills.filter(function (s) { return s.name.toLowerCase().includes(q) || (s.description || "").toLowerCase().includes(q); }) : skills; if (filtered.length === 0) { listEl.innerHTML = '
No skills found.
'; return; } listEl.innerHTML = filtered.map(function (s) { return renderSkillCard(s, alwaysNames); }).join(""); } function escHtml(s) { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; } function renderSkillCard(skill, activeNames) { var isActive = activeNames.includes(skill.name); var isYamlAlways = skill.always; var badgeClass = skill.source === "builtin" ? "builtin" : "workspace"; var availClass = skill.available ? "" : " unavailable"; var pinBtn = isYamlAlways ? 'lock' : '' + (isActive ? 'push_pin' : 'add_circle_outline') + ''; var deleteBtn = skill.source === "workspace" ? 'delete' : ''; return '
' + '
' + '
' + escHtml(skill.name) + ' ' + escHtml(skill.source) + '
' + '
' + escHtml(skill.description || 'No description') + '
' + (skill.missing_requirements ? '
Missing: ' + escHtml(skill.missing_requirements) + '
' : '') + '
' + '
' + pinBtn + deleteBtn + '
' + '
'; } window.toggleSkillPin = async function (name, pin) { let list = [...window._skillsPinnedList]; if (pin) { if (list.length >= window._skillsMaxPinned) { alert("Max pinned skills reached (" + window._skillsMaxPinned + ")"); return; } if (!list.includes(name)) list.push(name); } else { list = list.filter(n => n !== name); } try { const res = await authFetch("/api/skills/pin", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ pinned_skills: list }) }); if (!res.ok) { const d = await res.json().catch(() => ({})); alert(d.error || "Pin failed"); return; } window._skillsPinnedList = list; renderSkillsPanel(); } catch (e) { console.error("toggleSkillPin", e); } }; window.deleteSkill = async function (name) { if (!confirm("Delete skill '" + name + "'? This cannot be undone.")) return; try { const res = await authFetch("/api/skills/" + encodeURIComponent(name), { method: "DELETE" }); const d = await res.json().catch(() => ({})); if (!res.ok) { alert(d.error || "Delete failed"); return; } loadSkillsPanel(); } catch (e) { console.error("deleteSkill", e); } }; window.handleSkillsFileSelect = function (event) { const fileInput = event.target; const nameEl = document.getElementById("skills-import-filename"); const importBtn = document.getElementById("skills-import-btn"); if (fileInput.files.length) { if (nameEl) nameEl.textContent = fileInput.files[0].name; if (importBtn) importBtn.disabled = false; } else { if (nameEl) nameEl.textContent = "No file selected"; if (importBtn) importBtn.disabled = true; } }; window.importSkills = async function () { const fileInput = document.getElementById("skills-import-file"); if (!fileInput || !fileInput.files.length) return; const el = document.getElementById("skills-import-result"); const form = new FormData(); form.append("file", fileInput.files[0]); form.append("conflict", "overwrite"); if (el) { el.style.display = "block"; el.innerHTML = 'Importing...'; } try { const res = await authFetch("/api/skills/import", { method: "POST", body: form }); const d = await res.json(); if (!res.ok) { if (el) el.innerHTML = '' + escHtml(d.error || "Error") + ''; return; } if (el) el.innerHTML = 'Imported ' + (d.imported_count || 0) + ' skill(s)'; fileInput.value = ""; var nameEl = document.getElementById("skills-import-filename"); if (nameEl) nameEl.textContent = "No file selected"; document.getElementById("skills-import-btn").disabled = true; loadSkillsPanel(); } catch (e) { console.error("importSkills", e); if (el) { el.style.display = "block"; el.innerHTML = 'Network error'; } } }; document.addEventListener("DOMContentLoaded", function () { document.addEventListener("input", function (e) { if (e.target && e.target.id === "skills-search") renderSkillsPanel(); }); // Set up listener for memory compaction events if (typeof realtime !== 'undefined' && realtime) { realtime.on("memory_compacted", () => { if (state.contextModalOpen && state.sessionId) { _loadContextModalContent(); } }); } }); /* ── end Skills panel ── */ async function loadOAuthPanel() { const list = document.getElementById("oauth-list"); if (!list) return; _clearOAuthPollsByPrefix("settings:"); const providers = [ { name: "openrouter", label: "OpenRouter", icon: "route", desc: "Authenticate in the browser and store the returned OpenRouter API key directly in provider settings.", mode: "browser_redirect", cta: "Open OpenRouter" }, { name: "github_copilot", label: "GitHub Copilot", icon: "code", desc: "Authenticate via GitHub device flow. Uses native OAuth orchestration." }, { name: "openai_codex", label: "OpenAI Codex", icon: "psychology", desc: "Authenticate via OAuth CLI kit. Requires oauth-cli-kit package." }, ]; list.innerHTML = ""; for (const p of providers) { const card = document.createElement("div"); card.className = "accordion"; card.innerHTML = `
${p.icon} ${p.label}
Checking... expand_more
${p.desc}
`; list.appendChild(card); document.getElementById("btn-oauth-login-" + p.name).addEventListener("click", async () => { const btn = document.getElementById("btn-oauth-login-" + p.name); const badge = document.getElementById("oauth-badge-" + p.name); const logsEl = document.getElementById("oauth-logs-" + p.name); btn.disabled = true; btn.innerHTML = 'progress_activity Contacting...'; logsEl.style.display = "block"; logsEl.innerHTML = p.name === "openrouter" ? "Preparing OpenRouter login...\n" : "Requesting device code...\n"; const loginBtnHtml = 'login Login'; try { const resp = await authFetch("/api/oauth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider: p.name }) }); const jd = await resp.json(); if (jd.error) { logsEl.textContent = "Error: " + jd.error; btn.disabled = false; btn.innerHTML = loginBtnHtml; return; } if (jd.user_code && jd.verification_uri) { badge.textContent = "Awaiting auth..."; badge.className = "acc-badge off"; btn.innerHTML = 'progress_activity Waiting for auth...'; const codeId = "oauth-code-" + Date.now(); logsEl.innerHTML = `
` + `
` + `` + `open_in_new Open GitHub` + `` + `
` + `${jd.user_code}` + `content_copy` + `
` + `
` + `
Click to copy
` + `
` + `progress_activity Waiting for authorization...` + `
` + `
`; } if (jd.auth_url && p.mode === "browser_redirect") { badge.textContent = "Awaiting auth..."; badge.className = "acc-badge off"; btn.innerHTML = 'progress_activity Waiting for auth...'; logsEl.innerHTML = `
` + `
OpenRouter will return here automatically when the authorization is complete.
` + `` + `open_in_new ${p.cta || 'Open login'}` + `` + `
If no tab opened automatically, use the button above.
` + `
` + `progress_activity Waiting for browser callback...` + `
` + `
`; try { window.open(jd.auth_url, "_blank", "noopener,noreferrer"); } catch { /* ignore popup blockers */ } } if (jd.job_id) { const pollScope = "settings:" + p.name; _startOAuthJobPoll(pollScope, jd.job_id, async (job) => { if (job.status === "done") { badge.textContent = "Configured"; badge.className = "acc-badge on"; btn.disabled = false; btn.innerHTML = loginBtnHtml; logsEl.innerHTML = `
✅ Authentication successful!
`; if (p.name === "openrouter") { try { const settingsModal = document.getElementById("settings-modal"); if (settingsModal && settingsModal.classList.contains("active")) { const settingsRes = await authFetch("/api/settings"); const settingsCfg = await settingsRes.json(); if (!settingsCfg.error) { window._shibaConfig = settingsCfg; populateSettings(settingsCfg); _availableModels = []; // Clear model cache switchSettingsTab("oauth"); } } } catch { /* silent */ } } return true; } if (job.status === "error") { badge.textContent = "Error"; badge.className = "acc-badge off"; btn.disabled = false; btn.innerHTML = loginBtnHtml; const logs = (job.logs || []).join("\n"); logsEl.innerHTML = `
${logs}
`; return true; } if (job.status === "awaiting_redirect" && job.auth_url && p.mode === "browser_redirect" && !logsEl.querySelector('.oauth-browser-auth-ui')) { badge.textContent = "Awaiting auth..."; badge.className = "acc-badge off"; btn.innerHTML = 'progress_activity Waiting for auth...'; logsEl.innerHTML = `
` + `` + `open_in_new ${p.cta || 'Open login'}` + `` + `
` + `progress_activity Waiting for browser callback...` + `
` + `
`; } else if (job.status === "awaiting_code" && job.auth_url && !logsEl.querySelector('.codex-auth-ui')) { badge.textContent = "Awaiting auth..."; badge.className = "acc-badge off"; btn.innerHTML = 'progress_activity Waiting...'; const inputId = "codex-input-" + jd.job_id; const submitId = "codex-submit-" + jd.job_id; logsEl.innerHTML = `
` + `
Click the button below to sign in with OpenAI:
` + `` + `open_in_new Open OpenAI Login` + `` + `
` + `📋 After login, your browser will redirect to a URL like:
` + `http://localhost:1455/auth/callback?code=AUTH_CODE_HERE&state=...
` + `Paste the entire URL in the field below — the code will be extracted automatically.` + `
` + `
` + `` + `` + `
` + `
` + `progress_activity Waiting for authorization...` + `
` + `
`; setTimeout(() => { const submitBtn = document.getElementById(submitId); const inputEl = document.getElementById(inputId); if (submitBtn && inputEl) { const doSubmit = async () => { const code = inputEl.value.trim(); if (!code) return; submitBtn.disabled = true; submitBtn.textContent = "Sending..."; try { await authFetch("/api/oauth/code", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ job_id: jd.job_id, code }) }); inputEl.value = ""; inputEl.placeholder = "Code submitted, waiting..."; } catch { submitBtn.disabled = false; submitBtn.textContent = "Submit"; } }; submitBtn.addEventListener("click", doSubmit); inputEl.addEventListener("keydown", e => { if (e.key === "Enter") doSubmit(); }); } }, 50); } return false; }); } else if (!jd.user_code) { logsEl.textContent = jd.error || "Unknown response"; btn.disabled = false; btn.innerHTML = loginBtnHtml; } } catch (e) { logsEl.textContent = "Error: " + e; btn.disabled = false; btn.innerHTML = loginBtnHtml; } }); } _refreshOAuthStatus(); } async function _refreshOAuthStatus() { try { const r = await authFetch("/api/oauth/providers"); const data = await r.json(); for (const p of (data.providers || [])) { const badge = document.getElementById("oauth-badge-" + p.name); if (!badge) continue; const ok = p.status === "configured"; badge.textContent = ok ? "Configured" : (p.status === "missing_dependency" ? "Missing dep" : "Not configured"); badge.className = "acc-badge " + (ok ? "on" : "off"); } } catch { /* silent */ } } function _addProviderOption(sel, value, label) { if (sel.querySelector(`option[value="${value}"]`)) return; const opt = document.createElement("option"); opt.value = value; opt.textContent = label || value.charAt(0).toUpperCase() + value.slice(1); sel.appendChild(opt); } async function _populateOAuthProviders(sel, current) { try { const r = await authFetch("/api/oauth/providers"); const data = await r.json(); for (const p of (data.providers || [])) { if (p.status === "configured") _addProviderOption(sel, p.name, p.label); } if (current) sel.value = current; } catch { /* silent */ } } function providerKeyPlaceholder(name) { const placeholders = { anthropic: "sk-ant-...", deepseek: "sk-...", gemini: "AIza...", groq: "gsk_...", openai: "sk-...", openrouter: "sk-or-...", }; return placeholders[name] || "Enter API key"; } function populateSettings(cfg) { lastSettingsConfig = JSON.parse(JSON.stringify(cfg)); const d = cfg.agents?.defaults || {}; $("s-agent-model").value = d.model || ""; $("s-agent-consolidationModel").value = d.consolidationModel || ""; setupSettingsModelPickers(); void refreshSettingsModelPickers(); $("s-agent-temp").value = d.temperature ?? 0.1; $("s-agent-maxTokens").value = d.maxTokens ?? 8192; $("s-agent-ctxTokens").value = d.contextWindowTokens ?? 65536; $("s-agent-maxIter").value = d.maxToolIterations ?? 40; $("s-agent-workspace").value = d.workspace || "~/.shibaclaw/workspace"; $("s-agent-reasoning").value = d.reasoningEffort || ""; // Audio settings const au = cfg.audio || {}; $("s-audio-providerUrl").value = au.providerUrl || ""; $("s-audio-apiKey").value = au.apiKey || ""; $("s-audio-model").value = au.model || ""; // sync TTS toggle with config value (with localStorage as fallback) const ttsFromConfig = au.ttsEnabled !== undefined ? au.ttsEnabled : (localStorage.getItem("shibaclaw_tts_enabled") === "true"); $("tts-toggle").checked = ttsFromConfig; if (window.speechTTS) window.speechTTS.enabled = ttsFromConfig; const prov = cfg.providers || {}; const list = $("providers-list"); list.innerHTML = ""; for (const [name, pc] of Object.entries(prov)) { const hasKey = !!(pc.apiKey); const displayName = name.replace(/([A-Z])/g, " $1").replace(/^./, s => s.toUpperCase()); const card = document.createElement("div"); card.className = "accordion"; card.innerHTML = `
key ${displayName}
${hasKey ? 'Configured' : 'Not set'} expand_more
`; list.appendChild(card); } const tw = cfg.tools?.web || {}; const ts = tw.search || {}; $("s-tool-searchProvider").value = ts.provider || "brave"; $("s-tool-searchKey").value = ts.apiKey || ""; $("s-tool-searchMax").value = ts.maxResults ?? 5; $("s-tool-proxy").value = tw.proxy || ""; const te = cfg.tools?.exec || {}; $("s-tool-execEnable").checked = te.enable !== false; $("s-tool-execTimeout").value = te.timeout ?? 60; $("s-tool-restrict").checked = !!cfg.tools?.restrictToWorkspace; const gw = cfg.gateway || {}; $("s-gw-host").value = gw.host || "127.0.0.1"; $("s-gw-port").value = gw.port ?? 19999; const hb = gw.heartbeat || {}; $("s-hb-enabled").checked = hb.enabled !== false; $("s-hb-interval").value = hb.intervalMin ?? 30; $("s-hb-profile").value = hb.profileId || ""; const ch = cfg.channels || {}; const targetChanSelect = $("s-hb-target-channel"); if (targetChanSelect) { let html = ''; html += ''; for (const [name, cc] of Object.entries(ch)) { if (["sendProgress", "sendToolHints"].includes(name) || typeof cc !== "object") continue; if (cc.enabled === true) { const displayName = name.charAt(0).toUpperCase() + name.slice(1); html += ``; } } targetChanSelect.innerHTML = html; } const targets = Object.keys(hb.targets || {}); if (targets.length > 0) { const firstChan = targets[0]; if (targetChanSelect && targetChanSelect.querySelector(`option[value="${firstChan}"]`)) { targetChanSelect.value = firstChan; } else if (targetChanSelect) { // Add it if it's currently selected but disabled, so it doesn't just disappear targetChanSelect.innerHTML += ``; targetChanSelect.value = firstChan; } $("s-hb-target-id").value = hb.targets[firstChan] || ""; } else { if (targetChanSelect) targetChanSelect.value = ""; $("s-hb-target-id").value = ""; } $("s-ch-sendProgress").checked = ch.sendProgress !== false; $("s-ch-sendToolHints").checked = !!ch.sendToolHints; const detail = $("channels-detail"); detail.innerHTML = ""; const skip = ["sendProgress", "sendToolHints"]; const EMAIL_FIELD_CONFIG = { imapHost: { label: "IMAP Server", section: "inbound", type: "text", placeholder: "imap.gmail.com" }, imapPort: { label: "IMAP Port", section: "inbound", type: "number", placeholder: "993" }, imapUsername: { label: "IMAP Username", section: "inbound", type: "text", placeholder: "email@gmail.com" }, imapPassword: { label: "IMAP Password", section: "inbound", type: "password", placeholder: "App password" }, imapUseSsl: { label: "IMAP SSL", section: "inbound", type: "boolean" }, imapMailbox: { label: "IMAP Mailbox", section: "inbound", type: "text", placeholder: "INBOX" }, smtpHost: { label: "SMTP Server", section: "outbound", type: "text", placeholder: "smtp.gmail.com" }, smtpPort: { label: "SMTP Port", section: "outbound", type: "number", placeholder: "587" }, smtpUsername: { label: "SMTP Username", section: "outbound", type: "text", placeholder: "email@gmail.com" }, smtpPassword: { label: "SMTP Password", section: "outbound", type: "password", placeholder: "App password" }, smtpUseTls: { label: "SMTP STARTTLS", section: "outbound", type: "boolean" }, smtpUseSsl: { label: "SMTP SSL", section: "outbound", type: "boolean" }, fromAddress: { label: "From Address", section: "outbound", type: "text", placeholder: "shibaclaw@gmail.com" }, autoReplyEnabled: { label: "Auto Reply", section: "general", type: "boolean" }, pollIntervalSeconds: { label: "Poll Interval (sec)", section: "general", type: "number", placeholder: "30" }, markSeen: { label: "Mark as Read", section: "general", type: "boolean" }, maxBodyChars: { label: "Max Body Length", section: "general", type: "number", placeholder: "12000" }, subjectPrefix: { label: "Reply Prefix", section: "general", type: "text", placeholder: "Re: " }, allowFrom: { label: "Allowed Senders", section: "general", type: "array", placeholder: "email1@test.com, email2@test.com" }, }; for (const [name, cc] of Object.entries(ch)) { if (skip.includes(name) || typeof cc !== "object") continue; const enabled = cc.enabled === true; const displayName = name.charAt(0).toUpperCase() + name.slice(1); const card = document.createElement("div"); card.className = "accordion"; let fieldsHtml = `
`; if (name === "email") { fieldsHtml += `
Required to allow ShibaClaw to read and send emails on your behalf
`; } if (name === "email" && EMAIL_FIELD_CONFIG) { const sections = { inbound: [], outbound: [], general: [] }; for (const [key, val] of Object.entries(cc)) { if (key === "enabled" || key === "consentGranted" || key === "consent_granted") continue; const fieldConfig = EMAIL_FIELD_CONFIG[key] || EMAIL_FIELD_CONFIG[key.replace(/([A-Z])/g, (m) => m.toLowerCase())] || null; const section = fieldConfig?.section || "general"; const label = fieldConfig?.label || key; const inputType = fieldConfig?.type || "text"; const placeholder = fieldConfig?.placeholder || ""; let valStr = ""; let originalType = typeof val; if (Array.isArray(val)) { originalType = "array"; valStr = val.join(", "); } else if (val !== null && originalType === "object") { originalType = "object"; valStr = JSON.stringify(val); } else { if (val === null) originalType = "string"; valStr = val === null ? "" : String(val); } let inputHtml = ""; if (originalType === "boolean" || fieldConfig?.type === "boolean") { inputHtml = `
`; } else { const isPassword = fieldConfig?.type === "password" || key.toLowerCase().includes("password") || key.toLowerCase().includes("secret"); const safeVal = String(valStr).replace(/"/g, '"'); inputHtml = `
`; } if (!sections[section]) sections[section] = []; sections[section].push(inputHtml); } const sectionLabels = { inbound: '📥 Email IN (IMAP)', outbound: '📤 Email OUT (SMTP)', general: '⚙️ General' }; for (const [sectionKey, sectionFields] of Object.entries(sections)) { if (sectionFields.length > 0) { fieldsHtml += `
${sectionLabels[sectionKey] || sectionKey}
`; fieldsHtml += sectionFields.join(""); } } } else { for (const [key, val] of Object.entries(cc)) { if (key === "enabled" || key === "consentGranted" || key === "consent_granted") continue; let inputType = "text"; let valStr = ""; let originalType = typeof val; if (Array.isArray(val)) { originalType = "array"; valStr = val.join(", "); } else if (val !== null && originalType === "object") { originalType = "object"; valStr = JSON.stringify(val); } else { if (val === null) originalType = "string"; valStr = val === null ? "" : String(val); } if (originalType === "boolean") { fieldsHtml += `
`; continue; } const lowerKey = key.toLowerCase(); if (lowerKey.includes("token") || lowerKey.includes("secret") || lowerKey.includes("password")) { inputType = "password"; } const safeVal = String(valStr).replace(/"/g, '"'); fieldsHtml += `
`; } } const iconMap = { telegram: "send", discord: "forum", slack: "tag", whatsapp: "chat", webui: "language", cli: "terminal", email: "email", }; const iconName = iconMap[name] || "chat"; card.innerHTML = `
${iconName} ${displayName}
${enabled ? 'ON' : 'OFF'} expand_more
${fieldsHtml}
`; detail.appendChild(card); } const mcpServers = cfg.tools?.mcpServers || {}; const mcpList = $("mcp-servers-list"); mcpList.innerHTML = ""; const entries = Object.entries(mcpServers); if (entries.length === 1 && entries[0][0] === "mcp") { const note = document.createElement("div"); note.className = "settings-note"; note.innerHTML = "Nota: Questo è un esempio di server MCP. Modifica direttamente questo blocco per configurare il tuo server personalizzato."; mcpList.appendChild(note); } for (const [name, sc] of entries) { mcpList.appendChild(buildMcpServerCard(name, sc)); } if (entries.length === 0) { const card = buildMcpServerCard("", { args: [], enabled_tools: ["*"], tool_timeout: 30 }); card.classList.add("open"); mcpList.appendChild(card); } } function buildMcpServerCard(name, sc) { const card = document.createElement("div"); card.className = "accordion mcp-server-card"; const escName = name.replace(/"/g, """); card.innerHTML = `
hub ${escName}
expand_more
`; return card; } function collectMcpServers() { const result = {}; document.querySelectorAll(".mcp-server-card").forEach(card => { const name = card.querySelector(".mcp-name").value.trim(); if (!name) return; const parseJson = val => { try { return JSON.parse(val || "{}"); } catch { return {}; } }; result[name] = { type: card.querySelector(".mcp-type").value || null, command: card.querySelector(".mcp-command").value, args: card.querySelector(".mcp-args").value ? card.querySelector(".mcp-args").value.split(",").map(s => s.trim()).filter(Boolean) : [], url: card.querySelector(".mcp-url").value, headers: parseJson(card.querySelector(".mcp-headers").value), env: parseJson(card.querySelector(".mcp-env").value), tool_timeout: parseInt(card.querySelector(".mcp-timeout").value) || 30, enabled_tools: card.querySelector(".mcp-tools").value ? card.querySelector(".mcp-tools").value.split(",").map(s => s.trim()).filter(Boolean) : ["*"], }; }); return result; } window.addMcpServer = function () { const card = buildMcpServerCard("", { args: [], enabled_tools: ["*"], tool_timeout: 30 }); card.classList.add("open"); $("mcp-servers-list").appendChild(card); card.querySelector(".mcp-name").focus(); }; window.removeMcpServer = function (btn) { btn.closest(".mcp-server-card").remove(); }; window.saveSettings = async function () { const patch = { agents: { defaults: { provider: "auto", model: $("s-agent-model").value, consolidationModel: $("s-agent-consolidationModel").value || null, temperature: parseFloat($("s-agent-temp").value), maxTokens: parseInt($("s-agent-maxTokens").value), contextWindowTokens: parseInt($("s-agent-ctxTokens").value), maxToolIterations: parseInt($("s-agent-maxIter").value), workspace: $("s-agent-workspace").value, reasoningEffort: $("s-agent-reasoning").value || null, pinnedSkills: window._skillsPinnedList || [], maxPinnedSkills: window._skillsMaxPinned || 5, } }, providers: {}, tools: { web: { proxy: $("s-tool-proxy").value || null, search: { provider: $("s-tool-searchProvider").value, apiKey: $("s-tool-searchKey").value, maxResults: parseInt($("s-tool-searchMax").value), } }, exec: { enable: $("s-tool-execEnable").checked, timeout: parseInt($("s-tool-execTimeout").value), }, restrictToWorkspace: $("s-tool-restrict").checked, mcpServers: collectMcpServers(), }, gateway: { host: $("s-gw-host").value, port: parseInt($("s-gw-port").value), heartbeat: { enabled: $("s-hb-enabled").checked, intervalMin: parseInt($("s-hb-interval").value), model: $("s-hb-model").value || null, profileId: $("s-hb-profile").value || null, targets: (() => { const chan = $("s-hb-target-channel").value; const tid = $("s-hb-target-id").value; if (chan) { return { [chan]: tid }; } return {}; })() } }, channels: { sendProgress: $("s-ch-sendProgress").checked, sendToolHints: $("s-ch-sendToolHints").checked, }, audio: { providerUrl: $("s-audio-providerUrl").value || null, apiKey: $("s-audio-apiKey").value || null, model: $("s-audio-model").value || "whisper-large-v3-turbo", ttsEnabled: $("tts-toggle").checked, } }; document.querySelectorAll(".prov-key").forEach(el => { const name = el.dataset.prov; if (!patch.providers[name]) patch.providers[name] = {}; patch.providers[name].apiKey = el.value.trim(); }); document.querySelectorAll(".prov-base").forEach(el => { const name = el.dataset.prov; if (!patch.providers[name]) patch.providers[name] = {}; const value = el.value.trim(); patch.providers[name].apiBase = value || null; }); document.querySelectorAll(".ch-enabled").forEach(el => { const name = el.dataset.ch; if (!patch.channels[name]) patch.channels[name] = {}; patch.channels[name].enabled = el.checked; }); document.querySelectorAll(".ch-field").forEach(el => { const name = el.dataset.ch; const key = el.dataset.key; const type = el.dataset.type; if (!patch.channels[name]) patch.channels[name] = {}; let val; if (type === "boolean") { val = el.checked; } else if (type === "array") { val = el.value ? el.value.split(",").map(s => s.trim()).filter(s => s) : []; } else if (type === "object") { try { val = JSON.parse(el.value); } catch (e) { val = {}; } } else if (type === "number") { val = Number(el.value); } else { val = el.value; } patch.channels[name][key] = val; }); try { const res = await authFetch("/api/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(patch) }); const data = await res.json(); if (!res.ok) throw data.error || "Save failed"; closeModal("settings-modal"); _availableModels = []; // Clear model cache to force refresh fetchStatus(); if (data.restarted) { shibaDialog("alert", "Restart Required", "Gateway is restarting to apply network changes.", { confirmText: "OK" }); } else { // Hot-reloaded successfully without restarting let container = document.getElementById("toast-container"); if (!container) { container = document.createElement("div"); container.id = "toast-container"; document.body.appendChild(container); } const toast = document.createElement("div"); toast.className = "toast toast-success"; toast.innerHTML = `check_circle Settings saved & hot-reloaded successfully!`; container.appendChild(toast); setTimeout(() => { toast.classList.add("visible"); }, 100); setTimeout(() => { toast.classList.remove("visible"); toast.classList.add("hiding"); setTimeout(() => toast.remove(), 300); }, 3000); } } catch (e) { shibaDialog("alert", "Error", "Error saving settings: " + e, { confirmText: "Close", danger: true }); } }; // ── UI Helpers ──────────────────────────────────────────────── function activateChat() { welcomeScreen.style.display = "none"; chatHistory.classList.add("active"); } function showThinking(text) { hideTypingBubble(); thinkingIndicator.classList.add("active"); thinkingText.textContent = truncate(text, 80); } function hideThinking() { thinkingIndicator.classList.remove("active"); thinkingText.textContent = "Thinking..."; } // ── Login/Logout UI ─────────────────────────────────────────── function syncFooterActions() { const logoutBtn = document.getElementById("btn-logout"); if (logoutBtn) logoutBtn.hidden = !state.authRequired; } function showLogin(errorMsg = "") { const overlay = document.getElementById("login-overlay"); const appContainer = document.getElementById("app-container"); const errorEl = document.getElementById("login-error"); const tokenInput = document.getElementById("login-token"); overlay.style.display = "flex"; appContainer.style.display = "none"; if (errorMsg) { errorEl.textContent = errorMsg; errorEl.style.display = "block"; // Shake animation const card = overlay.querySelector(".login-card"); card.classList.remove("shake"); void card.offsetWidth; // force reflow card.classList.add("shake"); } else { errorEl.style.display = "none"; } setTimeout(() => tokenInput.focus(), 100); } function hideLogin() { const overlay = document.getElementById("login-overlay"); const appContainer = document.getElementById("app-container"); overlay.style.display = "none"; appContainer.style.display = ""; } async function attemptLogin(token) { try { const res = await fetch("/api/auth/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }), }); const data = await res.json(); if (data.valid) { setStoredToken(token); hideLogin(); startApp(); return true; } else { showLogin("Invalid token. Check the terminal output."); return false; } } catch (e) { showLogin("Connection error. Is the server running?"); return false; } } function logout() { clearStoredToken(); _clearAllOAuthPolls(); if (state.socket) { state.socket.disconnect({ clearToken: true }); state.socket = null; } if (state.healthTimer) { clearInterval(state.healthTimer); state.healthTimer = null; } if (state.historyTimer) { clearInterval(state.historyTimer); state.historyTimer = null; } if (state.autoTimer) { clearInterval(state.autoTimer); state.autoTimer = null; } state._initialConnectDone = false; state.contextModalOpen = false; state.processing = false; state.sessionId = null; state.sessionLoadSeq++; if (typeof resetNotificationCenter === "function") { resetNotificationCenter(); } setStatusIndicator("disconnected"); const logoutBtn = document.getElementById("btn-logout"); if (logoutBtn) logoutBtn.hidden = true; showLogin(); } function startApp() { initSocket(); initListeners(); fetchStatus(); loadHistory(); initAutomationSections(); refreshTokenBadge(); initFileHandlers(); initOnboardWizard(); if (typeof initNotificationCenter === "function") { void initNotificationCenter(); } chatInput.focus(); syncFooterActions(); // Gateway health check every 5s checkGatewayHealth(); if (state.healthTimer) clearInterval(state.healthTimer); state.healthTimer = setInterval(checkGatewayHealth, 5000); // Auto-refresh history every 30s if (state.historyTimer) clearInterval(state.historyTimer); state.historyTimer = setInterval(loadHistory, 30000); // Auto-refresh automation every 30s if (state.autoTimer) clearInterval(state.autoTimer); state.autoTimer = setInterval(() => { loadCronSection(); loadHeartbeatSection(); }, 30000); } // ── Update Panel ────────────────────────────────────────────── let _updateState = { manifestUrl: null, manifest: null, result: null, busy: false, commands: {} }; function _updateValue(data, key) { return (data && data[key]) ? data[key] : "-"; } function _renderUpdateManifestSection(manifest, personalFiles) { let section = ""; if (manifest && manifest.release_notes) { section += `
article What's new
${escapeHtml(manifest.release_notes)}
`; } if (personalFiles && personalFiles.length > 0) { const items = personalFiles.map(file => { const note = file.note ? ` - ${escapeHtml(file.note)}` : ""; return `
  • description ${escapeHtml(file.path)}${note}
  • `; }).join(""); section += `
    folder_open Files changed by this release
    If you customized any of these tracked files, back them up before updating. After the update, run shibaclaw onboard again to refresh them. If you keep personal information in these files, save a copy first so you can restore it afterward.
    `; } return section; } function _renderUpdateActionSection(data) { const actionCommand = (data.action_command || "").trim(); const actionUrl = (data.action_url || data.release_url || "").trim(); const actionLabel = escapeHtml(data.action_label || "Suggested action"); const notes = Array.isArray(data.notes) ? data.notes : []; _updateState.commands = { action: actionCommand }; const commandRow = actionCommand ? `
    Command
    ${escapeHtml(actionCommand)}
    ` : ""; const notesHtml = notes.length ? ` ` : ""; const buttons = []; if (data.update_available && data.action_kind === "automatic") { buttons.push(` `); } if (actionUrl) { buttons.push(` open_in_new ${actionLabel} `); } if (data.release_url && data.release_url !== actionUrl) { buttons.push(` article Release notes `); } if (!commandRow && buttons.length === 0 && !notesHtml) { return ""; } return `
    terminal How to update
    ${commandRow} ${notesHtml} ${buttons.length ? `
    ${buttons.join("")}
    ` : ""}
    `; } window.copyUpdateCommand = async function (key) { const value = ((_updateState.commands || {})[key] || "").trim(); if (!value) return; try { await navigator.clipboard.writeText(value); } catch (e) { console.error("copyUpdateCommand", e); } }; window.runUpdateAction = async function () { const panel = $("update-status-container"); const update = _updateState.result; if (!panel || !update || _updateState.busy) return; if (update.action_kind !== "automatic") return; const confirmed = await shibaDialog( "confirm", "Apply update?", "ShibaClaw will restart after a successful update.", { confirmText: "Update" } ); if (!confirmed) return; _updateState.busy = true; panel.innerHTML = `
    progress_activity Applying update...
    `; try { const res = await authFetch("/api/update/apply", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ update, manifest: _updateState.manifest }), }); const report = await res.json(); if (!res.ok || report.error) { throw new Error(report.error || report.message || `HTTP ${res.status}`); } const pipOutput = report.pip && report.pip.output ? escapeHtml(report.pip.output) : ""; const message = escapeHtml(report.message || "Update complete."); const icon = report.pip && report.pip.ok ? "check_circle" : "error_outline"; const color = report.pip && report.pip.ok ? "var(--accent-green)" : "var(--accent-red)"; const footer = report.restarting ? '
    Restarting ShibaClaw now...
    ' : '
    '; panel.innerHTML = `
    ${icon}
    ${message}
    ${pipOutput ? `
    terminal Installer output
    ${pipOutput}
    ` : ""} ${footer}
    `; } catch (e) { panel.innerHTML = `
    error_outline ${escapeHtml(e.message || "Failed to apply the update.")}
    `; } finally { _updateState.busy = false; } }; async function loadUpdatePanel(force = false) { const panel = $("update-status-container"); if (!panel) return; _updateState.manifestUrl = null; _updateState.manifest = null; _updateState.result = null; _updateState.commands = {}; panel.innerHTML = `
    progress_activity Checking for updates...
    `; try { const url = "/api/update/check" + (force ? "?force=1" : ""); const res = await authFetch(url); const data = await res.json(); if (data.error && !data.current) { panel.innerHTML = `
    error_outline ${escapeHtml(data.error)}
    `; return; } _updateState.result = data; const checkedAt = data.checked_at ? new Date(data.checked_at * 1000).toLocaleString() : "-"; const displayCurrent = escapeHtml(_updateValue(data, "display_current") || _updateValue(data, "current")); const displayLatest = escapeHtml(_updateValue(data, "display_latest") || _updateValue(data, "latest")); const summary = escapeHtml(data.summary || (data.update_available ? "Update available." : "You're up to date.")); let manifestSection = ""; if (data.manifest_url && data.update_available) { _updateState.manifestUrl = data.manifest_url; try { const mRes = await authFetch("/api/update/manifest?url=" + encodeURIComponent(data.manifest_url)); const mData = await mRes.json(); _updateState.manifest = mData.manifest || null; manifestSection = _renderUpdateManifestSection(_updateState.manifest, mData.personal_files || []); } catch (e) { manifestSection = `
    Could not load update details.
    `; } } const actionSection = _renderUpdateActionSection(data); const warningSection = data.error ? `
    warning Check warning
    ${escapeHtml(data.error)}
    ` : ""; const headline = data.update_available ? "Update available" : "Status checked"; const icon = data.update_available ? "system_update" : "check_circle"; const iconColor = data.update_available ? "var(--accent-orange)" : "var(--accent-green)"; const versionRow = data.update_available ? `
    ${displayCurrent} arrow_forward ${displayLatest}
    ` : `
    ${displayCurrent}
    `; panel.innerHTML = `
    ${icon}
    ${headline}
    ${summary}
    ${versionRow} ${manifestSection} ${warningSection} ${actionSection}
    Last checked: ${checkedAt}${data.stale ? " (cached)" : ""} ·
    `; } catch (e) { panel.innerHTML = `
    error_outline Failed to check for updates.
    `; } } // ── Onboard Wizard ────────────────────────────────────────── const _ob = { step: 1, provider: null, providers: [], templates: { existing: [] } }; function initOnboardWizard() { if (state.onboardInitialized) return; state.onboardInitialized = true; const eye = document.getElementById("ob-eye-toggle"); const keyInput = document.getElementById("ob-api-key"); if (eye && keyInput) { eye.addEventListener("click", () => { const show = keyInput.type === "password"; keyInput.type = show ? "text" : "password"; eye.querySelector("span").textContent = show ? "visibility" : "visibility_off"; }); } } window.openOnboardWizard = async function () { _ob.step = 1; _ob.provider = null; _ob._lastModelProvider = null; document.getElementById("ob-api-key").value = ""; document.getElementById("ob-model-input").value = ""; document.getElementById("ob-btn-finish").style.width = ""; _obShowStep(1); openModal("onboard-modal"); await _obLoadProviders(); await _obLoadTemplates(); }; async function _obLoadProviders() { const grid = document.getElementById("ob-provider-grid"); grid.innerHTML = '
    progress_activity
    '; try { const res = await authFetch("/api/onboard/providers"); const data = await res.json(); _ob.providers = data.providers || []; _ob.currentProvider = data.current_provider; _ob.currentModel = data.current_model; _obRenderGrid(); } catch (e) { grid.innerHTML = '

    Failed to load providers

    '; } } async function _obLoadTemplates() { try { const res = await authFetch("/api/onboard/templates"); const data = await res.json(); _ob.templates = { existing: data.existing_files || [], new_files: data.new_files || [] }; } catch (e) { _ob.templates = { existing: [], new_files: [] }; } } function _obRenderGrid() { const grid = document.getElementById("ob-provider-grid"); grid.innerHTML = ""; const ICONS = { openrouter: "route", anthropic: "psychology", openai: "auto_awesome", gemini: "diamond", deepseek: "explore", groq: "speed", ollama: "dns", github_copilot: "code" }; for (const p of _ob.providers) { const card = document.createElement("div"); card.className = "provider-card" + (p.name === _ob.currentProvider ? " selected" : ""); card.dataset.name = p.name; let badge = ""; if (p.status === "env_detected") badge = 'ENV'; else if (p.status === "configured") badge = 'Configured'; else if (p.status === "oauth_ok") badge = 'OAuth \u2713'; else if (p.is_local) badge = 'Local'; // Remove the default OAuth badge that was shown even when not authenticated const icon = ICONS[p.name] || "smart_toy"; card.innerHTML = `
    ${icon}
    ${p.label}${badge}
    ${p.env_key ? 'env: ' + p.env_key : (p.is_local ? 'No key needed' : (p.is_oauth ? 'OAuth login' : ''))}
    `; card.addEventListener("click", () => { grid.querySelectorAll(".provider-card").forEach(c => c.classList.remove("selected")); card.classList.add("selected"); _ob.provider = p; }); if (p.name === _ob.currentProvider) _ob.provider = p; grid.appendChild(card); } } function _obShowStep(n) { _ob.step = n; for (let i = 1; i <= 4; i++) { const panel = document.getElementById("ob-step-" + i); if (panel) panel.style.display = i === n ? "" : "none"; const dot = document.querySelector(`.ob-step[data-step="${i}"]`); if (dot) { dot.classList.toggle("active", i === n); dot.classList.toggle("done", i < n); } } document.getElementById("ob-btn-back").style.display = n > 1 ? "" : "none"; document.getElementById("ob-btn-next").style.display = n < 4 ? "" : "none"; document.getElementById("ob-btn-finish").style.display = n === 4 ? "" : "none"; if (n === 2) _obSetupStep2(); if (n === 3) _obSetupStep3(); if (n === 4) _obSetupStep4(); } function _obNormalizeModelValue(providerName, modelId) { const raw = (modelId || "").trim(); if (!raw || !providerName) return raw; const prefix = `${providerName}/`; return raw.startsWith(prefix) ? raw.slice(prefix.length) : raw; } function _obSetupStep2() { const p = _ob.provider; _clearOAuthPollsByPrefix("onboard:"); if (!p) return; const keySection = document.getElementById("ob-key-section"); const oauthSection = document.getElementById("ob-oauth-section"); const localSection = document.getElementById("ob-local-section"); keySection.style.display = "none"; oauthSection.style.display = "none"; localSection.style.display = "none"; if (p.is_local) { localSection.style.display = ""; } else if (p.is_oauth || p.name === "openrouter") { oauthSection.style.display = ""; if (p.name === "openrouter") { keySection.style.display = ""; document.getElementById("ob-key-title").textContent = p.label + " \u2014 API Key or OAuth"; document.getElementById("ob-key-hint").textContent = "You can enter your API key below, or use the browser OAuth login."; if (p.status === "env_detected" || p.status === "configured") { document.getElementById("ob-api-key").placeholder = "Leave blank to keep current key"; } else { document.getElementById("ob-api-key").value = ""; document.getElementById("ob-api-key").placeholder = providerKeyPlaceholder(p.name); } } else { document.getElementById("ob-key-title").textContent = p.label + " \u2014 OAuth"; } const btn = document.getElementById("ob-oauth-btn"); const statusEl = document.getElementById("ob-oauth-status"); if (p.status === "oauth_ok") { statusEl.innerHTML = 'check_circle Already authenticated'; } else { statusEl.innerHTML = ""; btn.style.width = ""; btn.innerHTML = p.name === "openrouter" ? 'route Login with OpenRouter' : 'lock_open Start OAuth Setup'; btn.onclick = async () => { btn.style.width = btn.offsetWidth + "px"; btn.disabled = true; btn.innerHTML = 'progress_activity Starting...'; try { const resp = await authFetch("/api/oauth/login", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider: p.name }) }); const jd = await resp.json(); if (jd.auth_url) { try { const newWindow = window.open(jd.auth_url, '_blank', 'width=600,height=800'); if (!newWindow) throw new Error("Popup blocked"); statusEl.innerHTML = '
    ' + '
    OpenRouter will return here automatically when the authorization is complete.
    ' + 'progress_activity Waiting for auth...
    '; } catch (ex) { statusEl.innerHTML = `
    ` + `` + `open_in_new Click here if popup is blocked` + `
    ` + `progress_activity Waiting for auth...
    `; } } else if (jd.user_code && jd.verification_uri) { statusEl.innerHTML = '
    ' + '' + 'open_in_new Open GitHub' + '
    ' + jd.user_code + '
    ' + '
    ' + 'progress_activity Waiting for auth...
    '; } if (jd.job_id) { const pollScope = "onboard:" + p.name; _startOAuthJobPoll(pollScope, jd.job_id, async (job) => { if (job.status === "done") { statusEl.innerHTML = 'check_circle Authenticated!'; btn.disabled = false; btn.innerHTML = 'check Done'; if (p.name === "openrouter") { document.getElementById("ob-api-key").value = ""; document.getElementById("ob-api-key").placeholder = "Authenticated via OAuth"; } return true; } if (job.status === "error") { statusEl.innerHTML = 'Authentication failed'; btn.disabled = false; btn.innerHTML = p.name === "openrouter" ? 'lock_open Retry' : 'lock_open Retry'; return true; } return false; }); } } catch (e) { statusEl.innerHTML = 'Error: ' + e + ''; btn.disabled = false; btn.innerHTML = p.name === "openrouter" ? 'lock_open Retry' : 'lock_open Retry'; } }; } } else { keySection.style.display = ""; document.getElementById("ob-key-title").textContent = p.label + " \u2014 API Key"; document.getElementById("ob-key-hint").textContent = p.env_key ? "You can also set the " + p.env_key + " environment variable." : ""; if (p.status === "env_detected" || p.status === "configured") { document.getElementById("ob-api-key").placeholder = "Leave blank to keep current key"; } else { document.getElementById("ob-api-key").value = ""; document.getElementById("ob-api-key").placeholder = providerKeyPlaceholder(p.name); } } } function _obSetupStep3() { const p = _ob.provider; if (!p) return; document.getElementById("ob-model-hint").textContent = "Provider: " + p.label + ". Check the provider docs for available models."; const modelInput = document.getElementById("ob-model-input"); const currentModel = (_ob.currentProvider === p.name) ? _obNormalizeModelValue(p.name, _ob.currentModel) : ""; const defaultModel = p.name === "openrouter" ? "google/gemma-4-31b-it:free" : _obNormalizeModelValue(p.name, p.default_model); if (!modelInput.value || _ob._lastModelProvider !== p.name) { _ob._lastModelProvider = p.name; modelInput.value = currentModel || defaultModel; } const wrapper = document.getElementById("ob-model-selector-wrapper"); const menu = document.getElementById("ob-model-dropdown-menu"); const list = document.getElementById("ob-model-list-container"); // Load models ensureAvailableModels(list).then(() => { _obRenderModelDropdown(modelInput.value); }); if (wrapper._closeDropdownListener) { document.removeEventListener("click", wrapper._closeDropdownListener); } const closeDropdown = (e) => { if (!wrapper.contains(e.target)) { menu.style.display = "none"; } }; wrapper._closeDropdownListener = closeDropdown; document.addEventListener("click", closeDropdown); modelInput.onfocus = () => { _obRenderModelDropdown(modelInput.value); menu.style.display = "block"; }; modelInput.oninput = () => { _obRenderModelDropdown(modelInput.value); menu.style.display = "block"; }; } function _obRenderModelDropdown(query) { const p = _ob.provider; if (!p) return; const list = document.getElementById("ob-model-list-container"); if (!list) return; let filtered = filterModelsByQuery(query); filtered = filtered.filter(m => m.provider === p.name); const currentModelId = _obNormalizeModelValue(p.name, document.getElementById("ob-model-input").value); const onboardModels = filtered.map(m => ({ ...m, id: _obNormalizeModelValue(p.name, m.raw_id || m.id), })); renderModelList(list, onboardModels, currentModelId, (m) => { document.getElementById("ob-model-input").value = m.id; document.getElementById("ob-model-dropdown-menu").style.display = "none"; }); } function _obSetupStep4() { const p = _ob.provider; const modelValue = p ? _obNormalizeModelValue(p.name, document.getElementById("ob-model-input").value) : document.getElementById("ob-model-input").value; document.getElementById("ob-sum-provider").textContent = p ? p.label : "\u2014"; document.getElementById("ob-sum-model").textContent = modelValue || "\u2014"; const tplSection = document.getElementById("ob-tpl-section"); const tplList = document.getElementById("ob-tpl-list"); if (_ob.templates.existing.length > 0) { tplSection.style.display = ""; tplList.innerHTML = ""; for (const f of _ob.templates.existing) { const item = document.createElement("label"); item.className = "ob-tpl-item"; item.innerHTML = ' description ' + f; tplList.appendChild(item); } } else { tplSection.style.display = "none"; } } window.obGoStep = function (dir) { let next = _ob.step + dir; if (next < 1) return; if (_ob.step === 1 && dir > 0 && !_ob.provider) { const grid = document.getElementById("ob-provider-grid"); grid.style.animation = "none"; grid.offsetHeight; grid.style.animation = "shake 0.3s"; return; } if (next === 2 && dir > 0 && _ob.provider && _ob.provider.is_local) { next = 3; } if (next === 2 && dir < 0 && _ob.provider && _ob.provider.is_local) { next = 1; } if (next > 4) return; _obShowStep(next); }; window.obSubmit = async function () { const btn = document.getElementById("ob-btn-finish"); btn.style.width = btn.offsetWidth + "px"; btn.disabled = true; btn.innerHTML = 'progress_activity Saving...'; const modelValue = _ob.provider ? _obNormalizeModelValue(_ob.provider.name, document.getElementById("ob-model-input").value) : document.getElementById("ob-model-input").value.trim(); const overwrite = []; document.querySelectorAll("#ob-tpl-list input:checked").forEach(cb => overwrite.push(cb.value)); try { const res = await authFetch("/api/onboard/submit", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ provider: _ob.provider.name, api_key: document.getElementById("ob-api-key").value.trim(), model: modelValue, overwrite_templates: overwrite, }) }); const data = await res.json(); if (!res.ok) throw data.error || "Setup failed"; btn.style.width = ""; closeModal("onboard-modal"); state.onboardModalShown = false; _availableModels = []; // Clear model cache to force refresh fetchStatus(); loadHistory(); } catch (e) { btn.style.width = ""; btn.disabled = false; btn.innerHTML = 'check Finish Setup'; await shibaDialog("alert", "Error", "Setup failed: " + e, { danger: true }); } }; /* ── Model Selector (Chat Window) ────────────────────────────────── */ let _availableModels = []; const SETTINGS_MODEL_PICKERS = [ { valueId: "s-agent-model", buttonId: "s-agent-model-button", displayId: "s-agent-model-display", providerId: "s-agent-model-provider", menuId: "s-agent-model-menu", searchId: "s-agent-model-search", listId: "s-agent-model-list", emptyLabel: "Select a default model", emptyProvider: "New sessions", emptyChoiceLabel: null, emptyChoiceProvider: null, allowEmpty: false, }, { valueId: "s-agent-consolidationModel", buttonId: "s-agent-consolidationModel-button", displayId: "s-agent-consolidationModel-display", providerId: "s-agent-consolidationModel-provider", menuId: "s-agent-consolidationModel-menu", searchId: "s-agent-consolidationModel-search", listId: "s-agent-consolidationModel-list", emptyLabel: "Same as default session model", emptyProvider: "Inherits", emptyChoiceLabel: "Same as default session model", emptyChoiceProvider: "Inherits", allowEmpty: true, }, { valueId: "s-hb-model", buttonId: "s-hb-model-button", displayId: "s-hb-model-display", providerId: "s-hb-model-provider", menuId: "s-hb-model-menu", searchId: "s-hb-model-search", listId: "s-hb-model-list", emptyLabel: "Same as default model", emptyProvider: "Inherits", emptyChoiceLabel: "Same as default model", emptyChoiceProvider: "Inherits", allowEmpty: true, }, ]; let _settingsModelPickersInitialized = false; async function fetchModels() { try { const res = await authFetch("/api/models"); const data = await res.json(); if (!res.ok) { throw new Error(data.error || "Failed to fetch models"); } if (Array.isArray(data.errors) && data.errors.length) { console.warn("Some providers failed to return models", data.errors); } return data.models || []; } catch (e) { console.error("Failed to fetch models", e); return []; } } async function ensureAvailableModels(listEl = null) { if (_availableModels.length) { return _availableModels; } if (listEl) { listEl.innerHTML = '
    Loading models...
    '; } _availableModels = await fetchModels(); return _availableModels; } function filterModelsByQuery(query) { const q = (query || "").trim().toLowerCase(); if (!q) { return _availableModels.slice(); } return _availableModels.filter(m => (m.name || "").toLowerCase().includes(q) || (m.raw_id || m.id || "").toLowerCase().includes(q) || (m.provider_label || "").toLowerCase().includes(q) || (m.provider || "").toLowerCase().includes(q) ); } function findAvailableModel(modelId) { if (!modelId) { return null; } return _availableModels.find(m => m.id === modelId || m.raw_id === modelId) || null; } function createModelListItem(model, currentModelId, onSelect) { const item = document.createElement("div"); item.className = "model-item" + (model.id === currentModelId ? " selected" : ""); const nameEl = document.createElement("span"); nameEl.className = "model-item-name"; nameEl.textContent = model.name || model.raw_id || model.id || ""; const providerEl = document.createElement("span"); providerEl.className = "model-item-provider"; providerEl.textContent = model.provider_label || model.provider || ""; item.appendChild(nameEl); item.appendChild(providerEl); item.title = [model.raw_id || model.id || "", model.provider_label || model.provider || ""].filter(Boolean).join(" • "); item.addEventListener("click", (e) => { e.stopPropagation(); onSelect(model); }); return item; } function renderModelList(list, models, currentModelId, onSelect, extraItems = []) { list.innerHTML = ""; const allItems = [...extraItems, ...models]; if (!allItems.length) { list.innerHTML = '
    No models found
    '; return; } allItems.forEach(model => list.appendChild(createModelListItem(model, currentModelId, onSelect))); } async function updateModelSelectorDisplay(modelId) { const display = document.getElementById("active-model-display"); if (!display) return; let resolvedModelId = modelId; if (!resolvedModelId) { try { const cfgRes = await authFetch("/api/settings"); const cfg = await cfgRes.json(); resolvedModelId = cfg.agents?.defaults?.model || ""; } catch (e) { } } state.activeModelId = resolvedModelId || ""; await ensureAvailableModels(); const match = findAvailableModel(resolvedModelId); display.textContent = match ? (match.name || match.raw_id || match.id) : (resolvedModelId || "Default"); } function closeSettingsModelMenus(exceptMenu = null) { SETTINGS_MODEL_PICKERS.forEach(cfg => { const menu = document.getElementById(cfg.menuId); if (menu && menu !== exceptMenu) { menu.style.display = "none"; } }); } async function updateSettingsModelPickerDisplay(config) { const input = document.getElementById(config.valueId); const display = document.getElementById(config.displayId); const provider = document.getElementById(config.providerId); if (!input || !display || !provider) { return; } const value = input.value.trim(); if (!value && config.allowEmpty) { display.textContent = config.emptyLabel; provider.textContent = config.emptyProvider; provider.classList.add("settings-model-button-provider-placeholder"); return; } if (!value) { display.textContent = config.emptyLabel; provider.textContent = config.emptyProvider; provider.classList.add("settings-model-button-provider-placeholder"); return; } await ensureAvailableModels(); const match = findAvailableModel(value); display.textContent = match ? (match.name || match.raw_id || match.id) : value; provider.textContent = match ? (match.provider_label || match.provider || "") : "Custom"; provider.classList.toggle("settings-model-button-provider-placeholder", !match); } async function refreshSettingsModelPickers() { for (const config of SETTINGS_MODEL_PICKERS) { await updateSettingsModelPickerDisplay(config); } } function renderSettingsModelPickerOptions(config) { const list = document.getElementById(config.listId); const search = document.getElementById(config.searchId); const input = document.getElementById(config.valueId); if (!list || !search || !input) { return; } const models = filterModelsByQuery(search.value); const extraItems = []; if (config.allowEmpty) { extraItems.push({ id: "", raw_id: "", name: config.emptyChoiceLabel, provider_label: config.emptyChoiceProvider, provider: "", }); } renderModelList( list, models, input.value.trim(), (model) => { input.value = model.id || ""; void updateSettingsModelPickerDisplay(config); const menu = document.getElementById(config.menuId); if (menu) { menu.style.display = "none"; } }, extraItems, ); } function setupSettingsModelPickers() { if (_settingsModelPickersInitialized) { return; } SETTINGS_MODEL_PICKERS.forEach(config => { const button = document.getElementById(config.buttonId); const menu = document.getElementById(config.menuId); const search = document.getElementById(config.searchId); const list = document.getElementById(config.listId); if (!button || !menu || !search || !list) { return; } button.addEventListener("click", async (e) => { e.stopPropagation(); const isOpen = menu.style.display === "flex"; if (isOpen) { menu.style.display = "none"; return; } closeSettingsModelMenus(menu); menu.style.display = "flex"; await ensureAvailableModels(list); search.value = ""; renderSettingsModelPickerOptions(config); search.focus(); }); menu.addEventListener("click", (e) => e.stopPropagation()); search.addEventListener("input", () => renderSettingsModelPickerOptions(config)); }); document.addEventListener("click", () => closeSettingsModelMenus()); _settingsModelPickersInitialized = true; } function setupModelSelector() { const btn = document.getElementById("btn-model-select"); const menu = document.getElementById("model-dropdown-menu"); const search = document.getElementById("model-search-input"); const list = document.getElementById("model-list-container"); if (!btn || !menu) return; btn.addEventListener("click", async (e) => { e.stopPropagation(); const isHidden = menu.style.display === "none"; if (isHidden) { menu.style.display = "flex"; await ensureAvailableModels(list); renderModels(_availableModels); search.value = ""; search.focus(); } else { menu.style.display = "none"; } }); document.addEventListener("click", (e) => { if (!menu.contains(e.target) && e.target !== btn && !btn.contains(e.target)) { menu.style.display = "none"; } }); search.addEventListener("input", () => { const filtered = filterModelsByQuery(search.value); renderModels(filtered); }); function renderModels(models) { const currentModelId = state.activeModelId || ""; renderModelList(list, models, currentModelId, async (model) => { state.activeModelId = model.id; updateModelSelectorDisplay(model.id); menu.style.display = "none"; if (state.sessionId) { await authFetch("/api/sessions/" + encodeURIComponent(state.sessionId), { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: model.id }) }); } }); } } document.addEventListener("DOMContentLoaded", () => { setupSettingsModelPickers(); setTimeout(setupModelSelector, 500); }); /* ── Heartbeat panel ── */ async function loadHeartbeatSettingsPanel() { const profileSelect = $("s-hb-profile"); if (!profileSelect) return; try { const res = await authFetch("/api/profiles"); if (res.ok) { const data = await res.json(); const profiles = data.profiles || []; let html = ''; for (const p of profiles) { html += ``; } const currentVal = profileSelect.value; profileSelect.innerHTML = html; profileSelect.value = currentVal; // Restore selection after populating } } catch (e) { console.error("loadHeartbeatSettingsPanel profiles fetch failed", e); } }