AI Vision: multi-provider config, model testing, per-client AI preferences
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:
+2
-1
@@ -12,7 +12,7 @@ from slowapi.errors import RateLimitExceeded
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import init_db
|
from app.database import init_db
|
||||||
from app.logging_config import configure_logging
|
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 import limiter
|
||||||
from app.middleware.logging_middleware import LoggingMiddleware
|
from app.middleware.logging_middleware import LoggingMiddleware
|
||||||
from app.middleware.versioning import APIVersioningMiddleware
|
from app.middleware.versioning import APIVersioningMiddleware
|
||||||
@@ -171,6 +171,7 @@ app.include_router(api_v1_router)
|
|||||||
# ── Routers non-versionnés ──────────────────────────────────
|
# ── Routers non-versionnés ──────────────────────────────────
|
||||||
app.include_router(ws_router)
|
app.include_router(ws_router)
|
||||||
app.include_router(admin_router)
|
app.include_router(admin_router)
|
||||||
|
app.include_router(admin_ai_router)
|
||||||
|
|
||||||
|
|
||||||
# ── Routes utilitaires ──────────────────────────────────────
|
# ── Routes utilitaires ──────────────────────────────────────
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ class APIClient(Base):
|
|||||||
quota_storage_mb = Column(Integer, default=500, nullable=False)
|
quota_storage_mb = Column(Integer, default=500, nullable=False)
|
||||||
quota_images = Column(Integer, default=1000, 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 ────────────────────────────────────────────
|
# ── Timestamps ────────────────────────────────────────────
|
||||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
updated_at = Column(
|
updated_at = Column(
|
||||||
|
|||||||
@@ -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.files import router as files_router
|
||||||
from app.routers.websocket import router as ws_router
|
from app.routers.websocket import router as ws_router
|
||||||
from app.routers.admin import router as admin_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"]
|
||||||
|
|||||||
@@ -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]}
|
||||||
@@ -47,6 +47,8 @@ class ClientUpdate(BaseModel):
|
|||||||
scopes: Optional[List[str]] = None
|
scopes: Optional[List[str]] = None
|
||||||
plan: Optional[ClientPlan] = None
|
plan: Optional[ClientPlan] = None
|
||||||
is_active: Optional[bool] = 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]
|
scopes: List[str]
|
||||||
plan: ClientPlan
|
plan: ClientPlan
|
||||||
is_active: bool
|
is_active: bool
|
||||||
|
ai_provider: Optional[str] = None
|
||||||
|
ai_model: Optional[str] = None
|
||||||
created_at: Optional[datetime] = None
|
created_at: Optional[datetime] = None
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|||||||
+36
-11
@@ -114,7 +114,8 @@ async def _generate_gemini(
|
|||||||
prompt: str,
|
prompt: str,
|
||||||
image_bytes: Optional[bytes] = None,
|
image_bytes: Optional[bytes] = None,
|
||||||
media_type: Optional[str] = None,
|
media_type: Optional[str] = None,
|
||||||
max_tokens: int = 1024
|
max_tokens: int = 1024,
|
||||||
|
model_override: Optional[str] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Appel à Google Gemini via SDK avec timeout et retry."""
|
"""Appel à Google Gemini via SDK avec timeout et retry."""
|
||||||
if not settings.GEMINI_API_KEY:
|
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(types.Part.from_bytes(data=image_bytes, mime_type=media_type))
|
||||||
contents.append(prompt)
|
contents.append(prompt)
|
||||||
|
|
||||||
|
model = model_override or settings.GEMINI_MODEL
|
||||||
|
|
||||||
async def _call():
|
async def _call():
|
||||||
return await asyncio.wait_for(
|
return await asyncio.wait_for(
|
||||||
asyncio.to_thread(
|
asyncio.to_thread(
|
||||||
client.models.generate_content,
|
client.models.generate_content,
|
||||||
model=settings.GEMINI_MODEL,
|
model=model,
|
||||||
contents=contents,
|
contents=contents,
|
||||||
config=types.GenerateContentConfig(
|
config=types.GenerateContentConfig(
|
||||||
max_output_tokens=max_tokens,
|
max_output_tokens=max_tokens,
|
||||||
@@ -154,13 +157,16 @@ async def _generate_openrouter(
|
|||||||
prompt: str,
|
prompt: str,
|
||||||
image_bytes: Optional[bytes] = None,
|
image_bytes: Optional[bytes] = None,
|
||||||
media_type: Optional[str] = None,
|
media_type: Optional[str] = None,
|
||||||
max_tokens: int = 1024
|
max_tokens: int = 1024,
|
||||||
|
model_override: Optional[str] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Appel à OpenRouter via HTTP avec timeout et retry."""
|
"""Appel à OpenRouter via HTTP avec timeout et retry."""
|
||||||
if not settings.OPENROUTER_API_KEY:
|
if not settings.OPENROUTER_API_KEY:
|
||||||
logger.warning("ai.openrouter.no_key")
|
logger.warning("ai.openrouter.no_key")
|
||||||
return {"text": None, "usage": (None, None)}
|
return {"text": None, "usage": (None, None)}
|
||||||
|
|
||||||
|
model = model_override or settings.OPENROUTER_MODEL
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
|
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
@@ -184,7 +190,7 @@ async def _generate_openrouter(
|
|||||||
messages.append({"role": "user", "content": content_payload})
|
messages.append({"role": "user", "content": content_payload})
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"model": settings.OPENROUTER_MODEL,
|
"model": model,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"max_tokens": max_tokens,
|
"max_tokens": max_tokens,
|
||||||
"response_format": {"type": "json_object"}
|
"response_format": {"type": "json_object"}
|
||||||
@@ -222,16 +228,24 @@ async def _generate(
|
|||||||
prompt: str,
|
prompt: str,
|
||||||
image_bytes: Optional[bytes] = None,
|
image_bytes: Optional[bytes] = None,
|
||||||
media_type: Optional[str] = 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:
|
) -> dict:
|
||||||
"""Dispatcher vers le bon provider."""
|
"""Dispatcher vers le bon provider, avec override optionnel par client."""
|
||||||
provider = settings.AI_PROVIDER.lower()
|
provider = provider_override or settings.AI_PROVIDER.lower()
|
||||||
logger.info("ai.generate", extra={"provider": provider})
|
logger.info("ai.generate", extra={"provider": provider})
|
||||||
|
|
||||||
if provider == "openrouter":
|
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:
|
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:
|
def _build_prompt(ocr_hint: Optional[str], language: str) -> str:
|
||||||
@@ -265,18 +279,27 @@ async def analyze_image(
|
|||||||
file_path: str,
|
file_path: str,
|
||||||
ocr_hint: Optional[str] = None,
|
ocr_hint: Optional[str] = None,
|
||||||
language: str = "français",
|
language: str = "français",
|
||||||
|
provider_override: Optional[str] = None,
|
||||||
|
model_override: Optional[str] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Envoie l'image à l'AI pour analyse (Description + Tags).
|
Envoie l'image à l'AI pour analyse (Description + Tags).
|
||||||
|
Supporte l'override par client pour le fournisseur/modèle.
|
||||||
"""
|
"""
|
||||||
if not settings.AI_ENABLED:
|
if not settings.AI_ENABLED:
|
||||||
return {}
|
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 = {
|
result = {
|
||||||
"description": None,
|
"description": None,
|
||||||
"tags": [],
|
"tags": [],
|
||||||
"confidence": None,
|
"confidence": None,
|
||||||
"model": settings.OPENROUTER_MODEL if settings.AI_PROVIDER == "openrouter" else settings.GEMINI_MODEL,
|
"model": active_model,
|
||||||
"prompt_tokens": None,
|
"prompt_tokens": None,
|
||||||
"output_tokens": None,
|
"output_tokens": None,
|
||||||
}
|
}
|
||||||
@@ -289,7 +312,9 @@ async def analyze_image(
|
|||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
image_bytes=image_bytes,
|
image_bytes=image_bytes,
|
||||||
media_type=media_type,
|
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")
|
text = response.get("text")
|
||||||
|
|||||||
@@ -181,9 +181,25 @@ async def process_image_pipeline(
|
|||||||
try:
|
try:
|
||||||
logger.info("pipeline.step.start", extra={"image_id": image_id, "step": "ai", "step_num": "3/3"})
|
logger.info("pipeline.step.start", extra={"image_id": image_id, "step": "ai", "step_num": "3/3"})
|
||||||
t0 = time.time()
|
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(
|
ai = await analyze_image(
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
ocr_hint=image.ocr_text,
|
ocr_hint=image.ocr_text,
|
||||||
|
provider_override=provider_override,
|
||||||
|
model_override=model_override,
|
||||||
)
|
)
|
||||||
|
|
||||||
image.ai_description = ai.get("description")
|
image.ai_description = ai.get("description")
|
||||||
|
|||||||
@@ -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,6 +4,7 @@ import { useHealth } from '@/hooks/useHealth';
|
|||||||
import { Settings, Lock, Info } from 'lucide-react';
|
import { Settings, Lock, Info } from 'lucide-react';
|
||||||
import { PLANS } from '@/utils/constants';
|
import { PLANS } from '@/utils/constants';
|
||||||
import { formatTokens, formatMB } from '@/utils/formatters';
|
import { formatTokens, formatMB } from '@/utils/formatters';
|
||||||
|
import { AIConfigSection } from '@/components/admin/AIConfigSection';
|
||||||
|
|
||||||
export default function Config() {
|
export default function Config() {
|
||||||
const { data: health, isLoading } = useHealth();
|
const { data: health, isLoading } = useHealth();
|
||||||
@@ -112,6 +113,9 @@ export default function Config() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* AI Configuration */}
|
||||||
|
<AIConfigSection />
|
||||||
|
|
||||||
{/* Components */}
|
{/* Components */}
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<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="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="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="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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ export interface APIClient {
|
|||||||
quota_storage_mb: number;
|
quota_storage_mb: number;
|
||||||
quota_ai_tokens: number;
|
quota_ai_tokens: number;
|
||||||
storage_used_bytes: number;
|
storage_used_bytes: number;
|
||||||
|
ai_provider: string | null;
|
||||||
|
ai_model: string | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -30,6 +32,8 @@ export interface APIClientUpdate {
|
|||||||
quota_images?: number;
|
quota_images?: number;
|
||||||
quota_storage_mb?: number;
|
quota_storage_mb?: number;
|
||||||
quota_ai_tokens?: number;
|
quota_ai_tokens?: number;
|
||||||
|
ai_provider?: string | null;
|
||||||
|
ai_model?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface APIClientCreateResponse extends APIClient {
|
export interface APIClientCreateResponse extends APIClient {
|
||||||
|
|||||||
Reference in New Issue
Block a user