Update on frontend con single user session

This commit is contained in:
Samuele E. Locatelli
2025-08-22 08:45:57 +00:00
parent c4b4bf0d2a
commit c955db7fa6
5 changed files with 194 additions and 107 deletions
+20 -106
View File
@@ -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 (
<div className={`d-flex flex-column vh-100 ${theme.bodyBg}`}>
<header className={`${theme.headerBg} text-center py-3 sticky-top shadow row`}>
<div class="col-3"></div>
<div class="col-6">
<h5 className="my-0">🤖 EgalWare's LLM ChatBot</h5>
</div>
<div class="col-3">
<button className="btn btn-sm btn-outline-light mt-2" onClick={toggleTheme}>
Toggle Theme
</button>
</div>
</header>
<ChatWindow messages={messages} loading={loading} theme={theme} />
<ChatInput onSend={sendMessage} loading={loading} />
</div>
<ChatLayout
theme={theme}
messages={messages}
loading={loading}
onSend={sendMessage}
onStop={stopGenerating}
onToggleTheme={toggleTheme}
/>
);
}
+19
View File
@@ -0,0 +1,19 @@
// ChatHeader.jsx
import React from "react";
export default function ChatHeader({ theme, onToggleTheme }) {
return (
<header className={`${theme.headerBg} text-center py-3 sticky-top shadow row`}>
<div class="col-3"></div>
<div class="col-6">
<h4 className="mb-0">🤖 EgalWare's LLM ChatBot</h4>
</div>
<div class="col-3">
<button className="btn btn-sm btn-outline-light mt-2" onClick={onToggleTheme}>
Toggle Theme
</button>
</div>
</header>
);
}
+33
View File
@@ -0,0 +1,33 @@
// ChatLayout.jsx
import React from "react";
import ChatHeader from "./ChatHeader";
import ChatWindow from "./ChatWindow";
import ChatInput from "./ChatInput";
export default function ChatLayout({
theme,
messages,
loading,
onSend,
onStop,
onToggleTheme
}) {
return (
<div className={`d-flex flex-column vh-100 ${theme.bodyBg}`}>
<ChatHeader theme={theme} onToggleTheme={onToggleTheme} />
<ChatWindow messages={messages} loading={loading} theme={theme} />
{loading && (
<div className="p-2 text-center">
<button className="btn btn-warning btn-sm" onClick={onStop}>
Stop Generating
</button>
</div>
)}
<ChatInput onSend={onSend} loading={loading} />
</div>
);
}
+7 -1
View File
@@ -1,3 +1,4 @@
// UserMessage.jsx
import React from "react";
export default function UserMessage({ content, theme }) {
@@ -9,7 +10,12 @@ export default function UserMessage({ content, theme }) {
>
<pre
className="m-0"
style={{ whiteSpace: "pre-wrap", fontFamily: "inherit" }}
style={{
whiteSpace: "pre-wrap",
fontFamily: "inherit",
backgroundColor: "transparent", // kill default <pre> background
color: "inherit", // match bubble text color
}}
>
{content}
</pre>
+115
View File
@@ -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 };
}