diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9c716cf..da87586 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,177 +1,124 @@ -// src/App.jsx -import React, { useState, useRef, useEffect } from 'react'; -import ReactMarkdown from 'react-markdown'; -import remarkMath from 'remark-math'; -import rehypeKatex from 'rehype-katex'; -import MessageContent from './MessageContent'; -import { useStreamBuffer } from './hooks/useStreamBuffer' -import './App.css'; -import 'katex/dist/katex.min.css'; +import React, { useState } from "react"; +import "./App.css"; +import { themes } from "./themes"; +import ChatWindow from "./ChatWindow"; +import ChatInput from "./ChatInput"; - -function App() { +export default function App() { const [messages, setMessages] = useState([]); - const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); - const messagesEndRef = useRef(null); - const { buffered, pushChunk, reset } = useStreamBuffer(80); - const sendMessage = async () => { + const [themeName, setThemeName] = useState("light"); + + const theme = themes[themeName]; + + const sendMessage = async (input) => { if (!input.trim() || loading) return; const userMessage = { role: "user", content: input }; - const userId = "user1"; + const userId = "userTest"; + const assistantIndex = messages.length + 1; - // Calculate where the assistant placeholder will land - const startIndex = messages.length; - const assistantIndex = startIndex + 1; - - // Optimistic UI: user + empty assistant - setMessages(prev => [...prev, userMessage, { role: "assistant", content: "" }]); - setInput(""); + setMessages((prev) => [ + ...prev, + userMessage, + { role: "assistant", content: "" }, + ]); setLoading(true); - reset(); // clear the buffer for the new response try { - const res = await fetch("/chat-stream", { + 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 }) + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + }, + body: JSON.stringify({ user_id: userId, message: userMessage.content }), }); - // Non-streaming fallback - if (!res.ok || !res.body) { - const json = await res.json().catch(() => null); - const text = json?.response ?? "Error: streaming not available."; - setMessages(prev => { - const next = [...prev]; - next[assistantIndex] = { role: "assistant", content: text }; - return next; - }); - setLoading(false); - return; - } + if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`); const reader = res.body.getReader(); const decoder = new TextDecoder("utf-8"); - let acc = ""; // final committed text - let sseBuffer = ""; // raw SSE buffer + let buffer = ""; + let acc = ""; while (true) { const { value, done } = await reader.read(); if (done) break; - - sseBuffer += decoder.decode(value, { stream: true }); - - // Split on SSE event boundaries - const events = sseBuffer.split("\n\n"); - sseBuffer = events.pop() || ""; - - for (const evt of events) { - const lines = evt.split("\n").map(l => l.trim()).filter(Boolean); - for (const line of lines) { - if (!line.startsWith("data:")) continue; - const data = line.slice(5).trim(); - if (data === "[DONE]") { - // Commit final text and finish - setMessages(prev => { + 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; }); - 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; // reliable final copy - pushChunk(piece); // smooth UI copy - } - } catch { - // ignore non-JSON control lines } + } catch { + // ignore malformed chunk } } } - - // Stream ended without an explicit [DONE] - setMessages(prev => { - const next = [...prev]; - next[assistantIndex] = { role: "assistant", content: acc }; - return next; - }); } catch (err) { - setMessages(prev => { + setMessages((prev) => { const next = [...prev]; - next[assistantIndex] = { role: "assistant", content: `Error: ${String(err)}` }; + next[assistantIndex] = { + role: "assistant", + content: `Error: ${String(err)}`, + }; return next; }); } finally { setLoading(false); } }; - - useEffect(() => { - if (loading) { - messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); - } - }, [buffered, loading]); + + const toggleTheme = () => { + setThemeName((t) => (t === "light" ? "dark" : "light")); + }; return ( -
-
-
- Egalware's LM Studio Chat -
+
+
+
+
+
🤖 EgalWare's LLM ChatBot
+
+
+ +
-
- {messages.map((msg, i) => { - const isLastAssistant = i === messages.length - 1 && msg.role === "assistant" && loading; - return ( -
-
-
- -
-
-
- ); - })} + - {loading && ( -
-
-
- The model is processing... -
-
- )} - -
-
- -
-
- setInput(e.target.value)} - onKeyDown={e => e.key === "Enter" && sendMessage()} - placeholder="Type your message..." - disabled={loading} - autoFocus - /> - -
-
+
); } -export default App; + diff --git a/frontend/src/App.jsx.orig b/frontend/src/App.jsx.orig new file mode 100644 index 0000000..d3d874b --- /dev/null +++ b/frontend/src/App.jsx.orig @@ -0,0 +1,177 @@ +// src/App.jsx +import React, { useState, useRef, useEffect } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkMath from 'remark-math'; +import rehypeKatex from 'rehype-katex'; +import MessageContent from './MessageContent'; +import { useStreamBuffer } from './hooks/useStreamBuffer' +import './App.css'; +import 'katex/dist/katex.min.css'; + + +function App() { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(""); + const [loading, setLoading] = useState(false); + const messagesEndRef = useRef(null); + const { buffered, pushChunk, reset } = useStreamBuffer(80); + const sendMessage = async () => { + if (!input.trim() || loading) return; + + const userMessage = { role: "user", content: input }; + const userId = "user1"; + + // Calculate where the assistant placeholder will land + const startIndex = messages.length; + const assistantIndex = startIndex + 1; + + // Optimistic UI: user + empty assistant + setMessages(prev => [...prev, userMessage, { role: "assistant", content: "" }]); + setInput(""); + setLoading(true); + reset(); // clear the buffer for the new response + + 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 }) + }); + + // Non-streaming fallback + if (!res.ok || !res.body) { + const json = await res.json().catch(() => null); + const text = json?.response ?? "Error: streaming not available."; + setMessages(prev => { + const next = [...prev]; + next[assistantIndex] = { role: "assistant", content: text }; + return next; + }); + setLoading(false); + return; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder("utf-8"); + let acc = ""; // final committed text + let sseBuffer = ""; // raw SSE buffer + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + + sseBuffer += decoder.decode(value, { stream: true }); + + // Split on SSE event boundaries + const events = sseBuffer.split("\n\n"); + sseBuffer = events.pop() || ""; + + for (const evt of events) { + const lines = evt.split("\n").map(l => l.trim()).filter(Boolean); + for (const line of lines) { + if (!line.startsWith("data:")) continue; + const data = line.slice(5).trim(); + if (data === "[DONE]") { + // Commit final text and finish + 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; // reliable final copy + pushChunk(piece); // smooth UI copy + } + } catch { + // ignore non-JSON control lines + } + } + } + } + + // Stream ended without an explicit [DONE] + setMessages(prev => { + const next = [...prev]; + next[assistantIndex] = { role: "assistant", content: acc }; + return next; + }); + } catch (err) { + setMessages(prev => { + const next = [...prev]; + next[assistantIndex] = { role: "assistant", content: `Error: ${String(err)}` }; + return next; + }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (loading) { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + } + }, [buffered, loading]); + + return ( +
+
+
+ Egalware's LM Studio Chat +
+
+ +
+ {messages.map((msg, i) => { + const isLastAssistant = i === messages.length - 1 && msg.role === "assistant" && loading; + return ( +
+
+
+ +
+
+
+ ); + })} + + {loading && ( +
+
+
+ The model is processing... +
+
+ )} + +
+
+ +
+
+ setInput(e.target.value)} + onKeyDown={e => e.key === "Enter" && sendMessage()} + placeholder="Type your message..." + disabled={loading} + autoFocus + /> + +
+
+
+ ); +} + +export default App; diff --git a/frontend/src/AssistantMessage.jsx b/frontend/src/AssistantMessage.jsx new file mode 100644 index 0000000..f1792b8 --- /dev/null +++ b/frontend/src/AssistantMessage.jsx @@ -0,0 +1,31 @@ +import React from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import remarkMath from "remark-math"; +import rehypeKatex from "rehype-katex"; + +export default function AssistantMessage({ content, theme }) { + return ( +
+
+ ( + + ), + th: (props) =>
+ }} + > + {content} + + + + ); +} + + diff --git a/frontend/src/ChatInput.jsx b/frontend/src/ChatInput.jsx new file mode 100644 index 0000000..060fcf4 --- /dev/null +++ b/frontend/src/ChatInput.jsx @@ -0,0 +1,31 @@ +import React, { useState } from "react"; + +export default function ChatInput({ onSend, loading }) { + const [value, setValue] = useState(""); + + const handleKeyDown = (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (value.trim()) { + onSend(value); + setValue(""); + } + } + }; + + return ( +
+