Spaces:
Running
Running
| import { useEffect, useRef, useState } from "react"; | |
| import { BrainCircuit, FileUp, Send, Square } from "lucide-react"; | |
| import { api, streamChat } from "../lib/api.js"; | |
| import MessageBubble from "./MessageBubble.jsx"; | |
| const SUGGESTIONS = [ | |
| { title: "Search the web", prompt: "Search the web for the latest breakthroughs in AI agents and summarize the top 3." }, | |
| { title: "Check the weather", prompt: "What's the weather like in Patiala right now?" }, | |
| { title: "Crunch numbers", prompt: "What is (1.07 ** 30) * 25000? Explain what this means for compound interest." }, | |
| { title: "Ask your documents", prompt: "Summarize the key points from my uploaded documents." }, | |
| ]; | |
| // In the public demo a sample report is preloaded, so the prompts point at it | |
| // and show retrieval with citations on the very first message. | |
| const DEMO_SUGGESTIONS = [ | |
| { | |
| title: "Query the preloaded report", | |
| prompt: "What was Northwind's 2026 revenue and gross margin?", | |
| }, | |
| { | |
| title: "Multi-step retrieval", | |
| prompt: "Summarize the autonomy research section and the three key results.", | |
| }, | |
| { | |
| title: "Live weather tool", | |
| prompt: "What's the weather in Amsterdam right now?", | |
| }, | |
| { | |
| title: "Precise arithmetic", | |
| prompt: "What is (1.07 ** 30) * 25000? Explain what this means for compound interest.", | |
| }, | |
| ]; | |
| export default function ChatView({ | |
| appInfo, | |
| sessionId, | |
| session, | |
| ensureSession, | |
| onSessionMeta, | |
| onOpenDocs, | |
| }) { | |
| const [messages, setMessages] = useState([]); | |
| const [input, setInput] = useState(""); | |
| const [streaming, setStreaming] = useState(false); | |
| const [error, setError] = useState(""); | |
| const [model, setModel] = useState(""); | |
| const abortRef = useRef(null); | |
| const scrollRef = useRef(null); | |
| const textareaRef = useRef(null); | |
| // Session ids this view created itself while sending. Reloading history for | |
| // those would wipe the reply that is still streaming into local state. | |
| const selfCreatedRef = useRef(null); | |
| const effectiveModel = | |
| model || session?.model || appInfo?.default_model || ""; | |
| const suggestions = appInfo?.demo_mode ? DEMO_SUGGESTIONS : SUGGESTIONS; | |
| useEffect(() => { | |
| if (!sessionId) { | |
| setMessages([]); | |
| return; | |
| } | |
| if (selfCreatedRef.current === sessionId) { | |
| // Skip exactly once, for the transition that created this session. | |
| selfCreatedRef.current = null; | |
| return; | |
| } | |
| api(`/api/sessions/${sessionId}/messages`) | |
| .then((list) => | |
| setMessages( | |
| list.map((m) => ({ | |
| id: m.id, | |
| role: m.role, | |
| content: m.content, | |
| tools: safeParse(m.tool_calls_json) || [], | |
| citations: safeParse(m.citations_json) || [], | |
| meta: | |
| m.role === "assistant" | |
| ? { | |
| model: m.model, | |
| input_tokens: m.input_tokens, | |
| output_tokens: m.output_tokens, | |
| cost_usd: m.cost_usd, | |
| latency_ms: m.latency_ms, | |
| } | |
| : null, | |
| })) | |
| ) | |
| ) | |
| .catch((err) => setError(err.message)); | |
| }, [sessionId]); | |
| useEffect(() => { | |
| const el = scrollRef.current; | |
| if (el) el.scrollTop = el.scrollHeight; | |
| }, [messages, streaming]); | |
| function safeParse(json) { | |
| try { | |
| return json ? JSON.parse(json) : null; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| function autoResize() { | |
| const el = textareaRef.current; | |
| if (!el) return; | |
| el.style.height = "auto"; | |
| el.style.height = Math.min(el.scrollHeight, 200) + "px"; | |
| } | |
| async function send(text) { | |
| const content = (text ?? input).trim(); | |
| if (!content || streaming) return; | |
| setError(""); | |
| setInput(""); | |
| requestAnimationFrame(autoResize); | |
| setStreaming(true); | |
| setMessages((prev) => [ | |
| ...prev, | |
| { id: `u-${Date.now()}`, role: "user", content, tools: [], citations: [] }, | |
| { | |
| id: `a-${Date.now()}`, | |
| role: "assistant", | |
| content: "", | |
| tools: [], | |
| citations: [], | |
| meta: null, | |
| live: true, | |
| }, | |
| ]); | |
| const controller = new AbortController(); | |
| abortRef.current = controller; | |
| try { | |
| const id = await ensureSession(); | |
| selfCreatedRef.current = id; | |
| await streamChat( | |
| id, | |
| { content, model: effectiveModel || undefined }, | |
| { | |
| onEvent: (event) => { | |
| setMessages((prev) => { | |
| const next = [...prev]; | |
| const last = { ...next[next.length - 1] }; | |
| if (event.type === "token") { | |
| last.content += event.text; | |
| } else if (event.type === "tool_start") { | |
| last.tools = [ | |
| ...last.tools, | |
| { name: event.name, arguments: event.arguments, status: "running" }, | |
| ]; | |
| } else if (event.type === "tool_end") { | |
| last.tools = last.tools.map((tool) => | |
| tool.name === event.name && tool.status === "running" | |
| ? { ...tool, status: "done", result_preview: event.result_preview } | |
| : tool | |
| ); | |
| } else if (event.type === "citations") { | |
| last.citations = event.citations; | |
| } else if (event.type === "usage") { | |
| last.meta = event; | |
| } else if (event.type === "title") { | |
| onSessionMeta(id, { title: event.title }); | |
| } else if (event.type === "done") { | |
| last.live = false; | |
| } else if (event.type === "error") { | |
| setError(event.message); | |
| last.live = false; | |
| } | |
| next[next.length - 1] = last; | |
| return next; | |
| }); | |
| }, | |
| }, | |
| controller.signal | |
| ); | |
| } catch (err) { | |
| if (err.name !== "AbortError") setError(err.message); | |
| } finally { | |
| setStreaming(false); | |
| abortRef.current = null; | |
| setMessages((prev) => | |
| prev.map((m) => (m.live ? { ...m, live: false } : m)) | |
| ); | |
| } | |
| } | |
| function stop() { | |
| abortRef.current?.abort(); | |
| } | |
| const showEmpty = messages.length === 0; | |
| return ( | |
| <> | |
| <div className="topbar"> | |
| <span className="topbar-title"> | |
| {session?.title || "New conversation"} | |
| </span> | |
| <div className="topbar-spacer" /> | |
| <button className="icon-btn" title="Upload documents" onClick={onOpenDocs}> | |
| <FileUp size={17} /> | |
| </button> | |
| {appInfo?.available_models?.length > 0 && ( | |
| <select | |
| className="model-select" | |
| value={effectiveModel} | |
| onChange={(e) => setModel(e.target.value)} | |
| title="Model for this conversation" | |
| > | |
| {appInfo.available_models.map((m) => ( | |
| <option key={m} value={m}> | |
| {m} | |
| </option> | |
| ))} | |
| </select> | |
| )} | |
| </div> | |
| <div className="chat-scroll" ref={scrollRef}> | |
| {showEmpty ? ( | |
| <div className="empty-state fade-in"> | |
| <div className="empty-logo"> | |
| <BrainCircuit size={30} /> | |
| </div> | |
| <h1>What can I do for you?</h1> | |
| <p> | |
| I'm Synapse. I can search the web, check the weather, do precise | |
| math, and answer questions about your uploaded documents, with | |
| sources cited. | |
| </p> | |
| <div className="suggestions"> | |
| {suggestions.map((s) => ( | |
| <button | |
| key={s.title} | |
| className="suggestion-card" | |
| onClick={() => send(s.prompt)} | |
| > | |
| <b>{s.title}</b> | |
| {s.prompt} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className="chat-inner"> | |
| {error && <div className="error-banner">{error}</div>} | |
| {messages.map((message) => ( | |
| <MessageBubble key={message.id} message={message} /> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| <div className="composer-wrap"> | |
| <div className="composer"> | |
| <textarea | |
| ref={textareaRef} | |
| rows={1} | |
| placeholder="Message Synapse..." | |
| value={input} | |
| onChange={(e) => { | |
| setInput(e.target.value); | |
| autoResize(); | |
| }} | |
| onKeyDown={(e) => { | |
| if (e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| send(); | |
| } | |
| }} | |
| /> | |
| {streaming ? ( | |
| <button className="send-btn stop" title="Stop" onClick={stop}> | |
| <Square size={15} /> | |
| </button> | |
| ) : ( | |
| <button | |
| className="send-btn" | |
| title="Send" | |
| disabled={!input.trim()} | |
| onClick={() => send()} | |
| > | |
| <Send size={16} /> | |
| </button> | |
| )} | |
| </div> | |
| <div className="composer-hint"> | |
| Synapse can use tools autonomously. Enter to send, Shift+Enter for a new line. | |
| </div> | |
| </div> | |
| </> | |
| ); | |
| } | |