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