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:
@ -11,9 +11,14 @@ export type { ModelServerLaunchBadgeProps } from "./ModelServerLaunchBadge";
|
||||
export {
|
||||
correlateModelServerStatus,
|
||||
modelServerOverlayText,
|
||||
describeModelServerDownload,
|
||||
formatBytes,
|
||||
useModelServerLaunchState,
|
||||
} from "./modelServerLaunch";
|
||||
export type { ModelServerLaunchState } from "./modelServerLaunch";
|
||||
export type {
|
||||
ModelServerLaunchState,
|
||||
ModelServerDownloadProgress,
|
||||
} from "./modelServerLaunch";
|
||||
export { ResumeProjectPanel } from "./ResumeProjectPanel";
|
||||
export type { ResumeProjectPanelProps } from "./ResumeProjectPanel";
|
||||
export { useResumeProject } from "./useResumeProject";
|
||||
|
||||
109
frontend/src/features/agents/modelServerLaunch.test.ts
Normal file
109
frontend/src/features/agents/modelServerLaunch.test.ts
Normal file
@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Ticket #54 F2 — pure download-progress formatting for the model-server overlay.
|
||||
* These lock the wire→display mapping independently of React: byte formatting,
|
||||
* the determinate/indeterminate split, and the "no fake %" rule.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import type { ModelServerStatus } from "@/domain";
|
||||
import {
|
||||
formatBytes,
|
||||
describeModelServerDownload,
|
||||
modelServerOverlayText,
|
||||
} from "./modelServerLaunch";
|
||||
|
||||
function downloading(
|
||||
fields: Partial<{
|
||||
downloadedBytes: number | null;
|
||||
totalBytes: number | null;
|
||||
percent: number | null;
|
||||
source: string | null;
|
||||
}> = {},
|
||||
): ModelServerStatus {
|
||||
return {
|
||||
state: "downloading",
|
||||
downloadedBytes: null,
|
||||
totalBytes: null,
|
||||
percent: null,
|
||||
source: null,
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
|
||||
describe("formatBytes", () => {
|
||||
it("shows whole bytes and SI-scaled larger units", () => {
|
||||
expect(formatBytes(0)).toBe("0 o");
|
||||
expect(formatBytes(512)).toBe("512 o");
|
||||
expect(formatBytes(1000)).toBe("1 Ko");
|
||||
expect(formatBytes(1_500_000)).toBe("1.5 Mo");
|
||||
expect(formatBytes(4_200_000_000)).toBe("4.2 Go");
|
||||
});
|
||||
|
||||
it("guards non-finite / negative", () => {
|
||||
expect(formatBytes(-5)).toBe("0 o");
|
||||
expect(formatBytes(Number.NaN)).toBe("0 o");
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeModelServerDownload", () => {
|
||||
it("returns null for every non-downloading state", () => {
|
||||
expect(describeModelServerDownload(undefined)).toBeNull();
|
||||
expect(describeModelServerDownload({ state: "probing" })).toBeNull();
|
||||
expect(describeModelServerDownload({ state: "starting" })).toBeNull();
|
||||
expect(
|
||||
describeModelServerDownload({ state: "ready", reused: false }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("exposes percent + a 'X / Y' bytes label when both bounds are known", () => {
|
||||
const p = describeModelServerDownload(
|
||||
downloading({
|
||||
downloadedBytes: 1_500_000,
|
||||
totalBytes: 3_000_000,
|
||||
percent: 50,
|
||||
source: "unsloth/Qwen3-Coder-30B",
|
||||
}),
|
||||
);
|
||||
expect(p).toEqual({
|
||||
percent: 50,
|
||||
bytesLabel: "1.5 Mo / 3 Mo",
|
||||
source: "unsloth/Qwen3-Coder-30B",
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps percent to 0..100", () => {
|
||||
expect(describeModelServerDownload(downloading({ percent: 137 }))?.percent).toBe(
|
||||
100,
|
||||
);
|
||||
expect(describeModelServerDownload(downloading({ percent: -3 }))?.percent).toBe(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
it("is indeterminate (percent null) when the total is unknown", () => {
|
||||
const p = describeModelServerDownload(
|
||||
downloading({ downloadedBytes: 800_000, totalBytes: null, percent: null }),
|
||||
);
|
||||
expect(p).toEqual({ percent: null, bytesLabel: "800 Ko", source: null });
|
||||
});
|
||||
|
||||
it("has no bytes label when nothing about size is known", () => {
|
||||
expect(describeModelServerDownload(downloading())).toEqual({
|
||||
percent: null,
|
||||
bytesLabel: null,
|
||||
source: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("modelServerOverlayText (unchanged F1 titles)", () => {
|
||||
it("keeps the download / loading titles", () => {
|
||||
expect(modelServerOverlayText(downloading())).toBe(
|
||||
"Téléchargement du modèle…",
|
||||
);
|
||||
expect(modelServerOverlayText({ state: "starting" })).toBe(
|
||||
"Chargement du serveur…",
|
||||
);
|
||||
expect(modelServerOverlayText({ state: "ready", reused: true })).toBeNull();
|
||||
});
|
||||
});
|
||||
@ -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 {
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user