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:
@ -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.
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user