From 110d5a9b3bd68819b9b95f62ad4187e0ae0f67d2 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 5 Aug 2026 22:43:15 -0700 Subject: [PATCH] [eric] voice+canvas: personal dictionary rides every decode, local dictation history with copy-back, double-click empty canvas fits everything --- backend/apps/settings/models.py | 2 + electron/main.js | 1 + electron/preload.js | 1 + electron/voice/whisperService.js | 11 +++- .../interaction/useDashboardInteractions.ts | 13 ++--- .../sections/general/GeneralInterface.tsx | 23 ++++++++ .../general/parts/DictationHistoryList.tsx | 53 +++++++++++++++++++ frontend/src/shared/state/settingsSlice.ts | 2 + .../shared/voice/VoiceDictationContext.tsx | 7 +++ .../src/shared/voice/useVoiceDictation.ts | 2 + frontend/src/shared/voice/voiceHistory.ts | 34 ++++++++++++ 11 files changed, 138 insertions(+), 11 deletions(-) create mode 100644 frontend/src/app/pages/Settings/sections/general/parts/DictationHistoryList.tsx create mode 100644 frontend/src/shared/voice/voiceHistory.ts diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index da9d924f..4f47fb35 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -95,6 +95,8 @@ class AppSettings(BaseModel): voice_hold_to_talk: bool = True # Whisper model id from the desktop catalog (electron/voice/whisperModels.js); None = its default. dictation_model: Optional[str] = None + # Personal glossary (comma-separated names/jargon) fed to whisper as a decode prompt so "Anthropic" never comes out "and Thropic". + dictation_dictionary: str = "" anthropic_api_key: Optional[str] = None browser_homepage: str = "https://www.google.com" # Opt-in: let a blocked browser agent borrow the sign-in you already have in your everyday diff --git a/electron/main.js b/electron/main.js index 19c91f4e..e6057a11 100644 --- a/electron/main.js +++ b/electron/main.js @@ -3076,6 +3076,7 @@ ipcMain.handle('voice:set-model', (_e, id) => { if (ready) whisperService.warmInBackground(voiceResourceDir(), voiceUserDataDir()); return { ok: true, ready }; }); +ipcMain.on('voice:set-dictionary', (_e, words) => { whisperService.setDictionary(words); }); // Paste the text into the frontmost app (dictate-anywhere). Returns whether the OS paste actually fired. ipcMain.handle('voice:inject', async (_e, text) => { try { const pasted = await injectText(String(text || '')); return { ok: true, pasted }; } catch (err) { return { ok: false, error: String(err && err.message ? err.message : err) }; } diff --git a/electron/preload.js b/electron/preload.js index 259d4dc1..1f0c1389 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -70,6 +70,7 @@ contextBridge.exposeInMainWorld('openswarm', { // Settings' model picker: the catalog with install state, and switching (downloads on demand). voiceModels: () => ipcRenderer.invoke('voice:models'), voiceSetModel: (id) => ipcRenderer.invoke('voice:set-model', id), + voiceSetDictionary: (words) => ipcRenderer.send('voice:set-dictionary', words), voiceTranscribe: (wavArrayBuffer) => ipcRenderer.invoke('voice:transcribe', wavArrayBuffer), voiceInject: (text) => ipcRenderer.invoke('voice:inject', text), // Streaming dictation: chunks flow up fire-and-forget, live partials flow back down. diff --git a/electron/voice/whisperService.js b/electron/voice/whisperService.js index e5dc7fdc..c6a722bf 100644 --- a/electron/voice/whisperService.js +++ b/electron/voice/whisperService.js @@ -12,6 +12,14 @@ const whisperModels = require('./whisperModels'); // Which catalog model the user picked. Settings pushes it in; until then the catalog default wins. let selectedModelId = whisperModels.DEFAULT_MODEL_ID; +// The user's personal glossary, pushed from Settings; fed to whisper as a decode prompt so names +// and jargon bias recognition without any retraining (the classic initial-prompt trick). +let dictionaryPrompt = ''; + +function setDictionary(words) { + const clean = String(words || '').split(',').map((w) => w.trim()).filter(Boolean).slice(0, 60); + dictionaryPrompt = clean.length ? `Glossary: ${clean.join(', ')}.` : ''; +} function modelStatus() { return whisperModels.downloadStatus(); @@ -227,6 +235,7 @@ async function transcribe(resourceDir, userDataDir, wavBuffer) { const form = new FormData(); form.append('file', new Blob([wavBuffer], { type: 'audio/wav' }), 'audio.wav'); form.append('response_format', 'text'); + if (dictionaryPrompt) form.append('prompt', dictionaryPrompt); const res = await fetch(`http://127.0.0.1:${p}/inference`, { method: 'POST', body: form }); if (!res.ok) throw new Error(`whisper-http-${res.status}`); const text = (await res.text()).trim(); @@ -287,4 +296,4 @@ async function reprimeAfterWake() { return true; } -module.exports = { ensureServer, warmInBackground, reprimeAfterWake, transcribe, stopServer, isWarm, setModel, selectedModel, resolveBinary, resolveModel, modelStatus }; +module.exports = { ensureServer, warmInBackground, reprimeAfterWake, transcribe, stopServer, isWarm, setModel, setDictionary, selectedModel, resolveBinary, resolveModel, modelStatus }; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts index 30b7915b..a1600124 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts @@ -226,16 +226,9 @@ export function useDashboardInteractions({ if (e.button !== 0) return; if (isCardTarget(e.target, e.currentTarget)) return; report('dashboard', 'canvas_double_clicked'); - const vp = (e.currentTarget as HTMLElement).getBoundingClientRect(); - const cx = e.clientX - vp.left; - const cy = e.clientY - vp.top; - const cur = canvas.actions.getLiveState(); - const nextZoom = Math.max(0.15, cur.zoom * 0.55); - canvas.actions.animateTo({ - zoom: nextZoom, - panX: cx - ((cx - cur.panX) / cur.zoom) * nextZoom, - panY: cy - ((cy - cur.panY) / cur.zoom) * nextZoom, - }); + // Double-tap on empty space = show me everything (Eric's call): the same animated fit the + // overview affordances use, instead of the old blind 0.55x zoom-out that just lost people. + canvas.actions.fitToView(); }, [canvas.actions]); // Double-click a card → always expand + center + zoom (cancels pending collapse from single-click) diff --git a/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx b/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx index d3927870..8527b19f 100644 --- a/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx +++ b/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx @@ -16,6 +16,7 @@ import type { SettingsStyles } from '../settingsStyles'; import { settingSelectAttrs } from '../settingSelect'; import ShortcutRecorderChip, { dictationDefaultCombo, comboDisplay } from './parts/ShortcutRecorderChip'; import DictationModelPicker from './parts/DictationModelPicker'; +import DictationHistoryList from './parts/DictationHistoryList'; const GeneralInterface: React.FC<{ form: AppSettings; @@ -142,6 +143,28 @@ const GeneralInterface: React.FC<{ /> + + + Dictionary + Comma-separated names and jargon (people, products, acronyms) that dictation should always spell right. + + setForm({ ...form, dictation_dictionary: e.target.value })} + sx={{ width: 280 }} + /> + + + + + History + Recent dictations, stored only on this machine. Copy one back if it landed in the wrong place. + + + + Canvas diff --git a/frontend/src/app/pages/Settings/sections/general/parts/DictationHistoryList.tsx b/frontend/src/app/pages/Settings/sections/general/parts/DictationHistoryList.tsx new file mode 100644 index 00000000..d2b21e3b --- /dev/null +++ b/frontend/src/app/pages/Settings/sections/general/parts/DictationHistoryList.tsx @@ -0,0 +1,53 @@ +import React, { useState } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Button from '@mui/material/Button'; +import Tooltip from '@mui/material/Tooltip'; +import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; +import { readDictationHistory, clearDictationHistory, DictationHistoryEntry } from '@/shared/voice/voiceHistory'; + +// The last dictations with one-click copy: rescue for a transcript that landed in the wrong field. +const DictationHistoryList: React.FC = () => { + const c = useClaudeTokens(); + const [entries, setEntries] = useState(readDictationHistory); + const [copiedAt, setCopiedAt] = useState(null); + + if (entries.length === 0) { + return Nothing dictated yet.; + } + + return ( + + {entries.slice(0, 8).map((e) => ( + + + {e.text} + + {new Date(e.at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} · {e.target} + + + + { void navigator.clipboard.writeText(e.text); setCopiedAt(e.at); }} + sx={{ color: c.text.muted }} + > + + + + + ))} + + + ); +}; + +export default DictationHistoryList; diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index 776f7499..344ccdb0 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -32,6 +32,7 @@ export interface AppSettings { new_agent_shortcut: string; dictation_shortcut?: string | null; dictation_model?: string | null; + dictation_dictionary?: string; anthropic_api_key: string | null; openai_api_key?: string | null; google_api_key?: string | null; @@ -163,6 +164,7 @@ export const DEFAULT_SETTINGS: AppSettings = { new_agent_shortcut: 'Meta+l', dictation_shortcut: null, dictation_model: null, + dictation_dictionary: '', anthropic_api_key: null, browser_homepage: 'https://duckduckgo.com', browser_import_signins: false, diff --git a/frontend/src/shared/voice/VoiceDictationContext.tsx b/frontend/src/shared/voice/VoiceDictationContext.tsx index 734070fc..8dc07203 100644 --- a/frontend/src/shared/voice/VoiceDictationContext.tsx +++ b/frontend/src/shared/voice/VoiceDictationContext.tsx @@ -38,6 +38,13 @@ export function VoiceDictationProvider({ children }: { children: React.ReactNode useEffect(() => { if (dictationModel) void window.openswarm?.voiceSetModel?.(dictationModel); }, [dictationModel]); + + // Personal glossary rides every decode as a whisper prompt; push on boot and on change. + const dictationDictionary = useAppSelector((s) => s.settings.data.dictation_dictionary ?? ''); + useEffect(() => { + const bridge = window as unknown as { openswarm?: { voiceSetDictionary?: (words: string) => void } }; + bridge.openswarm?.voiceSetDictionary?.(dictationDictionary); + }, [dictationDictionary]); const stateRef = useRef(state); stateRef.current = state; const heldRef = useRef(false); diff --git a/frontend/src/shared/voice/useVoiceDictation.ts b/frontend/src/shared/voice/useVoiceDictation.ts index dd2a7063..b94bc609 100644 --- a/frontend/src/shared/voice/useVoiceDictation.ts +++ b/frontend/src/shared/voice/useVoiceDictation.ts @@ -5,6 +5,7 @@ import { encodeWav, VOICE_SAMPLE_RATE } from './encodeWav'; import { playVoiceCue } from './voiceCues'; import { injectAtFocus } from './injectAtFocus'; import { createSilenceDetector } from './createSilenceDetector'; +import { pushDictation } from './voiceHistory'; import { createCaptureNode } from './createCaptureNode'; export type VoiceState = 'idle' | 'recording' | 'transcribing' | 'preparing'; @@ -244,6 +245,7 @@ export function useVoiceDictation() { // Success is silent: the text landing at the cursor IS the feedback. Only the clipboard // fallback still speaks, because the user has to act (paste) to get the text. const target = injectAtFocus(text); + pushDictation(text, target || 'clipboard'); if (target) { playVoiceCue('paste'); } else { diff --git a/frontend/src/shared/voice/voiceHistory.ts b/frontend/src/shared/voice/voiceHistory.ts new file mode 100644 index 00000000..255cce47 --- /dev/null +++ b/frontend/src/shared/voice/voiceHistory.ts @@ -0,0 +1,34 @@ +// The last N dictations, local-only (localStorage): a transcript that landed somewhere wrong or got +// overwritten is recoverable without re-speaking it. Never synced, never sent anywhere. + +export interface DictationHistoryEntry { + text: string; + at: number; + target: string; +} + +const KEY = 'osw-dictation-history'; +const CAP = 20; + +export function readDictationHistory(): DictationHistoryEntry[] { + try { + const raw = localStorage.getItem(KEY); + const parsed: unknown = raw ? JSON.parse(raw) : []; + if (!Array.isArray(parsed)) return []; + return parsed.filter((e): e is DictationHistoryEntry => + !!e && typeof e === 'object' && typeof (e as DictationHistoryEntry).text === 'string' && typeof (e as DictationHistoryEntry).at === 'number'); + } catch { + return []; + } +} + +export function pushDictation(text: string, target: string): void { + try { + const next = [{ text, at: Date.now(), target }, ...readDictationHistory()].slice(0, CAP); + localStorage.setItem(KEY, JSON.stringify(next)); + } catch { /* quota or private mode; history is a convenience, never a blocker */ } +} + +export function clearDictationHistory(): void { + try { localStorage.removeItem(KEY); } catch { /* same */ } +}