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,57 @@
/**
* `ModelServerSelect` (F35.2) — a dropdown to bind an OpenCode profile to one of
* the declared local model servers, or to none ("external endpoint"). Replaces
* the free-text `localModelServerId` input posed in F36. Presentational: the
* server list and the current value are passed in.
*/
import type { LocalModelServerConfig } from "@/domain";
import { cn } from "@/shared";
/** The sentinel option value standing for "no managed server". */
const NONE = "";
export interface ModelServerSelectProps {
/** The declared servers to choose from. */
servers: LocalModelServerConfig[];
/** The currently-bound server id, or `undefined` for none. */
value?: string;
/** Called with the new server id, or `undefined` when "none" is chosen. */
onChange: (serverId: string | undefined) => void;
/** Accessible label (defaults to a generic one). */
ariaLabel?: string;
}
export function ModelServerSelect({
servers,
value,
onChange,
ariaLabel = "local model server",
}: ModelServerSelectProps) {
// A previously-bound server that no longer exists (deleted): keep it visible
// so the binding isn't silently dropped from the UI.
const missing =
value !== undefined && !servers.some((s) => s.id === value);
return (
<select
aria-label={ariaLabel}
value={value ?? NONE}
onChange={(e) => onChange(e.target.value === NONE ? undefined : e.target.value)}
className={cn(
"h-8 rounded-md bg-raised px-2 text-xs text-content",
"border border-border outline-none focus:border-primary",
)}
>
<option value={NONE}>None (external endpoint)</option>
{servers.map((s) => (
<option key={s.id} value={s.id}>
{s.name} {s.servedModelName}
</option>
))}
{missing && (
<option value={value}>{value} (unknown / removed)</option>
)}
</select>
);
}

View File

@ -0,0 +1,323 @@
/**
* `ModelServersPanel` (F35.2) — declare / edit / delete the local `llama.cpp`
* servers an OpenCode profile can bind to. Presentational: all I/O state comes
* from a {@link ModelServersViewModel} passed in (`useModelServers`), so the
* panel renders and is testable in isolation.
*
* A single editable draft is shown at a time (add or edit); the list underneath
* offers Edit / Delete per server. The `model_server_in_use` rejection surfaces
* as the vm's error banner (a referenced server can't be removed).
*/
import { useState } from "react";
import type { LocalModelServerConfig, StopPolicy } from "@/domain";
import { Button, IconButton, Input, Panel, cn } from "@/shared";
import type { ModelServersViewModel } from "./useModelServers";
import {
emptyModelServer,
parseArgs,
validateModelServer,
type ModelServerErrors,
} from "./modelServer";
/** Small caption above a control. */
function Caption({ children }: { children: React.ReactNode }) {
return <span className="text-xs font-medium text-muted">{children}</span>;
}
const STOP_POLICIES: { value: StopPolicy; label: string }[] = [
{ value: "stopOnAppExit", label: "Stop on app exit" },
{ value: "keepAlive", label: "Keep alive" },
{ value: "stopWhenUnused", label: "Stop when unused" },
];
export interface ModelServersPanelProps {
/** The model-server registry view-model (from `useModelServers`). */
vm: ModelServersViewModel;
}
export function ModelServersPanel({ vm }: ModelServersPanelProps) {
// The server being edited, or a fresh draft when "Add" is clicked. `null` ⇒
// no editor open (list-only).
const [draft, setDraft] = useState<LocalModelServerConfig | null>(null);
function startAdd() {
vm.clearError();
setDraft(emptyModelServer());
}
function startEdit(server: LocalModelServerConfig) {
vm.clearError();
setDraft({ ...server });
}
async function submit() {
if (!draft) return;
const saved = await vm.save(draft);
if (saved) setDraft(null);
}
return (
<Panel
aria-label="local model servers"
title={
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-content">
Local model servers
</h3>
<Button
size="sm"
aria-label="add model server"
onClick={startAdd}
className="ml-auto"
>
Add server
</Button>
</div>
}
>
<div className="flex flex-col gap-3">
{vm.error && (
<p role="alert" className="text-sm text-danger">
{vm.error}
</p>
)}
{vm.servers.length === 0 && !draft && (
<p className="text-xs text-muted">
No local server declared yet. Add one to auto-manage a `llama-server`,
or keep using an external endpoint.
</p>
)}
<ul className="flex list-none flex-col gap-2 p-0">
{vm.servers.map((server) => (
<li
key={server.id}
className="flex items-center gap-2 rounded-md border border-border bg-raised p-2"
>
<span className="flex min-w-0 flex-col">
<strong className="truncate text-sm text-content">
{server.name}
</strong>
<span className="truncate text-xs text-muted">
{server.baseURL} · {server.servedModelName}
{server.autoStart ? " · auto-start" : ""}
</span>
</span>
<span className="ml-auto flex items-center gap-1">
<Button
size="sm"
aria-label={`edit ${server.name}`}
onClick={() => startEdit(server)}
disabled={vm.busy}
>
Edit
</Button>
<IconButton
size="sm"
aria-label={`delete ${server.name}`}
onClick={() => void vm.remove(server.id)}
disabled={vm.busy}
>
×
</IconButton>
</span>
</li>
))}
</ul>
{draft && (
<ServerEditor
draft={draft}
busy={vm.busy}
onChange={setDraft}
onCancel={() => {
vm.clearError();
setDraft(null);
}}
onSubmit={() => void submit()}
/>
)}
</div>
</Panel>
);
}
/** The add/edit form for one server draft. */
function ServerEditor({
draft,
busy,
onChange,
onCancel,
onSubmit,
}: {
draft: LocalModelServerConfig;
busy: boolean;
onChange: (next: LocalModelServerConfig) => void;
onCancel: () => void;
onSubmit: () => void;
}) {
const errors: ModelServerErrors = validateModelServer(draft);
const valid = Object.keys(errors).length === 0;
const patch = (next: Partial<LocalModelServerConfig>) =>
onChange({ ...draft, ...next });
return (
<fieldset
aria-label="model server editor"
className="flex flex-col gap-2 rounded-md border border-primary/40 p-3"
>
<legend className="px-1 text-xs font-medium text-muted">
{draft.name.trim().length > 0 ? draft.name : "New server"}
</legend>
<label className="flex flex-col gap-1">
<Caption>Name</Caption>
<Input
aria-label="server name"
value={draft.name}
invalid={Boolean(errors.name)}
onChange={(e) => patch({ name: e.target.value })}
/>
{errors.name && <small className="text-xs text-danger">{errors.name}</small>}
</label>
<div className="flex flex-wrap gap-2">
<label className="flex min-w-[12rem] flex-1 flex-col gap-1">
<Caption>Base URL</Caption>
<Input
aria-label="server base url"
value={draft.baseURL}
placeholder="http://localhost:8080/v1"
invalid={Boolean(errors.baseURL)}
onChange={(e) => patch({ baseURL: e.target.value })}
/>
{errors.baseURL && (
<small className="text-xs text-danger">{errors.baseURL}</small>
)}
</label>
<label className="flex min-w-[6rem] flex-col gap-1">
<Caption>Port</Caption>
<Input
aria-label="server port"
value={Number.isNaN(draft.port) ? "" : draft.port.toString()}
inputMode="numeric"
invalid={Boolean(errors.port)}
onChange={(e) => patch({ port: Number.parseInt(e.target.value, 10) })}
/>
{errors.port && <small className="text-xs text-danger">{errors.port}</small>}
</label>
</div>
<label className="flex flex-col gap-1">
<Caption>Served model name (sent to OpenCode as `model`)</Caption>
<Input
aria-label="server served model name"
value={draft.servedModelName}
placeholder="qwen3-coder-30b"
invalid={Boolean(errors.servedModelName)}
onChange={(e) => patch({ servedModelName: e.target.value })}
/>
{errors.servedModelName && (
<small className="text-xs text-danger">{errors.servedModelName}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Model path (absolute `.gguf` required for auto-start)</Caption>
<Input
aria-label="server model path"
value={draft.modelPath ?? ""}
placeholder="/models/qwen3-coder-30b.gguf"
invalid={Boolean(errors.modelPath)}
onChange={(e) => {
const v = e.target.value;
patch({ modelPath: v.length === 0 ? undefined : v });
}}
/>
{errors.modelPath && (
<small className="text-xs text-danger">{errors.modelPath}</small>
)}
</label>
<label className="flex flex-col gap-1">
<Caption>Binary path (optional `llama-server` command)</Caption>
<Input
aria-label="server binary path"
value={draft.binaryPath ?? ""}
placeholder="llama-server"
onChange={(e) => {
const v = e.target.value;
patch({ binaryPath: v.length === 0 ? undefined : v });
}}
/>
</label>
<label className="flex flex-col gap-1">
<Caption>Extra arguments</Caption>
<Input
aria-label="server args"
value={draft.args.join(" ")}
placeholder="--ctx-size 8192 --n-gpu-layers 99"
onChange={(e) => patch({ args: parseArgs(e.target.value) })}
/>
</label>
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
aria-label="server auto start"
checked={draft.autoStart}
onChange={(e) => patch({ autoStart: e.target.checked })}
className="accent-primary"
/>
<Caption>Auto-start</Caption>
</label>
<label className="flex items-center gap-2">
<Caption>Stop policy</Caption>
<select
aria-label="server stop policy"
value={draft.stopPolicy}
onChange={(e) => patch({ stopPolicy: e.target.value as StopPolicy })}
className={cn(
"h-8 rounded-md bg-raised px-2 text-xs text-content",
"border border-border outline-none focus:border-primary",
)}
>
{STOP_POLICIES.map((p) => (
<option key={p.value} value={p.value}>
{p.label}
</option>
))}
</select>
</label>
</div>
<div className="flex gap-2">
<Button
variant="primary"
size="sm"
aria-label="save model server"
onClick={onSubmit}
disabled={busy || !valid}
>
Save server
</Button>
<Button
variant="ghost"
size="sm"
aria-label="cancel model server"
onClick={onCancel}
disabled={busy}
>
Cancel
</Button>
</div>
</fieldset>
);
}

View File

@ -0,0 +1,19 @@
/**
* Local model servers feature — public surface (F35.2).
*/
export { useModelServers } from "./useModelServers";
export type { ModelServersViewModel } from "./useModelServers";
export { ModelServersPanel } from "./ModelServersPanel";
export type { ModelServersPanelProps } from "./ModelServersPanel";
export { ModelServerSelect } from "./ModelServerSelect";
export type { ModelServerSelectProps } from "./ModelServerSelect";
export {
emptyModelServer,
newModelServerId,
validateModelServer,
isModelServerValid,
isAbsolutePath,
parseArgs,
type ModelServerErrors,
} from "./modelServer";

View File

@ -0,0 +1,97 @@
/**
* Pure, framework-free helpers for the local model server feature (F35). Draft
* validation mirrors the backend invariants (`ModelServerEndpoint::new`,
* `LocalModelRef::new`, `LocalModelServerConfig::new`) so the form can surface
* errors before any `invoke`. No React, no Tauri here.
*/
import type { LocalModelServerConfig } from "@/domain";
/** A field-keyed validation error map (empty ⇒ valid). */
export type ModelServerErrors = Partial<
Record<
"name" | "baseURL" | "port" | "servedModelName" | "modelPath",
string
>
>;
/** Whether a string is a valid `http://`/`https://` URL (mirror of backend). */
function isValidHttpUrl(v: string): boolean {
if (v.trim().length === 0) return false;
return v.startsWith("http://") || v.startsWith("https://");
}
/**
* Whether a path looks absolute (mirror of the backend `is_absolute_path`:
* POSIX `/…` or a Windows drive/UNC). Blank is handled by the caller.
*/
export function isAbsolutePath(path: string): boolean {
if (path.startsWith("/") || path.startsWith("\\")) return true;
// Windows drive `C:/` or `C:\`.
return /^[a-zA-Z]:[/\\]/.test(path);
}
/**
* Validates a {@link LocalModelServerConfig} draft the way the backend would.
* Returns an empty object when the draft is valid.
*/
export function validateModelServer(c: LocalModelServerConfig): ModelServerErrors {
const errors: ModelServerErrors = {};
if (c.name.trim().length === 0) errors.name = "Name is required.";
if (!isValidHttpUrl(c.baseURL)) {
errors.baseURL = "Base URL must start with http:// or https://.";
}
if (!Number.isInteger(c.port) || c.port < 1 || c.port > 65_535) {
errors.port = "Port must be an integer between 1 and 65535.";
}
if (c.servedModelName.trim().length === 0) {
errors.servedModelName = "Served model name is required.";
}
const modelPath = c.modelPath?.trim() ?? "";
if (modelPath.length > 0 && !isAbsolutePath(modelPath)) {
errors.modelPath = "Model path must be absolute.";
}
// The backend rejects auto-start without an absolute model path.
if (c.autoStart && modelPath.length === 0) {
errors.modelPath = "Auto-start requires an absolute model path.";
}
return errors;
}
/** Whether a draft is valid (no errors). */
export function isModelServerValid(c: LocalModelServerConfig): boolean {
return Object.keys(validateModelServer(c)).length === 0;
}
/** Generates a UUID for a client-created server (crypto when available). */
export function newModelServerId(): string {
const c = globalThis.crypto as Crypto | undefined;
if (c && typeof c.randomUUID === "function") return c.randomUUID();
return `srv-${Math.random().toString(36).slice(2, 10)}`;
}
/**
* A fresh model-server draft (id minted client-side) pre-filled with a sensible
* local `llama-server` endpoint, so the form starts valid apart from the name.
*/
export function emptyModelServer(): LocalModelServerConfig {
return {
id: newModelServerId(),
kind: "llamaCpp",
name: "",
baseURL: "http://localhost:8080/v1",
port: 8080,
servedModelName: "qwen3-coder-30b",
args: [],
autoStart: false,
stopPolicy: "stopOnAppExit",
};
}
/** Parses a whitespace-separated args string into a trimmed, non-empty list. */
export function parseArgs(raw: string): string[] {
return raw
.split(/\s+/)
.map((s) => s.trim())
.filter((s) => s.length > 0);
}

View File

@ -0,0 +1,251 @@
/**
* F35.2 — local model server registry: the pure validation helpers, the
* {@link useModelServers} hook behind the real DIProvider + mock gateway, the
* {@link ModelServersPanel} CRUD UI (incl. the `model_server_in_use` rejection),
* and the {@link ModelServerSelect} binding dropdown.
*/
import { describe, it, expect } from "vitest";
import { act, render, renderHook, screen, fireEvent, waitFor } from "@testing-library/react";
import type { Gateways } from "@/ports";
import type { LocalModelServerConfig } from "@/domain";
import { MockModelServerGateway } from "@/adapters/mock";
import { DIProvider } from "@/app/di";
import { useModelServers } from "./useModelServers";
import { ModelServersPanel } from "./ModelServersPanel";
import { ModelServerSelect } from "./ModelServerSelect";
import {
emptyModelServer,
isAbsolutePath,
validateModelServer,
} from "./modelServer";
const SERVER: LocalModelServerConfig = {
id: "550e8400-e29b-41d4-a716-446655440000",
kind: "llamaCpp",
name: "Local A",
baseURL: "http://localhost:8080/v1",
port: 8080,
servedModelName: "qwen3-coder-30b",
args: [],
autoStart: false,
stopPolicy: "stopOnAppExit",
};
function setup(modelServer = new MockModelServerGateway()) {
const gateways = { modelServer } as unknown as Gateways;
const wrapper = ({ children }: { children: React.ReactNode }) => (
<DIProvider gateways={gateways}>{children}</DIProvider>
);
const view = renderHook(() => useModelServers(), { wrapper });
return { modelServer, view };
}
// ---------------------------------------------------------------------------
// Pure helpers
// ---------------------------------------------------------------------------
describe("modelServer helpers (F35.2)", () => {
it("emptyModelServer starts valid apart from the name", () => {
const draft = emptyModelServer();
expect(validateModelServer(draft)).toEqual({ name: "Name is required." });
});
it("flags a non-http base URL and an out-of-range port", () => {
const errors = validateModelServer({
...SERVER,
baseURL: "ftp://oops",
port: 70_000,
});
expect(errors.baseURL).toBeTruthy();
expect(errors.port).toBeTruthy();
});
it("requires an absolute model path, and requires it when auto-start is on", () => {
expect(validateModelServer({ ...SERVER, modelPath: "relative/x.gguf" }).modelPath).toBeTruthy();
expect(validateModelServer({ ...SERVER, autoStart: true }).modelPath).toMatch(
/auto-start/i,
);
expect(
validateModelServer({ ...SERVER, autoStart: true, modelPath: "/m/x.gguf" }).modelPath,
).toBeUndefined();
});
it("recognises absolute POSIX and Windows paths", () => {
expect(isAbsolutePath("/models/x.gguf")).toBe(true);
expect(isAbsolutePath("C:/models/x.gguf")).toBe(true);
expect(isAbsolutePath("models/x.gguf")).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
describe("useModelServers (F35.2)", () => {
it("saves and lists servers", async () => {
const { view } = setup();
await act(async () => {
await view.result.current.save(SERVER);
});
expect(view.result.current.servers).toHaveLength(1);
expect(view.result.current.servers[0].name).toBe("Local A");
});
it("removes a server", async () => {
const { view } = setup();
await act(async () => {
await view.result.current.save(SERVER);
});
await act(async () => {
const ok = await view.result.current.remove(SERVER.id);
expect(ok).toBe(true);
});
expect(view.result.current.servers).toHaveLength(0);
});
it("surfaces model_server_in_use as an actionable error and keeps the server", async () => {
const modelServer = new MockModelServerGateway();
const { view } = setup(modelServer);
await act(async () => {
await view.result.current.save(SERVER);
});
modelServer.markInUse(SERVER.id);
await act(async () => {
const ok = await view.result.current.remove(SERVER.id);
expect(ok).toBe(false);
});
expect(view.result.current.error).toMatch(/encore utilisé par un profil/i);
expect(view.result.current.servers).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// Panel
// ---------------------------------------------------------------------------
function renderPanel(modelServer = new MockModelServerGateway()) {
const gateways = { modelServer } as unknown as Gateways;
function Harness() {
const vm = useModelServers();
return <ModelServersPanel vm={vm} />;
}
return {
modelServer,
...render(
<DIProvider gateways={gateways}>
<Harness />
</DIProvider>,
),
};
}
describe("ModelServersPanel (F35.2)", () => {
it("declares a new server end-to-end", async () => {
const { modelServer } = renderPanel();
fireEvent.click(screen.getByLabelText("add model server"));
fireEvent.change(screen.getByLabelText("server name"), {
target: { value: "Local A" },
});
// A valid draft (pre-filled endpoint + servedModelName) enables Save once
// the initial passive load has settled.
const save = screen.getByLabelText("save model server") as HTMLButtonElement;
await waitFor(() => expect(save.disabled).toBe(false));
fireEvent.click(save);
await waitFor(async () => {
const saved = await modelServer.listModelServers();
expect(saved.map((s) => s.name)).toEqual(["Local A"]);
});
// The editor closed and the server shows in the list.
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
});
it("blocks Save while the draft is invalid (blank name)", () => {
renderPanel();
fireEvent.click(screen.getByLabelText("add model server"));
// Invalid draft (blank name) keeps Save disabled regardless of busy.
expect(
(screen.getByLabelText("save model server") as HTMLButtonElement).disabled,
).toBe(true);
});
it("shows an actionable banner when deleting a referenced server", async () => {
const modelServer = new MockModelServerGateway();
await modelServer.saveModelServer(SERVER);
modelServer.markInUse(SERVER.id);
renderPanel(modelServer);
await screen.findByLabelText("edit Local A");
fireEvent.click(screen.getByLabelText("delete Local A"));
expect((await screen.findByRole("alert")).textContent).toMatch(
/encore utilisé par un profil/i,
);
// The server is still listed (delete was refused).
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
});
it("edits an existing server's served model name", async () => {
const modelServer = new MockModelServerGateway();
await modelServer.saveModelServer(SERVER);
renderPanel(modelServer);
fireEvent.click(await screen.findByLabelText("edit Local A"));
fireEvent.change(screen.getByLabelText("server served model name"), {
target: { value: "qwen3-coder-7b" },
});
fireEvent.click(screen.getByLabelText("save model server"));
await waitFor(async () => {
const saved = await modelServer.listModelServers();
expect(saved[0].servedModelName).toBe("qwen3-coder-7b");
});
});
});
// ---------------------------------------------------------------------------
// Select
// ---------------------------------------------------------------------------
describe("ModelServerSelect (F35.2)", () => {
it("offers a 'none' option plus each declared server, and emits the id", () => {
let picked: string | undefined = "sentinel";
render(
<ModelServerSelect
servers={[SERVER]}
value={undefined}
onChange={(id) => (picked = id)}
/>,
);
const select = screen.getByLabelText("local model server") as HTMLSelectElement;
// "None" is selected when value is undefined.
expect(select.value).toBe("");
fireEvent.change(select, { target: { value: SERVER.id } });
expect(picked).toBe(SERVER.id);
});
it("emits undefined when 'none' is chosen", () => {
let picked: string | undefined = SERVER.id;
render(
<ModelServerSelect
servers={[SERVER]}
value={SERVER.id}
onChange={(id) => (picked = id)}
/>,
);
fireEvent.change(screen.getByLabelText("local model server"), {
target: { value: "" },
});
expect(picked).toBeUndefined();
});
it("keeps a removed/unknown bound id visible rather than dropping it", () => {
render(
<ModelServerSelect servers={[]} value="ghost-id" onChange={() => {}} />,
);
expect(screen.getByText(/ghost-id \(unknown \/ removed\)/)).toBeTruthy();
});
});

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 };
}