feat(frontend): panneau Settings Deployment et gateway serveur desktop (#68 F1+F2)
Donne à l'utilisateur la surface pour activer le serveur depuis l'app. - `features/settings/` : `SettingsView`, `DeploymentSettings`, `useDeployment`. - `adapters/desktopServer.ts` + port `DesktopServerGateway` et DTO associés ; adapters mock et http/unsupported alignés (le mode web n'expose pas le contrôle du serveur qui l'héberge). - `ProjectsView` : le `showSettings: boolean` devient une navigation interne `AI Profiles` / `Deployment`. Le libellé alternant « Close AI Profiles » disparaît — Settings existait déjà dans cette vue, la surface évolue au lieu d'ajouter un `PanelId`. CORRECTION D'UN BRIEF FAUX, remontée spontanément par DevFrontend et qui mérite de survivre : le cadrage décrivait le mode `remoteProxyOtherMachine` avec deux champs. `validate_settings` en exige un troisième, `lanBindAddress`, et rejette loopback comme unspecified. Construit selon la spec, chaque save et chaque start en mode 3 aurait échoué — le lot serait parti vert et cassé. Le panneau expose donc un select alimenté par `candidateLanAddresses` fourni par le backend : la règle « le frontend n'invente jamais une IP » tient. QA ré-exécutée par Git avant merge : `npm run typecheck` exit 0 · `npx vitest run` 87 files / 789 passed, 0 échec. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -8,9 +8,11 @@ import type {
|
||||
Agent,
|
||||
AgentDrift,
|
||||
AgentProfile,
|
||||
DiagnosticWarning,
|
||||
DomainEvent,
|
||||
EmbedderEngines,
|
||||
EmbedderProfile,
|
||||
EmbeddedServerStatus,
|
||||
FirstRunState,
|
||||
GatewayError,
|
||||
GitBranches,
|
||||
@ -36,6 +38,8 @@ import type {
|
||||
ProjectWorkState,
|
||||
ProfileAvailability,
|
||||
ResumableAgent,
|
||||
ServerExposurePreview,
|
||||
ServerExposureSettings,
|
||||
Skill,
|
||||
ReplyChunk,
|
||||
SkillScope,
|
||||
@ -63,6 +67,7 @@ import type {
|
||||
CreateAgentInput,
|
||||
CreateMemoryInput,
|
||||
CreateSkillInput,
|
||||
DesktopServerGateway,
|
||||
EmbedderGateway,
|
||||
CreateTemplateInput,
|
||||
Gateways,
|
||||
@ -1373,6 +1378,148 @@ export class MockModelServerGateway implements ModelServerGateway {
|
||||
}
|
||||
}
|
||||
|
||||
/** Mirror of the backend `default_settings()` (ticket #68). */
|
||||
const DEFAULT_EXPOSURE_SETTINGS: ServerExposureSettings = {
|
||||
mode: "localOnly",
|
||||
port: 17373,
|
||||
trustedProxies: [],
|
||||
};
|
||||
|
||||
/** Fixed LAN candidates so the offline UI has something to pick from. */
|
||||
const MOCK_LAN_ADDRESSES = ["192.168.1.42", "10.0.0.17"];
|
||||
|
||||
function invalid(message: string): never {
|
||||
const err: GatewayError = { code: "INVALID", message };
|
||||
throw err;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory embedded-server gateway (ticket #68).
|
||||
*
|
||||
* Mirrors the backend `validate_settings` / `preview_settings` closely enough
|
||||
* that the Deployment UI — including its rejection paths — is testable offline.
|
||||
* It is never the source of truth: production runs the real Tauri commands.
|
||||
* `candidateLanAddresses` is fixed data here precisely because the UI must take
|
||||
* addresses from the gateway rather than deriving them.
|
||||
*/
|
||||
export class MockDesktopServerGateway implements DesktopServerGateway {
|
||||
private settings: ServerExposureSettings = structuredClone(
|
||||
DEFAULT_EXPOSURE_SETTINGS,
|
||||
);
|
||||
private current: EmbeddedServerStatus = { state: "stopped" };
|
||||
private readonly handlers = new Set<(s: EmbeddedServerStatus) => void>();
|
||||
|
||||
async getExposureSettings(): Promise<ServerExposureSettings> {
|
||||
return structuredClone(this.settings);
|
||||
}
|
||||
|
||||
async saveExposureSettings(settings: ServerExposureSettings): Promise<void> {
|
||||
this.validate(settings);
|
||||
this.settings = structuredClone(settings);
|
||||
}
|
||||
|
||||
async previewExposure(
|
||||
settings: ServerExposureSettings,
|
||||
): Promise<ServerExposurePreview> {
|
||||
// The real `preview_server_exposure_settings` validates *before* previewing,
|
||||
// so an incomplete draft is rejected rather than previewed. Mirrored here —
|
||||
// the UI relies on it as its validation authority.
|
||||
this.validate(settings);
|
||||
const warnings: DiagnosticWarning[] = [];
|
||||
if (
|
||||
settings.mode === "remoteProxyOtherMachine" &&
|
||||
settings.trustedProxies.length === 0
|
||||
) {
|
||||
// Mirrors the backend's `missingTrustedProxy` warning — which the backend
|
||||
// itself cannot currently reach, since `validate_settings` rejects an
|
||||
// empty `trustedProxies` for this mode first. Kept aligned on purpose: if
|
||||
// the backend reorders, the mock already matches.
|
||||
warnings.push({
|
||||
code: "missingTrustedProxy",
|
||||
message:
|
||||
"Add the IP address or CIDR of the reverse proxy that connects to this server.",
|
||||
});
|
||||
}
|
||||
const bindAddress =
|
||||
settings.mode === "remoteProxyOtherMachine"
|
||||
? settings.lanBindAddress
|
||||
: "127.0.0.1";
|
||||
const upstreamUrl =
|
||||
settings.mode !== "localOnly" && bindAddress
|
||||
? `http://${bindAddress}:${settings.port}`
|
||||
: undefined;
|
||||
return {
|
||||
candidateLanAddresses: [...MOCK_LAN_ADDRESSES],
|
||||
upstreamUrl,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
async status(): Promise<EmbeddedServerStatus> {
|
||||
return structuredClone(this.current);
|
||||
}
|
||||
|
||||
async start(): Promise<EmbeddedServerStatus> {
|
||||
// The backend re-validates on start; a bad config fails there, not silently.
|
||||
this.validate(this.settings);
|
||||
const preview = await this.previewExposure(this.settings);
|
||||
this.emit({
|
||||
state: "running",
|
||||
localUrl: `http://127.0.0.1:${this.settings.port}`,
|
||||
publicUrl:
|
||||
this.settings.mode === "localOnly"
|
||||
? undefined
|
||||
: this.settings.publicOrigin,
|
||||
upstreamUrl: preview.upstreamUrl,
|
||||
// Runtime-only, regenerated per start — never persisted.
|
||||
pairingCode: "MOCK-PAIR-4242",
|
||||
});
|
||||
return structuredClone(this.current);
|
||||
}
|
||||
|
||||
async stop(): Promise<EmbeddedServerStatus> {
|
||||
this.emit({ state: "stopped" });
|
||||
return structuredClone(this.current);
|
||||
}
|
||||
|
||||
async onStatusChanged(
|
||||
handler: (status: EmbeddedServerStatus) => void,
|
||||
): Promise<Unsubscribe> {
|
||||
this.handlers.add(handler);
|
||||
return () => this.handlers.delete(handler);
|
||||
}
|
||||
|
||||
/** Test hook: force a status (e.g. a `failed` state) and notify subscribers. */
|
||||
setStatus(status: EmbeddedServerStatus): void {
|
||||
this.emit(status);
|
||||
}
|
||||
|
||||
private emit(status: EmbeddedServerStatus): void {
|
||||
this.current = status;
|
||||
for (const handler of this.handlers) handler(structuredClone(status));
|
||||
}
|
||||
|
||||
private validate(settings: ServerExposureSettings): void {
|
||||
if (settings.mode !== "localOnly") {
|
||||
const origin = settings.publicOrigin;
|
||||
if (!origin) invalid("remote exposure requires publicOrigin");
|
||||
if (!origin.startsWith("https://")) {
|
||||
invalid("remote exposure requires publicOrigin to start with https://");
|
||||
}
|
||||
if (origin.includes("*")) invalid("publicOrigin must be exact, not a wildcard");
|
||||
if (origin.endsWith("/")) invalid("publicOrigin must not end with '/'");
|
||||
}
|
||||
if (settings.mode === "remoteProxyOtherMachine") {
|
||||
if (!settings.lanBindAddress) {
|
||||
invalid("remoteProxyOtherMachine requires lanBindAddress");
|
||||
}
|
||||
if (settings.trustedProxies.length === 0) {
|
||||
invalid("remoteProxyOtherMachine requires at least one trustedProxies entry");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateful in-memory template gateway.
|
||||
*
|
||||
@ -2630,6 +2777,7 @@ export function createMockGateways(): Gateways {
|
||||
remote: new MockRemoteGateway(),
|
||||
profile: new MockProfileGateway(),
|
||||
modelServer: new MockModelServerGateway(),
|
||||
desktopServer: new MockDesktopServerGateway(),
|
||||
template: new MockTemplateGateway(agentGateway),
|
||||
skill: new MockSkillGateway(agentGateway),
|
||||
memory: new MockMemoryGateway(),
|
||||
|
||||
Reference in New Issue
Block a user