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:
126
frontend/src/features/model-servers/useModelServers.ts
Normal file
126
frontend/src/features/model-servers/useModelServers.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user