feat(dashboard): tableau de bord Second Brain configurable avec chargement progressif
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m3s
CI / Deploy production (on server) (push) Successful in 23s

Briefing granulaire, pistes rapides puis enrichissement async, layout persisté v5,
suggestions agents, intégration Gmail et navigation sidebar alignée sur /home.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-07-14 16:50:53 +00:00
parent d38a99586b
commit 30da592ba2
62 changed files with 7741 additions and 335 deletions

View File

@@ -24,14 +24,14 @@ const PROVIDER_INFO: Record<string, { name: string; hint: string }> = {
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' },
openrouter: { name: 'OpenRouter', hint: 'Multi-provider access' },
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' },
zai: { name: 'Zuki Journey', hint: 'OpenAI/Anthropic proxy' },
anthropic_custom: { name: 'Anthropic (custom)', hint: 'Anthropic-compatible proxy' },
custom_openai: { name: 'Compatible OpenAI', hint: 'Any OpenAI-compatible proxy' },
custom_anthropic: { name: 'Compatible Anthropic', hint: 'Any Anthropic-compatible proxy' },
custom: { name: 'Custom API', hint: 'Your own endpoint' },
}
function displayName(provider: string): string {
@@ -59,7 +59,7 @@ type SavedKey = {
async function loadKeys(): Promise<{ keys: SavedKey[]; allowedProviders: string[] }> {
const res = await fetch('/api/user/api-keys')
if (!res.ok) throw new Error('Erreur de chargement')
if (!res.ok) throw new Error('Failed to load')
return res.json()
}
@@ -92,6 +92,8 @@ function EditKeyForm({
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ ok: boolean; latency?: number; reply?: string; error?: string } | null>(null)
const { t } = useLanguage()
const needsUrl = NEEDS_BASE_URL.has(savedKey.provider)
const manualModel = MANUAL_MODEL_PROVIDERS.has(savedKey.provider)
@@ -118,10 +120,10 @@ function EditKeyForm({
body: JSON.stringify(payload),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec')
if (!res.ok) throw new Error(body.message ?? body.error ?? t('common.error'))
},
onSuccess: () => {
toast.success(`${displayName(savedKey.provider)} mis à jour ✓`)
toast.success(t('byok.updated', { name: displayName(savedKey.provider) }))
onInvalidate()
onDone()
},
@@ -132,7 +134,7 @@ function EditKeyForm({
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é"')
toast.info(t('byok.testHint'))
return
}
setTesting(true)
@@ -143,7 +145,7 @@ function EditKeyForm({
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' })
setTestResult({ ok: false, error: t('byok.contactError') })
} finally {
setTesting(false)
}
@@ -155,18 +157,18 @@ function EditKeyForm({
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)}
{t('byok.editLabel', { name: 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" />
<Label htmlFor={`edit-alias-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.aliasLabel')}</Label>
<Input id={`edit-alias-${savedKey.provider}`} value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} />
</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>
<Label htmlFor={`edit-baseurl-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiUrl')}</Label>
<Input id={`edit-baseurl-${savedKey.provider}`} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value.trim())} placeholder="https://api.example.com/v1" />
</div>
)}
@@ -174,21 +176,21 @@ function EditKeyForm({
{/* 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>
<Label htmlFor={`edit-model-${savedKey.provider}`} className="text-[9px] font-semibold uppercase tracking-widest text-concrete">{t('byok.model')}</Label>
{showModelDropdown ? (
<Select value={model} onValueChange={setModel}>
<SelectTrigger id={`edit-model-${savedKey.provider}`}><SelectValue placeholder="Choisir..." /></SelectTrigger>
<SelectTrigger id={`edit-model-${savedKey.provider}`}><SelectValue placeholder={t('byok.choose')} /></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 className="flex items-center gap-2 text-xs text-concrete py-1"><Loader2 className="h-3 w-3 animate-spin" />{t('common.loading')}</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>
{t('byok.newKey')} <span className="normal-case font-normal text-concrete">{t('byok.newKeyHint')}</span>
</Label>
<Input
id={`edit-key-${savedKey.provider}`}
@@ -196,7 +198,7 @@ function EditKeyForm({
autoComplete="off"
value={newKey}
onChange={(e) => { setNewKey(e.target.value); setTestResult(null) }}
placeholder="sk-... (optionnel)"
placeholder={t('byok.keyPlaceholder')}
/>
</div>
@@ -209,8 +211,8 @@ function EditKeyForm({
{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'
? `${t('byok.operational')}${testResult.latency ? ` (${testResult.latency}ms)` : ''}${testResult.reply ? ` · ${testResult.reply}` : ''}`
: testResult.error ?? t('common.error')
}
</span>
</div>
@@ -225,7 +227,7 @@ function EditKeyForm({
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
{t('byok.test')}
</button>
<Button
type="button"
@@ -233,7 +235,7 @@ function EditKeyForm({
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'}
{saveMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : t('byok.saveChanges')}
</Button>
<button
type="button"
@@ -305,7 +307,7 @@ export function ByokSettingsPanel() {
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 }
if (NEEDS_BASE_URL.has(provider) && !baseUrl) { toast.error(t('byok.baseUrlRequired')); return }
setVerifying(true)
setTestResult(null)
try {
@@ -316,12 +318,12 @@ export function ByokSettingsPanel() {
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 ✓')
toast.success(t('byok.keyValid'))
} else {
setKeyOk(false)
toast.error(body.message ?? 'Clé API invalide')
toast.error(body.message ?? t('byok.keyInvalid'))
}
} catch { setKeyOk(false); toast.error('Erreur de vérification') }
} catch { setKeyOk(false); toast.error(t('byok.verifyError')) }
finally { setVerifying(false) }
}
@@ -334,7 +336,7 @@ export function ByokSettingsPanel() {
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' }) }
} catch { setTestResult({ ok: false, error: t('byok.contactError') }) }
finally { setTesting(false) }
}
@@ -352,10 +354,10 @@ export function ByokSettingsPanel() {
body: JSON.stringify(payload),
})
const body = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(body.message ?? body.error ?? 'Échec')
if (!res.ok) throw new Error(body.message ?? body.error ?? t('common.error'))
},
onSuccess: () => {
toast.success(`Clé ${displayName(provider)} enregistrée ✓`)
toast.success(t('byok.keySaved', { name: displayName(provider) }))
setProvider(''); setApiKey(''); setAlias(''); setBaseUrl('')
setModel(''); setModels([]); setKeyOk(false); setTestResult(null)
invalidate()
@@ -369,7 +371,7 @@ export function ByokSettingsPanel() {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive }),
})
if (!res.ok) throw new Error('Erreur')
if (!res.ok) throw new Error(t('common.error'))
},
onSuccess: invalidate,
onError: () => toast.error(t('byokSettings.error')),
@@ -378,9 +380,9 @@ 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('Erreur')
if (!res.ok) throw new Error(t('common.error'))
},
onSuccess: () => { toast.success('Clé supprimée'); invalidate() },
onSuccess: () => { toast.success(t('byok.keyDeleted')); invalidate() },
onError: () => toast.error(t('byokSettings.error')),
})
@@ -417,9 +419,9 @@ export function ByokSettingsPanel() {
: '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></>
<><Zap size={14} className="shrink-0" /><span>{t('byok.byokActive')} · <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 Memento</span></>
<><Shield size={14} className="shrink-0" /><span>{t('byok.noActiveKey')}</span></>
)}
</div>
)}
@@ -431,7 +433,7 @@ export function ByokSettingsPanel() {
{/* 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>
<p className="text-[10px] font-bold uppercase tracking-widest text-concrete">{t('byok.savedKeys')}</p>
<ul className="space-y-1">
{keys.map((key) => (
<li key={key.provider}>
@@ -448,7 +450,7 @@ export function ByokSettingsPanel() {
{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'}
{key.isActive ? t('byok.activeStatus') : t('byok.inactiveStatus')}
</span>
</div>
</div>
@@ -481,7 +483,7 @@ export function ByokSettingsPanel() {
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) }}
onClick={() => { if (confirm(t('byok.confirmDelete', { name: displayName(key.provider) }))) deleteMutation.mutate(key.provider) }}
>
<Trash2 size={12} />
</button>
@@ -507,14 +509,14 @@ export function ByokSettingsPanel() {
{/* 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'}
{keys.length > 0 ? t('byok.addOrReplace') : t('byok.connectProvider')}
</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>
<Label htmlFor="byok-provider" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byokSettings.provider')}</Label>
<Select value={provider} onValueChange={onProviderChange} disabled={saveMutation.isPending}>
<SelectTrigger id="byok-provider"><SelectValue placeholder="Choisir un fournisseur..." /></SelectTrigger>
<SelectTrigger id="byok-provider"><SelectValue placeholder={t('byok.chooseProvider')} /></SelectTrigger>
<SelectContent>
{allowed.map((p) => (
<SelectItem key={p} value={p}>
@@ -526,20 +528,20 @@ export function ByokSettingsPanel() {
</Select>
</div>
<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} />
<Label htmlFor="byok-alias" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.aliasLabel')} <span className="normal-case font-normal">{t('byok.optional')}</span></Label>
<Input id="byok-alias" value={alias} onChange={(e) => setAlias(e.target.value)} placeholder={t('byok.aliasPlaceholder')} 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>
<Label htmlFor="byok-baseurl" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiUrl')}</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>
<Label htmlFor="byok-key" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.apiKey')}</Label>
<div className="flex gap-2">
<div className="relative flex-1">
<Input
@@ -555,19 +557,19 @@ export function ByokSettingsPanel() {
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')}
>
{verifying ? <Loader2 className="h-4 w-4 animate-spin" /> : keyOk ? <CheckCircle2 className="h-4 w-4" /> : 'Vérifier'}
{verifying ? <Loader2 className="h-4 w-4 animate-spin" /> : keyOk ? <CheckCircle2 className="h-4 w-4" /> : t('byok.verify')}
</button>
</div>
</div>
{provider && (
<div className="space-y-1.5">
<Label htmlFor="byok-model" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">Modèle</Label>
<Label htmlFor="byok-model" className="text-[10px] font-semibold uppercase tracking-widest text-concrete">{t('byok.model')}</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>
<div className="flex items-center gap-2 text-xs text-concrete py-2"><Loader2 className="h-3 w-3 animate-spin" />{t('byok.fetchingModels')}</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>
<SelectTrigger id="byok-model"><SelectValue placeholder={t('byok.chooseModel')} /></SelectTrigger>
<SelectContent>{models.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}</SelectContent>
</Select>
) : showModelInput ? (
@@ -582,8 +584,8 @@ export function ByokSettingsPanel() {
{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>}
<><p className="font-semibold">{t('byok.modelOperational')}{testResult.latency && <span className="font-normal ml-1">({testResult.latency}ms)</span>}</p>{testResult.reply && <p className="opacity-70 font-mono text-[10px]">{t('byok.reply')} {testResult.reply}</p>}</>
) : <p className="font-semibold">{testResult.error ?? t('byok.testFailed')}</p>}
</div>
</div>
)}
@@ -593,7 +595,7 @@ export function ByokSettingsPanel() {
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
{testing ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FlaskConical size={13} />}{t('byok.test')}
</button>
<Button
type="button" disabled={saveDisabled} onClick={() => saveMutation.mutate()}
@@ -601,7 +603,7 @@ export function ByokSettingsPanel() {
>
<div className="flex items-center justify-center gap-2">
{saveMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{provider ? `Enregistrer — ${displayName(provider)}` : 'Enregistrer'}
{provider ? t('byok.saveWithName', { name: displayName(provider) }) : t('byok.save')}
</div>
</Button>
</div>