diff --git a/README.md b/README.md index b5b4b3f..46ac084 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/README.pdf b/README.pdf index 2f06f11..140d81a 100644 Binary files a/README.pdf and b/README.pdf differ diff --git a/backend/api/v1/chat.py b/backend/api/v1/chat.py new file mode 100644 index 0000000..e5a37c7 --- /dev/null +++ b/backend/api/v1/chat.py @@ -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"} + + diff --git a/backend/api/v1/sessions.py b/backend/api/v1/sessions.py new file mode 100644 index 0000000..c9a1820 --- /dev/null +++ b/backend/api/v1/sessions.py @@ -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"} + + diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..af7d3f5 --- /dev/null +++ b/backend/config.py @@ -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() + diff --git a/backend/main.py b/backend/main.py index 1e19da6..4d03048 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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") diff --git a/backend/models/chat.py b/backend/models/chat.py new file mode 100644 index 0000000..4125add --- /dev/null +++ b/backend/models/chat.py @@ -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 diff --git a/backend/models/session.py b/backend/models/session.py new file mode 100644 index 0000000..b30fbcb --- /dev/null +++ b/backend/models/session.py @@ -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 + diff --git a/backend/services/lm_studio_client.py b/backend/services/lm_studio_client.py new file mode 100644 index 0000000..c89c133 --- /dev/null +++ b/backend/services/lm_studio_client.py @@ -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() + diff --git a/backend/services/redis_service.py b/backend/services/redis_service.py new file mode 100644 index 0000000..973ccb5 --- /dev/null +++ b/backend/services/redis_service.py @@ -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) + diff --git a/backend/utils/logging.py b/backend/utils/logging.py new file mode 100644 index 0000000..01aab23 --- /dev/null +++ b/backend/utils/logging.py @@ -0,0 +1,9 @@ +import logging + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s" +) + +logger = logging.getLogger("app") + diff --git a/frontend/src/App.css b/frontend/src/App.css index fcb31c2..9df215b 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; +} + + diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d26d7de..6f13d8b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 /> ); } diff --git a/frontend/src/App.jsx.old b/frontend/src/App.jsx.old deleted file mode 100644 index 9541539..0000000 --- a/frontend/src/App.jsx.old +++ /dev/null @@ -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 ( - - ); -} - - diff --git a/frontend/src/AssistantMessage.jsx b/frontend/src/AssistantMessage.jsx index f1792b8..aa32669 100644 --- a/frontend/src/AssistantMessage.jsx +++ b/frontend/src/AssistantMessage.jsx @@ -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(/([\s\S]*?)<\/think>/i); + const thinkContent = thinkMatch ? thinkMatch[1].trim() : null; + + const isComplete = isFinal || Boolean(timestamp || endedAt); + + const visibleContent = isComplete + ? content?.replace(/[\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 (
+ {showThink && ( +
+ 🤔 {thinkContent} +
+ )} + ( ), - th: (props) =>
+ th: (props) => , + code: CodeWithCopy }} > - {content} + {visibleContent} + {ts != null && ( +
+ {formatDateTime(ts)} +
+ )} ); } +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 ( + + {children} + + ); + } + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(codeText); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch (err) { + console.error("Copy failed", err); + } + }; + + return ( +
+
+        {codeText}
+      
+ + {copied && ( + + Copied! + + )} +
+ ); +} + +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" + }); +} + diff --git a/frontend/src/ChatHeader.jsx b/frontend/src/ChatHeader.jsx index 2166512..c3fe0b5 100644 --- a/frontend/src/ChatHeader.jsx +++ b/frontend/src/ChatHeader.jsx @@ -7,30 +7,39 @@ export default function ChatHeader({ onReloadHistory, onFreshStart, onCreateSession, - onManageSession + onManageSession, + sessionId, + sessionName }) { + const hasSession = Boolean(sessionId); + return (
- {/* Left column: control buttons */} -
- + {/* Left column */} +
+ {hasSession && ( + <> + - + + + )} + {/* Questi due pulsanti sempre visibili */} + + {/* Nome sessione solo se esiste */} + {hasSession && sessionName && ( + + {sessionName} + + )}
- {/* Center column: title */} -
+ {/* Center column: titolo solo se c'è sessione */} +

🤖 EgalWare's LLM ChatBot

- {/* Right column: theme toggle */} + {/* Right column: toggle tema solo se c'è sessione */}
- +
); } + diff --git a/frontend/src/ChatInput.jsx b/frontend/src/ChatInput.jsx index 060fcf4..8f2cf44 100644 --- a/frontend/src/ChatInput.jsx +++ b/frontend/src/ChatInput.jsx @@ -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 (