Compare commits

...

32 Commits

Author SHA1 Message Date
Matteo Rosati
865f4ee1b6 remove sensitive logs 2026-01-29 15:07:10 +01:00
Matteo Rosati
3a7f329499 MEDIUM thinking level is not supported by this model 2026-01-29 15:05:00 +01:00
Matteo Rosati
f14c56ddd3 explicit project_id 2026-01-29 15:02:18 +01:00
Matteo Rosati
da726dc777 add missing scopes 2026-01-29 14:40:43 +01:00
Matteo Rosati
ead140a2f4 add logging 2026-01-29 14:20:01 +01:00
Matteo Rosati
20919298c8 google credentials 2026-01-29 12:54:47 +01:00
Matteo Rosati
b18c56e2f2 remove copy 2026-01-29 10:49:45 +01:00
Matteo Rosati
a8f2c070e9 dynamic credentials from env 2026-01-29 10:38:15 +01:00
Matteo Rosati
111e47cb77 medium thinking level 2026-01-29 10:23:23 +01:00
Matteo Rosati
7c2318c655 handle websocket disconnect / 2 2026-01-22 10:25:09 +01:00
Matteo Rosati
867b029e1c handle websocket disconnect 2026-01-22 10:22:36 +01:00
Matteo Rosati
48d8ce9276 fix async with threads 2026-01-22 10:18:45 +01:00
Matteo Rosati
1ed452f1d9 fix the async mess 2026-01-22 09:44:17 +01:00
Matteo Rosati
31ee3fed8c reset parallelism 2026-01-21 11:52:37 +01:00
Matteo Rosati
f86cbed467 implement parallelism (maybe) 2026-01-21 11:11:30 +01:00
Matteo Rosati
08d37cdd94 make websocket concurrent 2026-01-21 10:45:14 +01:00
Matteo Rosati
a8461b05c9 fix message padding 2026-01-21 10:30:56 +01:00
Matteo Rosati
3c050afe61 fix margins 2026-01-21 10:26:11 +01:00
Matteo Rosati
790be07166 rename main -> lib (dockerfile) 2026-01-21 10:18:27 +01:00
Matteo Rosati
993c5cf8aa rename main -> lib 2026-01-21 10:17:29 +01:00
Matteo Rosati
5906bfb0ab update styles and logo 2026-01-21 10:09:49 +01:00
Matteo Rosati
543775d61e update style 2026-01-21 09:58:19 +01:00
Matteo Rosati
9f5d8a0a88 fix docstrings, update env dist 2026-01-21 09:42:13 +01:00
Matteo Rosati
b91c09504d restyle 2026-01-20 12:33:31 +01:00
Matteo Rosati
c97739d096 add better styling 2026-01-20 12:13:08 +01:00
Matteo Rosati
f5bafd1117 fix host 2026-01-20 12:12:59 +01:00
Matteo Rosati
c9b3f7d33c add basic auth 2026-01-20 12:07:16 +01:00
Matteo Rosati
5835e87fef remove log 2026-01-20 11:36:17 +01:00
Matteo Rosati
a8d25aa7fe fix dockerfile 2026-01-20 11:32:43 +01:00
Matteo Rosati
3fabcaac07 fix ws url 2026-01-20 11:09:36 +01:00
Matteo Rosati
3dd38dc6fa copy creds in container 2026-01-20 11:03:29 +01:00
Matteo Rosati
7df1b9f718 dockerize app 2026-01-20 10:46:14 +01:00
19 changed files with 1242 additions and 147 deletions

53
.dockerignore Normal file
View File

@@ -0,0 +1,53 @@
# Git
.git
.gitignore
# Python
__pycache__
*.pyc
*.pyo
*.pyd
.Python
*.so
*.egg
*.egg-info
dist
build
.pytest_cache
.coverage
htmlcov
.mypy_cache
.dmypy.json
dmypy.json
# Virtual environments
venv
env
ENV
.venv
# IDE
.vscode
.idea
*.swp
*.swo
*~
# Environment files
.env
.env.local
.env.*.local
# Documentation
README.md
*.md
# Lock files (not needed for pip install)
uv.lock
# Vercel
vercel.json
# Distribution files
*.tar.gz
*.zip

View File

@@ -1 +1,13 @@
# Google Cloud Authentication - Choose ONE of the following methods:
# Option 1: Local development - Path to JSON credentials file
GOOGLE_APPLICATION_CREDENTIALS=credentials.json GOOGLE_APPLICATION_CREDENTIALS=credentials.json
# Option 2: Production - JSON content as string (useful for serverless platforms like Vercel)
# Paste the entire JSON content of your service account key here
# GOOGLE_CREDENTIALS_JSON={"type":"service_account","project_id":"..."}
PORT=8000
HOST=0.0.0.0
BASIC_AUTH_USERNAME=admin
BASIC_AUTH_PASSWORD=admin

215
DOCKER.md Normal file
View File

@@ -0,0 +1,215 @@
# Docker Setup Guide
This document explains how to build and run the Akern-Genai application using Docker.
## Overview
The Docker configuration includes:
- **Multi-stage build** for smaller image size
- **Entrypoint script** with proper signal handling and exit status management
- **Configurable port** via environment variables
- **Health checks** for monitoring
- **Non-root user** for security
- **Optimized build** with `.dockerignore`
## Files
- [`Dockerfile`](Dockerfile) - Multi-stage Docker build configuration
- [`entrypoint.sh`](entrypoint.sh) - Entrypoint script with signal handling
- [`docker-compose.yml`](docker-compose.yml) - Docker Compose configuration
- [`.dockerignore`](.dockerignore) - Files excluded from Docker build context
## Environment Variables
The following environment variables can be configured:
| Variable | Default | Description |
| ----------- | --------- | ----------------------------------------------------- |
| `PORT` | `8000` | Port on which the application listens |
| `HOST` | `0.0.0.0` | Host address to bind to |
| `WORKERS` | `1` | Number of uvicorn worker processes |
| `LOG_LEVEL` | `info` | Logging level (debug, info, warning, error, critical) |
## Building the Image
### Using Docker directly:
```bash
docker build -t akern-genai:latest .
```
### Using Docker Compose:
```bash
docker-compose build
```
## Running the Container
### Using Docker directly:
```bash
# Run with default configuration (port 8000)
docker run -p 8000:8000 akern-genai:latest
# Run with custom port
docker run -p 3000:3000 -e PORT=3000 akern-genai:latest
# Run with multiple workers
docker run -p 8000:8000 -e WORKERS=4 akern-genai:latest
# Run with custom log level
docker run -p 8000:8000 -e LOG_LEVEL=debug akern-genai:latest
```
### Using Docker Compose:
```bash
# Run with default configuration
docker-compose up
# Run with custom port
PORT=3000 docker-compose up
# Run in detached mode
docker-compose up -d
# Stop the container
docker-compose down
```
### Using environment file:
Create a `.env` file in the project root:
```env
PORT=8000
HOST=0.0.0.0
WORKERS=1
LOG_LEVEL=info
```
Then run:
```bash
docker-compose up
```
## Signal Handling
The entrypoint script properly handles termination signals:
- **SIGTERM** - Graceful shutdown (sent by `docker stop`)
- **SIGINT** - Interrupt signal (Ctrl+C)
The script will:
1. Forward the signal to uvicorn
2. Wait up to 30 seconds for graceful shutdown
3. Force kill if shutdown doesn't complete in time
4. Exit with the same status code as uvicorn
## Health Check
The container includes a health check that monitors the application:
- **Interval**: 30 seconds
- **Timeout**: 10 seconds
- **Start period**: 5 seconds
- **Retries**: 3
Check container health:
```bash
docker ps
docker inspect <container_id> | grep -A 10 Health
```
## Multi-Stage Build
The Dockerfile uses a two-stage build:
### Stage 1: Builder
- Installs build dependencies (gcc, musl-dev, etc.)
- Creates a virtual environment
- Installs Python dependencies
### Stage 2: Runtime
- Uses minimal Alpine Linux
- Only includes runtime dependencies
- Copies the virtual environment from builder
- Runs as non-root user
This results in a significantly smaller final image.
## Security Features
- **Non-root user**: Application runs as `appuser` (UID 1000)
- **Minimal base image**: Alpine Linux with only necessary packages
- **No build tools in runtime**: Compiler and build tools are not included in final image
## Troubleshooting
### Container exits immediately
Check logs:
```bash
docker logs <container_id>
```
### Port already in use
Change the port:
```bash
docker run -p 3000:3000 -e PORT=3000 akern-genai:latest
```
### Health check failing
Verify the application is running:
```bash
docker exec -it <container_id> wget -O- http://localhost:8000/
```
### View real-time logs
```bash
docker logs -f <container_id>
```
## Image Size Comparison
- **Before**: ~200-300 MB (single stage with build tools)
- **After**: ~100-150 MB (multi-stage, minimal runtime)
## Production Considerations
For production deployment:
1. **Use specific image tags** instead of `latest`
2. **Set appropriate worker count** based on CPU cores
3. **Configure resource limits** in docker-compose.yml or Kubernetes
4. **Use a reverse proxy** (nginx, traefik) for SSL termination
5. **Set up logging aggregation** (ELK, Loki, etc.)
6. **Configure monitoring** (Prometheus, Grafana)
Example docker-compose.yml with resource limits:
```yaml
services:
app:
deploy:
resources:
limits:
cpus: "2"
memory: 1G
reservations:
cpus: "0.5"
memory: 512M
```

85
Dockerfile Normal file
View File

@@ -0,0 +1,85 @@
# ============================================
# Stage 1: Builder
# ============================================
FROM python:3.13-alpine AS builder
# Install build dependencies
RUN apk add --no-cache \
gcc \
musl-dev \
libffi-dev \
openssl-dev \
cargo
# Create virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Set working directory
WORKDIR /build
# Copy requirements first for better caching
COPY requirements.txt .
# Install dependencies
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# ============================================
# Stage 2: Runtime
# ============================================
FROM python:3.13-alpine AS runtime
# Install runtime dependencies only
RUN apk add --no-cache \
libstdc++ \
ca-certificates
# Create non-root user for security
RUN addgroup -g 1000 appuser && \
adduser -D -u 1000 -G appuser appuser
# Copy virtual environment from builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Create application directory
RUN mkdir /app && \
chown -R appuser:appuser /app
WORKDIR /app
# Copy application files
COPY --chown=appuser:appuser app.py .
COPY --chown=appuser:appuser llm_config.py .
COPY --chown=appuser:appuser lib.py .
RUN echo $CREDENTIALS > credentials.json
COPY --chown=appuser:appuser static ./static
# Copy and setup entrypoint script
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Switch to non-root user
USER appuser
# Expose default port (can be overridden via PORT env var)
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:${PORT:-8000}/ || exit 1
# Set environment variables
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PORT=8000 \
HOST=0.0.0.0 \
WORKERS=1 \
LOG_LEVEL=info
# Use entrypoint script
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

81
app.py
View File

@@ -1,10 +1,29 @@
"""FastAPI application for Akern-Genai project.
This module provides the web application with WebSocket support
for streaming responses from the Gemini model.
"""
import os import os
import logging import logging
from fastapi import FastAPI, Request, WebSocket from typing import Annotated
from fastapi import (
FastAPI,
Request,
WebSocket,
WebSocketDisconnect,
Depends,
HTTPException,
status,
)
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from main import generate
from lib import generate
# Configure logging format and level
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
@@ -13,11 +32,42 @@ logging.basicConfig(
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Static files configuration
STATIC_DIR: str = "static"
TEMPLATES_DIR: str = "templates"
STATIC_DIR = "static" # Security configuration
TEMPLATES_DIR = "templates" security = HTTPBasic()
def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)) -> str:
"""Verify HTTP Basic credentials against environment variables.
Args:
credentials: HTTP Basic authentication credentials.
Returns:
str: The authenticated username.
Raises:
HTTPException: If credentials are invalid.
"""
correct_username = os.getenv("BASIC_AUTH_USERNAME")
correct_password = os.getenv("BASIC_AUTH_PASSWORD")
if not (
credentials.username == correct_username
and credentials.password == correct_password
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
# Initialize FastAPI application
app = FastAPI() app = FastAPI()
app.mount(f"/{STATIC_DIR}", StaticFiles(directory=STATIC_DIR), name="static") app.mount(f"/{STATIC_DIR}", StaticFiles(directory=STATIC_DIR), name="static")
@@ -25,19 +75,34 @@ templates = Jinja2Templates(directory=os.path.join(STATIC_DIR, TEMPLATES_DIR))
@app.get("/") @app.get("/")
async def home(request: Request): def home(request: Request, username: Annotated[str, Depends(verify_credentials)]):
"""Render the main index page.
Args:
request: The incoming request object.
username: The authenticated username from HTTP Basic auth.
Returns:
TemplateResponse: The rendered HTML template.
"""
return templates.TemplateResponse("index.html", {"request": request}) return templates.TemplateResponse("index.html", {"request": request})
@app.websocket("/ws") @app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket): async def websocket_endpoint(websocket: WebSocket):
"""Handle WebSocket connections for streaming responses.
Args:
websocket: The WebSocket connection.
"""
await websocket.accept() await websocket.accept()
try:
while True: while True:
data = await websocket.receive_text() data = await websocket.receive_text()
logger.info(data) async for chunk in generate(data):
for chunk in generate(data):
await websocket.send_text(chunk) await websocket.send_text(chunk)
await websocket.send_text("<<END>>") await websocket.send_text("<<END>>")
except WebSocketDisconnect:
logger.info("Client disconnected")

32
docker-compose.yml Normal file
View File

@@ -0,0 +1,32 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: akern-genai-app
ports:
- "${PORT:-8000}:8000"
environment:
# Application configuration
- PORT=${PORT:-8000}
- HOST=${HOST:-0.0.0.0}
- WORKERS=${WORKERS:-1}
- LOG_LEVEL=${LOG_LEVEL:-info}
# Google GenAI configuration (load from .env file)
- GOOGLE_APPLICATION_CREDENTIALS=${GOOGLE_APPLICATION_CREDENTIALS}
restart: unless-stopped
healthcheck:
test:
[
"CMD",
"wget",
"--no-verbose",
"--tries=1",
"--spider",
"http://${HOST:-127.0.0.1}:${PORT:-8000}/",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s

55
entrypoint.sh Normal file
View File

@@ -0,0 +1,55 @@
#!/bin/sh
set -e
# Default values
PORT="${PORT:-8000}"
HOST="${HOST:-0.0.0.0}"
WORKERS="${WORKERS:-1}"
LOG_LEVEL="${LOG_LEVEL:-info}"
# Display configuration
echo "=========================================="
echo "Starting FastAPI application"
echo "=========================================="
echo "Host: ${HOST}"
echo "Port: ${PORT}"
echo "Workers: ${WORKERS}"
echo "Log Level: ${LOG_LEVEL}"
echo "=========================================="
# Trap signals for graceful shutdown
cleanup() {
echo "Received termination signal, shutting down gracefully..."
# Send SIGTERM to uvicorn process group
if [ -n "$UVICORN_PID" ]; then
kill -TERM "$UVICORN_PID" 2>/dev/null || true
# Wait for uvicorn to exit, but with a timeout
timeout 30 sh -c "while kill -0 $UVICORN_PID 2>/dev/null; do sleep 1; done" || {
echo "Uvicorn did not shut down gracefully, forcing exit..."
kill -KILL "$UVICORN_PID" 2>/dev/null || true
}
fi
exit 0
}
# Register signal handlers
trap cleanup SIGTERM SIGINT
# Start uvicorn in the background to capture PID
uvicorn app:app \
--host "${HOST}" \
--port "${PORT}" \
--workers "${WORKERS}" \
--log-level "${LOG_LEVEL}" \
--access-log \
&
UVICORN_PID=$!
# Wait for uvicorn process
wait $UVICORN_PID
EXIT_STATUS=$?
# Exit with the same status as uvicorn
echo "Uvicorn exited with status: ${EXIT_STATUS}"
exit ${EXIT_STATUS}

145
lib.py Normal file
View File

@@ -0,0 +1,145 @@
"""Google Gemini API integration for Akern-Genai project.
This module provides functionality to generate content using Google's Gemini model
with Vertex AI RAG (Retrieval-Augmented Generation) support.
"""
from llm_config import generate_content_config
import logging
import asyncio
import json
import os
import threading
from google import genai
from google.genai import types
from google.oauth2 import service_account
from dotenv import load_dotenv
logger = logging.getLogger(__name__)
# Load environment variables from .env file
load_dotenv()
def get_credentials():
"""Get Google Cloud credentials and project ID from environment.
Supports two methods:
1. GOOGLE_CREDENTIALS_JSON: Direct JSON content as string (production)
2. GOOGLE_APPLICATION_CREDENTIALS: Path to JSON file (local development)
Returns:
tuple: (credentials, project_id) where credentials is a
service_account.Credentials object and project_id is the Google Cloud project
"""
# OAuth scopes required for Vertex AI API
SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
# Try to load credentials from JSON content directly
credentials_json = os.getenv("GOOGLE_CREDENTIALS_JSON")
if credentials_json:
try:
credentials_info = json.loads(credentials_json)
project_id = credentials_info.get("project_id")
credentials = service_account.Credentials.from_service_account_info(
credentials_info, scopes=SCOPES
)
return credentials, project_id
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in GOOGLE_CREDENTIALS_JSON: {e}")
# Fall back to file-based credentials (standard behavior)
credentials_path = os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
if credentials_path and os.path.exists(credentials_path):
with open(credentials_path) as f:
credentials_info = json.load(f)
project_id = credentials_info.get("project_id")
credentials = service_account.Credentials.from_service_account_file(
credentials_path, scopes=SCOPES
)
return credentials, project_id
# If neither is provided, return None to let the client use default credentials
# (useful when running on Google Cloud with service account attached)
return None, None
# Gemini model name
GEMINI_MODEL: str = "gemini-3-pro-preview"
async def generate(prompt: str):
"""Generate content using Gemini model with RAG retrieval.
This function creates a streaming response from the Gemini model,
augmented with content from the configured RAG corpus.
The blocking API call is run in a thread pool to allow concurrent
processing of multiple WebSocket connections.
Args:
prompt: The user's input prompt to generate content for.
Yields:
str: Text chunks from the generated response.
"""
# Create a queue for streaming chunks
chunk_queue: asyncio.Queue[str] = asyncio.Queue()
loop = asyncio.get_event_loop()
def run_streaming():
"""Run the synchronous streaming in a separate thread."""
try:
credentials, project_id = get_credentials()
client = genai.Client(
vertexai=True,
credentials=credentials,
project=project_id
)
contents = [
types.Content(role="user", parts=[
types.Part.from_text(text=prompt)]),
]
for chunk in client.models.generate_content_stream(
model=GEMINI_MODEL,
contents=contents,
config=generate_content_config,
):
if (
chunk.candidates
and chunk.candidates[0].content
and chunk.candidates[0].content.parts
):
# Schedule the put operation in the event loop
future = asyncio.run_coroutine_threadsafe(
chunk_queue.put(chunk.text),
loop,
)
# Wait for the put to complete (quick operation)
future.result(timeout=1)
except Exception as e:
print(f"[ERROR] Streaming error: {e}")
finally:
asyncio.run_coroutine_threadsafe(
chunk_queue.put("<<END>>"),
loop,
)
# Start the streaming in a daemon thread
stream_thread = threading.Thread(target=run_streaming, daemon=True)
stream_thread.start()
# Yield chunks as they become available
while True:
chunk = await chunk_queue.get()
if chunk == "<<END>>":
break
yield chunk

39
llm_config.py Normal file
View File

@@ -0,0 +1,39 @@
from google.genai import types
# Vertex AI RAG Corpus resource path
CORPUS: str = (
"projects/520464122471/locations/europe-west3/ragCorpora/2305843009213693952"
)
tools = [
types.Tool(
retrieval=types.Retrieval(
vertex_rag_store=types.VertexRagStore(
rag_resources=[
types.VertexRagStoreRagResource(rag_corpus=CORPUS)],
)
)
)
]
generate_content_config = types.GenerateContentConfig(
temperature=1,
top_p=0.95,
max_output_tokens=65535,
safety_settings=[
types.SafetySetting(
category="HARM_CATEGORY_HATE_SPEECH", threshold="OFF"),
types.SafetySetting(
category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="OFF"
),
types.SafetySetting(
category="HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold="OFF"
),
types.SafetySetting(
category="HARM_CATEGORY_HARASSMENT", threshold="OFF"),
],
tools=tools,
thinking_config=types.ThinkingConfig(
thinking_level="HIGH",
),
)

67
main.py
View File

@@ -1,67 +0,0 @@
from google import genai
from google.genai import types
from dotenv import load_dotenv
load_dotenv()
CORPUS = "projects/520464122471/locations/europe-west3/ragCorpora/2305843009213693952"
def generate(prompt: str):
client = genai.Client(
vertexai=True,
)
model = "gemini-3-pro-preview"
contents = [
types.Content(role="user", parts=[types.Part.from_text(text=prompt)]),
]
tools = [
types.Tool(
retrieval=types.Retrieval(
vertex_rag_store=types.VertexRagStore(
rag_resources=[types.VertexRagStoreRagResource(rag_corpus=CORPUS)],
)
)
)
]
generate_content_config = types.GenerateContentConfig(
temperature=1,
top_p=0.95,
max_output_tokens=65535,
safety_settings=[
types.SafetySetting(category="HARM_CATEGORY_HATE_SPEECH", threshold="OFF"),
types.SafetySetting(
category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="OFF"
),
types.SafetySetting(
category="HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold="OFF"
),
types.SafetySetting(category="HARM_CATEGORY_HARASSMENT", threshold="OFF"),
],
tools=tools,
thinking_config=types.ThinkingConfig(
thinking_level="HIGH",
),
)
for chunk in client.models.generate_content_stream(
model=model,
contents=contents,
config=generate_content_config,
):
if (
not chunk.candidates
or not chunk.candidates[0].content
or not chunk.candidates[0].content.parts
):
continue
yield chunk.text
if __name__ == "__main__":
for chunk in generate("Come si calcola il rapporto sodio potassio?"):
print(chunk, end="")

View File

@@ -5,6 +5,7 @@ description = "Add your description here"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"asyncio>=4.0.0",
"fastapi>=0.128.0", "fastapi>=0.128.0",
"fastapi-sse>=1.1.1", "fastapi-sse>=1.1.1",
"google-genai>=1.59.0", "google-genai>=1.59.0",

View File

@@ -1,6 +1,7 @@
annotated-doc==0.0.4 annotated-doc==0.0.4
annotated-types==0.7.0 annotated-types==0.7.0
anyio==4.12.1 anyio==4.12.1
asyncio==4.0.0
certifi==2026.1.4 certifi==2026.1.4
charset-normalizer==3.4.4 charset-normalizer==3.4.4
click==8.3.1 click==8.3.1

View File

@@ -1,37 +1,425 @@
textarea { /* ===== CSS Variables ===== */
width: 100%; :root {
height: 80px; --accent-color: rgb(38, 186, 216);
--accent-hover: rgb(25, 150, 180);
--accent-light: rgb(80, 205, 235);
--background-gradient: linear-gradient(
135deg,
var(--accent-hover) 0%,
var(--accent-color) 100%
);
--glass-bg: rgba(255, 255, 255, 0.95);
--glass-border: rgba(255, 255, 255, 0.2);
--text-primary: #1f2937;
--text-secondary: #6b7280;
--text-light: #9ca3af;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md:
0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg:
0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl:
0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--border-radius-sm: 8px;
--border-radius-md: 12px;
--border-radius-lg: 16px;
--border-radius-xl: 24px;
--transition-fast: 0.15s ease;
--transition-normal: 0.3s ease;
--transition-slow: 0.5s ease;
} }
#messages { /* ===== Font Face Declarations ===== */
@font-face {
font-family: "Intelo";
src: url("../fonts/intelo-regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Intelo";
src: url("../fonts/intelo-light.woff2") format("woff2");
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Intelo";
src: url("../fonts/intelo-bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* ===== Reset & Base Styles ===== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
"Intelo",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif;
background: var(--background-gradient);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
color: var(--text-primary);
line-height: 1.6;
}
/* ===== App Container ===== */
.app-container {
width: 100%;
max-width: 900px;
height: 90vh;
max-height: 800px;
background: var(--glass-bg);
backdrop-filter: blur(20px);
border-radius: var(--border-radius-xl);
box-shadow: var(--shadow-xl);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; overflow: hidden;
padding: 20px; border: 1px solid var(--glass-border);
animation: fadeInUp 0.6s ease-out;
} }
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ===== Header ===== */
.chat-header {
background: linear-gradient(
135deg,
var(--accent-hover) 0%,
var(--accent-color) 100%
);
padding: 20px 24px;
display: flex;
justify-content: center;
align-items: center;
box-shadow: var(--shadow-md);
z-index: 10;
}
.header-content img {
width: 200px;
}
/* ===== Main Content ===== */
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
scroll-behavior: smooth;
}
.chat-main::-webkit-scrollbar {
width: 6px;
}
.chat-main::-webkit-scrollbar-track {
background: transparent;
}
.chat-main::-webkit-scrollbar-thumb {
background: var(--text-light);
border-radius: 3px;
}
.chat-main::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* ===== Messages Container ===== */
.messages-container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
flex-shrink: 0;
}
/* ===== Message Styles ===== */
.message { .message {
max-width: 70%; max-width: 75%;
padding: 10px 15px; padding: 14px 20px 6px 20px;
border-radius: 15px; border-radius: var(--border-radius-lg);
font-family: sans-serif; font-size: 0.95rem;
line-height: 1.4; line-height: 1.6;
animation: messageSlide 0.3s ease-out;
word-wrap: break-word;
}
@keyframes messageSlide {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
} }
.sent { .sent {
max-width: 70%;
background-color: lightgreen;
padding: 10px 15px;
border-radius: 15px;
font-family: sans-serif;
line-height: 1.4;
align-self: flex-end; align-self: flex-end;
border-bottom-right-radius: 2px; background: linear-gradient(
135deg,
var(--accent-hover) 0%,
var(--accent-color) 100%
);
color: white;
border-bottom-right-radius: 4px;
box-shadow: var(--shadow-md);
}
.sent a {
color: white;
text-decoration: underline;
} }
.received { .received {
align-self: flex-start; align-self: flex-start;
background-color: #e5e5ea; background: white;
color: black; color: var(--text-primary);
border-bottom-left-radius: 2px; border-bottom-left-radius: 4px;
box-shadow: var(--shadow-sm);
border: 1px solid rgba(0, 0, 0, 0.05);
}
.received a {
color: var(--accent-color);
text-decoration: none;
}
.received a:hover {
text-decoration: underline;
}
/* ===== Message Content Lists ===== */
.message ul,
.message ol {
margin: 0 0 8px 0;
padding-left: 20px;
}
.message p {
margin: 0 0 8px 0;
}
.message ul {
list-style-type: disc;
}
.message ol {
list-style-type: decimal;
}
.message li {
margin: 4px 0;
line-height: 1.6;
}
.message li::marker {
color: var(--accent-color);
}
/* Nested lists */
.message ul ul,
.message ol ol,
.message ul ol,
.message ol ul {
margin: 4px 0;
}
/* ===== Footer ===== */
.chat-footer {
background: white;
padding: 20px 24px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.05);
}
.input-wrapper {
display: flex;
gap: 12px;
align-items: flex-end;
background: var(--background-gradient);
padding: 6px;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md);
transition: box-shadow var(--transition-normal);
}
.input-wrapper:focus-within {
box-shadow: var(--shadow-lg);
}
.message-input {
flex: 1;
background: white;
border: none;
border-radius: var(--border-radius-md);
padding: 14px 16px;
font-size: 1rem;
font-family: inherit;
color: var(--text-primary);
resize: none;
outline: none;
min-height: 50px;
max-height: 150px;
field-sizing: content;
transition: box-shadow var(--transition-fast);
}
.message-input::placeholder {
color: var(--text-light);
}
.message-input:focus {
box-shadow: 0 0 0 3px rgba(38, 186, 216, 0.2);
}
.message-input:disabled {
opacity: 0.6;
cursor: not-allowed;
background: #f3f4f6;
}
.send-button {
width: 50px;
height: 50px;
background: linear-gradient(
135deg,
var(--accent-hover) 0%,
var(--accent-color) 100%
);
border: none;
border-radius: var(--border-radius-md);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-normal);
box-shadow: var(--shadow-md);
flex-shrink: 0;
}
.send-button:hover {
transform: scale(1.05);
box-shadow: var(--shadow-lg);
}
.send-button:active {
transform: scale(0.95);
}
.send-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.footer-hint {
text-align: center;
font-size: 0.75rem;
color: var(--text-light);
margin-top: 12px;
}
/* ===== Responsive Design ===== */
@media (max-width: 768px) {
body {
padding: 10px;
}
.app-container {
height: 95vh;
max-height: none;
border-radius: var(--border-radius-lg);
}
.chat-header {
padding: 16px 20px;
}
.chat-main {
padding: 0;
}
.messages-container {
padding: 16px;
gap: 12px;
}
.message {
max-width: 85%;
padding: 12px 14px;
font-size: 0.9rem;
}
.chat-footer {
padding: 16px 20px;
}
.message-input {
padding: 12px 14px;
font-size: 0.95rem;
}
.send-button {
width: 46px;
height: 46px;
}
}
@media (max-width: 480px) {
.message {
max-width: 90%;
}
}
/* ===== Loading Animation ===== */
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.loading-message {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
} }

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,19 +1,41 @@
(($) => { (($) => {
var ws = new WebSocket("ws://localhost:8000/ws"); const isSecure = location.protocol === "https:";
var ws = new WebSocket(
`ws${isSecure ? "s" : ""}://${location.host}/ws`,
);
const input = $("#message"); const input = $("#message");
const messages = $('#messages'); const button = $("#button");
const messages = $("#messages");
const chatMain = $(".chat-main");
var lastMessage; var lastMessage;
$('#button').on('click', () => { // Function to scroll to bottom of chat
const scrollToBottom = () => {
chatMain.scrollTop(chatMain[0].scrollHeight);
};
$("#button").on("click", () => {
const message = input.val(); const message = input.val();
if (message) { if (message) {
// Disable input and button while waiting for response
input.prop("disabled", true);
button.prop("disabled", true);
ws.send(message); ws.send(message);
lastMessage = $('<div class="message received"><p>Loading...</p></div>') lastMessage = $('<div class="message received"><p>Loading...</p></div>');
messages.append(`<div class="message sent"><p>${message}</p></div>`) messages.append(`<div class="message sent"><p>${message}</p></div>`);
messages.append(lastMessage); messages.append(lastMessage);
input.val(""); input.val("");
scrollToBottom();
}
});
input.on("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
$("#button").click();
} }
}); });
@@ -26,9 +48,14 @@
if (content.textContent === "<<END>>") { if (content.textContent === "<<END>>") {
lastMessage.html(marked.parse(lastMessage.text())); lastMessage.html(marked.parse(lastMessage.text()));
} // Re-enable input and button when response is complete
else { input.prop("disabled", false);
button.prop("disabled", false);
input.focus();
scrollToBottom();
} else {
lastMessage.append(content); lastMessage.append(content);
scrollToBottom();
} }
}; };
})(jQuery); })(jQuery);

View File

@@ -1,21 +1,54 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/static/css/main.css"> <link rel="stylesheet" href="/static/css/main.css" />
<title>Document</title> <title>AKERN Assistant</title>
</head> </head>
<body> <body>
<h1>Chat</h1> <div class="app-container">
<p>Come si calcola la massa magra? dammi una spiegazione dettagliata</p> <header class="chat-header">
<div class="header-content">
<div> <img
<textarea name="" id="message"></textarea> src="https://www.akern.com/wp-content/themes/zaki_new/resources/img/icon/logo@2x.png"
<input id="button" type="button" value="Send"> alt=""
/>
</div> </div>
</header>
<div id="messages"></div> <main class="chat-main">
<div id="messages" class="messages-container"></div>
</main>
<footer class="chat-footer">
<div class="input-wrapper">
<textarea
id="message"
class="message-input"
placeholder="Scrivi qui il tuo messaggio..."
rows="1"
></textarea>
<button id="button" class="send-button">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"
fill="currentColor"
/>
</svg>
</button>
</div>
<p class="footer-hint">
Premi Invio per inviare, Shift+Invio per una nuova riga
</p>
</footer>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>

11
uv.lock generated
View File

@@ -32,6 +32,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
] ]
[[package]]
name = "asyncio"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/71/ea/26c489a11f7ca862d5705db67683a7361ce11c23a7b98fc6c2deaeccede2/asyncio-4.0.0.tar.gz", hash = "sha256:570cd9e50db83bc1629152d4d0b7558d6451bb1bfd5dfc2e935d96fc2f40329b", size = 5371, upload-time = "2025-08-05T02:51:46.605Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/64/eff2564783bd650ca25e15938d1c5b459cda997574a510f7de69688cb0b4/asyncio-4.0.0-py3-none-any.whl", hash = "sha256:c1eddb0659231837046809e68103969b2bef8b0400d59cfa6363f6b5ed8cc88b", size = 5555, upload-time = "2025-08-05T02:51:45.767Z" },
]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2026.1.4" version = "2026.1.4"
@@ -144,6 +153,7 @@ name = "genai"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "asyncio" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "fastapi-sse" }, { name = "fastapi-sse" },
{ name = "google-genai" }, { name = "google-genai" },
@@ -155,6 +165,7 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "asyncio", specifier = ">=4.0.0" },
{ name = "fastapi", specifier = ">=0.128.0" }, { name = "fastapi", specifier = ">=0.128.0" },
{ name = "fastapi-sse", specifier = ">=1.1.1" }, { name = "fastapi-sse", specifier = ">=1.1.1" },
{ name = "google-genai", specifier = ">=1.59.0" }, { name = "google-genai", specifier = ">=1.59.0" },