AI Vision: multi-provider config, model testing, per-client AI preferences
CI / Lint & Format (push) Failing after 11s
CI / Tests (push) Has been skipped
CI / Security Scan (push) Failing after 8s
CI / Docker Build (push) Has been skipped

Backend:
- Add ai_provider + ai_model columns to APIClient model
- New admin_ai router: GET /admin/api/ai/status + POST /admin/api/ai/test
- Update ai_vision.py: provider_override + model_override support
- Update pipeline: load client AI prefs for image processing
- ClientUpdate/ClientResponse schemas include ai_provider/ai_model

Frontend:
- New AIConfigSection component: provider list, model dropdowns,
  test button with live results (no page refresh needed)
- AI types (ai.types.ts) + API (api/ai.ts)
- APIClient types extended with ai_provider/ai_model
This commit is contained in:
2026-06-25 10:30:50 -04:00
parent 1260a46c7c
commit 9bb440707c
12 changed files with 691 additions and 14 deletions
+10
View File
@@ -0,0 +1,10 @@
import { apiGet, apiPost } from './client';
import type { AIStatus, TestModelRequest, TestModelResponse } from '@/types/ai.types';
export async function fetchAIStatus(): Promise<AIStatus> {
return apiGet<AIStatus>('/admin/api/ai/status');
}
export async function testModel(body: TestModelRequest): Promise<TestModelResponse> {
return apiPost<TestModelResponse>('/admin/api/ai/test', body);
}
@@ -0,0 +1,324 @@
import { useState, useEffect, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { fetchAIStatus, testModel } from '@/api/ai';
import type { AIStatus, TestModelResponse } from '@/types/ai.types';
import { Bot, Zap, AlertTriangle, CheckCircle, Loader2, RefreshCw, Cpu, ChevronDown } from 'lucide-react';
import { clsx } from 'clsx';
export function AIConfigSection() {
const { data: aiStatus, isLoading, refetch } = useQuery({
queryKey: ['ai-status'],
queryFn: fetchAIStatus,
refetchInterval: 30000,
staleTime: 10000,
});
const [selectedProvider, setSelectedProvider] = useState<string>('');
const [selectedModel, setSelectedModel] = useState<string>('');
const [testResult, setTestResult] = useState<TestModelResponse | null>(null);
const [isTesting, setIsTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null);
// Sync dropdowns with current config when data loads
useEffect(() => {
if (aiStatus && !selectedProvider) {
setSelectedProvider(aiStatus.current_provider);
setSelectedModel(aiStatus.current_model);
}
}, [aiStatus, selectedProvider]);
// When provider changes, auto-select first model
const currentProviderModels = aiStatus?.providers.find(
p => p.key === selectedProvider
)?.models || [];
const handleProviderChange = useCallback((provider: string) => {
setSelectedProvider(provider);
const providerInfo = aiStatus?.providers.find(p => p.key === provider);
if (providerInfo?.models.length) {
setSelectedModel(providerInfo.models[0]);
} else {
setSelectedModel('');
}
setTestResult(null);
setTestError(null);
}, [aiStatus]);
const handleTest = useCallback(async () => {
if (!selectedProvider || !selectedModel) return;
setIsTesting(true);
setTestResult(null);
setTestError(null);
try {
const result = await testModel({
provider: selectedProvider,
model: selectedModel,
});
setTestResult(result);
} catch (err: any) {
setTestError(err?.message || 'Erreur de test');
} finally {
setIsTesting(false);
}
}, [selectedProvider, selectedModel]);
if (isLoading) {
return (
<section className="card mb-6">
<div className="flex items-center gap-2 mb-4">
<Bot size={16} className="text-content-muted" />
<h3 className="font-semibold text-content-primary">Intelligence Artificielle</h3>
</div>
<div className="animate-pulse space-y-3">
<div className="h-8 bg-surface-overlay rounded-lg" />
<div className="h-8 bg-surface-overlay rounded-lg" />
</div>
</section>
);
}
const activeProviderInfo = aiStatus?.providers.find(p => p.key === aiStatus.current_provider);
return (
<section className="card mb-6">
<div className="flex items-center gap-2 mb-4">
<Bot size={16} className="text-content-muted" />
<h3 className="font-semibold text-content-primary">Intelligence Artificielle</h3>
{aiStatus?.ai_enabled ? (
<span className="ml-auto flex items-center gap-1 text-xs text-status-ok bg-status-ok/10 px-2 py-0.5 rounded-full">
<CheckCircle size={10} /> Activé
</span>
) : (
<span className="ml-auto flex items-center gap-1 text-xs text-status-warning bg-status-warning/10 px-2 py-0.5 rounded-full">
<AlertTriangle size={10} /> Désactivé
</span>
)}
</div>
{/* Current configuration */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 mb-5">
<div className="bg-surface/50 rounded-lg p-3">
<div className="text-xs text-content-muted mb-1">Fournisseur actif</div>
<div className="flex items-center gap-2">
<Cpu size={14} className="text-brand-400" />
<span className="text-sm font-medium text-content-primary capitalize">
{activeProviderInfo?.name || aiStatus?.current_provider || '—'}
</span>
{activeProviderInfo?.configured ? (
<CheckCircle size={12} className="text-status-ok ml-auto" />
) : (
<AlertTriangle size={12} className="text-status-warning ml-auto" />
)}
</div>
</div>
<div className="bg-surface/50 rounded-lg p-3">
<div className="text-xs text-content-muted mb-1">Modèle actif</div>
<div className="flex items-center gap-2">
<Zap size={14} className="text-amber-400" />
<span className="text-sm font-medium text-content-primary truncate">
{aiStatus?.current_model || '—'}
</span>
</div>
</div>
<div className="bg-surface/50 rounded-lg p-3">
<div className="text-xs text-content-muted mb-1">Statut</div>
<div className={clsx(
'text-sm font-medium',
aiStatus?.ai_enabled && activeProviderInfo?.configured
? 'text-status-ok'
: 'text-status-warning'
)}>
{aiStatus?.ai_enabled && activeProviderInfo?.configured
? '✅ Connecté'
: '⚠️ Non configuré'}
</div>
</div>
</div>
{/* Provider list */}
<div className="mb-5">
<h4 className="text-xs font-semibold text-content-muted uppercase tracking-wider mb-3">
Fournisseurs disponibles
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{aiStatus?.providers.map(provider => (
<div
key={provider.key}
className={clsx(
'bg-surface/50 rounded-lg p-3 border transition-colors',
provider.configured
? 'border-border hover:border-brand-500/30'
: 'border-status-error/20 opacity-60'
)}
>
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-content-primary text-sm">
{provider.name}
</span>
{provider.configured ? (
<span className="text-[10px] text-status-ok bg-status-ok/10 px-1.5 py-0.5 rounded">
configuré
</span>
) : (
<span className="text-[10px] text-status-error bg-status-error/10 px-1.5 py-0.5 rounded">
clé manquante
</span>
)}
</div>
{provider.configured && provider.models.length > 0 && (
<div className="text-xs text-content-muted">
{provider.models.length} modèle{provider.models.length > 1 ? 's' : ''} disponible{provider.models.length > 1 ? 's' : ''}
</div>
)}
</div>
))}
</div>
</div>
{/* Test section */}
<div className="border-t border-border pt-5">
<h4 className="text-xs font-semibold text-content-muted uppercase tracking-wider mb-3">
Tester un modèle
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
{/* Provider selector */}
<div>
<label className="block text-xs text-content-muted mb-1.5">Fournisseur</label>
<div className="relative">
<select
value={selectedProvider}
onChange={e => handleProviderChange(e.target.value)}
className="w-full bg-surface-overlay border border-border rounded-lg px-3 py-2 text-sm text-content-primary appearance-none cursor-pointer focus:outline-none focus:border-brand-500/50 transition-colors"
>
{aiStatus?.providers.filter(p => p.configured).map(p => (
<option key={p.key} value={p.key}>{p.name}</option>
))}
</select>
<ChevronDown size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-content-muted pointer-events-none" />
</div>
</div>
{/* Model selector */}
<div>
<label className="block text-xs text-content-muted mb-1.5">Modèle</label>
<div className="relative">
<select
value={selectedModel}
onChange={e => setSelectedModel(e.target.value)}
className="w-full bg-surface-overlay border border-border rounded-lg px-3 py-2 text-sm text-content-primary appearance-none cursor-pointer focus:outline-none focus:border-brand-500/50 transition-colors"
>
{currentProviderModels.length === 0 && (
<option value="">Aucun modèle</option>
)}
{currentProviderModels.map(m => (
<option key={m} value={m}>{m}</option>
))}
</select>
<ChevronDown size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-content-muted pointer-events-none" />
</div>
</div>
</div>
<button
onClick={handleTest}
disabled={isTesting || !selectedProvider || !selectedModel}
className="btn-primary flex items-center gap-2 text-sm disabled:opacity-50"
>
{isTesting ? (
<>
<Loader2 size={14} className="animate-spin" />
Test en cours...
</>
) : (
<>
<Zap size={14} />
Tester {selectedModel || 'le modèle'}
</>
)}
</button>
{/* Test result — live, no refresh needed */}
{(testResult || testError || isTesting) && (
<div className={clsx(
'mt-4 rounded-lg p-4 border transition-all',
isTesting && 'bg-surface/30 border-border animate-pulse',
testResult?.success && 'bg-status-ok/5 border-status-ok/20',
(testResult && !testResult.success) && 'bg-status-error/5 border-status-error/20',
testError && 'bg-status-error/5 border-status-error/20',
)}>
{isTesting && (
<div className="flex items-center gap-2 text-content-muted text-sm">
<Loader2 size={14} className="animate-spin" />
Envoi de la requête au modèle {selectedModel}...
</div>
)}
{testResult?.success && (
<div>
<div className="flex items-center gap-2 mb-2">
<CheckCircle size={14} className="text-status-ok" />
<span className="text-sm font-medium text-status-ok">Test réussi</span>
<span className="text-xs text-content-muted ml-auto">
{testResult.latency_ms}ms
</span>
</div>
{testResult.response && (
<p className="text-sm text-content-secondary bg-surface/50 rounded p-3 font-mono whitespace-pre-wrap break-all">
{testResult.response}
</p>
)}
{testResult.tokens && (
<div className="flex items-center gap-3 mt-2 text-xs text-content-muted">
{testResult.tokens.prompt !== undefined && (
<span>Prompt: {testResult.tokens.prompt} tokens</span>
)}
{testResult.tokens.completion !== undefined && (
<span>Completion: {testResult.tokens.completion} tokens</span>
)}
</div>
)}
</div>
)}
{(testResult && !testResult.success) && (
<div>
<div className="flex items-center gap-2 mb-2">
<AlertTriangle size={14} className="text-status-error" />
<span className="text-sm font-medium text-status-error">Échec du test</span>
<span className="text-xs text-content-muted ml-auto">
{testResult.latency_ms}ms
</span>
</div>
<p className="text-sm text-status-error/80 bg-status-error/5 rounded p-3 font-mono break-all">
{testResult.error || 'Erreur inconnue'}
</p>
</div>
)}
{testError && (
<div>
<div className="flex items-center gap-2 mb-2">
<AlertTriangle size={14} className="text-status-error" />
<span className="text-sm font-medium text-status-error">Erreur</span>
</div>
<p className="text-sm text-status-error/80">{testError}</p>
</div>
)}
{(testResult || testError) && (
<button
onClick={() => { setTestResult(null); setTestError(null); }}
className="mt-3 flex items-center gap-1 text-xs text-content-muted hover:text-content-secondary transition-colors"
>
<RefreshCw size={10} /> Effacer le résultat
</button>
)}
</div>
)}
</div>
</section>
);
}
+4 -1
View File
@@ -4,6 +4,7 @@ import { useHealth } from '@/hooks/useHealth';
import { Settings, Lock, Info } from 'lucide-react';
import { PLANS } from '@/utils/constants';
import { formatTokens, formatMB } from '@/utils/formatters';
import { AIConfigSection } from '@/components/admin/AIConfigSection';
export default function Config() {
const { data: health, isLoading } = useHealth();
@@ -112,6 +113,9 @@ export default function Config() {
</div>
</section>
{/* AI Configuration */}
<AIConfigSection />
{/* Components */}
<section className="card">
<div className="flex items-center gap-2 mb-4">
@@ -122,7 +126,6 @@ export default function Config() {
<ConfigRow label="Base de données" value={health?.checks?.database?.status === 'ok' ? 'PostgreSQL — Connecté' : 'Non connecté'} readOnly />
<ConfigRow label="Redis" value={health?.checks?.redis?.status === 'ok' ? 'Redis 7 — Connecté' : 'Non connecté'} readOnly />
<ConfigRow label="OCR" value={health?.checks?.tesseract?.status === 'ok' ? 'Tesseract — Actif' : 'Non disponible'} readOnly />
<ConfigRow label="AI Vision" value={health?.checks?.ai?.status === 'ok' ? 'Configuré' : 'Non configuré'} readOnly />
</div>
</section>
</PageLayout>
+31
View File
@@ -0,0 +1,31 @@
// ── AI Configuration ───────────────────────────────────────
export interface AIStatus {
ai_enabled: boolean;
current_provider: string;
current_model: string;
providers: AIProvider[];
}
export interface AIProvider {
key: string;
name: string;
configured: boolean;
models: string[];
}
export interface TestModelRequest {
provider: string;
model: string;
prompt?: string;
image_base64?: string;
}
export interface TestModelResponse {
success: boolean;
provider: string;
model: string;
response?: string;
tokens?: { prompt?: number; completion?: number };
latency_ms: number;
error?: string;
}
+4
View File
@@ -9,6 +9,8 @@ export interface APIClient {
quota_storage_mb: number;
quota_ai_tokens: number;
storage_used_bytes: number;
ai_provider: string | null;
ai_model: string | null;
created_at: string;
updated_at: string;
}
@@ -30,6 +32,8 @@ export interface APIClientUpdate {
quota_images?: number;
quota_storage_mb?: number;
quota_ai_tokens?: number;
ai_provider?: string | null;
ai_model?: string | null;
}
export interface APIClientCreateResponse extends APIClient {