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

@ -19,6 +19,7 @@ import { TauriProjectGateway } from "./project";
import { TauriTerminalGateway } from "./terminal";
import { TauriLayoutGateway } from "./layout";
import { TauriProfileGateway } from "./profile";
import { TauriModelServerGateway } from "./modelServer";
import { TauriTemplateGateway } from "./template";
import { TauriSkillGateway } from "./skill";
import { TauriMemoryGateway } from "./memory";
@ -56,6 +57,7 @@ export function createTauriGateways(): Gateways {
git: new TauriGitGateway(),
remote: new TauriRemoteGateway(),
profile: new TauriProfileGateway(),
modelServer: new TauriModelServerGateway(),
template: new TauriTemplateGateway(),
skill: new TauriSkillGateway(),
memory: new TauriMemoryGateway(),
@ -76,6 +78,7 @@ export {
TauriTerminalGateway,
TauriLayoutGateway,
TauriProfileGateway,
TauriModelServerGateway,
TauriTemplateGateway,
TauriSkillGateway,
TauriMemoryGateway,

View File

@ -23,6 +23,7 @@ import type {
LayoutList,
LayoutOperation,
LayoutTree,
LocalModelServerConfig,
Memory,
MemoryIndexEntry,
MemoryLink,
@ -57,6 +58,7 @@ import type {
ConversationGateway,
ConversationPageRequest,
ConversationDetails,
CloneOpenCodeProfileFromSeedInput,
CreateAgentInput,
CreateMemoryInput,
CreateSkillInput,
@ -68,6 +70,7 @@ import type {
MemoryGateway,
GitGateway,
LayoutGateway,
ModelServerGateway,
OpenTerminalOptions,
ProfileGateway,
ProjectGateway,
@ -1185,6 +1188,8 @@ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [
export class MockProfileGateway implements ProfileGateway {
private profiles: AgentProfile[] = [];
private configured = false;
/** Monotonic suffix so cloned OpenCode profiles get distinct ids/names. */
private cloneCounter = 0;
async firstRunState(): Promise<FirstRunState> {
return {
@ -1228,6 +1233,61 @@ export class MockProfileGateway implements ProfileGateway {
this.configured = true;
return structuredClone(profiles);
}
async cloneOpenCodeProfileFromSeed(
input: CloneOpenCodeProfileFromSeedInput = {},
): Promise<AgentProfile> {
const seed = MOCK_REFERENCE_PROFILES.find(
(p) => p.structuredAdapter === "openCode",
);
if (!seed) throw new Error("no OpenCode seed profile");
this.cloneCounter += 1;
return structuredClone({
...seed,
id: `mock-opencode-clone-${this.cloneCounter}`,
name: input.name ?? `${seed.name} (copy ${this.cloneCounter})`,
opencode: input.opencode ?? seed.opencode,
});
}
}
/**
* In-memory local model server registry (F35). Tracks declared servers and can
* be told which ids are still referenced by a profile, so `deleteModelServer`
* reproduces the backend `model_server_in_use` rejection offline.
*/
export class MockModelServerGateway implements ModelServerGateway {
private servers: LocalModelServerConfig[] = [];
private readonly inUse = new Set<string>();
async listModelServers(): Promise<LocalModelServerConfig[]> {
return structuredClone(this.servers);
}
async saveModelServer(
config: LocalModelServerConfig,
): Promise<LocalModelServerConfig> {
const i = this.servers.findIndex((s) => s.id === config.id);
if (i >= 0) this.servers[i] = structuredClone(config);
else this.servers.push(structuredClone(config));
return structuredClone(config);
}
async deleteModelServer(serverId: string): Promise<void> {
if (this.inUse.has(serverId)) {
const err: GatewayError = {
code: "model_server_in_use",
message: "A profile still references this server.",
};
throw err;
}
this.servers = this.servers.filter((s) => s.id !== serverId);
}
/** Test hook: mark a server id as still referenced (delete then rejects). */
markInUse(serverId: string): void {
this.inUse.add(serverId);
}
}
/**
@ -2454,6 +2514,7 @@ export function createMockGateways(): Gateways {
git: new MockGitGateway(),
remote: new MockRemoteGateway(),
profile: new MockProfileGateway(),
modelServer: new MockModelServerGateway(),
template: new MockTemplateGateway(agentGateway),
skill: new MockSkillGateway(agentGateway),
memory: new MockMemoryGateway(),

View File

@ -21,6 +21,7 @@ describe("createMockGateways", () => {
"input",
"layout",
"memory",
"modelServer",
"permission",
"profile",
"project",

View File

@ -92,4 +92,33 @@ describe("MockProfileGateway", () => {
await gw.configureProfiles([]);
expect((await gw.firstRunState()).isFirstRun).toBe(false);
});
it("cloneOpenCodeProfileFromSeed mints distinct ids and copies the seed config (F36)", async () => {
const gw = new MockProfileGateway();
const a = await gw.cloneOpenCodeProfileFromSeed();
const b = await gw.cloneOpenCodeProfileFromSeed();
expect(a.structuredAdapter).toBe("openCode");
expect(a.opencode?.baseURL).toBe("http://localhost:8080/v1");
// Identity is the id — two clones must never collide.
expect(a.id).not.toBe(b.id);
});
it("cloneOpenCodeProfileFromSeed honours name/config overrides (Duplicate path)", async () => {
const gw = new MockProfileGateway();
const cloned = await gw.cloneOpenCodeProfileFromSeed({
name: "My local model",
opencode: {
baseURL: "http://localhost:9999/v1",
model: "custom-model",
localModelServerId: "srv-7",
},
});
expect(cloned.name).toBe("My local model");
expect(cloned.opencode?.baseURL).toBe("http://localhost:9999/v1");
expect(cloned.opencode?.model).toBe("custom-model");
expect(cloned.opencode?.localModelServerId).toBe("srv-7");
});
});

View File

@ -0,0 +1,31 @@
/**
* Tauri adapter for {@link ModelServerGateway} (F35). One of the only places that
* calls `invoke()`; features reach it exclusively through the port.
*
* Commands and payload keys are camelCase, matching the backend DTO convention.
* The flat `LocalModelServerConfig` wire shape is the DTO frozen by Architect —
* no nesting.
*/
import { invoke } from "@tauri-apps/api/core";
import type { LocalModelServerConfig } from "@/domain";
import type { ModelServerGateway } from "@/ports";
export class TauriModelServerGateway implements ModelServerGateway {
listModelServers(): Promise<LocalModelServerConfig[]> {
return invoke<LocalModelServerConfig[]>("list_model_servers");
}
saveModelServer(
config: LocalModelServerConfig,
): Promise<LocalModelServerConfig> {
return invoke<LocalModelServerConfig>("save_model_server", {
request: { config },
});
}
async deleteModelServer(serverId: string): Promise<void> {
await invoke("delete_model_server", { serverId });
}
}

View File

@ -9,7 +9,7 @@
import { invoke } from "@tauri-apps/api/core";
import type { AgentProfile, FirstRunState, ProfileAvailability } from "@/domain";
import type { ProfileGateway } from "@/ports";
import type { CloneOpenCodeProfileFromSeedInput, ProfileGateway } from "@/ports";
export class TauriProfileGateway implements ProfileGateway {
firstRunState(): Promise<FirstRunState> {
@ -43,4 +43,12 @@ export class TauriProfileGateway implements ProfileGateway {
request: { profiles },
});
}
cloneOpenCodeProfileFromSeed(
input: CloneOpenCodeProfileFromSeedInput = {},
): Promise<AgentProfile> {
return invoke<AgentProfile>("clone_opencode_profile_from_seed", {
request: { name: input.name, opencode: input.opencode },
});
}
}