feat: add download features to llm models

This commit is contained in:
2026-04-20 22:50:16 +02:00
parent 351dd3b608
commit 6274b4a0b8
11 changed files with 910 additions and 237 deletions

View File

@ -4,6 +4,12 @@ export interface AIProvider {
id: string; name: string; model: string; endpoint: string
is_active: boolean; has_key: boolean
}
export interface AIRoleConfig { provider_id: string; model: string }
export interface AIRoles { summary: AIRoleConfig; report: AIRoleConfig; filter: AIRoleConfig }
export interface OllamaModelInfo {
name: string; size: number; modified_at: string
details: { parameter_size: string; quantization_level: string; family: string }
}
export interface Source { id: string; name: string; type: string; enabled: boolean }
export interface ScrapeJob {
id: string; source_id: string; source_name: string; status: string
@ -32,6 +38,15 @@ export const adminApi = {
deleteProvider: (id: string) => api.delete<void>(`/admin/ai-providers/${id}`),
listModels: (id: string) => api.get<string[]>(`/admin/ai-providers/${id}/models`),
// AI Roles
getRoles: () => api.get<AIRoles>('/admin/ai-roles'),
updateRole: (role: string, data: AIRoleConfig) => api.put<void>(`/admin/ai-roles/${role}`, data),
// Ollama model management
listOllamaModels: () => api.get<OllamaModelInfo[]>('/admin/ollama/models'),
pullOllamaModel: (name: string) => api.post<void>('/admin/ollama/pull', { name }),
deleteOllamaModel: (name: string) => api.delete<void>(`/admin/ollama/models/${encodeURIComponent(name)}`),
// Sources
listSources: () => api.get<Source[]>('/admin/sources'),
updateSource: (id: string, enabled: boolean) => api.put<void>(`/admin/sources/${id}`, { enabled }),

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { Plus, Trash2, CheckCircle, RefreshCw } from 'lucide-react'
import { adminApi, type AIProvider } from '@/api/admin'
import { useState, useEffect, useCallback } from 'react'
import { Plus, Trash2, Star, Download, HardDrive, Cpu, ChevronDown, ChevronUp, X } from 'lucide-react'
import { adminApi, type AIProvider, type AIRoles, type OllamaModelInfo } from '@/api/admin'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@ -11,182 +11,577 @@ import { Spinner } from '@/components/ui/spinner'
const PROVIDER_NAMES = ['openai', 'anthropic', 'gemini', 'ollama'] as const
export function AIProviders() {
const [providers, setProviders] = useState<AIProvider[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ name: 'openai', api_key: '', model: '', endpoint: '' })
const isOllama = form.name === 'ollama'
const [models, setModels] = useState<Record<string, string[]>>({})
const [loadingModels, setLoadingModels] = useState<string | null>(null)
const [editId, setEditId] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const CUSTOM_MODELS_KEY = 'ollama_custom_models'
useEffect(() => { load() }, [])
const KNOWN_OLLAMA_MODELS: { name: string; family: string; tags: { tag: string; size: string }[] }[] = [
{ name: 'qwen3', family: 'Qwen3', tags: [{ tag: '0.6b', size: '~0.4 GB' }, { tag: '1.7b', size: '~1.1 GB' }, { tag: '4b', size: '~2.6 GB' }, { tag: '8b', size: '~5.2 GB' }, { tag: '14b', size: '~9.3 GB' }, { tag: '32b', size: '~20 GB' }] },
{ name: 'qwen2.5', family: 'Qwen2.5', tags: [{ tag: '0.5b', size: '~0.4 GB' }, { tag: '1.5b', size: '~1 GB' }, { tag: '3b', size: '~2 GB' }, { tag: '7b', size: '~4.7 GB' }, { tag: '14b', size: '~9 GB' }, { tag: '32b', size: '~20 GB' }] },
{ name: 'llama3.2', family: 'Llama 3.2', tags: [{ tag: '1b', size: '~1.3 GB' }, { tag: '3b', size: '~2 GB' }] },
{ name: 'llama3.1', family: 'Llama 3.1', tags: [{ tag: '8b', size: '~4.9 GB' }, { tag: '70b', size: '~40 GB' }] },
{ name: 'gemma3', family: 'Gemma 3', tags: [{ tag: '1b', size: '~0.8 GB' }, { tag: '4b', size: '~3.3 GB' }, { tag: '12b', size: '~8 GB' }, { tag: '27b', size: '~17 GB' }] },
{ name: 'mistral', family: 'Mistral', tags: [{ tag: '7b', size: '~4.1 GB' }] },
{ name: 'phi4', family: 'Phi-4', tags: [{ tag: 'latest', size: '~9 GB' }] },
{ name: 'phi4-mini', family: 'Phi-4 Mini', tags: [{ tag: 'latest', size: '~2.5 GB' }] },
{ name: 'deepseek-r1', family: 'DeepSeek-R1', tags: [{ tag: '1.5b', size: '~1.1 GB' }, { tag: '7b', size: '~4.7 GB' }, { tag: '8b', size: '~4.9 GB' }, { tag: '14b', size: '~9 GB' }, { tag: '32b', size: '~20 GB' }] },
]
async function load() {
setLoading(true)
try { setProviders((await adminApi.listProviders()) ?? []) } finally { setLoading(false) }
function formatSize(bytes: number): string {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(0)} MB`
return `${bytes} B`
}
function loadCustomModels(): string[] {
try { return JSON.parse(localStorage.getItem(CUSTOM_MODELS_KEY) ?? '[]') } catch { return [] }
}
function saveCustomModels(models: string[]) {
localStorage.setItem(CUSTOM_MODELS_KEY, JSON.stringify(models))
}
// ── Confirmation dialog ────────────────────────────────────────────────────
function ConfirmDownloadDialog({ modelName, onConfirm, onCancel }: {
modelName: string
onConfirm: () => void
onCancel: () => void
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-card border rounded-xl shadow-2xl p-6 w-full max-w-sm mx-4 space-y-4">
<div className="flex items-start justify-between">
<div>
<p className="font-semibold">Télécharger ce modèle ?</p>
<p className="text-sm text-muted-foreground mt-1">
<span className="font-mono text-foreground">{modelName}</span> sera téléchargé depuis Ollama Hub.
Le téléchargement peut prendre plusieurs minutes.
</p>
</div>
<button onClick={onCancel} className="text-muted-foreground hover:text-foreground ml-3 shrink-0">
<X className="h-4 w-4" />
</button>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" size="sm" onClick={onCancel}>Annuler</Button>
<Button size="sm" onClick={onConfirm}>
<Download className="h-3 w-3 mr-1" /> Télécharger
</Button>
</div>
</div>
</div>
)
}
// ── Shared model catalogue ─────────────────────────────────────────────────
function ModelCatalogue({
installedNames,
pulling,
onPull,
}: {
installedNames: Set<string>
pulling: string | null
onPull: (name: string) => void
}) {
const [customName, setCustomName] = useState('')
const [pendingModel, setPendingModel] = useState<string | null>(null)
const [customModels, setCustomModels] = useState<string[]>(loadCustomModels)
// All model names in the hardcoded catalogue
const knownNames = new Set(
KNOWN_OLLAMA_MODELS.flatMap(f => f.tags.map(t => `${f.name}:${t.tag}`))
)
function requestPull(name: string) {
setPendingModel(name)
}
async function loadModels(id: string) {
setLoadingModels(id)
try {
const m = await adminApi.listModels(id)
setModels(prev => ({ ...prev, [id]: m }))
} catch { /* silently ignore */ } finally { setLoadingModels(null) }
function confirmPull() {
if (!pendingModel) return
// If not in hardcoded catalogue, add to custom list
if (!knownNames.has(pendingModel)) {
const updated = [...new Set([...customModels, pendingModel])]
setCustomModels(updated)
saveCustomModels(updated)
}
onPull(pendingModel)
setPendingModel(null)
}
async function save() {
setSaving(true); setError('')
try {
if (editId) {
await adminApi.updateProvider(editId, form)
} else {
await adminApi.createProvider(form)
function submitCustom() {
const name = customName.trim()
if (!name) return
const fullName = name.includes(':') ? name : `${name}:latest`
setCustomName('')
requestPull(fullName)
}
// Families to display: hardcoded + custom entries
const customEntries = customModels.filter(m => !knownNames.has(m))
return (
<>
{pendingModel && (
<ConfirmDownloadDialog
modelName={pendingModel}
onConfirm={confirmPull}
onCancel={() => setPendingModel(null)}
/>
)}
<div className="space-y-4">
{/* Free-text input */}
<div>
<p className="text-xs font-semibold text-muted-foreground mb-1.5">Nom personnalisé</p>
<div className="flex gap-2">
<Input
className="h-8 text-sm font-mono"
placeholder="ex: llama3.2:3b, mymodel:latest…"
value={customName}
onChange={e => setCustomName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && submitCustom()}
disabled={pulling !== null}
/>
<Button size="sm" className="h-8 shrink-0" onClick={submitCustom}
disabled={!customName.trim() || pulling !== null}>
<Download className="h-3 w-3" /> Installer
</Button>
</div>
</div>
{/* Custom models section */}
{customEntries.length > 0 && (
<div>
<p className="text-xs font-semibold text-muted-foreground mb-1.5">Personnalisés</p>
<div className="flex flex-wrap gap-1.5">
{customEntries.map(fullName => {
const installed = installedNames.has(fullName)
const isPulling = pulling === fullName
return <ModelTag key={fullName} fullName={fullName} size="?" installed={installed} isPulling={isPulling} pulling={pulling} onRequest={requestPull} />
})}
</div>
</div>
)}
{/* Hardcoded catalogue */}
<div className="space-y-3">
{KNOWN_OLLAMA_MODELS.map(family => (
<div key={family.name}>
<p className="text-xs font-semibold text-muted-foreground mb-1.5">{family.family}</p>
<div className="flex flex-wrap gap-1.5">
{family.tags.map(({ tag, size }) => {
const fullName = `${family.name}:${tag}`
const installed = installedNames.has(fullName)
const isPulling = pulling === fullName
return <ModelTag key={fullName} fullName={fullName} size={size} installed={installed} isPulling={isPulling} pulling={pulling} onRequest={requestPull} />
})}
</div>
</div>
))}
</div>
</div>
</>
)
}
function ModelTag({ fullName, size, installed, isPulling, pulling, onRequest }: {
fullName: string; size: string
installed: boolean; isPulling: boolean; pulling: string | null
onRequest: (name: string) => void
}) {
return (
<button
type="button"
disabled={installed || pulling !== null}
onClick={() => !installed && !isPulling && onRequest(fullName)}
className={[
'flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors',
installed
? 'border-primary/40 bg-primary/5 text-primary cursor-default'
: isPulling
? 'border-border bg-muted text-muted-foreground cursor-wait'
: pulling !== null
? 'border-border bg-background text-muted-foreground opacity-50 cursor-not-allowed'
: 'border-border bg-background hover:border-primary/60 hover:bg-primary/5 cursor-pointer',
].join(' ')}
>
{isPulling
? <><Spinner className="h-2.5 w-2.5" /> Téléchargement</>
: installed
? <><span className="text-[10px]"></span> {fullName}</>
: <><Download className="h-2.5 w-2.5" /> {fullName} {size !== '?' && <span className="text-muted-foreground">{size}</span>}</>
}
setShowForm(false); setEditId(null)
setForm({ name: 'openai', api_key: '', model: '', endpoint: '' })
await load()
} catch (e) { setError(e instanceof Error ? e.message : 'Erreur') } finally { setSaving(false) }
}
</button>
)
}
async function activate(id: string) {
await adminApi.activateProvider(id)
await load()
}
// ── Ollama model picker (inside provider form) ─────────────────────────────
async function remove(id: string) {
if (!confirm('Supprimer ce fournisseur ?')) return
await adminApi.deleteProvider(id)
await load()
}
function OllamaModelPicker({ value, installedModels, onSelect, onRefresh }: {
value: string
installedModels: OllamaModelInfo[]
onSelect: (name: string) => void
onRefresh: () => Promise<OllamaModelInfo[]>
}) {
const [showCatalogue, setShowCatalogue] = useState(installedModels.length === 0)
const [pulling, setPulling] = useState<string | null>(null)
const installedNames = new Set(installedModels.map(m => m.name))
function startEdit(p: AIProvider) {
setEditId(p.id)
setForm({ name: p.name, api_key: '', model: p.model, endpoint: p.endpoint })
setShowForm(true)
async function pull(fullName: string) {
setPulling(fullName)
try {
await adminApi.pullOllamaModel(fullName)
await new Promise<void>(resolve => {
const iv = setInterval(async () => {
try {
const updated = await onRefresh()
if (updated.some(m => m.name === fullName)) { clearInterval(iv); onSelect(fullName); setShowCatalogue(false); resolve() }
} catch { /* ignore */ }
}, 4000)
setTimeout(() => { clearInterval(iv); resolve() }, 1800000)
})
} finally { setPulling(null) }
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Fournisseurs IA</h1>
<p className="text-muted-foreground text-sm">Configurez les fournisseurs IA et sélectionnez le modèle actif</p>
</div>
<Button onClick={() => { setShowForm(true); setEditId(null) }}>
<Plus className="h-4 w-4" /> Ajouter
</Button>
</div>
{showForm && (
<Card>
<CardHeader><CardTitle>{editId ? 'Modifier' : 'Nouveau fournisseur'}</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1">
<Label>Fournisseur</Label>
<Select
value={form.name}
onChange={e => {
const name = e.target.value
setForm(f => ({
...f,
name,
endpoint: name === 'ollama' ? 'http://ollama:11434' : '',
api_key: '',
}))
}}
disabled={!!editId}
>
{PROVIDER_NAMES.map(n => <option key={n} value={n}>{n}</option>)}
</Select>
</div>
{!isOllama && (
<div className="space-y-1">
<Label>Clé API {editId && <span className="text-muted-foreground">(laisser vide pour conserver)</span>}</Label>
<Input type="password" placeholder="sk-..." value={form.api_key} onChange={e => setForm(f => ({ ...f, api_key: e.target.value }))} />
</div>
)}
<div className="space-y-1">
<Label>Modèle</Label>
<Input placeholder="gpt-4o-mini, claude-sonnet-4-6…" value={form.model} onChange={e => setForm(f => ({ ...f, model: e.target.value }))} />
</div>
{isOllama && (
<div className="space-y-1">
<Label>Endpoint Ollama</Label>
<Input value="http://ollama:11434" readOnly className="opacity-60 cursor-not-allowed" />
</div>
)}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex gap-2">
<Button onClick={save} disabled={saving}>{saving ? <Spinner className="h-4 w-4" /> : 'Enregistrer'}</Button>
<Button variant="outline" onClick={() => { setShowForm(false); setEditId(null) }}>Annuler</Button>
</div>
</CardContent>
</Card>
)}
{loading ? (
<div className="flex justify-center py-12"><Spinner /></div>
) : (
<div className="space-y-3">
{providers.map(p => (
<Card key={p.id} className={p.is_active ? 'border-primary/50' : ''}>
<CardContent className="flex flex-wrap items-center gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold capitalize">{p.name}</span>
{p.is_active && <Badge variant="default">Actif</Badge>}
{!p.has_key && p.name !== 'ollama' && <Badge variant="outline" className="text-yellow-500 border-yellow-500">Sans clé</Badge>}
</div>
<div className="text-sm text-muted-foreground mt-1 flex gap-4 flex-wrap">
{p.model && <span>Modèle : <strong>{p.model}</strong></span>}
{p.endpoint && <span>Endpoint : {p.endpoint}</span>}
</div>
{/* Dropdown modèles disponibles */}
{models[p.id] && models[p.id].length > 0 && (
<div className="mt-2 space-y-1">
<Label className="text-xs">Choisir un modèle :</Label>
<Select
className="w-full max-w-xs"
value={p.model}
onChange={async e => {
await adminApi.updateProvider(p.id, { name: p.name, model: e.target.value, endpoint: p.endpoint })
await load()
}}
>
{models[p.id].map(m => <option key={m} value={m}>{m}</option>)}
</Select>
</div>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button variant="outline" size="sm" onClick={() => loadModels(p.id)} disabled={loadingModels === p.id}>
{loadingModels === p.id ? <Spinner className="h-3 w-3" /> : <RefreshCw className="h-3 w-3" />}
Modèles
</Button>
<Button variant="outline" size="sm" onClick={() => startEdit(p)}>Modifier</Button>
{!p.is_active && (
<Button size="sm" onClick={() => activate(p.id)}>
<CheckCircle className="h-3 w-3" /> Activer
</Button>
)}
<Button variant="destructive" size="sm" onClick={() => remove(p.id)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</CardContent>
</Card>
))}
{providers.length === 0 && (
<Card><CardContent className="py-8 text-center text-muted-foreground">Aucun fournisseur configuré</CardContent></Card>
)}
<div className="space-y-2">
<Select value={value} onChange={e => onSelect(e.target.value)}>
<option value=""> Choisir un modèle installé </option>
{installedModels.map(m => (
<option key={m.name} value={m.name}>
{m.name}{m.details.parameter_size ? ` (${m.details.parameter_size})` : ''}
</option>
))}
</Select>
<button type="button" className="flex items-center gap-1 text-xs text-primary hover:underline"
onClick={() => setShowCatalogue(v => !v)}>
{showCatalogue ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
{showCatalogue ? 'Masquer le catalogue' : 'Installer un nouveau modèle'}
</button>
{showCatalogue && (
<div className="rounded-lg border bg-muted/30 p-3 max-h-72 overflow-y-auto">
<ModelCatalogue installedNames={installedNames} pulling={pulling} onPull={pull} />
</div>
)}
</div>
)
}
// ── Role Assignment ────────────────────────────────────────────────────────
const ROLE_LABELS: Record<string, { label: string; desc: string }> = {
summary: { label: 'Résumés', desc: 'Génération du résumé quotidien (passe 2)' },
report: { label: 'Rapports', desc: 'Réponses aux questions sur les résumés' },
filter: { label: 'Filtre articles', desc: 'Sélection des articles pertinents (passe 1)' },
}
function RoleCard({ role, providers, currentProviderID, onSave }: {
role: string; providers: AIProvider[]; currentProviderID: string
onSave: (providerID: string) => Promise<void>
}) {
const [providerID, setProviderID] = useState(currentProviderID)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => { setProviderID(currentProviderID) }, [currentProviderID])
const { label, desc } = ROLE_LABELS[role]
async function save() {
setSaving(true)
try { await onSave(providerID); setSaved(true); setTimeout(() => setSaved(false), 2000) }
finally { setSaving(false) }
}
return (
<div className="grid gap-3 sm:grid-cols-[1fr_auto] items-end">
<div>
<Label className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{label}</Label>
<p className="text-xs text-muted-foreground mb-2">{desc}</p>
<Select value={providerID} onChange={e => setProviderID(e.target.value)}>
<option value=""> Fournisseur par défaut </option>
{providers.map(p => <option key={p.id} value={p.id}>{p.name}{p.model ? `${p.model}` : ''}</option>)}
</Select>
</div>
<Button size="sm" onClick={save} disabled={saving} className="whitespace-nowrap">
{saving ? <Spinner className="h-3 w-3" /> : saved ? '✓ Sauvegardé' : 'Appliquer'}
</Button>
</div>
)
}
// ── Ollama model manager card ──────────────────────────────────────────────
function OllamaModelsCard({ installedModels, onRefresh, onDelete }: {
installedModels: OllamaModelInfo[]
onRefresh: () => Promise<OllamaModelInfo[]>
onDelete: (name: string) => Promise<void>
}) {
const [showCatalogue, setShowCatalogue] = useState(false)
const [pulling, setPulling] = useState<string | null>(null)
const [deleting, setDeleting] = useState<string | null>(null)
const installedNames = new Set(installedModels.map(m => m.name))
async function pull(fullName: string) {
setPulling(fullName)
try {
await adminApi.pullOllamaModel(fullName)
await new Promise<void>(resolve => {
const iv = setInterval(async () => {
try {
const updated = await onRefresh()
if (updated.some(m => m.name === fullName)) { clearInterval(iv); resolve() }
} catch { /* ignore */ }
}, 4000)
setTimeout(() => { clearInterval(iv); resolve() }, 1800000)
})
} finally { setPulling(null) }
}
async function remove(name: string) {
setDeleting(name)
try { await onDelete(name) } finally { setDeleting(null) }
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2 text-base">
<HardDrive className="h-4 w-4" /> Modèles Ollama
</CardTitle>
<Button variant="outline" size="sm" onClick={() => setShowCatalogue(v => !v)}>
{showCatalogue ? <><ChevronUp className="h-3.5 w-3.5" /> Masquer</> : <><Download className="h-3.5 w-3.5" /> Installer</>}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-2 flex items-center gap-1.5">
<Cpu className="h-3.5 w-3.5" /> Installés ({installedModels.length})
</h3>
{installedModels.length === 0 ? (
<p className="text-sm text-muted-foreground italic">Aucun modèle installé</p>
) : (
<div className="space-y-1.5">
{installedModels.map(m => (
<div key={m.name} className="flex items-center justify-between rounded-md border bg-muted/30 px-3 py-2">
<div>
<span className="font-mono text-sm font-medium">{m.name}</span>
<span className="ml-3 text-xs text-muted-foreground">
{formatSize(m.size)}
{m.details.parameter_size && <> · {m.details.parameter_size}</>}
{m.details.quantization_level && <> · {m.details.quantization_level}</>}
</span>
</div>
<Button variant="ghost" size="sm"
className="text-destructive hover:text-destructive h-7 w-7 p-0"
onClick={() => remove(m.name)} disabled={deleting === m.name}>
{deleting === m.name ? <Spinner className="h-3 w-3" /> : <Trash2 className="h-3 w-3" />}
</Button>
</div>
))}
</div>
)}
</div>
{showCatalogue && (
<div className="rounded-lg border bg-muted/30 p-4">
<p className="text-sm font-semibold mb-3">Catalogue</p>
<ModelCatalogue installedNames={installedNames} pulling={pulling} onPull={pull} />
</div>
)}
</CardContent>
</Card>
)
}
// ── Main Page ──────────────────────────────────────────────────────────────
export function AIProviders() {
const [providers, setProviders] = useState<AIProvider[]>([])
const [roles, setRoles] = useState<AIRoles | null>(null)
const [ollamaModels, setOllamaModels] = useState<OllamaModelInfo[]>([])
const [loading, setLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [form, setForm] = useState({ name: 'openai', api_key: '', model: '', endpoint: '' })
const [editId, setEditId] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const isOllamaForm = form.name === 'ollama'
const loadOllamaModels = useCallback(async (): Promise<OllamaModelInfo[]> => {
try {
console.log('[ollama] fetching installed models…')
const m = await adminApi.listOllamaModels()
const list = m ?? []
console.log(`[ollama] ${list.length} model(s):`, list.map(x => x.name))
setOllamaModels(list)
return list
} catch (e) {
console.error('[ollama] error:', e)
return []
}
}, [])
const load = useCallback(async () => {
setLoading(true)
try {
const [p, r] = await Promise.all([adminApi.listProviders(), adminApi.getRoles()])
setProviders(p ?? [])
setRoles(r)
await loadOllamaModels()
} finally { setLoading(false) }
}, [loadOllamaModels])
useEffect(() => { load() }, [load])
async function save() {
setSaving(true); setError('')
try {
if (editId) { await adminApi.updateProvider(editId, form) }
else { await adminApi.createProvider(form) }
setShowForm(false); setEditId(null)
setForm({ name: 'openai', api_key: '', model: '', endpoint: '' })
await load()
} catch (e) { setError(e instanceof Error ? e.message : 'Erreur') }
finally { setSaving(false) }
}
async function setDefault(id: string) { await adminApi.activateProvider(id); await load() }
async function remove(id: string) {
if (!confirm('Supprimer ce fournisseur ?')) return
await adminApi.deleteProvider(id); await load()
}
function startEdit(p: AIProvider) {
setEditId(p.id); setForm({ name: p.name, api_key: '', model: p.model, endpoint: p.endpoint }); setShowForm(true)
}
async function saveRole(role: string, providerID: string) {
await adminApi.updateRole(role, { provider_id: providerID, model: '' }); await load()
}
async function deleteOllamaModel(name: string) {
await adminApi.deleteOllamaModel(name); await loadOllamaModels()
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Fournisseurs IA</h1>
<p className="text-muted-foreground text-sm">Configurez les fournisseurs et assignez un modèle à chaque tâche</p>
</div>
{loading ? (
<div className="flex justify-center py-12"><Spinner /></div>
) : (
<>
{/* Role assignments */}
{roles && providers.length > 0 && (
<Card>
<CardHeader><CardTitle className="text-base">Assignation des tâches</CardTitle></CardHeader>
<CardContent className="space-y-5 divide-y">
{(['summary', 'report', 'filter'] as const).map(role => (
<div key={role} className={role !== 'summary' ? 'pt-5' : ''}>
<RoleCard role={role} providers={providers}
currentProviderID={roles[role].provider_id}
onSave={pid => saveRole(role, pid)} />
</div>
))}
</CardContent>
</Card>
)}
{/* Providers list */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">Fournisseurs configurés</CardTitle>
<Button size="sm" onClick={() => { setShowForm(true); setEditId(null); setForm({ name: 'openai', api_key: '', model: '', endpoint: '' }) }}>
<Plus className="h-4 w-4" /> Ajouter
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{showForm && (
<div className="rounded-lg border p-4 space-y-4 bg-muted/30">
<p className="font-medium text-sm">{editId ? 'Modifier le fournisseur' : 'Nouveau fournisseur'}</p>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label>Fournisseur</Label>
<Select value={form.name} disabled={!!editId}
onChange={e => {
const name = e.target.value
setForm(f => ({ ...f, name, model: '', api_key: '', endpoint: name === 'ollama' ? 'http://ollama:11434' : '' }))
}}>
{PROVIDER_NAMES.map(n => <option key={n} value={n}>{n}</option>)}
</Select>
</div>
{!isOllamaForm && (
<div className="space-y-1">
<Label>Clé API {editId && <span className="text-muted-foreground text-xs">(vide = conserver)</span>}</Label>
<Input type="password" placeholder="sk-…" value={form.api_key} onChange={e => setForm(f => ({ ...f, api_key: e.target.value }))} />
</div>
)}
{isOllamaForm && (
<div className="space-y-1">
<Label>Endpoint</Label>
<Input value={form.endpoint || 'http://ollama:11434'} onChange={e => setForm(f => ({ ...f, endpoint: e.target.value }))} />
</div>
)}
</div>
<div className="space-y-1">
<Label>Modèle par défaut</Label>
{isOllamaForm ? (
<OllamaModelPicker value={form.model} installedModels={ollamaModels}
onSelect={m => setForm(f => ({ ...f, model: m }))}
onRefresh={loadOllamaModels} />
) : (
<Input placeholder="gpt-4o-mini, claude-sonnet-4-6…" value={form.model} onChange={e => setForm(f => ({ ...f, model: e.target.value }))} />
)}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex gap-2">
<Button size="sm" onClick={save} disabled={saving}>{saving ? <Spinner className="h-4 w-4" /> : 'Enregistrer'}</Button>
<Button size="sm" variant="outline" onClick={() => { setShowForm(false); setEditId(null) }}>Annuler</Button>
</div>
</div>
)}
{providers.length === 0 && !showForm && (
<p className="text-sm text-muted-foreground text-center py-4">Aucun fournisseur configuré</p>
)}
{providers.map(p => (
<div key={p.id} className={`flex flex-wrap items-center gap-3 rounded-md border px-3 py-2.5 ${p.is_active ? 'border-primary/50 bg-primary/5' : ''}`}>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold capitalize text-sm">{p.name}</span>
{p.is_active && <Badge variant="default" className="text-xs">Défaut</Badge>}
{!p.has_key && p.name !== 'ollama' && <Badge variant="outline" className="text-yellow-500 border-yellow-500 text-xs">Sans clé</Badge>}
</div>
<div className="text-xs text-muted-foreground flex gap-3 mt-0.5">
{p.model && <span>Modèle : <strong>{p.model}</strong></span>}
{p.endpoint && <span>{p.endpoint}</span>}
</div>
</div>
<div className="flex items-center gap-1.5">
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => startEdit(p)}>Modifier</Button>
{!p.is_active && (
<Button size="sm" variant="outline" className="h-7 px-2 text-xs" onClick={() => setDefault(p.id)}>
<Star className="h-3 w-3 mr-1" /> Défaut
</Button>
)}
<Button variant="ghost" size="sm" className="h-7 w-7 p-0 text-destructive hover:text-destructive" onClick={() => remove(p.id)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</CardContent>
</Card>
{/* Ollama model manager — always shown so user can install even without provider configured */}
<OllamaModelsCard
installedModels={ollamaModels}
onRefresh={loadOllamaModels}
onDelete={deleteOllamaModel}
/>
</>
)}
</div>
)
}

View File

@ -8,7 +8,8 @@ import { Label } from '@/components/ui/label'
import { Spinner } from '@/components/ui/spinner'
const NUMERIC_SETTINGS: Record<string, { label: string; description: string }> = {
summary_max_articles: { label: 'Articles max par résumé', description: 'Nombre maximum d\'articles envoyés à l\'IA' },
summary_max_articles: { label: 'Articles max par résumé', description: 'Nombre maximum d\'articles envoyés à l\'IA pour la passe 2 (résumé)' },
filter_batch_size: { label: 'Taille des batches (filtre)', description: 'Nombre d\'articles par appel IA lors de la passe 1 (filtrage). Réduire pour des réponses plus rapides.' },
}
const COMMON_TIMEZONES = [