| 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 | } |