mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[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:
@@ -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
|
||||
|
||||
@@ -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) }; }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
Reference in New Issue
Block a user