Files
IdeaSDK/frontend/src/features/model-servers/useModelServers.ts
Blomios 02603441c1 fix: livrable tickets #70 #100 #102 — UX surface scoping
- #70: implémentation suppression modèles locaux téléchargés
- #100: correction scroll OpenCode
- #102: correction fit TUI après switch/layout
- memory note scoping UX
2026-07-26 19:42:06 +02:00

209 lines
6.2 KiB
TypeScript

/**
* `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<void>;
/** Creates or updates a server; returns the persisted config (or `null` on error). */
save: (config: LocalModelServerConfig) => Promise<LocalModelServerConfig | null>;
/** Deletes a server by id; returns `true` on success. */
remove: (serverId: string) => Promise<boolean>;
/** Deletes only the managed downloaded model artifact; returns `true` on success. */
deleteArtifact: (serverId: string) => Promise<boolean>;
/**
* 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<ModelServerCommandPreview | null>;
/** 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<LocalModelServerConfig[]>([]);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [deletingArtifactId, setDeletingArtifactId] = useState<string | null>(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,
};
}