1 import { useState, useRef, useEffect, KeyboardEvent, useCallback } from "react";
2 import "./App.css";
3 import Settings from "./Settings";
4 import {
5 type ChatStatus,
6 type ModelInfo,
7 listModels,
8 setModel,
9 sendMessage as sendMessageCmd,
10 getStatus,
11 getHistory,
12 onStatusChanged,
13 onReply,
14 } from "./api";
15
16 type Role = "user" | "assistant";
17
18 interface Message {
19 id: number;
20 role: Role;
21 text: string;
22 }
23
24 let nextId = 1;
25
26 function SendIcon() {
27 return (
28 <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
29 <path
30 d="M2 8L14 8M14 8L9 3M14 8L9 13"
31 stroke="currentColor"
32 strokeWidth="1.5"
33 strokeLinecap="round"
34 strokeLinejoin="round"
35 />
36 </svg>
37 );
38 }
39
40 function GearIcon() {
41 return (
42 <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
43 <circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.6" />
44 <path
45 d="M12 2.5v2M12 19.5v2M21.5 12h-2M4.5 12h-2M18.7 5.3l-1.4 1.4M6.7 17.3l-1.4 1.4M18.7 18.7l-1.4-1.4M6.7 6.7 5.3 5.3"
46 stroke="currentColor"
47 strokeWidth="1.6"
48 strokeLinecap="round"
49 />
50 </svg>
51 );
52 }
53
54 function TypingBubble() {
55 return (
56 <div className="message-row assistant">
57 <div className="bubble typing-indicator">
58 <span className="typing-dot" />
59 <span className="typing-dot" />
60 <span className="typing-dot" />
61 </div>
62 </div>
63 );
64 }
65
66 export default function App() {
67 const [messages, setMessages] = useState<Message[]>([]);
68 const [input, setInput] = useState("");
69 const [thinking, setThinking] = useState(false);
70 const [settingsOpen, setSettingsOpen] = useState(false);
71 const [models, setModels] = useState<ModelInfo[]>([]);
72 const [status, setStatus] = useState<ChatStatus>("unloaded");
73 const [statusModel, setStatusModel] = useState<string | null>(null);
74 const [statusError, setStatusError] = useState<string | null>(null);
75
76 const bottomRef = useRef<HTMLDivElement>(null);
77 const textareaRef = useRef<HTMLTextAreaElement>(null);
78 // Backend present? false when running in a plain browser (vite preview).
79 const backendRef = useRef(true);
80
81 const refreshModels = useCallback(async () => {
82 try {
83 setModels(await listModels());
84 } catch {
85 /* backend unavailable, ignore */
86 }
87 }, []);
88
89 // ── Wire backend: initial state, history, and live events ─────────────────
90 useEffect(() => {
91 let unStatus: (() => void) | undefined;
92 let unReply: (() => void) | undefined;
93
94 (async () => {
95 try {
96 const s = await getStatus();
97 setStatus(s.status);
98 setStatusModel(s.model_name);
99
100 const history = await getHistory();
101 if (history.length) {
102 setMessages(
103 history.map((m) => ({
104 id: nextId++,
105 role: m.role,
106 text: m.content,
107 }))
108 );
109 }
110 await refreshModels();
111 } catch {
112 backendRef.current = false;
113 }
114
115 try {
116 unStatus = await onStatusChanged((p) => {
117 setStatus(p.status);
118 setStatusModel(p.model_name);
119 setStatusError(p.error);
120 if (p.status === "ready" || p.status === "error") refreshModels();
121 });
122 unReply = await onReply((p) => {
123 setThinking(false);
124 if (p.reply) {
125 setMessages((prev) => [
126 ...prev,
127 { id: nextId++, role: "assistant", text: p.reply as string },
128 ]);
129 } else if (p.error) {
130 setMessages((prev) => [
131 ...prev,
132 {
133 id: nextId++,
134 role: "assistant",
135 text: `⚠️ ${p.error}`,
136 },
137 ]);
138 }
139 });
140 } catch {
141 backendRef.current = false;
142 }
143 })();
144
145 return () => {
146 unStatus?.();
147 unReply?.();
148 };
149 }, [refreshModels]);
150
151 useEffect(() => {
152 bottomRef.current?.scrollIntoView({ behavior: "smooth" });
153 }, [messages, thinking]);
154
155 function autoResize() {
156 const el = textareaRef.current;
157 if (!el) return;
158 el.style.height = "auto";
159 el.style.height = Math.min(el.scrollHeight, 120) + "px";
160 }
161
162 async function sendMessage() {
163 const text = input.trim();
164 if (!text || thinking) return;
165
166 setMessages((prev) => [...prev, { id: nextId++, role: "user", text }]);
167 setInput("");
168 if (textareaRef.current) textareaRef.current.style.height = "auto";
169 setThinking(true);
170
171 if (!backendRef.current) {
172 // Browser preview fallback: no on-device engine available.
173 await new Promise((r) => setTimeout(r, 700));
174 setMessages((prev) => [
175 ...prev,
176 {
177 id: nextId++,
178 role: "assistant",
179 text: `(preview) I'm Siti. On a device I'd answer: "${text}"`,
180 },
181 ]);
182 setThinking(false);
183 return;
184 }
185
186 try {
187 // Reply arrives asynchronously via the `chat_reply` event listener.
188 await sendMessageCmd(text);
189 } catch (e) {
190 setThinking(false);
191 setMessages((prev) => [
192 ...prev,
193 { id: nextId++, role: "assistant", text: `⚠️ ${String(e)}` },
194 ]);
195 }
196 }
197
198 async function handleSelectModel(id: string) {
199 setModels((prev) => prev.map((m) => ({ ...m, is_selected: m.id === id })));
200 setStatus("loading");
201 setStatusError(null);
202 try {
203 await setModel(id);
204 } catch (e) {
205 setStatus("error");
206 setStatusError(String(e));
207 }
208 }
209
210 function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
211 if (e.key === "Enter" && !e.shiftKey) {
212 e.preventDefault();
213 sendMessage();
214 }
215 }
216
217 const dotThinking =
218 thinking || status === "loading" || status === "generating";
219 const dotClass =
220 status === "error" ? " error" : dotThinking ? " thinking" : "";
221
222 const subtitle =
223 status === "loading"
224 ? `Loading ${statusModel ?? "model"}…`
225 : status === "error"
226 ? "Model error · tap ⚙"
227 : statusModel
228 ? `${statusModel} · private`
229 : "on-device · private";
230
231 return (
232 <div className="app">
233 <header className="header">
234 <div className="header-title">
235 <h1>Siti AI</h1>
236 <span className="header-subtitle">{subtitle}</span>
237 </div>
238 <div className="header-actions">
239 <span className={`status-dot${dotClass}`} />
240 <button
241 className="icon-button"
242 onClick={() => setSettingsOpen(true)}
243 aria-label="Settings"
244 >
245 <GearIcon />
246 </button>
247 </div>
248 </header>
249
250 <div className="messages">
251 {messages.length === 0 && !thinking ? (
252 <div className="empty-state">
253 <div className="empty-icon">◇</div>
254 <h2>How can I help?</h2>
255 <p>Your personal assistant, running entirely on your device.</p>
256 </div>
257 ) : (
258 messages.map((msg) => (
259 <div key={msg.id} className={`message-row ${msg.role}`}>
260 <div className="bubble">{msg.text}</div>
261 </div>
262 ))
263 )}
264 {thinking && <TypingBubble />}
265 <div ref={bottomRef} />
266 </div>
267
268 <div className="input-area">
269 <div className="input-form">
270 <textarea
271 ref={textareaRef}
272 className="input-field"
273 value={input}
274 placeholder="Message Siti…"
275 rows={1}
276 onChange={(e) => {
277 setInput(e.target.value);
278 autoResize();
279 }}
280 onKeyDown={handleKeyDown}
281 />
282 <button
283 className="send-button"
284 onClick={sendMessage}
285 disabled={!input.trim() || thinking}
286 aria-label="Send"
287 >
288 <SendIcon />
289 </button>
290 </div>
291 <p className="input-hint">Return to send · Shift+Return for new line</p>
292 </div>
293
294 <Settings
295 open={settingsOpen}
296 models={models}
297 status={status}
298 statusError={statusError}
299 onSelectModel={handleSelectModel}
300 onClose={() => setSettingsOpen(false)}
301 />
302 </div>
303 );
304 }