Préchargement des modèles : warm + keep_alive + indicateur d'état

Évite le rechargement lent quand Ollama a déchargé le modèle de la VRAM :
- ollama_client.warm() : précharge un modèle (/api/generate sans prompt) ;
  chat() accepte keep_alive (durée de rétention en VRAM).
- agent.run_agent transmet keep_alive ; route chat le passe depuis la config.
- config : champ keep_alive (défaut 30m) par profil de modèle.
- routes/models : POST /api/models/warm, GET /api/models/loaded (placement
  GPU/CPU via /api/ps).
- main : préchargement du modèle par défaut au démarrage (arrière-plan,
  best-effort — n'empêche pas le démarrage si Ollama est absent).
- Frontend : préchargement automatique à la sélection d'un modèle, poll des
  modèles chargés (8s), pastille verte (GPU) / orange (CPU) / blanche (à
  charger) dans le sélecteur, réglage 'Maintien en VRAM' dans Configuration.

Tests : warm/loaded routes, keep_alive transmis, démarrage résilient sans
Ollama, build front.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
This commit is contained in:
Claude committed 2026-07-07 12:07:08 +00:00
1 parent d9be1c4dda
commit d9169a1d27
12 files changed
+205 -8

No files matched your search

+13 -4
View File
@@ -10,22 +10,31 @@ import { useStore } from "./store/useStore";
export default function App() {
const [view, setView] = useState<View>("chat");
const { refreshStatus, refreshSystemStats, refreshModels, refreshConfig } =
useStore();
const {
refreshStatus,
refreshSystemStats,
refreshModels,
refreshConfig,
refreshLoadedModels,
} = useStore();
// Au démarrage : statut Ollama + modèles + config. Poll du statut et des stats système.
// Au démarrage : statut Ollama + modèles + config. Poll du statut, des stats
// système et des modèles chargés en VRAM (indicateur de préchargement).
useEffect(() => {
refreshStatus();
refreshSystemStats();
refreshModels();
refreshConfig();
refreshLoadedModels();
const statusId = setInterval(refreshStatus, 10000);
const statsId = setInterval(refreshSystemStats, 2000);
const loadedId = setInterval(refreshLoadedModels, 8000);
return () => {
clearInterval(statusId);
clearInterval(statsId);
clearInterval(loadedId);
};
}, [refreshStatus, refreshSystemStats, refreshModels, refreshConfig]);
}, [refreshStatus, refreshSystemStats, refreshModels, refreshConfig, refreshLoadedModels]);
return (
<div className="flex h-full flex-col bg-base text-ink">
+29
View File
@@ -41,6 +41,34 @@ export async function getSystemStats(): Promise<SystemStats> {
return res.json();
}
export interface LoadedModel {
name: string;
on_gpu: boolean;
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> {
try {
await fetch("/api/models/warm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, keep_alive: keepAlive }),
});
} catch {
/* préchargement best-effort */
}
}
export async function getLoadedModels(): Promise<LoadedModel[]> {
try {
const res = await fetch("/api/models/loaded");
return (await res.json()).loaded;
} catch {
return [];
}
}
export async function getModels(): Promise<{
models: OllamaModel[];
default: string;
@@ -116,6 +144,7 @@ export interface AgentConfig {
self_review: boolean;
rag_enabled: boolean;
embed_model: string;
keep_alive: string;
}
// ── Benchmark de modèles ─────────────────────────────────────────────────
+29 -2
View File
@@ -4,10 +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 } = useStore();
const { models, selectedModel, setSelectedModel, loadedModels } = useStore();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const loadedInfo = (name: string) =>
loadedModels.find((m) => m.name === name);
const selectedLoaded = loadedInfo(selectedModel);
useEffect(() => {
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
@@ -24,7 +28,22 @@ export function ModelSelector() {
title={selectedModel || undefined}
style={{ borderRadius: 7 }}
>
<span className="h-2.5 w-2.5 border-2 border-line bg-white" />
<span
className={`h-2.5 w-2.5 border-2 border-line ${
selectedLoaded
? selectedLoaded.on_gpu
? "bg-ok"
: "bg-warn"
: "bg-white"
}`}
title={
selectedLoaded
? selectedLoaded.on_gpu
? "chargé sur GPU"
: `chargé (${selectedLoaded.gpu_percent}% GPU)`
: "à charger"
}
/>
<span className="min-w-0 truncate text-[14px] leading-none">{selectedModel || "—"}</span>
<ChevronDown size={12} className="flex-none" />
</button>
@@ -55,6 +74,14 @@ export function ModelSelector() {
<span className="min-w-0 flex-1 truncate text-xs text-ink-2" title={m.name}>
{m.name}
</span>
{loadedInfo(m.name) && (
<span
className={`h-1.5 w-1.5 border border-line ${
loadedInfo(m.name)!.on_gpu ? "bg-ok" : "bg-warn"
}`}
title={loadedInfo(m.name)!.on_gpu ? "chargé GPU" : "chargé CPU"}
/>
)}
{m.size_go > 0 && (
<span className="text-[10px] text-muted-2">{m.size_go} Go</span>
)}
+21
View File
@@ -405,6 +405,27 @@ export function SettingsView() {
/>
</div>
))}
<div className="mt-2 flex items-center gap-3 border-t-2 border-line-soft pt-3">
<span className="flex-1">
<span className="block text-[14px] text-ink">
Maintien en VRAM (préchargement)
</span>
<span className="block text-[12px] text-muted-2">
Garde le modèle chargé pour des réponses instantanées
</span>
</span>
<select
value={draft.keep_alive}
onChange={(e) => set("keep_alive", e.target.value)}
className="h-8 border-[3px] border-line bg-card px-2 text-[13px] text-ink"
>
<option value="0">Décharger aussitôt</option>
<option value="5m">5 min</option>
<option value="30m">30 min</option>
<option value="2h">2 heures</option>
<option value="-1">Toujours</option>
</select>
</div>
</Card>
<BenchCard />
+13
View File
@@ -7,6 +7,8 @@ import {
getSession,
getStatus,
getSystemStats,
getLoadedModels,
warmModel,
fileContent,
listFiles,
listSessions,
@@ -19,6 +21,7 @@ import {
type OllamaModel,
type OllamaStatus,
type Session,
type LoadedModel,
type SystemStats,
type ToolCall,
} from "../api/client";
@@ -26,6 +29,7 @@ import {
interface LokiState {
status: OllamaStatus | null;
systemStats: SystemStats | null;
loadedModels: LoadedModel[];
models: OllamaModel[];
selectedModel: string;
loadingModels: boolean;
@@ -59,6 +63,7 @@ interface LokiState {
setSelectedModel: (name: string) => void;
refreshStatus: () => Promise<void>;
refreshSystemStats: () => Promise<void>;
refreshLoadedModels: () => Promise<void>;
refreshModels: () => Promise<void>;
refreshFiles: () => Promise<void>;
@@ -75,6 +80,7 @@ let activeStreamController: AbortController | null = null;
export const useStore = create<LokiState>((set, get) => ({
status: null,
systemStats: null,
loadedModels: [],
models: [],
selectedModel: "",
loadingModels: false,
@@ -142,6 +148,9 @@ 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());
},
refreshFiles: async () => {
@@ -169,6 +178,10 @@ export const useStore = create<LokiState>((set, get) => ({
}
},
refreshLoadedModels: async () => {
set({ loadedModels: await getLoadedModels() });
},
refreshModels: async () => {
set({ loadingModels: true });
try {