| import { useEffect, useMemo, useRef, useState } from "react"; | |
| type Role = "user" | "assistant" | "system"; | |
| type ChatMessage = { | |
| id: string; | |
| role: Role; | |
| content: string; | |
| createdAt: number; | |
| }; | |
| type StatusEvent = { | |
| message: string; | |
| }; | |
| const SHORTCUTS = [ | |
| { combo: "Ctrl+Enter", action: "Send" }, | |
| { combo: "Ctrl+K", action: "Focus input" }, | |
| { combo: "Esc", action: "Clear" } | |
| ]; | |
| function useSessionId() { | |
| const [sessionId] = useState(() => { | |
| const cached = localStorage.getItem("novachat_session"); | |
| if (cached) return cached; | |
| const created = crypto.randomUUID(); | |
| localStorage.setItem("novachat_session", created); | |
| return created; | |
| }); | |
| return sessionId; | |
| } | |
| export default function App() { | |
| const sessionId = useSessionId(); | |
| const [messages, setMessages] = useState<ChatMessage[]>([]); | |
| const [input, setInput] = useState(""); | |
| const [status, setStatus] = useState("Idle"); | |
| const [isStreaming, setIsStreaming] = useState(false); | |
| const [theme, setTheme] = useState(() => localStorage.getItem("novachat_theme") ?? "dark"); | |
| const inputRef = useRef<HTMLTextAreaElement | null>(null); | |
| const bottomRef = useRef<HTMLDivElement | null>(null); | |
| const streamRef = useRef<EventSource | null>(null); | |
| useEffect(() => { | |
| document.documentElement.dataset.theme = theme; | |
| localStorage.setItem("novachat_theme", theme); | |
| }, [theme]); | |
| useEffect(() => { | |
| bottomRef.current?.scrollIntoView({ behavior: "smooth" }); | |
| }, [messages, status]); | |
| useEffect(() => { | |
| const handler = (event: KeyboardEvent) => { | |
| if (event.ctrlKey && event.key.toLowerCase() === "k") { | |
| event.preventDefault(); | |
| inputRef.current?.focus(); | |
| } | |
| if (event.ctrlKey && event.key === "Enter") { | |
| event.preventDefault(); | |
| handleSend(); | |
| } | |
| if (event.key === "Escape") { | |
| setInput(""); | |
| } | |
| }; | |
| window.addEventListener("keydown", handler); | |
| return () => window.removeEventListener("keydown", handler); | |
| }); | |
| const groupedMessages = useMemo(() => { | |
| const groups: ChatMessage[][] = []; | |
| messages.forEach((msg) => { | |
| const lastGroup = groups[groups.length - 1]; | |
| if (lastGroup && lastGroup[lastGroup.length - 1].role === msg.role) { | |
| lastGroup.push(msg); | |
| } else { | |
| groups.push([msg]); | |
| } | |
| }); | |
| return groups; | |
| }, [messages]); | |
| const handleSend = () => { | |
| const trimmed = input.trim(); | |
| if (!trimmed || isStreaming) return; | |
| const userMessage: ChatMessage = { | |
| id: `user_${Date.now()}`, | |
| role: "user", | |
| content: trimmed, | |
| createdAt: Date.now() | |
| }; | |
| setMessages((prev) => [...prev, userMessage]); | |
| setInput(""); | |
| setIsStreaming(true); | |
| setStatus("Connecting..."); | |
| streamRef.current?.close(); | |
| const url = new URL("/api/chat/stream", window.location.origin); | |
| url.searchParams.set("sessionId", sessionId); | |
| url.searchParams.set("message", trimmed); | |
| const stream = new EventSource(url); | |
| streamRef.current = stream; | |
| let assistantId = `assistant_${Date.now()}`; | |
| const appendAssistant = (token: string) => { | |
| setMessages((prev) => { | |
| const next = [...prev]; | |
| const existing = next.find((msg) => msg.id === assistantId); | |
| if (existing) { | |
| existing.content += token; | |
| return [...next]; | |
| } | |
| return [...next, { id: assistantId, role: "assistant", content: token, createdAt: Date.now() }]; | |
| }); | |
| }; | |
| stream.addEventListener("status", (event) => { | |
| const data = JSON.parse((event as MessageEvent).data) as StatusEvent; | |
| setStatus(data.message); | |
| }); | |
| stream.addEventListener("delta", (event) => { | |
| const data = JSON.parse((event as MessageEvent).data) as { token: string }; | |
| appendAssistant(data.token); | |
| setStatus("Responding..."); | |
| }); | |
| stream.addEventListener("done", () => { | |
| setIsStreaming(false); | |
| setStatus("Idle"); | |
| stream.close(); | |
| }); | |
| stream.addEventListener("error", (event) => { | |
| setIsStreaming(false); | |
| setStatus("Connection error"); | |
| stream.close(); | |
| }); | |
| }; | |
| return ( | |
| <div className="app"> | |
| <header className="topbar"> | |
| <div className="brand"> | |
| <div className="brand-mark" /> | |
| <div> | |
| <div className="brand-title">NovaChat</div> | |
| <div className="brand-subtitle">Real-time intelligence engine</div> | |
| </div> | |
| </div> | |
| <div className="topbar-actions"> | |
| <button className="ghost" onClick={() => setTheme(theme === "dark" ? "light" : "dark")}> | |
| {theme === "dark" ? "Light" : "Dark"} mode | |
| </button> | |
| <button className="ghost" onClick={() => setMessages([])}> | |
| New session | |
| </button> | |
| </div> | |
| </header> | |
| <main className="layout"> | |
| <section className="chat"> | |
| <div className="chat-header"> | |
| <div> | |
| <div className="chat-title">Control Room</div> | |
| <div className="chat-subtitle">Ultra-low latency responses with live web search.</div> | |
| </div> | |
| <div className="status"> | |
| <span className={isStreaming ? "pulse" : "dot"} /> | |
| {status} | |
| </div> | |
| </div> | |
| <div className="chat-body"> | |
| {groupedMessages.length === 0 ? ( | |
| <div className="empty"> | |
| <h2>Ask anything. Get fast, grounded answers.</h2> | |
| <p>Type a request and NovaChat will decide when to pull live web sources.</p> | |
| <div className="shortcut-grid"> | |
| {SHORTCUTS.map((shortcut) => ( | |
| <div key={shortcut.combo} className="shortcut-card"> | |
| <div className="shortcut-combo">{shortcut.combo}</div> | |
| <div className="shortcut-action">{shortcut.action}</div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| ) : ( | |
| groupedMessages.map((group) => ( | |
| <div key={group[0].id} className={`message-group ${group[0].role}`}> | |
| <div className="message-meta">{group[0].role === "user" ? "You" : "Nova"}</div> | |
| <div className="message-stack"> | |
| {group.map((msg) => ( | |
| <div key={msg.id} className="message-bubble"> | |
| {msg.content} | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )) | |
| )} | |
| {isStreaming && ( | |
| <div className="typing"> | |
| <span /> | |
| <span /> | |
| <span /> | |
| </div> | |
| )} | |
| <div ref={bottomRef} /> | |
| </div> | |
| <div className="chat-input"> | |
| <textarea | |
| ref={inputRef} | |
| placeholder="Ask NovaChat to research, summarize, or build something..." | |
| value={input} | |
| onChange={(event) => setInput(event.target.value)} | |
| rows={2} | |
| /> | |
| <div className="input-actions"> | |
| <div className="input-hint">Session: {sessionId.slice(0, 8)}</div> | |
| <button className="primary" onClick={handleSend} disabled={isStreaming || !input.trim()}> | |
| {isStreaming ? "Streaming..." : "Send"} | |
| </button> | |
| </div> | |
| </div> | |
| </section> | |
| <aside className="sidebar"> | |
| <div className="card"> | |
| <h3>Live Ops</h3> | |
| <p>Search cache and streaming are running in parallel for zero-lag delivery.</p> | |
| <div className="metric"> | |
| <span>Latency target</span> | |
| <strong>< 800ms</strong> | |
| </div> | |
| <div className="metric"> | |
| <span>Streaming</span> | |
| <strong>Token-by-token</strong> | |
| </div> | |
| </div> | |
| <div className="card"> | |
| <h3>Search Stack</h3> | |
| <p>Multi-source fetch, extraction, ranking, and summarization pipeline.</p> | |
| <div className="badge-row"> | |
| <span>DuckDuckGo</span> | |
| <span>Readability</span> | |
| <span>LRU Cache</span> | |
| </div> | |
| </div> | |
| <div className="card"> | |
| <h3>Controls</h3> | |
| <p>NovaChat automatically decides when to search the web.</p> | |
| <button className="ghost full" onClick={() => setStatus("Manual search mode coming soon")}>Enable manual search</button> | |
| </div> | |
| </aside> | |
| </main> | |
| </div> | |
| ); | |
| } | |