Compare commits
34 Commits
5f74f6fa04
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
865f4ee1b6 | ||
|
|
3a7f329499 | ||
|
|
f14c56ddd3 | ||
|
|
da726dc777 | ||
|
|
ead140a2f4 | ||
|
|
20919298c8 | ||
|
|
b18c56e2f2 | ||
|
|
a8f2c070e9 | ||
|
|
111e47cb77 | ||
|
|
7c2318c655 | ||
|
|
867b029e1c | ||
|
|
48d8ce9276 | ||
|
|
1ed452f1d9 | ||
|
|
31ee3fed8c | ||
|
|
f86cbed467 | ||
|
|
08d37cdd94 | ||
|
|
a8461b05c9 | ||
|
|
3c050afe61 | ||
|
|
790be07166 | ||
|
|
993c5cf8aa | ||
|
|
5906bfb0ab | ||
|
|
543775d61e | ||
|
|
9f5d8a0a88 | ||
|
|
b91c09504d | ||
|
|
c97739d096 | ||
|
|
f5bafd1117 | ||
|
|
c9b3f7d33c | ||
|
|
5835e87fef | ||
|
|
a8d25aa7fe | ||
|
|
3fabcaac07 | ||
|
|
3dd38dc6fa | ||
|
|
7df1b9f718 | ||
|
|
eb627047e5 | ||
|
|
657aa0c578 |
53
.dockerignore
Normal file
53
.dockerignore
Normal 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
|
||||||
12
.env.dist
12
.env.dist
@@ -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
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,3 +11,4 @@ wheels/
|
|||||||
|
|
||||||
credentials.json
|
credentials.json
|
||||||
.env
|
.env
|
||||||
|
.vercel
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
3.13
|
|
||||||
215
DOCKER.md
Normal file
215
DOCKER.md
Normal 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
85
Dockerfile
Normal 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"]
|
||||||
|
|
||||||
|
|
||||||
89
app.py
89
app.py
@@ -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()
|
||||||
while True:
|
try:
|
||||||
data = await websocket.receive_text()
|
while True:
|
||||||
|
data = await websocket.receive_text()
|
||||||
|
|
||||||
logger.info(data)
|
async for chunk in generate(data):
|
||||||
|
await websocket.send_text(chunk)
|
||||||
|
|
||||||
for chunk in generate(data):
|
await websocket.send_text("<<END>>")
|
||||||
await websocket.send_text(chunk)
|
except WebSocketDisconnect:
|
||||||
|
logger.info("Client disconnected")
|
||||||
await websocket.send_text("<<END>>")
|
|
||||||
|
|||||||
32
docker-compose.yml
Normal file
32
docker-compose.yml
Normal 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
55
entrypoint.sh
Normal 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
145
lib.py
Normal 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
39
llm_config.py
Normal 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
67
main.py
@@ -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="")
|
|
||||||
@@ -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",
|
||||||
|
|||||||
33
requirements.txt
Normal file
33
requirements.txt
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
annotated-doc==0.0.4
|
||||||
|
annotated-types==0.7.0
|
||||||
|
anyio==4.12.1
|
||||||
|
asyncio==4.0.0
|
||||||
|
certifi==2026.1.4
|
||||||
|
charset-normalizer==3.4.4
|
||||||
|
click==8.3.1
|
||||||
|
distro==1.9.0
|
||||||
|
fastapi==0.128.0
|
||||||
|
fastapi-sse==1.1.1
|
||||||
|
google-auth==2.47.0
|
||||||
|
google-genai==1.59.0
|
||||||
|
h11==0.16.0
|
||||||
|
httpcore==1.0.9
|
||||||
|
httpx==0.28.1
|
||||||
|
idna==3.11
|
||||||
|
jinja2==3.1.6
|
||||||
|
markupsafe==3.0.3
|
||||||
|
pyasn1==0.6.2
|
||||||
|
pyasn1-modules==0.4.2
|
||||||
|
pydantic==2.12.5
|
||||||
|
pydantic-core==2.41.5
|
||||||
|
python-dotenv==1.2.1
|
||||||
|
requests==2.32.5
|
||||||
|
rsa==4.9.1
|
||||||
|
sniffio==1.3.1
|
||||||
|
starlette==0.50.0
|
||||||
|
tenacity==9.1.2
|
||||||
|
typing-extensions==4.15.0
|
||||||
|
typing-inspection==0.4.2
|
||||||
|
urllib3==2.6.3
|
||||||
|
uvicorn==0.40.0
|
||||||
|
websockets==15.0.1
|
||||||
@@ -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 ===== */
|
||||||
display: flex;
|
@font-face {
|
||||||
flex-direction: column;
|
font-family: "Intelo";
|
||||||
gap: 10px;
|
src: url("../fonts/intelo-regular.woff2") format("woff2");
|
||||||
padding: 20px;
|
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;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
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%;
|
align-self: flex-end;
|
||||||
background-color: lightgreen;
|
background: linear-gradient(
|
||||||
padding: 10px 15px;
|
135deg,
|
||||||
border-radius: 15px;
|
var(--accent-hover) 0%,
|
||||||
font-family: sans-serif;
|
var(--accent-color) 100%
|
||||||
line-height: 1.4;
|
);
|
||||||
align-self: flex-end;
|
color: white;
|
||||||
border-bottom-right-radius: 2px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
BIN
static/fonts/intelo-bold.woff2
Normal file
BIN
static/fonts/intelo-bold.woff2
Normal file
Binary file not shown.
BIN
static/fonts/intelo-light.woff2
Normal file
BIN
static/fonts/intelo-light.woff2
Normal file
Binary file not shown.
BIN
static/fonts/intelo-regular.woff2
Normal file
BIN
static/fonts/intelo-regular.woff2
Normal file
Binary file not shown.
@@ -1,34 +1,61 @@
|
|||||||
(($) => {
|
(($) => {
|
||||||
var ws = new WebSocket("ws://localhost:8000/ws");
|
const isSecure = location.protocol === "https:";
|
||||||
const input = $("#message");
|
var ws = new WebSocket(
|
||||||
const messages = $('#messages');
|
`ws${isSecure ? "s" : ""}://${location.host}/ws`,
|
||||||
var lastMessage;
|
);
|
||||||
|
const input = $("#message");
|
||||||
|
const button = $("#button");
|
||||||
|
const messages = $("#messages");
|
||||||
|
const chatMain = $(".chat-main");
|
||||||
|
var lastMessage;
|
||||||
|
|
||||||
$('#button').on('click', () => {
|
// Function to scroll to bottom of chat
|
||||||
const message = input.val();
|
const scrollToBottom = () => {
|
||||||
|
chatMain.scrollTop(chatMain[0].scrollHeight);
|
||||||
|
};
|
||||||
|
|
||||||
if (message) {
|
$("#button").on("click", () => {
|
||||||
ws.send(message);
|
const message = input.val();
|
||||||
lastMessage = $('<div class="message received"><p>Loading...</p></div>')
|
|
||||||
messages.append(`<div class="message sent"><p>${message}</p></div>`)
|
|
||||||
messages.append(lastMessage);
|
|
||||||
|
|
||||||
input.val("");
|
if (message) {
|
||||||
}
|
// Disable input and button while waiting for response
|
||||||
});
|
input.prop("disabled", true);
|
||||||
|
button.prop("disabled", true);
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.send(message);
|
||||||
if (lastMessage.text() === "Loading...") {
|
lastMessage = $('<div class="message received"><p>Loading...</p></div>');
|
||||||
lastMessage.empty();
|
messages.append(`<div class="message sent"><p>${message}</p></div>`);
|
||||||
}
|
messages.append(lastMessage);
|
||||||
|
|
||||||
var content = document.createTextNode(event.data);
|
input.val("");
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (content.textContent === "<<END>>") {
|
input.on("keydown", (e) => {
|
||||||
lastMessage.html(marked.parse(lastMessage.text()));
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
}
|
e.preventDefault();
|
||||||
else {
|
$("#button").click();
|
||||||
lastMessage.append(content);
|
}
|
||||||
}
|
});
|
||||||
};
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
if (lastMessage.text() === "Loading...") {
|
||||||
|
lastMessage.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = document.createTextNode(event.data);
|
||||||
|
|
||||||
|
if (content.textContent === "<<END>>") {
|
||||||
|
lastMessage.html(marked.parse(lastMessage.text()));
|
||||||
|
// Re-enable input and button when response is complete
|
||||||
|
input.prop("disabled", false);
|
||||||
|
button.prop("disabled", false);
|
||||||
|
input.focus();
|
||||||
|
scrollToBottom();
|
||||||
|
} else {
|
||||||
|
lastMessage.append(content);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
};
|
||||||
})(jQuery);
|
})(jQuery);
|
||||||
|
|||||||
@@ -1,24 +1,57 @@
|
|||||||
<!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">
|
||||||
|
<img
|
||||||
|
src="https://www.akern.com/wp-content/themes/zaki_new/resources/img/icon/logo@2x.png"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<div>
|
<main class="chat-main">
|
||||||
<textarea name="" id="message"></textarea>
|
<div id="messages" class="messages-container"></div>
|
||||||
<input id="button" type="button" value="Send">
|
</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>
|
</div>
|
||||||
|
|
||||||
<div id="messages"></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>
|
||||||
<script src="/static/js/main.js"></script>
|
<script src="/static/js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
11
uv.lock
generated
11
uv.lock
generated
@@ -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" },
|
||||||
|
|||||||
4
vercel.json
Normal file
4
vercel.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"builds": [{ "src": "app.py", "use": "@vercel/python" }],
|
||||||
|
"routes": [{ "src": "/(.*)", "dest": "app.py" }]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user