import React, { useState, useEffect } from 'react'; import axios from 'axios'; import { toast } from 'sonner'; import { Sun, Clock, Cpu, Settings as SettingsIcon, Bell, Trash2, ChevronDown, ChevronUp, Edit2, CheckCircle2, AlertCircle, TrendingDown, DollarSign, Package, RefreshCw, Filter, Eye, Code, Brain, Sparkles, Globe, RotateCcw } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; import { Switch } from '@/components/ui/switch'; import { Button } from '@/components/ui/button'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Slider } from '@/components/ui/slider'; import { cn } from '@/lib/utils'; 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() { const [profiles, setProfiles] = useState([]); const [jobConfig, setJobConfig] = useState({ refresh_interval_minutes: 60, next_run: null, running: false }); const [config, setConfig] = useState({ ai_provider: 'ollama', ai_model: 'moondream', ai_api_key: '', ai_api_base: 'http://ollama:11434', ai_temperature: 0.1, ai_max_tokens: 300, confidence_threshold_price: 0.5, confidence_threshold_stock: 0.5, enable_json_repair: true, smart_scroll_enabled: false, smart_scroll_pixels: 350, text_context_enabled: false, text_context_length: 5000, scraper_timeout: 90000 }); const [newProfile, setNewProfile] = useState({ name: '', apprise_url: '', provider: 'custom', discord_webhook: '', telegram_token: '', telegram_chat_id: '', check_interval_minutes: 60, notify_on_price_drop: true, notify_on_target_price: true, price_drop_threshold_percent: 10, notify_on_stock_change: true }); const [editingProfileId, setEditingProfileId] = useState(null); const [showAdvancedAI, setShowAdvancedAI] = useState(false); // Search Sites state const [searchSites, setSearchSites] = useState([]); const [resettingSites, setResettingSites] = 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(() => { 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 () => { try { const [profilesRes, settingsRes, jobRes, sitesRes] = await Promise.all([ axios.get(`${API_URL}/notification-profiles`), axios.get(`${API_URL}/settings`), axios.get(`${API_URL}/jobs/config`), axios.get(`${API_URL}/search-sites`) ]); setProfiles(profilesRes.data); setJobConfig(jobRes.data); setSearchSites(sitesRes.data); const settingsMap = {}; settingsRes.data.forEach(s => settingsMap[s.key] = s.value); setConfig({ ai_provider: settingsMap['ai_provider'] || 'ollama', ai_model: settingsMap['ai_model'] || 'moondream', ai_api_key: settingsMap['ai_api_key'] || '', ai_api_base: settingsMap['ai_api_base'] || 'http://ollama:11434', ai_temperature: parseFloat(settingsMap['ai_temperature'] || '0.1'), ai_max_tokens: parseInt(settingsMap['ai_max_tokens'] || '300'), confidence_threshold_price: parseFloat(settingsMap['confidence_threshold_price'] || '0.5'), confidence_threshold_stock: parseFloat(settingsMap['confidence_threshold_stock'] || '0.5'), enable_json_repair: settingsMap['enable_json_repair'] !== 'false', smart_scroll_enabled: settingsMap['smart_scroll_enabled'] === 'true', smart_scroll_pixels: parseInt(settingsMap['smart_scroll_pixels'] || '350'), text_context_enabled: settingsMap['text_context_enabled'] === 'true', text_context_length: parseInt(settingsMap['text_context_length'] || '5000'), scraper_timeout: parseInt(settingsMap['scraper_timeout'] || '90000') }); } catch (error) { toast.error('Failed to fetch settings'); } }; const updateSetting = async (key, value) => { try { await axios.post(`${API_URL}/settings`, { key, value: value.toString() }); setConfig(prev => ({ ...prev, [key]: value })); toast.success('Setting updated'); } catch (error) { toast.error('Failed to update setting'); } }; const updateJobConfig = async () => { try { await axios.post(`${API_URL}/jobs/config`, { key: 'refresh_interval_minutes', value: jobConfig.refresh_interval_minutes.toString() }); toast.success('Job configuration updated'); fetchAll(); } catch (error) { toast.error('Failed to update job config'); } }; const handleProfileSubmit = async (e) => { e.preventDefault(); try { let finalAppriseUrl = newProfile.apprise_url; // Construct Apprise URL based on provider if (newProfile.provider === 'discord' && newProfile.discord_webhook) { // Parse Discord Webhook: https://discord.com/api/webhooks/{id}/{token} -> discord://{id}/{token} const match = newProfile.discord_webhook.match(/webhooks\/(\d+)\/(.+)/); if (match) { finalAppriseUrl = `discord://${match[1]}/${match[2]}`; } else { toast.error('Invalid Discord Webhook URL'); return; } } else if (newProfile.provider === 'telegram' && newProfile.telegram_token && newProfile.telegram_chat_id) { // Construct Telegram URL: tgram://{token}/{chat_id} finalAppriseUrl = `tgram://${newProfile.telegram_token}/${newProfile.telegram_chat_id}`; } const profileData = { ...newProfile, apprise_url: finalAppriseUrl }; if (editingProfileId) { await axios.put(`${API_URL}/notification-profiles/${editingProfileId}`, profileData); toast.success('Profile updated'); } else { await axios.post(`${API_URL}/notification-profiles`, profileData); toast.success('Profile created'); } setNewProfile({ name: '', apprise_url: '', discord_webhook: '', telegram_token: '', telegram_chat_id: '', provider: 'custom', check_interval_minutes: 60, notify_on_price_drop: true, notify_on_target_price: true, price_drop_threshold_percent: 10, notify_on_stock_change: true }); setEditingProfileId(null); fetchAll(); } catch (error) { toast.error(editingProfileId ? 'Failed to update profile' : 'Failed to create profile'); } }; const editProfile = (profile) => { let provider = 'custom'; let discord_webhook = ''; let telegram_token = ''; let telegram_chat_id = ''; // Deconstruct Apprise URL if (profile.apprise_url.startsWith('discord://')) { provider = 'discord'; // discord://{id}/{token} -> https://discord.com/api/webhooks/{id}/{token} const parts = profile.apprise_url.replace('discord://', '').split('/'); if (parts.length >= 2) { discord_webhook = `https://discord.com/api/webhooks/${parts[0]}/${parts[1]}`; } } else if (profile.apprise_url.startsWith('tgram://')) { provider = 'telegram'; // tgram://{token}/{chat_id} const parts = profile.apprise_url.replace('tgram://', '').split('/'); if (parts.length >= 2) { telegram_token = parts[0]; telegram_chat_id = parts[1]; } } setNewProfile({ name: profile.name, apprise_url: profile.apprise_url, provider, discord_webhook, telegram_token, telegram_chat_id, check_interval_minutes: profile.check_interval_minutes, notify_on_price_drop: profile.notify_on_price_drop, notify_on_target_price: profile.notify_on_target_price, price_drop_threshold_percent: profile.price_drop_threshold_percent, notify_on_stock_change: profile.notify_on_stock_change }); setEditingProfileId(profile.id); window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); }; const cancelEdit = () => { setNewProfile({ name: '', apprise_url: '', check_interval_minutes: 60, notify_on_price_drop: true, notify_on_target_price: true, price_drop_threshold_percent: 10, notify_on_stock_change: true }); setEditingProfileId(null); }; const deleteProfile = async (id) => { if (confirm('Are you sure you want to delete this profile?')) { try { await axios.delete(`${API_URL}/notification-profiles/${id}`); toast.success('Profile deleted'); if (editingProfileId === id) { cancelEdit(); } fetchAll(); } catch (error) { toast.error('Failed to delete profile'); } } }; // Search Sites functions const toggleSiteActive = async (site) => { try { await axios.put(`${API_URL}/search-sites/${site.id}`, { is_active: !site.is_active }); fetchAll(); } catch (error) { toast.error('Erreur lors de la mise à jour'); } }; const resetSitesToDefaults = async () => { if (!confirm('Êtes-vous sûr de vouloir réinitialiser tous les sites ? Cette action va remettre les valeurs par défaut.')) { return; } setResettingSites(true); try { const response = await axios.post(`${API_URL}/search-sites/reset`); toast.success(response.data.message); fetchAll(); } catch (error) { toast.error('Erreur lors de la réinitialisation'); } finally { setResettingSites(false); } }; return (

Settings

Configure application preferences and notifications.

{/* AI Configuration */} AI Configuration Configure the AI model used for analyzing product pages.
{config.ai_provider !== 'openrouter' ? (
updateSetting('ai_model', e.target.value)} placeholder="ex: moondream" />
) : (
)}
updateSetting('ai_api_key', e.target.value)} placeholder={config.ai_provider === 'openrouter' ? 'sk-or-...' : 'Optionnel pour les modèles locaux'} />
{/* OpenRouter Model Selector */} {config.ai_provider === 'openrouter' && (
{/* Filtres par catégorie - badges cliquables */}
{openrouterCategories.map(cat => { const Icon = categoryIcons[cat.id] || Filter; return ( ); })}
{/* Liste des modèles */}
{openrouterLoading ? (
Chargement des modèles...
) : ( )}
{/* Afficher les détails du modèle sélectionné */} {config.ai_model && openrouterModels.length > 0 && (
{(() => { const selectedModel = openrouterModels.find(m => m.id === config.ai_model); if (!selectedModel) return Modèle: {config.ai_model}; return (
{selectedModel.name}
{selectedModel.description}
Contexte: {selectedModel.context_length?.toLocaleString()} tokens Entrée: {formatPrice(selectedModel.pricing?.prompt)} Sortie: {formatPrice(selectedModel.pricing?.completion)} {selectedModel.categories?.map(cat => ( {cat} ))}
); })()}
)}
)}
{showAdvancedAI && (
updateSetting('ai_api_base', e.target.value)} />
{config.ai_temperature}
updateSetting('ai_temperature', parseFloat(e.target.value))} />
{config.confidence_threshold_price}
updateSetting('confidence_threshold_price', parseFloat(e.target.value))} />
{config.confidence_threshold_stock}
updateSetting('confidence_threshold_stock', parseFloat(e.target.value))} />
updateSetting('ai_max_tokens', parseInt(e.target.value))} />
updateSetting('enable_json_repair', checked)} />
)}
{/* Scraper Configuration */} Scraper Configuration Configure how the scraper interacts with web pages.

Scroll down to trigger lazy loading.

updateSetting('smart_scroll_enabled', checked)} />
{config.smart_scroll_enabled && (
updateSetting('smart_scroll_pixels', parseInt(e.target.value))} />
)}

Send page text to AI along with screenshot.

updateSetting('text_context_enabled', checked)} />
{config.text_context_enabled && (
updateSetting('text_context_length', parseInt(e.target.value))} />
)}
updateSetting('scraper_timeout', parseInt(e.target.value))} />
{/* Job Configuration */} Automated Refresh Job Configure the background job that checks for price updates.
setJobConfig({ ...jobConfig, refresh_interval_minutes: e.target.value })} />

Next run: {jobConfig.next_run ? new Date(jobConfig.next_run).toLocaleString() : 'Not scheduled'}

Status: {jobConfig.running ? 'Running' : 'Idle'}

{/* Search Sites Configuration */}
Sites de recherche Sites e-commerce français configurés pour la recherche de produits.
{searchSites.filter(s => s.is_active).length} site(s) actif(s) sur {searchSites.length}
{searchSites.length === 0 ? (

Aucun site configuré. Cliquez sur "Réinitialiser" pour charger les sites par défaut.

) : (
{searchSites.map(site => (

{site.name}

{site.domain}

toggleSiteActive(site)} className="ml-2" />
{site.category && ( {site.category} )}
))}
)}
{/* Notification Profiles */} TEST 2025 - Notification Profiles Manage notification settings for different groups of items.

{editingProfileId ? 'Edit Profile' : 'Create New Profile'}

{editingProfileId && ( )}
setNewProfile({ ...newProfile, name: e.target.value })} placeholder="e.g., Email Alerts" />
{(!newProfile.provider || newProfile.provider === 'custom') && (
setNewProfile({ ...newProfile, apprise_url: e.target.value })} placeholder="mailto://user:pass@gmail.com" className="font-mono text-sm" />

See Apprise documentation for supported services.

)} {newProfile.provider === 'discord' && (
setNewProfile({ ...newProfile, discord_webhook: e.target.value })} placeholder="https://discord.com/api/webhooks/..." className="font-mono text-sm" />

Paste the full Webhook URL from Discord Server Settings > Integrations > Webhooks.

)} {newProfile.provider === 'telegram' && (
setNewProfile({ ...newProfile, telegram_token: e.target.value })} placeholder="123456789:ABCdef..." className="font-mono text-sm" />
setNewProfile({ ...newProfile, telegram_chat_id: e.target.value })} placeholder="-100123456789" className="font-mono text-sm" />
)}
setNewProfile({ ...newProfile, check_interval_minutes: parseInt(e.target.value) })} />
setNewProfile({ ...newProfile, price_drop_threshold_percent: parseFloat(e.target.value) })} />
setNewProfile({ ...newProfile, notify_on_price_drop: checked })} />
setNewProfile({ ...newProfile, notify_on_target_price: checked })} />
setNewProfile({ ...newProfile, notify_on_stock_change: checked })} />

Existing Profiles

{profiles.length === 0 ? (

No profiles created yet.

) : (
{profiles.map(profile => (

{profile.name}

{profile.apprise_url}

{profile.check_interval_minutes}m interval
{profile.price_drop_threshold_percent}% drop
{profile.notify_on_price_drop && ( Price Drop )} {profile.notify_on_target_price && ( Target Hit )} {profile.notify_on_stock_change && ( Stock Change )}
))}
)}
); }