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
+2 -1
View File
@@ -12,7 +12,7 @@ from slowapi.errors import RateLimitExceeded
from app.config import settings
from app.database import init_db
from app.logging_config import configure_logging
from app.routers import images_router, ai_router, auth_router, files_router, ws_router, admin_router
from app.routers import images_router, ai_router, auth_router, files_router, ws_router, admin_router, admin_ai_router
from app.middleware import limiter
from app.middleware.logging_middleware import LoggingMiddleware
from app.middleware.versioning import APIVersioningMiddleware
@@ -171,6 +171,7 @@ app.include_router(api_v1_router)
# ── Routers non-versionnés ──────────────────────────────────
app.include_router(ws_router)
app.include_router(admin_router)
app.include_router(admin_ai_router)
# ── Routes utilitaires ──────────────────────────────────────
+4
View File
@@ -47,6 +47,10 @@ class APIClient(Base):
quota_storage_mb = Column(Integer, default=500, nullable=False)
quota_images = Column(Integer, default=1000, nullable=False)
# ── AI Preferences (per-client provider/model override) ────
ai_provider = Column(String(64), nullable=True)
ai_model = Column(String(256), nullable=True)
# ── Timestamps ────────────────────────────────────────────
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(
+2 -1
View File
@@ -4,5 +4,6 @@ from app.routers.auth import router as auth_router
from app.routers.files import router as files_router
from app.routers.websocket import router as ws_router
from app.routers.admin import router as admin_router
from app.routers.admin_ai import router as admin_ai_router
__all__ = ["images_router", "ai_router", "auth_router", "files_router", "ws_router", "admin_router"]
__all__ = ["images_router", "ai_router", "auth_router", "files_router", "ws_router", "admin_router", "admin_ai_router"]
+254
View File
@@ -0,0 +1,254 @@
"""
Router — Admin AI : configuration, test et préférences AI par client.
"""
import asyncio
import base64
import logging
import time
from typing import Optional
import httpx
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies.auth import require_scope
from app.models.client import APIClient
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/api/ai", tags=["Admin AI"])
# ─────────────────────────────────────────────────────────────
# Schemas
# ─────────────────────────────────────────────────────────────
class ProviderInfo(BaseModel):
key: str
name: str
configured: bool
models: list[str] = []
class AIStatusResponse(BaseModel):
ai_enabled: bool
current_provider: str
current_model: str
providers: list[ProviderInfo]
class TestModelRequest(BaseModel):
provider: str = Field(..., description="openrouter ou gemini")
model: str = Field(..., description="Nom du modèle à tester")
prompt: Optional[str] = Field(None, description="Prompt de test (optionnel)")
image_base64: Optional[str] = Field(None, description="Image base64 pour test vision (optionnel)")
class TestModelResponse(BaseModel):
success: bool
provider: str
model: str
response: Optional[str] = None
tokens: Optional[dict] = None
latency_ms: float = 0
error: Optional[str] = None
# ─────────────────────────────────────────────────────────────
# Constantes — Modèles disponibles par fournisseur
# ─────────────────────────────────────────────────────────────
OPENROUTER_VISION_MODELS = [
"google/gemini-2.5-flash-lite",
"google/gemini-2.5-flash",
"google/gemini-2.5-pro-preview",
"qwen/qwen2.5-vl-72b-instruct",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3-opus",
"openai/gpt-4o",
"openai/gpt-4o-mini",
"meta-llama/llama-4-maverick",
]
GEMINI_MODELS = [
"gemini-2.5-flash-lite",
"gemini-2.5-flash",
"gemini-2.5-pro-preview",
"gemini-2.0-flash",
]
def _build_providers() -> list[ProviderInfo]:
"""Construit la liste des fournisseurs disponibles selon la config .env."""
providers = []
# OpenRouter
or_configured = bool(settings.OPENROUTER_API_KEY)
providers.append(ProviderInfo(
key="openrouter",
name="OpenRouter",
configured=or_configured,
models=OPENROUTER_VISION_MODELS if or_configured else [],
))
# Gemini
gemini_configured = bool(settings.GEMINI_API_KEY)
providers.append(ProviderInfo(
key="gemini",
name="Google Gemini",
configured=gemini_configured,
models=GEMINI_MODELS if gemini_configured else [],
))
return providers
# ─────────────────────────────────────────────────────────────
# GET /admin/api/ai/status
# ─────────────────────────────────────────────────────────────
@router.get("/status", response_model=AIStatusResponse)
async def get_ai_status():
"""Retourne la configuration AI actuelle et les fournisseurs/modèles disponibles."""
active_model = (
settings.OPENROUTER_MODEL if settings.AI_PROVIDER == "openrouter"
else settings.GEMINI_MODEL
)
return AIStatusResponse(
ai_enabled=settings.AI_ENABLED,
current_provider=settings.AI_PROVIDER,
current_model=active_model,
providers=_build_providers(),
)
# ─────────────────────────────────────────────────────────────
# POST /admin/api/ai/test
# ─────────────────────────────────────────────────────────────
@router.post("/test", response_model=TestModelResponse)
async def test_model(
body: TestModelRequest,
_=Depends(require_scope("admin")),
):
"""Teste un modèle AI avec un prompt simple (et optionnellement une image)."""
t0 = time.monotonic()
if body.provider == "openrouter":
result = await _test_openrouter(body.model, body.prompt, body.image_base64)
elif body.provider == "gemini":
result = await _test_gemini(body.model, body.prompt, body.image_base64)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Fournisseur inconnu : {body.provider}",
)
result["latency_ms"] = round((time.monotonic() - t0) * 1000, 1)
result["provider"] = body.provider
result["model"] = body.model
return result
async def _test_openrouter(model: str, prompt: Optional[str], image_b64: Optional[str]) -> dict:
"""Appel test vers OpenRouter."""
if not settings.OPENROUTER_API_KEY:
return {"success": False, "error": "Clé API OpenRouter non configurée"}
test_prompt = prompt or "Réponds uniquement avec le mot 'OK' pour confirmer que tu fonctionnes."
headers = {
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
"Content-Type": "application/json",
"HTTP-Referer": "imago-test",
"X-Title": "Imago AI Test",
}
messages = []
content_payload = [{"type": "text", "text": test_prompt}]
if image_b64:
content_payload.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{image_b64}"},
})
messages.append({"role": "user", "content": content_payload})
payload = {
"model": model,
"messages": messages,
"max_tokens": 64,
}
try:
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://openrouter.ai/api/v1/chat/completions",
json=payload,
headers=headers,
timeout=60,
)
resp.raise_for_status()
data = resp.json()
text = data.get("choices", [{}])[0].get("message", {}).get("content", "")
usage = data.get("usage", {})
return {
"success": True,
"response": text[:500],
"tokens": {
"prompt": usage.get("prompt_tokens"),
"completion": usage.get("completion_tokens"),
},
}
except httpx.HTTPStatusError as e:
return {"success": False, "error": f"HTTP {e.response.status_code}: {e.response.text[:300]}"}
except Exception as e:
return {"success": False, "error": str(e)[:500]}
async def _test_gemini(model: str, prompt: Optional[str], image_b64: Optional[str]) -> dict:
"""Appel test vers Google Gemini."""
if not settings.GEMINI_API_KEY:
return {"success": False, "error": "Clé API Gemini non configurée"}
try:
from google import genai
from google.genai import types
except ImportError:
return {"success": False, "error": "SDK Google GenAI non installé"}
test_prompt = prompt or "Réponds uniquement avec le mot 'OK' pour confirmer que tu fonctionnes."
client = genai.Client(api_key=settings.GEMINI_API_KEY)
contents = []
if image_b64:
contents.append(types.Part.from_bytes(
data=base64.b64decode(image_b64),
mime_type="image/jpeg",
))
contents.append(test_prompt)
try:
response = await asyncio.wait_for(
asyncio.to_thread(
client.models.generate_content,
model=model,
contents=contents,
config=types.GenerateContentConfig(max_output_tokens=64),
),
timeout=60,
)
text = getattr(response, "text", "")
usage = getattr(response, "usage_metadata", None)
tokens = None
if usage:
tokens = {
"prompt": getattr(usage, "prompt_token_count", None),
"completion": getattr(usage, "candidates_token_count", None),
}
return {"success": True, "response": text[:500], "tokens": tokens}
except Exception as e:
return {"success": False, "error": str(e)[:500]}
+4
View File
@@ -47,6 +47,8 @@ class ClientUpdate(BaseModel):
scopes: Optional[List[str]] = None
plan: Optional[ClientPlan] = None
is_active: Optional[bool] = None
ai_provider: Optional[str] = Field(None, description="Fournisseur AI préféré (openrouter, gemini)")
ai_model: Optional[str] = Field(None, description="Modèle AI préféré")
# ─────────────────────────────────────────────────────────────
@@ -62,6 +64,8 @@ class ClientResponse(BaseModel):
scopes: List[str]
plan: ClientPlan
is_active: bool
ai_provider: Optional[str] = None
ai_model: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
+36 -11
View File
@@ -114,7 +114,8 @@ async def _generate_gemini(
prompt: str,
image_bytes: Optional[bytes] = None,
media_type: Optional[str] = None,
max_tokens: int = 1024
max_tokens: int = 1024,
model_override: Optional[str] = None,
) -> dict:
"""Appel à Google Gemini via SDK avec timeout et retry."""
if not settings.GEMINI_API_KEY:
@@ -127,11 +128,13 @@ async def _generate_gemini(
contents.append(types.Part.from_bytes(data=image_bytes, mime_type=media_type))
contents.append(prompt)
model = model_override or settings.GEMINI_MODEL
async def _call():
return await asyncio.wait_for(
asyncio.to_thread(
client.models.generate_content,
model=settings.GEMINI_MODEL,
model=model,
contents=contents,
config=types.GenerateContentConfig(
max_output_tokens=max_tokens,
@@ -154,13 +157,16 @@ async def _generate_openrouter(
prompt: str,
image_bytes: Optional[bytes] = None,
media_type: Optional[str] = None,
max_tokens: int = 1024
max_tokens: int = 1024,
model_override: Optional[str] = None,
) -> dict:
"""Appel à OpenRouter via HTTP avec timeout et retry."""
if not settings.OPENROUTER_API_KEY:
logger.warning("ai.openrouter.no_key")
return {"text": None, "usage": (None, None)}
model = model_override or settings.OPENROUTER_MODEL
headers = {
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
"Content-Type": "application/json",
@@ -184,7 +190,7 @@ async def _generate_openrouter(
messages.append({"role": "user", "content": content_payload})
payload = {
"model": settings.OPENROUTER_MODEL,
"model": model,
"messages": messages,
"max_tokens": max_tokens,
"response_format": {"type": "json_object"}
@@ -222,16 +228,24 @@ async def _generate(
prompt: str,
image_bytes: Optional[bytes] = None,
media_type: Optional[str] = None,
max_tokens: int = 1024
max_tokens: int = 1024,
provider_override: Optional[str] = None,
model_override: Optional[str] = None,
) -> dict:
"""Dispatcher vers le bon provider."""
provider = settings.AI_PROVIDER.lower()
"""Dispatcher vers le bon provider, avec override optionnel par client."""
provider = provider_override or settings.AI_PROVIDER.lower()
logger.info("ai.generate", extra={"provider": provider})
if provider == "openrouter":
return await _generate_openrouter(prompt, image_bytes, media_type, max_tokens)
return await _generate_openrouter(
prompt, image_bytes, media_type, max_tokens,
model_override=model_override,
)
else:
return await _generate_gemini(prompt, image_bytes, media_type, max_tokens)
return await _generate_gemini(
prompt, image_bytes, media_type, max_tokens,
model_override=model_override,
)
def _build_prompt(ocr_hint: Optional[str], language: str) -> str:
@@ -265,18 +279,27 @@ async def analyze_image(
file_path: str,
ocr_hint: Optional[str] = None,
language: str = "français",
provider_override: Optional[str] = None,
model_override: Optional[str] = None,
) -> dict:
"""
Envoie l'image à l'AI pour analyse (Description + Tags).
Supporte l'override par client pour le fournisseur/modèle.
"""
if not settings.AI_ENABLED:
return {}
active_provider = provider_override or settings.AI_PROVIDER
active_model = model_override or (
settings.OPENROUTER_MODEL if active_provider == "openrouter"
else settings.GEMINI_MODEL
)
result = {
"description": None,
"tags": [],
"confidence": None,
"model": settings.OPENROUTER_MODEL if settings.AI_PROVIDER == "openrouter" else settings.GEMINI_MODEL,
"model": active_model,
"prompt_tokens": None,
"output_tokens": None,
}
@@ -289,7 +312,9 @@ async def analyze_image(
prompt=prompt,
image_bytes=image_bytes,
media_type=media_type,
max_tokens=settings.GEMINI_MAX_TOKENS
max_tokens=settings.GEMINI_MAX_TOKENS,
provider_override=provider_override,
model_override=model_override,
)
text = response.get("text")
+16
View File
@@ -181,9 +181,25 @@ async def process_image_pipeline(
try:
logger.info("pipeline.step.start", extra={"image_id": image_id, "step": "ai", "step_num": "3/3"})
t0 = time.time()
# Charger les préférences AI du client
provider_override = None
model_override = None
if image.client_id:
from app.models.client import APIClient
client_result = await db.execute(
select(APIClient).where(APIClient.id == image.client_id)
)
owner = client_result.scalar_one_or_none()
if owner:
provider_override = owner.ai_provider
model_override = owner.ai_model
ai = await analyze_image(
file_path=file_path,
ocr_hint=image.ocr_text,
provider_override=provider_override,
model_override=model_override,
)
image.ai_description = ai.get("description")
+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 {