All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m57s
Pricing: 'deepl' removed from plan provider lists and the comparison table row. Landing and pricing copy (13 locales) no longer mention DeepL (65 values cleaned, 39 dead keys deleted: pricing.comparison.deepl, providerTheme.classic.deepl.*). Providers: available-provider responses are filtered so a DeepL entry from the backend can never surface in the engine picker or the services page; DeepL theme entry and static lib/api.ts list entry removed. Admin: DeepL config card, type fields, defaults and fallback-chain mentions removed; stats/chart/status maps and mock data cleaned. Backend adapter untouched — DeepL is invisible app-wide via the UI filter; removing the Python adapter itself is a separate step if wanted. Verified: build exit 0, vitest 9/9, eslint clean on touched files, zero 'deepl' occurrences outside the two intentional UI filters.
878 lines
35 KiB
TypeScript
878 lines
35 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { Settings, Save, Loader2, CheckCircle, XCircle, RefreshCw, FlaskConical, KeyRound, Mail } from "lucide-react";
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { useNotification } from "@/components/ui/notification";
|
|
import { ModelCombobox, ModelOption } from "@/components/ui/model-combobox";
|
|
import { useTranslationStore } from "@/lib/store";
|
|
import { API_BASE } from "@/lib/config";
|
|
|
|
interface ProviderConfig {
|
|
enabled: boolean;
|
|
api_key?: string;
|
|
base_url?: string;
|
|
model?: string;
|
|
timeout?: number;
|
|
max_retries?: number;
|
|
}
|
|
|
|
interface SmtpConfig {
|
|
enabled: boolean;
|
|
host?: string;
|
|
port: number;
|
|
username?: string;
|
|
password?: string;
|
|
from_email?: string;
|
|
use_tls: boolean;
|
|
}
|
|
|
|
interface SettingsConfig {
|
|
google: ProviderConfig;
|
|
google_cloud: ProviderConfig;
|
|
openai: ProviderConfig;
|
|
ollama: ProviderConfig;
|
|
openrouter: ProviderConfig;
|
|
openrouter_premium: ProviderConfig;
|
|
zai: ProviderConfig;
|
|
mistral: ProviderConfig;
|
|
smtp: SmtpConfig;
|
|
fallback_chain: string;
|
|
fallback_chain_classic: string;
|
|
fallback_chain_llm: string;
|
|
}
|
|
|
|
interface EnvInfo {
|
|
openai: boolean;
|
|
openrouter: boolean;
|
|
openrouter_premium: boolean;
|
|
zai: boolean;
|
|
mistral: boolean;
|
|
ollama: boolean;
|
|
google_cloud: boolean;
|
|
smtp: boolean;
|
|
}
|
|
|
|
interface OllamaModel {
|
|
name: string;
|
|
size: number;
|
|
modified_at: string;
|
|
}
|
|
|
|
const defaultConfig: SettingsConfig = {
|
|
google: { enabled: true, timeout: 30, max_retries: 3 },
|
|
google_cloud: { enabled: false, api_key: "", timeout: 30, max_retries: 3 },
|
|
openai: { enabled: false, api_key: "", timeout: 60, max_retries: 3 },
|
|
ollama: { enabled: false, base_url: "http://localhost:11434", model: "llama3" },
|
|
openrouter: { enabled: false, api_key: "", model: "deepseek/deepseek-chat" },
|
|
openrouter_premium: { enabled: false, api_key: "", model: "openai/gpt-4o-mini" },
|
|
zai: { enabled: false, api_key: "", base_url: "https://api.x.ai/v1", model: "grok-2-1212" },
|
|
mistral: { enabled: false, api_key: "", model: "mistral-ocr-latest", timeout: 180 },
|
|
smtp: { enabled: false, host: "", port: 587, username: "", password: "", from_email: "", use_tls: true },
|
|
fallback_chain: "google,google_cloud,openrouter,openrouter_premium,openai,deepseek,zai",
|
|
fallback_chain_classic: "google,google_cloud",
|
|
fallback_chain_llm: "openrouter,openrouter_premium,openai,deepseek,zai",
|
|
};
|
|
|
|
const defaultEnvInfo: EnvInfo = {
|
|
openai: false,
|
|
openrouter: false,
|
|
openrouter_premium: false,
|
|
zai: false,
|
|
mistral: false,
|
|
ollama: false,
|
|
google_cloud: false,
|
|
smtp: false,
|
|
};
|
|
|
|
export default function AdminSettingsPage() {
|
|
const [config, setConfig] = useState<SettingsConfig>(defaultConfig);
|
|
const [envInfo, setEnvInfo] = useState<EnvInfo>(defaultEnvInfo);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [testResults, setTestResults] = useState<Record<string, "ok" | "error" | "testing" | "idle">>({});
|
|
const [testMessages, setTestMessages] = useState<Record<string, string>>({});
|
|
const [ollamaModels, setOllamaModels] = useState<OllamaModel[]>([]);
|
|
const [isLoadingModels, setIsLoadingModels] = useState(false);
|
|
const [openaiModels, setOpenaiModels] = useState<ModelOption[]>([]);
|
|
const [openrouterModels, setOpenrouterModels] = useState<ModelOption[]>([]);
|
|
const [zaiModels, setZaiModels] = useState<ModelOption[]>([]);
|
|
const [loadingModelsProvider, setLoadingModelsProvider] = useState<string | null>(null);
|
|
const [isSendingTestEmail, setIsSendingTestEmail] = useState(false);
|
|
const [testEmailResult, setTestEmailResult] = useState<"idle" | "ok" | "error">("idle");
|
|
const [testEmailMessage, setTestEmailMessage] = useState<string>("");
|
|
const { success, error, info } = useNotification();
|
|
|
|
useEffect(() => {
|
|
loadConfig();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const getToken = () => useTranslationStore.getState().settings.adminToken ?? "";
|
|
|
|
const loadConfig = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const response = await fetch(`${API_BASE}/api/v1/admin/settings`, {
|
|
headers: { Authorization: `Bearer ${getToken()}` },
|
|
});
|
|
if (response.ok) {
|
|
const envelope = await response.json();
|
|
// API returns { data: {...settings...}, env_info: {...}, meta: {} }
|
|
const payload = envelope.data ?? envelope;
|
|
setConfig({ ...defaultConfig, ...payload });
|
|
if (envelope.env_info) {
|
|
setEnvInfo({ ...defaultEnvInfo, ...envelope.env_info });
|
|
}
|
|
} else {
|
|
error({ title: "Erreur de chargement", description: `HTTP ${response.status} — vérifiez votre token admin.` });
|
|
}
|
|
} catch (e) {
|
|
error({ title: "Erreur réseau", description: "Impossible de contacter le backend." });
|
|
console.error("Failed to load settings:", e);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const saveConfig = async () => {
|
|
setIsSaving(true);
|
|
try {
|
|
const response = await fetch(`${API_BASE}/api/v1/admin/settings`, {
|
|
method: "PUT",
|
|
headers: {
|
|
Authorization: `Bearer ${getToken()}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(config),
|
|
});
|
|
if (response.ok) {
|
|
success({ title: "✅ Configuration sauvegardée", description: "Les paramètres ont été enregistrés avec succès." });
|
|
} else {
|
|
const body = await response.json().catch(() => ({}));
|
|
error({ title: "Erreur de sauvegarde", description: body.detail || `HTTP ${response.status}` });
|
|
}
|
|
} catch (e) {
|
|
error({ title: "Erreur réseau", description: "Impossible de contacter le backend pour la sauvegarde." });
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
};
|
|
|
|
const testProvider = async (provider: string) => {
|
|
setTestResults((prev) => ({ ...prev, [provider]: "testing" }));
|
|
setTestMessages((prev) => ({ ...prev, [provider]: "" }));
|
|
try {
|
|
// For SMTP, send current form values so unsaved changes are tested
|
|
const smtpBody = provider === "smtp"
|
|
? { body: JSON.stringify(config.smtp) }
|
|
: {};
|
|
const response = await fetch(
|
|
`${API_BASE}/api/v1/admin/providers/${provider}/test`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${getToken()}`,
|
|
...(provider === "smtp" ? { "Content-Type": "application/json" } : {}),
|
|
},
|
|
...smtpBody,
|
|
}
|
|
);
|
|
const data = await response.json();
|
|
if (data.available) {
|
|
setTestResults((prev) => ({ ...prev, [provider]: "ok" }));
|
|
const detail = data.test_result || data.usage || data.models_count !== undefined
|
|
? `Connexion OK${data.models_count !== undefined ? ` — ${data.models_count} modèles` : ""}${data.test_result ? ` — "${data.test_result}"` : ""}`
|
|
: "Connexion OK";
|
|
setTestMessages((prev) => ({ ...prev, [provider]: detail }));
|
|
} else {
|
|
setTestResults((prev) => ({ ...prev, [provider]: "error" }));
|
|
setTestMessages((prev) => ({ ...prev, [provider]: data.error || "Échec" }));
|
|
}
|
|
} catch (e) {
|
|
setTestResults((prev) => ({ ...prev, [provider]: "error" }));
|
|
setTestMessages((prev) => ({ ...prev, [provider]: "Erreur réseau" }));
|
|
}
|
|
};
|
|
|
|
const fetchOllamaModels = async () => {
|
|
setIsLoadingModels(true);
|
|
try {
|
|
const url = config.ollama.base_url
|
|
? `${API_BASE}/api/v1/admin/providers/ollama/models?base_url=${encodeURIComponent(config.ollama.base_url)}`
|
|
: `${API_BASE}/api/v1/admin/providers/ollama/models`;
|
|
const response = await fetch(url, {
|
|
headers: { Authorization: `Bearer ${getToken()}` },
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setOllamaModels(data.data || []);
|
|
if (data.data?.length > 0 && !config.ollama.model) {
|
|
updateProvider("ollama", { model: data.data[0].name });
|
|
}
|
|
info({ title: `${data.data?.length || 0} modèles Ollama trouvés` });
|
|
} else {
|
|
error({ title: "Ollama inaccessible", description: "Vérifiez que Ollama tourne sur l'URL configurée." });
|
|
}
|
|
} catch (e) {
|
|
error({ title: "Erreur Ollama", description: "Impossible de contacter Ollama." });
|
|
} finally {
|
|
setIsLoadingModels(false);
|
|
}
|
|
};
|
|
|
|
const fetchModels = async (provider: string) => {
|
|
setLoadingModelsProvider(provider);
|
|
try {
|
|
const response = await fetch(
|
|
`${API_BASE}/api/v1/admin/providers/${provider}/models`,
|
|
{ headers: { Authorization: `Bearer ${getToken()}` } }
|
|
);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
const models: ModelOption[] = data.data || [];
|
|
if (provider === "openai") setOpenaiModels(models);
|
|
else if (provider === "openrouter") setOpenrouterModels(models);
|
|
else if (provider === "zai") setZaiModels(models);
|
|
info({ title: `${models.length} modèles ${provider} trouvés` });
|
|
} else {
|
|
const body = await response.json().catch(() => ({}));
|
|
error({ title: `Erreur ${provider}`, description: body.message || `HTTP ${response.status}` });
|
|
}
|
|
} catch (e) {
|
|
error({ title: `Erreur ${provider}`, description: "Impossible de contacter le serveur." });
|
|
} finally {
|
|
setLoadingModelsProvider(null);
|
|
}
|
|
};
|
|
|
|
type ProviderKey = keyof Omit<SettingsConfig, "fallback_chain" | "fallback_chain_classic" | "fallback_chain_llm" | "smtp">;
|
|
const updateProvider = (provider: ProviderKey, updates: Partial<ProviderConfig>) => {
|
|
setConfig((prev) => ({
|
|
...prev,
|
|
[provider]: { ...prev[provider], ...updates } as ProviderConfig,
|
|
}));
|
|
};
|
|
|
|
const updateSmtp = (updates: Partial<SmtpConfig>) => {
|
|
setConfig((prev) => ({
|
|
...prev,
|
|
smtp: { ...prev.smtp, ...updates },
|
|
}));
|
|
};
|
|
|
|
const sendTestEmail = async () => {
|
|
setIsSendingTestEmail(true);
|
|
setTestEmailResult("idle");
|
|
setTestEmailMessage("");
|
|
try {
|
|
const response = await fetch(
|
|
`${API_BASE}/api/v1/admin/providers/smtp/test-send`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${getToken()}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(config.smtp),
|
|
}
|
|
);
|
|
const data = await response.json();
|
|
if (data.available) {
|
|
setTestEmailResult("ok");
|
|
setTestEmailMessage(data.test_result || "Email envoyé avec succès");
|
|
} else {
|
|
setTestEmailResult("error");
|
|
setTestEmailMessage(data.error || "Échec de l'envoi");
|
|
}
|
|
} catch {
|
|
setTestEmailResult("error");
|
|
setTestEmailMessage("Erreur réseau");
|
|
} finally {
|
|
setIsSendingTestEmail(false);
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<Loader2 className="size-8 animate-spin text-muted-foreground" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 bg-purple-600/20 rounded-lg flex items-center justify-center">
|
|
<Settings className="w-5 h-5 text-purple-400" />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-foreground">Paramètres des providers</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
Configurez les clés API. Les clés peuvent aussi être définies dans le fichier <code>.env</code>.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-4">
|
|
<ProviderCard
|
|
title="Google Translate"
|
|
description="Accès web non officiel via deep_translator. Aucune clé requise, usage raisonnable recommandé."
|
|
enabled={config.google.enabled}
|
|
onToggle={(enabled) => updateProvider("google", { enabled })}
|
|
onTest={() => testProvider("google")}
|
|
testResult={testResults.google ?? "idle"}
|
|
testMessage={testMessages.google}
|
|
noApiKey
|
|
/>
|
|
|
|
<ProviderCard
|
|
title="Google Cloud Translation (officiel)"
|
|
description="API officielle Google Cloud. 500 000 car./mois offerts, puis ~$20/M car. Clé API sur console.cloud.google.com"
|
|
enabled={config.google_cloud.enabled}
|
|
onToggle={(enabled) => updateProvider("google_cloud", { enabled })}
|
|
onTest={() => testProvider("google_cloud")}
|
|
testResult={testResults.google_cloud ?? "idle"}
|
|
testMessage={testMessages.google_cloud}
|
|
envKeySet={envInfo.google_cloud}
|
|
>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="google-cloud-key">Clé API</Label>
|
|
<Input
|
|
id="google-cloud-key"
|
|
type="password"
|
|
placeholder={
|
|
envInfo.google_cloud
|
|
? "Clé configurée dans .env (laisser vide pour l'utiliser)"
|
|
: "AIza..."
|
|
}
|
|
value={config.google_cloud.api_key || ""}
|
|
onChange={(e) => updateProvider("google_cloud", { api_key: e.target.value })}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Activez <code>Cloud Translation API</code> dans Google Cloud Console, puis créez une clé API restreinte à cette API.
|
|
</p>
|
|
</div>
|
|
</ProviderCard>
|
|
|
|
<ProviderCard
|
|
title="OpenAI"
|
|
description="Traductions GPT-4. Obtenez une clé sur platform.openai.com"
|
|
enabled={config.openai.enabled}
|
|
onToggle={(enabled) => updateProvider("openai", { enabled })}
|
|
onTest={() => testProvider("openai")}
|
|
testResult={testResults.openai ?? "idle"}
|
|
testMessage={testMessages.openai}
|
|
envKeySet={envInfo.openai}
|
|
>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="openai-key">Clé API</Label>
|
|
<Input
|
|
id="openai-key"
|
|
type="password"
|
|
placeholder={envInfo.openai ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "sk-..."}
|
|
value={config.openai.api_key || ""}
|
|
onChange={(e) => updateProvider("openai", { api_key: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="openai-model">Modèle</Label>
|
|
<ModelCombobox
|
|
value={config.openai.model || ""}
|
|
onChange={(v) => updateProvider("openai", { model: v })}
|
|
models={openaiModels}
|
|
isLoading={loadingModelsProvider === "openai"}
|
|
onFetchModels={() => fetchModels("openai")}
|
|
providerLabel="OpenAI"
|
|
placeholder="gpt-4o"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</ProviderCard>
|
|
|
|
<ProviderCard
|
|
title="Ollama"
|
|
description="LLM local. Nécessite Ollama en cours d'exécution."
|
|
enabled={config.ollama.enabled}
|
|
onToggle={(enabled) => updateProvider("ollama", { enabled })}
|
|
onTest={() => testProvider("ollama")}
|
|
testResult={testResults.ollama ?? "idle"}
|
|
testMessage={testMessages.ollama}
|
|
envKeySet={envInfo.ollama}
|
|
>
|
|
<div className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="ollama-url">URL de base</Label>
|
|
<Input
|
|
id="ollama-url"
|
|
placeholder={envInfo.ollama ? "URL configurée dans .env" : "http://localhost:11434"}
|
|
value={config.ollama.base_url || ""}
|
|
onChange={(e) => updateProvider("ollama", { base_url: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="ollama-model">Modèle</Label>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={fetchOllamaModels}
|
|
disabled={isLoadingModels}
|
|
className="h-7 px-2 text-xs"
|
|
>
|
|
{isLoadingModels ? (
|
|
<Loader2 className="size-3 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="size-3" />
|
|
)}
|
|
<span className="ms-1">Récupérer les modèles</span>
|
|
</Button>
|
|
</div>
|
|
{ollamaModels.length > 0 ? (
|
|
<Select
|
|
value={config.ollama.model || ""}
|
|
onValueChange={(value) => updateProvider("ollama", { model: value })}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="Sélectionnez un modèle" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{ollamaModels.map((model) => (
|
|
<SelectItem key={model.name} value={model.name}>
|
|
{model.name}
|
|
{model.size > 0 && (
|
|
<span className="ms-2 text-xs text-muted-foreground">
|
|
({(model.size / 1e9).toFixed(1)} GB)
|
|
</span>
|
|
)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Input
|
|
id="ollama-model"
|
|
placeholder="llama3"
|
|
value={config.ollama.model || ""}
|
|
onChange={(e) => updateProvider("ollama", { model: e.target.value })}
|
|
/>
|
|
)}
|
|
{ollamaModels.length === 0 && (
|
|
<p className="text-xs text-muted-foreground">
|
|
Cliquez sur "Récupérer les modèles" pour charger la liste depuis Ollama.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</ProviderCard>
|
|
|
|
<ProviderCard
|
|
title="Traduction IA Essentielle"
|
|
description="Affichée aux utilisateurs comme 'Traduction IA Essentielle'. Modèles économiques recommandés : deepseek/deepseek-chat, google/gemini-2.0-flash, meta-llama/llama-3.3-70b-instruct. Clé API : openrouter.ai"
|
|
enabled={config.openrouter.enabled}
|
|
onToggle={(enabled) => updateProvider("openrouter", { enabled })}
|
|
onTest={() => testProvider("openrouter")}
|
|
testResult={testResults.openrouter ?? "idle"}
|
|
testMessage={testMessages.openrouter}
|
|
envKeySet={envInfo.openrouter}
|
|
>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="openrouter-key">Clé API OpenRouter</Label>
|
|
<Input
|
|
id="openrouter-key"
|
|
type="password"
|
|
placeholder={envInfo.openrouter ? "Clé configurée dans .env (partagée avec Premium)" : "sk-or-..."}
|
|
value={config.openrouter.api_key || ""}
|
|
onChange={(e) => updateProvider("openrouter", { api_key: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="openrouter-model">Modèle Essentiel</Label>
|
|
<ModelCombobox
|
|
value={config.openrouter.model || ""}
|
|
onChange={(v) => updateProvider("openrouter", { model: v })}
|
|
models={openrouterModels}
|
|
isLoading={loadingModelsProvider === "openrouter"}
|
|
onFetchModels={() => fetchModels("openrouter")}
|
|
providerLabel="OpenRouter"
|
|
placeholder="deepseek/deepseek-chat"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">Recommandé : <code>deepseek/deepseek-chat</code> (~€0.04/doc)</p>
|
|
</div>
|
|
</div>
|
|
</ProviderCard>
|
|
|
|
<ProviderCard
|
|
title="Traduction IA Premium"
|
|
description="Affichée aux utilisateurs comme 'Traduction IA Premium'. Modèles haute qualité : openai/gpt-4o, anthropic/claude-sonnet-4.6, google/gemini-3.5-pro. Partage la même clé OpenRouter."
|
|
enabled={config.openrouter_premium.enabled}
|
|
onToggle={(enabled) => updateProvider("openrouter_premium", { enabled })}
|
|
onTest={() => testProvider("openrouter_premium")}
|
|
testResult={testResults.openrouter_premium ?? "idle"}
|
|
testMessage={testMessages.openrouter_premium}
|
|
envKeySet={envInfo.openrouter_premium}
|
|
>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="openrouter-premium-model">Modèle Premium</Label>
|
|
<ModelCombobox
|
|
value={config.openrouter_premium.model || ""}
|
|
onChange={(v) => updateProvider("openrouter_premium", { model: v })}
|
|
models={openrouterModels}
|
|
isLoading={loadingModelsProvider === "openrouter"}
|
|
onFetchModels={() => fetchModels("openrouter")}
|
|
providerLabel="OpenRouter"
|
|
placeholder="anthropic/claude-sonnet-4.6"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Recommandé : <code>anthropic/claude-sonnet-4.6</code> (~€0.20/doc) ou <code>openai/gpt-4o</code> (~€0.30/doc)
|
|
</p>
|
|
</div>
|
|
</ProviderCard>
|
|
|
|
<ProviderCard
|
|
title="z.AI / xAI Grok"
|
|
description="Modèles Grok par xAI. API compatible OpenAI. Obtenez votre clé sur x.ai"
|
|
enabled={config.zai.enabled}
|
|
onToggle={(enabled) => updateProvider("zai", { enabled })}
|
|
onTest={() => testProvider("zai")}
|
|
testResult={testResults.zai ?? "idle"}
|
|
testMessage={testMessages.zai}
|
|
envKeySet={envInfo.zai}
|
|
>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="zai-key">Clé API</Label>
|
|
<Input
|
|
id="zai-key"
|
|
type="password"
|
|
placeholder={envInfo.zai ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "xai-..."}
|
|
value={config.zai.api_key || ""}
|
|
onChange={(e) => updateProvider("zai", { api_key: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="zai-model">Modèle</Label>
|
|
<ModelCombobox
|
|
value={config.zai.model || ""}
|
|
onChange={(v) => updateProvider("zai", { model: v })}
|
|
models={zaiModels}
|
|
isLoading={loadingModelsProvider === "zai"}
|
|
onFetchModels={() => fetchModels("zai")}
|
|
providerLabel="xAI"
|
|
placeholder="grok-2-1212"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3 space-y-2">
|
|
<Label htmlFor="zai-url">URL de base</Label>
|
|
<Input
|
|
id="zai-url"
|
|
placeholder="https://api.x.ai/v1"
|
|
value={config.zai.base_url || ""}
|
|
onChange={(e) => updateProvider("zai", { base_url: e.target.value })}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Par défaut : <code>https://api.x.ai/v1</code> — à changer uniquement si vous utilisez un proxy.
|
|
</p>
|
|
</div>
|
|
</ProviderCard>
|
|
|
|
<ProviderCard
|
|
title="OCR Mistral (PDF scannés)"
|
|
description="Extraction du texte des PDF scannés (pages image) avant traduction. Sans clé, ces fichiers sont refusés avec un message clair. ~1 € / 1 000 pages. Clé : console.mistral.ai"
|
|
enabled={config.mistral.enabled}
|
|
onToggle={(enabled) => updateProvider("mistral", { enabled })}
|
|
onTest={() => testProvider("mistral")}
|
|
testResult={testResults.mistral ?? "idle"}
|
|
testMessage={testMessages.mistral}
|
|
envKeySet={envInfo.mistral}
|
|
>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="mistral-key">Clé API Mistral</Label>
|
|
<Input
|
|
id="mistral-key"
|
|
type="password"
|
|
placeholder={envInfo.mistral ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "Clé console.mistral.ai"}
|
|
value={config.mistral.api_key || ""}
|
|
onChange={(e) => updateProvider("mistral", { api_key: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="mistral-model">Modèle OCR</Label>
|
|
<Input
|
|
id="mistral-model"
|
|
placeholder="mistral-ocr-latest"
|
|
value={config.mistral.model || ""}
|
|
onChange={(e) => updateProvider("mistral", { model: e.target.value })}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Recommandé : <code>mistral-ocr-latest</code>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</ProviderCard>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Chaîne de fallback</CardTitle>
|
|
<CardDescription>Ordre de priorité pour la sélection des providers</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label>Mode classique (Google)</Label>
|
|
<Input
|
|
value={config.fallback_chain_classic}
|
|
onChange={(e) => setConfig((prev) => ({ ...prev, fallback_chain_classic: e.target.value }))}
|
|
placeholder="google,google_cloud"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>Mode LLM (Ollama/OpenAI)</Label>
|
|
<Input
|
|
value={config.fallback_chain_llm}
|
|
onChange={(e) => setConfig((prev) => ({ ...prev, fallback_chain_llm: e.target.value }))}
|
|
placeholder="openrouter,openai,deepseek,zai"
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className={config.smtp.enabled ? "border-primary/30 overflow-visible" : "overflow-visible"}>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 bg-blue-600/20 rounded-md flex items-center justify-center">
|
|
<Mail className="w-4 h-4 text-blue-400" />
|
|
</div>
|
|
<CardTitle className="text-base">Email SMTP</CardTitle>
|
|
<Badge variant={config.smtp.enabled ? "default" : "secondary"} className="text-xs">
|
|
{config.smtp.enabled ? "Activé" : "Désactivé"}
|
|
</Badge>
|
|
{envInfo.smtp && (
|
|
<Badge variant="outline" className="text-xs gap-1 border-green-500/40 text-green-400">
|
|
<KeyRound className="size-3" />
|
|
Config dans .env
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => testProvider("smtp")}
|
|
disabled={testResults.smtp === "testing"}
|
|
className="h-8"
|
|
>
|
|
{testResults.smtp === "testing" ? (
|
|
<><Loader2 className="size-3 animate-spin me-1" />Test...</>
|
|
) : testResults.smtp === "ok" ? (
|
|
<><CheckCircle className="size-3 text-green-500 me-1" />OK</>
|
|
) : testResults.smtp === "error" ? (
|
|
<><XCircle className="size-3 text-red-500 me-1" />Erreur</>
|
|
) : (
|
|
<><FlaskConical className="size-3 me-1" />Tester</>
|
|
)}
|
|
</Button>
|
|
<Switch checked={config.smtp.enabled} onCheckedChange={(enabled) => updateSmtp({ enabled })} />
|
|
</div>
|
|
</div>
|
|
<CardDescription>
|
|
Configuration du serveur SMTP pour l'envoi d'emails (mot de passe oublié, notifications, etc.)
|
|
</CardDescription>
|
|
{testMessages.smtp && (
|
|
<p className={`text-xs mt-1 ${testResults.smtp === "ok" ? "text-green-400" : "text-red-400"}`}>
|
|
{testMessages.smtp}
|
|
</p>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent className="pt-0 space-y-4">
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="smtp-host">Hôte SMTP</Label>
|
|
<Input
|
|
id="smtp-host"
|
|
placeholder={envInfo.smtp ? "Configuré dans .env" : "smtp.example.com"}
|
|
value={config.smtp.host || ""}
|
|
onChange={(e) => updateSmtp({ host: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="smtp-port">Port</Label>
|
|
<Input
|
|
id="smtp-port"
|
|
type="number"
|
|
placeholder="587"
|
|
value={config.smtp.port}
|
|
onChange={(e) => updateSmtp({ port: parseInt(e.target.value) || 587 })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="smtp-username">Nom d'utilisateur</Label>
|
|
<Input
|
|
id="smtp-username"
|
|
placeholder={envInfo.smtp ? "Configuré dans .env" : "user@example.com"}
|
|
value={config.smtp.username || ""}
|
|
onChange={(e) => updateSmtp({ username: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="smtp-password">Mot de passe</Label>
|
|
<Input
|
|
id="smtp-password"
|
|
type="password"
|
|
placeholder="••••••••"
|
|
value={config.smtp.password || ""}
|
|
onChange={(e) => updateSmtp({ password: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="smtp-from">Adresse d'expédition</Label>
|
|
<Input
|
|
id="smtp-from"
|
|
type="email"
|
|
placeholder={envInfo.smtp ? "Configuré dans .env" : "noreply@example.com"}
|
|
value={config.smtp.from_email || ""}
|
|
onChange={(e) => updateSmtp({ from_email: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-3 pt-6">
|
|
<Switch
|
|
checked={config.smtp.use_tls}
|
|
onCheckedChange={(checked) => updateSmtp({ use_tls: checked })}
|
|
/>
|
|
<Label>Utiliser TLS</Label>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3 pt-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={sendTestEmail}
|
|
disabled={isSendingTestEmail || !config.smtp.enabled}
|
|
className="h-8"
|
|
>
|
|
{isSendingTestEmail ? (
|
|
<><Loader2 className="size-3 animate-spin me-1" />Envoi...</>
|
|
) : testEmailResult === "ok" ? (
|
|
<><CheckCircle className="size-3 text-green-500 me-1" />Envoyé</>
|
|
) : testEmailResult === "error" ? (
|
|
<><XCircle className="size-3 text-red-500 me-1" />Échec</>
|
|
) : (
|
|
<><Mail className="size-3 me-1" />Envoyer un email de test</>
|
|
)}
|
|
</Button>
|
|
{testEmailMessage && (
|
|
<p className={`text-xs ${testEmailResult === "ok" ? "text-green-400" : "text-red-400"}`}>
|
|
{testEmailMessage}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<Button onClick={saveConfig} disabled={isSaving} size="lg">
|
|
{isSaving ? (
|
|
<>
|
|
<Loader2 className="me-2 size-4 animate-spin" />
|
|
Sauvegarde...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Save className="me-2 size-4" />
|
|
Sauvegarder la configuration
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProviderCard({
|
|
title,
|
|
description,
|
|
enabled,
|
|
onToggle,
|
|
onTest,
|
|
testResult,
|
|
testMessage,
|
|
noApiKey = false,
|
|
envKeySet = false,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
description: string;
|
|
enabled: boolean;
|
|
onToggle: (enabled: boolean) => void;
|
|
onTest: () => void;
|
|
testResult: "ok" | "error" | "testing" | "idle";
|
|
testMessage?: string;
|
|
noApiKey?: boolean;
|
|
envKeySet?: boolean;
|
|
children?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<Card className={enabled ? "border-primary/30 overflow-visible" : "overflow-visible"}>
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<CardTitle className="text-base">{title}</CardTitle>
|
|
<Badge variant={enabled ? "default" : "secondary"} className="text-xs">
|
|
{enabled ? "Activé" : "Désactivé"}
|
|
</Badge>
|
|
{envKeySet && !noApiKey && (
|
|
<Badge variant="outline" className="text-xs gap-1 border-green-500/40 text-green-400">
|
|
<KeyRound className="size-3" />
|
|
Clé dans .env
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={onTest}
|
|
disabled={testResult === "testing"}
|
|
className="h-8"
|
|
>
|
|
{testResult === "testing" ? (
|
|
<><Loader2 className="size-3 animate-spin me-1" />Test...</>
|
|
) : testResult === "ok" ? (
|
|
<><CheckCircle className="size-3 text-green-500 me-1" />OK</>
|
|
) : testResult === "error" ? (
|
|
<><XCircle className="size-3 text-red-500 me-1" />Erreur</>
|
|
) : (
|
|
<><FlaskConical className="size-3 me-1" />Tester</>
|
|
)}
|
|
</Button>
|
|
<Switch checked={enabled} onCheckedChange={onToggle} />
|
|
</div>
|
|
</div>
|
|
<CardDescription>{description}</CardDescription>
|
|
{testMessage && (
|
|
<p className={`text-xs mt-1 ${testResult === "ok" ? "text-green-400" : "text-red-400"}`}>
|
|
{testMessage}
|
|
</p>
|
|
)}
|
|
</CardHeader>
|
|
{!noApiKey && children && <CardContent className="pt-0">{children}</CardContent>}
|
|
</Card>
|
|
);
|
|
}
|