feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle
Made-with: Cursor
This commit is contained in:
574
frontend/src/app/admin/settings/page.tsx
Normal file
574
frontend/src/app/admin/settings/page.tsx
Normal 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 "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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user