From ba6a4ac4e4ddcab1074c0b275974b7c78d10a99a Mon Sep 17 00:00:00 2001 From: jon Date: Thu, 23 Jul 2026 14:22:46 -0500 Subject: [PATCH] feat: workspaces, agentic action tools, local Whisper STT, vec recall - Projects/workspaces: documents grouped into projects; chat RAG scopes to the active project. Switcher in the Documents page. - Agentic action tools: web_search, fetch_url, and remember (first write tool), allowlist-gated per playbook. - Local Whisper STT (faster-whisper, no torch): on-device dictation replacing the browser Web Speech API. POST /stt + GET /stt/status; browser fallback. - Vector index extended to conversation recall (message_vectors), with the brute-force cosine scan kept as the fallback. Co-Authored-By: Claude Opus 4.8 --- interface/web/src/Chatbot.jsx | 75 +++++++++++-- interface/web/src/Documents.jsx | 63 ++++++++++- interface/web/src/Playbook.jsx | 2 +- requirements-base.txt | 2 + requirements-windows.txt | 2 + synapse/main.py | 65 ++++++++++- synapse/memory/store.py | 193 +++++++++++++++++++++++++++----- synapse/stt.py | 55 +++++++++ synapse/tools.py | 101 ++++++++++++++++- tests/test_documents.py | 56 +++++++++ tests/test_smoke.py | 7 ++ tests/test_tools.py | 18 +++ 12 files changed, 586 insertions(+), 53 deletions(-) create mode 100644 synapse/stt.py diff --git a/interface/web/src/Chatbot.jsx b/interface/web/src/Chatbot.jsx index b332753..6fec25b 100644 --- a/interface/web/src/Chatbot.jsx +++ b/interface/web/src/Chatbot.jsx @@ -19,23 +19,64 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang const [editingIdx, setEditingIdx] = useState(null); // user message being edited const [editText, setEditText] = useState(""); const [listening, setListening] = useState(false); // mic dictation active + const [transcribing, setTranscribing] = useState(false); // local STT running + const [sttLocal, setSttLocal] = useState(false); // backend Whisper available const [speakingIdx, setSpeakingIdx] = useState(null); // message being read aloud const recognitionRef = useRef(null); + const mediaRecRef = useRef(null); // Web Speech API — browser-native, no backend/model. Absent on unsupported browsers. const SpeechRec = typeof window !== "undefined" && (window.SpeechRecognition || window.webkitSpeechRecognition); const ttsSupported = typeof window !== "undefined" && "speechSynthesis" in window; + const canRecord = typeof navigator !== "undefined" && navigator.mediaDevices && window.MediaRecorder; - const toggleMic = () => { - if (!SpeechRec) return; - if (listening) { recognitionRef.current?.stop(); return; } + // Prefer local Whisper (on-device) over browser speech (Chrome routes audio to Google). + useEffect(() => { + fetch(`${API_BASE}/stt/status`).then(r => r.ok ? r.json() : null) + .then(d => setSttLocal(Boolean(d && d.available))).catch(() => {}); + }, []); + + const _appendTranscript = (text) => { + if (text) setInput(prev => (prev ? prev + " " : "") + text); + }; + + // Local path: record audio, POST to /stt (faster-whisper transcribes on-device). + const startLocalDictation = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const rec = new MediaRecorder(stream); + const chunks = []; + rec.ondataavailable = e => e.data.size && chunks.push(e.data); + rec.onstop = async () => { + stream.getTracks().forEach(t => t.stop()); + setListening(false); + setTranscribing(true); + try { + const b64 = await new Promise((res) => { + const fr = new FileReader(); + fr.onload = () => res(String(fr.result).split(",")[1]); + fr.readAsDataURL(new Blob(chunks, { type: rec.mimeType })); + }); + const r = await fetch(`${API_BASE}/stt`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ audio: b64 }), + }); + if (r.ok) _appendTranscript((await r.json()).text); + } catch { /* ignore */ } finally { + setTranscribing(false); + } + }; + mediaRecRef.current = rec; + setListening(true); + rec.start(); + } catch { setListening(false); } + }; + + const startBrowserDictation = () => { const rec = new SpeechRec(); rec.lang = "en-US"; rec.interimResults = false; - rec.onresult = (e) => { - const text = Array.from(e.results).map(r => r[0].transcript).join(" ").trim(); - if (text) setInput(prev => (prev ? prev + " " : "") + text); - }; + rec.onresult = (e) => _appendTranscript(Array.from(e.results).map(r => r[0].transcript).join(" ").trim()); rec.onend = () => setListening(false); rec.onerror = () => setListening(false); recognitionRef.current = rec; @@ -43,6 +84,17 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang rec.start(); }; + const toggleMic = () => { + if (transcribing) return; + if (listening) { + if (sttLocal && mediaRecRef.current) mediaRecRef.current.stop(); + else recognitionRef.current?.stop(); + return; + } + if (sttLocal && canRecord) startLocalDictation(); + else if (SpeechRec) startBrowserDictation(); + }; + // Read a reply aloud (light markdown strip so symbols aren't spoken). const speak = (idx, text) => { if (!ttsSupported) return; @@ -617,10 +669,11 @@ export function Chatbot({ conversationId, setConversationId, onConversationChang 📎 - {SpeechRec && ( - )}