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