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:
@ -19,6 +19,7 @@ import { TauriProjectGateway } from "./project";
|
|||||||
import { TauriTerminalGateway } from "./terminal";
|
import { TauriTerminalGateway } from "./terminal";
|
||||||
import { TauriLayoutGateway } from "./layout";
|
import { TauriLayoutGateway } from "./layout";
|
||||||
import { TauriProfileGateway } from "./profile";
|
import { TauriProfileGateway } from "./profile";
|
||||||
|
import { TauriModelServerGateway } from "./modelServer";
|
||||||
import { TauriTemplateGateway } from "./template";
|
import { TauriTemplateGateway } from "./template";
|
||||||
import { TauriSkillGateway } from "./skill";
|
import { TauriSkillGateway } from "./skill";
|
||||||
import { TauriMemoryGateway } from "./memory";
|
import { TauriMemoryGateway } from "./memory";
|
||||||
@ -56,6 +57,7 @@ export function createTauriGateways(): Gateways {
|
|||||||
git: new TauriGitGateway(),
|
git: new TauriGitGateway(),
|
||||||
remote: new TauriRemoteGateway(),
|
remote: new TauriRemoteGateway(),
|
||||||
profile: new TauriProfileGateway(),
|
profile: new TauriProfileGateway(),
|
||||||
|
modelServer: new TauriModelServerGateway(),
|
||||||
template: new TauriTemplateGateway(),
|
template: new TauriTemplateGateway(),
|
||||||
skill: new TauriSkillGateway(),
|
skill: new TauriSkillGateway(),
|
||||||
memory: new TauriMemoryGateway(),
|
memory: new TauriMemoryGateway(),
|
||||||
@ -76,6 +78,7 @@ export {
|
|||||||
TauriTerminalGateway,
|
TauriTerminalGateway,
|
||||||
TauriLayoutGateway,
|
TauriLayoutGateway,
|
||||||
TauriProfileGateway,
|
TauriProfileGateway,
|
||||||
|
TauriModelServerGateway,
|
||||||
TauriTemplateGateway,
|
TauriTemplateGateway,
|
||||||
TauriSkillGateway,
|
TauriSkillGateway,
|
||||||
TauriMemoryGateway,
|
TauriMemoryGateway,
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import type {
|
|||||||
LayoutList,
|
LayoutList,
|
||||||
LayoutOperation,
|
LayoutOperation,
|
||||||
LayoutTree,
|
LayoutTree,
|
||||||
|
LocalModelServerConfig,
|
||||||
Memory,
|
Memory,
|
||||||
MemoryIndexEntry,
|
MemoryIndexEntry,
|
||||||
MemoryLink,
|
MemoryLink,
|
||||||
@ -57,6 +58,7 @@ import type {
|
|||||||
ConversationGateway,
|
ConversationGateway,
|
||||||
ConversationPageRequest,
|
ConversationPageRequest,
|
||||||
ConversationDetails,
|
ConversationDetails,
|
||||||
|
CloneOpenCodeProfileFromSeedInput,
|
||||||
CreateAgentInput,
|
CreateAgentInput,
|
||||||
CreateMemoryInput,
|
CreateMemoryInput,
|
||||||
CreateSkillInput,
|
CreateSkillInput,
|
||||||
@ -68,6 +70,7 @@ import type {
|
|||||||
MemoryGateway,
|
MemoryGateway,
|
||||||
GitGateway,
|
GitGateway,
|
||||||
LayoutGateway,
|
LayoutGateway,
|
||||||
|
ModelServerGateway,
|
||||||
OpenTerminalOptions,
|
OpenTerminalOptions,
|
||||||
ProfileGateway,
|
ProfileGateway,
|
||||||
ProjectGateway,
|
ProjectGateway,
|
||||||
@ -1185,6 +1188,8 @@ export const MOCK_REFERENCE_PROFILES: AgentProfile[] = [
|
|||||||
export class MockProfileGateway implements ProfileGateway {
|
export class MockProfileGateway implements ProfileGateway {
|
||||||
private profiles: AgentProfile[] = [];
|
private profiles: AgentProfile[] = [];
|
||||||
private configured = false;
|
private configured = false;
|
||||||
|
/** Monotonic suffix so cloned OpenCode profiles get distinct ids/names. */
|
||||||
|
private cloneCounter = 0;
|
||||||
|
|
||||||
async firstRunState(): Promise<FirstRunState> {
|
async firstRunState(): Promise<FirstRunState> {
|
||||||
return {
|
return {
|
||||||
@ -1228,6 +1233,61 @@ export class MockProfileGateway implements ProfileGateway {
|
|||||||
this.configured = true;
|
this.configured = true;
|
||||||
return structuredClone(profiles);
|
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(),
|
git: new MockGitGateway(),
|
||||||
remote: new MockRemoteGateway(),
|
remote: new MockRemoteGateway(),
|
||||||
profile: new MockProfileGateway(),
|
profile: new MockProfileGateway(),
|
||||||
|
modelServer: new MockModelServerGateway(),
|
||||||
template: new MockTemplateGateway(agentGateway),
|
template: new MockTemplateGateway(agentGateway),
|
||||||
skill: new MockSkillGateway(agentGateway),
|
skill: new MockSkillGateway(agentGateway),
|
||||||
memory: new MockMemoryGateway(),
|
memory: new MockMemoryGateway(),
|
||||||
|
|||||||
@ -21,6 +21,7 @@ describe("createMockGateways", () => {
|
|||||||
"input",
|
"input",
|
||||||
"layout",
|
"layout",
|
||||||
"memory",
|
"memory",
|
||||||
|
"modelServer",
|
||||||
"permission",
|
"permission",
|
||||||
"profile",
|
"profile",
|
||||||
"project",
|
"project",
|
||||||
|
|||||||
@ -92,4 +92,33 @@ describe("MockProfileGateway", () => {
|
|||||||
await gw.configureProfiles([]);
|
await gw.configureProfiles([]);
|
||||||
expect((await gw.firstRunState()).isFirstRun).toBe(false);
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
31
frontend/src/adapters/modelServer.ts
Normal file
31
frontend/src/adapters/modelServer.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,7 +9,7 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
import type { AgentProfile, FirstRunState, ProfileAvailability } from "@/domain";
|
import type { AgentProfile, FirstRunState, ProfileAvailability } from "@/domain";
|
||||||
import type { ProfileGateway } from "@/ports";
|
import type { CloneOpenCodeProfileFromSeedInput, ProfileGateway } from "@/ports";
|
||||||
|
|
||||||
export class TauriProfileGateway implements ProfileGateway {
|
export class TauriProfileGateway implements ProfileGateway {
|
||||||
firstRunState(): Promise<FirstRunState> {
|
firstRunState(): Promise<FirstRunState> {
|
||||||
@ -43,4 +43,12 @@ export class TauriProfileGateway implements ProfileGateway {
|
|||||||
request: { profiles },
|
request: { profiles },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cloneOpenCodeProfileFromSeed(
|
||||||
|
input: CloneOpenCodeProfileFromSeedInput = {},
|
||||||
|
): Promise<AgentProfile> {
|
||||||
|
return invoke<AgentProfile>("clone_opencode_profile_from_seed", {
|
||||||
|
request: { name: input.name, opencode: input.opencode },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,11 +13,92 @@ export interface HealthReport {
|
|||||||
note: string | null;
|
note: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lifecycle status of a local model server during an agent launch (F35, mirror
|
||||||
|
* of the backend `ModelServerStatusDto`, tagged on `state`, camelCase wire).
|
||||||
|
*
|
||||||
|
* Contract note — the backend event carries the `serverId` on the *envelope*
|
||||||
|
* ({@link DomainEvent} `modelServerStatusChanged`), not inside the status, and it
|
||||||
|
* does NOT emit `baseURL`/`model` in the status payload: the UI reads what the
|
||||||
|
* backend actually sends. `reused` (on `ready`) tells apart a freshly-started
|
||||||
|
* server from a reused running one.
|
||||||
|
*/
|
||||||
|
export type ModelServerStatus =
|
||||||
|
| { state: "notConfigured" }
|
||||||
|
| { state: "probing" }
|
||||||
|
| { state: "starting" }
|
||||||
|
| { state: "ready"; reused: boolean }
|
||||||
|
| { state: "failed"; code: string; message: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop policy of a managed local model server (mirror of the backend
|
||||||
|
* `StopPolicyDto`, camelCase wire):
|
||||||
|
* - `keepAlive`: leave the process running,
|
||||||
|
* - `stopOnAppExit`: stop when IdeA exits (the effective default),
|
||||||
|
* - `stopWhenUnused`: stop when no profile references it.
|
||||||
|
*/
|
||||||
|
export type StopPolicy = "keepAlive" | "stopOnAppExit" | "stopWhenUnused";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A declared local model server (F35, mirror of the backend flat
|
||||||
|
* `LocalModelServerConfigDto`, camelCase wire). Global to IdeA (not project
|
||||||
|
* scoped). Referenced by an OpenCode profile through
|
||||||
|
* `OpenCodeConfig.localModelServerId` (= this `id`).
|
||||||
|
*
|
||||||
|
* Contract notes:
|
||||||
|
* - `id` is a UUID **minted client-side** on create (the backend requires a valid
|
||||||
|
* UUID; it does not generate the server id, only an internal model id it hides).
|
||||||
|
* - `servedModelName` is what the user configures and what is sent to OpenCode as
|
||||||
|
* the `model` — the backend's internal `model.id`/`model.label` are not exposed.
|
||||||
|
* - `modelPath` is required by the backend when `autoStart` is `true`.
|
||||||
|
*/
|
||||||
|
export interface LocalModelServerConfig {
|
||||||
|
id: string;
|
||||||
|
/** Only `llamaCpp` is supported today (camelCase, not "llamacpp"). */
|
||||||
|
kind: "llamaCpp";
|
||||||
|
name: string;
|
||||||
|
/** OpenAI-compatible base URL served by `llama-server` (normalised to `/v1`). */
|
||||||
|
baseURL: string;
|
||||||
|
port: number;
|
||||||
|
/** Absolute `.gguf` path. Required when `autoStart` is `true`. */
|
||||||
|
modelPath?: string;
|
||||||
|
/** Model name exposed by the server and sent to OpenCode as `model`. */
|
||||||
|
servedModelName: string;
|
||||||
|
/** Explicit `llama-server` executable path/command (optional). */
|
||||||
|
binaryPath?: string;
|
||||||
|
args: string[];
|
||||||
|
autoStart: boolean;
|
||||||
|
stopPolicy: StopPolicy;
|
||||||
|
}
|
||||||
|
|
||||||
/** A domain event relayed from the backend (tagged union on `type`). */
|
/** A domain event relayed from the backend (tagged union on `type`). */
|
||||||
export type DomainEvent =
|
export type DomainEvent =
|
||||||
| { type: "projectCreated"; projectId: string }
|
| { type: "projectCreated"; projectId: string }
|
||||||
| { type: "agentLaunched"; agentId: string; sessionId: string }
|
| { type: "agentLaunched"; agentId: string; sessionId: string }
|
||||||
| { type: "agentExited"; agentId: string; code: number }
|
| { type: "agentExited"; agentId: string; code: number }
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* A local model server changed lifecycle state while (re)launching an
|
||||||
|
* agent whose OpenCode profile binds `localModelServerId` (F35). Keyed by
|
||||||
|
* `serverId`; the frontend correlates it back to the agent through the
|
||||||
|
* profile's `opencode.localModelServerId`.
|
||||||
|
*/
|
||||||
|
type: "modelServerStatusChanged";
|
||||||
|
serverId: string;
|
||||||
|
status: ModelServerStatus;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
/**
|
||||||
|
* An agent launch failed before a runtime session was created (F35). Unlike
|
||||||
|
* a generic "agent failed", this carries an actionable `message` and a
|
||||||
|
* stable `code`; `cause` namespaces the failure (e.g. `"model_server"`).
|
||||||
|
*/
|
||||||
|
type: "agentLaunchFailed";
|
||||||
|
agentId: string;
|
||||||
|
cause: string;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
| { type: "agentProfileChanged"; agentId: string; profileId: string }
|
| { type: "agentProfileChanged"; agentId: string; profileId: string }
|
||||||
| { type: "agentBusyChanged"; agentId: string; busy: boolean }
|
| { type: "agentBusyChanged"; agentId: string; busy: boolean }
|
||||||
| {
|
| {
|
||||||
@ -687,6 +768,13 @@ export interface OpenCodeConfig {
|
|||||||
reasoning?: boolean;
|
reasoning?: boolean;
|
||||||
/** Enables attachments. Effective default: `false`. */
|
/** Enables attachments. Effective default: `false`. */
|
||||||
attachment?: boolean;
|
attachment?: boolean;
|
||||||
|
/**
|
||||||
|
* Optional id of a managed local model server (F35) this profile binds to.
|
||||||
|
* When set, IdeA can start/stop the referenced `llama-server` for the profile;
|
||||||
|
* omitted when the profile points at an externally-managed endpoint (mirrors
|
||||||
|
* the backend `skip_serializing_if`). Opaque string id on the wire.
|
||||||
|
*/
|
||||||
|
localModelServerId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import { useDrift } from "@/features/templates/useDrift";
|
|||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
import { useAgents } from "./useAgents";
|
import { useAgents } from "./useAgents";
|
||||||
import { AgentLimitBadge } from "./AgentLimitBadge";
|
import { AgentLimitBadge } from "./AgentLimitBadge";
|
||||||
|
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
||||||
|
|
||||||
export interface AgentsPanelProps {
|
export interface AgentsPanelProps {
|
||||||
/** The project whose agents to manage. */
|
/** The project whose agents to manage. */
|
||||||
@ -331,6 +332,15 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
const delegationSource = vm.delegationSourceByRequester[a.id];
|
const delegationSource = vm.delegationSourceByRequester[a.id];
|
||||||
// Session-limit state (ARCHITECTURE §21), if the agent is limited.
|
// Session-limit state (ARCHITECTURE §21), if the agent is limited.
|
||||||
const limitState = vm.limitByAgent[a.id];
|
const limitState = vm.limitByAgent[a.id];
|
||||||
|
// F35 — local model server launch state, correlated from the
|
||||||
|
// agent's profile binding (`opencode.localModelServerId`), plus the
|
||||||
|
// agent's last launch failure (if any).
|
||||||
|
const boundServerId = vm.profiles.find((p) => p.id === a.profileId)
|
||||||
|
?.opencode?.localModelServerId;
|
||||||
|
const modelServerStatus = boundServerId
|
||||||
|
? vm.modelServerStatusByServer[boundServerId]
|
||||||
|
: undefined;
|
||||||
|
const launchFailure = vm.launchFailureByAgent[a.id];
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={a.id}
|
key={a.id}
|
||||||
@ -388,6 +398,12 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* F35 — local model server launch state / actionable failure. */}
|
||||||
|
<ModelServerLaunchBadge
|
||||||
|
status={modelServerStatus}
|
||||||
|
failure={launchFailure}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* F2 (ticket #4): announcements this agent is waiting on while
|
{/* F2 (ticket #4): announcements this agent is waiting on while
|
||||||
it talks to another agent (requester == this row's id). Sits
|
it talks to another agent (requester == this row's id). Sits
|
||||||
just above the agent drop-list. */}
|
just above the agent drop-list. */}
|
||||||
|
|||||||
100
frontend/src/features/agents/ModelServerLaunchBadge.test.tsx
Normal file
100
frontend/src/features/agents/ModelServerLaunchBadge.test.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* F35.1 — presentational tests for {@link ModelServerLaunchBadge}. Pure render
|
||||||
|
* of the folded launch state: lifecycle labels, the reused/fresh distinction,
|
||||||
|
* the actionable failure (with a details toggle that hides the code by default),
|
||||||
|
* and the "render nothing" cases for unmanaged rows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
||||||
|
|
||||||
|
describe("ModelServerLaunchBadge (F35.1)", () => {
|
||||||
|
it("renders nothing without a status or a failure", () => {
|
||||||
|
const { container } = render(<ModelServerLaunchBadge />);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing for a notConfigured status (unmanaged endpoint)", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ModelServerLaunchBadge status={{ state: "notConfigured" }} />,
|
||||||
|
);
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the probing and starting lifecycle labels", () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<ModelServerLaunchBadge status={{ state: "probing" }} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("model server status").textContent).toMatch(
|
||||||
|
/vérification/i,
|
||||||
|
);
|
||||||
|
rerender(<ModelServerLaunchBadge status={{ state: "starting" }} />);
|
||||||
|
expect(screen.getByLabelText("model server status").textContent).toMatch(
|
||||||
|
/démarrage/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("distinguishes a reused server from a freshly started one", () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<ModelServerLaunchBadge status={{ state: "ready", reused: true }} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("model server status").textContent).toMatch(
|
||||||
|
/réutilisé/i,
|
||||||
|
);
|
||||||
|
rerender(
|
||||||
|
<ModelServerLaunchBadge status={{ state: "ready", reused: false }} />,
|
||||||
|
);
|
||||||
|
const label = screen.getByLabelText("model server status").textContent ?? "";
|
||||||
|
expect(label).toMatch(/prêt/i);
|
||||||
|
expect(label).not.toMatch(/réutilisé/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an actionable failure message, not a bare 'agent failed'", () => {
|
||||||
|
render(
|
||||||
|
<ModelServerLaunchBadge
|
||||||
|
failure={{
|
||||||
|
cause: "model_server",
|
||||||
|
code: "SERVER_UNREACHABLE",
|
||||||
|
message: "llama-server did not become ready on :8080",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const alert = screen.getByRole("alert");
|
||||||
|
expect(alert.textContent).toMatch(/did not become ready on :8080/);
|
||||||
|
// The code is not exposed by default (logs/detail stay hidden).
|
||||||
|
expect(screen.queryByLabelText("launch failure detail")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reveals the stable code/cause only when Détails is toggled", () => {
|
||||||
|
render(
|
||||||
|
<ModelServerLaunchBadge
|
||||||
|
failure={{ cause: "model_server", code: "SPAWN", message: "boom" }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByLabelText("toggle launch failure detail"));
|
||||||
|
const detail = screen.getByLabelText("launch failure detail");
|
||||||
|
expect(detail.textContent).toMatch(/SPAWN/);
|
||||||
|
expect(detail.textContent).toMatch(/model_server/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers the agent-scoped failure over a failed status", () => {
|
||||||
|
render(
|
||||||
|
<ModelServerLaunchBadge
|
||||||
|
status={{ state: "failed", code: "S1", message: "status-level" }}
|
||||||
|
failure={{ cause: "model_server", code: "A1", message: "agent-level" }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("alert").textContent).toMatch(/agent-level/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to a failed status when there is no agent failure", () => {
|
||||||
|
render(
|
||||||
|
<ModelServerLaunchBadge
|
||||||
|
status={{ state: "failed", code: "S1", message: "status-level boom" }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("alert").textContent).toMatch(/status-level boom/);
|
||||||
|
});
|
||||||
|
});
|
||||||
115
frontend/src/features/agents/ModelServerLaunchBadge.tsx
Normal file
115
frontend/src/features/agents/ModelServerLaunchBadge.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
/**
|
||||||
|
* `ModelServerLaunchBadge` — presentational launch-state indicator for an agent
|
||||||
|
* whose OpenCode profile binds a local model server (F35.1). All state is folded
|
||||||
|
* in {@link useAgents} (`modelServerStatusByServer` + `launchFailureByAgent`);
|
||||||
|
* this component only renders it.
|
||||||
|
*
|
||||||
|
* It shows, in order of severity:
|
||||||
|
* - a launch **failure** with its actionable message (not a bare "agent failed")
|
||||||
|
* — the full logs are never shown by default; a "Détails" toggle reveals the
|
||||||
|
* stable code/cause only;
|
||||||
|
* - otherwise the model-server lifecycle: `probing`, `starting`, `ready`
|
||||||
|
* (distinguishing a reused server from a freshly-started one).
|
||||||
|
*
|
||||||
|
* Renders nothing when there is neither a status to show nor a failure, so a
|
||||||
|
* plain (non-local-model) agent row stays untouched.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/shared";
|
||||||
|
import type { ModelServerStatus } from "@/domain";
|
||||||
|
import type { AgentLaunchFailure } from "./useAgents";
|
||||||
|
|
||||||
|
export interface ModelServerLaunchBadgeProps {
|
||||||
|
/** The bound server's lifecycle status, when one has been observed. */
|
||||||
|
status?: ModelServerStatus;
|
||||||
|
/** The agent's last launch failure, when one occurred. */
|
||||||
|
failure?: AgentLaunchFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Human label + tone for a non-failed lifecycle state. */
|
||||||
|
function describeStatus(
|
||||||
|
status: ModelServerStatus,
|
||||||
|
): { label: string; tone: string; role?: "status" } | null {
|
||||||
|
switch (status.state) {
|
||||||
|
case "notConfigured":
|
||||||
|
return null; // Nothing to surface for an unmanaged endpoint.
|
||||||
|
case "probing":
|
||||||
|
return { label: "vérification du serveur…", tone: "bg-muted/20 text-muted", role: "status" };
|
||||||
|
case "starting":
|
||||||
|
return { label: "démarrage du serveur…", tone: "bg-warning/20 text-warning", role: "status" };
|
||||||
|
case "ready":
|
||||||
|
return status.reused
|
||||||
|
? { label: "serveur prêt (réutilisé)", tone: "bg-success/20 text-success" }
|
||||||
|
: { label: "serveur prêt", tone: "bg-success/20 text-success" };
|
||||||
|
case "failed":
|
||||||
|
// Handled by the failure branch below (richer, actionable).
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelServerLaunchBadge({
|
||||||
|
status,
|
||||||
|
failure,
|
||||||
|
}: ModelServerLaunchBadgeProps) {
|
||||||
|
const [showDetail, setShowDetail] = useState(false);
|
||||||
|
|
||||||
|
// A launch failure (from `agentLaunchFailed`) or a `failed` status both mean
|
||||||
|
// the launch could not complete — prefer the agent-scoped failure (it carries
|
||||||
|
// the actionable message); fall back to the server `failed` status.
|
||||||
|
const failed: { code: string; message: string; cause?: string } | null =
|
||||||
|
failure
|
||||||
|
? failure
|
||||||
|
: status?.state === "failed"
|
||||||
|
? { code: status.code, message: status.message }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
return (
|
||||||
|
<span className="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
role="alert"
|
||||||
|
aria-label="model server launch failed"
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||||
|
"bg-danger/20 text-danger",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Échec du lancement : {failed.message}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="toggle launch failure detail"
|
||||||
|
onClick={() => setShowDetail((v) => !v)}
|
||||||
|
className="text-xs text-muted underline hover:text-content"
|
||||||
|
>
|
||||||
|
Détails
|
||||||
|
</button>
|
||||||
|
{showDetail && (
|
||||||
|
<span aria-label="launch failure detail" className="text-xs text-faint">
|
||||||
|
code {failed.code}
|
||||||
|
{failed.cause ? ` · cause ${failed.cause}` : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status) return null;
|
||||||
|
const described = describeStatus(status);
|
||||||
|
if (!described) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
role={described.role}
|
||||||
|
aria-label="model server status"
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-2 py-0.5 text-xs font-medium",
|
||||||
|
described.tone,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{described.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -5,7 +5,9 @@
|
|||||||
export { AgentsPanel } from "./AgentsPanel";
|
export { AgentsPanel } from "./AgentsPanel";
|
||||||
export type { AgentsPanelProps } from "./AgentsPanel";
|
export type { AgentsPanelProps } from "./AgentsPanel";
|
||||||
export { useAgents } from "./useAgents";
|
export { useAgents } from "./useAgents";
|
||||||
export type { AgentsViewModel } from "./useAgents";
|
export type { AgentsViewModel, AgentLaunchFailure } from "./useAgents";
|
||||||
|
export { ModelServerLaunchBadge } from "./ModelServerLaunchBadge";
|
||||||
|
export type { ModelServerLaunchBadgeProps } from "./ModelServerLaunchBadge";
|
||||||
export { ResumeProjectPanel } from "./ResumeProjectPanel";
|
export { ResumeProjectPanel } from "./ResumeProjectPanel";
|
||||||
export type { ResumeProjectPanelProps } from "./ResumeProjectPanel";
|
export type { ResumeProjectPanelProps } from "./ResumeProjectPanel";
|
||||||
export { useResumeProject } from "./useResumeProject";
|
export { useResumeProject } from "./useResumeProject";
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import type {
|
|||||||
Agent,
|
Agent,
|
||||||
AgentProfile,
|
AgentProfile,
|
||||||
GatewayError,
|
GatewayError,
|
||||||
|
ModelServerStatus,
|
||||||
TerminalSession,
|
TerminalSession,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import type { LiveAgent, OpenTerminalOptions, TerminalHandle } from "@/ports";
|
import type { LiveAgent, OpenTerminalOptions, TerminalHandle } from "@/ports";
|
||||||
@ -41,6 +42,17 @@ export interface AgentLimitState {
|
|||||||
suspected?: boolean;
|
suspected?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An agent-launch failure surfaced to the UI (F35), from `agentLaunchFailed`.
|
||||||
|
* `cause` namespaces the origin (e.g. `"model_server"`); `code`/`message` are
|
||||||
|
* the stable, actionable failure details.
|
||||||
|
*/
|
||||||
|
export interface AgentLaunchFailure {
|
||||||
|
cause: string;
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** What the agents UI needs from this hook. */
|
/** What the agents UI needs from this hook. */
|
||||||
export interface AgentsViewModel {
|
export interface AgentsViewModel {
|
||||||
/** All agents known to the project. */
|
/** All agents known to the project. */
|
||||||
@ -67,6 +79,19 @@ export interface AgentsViewModel {
|
|||||||
* absent from the map is unlimited.
|
* absent from the map is unlimited.
|
||||||
*/
|
*/
|
||||||
limitByAgent: Record<string, AgentLimitState>;
|
limitByAgent: Record<string, AgentLimitState>;
|
||||||
|
/**
|
||||||
|
* Local model-server lifecycle status keyed by `serverId` (F35), folded from
|
||||||
|
* `modelServerStatusChanged`. Correlate an agent to its server through the
|
||||||
|
* profile's `opencode.localModelServerId`. A server absent from the map has no
|
||||||
|
* observed status yet.
|
||||||
|
*/
|
||||||
|
modelServerStatusByServer: Record<string, ModelServerStatus>;
|
||||||
|
/**
|
||||||
|
* Last agent-launch failure keyed by agent id (F35), folded from
|
||||||
|
* `agentLaunchFailed`. Carries an actionable `message`/`code`/`cause`. Cleared
|
||||||
|
* for an agent as soon as it launches successfully (`agentLaunched`).
|
||||||
|
*/
|
||||||
|
launchFailureByAgent: Record<string, AgentLaunchFailure>;
|
||||||
/** Last error message, or `null`. */
|
/** Last error message, or `null`. */
|
||||||
error: string | null;
|
error: string | null;
|
||||||
/** Whether a request is in flight. */
|
/** Whether a request is in flight. */
|
||||||
@ -154,6 +179,12 @@ export function useAgents(projectId: string): AgentsViewModel {
|
|||||||
const [limitByAgent, setLimitByAgent] = useState<
|
const [limitByAgent, setLimitByAgent] = useState<
|
||||||
Record<string, AgentLimitState>
|
Record<string, AgentLimitState>
|
||||||
>({});
|
>({});
|
||||||
|
const [modelServerStatusByServer, setModelServerStatusByServer] = useState<
|
||||||
|
Record<string, ModelServerStatus>
|
||||||
|
>({});
|
||||||
|
const [launchFailureByAgent, setLaunchFailureByAgent] = useState<
|
||||||
|
Record<string, AgentLaunchFailure>
|
||||||
|
>({});
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@ -208,6 +239,15 @@ export function useAgents(projectId: string): AgentsViewModel {
|
|||||||
void refresh();
|
void refresh();
|
||||||
void refreshLiveAgents();
|
void refreshLiveAgents();
|
||||||
}
|
}
|
||||||
|
// A successful launch supersedes any prior launch failure for that agent
|
||||||
|
// (F35): drop its stale error banner.
|
||||||
|
if (event.type === "agentLaunched") {
|
||||||
|
setLaunchFailureByAgent((prev) => {
|
||||||
|
if (!(event.agentId in prev)) return prev;
|
||||||
|
const { [event.agentId]: _cleared, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
}
|
||||||
// Record which door a delegation came through (mcp vs file). Absent
|
// Record which door a delegation came through (mcp vs file). Absent
|
||||||
// `source` (older backend) leaves the map untouched → no badge.
|
// `source` (older backend) leaves the map untouched → no badge.
|
||||||
if (
|
if (
|
||||||
@ -269,6 +309,24 @@ export function useAgents(projectId: string): AgentsViewModel {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
break;
|
break;
|
||||||
|
// F35 — local model server lifecycle during launch, keyed by server id.
|
||||||
|
case "modelServerStatusChanged":
|
||||||
|
setModelServerStatusByServer((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[event.serverId]: event.status,
|
||||||
|
}));
|
||||||
|
break;
|
||||||
|
// F35 — a launch failed with an actionable cause/message, keyed by agent.
|
||||||
|
case "agentLaunchFailed":
|
||||||
|
setLaunchFailureByAgent((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[event.agentId]: {
|
||||||
|
cause: event.cause,
|
||||||
|
code: event.code,
|
||||||
|
message: event.message,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -475,6 +533,8 @@ export function useAgents(projectId: string): AgentsViewModel {
|
|||||||
liveAgents,
|
liveAgents,
|
||||||
delegationSourceByRequester,
|
delegationSourceByRequester,
|
||||||
limitByAgent,
|
limitByAgent,
|
||||||
|
modelServerStatusByServer,
|
||||||
|
launchFailureByAgent,
|
||||||
error,
|
error,
|
||||||
busy,
|
busy,
|
||||||
runningAgentId,
|
runningAgentId,
|
||||||
|
|||||||
129
frontend/src/features/agents/useAgentsModelServer.test.tsx
Normal file
129
frontend/src/features/agents/useAgentsModelServer.test.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* F35.1 — local model server launch state in {@link useAgents}.
|
||||||
|
*
|
||||||
|
* Drives the hook behind the real {@link DIProvider} with an in-memory
|
||||||
|
* {@link MockSystemGateway} to emit the two F35 domain events, and verifies they
|
||||||
|
* fold into `modelServerStatusByServer` (keyed by serverId) and
|
||||||
|
* `launchFailureByAgent` (keyed by agentId).
|
||||||
|
*
|
||||||
|
* Cases:
|
||||||
|
* - `modelServerStatusChanged` → status by server id (probing → starting → ready)
|
||||||
|
* - `agentLaunchFailed` → actionable failure by agent id
|
||||||
|
* - `agentLaunched` clears a prior launch failure for that agent
|
||||||
|
* - a `failed` status is retained per server (independent of the agent map)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
|
||||||
|
import type { DomainEvent } from "@/domain";
|
||||||
|
import type { Gateways } from "@/ports";
|
||||||
|
import { MockSystemGateway } from "@/adapters/mock";
|
||||||
|
import { DIProvider } from "@/app/di";
|
||||||
|
import { useAgents } from "./useAgents";
|
||||||
|
|
||||||
|
const PROJECT_ID = "proj-modelserver-001";
|
||||||
|
const AGENT = "agent-oc";
|
||||||
|
const SERVER = "srv-42";
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const system = new MockSystemGateway();
|
||||||
|
const agent = {
|
||||||
|
listAgents: async () => [],
|
||||||
|
listLiveAgents: async () => [],
|
||||||
|
};
|
||||||
|
const profile = { listProfiles: async () => [] };
|
||||||
|
const gateways = { system, agent, profile } as unknown as Gateways;
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<DIProvider gateways={gateways}>{children}</DIProvider>
|
||||||
|
);
|
||||||
|
const view = renderHook(() => useAgents(PROJECT_ID), { wrapper });
|
||||||
|
return { system, view };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emit(system: MockSystemGateway, event: DomainEvent) {
|
||||||
|
await act(async () => {
|
||||||
|
system.emit(event);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useAgents — local model server launch state (F35.1)", () => {
|
||||||
|
it("folds modelServerStatusChanged into modelServerStatusByServer by serverId", async () => {
|
||||||
|
const { system, view } = setup();
|
||||||
|
|
||||||
|
await emit(system, {
|
||||||
|
type: "modelServerStatusChanged",
|
||||||
|
serverId: SERVER,
|
||||||
|
status: { state: "probing" },
|
||||||
|
});
|
||||||
|
expect(view.result.current.modelServerStatusByServer[SERVER]).toEqual({
|
||||||
|
state: "probing",
|
||||||
|
});
|
||||||
|
|
||||||
|
await emit(system, {
|
||||||
|
type: "modelServerStatusChanged",
|
||||||
|
serverId: SERVER,
|
||||||
|
status: { state: "starting" },
|
||||||
|
});
|
||||||
|
await emit(system, {
|
||||||
|
type: "modelServerStatusChanged",
|
||||||
|
serverId: SERVER,
|
||||||
|
status: { state: "ready", reused: true },
|
||||||
|
});
|
||||||
|
expect(view.result.current.modelServerStatusByServer[SERVER]).toEqual({
|
||||||
|
state: "ready",
|
||||||
|
reused: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("folds agentLaunchFailed into launchFailureByAgent by agentId", async () => {
|
||||||
|
const { system, view } = setup();
|
||||||
|
await emit(system, {
|
||||||
|
type: "agentLaunchFailed",
|
||||||
|
agentId: AGENT,
|
||||||
|
cause: "model_server",
|
||||||
|
code: "SERVER_UNREACHABLE",
|
||||||
|
message: "llama-server did not become ready on :8080",
|
||||||
|
});
|
||||||
|
expect(view.result.current.launchFailureByAgent[AGENT]).toEqual({
|
||||||
|
cause: "model_server",
|
||||||
|
code: "SERVER_UNREACHABLE",
|
||||||
|
message: "llama-server did not become ready on :8080",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a successful agentLaunched clears the agent's prior launch failure", async () => {
|
||||||
|
const { system, view } = setup();
|
||||||
|
await emit(system, {
|
||||||
|
type: "agentLaunchFailed",
|
||||||
|
agentId: AGENT,
|
||||||
|
cause: "model_server",
|
||||||
|
code: "SERVER_UNREACHABLE",
|
||||||
|
message: "boom",
|
||||||
|
});
|
||||||
|
expect(view.result.current.launchFailureByAgent[AGENT]).toBeTruthy();
|
||||||
|
|
||||||
|
await emit(system, {
|
||||||
|
type: "agentLaunched",
|
||||||
|
agentId: AGENT,
|
||||||
|
sessionId: "sess-1",
|
||||||
|
});
|
||||||
|
expect(view.result.current.launchFailureByAgent[AGENT]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a per-server failed status independent of the agent failure map", async () => {
|
||||||
|
const { system, view } = setup();
|
||||||
|
await emit(system, {
|
||||||
|
type: "modelServerStatusChanged",
|
||||||
|
serverId: SERVER,
|
||||||
|
status: { state: "failed", code: "SPAWN", message: "binary not found" },
|
||||||
|
});
|
||||||
|
expect(view.result.current.modelServerStatusByServer[SERVER]).toEqual({
|
||||||
|
state: "failed",
|
||||||
|
code: "SPAWN",
|
||||||
|
message: "binary not found",
|
||||||
|
});
|
||||||
|
expect(view.result.current.launchFailureByAgent[AGENT]).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -13,8 +13,8 @@ import {
|
|||||||
fireEvent,
|
fireEvent,
|
||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
|
|
||||||
import { MockProfileGateway } from "@/adapters/mock";
|
import { MockModelServerGateway, MockProfileGateway } from "@/adapters/mock";
|
||||||
import type { ProfileAvailability } from "@/domain";
|
import type { LocalModelServerConfig, ProfileAvailability } from "@/domain";
|
||||||
import type { Gateways } from "@/ports";
|
import type { Gateways } from "@/ports";
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import { FirstRunWizard } from "./FirstRunWizard";
|
import { FirstRunWizard } from "./FirstRunWizard";
|
||||||
@ -23,10 +23,12 @@ import { DETECT_TIMEOUT_MS } from "./useFirstRun";
|
|||||||
function renderWizard(
|
function renderWizard(
|
||||||
profile: MockProfileGateway = new MockProfileGateway(),
|
profile: MockProfileGateway = new MockProfileGateway(),
|
||||||
onDone = vi.fn(),
|
onDone = vi.fn(),
|
||||||
|
modelServer: MockModelServerGateway = new MockModelServerGateway(),
|
||||||
) {
|
) {
|
||||||
const gateways = { profile } as unknown as Gateways;
|
const gateways = { profile, modelServer } as unknown as Gateways;
|
||||||
return {
|
return {
|
||||||
profile,
|
profile,
|
||||||
|
modelServer,
|
||||||
onDone,
|
onDone,
|
||||||
...render(
|
...render(
|
||||||
<DIProvider gateways={gateways}>
|
<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
|
// Ticket #28 — the wizard must survive a detection that never answers. When the
|
||||||
// backend `detect_profiles` command panics, the `invoke` promise is neither
|
// backend `detect_profiles` command panics, the `invoke` promise is neither
|
||||||
// resolved nor rejected: nothing after `await detectProfiles(...)` ever runs.
|
// resolved nor rejected: nothing after `await detectProfiles(...)` ever runs.
|
||||||
|
|||||||
@ -15,8 +15,18 @@
|
|||||||
* `./profile`.
|
* `./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 { Button, IconButton, Input, Panel, Toolbar, cn } from "@/shared";
|
||||||
|
import {
|
||||||
|
ModelServersPanel,
|
||||||
|
ModelServerSelect,
|
||||||
|
useModelServers,
|
||||||
|
} from "@/features/model-servers";
|
||||||
import { useFirstRun, type WizardEntry } from "./useFirstRun";
|
import { useFirstRun, type WizardEntry } from "./useFirstRun";
|
||||||
import {
|
import {
|
||||||
defaultHttpChatConfig,
|
defaultHttpChatConfig,
|
||||||
@ -48,6 +58,7 @@ export function FirstRunWizard({
|
|||||||
forceOpen?: boolean;
|
forceOpen?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const vm = useFirstRun();
|
const vm = useFirstRun();
|
||||||
|
const modelServers = useModelServers();
|
||||||
|
|
||||||
if (vm.isFirstRun === null) return null;
|
if (vm.isFirstRun === null) return null;
|
||||||
if (!forceOpen && vm.isFirstRun === false) return null;
|
if (!forceOpen && vm.isFirstRun === false) return null;
|
||||||
@ -93,16 +104,40 @@ export function FirstRunWizard({
|
|||||||
Detecting…
|
Detecting…
|
||||||
</span>
|
</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>
|
</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">
|
<ul className="flex list-none flex-col gap-3 p-0">
|
||||||
{vm.entries.map((entry) => (
|
{vm.entries.map((entry) => (
|
||||||
<ProfileRow
|
<ProfileRow
|
||||||
key={entry.profile.id}
|
key={entry.profile.id}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
|
servers={modelServers.servers}
|
||||||
onToggle={() => vm.toggle(entry.profile.id)}
|
onToggle={() => vm.toggle(entry.profile.id)}
|
||||||
onChange={(p) => vm.updateProfile(entry.profile.id, p)}
|
onChange={(p) => vm.updateProfile(entry.profile.id, p)}
|
||||||
onRemove={() => vm.remove(entry.profile.id)}
|
onRemove={() => vm.remove(entry.profile.id)}
|
||||||
|
onDuplicate={
|
||||||
|
entry.profile.structuredAdapter === "openCode"
|
||||||
|
? () =>
|
||||||
|
vm.addOpenCodeProfile({
|
||||||
|
name: `${entry.profile.name} (copy)`,
|
||||||
|
opencode: entry.profile.opencode,
|
||||||
|
})
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -120,17 +155,24 @@ export function FirstRunWizard({
|
|||||||
/** One editable candidate row: select, edit command/args, see availability. */
|
/** One editable candidate row: select, edit command/args, see availability. */
|
||||||
function ProfileRow({
|
function ProfileRow({
|
||||||
entry,
|
entry,
|
||||||
|
servers,
|
||||||
onToggle,
|
onToggle,
|
||||||
onChange,
|
onChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onDuplicate,
|
||||||
}: {
|
}: {
|
||||||
entry: WizardEntry;
|
entry: WizardEntry;
|
||||||
|
/** Declared local model servers (F35.2), for the OpenCode binding dropdown. */
|
||||||
|
servers: LocalModelServerConfig[];
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onChange: (p: AgentProfile) => void;
|
onChange: (p: AgentProfile) => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
/** Present only for OpenCode rows: clone this row into a new profile (F36). */
|
||||||
|
onDuplicate?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { profile, selected, available } = entry;
|
const { profile, selected, available } = entry;
|
||||||
const errors = validateProfile(profile);
|
const errors = validateProfile(profile);
|
||||||
|
const isOpenCode = profile.structuredAdapter === "openCode";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="flex flex-col gap-2 rounded-md border border-border bg-raised p-3">
|
<li className="flex flex-col gap-2 rounded-md border border-border bg-raised p-3">
|
||||||
@ -158,15 +200,40 @@ function ProfileRow({
|
|||||||
>
|
>
|
||||||
{available === null ? "—" : available ? "✓ installed" : "✗ not found"}
|
{available === null ? "—" : available ? "✓ installed" : "✗ not found"}
|
||||||
</span>
|
</span>
|
||||||
|
<div className="ml-auto flex items-center gap-1">
|
||||||
|
{onDuplicate && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
aria-label={`duplicate ${profile.name}`}
|
||||||
|
onClick={onDuplicate}
|
||||||
|
>
|
||||||
|
Duplicate
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
size="sm"
|
size="sm"
|
||||||
aria-label={`remove ${profile.name}`}
|
aria-label={`remove ${profile.name}`}
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
className="ml-auto"
|
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</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">
|
<label className="flex flex-col gap-1">
|
||||||
<Caption>Command</Caption>
|
<Caption>Command</Caption>
|
||||||
@ -193,7 +260,12 @@ function ProfileRow({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{profile.structuredAdapter === "openCode" && (
|
{profile.structuredAdapter === "openCode" && (
|
||||||
<OpenCodeFields profile={profile} errors={errors} onChange={onChange} />
|
<OpenCodeFields
|
||||||
|
profile={profile}
|
||||||
|
errors={errors}
|
||||||
|
servers={servers}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@ -208,10 +280,13 @@ function ProfileRow({
|
|||||||
function OpenCodeFields({
|
function OpenCodeFields({
|
||||||
profile,
|
profile,
|
||||||
errors,
|
errors,
|
||||||
|
servers,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
profile: AgentProfile;
|
profile: AgentProfile;
|
||||||
errors: ProfileErrors;
|
errors: ProfileErrors;
|
||||||
|
/** Declared local model servers (F35.2), for the binding dropdown. */
|
||||||
|
servers: LocalModelServerConfig[];
|
||||||
onChange: (p: AgentProfile) => void;
|
onChange: (p: AgentProfile) => void;
|
||||||
}) {
|
}) {
|
||||||
const opencode = profile.opencode ?? defaultOpenCodeConfig();
|
const opencode = profile.opencode ?? defaultOpenCodeConfig();
|
||||||
@ -262,6 +337,44 @@ function OpenCodeFields({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
</fieldset>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import type {
|
|||||||
AgentProfile,
|
AgentProfile,
|
||||||
FirstRunState,
|
FirstRunState,
|
||||||
GatewayError,
|
GatewayError,
|
||||||
|
OpenCodeConfig,
|
||||||
ProfileAvailability,
|
ProfileAvailability,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
import { useGateways } from "@/app/di";
|
import { useGateways } from "@/app/di";
|
||||||
@ -50,6 +51,15 @@ export interface FirstRunViewModel {
|
|||||||
toggle: (id: string) => void;
|
toggle: (id: string) => void;
|
||||||
/** Replaces a candidate's profile (edited command/args/injection). */
|
/** Replaces a candidate's profile (edited command/args/injection). */
|
||||||
updateProfile: (id: string, profile: AgentProfile) => void;
|
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. */
|
/** Removes a candidate by id. */
|
||||||
remove: (id: string) => void;
|
remove: (id: string) => void;
|
||||||
/** Runs detection over all candidates, filling availability. */
|
/** 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) => {
|
const remove = useCallback((id: string) => {
|
||||||
setEntries((prev) => prev.filter((e) => e.profile.id !== id));
|
setEntries((prev) => prev.filter((e) => e.profile.id !== id));
|
||||||
}, []);
|
}, []);
|
||||||
@ -221,6 +249,7 @@ export function useFirstRun(): FirstRunViewModel {
|
|||||||
detecting,
|
detecting,
|
||||||
toggle,
|
toggle,
|
||||||
updateProfile,
|
updateProfile,
|
||||||
|
addOpenCodeProfile,
|
||||||
remove,
|
remove,
|
||||||
detect,
|
detect,
|
||||||
finish,
|
finish,
|
||||||
|
|||||||
57
frontend/src/features/model-servers/ModelServerSelect.tsx
Normal file
57
frontend/src/features/model-servers/ModelServerSelect.tsx
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* `ModelServerSelect` (F35.2) — a dropdown to bind an OpenCode profile to one of
|
||||||
|
* the declared local model servers, or to none ("external endpoint"). Replaces
|
||||||
|
* the free-text `localModelServerId` input posed in F36. Presentational: the
|
||||||
|
* server list and the current value are passed in.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LocalModelServerConfig } from "@/domain";
|
||||||
|
import { cn } from "@/shared";
|
||||||
|
|
||||||
|
/** The sentinel option value standing for "no managed server". */
|
||||||
|
const NONE = "";
|
||||||
|
|
||||||
|
export interface ModelServerSelectProps {
|
||||||
|
/** The declared servers to choose from. */
|
||||||
|
servers: LocalModelServerConfig[];
|
||||||
|
/** The currently-bound server id, or `undefined` for none. */
|
||||||
|
value?: string;
|
||||||
|
/** Called with the new server id, or `undefined` when "none" is chosen. */
|
||||||
|
onChange: (serverId: string | undefined) => void;
|
||||||
|
/** Accessible label (defaults to a generic one). */
|
||||||
|
ariaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelServerSelect({
|
||||||
|
servers,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
ariaLabel = "local model server",
|
||||||
|
}: ModelServerSelectProps) {
|
||||||
|
// A previously-bound server that no longer exists (deleted): keep it visible
|
||||||
|
// so the binding isn't silently dropped from the UI.
|
||||||
|
const missing =
|
||||||
|
value !== undefined && !servers.some((s) => s.id === value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
value={value ?? NONE}
|
||||||
|
onChange={(e) => onChange(e.target.value === NONE ? undefined : e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
"h-8 rounded-md bg-raised px-2 text-xs text-content",
|
||||||
|
"border border-border outline-none focus:border-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value={NONE}>None (external endpoint)</option>
|
||||||
|
{servers.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name} — {s.servedModelName}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{missing && (
|
||||||
|
<option value={value}>{value} (unknown / removed)</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
323
frontend/src/features/model-servers/ModelServersPanel.tsx
Normal file
323
frontend/src/features/model-servers/ModelServersPanel.tsx
Normal file
@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* `ModelServersPanel` (F35.2) — declare / edit / delete the local `llama.cpp`
|
||||||
|
* servers an OpenCode profile can bind to. Presentational: all I/O state comes
|
||||||
|
* from a {@link ModelServersViewModel} passed in (`useModelServers`), so the
|
||||||
|
* panel renders and is testable in isolation.
|
||||||
|
*
|
||||||
|
* A single editable draft is shown at a time (add or edit); the list underneath
|
||||||
|
* offers Edit / Delete per server. The `model_server_in_use` rejection surfaces
|
||||||
|
* as the vm's error banner (a referenced server can't be removed).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import type { LocalModelServerConfig, StopPolicy } from "@/domain";
|
||||||
|
import { Button, IconButton, Input, Panel, cn } from "@/shared";
|
||||||
|
import type { ModelServersViewModel } from "./useModelServers";
|
||||||
|
import {
|
||||||
|
emptyModelServer,
|
||||||
|
parseArgs,
|
||||||
|
validateModelServer,
|
||||||
|
type ModelServerErrors,
|
||||||
|
} from "./modelServer";
|
||||||
|
|
||||||
|
/** Small caption above a control. */
|
||||||
|
function Caption({ children }: { children: React.ReactNode }) {
|
||||||
|
return <span className="text-xs font-medium text-muted">{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STOP_POLICIES: { value: StopPolicy; label: string }[] = [
|
||||||
|
{ value: "stopOnAppExit", label: "Stop on app exit" },
|
||||||
|
{ value: "keepAlive", label: "Keep alive" },
|
||||||
|
{ value: "stopWhenUnused", label: "Stop when unused" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export interface ModelServersPanelProps {
|
||||||
|
/** The model-server registry view-model (from `useModelServers`). */
|
||||||
|
vm: ModelServersViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelServersPanel({ vm }: ModelServersPanelProps) {
|
||||||
|
// The server being edited, or a fresh draft when "Add" is clicked. `null` ⇒
|
||||||
|
// no editor open (list-only).
|
||||||
|
const [draft, setDraft] = useState<LocalModelServerConfig | null>(null);
|
||||||
|
|
||||||
|
function startAdd() {
|
||||||
|
vm.clearError();
|
||||||
|
setDraft(emptyModelServer());
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(server: LocalModelServerConfig) {
|
||||||
|
vm.clearError();
|
||||||
|
setDraft({ ...server });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
if (!draft) return;
|
||||||
|
const saved = await vm.save(draft);
|
||||||
|
if (saved) setDraft(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
aria-label="local model servers"
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-content">
|
||||||
|
Local model servers
|
||||||
|
</h3>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
aria-label="add model server"
|
||||||
|
onClick={startAdd}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
Add server
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{vm.error && (
|
||||||
|
<p role="alert" className="text-sm text-danger">
|
||||||
|
{vm.error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{vm.servers.length === 0 && !draft && (
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
No local server declared yet. Add one to auto-manage a `llama-server`,
|
||||||
|
or keep using an external endpoint.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="flex list-none flex-col gap-2 p-0">
|
||||||
|
{vm.servers.map((server) => (
|
||||||
|
<li
|
||||||
|
key={server.id}
|
||||||
|
className="flex items-center gap-2 rounded-md border border-border bg-raised p-2"
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 flex-col">
|
||||||
|
<strong className="truncate text-sm text-content">
|
||||||
|
{server.name}
|
||||||
|
</strong>
|
||||||
|
<span className="truncate text-xs text-muted">
|
||||||
|
{server.baseURL} · {server.servedModelName}
|
||||||
|
{server.autoStart ? " · auto-start" : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
aria-label={`edit ${server.name}`}
|
||||||
|
onClick={() => startEdit(server)}
|
||||||
|
disabled={vm.busy}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<IconButton
|
||||||
|
size="sm"
|
||||||
|
aria-label={`delete ${server.name}`}
|
||||||
|
onClick={() => void vm.remove(server.id)}
|
||||||
|
disabled={vm.busy}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{draft && (
|
||||||
|
<ServerEditor
|
||||||
|
draft={draft}
|
||||||
|
busy={vm.busy}
|
||||||
|
onChange={setDraft}
|
||||||
|
onCancel={() => {
|
||||||
|
vm.clearError();
|
||||||
|
setDraft(null);
|
||||||
|
}}
|
||||||
|
onSubmit={() => void submit()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The add/edit form for one server draft. */
|
||||||
|
function ServerEditor({
|
||||||
|
draft,
|
||||||
|
busy,
|
||||||
|
onChange,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
draft: LocalModelServerConfig;
|
||||||
|
busy: boolean;
|
||||||
|
onChange: (next: LocalModelServerConfig) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
}) {
|
||||||
|
const errors: ModelServerErrors = validateModelServer(draft);
|
||||||
|
const valid = Object.keys(errors).length === 0;
|
||||||
|
const patch = (next: Partial<LocalModelServerConfig>) =>
|
||||||
|
onChange({ ...draft, ...next });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<fieldset
|
||||||
|
aria-label="model server editor"
|
||||||
|
className="flex flex-col gap-2 rounded-md border border-primary/40 p-3"
|
||||||
|
>
|
||||||
|
<legend className="px-1 text-xs font-medium text-muted">
|
||||||
|
{draft.name.trim().length > 0 ? draft.name : "New server"}
|
||||||
|
</legend>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Name</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label="server name"
|
||||||
|
value={draft.name}
|
||||||
|
invalid={Boolean(errors.name)}
|
||||||
|
onChange={(e) => patch({ name: e.target.value })}
|
||||||
|
/>
|
||||||
|
{errors.name && <small className="text-xs text-danger">{errors.name}</small>}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<label className="flex min-w-[12rem] flex-1 flex-col gap-1">
|
||||||
|
<Caption>Base URL</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label="server base url"
|
||||||
|
value={draft.baseURL}
|
||||||
|
placeholder="http://localhost:8080/v1"
|
||||||
|
invalid={Boolean(errors.baseURL)}
|
||||||
|
onChange={(e) => patch({ baseURL: e.target.value })}
|
||||||
|
/>
|
||||||
|
{errors.baseURL && (
|
||||||
|
<small className="text-xs text-danger">{errors.baseURL}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex min-w-[6rem] flex-col gap-1">
|
||||||
|
<Caption>Port</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label="server port"
|
||||||
|
value={Number.isNaN(draft.port) ? "" : draft.port.toString()}
|
||||||
|
inputMode="numeric"
|
||||||
|
invalid={Boolean(errors.port)}
|
||||||
|
onChange={(e) => patch({ port: Number.parseInt(e.target.value, 10) })}
|
||||||
|
/>
|
||||||
|
{errors.port && <small className="text-xs text-danger">{errors.port}</small>}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Served model name (sent to OpenCode as `model`)</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label="server served model name"
|
||||||
|
value={draft.servedModelName}
|
||||||
|
placeholder="qwen3-coder-30b"
|
||||||
|
invalid={Boolean(errors.servedModelName)}
|
||||||
|
onChange={(e) => patch({ servedModelName: e.target.value })}
|
||||||
|
/>
|
||||||
|
{errors.servedModelName && (
|
||||||
|
<small className="text-xs text-danger">{errors.servedModelName}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Model path (absolute `.gguf` — required for auto-start)</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label="server model path"
|
||||||
|
value={draft.modelPath ?? ""}
|
||||||
|
placeholder="/models/qwen3-coder-30b.gguf"
|
||||||
|
invalid={Boolean(errors.modelPath)}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
patch({ modelPath: v.length === 0 ? undefined : v });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{errors.modelPath && (
|
||||||
|
<small className="text-xs text-danger">{errors.modelPath}</small>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Binary path (optional — `llama-server` command)</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label="server binary path"
|
||||||
|
value={draft.binaryPath ?? ""}
|
||||||
|
placeholder="llama-server"
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
patch({ binaryPath: v.length === 0 ? undefined : v });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<Caption>Extra arguments</Caption>
|
||||||
|
<Input
|
||||||
|
aria-label="server args"
|
||||||
|
value={draft.args.join(" ")}
|
||||||
|
placeholder="--ctx-size 8192 --n-gpu-layers 99"
|
||||||
|
onChange={(e) => patch({ args: parseArgs(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label="server auto start"
|
||||||
|
checked={draft.autoStart}
|
||||||
|
onChange={(e) => patch({ autoStart: e.target.checked })}
|
||||||
|
className="accent-primary"
|
||||||
|
/>
|
||||||
|
<Caption>Auto-start</Caption>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<Caption>Stop policy</Caption>
|
||||||
|
<select
|
||||||
|
aria-label="server stop policy"
|
||||||
|
value={draft.stopPolicy}
|
||||||
|
onChange={(e) => patch({ stopPolicy: e.target.value as StopPolicy })}
|
||||||
|
className={cn(
|
||||||
|
"h-8 rounded-md bg-raised px-2 text-xs text-content",
|
||||||
|
"border border-border outline-none focus:border-primary",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STOP_POLICIES.map((p) => (
|
||||||
|
<option key={p.value} value={p.value}>
|
||||||
|
{p.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
aria-label="save model server"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={busy || !valid}
|
||||||
|
>
|
||||||
|
Save server
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label="cancel model server"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
frontend/src/features/model-servers/index.ts
Normal file
19
frontend/src/features/model-servers/index.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Local model servers feature — public surface (F35.2).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { useModelServers } from "./useModelServers";
|
||||||
|
export type { ModelServersViewModel } from "./useModelServers";
|
||||||
|
export { ModelServersPanel } from "./ModelServersPanel";
|
||||||
|
export type { ModelServersPanelProps } from "./ModelServersPanel";
|
||||||
|
export { ModelServerSelect } from "./ModelServerSelect";
|
||||||
|
export type { ModelServerSelectProps } from "./ModelServerSelect";
|
||||||
|
export {
|
||||||
|
emptyModelServer,
|
||||||
|
newModelServerId,
|
||||||
|
validateModelServer,
|
||||||
|
isModelServerValid,
|
||||||
|
isAbsolutePath,
|
||||||
|
parseArgs,
|
||||||
|
type ModelServerErrors,
|
||||||
|
} from "./modelServer";
|
||||||
97
frontend/src/features/model-servers/modelServer.ts
Normal file
97
frontend/src/features/model-servers/modelServer.ts
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* Pure, framework-free helpers for the local model server feature (F35). Draft
|
||||||
|
* validation mirrors the backend invariants (`ModelServerEndpoint::new`,
|
||||||
|
* `LocalModelRef::new`, `LocalModelServerConfig::new`) so the form can surface
|
||||||
|
* errors before any `invoke`. No React, no Tauri here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LocalModelServerConfig } from "@/domain";
|
||||||
|
|
||||||
|
/** A field-keyed validation error map (empty ⇒ valid). */
|
||||||
|
export type ModelServerErrors = Partial<
|
||||||
|
Record<
|
||||||
|
"name" | "baseURL" | "port" | "servedModelName" | "modelPath",
|
||||||
|
string
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
|
||||||
|
/** Whether a string is a valid `http://`/`https://` URL (mirror of backend). */
|
||||||
|
function isValidHttpUrl(v: string): boolean {
|
||||||
|
if (v.trim().length === 0) return false;
|
||||||
|
return v.startsWith("http://") || v.startsWith("https://");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a path looks absolute (mirror of the backend `is_absolute_path`:
|
||||||
|
* POSIX `/…` or a Windows drive/UNC). Blank is handled by the caller.
|
||||||
|
*/
|
||||||
|
export function isAbsolutePath(path: string): boolean {
|
||||||
|
if (path.startsWith("/") || path.startsWith("\\")) return true;
|
||||||
|
// Windows drive `C:/` or `C:\`.
|
||||||
|
return /^[a-zA-Z]:[/\\]/.test(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a {@link LocalModelServerConfig} draft the way the backend would.
|
||||||
|
* Returns an empty object when the draft is valid.
|
||||||
|
*/
|
||||||
|
export function validateModelServer(c: LocalModelServerConfig): ModelServerErrors {
|
||||||
|
const errors: ModelServerErrors = {};
|
||||||
|
if (c.name.trim().length === 0) errors.name = "Name is required.";
|
||||||
|
if (!isValidHttpUrl(c.baseURL)) {
|
||||||
|
errors.baseURL = "Base URL must start with http:// or https://.";
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(c.port) || c.port < 1 || c.port > 65_535) {
|
||||||
|
errors.port = "Port must be an integer between 1 and 65535.";
|
||||||
|
}
|
||||||
|
if (c.servedModelName.trim().length === 0) {
|
||||||
|
errors.servedModelName = "Served model name is required.";
|
||||||
|
}
|
||||||
|
const modelPath = c.modelPath?.trim() ?? "";
|
||||||
|
if (modelPath.length > 0 && !isAbsolutePath(modelPath)) {
|
||||||
|
errors.modelPath = "Model path must be absolute.";
|
||||||
|
}
|
||||||
|
// The backend rejects auto-start without an absolute model path.
|
||||||
|
if (c.autoStart && modelPath.length === 0) {
|
||||||
|
errors.modelPath = "Auto-start requires an absolute model path.";
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a draft is valid (no errors). */
|
||||||
|
export function isModelServerValid(c: LocalModelServerConfig): boolean {
|
||||||
|
return Object.keys(validateModelServer(c)).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generates a UUID for a client-created server (crypto when available). */
|
||||||
|
export function newModelServerId(): string {
|
||||||
|
const c = globalThis.crypto as Crypto | undefined;
|
||||||
|
if (c && typeof c.randomUUID === "function") return c.randomUUID();
|
||||||
|
return `srv-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fresh model-server draft (id minted client-side) pre-filled with a sensible
|
||||||
|
* local `llama-server` endpoint, so the form starts valid apart from the name.
|
||||||
|
*/
|
||||||
|
export function emptyModelServer(): LocalModelServerConfig {
|
||||||
|
return {
|
||||||
|
id: newModelServerId(),
|
||||||
|
kind: "llamaCpp",
|
||||||
|
name: "",
|
||||||
|
baseURL: "http://localhost:8080/v1",
|
||||||
|
port: 8080,
|
||||||
|
servedModelName: "qwen3-coder-30b",
|
||||||
|
args: [],
|
||||||
|
autoStart: false,
|
||||||
|
stopPolicy: "stopOnAppExit",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses a whitespace-separated args string into a trimmed, non-empty list. */
|
||||||
|
export function parseArgs(raw: string): string[] {
|
||||||
|
return raw
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => s.length > 0);
|
||||||
|
}
|
||||||
251
frontend/src/features/model-servers/modelServers.test.tsx
Normal file
251
frontend/src/features/model-servers/modelServers.test.tsx
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
/**
|
||||||
|
* F35.2 — local model server registry: the pure validation helpers, the
|
||||||
|
* {@link useModelServers} hook behind the real DIProvider + mock gateway, the
|
||||||
|
* {@link ModelServersPanel} CRUD UI (incl. the `model_server_in_use` rejection),
|
||||||
|
* and the {@link ModelServerSelect} binding dropdown.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { act, render, renderHook, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
import type { Gateways } from "@/ports";
|
||||||
|
import type { LocalModelServerConfig } from "@/domain";
|
||||||
|
import { MockModelServerGateway } from "@/adapters/mock";
|
||||||
|
import { DIProvider } from "@/app/di";
|
||||||
|
import { useModelServers } from "./useModelServers";
|
||||||
|
import { ModelServersPanel } from "./ModelServersPanel";
|
||||||
|
import { ModelServerSelect } from "./ModelServerSelect";
|
||||||
|
import {
|
||||||
|
emptyModelServer,
|
||||||
|
isAbsolutePath,
|
||||||
|
validateModelServer,
|
||||||
|
} from "./modelServer";
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
function setup(modelServer = new MockModelServerGateway()) {
|
||||||
|
const gateways = { modelServer } as unknown as Gateways;
|
||||||
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<DIProvider gateways={gateways}>{children}</DIProvider>
|
||||||
|
);
|
||||||
|
const view = renderHook(() => useModelServers(), { wrapper });
|
||||||
|
return { modelServer, view };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Pure helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("modelServer helpers (F35.2)", () => {
|
||||||
|
it("emptyModelServer starts valid apart from the name", () => {
|
||||||
|
const draft = emptyModelServer();
|
||||||
|
expect(validateModelServer(draft)).toEqual({ name: "Name is required." });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flags a non-http base URL and an out-of-range port", () => {
|
||||||
|
const errors = validateModelServer({
|
||||||
|
...SERVER,
|
||||||
|
baseURL: "ftp://oops",
|
||||||
|
port: 70_000,
|
||||||
|
});
|
||||||
|
expect(errors.baseURL).toBeTruthy();
|
||||||
|
expect(errors.port).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires an absolute model path, and requires it when auto-start is on", () => {
|
||||||
|
expect(validateModelServer({ ...SERVER, modelPath: "relative/x.gguf" }).modelPath).toBeTruthy();
|
||||||
|
expect(validateModelServer({ ...SERVER, autoStart: true }).modelPath).toMatch(
|
||||||
|
/auto-start/i,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
validateModelServer({ ...SERVER, autoStart: true, modelPath: "/m/x.gguf" }).modelPath,
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("recognises absolute POSIX and Windows paths", () => {
|
||||||
|
expect(isAbsolutePath("/models/x.gguf")).toBe(true);
|
||||||
|
expect(isAbsolutePath("C:/models/x.gguf")).toBe(true);
|
||||||
|
expect(isAbsolutePath("models/x.gguf")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hook
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("useModelServers (F35.2)", () => {
|
||||||
|
it("saves and lists servers", async () => {
|
||||||
|
const { view } = setup();
|
||||||
|
await act(async () => {
|
||||||
|
await view.result.current.save(SERVER);
|
||||||
|
});
|
||||||
|
expect(view.result.current.servers).toHaveLength(1);
|
||||||
|
expect(view.result.current.servers[0].name).toBe("Local A");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a server", async () => {
|
||||||
|
const { view } = setup();
|
||||||
|
await act(async () => {
|
||||||
|
await view.result.current.save(SERVER);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
const ok = await view.result.current.remove(SERVER.id);
|
||||||
|
expect(ok).toBe(true);
|
||||||
|
});
|
||||||
|
expect(view.result.current.servers).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces model_server_in_use as an actionable error and keeps the server", async () => {
|
||||||
|
const modelServer = new MockModelServerGateway();
|
||||||
|
const { view } = setup(modelServer);
|
||||||
|
await act(async () => {
|
||||||
|
await view.result.current.save(SERVER);
|
||||||
|
});
|
||||||
|
modelServer.markInUse(SERVER.id);
|
||||||
|
await act(async () => {
|
||||||
|
const ok = await view.result.current.remove(SERVER.id);
|
||||||
|
expect(ok).toBe(false);
|
||||||
|
});
|
||||||
|
expect(view.result.current.error).toMatch(/encore utilisé par un profil/i);
|
||||||
|
expect(view.result.current.servers).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Panel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function renderPanel(modelServer = new MockModelServerGateway()) {
|
||||||
|
const gateways = { modelServer } as unknown as Gateways;
|
||||||
|
function Harness() {
|
||||||
|
const vm = useModelServers();
|
||||||
|
return <ModelServersPanel vm={vm} />;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
modelServer,
|
||||||
|
...render(
|
||||||
|
<DIProvider gateways={gateways}>
|
||||||
|
<Harness />
|
||||||
|
</DIProvider>,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ModelServersPanel (F35.2)", () => {
|
||||||
|
it("declares a new server end-to-end", async () => {
|
||||||
|
const { modelServer } = renderPanel();
|
||||||
|
fireEvent.click(screen.getByLabelText("add model server"));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("server name"), {
|
||||||
|
target: { value: "Local A" },
|
||||||
|
});
|
||||||
|
// A valid draft (pre-filled endpoint + servedModelName) enables Save once
|
||||||
|
// the initial passive load has settled.
|
||||||
|
const save = screen.getByLabelText("save model server") as HTMLButtonElement;
|
||||||
|
await waitFor(() => expect(save.disabled).toBe(false));
|
||||||
|
fireEvent.click(save);
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const saved = await modelServer.listModelServers();
|
||||||
|
expect(saved.map((s) => s.name)).toEqual(["Local A"]);
|
||||||
|
});
|
||||||
|
// The editor closed and the server shows in the list.
|
||||||
|
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks Save while the draft is invalid (blank name)", () => {
|
||||||
|
renderPanel();
|
||||||
|
fireEvent.click(screen.getByLabelText("add model server"));
|
||||||
|
// Invalid draft (blank name) keeps Save disabled regardless of busy.
|
||||||
|
expect(
|
||||||
|
(screen.getByLabelText("save model server") as HTMLButtonElement).disabled,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an actionable banner when deleting a referenced server", async () => {
|
||||||
|
const modelServer = new MockModelServerGateway();
|
||||||
|
await modelServer.saveModelServer(SERVER);
|
||||||
|
modelServer.markInUse(SERVER.id);
|
||||||
|
renderPanel(modelServer);
|
||||||
|
|
||||||
|
await screen.findByLabelText("edit Local A");
|
||||||
|
fireEvent.click(screen.getByLabelText("delete Local A"));
|
||||||
|
|
||||||
|
expect((await screen.findByRole("alert")).textContent).toMatch(
|
||||||
|
/encore utilisé par un profil/i,
|
||||||
|
);
|
||||||
|
// The server is still listed (delete was refused).
|
||||||
|
expect(screen.getByLabelText("edit Local A")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("edits an existing server's served model name", async () => {
|
||||||
|
const modelServer = new MockModelServerGateway();
|
||||||
|
await modelServer.saveModelServer(SERVER);
|
||||||
|
renderPanel(modelServer);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByLabelText("edit Local A"));
|
||||||
|
fireEvent.change(screen.getByLabelText("server served model name"), {
|
||||||
|
target: { value: "qwen3-coder-7b" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByLabelText("save model server"));
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const saved = await modelServer.listModelServers();
|
||||||
|
expect(saved[0].servedModelName).toBe("qwen3-coder-7b");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Select
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("ModelServerSelect (F35.2)", () => {
|
||||||
|
it("offers a 'none' option plus each declared server, and emits the id", () => {
|
||||||
|
let picked: string | undefined = "sentinel";
|
||||||
|
render(
|
||||||
|
<ModelServerSelect
|
||||||
|
servers={[SERVER]}
|
||||||
|
value={undefined}
|
||||||
|
onChange={(id) => (picked = id)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const select = screen.getByLabelText("local model server") as HTMLSelectElement;
|
||||||
|
// "None" is selected when value is undefined.
|
||||||
|
expect(select.value).toBe("");
|
||||||
|
fireEvent.change(select, { target: { value: SERVER.id } });
|
||||||
|
expect(picked).toBe(SERVER.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits undefined when 'none' is chosen", () => {
|
||||||
|
let picked: string | undefined = SERVER.id;
|
||||||
|
render(
|
||||||
|
<ModelServerSelect
|
||||||
|
servers={[SERVER]}
|
||||||
|
value={SERVER.id}
|
||||||
|
onChange={(id) => (picked = id)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByLabelText("local model server"), {
|
||||||
|
target: { value: "" },
|
||||||
|
});
|
||||||
|
expect(picked).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a removed/unknown bound id visible rather than dropping it", () => {
|
||||||
|
render(
|
||||||
|
<ModelServerSelect servers={[]} value="ghost-id" onChange={() => {}} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText(/ghost-id \(unknown \/ removed\)/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
126
frontend/src/features/model-servers/useModelServers.ts
Normal file
126
frontend/src/features/model-servers/useModelServers.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* `useModelServers` — view-model for the local model server registry (F35.2).
|
||||||
|
* Owns the declared-servers list and the CRUD actions, consuming the
|
||||||
|
* {@link ModelServerGateway} exclusively (never `invoke()`), so the UI is
|
||||||
|
* testable with a mock.
|
||||||
|
*
|
||||||
|
* Delete surfaces the backend `model_server_in_use` rejection as a dedicated,
|
||||||
|
* human-readable message (a server still referenced by a profile cannot be
|
||||||
|
* removed).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import type { GatewayError, LocalModelServerConfig } from "@/domain";
|
||||||
|
import { useGateways } from "@/app/di";
|
||||||
|
|
||||||
|
/** What the model-server UI needs from this hook. */
|
||||||
|
export interface ModelServersViewModel {
|
||||||
|
/** The declared local model servers. */
|
||||||
|
servers: LocalModelServerConfig[];
|
||||||
|
/** Last error message, or `null`. */
|
||||||
|
error: string | null;
|
||||||
|
/** Whether a request is in flight. */
|
||||||
|
busy: boolean;
|
||||||
|
/** Reloads the server list. */
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
/** Creates or updates a server; returns the persisted config (or `null` on error). */
|
||||||
|
save: (config: LocalModelServerConfig) => Promise<LocalModelServerConfig | null>;
|
||||||
|
/** Deletes a server by id; returns `true` on success. */
|
||||||
|
remove: (serverId: string) => Promise<boolean>;
|
||||||
|
/** Clears the current error banner. */
|
||||||
|
clearError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(e: unknown): string {
|
||||||
|
if (e && typeof e === "object" && "message" in e) {
|
||||||
|
return String((e as GatewayError).message);
|
||||||
|
}
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeOf(e: unknown): string | undefined {
|
||||||
|
if (e && typeof e === "object" && "code" in e) {
|
||||||
|
return String((e as GatewayError).code);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useModelServers(): ModelServersViewModel {
|
||||||
|
const { modelServer } = useGateways();
|
||||||
|
const [servers, setServers] = useState<LocalModelServerConfig[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setServers(await modelServer.listModelServers());
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}, [modelServer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void reload();
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
const save = useCallback(
|
||||||
|
async (config: LocalModelServerConfig) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const saved = await modelServer.saveModelServer(config);
|
||||||
|
setServers((prev) => {
|
||||||
|
const i = prev.findIndex((s) => s.id === saved.id);
|
||||||
|
if (i >= 0) {
|
||||||
|
const next = prev.slice();
|
||||||
|
next[i] = saved;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
return [...prev, saved];
|
||||||
|
});
|
||||||
|
return saved;
|
||||||
|
} catch (e) {
|
||||||
|
setError(describe(e));
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[modelServer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const remove = useCallback(
|
||||||
|
async (serverId: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await modelServer.deleteModelServer(serverId);
|
||||||
|
setServers((prev) => prev.filter((s) => s.id !== serverId));
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
// A referenced server cannot be deleted — turn the stable backend code
|
||||||
|
// into an actionable message rather than a raw error string.
|
||||||
|
if (codeOf(e) === "model_server_in_use") {
|
||||||
|
setError(
|
||||||
|
"Ce serveur est encore utilisé par un profil : détache-le d'abord (choisis « aucun » ou un autre serveur) avant de le supprimer.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setError(describe(e));
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[modelServer],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearError = useCallback(() => setError(null), []);
|
||||||
|
|
||||||
|
return { servers, error, busy, reload, save, remove, clearError };
|
||||||
|
}
|
||||||
@ -25,10 +25,12 @@ import type {
|
|||||||
LayoutList,
|
LayoutList,
|
||||||
LayoutOperation,
|
LayoutOperation,
|
||||||
LayoutTree,
|
LayoutTree,
|
||||||
|
LocalModelServerConfig,
|
||||||
Memory,
|
Memory,
|
||||||
MemoryIndexEntry,
|
MemoryIndexEntry,
|
||||||
MemoryLink,
|
MemoryLink,
|
||||||
MemoryType,
|
MemoryType,
|
||||||
|
OpenCodeConfig,
|
||||||
EffectivePermissions,
|
EffectivePermissions,
|
||||||
PermissionSet,
|
PermissionSet,
|
||||||
PageDirection,
|
PageDirection,
|
||||||
@ -624,6 +626,45 @@ export interface ProfileGateway {
|
|||||||
deleteProfile(profileId: string): Promise<void>;
|
deleteProfile(profileId: string): Promise<void>;
|
||||||
/** Persists the batch of chosen profiles, closing the first run. */
|
/** Persists the batch of chosen profiles, closing the first run. */
|
||||||
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]>;
|
configureProfiles(profiles: AgentProfile[]): Promise<AgentProfile[]>;
|
||||||
|
/**
|
||||||
|
* Mints a new OpenCode profile from the canonical `opencode-llamacpp` seed
|
||||||
|
* (F36 — several local OpenCode profiles per project, identity = id). `name`
|
||||||
|
* overrides the seed display name; `opencode` overrides the seed config (used
|
||||||
|
* by "Duplicate" to carry an existing row's endpoint). Returns the new,
|
||||||
|
* freshly-id'd profile — it is not persisted until the batch is saved.
|
||||||
|
*/
|
||||||
|
cloneOpenCodeProfileFromSeed(
|
||||||
|
input?: CloneOpenCodeProfileFromSeedInput,
|
||||||
|
): Promise<AgentProfile>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Input for {@link ProfileGateway.cloneOpenCodeProfileFromSeed}. */
|
||||||
|
export interface CloneOpenCodeProfileFromSeedInput {
|
||||||
|
/** Optional display name for the new profile. */
|
||||||
|
name?: string;
|
||||||
|
/** Optional OpenCode config override; when omitted, the seed config is copied. */
|
||||||
|
opencode?: OpenCodeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local model servers (F35). CRUD over the global registry of declared
|
||||||
|
* `llama.cpp` servers an OpenCode profile can bind to via
|
||||||
|
* `OpenCodeConfig.localModelServerId`. Mirrors the three backend commands
|
||||||
|
* (`list_model_servers`, `save_model_server`, `delete_model_server`).
|
||||||
|
*/
|
||||||
|
export interface ModelServerGateway {
|
||||||
|
/** Lists the declared local model servers. */
|
||||||
|
listModelServers(): Promise<LocalModelServerConfig[]>;
|
||||||
|
/**
|
||||||
|
* Upserts a server config (create when `id` is a fresh client-minted UUID,
|
||||||
|
* update otherwise); returns the persisted config.
|
||||||
|
*/
|
||||||
|
saveModelServer(config: LocalModelServerConfig): Promise<LocalModelServerConfig>;
|
||||||
|
/**
|
||||||
|
* Deletes a server by id. Rejects with a `GatewayError` whose `code` is
|
||||||
|
* `model_server_in_use` when a profile still references it.
|
||||||
|
*/
|
||||||
|
deleteModelServer(serverId: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Input for {@link EmbedderGateway.saveEmbedderProfile}. */
|
/** Input for {@link EmbedderGateway.saveEmbedderProfile}. */
|
||||||
@ -933,6 +974,7 @@ export interface Gateways {
|
|||||||
git: GitGateway;
|
git: GitGateway;
|
||||||
remote: RemoteGateway;
|
remote: RemoteGateway;
|
||||||
profile: ProfileGateway;
|
profile: ProfileGateway;
|
||||||
|
modelServer: ModelServerGateway;
|
||||||
template: TemplateGateway;
|
template: TemplateGateway;
|
||||||
skill: SkillGateway;
|
skill: SkillGateway;
|
||||||
memory: MemoryGateway;
|
memory: MemoryGateway;
|
||||||
|
|||||||
Reference in New Issue
Block a user