From 0f6abf6ddeec53ba8556d518add6808ca9bb9903 Mon Sep 17 00:00:00 2001 From: R0m1k3 Date: Tue, 7 Jul 2026 15:25:12 +0200 Subject: [PATCH] =?UTF-8?q?Fiabilise=20le=20pr=C3=A9chargement=20et=20le?= =?UTF-8?q?=20benchmark?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/bench.py | 13 +++-- backend/app/ollama_client.py | 2 +- backend/app/routes/benchmark.py | 13 ++++- frontend/src/api/client.ts | 70 ++++++++++++++--------- frontend/src/components/ModelSelector.tsx | 19 +++++- frontend/src/panels/SettingsView.tsx | 42 ++++++++++++++ frontend/src/store/useStore.ts | 44 ++++++++++---- 7 files changed, 154 insertions(+), 49 deletions(-) diff --git a/backend/app/bench.py b/backend/app/bench.py index 1d0b12b..3200341 100644 --- a/backend/app/bench.py +++ b/backend/app/bench.py @@ -17,7 +17,7 @@ from typing import AsyncIterator import httpx from . import db -from .ollama_client import ollama +from .ollama_client import OllamaError, ollama BENCH_KEY = "bench" # config[bench] = {model: {score, details, at}} @@ -155,8 +155,9 @@ async def _task_json(model: str) -> tuple[int, str]: return 5, "JSON invalide" ok_nom = "marie" in str(data.get("nom", "")).lower() ok_ville = "lyon" in str(data.get("ville", "")).lower() - return 10 + 5 * ok_nom + 5 * ok_ville, "extraction correcte" \ - if ok_nom and ok_ville else (10 + 5 * ok_nom + 5 * ok_ville, "extraction partielle") + score = 10 + 5 * ok_nom + 5 * ok_ville + detail = "extraction correcte" if ok_nom and ok_ville else "extraction partielle" + return score, detail async def _task_format(model: str) -> tuple[int, str]: @@ -190,8 +191,12 @@ async def run_bench(model: str) -> AsyncIterator[dict]: yield {"type": "task_start", "task": name} try: score, detail = await fn(model) - except (httpx.HTTPError, OSError) as exc: + except (OllamaError, httpx.HTTPError, OSError) as exc: score, detail = 0, f"erreur : {str(exc)[:80]}" + except Exception as exc: + # Une épreuve défaillante ne doit pas couper silencieusement le SSE : + # elle vaut zéro et les autres épreuves continuent. + score, detail = 0, f"épreuve interrompue : {str(exc)[:80]}" total += score details.append({"task": name, "score": score, "detail": detail}) yield {"type": "task_done", "task": name, "score": score, "detail": detail} diff --git a/backend/app/ollama_client.py b/backend/app/ollama_client.py index d9eb45f..34808d0 100644 --- a/backend/app/ollama_client.py +++ b/backend/app/ollama_client.py @@ -125,7 +125,7 @@ class OllamaClient: async with httpx.AsyncClient(timeout=120.0, follow_redirects=True) as client: resp = await client.post( f"{self.host}/api/generate", - json={"model": model, "keep_alive": keep_alive}, + json={"model": model, "keep_alive": keep_alive, "stream": False}, ) resp.raise_for_status() return resp.json() diff --git a/backend/app/routes/benchmark.py b/backend/app/routes/benchmark.py index f4e246a..bd00614 100644 --- a/backend/app/routes/benchmark.py +++ b/backend/app/routes/benchmark.py @@ -24,9 +24,16 @@ async def scores() -> dict: @router.post("") async def run(req: BenchRequest) -> StreamingResponse: async def event_stream(): - async for ev in bench.run_bench(req.model): - etype = ev.pop("type") - yield f"event: {etype}\ndata: {json.dumps(ev, ensure_ascii=False)}\n\n" + try: + async for ev in bench.run_bench(req.model): + etype = ev.pop("type") + yield f"event: {etype}\ndata: {json.dumps(ev, ensure_ascii=False)}\n\n" + except Exception as exc: + payload = json.dumps( + {"message": f"benchmark interrompu : {str(exc)[:200]}"}, + ensure_ascii=False, + ) + yield f"event: error\ndata: {payload}\n\n" return StreamingResponse( event_stream(), diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 79578bf..37fcdd7 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -47,16 +47,24 @@ export interface LoadedModel { gpu_percent: number; } -/** Précharge un modèle en VRAM (best-effort, ne lève jamais). */ -export async function warmModel(name: string, keepAlive = "30m"): Promise { +async function apiError(res: Response, fallback: string): Promise { try { - await fetch("/api/models/warm", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, keep_alive: keepAlive }), - }); + const payload = await res.json(); + return new Error(payload?.detail ?? payload?.error ?? fallback); } catch { - /* préchargement best-effort */ + return new Error(fallback); + } +} + +/** Précharge un modèle en mémoire et remonte toute erreur à l'interface. */ +export async function warmModel(name: string, keepAlive = "30m"): Promise { + const res = await fetch("/api/models/warm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, keep_alive: keepAlive }), + }); + if (!res.ok) { + throw await apiError(res, `préchargement refusé (${res.status})`); } } @@ -175,39 +183,47 @@ export async function runBench( headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model }), }); - if (!res.body) return null; + if (!res.ok) { + throw await apiError(res, `benchmark refusé (${res.status})`); + } + if (!res.body) throw new Error("le serveur n'a pas renvoyé de progression"); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let final: BenchResult | null = null; + const dispatch = (block: string) => { + let event = "message"; + const dataLines: string[] = []; + for (const line of block.replace(/\r\n/g, "\n").split("\n")) { + if (line.startsWith("event:")) event = line.slice(6).trim(); + else if (line.startsWith("data:")) dataLines.push(line.slice(5).trimStart()); + } + if (dataLines.length === 0) return; + const payload = JSON.parse(dataLines.join("\n")); + if (event === "task_start") onProgress(payload.task, null); + else if (event === "task_done") + onProgress(payload.task, payload.score, payload.detail); + else if (event === "error") + throw new Error(payload.message ?? "le benchmark a échoué"); + else if (event === "done") + final = { score: payload.score, details: payload.details, at: Date.now() / 1000 }; + }; + while (true) { const { done, value } = await reader.read(); if (done) break; - buffer += decoder.decode(value, { stream: true }); + buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, "\n"); const events = buffer.split("\n\n"); buffer = events.pop() ?? ""; for (const block of events) { - let event = ""; - let data = ""; - for (const line of block.split("\n")) { - if (line.startsWith("event: ")) event = line.slice(7).trim(); - else if (line.startsWith("data: ")) data += line.slice(6); - } - if (!data) continue; - try { - const payload = JSON.parse(data); - if (event === "task_start") onProgress(payload.task, null); - else if (event === "task_done") - onProgress(payload.task, payload.score, payload.detail); - else if (event === "done") - final = { score: payload.score, details: payload.details, at: Date.now() / 1000 }; - } catch { - /* bloc partiel */ - } + if (block.trim()) dispatch(block); } } + buffer += decoder.decode(); + if (buffer.trim()) dispatch(buffer); + if (!final) throw new Error("le benchmark s'est interrompu avant le résultat"); return final; } diff --git a/frontend/src/components/ModelSelector.tsx b/frontend/src/components/ModelSelector.tsx index 2ce11a2..9a09755 100644 --- a/frontend/src/components/ModelSelector.tsx +++ b/frontend/src/components/ModelSelector.tsx @@ -4,7 +4,14 @@ import { ChevronDown } from "./Icon"; /** Sélecteur de modèle Ollama (chip orange de la barre supérieure). */ export function ModelSelector() { - const { models, selectedModel, setSelectedModel, loadedModels } = useStore(); + const { + models, + selectedModel, + setSelectedModel, + loadedModels, + warmingModel, + warmError, + } = useStore(); const [open, setOpen] = useState(false); const ref = useRef(null); @@ -30,14 +37,20 @@ export function ModelSelector() { > { const on = m.name === selectedModel; + const loaded = loadedModels.find((item) => item.name === m.name); + const warming = warmingModel === m.name; return (
+
+ + + {warming + ? "Préchargement en cours…" + : loaded?.on_gpu + ? "Chargé sur GPU" + : loaded + ? `Chargé · ${loaded.gpu_percent}% GPU` + : "Non chargé"} + +