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

@ -13,8 +13,8 @@ import {
fireEvent,
} from "@testing-library/react";
import { MockProfileGateway } from "@/adapters/mock";
import type { ProfileAvailability } from "@/domain";
import { MockModelServerGateway, MockProfileGateway } from "@/adapters/mock";
import type { LocalModelServerConfig, ProfileAvailability } from "@/domain";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { FirstRunWizard } from "./FirstRunWizard";
@ -23,10 +23,12 @@ import { DETECT_TIMEOUT_MS } from "./useFirstRun";
function renderWizard(
profile: MockProfileGateway = new MockProfileGateway(),
onDone = vi.fn(),
modelServer: MockModelServerGateway = new MockModelServerGateway(),
) {
const gateways = { profile } as unknown as Gateways;
const gateways = { profile, modelServer } as unknown as Gateways;
return {
profile,
modelServer,
onDone,
...render(
<DIProvider gateways={gateways}>
@ -277,6 +279,182 @@ describe("FirstRunWizard — OpenCode + llama.cpp local profile", () => {
});
});
describe("FirstRunWizard — several local OpenCode profiles (F36)", () => {
const OPENCODE = "OpenCode + llama.cpp";
const CLONE1 = `${OPENCODE} (copy 1)`;
it('"Add OpenCode profile" clones the seed into a new, editable, pre-selected row', async () => {
renderWizard();
await waitForLoaded();
// Only one OpenCode row to begin with (the seed reference).
expect(screen.getAllByText(OPENCODE).length).toBe(1);
fireEvent.click(
screen.getByRole("button", { name: "Add OpenCode profile" }),
);
// A second OpenCode row appears, pre-filled from the seed and pre-selected.
const added = await screen.findByLabelText(`use ${CLONE1}`);
expect((added as HTMLInputElement).checked).toBe(true);
expect(
(screen.getByLabelText(`${CLONE1} base url`) as HTMLInputElement).value,
).toBe("http://localhost:8080/v1");
// Its name is editable per profile (identity is the id, not the name).
expect(screen.getByLabelText(`${CLONE1} name`)).toBeTruthy();
});
it("persists two distinct OpenCode profiles when both are selected", async () => {
const { profile } = renderWizard();
await waitForLoaded();
// Select the seed row and edit it.
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
target: { value: "qwen3-coder-14b" },
});
// Add a second one and give it a different endpoint.
fireEvent.click(
screen.getByRole("button", { name: "Add OpenCode profile" }),
);
await screen.findByLabelText(`use ${CLONE1}`);
fireEvent.change(screen.getByLabelText(`${CLONE1} base url`), {
target: { value: "http://localhost:9191/v1" },
});
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
const opencode = saved.filter((p) => p.structuredAdapter === "openCode");
expect(opencode.length).toBe(2);
// Distinct ids (identity) and distinct endpoints.
expect(new Set(opencode.map((p) => p.id)).size).toBe(2);
expect(opencode.map((p) => p.opencode?.baseURL).sort()).toEqual([
"http://localhost:8080/v1",
"http://localhost:9191/v1",
]);
});
});
it("Duplicate clones a row carrying its current config over", async () => {
const { profile } = renderWizard();
await waitForLoaded();
// Edit the seed row, then duplicate it.
fireEvent.click(screen.getByLabelText(`use ${OPENCODE}`));
fireEvent.change(screen.getByLabelText(`${OPENCODE} model`), {
target: { value: "qwen3-coder-7b" },
});
fireEvent.click(
screen.getByRole("button", { name: `duplicate ${OPENCODE}` }),
);
const dupName = `${OPENCODE} (copy)`;
await screen.findByLabelText(`use ${dupName}`);
// The duplicate inherits the edited model.
expect(
(screen.getByLabelText(`${dupName} model`) as HTMLInputElement).value,
).toBe("qwen3-coder-7b");
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
const models = saved
.filter((p) => p.structuredAdapter === "openCode")
.map((p) => p.opencode?.model);
expect(models).toEqual(["qwen3-coder-7b", "qwen3-coder-7b"]);
});
});
it("editing the name round-trips on save", async () => {
const { profile } = renderWizard();
await waitForLoaded();
fireEvent.click(
screen.getByRole("button", { name: "Add OpenCode profile" }),
);
await screen.findByLabelText(`use ${CLONE1}`);
fireEvent.change(screen.getByLabelText(`${CLONE1} name`), {
target: { value: "Fast local model" },
});
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
const oc = saved.find((p) => p.structuredAdapter === "openCode");
expect(oc?.name).toBe("Fast local model");
});
});
it("reasoning/attachment round-trip on save (F36)", async () => {
const { profile } = renderWizard();
await waitForLoaded();
fireEvent.click(
screen.getByRole("button", { name: "Add OpenCode profile" }),
);
await screen.findByLabelText(`use ${CLONE1}`);
// Reasoning defaults on (backend effective default true); turn it off.
const reasoning = screen.getByLabelText(
`${CLONE1} reasoning`,
) as HTMLInputElement;
expect(reasoning.checked).toBe(true);
fireEvent.click(reasoning);
// Attachments default off; turn them on.
fireEvent.click(screen.getByLabelText(`${CLONE1} attachment`));
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
const oc = saved.find((p) => p.structuredAdapter === "openCode");
expect(oc?.opencode?.reasoning).toBe(false);
expect(oc?.opencode?.attachment).toBe(true);
});
});
it("binds localModelServerId via the server dropdown (F35.2), replacing free text", async () => {
// Seed a declared server so the dropdown offers it.
const modelServer = new MockModelServerGateway();
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",
};
await modelServer.saveModelServer(server);
const { profile } = renderWizard(new MockProfileGateway(), vi.fn(), modelServer);
await waitForLoaded();
fireEvent.click(
screen.getByRole("button", { name: "Add OpenCode profile" }),
);
await screen.findByLabelText(`use ${CLONE1}`);
// The old free-text field is gone; a dropdown replaces it.
expect(
screen.queryByLabelText(`${CLONE1} local model server id`),
).toBeNull();
const select = await screen.findByLabelText(`${CLONE1} local model server`);
fireEvent.change(select, { target: { value: server.id } });
fireEvent.click(screen.getByRole("button", { name: "Save and continue" }));
await waitFor(async () => {
const saved = await profile.listProfiles();
const oc = saved.find((p) => p.structuredAdapter === "openCode");
expect(oc?.opencode?.localModelServerId).toBe(server.id);
});
});
});
// Ticket #28 — the wizard must survive a detection that never answers. When the
// backend `detect_profiles` command panics, the `invoke` promise is neither
// resolved nor rejected: nothing after `await detectProfiles(...)` ever runs.