feat(model-server): frontend modèles locaux — badge de statut & CRUD serveurs (#35) et wizard multi-profils OpenCode (#36)

Sprint « Modeles locaux », couche frontend.

#35 :
- F35.1 badge de statut de lancement du serveur local
  (ModelServerLaunchBadge + useAgentsModelServer).
- F35.2 feature model-servers : CRUD (ModelServersPanel / useModelServers /
  gateway modelServer) et ModelServerSelect.

#36 :
- Liste multi-profils OpenCode dans le wizard de premier lancement,
  gateway de clonage (clone_opencode_profile_from_seed).

Tests verts (exécution réelle) : tsc propre, vitest 608/608.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 15:50:18 +02:00
parent b82ac76f8b
commit 72476a650a
23 changed files with 1893 additions and 15 deletions

View File

@ -0,0 +1,126 @@
/**
* `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 } 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;
/** Whether a request is in flight. */
busy: boolean;
/** 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>;
/** 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 [busy, setBusy] = useState(false);
const reload = useCallback(async () => {
setBusy(true);
setError(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);
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);
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 clearError = useCallback(() => setError(null), []);
return { servers, error, busy, reload, save, remove, clearError };
}