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/Models.tsx
118 lines4.0 KBJavaScript
1import { useEffect, useState } from "react";
2import { listen } from "@tauri-apps/api/event";
3import { deleteModel, downloadModel, listModels } from "../api";
4import type { DownloadProgress, ModelInfo, Settings } from "../types";
5
6interface Props {
7 settings: Settings;
8 onChange: (next: Settings) => void;
9}
10
11export default function ModelsPage({ settings, onChange }: Props) {
12 const [models, setModels] = useState<ModelInfo[]>([]);
13 const [progress, setProgress] = useState<Record<string, DownloadProgress>>({});
14 const [errors, setErrors] = useState<Record<string, string>>({});
15
16 const refresh = () => listModels().then(setModels).catch(console.error);
17
18 useEffect(() => {
19 refresh();
20 const sub = listen<DownloadProgress>("model-progress", (e) => {
21 setProgress((p) => ({ ...p, [e.payload.id]: e.payload }));
22 if (e.payload.done) refresh();
23 });
24 return () => {
25 sub.then((fn) => fn());
26 };
27 }, []);
28
29 const start = (id: string) => {
30 setErrors((e) => ({ ...e, [id]: "" }));
31 setProgress((p) => ({ ...p, [id]: { id, downloaded: 0, total: 0, done: false } }));
32 downloadModel(id)
33 .then(refresh)
34 .catch((err) => {
35 setErrors((e) => ({ ...e, [id]: String(err) }));
36 setProgress((p) => {
37 const { [id]: _dropped, ...rest } = p;
38 return rest;
39 });
40 });
41 };
42
43 const remove = (id: string) => {
44 deleteModel(id).then(refresh).catch(console.error);
45 setProgress((p) => {
46 const { [id]: _dropped, ...rest } = p;
47 return rest;
48 });
49 };
50
51 return (
52 <section>
53 <header className="page-head">
54 <h2>models</h2>
55 <p>
56 Dictation runs locally with whisper.cpp. Download a model once, then
57 everything works offline.
58 </p>
59 </header>
60
61 <ul className="model-list">
62 {models.map((m) => {
63 const p = progress[m.id];
64 const downloading = p !== undefined && !p.done && !m.installed;
65 const active = settings.dictation_model === m.id;
66 const pct =
67 p && p.total > 0 ? Math.min(100, (p.downloaded / p.total) * 100) : 0;
68 return (
69 <li key={m.id} className={active ? "model active" : "model"}>
70 <div className="model-main">
71 <div className="model-title">
72 <strong>{m.label}</strong>
73 <span className="badge">{m.size_mb} MB</span>
74 {m.multilingual && <span className="badge dim">multilingual</span>}
75 {active && <span className="badge accent">in use</span>}
76 </div>
77 <p className="hint">{m.description}</p>
78 {downloading && (
79 <div className="progress">
80 <div className="progress-fill" style={{ width: `${pct}%` }} />
81 </div>
82 )}
83 {errors[m.id] && <p className="hint error-text">{errors[m.id]}</p>}
84 </div>
85 <div className="model-actions">
86 {m.installed ? (
87 <>
88 {!active && (
89 <button
90 className="btn"
91 onClick={() => onChange({ ...settings, dictation_model: m.id })}
92 >
93 use
94 </button>
95 )}
96 <button className="btn ghost" onClick={() => remove(m.id)}>
97 delete
98 </button>
99 </>
100 ) : downloading ? (
101 <span className="hint">
102 {p.total > 0
103 ? `${Math.round(pct)}%`
104 : `${(p.downloaded / 1e6).toFixed(0)} MB`}
105 </span>
106 ) : (
107 <button className="btn" onClick={() => start(m.id)}>
108 install
109 </button>
110 )}
111 </div>
112 </li>
113 );
114 })}
115 </ul>
116 </section>
117 );
118}