From d6c39252f22d33468912f152626ec61146424b51 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Nov 2025 09:22:39 +0000 Subject: [PATCH] feat: Rebranding PriceFlow + OpenRouter + i18n FR + Docker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Modifications majeures: 1. Rebranding: Pricecious → PriceFlow - Mise à jour du titre, logo et noms de projet 2. Intégration OpenRouter - Ajout comme provider IA avec filtres (chat, vision, code, reasoning, free) - Récupération dynamique des modèles et coûts - Service et router dédiés (/api/openrouter/models) 3. Internationalisation (i18n) - Installation i18next + react-i18next - Application entièrement traduite en français - Fichier de traduction: fr.json 4. Configuration Docker - docker-compose.yml avec réseau nginx_default (external) - PostgreSQL sur port 5488:5432 - Browserless sur port 3012:3000 - init.sql pour initialisation de la base de données - .env.example avec variables documentées 5. Favicon - Documentation ajoutée dans index.html pour faciliter le remplacement --- .env.example | 19 ++ app/main.py | 8 +- app/routers/openrouter.py | 57 +++++ app/services/ai_service.py | 9 + app/services/openrouter_service.py | 160 ++++++++++++++ docker-compose.yml | 76 +++++++ frontend/index.html | 8 +- frontend/package.json | 4 +- .../dashboard/DeleteConfirmationModal.jsx | 11 +- .../src/components/dashboard/ItemCard.jsx | 23 +- .../src/components/dashboard/ItemModal.jsx | 42 ++-- frontend/src/components/layout/Layout.jsx | 24 ++- frontend/src/i18n/index.js | 18 ++ frontend/src/i18n/locales/fr.json | 195 +++++++++++++++++ frontend/src/main.jsx | 1 + frontend/src/pages/Dashboard.jsx | 30 +-- frontend/src/pages/Settings.jsx | 200 +++++++++++++++++- init.sql | 101 +++++++++ pyproject.toml | 4 +- 19 files changed, 914 insertions(+), 76 deletions(-) create mode 100644 .env.example create mode 100644 app/routers/openrouter.py create mode 100644 app/services/openrouter_service.py create mode 100644 docker-compose.yml create mode 100644 frontend/src/i18n/index.js create mode 100644 frontend/src/i18n/locales/fr.json create mode 100644 init.sql 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 ( - Delete Item + {t('dashboard.delete')} - Are you sure you want to delete {item?.name}? This action cannot be undone. + {t('dashboard.confirmDelete')} {item?.name} ? {t('dashboard.deleteWarning')} - - + + 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 }) { - {item ? 'Edit Item' : 'Add New Item'} + {item ? t('itemModal.editTitle') : t('itemModal.addTitle')}
- + setFormData({ ...formData, url: e.target.value })} />
- + setFormData({ ...formData, name: e.target.value })} />
- +
- + setFormData({ ...formData, selector: e.target.value })} />
- +
- + setFormData({ ...formData, tags: e.target.value })} />
- +