// Svrnty plugin frontend — Hermes WebUI extensions. // Loaded via /plugins/svrnty/app.js (registered by plugin.py register_static). // Runs in WebUI origin with full session authority. Additive + idempotent. (function () { "use strict"; if (window.__svrntyExtLoaded) return; window.__svrntyExtLoaded = true; // ── Voice-message mic mode (migrated from fork commit 014b9eef) ───────── // Replaces vanilla dictation-to-textbox mic flow with voice-message mode: // tap to record, tap again to stop → file attached + sent automatically. // The server-side audio_attachment_processor in routes/transcribe.py // transcribes it so text-only models can read voice messages. // // Strategy: replace #btnMic onclick handler (set by boot.js) with our own // MediaRecorder flow. Programmatically inject the recorded File into // #fileInput via DataTransfer + dispatch change event (boot.js's onchange // calls addFiles automatically). Then click #btnSend to submit. let _vmRecorder = null; let _vmStream = null; let _vmChunks = []; let _vmRecording = false; function _vmStopTracks() { if (_vmStream) { try { _vmStream.getTracks().forEach((t) => t.stop()); } catch (_) { /* ignore */ } _vmStream = null; } } async function _vmStart(btn, status) { if (!navigator.mediaDevices || !window.MediaRecorder) { console.warn("[svrnty] MediaRecorder unavailable; voice-message disabled"); return false; } try { _vmStream = await navigator.mediaDevices.getUserMedia({ audio: true }); } catch (e) { console.warn("[svrnty] mic permission denied:", e && e.message); return false; } const preferred = ["audio/webm;codecs=opus", "audio/webm", "audio/ogg;codecs=opus", "audio/mp4"]; const mime = preferred.find((t) => window.MediaRecorder.isTypeSupported && window.MediaRecorder.isTypeSupported(t)) || ""; _vmRecorder = new MediaRecorder(_vmStream, mime ? { mimeType: mime } : undefined); _vmChunks = []; _vmRecorder.ondataavailable = (e) => { if (e.data && e.data.size) _vmChunks.push(e.data); }; _vmRecorder.onerror = () => { _vmRecording = false; _vmStopTracks(); _vmSetUI(btn, status, false); }; _vmRecorder.onstop = () => { _vmRecording = false; _vmSetUI(btn, status, false); const blob = new Blob(_vmChunks, { type: _vmRecorder.mimeType || mime || "audio/webm" }); _vmStopTracks(); if (!blob.size) return; _vmAttachAndSend(blob); }; _vmRecorder.start(); _vmRecording = true; _vmSetUI(btn, status, true); return true; } function _vmStop() { if (_vmRecorder && _vmRecorder.state !== "inactive") { try { _vmRecorder.stop(); } catch (_) { /* ignore */ } } } function _vmSetUI(btn, status, on) { if (btn) btn.classList.toggle("recording", !!on); if (status) status.textContent = on ? "Recording…" : ""; } function _vmAttachAndSend(blob) { // Pick extension matching mime so the server-side filename detection // (voice-message-* + audio extension) triggers the audio processor. const bt = blob.type || ""; const ext = bt.includes("mp4") ? "mp4" : bt.includes("ogg") ? "ogg" : "webm"; const filename = `voice-message-${Date.now()}.${ext}`; const file = new File([blob], filename, { type: bt || `audio/${ext}` }); const fileInput = document.getElementById("fileInput"); const btnSend = document.getElementById("btnSend"); if (!fileInput || !btnSend) { console.warn("[svrnty] composer file input or send button missing"); return; } // DataTransfer trick to set FileList programmatically (Chrome/Firefox/Safari). const dt = new DataTransfer(); dt.items.add(file); fileInput.files = dt.files; fileInput.dispatchEvent(new Event("change", { bubbles: true })); // Wait one tick so addFiles() processes the attachment, then send. setTimeout(() => { try { btnSend.click(); } catch (_) { /* ignore */ } }, 50); } function _vmInstall() { const btn = document.getElementById("btnMic"); if (!btn || btn.dataset.svrntyVm === "1") return; btn.dataset.svrntyVm = "1"; const status = document.getElementById("micStatus"); // Replace boot.js's onclick. We own the mic now. btn.onclick = async (e) => { if (e && e.preventDefault) e.preventDefault(); if (_vmRecording) { _vmStop(); } else { const ok = await _vmStart(btn, status); if (!ok) console.warn("[svrnty] voice-message recording start failed"); } }; } function _vmHook() { // boot.js sets btn.onclick in its IIFE that runs at DOMContentLoaded; we // override after a microtask so our handler wins. Also re-install on // future DOM mutations (some session-switching flows rebuild the bar). _vmInstall(); const observer = new MutationObserver(() => _vmInstall()); observer.observe(document.body, { childList: true, subtree: true }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", _vmHook); } else { _vmHook(); } // ── Vault status panel (migrated from fork commit 3e2c74f3) ───────────── // Injects a "Vault connections" section into Settings → System on demand, // populates it from GET /api/vault/status (served by plugin/routes/vault_status.py). const VAULT_LABELS = { anthropic: "Anthropic", gitea: "Gitea", "keycloak-planb": "Keycloak (Plan B)", meta: "Meta", "google-ads": "Google Ads", "google-ai": "Google AI", "google-business": "Google Business", "google-search-console": "Search Console", quickbooks: "QuickBooks", "google-analytics": "Google Analytics", mailchimp: "Mailchimp", paypal: "PayPal", square: "Square", woocommerce: "WooCommerce", "wordpress-admin": "WordPress", agendrix: "Agendrix", perplexity: "Perplexity", wix: "Wix", }; function _injectVaultPanel() { // Already injected? if (document.getElementById("vaultStatusSection")) return true; // Anchor after the gateway-status card in System settings. const anchor = document.getElementById("gatewayStatusCard"); if (!anchor) return false; const section = document.createElement("div"); section.id = "vaultStatusSection"; section.style.marginTop = "16px"; section.innerHTML = '
' + '
Vault connections
' + '' + "
" + '
credctl-managed secrets — green = present, grey = absent
' + '
Loading…
'; anchor.parentNode.insertBefore(section, anchor.nextSibling); document.getElementById("vaultRefreshBtn").addEventListener("click", _loadVaultStatus); return true; } function _loadVaultStatus() { const list = document.getElementById("vaultStatusList"); if (!list) return; fetch("/api/vault/status") .then((r) => r.json()) .then((r) => { const present = new Set((r.secrets || []).map((s) => s.name)); const names = Object.keys(VAULT_LABELS); list.innerHTML = '
' + names.map((name) => { const ok = present.has(name); const color = ok ? "#22c55e" : "#6b7280"; const label = VAULT_LABELS[name] || name; return ( '' + `` + label.replace(/[<>&]/g, (c) => ({ "<": "<", ">": ">", "&": "&" }[c])) + "" ); }).join("") + "
"; }) .catch(() => { list.innerHTML = '
Failed to load vault status
'; }); } // Watch for the System settings tab opening; inject + load when it appears. // The panel exists in the DOM only after the user navigates Settings → System. function _hookSystemPanelOpen() { const observer = new MutationObserver(() => { if (_injectVaultPanel()) _loadVaultStatus(); }); observer.observe(document.body, { childList: true, subtree: true }); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", _hookSystemPanelOpen); } else { _hookSystemPanelOpen(); } })();