diff --git a/CONNECTION-MAP.md b/CONNECTION-MAP.md index 151c04a..d1cb12d 100644 --- a/CONNECTION-MAP.md +++ b/CONNECTION-MAP.md @@ -38,5 +38,5 @@ _None. Plugin uses only the public API._ ✓ | File | Line | URL | |---|---|---| -| `static/app.js` | 46 | `/api/vault/status` | +| `static/app.js` | 165 | `/api/vault/status` | diff --git a/static/app.js b/static/app.js index 3275107..bf7db32 100644 --- a/static/app.js +++ b/static/app.js @@ -6,6 +6,125 @@ 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). diff --git a/tests/unit/test_app_js.py b/tests/unit/test_app_js.py new file mode 100644 index 0000000..c069251 --- /dev/null +++ b/tests/unit/test_app_js.py @@ -0,0 +1,50 @@ +"""Static checks on static/app.js — voice-message UI + vault panel. + +Asserts the plugin's frontend code declares the right hooks and URLs without +needing a browser. Runtime behavior tested manually + by upstream-drift smoke. +""" +import re +from pathlib import Path + +APP_JS = Path(__file__).resolve().parents[2] / "static" / "app.js" + + +def test_app_js_exists_and_nonempty(): + assert APP_JS.is_file() + assert APP_JS.stat().st_size > 0 + + +def test_app_js_idempotent_guard(): + """Loading twice must not re-execute — protocol Rule 4 (no surprise side effects).""" + src = APP_JS.read_text() + assert "__svrntyExtLoaded" in src + assert "if (window.__svrntyExtLoaded) return" in src + + +def test_app_js_voice_message_filename_prefix(): + """voice-message-* prefix is the contract that the audio processor recognizes.""" + src = APP_JS.read_text() + assert "voice-message-" in src, ( + "static/app.js must produce voice-message-* filenames so " + "routes/transcribe.py:_transcribe_audio_attachments triggers" + ) + + +def test_app_js_uses_data_transfer_for_file_attach(): + """DataTransfer is the only cross-browser way to set programmatically.""" + src = APP_JS.read_text() + assert "new DataTransfer()" in src + + +def test_app_js_vault_status_url_pinned(): + """Frontend → /api/vault/status URL must match the plugin route.""" + src = APP_JS.read_text() + assert re.search(r"['\"`]/api/vault/status['\"`]", src), \ + "vault panel must call /api/vault/status (served by routes/vault_status.py)" + + +def test_app_js_overrides_mic_button(): + """Plugin owns #btnMic. Must use idempotent dataset flag to avoid double-install.""" + src = APP_JS.read_text() + assert "btnMic" in src + assert "svrntyVm" in src or "dataset" in src