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:
@ -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(),
|
||||
|
||||
@ -21,6 +21,7 @@ describe("createMockGateways", () => {
|
||||
"input",
|
||||
"layout",
|
||||
"memory",
|
||||
"modelServer",
|
||||
"permission",
|
||||
"profile",
|
||||
"project",
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user