Update on frontend con single user session
This commit is contained in:
+20
-106
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user