Raw
1 import { useEffect, useMemo, useState } from "react";
2 import type { ChatStatus, ModelInfo } from "./api";
3 import {
4 formatSize,
5 getAppVersion,
6 getBuildVersion,
7 getDownloadProgress,
8 } from "./api";
9 import { openUrl } from "@tauri-apps/plugin-opener";
10
11 interface SettingsProps {
12 open: boolean;
13 models: ModelInfo[];
14 status: ChatStatus;
15 statusError: string | null;
16 onSelectModel: (id: string) => void;
17 onRemoveModel: (id: string) => void;
18 onClose: () => void;
19 }
20
21 // Down-arrow-into-tray: download action for a not-yet-downloaded model.
22 function DownloadIcon() {
23 return (
24 <svg viewBox="0 0 20 20" width="18" height="18" aria-hidden>
25 <path
26 d="M10 3v9m0 0 3.5-3.5M10 12 6.5 8.5M4 14.5V16a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-1.5"
27 fill="none"
28 stroke="currentColor"
29 strokeWidth="1.6"
30 strokeLinecap="round"
31 strokeLinejoin="round"
32 />
33 </svg>
34 );
35 }
36
37 // Trash: remove a downloaded model's weights from disk.
38 function TrashIcon() {
39 return (
40 <svg viewBox="0 0 20 20" width="18" height="18" aria-hidden>
41 <path
42 d="M4 6h12M8 6V4.5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1V6m1.5 0-.6 9a1.5 1.5 0 0 1-1.5 1.4H8.1A1.5 1.5 0 0 1 6.6 15L6 6m2.5 2.5v5m3-5v5"
43 fill="none"
44 stroke="currentColor"
45 strokeWidth="1.6"
46 strokeLinecap="round"
47 strokeLinejoin="round"
48 />
49 </svg>
50 );
51 }
52
53 // Filled check badge: model is downloaded and available offline.
54 function DownloadedIcon() {
55 return (
56 <svg viewBox="0 0 20 20" width="18" height="18" aria-hidden>
57 <circle cx="10" cy="10" r="8" fill="currentColor" />
58 <path
59 d="m6.5 10.2 2.4 2.4 4.6-4.8"
60 fill="none"
61 stroke="var(--surface, #1c1c1e)"
62 strokeWidth="1.8"
63 strokeLinecap="round"
64 strokeLinejoin="round"
65 />
66 </svg>
67 );
68 }
69
70 /** Live weight-download progress, or `null` when there is nothing to report. */
71 interface DownloadProgress {
72 /** 0–1, clamped. */
73 fraction: number;
74 downloaded: number;
75 total: number;
76 }
77
78 /**
79 * Treat the download as finished slightly before the byte counts match. The
80 * small JSON assets (config, tokenizer) aren't in the model's expected size, and
81 * the last blob is renamed off `.part` only once complete, so the tail of the
82 * transfer is jumpy. Past this point the wait is the model loading into memory,
83 * which is what the user should be told.
84 */
85 const DOWNLOAD_DONE_FRACTION = 0.999;
86
87 function statusLine(
88 status: ChatStatus,
89 error: string | null,
90 progress: DownloadProgress | null
91 ) {
92 switch (status) {
93 case "loading":
94 if (progress && progress.fraction < DOWNLOAD_DONE_FRACTION) {
95 const pct = Math.floor(progress.fraction * 100);
96 // Byte detail is noise before the first chunk lands.
97 const detail =
98 progress.downloaded > 0
99 ? ` · ${formatSize(progress.downloaded)} of ${formatSize(progress.total)}`
100 : "";
101 return { text: `Downloading… ${pct}%${detail}`, cls: "loading" };
102 }
103 if (progress) return { text: "Loading into memory…", cls: "loading" };
104 return { text: "Downloading / loading…", cls: "loading" };
105 case "ready":
106 return { text: "Ready", cls: "ready" };
107 case "generating":
108 return { text: "Generating…", cls: "loading" };
109 case "error":
110 return { text: error ?? "Failed to load model", cls: "error" };
111 default:
112 return { text: "Not loaded", cls: "" };
113 }
114 }
115
116 export default function Settings({
117 open,
118 models,
119 status,
120 statusError,
121 onSelectModel,
122 onRemoveModel,
123 onClose,
124 }: SettingsProps) {
125 const selected = useMemo(
126 () => models.find((m) => m.is_selected) ?? models[0],
127 [models]
128 );
129 const busy = status === "loading";
130
131 // Poll the cache while a model loads so the indeterminate spinner becomes a
132 // real percentage. mistral.rs owns the transfer and reports progress only to
133 // the log, so bytes-on-disk is the only signal available to the UI.
134 const [downloadedBytes, setDownloadedBytes] = useState<number | null>(null);
135 const selectedId = selected?.id;
136 useEffect(() => {
137 if (!open || status !== "loading" || !selectedId) {
138 setDownloadedBytes(null);
139 return;
140 }
141 let active = true;
142 const tick = async () => {
143 const bytes = await getDownloadProgress(selectedId).catch(() => null);
144 if (active && bytes !== null) setDownloadedBytes(bytes);
145 };
146 void tick();
147 const timer = window.setInterval(tick, 1000);
148 return () => {
149 active = false;
150 window.clearInterval(timer);
151 };
152 }, [open, status, selectedId]);
153
154 const progress = useMemo<DownloadProgress | null>(() => {
155 const total = selected?.size_bytes;
156 if (status !== "loading" || !total || downloadedBytes === null) return null;
157 return {
158 fraction: Math.min(downloadedBytes / total, 1),
159 downloaded: downloadedBytes,
160 total,
161 };
162 }, [status, selected?.size_bytes, downloadedBytes]);
163
164 const line = statusLine(status, statusError, progress);
165
166 const handleOpenUrl = (url: string) => (e: React.MouseEvent) => {
167 e.preventDefault();
168 void openUrl(url);
169 };
170
171 // Real app version (CFBundleShortVersionString) and build number
172 // (CFBundleVersion), read from the bundle rather than hardcoded.
173 const [appVersion, setAppVersion] = useState("");
174 useEffect(() => {
175 let active = true;
176 (async () => {
177 const [version, build] = await Promise.all([
178 getAppVersion().catch(() => ""),
179 getBuildVersion().catch(() => null),
180 ]);
181 if (!active) return;
182 setAppVersion(build && build !== version ? `${version} (${build})` : version);
183 })();
184 return () => {
185 active = false;
186 };
187 }, []);
188
189 return (
190 <div className={`settings-overlay${open ? " open" : ""}`} aria-hidden={!open}>
191 <header className="settings-header">
192 <h1>Settings</h1>
193 <button className="settings-done" onClick={onClose}>
194 Done
195 </button>
196 </header>
197
198 <div className="settings-content">
199 <section className="settings-section">
200 <p className="section-label">Model</p>
201
202 <div className="settings-card">
203 <div className="settings-row">
204 <span className="row-label">On-device model</span>
205 <div className="select-wrap">
206 <select
207 className="model-select"
208 value={selected?.id ?? ""}
209 disabled={busy || models.length === 0}
210 onChange={(e) => onSelectModel(e.target.value)}
211 >
212 {models.length === 0 && <option value="">Unavailable</option>}
213 {models.map((m) => (
214 <option key={m.id} value={m.id}>
215 {m.name}
216 {m.size_bytes ? ` · ${formatSize(m.size_bytes)}` : ""}
217 </option>
218 ))}
219 </select>
220 <svg className="chevron" viewBox="0 0 12 8" aria-hidden>
221 <path
222 d="M1 1.5 6 6.5 11 1.5"
223 stroke="currentColor"
224 strokeWidth="1.6"
225 fill="none"
226 strokeLinecap="round"
227 strokeLinejoin="round"
228 />
229 </svg>
230 </div>
231 </div>
232
233 {selected && (
234 <div className="model-detail">
235 <span className="model-org">{selected.org}</span>
236 <p className="model-desc">{selected.description}</p>
237 <div className={`model-status ${line.cls}`}>
238 <span className="model-status-dot" />
239 {line.text}
240 </div>
241 {progress && progress.fraction < DOWNLOAD_DONE_FRACTION && (
242 <div
243 className="model-progress"
244 role="progressbar"
245 aria-valuenow={Math.floor(progress.fraction * 100)}
246 aria-valuemin={0}
247 aria-valuemax={100}
248 aria-label="Model download progress"
249 >
250 <div
251 className="model-progress-fill"
252 style={{ width: `${progress.fraction * 100}%` }}
253 />
254 </div>
255 )}
256 </div>
257 )}
258 </div>
259
260 <p className="settings-note">
261 The model downloads the first time you pick it, so use Wi-Fi if you
262 can. It then runs on your device, and nothing you type is sent to a
263 server.
264 </p>
265 </section>
266
267 <section className="settings-section">
268 <p className="section-label">Supported Models</p>
269 <div className="settings-card model-list">
270 {models.length === 0 && (
271 <div className="settings-row">
272 <span className="row-value">No models available.</span>
273 </div>
274 )}
275 {models.map((m) => (
276 <div className="model-list-row" key={m.id}>
277 <div className="model-list-info">
278 <span className="model-list-name">{m.name}</span>
279 <span className="model-list-meta">
280 {m.org}
281 {m.size_bytes ? ` · ${formatSize(m.size_bytes)}` : ""}
282 </span>
283 </div>
284 <div className="model-list-actions">
285 {m.is_downloaded ? (
286 <>
287 <span
288 className="model-downloaded"
289 title="Downloaded"
290 aria-label="Downloaded"
291 >
292 <DownloadedIcon />
293 </span>
294 <button
295 className="model-action remove"
296 onClick={() => onRemoveModel(m.id)}
297 disabled={busy}
298 title="Remove download"
299 aria-label={`Remove ${m.name}`}
300 >
301 <TrashIcon />
302 </button>
303 </>
304 ) : (
305 <button
306 className="model-action download"
307 onClick={() => onSelectModel(m.id)}
308 disabled={busy}
309 title="Download"
310 aria-label={`Download ${m.name}`}
311 >
312 <DownloadIcon />
313 </button>
314 )}
315 </div>
316 </div>
317 ))}
318 </div>
319 </section>
320
321 <section className="settings-section">
322 <p className="section-label">About</p>
323 <div className="settings-card about">
324 <div className="settings-row">
325 <span className="row-label">Siti AI</span>
326 <span className="row-value">{appVersion}</span>
327 </div>
328 <div className="settings-row">
329 <span className="row-label">Privacy</span>
330 <span className="row-value">On-device · private</span>
331 </div>
332 </div>
333 </section>
334
335 <section className="settings-section">
336 <p className="section-label">Contact</p>
337 <div className="settings-card about">
338 <div className="settings-row">
339 <span className="row-label">GitHub</span>
340 <a
341 className="row-value row-link"
342 href="https://github.com/getsigit/siti"
343 onClick={handleOpenUrl("https://github.com/getsigit/siti")}
344 >
345 getsigit/siti
346 </a>
347 </div>
348 <div className="settings-row">
349 <span className="row-label">Website</span>
350 <a
351 className="row-value row-link"
352 href="https://getsiti.5mb.app"
353 onClick={handleOpenUrl("https://getsiti.5mb.app")}
354 >
355 https://getsiti.5mb.app
356 </a>
357 </div>
358 </div>
359 <p className="settings-note">Profoundly personal. Entirely private.</p>
360 </section>
361 </div>
362 </div>
363 );
364 }