irongit

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.

mutterbox/src/pages/Enhance.tsx
430 lines13 KBJavaScript
1import { useEffect, useState } from "react";
2import { enhanceKeyPresent } from "../api";
3import type { EnhanceProfile, EnhanceProvider, Settings } from "../types";
4
5interface Props {
6 settings: Settings;
7 onChange: (next: Settings) => void;
8}
9
10interface ProviderDef {
11 id: EnhanceProvider;
12 label: string;
13 keyVar: string;
14 bin: string;
15 defaultModel: string;
16 modelHint: string;
17}
18
19const 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
54const 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
89function 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
97export 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}