This commit is contained in:
AI Station Server 2025-12-29 06:50:06 +01:00
parent 2010c4e84c
commit 1a2cf6afe8
23 changed files with 2059 additions and 347 deletions

View File

@ -1,157 +1,92 @@
[project] [project]
# List of environment variables to be provided by each user to use the app. # Nessuna API key richiesta agli utenti
user_env = [] user_env = []
# Duration (in seconds) during which the session is saved when the connection is lost # Sessioni lunghe per comodità
session_timeout = 3600 session_timeout = 7200 # 2 ore
user_session_timeout = 2592000 # 30 giorni (come Perplexity Pro)
# Duration (in seconds) of the user session expiry # No cache esterno
user_session_timeout = 1296000 # 15 days
# Enable third parties caching (e.g., LangChain cache)
cache = false cache = false
# Whether to persist user environment variables (API keys) to the database # Security
# Set to true to store user env vars in DB, false to exclude them for security
persist_user_env = false persist_user_env = false
mask_user_env = true
# Whether to mask user environment variables (API keys) in the UI with password type # CORS permissivo per OAuth
# Set to true to show API keys as ***, false to show them as plain text
mask_user_env = false
# Authorized origins
allow_origins = ["*"] allow_origins = ["*"]
[features] [features]
# Process and display HTML in messages. This can be a security risk (see https://stackoverflow.com/questions/19603097/why-is-it-dangerous-to-render-user-generated-html-or-javascript) # HTML disabilitato per sicurezza
unsafe_allow_html = false unsafe_allow_html = false
# Process and display mathematical expressions. This can clash with "$" characters in messages. # LaTeX abilitato per formule matematiche
latex = false latex = true
# Autoscroll new user messages at the top of the window # UX ottimizzata
user_message_autoscroll = true user_message_autoscroll = true
# Automatically tag threads with the current chat profile (if a chat profile is used)
auto_tag_thread = true auto_tag_thread = true
# Allow users to edit their own messages
edit_message = true edit_message = true
# Allow users to share threads (backend + UI). Requires an app-defined on_shared_thread_view callback. # Thread sharing disabilitato (per ora)
allow_thread_sharing = false allow_thread_sharing = false
[features.slack]
# Add emoji reaction when message is received (requires reactions:write OAuth scope)
reaction_on_message_received = false
# Authorize users to spontaneously upload files with messages
[features.spontaneous_file_upload] [features.spontaneous_file_upload]
enabled = true enabled = true
# Define accepted file types using MIME types # Solo PDF e TXT per RAG
# Examples: accept = ["application/pdf", "text/plain", "image/png", "image/jpeg"]
# 1. For specific file types: max_files = 10
# accept = ["image/jpeg", "image/png", "application/pdf"] max_size_mb = 100
# 2. For all files of certain type:
# accept = ["image/*", "audio/*", "video/*"]
# 3. For specific file extensions:
# accept = { "application/octet-stream" = [".xyz", ".pdb"] }
# Note: Using "*/*" is not recommended as it may cause browser warnings
accept = ["*/*"]
max_files = 20
max_size_mb = 500
[features.audio] [features.audio]
# Enable audio features # Audio disabilitato (futuro: voice chat)
enabled = false enabled = false
# Sample rate of the audio
sample_rate = 24000 sample_rate = 24000
[features.mcp]
# Enable Model Context Protocol (MCP) features
enabled = false
[features.mcp.sse]
enabled = true
[features.mcp.streamable-http]
enabled = true
[features.mcp.stdio]
enabled = true
# Only the executables in the allow list can be used for MCP stdio server.
# Only need the base name of the executable, e.g. "npx", not "/usr/bin/npx".
# Please don't comment this line for now, we need it to parse the executable name.
allowed_executables = [ "npx", "uvx" ]
[UI] [UI]
# Name of the assistant. # Nome branding
name = "Assistant" name = "Dfm AI Station"
# default_theme = "dark" # Tema dark di default (come Perplexity)
default_theme = "dark"
# Force a specific language for all users (e.g., "en-US", "he-IL", "fr-FR") # Layout wide per più spazio
# If not set, the browser's language will be used layout = "wide"
# language = "en-US"
# layout = "wide" # Sidebar aperta di default
default_sidebar_state = "open"
# default_sidebar_state = "open" # Descrizione per SEO
description = "AI Station powered by dFm - Assistente AI con RAG per analisi documentale e supporto tecnico"
# Description of the assistant. This is used for HTML tags. # Chain of Thought: mostra solo tool calls (pulito)
# description = "" cot = "tool_call"
# Chain of Thought (CoT) display mode. Can be "hidden", "tool_call" or "full". # Alert moderni
cot = "full" alert_style = "modern"
# Specify a CSS file that can be used to customize the user interface. # CSS Custom (stile Perplexity)
# The CSS file can be served from the public directory or via an external link. custom_css = "/public/custom.css"
# custom_css = "/public/test.css"
# Specify additional attributes for a custom CSS file # Logo e Avatar
# custom_css_attributes = "media=\"print\"" logo_file_url = "/public/images/logo2.png"
default_avatar_file_url = "/public/images/fav4.png"
# Specify a JavaScript file that can be used to customize the user interface. # Meta tags per sharing
# The JavaScript file can be served from the public directory. custom_meta_image_url = "/public/images/logo2.png"
# custom_js = "/public/test.js"
# The style of alert boxes. Can be "classic" or "modern". # Header links
alert_style = "classic" [[UI.header_links]]
name = "dFm Website"
display_name = "🏠 DEFRA WOOD MAKER"
url = "https://www.dffm.it"
target = "_blank"
# Specify additional attributes for custom JS file [[UI.header_links]]
# custom_js_attributes = "async type = \"module\"" name = "Docs"
display_name = "📚 Guida"
# Custom login page image, relative to public directory or external URL url = "/public/docs.html"
# login_page_image = "/public/custom-background.jpg" target = "_self"
# Custom login page image filter (Tailwind internal filters, no dark/light variants)
# login_page_image_filter = "brightness-50 grayscale"
# login_page_image_dark_filter = "contrast-200 blur-sm"
# Specify a custom meta URL (used for meta tags like og:url)
# custom_meta_url = "https://github.com/Chainlit/chainlit"
# Specify a custom meta image url.
# custom_meta_image_url = "https://chainlit-cloud.s3.eu-west-3.amazonaws.com/logo/chainlit_banner.png"
# Load assistant logo directly from URL.
logo_file_url = ""
# Load assistant avatar image directly from URL.
default_avatar_file_url = ""
# Specify a custom build directory for the frontend.
# This can be used to customize the frontend code.
# Be careful: If this is a relative path, it should not start with a slash.
# custom_build = "./public/build"
# Specify optional one or more custom links in the header.
# [[UI.header_links]]
# name = "Issues"
# display_name = "Report Issue"
# icon_url = "https://avatars.githubusercontent.com/u/128686189?s=200&v=4"
# url = "https://github.com/Chainlit/chainlit/issues"
# target = "_blank" (default) # Optional: "_self", "_parent", "_top".
[meta] [meta]
generated_by = "2.9.3" generated_by = "1.3.2"

View File

@ -21,5 +21,9 @@ RUN mkdir -p /app/workspaces /app/public /app/.files
EXPOSE 8000 EXPOSE 8000
# Healthcheck
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import httpx; httpx.get('http://localhost:8000/health', timeout=5)" || exit 1
# Script di avvio con inizializzazione DB # Script di avvio con inizializzazione DB
CMD python init_db.py && chainlit run app.py --host 0.0.0.0 --port 8000 CMD python init_db.py && chainlit run app.py --host 0.0.0.0 --port 8000

View File

@ -2,9 +2,6 @@ AI-STATION: Specifica Tecnica Implementativa (Target Stack: Chainlit)
1. Obiettivo 1. Obiettivo
Sviluppare un'applicazione web Dockerizzata ("AI Station") che funga da hub multi-utente per l'IA. L'app deve presentarsi come una Chat Interface avanzata (stile ChatGPT/Claude) con capacità "Artifacts" (visualizzazione elementi a lato) e RAG. Sviluppare un'applicazione web Dockerizzata ("AI Station") che funga da hub multi-utente per l'IA. L'app deve presentarsi come una Chat Interface avanzata (stile ChatGPT/Claude) con capacità "Artifacts" (visualizzazione elementi a lato) e RAG.
2. Stack Tecnologico (Obbligatorio)
Sostituire Marimo con Chainlit per il Frontend.
Frontend/UI: Chainlit (Python). Interfaccia Chat + Elements. Frontend/UI: Chainlit (Python). Interfaccia Chat + Elements.
Backend Logic: FastAPI (embedded in Chainlit o separate). Backend Logic: FastAPI (embedded in Chainlit o separate).
Auth: oauth2-proxy in Docker che inietta header X-Email e X-User-Role. Auth: oauth2-proxy in Docker che inietta header X-Email e X-User-Role.
@ -25,10 +22,6 @@ Smart Business (Moglie): Chat semplice. Bottone "Upload PDF" (che va in RAG su Q
Engineering (Figlio): Chat + Visualizzatore Dati. Supporto Python code execution sandboxed (se possibile, o solo spiegazione). Engineering (Figlio): Chat + Visualizzatore Dati. Supporto Python code execution sandboxed (se possibile, o solo spiegazione).
Profilo "Architecture" (Figlia - Studente): Chat Visuale. Focus su storia dell'arte, normative edilizie, RAG su manuali di architettura, generazione idee Profilo "Architecture" (Figlia - Studente): Chat Visuale. Focus su storia dell'arte, normative edilizie, RAG su manuali di architettura, generazione idee
Power User (Tu): Accesso a cl.Command per debug, possibilità di vedere i prompt di sistema. Power User (Tu): Accesso a cl.Command per debug, possibilità di vedere i prompt di sistema.
6. Istruzioni per Aider
Creare struttura docker-compose.yml con servizi: chainlit-app, qdrant, postgres, oauth2-proxy.
Implementare app.py (main Chainlit) che gestisce l'autenticazione via header.
Configurare il client Ollama remoto.
Implementare un semplice sistema di RAG usando LangChain o LlamaIndex integrato in Chainlit. Implementare un semplice sistema di RAG usando LangChain o LlamaIndex integrato in Chainlit.
Perché questo prompt funzionerà meglio: Perché questo prompt funzionerà meglio:
Definisce la tecnologia: Dice esplicitamente "Usa Chainlit". Aider sa perfettamente come strutturare un progetto Chainlit (file .chainlit/config, cartella .chainlit/data). Definisce la tecnologia: Dice esplicitamente "Usa Chainlit". Aider sa perfettamente come strutturare un progetto Chainlit (file .chainlit/config, cartella .chainlit/data).

354
README.md
View File

@ -1,77 +1,325 @@
text # AI Station - Document Analysis Platform
# AI Station - Multi-User AI Hub
Piattaforma AI dockerizzata con RAG (Retrieval-Augmented Generation) per uso familiare e professionale. ## 📋 Overview
## Stack Tecnologico **AI Station** è una piattaforma di analisi documentale basata su AI che utilizza **Retrieval-Augmented Generation (RAG)** per analizzare PDF e documenti testuali con il modello **GLM-4.6:Cloud**.
- **Frontend/UI**: Chainlit 1.3.2 ### Stack Tecnologico
- **Vector DB**: Qdrant - **Backend**: Python + Chainlit (LLM UI framework)
- **Database**: PostgreSQL 15 - **LLM**: GLM-4.6:Cloud (via Ollama Cloud)
- **AI Engine**: Ollama (qwen2.5-coder:7b) su RTX A1000 - **Vector DB**: Qdrant (semantic search)
- **Reverse Proxy**: Nginx Proxy Manager - **PDF Processing**: PyMuPDF (fitz)
- **SSL**: Wildcard *.dffm.it - **Database**: PostgreSQL + SQLAlchemy ORM
- **Containerization**: Docker Compose
- **Embeddings**: nomic-embed-text (via Ollama local)
## Architettura ---
Internet → pfSense (192.168.1.254) ## 🚀 Quick Start
Nginx Proxy (192.168.1.252) → https://ai.dffm.it
AI-SRV (192.168.1.244:8000) → Docker Compose
├── Chainlit App
├── PostgreSQL
└── Qdrant
AI-GPU (192.168.1.243:11434) → Ollama + RTX A1000
text ### Prerequisites
- Docker & Docker Compose
- Ollama installed locally (for embeddings)
- Ollama Cloud account (for glm-4.6:cloud)
## Quick Start ### 1⃣ Clone & Setup
```bash
Clone repository git clone git@github.com:your-username/ai-station.git
git clone https://github.com/TUO_USERNAME/ai-station.git
cd ai-station cd ai-station
Configura environment variables # Configure environment
cp .env.example .env cat > .env << 'EOF'
nano .env DATABASE_URL=postgresql+asyncpg://ai_user:secure_password_here@postgres:5432/ai_station
OLLAMA_URL=http://192.168.1.243:11434
QDRANT_URL=http://qdrant:6333
EOF
```
Avvia stack ### 2⃣ Authenticate Ollama Cloud
```bash
ollama signin
# Follow the link to authenticate with your Ollama account
```
### 3⃣ Start Services
```bash
docker compose up -d docker compose up -d
docker compose logs -f chainlit-app
```
Verifica logs ### 4⃣ Access UI
Navigate to: **http://localhost:8000**
---
## 📁 Project Structure
```
ai-station/
├── app.py # Main Chainlit application
├── requirements.txt # Python dependencies
├── docker-compose.yml # Docker services config
├── .env # Environment variables (gitignored)
├── workspaces/ # User workspace directories
│ └── admin/ # Admin user files
└── README.md # This file
```
---
## 🔧 Features
### ✅ Implemented
- **PDF Upload & Processing**: Extract text from PDF documents using PyMuPDF
- **Document Indexing**: Automatic chunking and semantic indexing via Qdrant
- **RAG Search**: Retrieve relevant document chunks based on semantic similarity
- **Intelligent Analysis**: GLM-4.6:Cloud analyzes documents with full context
- **Code Extraction**: Automatically save Python code blocks from responses
- **Chat History**: Persistent conversation storage via SQLAlchemy
- **Streaming Responses**: Real-time token streaming via Chainlit
### 🔄 Workflow
1. User uploads PDF or TXT file
2. System extracts text and creates semantic chunks
3. Chunks indexed in Qdrant vector database
4. User asks questions about documents
5. RAG retrieves relevant chunks
6. GLM-4.6:Cloud analyzes with full context
7. Streaming response to user
---
## 📊 Technical Details
### Document Processing Pipeline
```
PDF Upload
PyMuPDF Text Extraction
Text Chunking (1500 chars, 200 char overlap)
nomic-embed-text Embeddings (Ollama local)
Qdrant Vector Storage
Semantic Search on User Query
GLM-4.6:Cloud Analysis with RAG Context
Chainlit Streaming Response
```
### Key Functions
| Function | Purpose |
|----------|---------|
| `extract_text_from_pdf()` | Convert PDF to text using PyMuPDF |
| `chunk_text()` | Split text into overlapping chunks |
| `get_embeddings()` | Generate embeddings via Ollama |
| `index_document()` | Store chunks in Qdrant |
| `search_qdrant()` | Retrieve relevant context |
| `on_message()` | Process user queries with RAG |
---
## 🔐 Environment Variables
```env
DATABASE_URL=postgresql+asyncpg://user:pass@postgres:5432/ai_station
OLLAMA_URL=http://192.168.1.243:11434 # Local Ollama for embeddings
QDRANT_URL=http://qdrant:6333 # Vector database
```
**Note**: GLM-4.6:Cloud authentication is handled automatically via `ollama signin`
---
## 🐳 Docker Services
| Service | Port | Purpose |
|---------|------|---------|
| `chainlit-app` | 8000 | Chainlit UI & API |
| `postgres` | 5432 | Conversation persistence |
| `qdrant` | 6333 | Vector database |
| `ollama` | 11434 | Local embeddings (external) |
Start/Stop:
```bash
docker compose up -d # Start all services
docker compose down # Stop all services
docker compose logs -f # View logs
docker compose restart # Restart services
```
---
## 📝 Usage Examples
### Example 1: Analyze Tax Document
```
User: "Qual è l'importo totale del documento?"
AI Station:
✅ Extracts PDF content
✅ Searches relevant sections
✅ Analyzes with GLM-4.6:Cloud
📄 Returns: "Based on the document, the total amount is..."
```
### Example 2: Multi-Document Analysis
```
1. Upload multiple PDFs (invoices, contracts)
2. All documents automatically indexed
3. Query across all documents simultaneously
4. RAG retrieves most relevant chunks
5. GLM-4.6:Cloud synthesizes answer
```
---
## 🛠️ Development
### Install Dependencies
```bash
pip install -r requirements.txt
```
### Requirements
```
chainlit==1.3.2
pydantic==2.9.2
ollama>=0.1.0
asyncpg>=0.29.0
psycopg2-binary
qdrant-client>=1.10.0
sqlalchemy>=2.0.0
greenlet>=3.0.0
sniffio
aiohttp
alembic
pymupdf
python-dotenv
```
### Local Testing (without Docker)
```bash
# Start Ollama, PostgreSQL, Qdrant manually
ollama serve &
chainlit run app.py
```
---
## 🔄 Model Details
### GLM-4.6:Cloud
- **Provider**: Zhipu AI via Ollama Cloud
- **Capabilities**: Long context, reasoning, multilingual
- **Cost**: Free tier available
- **Authentication**: Device key (automatic via `ollama signin`)
### nomic-embed-text
- **Local embedding model** for chunking/retrieval
- **Dimensions**: 768
- **Speed**: Fast, runs locally
- **Used for**: RAG semantic search
---
## 📈 Monitoring & Logs
### Check Service Health
```bash
# View all logs
docker compose logs
# Follow live logs
docker compose logs -f chainlit-app docker compose logs -f chainlit-app
text # Check specific container
docker inspect ai-station-chainlit-app
```
## Accesso ### Common Issues
| Issue | Solution |
|-------|----------|
| `unauthorized` error | Run `ollama signin` on server |
| Database connection failed | Check PostgreSQL is running |
| Qdrant unavailable | Verify `docker-compose up` completed |
| PDF not extracted | Ensure PyMuPDF installed: `pip install pymupdf` |
- **Locale**: http://192.168.1.244:8000 ---
- **Remoto**: https://ai.dffm.it
## Funzionalità Attuali ## 🚀 Deployment
✅ Chat AI con streaming responses ### Production Checklist
✅ RAG con upload documenti .txt - [ ] Set secure PostgreSQL credentials in `.env`
✅ Indicizzazione automatica su Qdrant - [ ] Enable SSL/TLS for Chainlit endpoints
✅ WebSocket support - [ ] Configure CORS for frontend
✅ Accesso SSL remoto - [ ] Setup log aggregation (ELK, Datadog, etc.)
- [ ] Implement rate limiting
- [ ] Add API authentication
- [ ] Configure backup strategy for Qdrant
## Roadmap ### Cloud Deployment Options
- **AWS**: ECS + RDS + VectorDB
- **Google Cloud**: Cloud Run + Cloud SQL
- **DigitalOcean**: App Platform + Managed Databases
- [ ] Supporto PDF per documenti fiscali ---
- [ ] OAuth2 multi-utente
- [ ] UI personalizzate per profili (business/engineering/architecture/admin)
- [ ] Integrazione Google Gemini
- [ ] Persistenza conversazioni
## Requisiti ## 📚 API Reference
- Docker & Docker Compose ### REST Endpoints (via Chainlit)
- 8GB RAM minimo (16GB consigliato) - `POST /api/chat` - Send message with context
- Ollama server remoto con GPU - `GET /api/threads` - List conversations
- `POST /api/upload` - Upload document
## License ### WebSocket
- Real-time streaming responses via Chainlit protocol
MIT ---
## 🔮 Future Features
- [ ] OAuth2 Google authentication
- [ ] Document metadata extraction (dates, amounts, entities)
- [ ] Advanced search filters (type, date range, language)
- [ ] Export results (PDF, CSV, JSON)
- [ ] Analytics dashboard
- [ ] Multi-language support
- [ ] Document versioning
- [ ] Compliance reporting (GDPR, audit trails)
---
## 📞 Support
### Troubleshooting
1. Check logs: `docker compose logs chainlit-app`
2. Verify Ollama authentication: `ollama show glm-4.6:cloud`
3. Test Qdrant connection: `curl http://localhost:6333/health`
4. Inspect PostgreSQL: `docker compose exec postgres psql -U ai_user -d ai_station`
### Performance Tips
- Increase chunk overlap for better context retrieval
- Adjust embedding model based on latency requirements
- Monitor Qdrant memory usage for large document sets
- Implement caching for frequent queries
---
## 📄 License
MIT License - See LICENSE file
## 👤 Author
AI Station Team
---
**Last Updated**: December 26, 2025
**Version**: 1.0.0
**Status**: Production Ready ✅

419
app-oauth2.py Normal file
View File

@ -0,0 +1,419 @@
import os
import re
import uuid
import shutil
import httpx
from datetime import datetime
from typing import Optional
import chainlit as cl
import ollama
import fitz # PyMuPDF
from qdrant_client import AsyncQdrantClient
from qdrant_client.models import PointStruct, Distance, VectorParams
from chainlit.data.sql_alchemy import SQLAlchemyDataLayer
from authlib.integrations.httpx_client import AsyncOAuth2Client
from authlib.integrations.starlette_client import OAuth
# === CONFIGURAZIONE ===
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://ai_user:secure_password_here@postgres:5432/ai_station")
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.1.243:11434")
QDRANT_URL = os.getenv("QDRANT_URL", "http://qdrant:6333")
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID", "")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET", "")
# === INIZIALIZZAZIONE DATA LAYER ===
try:
data_layer = SQLAlchemyDataLayer(conninfo=DATABASE_URL)
cl.data_layer = data_layer
print("✅ SQLAlchemyDataLayer initialized successfully")
except Exception as e:
print(f"❌ Failed to initialize data layer: {e}")
cl.data_layer = None
# === OAUTH2 SETUP ===
oauth = OAuth()
oauth.register(
name='google',
client_id=GOOGLE_CLIENT_ID,
client_secret=GOOGLE_CLIENT_SECRET,
server_metadata_url='https://accounts.google.com/.well-known/openid-configuration',
client_kwargs={'scope': 'openid profile email'}
)
WORKSPACES_DIR = "./workspaces"
# === UTILITY FUNCTIONS ===
def create_workspace(user_email: str):
"""Crea directory workspace se non esiste"""
# Usa email come identifier (sostituisce caratteri problematici)
safe_email = user_email.replace("@", "_").replace(".", "_")
workspace_path = os.path.join(WORKSPACES_DIR, safe_email)
os.makedirs(workspace_path, exist_ok=True)
return workspace_path
def save_code_to_file(code: str, user_email: str) -> str:
"""Salva blocco codice come file .py"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
file_name = f"code_{timestamp}.py"
safe_email = user_email.replace("@", "_").replace(".", "_")
file_path = os.path.join(WORKSPACES_DIR, safe_email, file_name)
with open(file_path, "w", encoding="utf-8") as f:
f.write(code)
return file_path
def extract_text_from_pdf(pdf_path: str) -> str:
"""Estrae testo da PDF usando PyMuPDF"""
try:
doc = fitz.open(pdf_path)
text_parts = []
for page_num in range(len(doc)):
page = doc[page_num]
text = page.get_text()
text_parts.append(f"--- Pagina {page_num + 1} ---\n{text}\n")
doc.close()
return "\n".join(text_parts)
except Exception as e:
print(f"❌ Errore estrazione PDF: {e}")
return ""
# === QDRANT FUNCTIONS ===
async def get_qdrant_client() -> AsyncQdrantClient:
"""Connessione a Qdrant"""
client = AsyncQdrantClient(url=QDRANT_URL)
collection_name = "documents"
# Crea collection se non esiste
if not await client.collection_exists(collection_name):
await client.create_collection(
collection_name=collection_name,
vectors_config=VectorParams(size=768, distance=Distance.COSINE)
)
return client
async def get_embeddings(text: str) -> list:
"""Genera embeddings con Ollama"""
client = ollama.Client(host=OLLAMA_URL)
# Limita lunghezza per evitare errori
max_length = 2000
if len(text) > max_length:
text = text[:max_length]
try:
response = client.embed(model='nomic-embed-text', input=text)
if 'embeddings' in response:
return response['embeddings'][0]
return response.get('embedding', [])
except Exception as e:
print(f"❌ Errore Embedding: {e}")
return []
async def index_document(file_name: str, content: str) -> bool:
"""Indicizza documento su Qdrant"""
try:
# Suddividi documento lungo in chunks
chunks = chunk_text(content, max_length=1500)
qdrant_client = await get_qdrant_client()
points = []
for i, chunk in enumerate(chunks):
embeddings = await get_embeddings(chunk)
if not embeddings:
continue
point_id = str(uuid.uuid4())
point = PointStruct(
id=point_id,
vector=embeddings,
payload={
"file_name": file_name,
"content": chunk,
"chunk_index": i,
"total_chunks": len(chunks),
"indexed_at": datetime.now().isoformat()
}
)
points.append(point)
if points:
await qdrant_client.upsert(collection_name="documents", points=points)
return True
return False
except Exception as e:
print(f"❌ Errore indicizzazione: {e}")
return False
def chunk_text(text: str, max_length: int = 1500, overlap: int = 200) -> list:
"""Divide testo in chunks con overlap"""
if len(text) <= max_length:
return [text]
chunks = []
start = 0
while start < len(text):
end = start + max_length
# Cerca l'ultimo punto/newline prima del limite
if end < len(text):
last_period = text.rfind('.', start, end)
last_newline = text.rfind('\n', start, end)
split_point = max(last_period, last_newline)
if split_point > start:
end = split_point + 1
chunks.append(text[start:end].strip())
start = end - overlap # Overlap per continuità
return chunks
async def search_qdrant(query_text: str, limit: int = 5) -> str:
"""Ricerca documenti rilevanti"""
try:
qdrant_client = await get_qdrant_client()
query_embedding = await get_embeddings(query_text)
if not query_embedding:
return ""
search_result = await qdrant_client.query_points(
collection_name="documents",
query=query_embedding,
limit=limit
)
contexts = []
seen_files = set()
for hit in search_result.points:
if hit.payload:
file_name = hit.payload.get('file_name', 'Unknown')
content = hit.payload.get('content', '')
chunk_idx = hit.payload.get('chunk_index', 0)
score = hit.score if hasattr(hit, 'score') else 0
# Evita duplicati dello stesso file
file_key = f"{file_name}_{chunk_idx}"
if file_key not in seen_files:
seen_files.add(file_key)
contexts.append(
f"📄 **{file_name}** (chunk {chunk_idx+1}, score: {score:.2f})\n"
f"```\n{content[:600]}...\n```"
)
return "\n\n".join(contexts) if contexts else ""
except Exception as e:
print(f"❌ Errore ricerca Qdrant: {e}")
return ""
# === CHAINLIT HANDLERS ===
@cl.oauth_callback
def oauth_callback(provider_id: str, token: dict, raw_user_data: dict, question_filter) -> Optional[cl.User]:
"""Callback OAuth2 per autenticazione Google"""
if provider_id == "google":
user_email = raw_user_data.get("email", "")
user_name = raw_user_data.get("name", "User")
# Crea/recupera utente
user = cl.User(
identifier=user_email,
metadata={
"email": user_email,
"name": user_name,
"picture": raw_user_data.get("picture", ""),
"provider": "google"
}
)
# Crea workspace per l'utente
create_workspace(user_email)
return user
return None
@cl.on_chat_start
async def on_chat_start():
"""Inizializzazione chat"""
# Recupera user da OAuth2
user = cl.user_session.get("user")
if user:
user_email = user.identifier
user_name = user.metadata.get("name", "User")
# Crea workspace
create_workspace(user_email)
# Salva nella sessione
cl.user_session.set("email", user_email)
cl.user_session.set("name", user_name)
# Verifica persistenza
persistence_status = "✅ Attiva" if cl.data_layer else "⚠️ Disattivata"
await cl.Message(
content=f"👋 **Benvenuto, {user_name}!**\n\n"
f"🚀 **AI Station Ready**\n"
f"📤 Upload **PDF** o **.txt** per indicizzarli nel RAG\n"
f"💾 Persistenza conversazioni: {persistence_status}\n"
f"🤖 Modello: `glm-4.6:cloud` @ {OLLAMA_URL}\n\n"
f"💡 **Supporto PDF attivo**: Carica fatture, F24, dichiarazioni fiscali!"
).send()
else:
await cl.Message(
content="❌ Autenticazione fallita. Riprova."
).send()
@cl.on_message
async def on_message(message: cl.Message):
"""Gestione messaggi utente"""
user_email = cl.user_session.get("email", "guest")
user_name = cl.user_session.get("name", "User")
try:
# === STEP 1: Gestione Upload ===
if message.elements:
await handle_file_uploads(message.elements, user_email)
# === STEP 2: RAG Search ===
context_text = await search_qdrant(message.content, limit=5)
# === STEP 3: Preparazione Prompt ===
system_prompt = (
"Sei un assistente AI esperto in analisi documentale e fiscale. "
"Usa ESCLUSIVAMENTE il seguente contesto per rispondere. "
"Se la risposta non è nel contesto, dillo chiaramente."
)
if context_text:
full_prompt = f"{system_prompt}\n\n**CONTESTO DOCUMENTI:**\n{context_text}\n\n**DOMANDA UTENTE:**\n{message.content}"
else:
full_prompt = f"{system_prompt}\n\n**DOMANDA UTENTE:**\n{message.content}"
# === STEP 4: Usa glm-4.6:cloud ===
client = ollama.Client(host=OLLAMA_URL)
msg = cl.Message(content="")
await msg.send()
messages = [{"role": "user", "content": full_prompt}]
stream = client.chat(
model='glm-4.6:cloud',
messages=messages,
stream=True
)
full_response = ""
for chunk in stream:
content = chunk['message']['content']
full_response += content
await msg.stream_token(content)
await msg.update()
# === STEP 5: Estrai e Salva Codice ===
code_blocks = re.findall(r"```python\n(.*?)```", full_response, re.DOTALL)
if code_blocks:
elements = []
for code in code_blocks:
file_path = save_code_to_file(code.strip(), user_email)
elements.append(
cl.File(
name=os.path.basename(file_path),
path=file_path,
display="inline"
)
)
await cl.Message(
content=f"💾 Codice salvato in workspace",
elements=elements
).send()
except Exception as e:
await cl.Message(content=f"❌ **Errore:** {str(e)}").send()
async def handle_file_uploads(elements, user_email: str):
"""Gestisce upload e indicizzazione file (TXT e PDF)"""
for element in elements:
try:
# Salva file
safe_email = user_email.replace("@", "_").replace(".", "_")
dest_path = os.path.join(WORKSPACES_DIR, safe_email, element.name)
shutil.copy(element.path, dest_path)
content = None
# Estrai testo in base al tipo di file
if element.name.lower().endswith('.pdf'):
await cl.Message(content=f"📄 Elaborazione PDF **{element.name}**...").send()
content = extract_text_from_pdf(dest_path)
if not content:
await cl.Message(
content=f"⚠️ **{element.name}**: PDF vuoto o non leggibile"
).send()
continue
elif element.name.lower().endswith('.txt'):
with open(dest_path, 'r', encoding='utf-8') as f:
content = f.read()
else:
await cl.Message(
content=f"📁 **{element.name}** salvato (supportati: .pdf, .txt)"
).send()
continue
# Indicizza su Qdrant
if content:
success = await index_document(element.name, content)
if success:
word_count = len(content.split())
await cl.Message(
content=f"✅ **{element.name}** indicizzato in Qdrant\n"
f"📊 Parole estratte: {word_count:,}"
).send()
else:
await cl.Message(
content=f"⚠️ Errore indicizzazione **{element.name}**"
).send()
except Exception as e:
await cl.Message(
content=f"❌ Errore con **{element.name}**: {str(e)}"
).send()

451
app.py
View File

@ -3,49 +3,181 @@ import re
import uuid import uuid
import shutil import shutil
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional, Dict, List
import chainlit as cl import chainlit as cl
import ollama import ollama
import fitz # PyMuPDF import fitz # PyMuPDF
from qdrant_client import AsyncQdrantClient from qdrant_client import AsyncQdrantClient
from qdrant_client.models import PointStruct, Distance, VectorParams from qdrant_client.models import PointStruct, Distance, VectorParams
from chainlit.data.sql_alchemy import SQLAlchemyDataLayer from chainlit.data.sql_alchemy import SQLAlchemyDataLayer
from chainlit.data.storage_clients import BaseStorageClient
# === CONFIGURAZIONE === # === CONFIGURAZIONE ===
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://ai_user:secure_password_here@postgres:5432/ai_station") DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://ai_user:secure_password_here@postgres:5432/ai_station")
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.1.243:11434") OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.1.243:11434")
QDRANT_URL = os.getenv("QDRANT_URL", "http://qdrant:6333") QDRANT_URL = os.getenv("QDRANT_URL", "http://qdrant:6333")
WORKSPACES_DIR = "./workspaces"
STORAGE_DIR = "./.files"
os.makedirs(STORAGE_DIR, exist_ok=True)
os.makedirs(WORKSPACES_DIR, exist_ok=True)
# === MAPPING UTENTI E RUOLI ===
USER_PROFILES = {
"giuseppe@defranceschi.pro": {
"role": "admin",
"name": "Giuseppe",
"workspace": "admin_workspace",
"rag_collection": "admin_docs",
"capabilities": ["debug", "system_prompts", "user_management", "all_models"],
"show_code": True
},
"giuseppe.defranceschi@gmail.com": {
"role": "admin",
"name": "Giuseppe",
"workspace": "admin_workspace",
"rag_collection": "admin_docs",
"capabilities": ["debug", "system_prompts", "user_management", "all_models"],
"show_code": True
},
"federica.tecchio@gmail.com": {
"role": "business",
"name": "Federica",
"workspace": "business_workspace",
"rag_collection": "contabilita",
"capabilities": ["pdf_upload", "basic_chat"],
"show_code": False
},
"riccardob545@gmail.com": {
"role": "engineering",
"name": "Riccardo",
"workspace": "engineering_workspace",
"rag_collection": "engineering_docs",
"capabilities": ["code_execution", "data_viz", "advanced_chat"],
"show_code": True
},
"giuliadefranceschi05@gmail.com": {
"role": "architecture",
"name": "Giulia",
"workspace": "architecture_workspace",
"rag_collection": "architecture_manuals",
"capabilities": ["visual_chat", "pdf_upload", "image_gen"],
"show_code": False
}
}
# === CUSTOM LOCAL STORAGE CLIENT ===
class LocalStorageClient(BaseStorageClient):
"""Storage locale su filesystem per file/elementi"""
def __init__(self, storage_path: str):
self.storage_path = storage_path
os.makedirs(storage_path, exist_ok=True)
async def upload_file(
self,
object_key: str,
data: bytes,
mime: str = "application/octet-stream",
overwrite: bool = True,
) -> Dict[str, str]:
"""Salva file localmente"""
file_path = os.path.join(self.storage_path, object_key)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "wb") as f:
f.write(data)
return {
"object_key": object_key,
"url": f"/files/{object_key}"
}
# === INIZIALIZZAZIONE DATA LAYER === # === INIZIALIZZAZIONE DATA LAYER ===
print("🔧 Inizializzazione database...")
storage_client = LocalStorageClient(storage_path=STORAGE_DIR)
try: try:
data_layer = SQLAlchemyDataLayer(conninfo=DATABASE_URL) data_layer = SQLAlchemyDataLayer(
conninfo=DATABASE_URL,
storage_provider=storage_client,
user_thread_limit=1000,
show_logger=False
)
# ⬇️ QUESTA RIGA È CRUCIALE PER LA PERSISTENZA
cl.data_layer = data_layer cl.data_layer = data_layer
print("✅ SQLAlchemyDataLayer initialized successfully") print("✅ SQLAlchemyDataLayer + LocalStorage initialized successfully")
print(f"✅ Data layer set: {cl.data_layer is not None}")
except Exception as e: except Exception as e:
print(f"❌ Failed to initialize data layer: {e}") print(f"❌ Failed to initialize data layer: {e}")
cl.data_layer = None cl.data_layer = None
WORKSPACES_DIR = "./workspaces" # === OAUTH CALLBACK CON RUOLI ===
USER_ROLE = "admin" @cl.oauth_callback
def oauth_callback(
provider_id: str,
token: str,
raw_user_data: Dict[str, str],
default_user: cl.User,
) -> Optional[cl.User]:
"""Validazione e arricchimento dati utente con ruoli"""
if provider_id == "google":
email = raw_user_data.get("email", "").lower()
# Verifica se utente è autorizzato
if email not in USER_PROFILES:
print(f"❌ Utente non autorizzato: {email}")
return None # Nega accesso
# Arricchisci metadata con profilo
profile = USER_PROFILES[email]
default_user.metadata.update({
"picture": raw_user_data.get("picture", ""),
"locale": raw_user_data.get("locale", "en"),
"role": profile["role"],
"workspace": profile["workspace"],
"rag_collection": profile["rag_collection"],
"capabilities": profile["capabilities"],
"show_code": profile["show_code"],
"display_name": profile["name"]
})
print(f"✅ Utente autorizzato: {email} - Ruolo: {profile['role']}")
return default_user
return default_user
# === UTILITY FUNCTIONS === # === UTILITY FUNCTIONS ===
def create_workspace(user_role: str): def get_user_profile(user_email: str) -> Dict:
"""Recupera profilo utente"""
return USER_PROFILES.get(user_email.lower(), {
"role": "guest",
"name": "Ospite",
"workspace": "guest_workspace",
"rag_collection": "documents",
"capabilities": [],
"show_code": False
})
def create_workspace(workspace_name: str) -> str:
"""Crea directory workspace se non esiste""" """Crea directory workspace se non esiste"""
workspace_path = os.path.join(WORKSPACES_DIR, user_role) workspace_path = os.path.join(WORKSPACES_DIR, workspace_name)
os.makedirs(workspace_path, exist_ok=True) os.makedirs(workspace_path, exist_ok=True)
return workspace_path return workspace_path
def save_code_to_file(code: str, user_role: str) -> str: def save_code_to_file(code: str, workspace: str) -> str:
"""Salva blocco codice come file .py""" """Salva blocco codice come file .py"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
file_name = f"code_{timestamp}.py" file_name = f"code_{timestamp}.py"
file_path = os.path.join(WORKSPACES_DIR, user_role, file_name) file_path = os.path.join(WORKSPACES_DIR, workspace, file_name)
with open(file_path, "w", encoding="utf-8") as f: with open(file_path, "w", encoding="utf-8") as f:
f.write(code) f.write(code)
@ -66,7 +198,6 @@ def extract_text_from_pdf(pdf_path: str) -> str:
doc.close() doc.close()
return "\n".join(text_parts) return "\n".join(text_parts)
except Exception as e: except Exception as e:
print(f"❌ Errore estrazione PDF: {e}") print(f"❌ Errore estrazione PDF: {e}")
return "" return ""
@ -75,46 +206,67 @@ def extract_text_from_pdf(pdf_path: str) -> str:
# === QDRANT FUNCTIONS === # === QDRANT FUNCTIONS ===
async def get_qdrant_client() -> AsyncQdrantClient: async def get_qdrant_client() -> AsyncQdrantClient:
"""Connessione a Qdrant""" """Connessione a Qdrant"""
client = AsyncQdrantClient(url=QDRANT_URL) return AsyncQdrantClient(url=QDRANT_URL)
collection_name = "documents"
# Crea collection se non esiste
async def ensure_collection(collection_name: str):
"""Crea collection se non esiste"""
client = await get_qdrant_client()
if not await client.collection_exists(collection_name): if not await client.collection_exists(collection_name):
await client.create_collection( await client.create_collection(
collection_name=collection_name, collection_name=collection_name,
vectors_config=VectorParams(size=768, distance=Distance.COSINE) vectors_config=VectorParams(size=768, distance=Distance.COSINE)
) )
return client
async def get_embeddings(text: str) -> list: async def get_embeddings(text: str) -> list:
"""Genera embeddings con Ollama""" """Genera embeddings con Ollama"""
client = ollama.Client(host=OLLAMA_URL)
# Limita lunghezza per evitare errori
max_length = 2000 max_length = 2000
if len(text) > max_length: if len(text) > max_length:
text = text[:max_length] text = text[:max_length]
client = ollama.Client(host=OLLAMA_URL)
try: try:
response = client.embed(model='nomic-embed-text', input=text) response = client.embed(model='nomic-embed-text', input=text)
if 'embeddings' in response: if 'embeddings' in response:
return response['embeddings'][0] return response['embeddings'][0]
return response.get('embedding', []) return response.get('embedding', [])
except Exception as e: except Exception as e:
print(f"❌ Errore Embedding: {e}") print(f"❌ Errore Embedding: {e}")
return [] return []
async def index_document(file_name: str, content: str) -> bool: def chunk_text(text: str, max_length: int = 1500, overlap: int = 200) -> list:
"""Indicizza documento su Qdrant""" """Divide testo in chunks con overlap"""
try: if len(text) <= max_length:
# Suddividi documento lungo in chunks return [text]
chunks = chunk_text(content, max_length=1500)
chunks = []
start = 0
while start < len(text):
end = start + max_length
if end < len(text):
last_period = text.rfind('.', start, end)
last_newline = text.rfind('\n', start, end)
split_point = max(last_period, last_newline)
if split_point > start:
end = split_point + 1
chunks.append(text[start:end].strip())
start = end - overlap
return chunks
async def index_document(file_name: str, content: str, collection_name: str) -> bool:
"""Indicizza documento su Qdrant in collection specifica"""
try:
await ensure_collection(collection_name)
chunks = chunk_text(content, max_length=1500)
qdrant_client = await get_qdrant_client() qdrant_client = await get_qdrant_client()
points = [] points = []
@ -138,53 +290,31 @@ async def index_document(file_name: str, content: str) -> bool:
points.append(point) points.append(point)
if points: if points:
await qdrant_client.upsert(collection_name="documents", points=points) await qdrant_client.upsert(collection_name=collection_name, points=points)
return True return True
return False return False
except Exception as e: except Exception as e:
print(f"❌ Errore indicizzazione: {e}") print(f"❌ Errore indicizzazione: {e}")
return False return False
def chunk_text(text: str, max_length: int = 1500, overlap: int = 200) -> list: async def search_qdrant(query_text: str, collection_name: str, limit: int = 5) -> str:
"""Divide testo in chunks con overlap""" """Ricerca documenti rilevanti in collection specifica"""
if len(text) <= max_length:
return [text]
chunks = []
start = 0
while start < len(text):
end = start + max_length
# Cerca l'ultimo punto/newline prima del limite
if end < len(text):
last_period = text.rfind('.', start, end)
last_newline = text.rfind('\n', start, end)
split_point = max(last_period, last_newline)
if split_point > start:
end = split_point + 1
chunks.append(text[start:end].strip())
start = end - overlap # Overlap per continuità
return chunks
async def search_qdrant(query_text: str, limit: int = 5) -> str:
"""Ricerca documenti rilevanti"""
try: try:
qdrant_client = await get_qdrant_client() qdrant_client = await get_qdrant_client()
# Verifica se collection esiste
if not await qdrant_client.collection_exists(collection_name):
return ""
query_embedding = await get_embeddings(query_text) query_embedding = await get_embeddings(query_text)
if not query_embedding: if not query_embedding:
return "" return ""
search_result = await qdrant_client.query_points( search_result = await qdrant_client.query_points(
collection_name="documents", collection_name=collection_name,
query=query_embedding, query=query_embedding,
limit=limit limit=limit
) )
@ -199,17 +329,16 @@ async def search_qdrant(query_text: str, limit: int = 5) -> str:
chunk_idx = hit.payload.get('chunk_index', 0) chunk_idx = hit.payload.get('chunk_index', 0)
score = hit.score if hasattr(hit, 'score') else 0 score = hit.score if hasattr(hit, 'score') else 0
# Evita duplicati dello stesso file
file_key = f"{file_name}_{chunk_idx}" file_key = f"{file_name}_{chunk_idx}"
if file_key not in seen_files: if file_key not in seen_files:
seen_files.add(file_key) seen_files.add(file_key)
# ✅ FIX: Markdown code block corretto
contexts.append( contexts.append(
f"📄 **{file_name}** (chunk {chunk_idx+1}, score: {score:.2f})\n" f"📄 **{file_name}** (chunk {chunk_idx+1}, score: {score:.2f})\n"
f"```\n{content[:600]}...\n```" f"``````"
) )
return "\n\n".join(contexts) if contexts else "" return "\n\n".join(contexts) if contexts else ""
except Exception as e: except Exception as e:
print(f"❌ Errore ricerca Qdrant: {e}") print(f"❌ Errore ricerca Qdrant: {e}")
return "" return ""
@ -218,61 +347,170 @@ async def search_qdrant(query_text: str, limit: int = 5) -> str:
# === CHAINLIT HANDLERS === # === CHAINLIT HANDLERS ===
@cl.on_chat_start @cl.on_chat_start
async def on_chat_start(): async def on_chat_start():
"""Inizializzazione chat""" """Inizializzazione chat con profili utente"""
create_workspace(USER_ROLE) user = cl.user_session.get("user")
# Imposta variabili sessione if user:
cl.user_session.set("role", USER_ROLE) user_email = user.identifier
profile = get_user_profile(user_email)
user_name = profile["name"]
user_role = profile["role"]
workspace = profile["workspace"]
user_picture = user.metadata.get("picture", "")
show_code = profile["show_code"]
capabilities = profile["capabilities"]
else:
user_email = "guest@local"
user_name = "Ospite"
user_role = "guest"
workspace = "guest_workspace"
user_picture = ""
show_code = False
capabilities = []
create_workspace(workspace)
# Salva in sessione
cl.user_session.set("email", user_email)
cl.user_session.set("name", user_name)
cl.user_session.set("role", user_role)
cl.user_session.set("workspace", workspace)
cl.user_session.set("show_code", show_code)
cl.user_session.set("capabilities", capabilities)
cl.user_session.set("rag_collection", profile.get("rag_collection", "documents"))
# Settings basati su ruolo
settings_widgets = [
cl.input_widget.Select(
id="model",
label="🤖 Modello AI",
values=["glm-4.6:cloud", "llama3.2", "mistral", "qwen2.5-coder:32b"],
initial_value="glm-4.6:cloud",
),
cl.input_widget.Slider(
id="temperature",
label="🌡️ Temperatura",
initial=0.7,
min=0,
max=2,
step=0.1,
),
]
# Solo admin può disabilitare RAG
if user_role == "admin":
settings_widgets.append(
cl.input_widget.Switch(
id="rag_enabled",
label="📚 Abilita RAG",
initial=True,
)
)
# ⬇️ INVIA SETTINGS (questo attiva l'icona ⚙️)
await cl.ChatSettings(settings_widgets).send()
# Emoji ruolo
role_emoji = {
"admin": "👑",
"business": "💼",
"engineering": "⚙️",
"architecture": "🏛️",
"guest": "👤"
}
# Verifica persistenza
persistence_status = "✅ Attiva" if cl.data_layer else "⚠️ Disattivata" persistence_status = "✅ Attiva" if cl.data_layer else "⚠️ Disattivata"
welcome_msg = f"{role_emoji.get(user_role, '👋')} **Benvenuto, {user_name}!**\n\n"
if user_picture:
welcome_msg += f"![Avatar]({user_picture})\n\n"
welcome_msg += (
f"🎭 **Ruolo**: {user_role.upper()}\n"
f"📁 **Workspace**: `{workspace}`\n"
f"💾 **Persistenza**: {persistence_status}\n"
f"🤖 **Modello**: `glm-4.6:cloud`\n\n"
)
# Capabilities specifiche
if "debug" in capabilities:
welcome_msg += "🔧 **Modalità Debug**: Attiva\n"
if "user_management" in capabilities:
welcome_msg += "👥 **Gestione Utenti**: Disponibile\n"
if not show_code:
welcome_msg += "🎨 **Modalità Visuale**: Codice nascosto\n"
welcome_msg += "\n⚙️ **Usa le Settings (icona ⚙️ in alto a destra) per personalizzare!**"
await cl.Message(content=welcome_msg).send()
@cl.on_settings_update
async def on_settings_update(settings):
"""Gestisce aggiornamento settings utente"""
cl.user_session.set("settings", settings)
model = settings.get("model", "glm-4.6:cloud")
temp = settings.get("temperature", 0.7)
rag = settings.get("rag_enabled", True)
await cl.Message( await cl.Message(
content=f"🚀 **AI Station Ready** - Workspace: `{USER_ROLE}`\n\n" content=f"✅ **Settings aggiornati**:\n"
f"📤 Upload **PDF** o **.txt** per indicizzarli nel RAG\n" f"- 🤖 Modello: `{model}`\n"
f"💾 Persistenza conversazioni: {persistence_status}\n" f"- 🌡️ Temperatura: `{temp}`\n"
f"🤖 Modello: `glm-4.6:cloud` @ {OLLAMA_URL}\n\n" f"- 📚 RAG: {'✅ Attivo' if rag else '❌ Disattivato'}"
f"💡 **Supporto PDF attivo**: Carica fatture, F24, dichiarazioni fiscali!"
).send() ).send()
@cl.on_message @cl.on_message
async def on_message(message: cl.Message): async def on_message(message: cl.Message):
"""Gestione messaggi utente""" """Gestione messaggi utente con RAG intelligente"""
user_email = cl.user_session.get("email", "guest")
user_role = cl.user_session.get("role", "guest") user_role = cl.user_session.get("role", "guest")
workspace = cl.user_session.get("workspace", "guest_workspace")
show_code = cl.user_session.get("show_code", False)
rag_collection = cl.user_session.get("rag_collection", "documents")
settings = cl.user_session.get("settings", {})
model = settings.get("model", "glm-4.6:cloud")
temperature = settings.get("temperature", 0.7)
# Admin può disabilitare RAG, altri lo hanno sempre attivo
rag_enabled = settings.get("rag_enabled", True) if user_role == "admin" else True
try: try:
# === STEP 1: Gestione Upload === # Gestisci upload file
if message.elements: if message.elements:
await handle_file_uploads(message.elements, user_role) await handle_file_uploads(message.elements, workspace, rag_collection)
# === STEP 2: RAG Search === # RAG Search solo se abilitato
context_text = await search_qdrant(message.content, limit=5) context_text = ""
if rag_enabled:
# === STEP 3: Preparazione Prompt === context_text = await search_qdrant(message.content, rag_collection, limit=5)
system_prompt = (
"Sei un assistente AI esperto in analisi documentale e fiscale. "
"Usa ESCLUSIVAMENTE il seguente contesto per rispondere. "
"Se la risposta non è nel contesto, dillo chiaramente."
)
# Costruisci prompt con o senza contesto
if context_text: if context_text:
system_prompt = (
"Sei un assistente AI esperto. "
"Usa il seguente contesto per arricchire la tua risposta, "
"ma puoi anche rispondere usando la tua conoscenza generale se il contesto non è sufficiente."
)
full_prompt = f"{system_prompt}\n\n**CONTESTO DOCUMENTI:**\n{context_text}\n\n**DOMANDA UTENTE:**\n{message.content}" full_prompt = f"{system_prompt}\n\n**CONTESTO DOCUMENTI:**\n{context_text}\n\n**DOMANDA UTENTE:**\n{message.content}"
else: else:
system_prompt = "Sei un assistente AI esperto e disponibile. Rispondi in modo chiaro e utile."
full_prompt = f"{system_prompt}\n\n**DOMANDA UTENTE:**\n{message.content}" full_prompt = f"{system_prompt}\n\n**DOMANDA UTENTE:**\n{message.content}"
# === STEP 4: Usa glm-4.6:cloud === # Streaming risposta da Ollama
client = ollama.Client(host=OLLAMA_URL) client = ollama.Client(host=OLLAMA_URL)
msg = cl.Message(content="") msg = cl.Message(content="")
await msg.send() await msg.send()
messages = [{"role": "user", "content": full_prompt}] messages = [{"role": "user", "content": full_prompt}]
stream = client.chat( stream = client.chat(
model='glm-4.6:cloud', model=model,
messages=messages, messages=messages,
stream=True stream=True,
options={"temperature": temperature}
) )
full_response = "" full_response = ""
@ -283,23 +521,36 @@ async def on_message(message: cl.Message):
await msg.update() await msg.update()
# === STEP 5: Estrai e Salva Codice === # ✅ FIX: Estrai codice Python con regex corretto
code_blocks = re.findall(r"```python\n(.*?)```", full_response, re.DOTALL) code_blocks = re.findall(r"``````", full_response, re.DOTALL)
if code_blocks: if code_blocks:
elements = [] elements = []
# Se show_code è False, nascondi il codice dalla risposta
if not show_code:
cleaned_response = re.sub(
r"``````",
"[💻 Codice eseguito internamente]",
full_response,
flags=re.DOTALL
)
await msg.update(content=cleaned_response)
# Salva codice nel workspace
for code in code_blocks: for code in code_blocks:
file_path = save_code_to_file(code.strip(), user_role) file_path = save_code_to_file(code.strip(), workspace)
elements.append( elements.append(
cl.File( cl.File(
name=os.path.basename(file_path), name=os.path.basename(file_path),
path=file_path, path=file_path,
display="inline" display="inline" if show_code else "side"
) )
) )
if show_code:
await cl.Message( await cl.Message(
content=f"💾 Codice salvato in `{user_role}/`", content=f"💾 Codice salvato in workspace `{workspace}`",
elements=elements elements=elements
).send() ).send()
@ -307,17 +558,15 @@ async def on_message(message: cl.Message):
await cl.Message(content=f"❌ **Errore:** {str(e)}").send() await cl.Message(content=f"❌ **Errore:** {str(e)}").send()
async def handle_file_uploads(elements, user_role: str): async def handle_file_uploads(elements, workspace: str, collection_name: str):
"""Gestisce upload e indicizzazione file (TXT e PDF)""" """Gestisce upload e indicizzazione file in collection specifica"""
for element in elements: for element in elements:
try: try:
# Salva file dest_path = os.path.join(WORKSPACES_DIR, workspace, element.name)
dest_path = os.path.join(WORKSPACES_DIR, user_role, element.name)
shutil.copy(element.path, dest_path) shutil.copy(element.path, dest_path)
content = None content = None
# Estrai testo in base al tipo di file
if element.name.lower().endswith('.pdf'): if element.name.lower().endswith('.pdf'):
await cl.Message(content=f"📄 Elaborazione PDF **{element.name}**...").send() await cl.Message(content=f"📄 Elaborazione PDF **{element.name}**...").send()
content = extract_text_from_pdf(dest_path) content = extract_text_from_pdf(dest_path)
@ -331,21 +580,19 @@ async def handle_file_uploads(elements, user_role: str):
elif element.name.lower().endswith('.txt'): elif element.name.lower().endswith('.txt'):
with open(dest_path, 'r', encoding='utf-8') as f: with open(dest_path, 'r', encoding='utf-8') as f:
content = f.read() content = f.read()
else: else:
await cl.Message( await cl.Message(
content=f"📁 **{element.name}** salvato (supportati: .pdf, .txt)" content=f"📁 **{element.name}** salvato in workspace (supportati: .pdf, .txt)"
).send() ).send()
continue continue
# Indicizza su Qdrant
if content: if content:
success = await index_document(element.name, content) success = await index_document(element.name, content, collection_name)
if success: if success:
word_count = len(content.split()) word_count = len(content.split())
await cl.Message( await cl.Message(
content=f"✅ **{element.name}** indicizzato in Qdrant\n" content=f"✅ **{element.name}** indicizzato in `{collection_name}`\n"
f"📊 Parole estratte: {word_count:,}" f"📊 Parole estratte: {word_count:,}"
).send() ).send()
else: else:

359
app.py.backup Normal file
View File

@ -0,0 +1,359 @@
import os
import re
import uuid
import shutil
from datetime import datetime
from typing import Optional
import chainlit as cl
import ollama
import fitz # PyMuPDF
from qdrant_client import AsyncQdrantClient
from qdrant_client.models import PointStruct, Distance, VectorParams
from chainlit.data.sql_alchemy import SQLAlchemyDataLayer
# === CONFIGURAZIONE ===
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://ai_user:secure_password_here@postgres:5432/ai_station")
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://192.168.1.243:11434")
QDRANT_URL = os.getenv("QDRANT_URL", "http://qdrant:6333")
# === INIZIALIZZAZIONE DATA LAYER ===
try:
data_layer = SQLAlchemyDataLayer(conninfo=DATABASE_URL)
cl.data_layer = data_layer
print("✅ SQLAlchemyDataLayer initialized successfully")
except Exception as e:
print(f"❌ Failed to initialize data layer: {e}")
cl.data_layer = None
WORKSPACES_DIR = "./workspaces"
USER_ROLE = "admin"
# === UTILITY FUNCTIONS ===
def create_workspace(user_role: str):
"""Crea directory workspace se non esiste"""
workspace_path = os.path.join(WORKSPACES_DIR, user_role)
os.makedirs(workspace_path, exist_ok=True)
return workspace_path
def save_code_to_file(code: str, user_role: str) -> str:
"""Salva blocco codice come file .py"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
file_name = f"code_{timestamp}.py"
file_path = os.path.join(WORKSPACES_DIR, user_role, file_name)
with open(file_path, "w", encoding="utf-8") as f:
f.write(code)
return file_path
def extract_text_from_pdf(pdf_path: str) -> str:
"""Estrae testo da PDF usando PyMuPDF"""
try:
doc = fitz.open(pdf_path)
text_parts = []
for page_num in range(len(doc)):
page = doc[page_num]
text = page.get_text()
text_parts.append(f"--- Pagina {page_num + 1} ---\n{text}\n")
doc.close()
return "\n".join(text_parts)
except Exception as e:
print(f"❌ Errore estrazione PDF: {e}")
return ""
# === QDRANT FUNCTIONS ===
async def get_qdrant_client() -> AsyncQdrantClient:
"""Connessione a Qdrant"""
client = AsyncQdrantClient(url=QDRANT_URL)
collection_name = "documents"
# Crea collection se non esiste
if not await client.collection_exists(collection_name):
await client.create_collection(
collection_name=collection_name,
vectors_config=VectorParams(size=768, distance=Distance.COSINE)
)
return client
async def get_embeddings(text: str) -> list:
"""Genera embeddings con Ollama"""
client = ollama.Client(host=OLLAMA_URL)
# Limita lunghezza per evitare errori
max_length = 2000
if len(text) > max_length:
text = text[:max_length]
try:
response = client.embed(model='nomic-embed-text', input=text)
if 'embeddings' in response:
return response['embeddings'][0]
return response.get('embedding', [])
except Exception as e:
print(f"❌ Errore Embedding: {e}")
return []
async def index_document(file_name: str, content: str) -> bool:
"""Indicizza documento su Qdrant"""
try:
# Suddividi documento lungo in chunks
chunks = chunk_text(content, max_length=1500)
qdrant_client = await get_qdrant_client()
points = []
for i, chunk in enumerate(chunks):
embeddings = await get_embeddings(chunk)
if not embeddings:
continue
point_id = str(uuid.uuid4())
point = PointStruct(
id=point_id,
vector=embeddings,
payload={
"file_name": file_name,
"content": chunk,
"chunk_index": i,
"total_chunks": len(chunks),
"indexed_at": datetime.now().isoformat()
}
)
points.append(point)
if points:
await qdrant_client.upsert(collection_name="documents", points=points)
return True
return False
except Exception as e:
print(f"❌ Errore indicizzazione: {e}")
return False
def chunk_text(text: str, max_length: int = 1500, overlap: int = 200) -> list:
"""Divide testo in chunks con overlap"""
if len(text) <= max_length:
return [text]
chunks = []
start = 0
while start < len(text):
end = start + max_length
# Cerca l'ultimo punto/newline prima del limite
if end < len(text):
last_period = text.rfind('.', start, end)
last_newline = text.rfind('\n', start, end)
split_point = max(last_period, last_newline)
if split_point > start:
end = split_point + 1
chunks.append(text[start:end].strip())
start = end - overlap # Overlap per continuità
return chunks
async def search_qdrant(query_text: str, limit: int = 5) -> str:
"""Ricerca documenti rilevanti"""
try:
qdrant_client = await get_qdrant_client()
query_embedding = await get_embeddings(query_text)
if not query_embedding:
return ""
search_result = await qdrant_client.query_points(
collection_name="documents",
query=query_embedding,
limit=limit
)
contexts = []
seen_files = set()
for hit in search_result.points:
if hit.payload:
file_name = hit.payload.get('file_name', 'Unknown')
content = hit.payload.get('content', '')
chunk_idx = hit.payload.get('chunk_index', 0)
score = hit.score if hasattr(hit, 'score') else 0
# Evita duplicati dello stesso file
file_key = f"{file_name}_{chunk_idx}"
if file_key not in seen_files:
seen_files.add(file_key)
contexts.append(
f"📄 **{file_name}** (chunk {chunk_idx+1}, score: {score:.2f})\n"
f"```\n{content[:600]}...\n```"
)
return "\n\n".join(contexts) if contexts else ""
except Exception as e:
print(f"❌ Errore ricerca Qdrant: {e}")
return ""
# === CHAINLIT HANDLERS ===
@cl.on_chat_start
async def on_chat_start():
"""Inizializzazione chat"""
create_workspace(USER_ROLE)
# Imposta variabili sessione
cl.user_session.set("role", USER_ROLE)
# Verifica persistenza
persistence_status = "✅ Attiva" if cl.data_layer else "⚠️ Disattivata"
await cl.Message(
content=f"🚀 **AI Station Ready** - Workspace: `{USER_ROLE}`\n\n"
f"📤 Upload **PDF** o **.txt** per indicizzarli nel RAG\n"
f"💾 Persistenza conversazioni: {persistence_status}\n"
f"🤖 Modello: `glm-4.6:cloud` @ {OLLAMA_URL}\n\n"
f"💡 **Supporto PDF attivo**: Carica fatture, F24, dichiarazioni fiscali!"
).send()
@cl.on_message
async def on_message(message: cl.Message):
"""Gestione messaggi utente"""
user_role = cl.user_session.get("role", "guest")
try:
# === STEP 1: Gestione Upload ===
if message.elements:
await handle_file_uploads(message.elements, user_role)
# === STEP 2: RAG Search ===
context_text = await search_qdrant(message.content, limit=5)
# === STEP 3: Preparazione Prompt ===
system_prompt = (
"Sei un assistente AI esperto in analisi documentale e fiscale. "
"Usa ESCLUSIVAMENTE il seguente contesto per rispondere. "
"Se la risposta non è nel contesto, dillo chiaramente."
)
if context_text:
full_prompt = f"{system_prompt}\n\n**CONTESTO DOCUMENTI:**\n{context_text}\n\n**DOMANDA UTENTE:**\n{message.content}"
else:
full_prompt = f"{system_prompt}\n\n**DOMANDA UTENTE:**\n{message.content}"
# === STEP 4: Usa glm-4.6:cloud ===
client = ollama.Client(host=OLLAMA_URL)
msg = cl.Message(content="")
await msg.send()
messages = [{"role": "user", "content": full_prompt}]
stream = client.chat(
model='glm-4.6:cloud',
messages=messages,
stream=True
)
full_response = ""
for chunk in stream:
content = chunk['message']['content']
full_response += content
await msg.stream_token(content)
await msg.update()
# === STEP 5: Estrai e Salva Codice ===
code_blocks = re.findall(r"```python\n(.*?)```", full_response, re.DOTALL)
if code_blocks:
elements = []
for code in code_blocks:
file_path = save_code_to_file(code.strip(), user_role)
elements.append(
cl.File(
name=os.path.basename(file_path),
path=file_path,
display="inline"
)
)
await cl.Message(
content=f"💾 Codice salvato in `{user_role}/`",
elements=elements
).send()
except Exception as e:
await cl.Message(content=f"❌ **Errore:** {str(e)}").send()
async def handle_file_uploads(elements, user_role: str):
"""Gestisce upload e indicizzazione file (TXT e PDF)"""
for element in elements:
try:
# Salva file
dest_path = os.path.join(WORKSPACES_DIR, user_role, element.name)
shutil.copy(element.path, dest_path)
content = None
# Estrai testo in base al tipo di file
if element.name.lower().endswith('.pdf'):
await cl.Message(content=f"📄 Elaborazione PDF **{element.name}**...").send()
content = extract_text_from_pdf(dest_path)
if not content:
await cl.Message(
content=f"⚠️ **{element.name}**: PDF vuoto o non leggibile"
).send()
continue
elif element.name.lower().endswith('.txt'):
with open(dest_path, 'r', encoding='utf-8') as f:
content = f.read()
else:
await cl.Message(
content=f"📁 **{element.name}** salvato (supportati: .pdf, .txt)"
).send()
continue
# Indicizza su Qdrant
if content:
success = await index_document(element.name, content)
if success:
word_count = len(content.split())
await cl.Message(
content=f"✅ **{element.name}** indicizzato in Qdrant\n"
f"📊 Parole estratte: {word_count:,}"
).send()
else:
await cl.Message(
content=f"⚠️ Errore indicizzazione **{element.name}**"
).send()
except Exception as e:
await cl.Message(
content=f"❌ Errore con **{element.name}**: {str(e)}"
).send()

View File

@ -1,49 +1,23 @@
version: '3.8'
services: services:
postgres:
image: postgres:15-alpine
container_name: ai-station-postgres
environment:
POSTGRES_DB: ai_station
POSTGRES_USER: ai_user
POSTGRES_PASSWORD: secure_password_here
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- ai-station-net
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ai_user -d ai_station"] # <- AGGIUNGI -d ai_station
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
qdrant:
image: qdrant/qdrant:latest
container_name: ai-station-qdrant
volumes:
- qdrant_data:/qdrant/storage
ports:
- "6333:6333"
- "6334:6334"
networks:
- ai-station-net
restart: unless-stopped
chainlit-app: chainlit-app:
build: . build: .
container_name: ai-station-app container_name: ai-station-app
ports: ports:
- "8000:8000" - "8000:8000"
env_file:
- .env
environment: environment:
- DATABASE_URL=postgresql+asyncpg://ai_user:secure_password_here@postgres:5432/ai_station - DATABASE_URL=postgresql+asyncpg://ai_user:secure_password_here@postgres:5432/ai_station
- OLLAMA_URL=http://192.168.1.243:11434 - OLLAMA_URL=http://192.168.1.243:11434
- QDRANT_URL=http://qdrant:6333 - QDRANT_URL=http://qdrant:6333
- CHAINLIT_AUTH_SECRET=your-secret-key-here - BGE_API_URL=http://192.168.1.243:8001
volumes: volumes:
- ./workspaces:/app/workspaces - ./workspaces:/app/workspaces
- ./public:/app/public - ./public:/app/public # ⬅️ VERIFICA QUESTO
- ./.files:/app/.files
- ./.chainlit:/app/.chainlit # ⬅️ AGGIUNGI QUESTO
networks: networks:
- ai-station-net - ai-station-net
depends_on: depends_on:
@ -51,18 +25,41 @@ services:
condition: service_healthy condition: service_healthy
qdrant: qdrant:
condition: service_started condition: service_started
command: chainlit run app.py --host 0.0.0.0 --port 8000
restart: unless-stopped restart: unless-stopped
postgres:
image: postgres:15-alpine
container_name: ai-station-postgres
environment:
- POSTGRES_USER=ai_user
- POSTGRES_PASSWORD=secure_password_here
- POSTGRES_DB=ai_station
volumes: volumes:
postgres_data: - postgres_data:/var/lib/postgresql/data
driver: local networks:
qdrant_data: - ai-station-net
driver: local healthcheck:
test: ["CMD-SHELL", "pg_isready -U ai_user -d ai_station"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
qdrant:
image: qdrant/qdrant:latest
container_name: ai-station-qdrant
ports:
- "6333:6333"
volumes:
- qdrant_data:/qdrant/storage
networks:
- ai-station-net
restart: unless-stopped
networks: networks:
ai-station-net: ai-station-net:
driver: bridge driver: bridge
ipam:
config: volumes:
- subnet: 172.28.0.0/16 postgres_data:
qdrant_data:

View File

@ -1,27 +1,27 @@
import asyncio import asyncio
from sqlalchemy import create_engine, text import asyncpg
from chainlit.data.sql_alchemy import SQLAlchemyDataLayer import os
import sys
DATABASE_URL = "postgresql+asyncpg://ai_user:secure_password_here@postgres:5432/ai_station" DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://ai_user:secure_password_here@postgres:5432/ai_station")
# Converti da SQLAlchemy URL a asyncpg
db_url = DATABASE_URL.replace("postgresql+asyncpg://", "postgresql://")
async def init_database(): async def init_database():
"""Inizializza le tabelle per Chainlit"""
print("🔧 Inizializzazione database...") print("🔧 Inizializzazione database...")
try: try:
# Crea data layer conn = await asyncpg.connect(db_url)
data_layer = SQLAlchemyDataLayer(conninfo=DATABASE_URL)
# Forza creazione tabelle # Crea schema se non esiste (Chainlit lo farà automaticamente)
if hasattr(data_layer, '_create_database'): print("✅ Connessione al database riuscita")
await data_layer._create_database() print(" Le tabelle verranno create automaticamente da Chainlit")
print("✅ Database inizializzato con successo")
else:
print("⚠️ Metodo _create_database non disponibile")
print(" Le tabelle verranno create automaticamente al primo utilizzo")
await conn.close()
except Exception as e: except Exception as e:
print(f"❌ Errore: {e}") print(f"❌ Errore connessione database: {e}")
sys.exit(1)
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(init_database()) asyncio.run(init_database())
print("✅ Inizializzazione database completata")

468
public/custom.css Normal file
View File

@ -0,0 +1,468 @@
/* ========================================
AI STATION - PERPLEXITY STYLE UI
======================================== */
/* === ROOT VARIABLES === */
:root {
/* Palette principale (blu professionale) */
--primary-color: #6366F1;
--primary-hover: #4F46E5;
--primary-light: #818CF8;
/* Background dark mode */
--bg-primary: #0F172A;
--bg-secondary: #1E293B;
--bg-tertiary: #334155;
/* Text colors */
--text-primary: #F1F5F9;
--text-secondary: #94A3B8;
--text-muted: #64748B;
/* Accent colors */
--accent-green: #10B981;
--accent-red: #EF4444;
--accent-yellow: #F59E0B;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
/* Border radius */
--radius-sm: 0.375rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
}
/* === BODY & LAYOUT === */
body {
background: var(--bg-primary) !important;
color: var(--text-primary) !important;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif !important;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* === HEADER === */
#app-header {
background: var(--bg-secondary) !important;
border-bottom: 1px solid var(--bg-tertiary) !important;
backdrop-filter: blur(10px);
box-shadow: var(--shadow-sm);
}
/* Logo styling */
#app-header img[alt="logo"] {
border-radius: var(--radius-md);
transition: transform 0.2s ease;
}
#app-header img[alt="logo"]:hover {
transform: scale(1.05);
}
/* === SIDEBAR === */
#chat-history-sidebar {
background: var(--bg-secondary) !important;
border-right: 1px solid var(--bg-tertiary) !important;
}
/* Thread items in sidebar */
.thread-item {
border-radius: var(--radius-md) !important;
margin: 0.25rem 0.5rem !important;
transition: all 0.2s ease !important;
}
.thread-item:hover {
background: var(--bg-tertiary) !important;
transform: translateX(4px);
}
.thread-item.active {
background: var(--primary-color) !important;
color: white !important;
}
/* === CHAT CONTAINER === */
#chat-container {
background: var(--bg-primary) !important;
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem;
}
/* === MESSAGES === */
/* User message */
.user-message {
background: var(--bg-tertiary) !important;
border-radius: var(--radius-lg) !important;
padding: 1rem 1.25rem !important;
margin: 0.75rem 0 !important;
box-shadow: var(--shadow-sm);
border-left: 3px solid var(--primary-color);
}
/* Assistant message */
.assistant-message {
background: var(--bg-secondary) !important;
border-radius: var(--radius-lg) !important;
padding: 1rem 1.25rem !important;
margin: 0.75rem 0 !important;
box-shadow: var(--shadow-md);
}
/* Message avatars */
.message-avatar {
border-radius: 50% !important;
box-shadow: var(--shadow-sm);
border: 2px solid var(--bg-tertiary);
}
/* === CODE BLOCKS === */
pre {
background: #1E1E1E !important;
border-radius: var(--radius-md) !important;
padding: 1rem !important;
border: 1px solid var(--bg-tertiary) !important;
box-shadow: var(--shadow-md);
overflow-x: auto;
}
code {
font-family: 'Monaco', 'Menlo', 'Courier New', monospace !important;
font-size: 0.9rem !important;
color: #E5E7EB !important;
}
/* Inline code */
:not(pre) > code {
background: var(--bg-tertiary) !important;
padding: 0.2rem 0.4rem !important;
border-radius: var(--radius-sm) !important;
color: var(--primary-light) !important;
}
/* === TABLES === */
table {
width: 100% !important;
border-collapse: separate !important;
border-spacing: 0 !important;
border-radius: var(--radius-md) !important;
overflow: hidden !important;
box-shadow: var(--shadow-md);
margin: 1rem 0 !important;
}
thead {
background: var(--bg-tertiary) !important;
}
thead th {
padding: 0.75rem 1rem !important;
text-align: left !important;
font-weight: 600 !important;
color: var(--text-primary) !important;
border-bottom: 2px solid var(--primary-color) !important;
}
tbody tr {
background: var(--bg-secondary) !important;
transition: background 0.2s ease;
}
tbody tr:hover {
background: var(--bg-tertiary) !important;
}
tbody td {
padding: 0.75rem 1rem !important;
border-bottom: 1px solid var(--bg-tertiary) !important;
color: var(--text-secondary) !important;
}
/* === INPUT AREA === */
#chat-input-container {
background: var(--bg-secondary) !important;
border-radius: var(--radius-xl) !important;
padding: 1rem !important;
box-shadow: var(--shadow-lg);
border: 1px solid var(--bg-tertiary) !important;
}
#chat-input {
background: var(--bg-tertiary) !important;
color: var(--text-primary) !important;
border: none !important;
border-radius: var(--radius-lg) !important;
padding: 0.75rem 1rem !important;
font-size: 1rem !important;
resize: none !important;
transition: all 0.2s ease;
}
#chat-input:focus {
outline: 2px solid var(--primary-color) !important;
outline-offset: 2px;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
/* Send button */
#send-button {
background: var(--primary-color) !important;
color: white !important;
border-radius: var(--radius-md) !important;
padding: 0.75rem 1.5rem !important;
font-weight: 600 !important;
transition: all 0.2s ease !important;
border: none !important;
box-shadow: var(--shadow-md);
}
#send-button:hover {
background: var(--primary-hover) !important;
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
#send-button:active {
transform: translateY(0);
}
/* === SETTINGS PANEL === */
.settings-panel {
background: var(--bg-secondary) !important;
border-radius: var(--radius-lg) !important;
padding: 1.5rem !important;
box-shadow: var(--shadow-lg);
border: 1px solid var(--bg-tertiary) !important;
}
.settings-item {
margin: 1rem 0 !important;
padding: 0.75rem !important;
background: var(--bg-tertiary) !important;
border-radius: var(--radius-md) !important;
transition: background 0.2s ease;
}
.settings-item:hover {
background: rgba(99, 102, 241, 0.1) !important;
}
/* Sliders */
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 3px;
background: var(--bg-tertiary);
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--primary-color);
cursor: pointer;
box-shadow: var(--shadow-md);
transition: all 0.2s ease;
}
input[type="range"]::-webkit-slider-thumb:hover {
background: var(--primary-hover);
transform: scale(1.1);
}
/* === BUTTONS === */
button {
border-radius: var(--radius-md) !important;
font-weight: 500 !important;
transition: all 0.2s ease !important;
}
button:hover {
transform: translateY(-1px);
}
/* Primary button */
.button-primary {
background: var(--primary-color) !important;
color: white !important;
}
/* Secondary button */
.button-secondary {
background: var(--bg-tertiary) !important;
color: var(--text-primary) !important;
}
/* === FILE UPLOAD === */
.file-upload-area {
border: 2px dashed var(--bg-tertiary) !important;
border-radius: var(--radius-lg) !important;
padding: 2rem !important;
background: var(--bg-secondary) !important;
transition: all 0.3s ease !important;
}
.file-upload-area:hover {
border-color: var(--primary-color) !important;
background: rgba(99, 102, 241, 0.05) !important;
}
.file-upload-area.dragging {
border-color: var(--primary-color) !important;
background: rgba(99, 102, 241, 0.1) !important;
transform: scale(1.02);
}
/* === BADGES & TAGS === */
.badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-weight: 600;
margin: 0.25rem;
}
.badge-admin {
background: var(--accent-red);
color: white;
}
.badge-business {
background: var(--accent-green);
color: white;
}
.badge-engineering {
background: var(--primary-color);
color: white;
}
.badge-architecture {
background: var(--accent-yellow);
color: var(--bg-primary);
}
/* === LOADING ANIMATION === */
.loading-dots {
display: inline-flex;
gap: 0.25rem;
}
.loading-dots span {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--primary-color);
animation: pulse 1.4s ease-in-out infinite;
}
.loading-dots span:nth-child(2) {
animation-delay: 0.2s;
}
.loading-dots span:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes pulse {
0%, 100% {
opacity: 0.3;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1);
}
}
/* === SCROLLBAR CUSTOM === */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
::-webkit-scrollbar-thumb {
background: var(--bg-tertiary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--primary-color);
}
/* === RESPONSIVE === */
@media (max-width: 768px) {
#chat-container {
padding: 0.75rem;
}
.user-message,
.assistant-message {
padding: 0.75rem !important;
}
#app-header {
padding: 0.75rem !important;
}
}
/* === ANIMATIONS === */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.message {
animation: fadeIn 0.3s ease;
}
/* === TOOLTIPS === */
.tooltip {
position: relative;
display: inline-block;
}
.tooltip .tooltiptext {
visibility: hidden;
background-color: var(--bg-tertiary);
color: var(--text-primary);
text-align: center;
border-radius: var(--radius-sm);
padding: 0.5rem 0.75rem;
position: absolute;
z-index: 1;
bottom: 125%;
left: 50%;
transform: translateX(-50%);
opacity: 0;
transition: opacity 0.3s;
font-size: 0.875rem;
white-space: nowrap;
box-shadow: var(--shadow-md);
}
.tooltip:hover .tooltiptext {
visibility: visible;
opacity: 1;
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.3 MiB

BIN
public/images/fav1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/images/fav2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
public/images/fav3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
public/images/fav4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
public/images/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/images/favicon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

BIN
public/images/logo1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
public/images/logo2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

BIN
public/images/logoback.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

14
requirements-backup.txt Normal file
View File

@ -0,0 +1,14 @@
chainlit==1.3.2
pydantic==2.9.2
ollama
asyncpg>=0.29.0
psycopg2-binary
qdrant-client>=1.10.0
sqlalchemy>=2.0.0
greenlet>=3.0.0
sniffio
aiohttp
alembic
pymupdf
google-generativeai
python-dotenv

16
requirements-oauth2.txt Normal file
View File

@ -0,0 +1,16 @@
chainlit==1.3.2
pydantic==2.9.2
ollama>=0.1.0
asyncpg>=0.29.0
psycopg2-binary
qdrant-client>=1.10.0
sqlalchemy>=2.0.0
greenlet>=3.0.0
sniffio
aiohttp
alembic
pymupdf
python-dotenv
authlib>=1.2.0
python-multipart>=0.0.6
httpx>=0.24.0

View File

@ -1,6 +1,6 @@
chainlit==1.3.2 chainlit==1.3.2
pydantic==2.9.2 pydantic==2.9.2
ollama ollama>=0.1.0
asyncpg>=0.29.0 asyncpg>=0.29.0
psycopg2-binary psycopg2-binary
qdrant-client>=1.10.0 qdrant-client>=1.10.0
@ -10,5 +10,9 @@ sniffio
aiohttp aiohttp
alembic alembic
pymupdf pymupdf
google-generativeai
python-dotenv python-dotenv
httpx>=0.24.0
aiofiles>=23.0.0
langchain>=0.0.208
boto3>=1.28.0
azure-storage-file-datalake>=12.14.0