feat(model-server): progression fine du téléchargement du modèle llamacpp (#54)

Stretch B2/F2 de #54, par-dessus le MVP déjà mergé (B1/F1).

Backend : le port de téléchargement HF publie une progression débouncée
(bytes reçus / total, pourcentage) via le stream de statut du serveur
modèle, avec gestion du total inconnu (pas de faux %), du cache hit,
de l'annulation et du timeout.

Frontend : l'overlay plein-cellule de préparation du serveur affiche la
progression réelle (barre, %, octets, source) en mappant le fil de
statut, avec la règle « pas de faux % » quand le total est inconnu.

Tests : application + infrastructure (téléchargement débouncé, cancel,
timeout, cache hit, total inconnu) et vitest (overlay + formatage pur).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 10:37:14 +02:00
parent bd335c3a1c
commit 2183dfd291
15 changed files with 1368 additions and 87 deletions

View File

@ -37,9 +37,9 @@ export function correlateModelServerStatus(
* - `downloading` → "Téléchargement du modèle…",
* - `starting`/`probing` → "Chargement du serveur…".
*
* F1 MVP ignores the progress fields (bytes/percent) — the bar/% is the F2
* stretch. This is the single predicate that decides whether the launch overlay
* covers a cell.
* This is the single predicate that decides whether the launch overlay covers a
* cell (and supplies its title). The download *progress* (bar/%/bytes, F2) is a
* separate, additive concern — see {@link describeModelServerDownload}.
*/
export function modelServerOverlayText(
status: ModelServerStatus | undefined,
@ -56,6 +56,60 @@ export function modelServerOverlayText(
}
}
/**
* Human-readable byte size, decimal (SI) units so it matches what download UIs
* and llama.cpp report (`1 Mo = 1000 Ko`). Bytes are shown whole; larger units
* keep one decimal, trailing `.0` trimmed. Guards against non-finite/negative
* inputs (→ "0 o").
*/
export function formatBytes(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "0 o";
const units = ["o", "Ko", "Mo", "Go", "To"];
let value = n;
let unit = 0;
while (value >= 1000 && unit < units.length - 1) {
value /= 1000;
unit += 1;
}
const rounded =
unit === 0 ? Math.round(value) : Math.round(value * 10) / 10;
return `${rounded} ${units[unit]}`;
}
/**
* Download progress detail extracted from a `downloading` status (F2), or `null`
* for any other state (`starting`/`probing`/`ready`/`failed` → no bar). Mirrors
* the wire shape without inventing data:
* - `percent`: the backend's completion % clamped to `0..100` when the total is
* known, else `null` → the overlay shows an **indeterminate** bar (no fake %);
* - `bytesLabel`: `"X Mo / Y Mo"` when both bounds are known, `"X Mo"` when only
* the downloaded amount is, else `null`;
* - `source`: the remote model ref (e.g. HF `namespace/repo`) when present.
*/
export interface ModelServerDownloadProgress {
percent: number | null;
bytesLabel: string | null;
source: string | null;
}
export function describeModelServerDownload(
status: ModelServerStatus | undefined,
): ModelServerDownloadProgress | null {
if (!status || status.state !== "downloading") return null;
const { downloadedBytes, totalBytes, percent, source } = status;
const bytesLabel =
downloadedBytes != null && totalBytes != null
? `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}`
: downloadedBytes != null
? formatBytes(downloadedBytes)
: null;
return {
percent: percent != null ? Math.min(100, Math.max(0, percent)) : null,
bytesLabel,
source: source ?? null,
};
}
/** What {@link useModelServerLaunchState} exposes to a consumer. */
export interface ModelServerLaunchState {
/**