Fix timestap messaggi

This commit is contained in:
Samuele E. Locatelli
2025-09-03 08:59:59 +00:00
parent be1e57b2d7
commit c1cbff0a7b
7 changed files with 175 additions and 341 deletions
-81
View File
@@ -1,81 +0,0 @@
//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}
/>
);
}
+31 -21
View File
@@ -1,32 +1,45 @@
// AssistantMessage.jsx
// src/AssistantMessage.jsx
import React, { useState, useEffect } 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, timestamp, isFinal }) {
export default function AssistantMessage({
content,
theme,
timestamp,
startedAt,
endedAt,
isFinal
}) {
const [showThink, setShowThink] = useState(false);
const [fadeOut, setFadeOut] = useState(false);
// timestamp da mostrare: priorità a timestamp salvato, poi endedAt, poi startedAt
const ts = timestamp ?? endedAt ?? startedAt;
const thinkMatch = content?.match(/<think>([\s\S]*?)<\/think>/i);
const thinkContent = thinkMatch ? thinkMatch[1].trim() : null;
const visibleContent = isFinal
// Consideriamo "completo" se isFinal è true oppure se il messaggio ha un timestamp salvato
const isComplete = isFinal || Boolean(timestamp || endedAt);
const visibleContent = isComplete
? content?.replace(/<think>[\s\S]*?<\/think>/i, "").trim()
: content;
useEffect(() => {
if (thinkContent && !isFinal) {
if (thinkContent && !isComplete) {
setShowThink(true);
setFadeOut(false);
}
if (thinkContent && isFinal) {
if (thinkContent && isComplete) {
setFadeOut(true);
const timer = setTimeout(() => setShowThink(false), 600);
return () => clearTimeout(timer);
}
}, [thinkContent, isFinal]);
}, [thinkContent, isComplete]);
return (
<div className="mb-2 text-start">
@@ -62,7 +75,7 @@ export default function AssistantMessage({ content, theme, timestamp, isFinal })
{visibleContent}
</ReactMarkdown>
</div>
{timestamp && (
{ts != null && (
<div
style={{
fontSize: "0.75rem",
@@ -71,7 +84,7 @@ export default function AssistantMessage({ content, theme, timestamp, isFinal })
marginLeft: "0.25rem"
}}
>
{formatDateTime(timestamp)}
{formatDateTime(ts)}
</div>
)}
</div>
@@ -139,19 +152,16 @@ function CodeWithCopy({ inline, className = "", children, ...props }) {
}
function formatDateTime(dateTime) {
try {
const date = new Date(dateTime);
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
} catch {
return dateTime;
}
const date = dateTime instanceof Date ? dateTime : new Date(dateTime);
if (Number.isNaN(date.getTime())) return String(dateTime);
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
}
-32
View File
@@ -1,32 +0,0 @@
// src/AssistantMessage.jsx
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 (
<div className="mb-2 text-start">
<div
className={`d-inline-block p-2 rounded ${theme.assistantBg}`}
style={{ maxWidth: "95%" }}
>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
table: (props) => (
<table className="table table-sm table-bordered" {...props} />
),
th: (props) => <th className="bg-light" {...props} />
}}
>
{content}
</ReactMarkdown>
</div>
</div>
);
}
-79
View File
@@ -1,79 +0,0 @@
// 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>
);
}
+4
View File
@@ -19,6 +19,8 @@ export default function ChatWindow({ messages, loading, theme }) {
content={msg.content}
theme={theme}
timestamp={msg.timestamp}
startedAt={msg.startedAt}
endedAt={msg.endedAt}
/>
) : (
<AssistantMessage
@@ -26,6 +28,8 @@ export default function ChatWindow({ messages, loading, theme }) {
content={msg.content}
theme={theme}
timestamp={msg.timestamp}
startedAt={msg.startedAt}
endedAt={msg.endedAt}
isFinal={msg.isFinal}
/>
)
+17 -19
View File
@@ -1,14 +1,17 @@
// src/UserMessage.jsx
import React from "react";
export default function UserMessage({ content, theme, timestamp }) {
export default function UserMessage({ content, theme, timestamp, startedAt, endedAt }) {
// Priorità: timestamp dal modello dati → endedAt → startedAt
const ts = timestamp ?? endedAt ?? startedAt;
return (
<div className="mb-2 d-flex flex-column align-items-end">
<div
className={`p-2 rounded ${theme.userBg}`}
style={{
maxWidth: "95%",
textAlign: "left", // ensures text inside is left-aligned
textAlign: "left",
}}
>
<pre
@@ -23,7 +26,7 @@ export default function UserMessage({ content, theme, timestamp }) {
{content}
</pre>
</div>
{timestamp && (
{ts != null && (
<div
style={{
fontSize: "0.75rem",
@@ -32,28 +35,23 @@ export default function UserMessage({ content, theme, timestamp }) {
marginRight: "0.25rem"
}}
>
{formatDateTime(timestamp)}
{formatDateTime(ts)}
</div>
)}
</div>
);
}
// Same helper used in AssistantMessage
function formatDateTime(dateTime) {
try {
const date = new Date(dateTime);
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
} catch {
return dateTime;
}
const date = dateTime instanceof Date ? dateTime : new Date(dateTime);
if (Number.isNaN(date.getTime())) return String(dateTime);
return date.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
}
+123 -109
View File
@@ -1,4 +1,4 @@
// useChatStream.js
// src/useChatStream.js
import { useState, useCallback, useRef } from "react";
import { getSessionId, getUserId } from './useSessionId';
@@ -9,130 +9,144 @@ export function useChatStream() {
const userId = getUserId();
const sendMessage = useCallback(
async (input) => {
if (!input.trim()) return;
const sendMessage = useCallback(async (input) => {
if (!input.trim()) return;
const sessionId = getSessionId();
const sessionId = getSessionId();
if (abortRef.current) {
abortRef.current.abort();
}
const controller = new AbortController();
abortRef.current = controller;
// interrompe eventuale stream in corso
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) => [
const startedAt = Date.now();
let assistantIndex;
// aggiunge messaggi user + placeholder assistant
setMessages((prev) => {
assistantIndex = prev.length + 1;
return [
...prev,
userMessage,
{ role: "assistant", content: "", isFinal: false }
]);
setLoading(true);
{ role: "user", content: input, startedAt },
{ role: "assistant", content: "", isFinal: false, startedAt }
];
});
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,
session_id: sessionId,
message: input
}),
signal: controller.signal,
});
setLoading(true);
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
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,
session_id: sessionId,
message: input
}),
signal: controller.signal,
});
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let acc = "";
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
while (true) {
const { value, done } = await reader.read();
if (done) break;
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let acc = "";
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop() || "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
for (const part of parts) {
const line = part
.split("\n")
.map((l) => l.trim())
.find((l) => l.startsWith("data:"));
if (!line) continue;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop() || "";
const data = line.slice(5).trim();
for (const part of parts) {
const line = part
.split("\n")
.map((l) => l.trim())
.find((l) => l.startsWith("data:"));
if (!line) continue;
if (data === "[DONE]") {
// Delay prima di settare isFinal: true
setTimeout(() => {
setMessages((prev) => {
const next = [...prev];
if (next[assistantIndex]) {
next[assistantIndex] = {
...next[assistantIndex],
content: acc,
isFinal: true
};
}
return next;
});
setLoading(false);
}, 800); // <-- delay in ms
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];
if (next[assistantIndex]) {
next[assistantIndex] = {
role: "assistant",
content: acc,
isFinal: false
};
}
return next;
});
}
} catch {
// ignora chunk non validi
const data = line.slice(5).trim();
if (data === "[DONE]") {
const endedAt = Date.now();
// Delay prima di settare isFinal: true
setTimeout(() => {
setMessages((prev) => {
const next = [...prev];
if (next[assistantIndex]) {
next[assistantIndex] = {
...next[assistantIndex],
content: acc,
isFinal: true,
endedAt
};
}
const userIdx = assistantIndex - 1;
if (userIdx >= 0 && next[userIdx]?.role === "user") {
next[userIdx] = {
...next[userIdx],
endedAt
};
}
return next;
});
setLoading(false);
}, 800); // <-- delay in ms
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];
if (next[assistantIndex]) {
next[assistantIndex] = {
...next[assistantIndex], // mantieni campi extra
content: acc,
isFinal: false
};
}
return next;
});
}
} catch {
// ignora chunk non validi
}
}
} catch (err) {
if (err.name !== "AbortError") {
setMessages((prev) => {
const next = [...prev];
if (next[assistantIndex]) {
next[assistantIndex] = {
role: "assistant",
content: `Error: ${String(err)}`,
isFinal: true
};
}
return next;
});
}
} finally {
abortRef.current = null;
}
},
[messages, userId]
);
} catch (err) {
if (err.name !== "AbortError") {
setMessages((prev) => {
const next = [...prev];
if (next[assistantIndex]) {
next[assistantIndex] = {
...next[assistantIndex],
content: `Error: ${String(err)}`,
isFinal: true,
endedAt: Date.now()
};
}
return next;
});
}
} finally {
abortRef.current = null;
}
}, [userId]);
const stopGenerating = useCallback(() => {
if (abortRef.current) {