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.

View File

@ -15,8 +15,18 @@
* `./profile`.
*/
import type { AgentProfile, HttpChatConfig, OpenCodeConfig } from "@/domain";
import type {
AgentProfile,
HttpChatConfig,
LocalModelServerConfig,
OpenCodeConfig,
} from "@/domain";
import { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
import {
ModelServersPanel,
ModelServerSelect,
useModelServers,
} from "@/features/model-servers";
import { useFirstRun, type WizardEntry } from "./useFirstRun";
import {
defaultHttpChatConfig,
@ -48,6 +58,7 @@ export function FirstRunWizard({
forceOpen?: boolean;
}) {
const vm = useFirstRun();
const modelServers = useModelServers();
if (vm.isFirstRun === null) return null;
if (!forceOpen && vm.isFirstRun === false) return null;
@ -93,16 +104,40 @@ export function FirstRunWizard({
Detecting
</span>
)}
{/* F36: declare several local OpenCode profiles. Each click clones the
canonical `opencode-llamacpp` seed into a new, editable row. */}
<Button
onClick={() => void vm.addOpenCodeProfile()}
disabled={vm.busy}
className="whitespace-nowrap"
>
Add OpenCode profile
</Button>
</Toolbar>
{/* F35.2 — declare/edit/delete the local llama.cpp servers an OpenCode
profile can bind to. Sits above the profile list so a server exists
before it is picked in the OpenCode dropdown. */}
<ModelServersPanel vm={modelServers} />
<ul className="flex list-none flex-col gap-3 p-0">
{vm.entries.map((entry) => (
<ProfileRow
key={entry.profile.id}
entry={entry}
servers={modelServers.servers}
onToggle={() => vm.toggle(entry.profile.id)}
onChange={(p) => vm.updateProfile(entry.profile.id, p)}
onRemove={() => vm.remove(entry.profile.id)}
onDuplicate={
entry.profile.structuredAdapter === "openCode"
? () =>
vm.addOpenCodeProfile({
name: `${entry.profile.name} (copy)`,
opencode: entry.profile.opencode,
})
: undefined
}
/>
))}
</ul>
@ -120,17 +155,24 @@ export function FirstRunWizard({
/** One editable candidate row: select, edit command/args, see availability. */
function ProfileRow({
entry,
servers,
onToggle,
onChange,
onRemove,
onDuplicate,
}: {
entry: WizardEntry;
/** Declared local model servers (F35.2), for the OpenCode binding dropdown. */
servers: LocalModelServerConfig[];
onToggle: () => void;
onChange: (p: AgentProfile) => void;
onRemove: () => void;
/** Present only for OpenCode rows: clone this row into a new profile (F36). */
onDuplicate?: () => void;
}) {
const { profile, selected, available } = entry;
const errors = validateProfile(profile);
const isOpenCode = profile.structuredAdapter === "openCode";
return (
<li className="flex flex-col gap-2 rounded-md border border-border bg-raised p-3">
@ -158,16 +200,41 @@ function ProfileRow({
>
{available === null ? "—" : available ? "✓ installed" : "✗ not found"}
</span>
<IconButton
size="sm"
aria-label={`remove ${profile.name}`}
onClick={onRemove}
className="ml-auto"
>
×
</IconButton>
<div className="ml-auto flex items-center gap-1">
{onDuplicate && (
<Button
size="sm"
aria-label={`duplicate ${profile.name}`}
onClick={onDuplicate}
>
Duplicate
</Button>
)}
<IconButton
size="sm"
aria-label={`remove ${profile.name}`}
onClick={onRemove}
>
×
</IconButton>
</div>
</div>
{/* F36: an OpenCode profile's name is identity-neutral but user-facing, so
it is editable per profile (several local models coexist). */}
{isOpenCode && (
<label className="flex flex-col gap-1">
<Caption>Name</Caption>
<Input
aria-label={`${profile.name} name`}
value={profile.name}
invalid={Boolean(errors.name)}
onChange={(e) => onChange({ ...profile, name: e.target.value })}
/>
{errors.name && <small className="text-xs text-danger">{errors.name}</small>}
</label>
)}
<label className="flex flex-col gap-1">
<Caption>Command</Caption>
<Input
@ -193,7 +260,12 @@ function ProfileRow({
)}
{profile.structuredAdapter === "openCode" && (
<OpenCodeFields profile={profile} errors={errors} onChange={onChange} />
<OpenCodeFields
profile={profile}
errors={errors}
servers={servers}
onChange={onChange}
/>
)}
</li>
);
@ -208,10 +280,13 @@ function ProfileRow({
function OpenCodeFields({
profile,
errors,
servers,
onChange,
}: {
profile: AgentProfile;
errors: ProfileErrors;
/** Declared local model servers (F35.2), for the binding dropdown. */
servers: LocalModelServerConfig[];
onChange: (p: AgentProfile) => void;
}) {
const opencode = profile.opencode ?? defaultOpenCodeConfig();
@ -262,6 +337,44 @@ function OpenCodeFields({
}}
/>
</label>
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2">
<input
type="checkbox"
aria-label={`${profile.name} reasoning`}
// Effective backend default is `true`; treat omitted as enabled.
checked={opencode.reasoning ?? true}
onChange={(e) => patch({ reasoning: e.target.checked })}
className="accent-primary"
/>
<Caption>Reasoning</Caption>
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
aria-label={`${profile.name} attachment`}
// Effective backend default is `false`.
checked={opencode.attachment ?? false}
onChange={(e) => patch({ attachment: e.target.checked })}
className="accent-primary"
/>
<Caption>Attachments</Caption>
</label>
</div>
<label className="flex flex-col gap-1">
<Caption>
Local model server (bind to a managed llama.cpp server, or none)
</Caption>
<ModelServerSelect
ariaLabel={`${profile.name} local model server`}
servers={servers}
value={opencode.localModelServerId}
onChange={(serverId) => patch({ localModelServerId: serverId })}
/>
</label>
</fieldset>
);
}

View File

@ -19,6 +19,7 @@ import type {
AgentProfile,
FirstRunState,
GatewayError,
OpenCodeConfig,
ProfileAvailability,
} from "@/domain";
import { useGateways } from "@/app/di";
@ -50,6 +51,15 @@ export interface FirstRunViewModel {
toggle: (id: string) => void;
/** Replaces a candidate's profile (edited command/args/injection). */
updateProfile: (id: string, profile: AgentProfile) => void;
/**
* Mints a new OpenCode profile from the seed (F36 — several local OpenCode
* profiles) and appends it, pre-selected, to the rows. Optionally overrides the
* name/config (used by "Duplicate" to carry a row's endpoint over).
*/
addOpenCodeProfile: (input?: {
name?: string;
opencode?: OpenCodeConfig;
}) => Promise<void>;
/** Removes a candidate by id. */
remove: (id: string) => void;
/** Runs detection over all candidates, filling availability. */
@ -187,6 +197,24 @@ export function useFirstRun(): FirstRunViewModel {
);
}, []);
const addOpenCodeProfile = useCallback(
async (input?: { name?: string; opencode?: OpenCodeConfig }) => {
setError(null);
try {
const created = await profile.cloneOpenCodeProfileFromSeed(input);
// Freshly cloned profiles start selected (the user opted in by adding
// one) and available=null (detection re-probes them on demand).
setEntries((prev) => [
...prev,
{ profile: created, selected: true, available: null },
]);
} catch (e) {
setError(describe(e));
}
},
[profile],
);
const remove = useCallback((id: string) => {
setEntries((prev) => prev.filter((e) => e.profile.id !== id));
}, []);
@ -221,6 +249,7 @@ export function useFirstRun(): FirstRunViewModel {
detecting,
toggle,
updateProfile,
addOpenCodeProfile,
remove,
detect,
finish,