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..367e8db --- /dev/null +++ b/backend/api/v1/sessions.py @@ -0,0 +1,63 @@ +# api/v1/sessions.py + +from typing import List +from fastapi import Body, Query, Path +from services import redis_service +from fastapi import APIRouter +from models.session import SessionMeta + +router = APIRouter() + +@router.post("/sessions", response_model=dict) +async def create_session_endpoint( + user_id: str = Query(..., description="User ID"), + first_message: str = Body("", embed=True) +): + """ + Create a new chat session for a user. + Session name is taken from `first_message` or defaults to "New Chat". + """ + meta = redis_service.create_session(user_id, first_message) + return meta + +@router.get("/sessions", response_model=List[dict]) +async def list_sessions_endpoint( + user_id: str = Query(..., description="User ID") +): + """ + Get a list of all saved sessions for a user (most recent first). + """ + return redis_service.get_sessions(user_id) + +@router.get("/sessions/{session_id}", response_model=dict) +async def get_session_meta_endpoint( + user_id: str = Query(..., description="User ID"), + session_id: str = Path(..., description="Session ID") +): + """ + Get metadata for a single session. + """ + return redis_service.get_session_meta(user_id, session_id) or {} + +@router.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) +): + """ + Rename a session or update metadata fields. + """ + return redis_service.update_session_meta(user_id, session_id, session_name=session_name) or {} + +@router.delete("/sessions/{session_id}", response_model=dict) +async def delete_session_endpoint( + user_id: str = Query(..., description="User ID"), + session_id: str = Path(..., description="Session ID") +): + """ + Delete a session and its chat history. + """ + redis_service.delete_session(user_id, session_id) + return {"status": "deleted"} + diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..e71e751 --- /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..149af0d --- /dev/null +++ b/backend/services/redis_service.py @@ -0,0 +1,152 @@ +# services/redis_service.py +import redis +import json +import uuid +from config import settings +from datetime import datetime +from typing import List, Optional + +r = redis.Redis( + host=settings.REDIS_HOST, + port=settings.REDIS_PORT, + db=settings.REDIS_DB, + decode_responses=True +) + +# ------------------------- +# Chat message operations +# ------------------------- + +def save_chat(user_id: str, session_id: str, message: dict): + """ + Save a message for a user/session, adding a UTC ISO timestamp. + Also updates session metadata (message_count, history_size_bytes). + """ + key = f"chatHistory:{user_id}:{session_id}" + enriched = { + **message, + "timestamp": datetime.utcnow().isoformat() + "Z" + } + r.rpush(key, json.dumps(enriched)) + + # Update metadata after adding + _update_session_stats(user_id, session_id) + + +def get_chat(user_id: str, session_id: str, limit: Optional[int] = None): + """ + Retrieve messages for a user/session, sorted by timestamp. + Optionally limit to the last N messages. + """ + 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): + """ + Delete all messages for a user/session. + Also resets metadata stats (message_count, history_size_bytes). + """ + key = f"chatHistory:{user_id}:{session_id}" + r.delete(key) + _update_session_stats(user_id, session_id, reset=True) + + +# ------------------------- +# Session metadata ops +# ------------------------- + +def create_session(user_id: str, first_message: str) -> dict: + """ + Create a new session with initial metadata. + """ + 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 + } + + # Save metadata + r.set(f"chatSession:{user_id}:{session_id}", json.dumps(meta)) + + # Add to per-user index (sorted set with score = timestamp) + r.zadd(f"chatSessionsIndex:{user_id}", {session_id: datetime.utcnow().timestamp()}) + + return meta + + +def get_sessions(user_id: str) -> List[dict]: + """ + Return all sessions for a user, newest first. + """ + session_ids = r.zrevrangebyscore(f"chatSessionsIndex:{user_id}", "+inf", "-inf") + sessions = [] + for sid in session_ids: + raw = r.get(f"chatSession:{user_id}:{sid}") + if raw: + sessions.append(json.loads(raw)) + return sessions + + +def get_session_meta(user_id: str, session_id: str) -> Optional[dict]: + raw = r.get(f"chatSession:{user_id}:{session_id}") + return json.loads(raw) if raw else None + + +def update_session_meta(user_id: str, session_id: str, **updates) -> Optional[dict]: + """ + Update metadata fields (e.g., session_name) for a session. + """ + meta = get_session_meta(user_id, session_id) + if not meta: + return None + meta.update(updates) + r.set(f"chatSession:{user_id}:{session_id}", json.dumps(meta)) + return meta + + +def delete_session(user_id: str, session_id: str): + """ + Delete metadata, chat history, and remove from index. + """ + r.delete(f"chatSession:{user_id}:{session_id}") + r.delete(f"chatHistory:{user_id}:{session_id}") + r.zrem(f"chatSessionsIndex:{user_id}", session_id) + + +# ------------------------- +# Internal helpers +# ------------------------- + +def _update_session_stats(user_id: str, session_id: str, reset: bool = False): + """ + Refresh message_count and history_size_bytes in metadata. + """ + meta = get_session_meta(user_id, session_id) + if not meta: + return + + if reset: + meta["message_count"] = 0 + meta["history_size_bytes"] = 0 + else: + # Count messages and total size + 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)) + + 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/streamlit-v1/README.md b/streamlit-v1/README.md new file mode 100644 index 0000000..20591bd --- /dev/null +++ b/streamlit-v1/README.md @@ -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 + diff --git a/streamlit-v1/app.py b/streamlit-v1/app.py new file mode 100644 index 0000000..8f78b58 --- /dev/null +++ b/streamlit-v1/app.py @@ -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)) diff --git a/streamlit-v1/app.py.2 b/streamlit-v1/app.py.2 new file mode 100644 index 0000000..771a939 --- /dev/null +++ b/streamlit-v1/app.py.2 @@ -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""" + + """, 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(""" + +""", unsafe_allow_html=True) + +# Header with clear button +st.markdown('