Merge branch 'release/FixSessionMan_02'

This commit is contained in:
Samuele Locatelli
2025-09-03 17:43:31 +02:00
31 changed files with 1541 additions and 620 deletions
+1
View File
@@ -69,4 +69,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 |
| 0.2.2509.0317 | miglioramento gestione memoria sessioni | 2025.09.03 |
BIN
View File
Binary file not shown.
+143
View File
@@ -0,0 +1,143 @@
# api/v1/chat.py
import httpx
import json
import asyncio
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
from typing import List, Dict, Any, Optional
from models.chat import ChatRequest, ChatResponse
from services import redis_service # now using updated service with session support
from utils.logging import logger
from config import settings
router = APIRouter()
MAX_HISTORY_LENGTH = 50
@router.post("/chat", response_model=ChatResponse)
async def chat_endpoint(payload: ChatRequest):
try:
# Create a new session if session_id not provided
session_id = payload.session_id
if not session_id:
meta = redis_service.create_session(payload.user_id, payload.message)
session_id = meta["session_id"]
# Save user message
redis_service.save_chat(payload.user_id, session_id, {"role": "user", "content": payload.message})
history = redis_service.get_chat(payload.user_id, session_id, limit=MAX_HISTORY_LENGTH)
async with httpx.AsyncClient(timeout=settings.REQUEST_TIMEOUT) as client:
resp = await client.post(
settings.LM_STUDIO_URL,
json={"model": settings.MODEL_NAME, "messages": history},
)
resp.raise_for_status()
data = resp.json()
reply = data["choices"][0]["message"]["content"]
# Save assistant message
redis_service.save_chat(payload.user_id, session_id, {"role": "assistant", "content": reply})
# Return normal ChatResponse, but could also include session_id if needed
return ChatResponse(response=reply, session_id=session_id)
except Exception:
logger.exception("Error in /chat endpoint")
raise HTTPException(status_code=500, detail="Internal server error")
@router.post("/chat-stream")
async def chat_stream_endpoint(payload: ChatRequest):
"""
Streams model output token-by-token using SSE.
"""
session_id = payload.session_id
if not session_id:
meta = redis_service.create_session(payload.user_id, payload.message)
session_id = meta["session_id"]
redis_service.save_chat(payload.user_id, session_id, {"role": "user", "content": payload.message})
history = redis_service.get_chat(payload.user_id, session_id, limit=MAX_HISTORY_LENGTH)
async def event_generator():
assistant_text = ""
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream(
"POST",
settings.LM_STUDIO_URL,
json={
"model": settings.MODEL_NAME,
"messages": history,
"stream": True
}
) as r:
async for raw_line in r.aiter_lines():
if not raw_line:
continue
line = raw_line if raw_line.startswith("data:") else f"data: {raw_line}"
payload_str = line[len("data: "):].strip()
if payload_str == "[DONE]":
yield "data: [DONE]\n\n"
break
yield f"data: {payload_str}\n\n"
try:
obj = json.loads(payload_str)
choice = obj.get("choices", [{}])[0]
delta = choice.get("delta", {})
piece = delta.get("content") or choice.get("text")
if piece:
assistant_text += piece
except json.JSONDecodeError:
pass
await asyncio.sleep(0)
except Exception as e:
logger.exception("Streaming error in /chat-stream")
yield f"event: error\ndata: {str(e)}\n\n"
finally:
if assistant_text:
redis_service.save_chat(payload.user_id, session_id, {"role": "assistant", "content": assistant_text})
headers = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
@router.get("/history")
async def get_history(
user_id: str = Query(..., description="User ID"),
session_id: str = Query(..., description="Session ID"),
limit: int = Query(MAX_HISTORY_LENGTH, description="Max number of messages to return")
) -> List[Dict[str, Any]]:
"""
Return all history saved for a given user/session.
"""
logger.info(f"[GET /history] user_id={user_id}, session_id={session_id}, limit={limit}")
history = redis_service.get_chat(user_id, session_id, limit=limit)
return history or []
@router.delete("/history")
async def delete_history(
user_id: str = Query(..., description="User ID"),
session_id: str = Query(..., description="Session ID")
):
"""
Clears history for a given user/session.
"""
logger.info(f"[DELETE /history] user_id={user_id}, session_id={session_id}")
redis_service.clear_chat(user_id, session_id)
return {"status": "cleared"}
+98
View File
@@ -0,0 +1,98 @@
# api/v1/sessions.py
from typing import List
from fastapi import Body, Query, Path, WebSocket, WebSocketDisconnect
from services import redis_service
from fastapi import APIRouter
from models.session import SessionMeta
import json
import asyncio
router = APIRouter()
# -------------------------
# WebSocket per aggiornamenti sessioni
# -------------------------
@router.websocket("/ws/sessions")
async def sessions_ws(websocket: WebSocket, user_id: str = Query(...)):
await websocket.accept()
try:
# Invia subito la lista completa
sessions = redis_service.get_sessions(user_id)
await websocket.send_json({"type": "full_list", "sessions": sessions})
# Sottoscrizione al canale Redis
pubsub = redis_service.r.pubsub()
channel = f"sessions:{user_id}"
await pubsub.subscribe(channel)
async for message in pubsub.listen():
if message["type"] != "message":
continue
try:
data = json.loads(message["data"])
await websocket.send_json(data)
except Exception as e:
print(f"[WS] Errore parsing messaggio: {e}")
except WebSocketDisconnect:
print(f"[WS] Disconnesso: {user_id}")
finally:
await pubsub.unsubscribe(channel)
await pubsub.close()
# -------------------------
# Endpoint REST
# -------------------------
@router.get("/sessions", response_model=List[dict])
async def list_sessions_endpoint(user_id: str = Query(...)):
return redis_service.get_sessions(user_id)
@router.get("/sessions/{session_id}", response_model=dict)
async def get_session_meta_endpoint(
user_id: str = Query(..., description="User ID"),
session_id: str = Path(..., description="Session ID")
):
return redis_service.get_session_meta(user_id, session_id) or {}
@router.post("/sessions", response_model=dict)
async def create_session_endpoint(
user_id: str = Query(..., description="User ID"),
first_message: str = Body("", embed=True)
):
meta = redis_service.create_session(user_id, first_message)
# Notifica WS
redis_service.r.publish(
f"sessions:{user_id}",
json.dumps({"type": "created", "session": meta})
)
return meta
@router.patch("/sessions/{session_id}", response_model=dict)
async def update_session_endpoint(
user_id: str = Query(..., description="User ID"),
session_id: str = Path(..., description="Session ID"),
session_name: str = Body(..., embed=True)
):
updated = redis_service.update_session_meta(user_id, session_id, session_name=session_name) or {}
if updated:
redis_service.r.publish(
f"sessions:{user_id}",
json.dumps({"type": "updated", "session": updated})
)
return updated
@router.delete("/sessions/{session_id}", response_model=dict)
async def delete_session_endpoint(
user_id: str = Query(..., description="User ID"),
session_id: str = Path(..., description="Session ID")
):
redis_service.delete_session(user_id, session_id)
redis_service.r.publish(
f"sessions:{user_id}",
json.dumps({"type": "deleted", "session_id": session_id})
)
return {"status": "deleted"}
+14
View File
@@ -0,0 +1,14 @@
import os
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
REDIS_HOST: str = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT: int = int(os.getenv("REDIS_PORT", 6379))
REDIS_DB: int = int(os.getenv("REDIS_DB", 0))
LM_STUDIO_URL: str = os.getenv("LM_STUDIO_URL", "http://10.74.83.100:1234/v1/chat/completions")
#MODEL_NAME: str = os.getenv("MODEL_NAME", "qwen/qwen3-4b-thinking-2507")
MODEL_NAME: str = os.getenv("MODEL_NAME", "qwen/qwen3-4b-2507")
REQUEST_TIMEOUT: float = float(os.getenv("REQUEST_TIMEOUT", 30.0))
settings = Settings()
+6 -96
View File
@@ -1,19 +1,7 @@
# main.py
from fastapi import FastAPI, Request
# main.py (new project entrypoint)
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
import redis
import json
import httpx
r = redis.Redis(host='localhost', port=6379, db=1)
def save_chat(user_id, message):
r.rpush(f"chat:{user_id}", json.dumps(message))
def get_chat(user_id):
messages = r.lrange(f"chat:{user_id}", 0, -1)
return [json.loads(m.decode('utf-8')) for m in messages]
from api.v1 import chat, sessions # import the routers
app = FastAPI()
@@ -24,84 +12,6 @@ app.add_middleware(
allow_headers=["*"],
)
LM_STUDIO_URL = "http://10.74.83.100:1234/v1/chat/completions"
MODEL_NAME = "qwen/qwen3-4b-2507" # update as needed
@app.post("/chat")
async def chat(request: Request):
data = await request.json()
user_id = data.get("user_id", "default")
message = data["message"]
save_chat(user_id, {"role": "user", "content": message})
history = get_chat(user_id)
async with httpx.AsyncClient(timeout=None) as client:
resp = await client.post(LM_STUDIO_URL, json={
"model": MODEL_NAME,
"messages": history,
})
result = resp.json()
reply = result["choices"][0]["message"]["content"]
save_chat(user_id, {"role": "assistant", "content": reply})
return {"response": reply}
@app.post("/chat-stream")
async def chat_stream(request: Request):
data = await request.json()
user_id = data.get("user_id", "default")
message = data["message"]
# Save user message and build history
save_chat(user_id, {"role": "user", "content": message})
history = get_chat(user_id)
async def event_generator():
assistant_text = ""
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("POST", LM_STUDIO_URL, json={
"model": MODEL_NAME,
"messages": history,
"stream": True
}) as r:
async for raw_line in r.aiter_lines():
if not raw_line:
continue
# Normalize to standard SSE "data: ..." form
line = raw_line if raw_line.startswith("data:") else f"data: {raw_line}"
payload = line[len("data: "):].strip()
if payload == "[DONE]":
# Finalize and flush
yield "data: [DONE]\n\n"
break
# Echo the SSE line to client
yield f"data: {payload}\n\n"
# Accumulate content for saving to Redis
try:
obj = json.loads(payload)
choice = obj.get("choices", [{}])[0]
# Handle OpenAI-style streaming objects
delta = choice.get("delta", {})
piece = delta.get("content")
if piece is None:
# Some servers send "text" instead
piece = choice.get("text")
if piece:
assistant_text += piece
except Exception:
# Ignore non-JSON control lines
pass
finally:
if assistant_text:
save_chat(user_id, {"role": "assistant", "content": assistant_text})
headers = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no" # helps if behind Nginx
}
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
# Mount the router under /v1
app.include_router(chat.router, prefix="/v1")
app.include_router(sessions.router, prefix="/v1")
+13
View File
@@ -0,0 +1,13 @@
# models/chat.py
from pydantic import BaseModel
from datetime import datetime
from typing import List, Optional
class ChatRequest(BaseModel):
user_id: str # identifier for the user (can be same as session if desired)
session_id: Optional[str] = None # new: multi-session handling
message: str # user input text
class ChatResponse(BaseModel):
response: str # assistant's reply
session_id: str # <-- now included in every response
+12
View File
@@ -0,0 +1,12 @@
# models/session.py
from pydantic import BaseModel
from datetime import datetime
from typing import List, Optional
class SessionMeta(BaseModel):
session_id: str
created_at: datetime
session_name: str
message_count: int = 0
history_size_bytes: int = 0
+12
View File
@@ -0,0 +1,12 @@
import httpx
from ..config import settings
async def send_chat_completion(history: list):
async with httpx.AsyncClient(timeout=settings.REQUEST_TIMEOUT) as client:
resp = await client.post(
settings.LM_STUDIO_URL,
json={"model": settings.MODEL_NAME, "messages": history},
)
resp.raise_for_status()
return resp.json()
+175
View File
@@ -0,0 +1,175 @@
# services/redis_service.py
import redis
import json
import uuid
from config import settings
from datetime import datetime
from typing import List, Optional
# TTL di default: 30 giorni
DEFAULT_TTL_SECONDS = 30 * 24 * 60 * 60
# Connessione principale
r = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DB,
decode_responses=True
)
# -------------------------
# Nuovo: helper per WS
# -------------------------
def publish_session_event(user_id: str, event_type: str, **kwargs):
"""
Pubblica un evento sul canale WS dell'utente.
event_type: "full_list", "created", "updated", "deleted"
kwargs: dati extra (es. session, session_id)
"""
channel = f"sessions:{user_id}"
payload = {"type": event_type}
payload.update(kwargs)
try:
r.publish(channel, json.dumps(payload))
except Exception as e:
print(f"[Redis] Errore publish su {channel}: {e}")
# -------------------------
# Chat message operations
# -------------------------
def save_chat(user_id: str, session_id: str, message: dict):
key = f"chatHistory:{user_id}:{session_id}"
enriched = {**message, "timestamp": datetime.utcnow().isoformat() + "Z"}
r.rpush(key, json.dumps(enriched))
r.expire(key, DEFAULT_TTL_SECONDS)
_update_session_stats(user_id, session_id)
refresh_session_ttl(user_id, session_id)
def get_chat(user_id: str, session_id: str, limit: Optional[int] = None):
key = f"chatHistory:{user_id}:{session_id}"
data = r.lrange(key, 0, -1)
messages = [json.loads(item) for item in data]
messages.sort(key=lambda m: m.get("timestamp", ""))
if limit:
messages = messages[-limit:]
return messages
def clear_chat(user_id: str, session_id: str):
key = f"chatHistory:{user_id}:{session_id}"
r.delete(key)
_update_session_stats(user_id, session_id, reset=True)
refresh_session_ttl(user_id, session_id)
# -------------------------
# Session metadata ops
# -------------------------
def create_session(user_id: str, first_message: str) -> dict:
session_id = str(uuid.uuid4())
created_at = datetime.utcnow().isoformat() + "Z"
session_name = (first_message.strip()[:50] or "New Chat")
meta = {
"session_id": session_id,
"created_at": created_at,
"session_name": session_name,
"message_count": 0,
"history_size_bytes": 0
}
meta_key = f"chatSession:{user_id}:{session_id}"
index_key = f"chatSessionsIndex:{user_id}"
r.set(meta_key, json.dumps(meta), ex=DEFAULT_TTL_SECONDS)
score = float(datetime.utcnow().timestamp())
r.zadd(index_key, {session_id: score})
r.expire(index_key, DEFAULT_TTL_SECONDS)
# Notifica WS
publish_session_event(user_id, "created", session=meta)
return meta
def get_sessions(user_id: str) -> List[dict]:
key = f"chatSessionsIndex:{user_id}"
session_ids = r.zrevrange(key, 0, -1)
sessions = []
for sid in session_ids:
session_key = f"chatSession:{user_id}:{sid}"
raw = r.get(session_key)
if raw:
sessions.append(json.loads(raw))
return sessions
def get_session_meta(user_id: str, session_id: str) -> Optional[dict]:
raw = r.get(f"chatSession:{user_id}:{session_id}")
return json.loads(raw) if raw else None
def update_session_meta(user_id: str, session_id: str, **updates) -> Optional[dict]:
meta = get_session_meta(user_id, session_id)
if not meta:
return None
meta.update(updates)
r.set(f"chatSession:{user_id}:{session_id}", json.dumps(meta), ex=DEFAULT_TTL_SECONDS)
refresh_session_ttl(user_id, session_id)
# Notifica WS
publish_session_event(user_id, "updated", session=meta)
return meta
def delete_session(user_id: str, session_id: str):
r.delete(f"chatSession:{user_id}:{session_id}")
r.delete(f"chatHistory:{user_id}:{session_id}")
r.zrem(f"chatSessionsIndex:{user_id}", session_id)
# Notifica WS
publish_session_event(user_id, "deleted", session_id=session_id)
# -------------------------
# TTL management
# -------------------------
def extend_session_ttl(user_id: str, session_id: str, extra_seconds: int):
keys = [
f"chatSession:{user_id}:{session_id}",
f"chatHistory:{user_id}:{session_id}",
f"chatSessionsIndex:{user_id}"
]
for key in keys:
if r.exists(key):
current_ttl = r.ttl(key)
if current_ttl > 0:
r.expire(key, current_ttl + extra_seconds)
else:
r.expire(key, extra_seconds)
def refresh_session_ttl(user_id: str, session_id: str):
keys = [
f"chatSession:{user_id}:{session_id}",
f"chatHistory:{user_id}:{session_id}",
f"chatSessionsIndex:{user_id}"
]
for key in keys:
if r.exists(key):
r.expire(key, DEFAULT_TTL_SECONDS)
# -------------------------
# Internal helpers
# -------------------------
def _update_session_stats(user_id: str, session_id: str, reset: bool = False):
meta = get_session_meta(user_id, session_id)
if not meta:
return
if reset:
meta["message_count"] = 0
meta["history_size_bytes"] = 0
else:
key = f"chatHistory:{user_id}:{session_id}"
messages = r.lrange(key, 0, -1)
meta["message_count"] = len(messages)
meta["history_size_bytes"] = sum(len(m.encode("utf-8")) for m in messages)
r.set(f"chatSession:{user_id}:{session_id}", json.dumps(meta), ex=DEFAULT_TTL_SECONDS)
+9
View File
@@ -0,0 +1,9 @@
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
logger = logging.getLogger("app")
+73 -33
View File
@@ -1,9 +1,13 @@
/* src/App.css */
/* =========================
RESET & BASE
========================= */
html, body, #root {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
overflow: hidden; /* lo scroll è gestito dal main della chat */
}
body {
@@ -11,26 +15,19 @@ body {
font-family: sans-serif;
}
/* Animate only the wrapper, not the whole page */
/* =========================
SESSIONS WRAPPER & OFFCANVAS
========================= */
.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;
@@ -38,31 +35,19 @@ body.sessions-open .sessions-wrapper {
opacity: 0;
pointer-events: none;
transition: opacity 250ms ease;
z-index: 1030; /* just under offcanvas (Bootstrap sets .offcanvas at 1045) */
z-index: 1030; /* sotto offcanvas (Bootstrap .offcanvas = 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;
width: 280px;
border-right: 1px solid #dee2e6;
}
.offcanvas.show {
box-shadow: 6px 0 20px rgba(0, 0, 0, 0.08);
}
@@ -70,8 +55,8 @@ body.sessions-open .sessions-wrapper {
.offcanvas-left {
position: fixed;
top: 0;
left: -300px; /* panel width */
width: 300px;
left: -280px;
width: 280px;
height: 100%;
background: var(--bs-body-bg, #fff);
box-shadow: 2px 0 5px rgba(0,0,0,0.3);
@@ -83,15 +68,32 @@ body.sessions-open .sessions-wrapper {
left: 0;
}
.session-table {
font-size: 0.85rem;
}
/* Chat styles */
.chat-container {
/* =========================
CHAT LAYOUT
========================= */
.chat-layout {
display: flex;
flex-direction: column;
height: 100vh;
background-color: #f3f4f6;
}
.chat-layout > .flex-grow-1 {
flex: 1;
min-height: 0; /* fondamentale per Safari iOS */
overflow-y: auto;
}
.chat-layout footer {
flex-shrink: 0;
}
/* =========================
CHAT STYLES
========================= */
.chat-box {
flex: 1;
overflow-y: auto;
@@ -108,9 +110,11 @@ body.sessions-open .sessions-wrapper {
max-width: 95%;
word-wrap: break-word;
}
.message:last-child {
overflow-anchor: auto;
}
@media (min-width: 768px) {
.message { max-width: 75%; }
}
@@ -122,6 +126,7 @@ body.sessions-open .sessions-wrapper {
text-align: right;
border-top-right-radius: 0;
}
.message.assistant {
background-color: #e9ecef;
color: #212529;
@@ -130,12 +135,16 @@ body.sessions-open .sessions-wrapper {
border-top-left-radius: 0;
}
/* =========================
INPUT BAR
========================= */
.input-bar {
display: flex;
padding: 1rem;
background-color: white;
border-top: 1px solid #ccc;
}
.chat-input {
flex: 1;
padding: 0.75rem;
@@ -143,6 +152,7 @@ body.sessions-open .sessions-wrapper {
border-radius: 8px;
font-size: 1rem;
}
.send-button {
margin-left: 0.5rem;
padding: 0.75rem 1rem;
@@ -153,6 +163,9 @@ body.sessions-open .sessions-wrapper {
cursor: pointer;
}
/* =========================
CODE BLOCKS
========================= */
pre {
background-color: #212529;
color: #f8f9fa;
@@ -160,11 +173,38 @@ pre {
border-radius: 0.375rem;
overflow-x: auto;
}
code {
font-family: 'Fira Code', monospace;
font-size: 0.9rem;
}
.btn-copy {
font-size: 0.75rem;
}
/* =========================
THINKING BLOCK (limit growth)
========================= */
.message.thinking {
max-height: 40vh;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.think-block {
opacity: 1;
transition: opacity 0.6s ease;
max-height: 40vh; /* non occupa più del 40% dell'altezza viewport */
overflow-y: auto; /* scroll interno se il contenuto è troppo */
padding: 0.5rem; /* un po di respiro interno */
box-sizing: border-box; /* padding incluso nel calcolo altezza */
background: rgba(0,0,0,0.03);
}
.think-block.fade-out {
opacity: 0;
}
+30 -4
View File
@@ -4,14 +4,16 @@ import "./App.css";
import { themes } from "./themes";
import ChatLayout from "./ChatLayout";
import { useChatStream } from "./useChatStream";
import { getSessionId, getUserId, resetSessionId, setSessionId } from "./useSessionId";
import { getSessionId, getUserId, clearSessionId, setSessionId } from "./useSessionId";
import "katex/dist/katex.min.css"; // <-- IMPORTANTE
export default function App() {
const { messages, loading, sendMessage, stopGenerating, setMessages } = useChatStream();
const [themeName, setThemeName] = useState("light");
const [sessionName, setSessionName] = useState("");
const theme = themes[themeName];
const sessionId = getSessionId();
const userId = getUserId();
const sessionId = getSessionId();
useEffect(() => {
const saved = localStorage.getItem("preferredTheme");
@@ -22,6 +24,26 @@ export default function App() {
localStorage.setItem("preferredTheme", themeName);
}, [themeName]);
// Carica il nome della sessione corrente
useEffect(() => {
if (!sessionId) {
setSessionName("");
return;
}
(async () => {
try {
const res = await fetch(`/v1/sessions/${sessionId}?user_id=${userId}`);
if (res.ok) {
const meta = await res.json();
setSessionName(meta.session_name || "");
}
} catch (err) {
console.error("Errore nel recupero session_name", err);
setSessionName("");
}
})();
}, [sessionId, userId]);
const toggleTheme = () => setThemeName(t => (t === "light" ? "dark" : "light"));
const reloadHistory = async (id = sessionId) => {
@@ -33,8 +55,8 @@ export default function App() {
const freshStart = async () => {
await fetch(`/v1/history?user_id=${userId}&session_id=${sessionId}`, { method: "DELETE" });
setMessages([]);
const newId = resetSessionId();
setSessionId(newId);
clearSessionId(); // <-- ora rimuove del tutto la sessione
setSessionName("");
};
const createSession = async () => {
@@ -46,6 +68,7 @@ export default function App() {
});
const meta = await res.json();
setSessionId(meta.session_id);
setSessionName(meta.session_name || "");
setMessages([]);
};
@@ -57,6 +80,7 @@ export default function App() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_name: newName }),
});
setSessionName(newName);
};
const handleSelectSession = async (selectedId) => {
@@ -78,6 +102,8 @@ export default function App() {
onEditSession={editSession}
onSelectSession={handleSelectSession}
userId={userId}
sessionId={sessionId}
sessionName={sessionName} // <-- aggiunto
/>
);
}
-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}
/>
);
}
+138 -4
View File
@@ -1,16 +1,64 @@
import React from "react";
// 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 }) {
export default function AssistantMessage({
content,
theme,
timestamp,
startedAt,
endedAt,
isFinal
}) {
const [showThink, setShowThink] = useState(false);
const [fadeOut, setFadeOut] = useState(false);
const ts = timestamp ?? endedAt ?? startedAt;
const thinkMatch = content?.match(/<think>([\s\S]*?)<\/think>/i);
const thinkContent = thinkMatch ? thinkMatch[1].trim() : null;
const isComplete = isFinal || Boolean(timestamp || endedAt);
const visibleContent = isComplete
? content?.replace(/<think>[\s\S]*?<\/think>/i, "").trim()
: content;
useEffect(() => {
if (thinkContent && !isComplete) {
setShowThink(true);
setFadeOut(false);
}
if (thinkContent && isComplete) {
setFadeOut(true);
const timer = setTimeout(() => setShowThink(false), 600);
return () => clearTimeout(timer);
}
}, [thinkContent, isComplete]);
return (
<div className="mb-2 text-start">
<div
className={`d-inline-block p-2 rounded ${theme.assistantBg}`}
style={{ maxWidth: "95%" }}
>
{showThink && (
<div
className={`think-block${fadeOut ? " fade-out" : ""}`}
style={{
fontStyle: "italic",
opacity: 0.7,
marginBottom: "0.5rem",
whiteSpace: "pre-wrap"
}}
>
🤔 {thinkContent}
</div>
)}
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
@@ -18,14 +66,100 @@ export default function AssistantMessage({ content, theme }) {
table: (props) => (
<table className="table table-sm table-bordered" {...props} />
),
th: (props) => <th className="bg-light" {...props} />
th: (props) => <th className="bg-light" {...props} />,
code: CodeWithCopy
}}
>
{content}
{visibleContent}
</ReactMarkdown>
</div>
{ts != null && (
<div
style={{
fontSize: "0.75rem",
color: "#666",
marginTop: "0.2rem",
marginLeft: "0.25rem"
}}
>
{formatDateTime(ts)}
</div>
)}
</div>
);
}
function CodeWithCopy({ inline, className = "", children, ...props }) {
const [copied, setCopied] = useState(false);
const codeText = String(children).replace(/\n$/, "");
const isFencedBlock = !inline && /^language-/.test(className);
if (!isFencedBlock) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(codeText);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch (err) {
console.error("Copy failed", err);
}
};
return (
<div style={{ position: "relative" }}>
<pre className={className} {...props} style={{ paddingRight: "2rem" }}>
<code>{codeText}</code>
</pre>
<button
onClick={handleCopy}
style={{
position: "absolute",
top: "0.25rem",
right: "0.25rem",
border: "none",
background: "transparent",
cursor: "pointer",
fontSize: "0.85rem"
}}
title="Copy to clipboard"
>
📋
</button>
{copied && (
<span
style={{
position: "absolute",
top: "0.25rem",
right: "2rem",
fontSize: "0.8rem",
color: "green"
}}
>
Copied!
</span>
)}
</div>
);
}
function formatDateTime(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"
});
}
+50 -29
View File
@@ -7,30 +7,39 @@ export default function ChatHeader({
onReloadHistory,
onFreshStart,
onCreateSession,
onManageSession
onManageSession,
sessionId,
sessionName
}) {
const hasSession = Boolean(sessionId);
return (
<header
className={`${theme.headerBg} p-2 sticky-top shadow row align-items-center`}
>
{/* 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"
>
🔄
</button>
{/* Left column */}
<div className="col-4 d-flex flex-wrap gap-2 align-items-center">
{hasSession && (
<>
<button
className="btn btn-sm btn-outline-light"
onClick={onReloadHistory}
title="Reload current session history"
>
🔄
</button>
<button
className="btn btn-sm btn-warning"
onClick={onFreshStart}
title="Reset/Clear current session and start fresh"
>
🆕
</button>
<button
className="btn btn-sm btn-warning"
onClick={onFreshStart}
title="Reset/Clear current session and start fresh"
>
🆕
</button>
</>
)}
{/* Questi due pulsanti sempre visibili */}
<button
className="btn btn-sm btn-success"
onClick={onCreateSession}
@@ -42,28 +51,40 @@ export default function ChatHeader({
<button
className="btn btn-sm btn-info"
onClick={onManageSession}
title="Manage your chat sessions"
title="Manage your chat sessions"
>
📂
📂
</button>
{/* Nome sessione solo se esiste */}
{hasSession && sessionName && (
<span
className="badge bg-secondary text-truncate small"
style={{ maxWidth: "150px" }}
title={sessionName}
>
{sessionName}
</span>
)}
</div>
{/* Center column: title */}
<div className="col-6 text-center">
{/* Center column: titolo solo se c'è sessione */}
<div className="col-5 text-center">
<h4 className="mb-0">🤖 EgalWare&apos;s LLM ChatBot</h4>
</div>
{/* Right column: theme toggle */}
{/* Right column: toggle tema solo se c'è sessione */}
<div className="col-3 d-flex flex-row-reverse">
<button
className="btn btn-sm btn-outline-light d-flex align-items-center gap-1"
onClick={onToggleTheme}
title="Toggle light/dark theme"
>
<span role="img" aria-label="theme toggle icon">🌓</span>
</button>
<button
className="btn btn-sm btn-outline-light d-flex align-items-center gap-1"
onClick={onToggleTheme}
title="Toggle light/dark theme"
>
<span role="img" aria-label="theme toggle icon">🌓</span>
</button>
</div>
</header>
);
}
+11 -1
View File
@@ -1,7 +1,16 @@
import React, { useState } from "react";
// src/ChatInput.jsx
import React, { useState, useRef, useEffect } from "react";
export default function ChatInput({ onSend, loading }) {
const [value, setValue] = useState("");
const textareaRef = useRef(null);
// Autofocus on mount and after each retrieval
useEffect(() => {
if (!loading && textareaRef.current) {
textareaRef.current.focus();
}
}, [loading]);
const handleKeyDown = (e) => {
if (e.key === "Enter" && !e.shiftKey) {
@@ -16,6 +25,7 @@ export default function ChatInput({ onSend, loading }) {
return (
<div className="p-2 border-top bg-white">
<textarea
ref={textareaRef}
className="form-control"
rows={3}
value={value}
+29 -17
View File
@@ -1,9 +1,10 @@
// App.jsx
// ChatLayout.jsx
import React, { useState, useEffect } from "react";
import ChatHeader from "./ChatHeader";
import ChatWindow from "./ChatWindow";
import ChatInput from "./ChatInput";
import SessionTable from "./SessionTable";
import NoSessionBox from "./NoSessionBox";
export default function ChatLayout({
theme,
@@ -17,21 +18,20 @@ export default function ChatLayout({
onCreateSession,
onEditSession,
onSelectSession,
userId
userId,
sessionId,
sessionName
}) {
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";
@@ -43,11 +43,13 @@ export default function ChatLayout({
document.body.style.overflow = "";
document.body.style.paddingRight = "";
setShowSessionsPanel(false);
document.body.classList.remove("sessions-open");
};
return (
<>
<div className={`sessions-wrapper d-flex flex-column vh-100 ${theme.bodyBg}`}>
<div className={`chat-layout sessions-wrapper ${theme.bodyBg}`}>
{/* HEADER */}
<ChatHeader
theme={theme}
onToggleTheme={onToggleTheme}
@@ -55,24 +57,33 @@ export default function ChatLayout({
onFreshStart={onFreshStart}
onCreateSession={onCreateSession}
onEditSession={onEditSession}
onManageSession={openSessionManager} // opens panel
onManageSession={openSessionManager}
sessionId={sessionId}
sessionName={sessionName}
/>
<div className="flex-grow-1 overflow-auto">
{/* AREA SCROLLABILE */}
<div className="flex-grow-1 overflow-auto" style={{ minHeight: 0 }}>
<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>
)}
</div>
{loading && (
<div className="p-2 text-center">
<button className="btn btn-warning btn-sm" onClick={onStop}>
Stop Generating
</button>
</div>
{/* INPUT SEMPRE IN BASSO */}
{sessionId ? (
<ChatInput onSend={onSend} onStop={onStop} loading={loading} />
) : (
<NoSessionBox onCreateSession={onCreateSession} />
)}
<ChatInput onSend={onSend} loading={loading} />
</div>
{/* PANEL SESSIONI */}
<div
className={`offcanvas offcanvas-start ${showSessionsPanel ? "show" : ""}`}
tabIndex="-1"
@@ -83,7 +94,7 @@ export default function ChatLayout({
<button
type="button"
className="btn-close text-reset"
onClick={closeSessionManager} // closes panel
onClick={closeSessionManager}
></button>
</div>
<div className="offcanvas-body">
@@ -98,3 +109,4 @@ export default function ChatLayout({
);
}
-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>
);
}
+32 -6
View File
@@ -1,3 +1,4 @@
// ChatWindow.jsx
import React, { useRef, useEffect } from "react";
import UserMessage from "./UserMessage";
import AssistantMessage from "./AssistantMessage";
@@ -10,19 +11,44 @@ export default function ChatWindow({ messages, loading, theme }) {
}, [messages]);
return (
<main className={`flex-grow-1 overflow-auto p-3 ${theme.bodyBg}`}>
{messages.map((m, idx) =>
m.role === "user" ? (
<UserMessage key={idx} content={m.content} theme={theme} />
<main className={`flex-1 overflow-y-auto p-3 ${theme.bodyBg}`}>
{messages.map((msg, idx) =>
msg.role === "user" ? (
<UserMessage
key={idx}
content={msg.content}
theme={theme}
timestamp={msg.timestamp}
startedAt={msg.startedAt}
endedAt={msg.endedAt}
/>
) : (
<AssistantMessage key={idx} content={m.content} theme={theme} />
<AssistantMessage
key={idx}
content={msg.content}
theme={theme}
timestamp={msg.timestamp}
startedAt={msg.startedAt}
endedAt={msg.endedAt}
isFinal={msg.isFinal}
/>
)
)}
{loading && (
<div className="text-muted small fst-italic">
<div
className="thinking-block text-muted small fst-italic"
style={{
maxHeight: "150px",
overflowY: "auto",
padding: "0.5rem",
border: "1px solid #ddd",
borderRadius: "4px"
}}
>
The model is processing...
</div>
)}
<div ref={endRef} />
</main>
);
-1
View File
@@ -4,7 +4,6 @@ import ReactMarkdown from 'react-markdown';
import remarkMath from 'remark-math';
import remarkGfm from 'remark-gfm';
import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
import './MessageContent.css';
+34
View File
@@ -0,0 +1,34 @@
// srv/NoSessionBox.jsx
import React from "react";
export default function NoSessionBox({ onCreateSession }) {
return (
<div
className="d-flex flex-column align-items-center justify-content-center text-center p-4"
style={{ height: "100%", minHeight: "200px" }}
>
<h3 className="mb-3">Nessuna sessione attiva</h3>
<div className="row">
<div className="col-3"></div>
<div className="col-6">
<div className="text-muted mb-4">
Per iniziare una nuova conversazione, crea una sessione assegnandole un nome.
Potrai poi rivedere le sessioni precedendi e riprenderle salvando la cronologia.
</div>
<div className="alert alert-info shadow small text-muted">
NB: il numero massimo di messaggi è limitato dalla finestra di contesto massima operativa e potrebbe portare a chiudere una sessione al raggiungimento del limite stesso.
</div>
</div>
<div className="col-3"></div>
</div>
<button
className="btn btn-success"
onClick={onCreateSession}
title="Crea una nuova sessione"
>
Crea nuova sessione
</button>
</div>
);
}
+57 -5
View File
@@ -1,5 +1,5 @@
// src/SessionTable.jsx
import React, { useEffect, useState } from "react";
import React, { useEffect, useState, useRef } from "react";
import { getSessionId } from "./useSessionId";
export default function SessionTable({ userId, onSelectSession, onClosePanel }) {
@@ -8,8 +8,10 @@ export default function SessionTable({ userId, onSelectSession, onClosePanel })
const [editingSessionId, setEditingSessionId] = useState(null);
const [editName, setEditName] = useState("");
const activeSessionId = getSessionId();
const wsRef = useRef(null);
const fetchSessions = async () => {
if (!userId) return;
setLoading(true);
try {
const res = await fetch(`/v1/sessions?user_id=${userId}`);
@@ -23,7 +25,51 @@ export default function SessionTable({ userId, onSelectSession, onClosePanel })
};
useEffect(() => {
if (userId) fetchSessions();
if (!userId) return;
// Caricamento iniziale
fetchSessions();
// Apri WebSocket
const wsUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/v1/ws/sessions?user_id=${userId}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
console.log("[WS] Connected to sessions stream");
};
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
// Possibili tipi di evento: full_list, created, updated, deleted
if (msg.type === "full_list") {
setSessions(msg.sessions);
} else if (msg.type === "created") {
setSessions((prev) => [msg.session, ...prev]);
} else if (msg.type === "updated" && msg.session?.session_id) {
setSessions((prev) =>
prev.map((s) =>
String(s.session_id) === String(msg.session.session_id)
? { ...s, ...msg.session }
: s
)
);
} else if (msg.type === "deleted") {
setSessions((prev) => prev.filter((s) => s.session_id !== msg.session_id));
}
} catch (err) {
console.error("[WS] Error parsing message", err);
}
};
ws.onclose = () => {
console.log("[WS] Disconnected from sessions stream");
};
return () => {
ws.close();
};
}, [userId]);
const startEditing = (session) => {
@@ -38,8 +84,13 @@ export default function SessionTable({ userId, onSelectSession, onClosePanel })
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_name: editName }),
});
// Aggiornamento ottimistico
setSessions((prev) =>
prev.map((s) =>
s.session_id === sessionId ? { ...s, session_name: editName } : s
)
);
setEditingSessionId(null);
fetchSessions();
} catch (err) {
console.error("Failed to rename session", err);
}
@@ -49,7 +100,7 @@ export default function SessionTable({ userId, onSelectSession, onClosePanel })
if (!window.confirm("Delete this session and its history?")) return;
try {
await fetch(`/v1/sessions/${sessionId}?user_id=${userId}`, { method: "DELETE" });
fetchSessions();
// Non serve fetchSessions: il WS notificherà la cancellazione
} catch (err) {
console.error("Failed to delete session", err);
}
@@ -115,7 +166,7 @@ export default function SessionTable({ userId, onSelectSession, onClosePanel })
<td>{new Date(s.created_at).toLocaleString()}</td>
<td title={s.history_size_bytes}>
{s.message_count}
</td>
</td>
<td className="text-end text-nowrap">
<button
className="btn btn-sm px-1 btn-outline-danger"
@@ -131,3 +182,4 @@ export default function SessionTable({ userId, onSelectSession, onClosePanel })
);
}
+105 -13
View File
@@ -1,31 +1,123 @@
// UserMessage.jsx
// src/UserMessage.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 UserMessage({ content, theme, timestamp, startedAt, endedAt }) {
const ts = timestamp ?? endedAt ?? startedAt;
export default function UserMessage({ content, theme }) {
return (
// This outer div aligns the *bubble* to the right
<div className="mb-2 d-flex justify-content-end">
<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
className="m-0 p-0"
style={{
whiteSpace: "pre-wrap",
fontFamily: "inherit",
backgroundColor: "transparent",
color: "inherit",
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
table: (props) => (
<table className="table table-sm table-bordered" {...props} />
),
th: (props) => <th className="bg-light" {...props} />,
code: CodeWithCopy
}}
>
{content}
</pre>
</ReactMarkdown>
</div>
{ts != null && (
<div
style={{
fontSize: "0.75rem",
color: "#666",
marginTop: "0.2rem",
marginRight: "0.25rem"
}}
>
{formatDateTime(ts)}
</div>
)}
</div>
);
}
function CodeWithCopy({ inline, className = "", children, ...props }) {
const [copied, setCopied] = React.useState(false);
const codeText = String(children).replace(/\n$/, "");
const isFencedBlock = !inline && /^language-/.test(className);
if (!isFencedBlock) {
return (
<code className={className} {...props}>
{children}
</code>
);
}
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(codeText);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch (err) {
console.error("Copy failed", err);
}
};
return (
<div style={{ position: "relative" }}>
<pre className={className} {...props} style={{ paddingRight: "2rem" }}>
<code>{codeText}</code>
</pre>
<button
onClick={handleCopy}
style={{
position: "absolute",
top: "0.25rem",
right: "0.25rem",
border: "none",
background: "transparent",
cursor: "pointer",
fontSize: "0.85rem"
}}
title="Copy to clipboard"
>
📋
</button>
{copied && (
<span
style={{
position: "absolute",
top: "0.25rem",
right: "2rem",
fontSize: "0.8rem",
color: "green"
}}
>
Copied!
</span>
)}
</div>
);
}
function formatDateTime(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"
});
}
+121 -76
View File
@@ -1,4 +1,4 @@
// useChatStream.js
// src/useChatStream.js
import { useState, useCallback, useRef } from "react";
import { getSessionId, getUserId } from './useSessionId';
@@ -9,100 +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(); // <-- get latest every time
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) => [...prev, userMessage, { role: "assistant", content: "" }]);
setLoading(true);
const startedAt = Date.now();
let assistantIndex;
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,
});
// aggiunge messaggi user + placeholder assistant
setMessages((prev) => {
assistantIndex = prev.length + 1;
return [
...prev,
{ role: "user", content: input, startedAt },
{ role: "assistant", content: "", isFinal: false, startedAt }
];
});
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
setLoading(true);
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let acc = "";
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,
});
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() || "";
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
for (const part of parts) {
const line = part
.split("\n")
.map((l) => l.trim())
.find((l) => l.startsWith("data:"));
if (!line) continue;
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let acc = "";
const data = line.slice(5).trim();
if (data === "[DONE]") {
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]") {
const endedAt = Date.now();
// Delay prima di settare isFinal: true
setTimeout(() => {
setMessages((prev) => {
const next = [...prev];
next[assistantIndex] = { role: "assistant", content: acc };
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);
return;
}
}, 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];
next[assistantIndex] = { role: "assistant", content: acc };
return next;
});
}
} catch {
/* ignore malformed JSON chunks */
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];
next[assistantIndex] = {
role: "assistant",
content: `Error: ${String(err)}`,
};
return next;
});
}
} finally {
setLoading(false);
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) {
@@ -115,3 +159,4 @@ export function useChatStream() {
return { messages, loading, sendMessage, stopGenerating, setMessages };
}
+4 -11
View File
@@ -9,23 +9,16 @@ export function getUserId() {
}
export function getSessionId() {
// Always reread localStorage so latest value is returned
let id = localStorage.getItem("sessionId");
if (!id) {
id = crypto.randomUUID();
localStorage.setItem("sessionId", id);
}
return id;
// Legge sempre da localStorage
return localStorage.getItem("sessionId") || null;
}
export function setSessionId(id) {
localStorage.setItem("sessionId", id);
}
export function resetSessionId() {
const newId = crypto.randomUUID();
localStorage.setItem("sessionId", newId);
return newId;
export function clearSessionId() {
localStorage.removeItem("sessionId");
}
+8
View File
@@ -0,0 +1,8 @@
## Streamlit chatbot client
Client Chatbot con update realtime che sfrutta le API di openAI per connettersi a LM Studio locale
esempio tratto inizialmente da qui:
https://github.com/ingridstevens/AI-projects/tree/main/streamlit-streaming-langchain
+66
View File
@@ -0,0 +1,66 @@
####
#### Streamlit Streaming using LM Studio as OpenAI Standin
#### run with `streamlit run app.py`
# !pip install pypdf langchain langchain_openai
import streamlit as st
from langchain_core.messages import AIMessage, HumanMessage
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
# app config
st.set_page_config(page_title="Egalware Chatbot", page_icon="🤖")
st.title("Egalware's Chatbot")
def get_response(user_query, chat_history):
template = """
You are a helpful assistant. Answer the following questions considering the history of the conversation:
Chat history: {chat_history}
User question: {user_question}
"""
prompt = ChatPromptTemplate.from_template(template)
# Using LM Studio Local Inference Server
llm = ChatOpenAI(base_url="http://10.74.83.100:1234/v1",api_key="lm-studio", model="qwen/qwen3-4b-2507")
chain = prompt | llm | StrOutputParser()
return chain.stream({
"chat_history": chat_history,
"user_question": user_query,
})
# session state
if "chat_history" not in st.session_state:
st.session_state.chat_history = [
AIMessage(content="Hello, I am EgalWare's current ChatBot. How can I help you? (puoi fare domande in italiano, ma in inglese funziona meglio...)"),
]
# conversation
for message in st.session_state.chat_history:
if isinstance(message, AIMessage):
with st.chat_message("AI"):
st.write(message.content)
elif isinstance(message, HumanMessage):
with st.chat_message("Human"):
st.write(message.content)
# user input
user_query = st.chat_input("Type your message here...")
if user_query is not None and user_query != "":
st.session_state.chat_history.append(HumanMessage(content=user_query))
with st.chat_message("Human"):
st.markdown(user_query)
with st.chat_message("AI"):
response = st.write_stream(get_response(user_query, st.session_state.chat_history))
st.session_state.chat_history.append(AIMessage(content=response))
+186
View File
@@ -0,0 +1,186 @@
import streamlit as st
import streamlit.components.v1 as components
import redis
import json
import uuid
import hashlib
from langchain_core.messages import AIMessage, HumanMessage
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
# ---------------------
# Redis connection
# ---------------------
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
# ---------------------
# Session ID helpers
# ---------------------
def get_or_set_session_id():
"""Get or set session_id from browser localStorage and rerun on first set."""
if "session_id" in st.session_state:
return st.session_state.session_id
components.html(f"""
<script>
const key = 'egalware_session_id';
let sid = window.localStorage.getItem(key);
if (!sid) {{
sid = '{uuid.uuid4()}';
window.localStorage.setItem(key, sid);
}}
const streamlitDoc = window.parent.document;
let hidden = streamlitDoc.querySelector('#session_id_input_hidden');
if (!hidden) {{
hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.id = 'session_id_input_hidden';
streamlitDoc.body.appendChild(hidden);
}}
hidden.value = sid;
hidden.dispatchEvent(new Event('input', {{ bubbles: true }}));
</script>
""", height=0)
sid = st.text_input("session_id_input_hidden", label_visibility="collapsed", key="session_id_input_hidden")
if sid and sid != st.session_state.get("session_id"):
st.session_state.session_id = sid
st.rerun()
return st.session_state.get("session_id")
# ---------------------
# Chat history persistence
# ---------------------
def load_history(session_id):
raw = r.get(f"chatbot:history:{session_id}")
if raw:
messages = json.loads(raw)
return [
AIMessage(content=m["content"]) if m["type"] == "ai"
else HumanMessage(content=m["content"])
for m in messages
]
return [AIMessage(content="Hello, I am EgalWare's ChatBot. How can I help you? (puoi fare domande in italiano, ma in inglese funziona meglio...)")]
def save_history(session_id, history):
messages = [
{"type": "ai" if isinstance(m, AIMessage) else "human", "content": m.content}
for m in history
]
r.set(f"chatbot:history:{session_id}", json.dumps(messages))
r.expire(f"chatbot:history:{session_id}", 60*60*24*7)
def delete_history(session_id):
r.delete(f"chatbot:history:{session_id}")
for key in r.scan_iter(f"chatbot:cache:{session_id}:*"):
r.delete(key)
# ---------------------
# Caching
# ---------------------
def get_cache_key(session_id, prompt):
digest = hashlib.sha256(prompt.encode()).hexdigest()
return f"chatbot:cache:{session_id}:{digest}"
def get_cached_response(session_id, prompt):
return r.get(get_cache_key(session_id, prompt))
def set_cached_response(session_id, prompt, response):
r.setex(get_cache_key(session_id, prompt), 300, response)
# ---------------------
# LLM
# ---------------------
def get_response(session_id, user_query, chat_history):
cached = get_cached_response(session_id, user_query)
if cached:
yield cached
return
prompt = ChatPromptTemplate.from_template(
"You are a helpful assistant. Answer the following considering the history:\n\n"
"Chat history: {chat_history}\n\nUser question: {user_question}"
)
llm = ChatOpenAI(
base_url="http://10.74.83.100:1234/v1",
api_key="lm-studio",
model="qwen/qwen3-4b-2507"
)
chain = prompt | llm | StrOutputParser()
full_resp = ""
for chunk in chain.stream({
"chat_history": chat_history,
"user_question": user_query
}):
full_resp += chunk
yield chunk
set_cached_response(session_id, user_query, full_resp)
# ---------------------
# UI Layout
# ---------------------
st.set_page_config(page_title="Egalware Chatbot", page_icon="🤖")
session_id = get_or_set_session_id()
# Optional: user label separate from session_id
user_label = st.text_input("Optional display name (does not affect session ID):", key="user_label")
# Sticky header CSS
st.markdown("""
<style>
.sticky-header {
position: sticky;
top: 0;
background-color: white;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
z-index: 999;
border-bottom: 1px solid #ddd;
}
</style>
""", unsafe_allow_html=True)
# Header with clear button
st.markdown('<div class="sticky-header">', unsafe_allow_html=True)
col_title, col_btn = st.columns([0.9, 0.1])
with col_title:
st.title("Egalware's Chatbot")
with col_btn:
if st.button("🗑️", help="Clear conversation", type="secondary"):
delete_history(session_id)
st.session_state.chat_history = load_history(session_id)
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
# Initialize history
if not session_id:
if "chat_history" not in st.session_state:
st.session_state.chat_history = [AIMessage(content="Initializing session… please wait")]
else:
if "chat_history" not in st.session_state:
st.session_state.chat_history = load_history(session_id)
# Display messages
for message in st.session_state.chat_history:
role = "AI" if isinstance(message, AIMessage) else "Human"
with st.chat_message(role):
st.write(message.content)
# Input for chat
user_query = st.chat_input("Type your message here…")
if session_id and user_query:
st.session_state.chat_history.append(HumanMessage(content=user_query))
with st.chat_message("Human"):
st.markdown(user_query)
with st.chat_message("AI"):
response_text = st.write_stream(
get_response(session_id, user_query, st.session_state.chat_history)
)
st.session_state.chat_history.append(AIMessage(content=response_text))
save_history(session_id, st.session_state.chat_history)
+66
View File
@@ -0,0 +1,66 @@
####
#### Streamlit Streaming using LM Studio as OpenAI Standin
#### run with `streamlit run app.py`
# !pip install pypdf langchain langchain_openai
import streamlit as st
from langchain_core.messages import AIMessage, HumanMessage
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
# app config
st.set_page_config(page_title="Egalware Chatbot", page_icon="🤖")
st.title("Egalware's Chatbot")
def get_response(user_query, chat_history):
template = """
You are a helpful assistant. Answer the following questions considering the history of the conversation:
Chat history: {chat_history}
User question: {user_question}
"""
prompt = ChatPromptTemplate.from_template(template)
# Using LM Studio Local Inference Server
llm = ChatOpenAI(base_url="http://10.74.83.100:1234/v1",api_key="lm-studio", model="qwen/qwen3-4b-2507")
chain = prompt | llm | StrOutputParser()
return chain.stream({
"chat_history": chat_history,
"user_question": user_query,
})
# session state
if "chat_history" not in st.session_state:
st.session_state.chat_history = [
AIMessage(content="Hello, I am EgalWare's current ChatBot. How can I help you? (puoi fare domande in italiano, ma in inglese funziona meglio...)"),
]
# conversation
for message in st.session_state.chat_history:
if isinstance(message, AIMessage):
with st.chat_message("AI"):
st.write(message.content)
elif isinstance(message, HumanMessage):
with st.chat_message("Human"):
st.write(message.content)
# user input
user_query = st.chat_input("Type your message here...")
if user_query is not None and user_query != "":
st.session_state.chat_history.append(HumanMessage(content=user_query))
with st.chat_message("Human"):
st.markdown(user_query)
with st.chat_message("AI"):
response = st.write_stream(get_response(user_query, st.session_state.chat_history))
st.session_state.chat_history.append(AIMessage(content=response))
+48 -164
View File
@@ -1,182 +1,66 @@
####
#### Streamlit Streaming using LM Studio as OpenAI Standin
#### run with `streamlit run app.py`
# !pip install pypdf langchain langchain_openai
import streamlit as st
import streamlit.components.v1 as components
import redis
import json
import uuid
import hashlib
from langchain_core.messages import AIMessage, HumanMessage
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
# ---------------------
# Redis connection
# ---------------------
r = redis.Redis(host="localhost", port=6379, decode_responses=True)
# ---------------------
# Session ID helpers
# ---------------------
def get_or_set_session_id():
"""Get or set session_id from browser localStorage and trigger rerun when first set."""
if "session_id" in st.session_state:
return st.session_state.session_id
components.html(f"""
<script>
const key = 'egalware_session_id';
let sid = window.localStorage.getItem(key);
if (!sid) {{
sid = '{uuid.uuid4()}';
window.localStorage.setItem(key, sid);
}}
const streamlitDoc = window.parent.document;
let hidden = streamlitDoc.querySelector('#session_id_input');
if (!hidden) {{
hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.id = 'session_id_input';
streamlitDoc.body.appendChild(hidden);
}}
hidden.value = sid;
hidden.dispatchEvent(new Event('input', {{ bubbles: true }}));
</script>
""", height=0)
sid = st.text_input("session_id_input", label_visibility="collapsed")
if sid and sid != st.session_state.get("session_id"):
st.session_state.session_id = sid
st.rerun() # immediately rerun with the new ID
return st.session_state.get("session_id")
# ---------------------
# Chat history persistence
# ---------------------
def load_history(session_id):
raw = r.get(f"chatbot:history:{session_id}")
if raw:
messages = json.loads(raw)
return [
AIMessage(content=m["content"]) if m["type"] == "ai"
else HumanMessage(content=m["content"])
for m in messages
]
return [AIMessage(content="Hello, I am EgalWare's ChatBot. How can I help you? (puoi fare domande in italiano, ma in inglese funziona meglio...)")]
def save_history(session_id, history):
messages = [
{"type": "ai" if isinstance(m, AIMessage) else "human", "content": m.content}
for m in history
]
r.set(f"chatbot:history:{session_id}", json.dumps(messages))
r.expire(f"chatbot:history:{session_id}", 60*60*24*7)
def delete_history(session_id):
r.delete(f"chatbot:history:{session_id}")
for key in r.scan_iter(f"chatbot:cache:{session_id}:*"):
r.delete(key)
# ---------------------
# Caching
# ---------------------
def get_cache_key(session_id, prompt):
digest = hashlib.sha256(prompt.encode()).hexdigest()
return f"chatbot:cache:{session_id}:{digest}"
def get_cached_response(session_id, prompt):
return r.get(get_cache_key(session_id, prompt))
def set_cached_response(session_id, prompt, response):
r.setex(get_cache_key(session_id, prompt), 300, response)
# ---------------------
# LLM
# ---------------------
def get_response(session_id, user_query, chat_history):
cached = get_cached_response(session_id, user_query)
if cached:
yield cached
return
prompt = ChatPromptTemplate.from_template(
"You are a helpful assistant. Answer the following considering the history:\n\n"
"Chat history: {chat_history}\n\nUser question: {user_question}"
)
llm = ChatOpenAI(
base_url="http://10.74.83.100:1234/v1",
api_key="lm-studio",
model="qwen/qwen3-4b-2507"
)
chain = prompt | llm | StrOutputParser()
full_resp = ""
for chunk in chain.stream({
"chat_history": chat_history,
"user_question": user_query
}):
full_resp += chunk
yield chunk
set_cached_response(session_id, user_query, full_resp)
# ---------------------
# UI
# ---------------------
# app config
st.set_page_config(page_title="Egalware Chatbot", page_icon="🤖")
session_id = get_or_set_session_id()
st.title("Egalware's Live Chatbot")
# Sticky header CSS
st.markdown("""
<style>
.sticky-header {
position: sticky;
top: 0;
background-color: white;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
z-index: 999;
border-bottom: 1px solid #ddd;
}
</style>
""", unsafe_allow_html=True)
def get_response(user_query, chat_history):
# Header
st.markdown('<div class="sticky-header">', unsafe_allow_html=True)
col_title, col_btn = st.columns([0.9, 0.1])
with col_title:
st.title("Egalware's Chatbot")
with col_btn:
if st.button("🗑️", help="Clear conversation", type="secondary"):
delete_history(session_id)
st.session_state.chat_history = load_history(session_id)
st.rerun()
st.markdown('</div>', unsafe_allow_html=True)
template = """
You are a helpful assistant. Answer the following questions considering the history of the conversation:
# If still no ID, display placeholder history so UI doesn't look empty
if not session_id:
if "chat_history" not in st.session_state:
st.session_state.chat_history = [AIMessage(content="Initializing session… please wait")]
else:
if "chat_history" not in st.session_state:
st.session_state.chat_history = load_history(session_id)
Chat history: {chat_history}
# Conversation display
User question: {user_question}
"""
prompt = ChatPromptTemplate.from_template(template)
# Using LM Studio Local Inference Server
llm = ChatOpenAI(base_url="http://10.74.83.100:1234/v1",api_key="lm-studio", model="qwen/qwen3-4b-2507")
chain = prompt | llm | StrOutputParser()
return chain.stream({
"chat_history": chat_history,
"user_question": user_query,
})
# session state
if "chat_history" not in st.session_state:
st.session_state.chat_history = [
AIMessage(content="Hello, I am EgalWare's Live & Stateless ChatBot. How can I help you? (puoi fare domande in italiano, ma in inglese funziona meglio...)"),
]
# conversation
for message in st.session_state.chat_history:
role = "AI" if isinstance(message, AIMessage) else "Human"
with st.chat_message(role):
st.write(message.content)
if isinstance(message, AIMessage):
with st.chat_message("AI"):
st.write(message.content)
elif isinstance(message, HumanMessage):
with st.chat_message("Human"):
st.write(message.content)
# Input
user_query = st.chat_input("Type your message here")
if session_id and user_query:
# user input
user_query = st.chat_input("Type your message here...")
if user_query is not None and user_query != "":
st.session_state.chat_history.append(HumanMessage(content=user_query))
with st.chat_message("Human"):
st.markdown(user_query)
with st.chat_message("AI"):
response_text = st.write_stream(
get_response(session_id, user_query, st.session_state.chat_history)
)
st.session_state.chat_history.append(AIMessage(content=response_text))
save_history(session_id, st.session_state.chat_history)
response = st.write_stream(get_response(user_query, st.session_state.chat_history))
st.session_state.chat_history.append(AIMessage(content=response))