Ok session display e Single Source of Truth

This commit is contained in:
Samuele E. Locatelli
2025-08-22 16:11:57 +00:00
parent 5481c1f4b7
commit d7747b0b60
7 changed files with 103 additions and 194 deletions
+8 -8
View File
@@ -4,12 +4,13 @@ import "./App.css";
import { themes } from "./themes";
import ChatLayout from "./ChatLayout";
import { useChatStream } from "./useChatStream";
import { getSessionId, getUserId, resetSessionId } from "./useSessionId";
import { getSessionId, getUserId, resetSessionId, setSessionId } from "./useSessionId";
export default function App() {
const { messages, loading, sendMessage, stopGenerating, setMessages } = useChatStream();
const [themeName, setThemeName] = useState("light");
const theme = themes[themeName];
const sessionId = getSessionId();
const userId = getUserId();
@@ -29,16 +30,14 @@ export default function App() {
const reloadHistory = async () => {
const res = await fetch(`/v1/history?user_id=${userId}&session_id=${sessionId}`);
const history = await res.json();
setMessages(history); // from useChatStream
setMessages(history);
};
const freshStart = async () => {
await fetch(`/v1/history?user_id=${userId}&session_id=${sessionId}`, { method: "DELETE" });
setMessages([]);
resetSessionId();
// or start a brand new sessionId:
//localStorage.removeItem("sessionId");
//window.location.reload();
const newId = resetSessionId();
setSessionId(newId);
};
const createSession = async () => {
@@ -50,7 +49,7 @@ export default function App() {
});
const meta = await res.json();
setSessionId(meta.session_id);
setMessages([]); // clear chat window
setMessages([]);
};
const editSession = async () => {
@@ -74,8 +73,9 @@ export default function App() {
onReloadHistory={reloadHistory}
onFreshStart={freshStart}
onCreateSession={createSession}
onEditSession={editSession}
userId={userId}
/>
);
}
+81
View File
@@ -0,0 +1,81 @@
//App.jsx
import React, { useState, useEffect } from "react";
import "./App.css";
import { themes } from "./themes";
import ChatLayout from "./ChatLayout";
import { useChatStream } from "./useChatStream";
import { getSessionId, getUserId, resetSessionId } from "./useSessionId";
export default function App() {
const { messages, loading, sendMessage, stopGenerating, setMessages } = useChatStream();
const [themeName, setThemeName] = useState("light");
const theme = themes[themeName];
const sessionId = getSessionId();
const userId = getUserId();
useEffect(() => {
const saved = localStorage.getItem("preferredTheme");
if (saved && themes[saved]) setThemeName(saved);
}, []);
useEffect(() => {
localStorage.setItem("preferredTheme", themeName);
}, [themeName]);
const toggleTheme = () => {
setThemeName((t) => (t === "light" ? "dark" : "light"));
};
const reloadHistory = async () => {
const res = await fetch(`/v1/history?user_id=${userId}&session_id=${sessionId}`);
const history = await res.json();
setMessages(history); // from useChatStream
};
const freshStart = async () => {
await fetch(`/v1/history?user_id=${userId}&session_id=${sessionId}`, { method: "DELETE" });
setMessages([]);
resetSessionId();
// or start a brand new sessionId:
//localStorage.removeItem("sessionId");
//window.location.reload();
};
const createSession = async () => {
const firstMessage = prompt("Enter a name or first message for the new session:") || "";
const res = await fetch(`/v1/sessions?user_id=${userId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ first_message: firstMessage }),
});
const meta = await res.json();
setSessionId(meta.session_id);
setMessages([]); // clear chat window
};
const editSession = async () => {
const newName = prompt("Enter a new name for this session:");
if (!newName) return;
await fetch(`/v1/sessions/${sessionId}?user_id=${userId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_name: newName }),
});
};
return (
<ChatLayout
theme={theme}
messages={messages}
loading={loading}
onSend={sendMessage}
onStop={stopGenerating}
onToggleTheme={toggleTheme}
onReloadHistory={reloadHistory}
onFreshStart={freshStart}
onCreateSession={createSession}
/>
);
}
-177
View File
@@ -1,177 +0,0 @@
// 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 (
<div className="chat-container d-flex flex-column vh-100">
<header className="navbar navbar-dark bg-primary sticky-top">
<div className="container-fluid">
<span className="navbar-brand mb-0 h1">Egalware&apos;s LM Studio Chat</span>
</div>
</header>
<div className="chat-box container-fluid py-2 flex-grow-1 overflow-auto">
{messages.map((msg, i) => {
const isLastAssistant = i === messages.length - 1 && msg.role === "assistant" && loading;
return (
<div className="row mb-2" key={i}>
<div className={`col-12 d-flex ${msg.role === "user" ? "justify-content-end" : "justify-content-start"}`}>
<div className={`p-2 rounded-3 shadow-sm ${ msg.role === "user" ? "bg-primary bg-opacity-75 text-white" : "bg-light border text-dark" }`} style={{ width: "95%" }} >
<MessageContent content={isLastAssistant ? buffered : msg.content} />
</div>
</div>
</div>
);
})}
{loading && (
<div className="row text-muted ps-3">
<div className="d-flex align-items-center">
<div className="spinner-border spinner-border-sm me-2" role="status" />
The model is processing...
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="input-bar d-flex justify-content-center p-3 bg-light border-top">
<div className="w-100 w-md-75 w-lg-50 d-flex">
<input
className="form-control me-2"
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === "Enter" && sendMessage()}
placeholder="Type your message..."
disabled={loading}
autoFocus
/>
<button className="btn btn-primary" onClick={sendMessage} disabled={loading}>
{loading ? "Sending..." : "Send"}
</button>
</div>
</div>
</div>
);
}
export default App;
+2 -2
View File
@@ -14,7 +14,7 @@ export default function ChatHeader({
className={`${theme.headerBg} p-2 sticky-top shadow row align-items-center`}
>
{/* Left column: control buttons */}
<div className="col-5 d-flex flex-wrap gap-2 justify-content-start">
<div className="col-3 d-flex flex-wrap gap-2 justify-content-start">
<button
className="btn btn-sm btn-outline-light"
onClick={onReloadHistory}
@@ -49,7 +49,7 @@ export default function ChatHeader({
</div>
{/* Center column: title */}
<div className="col-4 text-center">
<div className="col-6 text-center">
<h4 className="mb-0">🤖 EgalWare&apos;s LLM ChatBot</h4>
</div>
+1
View File
@@ -1,3 +1,4 @@
// src/SessionTable.jsx
import React, { useEffect, useState } from "react";
export default function SessionTable({ userId, onSelectSession }) {
+5 -6
View File
@@ -6,18 +6,18 @@ export function useChatStream() {
const [messages, setMessages] = useState([]);
const [loading, setLoading] = useState(false);
const abortRef = useRef(null);
const sessionId = getSessionId();
const userId = getUserId();
const sendMessage = useCallback(
async (input) => {
if (!input.trim()) return;
// Abort any previous stream
const sessionId = getSessionId(); // <-- get latest every time
if (abortRef.current) {
abortRef.current.abort();
}
const controller = new AbortController();
abortRef.current = controller;
@@ -33,7 +33,7 @@ export function useChatStream() {
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify({ user_id: userId, session_id: sessionId, message: input }),
body: JSON.stringify({ user_id: userId, session_id: sessionId, message: input }),
signal: controller.signal,
});
@@ -101,7 +101,7 @@ export function useChatStream() {
abortRef.current = null;
}
},
[messages]
[messages, userId]
);
const stopGenerating = useCallback(() => {
@@ -115,4 +115,3 @@ export function useChatStream() {
return { messages, loading, sendMessage, stopGenerating, setMessages };
}
+6 -1
View File
@@ -1,4 +1,4 @@
// useIds.js
// useSessionId.js
export function getUserId() {
let id = localStorage.getItem("userId");
if (!id) {
@@ -9,6 +9,7 @@ export function getUserId() {
}
export function getSessionId() {
// Always reread localStorage so latest value is returned
let id = localStorage.getItem("sessionId");
if (!id) {
id = crypto.randomUUID();
@@ -17,6 +18,10 @@ export function getSessionId() {
return id;
}
export function setSessionId(id) {
localStorage.setItem("sessionId", id);
}
export function resetSessionId() {
const newId = crypto.randomUUID();
localStorage.setItem("sessionId", newId);