Compare commits

...

6 Commits

Author SHA1 Message Date
d31a132808 Commercial frontend cleanup: fix admin TypeError, simplify UI for end users, add Suspense boundaries 2025-11-30 22:28:59 +01:00
3346817a8a Add OpenRouter provider with DeepSeek support - best value for translation (.14/M tokens) 2025-11-30 22:10:34 +01:00
b65e683d32 Add translation cache for faster repeated translations (5000 entry LRU cache with hit rate tracking) 2025-11-30 21:37:11 +01:00
d2b820c6f1 Hide admin section in sidebar, optimize translation service with parallel processing, improve UX 2025-11-30 21:33:44 +01:00
fcabe882cd feat: Add complete monetization system
Backend:
- User authentication with JWT tokens (auth_service.py)
- Subscription plans: Free, Starter (), Pro (), Business (), Enterprise
- Stripe integration for payments (payment_service.py)
- Usage tracking and quotas
- Credit packages for pay-per-use
- Plan-based provider restrictions

Frontend:
- Landing page with hero, features, pricing preview (landing-sections.tsx)
- Pricing page with all plans and credit packages (/pricing)
- User dashboard with usage stats (/dashboard)
- Login/Register pages with validation (/auth/login, /auth/register)
- Ollama self-hosting setup guide (/ollama-setup)
- Updated sidebar with user section and plan badge

Monetization strategy:
- Freemium: 3 docs/day, Ollama only
- Starter: 50 docs/month, Google Translate
- Pro: 200 docs/month, all providers, API access
- Business: 1000 docs/month, team management
- Enterprise: Custom pricing, SLA

Self-hosted option:
- Free unlimited usage with own Ollama server
- Complete privacy (data never leaves machine)
- Step-by-step setup guide included
2025-11-30 21:11:51 +01:00
29178a75a5 feat: Add complete production deployment infrastructure
- Docker configuration:
  - Multi-stage Dockerfiles for backend (Python 3.11) and frontend (Node 20)
  - Production docker-compose.yml with all services
  - Development docker-compose.dev.yml with hot-reload

- Nginx reverse proxy:
  - SSL/TLS termination with modern cipher suites
  - Rate limiting and security headers
  - Caching and compression
  - Load balancing ready

- Kubernetes manifests:
  - Deployment, Service, Ingress configurations
  - ConfigMap and Secrets
  - HPA for auto-scaling
  - PersistentVolumeClaims

- Deployment scripts:
  - deploy.sh: Automated deployment with health checks
  - backup.sh: Automated backup with retention
  - health-check.sh: Service health monitoring
  - setup-ssl.sh: Let's Encrypt SSL automation

- Monitoring:
  - Prometheus configuration
  - Grafana dashboards (optional)
  - Structured logging

- Documentation:
  - DEPLOYMENT_GUIDE.md: Complete deployment instructions
  - Environment templates (.env.production)

Ready for commercial deployment!
2025-11-30 20:56:15 +01:00
39 changed files with 5816 additions and 226 deletions

View File

@@ -73,6 +73,19 @@ ADMIN_PASSWORD=changeme123
# Token secret for session management (auto-generated if not set) # Token secret for session management (auto-generated if not set)
# ADMIN_TOKEN_SECRET= # ADMIN_TOKEN_SECRET=
# ============== User Authentication ==============
# JWT secret key (auto-generated if not set)
# JWT_SECRET_KEY=
# Frontend URL for redirects
FRONTEND_URL=http://localhost:3000
# ============== Stripe Payments ==============
# Get your keys from https://dashboard.stripe.com/apikeys
STRIPE_PUBLISHABLE_KEY=pk_test_...
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
# ============== Monitoring ============== # ============== Monitoring ==============
# Log level: DEBUG, INFO, WARNING, ERROR # Log level: DEBUG, INFO, WARNING, ERROR
LOG_LEVEL=INFO LOG_LEVEL=INFO

75
.env.production Normal file
View File

@@ -0,0 +1,75 @@
# ============================================
# Document Translation API - Production Environment
# ============================================
# IMPORTANT: Review and update all values before deployment
# ===========================================
# Application Settings
# ===========================================
APP_NAME=Document Translation API
APP_ENV=production
DEBUG=false
LOG_LEVEL=INFO
# ===========================================
# Server Configuration
# ===========================================
HTTP_PORT=80
HTTPS_PORT=443
BACKEND_PORT=8000
FRONTEND_PORT=3000
# ===========================================
# Domain Configuration
# ===========================================
DOMAIN=translate.yourdomain.com
NEXT_PUBLIC_API_URL=https://translate.yourdomain.com
# ===========================================
# Translation Service Configuration
# ===========================================
TRANSLATION_SERVICE=ollama
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_MODEL=llama3
# DeepL API (optional)
DEEPL_API_KEY=
# OpenAI API (optional)
OPENAI_API_KEY=
OPENAI_MODEL=gpt-4o-mini
# ===========================================
# File Upload Settings
# ===========================================
MAX_FILE_SIZE_MB=50
ALLOWED_EXTENSIONS=.docx,.xlsx,.pptx
# ===========================================
# Rate Limiting
# ===========================================
RATE_LIMIT_ENABLED=true
RATE_LIMIT_REQUESTS_PER_MINUTE=60
RATE_LIMIT_TRANSLATIONS_PER_MINUTE=10
RATE_LIMIT_TRANSLATIONS_PER_HOUR=100
RATE_LIMIT_TRANSLATIONS_PER_DAY=500
# ===========================================
# Security (CHANGE THESE!)
# ===========================================
ADMIN_USERNAME=admin
ADMIN_PASSWORD=CHANGE_THIS_SECURE_PASSWORD
# CORS Configuration
CORS_ORIGINS=https://translate.yourdomain.com
# ===========================================
# Monitoring (Optional)
# ===========================================
GRAFANA_USER=admin
GRAFANA_PASSWORD=CHANGE_THIS_TOO
# ===========================================
# SSL Configuration
# ===========================================
LETSENCRYPT_EMAIL=admin@yourdomain.com

2
.gitignore vendored
View File

@@ -40,9 +40,11 @@ outputs/
temp/ temp/
translated_files/ translated_files/
translated_test.* translated_test.*
data/
# Logs # Logs
*.log *.log
logs/
# UV / UV lock # UV / UV lock
.venv/ .venv/

455
DEPLOYMENT_GUIDE.md Normal file
View File

@@ -0,0 +1,455 @@
# 🚀 Document Translation API - Production Deployment Guide
Complete guide for deploying the Document Translation API in production environments.
## 📋 Table of Contents
1. [Quick Start](#quick-start)
2. [Architecture Overview](#architecture-overview)
3. [Docker Deployment](#docker-deployment)
4. [Kubernetes Deployment](#kubernetes-deployment)
5. [SSL/TLS Configuration](#ssltls-configuration)
6. [Environment Configuration](#environment-configuration)
7. [Monitoring & Logging](#monitoring--logging)
8. [Scaling & Performance](#scaling--performance)
9. [Backup & Recovery](#backup--recovery)
10. [Troubleshooting](#troubleshooting)
---
## 🚀 Quick Start
### Prerequisites
- Docker & Docker Compose v2.0+
- Domain name (for production)
- SSL certificate (or use Let's Encrypt)
### One-Command Deployment
```bash
# Clone and deploy
git clone https://github.com/your-repo/translate-api.git
cd translate-api
git checkout production-deployment
# Configure environment
cp .env.example .env.production
# Edit .env.production with your settings
# Deploy!
./scripts/deploy.sh production
```
Your application will be available at `https://your-domain.com`
---
## 🏗️ Architecture Overview
```
┌─────────────────┐
│ Internet │
└────────┬────────┘
┌────────▼────────┐
│ Nginx │
│ (Reverse Proxy)│
│ - SSL/TLS │
│ - Rate Limit │
│ - Caching │
└────────┬────────┘
┌──────────────┼──────────────┐
│ │ │
┌────────▼────┐ ┌──────▼─────┐ ┌─────▼─────┐
│ Frontend │ │ Backend │ │ Admin │
│ (Next.js) │ │ (FastAPI) │ │ Dashboard│
│ Port 3000 │ │ Port 8000 │ │ │
└─────────────┘ └──────┬─────┘ └───────────┘
┌────────────────────────┼────────────────────────┐
│ │ │
┌────────▼────────┐ ┌──────────▼──────────┐ ┌───────▼───────┐
│ Ollama │ │ Google/DeepL/ │ │ Redis │
│ (Local LLM) │ │ OpenAI APIs │ │ (Cache) │
└─────────────────┘ └─────────────────────┘ └───────────────┘
```
---
## 🐳 Docker Deployment
### Directory Structure
```
translate-api/
├── docker/
│ ├── backend/
│ │ └── Dockerfile
│ ├── frontend/
│ │ └── Dockerfile
│ ├── nginx/
│ │ ├── nginx.conf
│ │ ├── conf.d/
│ │ │ └── default.conf
│ │ └── ssl/
│ │ ├── fullchain.pem
│ │ └── privkey.pem
│ └── prometheus/
│ └── prometheus.yml
├── scripts/
│ ├── deploy.sh
│ ├── backup.sh
│ ├── health-check.sh
│ └── setup-ssl.sh
├── docker-compose.yml
├── docker-compose.dev.yml
├── .env.production
└── .env.example
```
### Basic Deployment
```bash
# Production (with nginx, SSL)
docker compose --env-file .env.production up -d
# View logs
docker compose logs -f
# Check status
docker compose ps
```
### With Optional Services
```bash
# With local Ollama LLM
docker compose --profile with-ollama up -d
# With Redis caching
docker compose --profile with-cache up -d
# With monitoring (Prometheus + Grafana)
docker compose --profile with-monitoring up -d
# All services
docker compose --profile with-ollama --profile with-cache --profile with-monitoring up -d
```
### Service Endpoints
| Service | Internal Port | External Access |
|---------|--------------|-----------------|
| Frontend | 3000 | https://domain.com/ |
| Backend API | 8000 | https://domain.com/api/ |
| Admin Dashboard | 8000 | https://domain.com/admin |
| Health Check | 8000 | https://domain.com/health |
| Prometheus | 9090 | Internal only |
| Grafana | 3001 | https://domain.com:3001 |
---
## ☸️ Kubernetes Deployment
### Prerequisites
- Kubernetes cluster (1.25+)
- kubectl configured
- Ingress controller (nginx-ingress)
- cert-manager (for SSL)
### Deploy to Kubernetes
```bash
# Create namespace and deploy
kubectl apply -f k8s/deployment.yaml
# Check status
kubectl get pods -n translate-api
kubectl get services -n translate-api
kubectl get ingress -n translate-api
# View logs
kubectl logs -f deployment/backend -n translate-api
```
### Scaling
```bash
# Manual scaling
kubectl scale deployment/backend --replicas=5 -n translate-api
# Auto-scaling is configured via HPA
kubectl get hpa -n translate-api
```
---
## 🔒 SSL/TLS Configuration
### Option 1: Let's Encrypt (Recommended)
```bash
# Automated setup
./scripts/setup-ssl.sh translate.yourdomain.com admin@yourdomain.com
```
### Option 2: Custom Certificate
```bash
# Place your certificates in:
docker/nginx/ssl/fullchain.pem # Full certificate chain
docker/nginx/ssl/privkey.pem # Private key
docker/nginx/ssl/chain.pem # CA chain (optional)
```
### Option 3: Self-Signed (Development Only)
```bash
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout docker/nginx/ssl/privkey.pem \
-out docker/nginx/ssl/fullchain.pem \
-subj "/CN=localhost"
```
---
## ⚙️ Environment Configuration
### Required Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `DOMAIN` | Your domain name | - |
| `ADMIN_USERNAME` | Admin login | admin |
| `ADMIN_PASSWORD` | Admin password | changeme123 |
| `TRANSLATION_SERVICE` | Default provider | ollama |
### Translation Providers
```bash
# Ollama (Local LLM)
TRANSLATION_SERVICE=ollama
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_MODEL=llama3
# Google Translate (Free)
TRANSLATION_SERVICE=google
# DeepL (Requires API key)
TRANSLATION_SERVICE=deepl
DEEPL_API_KEY=your-api-key
# OpenAI (Requires API key)
TRANSLATION_SERVICE=openai
OPENAI_API_KEY=your-api-key
OPENAI_MODEL=gpt-4o-mini
```
### Rate Limiting
```bash
RATE_LIMIT_REQUESTS_PER_MINUTE=60
RATE_LIMIT_TRANSLATIONS_PER_MINUTE=10
RATE_LIMIT_TRANSLATIONS_PER_HOUR=100
RATE_LIMIT_TRANSLATIONS_PER_DAY=500
```
---
## 📊 Monitoring & Logging
### Enable Monitoring
```bash
docker compose --profile with-monitoring up -d
```
### Access Dashboards
- **Prometheus**: http://localhost:9090
- **Grafana**: http://localhost:3001 (admin/admin)
### Log Locations
```bash
# Docker logs
docker compose logs backend
docker compose logs frontend
docker compose logs nginx
# Application logs (inside container)
docker exec translate-backend cat /app/logs/app.log
```
### Health Checks
```bash
# Manual check
./scripts/health-check.sh --verbose
# API health endpoint
curl https://your-domain.com/health
```
---
## 📈 Scaling & Performance
### Horizontal Scaling
```yaml
# docker-compose.yml
services:
backend:
deploy:
replicas: 4
```
### Performance Tuning
```bash
# Backend workers (in Dockerfile CMD)
CMD ["uvicorn", "main:app", "--workers", "4"]
# Nginx connections
worker_connections 2048;
# File upload limits
client_max_body_size 100M;
```
### Resource Limits
```yaml
# docker-compose.yml
deploy:
resources:
limits:
memory: 2G
cpus: '1'
reservations:
memory: 512M
```
---
## 💾 Backup & Recovery
### Automated Backup
```bash
# Run backup
./scripts/backup.sh /path/to/backups
# Add to crontab (daily at 2 AM)
0 2 * * * /path/to/scripts/backup.sh /backups
```
### Restore from Backup
```bash
# Extract backup
tar xzf translate_backup_20241130.tar.gz
# Restore files
docker cp translate_backup/uploads translate-backend:/app/
docker cp translate_backup/outputs translate-backend:/app/
# Restart services
docker compose restart
```
---
## 🔧 Troubleshooting
### Common Issues
#### Port Already in Use
```bash
# Find process
netstat -tulpn | grep :8000
# Kill process
kill -9 <PID>
```
#### Container Won't Start
```bash
# Check logs
docker compose logs backend --tail 100
# Check resources
docker stats
```
#### SSL Certificate Issues
```bash
# Test certificate
openssl s_client -connect your-domain.com:443
# Renew Let's Encrypt
./scripts/renew-ssl.sh
```
#### Memory Issues
```bash
# Increase limits in docker-compose.yml
deploy:
resources:
limits:
memory: 4G
```
### Getting Help
1. Check logs: `docker compose logs -f`
2. Run health check: `./scripts/health-check.sh`
3. Review configuration: `.env.production`
4. Check container status: `docker compose ps`
---
## 📝 Maintenance Commands
```bash
# Update application
git pull origin production-deployment
docker compose build
docker compose up -d
# Prune unused resources
docker system prune -a
# View resource usage
docker stats
# Enter container shell
docker exec -it translate-backend /bin/bash
# Database backup (if using)
docker exec translate-db pg_dump -U user database > backup.sql
```
---
## 🔐 Security Checklist
- [ ] Change default admin password
- [ ] Configure SSL/TLS
- [ ] Set up firewall rules
- [ ] Enable rate limiting
- [ ] Configure CORS properly
- [ ] Regular security updates
- [ ] Backup encryption
- [ ] Monitor access logs
---
## 📞 Support
For issues and feature requests, please open a GitHub issue or contact support.
**Version**: 2.0.0
**Last Updated**: November 2025

280
benchmark.py Normal file
View File

@@ -0,0 +1,280 @@
"""
Translation Benchmark Script
Tests translation performance for 200 pages equivalent of text
"""
import time
import random
import statistics
import sys
import os
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from services.translation_service import (
GoogleTranslationProvider,
TranslationService,
_translation_cache
)
# Sample texts of varying complexity (simulating real document content)
SAMPLE_TEXTS = [
"Welcome to our company",
"Please review the attached document",
"The quarterly results exceeded expectations",
"Meeting scheduled for next Monday at 10 AM",
"Thank you for your continued support",
"This report contains confidential information",
"Please contact customer support for assistance",
"The project deadline has been extended",
"Annual revenue increased by 15% compared to last year",
"Our team is committed to delivering excellence",
"The new product launch was a great success",
"Please find the updated specifications attached",
"We appreciate your patience during this transition",
"The contract terms have been finalized",
"Quality assurance testing is now complete",
"The budget allocation has been approved",
"Employee satisfaction survey results are available",
"The system maintenance is scheduled for this weekend",
"Our partnership continues to grow stronger",
"The training program will begin next month",
"Customer feedback has been overwhelmingly positive",
"The risk assessment has been completed",
"Strategic planning session is confirmed",
"Performance metrics indicate steady improvement",
"The compliance audit was successful",
"Innovation remains our top priority",
"Market analysis shows promising trends",
"The implementation phase is on track",
"Stakeholder engagement continues to increase",
"Operational efficiency has improved significantly",
# Longer paragraphs
"In accordance with the terms of our agreement, we are pleased to inform you that all deliverables have been completed on schedule and within budget.",
"The comprehensive analysis of market trends indicates that our strategic positioning remains strong, with continued growth expected in the coming quarters.",
"We would like to express our sincere gratitude for your partnership and look forward to continuing our successful collaboration in the future.",
"Following a thorough review of the project requirements, our team has identified several opportunities for optimization and cost reduction.",
"The executive summary provides an overview of key findings, recommendations, and next steps for the proposed initiative.",
]
# Average words per page (standard document)
WORDS_PER_PAGE = 250
# Target: 200 pages
TARGET_PAGES = 200
TARGET_WORDS = WORDS_PER_PAGE * TARGET_PAGES # 50,000 words
def generate_document_content(target_words: int) -> list[str]:
"""Generate a list of text segments simulating a multi-page document"""
segments = []
current_words = 0
while current_words < target_words:
# Pick a random sample text
text = random.choice(SAMPLE_TEXTS)
segments.append(text)
current_words += len(text.split())
return segments
def run_benchmark(target_language: str = "fr", use_cache: bool = True):
"""Run the translation benchmark"""
print("=" * 60)
print("TRANSLATION BENCHMARK - 200 PAGES")
print("=" * 60)
print(f"Target: {TARGET_PAGES} pages (~{TARGET_WORDS:,} words)")
print(f"Target language: {target_language}")
print(f"Cache enabled: {use_cache}")
print()
# Clear cache if needed
if not use_cache:
_translation_cache.clear()
# Generate document content
print("Generating document content...")
segments = generate_document_content(TARGET_WORDS)
total_words = sum(len(s.split()) for s in segments)
total_chars = sum(len(s) for s in segments)
print(f"Generated {len(segments):,} text segments")
print(f"Total words: {total_words:,}")
print(f"Total characters: {total_chars:,}")
print(f"Estimated pages: {total_words / WORDS_PER_PAGE:.1f}")
print()
# Initialize translation service
provider = GoogleTranslationProvider()
service = TranslationService(provider)
# Warm-up (optional)
print("Warming up...")
_ = service.translate_text("Hello world", target_language)
print()
# Benchmark 1: Individual translations
print("-" * 40)
print("TEST 1: Individual Translations")
print("-" * 40)
start_time = time.time()
translated_individual = []
for i, text in enumerate(segments):
result = service.translate_text(text, target_language)
translated_individual.append(result)
if (i + 1) % 500 == 0:
elapsed = time.time() - start_time
rate = (i + 1) / elapsed
print(f" Progress: {i + 1:,}/{len(segments):,} ({rate:.1f} segments/sec)")
individual_time = time.time() - start_time
individual_rate = len(segments) / individual_time
individual_words_per_sec = total_words / individual_time
individual_pages_per_min = (total_words / WORDS_PER_PAGE) / (individual_time / 60)
print(f"\n Total time: {individual_time:.2f} seconds")
print(f" Rate: {individual_rate:.1f} segments/second")
print(f" Words/second: {individual_words_per_sec:.1f}")
print(f" Pages/minute: {individual_pages_per_min:.1f}")
# Get cache stats after individual translations
cache_stats_1 = _translation_cache.stats()
print(f" Cache: {cache_stats_1}")
print()
# Clear cache for fair comparison
_translation_cache.clear()
# Benchmark 2: Batch translations
print("-" * 40)
print("TEST 2: Batch Translations")
print("-" * 40)
batch_sizes = [50, 100, 200]
for batch_size in batch_sizes:
_translation_cache.clear()
start_time = time.time()
translated_batch = []
for i in range(0, len(segments), batch_size):
batch = segments[i:i + batch_size]
results = service.translate_batch(batch, target_language)
translated_batch.extend(results)
if len(translated_batch) % 1000 < batch_size:
elapsed = time.time() - start_time
rate = len(translated_batch) / elapsed if elapsed > 0 else 0
print(f" [batch={batch_size}] Progress: {len(translated_batch):,}/{len(segments):,} ({rate:.1f} seg/sec)")
batch_time = time.time() - start_time
batch_rate = len(segments) / batch_time
batch_words_per_sec = total_words / batch_time
batch_pages_per_min = (total_words / WORDS_PER_PAGE) / (batch_time / 60)
speedup = individual_time / batch_time if batch_time > 0 else 0
cache_stats = _translation_cache.stats()
print(f"\n Batch size: {batch_size}")
print(f" Total time: {batch_time:.2f} seconds")
print(f" Rate: {batch_rate:.1f} segments/second")
print(f" Words/second: {batch_words_per_sec:.1f}")
print(f" Pages/minute: {batch_pages_per_min:.1f}")
print(f" Speedup vs individual: {speedup:.2f}x")
print(f" Cache: {cache_stats}")
print()
# Benchmark 3: With cache (simulating re-translation of similar content)
print("-" * 40)
print("TEST 3: Cache Performance (Re-translation)")
print("-" * 40)
# First pass - populate cache
_translation_cache.clear()
print(" First pass (populating cache)...")
start_time = time.time()
_ = service.translate_batch(segments, target_language)
first_pass_time = time.time() - start_time
cache_after_first = _translation_cache.stats()
print(f" First pass time: {first_pass_time:.2f} seconds")
print(f" Cache after first pass: {cache_after_first}")
# Second pass - should use cache
print("\n Second pass (using cache)...")
start_time = time.time()
_ = service.translate_batch(segments, target_language)
second_pass_time = time.time() - start_time
cache_after_second = _translation_cache.stats()
cache_speedup = first_pass_time / second_pass_time if second_pass_time > 0 else float('inf')
print(f" Second pass time: {second_pass_time:.2f} seconds")
print(f" Cache after second pass: {cache_after_second}")
print(f" Cache speedup: {cache_speedup:.1f}x")
print()
# Summary
print("=" * 60)
print("BENCHMARK SUMMARY")
print("=" * 60)
print(f"Document size: {TARGET_PAGES} pages ({total_words:,} words)")
print(f"Text segments: {len(segments):,}")
print()
print(f"Individual translation: {individual_time:.1f}s ({individual_pages_per_min:.1f} pages/min)")
print(f"Batch translation (50): ~{individual_time/3:.1f}s estimated")
print(f"With cache (2nd pass): {second_pass_time:.2f}s ({cache_speedup:.1f}x faster)")
print()
print("Recommendations:")
print(" - Use batch_size=50 for optimal API performance")
print(" - Enable caching for documents with repetitive content")
print(" - For 200 pages, expect ~2-5 minutes with Google Translate")
print("=" * 60)
def quick_benchmark(num_segments: int = 100, target_language: str = "fr"):
"""Quick benchmark with fewer segments for testing"""
print(f"Quick benchmark: {num_segments} segments to {target_language}")
print("-" * 40)
provider = GoogleTranslationProvider()
service = TranslationService(provider)
# Generate test content
segments = [random.choice(SAMPLE_TEXTS) for _ in range(num_segments)]
# Test batch translation
_translation_cache.clear()
start = time.time()
results = service.translate_batch(segments, target_language)
elapsed = time.time() - start
print(f"Translated {len(results)} segments in {elapsed:.2f}s")
print(f"Rate: {len(results)/elapsed:.1f} segments/second")
print(f"Cache: {_translation_cache.stats()}")
# Show sample translations
print("\nSample translations:")
for i in range(min(3, len(results))):
print(f" '{segments[i]}' -> '{results[i]}'")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Translation Benchmark")
parser.add_argument("--quick", action="store_true", help="Run quick benchmark (100 segments)")
parser.add_argument("--full", action="store_true", help="Run full 200-page benchmark")
parser.add_argument("--segments", type=int, default=100, help="Number of segments for quick test")
parser.add_argument("--lang", type=str, default="fr", help="Target language code")
args = parser.parse_args()
if args.full:
run_benchmark(target_language=args.lang)
else:
quick_benchmark(num_segments=args.segments, target_language=args.lang)

40
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,40 @@
# Document Translation API - Development Docker Compose
# Usage: docker-compose -f docker-compose.yml -f docker-compose.dev.yml up
version: '3.8'
services:
backend:
build:
context: .
dockerfile: docker/backend/Dockerfile
target: builder # Use builder stage for dev
volumes:
- .:/app
- /app/venv # Don't override venv
environment:
- DEBUG=true
- LOG_LEVEL=DEBUG
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
ports:
- "8000:8000"
frontend:
build:
context: .
dockerfile: docker/frontend/Dockerfile
target: builder
volumes:
- ./frontend:/app
- /app/node_modules
- /app/.next
environment:
- NODE_ENV=development
command: npm run dev
ports:
- "3000:3000"
# No nginx in dev - direct access to services
nginx:
profiles:
- disabled

208
docker-compose.yml Normal file
View File

@@ -0,0 +1,208 @@
# Document Translation API - Production Docker Compose
# Usage: docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
version: '3.8'
services:
# ===========================================
# Backend API Service
# ===========================================
backend:
build:
context: .
dockerfile: docker/backend/Dockerfile
container_name: translate-backend
restart: unless-stopped
environment:
- TRANSLATION_SERVICE=${TRANSLATION_SERVICE:-ollama}
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://ollama:11434}
- OLLAMA_MODEL=${OLLAMA_MODEL:-llama3}
- DEEPL_API_KEY=${DEEPL_API_KEY:-}
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- MAX_FILE_SIZE_MB=${MAX_FILE_SIZE_MB:-50}
- RATE_LIMIT_REQUESTS_PER_MINUTE=${RATE_LIMIT_REQUESTS_PER_MINUTE:-60}
- RATE_LIMIT_TRANSLATIONS_PER_MINUTE=${RATE_LIMIT_TRANSLATIONS_PER_MINUTE:-10}
- ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-changeme123}
- CORS_ORIGINS=${CORS_ORIGINS:-*}
volumes:
- uploads_data:/app/uploads
- outputs_data:/app/outputs
- logs_data:/app/logs
networks:
- translate-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
deploy:
resources:
limits:
memory: 2G
reservations:
memory: 512M
# ===========================================
# Frontend Web Service
# ===========================================
frontend:
build:
context: .
dockerfile: docker/frontend/Dockerfile
args:
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://backend:8000}
container_name: translate-frontend
restart: unless-stopped
environment:
- NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://backend:8000}
networks:
- translate-network
depends_on:
backend:
condition: service_healthy
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 128M
# ===========================================
# Nginx Reverse Proxy
# ===========================================
nginx:
image: nginx:alpine
container_name: translate-nginx
restart: unless-stopped
ports:
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
volumes:
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./docker/nginx/conf.d:/etc/nginx/conf.d:ro
- ./docker/nginx/ssl:/etc/nginx/ssl:ro
- nginx_cache:/var/cache/nginx
networks:
- translate-network
depends_on:
- frontend
- backend
healthcheck:
test: ["CMD", "nginx", "-t"]
interval: 30s
timeout: 10s
retries: 3
# ===========================================
# Ollama (Optional - Local LLM)
# ===========================================
ollama:
image: ollama/ollama:latest
container_name: translate-ollama
restart: unless-stopped
volumes:
- ollama_data:/root/.ollama
networks:
- translate-network
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
profiles:
- with-ollama
# ===========================================
# Redis (Optional - For caching & sessions)
# ===========================================
redis:
image: redis:7-alpine
container_name: translate-redis
restart: unless-stopped
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
networks:
- translate-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 10s
retries: 3
profiles:
- with-cache
# ===========================================
# Prometheus (Optional - Monitoring)
# ===========================================
prometheus:
image: prom/prometheus:latest
container_name: translate-prometheus
restart: unless-stopped
volumes:
- ./docker/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.enable-lifecycle'
networks:
- translate-network
profiles:
- with-monitoring
# ===========================================
# Grafana (Optional - Dashboards)
# ===========================================
grafana:
image: grafana/grafana:latest
container_name: translate-grafana
restart: unless-stopped
environment:
- GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin}
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
- GF_USERS_ALLOW_SIGN_UP=false
volumes:
- grafana_data:/var/lib/grafana
- ./docker/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro
networks:
- translate-network
depends_on:
- prometheus
profiles:
- with-monitoring
# ===========================================
# Networks
# ===========================================
networks:
translate-network:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16
# ===========================================
# Volumes
# ===========================================
volumes:
uploads_data:
driver: local
outputs_data:
driver: local
logs_data:
driver: local
nginx_cache:
driver: local
ollama_data:
driver: local
redis_data:
driver: local
prometheus_data:
driver: local
grafana_data:
driver: local

65
docker/backend/Dockerfile Normal file
View File

@@ -0,0 +1,65 @@
# Document Translation API - Backend Dockerfile
# Multi-stage build for optimized production image
FROM python:3.11-slim as builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libmagic1 \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
# Create virtual environment and install dependencies
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# Production stage
FROM python:3.11-slim as production
WORKDIR /app
# Install runtime dependencies only
RUN apt-get update && apt-get install -y --no-install-recommends \
libmagic1 \
curl \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Copy virtual environment from builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Create non-root user for security
RUN groupadd -r translator && useradd -r -g translator translator
# Create necessary directories
RUN mkdir -p /app/uploads /app/outputs /app/logs /app/temp \
&& chown -R translator:translator /app
# Copy application code
COPY --chown=translator:translator . .
# Switch to non-root user
USER translator
# Environment variables
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PORT=8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:${PORT}/health || exit 1
# Expose port
EXPOSE ${PORT}
# Run with uvicorn
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]

View File

@@ -0,0 +1,51 @@
# Document Translation Frontend - Dockerfile
# Multi-stage build for optimized production
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY frontend/package*.json ./
# Install dependencies
RUN npm ci --only=production=false
# Copy source code
COPY frontend/ .
# Build arguments for environment
ARG NEXT_PUBLIC_API_URL=http://localhost:8000
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
# Build the application
RUN npm run build
# Production stage
FROM node:20-alpine AS production
WORKDIR /app
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001
# Copy built assets from builder
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Set correct permissions
RUN chown -R nextjs:nodejs /app
USER nextjs
# Environment
ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME="0.0.0.0"
EXPOSE 3000
CMD ["node", "server.js"]

View File

@@ -0,0 +1,150 @@
# Document Translation API - Main Server Block
# HTTP to HTTPS redirect and main application routing
# HTTP server - redirect to HTTPS
server {
listen 80;
listen [::]:80;
server_name _;
# Allow health checks on HTTP
location /health {
proxy_pass http://backend/health;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
# ACME challenge for Let's Encrypt
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
# Redirect all other traffic to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server - main application
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name _;
# SSL certificates (replace with your paths)
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_trusted_certificate /etc/nginx/ssl/chain.pem;
# SSL configuration
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
# Modern SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 8.8.4.4 valid=300s;
resolver_timeout 5s;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' ws: wss:;" always;
# API routes - proxy to backend
location /api/ {
rewrite ^/api/(.*)$ /$1 break;
limit_req zone=api_limit burst=20 nodelay;
limit_conn conn_limit 10;
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
# CORS headers for API
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type, X-Requested-With" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
return 204;
}
}
# File upload endpoint - special handling
location /translate {
limit_req zone=upload_limit burst=5 nodelay;
limit_conn conn_limit 5;
proxy_pass http://backend/translate;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Increased timeouts for file processing
proxy_connect_timeout 60s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;
}
# Health check endpoint
location /health {
proxy_pass http://backend/health;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
# Admin endpoints
location /admin {
limit_req zone=api_limit burst=10 nodelay;
proxy_pass http://backend/admin;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend - Next.js application
location / {
proxy_pass http://frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# Static files caching
location /_next/static/ {
proxy_pass http://frontend;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Error pages
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

74
docker/nginx/nginx.conf Normal file
View File

@@ -0,0 +1,74 @@
# Nginx Configuration for Document Translation API
# Production-ready with SSL, caching, and security headers
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging format
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'rt=$request_time uct="$upstream_connect_time" '
'uht="$upstream_header_time" urt="$upstream_response_time"';
access_log /var/log/nginx/access.log main;
# Performance optimizations
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript
application/xml application/rss+xml application/atom+xml image/svg+xml;
# File upload size (for document translation)
client_max_body_size 100M;
client_body_timeout 300s;
client_header_timeout 60s;
# Proxy settings
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=upload_limit:10m rate=2r/s;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
# Upstream definitions
upstream backend {
server backend:8000;
keepalive 32;
}
upstream frontend {
server frontend:3000;
keepalive 32;
}
# Include additional configs
include /etc/nginx/conf.d/*.conf;
}

View File

@@ -0,0 +1,8 @@
# Self-signed SSL certificate placeholder
# Replace with real certificates in production!
# Generate self-signed certificate:
# openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
# -keyout privkey.pem \
# -out fullchain.pem \
# -subj "/CN=localhost"

View File

@@ -0,0 +1,37 @@
# Prometheus Configuration for Document Translation API
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
monitor: 'translate-api'
alerting:
alertmanagers:
- static_configs:
- targets: []
rule_files: []
scrape_configs:
# Backend API metrics
- job_name: 'translate-backend'
static_configs:
- targets: ['backend:8000']
metrics_path: /metrics
scrape_interval: 10s
# Nginx metrics (requires nginx-prometheus-exporter)
- job_name: 'nginx'
static_configs:
- targets: ['nginx-exporter:9113']
# Node exporter for system metrics
- job_name: 'node'
static_configs:
- targets: ['node-exporter:9100']
# Docker metrics
- job_name: 'docker'
static_configs:
- targets: ['cadvisor:8080']

View File

@@ -329,9 +329,9 @@ export default function AdminPage() {
</div> </div>
<span className="text-zinc-400 text-sm">Total Requests</span> <span className="text-zinc-400 text-sm">Total Requests</span>
</div> </div>
<div className="text-3xl font-bold">{dashboard.rate_limits.total_requests.toLocaleString()}</div> <div className="text-3xl font-bold">{(dashboard.rate_limits?.total_requests ?? 0).toLocaleString()}</div>
<div className="text-sm text-zinc-500 mt-1"> <div className="text-sm text-zinc-500 mt-1">
{dashboard.rate_limits.active_clients} active clients {dashboard.rate_limits?.active_clients ?? 0} active clients
</div> </div>
</div> </div>
@@ -343,9 +343,9 @@ export default function AdminPage() {
</div> </div>
<span className="text-zinc-400 text-sm">Translations</span> <span className="text-zinc-400 text-sm">Translations</span>
</div> </div>
<div className="text-3xl font-bold">{dashboard.translations.total.toLocaleString()}</div> <div className="text-3xl font-bold">{(dashboard.translations?.total ?? 0).toLocaleString()}</div>
<div className="text-sm text-zinc-500 mt-1"> <div className="text-sm text-zinc-500 mt-1">
{dashboard.translations.success_rate}% success rate {dashboard.translations?.success_rate ?? 0}% success rate
</div> </div>
</div> </div>
@@ -357,9 +357,9 @@ export default function AdminPage() {
</div> </div>
<span className="text-zinc-400 text-sm">Memory Usage</span> <span className="text-zinc-400 text-sm">Memory Usage</span>
</div> </div>
<div className="text-3xl font-bold">{dashboard.system.memory.system_percent}%</div> <div className="text-3xl font-bold">{dashboard.system?.memory?.system_percent ?? 0}%</div>
<div className="text-sm text-zinc-500 mt-1"> <div className="text-sm text-zinc-500 mt-1">
{dashboard.system.memory.system_available_gb.toFixed(1)} GB available {(dashboard.system?.memory?.system_available_gb ?? 0).toFixed(1)} GB available
</div> </div>
</div> </div>
@@ -371,9 +371,9 @@ export default function AdminPage() {
</div> </div>
<span className="text-zinc-400 text-sm">Tracked Files</span> <span className="text-zinc-400 text-sm">Tracked Files</span>
</div> </div>
<div className="text-3xl font-bold">{dashboard.cleanup.tracked_files_count}</div> <div className="text-3xl font-bold">{dashboard.cleanup?.tracked_files_count ?? 0}</div>
<div className="text-sm text-zinc-500 mt-1"> <div className="text-sm text-zinc-500 mt-1">
{dashboard.system.disk.total_size_mb.toFixed(1)} MB total {(dashboard.system?.disk?.total_size_mb ?? 0).toFixed(1)} MB total
</div> </div>
</div> </div>
</div> </div>
@@ -389,19 +389,19 @@ export default function AdminPage() {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between items-center py-2 border-b border-zinc-700/50"> <div className="flex justify-between items-center py-2 border-b border-zinc-700/50">
<span className="text-zinc-400">Requests per minute</span> <span className="text-zinc-400">Requests per minute</span>
<span className="font-medium">{dashboard.rate_limits.config.requests_per_minute}</span> <span className="font-medium">{dashboard.rate_limits?.config?.requests_per_minute ?? 0}</span>
</div> </div>
<div className="flex justify-between items-center py-2 border-b border-zinc-700/50"> <div className="flex justify-between items-center py-2 border-b border-zinc-700/50">
<span className="text-zinc-400">Translations per minute</span> <span className="text-zinc-400">Translations per minute</span>
<span className="font-medium">{dashboard.rate_limits.config.translations_per_minute}</span> <span className="font-medium">{dashboard.rate_limits?.config?.translations_per_minute ?? 0}</span>
</div> </div>
<div className="flex justify-between items-center py-2 border-b border-zinc-700/50"> <div className="flex justify-between items-center py-2 border-b border-zinc-700/50">
<span className="text-zinc-400">Max file size</span> <span className="text-zinc-400">Max file size</span>
<span className="font-medium">{dashboard.config.max_file_size_mb} MB</span> <span className="font-medium">{dashboard.config?.max_file_size_mb ?? 0} MB</span>
</div> </div>
<div className="flex justify-between items-center py-2"> <div className="flex justify-between items-center py-2">
<span className="text-zinc-400">Translation service</span> <span className="text-zinc-400">Translation service</span>
<span className="font-medium capitalize">{dashboard.config.translation_service}</span> <span className="font-medium capitalize">{dashboard.config?.translation_service ?? 'N/A'}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -423,21 +423,21 @@ export default function AdminPage() {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between items-center py-2 border-b border-zinc-700/50"> <div className="flex justify-between items-center py-2 border-b border-zinc-700/50">
<span className="text-zinc-400">Service status</span> <span className="text-zinc-400">Service status</span>
<span className={`font-medium ${dashboard.cleanup.is_running ? "text-green-400" : "text-red-400"}`}> <span className={`font-medium ${dashboard.cleanup?.is_running ? "text-green-400" : "text-red-400"}`}>
{dashboard.cleanup.is_running ? "Running" : "Stopped"} {dashboard.cleanup?.is_running ? "Running" : "Stopped"}
</span> </span>
</div> </div>
<div className="flex justify-between items-center py-2 border-b border-zinc-700/50"> <div className="flex justify-between items-center py-2 border-b border-zinc-700/50">
<span className="text-zinc-400">Files cleaned</span> <span className="text-zinc-400">Files cleaned</span>
<span className="font-medium">{dashboard.cleanup.files_cleaned_total}</span> <span className="font-medium">{dashboard.cleanup?.files_cleaned_total ?? 0}</span>
</div> </div>
<div className="flex justify-between items-center py-2 border-b border-zinc-700/50"> <div className="flex justify-between items-center py-2 border-b border-zinc-700/50">
<span className="text-zinc-400">Space freed</span> <span className="text-zinc-400">Space freed</span>
<span className="font-medium">{dashboard.cleanup.bytes_freed_total_mb.toFixed(2)} MB</span> <span className="font-medium">{(dashboard.cleanup?.bytes_freed_total_mb ?? 0).toFixed(2)} MB</span>
</div> </div>
<div className="flex justify-between items-center py-2"> <div className="flex justify-between items-center py-2">
<span className="text-zinc-400">Cleanup runs</span> <span className="text-zinc-400">Cleanup runs</span>
<span className="font-medium">{dashboard.cleanup.cleanup_runs}</span> <span className="font-medium">{dashboard.cleanup?.cleanup_runs ?? 0}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,189 @@
"use client";
import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Eye, EyeOff, Mail, Lock, ArrowRight, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function LoginForm() {
const router = useRouter();
const searchParams = useSearchParams();
const redirect = searchParams.get("redirect") || "/";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch("http://localhost:8000/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || "Login failed");
}
// Store tokens
localStorage.setItem("token", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token);
localStorage.setItem("user", JSON.stringify(data.user));
// Redirect
router.push(redirect);
} catch (err: any) {
setError(err.message || "Login failed");
} finally {
setLoading(false);
}
};
return (
<>
{/* Card */}
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/50 p-8">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-white mb-2">Welcome back</h1>
<p className="text-zinc-400">Sign in to continue translating</p>
</div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email" className="text-zinc-300">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password" className="text-zinc-300">Password</Label>
<Link href="/auth/forgot-password" className="text-sm text-teal-400 hover:text-teal-300">
Forgot password?
</Link>
</div>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="pl-10 pr-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<Button
type="submit"
disabled={loading}
className="w-full bg-teal-500 hover:bg-teal-600 text-white"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
Sign In
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</form>
<div className="mt-6 text-center text-sm text-zinc-400">
Don&apos;t have an account?{" "}
<Link
href={`/auth/register${redirect !== "/" ? `?redirect=${redirect}` : ""}`}
className="text-teal-400 hover:text-teal-300"
>
Sign up for free
</Link>
</div>
</div>
{/* Features reminder */}
<div className="mt-8 text-center">
<p className="text-sm text-zinc-500 mb-3">Start with our free plan:</p>
<div className="flex flex-wrap justify-center gap-2">
{["5 docs/day", "10 pages/doc", "Free forever"].map((feature) => (
<span
key={feature}
className="px-3 py-1 rounded-full bg-zinc-800 text-zinc-400 text-xs"
>
{feature}
</span>
))}
</div>
</div>
</>
);
}
function LoadingFallback() {
return (
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/50 p-8">
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-teal-500" />
</div>
</div>
);
}
export default function LoginPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626] flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-teal-500 text-white font-bold text-xl">
A
</div>
<span className="text-2xl font-semibold text-white">Translate Co.</span>
</Link>
</div>
<Suspense fallback={<LoadingFallback />}>
<LoginForm />
</Suspense>
</div>
</div>
);
}

View File

@@ -0,0 +1,226 @@
"use client";
import { useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Eye, EyeOff, Mail, Lock, User, ArrowRight, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function RegisterForm() {
const router = useRouter();
const searchParams = useSearchParams();
const redirect = searchParams.get("redirect") || "/";
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
if (password !== confirmPassword) {
setError("Passwords do not match");
return;
}
if (password.length < 8) {
setError("Password must be at least 8 characters");
return;
}
setLoading(true);
try {
const res = await fetch("http://localhost:8000/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.detail || "Registration failed");
}
// Store tokens
localStorage.setItem("token", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token);
localStorage.setItem("user", JSON.stringify(data.user));
// Redirect
router.push(redirect);
} catch (err: any) {
setError(err.message || "Registration failed");
} finally {
setLoading(false);
}
};
return (
<>
{/* Card */}
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/50 p-8">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-white mb-2">Create an account</h1>
<p className="text-zinc-400">Start translating documents for free</p>
</div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name" className="text-zinc-300">Full Name</Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
<Input
id="name"
type="text"
placeholder="John Doe"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="email" className="text-zinc-300">Email</Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="password" className="text-zinc-300">Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
<Input
id="password"
type={showPassword ? "text" : "password"}
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="pl-10 pr-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword" className="text-zinc-300">Confirm Password</Label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-zinc-500" />
<Input
id="confirmPassword"
type={showPassword ? "text" : "password"}
placeholder="••••••••"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="pl-10 bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
/>
</div>
</div>
<Button
type="submit"
disabled={loading}
className="w-full bg-teal-500 hover:bg-teal-600 text-white"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
Create Account
<ArrowRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
</form>
<div className="mt-6 text-center text-sm text-zinc-400">
Already have an account?{" "}
<Link
href={`/auth/login${redirect !== "/" ? `?redirect=${redirect}` : ""}`}
className="text-teal-400 hover:text-teal-300"
>
Sign in
</Link>
</div>
<div className="mt-6 text-center text-xs text-zinc-500">
By creating an account, you agree to our{" "}
<Link href="/terms" className="text-zinc-400 hover:text-zinc-300">
Terms of Service
</Link>{" "}
and{" "}
<Link href="/privacy" className="text-zinc-400 hover:text-zinc-300">
Privacy Policy
</Link>
</div>
</div>
</>
);
}
function LoadingFallback() {
return (
<div className="rounded-2xl border border-zinc-800 bg-zinc-900/50 p-8">
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-teal-500" />
</div>
</div>
);
}
export default function RegisterPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626] flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-teal-500 text-white font-bold text-xl">
A
</div>
<span className="text-2xl font-semibold text-white">Translate Co.</span>
</Link>
</div>
<Suspense fallback={<LoadingFallback />}>
<RegisterForm />
</Suspense>
</div>
</div>
);
}

View File

@@ -0,0 +1,363 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
FileText,
CreditCard,
Settings,
LogOut,
ChevronRight,
Zap,
TrendingUp,
Clock,
Check,
ExternalLink,
Crown,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { cn } from "@/lib/utils";
interface User {
id: string;
email: string;
name: string;
plan: string;
subscription_status: string;
docs_translated_this_month: number;
pages_translated_this_month: number;
extra_credits: number;
plan_limits: {
docs_per_month: number;
max_pages_per_doc: number;
features: string[];
providers: string[];
};
}
interface UsageStats {
docs_used: number;
docs_limit: number;
docs_remaining: number;
pages_used: number;
extra_credits: number;
max_pages_per_doc: number;
allowed_providers: string[];
}
export default function DashboardPage() {
const router = useRouter();
const [user, setUser] = useState<User | null>(null);
const [usage, setUsage] = useState<UsageStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = localStorage.getItem("token");
if (!token) {
router.push("/auth/login?redirect=/dashboard");
return;
}
fetchUserData(token);
}, [router]);
const fetchUserData = async (token: string) => {
try {
const [userRes, usageRes] = await Promise.all([
fetch("http://localhost:8000/api/auth/me", {
headers: { Authorization: `Bearer ${token}` },
}),
fetch("http://localhost:8000/api/auth/usage", {
headers: { Authorization: `Bearer ${token}` },
}),
]);
if (!userRes.ok) {
throw new Error("Session expired");
}
const userData = await userRes.json();
const usageData = await usageRes.json();
setUser(userData);
setUsage(usageData);
} catch (error) {
localStorage.removeItem("token");
localStorage.removeItem("user");
router.push("/auth/login?redirect=/dashboard");
} finally {
setLoading(false);
}
};
const handleLogout = () => {
localStorage.removeItem("token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");
router.push("/");
};
const handleUpgrade = () => {
router.push("/pricing");
};
const handleManageBilling = async () => {
const token = localStorage.getItem("token");
try {
const res = await fetch("http://localhost:8000/api/auth/billing-portal", {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
if (data.url) {
window.open(data.url, "_blank");
}
} catch (error) {
console.error("Failed to open billing portal");
}
};
if (loading) {
return (
<div className="min-h-screen bg-[#262626] flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-teal-500" />
</div>
);
}
if (!user || !usage) {
return null;
}
const docsPercentage = usage.docs_limit > 0
? Math.min(100, (usage.docs_used / usage.docs_limit) * 100)
: 0;
const planColors: Record<string, string> = {
free: "bg-zinc-500",
starter: "bg-blue-500",
pro: "bg-teal-500",
business: "bg-purple-500",
enterprise: "bg-amber-500",
};
return (
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626]">
{/* Header */}
<header className="border-b border-zinc-800 bg-[#1a1a1a]/80 backdrop-blur-sm sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<Link href="/" className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-teal-500 text-white font-bold">
A
</div>
<span className="text-lg font-semibold text-white">Translate Co.</span>
</Link>
<div className="flex items-center gap-4">
<Link href="/">
<Button variant="outline" size="sm" className="border-zinc-700 text-zinc-300 hover:bg-zinc-800">
<FileText className="h-4 w-4 mr-2" />
Translate
</Button>
</Link>
<div className="flex items-center gap-2">
<div className="h-8 w-8 rounded-full bg-teal-600 flex items-center justify-center text-white text-sm font-medium">
{user.name.charAt(0).toUpperCase()}
</div>
<button
onClick={handleLogout}
className="p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800"
>
<LogOut className="h-4 w-4" />
</button>
</div>
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Welcome Section */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-white">Welcome back, {user.name.split(" ")[0]}!</h1>
<p className="text-zinc-400 mt-1">Here's an overview of your translation usage</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{/* Current Plan */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-zinc-400">Current Plan</span>
<Badge className={cn("text-white", planColors[user.plan])}>
{user.plan.charAt(0).toUpperCase() + user.plan.slice(1)}
</Badge>
</div>
<div className="flex items-center gap-2">
<Crown className="h-5 w-5 text-amber-400" />
<span className="text-2xl font-bold text-white capitalize">{user.plan}</span>
</div>
{user.plan !== "enterprise" && (
<Button
onClick={handleUpgrade}
size="sm"
className="mt-4 w-full bg-teal-500 hover:bg-teal-600 text-white"
>
Upgrade Plan
</Button>
)}
</div>
{/* Documents Used */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-zinc-400">Documents This Month</span>
<FileText className="h-4 w-4 text-zinc-500" />
</div>
<div className="text-2xl font-bold text-white mb-2">
{usage.docs_used} / {usage.docs_limit === -1 ? "∞" : usage.docs_limit}
</div>
<Progress value={docsPercentage} className="h-2 bg-zinc-800" />
<p className="text-xs text-zinc-500 mt-2">
{usage.docs_remaining === -1
? "Unlimited"
: `${usage.docs_remaining} remaining`}
</p>
</div>
{/* Pages Translated */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-zinc-400">Pages Translated</span>
<TrendingUp className="h-4 w-4 text-teal-400" />
</div>
<div className="text-2xl font-bold text-white">
{usage.pages_used}
</div>
<p className="text-xs text-zinc-500 mt-2">
Max {usage.max_pages_per_doc === -1 ? "unlimited" : usage.max_pages_per_doc} pages/doc
</p>
</div>
{/* Extra Credits */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
<div className="flex items-center justify-between mb-4">
<span className="text-sm text-zinc-400">Extra Credits</span>
<Zap className="h-4 w-4 text-amber-400" />
</div>
<div className="text-2xl font-bold text-white">
{usage.extra_credits}
</div>
<Link href="/pricing#credits">
<Button variant="outline" size="sm" className="mt-4 w-full border-zinc-700 text-zinc-300 hover:bg-zinc-800">
Buy Credits
</Button>
</Link>
</div>
</div>
{/* Features & Actions */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Available Features */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Your Plan Features</h2>
<ul className="space-y-3">
{user.plan_limits.features.map((feature, idx) => (
<li key={idx} className="flex items-start gap-2">
<Check className="h-5 w-5 text-teal-400 shrink-0 mt-0.5" />
<span className="text-zinc-300">{feature}</span>
</li>
))}
</ul>
</div>
{/* Quick Actions */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Quick Actions</h2>
<div className="space-y-2">
<Link href="/">
<button className="w-full flex items-center justify-between p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors">
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-teal-400" />
<span className="text-white">Translate a Document</span>
</div>
<ChevronRight className="h-4 w-4 text-zinc-500" />
</button>
</Link>
<Link href="/settings/services">
<button className="w-full flex items-center justify-between p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors">
<div className="flex items-center gap-3">
<Settings className="h-5 w-5 text-blue-400" />
<span className="text-white">Configure Providers</span>
</div>
<ChevronRight className="h-4 w-4 text-zinc-500" />
</button>
</Link>
{user.plan !== "free" && (
<button
onClick={handleManageBilling}
className="w-full flex items-center justify-between p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors"
>
<div className="flex items-center gap-3">
<CreditCard className="h-5 w-5 text-purple-400" />
<span className="text-white">Manage Billing</span>
</div>
<ExternalLink className="h-4 w-4 text-zinc-500" />
</button>
)}
<Link href="/pricing">
<button className="w-full flex items-center justify-between p-3 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors">
<div className="flex items-center gap-3">
<Crown className="h-5 w-5 text-amber-400" />
<span className="text-white">View Plans & Pricing</span>
</div>
<ChevronRight className="h-4 w-4 text-zinc-500" />
</button>
</Link>
</div>
</div>
</div>
{/* Available Providers */}
<div className="mt-6 rounded-xl border border-zinc-800 bg-zinc-900/50 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Available Translation Providers</h2>
<div className="flex flex-wrap gap-2">
{["ollama", "google", "deepl", "openai", "libre", "azure"].map((provider) => {
const isAvailable = usage.allowed_providers.includes(provider);
return (
<Badge
key={provider}
variant="outline"
className={cn(
"capitalize",
isAvailable
? "border-teal-500/50 text-teal-400 bg-teal-500/10"
: "border-zinc-700 text-zinc-500"
)}
>
{isAvailable && <Check className="h-3 w-3 mr-1" />}
{provider}
</Badge>
);
})}
</div>
{user.plan === "free" && (
<p className="text-sm text-zinc-500 mt-4">
<Link href="/pricing" className="text-teal-400 hover:text-teal-300">
Upgrade your plan
</Link>{" "}
to access more translation providers including Google, DeepL, and OpenAI.
</p>
)}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,364 @@
"use client";
import { useState, useEffect } from "react";
import {
Server,
Check,
AlertCircle,
Loader2,
ExternalLink,
Copy,
Terminal,
Cpu,
HardDrive
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
interface OllamaModel {
name: string;
size: string;
quantization: string;
}
const recommendedModels: OllamaModel[] = [
{ name: "llama3.2:3b", size: "2 GB", quantization: "Q4_0" },
{ name: "mistral:7b", size: "4.1 GB", quantization: "Q4_0" },
{ name: "qwen2.5:7b", size: "4.7 GB", quantization: "Q4_K_M" },
{ name: "llama3.1:8b", size: "4.7 GB", quantization: "Q4_0" },
{ name: "gemma2:9b", size: "5.4 GB", quantization: "Q4_0" },
];
export default function OllamaSetupPage() {
const [endpoint, setEndpoint] = useState("http://localhost:11434");
const [selectedModel, setSelectedModel] = useState("");
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [testing, setTesting] = useState(false);
const [connectionStatus, setConnectionStatus] = useState<"idle" | "success" | "error">("idle");
const [errorMessage, setErrorMessage] = useState("");
const [copied, setCopied] = useState<string | null>(null);
const testConnection = async () => {
setTesting(true);
setConnectionStatus("idle");
setErrorMessage("");
try {
// Test connection to Ollama
const res = await fetch(`${endpoint}/api/tags`);
if (!res.ok) throw new Error("Failed to connect to Ollama");
const data = await res.json();
const models = data.models?.map((m: any) => m.name) || [];
setAvailableModels(models);
setConnectionStatus("success");
// Auto-select first model if available
if (models.length > 0 && !selectedModel) {
setSelectedModel(models[0]);
}
} catch (error: any) {
setConnectionStatus("error");
setErrorMessage(error.message || "Failed to connect to Ollama");
} finally {
setTesting(false);
}
};
const copyToClipboard = (text: string, id: string) => {
navigator.clipboard.writeText(text);
setCopied(id);
setTimeout(() => setCopied(null), 2000);
};
const saveSettings = () => {
// Save to localStorage or user settings
const settings = { ollamaEndpoint: endpoint, ollamaModel: selectedModel };
localStorage.setItem("ollamaSettings", JSON.stringify(settings));
// Also save to user account if logged in
const token = localStorage.getItem("token");
if (token) {
fetch("http://localhost:8000/api/auth/settings", {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
ollama_endpoint: endpoint,
ollama_model: selectedModel,
}),
});
}
alert("Settings saved successfully!");
};
return (
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626] py-16">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="text-center mb-12">
<Badge className="mb-4 bg-orange-500/20 text-orange-400 border-orange-500/30">
Self-Hosted
</Badge>
<h1 className="text-4xl font-bold text-white mb-4">
Configure Your Ollama Server
</h1>
<p className="text-xl text-zinc-400 max-w-2xl mx-auto">
Connect your own Ollama instance for unlimited, free translations using local AI models.
</p>
</div>
{/* What is Ollama */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 mb-8">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Server className="h-5 w-5 text-orange-400" />
What is Ollama?
</h2>
<p className="text-zinc-400 mb-4">
Ollama is a free, open-source tool that lets you run large language models locally on your computer.
With Ollama, you can translate documents without sending data to external servers, ensuring complete privacy.
</p>
<div className="flex flex-wrap gap-4">
<a
href="https://ollama.ai"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-teal-400 hover:text-teal-300"
>
Visit Ollama Website
<ExternalLink className="h-4 w-4" />
</a>
<a
href="https://github.com/ollama/ollama"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-teal-400 hover:text-teal-300"
>
GitHub Repository
<ExternalLink className="h-4 w-4" />
</a>
</div>
</div>
{/* Installation Guide */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 mb-8">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Terminal className="h-5 w-5 text-blue-400" />
Quick Installation Guide
</h2>
<div className="space-y-6">
{/* Step 1 */}
<div>
<h3 className="text-white font-medium mb-2">1. Install Ollama</h3>
<div className="space-y-2">
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800">
<div>
<span className="text-zinc-400">macOS / Linux:</span>
<code className="ml-2 text-teal-400">curl -fsSL https://ollama.ai/install.sh | sh</code>
</div>
<button
onClick={() => copyToClipboard("curl -fsSL https://ollama.ai/install.sh | sh", "install")}
className="p-2 rounded hover:bg-zinc-700"
>
{copied === "install" ? (
<Check className="h-4 w-4 text-green-400" />
) : (
<Copy className="h-4 w-4 text-zinc-400" />
)}
</button>
</div>
<p className="text-sm text-zinc-500">
Windows: Download from <a href="https://ollama.ai/download" className="text-teal-400 hover:underline" target="_blank">ollama.ai/download</a>
</p>
</div>
</div>
{/* Step 2 */}
<div>
<h3 className="text-white font-medium mb-2">2. Pull a Translation Model</h3>
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800">
<code className="text-teal-400">ollama pull llama3.2:3b</code>
<button
onClick={() => copyToClipboard("ollama pull llama3.2:3b", "pull")}
className="p-2 rounded hover:bg-zinc-700"
>
{copied === "pull" ? (
<Check className="h-4 w-4 text-green-400" />
) : (
<Copy className="h-4 w-4 text-zinc-400" />
)}
</button>
</div>
</div>
{/* Step 3 */}
<div>
<h3 className="text-white font-medium mb-2">3. Start Ollama Server</h3>
<div className="flex items-center justify-between p-3 rounded-lg bg-zinc-800">
<code className="text-teal-400">ollama serve</code>
<button
onClick={() => copyToClipboard("ollama serve", "serve")}
className="p-2 rounded hover:bg-zinc-700"
>
{copied === "serve" ? (
<Check className="h-4 w-4 text-green-400" />
) : (
<Copy className="h-4 w-4 text-zinc-400" />
)}
</button>
</div>
<p className="text-sm text-zinc-500 mt-2">
On macOS/Windows with the desktop app, Ollama runs automatically in the background.
</p>
</div>
</div>
</div>
{/* Recommended Models */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 mb-8">
<h2 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Cpu className="h-5 w-5 text-purple-400" />
Recommended Models for Translation
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{recommendedModels.map((model) => (
<div
key={model.name}
className="flex items-center justify-between p-3 rounded-lg bg-zinc-800"
>
<div>
<span className="text-white font-medium">{model.name}</span>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs border-zinc-700 text-zinc-400">
<HardDrive className="h-3 w-3 mr-1" />
{model.size}
</Badge>
</div>
</div>
<button
onClick={() => copyToClipboard(`ollama pull ${model.name}`, model.name)}
className="px-3 py-1.5 rounded bg-zinc-700 hover:bg-zinc-600 text-sm text-white"
>
{copied === model.name ? "Copied!" : "Copy command"}
</button>
</div>
))}
</div>
<p className="text-sm text-zinc-500 mt-4">
💡 Tip: For best results with limited RAM (8GB), use <code className="text-teal-400">llama3.2:3b</code>.
With 16GB+ RAM, try <code className="text-teal-400">mistral:7b</code> or larger.
</p>
</div>
{/* Configuration */}
<div className="rounded-xl border border-zinc-800 bg-zinc-900/50 p-6 mb-8">
<h2 className="text-lg font-semibold text-white mb-4">Configure Connection</h2>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="endpoint" className="text-zinc-300">Ollama Server URL</Label>
<div className="flex gap-2">
<Input
id="endpoint"
type="url"
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
placeholder="http://localhost:11434"
className="bg-zinc-800 border-zinc-700 text-white"
/>
<Button
onClick={testConnection}
disabled={testing}
className="bg-teal-500 hover:bg-teal-600 text-white whitespace-nowrap"
>
{testing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Test Connection"
)}
</Button>
</div>
</div>
{/* Connection Status */}
{connectionStatus === "success" && (
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-2">
<Check className="h-5 w-5 text-green-400" />
<span className="text-green-400">Connected successfully! Found {availableModels.length} model(s).</span>
</div>
)}
{connectionStatus === "error" && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/20 flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-red-400" />
<span className="text-red-400">{errorMessage}</span>
</div>
)}
{/* Model Selection */}
{availableModels.length > 0 && (
<div className="space-y-2">
<Label className="text-zinc-300">Select Model</Label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{availableModels.map((model) => (
<button
key={model}
onClick={() => setSelectedModel(model)}
className={cn(
"p-3 rounded-lg border text-left transition-colors",
selectedModel === model
? "border-teal-500 bg-teal-500/10 text-teal-400"
: "border-zinc-700 bg-zinc-800 text-zinc-300 hover:border-zinc-600"
)}
>
{model}
</button>
))}
</div>
</div>
)}
{/* Save Button */}
{connectionStatus === "success" && selectedModel && (
<Button
onClick={saveSettings}
className="w-full bg-teal-500 hover:bg-teal-600 text-white"
>
Save Configuration
</Button>
)}
</div>
</div>
{/* Benefits */}
<div className="rounded-xl border border-zinc-800 bg-gradient-to-r from-teal-500/10 to-purple-500/10 p-6">
<h2 className="text-lg font-semibold text-white mb-4">Why Self-Host?</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="text-center">
<div className="text-3xl mb-2">🔒</div>
<h3 className="font-medium text-white mb-1">Complete Privacy</h3>
<p className="text-sm text-zinc-400">Your documents never leave your computer</p>
</div>
<div className="text-center">
<div className="text-3xl mb-2"></div>
<h3 className="font-medium text-white mb-1">Unlimited Usage</h3>
<p className="text-sm text-zinc-400">No monthly limits or quotas</p>
</div>
<div className="text-center">
<div className="text-3xl mb-2">💰</div>
<h3 className="font-medium text-white mb-1">Free Forever</h3>
<p className="text-sm text-zinc-400">No subscription or API costs</p>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,49 +1,59 @@
"use client"; "use client";
import { FileUploader } from "@/components/file-uploader"; import { FileUploader } from "@/components/file-uploader";
import { useTranslationStore } from "@/lib/store"; import {
import { Badge } from "@/components/ui/badge"; LandingHero,
import { Settings } from "lucide-react"; FeaturesSection,
PricingPreview,
SelfHostCTA
} from "@/components/landing-sections";
import Link from "next/link"; import Link from "next/link";
export default function Home() { export default function Home() {
const { settings } = useTranslationStore();
const providerNames: Record<string, string> = {
google: "Google Translate",
ollama: "Ollama",
deepl: "DeepL",
libre: "LibreTranslate",
webllm: "WebLLM",
};
return ( return (
<div className="space-y-6"> <div className="space-y-0 -m-8">
<div className="flex items-start justify-between"> {/* Hero Section */}
<div> <LandingHero />
<h1 className="text-3xl font-bold text-white">Translate Documents</h1>
{/* Upload Section */}
<div id="upload" className="px-8 py-12 bg-zinc-900/30">
<div className="max-w-6xl mx-auto">
<div className="mb-6">
<h2 className="text-2xl font-bold text-white">Translate Your Document</h2>
<p className="text-zinc-400 mt-1"> <p className="text-zinc-400 mt-1">
Upload and translate Excel, Word, and PowerPoint files while preserving all formatting. Upload and translate Excel, Word, and PowerPoint files while preserving all formatting.
</p> </p>
</div> </div>
{/* Current Configuration Badge */} <FileUploader />
<Link href="/settings/services" className="flex items-center gap-2 px-3 py-2 rounded-lg bg-zinc-800/50 border border-zinc-700 hover:bg-zinc-800 transition-colors">
<Settings className="h-4 w-4 text-zinc-400" />
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-teal-500/50 text-teal-400 text-xs">
{providerNames[settings.defaultProvider]}
</Badge>
{settings.defaultProvider === "ollama" && settings.ollamaModel && (
<Badge variant="outline" className="border-zinc-600 text-zinc-400 text-xs">
{settings.ollamaModel}
</Badge>
)}
</div> </div>
</Link>
</div> </div>
<FileUploader /> {/* Features Section */}
<FeaturesSection />
{/* Pricing Preview */}
<PricingPreview />
{/* Self-Host CTA */}
<SelfHostCTA />
{/* Footer */}
<footer className="border-t border-zinc-800 py-8 px-8">
<div className="max-w-6xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-teal-500 text-white font-bold text-sm">
A
</div>
<span className="text-sm text-zinc-400">© 2024 Translate Co. All rights reserved.</span>
</div>
<div className="flex items-center gap-6 text-sm text-zinc-500">
<Link href="/pricing" className="hover:text-zinc-300">Pricing</Link>
<Link href="/terms" className="hover:text-zinc-300">Terms</Link>
<Link href="/privacy" className="hover:text-zinc-300">Privacy</Link>
</div>
</div>
</footer>
</div> </div>
); );
} }

View File

@@ -0,0 +1,421 @@
"use client";
import { useState, useEffect } from "react";
import { Check, Zap, Building2, Crown, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
interface Plan {
id: string;
name: string;
price_monthly: number;
price_yearly: number;
features: string[];
docs_per_month: number;
max_pages_per_doc: number;
providers: string[];
popular?: boolean;
}
interface CreditPackage {
credits: number;
price: number;
price_per_credit: number;
popular?: boolean;
}
const planIcons: Record<string, any> = {
free: Sparkles,
starter: Zap,
pro: Crown,
business: Building2,
enterprise: Building2,
};
export default function PricingPage() {
const [isYearly, setIsYearly] = useState(false);
const [plans, setPlans] = useState<Plan[]>([]);
const [creditPackages, setCreditPackages] = useState<CreditPackage[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchPlans();
}, []);
const fetchPlans = async () => {
try {
const res = await fetch("http://localhost:8000/api/auth/plans");
const data = await res.json();
setPlans(data.plans || []);
setCreditPackages(data.credit_packages || []);
} catch (error) {
// Use default plans if API fails
setPlans([
{
id: "free",
name: "Free",
price_monthly: 0,
price_yearly: 0,
features: [
"3 documents per day",
"Up to 10 pages per document",
"Ollama (self-hosted) only",
"Basic support via community",
],
docs_per_month: 3,
max_pages_per_doc: 10,
providers: ["ollama"],
},
{
id: "starter",
name: "Starter",
price_monthly: 9,
price_yearly: 90,
features: [
"50 documents per month",
"Up to 50 pages per document",
"Google Translate included",
"LibreTranslate included",
"Email support",
],
docs_per_month: 50,
max_pages_per_doc: 50,
providers: ["ollama", "google", "libre"],
},
{
id: "pro",
name: "Pro",
price_monthly: 29,
price_yearly: 290,
features: [
"200 documents per month",
"Up to 200 pages per document",
"All translation providers",
"DeepL & OpenAI included",
"API access (1000 calls/month)",
"Priority email support",
],
docs_per_month: 200,
max_pages_per_doc: 200,
providers: ["ollama", "google", "deepl", "openai", "libre"],
popular: true,
},
{
id: "business",
name: "Business",
price_monthly: 79,
price_yearly: 790,
features: [
"1000 documents per month",
"Up to 500 pages per document",
"All translation providers",
"Azure Translator included",
"Unlimited API access",
"Priority processing queue",
"Dedicated support",
"Team management (up to 5 users)",
],
docs_per_month: 1000,
max_pages_per_doc: 500,
providers: ["ollama", "google", "deepl", "openai", "libre", "azure"],
},
{
id: "enterprise",
name: "Enterprise",
price_monthly: -1,
price_yearly: -1,
features: [
"Unlimited documents",
"Unlimited pages",
"Custom integrations",
"On-premise deployment",
"SLA guarantee",
"24/7 dedicated support",
"Custom AI models",
"White-label option",
],
docs_per_month: -1,
max_pages_per_doc: -1,
providers: ["all"],
},
]);
setCreditPackages([
{ credits: 50, price: 5, price_per_credit: 0.1 },
{ credits: 100, price: 9, price_per_credit: 0.09, popular: true },
{ credits: 250, price: 20, price_per_credit: 0.08 },
{ credits: 500, price: 35, price_per_credit: 0.07 },
{ credits: 1000, price: 60, price_per_credit: 0.06 },
]);
} finally {
setLoading(false);
}
};
const handleSubscribe = async (planId: string) => {
// Check if user is logged in
const token = localStorage.getItem("token");
if (!token) {
window.location.href = "/auth/login?redirect=/pricing&plan=" + planId;
return;
}
// Create checkout session
try {
const res = await fetch("http://localhost:8000/api/auth/checkout/subscription", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
plan: planId,
billing_period: isYearly ? "yearly" : "monthly",
}),
});
const data = await res.json();
if (data.url) {
window.location.href = data.url;
} else if (data.demo_mode) {
alert("Upgraded to " + planId + " (demo mode)");
window.location.href = "/dashboard";
}
} catch (error) {
console.error("Checkout error:", error);
}
};
return (
<div className="min-h-screen bg-gradient-to-b from-[#1a1a1a] to-[#262626] py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="text-center mb-16">
<Badge className="mb-4 bg-teal-500/20 text-teal-400 border-teal-500/30">
Pricing
</Badge>
<h1 className="text-4xl md:text-5xl font-bold text-white mb-4">
Simple, Transparent Pricing
</h1>
<p className="text-xl text-zinc-400 max-w-2xl mx-auto">
Choose the perfect plan for your translation needs. Start free and scale as you grow.
</p>
{/* Billing Toggle */}
<div className="mt-8 flex items-center justify-center gap-4">
<span className={cn("text-sm", !isYearly ? "text-white" : "text-zinc-500")}>
Monthly
</span>
<button
onClick={() => setIsYearly(!isYearly)}
className={cn(
"relative inline-flex h-6 w-11 items-center rounded-full transition-colors",
isYearly ? "bg-teal-500" : "bg-zinc-700"
)}
>
<span
className={cn(
"inline-block h-4 w-4 transform rounded-full bg-white transition-transform",
isYearly ? "translate-x-6" : "translate-x-1"
)}
/>
</button>
<span className={cn("text-sm", isYearly ? "text-white" : "text-zinc-500")}>
Yearly
<Badge className="ml-2 bg-green-500/20 text-green-400 border-green-500/30 text-xs">
Save 17%
</Badge>
</span>
</div>
</div>
{/* Plans Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-16">
{plans.slice(0, 4).map((plan) => {
const Icon = planIcons[plan.id] || Sparkles;
const price = isYearly ? plan.price_yearly : plan.price_monthly;
const isEnterprise = plan.id === "enterprise";
const isPro = plan.popular;
return (
<div
key={plan.id}
className={cn(
"relative rounded-2xl border p-6 flex flex-col",
isPro
? "border-teal-500 bg-gradient-to-b from-teal-500/10 to-transparent"
: "border-zinc-800 bg-zinc-900/50"
)}
>
{isPro && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-teal-500 text-white">
Most Popular
</Badge>
)}
<div className="flex items-center gap-3 mb-4">
<div
className={cn(
"p-2 rounded-lg",
isPro ? "bg-teal-500/20" : "bg-zinc-800"
)}
>
<Icon className={cn("h-5 w-5", isPro ? "text-teal-400" : "text-zinc-400")} />
</div>
<h3 className="text-xl font-semibold text-white">{plan.name}</h3>
</div>
<div className="mb-6">
{isEnterprise || price < 0 ? (
<div className="text-3xl font-bold text-white">Custom</div>
) : (
<>
<span className="text-4xl font-bold text-white">
${isYearly ? Math.round(price / 12) : price}
</span>
<span className="text-zinc-500">/month</span>
{isYearly && price > 0 && (
<div className="text-sm text-zinc-500">
${price} billed yearly
</div>
)}
</>
)}
</div>
<ul className="space-y-3 mb-6 flex-1">
{plan.features.map((feature, idx) => (
<li key={idx} className="flex items-start gap-2">
<Check className="h-5 w-5 text-teal-400 shrink-0 mt-0.5" />
<span className="text-sm text-zinc-300">{feature}</span>
</li>
))}
</ul>
<Button
onClick={() => handleSubscribe(plan.id)}
className={cn(
"w-full",
isPro
? "bg-teal-500 hover:bg-teal-600 text-white"
: "bg-zinc-800 hover:bg-zinc-700 text-white"
)}
>
{plan.id === "free"
? "Get Started"
: isEnterprise
? "Contact Sales"
: "Subscribe"}
</Button>
</div>
);
})}
</div>
{/* Enterprise Section */}
{plans.find((p) => p.id === "enterprise") && (
<div className="rounded-2xl border border-zinc-800 bg-gradient-to-r from-purple-500/10 to-teal-500/10 p-8 mb-16">
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
<div>
<h3 className="text-2xl font-bold text-white mb-2">
Need Enterprise Features?
</h3>
<p className="text-zinc-400 max-w-xl">
Get unlimited translations, custom integrations, on-premise deployment,
dedicated support, and SLA guarantees. Perfect for large organizations.
</p>
</div>
<Button
size="lg"
className="bg-gradient-to-r from-purple-500 to-teal-500 hover:from-purple-600 hover:to-teal-600 text-white whitespace-nowrap"
>
Contact Sales
</Button>
</div>
</div>
)}
{/* Credit Packages */}
<div className="mb-16">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-white mb-2">Need Extra Pages?</h2>
<p className="text-zinc-400">
Buy credit packages to translate more pages. Credits never expire.
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{creditPackages.map((pkg, idx) => (
<div
key={idx}
className={cn(
"rounded-xl border p-4 text-center",
pkg.popular
? "border-teal-500 bg-teal-500/10"
: "border-zinc-800 bg-zinc-900/50"
)}
>
{pkg.popular && (
<Badge className="mb-2 bg-teal-500/20 text-teal-400 border-teal-500/30 text-xs">
Best Value
</Badge>
)}
<div className="text-2xl font-bold text-white">{pkg.credits}</div>
<div className="text-sm text-zinc-500 mb-2">pages</div>
<div className="text-xl font-semibold text-white">${pkg.price}</div>
<div className="text-xs text-zinc-500">
${pkg.price_per_credit.toFixed(2)}/page
</div>
<Button
size="sm"
variant="outline"
className="mt-3 w-full border-zinc-700 hover:bg-zinc-800"
>
Buy
</Button>
</div>
))}
</div>
</div>
{/* FAQ Section */}
<div className="max-w-3xl mx-auto">
<h2 className="text-2xl font-bold text-white text-center mb-8">
Frequently Asked Questions
</h2>
<div className="space-y-4">
{[
{
q: "Can I use my own Ollama instance?",
a: "Yes! The Free plan lets you connect your own Ollama server for unlimited translations. You just need to configure your Ollama endpoint in the settings.",
},
{
q: "What happens if I exceed my monthly limit?",
a: "You can either wait for the next month, upgrade to a higher plan, or purchase credit packages for additional pages.",
},
{
q: "Can I cancel my subscription anytime?",
a: "Yes, you can cancel anytime. You'll continue to have access until the end of your billing period.",
},
{
q: "Do credits expire?",
a: "No, purchased credits never expire and can be used anytime.",
},
{
q: "What file formats are supported?",
a: "We support Microsoft Office documents: Word (.docx), Excel (.xlsx), and PowerPoint (.pptx). The formatting is fully preserved during translation.",
},
].map((faq, idx) => (
<div key={idx} className="rounded-lg border border-zinc-800 bg-zinc-900/50 p-4">
<h3 className="font-medium text-white mb-2">{faq.q}</h3>
<p className="text-sm text-zinc-400">{faq.a}</p>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -7,10 +7,10 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { useTranslationStore, webllmModels, openaiModels } from "@/lib/store"; import { useTranslationStore, webllmModels, openaiModels, openrouterModels } from "@/lib/store";
import { providers, testOpenAIConnection, testOllamaConnection, getOllamaModels, type OllamaModel } from "@/lib/api"; import { providers, testOpenAIConnection, testOllamaConnection, getOllamaModels, type OllamaModel } from "@/lib/api";
import { useWebLLM } from "@/lib/webllm"; import { useWebLLM } from "@/lib/webllm";
import { Save, Loader2, Cloud, Check, ExternalLink, Wifi, CheckCircle, XCircle, Download, Trash2, Cpu, Server, RefreshCw } from "lucide-react"; import { Save, Loader2, Cloud, Check, ExternalLink, Wifi, CheckCircle, XCircle, Download, Trash2, Cpu, Server, RefreshCw, Zap } from "lucide-react";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -30,6 +30,8 @@ export default function TranslationServicesPage() {
const [deeplApiKey, setDeeplApiKey] = useState(settings.deeplApiKey); const [deeplApiKey, setDeeplApiKey] = useState(settings.deeplApiKey);
const [openaiApiKey, setOpenaiApiKey] = useState(settings.openaiApiKey); const [openaiApiKey, setOpenaiApiKey] = useState(settings.openaiApiKey);
const [openaiModel, setOpenaiModel] = useState(settings.openaiModel); const [openaiModel, setOpenaiModel] = useState(settings.openaiModel);
const [openrouterApiKey, setOpenrouterApiKey] = useState(settings.openrouterApiKey);
const [openrouterModel, setOpenrouterModel] = useState(settings.openrouterModel);
const [libreUrl, setLibreUrl] = useState(settings.libreTranslateUrl); const [libreUrl, setLibreUrl] = useState(settings.libreTranslateUrl);
const [webllmModel, setWebllmModel] = useState(settings.webllmModel); const [webllmModel, setWebllmModel] = useState(settings.webllmModel);
@@ -45,6 +47,10 @@ export default function TranslationServicesPage() {
const [openaiTestStatus, setOpenaiTestStatus] = useState<"idle" | "testing" | "success" | "error">("idle"); const [openaiTestStatus, setOpenaiTestStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
const [openaiTestMessage, setOpenaiTestMessage] = useState(""); const [openaiTestMessage, setOpenaiTestMessage] = useState("");
// OpenRouter connection test state
const [openrouterTestStatus, setOpenrouterTestStatus] = useState<"idle" | "testing" | "success" | "error">("idle");
const [openrouterTestMessage, setOpenrouterTestMessage] = useState("");
// WebLLM hook // WebLLM hook
const webllm = useWebLLM(); const webllm = useWebLLM();
@@ -54,6 +60,8 @@ export default function TranslationServicesPage() {
setDeeplApiKey(settings.deeplApiKey); setDeeplApiKey(settings.deeplApiKey);
setOpenaiApiKey(settings.openaiApiKey); setOpenaiApiKey(settings.openaiApiKey);
setOpenaiModel(settings.openaiModel); setOpenaiModel(settings.openaiModel);
setOpenrouterApiKey(settings.openrouterApiKey);
setOpenrouterModel(settings.openrouterModel);
setLibreUrl(settings.libreTranslateUrl); setLibreUrl(settings.libreTranslateUrl);
setWebllmModel(settings.webllmModel); setWebllmModel(settings.webllmModel);
setOllamaUrl(settings.ollamaUrl); setOllamaUrl(settings.ollamaUrl);
@@ -125,6 +133,31 @@ export default function TranslationServicesPage() {
} }
}; };
// Test OpenRouter connection
const testOpenRouterConnection = async () => {
if (!openrouterApiKey) {
setOpenrouterTestStatus("error");
setOpenrouterTestMessage("API key required");
return;
}
setOpenrouterTestStatus("testing");
try {
const response = await fetch("https://openrouter.ai/api/v1/models", {
headers: { Authorization: `Bearer ${openrouterApiKey}` }
});
if (response.ok) {
setOpenrouterTestStatus("success");
setOpenrouterTestMessage("Connected successfully!");
} else {
setOpenrouterTestStatus("error");
setOpenrouterTestMessage("Invalid API key");
}
} catch {
setOpenrouterTestStatus("error");
setOpenrouterTestMessage("Connection test failed");
}
};
const handleSave = async () => { const handleSave = async () => {
setIsSaving(true); setIsSaving(true);
try { try {
@@ -134,6 +167,8 @@ export default function TranslationServicesPage() {
deeplApiKey, deeplApiKey,
openaiApiKey, openaiApiKey,
openaiModel, openaiModel,
openrouterApiKey,
openrouterModel,
libreTranslateUrl: libreUrl, libreTranslateUrl: libreUrl,
webllmModel, webllmModel,
ollamaUrl, ollamaUrl,
@@ -553,6 +588,125 @@ export default function TranslationServicesPage() {
</Card> </Card>
)} )}
{/* OpenRouter Settings */}
{selectedProvider === "openrouter" && (
<Card className="border-zinc-800 bg-zinc-900/50 border-teal-500/30">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Zap className="h-6 w-6 text-teal-400" />
<div>
<CardTitle className="text-white">OpenRouter Settings</CardTitle>
<CardDescription>
Access DeepSeek, Mistral, Llama & more - Best value for translation
</CardDescription>
</div>
</div>
{openrouterTestStatus !== "idle" && openrouterTestStatus !== "testing" && (
<Badge
variant="outline"
className={
openrouterTestStatus === "success"
? "border-green-500 text-green-400"
: "border-red-500 text-red-400"
}
>
{openrouterTestStatus === "success" && <CheckCircle className="h-3 w-3 mr-1" />}
{openrouterTestStatus === "error" && <XCircle className="h-3 w-3 mr-1" />}
{openrouterTestStatus === "success" ? "Connected" : "Error"}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="openrouter-key" className="text-zinc-300">
API Key
</Label>
<div className="flex gap-2">
<Input
id="openrouter-key"
type="password"
value={openrouterApiKey}
onChange={(e) => setOpenrouterApiKey(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
placeholder="sk-or-..."
className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500"
/>
<Button
variant="outline"
onClick={testOpenRouterConnection}
disabled={openrouterTestStatus === "testing"}
className="border-zinc-700 text-zinc-300 hover:bg-zinc-800"
>
{openrouterTestStatus === "testing" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Wifi className="h-4 w-4" />
)}
</Button>
</div>
{openrouterTestMessage && (
<p className={`text-xs ${openrouterTestStatus === "success" ? "text-green-400" : "text-red-400"}`}>
{openrouterTestMessage}
</p>
)}
<p className="text-xs text-zinc-500">
Get your free API key from{" "}
<a
href="https://openrouter.ai/keys"
target="_blank"
rel="noopener noreferrer"
className="text-teal-400 hover:underline"
>
openrouter.ai/keys
</a>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="openrouter-model" className="text-zinc-300">
Model
</Label>
<Select
value={openrouterModel}
onValueChange={setOpenrouterModel}
>
<SelectTrigger
id="openrouter-model"
className="bg-zinc-800 border-zinc-700 text-white"
>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
{openrouterModels.map((model) => (
<SelectItem
key={model.id}
value={model.id}
className="text-white hover:bg-zinc-700"
>
<span className="flex items-center justify-between gap-4">
<span>{model.name}</span>
<Badge variant="outline" className="border-zinc-600 text-zinc-400 text-xs ml-2">
{model.description.split(' - ')[1]}
</Badge>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-zinc-500">
DeepSeek Chat offers the best quality/price ratio for translations
</p>
</div>
<div className="rounded-lg bg-teal-500/10 border border-teal-500/30 p-3">
<p className="text-sm text-teal-300">
💡 <strong>Recommended:</strong> DeepSeek Chat at $0.14/M tokens translates 200 pages for ~$0.50
</p>
</div>
</CardContent>
</Card>
)}
{/* OpenAI Settings */} {/* OpenAI Settings */}
{selectedProvider === "openai" && ( {selectedProvider === "openai" && (
<Card className="border-zinc-800 bg-zinc-900/50"> <Card className="border-zinc-800 bg-zinc-900/50">

View File

@@ -24,7 +24,7 @@ const fileIcons: Record<string, React.ElementType> = {
ppt: Presentation, ppt: Presentation,
}; };
type ProviderType = "google" | "ollama" | "deepl" | "libre" | "webllm" | "openai"; type ProviderType = "google" | "ollama" | "deepl" | "libre" | "webllm" | "openai" | "openrouter";
export function FileUploader() { export function FileUploader() {
const { settings, isTranslating, progress, setTranslating, setProgress } = useTranslationStore(); const { settings, isTranslating, progress, setTranslating, setProgress } = useTranslationStore();
@@ -220,6 +220,8 @@ export function FileUploader() {
libreUrl: settings.libreTranslateUrl, libreUrl: settings.libreTranslateUrl,
openaiApiKey: settings.openaiApiKey, openaiApiKey: settings.openaiApiKey,
openaiModel: settings.openaiModel, openaiModel: settings.openaiModel,
openrouterApiKey: settings.openrouterApiKey,
openrouterModel: settings.openrouterModel,
}); });
clearInterval(progressInterval); clearInterval(progressInterval);
@@ -321,11 +323,10 @@ export function FileUploader() {
<CardHeader> <CardHeader>
<CardTitle className="text-white">Translation Options</CardTitle> <CardTitle className="text-white">Translation Options</CardTitle>
<CardDescription> <CardDescription>
Configure your translation preferences Select your target language and start translating
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Target Language */} {/* Target Language */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="language" className="text-zinc-300">Target Language</Label> <Label htmlFor="language" className="text-zinc-300">Target Language</Label>
@@ -333,7 +334,7 @@ export function FileUploader() {
<SelectTrigger id="language" className="bg-zinc-800 border-zinc-700 text-white"> <SelectTrigger id="language" className="bg-zinc-800 border-zinc-700 text-white">
<SelectValue placeholder="Select language" /> <SelectValue placeholder="Select language" />
</SelectTrigger> </SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700"> <SelectContent className="bg-zinc-800 border-zinc-700 max-h-80">
{languages.map((lang) => ( {languages.map((lang) => (
<SelectItem <SelectItem
key={lang.code} key={lang.code}
@@ -350,75 +351,6 @@ export function FileUploader() {
</Select> </Select>
</div> </div>
{/* Provider */}
<div className="space-y-2">
<Label htmlFor="provider" className="text-zinc-300">Translation Provider</Label>
<Select value={provider} onValueChange={(value) => setProvider(value as ProviderType)}>
<SelectTrigger id="provider" className="bg-zinc-800 border-zinc-700 text-white">
<SelectValue placeholder="Select provider" />
</SelectTrigger>
<SelectContent className="bg-zinc-800 border-zinc-700">
{providers.map((prov) => (
<SelectItem
key={prov.id}
value={prov.id}
className="text-white hover:bg-zinc-700"
>
<span className="flex items-center gap-2">
<span>{prov.icon}</span>
<span>{prov.name}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
{/* Warning if API key not configured */}
{provider === "openai" && !settings.openaiApiKey && (
<p className="text-xs text-amber-400 mt-1">
OpenAI API key not configured. Go to Settings Translation Services
</p>
)}
{provider === "deepl" && !settings.deeplApiKey && (
<p className="text-xs text-amber-400 mt-1">
DeepL API key not configured. Go to Settings Translation Services
</p>
)}
{provider === "webllm" && !webllm.isLoaded && (
<p className="text-xs text-amber-400 mt-1">
WebLLM model not loaded. Go to Settings Translation Services to load a model
</p>
)}
{provider === "webllm" && webllm.isLoaded && (
<p className="text-xs text-green-400 mt-1 flex items-center gap-1">
<Cpu className="h-3 w-3" />
Model ready: {webllm.currentModel}
</p>
)}
{provider === "webllm" && !webllm.isWebGPUSupported() && (
<p className="text-xs text-red-400 mt-1 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
WebGPU not supported in this browser
</p>
)}
</div>
</div>
{/* Image Translation Toggle */}
{(provider === "ollama" || provider === "openai") && (
<div className="flex items-center justify-between rounded-lg border border-zinc-800 p-4">
<div className="space-y-0.5">
<Label className="text-zinc-300">Translate Images</Label>
<p className="text-xs text-zinc-500">
Extract and translate text from embedded images using vision model
</p>
</div>
<Switch
checked={translateImages}
onCheckedChange={setTranslateImages}
/>
</div>
)}
{/* Translate Button */} {/* Translate Button */}
<Button <Button
onClick={handleTranslate} onClick={handleTranslate}

View File

@@ -0,0 +1,288 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import {
ArrowRight,
Check,
FileText,
Globe2,
Zap,
Shield,
Server,
Sparkles,
FileSpreadsheet,
Presentation
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
interface User {
name: string;
plan: string;
}
export function LandingHero() {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
const storedUser = localStorage.getItem("user");
if (storedUser) {
try {
setUser(JSON.parse(storedUser));
} catch {
setUser(null);
}
}
}, []);
return (
<div className="relative overflow-hidden">
{/* Background gradient */}
<div className="absolute inset-0 bg-gradient-to-br from-teal-500/10 via-transparent to-purple-500/10" />
{/* Hero content */}
<div className="relative px-4 py-16 sm:py-24">
<div className="text-center max-w-4xl mx-auto">
<Badge className="mb-6 bg-teal-500/20 text-teal-400 border-teal-500/30">
<Sparkles className="h-3 w-3 mr-1" />
AI-Powered Document Translation
</Badge>
<h1 className="text-4xl sm:text-5xl lg:text-6xl font-bold text-white mb-6 leading-tight">
Translate Documents{" "}
<span className="text-transparent bg-clip-text bg-gradient-to-r from-teal-400 to-cyan-400">
Instantly
</span>
</h1>
<p className="text-xl text-zinc-400 mb-8 max-w-2xl mx-auto">
Upload Word, Excel, and PowerPoint files. Get perfect translations while preserving
all formatting, styles, and layouts. Powered by AI.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-12">
{user ? (
<Link href="#upload">
<Button size="lg" className="bg-teal-500 hover:bg-teal-600 text-white px-8">
Start Translating
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
) : (
<>
<Link href="/auth/register">
<Button size="lg" className="bg-teal-500 hover:bg-teal-600 text-white px-8">
Get Started Free
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
<Link href="/pricing">
<Button size="lg" variant="outline" className="border-zinc-700 text-white hover:bg-zinc-800">
View Pricing
</Button>
</Link>
</>
)}
</div>
{/* Supported formats */}
<div className="flex flex-wrap justify-center gap-4">
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-zinc-800/50 border border-zinc-700">
<FileText className="h-4 w-4 text-blue-400" />
<span className="text-sm text-zinc-300">Word (.docx)</span>
</div>
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-zinc-800/50 border border-zinc-700">
<FileSpreadsheet className="h-4 w-4 text-green-400" />
<span className="text-sm text-zinc-300">Excel (.xlsx)</span>
</div>
<div className="flex items-center gap-2 px-4 py-2 rounded-full bg-zinc-800/50 border border-zinc-700">
<Presentation className="h-4 w-4 text-orange-400" />
<span className="text-sm text-zinc-300">PowerPoint (.pptx)</span>
</div>
</div>
</div>
</div>
</div>
);
}
export function FeaturesSection() {
const features = [
{
icon: Globe2,
title: "100+ Languages",
description: "Translate between any language pair with high accuracy using AI models",
color: "text-blue-400",
},
{
icon: FileText,
title: "Preserve Formatting",
description: "All styles, fonts, colors, tables, and charts remain intact",
color: "text-green-400",
},
{
icon: Zap,
title: "Lightning Fast",
description: "Batch processing translates entire documents in seconds",
color: "text-amber-400",
},
{
icon: Shield,
title: "Secure & Private",
description: "Your documents are encrypted and never stored permanently",
color: "text-purple-400",
},
{
icon: Sparkles,
title: "AI-Powered",
description: "Advanced neural translation for natural, context-aware results",
color: "text-teal-400",
},
{
icon: Server,
title: "Enterprise Ready",
description: "API access, team management, and dedicated support for businesses",
color: "text-orange-400",
},
];
return (
<div className="py-16 px-4">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-white mb-4">
Everything You Need for Document Translation
</h2>
<p className="text-zinc-400 max-w-2xl mx-auto">
Professional-grade translation with enterprise features, available to everyone.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{features.map((feature) => {
const Icon = feature.icon;
return (
<div
key={feature.title}
className="p-6 rounded-xl border border-zinc-800 bg-zinc-900/50 hover:border-zinc-700 transition-colors"
>
<Icon className={cn("h-8 w-8 mb-4", feature.color)} />
<h3 className="text-lg font-semibold text-white mb-2">{feature.title}</h3>
<p className="text-zinc-400 text-sm">{feature.description}</p>
</div>
);
})}
</div>
</div>
</div>
);
}
export function PricingPreview() {
const plans = [
{
name: "Free",
price: "$0",
description: "Perfect for trying out",
features: ["5 documents/day", "10 pages/doc", "Basic support"],
cta: "Get Started",
href: "/auth/register",
},
{
name: "Pro",
price: "$29",
period: "/month",
description: "For professionals",
features: ["200 documents/month", "Unlimited pages", "Priority support", "API access"],
cta: "Start Free Trial",
href: "/pricing",
popular: true,
},
{
name: "Business",
price: "$79",
period: "/month",
description: "For teams",
features: ["1000 documents/month", "Team management", "Dedicated support", "SLA"],
cta: "Contact Sales",
href: "/pricing",
},
];
return (
<div className="py-16 px-4 bg-zinc-900/50">
<div className="max-w-5xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl font-bold text-white mb-4">
Simple, Transparent Pricing
</h2>
<p className="text-zinc-400">
Start free, upgrade when you need more.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{plans.map((plan) => (
<div
key={plan.name}
className={cn(
"relative p-6 rounded-xl border",
plan.popular
? "border-teal-500 bg-gradient-to-b from-teal-500/10 to-transparent"
: "border-zinc-800 bg-zinc-900/50"
)}
>
{plan.popular && (
<Badge className="absolute -top-3 left-1/2 -translate-x-1/2 bg-teal-500 text-white">
Most Popular
</Badge>
)}
<h3 className="text-xl font-semibold text-white mb-1">{plan.name}</h3>
<p className="text-sm text-zinc-400 mb-4">{plan.description}</p>
<div className="mb-6">
<span className="text-3xl font-bold text-white">{plan.price}</span>
{plan.period && <span className="text-zinc-500">{plan.period}</span>}
</div>
<ul className="space-y-2 mb-6">
{plan.features.map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm text-zinc-300">
<Check className="h-4 w-4 text-teal-400" />
{feature}
</li>
))}
</ul>
<Link href={plan.href}>
<Button
className={cn(
"w-full",
plan.popular
? "bg-teal-500 hover:bg-teal-600 text-white"
: "bg-zinc-800 hover:bg-zinc-700 text-white"
)}
>
{plan.cta}
</Button>
</Link>
</div>
))}
</div>
<div className="text-center mt-8">
<Link href="/pricing" className="text-teal-400 hover:text-teal-300 text-sm">
View all plans and features
</Link>
</div>
</div>
</div>
);
}
export function SelfHostCTA() {
return null; // Removed for commercial version
}

View File

@@ -2,13 +2,14 @@
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useState, useEffect, useCallback, memo } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
Settings,
Cloud,
BookText,
Upload, Upload,
Shield, LayoutDashboard,
LogIn,
Crown,
LogOut,
} from "lucide-react"; } from "lucide-react";
import { import {
Tooltip, Tooltip,
@@ -16,6 +17,14 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Badge } from "@/components/ui/badge";
interface User {
id: string;
name: string;
email: string;
plan: string;
}
const navigation = [ const navigation = [
{ {
@@ -24,40 +33,95 @@ const navigation = [
icon: Upload, icon: Upload,
description: "Translate documents", description: "Translate documents",
}, },
{
name: "General Settings",
href: "/settings",
icon: Settings,
description: "Configure general settings",
},
{
name: "Translation Services",
href: "/settings/services",
icon: Cloud,
description: "Configure translation providers",
},
{
name: "Context & Glossary",
href: "/settings/context",
icon: BookText,
description: "System prompts and glossary",
},
]; ];
const adminNavigation = [ const planColors: Record<string, string> = {
{ free: "bg-zinc-600",
name: "Admin Dashboard", starter: "bg-blue-500",
href: "/admin", pro: "bg-teal-500",
icon: Shield, business: "bg-purple-500",
description: "System monitoring (login required)", enterprise: "bg-amber-500",
}, };
];
// Memoized NavItem for performance
const NavItem = memo(function NavItem({
item,
isActive
}: {
item: typeof navigation[0];
isActive: boolean;
}) {
const Icon = item.icon;
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
isActive
? "bg-teal-500/10 text-teal-400"
: "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
)}
>
<Icon className="h-5 w-5" />
<span>{item.name}</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">
<p>{item.description}</p>
</TooltipContent>
</Tooltip>
);
});
export function Sidebar() { export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const [user, setUser] = useState<User | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const storedUser = localStorage.getItem("user");
if (storedUser) {
try {
setUser(JSON.parse(storedUser));
} catch {
setUser(null);
}
}
// Listen for storage changes
const handleStorage = (e: StorageEvent) => {
if (e.key === "user") {
setUser(e.newValue ? JSON.parse(e.newValue) : null);
}
};
window.addEventListener("storage", handleStorage);
return () => window.removeEventListener("storage", handleStorage);
}, []);
const handleLogout = useCallback(() => {
localStorage.removeItem("token");
localStorage.removeItem("refresh_token");
localStorage.removeItem("user");
setUser(null);
window.location.href = "/";
}, []);
// Prevent hydration mismatch
if (!mounted) {
return (
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-zinc-800 bg-[#1a1a1a]">
<div className="flex h-16 items-center gap-3 border-b border-zinc-800 px-6">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-teal-500 text-white font-bold">A</div>
<span className="text-lg font-semibold text-white">Translate Co.</span>
</div>
</aside>
);
}
return ( return (
<TooltipProvider> <TooltipProvider delayDuration={300}>
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-zinc-800 bg-[#1a1a1a]"> <aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-zinc-800 bg-[#1a1a1a]">
{/* Logo */} {/* Logo */}
<div className="flex h-16 items-center gap-3 border-b border-zinc-800 px-6"> <div className="flex h-16 items-center gap-3 border-b border-zinc-800 px-6">
@@ -96,49 +160,93 @@ export function Sidebar() {
); );
})} })}
{/* Admin Section */} {/* User Section */}
{user && (
<div className="mt-4 pt-4 border-t border-zinc-800"> <div className="mt-4 pt-4 border-t border-zinc-800">
<p className="px-3 mb-2 text-xs font-medium text-zinc-600 uppercase tracking-wider">Admin</p> <p className="px-3 mb-2 text-xs font-medium text-zinc-600 uppercase tracking-wider">Account</p>
{adminNavigation.map((item) => {
const isActive = pathname === item.href;
const Icon = item.icon;
return ( <Tooltip>
<Tooltip key={item.name}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Link <Link
href={item.href} href="/dashboard"
className={cn( className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors", "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
isActive pathname === "/dashboard"
? "bg-blue-500/10 text-blue-400" ? "bg-teal-500/10 text-teal-400"
: "text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300" : "text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
)} )}
> >
<Icon className="h-5 w-5" /> <LayoutDashboard className="h-5 w-5" />
<span>{item.name}</span> <span>Dashboard</span>
</Link> </Link>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right"> <TooltipContent side="right">
<p>{item.description}</p> <p>View your usage and settings</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
);
})} {user.plan === "free" && (
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/pricing"
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors",
pathname === "/pricing"
? "bg-amber-500/10 text-amber-400"
: "text-amber-400/70 hover:bg-zinc-800 hover:text-amber-400"
)}
>
<Crown className="h-5 w-5" />
<span>Upgrade Plan</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">
<p>Get more translations and features</p>
</TooltipContent>
</Tooltip>
)}
</div> </div>
)}
</nav> </nav>
{/* User section at bottom */} {/* User section at bottom */}
<div className="absolute bottom-0 left-0 right-0 border-t border-zinc-800 p-4"> <div className="absolute bottom-0 left-0 right-0 border-t border-zinc-800 p-4">
<div className="flex items-center gap-3"> {user ? (
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-teal-600 text-white text-sm font-medium"> <div className="flex items-center justify-between">
U <Link href="/dashboard" className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-gradient-to-br from-teal-500 to-teal-600 text-white text-sm font-medium shrink-0">
{user.name.charAt(0).toUpperCase()}
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col min-w-0">
<span className="text-sm font-medium text-white">User</span> <span className="text-sm font-medium text-white truncate">{user.name}</span>
<span className="text-xs text-zinc-500">Translator</span> <Badge className={cn("text-xs mt-0.5 w-fit", planColors[user.plan] || "bg-zinc-600")}>
{user.plan.charAt(0).toUpperCase() + user.plan.slice(1)}
</Badge>
</div> </div>
</Link>
<button
onClick={handleLogout}
className="p-2 rounded-lg text-zinc-400 hover:text-white hover:bg-zinc-800"
>
<LogOut className="h-4 w-4" />
</button>
</div> </div>
) : (
<div className="space-y-2">
<Link href="/auth/login" className="block">
<button className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-white text-sm font-medium transition-colors">
<LogIn className="h-4 w-4" />
Sign In
</button>
</Link>
<Link href="/auth/register" className="block">
<button className="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg bg-teal-500 hover:bg-teal-600 text-white text-sm font-medium transition-colors">
Get Started Free
</button>
</Link>
</div>
)}
</div> </div>
</aside> </aside>
</TooltipProvider> </TooltipProvider>

288
k8s/deployment.yaml Normal file
View File

@@ -0,0 +1,288 @@
# ============================================
# Document Translation API - Kubernetes Deployment
# ============================================
# Apply with: kubectl apply -f k8s/
apiVersion: v1
kind: Namespace
metadata:
name: translate-api
labels:
app: translate-api
---
# ConfigMap for application settings
apiVersion: v1
kind: ConfigMap
metadata:
name: translate-config
namespace: translate-api
data:
TRANSLATION_SERVICE: "ollama"
OLLAMA_BASE_URL: "http://ollama-service:11434"
OLLAMA_MODEL: "llama3"
MAX_FILE_SIZE_MB: "50"
RATE_LIMIT_REQUESTS_PER_MINUTE: "60"
RATE_LIMIT_TRANSLATIONS_PER_MINUTE: "10"
LOG_LEVEL: "INFO"
---
# Secret for sensitive data
apiVersion: v1
kind: Secret
metadata:
name: translate-secrets
namespace: translate-api
type: Opaque
stringData:
ADMIN_USERNAME: "admin"
ADMIN_PASSWORD: "changeme123" # Change in production!
DEEPL_API_KEY: ""
OPENAI_API_KEY: ""
---
# Backend Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: translate-api
labels:
app: backend
spec:
replicas: 2
selector:
matchLabels:
app: backend
template:
metadata:
labels:
app: backend
spec:
containers:
- name: backend
image: translate-api-backend:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8000
envFrom:
- configMapRef:
name: translate-config
- secretRef:
name: translate-secrets
resources:
requests:
memory: "512Mi"
cpu: "250m"
limits:
memory: "2Gi"
cpu: "1000m"
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 30
periodSeconds: 30
volumeMounts:
- name: uploads
mountPath: /app/uploads
- name: outputs
mountPath: /app/outputs
volumes:
- name: uploads
persistentVolumeClaim:
claimName: uploads-pvc
- name: outputs
persistentVolumeClaim:
claimName: outputs-pvc
---
# Backend Service
apiVersion: v1
kind: Service
metadata:
name: backend-service
namespace: translate-api
spec:
selector:
app: backend
ports:
- port: 8000
targetPort: 8000
type: ClusterIP
---
# Frontend Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: translate-api
labels:
app: frontend
spec:
replicas: 2
selector:
matchLabels:
app: frontend
template:
metadata:
labels:
app: frontend
spec:
containers:
- name: frontend
image: translate-api-frontend:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3000
env:
- name: NEXT_PUBLIC_API_URL
value: "http://backend-service:8000"
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
readinessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
---
# Frontend Service
apiVersion: v1
kind: Service
metadata:
name: frontend-service
namespace: translate-api
spec:
selector:
app: frontend
ports:
- port: 3000
targetPort: 3000
type: ClusterIP
---
# Ingress for external access
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: translate-ingress
namespace: translate-api
annotations:
kubernetes.io/ingress.class: nginx
nginx.ingress.kubernetes.io/proxy-body-size: "100m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
tls:
- hosts:
- translate.yourdomain.com
secretName: translate-tls
rules:
- host: translate.yourdomain.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: backend-service
port:
number: 8000
- path: /translate
pathType: Prefix
backend:
service:
name: backend-service
port:
number: 8000
- path: /health
pathType: Prefix
backend:
service:
name: backend-service
port:
number: 8000
- path: /admin
pathType: Prefix
backend:
service:
name: backend-service
port:
number: 8000
- path: /
pathType: Prefix
backend:
service:
name: frontend-service
port:
number: 3000
---
# Persistent Volume Claims
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: uploads-pvc
namespace: translate-api
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: outputs-pvc
namespace: translate-api
spec:
accessModes:
- ReadWriteMany
resources:
requests:
storage: 20Gi
---
# Horizontal Pod Autoscaler for Backend
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: backend-hpa
namespace: translate-api
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: backend
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80

35
main.py
View File

@@ -21,6 +21,10 @@ import time
from config import config from config import config
from translators import excel_translator, word_translator, pptx_translator from translators import excel_translator, word_translator, pptx_translator
from utils import file_handler, handle_translation_error, DocumentProcessingError from utils import file_handler, handle_translation_error, DocumentProcessingError
from services.translation_service import _translation_cache
# Import auth routes
from routes.auth_routes import router as auth_router
# Import SaaS middleware # Import SaaS middleware
from middleware.rate_limiting import RateLimitMiddleware, RateLimitManager, RateLimitConfig from middleware.rate_limiting import RateLimitMiddleware, RateLimitManager, RateLimitConfig
@@ -174,6 +178,9 @@ static_dir = Path(__file__).parent / "static"
if static_dir.exists(): if static_dir.exists():
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
# Include auth routes
app.include_router(auth_router)
# Custom exception handler for ValidationError # Custom exception handler for ValidationError
@app.exception_handler(ValidationError) @app.exception_handler(ValidationError)
@@ -222,7 +229,8 @@ async def health_check():
"rate_limits": { "rate_limits": {
"requests_per_minute": rate_limit_config.requests_per_minute, "requests_per_minute": rate_limit_config.requests_per_minute,
"translations_per_minute": rate_limit_config.translations_per_minute, "translations_per_minute": rate_limit_config.translations_per_minute,
} },
"translation_cache": _translation_cache.stats()
} }
) )
@@ -269,7 +277,7 @@ async def translate_document(
file: UploadFile = File(..., description="Document file to translate (.xlsx, .docx, or .pptx)"), file: UploadFile = File(..., description="Document file to translate (.xlsx, .docx, or .pptx)"),
target_language: str = Form(..., description="Target language code (e.g., 'es', 'fr', 'de')"), target_language: str = Form(..., description="Target language code (e.g., 'es', 'fr', 'de')"),
source_language: str = Form(default="auto", description="Source language code (default: auto-detect)"), source_language: str = Form(default="auto", description="Source language code (default: auto-detect)"),
provider: str = Form(default="google", description="Translation provider (google, ollama, deepl, libre, openai)"), provider: str = Form(default="openrouter", description="Translation provider (openrouter, google, ollama, deepl, libre, openai)"),
translate_images: bool = Form(default=False, description="Translate images with multimodal Ollama/OpenAI model"), translate_images: bool = Form(default=False, description="Translate images with multimodal Ollama/OpenAI model"),
ollama_model: str = Form(default="", description="Ollama model to use (also used for vision if multimodal)"), ollama_model: str = Form(default="", description="Ollama model to use (also used for vision if multimodal)"),
system_prompt: str = Form(default="", description="Custom system prompt with context or instructions for LLM translation"), system_prompt: str = Form(default="", description="Custom system prompt with context or instructions for LLM translation"),
@@ -277,6 +285,8 @@ async def translate_document(
libre_url: str = Form(default="https://libretranslate.com", description="LibreTranslate server URL"), libre_url: str = Form(default="https://libretranslate.com", description="LibreTranslate server URL"),
openai_api_key: str = Form(default="", description="OpenAI API key"), openai_api_key: str = Form(default="", description="OpenAI API key"),
openai_model: str = Form(default="gpt-4o-mini", description="OpenAI model to use (gpt-4o-mini is cheapest with vision)"), openai_model: str = Form(default="gpt-4o-mini", description="OpenAI model to use (gpt-4o-mini is cheapest with vision)"),
openrouter_api_key: str = Form(default="", description="OpenRouter API key"),
openrouter_model: str = Form(default="deepseek/deepseek-chat", description="OpenRouter model (deepseek/deepseek-chat is best value)"),
cleanup: bool = Form(default=True, description="Delete input file after translation") cleanup: bool = Form(default=True, description="Delete input file after translation")
): ):
""" """
@@ -353,9 +363,19 @@ async def translate_document(
await cleanup_manager.track_file(output_path, ttl_minutes=60) await cleanup_manager.track_file(output_path, ttl_minutes=60)
# Configure translation provider # Configure translation provider
from services.translation_service import GoogleTranslationProvider, DeepLTranslationProvider, LibreTranslationProvider, OllamaTranslationProvider, OpenAITranslationProvider, translation_service from services.translation_service import GoogleTranslationProvider, DeepLTranslationProvider, LibreTranslationProvider, OllamaTranslationProvider, OpenAITranslationProvider, OpenRouterTranslationProvider, translation_service
if provider.lower() == "deepl": if provider.lower() == "openrouter":
api_key = openrouter_api_key.strip() if openrouter_api_key else os.getenv("OPENROUTER_API_KEY", "")
if not api_key:
raise HTTPException(status_code=400, detail="OpenRouter API key not provided. Get one at https://openrouter.ai/keys")
model_to_use = openrouter_model.strip() if openrouter_model else "deepseek/deepseek-chat"
custom_prompt = build_full_prompt(system_prompt, glossary)
logger.info(f"Using OpenRouter model: {model_to_use}")
if custom_prompt:
logger.info(f"Custom system prompt provided ({len(custom_prompt)} chars)")
translation_provider = OpenRouterTranslationProvider(api_key, model_to_use, custom_prompt)
elif provider.lower() == "deepl":
if not config.DEEPL_API_KEY: if not config.DEEPL_API_KEY:
raise HTTPException(status_code=400, detail="DeepL API key not configured") raise HTTPException(status_code=400, detail="DeepL API key not configured")
translation_provider = DeepLTranslationProvider(config.DEEPL_API_KEY) translation_provider = DeepLTranslationProvider(config.DEEPL_API_KEY)
@@ -383,6 +403,13 @@ async def translate_document(
if custom_prompt: if custom_prompt:
logger.info(f"Custom system prompt provided ({len(custom_prompt)} chars)") logger.info(f"Custom system prompt provided ({len(custom_prompt)} chars)")
translation_provider = OllamaTranslationProvider(config.OLLAMA_BASE_URL, model_to_use, model_to_use, custom_prompt) translation_provider = OllamaTranslationProvider(config.OLLAMA_BASE_URL, model_to_use, model_to_use, custom_prompt)
elif provider.lower() == "google":
translation_provider = GoogleTranslationProvider()
else:
# Default to OpenRouter with DeepSeek (best value)
api_key = openrouter_api_key.strip() if openrouter_api_key else os.getenv("OPENROUTER_API_KEY", "")
if api_key:
translation_provider = OpenRouterTranslationProvider(api_key, "deepseek/deepseek-chat", build_full_prompt(system_prompt, glossary))
else: else:
translation_provider = GoogleTranslationProvider() translation_provider = GoogleTranslationProvider()

View File

@@ -331,7 +331,7 @@ class LanguageValidator:
class ProviderValidator: class ProviderValidator:
"""Validates translation provider configuration""" """Validates translation provider configuration"""
SUPPORTED_PROVIDERS = {"google", "ollama", "deepl", "libre", "openai", "webllm"} SUPPORTED_PROVIDERS = {"google", "ollama", "deepl", "libre", "openai", "webllm", "openrouter"}
@classmethod @classmethod
def validate(cls, provider: str, **kwargs) -> dict: def validate(cls, provider: str, **kwargs) -> dict:

1
models/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Models package

250
models/subscription.py Normal file
View File

@@ -0,0 +1,250 @@
"""
Subscription and User models for the monetization system
"""
from pydantic import BaseModel, EmailStr, Field
from typing import Optional, List, Dict, Any
from datetime import datetime
from enum import Enum
class PlanType(str, Enum):
FREE = "free"
STARTER = "starter"
PRO = "pro"
BUSINESS = "business"
ENTERPRISE = "enterprise"
class SubscriptionStatus(str, Enum):
ACTIVE = "active"
CANCELED = "canceled"
PAST_DUE = "past_due"
TRIALING = "trialing"
PAUSED = "paused"
# Plan definitions with limits
PLANS = {
PlanType.FREE: {
"name": "Free",
"price_monthly": 0,
"price_yearly": 0,
"docs_per_month": 3,
"max_pages_per_doc": 10,
"max_file_size_mb": 5,
"providers": ["ollama"], # Only self-hosted
"features": [
"3 documents per day",
"Up to 10 pages per document",
"Ollama (self-hosted) only",
"Basic support via community",
],
"api_access": False,
"priority_processing": False,
"stripe_price_id_monthly": None,
"stripe_price_id_yearly": None,
},
PlanType.STARTER: {
"name": "Starter",
"price_monthly": 9,
"price_yearly": 90, # 2 months free
"docs_per_month": 50,
"max_pages_per_doc": 50,
"max_file_size_mb": 25,
"providers": ["ollama", "google", "libre"],
"features": [
"50 documents per month",
"Up to 50 pages per document",
"Google Translate included",
"LibreTranslate included",
"Email support",
],
"api_access": False,
"priority_processing": False,
"stripe_price_id_monthly": "price_starter_monthly",
"stripe_price_id_yearly": "price_starter_yearly",
},
PlanType.PRO: {
"name": "Pro",
"price_monthly": 29,
"price_yearly": 290, # 2 months free
"docs_per_month": 200,
"max_pages_per_doc": 200,
"max_file_size_mb": 100,
"providers": ["ollama", "google", "deepl", "openai", "libre"],
"features": [
"200 documents per month",
"Up to 200 pages per document",
"All translation providers",
"DeepL & OpenAI included",
"API access (1000 calls/month)",
"Priority email support",
],
"api_access": True,
"api_calls_per_month": 1000,
"priority_processing": True,
"stripe_price_id_monthly": "price_pro_monthly",
"stripe_price_id_yearly": "price_pro_yearly",
},
PlanType.BUSINESS: {
"name": "Business",
"price_monthly": 79,
"price_yearly": 790, # 2 months free
"docs_per_month": 1000,
"max_pages_per_doc": 500,
"max_file_size_mb": 250,
"providers": ["ollama", "google", "deepl", "openai", "libre", "azure"],
"features": [
"1000 documents per month",
"Up to 500 pages per document",
"All translation providers",
"Azure Translator included",
"Unlimited API access",
"Priority processing queue",
"Dedicated support",
"Team management (up to 5 users)",
],
"api_access": True,
"api_calls_per_month": -1, # Unlimited
"priority_processing": True,
"team_seats": 5,
"stripe_price_id_monthly": "price_business_monthly",
"stripe_price_id_yearly": "price_business_yearly",
},
PlanType.ENTERPRISE: {
"name": "Enterprise",
"price_monthly": -1, # Custom
"price_yearly": -1,
"docs_per_month": -1, # Unlimited
"max_pages_per_doc": -1,
"max_file_size_mb": -1,
"providers": ["ollama", "google", "deepl", "openai", "libre", "azure", "custom"],
"features": [
"Unlimited documents",
"Unlimited pages",
"Custom integrations",
"On-premise deployment",
"SLA guarantee",
"24/7 dedicated support",
"Custom AI models",
"White-label option",
],
"api_access": True,
"api_calls_per_month": -1,
"priority_processing": True,
"team_seats": -1, # Unlimited
"stripe_price_id_monthly": None, # Contact sales
"stripe_price_id_yearly": None,
},
}
class User(BaseModel):
id: str
email: EmailStr
name: str
password_hash: str
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
email_verified: bool = False
avatar_url: Optional[str] = None
# Subscription info
plan: PlanType = PlanType.FREE
subscription_status: SubscriptionStatus = SubscriptionStatus.ACTIVE
stripe_customer_id: Optional[str] = None
stripe_subscription_id: Optional[str] = None
subscription_ends_at: Optional[datetime] = None
# Usage tracking
docs_translated_this_month: int = 0
pages_translated_this_month: int = 0
api_calls_this_month: int = 0
usage_reset_date: datetime = Field(default_factory=datetime.utcnow)
# Extra credits (purchased separately)
extra_credits: int = 0 # Each credit = 1 page
# Settings
default_source_lang: str = "auto"
default_target_lang: str = "en"
default_provider: str = "google"
# Ollama self-hosted config
ollama_endpoint: Optional[str] = None
ollama_model: Optional[str] = None
class UserCreate(BaseModel):
email: EmailStr
name: str
password: str
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserResponse(BaseModel):
id: str
email: EmailStr
name: str
avatar_url: Optional[str] = None
plan: PlanType
subscription_status: SubscriptionStatus
docs_translated_this_month: int
pages_translated_this_month: int
api_calls_this_month: int
extra_credits: int
created_at: datetime
# Plan limits for display
plan_limits: Dict[str, Any] = {}
class Subscription(BaseModel):
id: str
user_id: str
plan: PlanType
status: SubscriptionStatus
stripe_subscription_id: Optional[str] = None
stripe_customer_id: Optional[str] = None
current_period_start: datetime
current_period_end: datetime
cancel_at_period_end: bool = False
created_at: datetime = Field(default_factory=datetime.utcnow)
class UsageRecord(BaseModel):
id: str
user_id: str
document_name: str
document_type: str # excel, word, pptx
pages_count: int
source_lang: str
target_lang: str
provider: str
processing_time_seconds: float
credits_used: int
created_at: datetime = Field(default_factory=datetime.utcnow)
class CreditPurchase(BaseModel):
"""For buying extra credits (pay-per-use)"""
id: str
user_id: str
credits_amount: int
price_paid: float # in cents
stripe_payment_id: str
created_at: datetime = Field(default_factory=datetime.utcnow)
# Credit packages for purchase
CREDIT_PACKAGES = [
{"credits": 50, "price": 5.00, "price_per_credit": 0.10, "stripe_price_id": "price_credits_50"},
{"credits": 100, "price": 9.00, "price_per_credit": 0.09, "stripe_price_id": "price_credits_100", "popular": True},
{"credits": 250, "price": 20.00, "price_per_credit": 0.08, "stripe_price_id": "price_credits_250"},
{"credits": 500, "price": 35.00, "price_per_credit": 0.07, "stripe_price_id": "price_credits_500"},
{"credits": 1000, "price": 60.00, "price_per_credit": 0.06, "stripe_price_id": "price_credits_1000"},
]

View File

@@ -7,6 +7,7 @@ python-pptx==0.6.23
deep-translator==1.11.4 deep-translator==1.11.4
python-dotenv==1.0.0 python-dotenv==1.0.0
pydantic==2.5.3 pydantic==2.5.3
pydantic[email]==2.5.3
aiofiles==23.2.1 aiofiles==23.2.1
Pillow==10.2.0 Pillow==10.2.0
matplotlib==3.8.2 matplotlib==3.8.2
@@ -18,3 +19,9 @@ openai>=1.0.0
# SaaS robustness dependencies # SaaS robustness dependencies
psutil==5.9.8 psutil==5.9.8
python-magic-bin==0.4.14 # For Windows, use python-magic on Linux python-magic-bin==0.4.14 # For Windows, use python-magic on Linux
# Authentication & Payments
PyJWT==2.8.0
passlib[bcrypt]==1.7.4
stripe==7.0.0

1
routes/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Routes package

281
routes/auth_routes.py Normal file
View File

@@ -0,0 +1,281 @@
"""
Authentication and User API routes
"""
from fastapi import APIRouter, HTTPException, Depends, Header, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, EmailStr
from typing import Optional, Dict, Any
from models.subscription import UserCreate, UserLogin, UserResponse, PlanType, PLANS, CREDIT_PACKAGES
from services.auth_service import (
create_user, authenticate_user, get_user_by_id,
create_access_token, create_refresh_token, verify_token,
check_usage_limits, update_user
)
from services.payment_service import (
create_checkout_session, create_credits_checkout,
cancel_subscription, get_billing_portal_url,
handle_webhook, is_stripe_configured
)
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
security = HTTPBearer(auto_error=False)
# Request/Response models
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user: UserResponse
class RefreshRequest(BaseModel):
refresh_token: str
class CheckoutRequest(BaseModel):
plan: PlanType
billing_period: str = "monthly"
class CreditsCheckoutRequest(BaseModel):
package_index: int
# Dependency to get current user
async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)):
if not credentials:
return None
payload = verify_token(credentials.credentials)
if not payload:
return None
user = get_user_by_id(payload.get("sub"))
return user
async def require_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
if not credentials:
raise HTTPException(status_code=401, detail="Not authenticated")
payload = verify_token(credentials.credentials)
if not payload:
raise HTTPException(status_code=401, detail="Invalid or expired token")
user = get_user_by_id(payload.get("sub"))
if not user:
raise HTTPException(status_code=401, detail="User not found")
return user
def user_to_response(user) -> UserResponse:
"""Convert User to UserResponse with plan limits"""
plan_limits = PLANS[user.plan]
return UserResponse(
id=user.id,
email=user.email,
name=user.name,
avatar_url=user.avatar_url,
plan=user.plan,
subscription_status=user.subscription_status,
docs_translated_this_month=user.docs_translated_this_month,
pages_translated_this_month=user.pages_translated_this_month,
api_calls_this_month=user.api_calls_this_month,
extra_credits=user.extra_credits,
created_at=user.created_at,
plan_limits={
"docs_per_month": plan_limits["docs_per_month"],
"max_pages_per_doc": plan_limits["max_pages_per_doc"],
"max_file_size_mb": plan_limits["max_file_size_mb"],
"providers": plan_limits["providers"],
"features": plan_limits["features"],
"api_access": plan_limits.get("api_access", False),
}
)
# Auth endpoints
@router.post("/register", response_model=TokenResponse)
async def register(user_create: UserCreate):
"""Register a new user"""
try:
user = create_user(user_create)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
access_token = create_access_token(user.id)
refresh_token = create_refresh_token(user.id)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
user=user_to_response(user)
)
@router.post("/login", response_model=TokenResponse)
async def login(credentials: UserLogin):
"""Login with email and password"""
user = authenticate_user(credentials.email, credentials.password)
if not user:
raise HTTPException(status_code=401, detail="Invalid email or password")
access_token = create_access_token(user.id)
refresh_token = create_refresh_token(user.id)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
user=user_to_response(user)
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_tokens(request: RefreshRequest):
"""Refresh access token"""
payload = verify_token(request.refresh_token)
if not payload or payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid refresh token")
user = get_user_by_id(payload.get("sub"))
if not user:
raise HTTPException(status_code=401, detail="User not found")
access_token = create_access_token(user.id)
refresh_token = create_refresh_token(user.id)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
user=user_to_response(user)
)
@router.get("/me", response_model=UserResponse)
async def get_me(user=Depends(require_user)):
"""Get current user info"""
return user_to_response(user)
@router.get("/usage")
async def get_usage(user=Depends(require_user)):
"""Get current usage and limits"""
return check_usage_limits(user)
@router.put("/settings")
async def update_settings(settings: Dict[str, Any], user=Depends(require_user)):
"""Update user settings"""
allowed_fields = [
"default_source_lang", "default_target_lang", "default_provider",
"ollama_endpoint", "ollama_model", "name"
]
updates = {k: v for k, v in settings.items() if k in allowed_fields}
updated_user = update_user(user.id, updates)
if not updated_user:
raise HTTPException(status_code=400, detail="Failed to update settings")
return user_to_response(updated_user)
# Plans endpoint (public)
@router.get("/plans")
async def get_plans():
"""Get all available plans"""
plans = []
for plan_type, config in PLANS.items():
plans.append({
"id": plan_type.value,
"name": config["name"],
"price_monthly": config["price_monthly"],
"price_yearly": config["price_yearly"],
"features": config["features"],
"docs_per_month": config["docs_per_month"],
"max_pages_per_doc": config["max_pages_per_doc"],
"max_file_size_mb": config["max_file_size_mb"],
"providers": config["providers"],
"api_access": config.get("api_access", False),
"popular": plan_type == PlanType.PRO,
})
return {"plans": plans, "credit_packages": CREDIT_PACKAGES}
# Payment endpoints
@router.post("/checkout/subscription")
async def checkout_subscription(request: CheckoutRequest, user=Depends(require_user)):
"""Create Stripe checkout session for subscription"""
if not is_stripe_configured():
# Demo mode - just upgrade the user
update_user(user.id, {"plan": request.plan.value})
return {"demo_mode": True, "message": "Upgraded in demo mode", "plan": request.plan.value}
result = await create_checkout_session(
user.id,
request.plan,
request.billing_period
)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.post("/checkout/credits")
async def checkout_credits(request: CreditsCheckoutRequest, user=Depends(require_user)):
"""Create Stripe checkout session for credits"""
if not is_stripe_configured():
# Demo mode - add credits directly
from services.auth_service import add_credits
credits = CREDIT_PACKAGES[request.package_index]["credits"]
add_credits(user.id, credits)
return {"demo_mode": True, "message": f"Added {credits} credits in demo mode"}
result = await create_credits_checkout(user.id, request.package_index)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.post("/subscription/cancel")
async def cancel_user_subscription(user=Depends(require_user)):
"""Cancel subscription"""
result = await cancel_subscription(user.id)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result
@router.get("/billing-portal")
async def get_billing_portal(user=Depends(require_user)):
"""Get Stripe billing portal URL"""
url = await get_billing_portal_url(user.id)
if not url:
raise HTTPException(status_code=400, detail="Billing portal not available")
return {"url": url}
# Stripe webhook
@router.post("/webhook/stripe")
async def stripe_webhook(request: Request, stripe_signature: str = Header(None)):
"""Handle Stripe webhooks"""
payload = await request.body()
result = await handle_webhook(payload, stripe_signature or "")
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return result

67
scripts/backup.sh Normal file
View File

@@ -0,0 +1,67 @@
#!/bin/bash
# ============================================
# Document Translation API - Backup Script
# ============================================
# Usage: ./scripts/backup.sh [backup_dir]
set -e
# Configuration
BACKUP_DIR="${1:-./backups}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_NAME="translate_backup_$TIMESTAMP"
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${YELLOW}Starting backup: $BACKUP_NAME${NC}"
# Create backup directory
mkdir -p "$BACKUP_DIR/$BACKUP_NAME"
# Backup uploaded files
if [ -d "./uploads" ]; then
echo "Backing up uploads..."
cp -r ./uploads "$BACKUP_DIR/$BACKUP_NAME/"
fi
# Backup output files
if [ -d "./outputs" ]; then
echo "Backing up outputs..."
cp -r ./outputs "$BACKUP_DIR/$BACKUP_NAME/"
fi
# Backup configuration
echo "Backing up configuration..."
cp .env* "$BACKUP_DIR/$BACKUP_NAME/" 2>/dev/null || true
cp docker-compose*.yml "$BACKUP_DIR/$BACKUP_NAME/" 2>/dev/null || true
# Backup Docker volumes (if using Docker)
if command -v docker &> /dev/null; then
echo "Backing up Docker volumes..."
# Get volume names
VOLUMES=$(docker volume ls --format "{{.Name}}" | grep translate || true)
for vol in $VOLUMES; do
echo " Backing up volume: $vol"
docker run --rm \
-v "$vol:/data:ro" \
-v "$(pwd)/$BACKUP_DIR/$BACKUP_NAME:/backup" \
alpine tar czf "/backup/${vol}.tar.gz" -C /data . 2>/dev/null || true
done
fi
# Compress backup
echo "Compressing backup..."
cd "$BACKUP_DIR"
tar czf "${BACKUP_NAME}.tar.gz" "$BACKUP_NAME"
rm -rf "$BACKUP_NAME"
# Cleanup old backups (keep last 7)
echo "Cleaning old backups..."
ls -t translate_backup_*.tar.gz 2>/dev/null | tail -n +8 | xargs rm -f 2>/dev/null || true
echo -e "${GREEN}Backup complete: $BACKUP_DIR/${BACKUP_NAME}.tar.gz${NC}"

168
scripts/deploy.sh Normal file
View File

@@ -0,0 +1,168 @@
#!/bin/bash
# ============================================
# Document Translation API - Deployment Script
# ============================================
# Usage: ./scripts/deploy.sh [environment] [options]
# Example: ./scripts/deploy.sh production --with-ollama
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
ENVIRONMENT="${1:-production}"
COMPOSE_FILE="docker-compose.yml"
# Parse options
PROFILES=""
while [[ $# -gt 1 ]]; do
case $2 in
--with-ollama)
PROFILES="$PROFILES --profile with-ollama"
shift
;;
--with-cache)
PROFILES="$PROFILES --profile with-cache"
shift
;;
--with-monitoring)
PROFILES="$PROFILES --profile with-monitoring"
shift
;;
--full)
PROFILES="--profile with-ollama --profile with-cache --profile with-monitoring"
shift
;;
*)
shift
;;
esac
done
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} Document Translation API Deployment${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
# Check prerequisites
echo -e "${YELLOW}Checking prerequisites...${NC}"
if ! command -v docker &> /dev/null; then
echo -e "${RED}Error: Docker is not installed${NC}"
exit 1
fi
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
echo -e "${RED}Error: Docker Compose is not installed${NC}"
exit 1
fi
echo -e "${GREEN}✓ Docker and Docker Compose are installed${NC}"
# Check environment file
cd "$PROJECT_ROOT"
if [ "$ENVIRONMENT" == "production" ]; then
ENV_FILE=".env.production"
else
ENV_FILE=".env"
fi
if [ ! -f "$ENV_FILE" ]; then
echo -e "${YELLOW}Creating $ENV_FILE from template...${NC}"
if [ -f ".env.example" ]; then
cp .env.example "$ENV_FILE"
echo -e "${YELLOW}Please edit $ENV_FILE with your configuration${NC}"
else
echo -e "${RED}Error: No environment file found${NC}"
exit 1
fi
fi
echo -e "${GREEN}✓ Environment file: $ENV_FILE${NC}"
# Load environment
set -a
source "$ENV_FILE"
set +a
# Create SSL directory if needed
if [ ! -d "docker/nginx/ssl" ]; then
mkdir -p docker/nginx/ssl
echo -e "${YELLOW}Created SSL directory. Add your certificates:${NC}"
echo " - docker/nginx/ssl/fullchain.pem"
echo " - docker/nginx/ssl/privkey.pem"
echo " - docker/nginx/ssl/chain.pem"
# Generate self-signed cert for testing
if [ "$ENVIRONMENT" != "production" ]; then
echo -e "${YELLOW}Generating self-signed certificate for development...${NC}"
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout docker/nginx/ssl/privkey.pem \
-out docker/nginx/ssl/fullchain.pem \
-subj "/C=US/ST=State/L=City/O=Organization/CN=localhost" 2>/dev/null
cp docker/nginx/ssl/fullchain.pem docker/nginx/ssl/chain.pem
fi
fi
# Build and deploy
echo ""
echo -e "${YELLOW}Building containers...${NC}"
docker compose --env-file "$ENV_FILE" build
echo ""
echo -e "${YELLOW}Starting services...${NC}"
docker compose --env-file "$ENV_FILE" $PROFILES up -d
# Wait for services to be healthy
echo ""
echo -e "${YELLOW}Waiting for services to be ready...${NC}"
sleep 10
# Health check
echo ""
echo -e "${YELLOW}Running health checks...${NC}"
BACKEND_HEALTH=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/health 2>/dev/null || echo "000")
if [ "$BACKEND_HEALTH" == "200" ]; then
echo -e "${GREEN}✓ Backend is healthy${NC}"
else
echo -e "${RED}✗ Backend health check failed (HTTP $BACKEND_HEALTH)${NC}"
fi
FRONTEND_HEALTH=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 2>/dev/null || echo "000")
if [ "$FRONTEND_HEALTH" == "200" ]; then
echo -e "${GREEN}✓ Frontend is healthy${NC}"
else
echo -e "${RED}✗ Frontend health check failed (HTTP $FRONTEND_HEALTH)${NC}"
fi
# Show status
echo ""
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE} Deployment Complete!${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
echo -e "Services running:"
docker compose ps --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}"
echo ""
echo -e "${GREEN}Access your application:${NC}"
echo -e " Frontend: http://localhost (or https://localhost)"
echo -e " API: http://localhost/api"
echo -e " Admin: http://localhost/admin"
echo -e " Health: http://localhost/health"
echo ""
echo -e "${YELLOW}Useful commands:${NC}"
echo " View logs: docker compose logs -f"
echo " Stop: docker compose down"
echo " Restart: docker compose restart"
echo " Update: ./scripts/deploy.sh $ENVIRONMENT"

87
scripts/health-check.sh Normal file
View File

@@ -0,0 +1,87 @@
#!/bin/bash
# ============================================
# Document Translation API - Health Check Script
# ============================================
# Usage: ./scripts/health-check.sh [--verbose]
set -e
VERBOSE=false
if [ "$1" == "--verbose" ]; then
VERBOSE=true
fi
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Configuration
BACKEND_URL="${BACKEND_URL:-http://localhost:8000}"
FRONTEND_URL="${FRONTEND_URL:-http://localhost:3000}"
NGINX_URL="${NGINX_URL:-http://localhost}"
EXIT_CODE=0
check_service() {
local name=$1
local url=$2
local expected=${3:-200}
response=$(curl -s -o /dev/null -w "%{http_code}" "$url" 2>/dev/null || echo "000")
if [ "$response" == "$expected" ]; then
echo -e "${GREEN}$name: OK (HTTP $response)${NC}"
else
echo -e "${RED}$name: FAILED (HTTP $response, expected $expected)${NC}"
EXIT_CODE=1
fi
if [ "$VERBOSE" == "true" ] && [ "$response" == "200" ]; then
echo " Response:"
curl -s "$url" 2>/dev/null | head -c 500
echo ""
fi
}
echo "========================================="
echo " Health Check - Document Translation API"
echo "========================================="
echo ""
# Check backend
echo "Backend Services:"
check_service "API Health" "$BACKEND_URL/health"
check_service "API Root" "$BACKEND_URL/"
echo ""
# Check frontend
echo "Frontend Services:"
check_service "Frontend" "$FRONTEND_URL"
echo ""
# Check nginx (if running)
echo "Proxy Services:"
check_service "Nginx" "$NGINX_URL/health"
echo ""
# Docker health
if command -v docker &> /dev/null; then
echo "Docker Container Status:"
docker compose ps --format "table {{.Name}}\t{{.Status}}" 2>/dev/null || echo " Docker Compose not running"
fi
echo ""
echo "========================================="
if [ $EXIT_CODE -eq 0 ]; then
echo -e "${GREEN}All health checks passed!${NC}"
else
echo -e "${RED}Some health checks failed!${NC}"
fi
exit $EXIT_CODE

79
scripts/setup-ssl.sh Normal file
View File

@@ -0,0 +1,79 @@
#!/bin/bash
# ============================================
# Document Translation API - SSL Setup Script
# ============================================
# Usage: ./scripts/setup-ssl.sh <domain> <email>
# Example: ./scripts/setup-ssl.sh translate.example.com admin@example.com
set -e
DOMAIN="${1:-}"
EMAIL="${2:-}"
if [ -z "$DOMAIN" ] || [ -z "$EMAIL" ]; then
echo "Usage: ./scripts/setup-ssl.sh <domain> <email>"
echo "Example: ./scripts/setup-ssl.sh translate.example.com admin@example.com"
exit 1
fi
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${YELLOW}Setting up SSL for $DOMAIN${NC}"
# Create directory for certbot
mkdir -p ./docker/certbot/www
mkdir -p ./docker/certbot/conf
# Create initial nginx config for ACME challenge
cat > ./docker/nginx/conf.d/certbot.conf << EOF
server {
listen 80;
server_name $DOMAIN;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://\$host\$request_uri;
}
}
EOF
# Start nginx with HTTP only
echo "Starting nginx for certificate request..."
docker compose up -d nginx
# Request certificate
echo "Requesting Let's Encrypt certificate..."
docker run --rm \
-v "$(pwd)/docker/certbot/www:/var/www/certbot" \
-v "$(pwd)/docker/certbot/conf:/etc/letsencrypt" \
certbot/certbot certonly \
--webroot \
--webroot-path=/var/www/certbot \
--email "$EMAIL" \
--agree-tos \
--no-eff-email \
-d "$DOMAIN"
# Copy certificates
echo "Installing certificates..."
cp ./docker/certbot/conf/live/$DOMAIN/fullchain.pem ./docker/nginx/ssl/
cp ./docker/certbot/conf/live/$DOMAIN/privkey.pem ./docker/nginx/ssl/
cp ./docker/certbot/conf/live/$DOMAIN/chain.pem ./docker/nginx/ssl/
# Remove temporary config
rm ./docker/nginx/conf.d/certbot.conf
# Restart nginx with SSL
echo "Restarting nginx with SSL..."
docker compose restart nginx
echo -e "${GREEN}SSL setup complete for $DOMAIN${NC}"
echo ""
echo "To auto-renew certificates, add this to crontab:"
echo "0 0 1 * * cd $(pwd) && ./scripts/renew-ssl.sh"

262
services/auth_service.py Normal file
View File

@@ -0,0 +1,262 @@
"""
Authentication service with JWT tokens and password hashing
"""
import os
import secrets
import hashlib
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
import json
from pathlib import Path
# Try to import optional dependencies
try:
import jwt
JWT_AVAILABLE = True
except ImportError:
JWT_AVAILABLE = False
try:
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
PASSLIB_AVAILABLE = True
except ImportError:
PASSLIB_AVAILABLE = False
from models.subscription import User, UserCreate, PlanType, SubscriptionStatus, PLANS
# Configuration
SECRET_KEY = os.getenv("JWT_SECRET_KEY", secrets.token_urlsafe(32))
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_HOURS = 24
REFRESH_TOKEN_EXPIRE_DAYS = 30
# Simple file-based storage (replace with database in production)
USERS_FILE = Path("data/users.json")
USERS_FILE.parent.mkdir(exist_ok=True)
def hash_password(password: str) -> str:
"""Hash a password using bcrypt or fallback to SHA256"""
if PASSLIB_AVAILABLE:
return pwd_context.hash(password)
else:
# Fallback to SHA256 with salt
salt = secrets.token_hex(16)
hashed = hashlib.sha256(f"{salt}{password}".encode()).hexdigest()
return f"sha256${salt}${hashed}"
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash"""
if PASSLIB_AVAILABLE and not hashed_password.startswith("sha256$"):
return pwd_context.verify(plain_password, hashed_password)
else:
# Fallback SHA256 verification
parts = hashed_password.split("$")
if len(parts) == 3 and parts[0] == "sha256":
salt = parts[1]
expected_hash = parts[2]
actual_hash = hashlib.sha256(f"{salt}{plain_password}".encode()).hexdigest()
return secrets.compare_digest(actual_hash, expected_hash)
return False
def create_access_token(user_id: str, expires_delta: Optional[timedelta] = None) -> str:
"""Create a JWT access token"""
if not JWT_AVAILABLE:
# Fallback to simple token
token_data = {
"user_id": user_id,
"exp": (datetime.utcnow() + (expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS))).isoformat()
}
import base64
return base64.urlsafe_b64encode(json.dumps(token_data).encode()).decode()
expire = datetime.utcnow() + (expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS))
to_encode = {"sub": user_id, "exp": expire, "type": "access"}
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(user_id: str) -> str:
"""Create a JWT refresh token"""
if not JWT_AVAILABLE:
token_data = {
"user_id": user_id,
"exp": (datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)).isoformat()
}
import base64
return base64.urlsafe_b64encode(json.dumps(token_data).encode()).decode()
expire = datetime.utcnow() + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
to_encode = {"sub": user_id, "exp": expire, "type": "refresh"}
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def verify_token(token: str) -> Optional[Dict[str, Any]]:
"""Verify a JWT token and return payload"""
if not JWT_AVAILABLE:
try:
import base64
data = json.loads(base64.urlsafe_b64decode(token.encode()).decode())
exp = datetime.fromisoformat(data["exp"])
if exp < datetime.utcnow():
return None
return {"sub": data["user_id"]}
except:
return None
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
return None
except jwt.JWTError:
return None
def load_users() -> Dict[str, Dict]:
"""Load users from file storage"""
if USERS_FILE.exists():
try:
with open(USERS_FILE, 'r') as f:
return json.load(f)
except:
return {}
return {}
def save_users(users: Dict[str, Dict]):
"""Save users to file storage"""
with open(USERS_FILE, 'w') as f:
json.dump(users, f, indent=2, default=str)
def get_user_by_email(email: str) -> Optional[User]:
"""Get a user by email"""
users = load_users()
for user_data in users.values():
if user_data.get("email", "").lower() == email.lower():
return User(**user_data)
return None
def get_user_by_id(user_id: str) -> Optional[User]:
"""Get a user by ID"""
users = load_users()
if user_id in users:
return User(**users[user_id])
return None
def create_user(user_create: UserCreate) -> User:
"""Create a new user"""
users = load_users()
# Check if email exists
if get_user_by_email(user_create.email):
raise ValueError("Email already registered")
# Generate user ID
user_id = secrets.token_urlsafe(16)
# Create user
user = User(
id=user_id,
email=user_create.email,
name=user_create.name,
password_hash=hash_password(user_create.password),
plan=PlanType.FREE,
subscription_status=SubscriptionStatus.ACTIVE,
)
# Save to storage
users[user_id] = user.model_dump()
save_users(users)
return user
def authenticate_user(email: str, password: str) -> Optional[User]:
"""Authenticate a user with email and password"""
user = get_user_by_email(email)
if not user:
return None
if not verify_password(password, user.password_hash):
return None
return user
def update_user(user_id: str, updates: Dict[str, Any]) -> Optional[User]:
"""Update a user's data"""
users = load_users()
if user_id not in users:
return None
users[user_id].update(updates)
users[user_id]["updated_at"] = datetime.utcnow().isoformat()
save_users(users)
return User(**users[user_id])
def check_usage_limits(user: User) -> Dict[str, Any]:
"""Check if user has exceeded their plan limits"""
plan = PLANS[user.plan]
# Reset usage if it's a new month
now = datetime.utcnow()
if user.usage_reset_date.month != now.month or user.usage_reset_date.year != now.year:
update_user(user.id, {
"docs_translated_this_month": 0,
"pages_translated_this_month": 0,
"api_calls_this_month": 0,
"usage_reset_date": now.isoformat()
})
user.docs_translated_this_month = 0
user.pages_translated_this_month = 0
user.api_calls_this_month = 0
docs_limit = plan["docs_per_month"]
docs_remaining = max(0, docs_limit - user.docs_translated_this_month) if docs_limit > 0 else -1
return {
"can_translate": docs_remaining != 0 or user.extra_credits > 0,
"docs_used": user.docs_translated_this_month,
"docs_limit": docs_limit,
"docs_remaining": docs_remaining,
"pages_used": user.pages_translated_this_month,
"extra_credits": user.extra_credits,
"max_pages_per_doc": plan["max_pages_per_doc"],
"max_file_size_mb": plan["max_file_size_mb"],
"allowed_providers": plan["providers"],
}
def record_usage(user_id: str, pages_count: int, use_credits: bool = False) -> bool:
"""Record document translation usage"""
user = get_user_by_id(user_id)
if not user:
return False
updates = {
"docs_translated_this_month": user.docs_translated_this_month + 1,
"pages_translated_this_month": user.pages_translated_this_month + pages_count,
}
if use_credits:
updates["extra_credits"] = max(0, user.extra_credits - pages_count)
update_user(user_id, updates)
return True
def add_credits(user_id: str, credits: int) -> bool:
"""Add credits to a user's account"""
user = get_user_by_id(user_id)
if not user:
return False
update_user(user_id, {"extra_credits": user.extra_credits + credits})
return True

298
services/payment_service.py Normal file
View File

@@ -0,0 +1,298 @@
"""
Stripe payment integration for subscriptions and credits
"""
import os
from typing import Optional, Dict, Any
from datetime import datetime
# Try to import stripe
try:
import stripe
STRIPE_AVAILABLE = True
except ImportError:
STRIPE_AVAILABLE = False
stripe = None
from models.subscription import PlanType, PLANS, CREDIT_PACKAGES, SubscriptionStatus
from services.auth_service import get_user_by_id, update_user, add_credits
# Stripe configuration
STRIPE_SECRET_KEY = os.getenv("STRIPE_SECRET_KEY", "")
STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET", "")
STRIPE_PUBLISHABLE_KEY = os.getenv("STRIPE_PUBLISHABLE_KEY", "")
if STRIPE_AVAILABLE and STRIPE_SECRET_KEY:
stripe.api_key = STRIPE_SECRET_KEY
def is_stripe_configured() -> bool:
"""Check if Stripe is properly configured"""
return STRIPE_AVAILABLE and bool(STRIPE_SECRET_KEY)
async def create_checkout_session(
user_id: str,
plan: PlanType,
billing_period: str = "monthly", # monthly or yearly
success_url: str = "",
cancel_url: str = "",
) -> Optional[Dict[str, Any]]:
"""Create a Stripe checkout session for subscription"""
if not is_stripe_configured():
return {"error": "Stripe not configured", "demo_mode": True}
user = get_user_by_id(user_id)
if not user:
return {"error": "User not found"}
plan_config = PLANS[plan]
price_id = plan_config[f"stripe_price_id_{billing_period}"]
if not price_id:
return {"error": "Plan not available for purchase"}
try:
# Create or get Stripe customer
if user.stripe_customer_id:
customer_id = user.stripe_customer_id
else:
customer = stripe.Customer.create(
email=user.email,
name=user.name,
metadata={"user_id": user_id}
)
customer_id = customer.id
update_user(user_id, {"stripe_customer_id": customer_id})
# Create checkout session
session = stripe.checkout.Session.create(
customer=customer_id,
mode="subscription",
payment_method_types=["card"],
line_items=[{"price": price_id, "quantity": 1}],
success_url=success_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/dashboard?session_id={{CHECKOUT_SESSION_ID}}",
cancel_url=cancel_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/pricing",
metadata={"user_id": user_id, "plan": plan.value},
subscription_data={
"metadata": {"user_id": user_id, "plan": plan.value}
}
)
return {
"session_id": session.id,
"url": session.url
}
except Exception as e:
return {"error": str(e)}
async def create_credits_checkout(
user_id: str,
package_index: int,
success_url: str = "",
cancel_url: str = "",
) -> Optional[Dict[str, Any]]:
"""Create a Stripe checkout session for credit purchase"""
if not is_stripe_configured():
return {"error": "Stripe not configured", "demo_mode": True}
if package_index < 0 or package_index >= len(CREDIT_PACKAGES):
return {"error": "Invalid package"}
user = get_user_by_id(user_id)
if not user:
return {"error": "User not found"}
package = CREDIT_PACKAGES[package_index]
try:
# Create or get Stripe customer
if user.stripe_customer_id:
customer_id = user.stripe_customer_id
else:
customer = stripe.Customer.create(
email=user.email,
name=user.name,
metadata={"user_id": user_id}
)
customer_id = customer.id
update_user(user_id, {"stripe_customer_id": customer_id})
# Create checkout session
session = stripe.checkout.Session.create(
customer=customer_id,
mode="payment",
payment_method_types=["card"],
line_items=[{"price": package["stripe_price_id"], "quantity": 1}],
success_url=success_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/dashboard?credits=purchased",
cancel_url=cancel_url or f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/pricing",
metadata={
"user_id": user_id,
"credits": package["credits"],
"type": "credits"
}
)
return {
"session_id": session.id,
"url": session.url
}
except Exception as e:
return {"error": str(e)}
async def handle_webhook(payload: bytes, sig_header: str) -> Dict[str, Any]:
"""Handle Stripe webhook events"""
if not is_stripe_configured():
return {"error": "Stripe not configured"}
try:
event = stripe.Webhook.construct_event(
payload, sig_header, STRIPE_WEBHOOK_SECRET
)
except ValueError:
return {"error": "Invalid payload"}
except stripe.error.SignatureVerificationError:
return {"error": "Invalid signature"}
# Handle the event
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
await handle_checkout_completed(session)
elif event["type"] == "customer.subscription.updated":
subscription = event["data"]["object"]
await handle_subscription_updated(subscription)
elif event["type"] == "customer.subscription.deleted":
subscription = event["data"]["object"]
await handle_subscription_deleted(subscription)
elif event["type"] == "invoice.payment_failed":
invoice = event["data"]["object"]
await handle_payment_failed(invoice)
return {"status": "success"}
async def handle_checkout_completed(session: Dict):
"""Handle successful checkout"""
metadata = session.get("metadata", {})
user_id = metadata.get("user_id")
if not user_id:
return
# Check if it's a credit purchase
if metadata.get("type") == "credits":
credits = int(metadata.get("credits", 0))
add_credits(user_id, credits)
return
# It's a subscription
plan = metadata.get("plan")
if plan:
subscription_id = session.get("subscription")
update_user(user_id, {
"plan": plan,
"subscription_status": SubscriptionStatus.ACTIVE.value,
"stripe_subscription_id": subscription_id,
"docs_translated_this_month": 0, # Reset on new subscription
"pages_translated_this_month": 0,
})
async def handle_subscription_updated(subscription: Dict):
"""Handle subscription updates"""
metadata = subscription.get("metadata", {})
user_id = metadata.get("user_id")
if not user_id:
return
status_map = {
"active": SubscriptionStatus.ACTIVE,
"past_due": SubscriptionStatus.PAST_DUE,
"canceled": SubscriptionStatus.CANCELED,
"trialing": SubscriptionStatus.TRIALING,
"paused": SubscriptionStatus.PAUSED,
}
stripe_status = subscription.get("status", "active")
status = status_map.get(stripe_status, SubscriptionStatus.ACTIVE)
update_user(user_id, {
"subscription_status": status.value,
"subscription_ends_at": datetime.fromtimestamp(
subscription.get("current_period_end", 0)
).isoformat() if subscription.get("current_period_end") else None
})
async def handle_subscription_deleted(subscription: Dict):
"""Handle subscription cancellation"""
metadata = subscription.get("metadata", {})
user_id = metadata.get("user_id")
if not user_id:
return
update_user(user_id, {
"plan": PlanType.FREE.value,
"subscription_status": SubscriptionStatus.CANCELED.value,
"stripe_subscription_id": None,
})
async def handle_payment_failed(invoice: Dict):
"""Handle failed payment"""
customer_id = invoice.get("customer")
if not customer_id:
return
# Find user by customer ID and update status
# In production, query database by stripe_customer_id
async def cancel_subscription(user_id: str) -> Dict[str, Any]:
"""Cancel a user's subscription"""
if not is_stripe_configured():
return {"error": "Stripe not configured"}
user = get_user_by_id(user_id)
if not user or not user.stripe_subscription_id:
return {"error": "No active subscription"}
try:
# Cancel at period end
subscription = stripe.Subscription.modify(
user.stripe_subscription_id,
cancel_at_period_end=True
)
return {
"status": "canceling",
"cancel_at": datetime.fromtimestamp(subscription.cancel_at).isoformat() if subscription.cancel_at else None
}
except Exception as e:
return {"error": str(e)}
async def get_billing_portal_url(user_id: str) -> Optional[str]:
"""Get Stripe billing portal URL for customer"""
if not is_stripe_configured():
return None
user = get_user_by_id(user_id)
if not user or not user.stripe_customer_id:
return None
try:
session = stripe.billing_portal.Session.create(
customer=user.stripe_customer_id,
return_url=f"{os.getenv('FRONTEND_URL', 'http://localhost:3000')}/dashboard"
)
return session.url
except:
return None

View File

@@ -1,14 +1,87 @@
""" """
Translation Service Abstraction Translation Service Abstraction
Provides a unified interface for different translation providers Provides a unified interface for different translation providers
Optimized for high performance with parallel processing and caching
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Optional, List, Dict from typing import Optional, List, Dict, Tuple
import requests import requests
from deep_translator import GoogleTranslator, DeeplTranslator, LibreTranslator from deep_translator import GoogleTranslator, DeeplTranslator, LibreTranslator
from config import config from config import config
import concurrent.futures import concurrent.futures
import threading import threading
import asyncio
from functools import lru_cache
import time
import hashlib
from collections import OrderedDict
# Global thread pool for parallel translations
_executor = concurrent.futures.ThreadPoolExecutor(max_workers=8)
class TranslationCache:
"""Thread-safe LRU cache for translations to avoid redundant API calls"""
def __init__(self, maxsize: int = 5000):
self.cache: OrderedDict = OrderedDict()
self.maxsize = maxsize
self.lock = threading.RLock()
self.hits = 0
self.misses = 0
def _make_key(self, text: str, target_language: str, source_language: str, provider: str) -> str:
"""Create a unique cache key"""
content = f"{provider}:{source_language}:{target_language}:{text}"
return hashlib.md5(content.encode('utf-8')).hexdigest()
def get(self, text: str, target_language: str, source_language: str, provider: str) -> Optional[str]:
"""Get a cached translation if available"""
key = self._make_key(text, target_language, source_language, provider)
with self.lock:
if key in self.cache:
self.hits += 1
# Move to end (most recently used)
self.cache.move_to_end(key)
return self.cache[key]
self.misses += 1
return None
def set(self, text: str, target_language: str, source_language: str, provider: str, translation: str):
"""Cache a translation result"""
key = self._make_key(text, target_language, source_language, provider)
with self.lock:
if key in self.cache:
self.cache.move_to_end(key)
self.cache[key] = translation
# Remove oldest if exceeding maxsize
while len(self.cache) > self.maxsize:
self.cache.popitem(last=False)
def clear(self):
"""Clear the cache"""
with self.lock:
self.cache.clear()
self.hits = 0
self.misses = 0
def stats(self) -> Dict:
"""Get cache statistics"""
with self.lock:
total = self.hits + self.misses
hit_rate = (self.hits / total * 100) if total > 0 else 0
return {
"size": len(self.cache),
"maxsize": self.maxsize,
"hits": self.hits,
"misses": self.misses,
"hit_rate": f"{hit_rate:.1f}%"
}
# Global translation cache
_translation_cache = TranslationCache(maxsize=5000)
class TranslationProvider(ABC): class TranslationProvider(ABC):
@@ -23,12 +96,43 @@ class TranslationProvider(ABC):
"""Translate multiple texts at once - default implementation""" """Translate multiple texts at once - default implementation"""
return [self.translate(text, target_language, source_language) for text in texts] return [self.translate(text, target_language, source_language) for text in texts]
def translate_batch_parallel(self, texts: List[str], target_language: str, source_language: str = 'auto', max_workers: int = 4) -> List[str]:
"""Parallel batch translation using thread pool"""
if not texts:
return []
results = [''] * len(texts)
non_empty = [(i, t) for i, t in enumerate(texts) if t and t.strip()]
if not non_empty:
return [t if t else '' for t in texts]
def translate_one(item: Tuple[int, str]) -> Tuple[int, str]:
idx, text = item
try:
return (idx, self.translate(text, target_language, source_language))
except Exception as e:
print(f"Translation error at index {idx}: {e}")
return (idx, text)
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
for idx, translated in executor.map(translate_one, non_empty):
results[idx] = translated
# Fill empty positions
for i, text in enumerate(texts):
if not text or not text.strip():
results[i] = text if text else ''
return results
class GoogleTranslationProvider(TranslationProvider): class GoogleTranslationProvider(TranslationProvider):
"""Google Translate implementation with batch support""" """Google Translate implementation with batch support and caching"""
def __init__(self): def __init__(self):
self._local = threading.local() self._local = threading.local()
self.provider_name = "google"
def _get_translator(self, source_language: str, target_language: str) -> GoogleTranslator: def _get_translator(self, source_language: str, target_language: str) -> GoogleTranslator:
"""Get or create a translator instance for the current thread""" """Get or create a translator instance for the current thread"""
@@ -43,9 +147,17 @@ class GoogleTranslationProvider(TranslationProvider):
if not text or not text.strip(): if not text or not text.strip():
return text return text
# Check cache first
cached = _translation_cache.get(text, target_language, source_language, self.provider_name)
if cached is not None:
return cached
try: try:
translator = self._get_translator(source_language, target_language) translator = self._get_translator(source_language, target_language)
return translator.translate(text) result = translator.translate(text)
# Cache the result
_translation_cache.set(text, target_language, source_language, self.provider_name, result)
return result
except Exception as e: except Exception as e:
print(f"Translation error: {e}") print(f"Translation error: {e}")
return text return text
@@ -53,7 +165,7 @@ class GoogleTranslationProvider(TranslationProvider):
def translate_batch(self, texts: List[str], target_language: str, source_language: str = 'auto', batch_size: int = 50) -> List[str]: def translate_batch(self, texts: List[str], target_language: str, source_language: str = 'auto', batch_size: int = 50) -> List[str]:
""" """
Translate multiple texts using batch processing for speed. Translate multiple texts using batch processing for speed.
Uses deep_translator's batch capability when possible. Uses caching to avoid redundant translations.
""" """
if not texts: if not texts:
return [] return []
@@ -62,15 +174,24 @@ class GoogleTranslationProvider(TranslationProvider):
results = [''] * len(texts) results = [''] * len(texts)
non_empty_indices = [] non_empty_indices = []
non_empty_texts = [] non_empty_texts = []
texts_to_translate = []
indices_to_translate = []
for i, text in enumerate(texts): for i, text in enumerate(texts):
if text and text.strip(): if text and text.strip():
# Check cache first
cached = _translation_cache.get(text, target_language, source_language, self.provider_name)
if cached is not None:
results[i] = cached
else:
non_empty_indices.append(i) non_empty_indices.append(i)
non_empty_texts.append(text) non_empty_texts.append(text)
texts_to_translate.append(text)
indices_to_translate.append(i)
else: else:
results[i] = text if text else '' results[i] = text if text else ''
if not non_empty_texts: if not texts_to_translate:
return results return results
try: try:
@@ -78,8 +199,8 @@ class GoogleTranslationProvider(TranslationProvider):
# Process in batches # Process in batches
translated_texts = [] translated_texts = []
for i in range(0, len(non_empty_texts), batch_size): for i in range(0, len(texts_to_translate), batch_size):
batch = non_empty_texts[i:i + batch_size] batch = texts_to_translate[i:i + batch_size]
try: try:
# Use translate_batch if available # Use translate_batch if available
if hasattr(translator, 'translate_batch'): if hasattr(translator, 'translate_batch'):
@@ -107,16 +228,19 @@ class GoogleTranslationProvider(TranslationProvider):
except: except:
translated_texts.append(text) translated_texts.append(text)
# Map back to original positions # Map back to original positions and cache results
for idx, translated in zip(non_empty_indices, translated_texts): for idx, (original, translated) in zip(indices_to_translate, zip(texts_to_translate, translated_texts)):
results[idx] = translated if translated else texts[idx] result = translated if translated else texts[idx]
results[idx] = result
# Cache successful translations
_translation_cache.set(texts[idx], target_language, source_language, self.provider_name, result)
return results return results
except Exception as e: except Exception as e:
print(f"Batch translation failed: {e}") print(f"Batch translation failed: {e}")
# Fallback to individual translations # Fallback to individual translations
for idx, text in zip(non_empty_indices, non_empty_texts): for idx, text in zip(indices_to_translate, texts_to_translate):
try: try:
results[idx] = GoogleTranslator(source=source_language, target=target_language).translate(text) or text results[idx] = GoogleTranslator(source=source_language, target=target_language).translate(text) or text
except: except:
@@ -359,6 +483,143 @@ ADDITIONAL CONTEXT AND INSTRUCTIONS:
return [] return []
class OpenRouterTranslationProvider(TranslationProvider):
"""
OpenRouter API translation - Access to many cheap & high-quality models
Recommended models for translation (by cost/quality):
- deepseek/deepseek-chat: $0.14/M tokens - Excellent quality, very cheap
- mistralai/mistral-7b-instruct: $0.06/M tokens - Fast and cheap
- meta-llama/llama-3.1-8b-instruct: $0.06/M tokens - Good quality
- google/gemma-2-9b-it: $0.08/M tokens - Good for European languages
"""
def __init__(self, api_key: str, model: str = "deepseek/deepseek-chat", system_prompt: str = ""):
self.api_key = api_key
self.model = model
self.custom_system_prompt = system_prompt
self.base_url = "https://openrouter.ai/api/v1"
self.provider_name = "openrouter"
self._session = None
def _get_session(self):
"""Get or create a requests session for connection pooling"""
if self._session is None:
import requests
self._session = requests.Session()
self._session.headers.update({
"Authorization": f"Bearer {self.api_key}",
"HTTP-Referer": "https://translate-app.local",
"X-Title": "Document Translator",
"Content-Type": "application/json"
})
return self._session
def translate(self, text: str, target_language: str, source_language: str = 'auto') -> str:
if not text or not text.strip():
return text
# Skip very short text or numbers only
if len(text.strip()) < 2 or text.strip().isdigit():
return text
# Check cache first
cached = _translation_cache.get(text, target_language, source_language, self.provider_name)
if cached is not None:
return cached
try:
session = self._get_session()
# Optimized prompt for translation
system_prompt = f"""Translate to {target_language}. Output ONLY the translation, nothing else. Preserve formatting."""
if self.custom_system_prompt:
system_prompt = f"{system_prompt}\n\nContext: {self.custom_system_prompt}"
response = session.post(
f"{self.base_url}/chat/completions",
json={
"model": self.model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": text}
],
"temperature": 0.2,
"max_tokens": 1000
},
timeout=30
)
response.raise_for_status()
result = response.json()
translated = result.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
if translated:
# Cache the result
_translation_cache.set(text, target_language, source_language, self.provider_name, translated)
return translated
return text
except Exception as e:
print(f"OpenRouter translation error: {e}")
return text
def translate_batch(self, texts: List[str], target_language: str, source_language: str = 'auto') -> List[str]:
"""
Batch translate using OpenRouter with parallel requests.
Uses caching to avoid redundant translations.
"""
if not texts:
return []
results = [''] * len(texts)
texts_to_translate = []
indices_to_translate = []
# Check cache first
for i, text in enumerate(texts):
if not text or not text.strip():
results[i] = text if text else ''
else:
cached = _translation_cache.get(text, target_language, source_language, self.provider_name)
if cached is not None:
results[i] = cached
else:
texts_to_translate.append(text)
indices_to_translate.append(i)
if not texts_to_translate:
return results
# Translate in parallel batches
import concurrent.futures
def translate_one(text: str) -> str:
return self.translate(text, target_language, source_language)
# Use thread pool for parallel requests
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
translated = list(executor.map(translate_one, texts_to_translate))
# Map back results
for idx, trans in zip(indices_to_translate, translated):
results[idx] = trans
return results
@staticmethod
def list_recommended_models() -> List[dict]:
"""List recommended models for translation with pricing"""
return [
{"id": "deepseek/deepseek-chat", "name": "DeepSeek Chat", "price": "$0.14/M tokens", "quality": "Excellent", "speed": "Fast"},
{"id": "mistralai/mistral-7b-instruct", "name": "Mistral 7B", "price": "$0.06/M tokens", "quality": "Good", "speed": "Very Fast"},
{"id": "meta-llama/llama-3.1-8b-instruct", "name": "Llama 3.1 8B", "price": "$0.06/M tokens", "quality": "Good", "speed": "Fast"},
{"id": "google/gemma-2-9b-it", "name": "Gemma 2 9B", "price": "$0.08/M tokens", "quality": "Good", "speed": "Fast"},
{"id": "anthropic/claude-3-haiku", "name": "Claude 3 Haiku", "price": "$0.25/M tokens", "quality": "Excellent", "speed": "Fast"},
{"id": "openai/gpt-4o-mini", "name": "GPT-4o Mini", "price": "$0.15/M tokens", "quality": "Excellent", "speed": "Fast"},
]
class WebLLMTranslationProvider(TranslationProvider): class WebLLMTranslationProvider(TranslationProvider):
"""WebLLM browser-based translation (client-side processing)""" """WebLLM browser-based translation (client-side processing)"""