Fiabilise le préchargement et le benchmark

This commit is contained in:
Michael committed 2026-07-07 15:25:12 +02:00
1 parent d9169a1d27
commit 0f6abf6dde
7 files changed
+154 -49

No files matched your search

+43 -27
View File
@@ -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<void> {
async function apiError(res: Response, fallback: string): Promise<Error> {
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<void> {
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;
}
+16 -3
View File
@@ -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<HTMLDivElement>(null);
@@ -30,14 +37,20 @@ export function ModelSelector() {
>
<span
className={`h-2.5 w-2.5 border-2 border-line ${
selectedLoaded
warmingModel === selectedModel
? "bg-accent animate-pulse"
: selectedLoaded
? selectedLoaded.on_gpu
? "bg-ok"
: "bg-warn"
: "bg-white"
}`}
title={
selectedLoaded
warmError
? warmError
: warmingModel === selectedModel
? "préchargement en cours…"
: selectedLoaded
? selectedLoaded.on_gpu
? "chargé sur GPU"
: `chargé (${selectedLoaded.gpu_percent}% GPU)`
+42
View File
@@ -23,6 +23,9 @@ export function SettingsView() {
selectedModel,
setSelectedModel,
refreshModels,
loadedModels,
warmingModel,
warmError,
status,
config,
availableTools,
@@ -153,6 +156,8 @@ export function SettingsView() {
)}
{models.map((m) => {
const on = m.name === selectedModel;
const loaded = loadedModels.find((item) => item.name === m.name);
const warming = warmingModel === m.name;
return (
<div
key={m.name}
@@ -187,6 +192,28 @@ export function SettingsView() {
.filter(Boolean)
.join(" · ")}
</div>
<div className="mt-1.5 flex items-center gap-1.5 text-[11px]">
<span
className={`h-2 w-2 border border-line ${
warming
? "bg-accent animate-pulse"
: loaded?.on_gpu
? "bg-ok"
: loaded
? "bg-warn"
: "bg-card"
}`}
/>
<span className={on ? "text-on-dark-2" : "text-muted-2"}>
{warming
? "Préchargement en cours…"
: loaded?.on_gpu
? "Chargé sur GPU"
: loaded
? `Chargé · ${loaded.gpu_percent}% GPU`
: "Non chargé"}
</span>
</div>
</div>
</button>
<button
@@ -205,6 +232,11 @@ export function SettingsView() {
{deleteError}
</div>
)}
{warmError && (
<div className="border-2 border-warn bg-base px-3 py-2 text-[12px] text-warn">
Préchargement : {warmError}
</div>
)}
</div>
<div className="my-4 h-[3px] bg-line" />
@@ -461,6 +493,7 @@ function BenchCard() {
const selectedModel = useStore((s) => s.selectedModel);
const [scores, setScores] = useState<Record<string, BenchResult>>({});
const [running, setRunning] = useState(false);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState<
{ task: string; score: number | null; detail?: string }[]
>([]);
@@ -472,6 +505,7 @@ function BenchCard() {
const launch = async () => {
if (!selectedModel || running) return;
setRunning(true);
setError(null);
setProgress([]);
try {
const result = await runBench(selectedModel, (task, score, detail) => {
@@ -481,6 +515,8 @@ function BenchCard() {
});
});
if (result) setScores((s) => ({ ...s, [selectedModel]: result }));
} catch (err) {
setError(err instanceof Error ? err.message : "benchmark impossible");
} finally {
setRunning(false);
}
@@ -524,6 +560,12 @@ function BenchCard() {
</div>
)}
{error && (
<div className="border-2 border-warn bg-base px-3 py-2 text-[12px] text-warn">
Échec du test : {error}
</div>
)}
{!running && current && (
<div className="border-2 border-line bg-base px-3 py-2">
<div className="mb-1 flex items-center justify-between">
+33 -11
View File
@@ -30,6 +30,8 @@ interface LokiState {
status: OllamaStatus | null;
systemStats: SystemStats | null;
loadedModels: LoadedModel[];
warmingModel: string | null;
warmError: string | null;
models: OllamaModel[];
selectedModel: string;
loadingModels: boolean;
@@ -81,6 +83,8 @@ export const useStore = create<LokiState>((set, get) => ({
status: null,
systemStats: null,
loadedModels: [],
warmingModel: null,
warmError: null,
models: [],
selectedModel: "",
loadingModels: false,
@@ -129,10 +133,11 @@ export const useStore = create<LokiState>((set, get) => ({
},
refreshConfig: async () => {
const { config, available_tools } = await getConfig(
get().selectedModel || undefined
);
set({ config, availableTools: available_tools });
const model = get().selectedModel;
const { config, available_tools } = await getConfig(model || undefined);
if (get().selectedModel === model) {
set({ config, availableTools: available_tools });
}
},
updateConfig: async (patch) => {
@@ -146,11 +151,26 @@ export const useStore = create<LokiState>((set, get) => ({
},
setSelectedModel: (name) => {
set({ selectedModel: name });
void get().refreshConfig();
// Préchargement : le modèle est chargé en VRAM dès la sélection.
const ka = get().config?.keep_alive ?? "30m";
void warmModel(name, ka).then(() => get().refreshLoadedModels());
set({ selectedModel: name, warmingModel: name || null, warmError: null });
void (async () => {
try {
await get().refreshConfig();
if (!name || get().selectedModel !== name) return;
// La configuration est propre au modèle : on attend son chargement avant
// d'utiliser keep_alive, sinon la valeur du modèle précédent est envoyée.
const ka = get().config?.keep_alive ?? "30m";
await warmModel(name, ka);
await get().refreshLoadedModels();
} catch (err) {
if (get().selectedModel === name) {
set({
warmError: err instanceof Error ? err.message : "préchargement impossible",
});
}
} finally {
if (get().warmingModel === name) set({ warmingModel: null });
}
})();
},
refreshFiles: async () => {
@@ -193,8 +213,10 @@ export const useStore = create<LokiState>((set, get) => ({
: installed.has(def)
? def
: models[0]?.name ?? "";
set({ models, selectedModel });
await get().refreshConfig();
set({ models });
// Passe par l'action de sélection afin de précharger aussi le modèle choisi
// automatiquement au démarrage (défaut ou premier modèle installé).
get().setSelectedModel(selectedModel);
} finally {
set({ loadingModels: false });
}