diff --git a/src/lib/gateway/GatewayClient.ts b/src/lib/gateway/GatewayClient.ts index 06c186a..34d6761 100644 --- a/src/lib/gateway/GatewayClient.ts +++ b/src/lib/gateway/GatewayClient.ts @@ -14,6 +14,10 @@ import type { StudioSettingsPatch, StudioSettingsPublic, } from "@/lib/studio/settings"; +import { + resolveDefaultStudioGatewayProfile, + resolveStudioGatewayProfiles, +} from "@/lib/studio/settings"; import type { StudioSettingsLoadOptions, StudioSettingsResponse, @@ -115,7 +119,6 @@ const parseConnectFailedCloseReason = ( const DEFAULT_UPSTREAM_GATEWAY_URL = process.env.NEXT_PUBLIC_GATEWAY_URL || "ws://localhost:18789"; -const DEFAULT_CUSTOM_RUNTIME_URL = "http://localhost:7770"; const INITIAL_AUTO_CONNECT_DELAY_MS = 900; const INITIAL_CONNECT_RETRY_DELAY_MS = 1_200; const OPENCLAW_CONTROL_UI_CLIENT_ID = "openclaw-control-ui"; @@ -162,25 +165,6 @@ export const resolveInitialGatewayConnectAttemptCount = ( } }; -const resolveDefaultGatewayProfile = ( - adapterType: StudioGatewayAdapterType, - localDefaults: StudioGatewaySettings | null -): { url: string; token: string } => { - switch (adapterType) { - case "custom": - return { url: DEFAULT_CUSTOM_RUNTIME_URL, token: "" }; - case "demo": - case "hermes": - return { url: "ws://localhost:18789", token: "" }; - case "openclaw": - default: - return { - url: localDefaults?.url || DEFAULT_UPSTREAM_GATEWAY_URL, - token: localDefaults?.token ?? "", - }; - } -}; - const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings | null => { if (!value || typeof value !== "object") return null; const raw = value as { @@ -734,7 +718,7 @@ export const useGatewayConnection = ( (value: StudioGatewayAdapterType) => { setSelectedAdapterTypeState(value); const profile = - adapterProfiles[value] ?? resolveDefaultGatewayProfile(value, localGatewayDefaults); + adapterProfiles[value] ?? resolveDefaultStudioGatewayProfile(value, localGatewayDefaults); setGatewayUrl(profile.url); setToken(profile.token); setError(null); @@ -780,72 +764,45 @@ export const useGatewayConnection = ( : "openclaw", } : null; - // When the user has no saved gateway URL, prefer the runtime - // localGatewayDefaults (from openclaw.json, CLAW3D_GATEWAY_URL, - // or detected local Hermes/demo adapter ports) - // over the build-time NEXT_PUBLIC_GATEWAY_URL which may be stale - // or empty if the operator forgot to rebuild after .env changes. - const hasSavedUrl = Boolean(gateway?.url?.trim()); - const savedAdapterType = - hasSavedUrl && gateway && "adapterType" in gateway && typeof gateway.adapterType === "string" - ? ((gateway.adapterType === "demo" || + const gatewaySettings: StudioGatewaySettings | null = gateway + ? { + url: typeof gateway.url === "string" ? gateway.url.trim() : "", + token: "", + adapterType: + gateway.adapterType === "demo" || gateway.adapterType === "hermes" || gateway.adapterType === "openclaw" || gateway.adapterType === "custom" - ? gateway.adapterType - : "openclaw") as StudioGatewayAdapterType) - : null; - const nextAdapterType = - savedAdapterType ?? - lastKnownGood?.adapterType ?? - normalizedDefaults?.adapterType ?? - "openclaw"; - const lastKnownGoodForSelectedAdapter = - lastKnownGood?.adapterType === nextAdapterType ? lastKnownGood : null; - const resolvedUrl = hasSavedUrl - ? gateway!.url - : lastKnownGoodForSelectedAdapter?.url || - normalizedDefaults?.url || - DEFAULT_UPSTREAM_GATEWAY_URL; - const baseProfiles = { - ...(gateway?.profiles - ? normalizeGatewayProfilesPublic(gateway.profiles) - : undefined), - ...(normalizedDefaults?.profiles ?? {}), - }; - const mergedProfiles = { - ...baseProfiles, - ...(hasSavedUrl - ? { - [nextAdapterType]: { - url: resolvedUrl, - token: - gateway && "token" in gateway && typeof gateway.token === "string" - ? gateway.token - : "", - }, - } - : {}), - }; - const selectedProfile = ( - mergedProfiles[nextAdapterType] ?? - lastKnownGoodForSelectedAdapter ?? - resolveDefaultGatewayProfile(nextAdapterType, normalizedDefaults) - ); + ? gateway.adapterType + : "openclaw", + ...(gateway?.profiles + ? { profiles: normalizeGatewayProfilesPublic(gateway.profiles) } + : {}), + ...(lastKnownGood ? { lastKnownGood } : {}), + } + : null; + const resolvedGatewayProfiles = resolveStudioGatewayProfiles({ + gateway: gatewaySettings, + localDefaults: normalizedDefaults, + }); + const nextAdapterType = resolvedGatewayProfiles.selectedAdapterType; + const selectedProfile = + resolvedGatewayProfiles.activeProfile ?? + resolveDefaultStudioGatewayProfile(nextAdapterType, normalizedDefaults); const nextGatewayUrl = selectedProfile.url; const nextToken = selectedProfile.token; loadedGatewaySettings.current = { gatewayUrl: nextGatewayUrl.trim(), token: nextToken, adapterType: nextAdapterType, - profiles: mergedProfiles, - hasLastKnownGood: Boolean(lastKnownGoodForSelectedAdapter?.url), + profiles: resolvedGatewayProfiles.profiles, + hasLastKnownGood: Boolean(resolvedGatewayProfiles.lastKnownGoodForSelected?.url), }; setGatewayUrl(nextGatewayUrl); setToken(nextToken); setSelectedAdapterTypeState(nextAdapterType); - setAdapterProfiles(mergedProfiles); - setHasLastKnownGoodState(Boolean(lastKnownGoodForSelectedAdapter?.url)); + setAdapterProfiles(resolvedGatewayProfiles.profiles); + setHasLastKnownGoodState(Boolean(resolvedGatewayProfiles.lastKnownGoodForSelected?.url)); } catch (err) { if (!cancelled) { const message = err instanceof Error ? err.message : "Failed to load gateway settings."; diff --git a/src/lib/studio/settings.ts b/src/lib/studio/settings.ts index 453a832..f4ba30e 100644 --- a/src/lib/studio/settings.ts +++ b/src/lib/studio/settings.ts @@ -24,6 +24,7 @@ export type StudioGatewaySettings = { }; export type StudioGatewayAdapterType = "openclaw" | "hermes" | "demo" | "custom"; +export const STUDIO_GATEWAY_ADAPTER_TYPES = ["openclaw", "hermes", "demo", "custom"] as const; export type StudioGatewayProfile = { url: string; @@ -74,6 +75,13 @@ export type StudioGatewayConnectionStatePatch = { adapterType?: StudioGatewayAdapterType | null; }; +export type ResolvedStudioGatewayProfiles = { + selectedAdapterType: StudioGatewayAdapterType; + activeProfile: StudioGatewayProfile; + profiles: Partial>; + lastKnownGoodForSelected: StudioGatewayConnectionState | null; +}; + export type FocusFilter = "all" | "running" | "approvals"; export type StudioViewMode = "focused"; @@ -222,6 +230,9 @@ export type StudioSettingsPatch = { }; const SETTINGS_VERSION = 1 as const; +const DEFAULT_OPENCLAW_GATEWAY_URL = "ws://localhost:18789"; +const DEFAULT_LOCAL_ADAPTER_GATEWAY_URL = "ws://localhost:18789"; +const DEFAULT_CUSTOM_RUNTIME_URL = "http://localhost:7770"; const isRecord = (value: unknown): value is Record => Boolean(value && typeof value === "object"); @@ -830,6 +841,87 @@ const normalizeGatewayAdapterType = ( return fallback; }; +export const resolveDefaultStudioGatewayProfile = ( + adapterType: StudioGatewayAdapterType, + localDefaults: StudioGatewaySettings | null = null +): StudioGatewayProfile => { + const explicitProfile = localDefaults?.profiles?.[adapterType]; + if (explicitProfile?.url) { + return { + url: explicitProfile.url, + token: explicitProfile.token, + }; + } + + if (localDefaults?.adapterType === adapterType && localDefaults.url.trim()) { + return { + url: localDefaults.url, + token: localDefaults.token, + }; + } + + switch (adapterType) { + case "custom": + return { url: DEFAULT_CUSTOM_RUNTIME_URL, token: "" }; + case "hermes": + case "demo": + return { url: DEFAULT_LOCAL_ADAPTER_GATEWAY_URL, token: "" }; + case "openclaw": + default: + return { url: DEFAULT_OPENCLAW_GATEWAY_URL, token: "" }; + } +}; + +export const resolveStudioGatewayProfiles = ({ + gateway, + localDefaults = null, +}: { + gateway: StudioGatewaySettings | null; + localDefaults?: StudioGatewaySettings | null; +}): ResolvedStudioGatewayProfiles => { + const selectedAdapterType = + gateway?.adapterType ?? + gateway?.lastKnownGood?.adapterType ?? + localDefaults?.adapterType ?? + "openclaw"; + + const profiles: Partial> = { + ...(localDefaults?.profiles ?? {}), + ...(gateway?.profiles ?? {}), + }; + + if (gateway?.url?.trim()) { + profiles[selectedAdapterType] = { + url: gateway.url, + token: gateway.token, + }; + } + + const lastKnownGoodForSelected = + gateway?.lastKnownGood?.adapterType === selectedAdapterType ? gateway.lastKnownGood : null; + + if (!profiles[selectedAdapterType] && lastKnownGoodForSelected?.url) { + profiles[selectedAdapterType] = { + url: lastKnownGoodForSelected.url, + token: lastKnownGoodForSelected.token, + }; + } + + for (const adapterType of STUDIO_GATEWAY_ADAPTER_TYPES) { + if (profiles[adapterType]?.url) continue; + profiles[adapterType] = resolveDefaultStudioGatewayProfile(adapterType, localDefaults); + } + + return { + selectedAdapterType, + activeProfile: + profiles[selectedAdapterType] ?? + resolveDefaultStudioGatewayProfile(selectedAdapterType, localDefaults), + profiles, + lastKnownGoodForSelected, + }; +}; + const normalizeFocused = (value: unknown): Record => { if (!isRecord(value)) return {}; const focused: Record = {}; diff --git a/tests/unit/studioSettings.test.ts b/tests/unit/studioSettings.test.ts index 8d29d92..6737929 100644 --- a/tests/unit/studioSettings.test.ts +++ b/tests/unit/studioSettings.test.ts @@ -4,6 +4,8 @@ import { createDefaultAgentAvatarProfile } from "@/lib/avatars/profile"; import { mergeStudioSettings, normalizeStudioSettings, + resolveDefaultStudioGatewayProfile, + resolveStudioGatewayProfiles, } from "@/lib/studio/settings"; describe("studio settings normalization", () => { @@ -300,4 +302,45 @@ describe("studio settings normalization", () => { }), ); }); + + it("resolves simultaneous runtime profiles without collapsing them to one active url", () => { + const resolved = resolveStudioGatewayProfiles({ + gateway: normalizeStudioSettings({ + gateway: { + url: "ws://localhost:28789", + token: "", + adapterType: "hermes", + profiles: { + openclaw: { url: "ws://localhost:18789", token: "open-token" }, + demo: { url: "ws://localhost:38789", token: "" }, + }, + }, + }).gateway, + localDefaults: null, + }); + + expect(resolved.selectedAdapterType).toBe("hermes"); + expect(resolved.activeProfile).toEqual({ + url: "ws://localhost:28789", + token: "", + }); + expect(resolved.profiles).toEqual( + expect.objectContaining({ + openclaw: { url: "ws://localhost:18789", token: "open-token" }, + hermes: { url: "ws://localhost:28789", token: "" }, + demo: { url: "ws://localhost:38789", token: "" }, + }), + ); + }); + + it("resolves adapter-specific defaults for dormant profiles", () => { + expect(resolveDefaultStudioGatewayProfile("custom", null)).toEqual({ + url: "http://localhost:7770", + token: "", + }); + expect(resolveDefaultStudioGatewayProfile("demo", null)).toEqual({ + url: "ws://localhost:18789", + token: "", + }); + }); });