Open source voice dictation for your whole desktop. Local whisper.cpp transcription, global hotkeys, optional LLM enhancement via Claude Code, Gemini CLI, Codex, or any API. Wayland-first.
| 1 | import { useEffect, useState } from "react"; |
| 2 | import { enhanceKeyPresent } from "../api"; |
| 3 | import type { EnhanceProfile, EnhanceProvider, Settings } from "../types"; |
| 4 | |
| 5 | interface Props { |
| 6 | settings: Settings; |
| 7 | onChange: (next: Settings) => void; |
| 8 | } |
| 9 | |
| 10 | interface ProviderDef { |
| 11 | id: EnhanceProvider; |
| 12 | label: string; |
| 13 | keyVar: string; |
| 14 | bin: string; |
| 15 | defaultModel: string; |
| 16 | modelHint: string; |
| 17 | } |
| 18 | |
| 19 | const API_PROVIDERS: ProviderDef[] = [ |
| 20 | { |
| 21 | id: "anthropic", |
| 22 | label: "claude", |
| 23 | keyVar: "ANTHROPIC_API_KEY", |
| 24 | bin: "", |
| 25 | defaultModel: "claude-opus-4-8", |
| 26 | modelHint: "claude-opus-4-8, claude-sonnet-5, claude-haiku-4-5", |
| 27 | }, |
| 28 | { |
| 29 | id: "gemini", |
| 30 | label: "gemini", |
| 31 | keyVar: "GEMINI_API_KEY", |
| 32 | bin: "", |
| 33 | defaultModel: "gemini-2.5-flash", |
| 34 | modelHint: "gemini-2.5-flash, gemini-2.5-pro", |
| 35 | }, |
| 36 | { |
| 37 | id: "openai", |
| 38 | label: "openai compatible", |
| 39 | keyVar: "OPENAI_API_KEY", |
| 40 | bin: "", |
| 41 | defaultModel: "gpt-4o-mini", |
| 42 | modelHint: "any model id your endpoint serves", |
| 43 | }, |
| 44 | { |
| 45 | id: "ollama", |
| 46 | label: "ollama (local)", |
| 47 | keyVar: "", |
| 48 | bin: "", |
| 49 | defaultModel: "llama3.2", |
| 50 | modelHint: "any model pulled into ollama", |
| 51 | }, |
| 52 | ]; |
| 53 | |
| 54 | const CLI_PROVIDERS: ProviderDef[] = [ |
| 55 | { |
| 56 | id: "claude-cli", |
| 57 | label: "claude code", |
| 58 | keyVar: "", |
| 59 | bin: "claude", |
| 60 | defaultModel: "", |
| 61 | modelHint: "empty = your CLI default, or sonnet, opus, a full model id", |
| 62 | }, |
| 63 | { |
| 64 | id: "gemini-cli", |
| 65 | label: "gemini cli", |
| 66 | keyVar: "", |
| 67 | bin: "gemini", |
| 68 | defaultModel: "", |
| 69 | modelHint: "empty = your CLI default, or gemini-2.5-pro", |
| 70 | }, |
| 71 | { |
| 72 | id: "codex-cli", |
| 73 | label: "codex", |
| 74 | keyVar: "", |
| 75 | bin: "codex", |
| 76 | defaultModel: "", |
| 77 | modelHint: "empty = your CLI default", |
| 78 | }, |
| 79 | { |
| 80 | id: "custom-cli", |
| 81 | label: "custom", |
| 82 | keyVar: "", |
| 83 | bin: "", |
| 84 | defaultModel: "", |
| 85 | modelHint: "model handling is up to your command", |
| 86 | }, |
| 87 | ]; |
| 88 | |
| 89 | function uniqueName(base: string, profiles: EnhanceProfile[]): string { |
| 90 | const names = new Set(profiles.map((p) => p.name)); |
| 91 | if (!names.has(base)) return base; |
| 92 | let n = 2; |
| 93 | while (names.has(`${base} ${n}`)) n++; |
| 94 | return `${base} ${n}`; |
| 95 | } |
| 96 | |
| 97 | export default function EnhancePage({ settings, onChange }: Props) { |
| 98 | const cfg = settings.enhance; |
| 99 | const profile = |
| 100 | cfg.profiles.find((p) => p.name === cfg.active_profile) ?? cfg.profiles[0]; |
| 101 | const [ready, setReady] = useState<boolean | null>(null); |
| 102 | const [nameDraft, setNameDraft] = useState(profile?.name ?? ""); |
| 103 | |
| 104 | useEffect(() => { |
| 105 | setNameDraft(profile?.name ?? ""); |
| 106 | }, [profile?.name]); |
| 107 | |
| 108 | useEffect(() => { |
| 109 | enhanceKeyPresent().then(setReady).catch(() => setReady(null)); |
| 110 | }, [ |
| 111 | profile?.provider, |
| 112 | profile?.api_key_env, |
| 113 | profile?.cli_command, |
| 114 | cfg.active_profile, |
| 115 | ]); |
| 116 | |
| 117 | if (!profile) { |
| 118 | return ( |
| 119 | <section> |
| 120 | <header className="page-head"> |
| 121 | <h2>enhance</h2> |
| 122 | </header> |
| 123 | <button |
| 124 | className="btn" |
| 125 | onClick={() => |
| 126 | onChange({ |
| 127 | ...settings, |
| 128 | enhance: { |
| 129 | ...cfg, |
| 130 | active_profile: "cleanup", |
| 131 | profiles: [ |
| 132 | { |
| 133 | name: "cleanup", |
| 134 | provider: "anthropic", |
| 135 | model: "claude-opus-4-8", |
| 136 | base_url: "", |
| 137 | api_key_env: "", |
| 138 | cli_command: "", |
| 139 | instructions: |
| 140 | "Fix punctuation, capitalization, filler words, and obvious transcription mistakes. Keep the speaker's wording and tone unchanged.", |
| 141 | }, |
| 142 | ], |
| 143 | }, |
| 144 | }) |
| 145 | } |
| 146 | > |
| 147 | create a profile |
| 148 | </button> |
| 149 | </section> |
| 150 | ); |
| 151 | } |
| 152 | |
| 153 | const isCli = profile.provider.endsWith("-cli"); |
| 154 | const providers = isCli ? CLI_PROVIDERS : API_PROVIDERS; |
| 155 | const providerDef = providers.find((p) => p.id === profile.provider) ?? providers[0]; |
| 156 | |
| 157 | const patchEnhance = (partial: Partial<typeof cfg>) => |
| 158 | onChange({ ...settings, enhance: { ...cfg, ...partial } }); |
| 159 | |
| 160 | const patchProfile = (partial: Partial<EnhanceProfile>) => { |
| 161 | const profiles = cfg.profiles.map((p) => |
| 162 | p.name === profile.name ? { ...p, ...partial } : p, |
| 163 | ); |
| 164 | patchEnhance({ |
| 165 | profiles, |
| 166 | active_profile: partial.name ?? cfg.active_profile, |
| 167 | }); |
| 168 | }; |
| 169 | |
| 170 | const selectProfile = (name: string) => patchEnhance({ active_profile: name }); |
| 171 | |
| 172 | const addProfile = () => { |
| 173 | const name = uniqueName("profile", cfg.profiles); |
| 174 | patchEnhance({ |
| 175 | profiles: [...cfg.profiles, { ...profile, name }], |
| 176 | active_profile: name, |
| 177 | }); |
| 178 | }; |
| 179 | |
| 180 | const deleteProfile = () => { |
| 181 | if (cfg.profiles.length < 2) return; |
| 182 | const profiles = cfg.profiles.filter((p) => p.name !== profile.name); |
| 183 | patchEnhance({ profiles, active_profile: profiles[0].name }); |
| 184 | }; |
| 185 | |
| 186 | const commitRename = () => { |
| 187 | const name = nameDraft.trim(); |
| 188 | if (!name || name === profile.name) { |
| 189 | setNameDraft(profile.name); |
| 190 | return; |
| 191 | } |
| 192 | if (cfg.profiles.some((p) => p.name === name)) { |
| 193 | setNameDraft(profile.name); // duplicate: revert |
| 194 | return; |
| 195 | } |
| 196 | patchProfile({ name }); |
| 197 | }; |
| 198 | |
| 199 | const switchBackend = (cli: boolean) => { |
| 200 | if (cli === isCli) return; |
| 201 | const next = cli ? CLI_PROVIDERS[0] : API_PROVIDERS[0]; |
| 202 | patchProfile({ provider: next.id, model: next.defaultModel }); |
| 203 | }; |
| 204 | |
| 205 | return ( |
| 206 | <section> |
| 207 | <header className="page-head"> |
| 208 | <h2>enhance</h2> |
| 209 | <p> |
| 210 | Optionally pass the raw transcript through an LLM before it is typed. |
| 211 | Profiles are presets you can switch between, from here or the tray |
| 212 | menu. |
| 213 | </p> |
| 214 | </header> |
| 215 | |
| 216 | <div className="field"> |
| 217 | <label className="check"> |
| 218 | <input |
| 219 | type="checkbox" |
| 220 | checked={cfg.enabled} |
| 221 | onChange={(e) => patchEnhance({ enabled: e.currentTarget.checked })} |
| 222 | /> |
| 223 | enable enhancement |
| 224 | </label> |
| 225 | <p className="hint"> |
| 226 | Off by default. Dictation stays fully local when this is off. |
| 227 | </p> |
| 228 | </div> |
| 229 | |
| 230 | <div className={cfg.enabled ? "" : "dimmed"}> |
| 231 | <div className="field"> |
| 232 | <label>profiles</label> |
| 233 | <div className="chip-row"> |
| 234 | {cfg.profiles.map((p) => ( |
| 235 | <button |
| 236 | key={p.name} |
| 237 | className={p.name === profile.name ? "chip active" : "chip"} |
| 238 | onClick={() => selectProfile(p.name)} |
| 239 | > |
| 240 | {p.name} |
| 241 | </button> |
| 242 | ))} |
| 243 | <button className="chip add" onClick={addProfile}> |
| 244 | + new |
| 245 | </button> |
| 246 | </div> |
| 247 | <p className="hint"> |
| 248 | The selected profile is what dictation uses. Everything below edits |
| 249 | it. |
| 250 | </p> |
| 251 | </div> |
| 252 | |
| 253 | <div className="field"> |
| 254 | <label>profile name</label> |
| 255 | <div className="field-row"> |
| 256 | <input |
| 257 | value={nameDraft} |
| 258 | onChange={(e) => setNameDraft(e.currentTarget.value)} |
| 259 | onBlur={commitRename} |
| 260 | onKeyDown={(e) => { |
| 261 | if (e.key === "Enter") e.currentTarget.blur(); |
| 262 | }} |
| 263 | spellCheck={false} |
| 264 | /> |
| 265 | <button |
| 266 | className="btn ghost" |
| 267 | disabled={cfg.profiles.length < 2} |
| 268 | onClick={deleteProfile} |
| 269 | > |
| 270 | delete |
| 271 | </button> |
| 272 | </div> |
| 273 | </div> |
| 274 | |
| 275 | <div className="field"> |
| 276 | <label>backend</label> |
| 277 | <div className="segmented"> |
| 278 | <button |
| 279 | className={!isCli ? "seg active" : "seg"} |
| 280 | onClick={() => switchBackend(false)} |
| 281 | > |
| 282 | api key |
| 283 | </button> |
| 284 | <button |
| 285 | className={isCli ? "seg active" : "seg"} |
| 286 | onClick={() => switchBackend(true)} |
| 287 | > |
| 288 | cli tool |
| 289 | </button> |
| 290 | </div> |
| 291 | <p className="hint"> |
| 292 | {isCli |
| 293 | ? "Runs an installed CLI agent with your existing login. No API keys needed." |
| 294 | : "Calls the provider HTTP API directly using a key from your environment."} |
| 295 | </p> |
| 296 | </div> |
| 297 | |
| 298 | <div className="field"> |
| 299 | <label>provider</label> |
| 300 | <div className="segmented"> |
| 301 | {providers.map((p) => ( |
| 302 | <button |
| 303 | key={p.id} |
| 304 | className={profile.provider === p.id ? "seg active" : "seg"} |
| 305 | onClick={() => |
| 306 | patchProfile({ provider: p.id, model: p.defaultModel }) |
| 307 | } |
| 308 | > |
| 309 | {p.label} |
| 310 | </button> |
| 311 | ))} |
| 312 | </div> |
| 313 | {isCli ? ( |
| 314 | providerDef.bin ? ( |
| 315 | <p className="hint"> |
| 316 | Uses the <code>{providerDef.bin}</code> binary from your PATH{" "} |
| 317 | {ready === true ? ( |
| 318 | <span className="ok-text">— found</span> |
| 319 | ) : ready === false ? ( |
| 320 | <span className="error-text">— not found in PATH</span> |
| 321 | ) : null} |
| 322 | </p> |
| 323 | ) : ( |
| 324 | <p className="hint"> |
| 325 | Any shell command. The full prompt arrives on stdin; print only |
| 326 | the rewritten text to stdout. |
| 327 | </p> |
| 328 | ) |
| 329 | ) : providerDef.keyVar ? ( |
| 330 | <p className="hint"> |
| 331 | Reads the key from{" "} |
| 332 | <code>{profile.api_key_env || providerDef.keyVar}</code>{" "} |
| 333 | {ready === true ? ( |
| 334 | <span className="ok-text">— found</span> |
| 335 | ) : ready === false ? ( |
| 336 | <span className="error-text"> |
| 337 | — not set. Export it in your shell profile and restart mutterbox. |
| 338 | </span> |
| 339 | ) : null} |
| 340 | </p> |
| 341 | ) : ( |
| 342 | <p className="hint">Talks to a local ollama server. No key needed.</p> |
| 343 | )} |
| 344 | </div> |
| 345 | |
| 346 | {profile.provider === "custom-cli" && ( |
| 347 | <div className="field"> |
| 348 | <label>command</label> |
| 349 | <input |
| 350 | value={profile.cli_command} |
| 351 | placeholder='e.g. llm -m gpt-4o-mini (prompt arrives on stdin)' |
| 352 | onChange={(e) => patchProfile({ cli_command: e.currentTarget.value })} |
| 353 | spellCheck={false} |
| 354 | /> |
| 355 | <p className="hint"> |
| 356 | Runs via <code>sh -c</code>. Must exit 0 and print only the result. |
| 357 | </p> |
| 358 | </div> |
| 359 | )} |
| 360 | |
| 361 | {profile.provider !== "custom-cli" && ( |
| 362 | <div className="field"> |
| 363 | <label> |
| 364 | model {isCli && <span className="optional">(optional)</span>} |
| 365 | </label> |
| 366 | <input |
| 367 | value={profile.model} |
| 368 | placeholder={isCli ? "CLI default" : ""} |
| 369 | onChange={(e) => patchProfile({ model: e.currentTarget.value })} |
| 370 | spellCheck={false} |
| 371 | /> |
| 372 | <p className="hint">e.g. {providerDef.modelHint}</p> |
| 373 | </div> |
| 374 | )} |
| 375 | |
| 376 | {(profile.provider === "openai" || profile.provider === "ollama") && ( |
| 377 | <div className="field"> |
| 378 | <label>base url</label> |
| 379 | <input |
| 380 | value={profile.base_url} |
| 381 | placeholder={ |
| 382 | profile.provider === "ollama" |
| 383 | ? "http://localhost:11434/v1" |
| 384 | : "https://api.openai.com/v1" |
| 385 | } |
| 386 | onChange={(e) => patchProfile({ base_url: e.currentTarget.value })} |
| 387 | spellCheck={false} |
| 388 | /> |
| 389 | <p className="hint"> |
| 390 | Any OpenAI-compatible endpoint works: OpenAI, OpenRouter, llama.cpp |
| 391 | server, vLLM, LM Studio. |
| 392 | </p> |
| 393 | </div> |
| 394 | )} |
| 395 | |
| 396 | {!isCli && profile.provider !== "ollama" && ( |
| 397 | <div className="field"> |
| 398 | <label>custom key variable (optional)</label> |
| 399 | <input |
| 400 | value={profile.api_key_env} |
| 401 | placeholder={providerDef.keyVar || "not needed"} |
| 402 | onChange={(e) => patchProfile({ api_key_env: e.currentTarget.value })} |
| 403 | spellCheck={false} |
| 404 | /> |
| 405 | <p className="hint"> |
| 406 | Name of an environment variable to read the API key from, if not the |
| 407 | default. Keys are never stored by mutterbox. |
| 408 | </p> |
| 409 | </div> |
| 410 | )} |
| 411 | |
| 412 | <div className="field"> |
| 413 | <label>instructions</label> |
| 414 | <textarea |
| 415 | rows={7} |
| 416 | value={profile.instructions} |
| 417 | onChange={(e) => patchProfile({ instructions: e.currentTarget.value })} |
| 418 | /> |
| 419 | <p className="hint"> |
| 420 | Style guidance only. mutterbox always instructs the model to rewrite |
| 421 | your transcript and never respond to it, so if you dictate a |
| 422 | question you get the polished question back, not an answer. Use this |
| 423 | field for tone and format: summaries, bullet points, email voice, |
| 424 | translation. |
| 425 | </p> |
| 426 | </div> |
| 427 | </div> |
| 428 | </section> |
| 429 | ); |
| 430 | } |