Raw
1 import { useEffect, useMemo, useState } from "react";
2 import type { ChatStatus, ModelInfo } from "./api";
3 import { formatSize, getAppVersion, getBuildVersion } from "./api";
4 import { openUrl } from "@tauri-apps/plugin-opener";
5
6 interface SettingsProps {
7 open: boolean;
8 models: ModelInfo[];
9 status: ChatStatus;
10 statusError: string | null;
11 onSelectModel: (id: string) => void;
12 onRemoveModel: (id: string) => void;
13 onClose: () => void;
14 }
15
16 // Down-arrow-into-tray: download action for a not-yet-downloaded model.
17 function DownloadIcon() {
18 return (
19 <svg viewBox="0 0 20 20" width="18" height="18" aria-hidden>
20 <path
21 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"
22 fill="none"
23 stroke="currentColor"
24 strokeWidth="1.6"
25 strokeLinecap="round"
26 strokeLinejoin="round"
27 />
28 </svg>
29 );
30 }
31
32 // Trash: remove a downloaded model's weights from disk.
33 function TrashIcon() {
34 return (
35 <svg viewBox="0 0 20 20" width="18" height="18" aria-hidden>
36 <path
37 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"
38 fill="none"
39 stroke="currentColor"
40 strokeWidth="1.6"
41 strokeLinecap="round"
42 strokeLinejoin="round"
43 />
44 </svg>
45 );
46 }
47
48 // Filled check badge: model is downloaded and available offline.
49 function DownloadedIcon() {
50 return (
51 <svg viewBox="0 0 20 20" width="18" height="18" aria-hidden>
52 <circle cx="10" cy="10" r="8" fill="currentColor" />
53 <path
54 d="m6.5 10.2 2.4 2.4 4.6-4.8"
55 fill="none"
56 stroke="var(--surface, #1c1c1e)"
57 strokeWidth="1.8"
58 strokeLinecap="round"
59 strokeLinejoin="round"
60 />
61 </svg>
62 );
63 }
64
65 function statusLine(status: ChatStatus, error: string | null) {
66 switch (status) {
67 case "loading":
68 return { text: "Downloading / loading…", cls: "loading" };
69 case "ready":
70 return { text: "Ready", cls: "ready" };
71 case "generating":
72 return { text: "Generating…", cls: "loading" };
73 case "error":
74 return { text: error ?? "Failed to load model", cls: "error" };
75 default:
76 return { text: "Not loaded", cls: "" };
77 }
78 }
79
80 export default function Settings({
81 open,
82 models,
83 status,
84 statusError,
85 onSelectModel,
86 onRemoveModel,
87 onClose,
88 }: SettingsProps) {
89 const selected = useMemo(
90 () => models.find((m) => m.is_selected) ?? models[0],
91 [models]
92 );
93 const busy = status === "loading";
94 const line = statusLine(status, statusError);
95
96 const handleOpenUrl = (url: string) => (e: React.MouseEvent) => {
97 e.preventDefault();
98 void openUrl(url);
99 };
100
101 // Real app version (CFBundleShortVersionString) and build number
102 // (CFBundleVersion), read from the bundle rather than hardcoded.
103 const [appVersion, setAppVersion] = useState("");
104 useEffect(() => {
105 let active = true;
106 (async () => {
107 const [version, build] = await Promise.all([
108 getAppVersion().catch(() => ""),
109 getBuildVersion().catch(() => null),
110 ]);
111 if (!active) return;
112 setAppVersion(build && build !== version ? `${version} (${build})` : version);
113 })();
114 return () => {
115 active = false;
116 };
117 }, []);
118
119 return (
120 <div className={`settings-overlay${open ? " open" : ""}`} aria-hidden={!open}>
121 <header className="settings-header">
122 <h1>Settings</h1>
123 <button className="settings-done" onClick={onClose}>
124 Done
125 </button>
126 </header>
127
128 <div className="settings-content">
129 <section className="settings-section">
130 <p className="section-label">Model</p>
131
132 <div className="settings-card">
133 <div className="settings-row">
134 <span className="row-label">On-device model</span>
135 <div className="select-wrap">
136 <select
137 className="model-select"
138 value={selected?.id ?? ""}
139 disabled={busy || models.length === 0}
140 onChange={(e) => onSelectModel(e.target.value)}
141 >
142 {models.length === 0 && <option value="">Unavailable</option>}
143 {models.map((m) => (
144 <option key={m.id} value={m.id}>
145 {m.name}
146 {m.size_bytes ? ` · ${formatSize(m.size_bytes)}` : ""}
147 </option>
148 ))}
149 </select>
150 <svg className="chevron" viewBox="0 0 12 8" aria-hidden>
151 <path
152 d="M1 1.5 6 6.5 11 1.5"
153 stroke="currentColor"
154 strokeWidth="1.6"
155 fill="none"
156 strokeLinecap="round"
157 strokeLinejoin="round"
158 />
159 </svg>
160 </div>
161 </div>
162
163 {selected && (
164 <div className="model-detail">
165 <span className="model-org">{selected.org}</span>
166 <p className="model-desc">{selected.description}</p>
167 <div className={`model-status ${line.cls}`}>
168 <span className="model-status-dot" />
169 {line.text}
170 </div>
171 </div>
172 )}
173 </div>
174
175 <p className="settings-note">
176 The model downloads the first time you pick it, so use Wi-Fi if you
177 can. It then runs on your device, and nothing you type is sent to a
178 server.
179 </p>
180 </section>
181
182 <section className="settings-section">
183 <p className="section-label">Supported Models</p>
184 <div className="settings-card model-list">
185 {models.length === 0 && (
186 <div className="settings-row">
187 <span className="row-value">No models available.</span>
188 </div>
189 )}
190 {models.map((m) => (
191 <div className="model-list-row" key={m.id}>
192 <div className="model-list-info">
193 <span className="model-list-name">{m.name}</span>
194 <span className="model-list-meta">
195 {m.org}
196 {m.size_bytes ? ` · ${formatSize(m.size_bytes)}` : ""}
197 </span>
198 </div>
199 <div className="model-list-actions">
200 {m.is_downloaded ? (
201 <>
202 <span
203 className="model-downloaded"
204 title="Downloaded"
205 aria-label="Downloaded"
206 >
207 <DownloadedIcon />
208 </span>
209 <button
210 className="model-action remove"
211 onClick={() => onRemoveModel(m.id)}
212 disabled={busy}
213 title="Remove download"
214 aria-label={`Remove ${m.name}`}
215 >
216 <TrashIcon />
217 </button>
218 </>
219 ) : (
220 <button
221 className="model-action download"
222 onClick={() => onSelectModel(m.id)}
223 disabled={busy}
224 title="Download"
225 aria-label={`Download ${m.name}`}
226 >
227 <DownloadIcon />
228 </button>
229 )}
230 </div>
231 </div>
232 ))}
233 </div>
234 </section>
235
236 <section className="settings-section">
237 <p className="section-label">About</p>
238 <div className="settings-card about">
239 <div className="settings-row">
240 <span className="row-label">Siti AI</span>
241 <span className="row-value">{appVersion}</span>
242 </div>
243 <div className="settings-row">
244 <span className="row-label">Privacy</span>
245 <span className="row-value">On-device · private</span>
246 </div>
247 </div>
248 </section>
249
250 <section className="settings-section">
251 <p className="section-label">Contact</p>
252 <div className="settings-card about">
253 <div className="settings-row">
254 <span className="row-label">GitHub</span>
255 <a
256 className="row-value row-link"
257 href="https://github.com/getsigit/siti"
258 onClick={handleOpenUrl("https://github.com/getsigit/siti")}
259 >
260 getsigit/siti
261 </a>
262 </div>
263 <div className="settings-row">
264 <span className="row-label">Website</span>
265 <a
266 className="row-value row-link"
267 href="https://getsiti.5mb.app"
268 onClick={handleOpenUrl("https://getsiti.5mb.app")}
269 >
270 https://getsiti.5mb.app
271 </a>
272 </div>
273 </div>
274 <p className="settings-note">Profoundly personal. Entirely private.</p>
275 </section>
276 </div>
277 </div>
278 );
279 }