[eric] voice+canvas: personal dictionary rides every decode, local dictation history with copy-back, double-click empty canvas fits everything

This commit is contained in:
ciregenz
2026-08-05 22:43:15 -07:00
parent ba8a9c4038
commit 110d5a9b3b
11 changed files with 138 additions and 11 deletions
+2
View File
@@ -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
+1
View File
@@ -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) }; }
+1
View File
@@ -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.
+10 -1
View File
@@ -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 };
@@ -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)
@@ -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<{
/>
</Box>
<Box sx={inlineRowSx} {...settingSelectAttrs('dictation_dictionary', 'Dictation dictionary', 'Interface', 'Names and jargon dictation should always spell right.')}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Dictionary</Typography>
<Typography sx={descSx}>Comma-separated names and jargon (people, products, acronyms) that dictation should always spell right.</Typography>
</Box>
<TextField
size="small"
placeholder="Anthropic, Kubernetes, OpenSwarm"
value={form.dictation_dictionary ?? ''}
onChange={(e) => setForm({ ...form, dictation_dictionary: e.target.value })}
sx={{ width: 280 }}
/>
</Box>
<Box sx={{ ...inlineRowSx, alignItems: 'flex-start' }} {...settingSelectAttrs('dictation_history', 'Dictation history', 'Interface', 'Your recent dictations, copyable.')}>
<Box sx={{ mr: 3, flexShrink: 0, width: 220 }}>
<Typography sx={labelSx}>History</Typography>
<Typography sx={descSx}>Recent dictations, stored only on this machine. Copy one back if it landed in the wrong place.</Typography>
</Box>
<DictationHistoryList />
</Box>
<Typography sx={sectionSx}>Canvas</Typography>
<Box sx={inlineRowSx} {...settingSelectAttrs('mouse_wheel_action', 'Mouse wheel', 'Interface', 'What a mouse wheel does on the dashboard canvas.')}>
@@ -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<DictationHistoryEntry[]>(readDictationHistory);
const [copiedAt, setCopiedAt] = useState<number | null>(null);
if (entries.length === 0) {
return <Typography sx={{ color: c.text.ghost, fontSize: '0.8125rem' }}>Nothing dictated yet.</Typography>;
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.25, width: '100%' }}>
{entries.slice(0, 8).map((e) => (
<Box key={e.at} sx={{ display: 'flex', alignItems: 'center', gap: 1, py: 0.5, borderBottom: `1px solid ${c.border.subtle}`, '&:last-of-type': { borderBottom: 'none' } }}>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography noWrap sx={{ color: c.text.primary, fontSize: '0.8125rem' }}>{e.text}</Typography>
<Typography sx={{ color: c.text.ghost, fontSize: '0.6875rem' }}>
{new Date(e.at).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })} · {e.target}
</Typography>
</Box>
<Tooltip title={copiedAt === e.at ? 'Copied' : 'Copy'} placement="left">
<IconButton
size="small"
onClick={() => { void navigator.clipboard.writeText(e.text); setCopiedAt(e.at); }}
sx={{ color: c.text.muted }}
>
<ContentCopyRoundedIcon sx={{ fontSize: 15 }} />
</IconButton>
</Tooltip>
</Box>
))}
<Button
size="small"
onClick={() => { clearDictationHistory(); setEntries([]); }}
sx={{ alignSelf: 'flex-start', mt: 0.5, textTransform: 'none', fontSize: '0.75rem', color: c.text.muted }}
>
Clear history
</Button>
</Box>
);
};
export default DictationHistoryList;
@@ -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,
@@ -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);
@@ -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 {
+34
View File
@@ -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 */ }
}