perf: memo GridCard, fuse save fns, fix slash tab active color
This commit is contained in:
@@ -4,7 +4,7 @@ import { useState } from 'react'
|
||||
import { updateAISettings } from '@/app/actions/ai-settings'
|
||||
import { DemoModeToggle } from '@/components/demo-mode-toggle'
|
||||
import { toast } from 'sonner'
|
||||
import { Loader2, Sparkles, Brain, Languages, Tag, History, Wand2 } from 'lucide-react'
|
||||
import { Loader2, Sparkles, Brain, Languages, Tag, History, Wand2, ImageIcon } from 'lucide-react'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { motion } from 'motion/react'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -23,6 +23,7 @@ interface AISettingsPanelProps {
|
||||
autoLabeling: boolean
|
||||
noteHistory: boolean
|
||||
noteHistoryMode: 'manual' | 'auto'
|
||||
svgComplexity?: 'simple' | 'illustrated' | 'rich'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +74,20 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSvgComplexityChange = async (value: 'simple' | 'illustrated' | 'rich') => {
|
||||
setSettings(prev => ({ ...prev, svgComplexity: value }))
|
||||
try {
|
||||
setIsPending(true)
|
||||
await updateAISettings({ svgComplexity: value })
|
||||
toast.success(t('aiSettings.saved'))
|
||||
} catch {
|
||||
toast.error(t('aiSettings.error'))
|
||||
setSettings(initialSettings)
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDemoModeToggle = async (enabled: boolean) => {
|
||||
setSettings(prev => ({ ...prev, demoMode: enabled }))
|
||||
try {
|
||||
@@ -255,6 +270,117 @@ export function AISettingsPanel({ initialSettings }: AISettingsPanelProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SVG Complexity selector */}
|
||||
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-paper dark:bg-brand-accent/10 rounded-2xl border border-brand-accent/20 text-brand-accent">
|
||||
<ImageIcon size={18} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-[13px] font-bold text-ink">{t('aiSettings.svgComplexity')}</h4>
|
||||
<p className="text-[10px] text-concrete leading-relaxed">{t('aiSettings.svgComplexityDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
{([
|
||||
{
|
||||
value: 'simple' as const,
|
||||
label: t('aiSettings.svgComplexitySimple'),
|
||||
desc: t('aiSettings.svgComplexitySimpleDesc'),
|
||||
preview: (
|
||||
<svg viewBox="0 0 80 60" className="w-full h-full">
|
||||
<rect width="80" height="60" fill="#F5F0E8" rx="4"/>
|
||||
<circle cx="40" cy="28" r="14" fill="none" stroke="#C4A882" strokeWidth="2.5"/>
|
||||
<line x1="40" y1="14" x2="40" y2="42" stroke="#C4A882" strokeWidth="2"/>
|
||||
<line x1="26" y1="28" x2="54" y2="28" stroke="#C4A882" strokeWidth="2"/>
|
||||
<circle cx="40" cy="28" r="3" fill="#8B4513"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'illustrated' as const,
|
||||
label: t('aiSettings.svgComplexityIllustrated'),
|
||||
desc: t('aiSettings.svgComplexityIllustratedDesc'),
|
||||
preview: (
|
||||
<svg viewBox="0 0 80 60" className="w-full h-full">
|
||||
<defs>
|
||||
<linearGradient id="bg-grad" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#1a1a2e"/>
|
||||
<stop offset="100%" stopColor="#16213e"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="50%" cy="40%">
|
||||
<stop offset="0%" stopColor="#5F9EA0" stopOpacity="0.6"/>
|
||||
<stop offset="100%" stopColor="#5F9EA0" stopOpacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="80" height="60" fill="url(#bg-grad)" rx="4"/>
|
||||
<ellipse cx="40" cy="24" rx="28" ry="18" fill="url(#glow)"/>
|
||||
<rect x="18" y="30" width="44" height="22" fill="#2a2a4a" rx="3"/>
|
||||
<rect x="24" y="35" width="8" height="12" fill="#5F9EA0" rx="2" opacity="0.8"/>
|
||||
<rect x="36" y="32" width="8" height="15" fill="#C4A882" rx="2" opacity="0.8"/>
|
||||
<rect x="48" y="37" width="8" height="10" fill="#C9A9A6" rx="2" opacity="0.8"/>
|
||||
<circle cx="40" cy="16" r="8" fill="none" stroke="#5F9EA0" strokeWidth="1.5" opacity="0.7"/>
|
||||
<circle cx="40" cy="16" r="3" fill="#5F9EA0" opacity="0.9"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: 'rich' as const,
|
||||
label: t('aiSettings.svgComplexityRich'),
|
||||
desc: t('aiSettings.svgComplexityRichDesc'),
|
||||
preview: (
|
||||
<svg viewBox="0 0 80 60" className="w-full h-full">
|
||||
<rect width="80" height="60" fill="#0F172A" rx="4"/>
|
||||
<circle cx="40" cy="30" r="8" fill="#5F9EA0" opacity="0.9"/>
|
||||
<circle cx="16" cy="18" r="6" fill="#C4A882" opacity="0.8"/>
|
||||
<circle cx="64" cy="18" r="6" fill="#C9A9A6" opacity="0.8"/>
|
||||
<circle cx="16" cy="42" r="6" fill="#A8B5A0" opacity="0.8"/>
|
||||
<circle cx="64" cy="42" r="6" fill="#8B4513" opacity="0.8"/>
|
||||
<line x1="40" y1="30" x2="16" y2="18" stroke="#5F9EA0" strokeWidth="1" opacity="0.5"/>
|
||||
<line x1="40" y1="30" x2="64" y2="18" stroke="#C4A882" strokeWidth="1" opacity="0.5"/>
|
||||
<line x1="40" y1="30" x2="16" y2="42" stroke="#A8B5A0" strokeWidth="1" opacity="0.5"/>
|
||||
<line x1="40" y1="30" x2="64" y2="42" stroke="#C9A9A6" strokeWidth="1" opacity="0.5"/>
|
||||
<text x="40" y="33" textAnchor="middle" fontSize="4" fill="white" fontFamily="sans-serif">MAIN</text>
|
||||
<text x="16" y="21" textAnchor="middle" fontSize="3.5" fill="white" fontFamily="sans-serif">idea 1</text>
|
||||
<text x="64" y="21" textAnchor="middle" fontSize="3.5" fill="white" fontFamily="sans-serif">idea 2</text>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
] as const).map((opt) => {
|
||||
const isActive = (settings.svgComplexity ?? 'simple') === opt.value
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => handleSvgComplexityChange(opt.value)}
|
||||
className={cn(
|
||||
'relative flex flex-col rounded-2xl border-2 overflow-hidden transition-all duration-200 text-left cursor-pointer',
|
||||
isActive
|
||||
? 'border-brand-accent shadow-md shadow-brand-accent/10'
|
||||
: 'border-border hover:border-brand-accent/40 hover:shadow-sm',
|
||||
)}
|
||||
>
|
||||
<div className="aspect-[4/3] w-full bg-muted/30">
|
||||
{opt.preview}
|
||||
</div>
|
||||
<div className={cn(
|
||||
'p-3 space-y-0.5 transition-colors',
|
||||
isActive ? 'bg-brand-accent/5' : 'bg-transparent',
|
||||
)}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] font-bold text-ink">{opt.label}</span>
|
||||
{isActive && (
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-brand-accent"/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[9px] text-concrete leading-relaxed">{opt.desc}</p>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DemoModeToggle demoMode={settings.demoMode} onToggle={handleDemoModeToggle} />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { KeyRound, Loader2, Shield, Trash2 } from 'lucide-react'
|
||||
import { KeyRound, Loader2, Shield, Trash2, CheckCircle2, XCircle, Zap, FlaskConical, Pencil, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -16,154 +17,359 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type PublicKey = {
|
||||
// ─── Provider display info ────────────────────────────────────────────────────
|
||||
const PROVIDER_INFO: Record<string, { name: string; hint: string }> = {
|
||||
openai: { name: 'OpenAI', hint: 'GPT-4o, GPT-4' },
|
||||
anthropic: { name: 'Anthropic', hint: 'Claude 3.5 Sonnet, Haiku' },
|
||||
minimax: { name: 'MiniMax', hint: 'MiniMax-M2.7, M2.5' },
|
||||
google: { name: 'Google AI', hint: 'Gemini 1.5 Flash, Pro' },
|
||||
deepseek: { name: 'DeepSeek', hint: 'DeepSeek Chat, Reasoner' },
|
||||
openrouter: { name: 'OpenRouter', hint: 'Accès multi-fournisseurs' },
|
||||
mistral: { name: 'Mistral AI', hint: 'Mistral Small, Large' },
|
||||
glm: { name: 'GLM (Zhipu)', hint: 'GLM-4, GLM-4-Flash' },
|
||||
zai: { name: 'Zuki Journey', hint: 'Proxy OpenAI/Anthropic' },
|
||||
anthropic_custom: { name: 'Anthropic (custom)', hint: 'Proxy compatible Anthropic' },
|
||||
custom_openai: { name: 'Compatible OpenAI', hint: 'Tout proxy compatible OpenAI' },
|
||||
custom_anthropic: { name: 'Compatible Anthropic', hint: 'Tout proxy compatible Anthropic' },
|
||||
custom: { name: 'API personnalisée', hint: 'Votre propre endpoint' },
|
||||
}
|
||||
|
||||
function displayName(provider: string): string {
|
||||
return PROVIDER_INFO[provider]?.name ?? provider
|
||||
}
|
||||
|
||||
const MANUAL_MODEL_PROVIDERS = new Set(['custom'])
|
||||
const NEEDS_BASE_URL = new Set(['custom', 'custom_openai', 'custom_anthropic'])
|
||||
|
||||
function getDefaultBaseUrl(p: string): string {
|
||||
if (p === 'custom_openai') return 'https://api.openai.com/v1'
|
||||
if (p === 'custom_anthropic') return 'https://api.anthropic.com/v1'
|
||||
return ''
|
||||
}
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
type SavedKey = {
|
||||
provider: string
|
||||
alias: string
|
||||
alias: string | null
|
||||
model: string | null
|
||||
baseUrl: string | null
|
||||
isActive: boolean
|
||||
lastUsedAt: string | null
|
||||
}
|
||||
|
||||
async function fetchByokKeys(): Promise<{
|
||||
keys: PublicKey[]
|
||||
allowedProviders: string[]
|
||||
providerModels: Record<string, string[]>
|
||||
}> {
|
||||
async function loadKeys(): Promise<{ keys: SavedKey[]; allowedProviders: string[] }> {
|
||||
const res = await fetch('/api/user/api-keys')
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body.message || body.error || 'Failed to load keys')
|
||||
}
|
||||
if (!res.ok) throw new Error('Erreur de chargement')
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function providerLabel(t: (key: string) => string, provider: string): string {
|
||||
const key = `byokSettings.providers.${provider}`
|
||||
const translated = t(key)
|
||||
return translated === key ? provider : translated
|
||||
async function fetchModelsFromServer(provider: string, key?: string, baseUrl?: string): Promise<string[]> {
|
||||
const q = new URLSearchParams({ provider })
|
||||
if (key) q.set('key', key)
|
||||
if (baseUrl) q.set('baseUrl', baseUrl)
|
||||
const res = await fetch(`/api/user/api-keys/live-models?${q}`)
|
||||
if (!res.ok) return []
|
||||
const body = await res.json()
|
||||
return body.success && Array.isArray(body.models) ? body.models : []
|
||||
}
|
||||
|
||||
// ─── Inline edit form for a saved key ────────────────────────────────────────
|
||||
function EditKeyForm({
|
||||
savedKey,
|
||||
onDone,
|
||||
onInvalidate,
|
||||
}: {
|
||||
savedKey: SavedKey
|
||||
onDone: () => void
|
||||
onInvalidate: () => void
|
||||
}) {
|
||||
const [alias, setAlias] = useState(savedKey.alias ?? '')
|
||||
const [model, setModel] = useState(savedKey.model ?? '')
|
||||
const [baseUrl, setBaseUrl] = useState(savedKey.baseUrl ?? getDefaultBaseUrl(savedKey.provider))
|
||||
const [newKey, setNewKey] = useState('') // empty = keep existing key
|
||||
const [models, setModels] = useState<string[]>([])
|
||||
const [loadingModels, setLoadingModels] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; latency?: number; reply?: string; error?: string } | null>(null)
|
||||
|
||||
const needsUrl = NEEDS_BASE_URL.has(savedKey.provider)
|
||||
const manualModel = MANUAL_MODEL_PROVIDERS.has(savedKey.provider)
|
||||
|
||||
// Load models on mount
|
||||
useState(() => {
|
||||
if (manualModel) return
|
||||
fetchModelsFromServer(savedKey.provider, undefined, baseUrl || undefined).then((list) => {
|
||||
setModels(list)
|
||||
if (list.length > 0 && !model) setModel(list[0])
|
||||
})
|
||||
})
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload: Record<string, string | undefined> = {
|
||||
model: model || undefined,
|
||||
alias: alias.trim() || undefined,
|
||||
}
|
||||
if (needsUrl) payload.baseUrl = baseUrl
|
||||
if (newKey.length >= 8) payload.apiKey = newKey
|
||||
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(savedKey.provider)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec')
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(`${displayName(savedKey.provider)} mis à jour ✓`)
|
||||
onInvalidate()
|
||||
onDone()
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
})
|
||||
|
||||
async function testModel() {
|
||||
const keyToUse = newKey.length >= 8 ? newKey : ''
|
||||
if (!keyToUse && !model) return
|
||||
if (!keyToUse) {
|
||||
toast.info('Pour tester, entrez la clé API dans le champ "Nouvelle clé"')
|
||||
return
|
||||
}
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
const q = new URLSearchParams({ provider: savedKey.provider, key: keyToUse, model })
|
||||
if (needsUrl && baseUrl) q.append('baseUrl', baseUrl)
|
||||
const res = await fetch(`/api/user/api-keys/test-model?${q}`)
|
||||
setTestResult(await res.json())
|
||||
} catch {
|
||||
setTestResult({ ok: false, error: 'Impossible de contacter le serveur' })
|
||||
} finally {
|
||||
setTesting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showModelDropdown = !manualModel && models.length > 0
|
||||
const showModelInput = manualModel || (!loadingModels && models.length === 0)
|
||||
|
||||
return (
|
||||
<div className="mt-2 border border-violet-500/20 rounded-xl bg-violet-500/5 p-4 space-y-3">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-violet-600 dark:text-violet-400">
|
||||
Modifier — {displayName(savedKey.provider)}
|
||||
</p>
|
||||
|
||||
{/* Alias */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">Alias</Label>
|
||||
<Input id={`edit-alias-${savedKey.provider}`} value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="ex. Ma clé pro" />
|
||||
</div>
|
||||
{needsUrl && (
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`edit-baseurl-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">URL de l'API</Label>
|
||||
<Input id={`edit-baseurl-${savedKey.provider}`} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`edit-model-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">Modèle</Label>
|
||||
{showModelDropdown ? (
|
||||
<Select value={model} onValueChange={setModel}>
|
||||
<SelectTrigger id={`edit-model-${savedKey.provider}`}><SelectValue placeholder="Choisir..." /></SelectTrigger>
|
||||
<SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
) : showModelInput ? (
|
||||
<Input id={`edit-model-${savedKey.provider}`} value={model} onChange={(e) => setModel(e.target.value)} placeholder="ex. MiniMax-M2.7" />
|
||||
) : <div className="flex items-center gap-2 text-xs text-concrete py-1"><Loader2 className="h-3 w-3 animate-spin" />Chargement...</div>}
|
||||
</div>
|
||||
|
||||
{/* Key rotation (optional) */}
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`edit-key-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">
|
||||
Nouvelle clé API <span className="normal-case font-normal text-concrete">(laisser vide pour conserver l'actuelle)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id={`edit-key-${savedKey.provider}`}
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={newKey}
|
||||
onChange={(e) => { setNewKey(e.target.value); setTestResult(null) }}
|
||||
placeholder="sk-... (optionnel)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<div className={cn(
|
||||
'flex items-start gap-2 rounded-lg px-3 py-2 text-[10px] border',
|
||||
testResult.ok ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-700 dark:text-emerald-400' : 'bg-rose-500/10 border-rose-500/20 text-rose-700 dark:text-rose-400'
|
||||
)}>
|
||||
{testResult.ok ? <CheckCircle2 size={12} className="shrink-0 mt-0.5" /> : <XCircle size={12} className="shrink-0 mt-0.5" />}
|
||||
<span>
|
||||
{testResult.ok
|
||||
? `✓ Opérationnel${testResult.latency ? ` (${testResult.latency}ms)` : ''}${testResult.reply ? ` · ${testResult.reply}` : ''}`
|
||||
: testResult.error ?? 'Échec'
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!newKey || newKey.length < 8 || testing}
|
||||
onClick={testModel}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.1em] border border-border bg-white dark:bg-white/5 hover:border-violet-400 transition-colors disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
{testing ? <Loader2 size={11} className="animate-spin" /> : <FlaskConical size={11} />}
|
||||
Tester
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
className="flex-1 py-2 rounded-lg text-[9px] font-bold uppercase tracking-[0.12em] bg-ink text-paper shadow hover:scale-[1.01] active:scale-[0.99] transition-all disabled:opacity-40"
|
||||
>
|
||||
{saveMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Enregistrer les modifications'}
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDone}
|
||||
className="h-9 w-9 rounded-lg flex items-center justify-center border border-border text-concrete hover:text-ink hover:bg-muted transition-colors"
|
||||
>
|
||||
<X size={13} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
export function ByokSettingsPanel() {
|
||||
const { t } = useLanguage()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [provider, setProvider] = useState('')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [alias, setAlias] = useState('')
|
||||
const [baseUrl, setBaseUrl] = useState('')
|
||||
const [model, setModel] = useState('')
|
||||
const [customModel, setCustomModel] = useState('')
|
||||
const [isCustomModel, setIsCustomModel] = useState(false)
|
||||
const [models, setModels] = useState<string[]>([])
|
||||
const [loadingModels, setLoadingModels] = useState(false)
|
||||
const [keyOk, setKeyOk] = useState(false)
|
||||
const [verifying, setVerifying] = useState(false)
|
||||
const [editingProvider, setEditingProvider] = useState<string | null>(null)
|
||||
|
||||
// Dynamic models fetched directly via user's API Key
|
||||
const [liveModels, setLiveModels] = useState<string[]>([])
|
||||
const [isFetchingLiveModels, setIsFetchingLiveModels] = useState(false)
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; latency?: number; reply?: string; error?: string } | null>(null)
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['user', 'api-keys'],
|
||||
queryFn: fetchByokKeys,
|
||||
queryFn: loadKeys,
|
||||
})
|
||||
|
||||
const providerModels = data?.providerModels ?? {}
|
||||
|
||||
const handleProviderChange = (p: string) => {
|
||||
setProvider(p)
|
||||
setApiKey('')
|
||||
setLiveModels([])
|
||||
setIsCustomModel(false)
|
||||
setModel('')
|
||||
setCustomModel('')
|
||||
}
|
||||
|
||||
// Triggered dynamically to fetch models when user enters/pastes their API key
|
||||
const fetchLiveModels = async (p: string, key: string) => {
|
||||
if (!p || !key || key.length < 8) return;
|
||||
setIsFetchingLiveModels(true)
|
||||
try {
|
||||
const query = new URLSearchParams({ provider: p, key })
|
||||
const res = await fetch(`/api/user/api-keys/live-models?${query.toString()}`)
|
||||
if (res.ok) {
|
||||
const body = await res.json()
|
||||
if (body.success && Array.isArray(body.models)) {
|
||||
setLiveModels(body.models)
|
||||
if (body.models.length > 0) {
|
||||
setModel(body.models[0])
|
||||
setIsCustomModel(false)
|
||||
} else {
|
||||
setIsCustomModel(true)
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[fetchLiveModels] Failed:', err)
|
||||
} finally {
|
||||
setIsFetchingLiveModels(false)
|
||||
}
|
||||
|
||||
// Fallback if request fails
|
||||
const fallbackList = providerModels[p] || []
|
||||
setLiveModels(fallbackList)
|
||||
if (fallbackList.length > 0) {
|
||||
setModel(fallbackList[0])
|
||||
setIsCustomModel(false)
|
||||
} else {
|
||||
setIsCustomModel(true)
|
||||
}
|
||||
}
|
||||
|
||||
const invalidate = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['user', 'api-keys'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['usage', 'current'] })
|
||||
}, [queryClient])
|
||||
|
||||
async function onProviderChange(p: string) {
|
||||
setProvider(p)
|
||||
setApiKey('')
|
||||
setBaseUrl(getDefaultBaseUrl(p))
|
||||
setKeyOk(false)
|
||||
setTestResult(null)
|
||||
setModel('')
|
||||
setModels([])
|
||||
if (MANUAL_MODEL_PROVIDERS.has(p)) return
|
||||
setLoadingModels(true)
|
||||
try {
|
||||
const list = await fetchModelsFromServer(p)
|
||||
setModels(list)
|
||||
if (list.length > 0) setModel(list[0])
|
||||
} finally { setLoadingModels(false) }
|
||||
}
|
||||
|
||||
async function refreshModels(p: string, key: string, _baseUrl?: string) {
|
||||
if (!p || key.length < 8 || MANUAL_MODEL_PROVIDERS.has(p)) return
|
||||
const effectiveBaseUrl = _baseUrl ?? (NEEDS_BASE_URL.has(p) ? baseUrl : undefined)
|
||||
setLoadingModels(true)
|
||||
try {
|
||||
const list = await fetchModelsFromServer(p, key, effectiveBaseUrl)
|
||||
if (list.length > 0) { setModels(list); if (!model) setModel(list[0]) }
|
||||
} finally { setLoadingModels(false) }
|
||||
}
|
||||
|
||||
async function verifyKey() {
|
||||
if (!provider || apiKey.length < 8) return
|
||||
if (NEEDS_BASE_URL.has(provider) && !baseUrl) { toast.error("Veuillez renseigner l'URL de l'API"); return }
|
||||
setVerifying(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
const q = new URLSearchParams({ provider, key: apiKey })
|
||||
if (NEEDS_BASE_URL.has(provider) && baseUrl) q.append('baseUrl', baseUrl)
|
||||
const res = await fetch(`/api/user/api-keys/verify?${q}`)
|
||||
const body = await res.json()
|
||||
if (res.ok && body.valid) {
|
||||
setKeyOk(true)
|
||||
if (Array.isArray(body.models) && body.models.length > 0) { setModels(body.models); if (!model) setModel(body.models[0]) }
|
||||
toast.success('Clé API valide ✓')
|
||||
} else {
|
||||
setKeyOk(false)
|
||||
toast.error(body.message ?? 'Clé API invalide')
|
||||
}
|
||||
} catch { setKeyOk(false); toast.error('Erreur de vérification') }
|
||||
finally { setVerifying(false) }
|
||||
}
|
||||
|
||||
async function testModel() {
|
||||
if (!provider || apiKey.length < 8 || !model) return
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
try {
|
||||
const q = new URLSearchParams({ provider, key: apiKey, model })
|
||||
if (NEEDS_BASE_URL.has(provider) && baseUrl) q.append('baseUrl', baseUrl)
|
||||
const res = await fetch(`/api/user/api-keys/test-model?${q}`)
|
||||
setTestResult(await res.json())
|
||||
} catch { setTestResult({ ok: false, error: 'Impossible de contacter le serveur' }) }
|
||||
finally { setTesting(false) }
|
||||
}
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const resolvedModel = isCustomModel ? customModel : model
|
||||
const payload: Record<string, string | undefined> = {
|
||||
provider, apiKey,
|
||||
model: model || undefined,
|
||||
alias: alias.trim() || undefined,
|
||||
}
|
||||
if (NEEDS_BASE_URL.has(provider)) payload.baseUrl = baseUrl
|
||||
const res = await fetch('/api/user/api-keys', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
provider,
|
||||
apiKey,
|
||||
alias: alias || undefined,
|
||||
model: resolvedModel || undefined,
|
||||
}),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
throw new Error(body.message || body.error || 'Save failed')
|
||||
}
|
||||
return body
|
||||
if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec')
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('byokSettings.saved'))
|
||||
setApiKey('')
|
||||
setAlias('')
|
||||
setProvider('')
|
||||
setModel('')
|
||||
setCustomModel('')
|
||||
setLiveModels([])
|
||||
setIsCustomModel(false)
|
||||
toast.success(`Clé ${displayName(provider)} enregistrée ✓`)
|
||||
setProvider(''); setApiKey(''); setAlias(''); setBaseUrl('')
|
||||
setModel(''); setModels([]); setKeyOk(false); setTestResult(null)
|
||||
invalidate()
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message || t('byokSettings.error'))
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
})
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
provider: p,
|
||||
isActive,
|
||||
}: {
|
||||
provider: string
|
||||
isActive: boolean
|
||||
}) => {
|
||||
mutationFn: async ({ p, isActive }: { p: string; isActive: boolean }) => {
|
||||
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isActive }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Update failed')
|
||||
if (!res.ok) throw new Error('Erreur')
|
||||
},
|
||||
onSuccess: invalidate,
|
||||
onError: () => toast.error(t('byokSettings.error')),
|
||||
@@ -171,246 +377,238 @@ export function ByokSettingsPanel() {
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (p: string) => {
|
||||
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
if (!res.ok) throw new Error('Delete failed')
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t('byokSettings.deleted'))
|
||||
invalidate()
|
||||
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, { method: 'DELETE' })
|
||||
if (!res.ok) throw new Error('Erreur')
|
||||
},
|
||||
onSuccess: () => { toast.success('Clé supprimée'); invalidate() },
|
||||
onError: () => toast.error(t('byokSettings.error')),
|
||||
})
|
||||
|
||||
const allowed = data?.allowedProviders ?? []
|
||||
const keys = data?.keys ?? []
|
||||
const hasActiveByok = keys.some((k) => k.isActive)
|
||||
const activeKey = keys.find((k) => k.isActive)
|
||||
const tierBlocked = !isLoading && allowed.length === 0
|
||||
const saveDisabled = !provider || apiKey.length < 8 || saveMutation.isPending || (NEEDS_BASE_URL.has(provider) && baseUrl.length < 4)
|
||||
const canTest = provider && apiKey.length >= 8 && model.length > 0
|
||||
const showModelDropdown = !MANUAL_MODEL_PROVIDERS.has(provider) && models.length > 0
|
||||
const showModelInput = !!provider && (MANUAL_MODEL_PROVIDERS.has(provider) || (!loadingModels && models.length === 0))
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground p-5">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
{t('byokSettings.loading')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p className="text-sm text-destructive p-5">{t('byokSettings.loadError')}</p>
|
||||
)
|
||||
}
|
||||
if (isLoading) return <div className="flex items-center gap-2 text-sm text-muted-foreground p-5"><Loader2 className="h-4 w-4 animate-spin" />{t('byokSettings.loading')}</div>
|
||||
if (error) return <p className="text-sm text-destructive p-5">{t('byokSettings.loadError')}</p>
|
||||
|
||||
return (
|
||||
<div
|
||||
id="byok"
|
||||
className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 scroll-mt-6 space-y-6"
|
||||
>
|
||||
<div id="byok" className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 scroll-mt-6 space-y-6">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="p-3 bg-violet-500/10 rounded-2xl text-violet-500 border border-violet-500/20">
|
||||
<KeyRound size={18} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-[13px] font-bold text-ink flex items-center gap-2 flex-wrap">
|
||||
{t('byokSettings.title')}
|
||||
{hasActiveByok && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-2 py-0.5 text-[10px] font-medium">
|
||||
<Shield size={10} />
|
||||
{t('byokSettings.badgeActive')}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="p-3 bg-violet-500/10 rounded-2xl text-violet-500 border border-violet-500/20"><KeyRound size={18} /></div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<h3 className="text-[13px] font-bold text-ink">{t('byokSettings.title')}</h3>
|
||||
<p className="text-[10px] text-concrete leading-relaxed">{t('byokSettings.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tierBlocked ? (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400 bg-amber-500/10 rounded-xl px-4 py-3">
|
||||
{t('byokSettings.tierRequired')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4 border border-border/60 rounded-2xl p-6">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="byok-provider" className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byokSettings.provider')}</Label>
|
||||
<Select
|
||||
value={provider}
|
||||
onValueChange={handleProviderChange}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
<SelectTrigger id="byok-provider">
|
||||
<SelectValue placeholder={t('byokSettings.providerPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allowed.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{providerLabel(t, p)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="byok-alias" className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byokSettings.alias')}</Label>
|
||||
<Input
|
||||
id="byok-alias"
|
||||
value={alias}
|
||||
onChange={(e) => setAlias(e.target.value)}
|
||||
placeholder={t('byokSettings.aliasPlaceholder')}
|
||||
disabled={saveMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Selection Row */}
|
||||
{provider && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 pt-2 border-t border-border/40">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="byok-model-select" className="text-[10px] font-bold uppercase tracking-widest text-concrete">
|
||||
Modèle de l'IA (Optionnel)
|
||||
</Label>
|
||||
{isFetchingLiveModels ? (
|
||||
<div className="flex items-center gap-2 text-xs text-concrete py-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-brand-accent" />
|
||||
Récupération de vos modèles disponibles...
|
||||
</div>
|
||||
) : liveModels && liveModels.length > 0 ? (
|
||||
<Select
|
||||
value={isCustomModel ? 'custom' : model}
|
||||
onValueChange={(val) => {
|
||||
if (val === 'custom') {
|
||||
setIsCustomModel(true)
|
||||
} else {
|
||||
setIsCustomModel(false)
|
||||
setModel(val)
|
||||
}
|
||||
}}
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
<SelectTrigger id="byok-model-select">
|
||||
<SelectValue placeholder="Choisir un modèle..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{liveModels.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">Autre / Modèle personnalisé...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="text-xs text-concrete py-2 italic">
|
||||
Entrez votre clé API ci-dessous pour charger vos modèles.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(isCustomModel || (!isFetchingLiveModels && !(liveModels && liveModels.length > 0))) && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="byok-model-custom" className="text-[10px] font-bold uppercase tracking-widest text-concrete">Saisir le nom du modèle</Label>
|
||||
<Input
|
||||
id="byok-model-custom"
|
||||
value={isCustomModel ? customModel : model}
|
||||
onChange={(e) => {
|
||||
if (isCustomModel) {
|
||||
setCustomModel(e.target.value)
|
||||
} else {
|
||||
setModel(e.target.value)
|
||||
}
|
||||
}}
|
||||
placeholder="ex. deepseek-reasoner, minimax-abab6.5"
|
||||
disabled={saveMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* BYOK Status */}
|
||||
{!tierBlocked && (
|
||||
<div className={cn(
|
||||
'flex items-center gap-3 rounded-xl px-4 py-3 text-[11px] font-medium border',
|
||||
activeKey
|
||||
? 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20'
|
||||
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/20'
|
||||
)}>
|
||||
{activeKey ? (
|
||||
<><Zap size={14} className="shrink-0" /><span>BYOK actif · <strong>{displayName(activeKey.provider)}</strong>{activeKey.model && <> · <code className="font-mono text-[10px]">{activeKey.model}</code></>}{activeKey.alias && <> · {activeKey.alias}</>}</span></>
|
||||
) : (
|
||||
<><Shield size={14} className="shrink-0" /><span>Aucune clé active — utilisation des quotas Momento</span></>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="byok-key" className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byokSettings.apiKey')}</Label>
|
||||
<Input
|
||||
id="byok-key"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
value={apiKey}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value
|
||||
setApiKey(val)
|
||||
fetchLiveModels(provider, val)
|
||||
}}
|
||||
placeholder={t('byokSettings.apiKeyPlaceholder')}
|
||||
disabled={saveMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!provider || apiKey.length < 8 || saveMutation.isPending}
|
||||
onClick={() => saveMutation.mutate()}
|
||||
className={cn(
|
||||
'px-6 py-3 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] transition-all duration-300',
|
||||
'bg-ink text-paper shadow-xl shadow-ink/20 hover:scale-[1.02] active:scale-95',
|
||||
'disabled:opacity-40 disabled:pointer-events-none disabled:shadow-none'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{t('byokSettings.save')}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{keys.length > 0 && (
|
||||
<ul className="space-y-3">
|
||||
{keys.map((key) => (
|
||||
<li
|
||||
key={key.provider}
|
||||
className="flex items-center justify-between gap-3 rounded-2xl border border-border/60 px-4 py-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-bold text-ink">{providerLabel(t, key.provider)}</div>
|
||||
<div className="flex flex-col gap-0.5 mt-0.5">
|
||||
{key.alias ? (
|
||||
<p className="text-[10px] text-concrete truncate">{key.alias}</p>
|
||||
) : null}
|
||||
{key.model ? (
|
||||
<p className="text-[9px] text-brand-accent font-mono truncate">Modèle : {key.model}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{tierBlocked ? (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400 bg-amber-500/10 rounded-xl px-4 py-3">{t('byokSettings.tierRequired')}</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Saved keys */}
|
||||
{keys.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">Clés enregistrées</p>
|
||||
<ul className="space-y-1">
|
||||
{keys.map((key) => (
|
||||
<li key={key.provider}>
|
||||
<div className={cn(
|
||||
'flex items-center justify-between gap-3 rounded-xl border px-4 py-3 transition-colors',
|
||||
key.isActive ? 'border-emerald-500/30 bg-emerald-500/5' : 'border-border/60',
|
||||
editingProvider === key.provider && 'rounded-b-none border-b-0'
|
||||
)}>
|
||||
<div className="min-w-0 flex items-center gap-3">
|
||||
<div className={cn('h-2 w-2 rounded-full shrink-0', key.isActive ? 'bg-emerald-500' : 'bg-gray-300 dark:bg-white/20')} />
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold text-ink leading-tight">{displayName(key.provider)}</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{key.model && <code className="text-[9px] font-mono text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded">{key.model}</code>}
|
||||
{key.alias && <span className="text-[9px] text-concrete">{key.alias}</span>}
|
||||
<span className={cn('text-[9px] font-medium', key.isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-concrete')}>
|
||||
{key.isActive ? '● Actif' : '○ Inactif'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{/* Edit button */}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'h-7 w-7 rounded-lg flex items-center justify-center transition-colors',
|
||||
editingProvider === key.provider
|
||||
? 'text-violet-600 bg-violet-500/10 border border-violet-500/30'
|
||||
: 'text-concrete hover:text-ink hover:bg-muted border border-transparent'
|
||||
)}
|
||||
onClick={() => setEditingProvider(editingProvider === key.provider ? null : key.provider)}
|
||||
>
|
||||
<Pencil size={12} />
|
||||
</button>
|
||||
{/* Toggle */}
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox" className="sr-only peer" checked={key.isActive}
|
||||
onChange={(e) => toggleMutation.mutate({ p: key.provider, isActive: e.target.checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
/>
|
||||
<div className="w-9 h-5 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[16px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[3px] after:left-[3px] after:bg-white after:rounded-full after:h-3.5 after:w-3.5 after:transition-all peer-checked:bg-emerald-500" />
|
||||
</label>
|
||||
{/* Delete */}
|
||||
<button
|
||||
type="button"
|
||||
className="h-7 w-7 rounded-lg flex items-center justify-center text-destructive/50 hover:text-destructive hover:bg-rose-50 dark:hover:bg-rose-500/10 transition-colors"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => { if (confirm(`Supprimer la clé ${displayName(key.provider)} ?`)) deleteMutation.mutate(key.provider) }}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline edit form */}
|
||||
{editingProvider === key.provider && (
|
||||
<div className="border border-violet-500/20 border-t-0 rounded-b-xl overflow-hidden">
|
||||
<EditKeyForm
|
||||
savedKey={key}
|
||||
onDone={() => setEditingProvider(null)}
|
||||
onInvalidate={invalidate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add key form */}
|
||||
<div className="space-y-4 border border-border/60 rounded-2xl p-5">
|
||||
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">
|
||||
{keys.length > 0 ? 'Ajouter / remplacer une clé' : 'Connecter votre fournisseur IA'}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="byok-provider" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Fournisseur</Label>
|
||||
<Select value={provider} onValueChange={onProviderChange} disabled={saveMutation.isPending}>
|
||||
<SelectTrigger id="byok-provider"><SelectValue placeholder="Choisir un fournisseur..." /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{allowed.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
<span className="font-medium">{displayName(p)}</span>
|
||||
{PROVIDER_INFO[p]?.hint && <span className="ml-2 text-[10px] text-concrete">{PROVIDER_INFO[p].hint}</span>}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={key.isActive}
|
||||
onChange={(e) => toggleMutation.mutate({ provider: key.provider, isActive: e.target.checked })}
|
||||
disabled={toggleMutation.isPending}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="byok-alias" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Alias <span className="normal-case font-normal">(optionnel)</span></Label>
|
||||
<Input id="byok-alias" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder="ex. Ma clé pro" disabled={saveMutation.isPending} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{NEEDS_BASE_URL.has(provider) && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="byok-baseurl" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">URL de l'API</Label>
|
||||
<Input id="byok-baseurl" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" disabled={saveMutation.isPending} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="byok-key" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Clé API</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="byok-key" type="password" autoComplete="off" value={apiKey}
|
||||
onChange={(e) => { const val = e.target.value; setApiKey(val); setKeyOk(false); setTestResult(null); refreshModels(provider, val, baseUrl) }}
|
||||
placeholder="sk-..." disabled={saveMutation.isPending}
|
||||
className={cn(keyOk && 'border-emerald-500/60')}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-brand-accent" />
|
||||
</label>
|
||||
{keyOk && <CheckCircle2 className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-emerald-500 pointer-events-none" />}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 w-8 rounded-lg flex items-center justify-center text-destructive/60 hover:text-destructive hover:bg-rose-50 dark:hover:bg-rose-500/10 transition-colors"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(t('byokSettings.confirmDelete'))) {
|
||||
deleteMutation.mutate(key.provider)
|
||||
}
|
||||
}}
|
||||
type="button" disabled={!provider || apiKey.length < 8 || verifying} onClick={verifyKey}
|
||||
className={cn('shrink-0 px-4 py-2 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border disabled:opacity-40 disabled:pointer-events-none',
|
||||
keyOk ? 'bg-emerald-500/10 border-emerald-500/40 text-emerald-700 dark:text-emerald-400' : 'bg-white dark:bg-white/5 border-border hover:border-violet-400')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{verifying ? <Loader2 className="h-4 w-4 animate-spin" /> : keyOk ? <CheckCircle2 className="h-4 w-4" /> : 'Vérifier'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!tierBlocked && keys.length === 0 && (
|
||||
<p className="text-[11px] text-concrete">{t('byokSettings.empty')}</p>
|
||||
{provider && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="byok-model" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Modèle</Label>
|
||||
{loadingModels ? (
|
||||
<div className="flex items-center gap-2 text-xs text-concrete py-2"><Loader2 className="h-3 w-3 animate-spin" />Récupération des modèles...</div>
|
||||
) : showModelDropdown ? (
|
||||
<Select value={model} onValueChange={(v) => { setModel(v); setTestResult(null) }} disabled={saveMutation.isPending}>
|
||||
<SelectTrigger id="byok-model"><SelectValue placeholder="Choisir un modèle..." /></SelectTrigger>
|
||||
<SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
) : showModelInput ? (
|
||||
<Input id="byok-model" value={model} onChange={(e) => { setModel(e.target.value); setTestResult(null) }} placeholder="ex. gpt-4o-mini, MiniMax-M2.7" disabled={saveMutation.isPending} />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResult && (
|
||||
<div className={cn('flex items-start gap-3 rounded-xl px-4 py-3 text-[11px] border',
|
||||
testResult.ok ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-700 dark:text-emerald-400' : 'bg-rose-500/10 border-rose-500/20 text-rose-700 dark:text-rose-400')}>
|
||||
{testResult.ok ? <CheckCircle2 size={14} className="shrink-0 mt-0.5" /> : <XCircle size={14} className="shrink-0 mt-0.5" />}
|
||||
<div className="space-y-0.5">
|
||||
{testResult.ok ? (
|
||||
<><p className="font-semibold">Modèle opérationnel ✓{testResult.latency && <span className="font-normal ml-1">({testResult.latency}ms)</span>}</p>{testResult.reply && <p className="opacity-70 font-mono text-[10px]">Réponse : {testResult.reply}</p>}</>
|
||||
) : <p className="font-semibold">{testResult.error ?? 'Échec du test'}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button" disabled={!canTest || testing || saveMutation.isPending} onClick={testModel}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] transition-all border bg-white dark:bg-white/5 border-border hover:border-violet-400 disabled:opacity-40 disabled:pointer-events-none"
|
||||
>
|
||||
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FlaskConical size={13} />}Tester
|
||||
</button>
|
||||
<Button
|
||||
type="button" disabled={saveDisabled} onClick={() => saveMutation.mutate()}
|
||||
className="flex-1 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.15em] transition-all duration-200 bg-ink text-paper shadow-lg hover:scale-[1.01] active:scale-[0.99] disabled:opacity-40 disabled:pointer-events-none disabled:shadow-none"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{provider ? `Enregistrer — ${displayName(provider)}` : 'Enregistrer'}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{keys.length === 0 && <p className="text-[11px] text-concrete">{t('byokSettings.empty')}</p>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user