svrnty-hermes-webui-plugin/static/app.js
Svrnty 0ef66ab599
All checks were successful
plugin-tests / test (push) Successful in 6s
feat(plugin): voice-message mic UI in app.js — closes Phase 2.A UX gap (L8)
Migrates the boot.js mic-button behavior change from reverted fork commit
014b9eef into plugin/static/app.js. Replaces vanilla dictation-to-textbox
mic flow with voice-message mode: tap to record, tap again to stop → audio
File attached + sent automatically. Server-side audio_attachment_processor
in routes/transcribe.py transcribes for text-only models.

Implementation strategy:
- Own #btnMic onclick (boot.js sets it during init; plugin overrides via
  MutationObserver after DOM mutations + idempotent dataset.svrntyVm flag)
- MediaRecorder with same mime-type fallbacks as the original
  (webm/opus → webm → ogg/opus → mp4)
- DataTransfer to set #fileInput.files programmatically (cross-browser path)
- Dispatch 'change' so boot.js's fileInput.onchange → addFiles() runs
- Click #btnSend after 50ms tick so attachment registers before submit

Filename convention: voice-message-${timestamp}.{webm|ogg|mp4} — matches the
audio processor's name-prefix detection in _transcribe_audio_attachments.

Tests: 6 new JS-static checks in tests/unit/test_app_js.py (idempotent
guard, voice-message prefix, DataTransfer pattern, vault URL pin, mic
override). 26 tests total, all PASS.

Connection map: now 9 public API · 0 forced internal · 1 frontend.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:28:47 -04:00

205 lines
8.8 KiB
JavaScript

// 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 =
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">' +
'<div style="font-weight:500;font-size:13px">Vault connections</div>' +
'<button id="vaultRefreshBtn" style="background:none;border:1px solid var(--border2);color:var(--text);padding:2px 8px;border-radius:4px;font-size:11px;cursor:pointer">Refresh</button>' +
"</div>" +
'<div style="font-size:11px;color:var(--muted);margin-bottom:8px">credctl-managed secrets — green = present, grey = absent</div>' +
'<div id="vaultStatusList"><span style="color:var(--muted);font-size:12px">Loading…</span></div>';
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 =
'<div style="display:flex;flex-wrap:wrap;gap:6px">' +
names.map((name) => {
const ok = present.has(name);
const color = ok ? "#22c55e" : "#6b7280";
const label = VAULT_LABELS[name] || name;
return (
'<span style="display:inline-flex;align-items:center;gap:5px;padding:4px 10px;background:var(--code-bg);border:1px solid var(--border2);border-radius:12px;font-size:12px">' +
`<span style="width:7px;height:7px;border-radius:50%;background:${color};flex-shrink:0"></span>` +
label.replace(/[<>&]/g, (c) => ({ "<": "&lt;", ">": "&gt;", "&": "&amp;" }[c])) +
"</span>"
);
}).join("") +
"</div>";
})
.catch(() => {
list.innerHTML = '<div style="color:#ef4444;font-size:12px">Failed to load vault status</div>';
});
}
// 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();
}
})();