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