feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle

Made-with: Cursor
This commit is contained in:
Sepehr Ramezani
2026-03-07 11:42:58 +01:00
parent 3d37ce4582
commit 473b3e26c7
181 changed files with 30617 additions and 7170 deletions

View File

@@ -0,0 +1,574 @@
"use client";
import { useState, useEffect } from "react";
import { Settings, Save, Loader2, CheckCircle, XCircle, RefreshCw, FlaskConical, KeyRound } 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 { 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 SettingsConfig {
google: ProviderConfig;
deepl: ProviderConfig;
openai: ProviderConfig;
ollama: ProviderConfig;
openrouter: ProviderConfig;
openrouter_premium: ProviderConfig;
zai: ProviderConfig;
fallback_chain: string;
fallback_chain_classic: string;
fallback_chain_llm: string;
}
interface EnvInfo {
deepl: boolean;
openai: boolean;
openrouter: boolean;
openrouter_premium: boolean;
zai: boolean;
ollama: boolean;
}
interface OllamaModel {
name: string;
size: number;
modified_at: string;
}
const defaultConfig: SettingsConfig = {
google: { enabled: true, timeout: 30, max_retries: 3 },
deepl: { 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" },
fallback_chain: "google,deepl,openai,ollama,openrouter,openrouter_premium,zai",
fallback_chain_classic: "google,deepl",
fallback_chain_llm: "ollama,openai,openrouter,zai",
};
const defaultEnvInfo: EnvInfo = {
deepl: false,
openai: false,
openrouter: false,
openrouter_premium: false,
zai: false,
ollama: 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 { 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 {
const response = await fetch(
`${API_BASE}/api/v1/admin/providers/${provider}/test`,
{
method: "POST",
headers: { Authorization: `Bearer ${getToken()}` },
}
);
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 response = await fetch(
`${API_BASE}/api/v1/admin/providers/ollama/models`,
{ 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);
}
};
type ProviderKey = keyof Omit<SettingsConfig, "fallback_chain" | "fallback_chain_classic" | "fallback_chain_llm">;
const updateProvider = (provider: ProviderKey, updates: Partial<ProviderConfig>) => {
setConfig((prev) => ({
...prev,
[provider]: { ...prev[provider], ...updates } as ProviderConfig,
}));
};
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="Tier gratuit : 500 000 caractères/mois. Aucune clé requise."
enabled={config.google.enabled}
onToggle={(enabled) => updateProvider("google", { enabled })}
onTest={() => testProvider("google")}
testResult={testResults.google ?? "idle"}
testMessage={testMessages.google}
noApiKey
/>
<ProviderCard
title="DeepL"
description="Traduction professionnelle. Obtenez une clé sur deepl.com/pro-api"
enabled={config.deepl.enabled}
onToggle={(enabled) => updateProvider("deepl", { enabled })}
onTest={() => testProvider("deepl")}
testResult={testResults.deepl ?? "idle"}
testMessage={testMessages.deepl}
envKeySet={envInfo.deepl}
>
<div className="space-y-2">
<Label htmlFor="deepl-key">Clé API</Label>
<Input
id="deepl-key"
type="password"
placeholder={envInfo.deepl ? "Clé configurée dans .env (laisser vide pour l'utiliser)" : "Entrez votre clé DeepL"}
value={config.deepl.api_key || ""}
onChange={(e) => updateProvider("deepl", { api_key: e.target.value })}
/>
</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="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>
</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="ml-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="ml-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 &quot;Récupérer les modèles&quot; 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>
<Input
id="openrouter-model"
placeholder="deepseek/deepseek-chat"
value={config.openrouter.model || ""}
onChange={(e) => updateProvider("openrouter", { model: e.target.value })}
/>
<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-3.5-sonnet, google/gemini-1.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>
<Input
id="openrouter-premium-model"
placeholder="openai/gpt-4o-mini"
value={config.openrouter_premium.model || ""}
onChange={(e) => updateProvider("openrouter_premium", { model: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Recommandé : <code>openai/gpt-4o-mini</code> (~0.15/doc) ou <code>anthropic/claude-3.5-haiku</code> (~0.20/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>
<Input
id="zai-model"
placeholder="grok-2-1212"
value={config.zai.model || ""}
onChange={(e) => updateProvider("zai", { model: e.target.value })}
/>
</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>
<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/DeepL)</Label>
<Input
value={config.fallback_chain_classic}
onChange={(e) => setConfig((prev) => ({ ...prev, fallback_chain_classic: e.target.value }))}
placeholder="google,deepl"
/>
</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="ollama,openai"
/>
</div>
</CardContent>
</Card>
</div>
<div className="flex justify-end">
<Button onClick={saveConfig} disabled={isSaving} size="lg">
{isSaving ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Sauvegarde...
</>
) : (
<>
<Save className="mr-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" : ""}>
<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 mr-1" />Test...</>
) : testResult === "ok" ? (
<><CheckCircle className="size-3 text-green-500 mr-1" />OK</>
) : testResult === "error" ? (
<><XCircle className="size-3 text-red-500 mr-1" />Erreur</>
) : (
<><FlaskConical className="size-3 mr-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>
);
}