Merge pull request #1 from R0m1k3/claude/add-openrouter-integration-01Wo4d6cqjpCG7hiJceKCmKb

feat: Rebranding PriceFlow + OpenRouter + i18n FR + Docker
This commit is contained in:
LogiFlow authored and GitHub committed 2025-11-22 10:23:21 +01:00
commit a941b96a55
19 files changed
+914 -76

No files matched your search

+19
View File
@@ -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
+4 -4
View File
@@ -11,7 +11,7 @@ from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
from app.limiter import limiter 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 from app.services.scheduler_service import scheduled_refresh, scheduler
# Configure logging # Configure logging
@@ -34,7 +34,7 @@ async def lifespan(app: FastAPI):
logger.info("Application shutdown complete") 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 # Rate Limiting & CORS
app.state.limiter = limiter app.state.limiter = limiter
@@ -63,13 +63,13 @@ os.makedirs("screenshots", exist_ok=True)
app.mount("/screenshots", StaticFiles(directory="screenshots"), name="screenshots") app.mount("/screenshots", StaticFiles(directory="screenshots"), name="screenshots")
# Routers # 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.include_router(router, prefix="/api")
@app.get("/api/") @app.get("/api/")
def read_root(): def read_root():
return {"message": "Welcome to Pricecious API"} return {"message": "Welcome to PriceFlow API"}
# Frontend Serving # Frontend Serving
+57
View File
@@ -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"}
]
}
+9
View File
@@ -186,6 +186,9 @@ class AIService:
kwargs["format"] = "json" kwargs["format"] = "json"
elif config["provider"] == "openai" and config["api_base"]: elif config["provider"] == "openai" and config["api_base"]:
kwargs["api_base"] = 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) response = await acompletion(**kwargs)
repaired_text = response.choices[0].message.content repaired_text = response.choices[0].message.content
@@ -250,6 +253,12 @@ class AIService:
# Anthropic doesn't have native JSON mode yet, rely on prompt # Anthropic doesn't have native JSON mode yet, rely on prompt
if config["api_base"]: if config["api_base"]:
kwargs["api_base"] = 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 # Other providers - rely on prompt engineering
elif config["api_base"]: elif config["api_base"]:
kwargs["api_base"] = config["api_base"] kwargs["api_base"] = config["api_base"]
+160
View File
@@ -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"
+76
View File
@@ -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
+6 -2
View File
@@ -1,12 +1,16 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="fr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- FAVICON: Remplacer les fichiers dans /frontend/public/ -->
<!-- - favicon.ico (32x32 ou 64x64) -->
<!-- - apple-touch-icon.png (180x180) -->
<!-- - logo.png (logo principal de l'application) -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pricecious</title> <title>PriceFlow</title>
</head> </head>
<body> <body>
+3 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "pricecious-frontend", "name": "priceflow-frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
@@ -17,9 +17,11 @@
"axios": "^1.6.0", "axios": "^1.6.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"i18next": "^23.11.5",
"lucide-react": "^0.292.0", "lucide-react": "^0.292.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^14.1.2",
"react-router-dom": "^6.30.2", "react-router-dom": "^6.30.2",
"sonner": "^1.4.0", "sonner": "^1.4.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
@@ -1,20 +1,23 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
export function DeleteConfirmationModal({ item, onClose, onConfirm, open }) { export function DeleteConfirmationModal({ item, onClose, onConfirm, open }) {
const { t } = useTranslation();
return ( return (
<Dialog open={open} onOpenChange={onClose}> <Dialog open={open} onOpenChange={onClose}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Delete Item</DialogTitle> <DialogTitle>{t('dashboard.delete')}</DialogTitle>
<DialogDescription> <DialogDescription>
Are you sure you want to delete <span className="font-semibold text-foreground">{item?.name}</span>? This action cannot be undone. {t('dashboard.confirmDelete')} <span className="font-semibold text-foreground">{item?.name}</span> ? {t('dashboard.deleteWarning')}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}>Cancel</Button> <Button variant="outline" onClick={onClose}>{t('dashboard.cancel')}</Button>
<Button variant="destructive" onClick={onConfirm}>Delete</Button> <Button variant="destructive" onClick={onConfirm}>{t('dashboard.confirm')}</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
+13 -10
View File
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next';
import { ExternalLink, Edit2, RefreshCw, Trash2, AlertTriangle, Clock } from 'lucide-react'; import { ExternalLink, Edit2, RefreshCw, Trash2, AlertTriangle, Clock } from 'lucide-react';
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card'; import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -6,10 +7,12 @@ import { Marquee } from '@/components/ui/marquee';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) { export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
const { t } = useTranslation();
const getStockStatus = (inStock) => { const getStockStatus = (inStock) => {
if (inStock === true) return { label: 'In Stock', color: 'bg-green-500/90 text-white border-green-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: 'Out of Stock', color: 'bg-red-500/90 text-white border-red-400/50' }; if (inStock === false) return { label: t('dashboard.outOfStock'), 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' }; return { label: t('dashboard.stockUnknown'), color: 'bg-zinc-500/90 text-white border-zinc-400/50' };
}; };
const stockStatus = getStockStatus(item.in_stock); const stockStatus = getStockStatus(item.in_stock);
@@ -29,7 +32,7 @@ export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
</> </>
) : ( ) : (
<div className="flex h-full w-full items-center justify-center text-sm text-zinc-400"> <div className="flex h-full w-full items-center justify-center text-sm text-zinc-400">
No screenshot Pas de capture
</div> </div>
)} )}
@@ -79,14 +82,14 @@ export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<div className="mb-1 flex items-baseline justify-between"> <div className="mb-1 flex items-baseline justify-between">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">Price</span> <span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">{t('dashboard.currentPrice')}</span>
{item.target_price && ( {item.target_price && (
<span className={cn("rounded px-2 py-0.5 text-xs font-medium", <span className={cn("rounded px-2 py-0.5 text-xs font-medium",
item.current_price <= item.target_price item.current_price <= item.target_price
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400' : 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400'
)}> )}>
Target: ${item.target_price} {t('dashboard.targetPrice')}: {item.target_price}€
</span> </span>
)} )}
</div> </div>
@@ -97,7 +100,7 @@ export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
? 'text-green-600 dark:text-green-400' ? 'text-green-600 dark:text-green-400'
: 'text-foreground' : 'text-foreground'
)}> )}>
{item.current_price ? `$${item.current_price}` : '---'} {item.current_price ? `${item.current_price}€` : '---'}
</span> </span>
</div> </div>
@@ -114,7 +117,7 @@ export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
/> />
</div> </div>
<div className="absolute -bottom-4 left-0 text-[10px] text-muted-foreground opacity-0 transition-opacity group-hover/confidence:opacity-100"> <div className="absolute -bottom-4 left-0 text-[10px] text-muted-foreground opacity-0 transition-opacity group-hover/confidence:opacity-100">
Price Confidence: {Math.round(item.current_price_confidence * 100)}% {t('dashboard.confidence')} (prix): {Math.round(item.current_price_confidence * 100)}%
</div> </div>
</div> </div>
)} )}
@@ -145,12 +148,12 @@ export function ItemCard({ item, onEdit, onDelete, onCheck, onZoom }) {
<div className="flex items-center gap-3 text-[10px] text-muted-foreground"> <div className="flex items-center gap-3 text-[10px] text-muted-foreground">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
<span>{item.last_checked ? new Date(item.last_checked).toLocaleString() : 'Never'}</span> <span>{item.last_checked ? new Date(item.last_checked).toLocaleString('fr-FR') : t('dashboard.never')}</span>
</div> </div>
{item.last_error && ( {item.last_error && (
<div className="flex items-center gap-1 text-destructive" title={item.last_error}> <div className="flex items-center gap-1 text-destructive" title={item.last_error}>
<AlertTriangle className="h-3 w-3" /> <AlertTriangle className="h-3 w-3" />
<span className="max-w-[100px] truncate">Error</span> <span className="max-w-[100px] truncate">{t('common.error')}</span>
</div> </div>
)} )}
</div> </div>
+22 -20
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import axios from 'axios'; import axios from 'axios';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -10,6 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
const API_URL = '/api'; const API_URL = '/api';
export function ItemModal({ item, onClose, onSaved, open }) { export function ItemModal({ item, onClose, onSaved, open }) {
const { t } = useTranslation();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
url: '', url: '',
name: '', name: '',
@@ -62,16 +64,16 @@ export function ItemModal({ item, onClose, onSaved, open }) {
if (item) { if (item) {
await axios.put(`${API_URL}/items/${item.id}`, payload); await axios.put(`${API_URL}/items/${item.id}`, payload);
toast.success('Item updated'); toast.success(t('toast.itemUpdated'));
} else { } else {
await axios.post(`${API_URL}/items`, payload); await axios.post(`${API_URL}/items`, payload);
toast.success('Item added'); toast.success(t('toast.itemCreated'));
} }
onSaved(); onSaved();
onClose(); onClose();
} catch (error) { } catch (error) {
console.error('Error saving item:', 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 }) {
<Dialog open={open} onOpenChange={onClose}> <Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[500px]"> <DialogContent className="sm:max-w-[500px]">
<DialogHeader> <DialogHeader>
<DialogTitle>{item ? 'Edit Item' : 'Add New Item'}</DialogTitle> <DialogTitle>{item ? t('itemModal.editTitle') : t('itemModal.addTitle')}</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 py-4"> <form onSubmit={handleSubmit} className="space-y-4 py-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="url">URL</Label> <Label htmlFor="url">{t('itemModal.url')}</Label>
<Input <Input
id="url" id="url"
type="url" type="url"
required required
placeholder="https://example.com/product" placeholder={t('itemModal.urlPlaceholder')}
value={formData.url} value={formData.url}
onChange={e => setFormData({ ...formData, url: e.target.value })} onChange={e => setFormData({ ...formData, url: e.target.value })}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Name</Label> <Label htmlFor="name">{t('itemModal.name')}</Label>
<Input <Input
id="name" id="name"
required required
placeholder="Product Name" placeholder={t('itemModal.namePlaceholder')}
value={formData.name} value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })} onChange={e => setFormData({ ...formData, name: e.target.value })}
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="target_price">Target Price ($)</Label> <Label htmlFor="target_price">{t('itemModal.targetPrice')} (€)</Label>
<Input <Input
id="target_price" id="target_price"
type="number" type="number"
@@ -116,26 +118,26 @@ export function ItemModal({ item, onClose, onSaved, open }) {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="selector">Selector (Optional)</Label> <Label htmlFor="selector">{t('itemModal.selector')}</Label>
<Input <Input
id="selector" id="selector"
placeholder=".price-class" placeholder={t('itemModal.selectorPlaceholder')}
value={formData.selector} value={formData.selector}
onChange={e => setFormData({ ...formData, selector: e.target.value })} onChange={e => setFormData({ ...formData, selector: e.target.value })}
/> />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="profile">Notification Profile</Label> <Label htmlFor="profile">{t('itemModal.notificationProfile')}</Label>
<Select <Select
value={formData.notification_profile_id} value={formData.notification_profile_id}
onValueChange={(value) => setFormData({ ...formData, notification_profile_id: value })} onValueChange={(value) => setFormData({ ...formData, notification_profile_id: value })}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a profile" /> <SelectValue placeholder={t('itemModal.selectProfile')} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="none">None</SelectItem> <SelectItem value="none">{t('itemModal.noProfile')}</SelectItem>
{profiles.map(p => ( {profiles.map(p => (
<SelectItem key={p.id} value={p.id.toString()}>{p.name}</SelectItem> <SelectItem key={p.id} value={p.id.toString()}>{p.name}</SelectItem>
))} ))}
@@ -143,27 +145,27 @@ export function ItemModal({ item, onClose, onSaved, open }) {
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="tags">Tags</Label> <Label htmlFor="tags">{t('itemModal.tags')}</Label>
<Input <Input
id="tags" id="tags"
placeholder="tech, home, gift (comma separated)" placeholder={t('itemModal.tagsPlaceholder')}
value={formData.tags} value={formData.tags}
onChange={e => setFormData({ ...formData, tags: e.target.value })} onChange={e => setFormData({ ...formData, tags: e.target.value })}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Description</Label> <Label htmlFor="description">{t('itemModal.description')}</Label>
<textarea <textarea
id="description" id="description"
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="Add notes about this item..." placeholder={t('itemModal.descriptionPlaceholder')}
value={formData.description} value={formData.description}
onChange={e => setFormData({ ...formData, description: e.target.value })} onChange={e => setFormData({ ...formData, description: e.target.value })}
/> />
</div> </div>
<DialogFooter> <DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button> <Button type="button" variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
<Button type="submit">Save Item</Button> <Button type="submit">{t('itemModal.save')}</Button>
</DialogFooter> </DialogFooter>
</form> </form>
</DialogContent> </DialogContent>
+13 -11
View File
@@ -1,17 +1,19 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { LayoutDashboard, Settings, Moon, Sun, Menu } from 'lucide-react'; import { LayoutDashboard, Settings, Moon, Sun, Menu } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const Layout = ({ children, theme, toggleTheme }) => { const Layout = ({ children, theme, toggleTheme }) => {
const { t } = useTranslation();
const location = useLocation(); const location = useLocation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const navItems = [ const navItems = [
{ icon: LayoutDashboard, label: 'Dashboard', path: '/' }, { icon: LayoutDashboard, label: t('nav.dashboard'), path: '/' },
{ icon: Settings, label: 'Settings', path: '/settings' }, { icon: Settings, label: t('nav.settings'), path: '/settings' },
]; ];
return ( return (
@@ -21,9 +23,9 @@ const Layout = ({ children, theme, toggleTheme }) => {
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex h-14 items-center border-b px-6"> <div className="flex h-14 items-center border-b px-6">
<Link to="/" className="flex items-center gap-2 font-semibold"> <Link to="/" className="flex items-center gap-2 font-semibold">
<img src="/logo.png" alt="Pricecious Logo" className="h-8 w-8" /> <img src="/logo.png" alt="PriceFlow Logo" className="h-8 w-8" />
<span className="text-xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent"> <span className="text-xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
Pricecious PriceFlow
</span> </span>
</Link> </Link>
</div> </div>
@@ -54,12 +56,12 @@ const Layout = ({ children, theme, toggleTheme }) => {
{theme === 'dark' ? ( {theme === 'dark' ? (
<> <>
<Sun className="h-4 w-4" /> <Sun className="h-4 w-4" />
<span>Light Mode</span> <span>{t('nav.lightMode')}</span>
</> </>
) : ( ) : (
<> <>
<Moon className="h-4 w-4" /> <Moon className="h-4 w-4" />
<span>Dark Mode</span> <span>{t('nav.darkMode')}</span>
</> </>
)} )}
</Button> </Button>
@@ -78,7 +80,7 @@ const Layout = ({ children, theme, toggleTheme }) => {
<SheetContent side="left" className="w-64 p-0"> <SheetContent side="left" className="w-64 p-0">
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="flex h-14 items-center border-b px-6"> <div className="flex h-14 items-center border-b px-6">
<img src="/logo.png" alt="Pricecious Logo" className="h-8 w-8 mr-2" /> <img src="/logo.png" alt="PriceFlow Logo" className="h-8 w-8 mr-2" />
<span className="text-xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent"> <span className="text-xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
Pricecious Pricecious
</span> </span>
@@ -109,12 +111,12 @@ const Layout = ({ children, theme, toggleTheme }) => {
{theme === 'dark' ? ( {theme === 'dark' ? (
<> <>
<Sun className="h-4 w-4" /> <Sun className="h-4 w-4" />
<span>Light Mode</span> <span>{t('nav.lightMode')}</span>
</> </>
) : ( ) : (
<> <>
<Moon className="h-4 w-4" /> <Moon className="h-4 w-4" />
<span>Dark Mode</span> <span>{t('nav.darkMode')}</span>
</> </>
)} )}
</Button> </Button>
@@ -123,8 +125,8 @@ const Layout = ({ children, theme, toggleTheme }) => {
</SheetContent> </SheetContent>
</Sheet> </Sheet>
<Link to="/" className="flex items-center gap-2 font-semibold"> <Link to="/" className="flex items-center gap-2 font-semibold">
<img src="/logo.png" alt="Pricecious Logo" className="h-6 w-6" /> <img src="/logo.png" alt="PriceFlow Logo" className="h-6 w-6" />
Pricecious PriceFlow
</Link> </Link>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<Button variant="ghost" size="icon" onClick={toggleTheme}> <Button variant="ghost" size="icon" onClick={toggleTheme}>
+18
View File
@@ -0,0 +1,18 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import fr from './locales/fr.json';
i18n
.use(initReactI18next)
.init({
resources: {
fr: { translation: fr }
},
lng: 'fr', // Langue par défaut: Français
fallbackLng: 'fr',
interpolation: {
escapeValue: false
}
});
export default i18n;
+195
View File
@@ -0,0 +1,195 @@
{
"app": {
"name": "PriceFlow",
"tagline": "Suivi de prix intelligent"
},
"nav": {
"dashboard": "Tableau de bord",
"settings": "Paramètres",
"lightMode": "Mode clair",
"darkMode": "Mode sombre"
},
"dashboard": {
"title": "Tableau de bord",
"description": "Gérez vos articles suivis et surveillez les changements de prix.",
"addItem": "Ajouter un article",
"refreshAll": "Actualiser tout",
"noItems": "Aucun article suivi",
"noItemsDescription": "Ajoutez votre premier article pour commencer le suivi des prix.",
"search": "Rechercher des articles...",
"filterAll": "Tous",
"filterActive": "Actifs",
"filterInactive": "Inactifs",
"itemCount": "{{count}} article(s)",
"lastChecked": "Dernière vérification",
"never": "Jamais",
"targetPrice": "Prix cible",
"currentPrice": "Prix actuel",
"inStock": "En stock",
"outOfStock": "Rupture de stock",
"stockUnknown": "Stock inconnu",
"priceHistory": "Historique des prix",
"confidence": "Confiance",
"refresh": "Actualiser",
"edit": "Modifier",
"delete": "Supprimer",
"confirmDelete": "Êtes-vous sûr de vouloir supprimer cet article ?",
"deleteWarning": "Cette action est irréversible. L'historique des prix sera également supprimé.",
"cancel": "Annuler",
"confirm": "Confirmer"
},
"itemModal": {
"addTitle": "Ajouter un article",
"editTitle": "Modifier l'article",
"url": "URL de la page produit",
"urlPlaceholder": "https://exemple.com/produit",
"name": "Nom de l'article",
"namePlaceholder": "ex: iPhone 15 Pro",
"nameOptional": "(optionnel - extrait automatiquement)",
"targetPrice": "Prix cible",
"targetPricePlaceholder": "ex: 999.99",
"targetPriceOptional": "(optionnel)",
"selector": "Sélecteur CSS",
"selectorPlaceholder": "ex: .price",
"selectorOptional": "(optionnel - pour les pages complexes)",
"tags": "Tags",
"tagsPlaceholder": "ex: tech, téléphone",
"tagsOptional": "(optionnel - séparés par des virgules)",
"description": "Description",
"descriptionPlaceholder": "Notes personnelles...",
"descriptionOptional": "(optionnel)",
"notificationProfile": "Profil de notification",
"selectProfile": "Sélectionner un profil",
"noProfile": "Aucun profil",
"active": "Actif",
"activeDescription": "Activer le suivi automatique",
"save": "Enregistrer",
"saving": "Enregistrement...",
"close": "Fermer"
},
"settings": {
"title": "Paramètres",
"description": "Configurez les préférences de l'application et les notifications.",
"ai": {
"title": "Configuration IA",
"description": "Configurez le modèle IA utilisé pour analyser les pages produits.",
"provider": "Fournisseur",
"model": "Modèle",
"modelPlaceholder": "ex: moondream",
"apiKey": "Clé API",
"apiKeyPlaceholder": "Optionnel pour les modèles locaux",
"apiKeyOpenRouter": "sk-or-...",
"advancedSettings": "Paramètres avancés",
"apiBaseUrl": "URL de base de l'API",
"temperature": "Température",
"maxTokens": "Tokens maximum",
"priceConfidence": "Seuil de confiance (prix)",
"stockConfidence": "Seuil de confiance (stock)",
"jsonRepair": "Activer la réparation JSON",
"jsonRepairDescription": "Tente de réparer les réponses JSON invalides"
},
"openrouter": {
"title": "Sélection du modèle OpenRouter",
"refresh": "Actualiser",
"loading": "Chargement des modèles...",
"modelsAvailable": "{{count}} disponibles",
"selectModel": "Sélectionner un modèle",
"input": "Entrée",
"output": "Sortie",
"context": "Contexte",
"tokens": "tokens",
"free": "Gratuit",
"categories": {
"all": "Tous",
"chat": "Chat",
"vision": "Vision",
"code": "Code",
"reasoning": "Raisonnement",
"free": "Gratuit"
}
},
"scraper": {
"title": "Configuration du scraper",
"description": "Configurez comment le scraper interagit avec les pages web.",
"smartScroll": "Défilement intelligent",
"smartScrollDescription": "Faire défiler pour déclencher le chargement différé.",
"scrollPixels": "Pixels de défilement",
"textContext": "Contexte textuel",
"textContextDescription": "Envoyer le texte de la page à l'IA avec la capture d'écran.",
"maxTextLength": "Longueur max du texte",
"timeout": "Délai d'attente (ms)"
},
"job": {
"title": "Tâche de rafraîchissement automatique",
"description": "Configurez la tâche de fond qui vérifie les mises à jour de prix.",
"refreshInterval": "Intervalle de rafraîchissement (minutes)",
"nextRun": "Prochaine exécution",
"notScheduled": "Non planifiée",
"status": "Statut",
"running": "En cours",
"idle": "En attente",
"saveConfig": "Enregistrer la configuration"
},
"notifications": {
"title": "Profils de notification",
"description": "Gérez les paramètres de notification pour différents groupes d'articles.",
"createTitle": "Créer un nouveau profil",
"editTitle": "Modifier le profil",
"cancelEdit": "Annuler",
"name": "Nom",
"namePlaceholder": "ex: Alertes email",
"appriseUrl": "URL Apprise",
"appriseUrlPlaceholder": "mailto://user:pass@gmail.com",
"checkInterval": "Intervalle de vérification (minutes)",
"priceDropThreshold": "Seuil de baisse de prix (%)",
"notifyOnDrop": "Notifier en cas de baisse",
"notifyOnTarget": "Notifier au prix cible",
"notifyOnStock": "Notifier en cas de changement de stock",
"createProfile": "Créer le profil",
"updateProfile": "Mettre à jour le profil",
"existingProfiles": "Profils existants",
"noProfiles": "Aucun profil créé.",
"interval": "intervalle",
"drop": "baisse",
"badges": {
"priceDrop": "Baisse de prix",
"targetHit": "Prix cible atteint",
"stockChange": "Changement de stock"
},
"confirmDelete": "Êtes-vous sûr de vouloir supprimer ce profil ?"
}
},
"common": {
"loading": "Chargement...",
"error": "Erreur",
"success": "Succès",
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"edit": "Modifier",
"close": "Fermer",
"confirm": "Confirmer",
"yes": "Oui",
"no": "Non",
"or": "ou",
"and": "et"
},
"toast": {
"settingUpdated": "Paramètre mis à jour",
"settingError": "Erreur lors de la mise à jour",
"fetchError": "Erreur lors du chargement",
"profileCreated": "Profil créé",
"profileUpdated": "Profil mis à jour",
"profileDeleted": "Profil supprimé",
"profileError": "Erreur lors de l'opération sur le profil",
"itemCreated": "Article ajouté",
"itemUpdated": "Article mis à jour",
"itemDeleted": "Article supprimé",
"itemError": "Erreur lors de l'opération sur l'article",
"refreshStarted": "Actualisation lancée",
"refreshComplete": "Actualisation terminée",
"refreshError": "Erreur lors de l'actualisation",
"jobConfigUpdated": "Configuration de la tâche mise à jour",
"openrouterError": "Erreur lors du chargement des modèles OpenRouter"
}
}
+1
View File
@@ -2,6 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import App from './App.jsx' import App from './App.jsx'
import './index.css' import './index.css'
import './i18n' // Initialisation i18n - Français par défaut
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
+16 -14
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import axios from 'axios'; import axios from 'axios';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Plus, RefreshCw, Search, X } from 'lucide-react'; import { Plus, RefreshCw, Search, X } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -11,6 +12,7 @@ import { DeleteConfirmationModal } from '@/components/dashboard/DeleteConfirmati
const API_URL = '/api'; const API_URL = '/api';
export default function Dashboard() { export default function Dashboard() {
const { t } = useTranslation();
const [items, setItems] = useState([]); const [items, setItems] = useState([]);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [showAddModal, setShowAddModal] = useState(false); const [showAddModal, setShowAddModal] = useState(false);
@@ -25,7 +27,7 @@ export default function Dashboard() {
setItems(sortedItems); setItems(sortedItems);
} catch (error) { } catch (error) {
console.error('Error fetching items:', error); console.error('Error fetching items:', error);
toast.error('Failed to fetch items'); toast.error(t('toast.fetchError'));
} }
}; };
@@ -37,25 +39,25 @@ export default function Dashboard() {
const handleCheck = async (id) => { const handleCheck = async (id) => {
try { try {
toast.info('Check triggered...'); toast.info(t('toast.refreshStarted'));
await axios.post(`${API_URL}/items/${id}/check`); await axios.post(`${API_URL}/items/${id}/check`);
toast.success('Check started in background'); toast.success(t('toast.refreshComplete'));
refreshItems(); refreshItems();
} catch (error) { } catch (error) {
console.error('Error triggering check:', error); console.error('Error triggering check:', error);
toast.error('Failed to trigger check'); toast.error(t('toast.refreshError'));
} }
}; };
const handleRefreshAll = async () => { const handleRefreshAll = async () => {
try { try {
toast.info('Triggering refresh for all items...'); toast.info(t('toast.refreshStarted'));
setItems(prevItems => prevItems.map(item => ({ ...item, is_refreshing: true }))); setItems(prevItems => prevItems.map(item => ({ ...item, is_refreshing: true })));
await axios.post(`${API_URL}/jobs/refresh-all`); await axios.post(`${API_URL}/jobs/refresh-all`);
toast.success('Refresh all started'); toast.success(t('toast.refreshComplete'));
} catch (error) { } catch (error) {
console.error('Error triggering refresh all:', error); console.error('Error triggering refresh all:', error);
toast.error('Failed to trigger refresh all'); toast.error(t('toast.refreshError'));
refreshItems(); refreshItems();
} }
}; };
@@ -64,12 +66,12 @@ export default function Dashboard() {
if (!itemToDelete) return; if (!itemToDelete) return;
try { try {
await axios.delete(`${API_URL}/items/${itemToDelete.id}`); await axios.delete(`${API_URL}/items/${itemToDelete.id}`);
toast.success('Item deleted'); toast.success(t('toast.itemDeleted'));
refreshItems(); refreshItems();
setItemToDelete(null); setItemToDelete(null);
} catch (error) { } catch (error) {
console.error('Error deleting item:', error); console.error('Error deleting item:', error);
toast.error('Failed to delete item'); toast.error(t('toast.itemError'));
} }
}; };
@@ -87,14 +89,14 @@ export default function Dashboard() {
<div className="space-y-6 animate-in fade-in duration-500"> <div className="space-y-6 animate-in fade-in duration-500">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div> <div>
<h2 className="text-2xl font-bold tracking-tight">Dashboard</h2> <h2 className="text-2xl font-bold tracking-tight">{t('dashboard.title')}</h2>
<p className="text-muted-foreground">Manage and monitor your product watchlist.</p> <p className="text-muted-foreground">{t('dashboard.description')}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative w-full md:w-64"> <div className="relative w-full md:w-64">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
placeholder="Search items..." placeholder={t('dashboard.search')}
className="pl-8" className="pl-8"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
@@ -102,11 +104,11 @@ export default function Dashboard() {
</div> </div>
<Button variant="outline" onClick={handleRefreshAll}> <Button variant="outline" onClick={handleRefreshAll}>
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
Refresh All {t('dashboard.refreshAll')}
</Button> </Button>
<Button onClick={() => setShowAddModal(true)}> <Button onClick={() => setShowAddModal(true)}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Item {t('dashboard.addItem')}
</Button> </Button>
</div> </div>
</div> </div>
+192 -8
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import axios from 'axios'; import axios from 'axios';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Sun, Clock, Cpu, Settings as SettingsIcon, Bell, Trash2, ChevronDown, ChevronUp, Edit2, CheckCircle2, AlertCircle, TrendingDown, DollarSign, Package } from 'lucide-react'; import { Sun, Clock, Cpu, Settings as SettingsIcon, Bell, Trash2, ChevronDown, ChevronUp, Edit2, CheckCircle2, AlertCircle, TrendingDown, DollarSign, Package, RefreshCw, Filter, Eye, Code, Brain, Sparkles } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -13,6 +13,15 @@ import { cn } from '@/lib/utils';
const API_URL = '/api'; const API_URL = '/api';
// Icônes pour les catégories OpenRouter
const categoryIcons = {
chat: Sparkles,
vision: Eye,
code: Code,
reasoning: Brain,
free: DollarSign
};
export default function Settings() { export default function Settings() {
const [profiles, setProfiles] = useState([]); const [profiles, setProfiles] = useState([]);
const [jobConfig, setJobConfig] = useState({ refresh_interval_minutes: 60, next_run: null, running: false }); const [jobConfig, setJobConfig] = useState({ refresh_interval_minutes: 60, next_run: null, running: false });
@@ -45,10 +54,58 @@ export default function Settings() {
const [editingProfileId, setEditingProfileId] = useState(null); const [editingProfileId, setEditingProfileId] = useState(null);
const [showAdvancedAI, setShowAdvancedAI] = useState(false); const [showAdvancedAI, setShowAdvancedAI] = useState(false);
// OpenRouter state
const [openrouterModels, setOpenrouterModels] = useState([]);
const [openrouterLoading, setOpenrouterLoading] = useState(false);
const [openrouterCategory, setOpenrouterCategory] = useState('all');
const [openrouterCategories] = useState([
{ id: 'all', name: 'Tous', description: 'Tous les modèles' },
{ id: 'chat', name: 'Chat', description: 'Conversation générale' },
{ id: 'vision', name: 'Vision', description: 'Analyse d\'images' },
{ id: 'code', name: 'Code', description: 'Programmation' },
{ id: 'reasoning', name: 'Raisonnement', description: 'Raisonnement avancé' },
{ id: 'free', name: 'Gratuit', description: 'Modèles gratuits' }
]);
useEffect(() => { useEffect(() => {
fetchAll(); fetchAll();
}, []); }, []);
// Charger les modèles OpenRouter quand le provider change ou la catégorie
useEffect(() => {
if (config.ai_provider === 'openrouter') {
fetchOpenRouterModels();
}
}, [config.ai_provider, openrouterCategory]);
const fetchOpenRouterModels = async () => {
setOpenrouterLoading(true);
try {
const params = new URLSearchParams();
if (config.ai_api_key) {
params.append('api_key', config.ai_api_key);
}
if (openrouterCategory && openrouterCategory !== 'all') {
params.append('category', openrouterCategory);
}
const response = await axios.get(`${API_URL}/openrouter/models?${params.toString()}`);
setOpenrouterModels(response.data.models || []);
} catch (error) {
console.error('Erreur chargement modèles OpenRouter:', error);
toast.error('Erreur lors du chargement des modèles OpenRouter');
} finally {
setOpenrouterLoading(false);
}
};
const formatPrice = (pricePerToken) => {
if (!pricePerToken || pricePerToken === 0) return 'Gratuit';
const pricePerMillion = pricePerToken * 1000000;
if (pricePerMillion < 0.01) return `$${pricePerMillion.toFixed(4)}/1M`;
if (pricePerMillion < 1) return `$${pricePerMillion.toFixed(3)}/1M`;
return `$${pricePerMillion.toFixed(2)}/1M`;
};
const fetchAll = async () => { const fetchAll = async () => {
try { try {
const [profilesRes, settingsRes, jobRes] = await Promise.all([ const [profilesRes, settingsRes, jobRes] = await Promise.all([
@@ -192,26 +249,153 @@ export default function Settings() {
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Provider</Label> <Label>Fournisseur</Label>
<Select value={config.ai_provider} onValueChange={(val) => updateSetting('ai_provider', val)}> <Select value={config.ai_provider} onValueChange={(val) => updateSetting('ai_provider', val)}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="ollama">Ollama</SelectItem> <SelectItem value="ollama">Ollama</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem> <SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem> <SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="openrouter">OpenRouter</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="space-y-2"> {config.ai_provider !== 'openrouter' ? (
<Label>Model</Label> <div className="space-y-2">
<Input value={config.ai_model} onChange={(e) => updateSetting('ai_model', e.target.value)} placeholder="e.g., moondream" /> <Label>Modèle</Label>
</div> <Input value={config.ai_model} onChange={(e) => updateSetting('ai_model', e.target.value)} placeholder="ex: moondream" />
</div>
) : (
<div className="space-y-2">
<Label>Catégorie</Label>
<Select value={openrouterCategory} onValueChange={setOpenrouterCategory}>
<SelectTrigger>
<SelectValue placeholder="Filtrer par catégorie" />
</SelectTrigger>
<SelectContent>
{openrouterCategories.map(cat => (
<SelectItem key={cat.id} value={cat.id}>
<span className="flex items-center gap-2">
{cat.name}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>API Key</Label> <Label>Clé API</Label>
<Input type="password" value={config.ai_api_key} onChange={(e) => updateSetting('ai_api_key', e.target.value)} placeholder="Optional for local models" /> <Input
type="password"
value={config.ai_api_key}
onChange={(e) => updateSetting('ai_api_key', e.target.value)}
placeholder={config.ai_provider === 'openrouter' ? 'sk-or-...' : 'Optionnel pour les modèles locaux'}
/>
</div> </div>
{/* OpenRouter Model Selector */}
{config.ai_provider === 'openrouter' && (
<div className="space-y-4 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center justify-between">
<Label className="text-base font-medium">Sélection du modèle OpenRouter</Label>
<Button
variant="outline"
size="sm"
onClick={fetchOpenRouterModels}
disabled={openrouterLoading}
>
<RefreshCw className={cn("h-4 w-4 mr-2", openrouterLoading && "animate-spin")} />
Actualiser
</Button>
</div>
{/* Filtres par catégorie - badges cliquables */}
<div className="flex flex-wrap gap-2">
{openrouterCategories.map(cat => {
const Icon = categoryIcons[cat.id] || Filter;
return (
<Button
key={cat.id}
variant={openrouterCategory === cat.id ? "default" : "outline"}
size="sm"
onClick={() => setOpenrouterCategory(cat.id)}
className="text-xs"
>
<Icon className="h-3 w-3 mr-1" />
{cat.name}
</Button>
);
})}
</div>
{/* Liste des modèles */}
<div className="space-y-2">
<Label>Modèle ({openrouterModels.length} disponibles)</Label>
{openrouterLoading ? (
<div className="text-sm text-muted-foreground py-4 text-center">
Chargement des modèles...
</div>
) : (
<Select
value={config.ai_model}
onValueChange={(val) => updateSetting('ai_model', val)}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner un modèle" />
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{openrouterModels.map(model => (
<SelectItem key={model.id} value={model.id}>
<div className="flex flex-col">
<span className="font-medium">{model.name}</span>
<span className="text-xs text-muted-foreground">
Entrée: {formatPrice(model.pricing?.prompt)} | Sortie: {formatPrice(model.pricing?.completion)}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* Afficher les détails du modèle sélectionné */}
{config.ai_model && openrouterModels.length > 0 && (
<div className="text-sm p-3 bg-background rounded border">
{(() => {
const selectedModel = openrouterModels.find(m => m.id === config.ai_model);
if (!selectedModel) return <span className="text-muted-foreground">Modèle: {config.ai_model}</span>;
return (
<div className="space-y-2">
<div className="font-medium">{selectedModel.name}</div>
<div className="text-muted-foreground text-xs">{selectedModel.description}</div>
<div className="flex flex-wrap gap-2 pt-1">
<span className="px-2 py-0.5 bg-primary/10 rounded text-xs">
Contexte: {selectedModel.context_length?.toLocaleString()} tokens
</span>
<span className="px-2 py-0.5 bg-green-500/10 text-green-700 dark:text-green-400 rounded text-xs">
Entrée: {formatPrice(selectedModel.pricing?.prompt)}
</span>
<span className="px-2 py-0.5 bg-blue-500/10 text-blue-700 dark:text-blue-400 rounded text-xs">
Sortie: {formatPrice(selectedModel.pricing?.completion)}
</span>
{selectedModel.categories?.map(cat => (
<span key={cat} className="px-2 py-0.5 bg-secondary rounded text-xs capitalize">
{cat}
</span>
))}
</div>
</div>
);
})()}
</div>
)}
</div>
)}
<div className="pt-2"> <div className="pt-2">
<Button <Button
variant="outline" variant="outline"
+101
View File
@@ -0,0 +1,101 @@
-- PriceFlow - Script d'initialisation de la base de données
-- Ce script est exécuté automatiquement au premier lancement du conteneur PostgreSQL
-- Extension pour les UUID (optionnel, pour future utilisation)
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Table des profils de notification
CREATE TABLE IF NOT EXISTS notification_profiles (
id SERIAL PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL,
apprise_url VARCHAR(1024),
notify_on_price_drop BOOLEAN DEFAULT TRUE,
notify_on_target_price BOOLEAN DEFAULT TRUE,
price_drop_threshold_percent FLOAT DEFAULT 10.0,
notify_on_stock_change BOOLEAN DEFAULT TRUE,
check_interval_minutes INTEGER DEFAULT 60
);
-- Index sur le nom du profil
CREATE INDEX IF NOT EXISTS idx_notification_profiles_name ON notification_profiles(name);
-- Table des articles suivis
CREATE TABLE IF NOT EXISTS items (
id SERIAL PRIMARY KEY,
url VARCHAR(2048) NOT NULL,
name VARCHAR(512) NOT NULL,
selector VARCHAR(512),
target_price FLOAT,
check_interval_minutes INTEGER DEFAULT 60,
current_price FLOAT,
in_stock BOOLEAN,
tags VARCHAR(512),
description TEXT,
current_price_confidence FLOAT,
in_stock_confidence FLOAT,
is_active BOOLEAN DEFAULT TRUE,
last_checked TIMESTAMP,
is_refreshing BOOLEAN DEFAULT FALSE,
last_error VARCHAR(1024),
notification_profile_id INTEGER REFERENCES notification_profiles(id) ON DELETE SET NULL
);
-- Index sur les articles
CREATE INDEX IF NOT EXISTS idx_items_url ON items(url);
CREATE INDEX IF NOT EXISTS idx_items_is_active ON items(is_active);
CREATE INDEX IF NOT EXISTS idx_items_notification_profile_id ON items(notification_profile_id);
-- Table de l'historique des prix
CREATE TABLE IF NOT EXISTS price_history (
id SERIAL PRIMARY KEY,
item_id INTEGER NOT NULL REFERENCES items(id) ON DELETE CASCADE,
price FLOAT NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
screenshot_path VARCHAR(512),
price_confidence FLOAT,
in_stock_confidence FLOAT,
ai_model VARCHAR(255),
ai_provider VARCHAR(100),
prompt_version VARCHAR(50),
repair_used BOOLEAN DEFAULT FALSE
);
-- Index sur l'historique des prix
CREATE INDEX IF NOT EXISTS idx_price_history_item_id ON price_history(item_id);
CREATE INDEX IF NOT EXISTS idx_price_history_timestamp ON price_history(timestamp);
-- Table des paramètres
CREATE TABLE IF NOT EXISTS settings (
key VARCHAR(255) PRIMARY KEY,
value TEXT
);
-- Index sur les paramètres
CREATE INDEX IF NOT EXISTS idx_settings_key ON settings(key);
-- Paramètres par défaut pour l'application
INSERT INTO settings (key, value) VALUES
('ai_provider', 'ollama'),
('ai_model', 'gemma3:4b'),
('ai_api_base', 'http://ollama:11434'),
('ai_temperature', '0.1'),
('ai_max_tokens', '300'),
('ai_timeout', '30'),
('enable_json_repair', 'true'),
('enable_multi_sample', 'false'),
('multi_sample_confidence_threshold', '0.6'),
('confidence_threshold_price', '0.5'),
('confidence_threshold_stock', '0.5'),
('smart_scroll_enabled', 'false'),
('smart_scroll_pixels', '350'),
('text_context_enabled', 'false'),
('text_context_length', '5000'),
('scraper_timeout', '90000'),
('refresh_interval_minutes', '60')
ON CONFLICT (key) DO NOTHING;
-- Message de fin d'initialisation
DO $$
BEGIN
RAISE NOTICE 'PriceFlow database initialized successfully!';
END $$;
+2 -2
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "pricecious-backend" name = "priceflow-backend"
version = "0.1.0" version = "0.1.0"
description = "Backend for Pricecious Price Tracker" description = "Backend for PriceFlow Price Tracker"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [