Merge branch 'release/FixSessionMan_01'
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 754 KiB |
@@ -20,6 +20,8 @@ La soluzione è basata sul seguente stack
|
||||
* Abilitazione LM Studio x chiamate locali su porta 1234
|
||||
* virtual machine linux con soluzione backend/frontend di proxy/caching verso il modello AI di LM Studio
|
||||
|
||||

|
||||
|
||||
## Startup
|
||||
|
||||
Al momento per l0'esecuzione della soluzione, sulla virtual machine di proxy, vanno avviati backend (python) e frontend (node) manualmente.
|
||||
@@ -66,4 +68,5 @@ Mancano molti punti di ottimizzazione:
|
||||
|---------------|-----------------------------------------|------------|
|
||||
| 0.1.2508.2019 | Versione test solo locale con LM Studio | 2025.08.20 |
|
||||
| 0.1.2508.2119 | Versione con esecuzione locale completa | 2025.08.21 |
|
||||
| 0.1.2508.2219 | Versione completa e rivisitata graficamente x chat (con memoria sessioni) | 2025.08.22 |
|
||||
|
||||
|
||||
BIN
Binary file not shown.
+92
-22
@@ -1,11 +1,90 @@
|
||||
/* src/App.css */
|
||||
body, html {
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bs-body-bg);
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
/* Animate only the wrapper, not the whole page */
|
||||
.sessions-wrapper {
|
||||
transition: transform 300ms ease, filter 300ms ease;
|
||||
will-change: transform, filter;
|
||||
}
|
||||
body.sessions-open .sessions-wrapper {
|
||||
transform: translateX(280px) scale(0.985);
|
||||
filter: saturate(0.95);
|
||||
}
|
||||
|
||||
/* Offcanvas stays fixed to the viewport */
|
||||
.offcanvas-start {
|
||||
width: 280px;
|
||||
border-right: 1px solid #dee2e6;
|
||||
}
|
||||
.offcanvas.show {
|
||||
box-shadow: 6px 0 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Dark overlay for sessions panel */
|
||||
.sessions-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 250ms ease;
|
||||
z-index: 1030; /* just under offcanvas (Bootstrap sets .offcanvas at 1045) */
|
||||
}
|
||||
.sessions-overlay.show {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Animate only wrapper */
|
||||
.sessions-wrapper {
|
||||
transition: transform 300ms ease, filter 300ms ease;
|
||||
will-change: transform, filter;
|
||||
}
|
||||
body.sessions-open .sessions-wrapper {
|
||||
transform: translateX(280px) scale(0.985);
|
||||
filter: saturate(0.95);
|
||||
}
|
||||
|
||||
.session-table {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.offcanvas-start {
|
||||
width: 420px;
|
||||
border-right: 1px solid #dee2e6;
|
||||
}
|
||||
.offcanvas.show {
|
||||
box-shadow: 6px 0 20px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.offcanvas-left {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: -300px; /* panel width */
|
||||
width: 300px;
|
||||
height: 100%;
|
||||
background: var(--bs-body-bg, #fff);
|
||||
box-shadow: 2px 0 5px rgba(0,0,0,0.3);
|
||||
transition: left 0.3s ease;
|
||||
z-index: 1050;
|
||||
}
|
||||
|
||||
.offcanvas-left.show {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
|
||||
/* Chat styles */
|
||||
.chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -17,40 +96,34 @@ body, html {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
background-color: #f8f9fa; /* Bootstrap light gray */
|
||||
overflow-anchor: none; /* prevent anchor jumps */
|
||||
background-color: #f8f9fa;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
/* Wider bubbles — use Bootstrap breakpoints for responsiveness */
|
||||
.message {
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 12px;
|
||||
width: auto;
|
||||
max-width: 95%; /* was 80% */
|
||||
max-width: 95%;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message:last-child {
|
||||
overflow-anchor: auto; /* anchor here */
|
||||
overflow-anchor: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.message {
|
||||
max-width: 75%; /* keep some margin on larger screens */
|
||||
}
|
||||
.message { max-width: 75%; }
|
||||
}
|
||||
|
||||
.message.user {
|
||||
background-color: #0d6efd; /* Bootstrap primary */
|
||||
background-color: #0d6efd;
|
||||
color: #fff;
|
||||
align-self: flex-end;
|
||||
text-align: right;
|
||||
border-top-right-radius: 0; /* subtle speech bubble effect */
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
background-color: #e9ecef; /* Bootstrap light gray */
|
||||
background-color: #e9ecef;
|
||||
color: #212529;
|
||||
align-self: flex-start;
|
||||
text-align: left;
|
||||
@@ -63,7 +136,6 @@ body, html {
|
||||
background-color: white;
|
||||
border-top: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
@@ -71,11 +143,10 @@ body, html {
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.send-button {
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: #0d6efd; /* Bootstrap primary */
|
||||
background-color: #0d6efd;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
@@ -83,18 +154,17 @@ body, html {
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: #212529; /* dark background for code */
|
||||
background-color: #212529;
|
||||
color: #f8f9fa;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: 'Fira Code', monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-copy {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
|
||||
+39
-13
@@ -4,7 +4,7 @@ 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();
|
||||
@@ -22,23 +22,46 @@ export default function App() {
|
||||
localStorage.setItem("preferredTheme", themeName);
|
||||
}, [themeName]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setThemeName((t) => (t === "light" ? "dark" : "light"));
|
||||
};
|
||||
const toggleTheme = () => setThemeName(t => (t === "light" ? "dark" : "light"));
|
||||
|
||||
const reloadHistory = async () => {
|
||||
const res = await fetch(`/v1/history?user_id=${userId}&session_id=${sessionId}`);
|
||||
const reloadHistory = async (id = sessionId) => {
|
||||
const res = await fetch(`/v1/history?user_id=${userId}&session_id=${id}`);
|
||||
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 () => {
|
||||
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([]);
|
||||
};
|
||||
|
||||
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 }),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectSession = async (selectedId) => {
|
||||
setSessionId(selectedId);
|
||||
await reloadHistory(selectedId);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -49,10 +72,13 @@ export default function App() {
|
||||
onSend={sendMessage}
|
||||
onStop={stopGenerating}
|
||||
onToggleTheme={toggleTheme}
|
||||
onReloadHistory={reloadHistory}
|
||||
onReloadHistory={() => reloadHistory()}
|
||||
onFreshStart={freshStart}
|
||||
onCreateSession={createSession}
|
||||
onEditSession={editSession}
|
||||
onSelectSession={handleSelectSession}
|
||||
userId={userId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'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;
|
||||
+33
-12
@@ -1,29 +1,50 @@
|
||||
// ChatHeader.jsx
|
||||
// src/ChatHeader.jsx
|
||||
import React from "react";
|
||||
|
||||
export default function ChatHeader({
|
||||
theme,
|
||||
onToggleTheme,
|
||||
onReloadHistory,
|
||||
onFreshStart
|
||||
onFreshStart,
|
||||
onCreateSession,
|
||||
onManageSession
|
||||
}) {
|
||||
return (
|
||||
<header
|
||||
className={`${theme.headerBg} py-3 sticky-top shadow row align-items-center`}
|
||||
className={`${theme.headerBg} p-2 sticky-top shadow row align-items-center`}
|
||||
>
|
||||
{/* Left column: new buttons */}
|
||||
<div className="col-3 d-flex gap-2 justify-content-start">
|
||||
{/* Left column: control buttons */}
|
||||
<div className="col-3 d-flex flex-wrap gap-2 justify-content-start">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-light"
|
||||
onClick={onReloadHistory}
|
||||
title="Reload current session history"
|
||||
>
|
||||
🔄 Reload
|
||||
🔄
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-sm btn-danger"
|
||||
className="btn btn-sm btn-warning"
|
||||
onClick={onFreshStart}
|
||||
title="Reset/Clear current session and start fresh"
|
||||
>
|
||||
🆕 Fresh Start
|
||||
🆕
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={onCreateSession}
|
||||
title="Create a brand new session"
|
||||
>
|
||||
➕
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="btn btn-sm btn-info"
|
||||
onClick={onManageSession}
|
||||
title="Manage your chat sessions"
|
||||
>
|
||||
📂
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -33,16 +54,16 @@ export default function ChatHeader({
|
||||
</div>
|
||||
|
||||
{/* Right column: theme toggle */}
|
||||
<div className="col-3 text-end">
|
||||
<div className="col-3 d-flex flex-row-reverse">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-light"
|
||||
className="btn btn-sm btn-outline-light d-flex align-items-center gap-1"
|
||||
onClick={onToggleTheme}
|
||||
title="Toggle light/dark theme"
|
||||
>
|
||||
Toggle Theme
|
||||
<span role="img" aria-label="theme toggle icon">🌓</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
+81
-19
@@ -1,8 +1,9 @@
|
||||
// ChatLayout.jsx
|
||||
import React from "react";
|
||||
// App.jsx
|
||||
import React, { useState, useEffect } from "react";
|
||||
import ChatHeader from "./ChatHeader";
|
||||
import ChatWindow from "./ChatWindow";
|
||||
import ChatInput from "./ChatInput";
|
||||
import SessionTable from "./SessionTable";
|
||||
|
||||
export default function ChatLayout({
|
||||
theme,
|
||||
@@ -12,27 +13,88 @@ export default function ChatLayout({
|
||||
onStop,
|
||||
onToggleTheme,
|
||||
onReloadHistory,
|
||||
onFreshStart
|
||||
onFreshStart,
|
||||
onCreateSession,
|
||||
onEditSession,
|
||||
onSelectSession,
|
||||
userId
|
||||
}) {
|
||||
const [showSessionsPanel, setShowSessionsPanel] = useState(false);
|
||||
|
||||
// 1️⃣ helper at the top (inside the component is fine)
|
||||
function getScrollbarWidth() {
|
||||
return window.innerWidth - document.documentElement.clientWidth;
|
||||
}
|
||||
|
||||
// update body class when panel state changes
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle("sessions-open", showSessionsPanel);
|
||||
}, [showSessionsPanel]);
|
||||
|
||||
// 2️⃣ revised toggles
|
||||
const openSessionManager = () => {
|
||||
const scrollBarWidth = getScrollbarWidth();
|
||||
document.body.style.overflow = "hidden";
|
||||
document.body.style.paddingRight = `${scrollBarWidth}px`;
|
||||
setShowSessionsPanel(true);
|
||||
};
|
||||
|
||||
const closeSessionManager = () => {
|
||||
document.body.style.overflow = "";
|
||||
document.body.style.paddingRight = "";
|
||||
setShowSessionsPanel(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`mx-3 d-flex flex-column vh-100 ${theme.bodyBg}`}>
|
||||
<ChatHeader
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
onReloadHistory={onReloadHistory}
|
||||
onFreshStart={onFreshStart}
|
||||
/>
|
||||
<ChatWindow messages={messages} loading={loading} theme={theme} />
|
||||
<>
|
||||
<div className={`sessions-wrapper d-flex flex-column vh-100 ${theme.bodyBg}`}>
|
||||
<ChatHeader
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
onReloadHistory={onReloadHistory}
|
||||
onFreshStart={onFreshStart}
|
||||
onCreateSession={onCreateSession}
|
||||
onEditSession={onEditSession}
|
||||
onManageSession={openSessionManager} // opens panel
|
||||
/>
|
||||
|
||||
{loading && (
|
||||
<div className="p-2 text-center">
|
||||
<button className="btn btn-warning btn-sm" onClick={onStop}>
|
||||
⏹ Stop Generating
|
||||
</button>
|
||||
<div className="flex-grow-1 overflow-auto">
|
||||
<ChatWindow messages={messages} loading={loading} theme={theme} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatInput onSend={onSend} loading={loading} />
|
||||
</div>
|
||||
{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>
|
||||
|
||||
<div
|
||||
className={`offcanvas offcanvas-start ${showSessionsPanel ? "show" : ""}`}
|
||||
tabIndex="-1"
|
||||
style={{ visibility: showSessionsPanel ? "visible" : "hidden" }}
|
||||
>
|
||||
<div className="offcanvas-header">
|
||||
<h5 className="offcanvas-title">Manage Sessions</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close text-reset"
|
||||
onClick={closeSessionManager} // closes panel
|
||||
></button>
|
||||
</div>
|
||||
<div className="offcanvas-body">
|
||||
<SessionTable
|
||||
userId={userId}
|
||||
onSelectSession={onSelectSession}
|
||||
onClosePanel={closeSessionManager}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// src/ChatLayout.jsx
|
||||
import React, { useState } from "react";
|
||||
import ChatHeader from "./ChatHeader";
|
||||
import ChatWindow from "./ChatWindow";
|
||||
import ChatInput from "./ChatInput";
|
||||
import SessionTable from "./SessionTable"; // your existing table
|
||||
|
||||
export default function ChatLayout({
|
||||
theme,
|
||||
messages,
|
||||
loading,
|
||||
onSend,
|
||||
onStop,
|
||||
onToggleTheme,
|
||||
onReloadHistory,
|
||||
onFreshStart,
|
||||
onCreateSession,
|
||||
onSelectSession, // new: load a chosen session
|
||||
userId // new: so SessionTable can fetch sessions
|
||||
}) {
|
||||
const [showSessionsPanel, setShowSessionsPanel] = useState(false);
|
||||
|
||||
const openSessionManager = () => setShowSessionsPanel(true);
|
||||
const closeSessionManager = () => setShowSessionsPanel(false);
|
||||
|
||||
return (
|
||||
<div className={`d-flex flex-column vh-100 ${theme.bodyBg}`}>
|
||||
<ChatHeader
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
onReloadHistory={onReloadHistory}
|
||||
onFreshStart={onFreshStart}
|
||||
onCreateSession={onCreateSession}
|
||||
onManageSession={openSessionManager} // now opens the panel
|
||||
/>
|
||||
|
||||
<div className="flex-grow-1 overflow-auto">
|
||||
<ChatWindow messages={messages} loading={loading} theme={theme} />
|
||||
</div>
|
||||
|
||||
{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} />
|
||||
|
||||
{/* Offcanvas Session Manager */}
|
||||
<div
|
||||
className={`offcanvas offcanvas-start ${showSessionsPanel ? "show" : ""}`}
|
||||
tabIndex="-1"
|
||||
style={{ visibility: showSessionsPanel ? "visible" : "hidden" }}
|
||||
>
|
||||
<div className="offcanvas-header">
|
||||
<h5 className="offcanvas-title">Manage Sessions</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close text-reset"
|
||||
onClick={closeSessionManager}
|
||||
></button>
|
||||
</div>
|
||||
<div className="offcanvas-body">
|
||||
<SessionTable
|
||||
userId={userId}
|
||||
onSelectSession={(sessionId) => {
|
||||
onSelectSession(sessionId); // tell parent to load history
|
||||
closeSessionManager();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// src/SessionTable.jsx
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { getSessionId } from "./useSessionId";
|
||||
|
||||
export default function SessionTable({ userId, onSelectSession, onClosePanel }) {
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingSessionId, setEditingSessionId] = useState(null);
|
||||
const [editName, setEditName] = useState("");
|
||||
const activeSessionId = getSessionId();
|
||||
|
||||
const fetchSessions = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/v1/sessions?user_id=${userId}`);
|
||||
const data = await res.json();
|
||||
setSessions(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch sessions", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) fetchSessions();
|
||||
}, [userId]);
|
||||
|
||||
const startEditing = (session) => {
|
||||
setEditingSessionId(session.session_id);
|
||||
setEditName(session.session_name);
|
||||
};
|
||||
|
||||
const saveName = async (sessionId) => {
|
||||
try {
|
||||
await fetch(`/v1/sessions/${sessionId}?user_id=${userId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_name: editName }),
|
||||
});
|
||||
setEditingSessionId(null);
|
||||
fetchSessions();
|
||||
} catch (err) {
|
||||
console.error("Failed to rename session", err);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteSession = async (sessionId) => {
|
||||
if (!window.confirm("Delete this session and its history?")) return;
|
||||
try {
|
||||
await fetch(`/v1/sessions/${sessionId}?user_id=${userId}`, { method: "DELETE" });
|
||||
fetchSessions();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete session", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="table-responsive session-table">
|
||||
<table className="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Session</th>
|
||||
<th>Created</th>
|
||||
<th className="text-nowrap"># mess</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && <tr><td colSpan="5" className="text-center">Loading…</td></tr>}
|
||||
{!loading && sessions.length === 0 && (
|
||||
<tr><td colSpan="5" className="text-center">No sessions found</td></tr>
|
||||
)}
|
||||
{!loading && sessions.map((s) => (
|
||||
<tr
|
||||
key={s.session_id}
|
||||
className={s.session_id === activeSessionId ? "table-primary" : ""}
|
||||
>
|
||||
<td className="text-end text-nowrap">
|
||||
<button
|
||||
className="btn btn-sm px-1 btn-outline-secondary me-1"
|
||||
onClick={() => startEditing(s)}
|
||||
title="Rename"
|
||||
>✏️</button>
|
||||
</td>
|
||||
<td>
|
||||
{editingSessionId === s.session_id ? (
|
||||
<input
|
||||
type="text"
|
||||
className="form-control form-control-sm"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onBlur={() => saveName(s.session_id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") saveName(s.session_id);
|
||||
if (e.key === "Escape") setEditingSessionId(null);
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
role="button"
|
||||
className="text-primary"
|
||||
onClick={() => {
|
||||
onSelectSession(s.session_id);
|
||||
if (typeof onClosePanel === "function") onClosePanel();
|
||||
}}
|
||||
title="Click to load this session"
|
||||
>
|
||||
{s.session_name}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{new Date(s.created_at).toLocaleString()}</td>
|
||||
<td title={s.history_size_bytes}>
|
||||
{s.message_count}
|
||||
</td>
|
||||
<td className="text-end text-nowrap">
|
||||
<button
|
||||
className="btn btn-sm px-1 btn-outline-danger"
|
||||
onClick={() => deleteSession(s.session_id)}
|
||||
title="Delete"
|
||||
>🗑️</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,18 +3,22 @@ import React from "react";
|
||||
|
||||
export default function UserMessage({ content, theme }) {
|
||||
return (
|
||||
<div className="mb-2 text-end">
|
||||
// This outer div aligns the *bubble* to the right
|
||||
<div className="mb-2 d-flex justify-content-end">
|
||||
<div
|
||||
className={`d-inline-block p-2 rounded ${theme.userBg}`}
|
||||
style={{ maxWidth: "95%" }}
|
||||
className={`p-2 rounded ${theme.userBg}`}
|
||||
style={{
|
||||
maxWidth: "95%",
|
||||
textAlign: "left", // ensures text inside is left-aligned
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
className="m-0"
|
||||
className="m-0 p-0"
|
||||
style={{
|
||||
whiteSpace: "pre-wrap",
|
||||
fontFamily: "inherit",
|
||||
backgroundColor: "transparent", // kill default <pre> background
|
||||
color: "inherit", // match bubble text color
|
||||
backgroundColor: "transparent",
|
||||
color: "inherit",
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
// themes.js
|
||||
export const themes = {
|
||||
light: {
|
||||
userBg: "bg-primary text-white",
|
||||
userBg: "bg-primary bg-opacity-25 text-dark",
|
||||
assistantBg: "bg-white border text-dark",
|
||||
bodyBg: "bg-light",
|
||||
headerBg: "bg-primary text-white",
|
||||
},
|
||||
dark: {
|
||||
userBg: "bg-dark text-white",
|
||||
userBg: "bg-primary text-white",
|
||||
assistantBg: "bg-secondary text-white",
|
||||
bodyBg: "bg-black text-white",
|
||||
headerBg: "bg-dark text-white",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 re‑read 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);
|
||||
|
||||
Reference in New Issue
Block a user