diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..20dfe76
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,19 @@
+# PriceFlow - Variables d'environnement
+
+# Base de données PostgreSQL
+DATABASE_URL=postgresql://priceflow:priceflow@db:5432/priceflow
+
+# Browserless (navigateur headless)
+BROWSERLESS_URL=ws://browserless:3000
+
+# Niveau de log (DEBUG, INFO, WARNING, ERROR)
+LOG_LEVEL=INFO
+
+# CORS - Origines autorisées (séparées par des virgules)
+CORS_ORIGINS=*
+
+# OpenRouter API Key (optionnel - pour utiliser OpenRouter comme provider IA)
+# OPENROUTER_API_KEY=sk-or-v1-xxxx
+
+# Ollama API Base (par défaut)
+# AI_API_BASE=http://ollama:11434
diff --git a/app/main.py b/app/main.py
index fa7fe1a..802072e 100644
--- a/app/main.py
+++ b/app/main.py
@@ -11,7 +11,7 @@ from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from app.limiter import limiter
-from app.routers import items, jobs, notifications, settings
+from app.routers import items, jobs, notifications, openrouter, settings
from app.services.scheduler_service import scheduled_refresh, scheduler
# Configure logging
@@ -34,7 +34,7 @@ async def lifespan(app: FastAPI):
logger.info("Application shutdown complete")
-app = FastAPI(title="Pricecious API", version="0.1.0", lifespan=lifespan)
+app = FastAPI(title="PriceFlow API", version="0.1.0", lifespan=lifespan)
# Rate Limiting & CORS
app.state.limiter = limiter
@@ -63,13 +63,13 @@ os.makedirs("screenshots", exist_ok=True)
app.mount("/screenshots", StaticFiles(directory="screenshots"), name="screenshots")
# Routers
-for router in [notifications.router, items.router, settings.router, jobs.router]:
+for router in [notifications.router, items.router, settings.router, jobs.router, openrouter.router]:
app.include_router(router, prefix="/api")
@app.get("/api/")
def read_root():
- return {"message": "Welcome to Pricecious API"}
+ return {"message": "Welcome to PriceFlow API"}
# Frontend Serving
diff --git a/app/routers/openrouter.py b/app/routers/openrouter.py
new file mode 100644
index 0000000..51f7037
--- /dev/null
+++ b/app/routers/openrouter.py
@@ -0,0 +1,57 @@
+"""
+Router pour les endpoints OpenRouter.
+Permet de récupérer la liste des modèles et leurs coûts.
+"""
+
+from fastapi import APIRouter, Query
+
+from app.services.openrouter_service import OpenRouterService
+
+router = APIRouter(prefix="/openrouter", tags=["openrouter"])
+
+
+@router.get("/models")
+def get_openrouter_models(
+ api_key: str | None = Query(None, description="Clé API OpenRouter (optionnelle)"),
+ category: str | None = Query(None, description="Filtrer par catégorie: chat, vision, code, reasoning, free")
+):
+ """
+ Récupère la liste des modèles OpenRouter disponibles.
+
+ - **api_key**: Clé API OpenRouter (optionnelle pour la liste publique)
+ - **category**: Filtrer par catégorie (chat, vision, code, reasoning, free)
+
+ Retourne la liste des modèles avec:
+ - id: Identifiant du modèle
+ - name: Nom affiché
+ - description: Description du modèle
+ - context_length: Longueur de contexte maximale
+ - pricing: Coûts (prompt et completion par token)
+ - categories: Liste des catégories du modèle
+ """
+ if category:
+ models = OpenRouterService.get_models_by_category(api_key, category)
+ else:
+ models = OpenRouterService.get_models(api_key)
+
+ return {
+ "models": models,
+ "total": len(models),
+ "categories": ["chat", "vision", "code", "reasoning", "free"]
+ }
+
+
+@router.get("/categories")
+def get_categories():
+ """
+ Retourne la liste des catégories disponibles pour filtrer les modèles.
+ """
+ return {
+ "categories": [
+ {"id": "chat", "name": "Chat", "description": "Modèles de conversation générale"},
+ {"id": "vision", "name": "Vision", "description": "Modèles avec capacités d'analyse d'images"},
+ {"id": "code", "name": "Code", "description": "Modèles spécialisés en programmation"},
+ {"id": "reasoning", "name": "Raisonnement", "description": "Modèles avec capacités de raisonnement avancé"},
+ {"id": "free", "name": "Gratuit", "description": "Modèles gratuits"}
+ ]
+ }
diff --git a/app/services/ai_service.py b/app/services/ai_service.py
index 8d00327..68d62ae 100644
--- a/app/services/ai_service.py
+++ b/app/services/ai_service.py
@@ -186,6 +186,9 @@ class AIService:
kwargs["format"] = "json"
elif config["provider"] == "openai" and config["api_base"]:
kwargs["api_base"] = config["api_base"]
+ elif config["provider"] == "openrouter":
+ kwargs["api_base"] = "https://openrouter.ai/api/v1"
+ kwargs["model"] = f"openrouter/{config['model']}"
response = await acompletion(**kwargs)
repaired_text = response.choices[0].message.content
@@ -250,6 +253,12 @@ class AIService:
# Anthropic doesn't have native JSON mode yet, rely on prompt
if config["api_base"]:
kwargs["api_base"] = config["api_base"]
+ elif config["provider"] == "openrouter":
+ # OpenRouter uses OpenAI-compatible API
+ kwargs["api_base"] = "https://openrouter.ai/api/v1"
+ kwargs["response_format"] = {"type": "json_object"}
+ # OpenRouter requires model prefix for routing
+ kwargs["model"] = f"openrouter/{config['model']}"
# Other providers - rely on prompt engineering
elif config["api_base"]:
kwargs["api_base"] = config["api_base"]
diff --git a/app/services/openrouter_service.py b/app/services/openrouter_service.py
new file mode 100644
index 0000000..f4e954d
--- /dev/null
+++ b/app/services/openrouter_service.py
@@ -0,0 +1,160 @@
+"""
+Service pour interagir avec l'API OpenRouter.
+Récupère la liste des modèles disponibles avec leurs coûts et catégories.
+"""
+
+import logging
+from typing import Any
+
+import requests
+
+logger = logging.getLogger(__name__)
+
+OPENROUTER_API_URL = "https://openrouter.ai/api/v1"
+
+
+class OpenRouterService:
+ @staticmethod
+ def get_models(api_key: str | None = None) -> list[dict[str, Any]]:
+ """
+ Récupère la liste des modèles disponibles sur OpenRouter.
+
+ Args:
+ api_key: Clé API OpenRouter (optionnelle pour la liste des modèles)
+
+ Returns:
+ Liste des modèles avec leurs informations (id, nom, coûts, catégories)
+ """
+ try:
+ headers = {"Content-Type": "application/json"}
+ if api_key:
+ headers["Authorization"] = f"Bearer {api_key}"
+
+ response = requests.get(
+ f"{OPENROUTER_API_URL}/models",
+ headers=headers,
+ timeout=30
+ )
+ response.raise_for_status()
+
+ data = response.json()
+ models = data.get("data", [])
+
+ # Transformer et enrichir les données des modèles
+ processed_models = []
+ for model in models:
+ processed_model = {
+ "id": model.get("id", ""),
+ "name": model.get("name", model.get("id", "")),
+ "description": model.get("description", ""),
+ "context_length": model.get("context_length", 0),
+ "pricing": {
+ "prompt": float(model.get("pricing", {}).get("prompt", 0)),
+ "completion": float(model.get("pricing", {}).get("completion", 0)),
+ },
+ "top_provider": model.get("top_provider", {}),
+ "architecture": model.get("architecture", {}),
+ }
+
+ # Déterminer les catégories du modèle
+ categories = OpenRouterService._categorize_model(model)
+ processed_model["categories"] = categories
+
+ processed_models.append(processed_model)
+
+ # Trier par nom
+ processed_models.sort(key=lambda x: x["name"].lower())
+
+ logger.info(f"Récupéré {len(processed_models)} modèles OpenRouter")
+ return processed_models
+
+ except requests.RequestException as e:
+ logger.error(f"Erreur lors de la récupération des modèles OpenRouter: {e}")
+ return []
+ except Exception as e:
+ logger.error(f"Erreur inattendue: {e}")
+ return []
+
+ @staticmethod
+ def _categorize_model(model: dict[str, Any]) -> list[str]:
+ """
+ Détermine les catégories d'un modèle basé sur ses capacités.
+
+ Categories possibles: chat, vision, code, reasoning, free
+ """
+ categories = []
+ model_id = model.get("id", "").lower()
+ architecture = model.get("architecture", {})
+
+ # Tous les modèles supportent le chat de base
+ categories.append("chat")
+
+ # Détection Vision
+ modality = architecture.get("modality", "")
+ if "image" in modality or "vision" in model_id or "4o" in model_id:
+ categories.append("vision")
+
+ # Détection Code
+ code_keywords = ["code", "codestral", "deepseek-coder", "starcoder", "wizard-coder"]
+ if any(kw in model_id for kw in code_keywords):
+ categories.append("code")
+
+ # Détection Reasoning (modèles avec capacités de raisonnement avancé)
+ reasoning_keywords = ["o1", "o3", "reasoning", "think", "r1"]
+ if any(kw in model_id for kw in reasoning_keywords):
+ categories.append("reasoning")
+
+ # Modèles gratuits
+ pricing = model.get("pricing", {})
+ prompt_price = float(pricing.get("prompt", 1))
+ completion_price = float(pricing.get("completion", 1))
+ if prompt_price == 0 and completion_price == 0:
+ categories.append("free")
+
+ return categories
+
+ @staticmethod
+ def get_models_by_category(
+ api_key: str | None = None,
+ category: str | None = None
+ ) -> list[dict[str, Any]]:
+ """
+ Récupère les modèles filtrés par catégorie.
+
+ Args:
+ api_key: Clé API OpenRouter
+ category: Catégorie à filtrer (chat, vision, code, reasoning, free)
+
+ Returns:
+ Liste des modèles filtrés
+ """
+ models = OpenRouterService.get_models(api_key)
+
+ if not category:
+ return models
+
+ return [m for m in models if category.lower() in m.get("categories", [])]
+
+ @staticmethod
+ def format_price(price_per_token: float) -> str:
+ """
+ Formate le prix par token en format lisible.
+
+ Args:
+ price_per_token: Prix par token (généralement très petit)
+
+ Returns:
+ Prix formaté (ex: "$0.50 / 1M tokens")
+ """
+ if price_per_token == 0:
+ return "Gratuit"
+
+ # Prix par million de tokens
+ price_per_million = price_per_token * 1_000_000
+
+ if price_per_million < 0.01:
+ return f"${price_per_million:.4f} / 1M"
+ elif price_per_million < 1:
+ return f"${price_per_million:.3f} / 1M"
+ else:
+ return f"${price_per_million:.2f} / 1M"
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..6f3c138
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,76 @@
+version: '3.8'
+
+services:
+ # Application PriceFlow
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: priceflow-app
+ restart: unless-stopped
+ ports:
+ - "8000:8000"
+ environment:
+ - DATABASE_URL=postgresql://priceflow:priceflow@db:5432/priceflow
+ - BROWSERLESS_URL=ws://browserless:3000
+ - LOG_LEVEL=INFO
+ - CORS_ORIGINS=*
+ depends_on:
+ db:
+ condition: service_healthy
+ browserless:
+ condition: service_started
+ volumes:
+ - screenshots_data:/app/screenshots
+ networks:
+ - nginx_default
+
+ # Base de données PostgreSQL
+ db:
+ image: postgres:15-alpine
+ container_name: priceflow-db
+ restart: unless-stopped
+ ports:
+ - "5488:5432"
+ environment:
+ - POSTGRES_USER=priceflow
+ - POSTGRES_PASSWORD=priceflow
+ - POSTGRES_DB=priceflow
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U priceflow -d priceflow"]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks:
+ - nginx_default
+
+ # Browserless - Navigateur headless pour le scraping
+ browserless:
+ image: browserless/chrome:latest
+ container_name: priceflow-browserless
+ restart: unless-stopped
+ ports:
+ - "3012:3000"
+ environment:
+ - MAX_CONCURRENT_SESSIONS=5
+ - CONNECTION_TIMEOUT=120000
+ - MAX_QUEUE_LENGTH=10
+ - PREBOOT_CHROME=true
+ - KEEP_ALIVE=true
+ - DEFAULT_BLOCK_ADS=true
+ - DEFAULT_STEALTH=true
+ networks:
+ - nginx_default
+
+volumes:
+ postgres_data:
+ driver: local
+ screenshots_data:
+ driver: local
+
+networks:
+ nginx_default:
+ external: true
diff --git a/frontend/index.html b/frontend/index.html
index 12eae17..bb95150 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1,12 +1,16 @@
-
+
+
+
+
+
- Pricecious
+ PriceFlow
diff --git a/frontend/package.json b/frontend/package.json
index 129bd64..f3475fc 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,5 +1,5 @@
{
- "name": "pricecious-frontend",
+ "name": "priceflow-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
@@ -17,9 +17,11 @@
"axios": "^1.6.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "i18next": "^23.11.5",
"lucide-react": "^0.292.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+ "react-i18next": "^14.1.2",
"react-router-dom": "^6.30.2",
"sonner": "^1.4.0",
"tailwind-merge": "^3.4.0",
diff --git a/frontend/src/components/dashboard/DeleteConfirmationModal.jsx b/frontend/src/components/dashboard/DeleteConfirmationModal.jsx
index fc7b85c..836af43 100644
--- a/frontend/src/components/dashboard/DeleteConfirmationModal.jsx
+++ b/frontend/src/components/dashboard/DeleteConfirmationModal.jsx
@@ -1,20 +1,23 @@
import React from 'react';
+import { useTranslation } from 'react-i18next';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
export function DeleteConfirmationModal({ item, onClose, onConfirm, open }) {
+ const { t } = useTranslation();
+
return (
diff --git a/frontend/src/components/dashboard/ItemCard.jsx b/frontend/src/components/dashboard/ItemCard.jsx
index 66c076f..37f945f 100644
--- a/frontend/src/components/dashboard/ItemCard.jsx
+++ b/frontend/src/components/dashboard/ItemCard.jsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { useTranslation } from 'react-i18next';
import { ExternalLink, Edit2, RefreshCw, Trash2, AlertTriangle, Clock } from 'lucide-react';
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -6,10 +7,12 @@ import { Marquee } from '@/components/ui/marquee';
import { cn } from '@/lib/utils';
export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
+ const { t } = useTranslation();
+
const getStockStatus = (inStock) => {
- if (inStock === true) return { label: 'In Stock', color: 'bg-green-500/90 text-white border-green-400/50' };
- if (inStock === false) return { label: 'Out of Stock', color: 'bg-red-500/90 text-white border-red-400/50' };
- return { label: 'Unknown', color: 'bg-zinc-500/90 text-white border-zinc-400/50' };
+ if (inStock === true) return { label: t('dashboard.inStock'), color: 'bg-green-500/90 text-white border-green-400/50' };
+ if (inStock === false) return { label: t('dashboard.outOfStock'), color: 'bg-red-500/90 text-white border-red-400/50' };
+ return { label: t('dashboard.stockUnknown'), color: 'bg-zinc-500/90 text-white border-zinc-400/50' };
};
const stockStatus = getStockStatus(item.in_stock);
@@ -29,7 +32,7 @@ export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
>
) : (
- No screenshot
+ Pas de capture
)}
@@ -79,14 +82,14 @@ export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
- Price
+ {t('dashboard.currentPrice')}
{item.target_price && (
- Target: ${item.target_price}
+ {t('dashboard.targetPrice')}: {item.target_price}€
)}
@@ -97,7 +100,7 @@ export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
? 'text-green-600 dark:text-green-400'
: 'text-foreground'
)}>
- {item.current_price ? `$${item.current_price}` : '---'}
+ {item.current_price ? `${item.current_price}€` : '---'}
@@ -114,7 +117,7 @@ export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
/>
- Price Confidence: {Math.round(item.current_price_confidence * 100)}%
+ {t('dashboard.confidence')} (prix): {Math.round(item.current_price_confidence * 100)}%
)}
@@ -145,12 +148,12 @@ export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
- {item.last_checked ? new Date(item.last_checked).toLocaleString() : 'Never'}
+ {item.last_checked ? new Date(item.last_checked).toLocaleString('fr-FR') : t('dashboard.never')}
{item.last_error && (
-
Error
+
{t('common.error')}
)}
diff --git a/frontend/src/components/dashboard/ItemModal.jsx b/frontend/src/components/dashboard/ItemModal.jsx
index 1ab0663..9e3a37e 100644
--- a/frontend/src/components/dashboard/ItemModal.jsx
+++ b/frontend/src/components/dashboard/ItemModal.jsx
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { toast } from 'sonner';
+import { useTranslation } from 'react-i18next';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -10,6 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
const API_URL = '/api';
export function ItemModal({ item, onClose, onSaved, open }) {
+ const { t } = useTranslation();
const [formData, setFormData] = useState({
url: '',
name: '',
@@ -62,16 +64,16 @@ export function ItemModal({ item, onClose, onSaved, open }) {
if (item) {
await axios.put(`${API_URL}/items/${item.id}`, payload);
- toast.success('Item updated');
+ toast.success(t('toast.itemUpdated'));
} else {
await axios.post(`${API_URL}/items`, payload);
- toast.success('Item added');
+ toast.success(t('toast.itemCreated'));
}
onSaved();
onClose();
} catch (error) {
console.error('Error saving item:', error);
- toast.error('Failed to save item');
+ toast.error(t('toast.itemError'));
}
};
@@ -79,33 +81,33 @@ export function ItemModal({ item, onClose, onSaved, open }) {