feat(plugin): voice-message mic UI in app.js — closes Phase 2.A UX gap (L8)
All checks were successful
plugin-tests / test (push) Successful in 6s
All checks were successful
plugin-tests / test (push) Successful in 6s
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>
This commit is contained in:
parent
37123f570b
commit
0ef66ab599
@ -38,5 +38,5 @@ _None. Plugin uses only the public API._ ✓
|
|||||||
|
|
||||||
| File | Line | URL |
|
| File | Line | URL |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `static/app.js` | 46 | `/api/vault/status` |
|
| `static/app.js` | 165 | `/api/vault/status` |
|
||||||
|
|
||||||
|
|||||||
119
static/app.js
119
static/app.js
@ -6,6 +6,125 @@
|
|||||||
if (window.__svrntyExtLoaded) return;
|
if (window.__svrntyExtLoaded) return;
|
||||||
window.__svrntyExtLoaded = true;
|
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) ─────────────
|
// ── Vault status panel (migrated from fork commit 3e2c74f3) ─────────────
|
||||||
// Injects a "Vault connections" section into Settings → System on demand,
|
// Injects a "Vault connections" section into Settings → System on demand,
|
||||||
// populates it from GET /api/vault/status (served by plugin/routes/vault_status.py).
|
// populates it from GET /api/vault/status (served by plugin/routes/vault_status.py).
|
||||||
|
|||||||
50
tests/unit/test_app_js.py
Normal file
50
tests/unit/test_app_js.py
Normal file
@ -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 <input type=file> 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
|
||||||
Loading…
Reference in New Issue
Block a user