diff --git a/backend/api/v1/sessions.py b/backend/api/v1/sessions.py index 339c92d..c9a1820 100644 --- a/backend/api/v1/sessions.py +++ b/backend/api/v1/sessions.py @@ -1,49 +1,73 @@ # api/v1/sessions.py from typing import List -from fastapi import Body, Query, Path +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() -@router.post("/sessions", response_model=dict) -async def create_session_endpoint( - user_id: str = Query(..., description="User ID"), - first_message: str = Body("", embed=True) -): - """ - Create a new chat session for a user. - Session name is taken from `first_message` or defaults to "New Chat". - """ - meta = redis_service.create_session(user_id, first_message) - return meta +# ------------------------- +# 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(..., description="User ID") -): - """ - Get a list of all saved sessions for a user (most recent first). - """ - #print(f"[DEBUG] user_id={user_id!r}") - sessions = redis_service.get_sessions(user_id) - print(f"[DEBUG] sessions={sessions}") - return sessions - +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") ): - """ - Get metadata for a single session. - """ 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( @@ -51,53 +75,24 @@ async def update_session_endpoint( session_id: str = Path(..., description="Session ID"), session_name: str = Body(..., embed=True) ): - """ - Rename a session or update metadata fields. - """ - return redis_service.update_session_meta(user_id, session_id, session_name=session_name) or {} - + 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") ): - """ - Delete a session and its chat history. - """ 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"} -# ------------------------- -# Gestione TTL sessioni -# ------------------------- - -@router.post("/sessions/{session_id}/extend_ttl", response_model=dict) -async def extend_ttl_endpoint( - user_id: str = Query(..., description="User ID"), - session_id: str = Path(..., description="Session ID"), - extra_seconds: int = Body(..., embed=True, description="Seconds to extend the TTL") -): - """ - Extend the TTL (time-to-live) for a given session and its related data. - """ - redis_service.extend_session_ttl(user_id, session_id, extra_seconds) - return {"status": "ttl_extended", "extra_seconds": extra_seconds} - - -@router.post("/sessions/{session_id}/refresh_ttl", response_model=dict) -async def refresh_ttl_endpoint( - user_id: str = Query(..., description="User ID"), - session_id: str = Path(..., description="Session ID") -): - """ - Reset the TTL for a given session and its related data back to the default value. - """ - redis_service.refresh_session_ttl(user_id, session_id) - return { - "status": "ttl_refreshed", - "default_ttl_seconds": redis_service.DEFAULT_TTL_SECONDS - } - - diff --git a/backend/config.py b/backend/config.py index e71e751..af7d3f5 100644 --- a/backend/config.py +++ b/backend/config.py @@ -6,8 +6,8 @@ class Settings(BaseSettings): 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") + #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() diff --git a/backend/services/redis_service.py b/backend/services/redis_service.py index bbc9174..973ccb5 100644 --- a/backend/services/redis_service.py +++ b/backend/services/redis_service.py @@ -10,6 +10,7 @@ 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, @@ -17,24 +18,37 @@ r = redis.Redis( 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): - """Save a message for a user/session, adding a UTC ISO timestamp. - Also updates session metadata and TTL. - """ 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) # rinnova TTL history + r.expire(key, DEFAULT_TTL_SECONDS) _update_session_stats(user_id, session_id) - refresh_session_ttl(user_id, session_id) # rinnova TTL meta e indice - + refresh_session_ttl(user_id, session_id) def get_chat(user_id: str, session_id: str, limit: Optional[int] = None): - """Retrieve messages for a user/session, sorted by timestamp.""" key = f"chatHistory:{user_id}:{session_id}" data = r.lrange(key, 0, -1) messages = [json.loads(item) for item in data] @@ -43,21 +57,17 @@ def get_chat(user_id: str, session_id: str, limit: Optional[int] = None): messages = messages[-limit:] return messages - def clear_chat(user_id: str, session_id: str): - """Delete all messages for a user/session and reset stats.""" 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: - """Create a new session with initial metadata and TTL.""" session_id = str(uuid.uuid4()) created_at = datetime.utcnow().isoformat() + "Z" session_name = (first_message.strip()[:50] or "New Chat") @@ -72,64 +82,56 @@ def create_session(user_id: str, first_message: str) -> dict: index_key = f"chatSessionsIndex:{user_id}" r.set(meta_key, json.dumps(meta), ex=DEFAULT_TTL_SECONDS) - - # Score come timestamp float, ma non sarà usato per filtrare 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]: - """Return all sessions for a user, newest first, ignoring score filtering.""" key = f"chatSessionsIndex:{user_id}" - - # Recupera tutti i membri in ordine di score decrescente session_ids = r.zrevrange(key, 0, -1) - - print(f"[DEBUG] key={key}") - print(f"[DEBUG] session_ids={session_ids}") - sessions = [] for sid in session_ids: session_key = f"chatSession:{user_id}:{sid}" raw = r.get(session_key) - print(f"[DEBUG] GET {session_key} -> {raw}") 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]: - """Update metadata fields (e.g., session_name) for a session.""" 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): - """Delete metadata, chat history, and remove from index.""" 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): - """Extend TTL for session metadata, history, and index.""" keys = [ f"chatSession:{user_id}:{session_id}", f"chatHistory:{user_id}:{session_id}", @@ -143,9 +145,7 @@ def extend_session_ttl(user_id: str, session_id: str, extra_seconds: int): else: r.expire(key, extra_seconds) - def refresh_session_ttl(user_id: str, session_id: str): - """Reset TTL for session metadata, history, and index to default.""" keys = [ f"chatSession:{user_id}:{session_id}", f"chatHistory:{user_id}:{session_id}", @@ -155,13 +155,11 @@ def refresh_session_ttl(user_id: str, session_id: str): 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): - """Refresh message_count and history_size_bytes in metadata.""" meta = get_session_meta(user_id, session_id) if not meta: return @@ -175,4 +173,3 @@ def _update_session_stats(user_id: str, session_id: str, reset: bool = False): 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) - diff --git a/frontend/src/SessionTable.jsx b/frontend/src/SessionTable.jsx index 4ce508d..6941c2c 100644 --- a/frontend/src/SessionTable.jsx +++ b/frontend/src/SessionTable.jsx @@ -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 }) {new Date(s.created_at).toLocaleString()} {s.message_count} - +