diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index da87586..0f3cd78 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,123 +1,37 @@ -import React, { useState } from "react"; +// App.jsx +import React, { useState, useEffect } from "react"; import "./App.css"; import { themes } from "./themes"; -import ChatWindow from "./ChatWindow"; -import ChatInput from "./ChatInput"; +import ChatLayout from "./ChatLayout"; +import { useChatStream } from "./useChatStream"; export default function App() { - const [messages, setMessages] = useState([]); - const [loading, setLoading] = useState(false); + const { messages, loading, sendMessage, stopGenerating } = useChatStream(); const [themeName, setThemeName] = useState("light"); - const theme = themes[themeName]; - const sendMessage = async (input) => { - if (!input.trim() || loading) return; + useEffect(() => { + const saved = localStorage.getItem("preferredTheme"); + if (saved && themes[saved]) setThemeName(saved); + }, []); - const userMessage = { role: "user", content: input }; - const userId = "userTest"; - const assistantIndex = messages.length + 1; - - setMessages((prev) => [ - ...prev, - userMessage, - { role: "assistant", content: "" }, - ]); - setLoading(true); - - try { - const res = await fetch("/v1/chat-stream", { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "text/event-stream", - }, - body: JSON.stringify({ user_id: userId, message: userMessage.content }), - }); - - if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`); - - const reader = res.body.getReader(); - const decoder = new TextDecoder("utf-8"); - let buffer = ""; - let acc = ""; - - while (true) { - const { value, done } = await reader.read(); - if (done) break; - buffer += decoder.decode(value, { stream: true }); - const parts = buffer.split("\n\n"); - buffer = parts.pop() || ""; - for (const part of parts) { - const line = part - .split("\n") - .map((l) => l.trim()) - .find((l) => l.startsWith("data:")); - if (!line) continue; - const data = line.slice(5).trim(); - if (data === "[DONE]") { - setMessages((prev) => { - const next = [...prev]; - next[assistantIndex] = { role: "assistant", content: acc }; - return next; - }); - setLoading(false); - return; - } - try { - const obj = JSON.parse(data); - const choice = obj?.choices?.[0] ?? {}; - const delta = choice.delta ?? {}; - const piece = delta.content ?? choice.text ?? ""; - if (piece) { - acc += piece; - setMessages((prev) => { - const next = [...prev]; - next[assistantIndex] = { role: "assistant", content: acc }; - return next; - }); - } - } catch { - // ignore malformed chunk - } - } - } - } catch (err) { - setMessages((prev) => { - const next = [...prev]; - next[assistantIndex] = { - role: "assistant", - content: `Error: ${String(err)}`, - }; - return next; - }); - } finally { - setLoading(false); - } - }; + useEffect(() => { + localStorage.setItem("preferredTheme", themeName); + }, [themeName]); const toggleTheme = () => { setThemeName((t) => (t === "light" ? "dark" : "light")); }; return ( -
background
+ color: "inherit", // match bubble text color
+ }}
>
{content}
diff --git a/frontend/src/useChatStream.js b/frontend/src/useChatStream.js
new file mode 100644
index 0000000..56147e6
--- /dev/null
+++ b/frontend/src/useChatStream.js
@@ -0,0 +1,115 @@
+// useChatStream.js
+import { useState, useCallback, useRef } from "react";
+
+export function useChatStream() {
+ const [messages, setMessages] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const abortRef = useRef(null);
+
+ const sendMessage = useCallback(
+ async (input) => {
+ if (!input.trim()) return;
+
+ // Abort any previous stream
+ if (abortRef.current) {
+ abortRef.current.abort();
+ }
+
+ const controller = new AbortController();
+ abortRef.current = controller;
+
+ const userMessage = { role: "user", content: input };
+ const assistantIndex = messages.length + 1;
+ setMessages((prev) => [...prev, userMessage, { role: "assistant", content: "" }]);
+ setLoading(true);
+
+ try {
+ const res = await fetch("/v1/chat-stream", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "text/event-stream",
+ },
+ body: JSON.stringify({ user_id: "userTest", message: input }),
+ signal: controller.signal,
+ });
+
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
+
+ const reader = res.body.getReader();
+ const decoder = new TextDecoder("utf-8");
+ let buffer = "";
+ let acc = "";
+
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ buffer += decoder.decode(value, { stream: true });
+ const parts = buffer.split("\n\n");
+ buffer = parts.pop() || "";
+
+ for (const part of parts) {
+ const line = part
+ .split("\n")
+ .map((l) => l.trim())
+ .find((l) => l.startsWith("data:"));
+ if (!line) continue;
+
+ const data = line.slice(5).trim();
+ if (data === "[DONE]") {
+ setMessages((prev) => {
+ const next = [...prev];
+ next[assistantIndex] = { role: "assistant", content: acc };
+ return next;
+ });
+ setLoading(false);
+ return;
+ }
+
+ try {
+ const obj = JSON.parse(data);
+ const piece = obj?.choices?.[0]?.delta?.content ?? obj?.choices?.[0]?.text ?? "";
+ if (piece) {
+ acc += piece;
+ setMessages((prev) => {
+ const next = [...prev];
+ next[assistantIndex] = { role: "assistant", content: acc };
+ return next;
+ });
+ }
+ } catch {
+ /* ignore malformed JSON chunks */
+ }
+ }
+ }
+ } catch (err) {
+ if (err.name !== "AbortError") {
+ setMessages((prev) => {
+ const next = [...prev];
+ next[assistantIndex] = {
+ role: "assistant",
+ content: `Error: ${String(err)}`,
+ };
+ return next;
+ });
+ }
+ } finally {
+ setLoading(false);
+ abortRef.current = null;
+ }
+ },
+ [messages]
+ );
+
+ const stopGenerating = useCallback(() => {
+ if (abortRef.current) {
+ abortRef.current.abort();
+ abortRef.current = null;
+ setLoading(false);
+ }
+ }, []);
+
+ return { messages, loading, sendMessage, stopGenerating };
+}
+
+