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

@ -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");
});
});