Merge branch 'release/FixSessionMan_02'
This commit is contained in:
@@ -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
Binary file not shown.
@@ -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"}
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
//App.jsx
|
||||
import React, { useState, useEffect } from "react";
|
||||
import "./App.css";
|
||||
import { themes } from "./themes";
|
||||
import ChatLayout from "./ChatLayout";
|
||||
import { useChatStream } from "./useChatStream";
|
||||
import { getSessionId, getUserId, resetSessionId } from "./useSessionId";
|
||||
|
||||
export default function App() {
|
||||
const { messages, loading, sendMessage, stopGenerating, setMessages } = useChatStream();
|
||||
const [themeName, setThemeName] = useState("light");
|
||||
const theme = themes[themeName];
|
||||
const sessionId = getSessionId();
|
||||
const userId = getUserId();
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem("preferredTheme");
|
||||
if (saved && themes[saved]) setThemeName(saved);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("preferredTheme", themeName);
|
||||
}, [themeName]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setThemeName((t) => (t === "light" ? "dark" : "light"));
|
||||
};
|
||||
|
||||
const reloadHistory = async () => {
|
||||
const res = await fetch(`/v1/history?user_id=${userId}&session_id=${sessionId}`);
|
||||
const history = await res.json();
|
||||
setMessages(history); // from useChatStream
|
||||
};
|
||||
|
||||
const freshStart = async () => {
|
||||
await fetch(`/v1/history?user_id=${userId}&session_id=${sessionId}`, { method: "DELETE" });
|
||||
setMessages([]);
|
||||
resetSessionId();
|
||||
// or start a brand new sessionId:
|
||||
//localStorage.removeItem("sessionId");
|
||||
//window.location.reload();
|
||||
};
|
||||
|
||||
const createSession = async () => {
|
||||
const firstMessage = prompt("Enter a name or first message for the new session:") || "";
|
||||
const res = await fetch(`/v1/sessions?user_id=${userId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ first_message: firstMessage }),
|
||||
});
|
||||
const meta = await res.json();
|
||||
setSessionId(meta.session_id);
|
||||
setMessages([]); // clear chat window
|
||||
};
|
||||
|
||||
const editSession = async () => {
|
||||
const newName = prompt("Enter a new name for this session:");
|
||||
if (!newName) return;
|
||||
await fetch(`/v1/sessions/${sessionId}?user_id=${userId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_name: newName }),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ChatLayout
|
||||
theme={theme}
|
||||
messages={messages}
|
||||
loading={loading}
|
||||
onSend={sendMessage}
|
||||
onStop={stopGenerating}
|
||||
onToggleTheme={toggleTheme}
|
||||
onReloadHistory={reloadHistory}
|
||||
onFreshStart={freshStart}
|
||||
onCreateSession={createSession}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,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
@@ -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'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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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({
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -9,23 +9,16 @@ export function getUserId() {
|
||||
}
|
||||
|
||||
export function getSessionId() {
|
||||
// Always re‑read 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");
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user