Fix timestap messaggi
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user