418 lines
15 KiB
TypeScript
418 lines
15 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useState } from 'react'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { KeyRound, Loader2, Shield, Trash2 } 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 {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
type PublicKey = {
|
|
provider: string
|
|
alias: string
|
|
model: string | null
|
|
isActive: boolean
|
|
lastUsedAt: string | null
|
|
}
|
|
|
|
async function fetchByokKeys(): Promise<{
|
|
keys: PublicKey[]
|
|
allowedProviders: string[]
|
|
providerModels: Record<string, 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')
|
|
}
|
|
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
|
|
}
|
|
|
|
export function ByokSettingsPanel() {
|
|
const { t } = useLanguage()
|
|
const queryClient = useQueryClient()
|
|
const [provider, setProvider] = useState('')
|
|
const [apiKey, setApiKey] = useState('')
|
|
const [alias, setAlias] = useState('')
|
|
const [model, setModel] = useState('')
|
|
const [customModel, setCustomModel] = useState('')
|
|
const [isCustomModel, setIsCustomModel] = useState(false)
|
|
|
|
// Dynamic models fetched directly via user's API Key
|
|
const [liveModels, setLiveModels] = useState<string[]>([])
|
|
const [isFetchingLiveModels, setIsFetchingLiveModels] = useState(false)
|
|
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ['user', 'api-keys'],
|
|
queryFn: fetchByokKeys,
|
|
})
|
|
|
|
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])
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const resolvedModel = isCustomModel ? customModel : model
|
|
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,
|
|
}),
|
|
})
|
|
const body = await res.json().catch(() => ({}))
|
|
if (!res.ok) {
|
|
throw new Error(body.message || body.error || 'Save failed')
|
|
}
|
|
return body
|
|
},
|
|
onSuccess: () => {
|
|
toast.success(t('byokSettings.saved'))
|
|
setApiKey('')
|
|
setAlias('')
|
|
setProvider('')
|
|
setModel('')
|
|
setCustomModel('')
|
|
setLiveModels([])
|
|
setIsCustomModel(false)
|
|
invalidate()
|
|
},
|
|
onError: (err: Error) => {
|
|
toast.error(err.message || t('byokSettings.error'))
|
|
},
|
|
})
|
|
|
|
const toggleMutation = useMutation({
|
|
mutationFn: async ({
|
|
provider: p,
|
|
isActive,
|
|
}: {
|
|
provider: string
|
|
isActive: boolean
|
|
}) => {
|
|
const res = await fetch(`/api/user/api-keys/${encodeURIComponent(p)}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ isActive }),
|
|
})
|
|
if (!res.ok) throw new Error('Update failed')
|
|
},
|
|
onSuccess: invalidate,
|
|
onError: () => toast.error(t('byokSettings.error')),
|
|
})
|
|
|
|
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()
|
|
},
|
|
onError: () => toast.error(t('byokSettings.error')),
|
|
})
|
|
|
|
const allowed = data?.allowedProviders ?? []
|
|
const keys = data?.keys ?? []
|
|
const hasActiveByok = keys.some((k) => k.isActive)
|
|
const tierBlocked = !isLoading && allowed.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>
|
|
)
|
|
}
|
|
|
|
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 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>
|
|
<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>
|
|
)}
|
|
<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>
|
|
</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="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>
|
|
<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)
|
|
}
|
|
}}
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{!tierBlocked && keys.length === 0 && (
|
|
<p className="text-[11px] text-concrete">{t('byokSettings.empty')}</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|