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