1 import { useEffect, useMemo, useState } from "react";
2 import type { ChatStatus, ModelInfo } from "./api";
3 import { formatSize, getAppVersion, getBuildVersion } from "./api";
4
5 interface SettingsProps {
6 open: boolean;
7 models: ModelInfo[];
8 status: ChatStatus;
9 statusError: string | null;
10 onSelectModel: (id: string) => void;
11 onClose: () => void;
12 }
13
14 function statusLine(status: ChatStatus, error: string | null) {
15 switch (status) {
16 case "loading":
17 return { text: "Downloading / loading…", cls: "loading" };
18 case "ready":
19 return { text: "Ready", cls: "ready" };
20 case "generating":
21 return { text: "Generating…", cls: "loading" };
22 case "error":
23 return { text: error ?? "Failed to load model", cls: "error" };
24 default:
25 return { text: "Not loaded", cls: "" };
26 }
27 }
28
29 export default function Settings({
30 open,
31 models,
32 status,
33 statusError,
34 onSelectModel,
35 onClose,
36 }: SettingsProps) {
37 const selected = useMemo(
38 () => models.find((m) => m.is_selected) ?? models[0],
39 [models]
40 );
41 const busy = status === "loading";
42 const line = statusLine(status, statusError);
43
44 // Real app version (CFBundleShortVersionString) and build number
45 // (CFBundleVersion), read from the bundle rather than hardcoded.
46 const [appVersion, setAppVersion] = useState("");
47 useEffect(() => {
48 let active = true;
49 (async () => {
50 const [version, build] = await Promise.all([
51 getAppVersion().catch(() => ""),
52 getBuildVersion().catch(() => null),
53 ]);
54 if (!active) return;
55 setAppVersion(build && build !== version ? `${version} (${build})` : version);
56 })();
57 return () => {
58 active = false;
59 };
60 }, []);
61
62 return (
63 <div className={`settings-overlay${open ? " open" : ""}`} aria-hidden={!open}>
64 <header className="settings-header">
65 <h1>Settings</h1>
66 <button className="settings-done" onClick={onClose}>
67 Done
68 </button>
69 </header>
70
71 <div className="settings-content">
72 <section className="settings-section">
73 <p className="section-label">Model</p>
74
75 <div className="settings-card">
76 <div className="settings-row">
77 <span className="row-label">On-device model</span>
78 <div className="select-wrap">
79 <select
80 className="model-select"
81 value={selected?.id ?? ""}
82 disabled={busy || models.length === 0}
83 onChange={(e) => onSelectModel(e.target.value)}
84 >
85 {models.length === 0 && <option value="">Unavailable</option>}
86 {models.map((m) => (
87 <option key={m.id} value={m.id}>
88 {m.name}
89 {m.size_bytes ? ` · ${formatSize(m.size_bytes)}` : ""}
90 </option>
91 ))}
92 </select>
93 <svg className="chevron" viewBox="0 0 12 8" aria-hidden>
94 <path
95 d="M1 1.5 6 6.5 11 1.5"
96 stroke="currentColor"
97 strokeWidth="1.6"
98 fill="none"
99 strokeLinecap="round"
100 strokeLinejoin="round"
101 />
102 </svg>
103 </div>
104 </div>
105
106 {selected && (
107 <div className="model-detail">
108 <span className="model-org">{selected.org}</span>
109 <p className="model-desc">{selected.description}</p>
110 <div className={`model-status ${line.cls}`}>
111 <span className="model-status-dot" />
112 {line.text}
113 </div>
114 </div>
115 )}
116 </div>
117
118 <p className="settings-note">
119 The model downloads the first time you pick it, so use Wi-Fi if you
120 can. It then runs on your device, and nothing you type is sent to a
121 server.
122 </p>
123 </section>
124
125 <section className="settings-section">
126 <p className="section-label">About</p>
127 <div className="settings-card about">
128 <div className="settings-row">
129 <span className="row-label">Siti AI</span>
130 <span className="row-value">{appVersion}</span>
131 </div>
132 <div className="settings-row">
133 <span className="row-label">Privacy</span>
134 <span className="row-value">On-device · private</span>
135 </div>
136 </div>
137 <p className="settings-note">Profoundly personal. Entirely private.</p>
138 </section>
139 </div>
140 </div>
141 );
142 }