synapse / frontend /src /components /ChatView.jsx
adwitiyashukla's picture
pushed Synapse, an agentic AI assistant with hybrid RAG
b2c1c67
Raw
History Blame Contribute Delete
9.28 kB
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>
</>
);
}