/** * `useModelServers` — view-model for the local model server registry (F35.2). * Owns the declared-servers list and the CRUD actions, consuming the * {@link ModelServerGateway} exclusively (never `invoke()`), so the UI is * testable with a mock. * * Delete surfaces the backend `model_server_in_use` rejection as a dedicated, * human-readable message (a server still referenced by a profile cannot be * removed). */ import { useCallback, useEffect, useState } from "react"; import type { GatewayError, LocalModelServerConfig, ModelServerCommandPreview, } from "@/domain"; import { useGateways } from "@/app/di"; /** What the model-server UI needs from this hook. */ export interface ModelServersViewModel { /** The declared local model servers. */ servers: LocalModelServerConfig[]; /** Last error message, or `null`. */ error: string | null; /** Last non-blocking success message, or `null`. */ notice: string | null; /** Whether a request is in flight. */ busy: boolean; /** Server id whose managed artifact is currently being deleted, or `null`. */ deletingArtifactId: string | null; /** Reloads the server list. */ reload: () => Promise; /** Creates or updates a server; returns the persisted config (or `null` on error). */ save: (config: LocalModelServerConfig) => Promise; /** Deletes a server by id; returns `true` on success. */ remove: (serverId: string) => Promise; /** Deletes only the managed downloaded model artifact; returns `true` on success. */ deleteArtifact: (serverId: string) => Promise; /** * Asks the backend to build the `llama-server` command line for a draft * (never reconstructed client-side). Returns `null` when the draft is * invalid — the editor shows a placeholder instead of a stale command. */ preview: ( config: LocalModelServerConfig, ) => Promise; /** Clears the current error banner. */ clearError: () => void; } function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); } return String(e); } function codeOf(e: unknown): string | undefined { if (e && typeof e === "object" && "code" in e) { return String((e as GatewayError).code); } return undefined; } export function useModelServers(): ModelServersViewModel { const { modelServer } = useGateways(); const [servers, setServers] = useState([]); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [busy, setBusy] = useState(false); const [deletingArtifactId, setDeletingArtifactId] = useState(null); const reload = useCallback(async () => { setBusy(true); setError(null); setNotice(null); try { setServers(await modelServer.listModelServers()); } catch (e) { setError(describe(e)); } finally { setBusy(false); } }, [modelServer]); useEffect(() => { void reload(); }, [reload]); const save = useCallback( async (config: LocalModelServerConfig) => { setBusy(true); setError(null); setNotice(null); try { const saved = await modelServer.saveModelServer(config); setServers((prev) => { const i = prev.findIndex((s) => s.id === saved.id); if (i >= 0) { const next = prev.slice(); next[i] = saved; return next; } return [...prev, saved]; }); return saved; } catch (e) { setError(describe(e)); return null; } finally { setBusy(false); } }, [modelServer], ); const remove = useCallback( async (serverId: string) => { setBusy(true); setError(null); setNotice(null); try { await modelServer.deleteModelServer(serverId); setServers((prev) => prev.filter((s) => s.id !== serverId)); return true; } catch (e) { // A referenced server cannot be deleted — turn the stable backend code // into an actionable message rather than a raw error string. if (codeOf(e) === "model_server_in_use") { setError( "Ce serveur est encore utilisé par un profil : détache-le d'abord (choisis « aucun » ou un autre serveur) avant de le supprimer.", ); } else { setError(describe(e)); } return false; } finally { setBusy(false); } }, [modelServer], ); const deleteArtifact = useCallback( async (serverId: string) => { setBusy(true); setDeletingArtifactId(serverId); setError(null); setNotice(null); try { await modelServer.deleteModelArtifact(serverId); setServers(await modelServer.listModelServers()); setNotice("Modèle téléchargé supprimé. Le serveur reste configuré."); return true; } catch (e) { const code = codeOf(e); if (code === "model_server_in_use") { setError("Impossible de supprimer ce modèle : téléchargement en cours ou agent actif."); } else if (code === "invalid") { setError("Aucun modèle téléchargé géré à supprimer."); } else if (code === "not_configured") { setError("Serveur introuvable."); } else { setError(describe(e)); } return false; } finally { setDeletingArtifactId(null); setBusy(false); } }, [modelServer], ); const preview = useCallback( async (config: LocalModelServerConfig) => { try { return await modelServer.previewModelServerCommand(config); } catch { // A preview is best-effort UI sugar: an invalid draft (e.g. no source) // simply yields no command, without touching the CRUD error banner. return null; } }, [modelServer], ); const clearError = useCallback(() => { setError(null); setNotice(null); }, []); return { servers, error, notice, busy, deletingArtifactId, reload, save, remove, deleteArtifact, preview, clearError, }; }