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>
196 lines
6.9 KiB
TypeScript
196 lines
6.9 KiB
TypeScript
/**
|
|
* Shared correlation of an agent to its local model server's launch state
|
|
* (F35 / ticket #54). One place owns the chain
|
|
* `agentId → profileId → opencode.localModelServerId → statusByServer[serverId]`
|
|
* so both the {@link AgentsPanel} badge and the full-cell overlay in
|
|
* `LayoutGrid`/`LeafView` read the same truth instead of duplicating it.
|
|
*
|
|
* Pure data + a thin `useGateways`-backed hook: no Tauri names here (the event
|
|
* stream and profile list come through the gateways/adapters).
|
|
*/
|
|
|
|
import { useCallback, useEffect, useState } from "react";
|
|
|
|
import type { Agent, AgentProfile, ModelServerStatus } from "@/domain";
|
|
import { useGateways } from "@/app/di";
|
|
|
|
/**
|
|
* Correlate a pinned agent to its model server's current lifecycle status, if
|
|
* any. Returns `undefined` when the agent's profile binds no local model server
|
|
* or when that server has emitted no status yet.
|
|
*/
|
|
export function correlateModelServerStatus(
|
|
agent: Pick<Agent, "profileId"> | undefined,
|
|
profiles: AgentProfile[],
|
|
statusByServer: Record<string, ModelServerStatus>,
|
|
): ModelServerStatus | undefined {
|
|
if (!agent) return undefined;
|
|
const serverId = profiles.find((p) => p.id === agent.profileId)?.opencode
|
|
?.localModelServerId;
|
|
return serverId ? statusByServer[serverId] : undefined;
|
|
}
|
|
|
|
/**
|
|
* The full-cell overlay text for a model server that is still preparing, or
|
|
* `null` when no overlay should show. Only the "not yet usable" states raise a
|
|
* veil — `ready`/`failed`/`notConfigured` (and an absent status) return `null`:
|
|
* - `downloading` → "Téléchargement du modèle…",
|
|
* - `starting`/`probing` → "Chargement du serveur…".
|
|
*
|
|
* 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,
|
|
): string | null {
|
|
if (!status) return null;
|
|
switch (status.state) {
|
|
case "downloading":
|
|
return "Téléchargement du modèle…";
|
|
case "starting":
|
|
case "probing":
|
|
return "Chargement du serveur…";
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
/**
|
|
* The correlated model-server status for a pinned agent (or `undefined` when
|
|
* it has no bound server / no status yet). Stable across renders unless the
|
|
* profiles or the per-server status map change.
|
|
*/
|
|
statusForAgent: (
|
|
agent: Pick<Agent, "profileId"> | undefined,
|
|
) => ModelServerStatus | undefined;
|
|
}
|
|
|
|
/**
|
|
* Self-contained correlation source for consumers that do NOT already hold the
|
|
* `useAgents` view-model (e.g. `LeafView`). Loads the profiles once and folds
|
|
* every `modelServerStatusChanged` event into a per-server status map, exactly
|
|
* like `useAgents` does — reusing {@link correlateModelServerStatus} for the
|
|
* lookup. A profile change (which may rebind the server) re-pulls the profiles.
|
|
*
|
|
* `profile`/`system` may be absent in unit tests that inject a partial gateway
|
|
* set; both are guarded, in which case the status is simply never found.
|
|
*/
|
|
export function useModelServerLaunchState(
|
|
_projectId: string,
|
|
): ModelServerLaunchState {
|
|
const { profile, system } = useGateways();
|
|
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
|
|
const [statusByServer, setStatusByServer] = useState<
|
|
Record<string, ModelServerStatus>
|
|
>({});
|
|
|
|
// Load (and reload) the profiles: the `profileId → localModelServerId`
|
|
// binding lives on the profile, so we need the current list to correlate.
|
|
const reloadProfiles = useCallback(() => {
|
|
if (!profile) return;
|
|
profile
|
|
.listProfiles()
|
|
.then(setProfiles)
|
|
.catch(() => {
|
|
/* ignore — no binding resolvable, overlay stays off */
|
|
});
|
|
}, [profile]);
|
|
|
|
useEffect(() => {
|
|
reloadProfiles();
|
|
}, [reloadProfiles]);
|
|
|
|
// Fold the model-server lifecycle stream, keyed by server id — the same
|
|
// reduction `useAgents` performs. A profile change may rebind the server, so
|
|
// re-pull the profiles too.
|
|
useEffect(() => {
|
|
if (!system) return;
|
|
let unsubscribe: (() => void) | undefined;
|
|
let cancelled = false;
|
|
void system
|
|
.onDomainEvent((event) => {
|
|
if (event.type === "modelServerStatusChanged") {
|
|
setStatusByServer((prev) => ({
|
|
...prev,
|
|
[event.serverId]: event.status,
|
|
}));
|
|
} else if (event.type === "agentProfileChanged") {
|
|
reloadProfiles();
|
|
}
|
|
})
|
|
.then((un) => {
|
|
if (cancelled) un();
|
|
else unsubscribe = un;
|
|
});
|
|
return () => {
|
|
cancelled = true;
|
|
unsubscribe?.();
|
|
};
|
|
}, [system, reloadProfiles]);
|
|
|
|
const statusForAgent = useCallback(
|
|
(agent: Pick<Agent, "profileId"> | undefined) =>
|
|
correlateModelServerStatus(agent, profiles, statusByServer),
|
|
[profiles, statusByServer],
|
|
);
|
|
|
|
return { statusForAgent };
|
|
}
|