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

@ -40,6 +40,7 @@ import {
} from "@/features/announcements";
import {
modelServerOverlayText,
describeModelServerDownload,
useModelServerLaunchState,
} from "@/features/agents";
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
@ -382,7 +383,12 @@ function LeafView({
const pinnedAgent = agentId
? agents.find((a) => a.id === agentId)
: undefined;
const modelServerOverlay = modelServerOverlayText(statusForAgent(pinnedAgent));
const modelServerStatus = statusForAgent(pinnedAgent);
const modelServerOverlay = modelServerOverlayText(modelServerStatus);
// F2 — download progress (bar/%/bytes/source) when the status carries it; null
// for every non-`downloading` state, so `starting`/`probing` show just the
// title. An indeterminate download (unknown total) yields `percent: null`.
const modelServerDownload = describeModelServerDownload(modelServerStatus);
const agentWork = agentId
? workState?.agents.find((row) => row.agentId === agentId)
: undefined;
@ -976,16 +982,101 @@ function LeafView({
userSelect: "none",
}}
>
<span
<div
style={{
display: "flex",
flexDirection: "column",
gap: 6,
minWidth: 0,
maxWidth: "80%",
background: "var(--color-surface, #1e1e1e)",
border: "1px solid var(--color-border, #3a3a3a)",
borderRadius: 4,
padding: "6px 12px",
padding: "8px 14px",
}}
>
{modelServerOverlay}
</span>
<span>{modelServerOverlay}</span>
{modelServerDownload && (
<>
{/* Determinate bar when the total is known; an indeterminate
track (no aria-valuenow → ARIA indeterminate) otherwise. */}
<div
data-testid="model-server-progress"
role="progressbar"
aria-label="progression du téléchargement"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={
modelServerDownload.percent != null
? Math.round(modelServerDownload.percent)
: undefined
}
style={{
position: "relative",
width: 220,
maxWidth: "100%",
height: 6,
borderRadius: 3,
overflow: "hidden",
background: "var(--color-raised, #2a2a2a)",
}}
>
<div
style={
modelServerDownload.percent != null
? {
height: "100%",
width: `${modelServerDownload.percent}%`,
background: "var(--color-primary, #5b9bd5)",
transition: "width 120ms linear",
}
: {
// Indeterminate: a sliding sliver.
position: "absolute",
top: 0,
bottom: 0,
width: "40%",
background: "var(--color-primary, #5b9bd5)",
animation:
"model-server-indeterminate 1.1s ease-in-out infinite",
}
}
/>
</div>
{(modelServerDownload.percent != null ||
modelServerDownload.bytesLabel) && (
<span
data-testid="model-server-progress-label"
style={{ fontSize: 12, color: "var(--color-content-muted, #9a9a9a)" }}
>
{modelServerDownload.percent != null
? `${Math.round(modelServerDownload.percent)} %`
: ""}
{modelServerDownload.percent != null &&
modelServerDownload.bytesLabel
? " · "
: ""}
{modelServerDownload.bytesLabel ?? ""}
</span>
)}
{modelServerDownload.source && (
<span
data-testid="model-server-source"
style={{
fontSize: 11,
color: "var(--color-content-muted, #9a9a9a)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={modelServerDownload.source}
>
{modelServerDownload.source}
</span>
)}
</>
)}
</div>
</div>
)}
</div>