"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { GatewayBrowserClient, clearGatewayBrowserSessionStorage, type GatewayHelloOk, } from "./openclaw/GatewayBrowserClient"; import type { StudioGatewayProfilePublic, StudioGatewayAdapterType, StudioGatewaySettings, StudioSettings, StudioSettingsPatch, StudioSettingsPublic, } from "@/lib/studio/settings"; import type { StudioSettingsLoadOptions, StudioSettingsResponse, } from "@/lib/studio/coordinator"; import { resolveStudioProxyGatewayUrl } from "@/lib/gateway/proxy-url"; import { ensureGatewayReloadModeHotForLocalStudio } from "@/lib/gateway/gatewayReloadMode"; import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway"; import { GatewayResponseError } from "@/lib/gateway/errors"; const gatewayDebugEnabled = process.env.NODE_ENV !== "production"; const gatewayDebugLog = (message: string, details?: Record) => { if (!gatewayDebugEnabled) return; if (details) { console.info("[gateway-client]", message, details); return; } console.info("[gateway-client]", message); }; import { probeCustomRuntime } from "@/lib/runtime/custom/http"; export type ReqFrame = { type: "req"; id: string; method: string; params: unknown; }; export type ResFrame = { type: "res"; id: string; ok: boolean; payload?: unknown; error?: { code: string; message: string; details?: unknown; retryable?: boolean; retryAfterMs?: number; }; }; export type GatewayStateVersion = { presence: number; health: number; }; export type EventFrame = { type: "event"; event: string; payload?: unknown; seq?: number; stateVersion?: GatewayStateVersion; }; export type GatewayFrame = ReqFrame | ResFrame | EventFrame; export const parseGatewayFrame = (raw: string): GatewayFrame | null => { try { return JSON.parse(raw) as GatewayFrame; } catch { return null; } }; export const buildAgentMainSessionKey = (agentId: string, mainKey: string) => { const trimmedAgent = agentId.trim(); const trimmedKey = mainKey.trim() || "main"; return `agent:${trimmedAgent}:${trimmedKey}`; }; export const parseAgentIdFromSessionKey = (sessionKey: string): string | null => { const match = sessionKey.match(/^agent:([^:]+):/); return match ? match[1] : null; }; export const isSameSessionKey = (a: string, b: string) => { const left = a.trim(); const right = b.trim(); return left.length > 0 && left === right; }; const CONNECT_FAILED_CLOSE_CODE = 4008; const GATEWAY_CONNECT_TIMEOUT_MS = 13_000; const parseConnectFailedCloseReason = ( reason: string ): { code: string; message: string } | null => { const trimmed = reason.trim(); if (!trimmed.toLowerCase().startsWith("connect failed:")) return null; const remainder = trimmed.slice("connect failed:".length).trim(); if (!remainder) return null; const idx = remainder.indexOf(" "); const code = (idx === -1 ? remainder : remainder.slice(0, idx)).trim(); if (!code) return null; const message = (idx === -1 ? "" : remainder.slice(idx + 1)).trim(); return { code, message: message || "connect failed" }; }; 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"; const OPENCLAW_WEBCHAT_UI_CLIENT_ID = "webchat-ui"; const isAutoManagedAdapter = (adapterType: StudioGatewayAdapterType) => adapterType !== "custom"; export const resolveGatewayClientName = ( adapterType: StudioGatewayAdapterType, gatewayUrl: string ) => { if (adapterType !== "openclaw") { return OPENCLAW_CONTROL_UI_CLIENT_ID; } return isLocalGatewayUrl(gatewayUrl) ? OPENCLAW_CONTROL_UI_CLIENT_ID : OPENCLAW_WEBCHAT_UI_CLIENT_ID; }; export const resolveInitialGatewayAutoConnectDelayMs = ( adapterType: StudioGatewayAdapterType ): number => { switch (adapterType) { case "hermes": case "demo": return INITIAL_AUTO_CONNECT_DELAY_MS; default: return 0; } }; export const resolveInitialGatewayConnectAttemptCount = ( adapterType: StudioGatewayAdapterType, hasConnectedOnce: boolean ): number => { switch (adapterType) { case "hermes": case "demo": return 2; default: if (hasConnectedOnce) return 1; return 1; } }; 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 { url?: unknown; token?: unknown; tokenConfigured?: unknown; adapterType?: unknown; profiles?: unknown; }; const url = typeof raw.url === "string" ? raw.url.trim() : ""; if (!url) return null; // Accept both full settings ({ url, token }) and the sanitized public // form ({ url, tokenConfigured }) returned by /api/studio. When only // tokenConfigured is present the actual token isn't available on the // client — leave it empty so the connection dialog can prompt if needed. const token = typeof raw.token === "string" ? raw.token.trim() : ""; const adapterType = raw.adapterType === "demo" || raw.adapterType === "hermes" || raw.adapterType === "openclaw" || raw.adapterType === "custom" ? raw.adapterType : "openclaw"; const profiles = normalizeGatewayProfilesPublic(raw.profiles); return { url, token, adapterType, ...(profiles ? { profiles } : {}) }; }; const normalizeGatewayProfilePublic = ( value: unknown ): { url: string; token: string } | null => { if (!value || typeof value !== "object") return null; const raw = value as { url?: unknown }; const url = typeof raw.url === "string" ? raw.url.trim() : ""; if (!url) return null; return { url, token: "" }; }; const normalizeGatewayProfilesPublic = ( value: unknown ): Partial> | undefined => { if (!value || typeof value !== "object") return undefined; const raw = value as Partial>; const profiles: Partial> = {}; for (const adapterType of ["openclaw", "hermes", "demo", "custom"] as const) { const profile = normalizeGatewayProfilePublic(raw[adapterType]); if (profile) { profiles[adapterType] = profile; } } return Object.keys(profiles).length > 0 ? profiles : undefined; }; type StatusHandler = (status: GatewayStatus) => void; type EventHandler = (event: EventFrame) => void; export type GatewayGapInfo = { expected: number; received: number }; type GapHandler = (info: GatewayGapInfo) => void; export type GatewayStatus = "disconnected" | "connecting" | "connected"; export type GatewayConnectOptions = { gatewayUrl: string; token?: string; authScopeKey?: string; clientName?: string; disableDeviceAuth?: boolean; }; export { GatewayResponseError } from "@/lib/gateway/errors"; export type { GatewayErrorPayload } from "@/lib/gateway/errors"; export class GatewayClient { private client: GatewayBrowserClient | null = null; private statusHandlers = new Set(); private eventHandlers = new Set(); private gapHandlers = new Set(); private status: GatewayStatus = "disconnected"; private pendingConnect: Promise | null = null; private resolveConnect: (() => void) | null = null; private rejectConnect: ((error: Error) => void) | null = null; private manualDisconnect = false; private lastHello: GatewayHelloOk | null = null; private _lastDisconnectCode: number | null = null; onStatus(handler: StatusHandler) { this.statusHandlers.add(handler); handler(this.status); return () => { this.statusHandlers.delete(handler); }; } onEvent(handler: EventHandler) { this.eventHandlers.add(handler); return () => { this.eventHandlers.delete(handler); }; } onGap(handler: GapHandler) { this.gapHandlers.add(handler); return () => { this.gapHandlers.delete(handler); }; } async connect(options: GatewayConnectOptions) { if (!options.gatewayUrl.trim()) { throw new Error("Gateway URL is required."); } if (this.client) { throw new Error("Gateway is already connected or connecting."); } this.manualDisconnect = false; this.updateStatus("connecting"); this.pendingConnect = new Promise((resolve, reject) => { this.resolveConnect = resolve; this.rejectConnect = reject; }); const nextClient = new GatewayBrowserClient({ url: options.gatewayUrl, token: options.token, authScopeKey: options.authScopeKey, clientName: options.clientName, disableDeviceAuth: options.disableDeviceAuth, onHello: (hello) => { if (this.client !== nextClient) return; this.lastHello = hello; this.updateStatus("connected"); this.resolveConnect?.(); this.clearConnectPromise(); }, onEvent: (event) => { if (this.client !== nextClient) return; this.eventHandlers.forEach((handler) => handler(event)); }, onClose: ({ code, reason }) => { if (this.client !== nextClient) return; this._lastDisconnectCode = code; const connectFailed = code === CONNECT_FAILED_CLOSE_CODE ? parseConnectFailedCloseReason(reason) : null; const err = connectFailed ? new GatewayResponseError({ code: connectFailed.code, message: connectFailed.message, }) : new Error(`Gateway closed (${code}): ${reason}`); if (this.rejectConnect) { this.rejectConnect(err); this.clearConnectPromise(); } if (!this.manualDisconnect) { nextClient.stop(); } if (this.client === nextClient) { this.client = null; } this.updateStatus("disconnected"); if (this.manualDisconnect) { console.info("Gateway disconnected."); } }, onGap: ({ expected, received }) => { if (this.client !== nextClient) return; this.gapHandlers.forEach((handler) => handler({ expected, received })); }, }); this.client = nextClient; nextClient.start(); let connectTimeoutId: number | null = null; try { await Promise.race([ this.pendingConnect, new Promise((_, reject) => { connectTimeoutId = window.setTimeout(() => { reject( new Error( "Timed out connecting to the gateway. Check that it is running, or change the gateway address and try again." ) ); }, GATEWAY_CONNECT_TIMEOUT_MS); }), ]); } catch (err) { const activeClient = this.client; this.clearConnectPromise(); activeClient?.stop(); if (this.client === activeClient) { this.client = null; } this.updateStatus("disconnected"); throw err; } finally { if (connectTimeoutId !== null) { window.clearTimeout(connectTimeoutId); } } } disconnect() { if (!this.client) { return; } this.manualDisconnect = true; this.client.stop(); this.client = null; this.clearConnectPromise(); this.updateStatus("disconnected"); console.info("Gateway disconnected."); } async call(method: string, params: unknown): Promise { if (!method.trim()) { throw new Error("Gateway method is required."); } if (!this.client || !this.client.connected) { throw new Error("Gateway is not connected."); } const payload = await this.client.request(method, params); return payload as T; } getLastHello() { return this.lastHello; } get lastDisconnectCode() { return this._lastDisconnectCode; } private updateStatus(status: GatewayStatus) { this.status = status; this.statusHandlers.forEach((handler) => handler(status)); } private clearConnectPromise() { this.pendingConnect = null; this.resolveConnect = null; this.rejectConnect = null; } } export const isGatewayDisconnectLikeError = (err: unknown): boolean => { if (!(err instanceof Error)) return false; const msg = err.message.toLowerCase(); if (!msg) return false; if ( msg.includes("gateway not connected") || msg.includes("gateway is not connected") || msg.includes("gateway client stopped") ) { return true; } const match = msg.match(/gateway closed \\((\\d+)\\)/); if (!match) return false; const code = Number(match[1]); return Number.isFinite(code) && code === 1012; }; const WEBCHAT_SESSION_MUTATION_BLOCKED_RE = /webchat clients cannot (patch|delete) sessions/i; const WEBCHAT_SESSION_MUTATION_HINT_RE = /use chat\.send for session-scoped updates/i; export const isWebchatSessionMutationBlockedError = (error: unknown): boolean => { if (!(error instanceof GatewayResponseError)) return false; if (error.code.trim().toUpperCase() !== "INVALID_REQUEST") return false; const message = error.message.trim(); if (!message) return false; return ( WEBCHAT_SESSION_MUTATION_BLOCKED_RE.test(message) && WEBCHAT_SESSION_MUTATION_HINT_RE.test(message) ); }; type SessionSettingsPatchPayload = { key: string; model?: string | null; thinkingLevel?: string | null; execHost?: "sandbox" | "gateway" | "node" | null; execSecurity?: "deny" | "allowlist" | "full" | null; execAsk?: "off" | "on-miss" | "always" | null; }; export type GatewaySessionsPatchResult = { ok: true; key: string; entry?: { thinkingLevel?: string; }; resolved?: { modelProvider?: string; model?: string; }; }; export type SyncGatewaySessionSettingsParams = { client: GatewayClient; sessionKey: string; model?: string | null; thinkingLevel?: string | null; execHost?: "sandbox" | "gateway" | "node" | null; execSecurity?: "deny" | "allowlist" | "full" | null; execAsk?: "off" | "on-miss" | "always" | null; }; export const syncGatewaySessionSettings = async ({ client, sessionKey, model, thinkingLevel, execHost, execSecurity, execAsk, }: SyncGatewaySessionSettingsParams) => { const key = sessionKey.trim(); if (!key) { throw new Error("Session key is required."); } const includeModel = model !== undefined; const includeThinkingLevel = thinkingLevel !== undefined; const includeExecHost = execHost !== undefined; const includeExecSecurity = execSecurity !== undefined; const includeExecAsk = execAsk !== undefined; if ( !includeModel && !includeThinkingLevel && !includeExecHost && !includeExecSecurity && !includeExecAsk ) { throw new Error("At least one session setting must be provided."); } const payload: SessionSettingsPatchPayload = { key }; if (includeModel) { payload.model = model ?? null; } if (includeThinkingLevel) { payload.thinkingLevel = thinkingLevel ?? null; } if (includeExecHost) { payload.execHost = execHost ?? null; } if (includeExecSecurity) { payload.execSecurity = execSecurity ?? null; } if (includeExecAsk) { payload.execAsk = execAsk ?? null; } return await client.call("sessions.patch", payload); }; const doctorFixHint = "Run `npx openclaw doctor --fix` on the gateway host (or `pnpm openclaw doctor --fix` in a source checkout)."; const protocolMismatchHint = "This gateway looks too old for Claw3D's protocol v3. Upgrade OpenClaw, use the Hermes adapter, or run `npm run demo-gateway` for a no-framework office demo."; const tailscaleGatewayHint = "If this is a remote OpenClaw/Tailscale gateway, confirm the Studio host can reach the `wss://...` address and approve the first device pairing on the gateway host with `openclaw devices approve --latest`."; const pairingRequiredHint = "This gateway is asking for first-time device approval. Run `openclaw devices approve --latest` on the gateway host, then restart Claw3D and reconnect from this browser."; const requiresDeviceIdentityHint = "This gateway rejected the client as a control UI without device identity. For remote OpenClaw/Tailscale connections, update to the latest Claw3D build and approve the device pairing on the gateway host."; const isGatewayProtocolMismatchError = (error: GatewayResponseError) => { if (error.code.trim().toUpperCase() !== "INVALID_REQUEST") return false; const message = error.message.trim(); if (!message) return false; return /minProtocol|maxProtocol/i.test(message); }; const formatGatewayError = (error: unknown) => { if (error instanceof GatewayResponseError) { if (isGatewayProtocolMismatchError(error)) { return `Gateway error (${error.code}): ${error.message}. ${protocolMismatchHint}`; } if (error.code === "INVALID_REQUEST" && /invalid config/i.test(error.message)) { return `Gateway error (${error.code}): ${error.message}. ${doctorFixHint}`; } if (error.code === "studio.upstream_timeout") { return `Gateway error (${error.code}): ${error.message} ${tailscaleGatewayHint}`; } if (error.code === "studio.upstream_rejected") { const lower = error.message.toLowerCase(); if (lower.includes("pairing required")) { return `Gateway error (${error.code}): ${error.message}. ${pairingRequiredHint}`; } if (lower.includes("device identity")) { return `Gateway error (${error.code}): ${error.message}. ${requiresDeviceIdentityHint}`; } } return `Gateway error (${error.code}): ${error.message}`; } if (error instanceof Error) { if (/timed out connecting to the gateway/i.test(error.message)) { return `${error.message} If you are testing locally, an older OpenClaw build may be speaking an incompatible protocol. Try upgrading OpenClaw, using the Hermes adapter, or running \`npm run demo-gateway\`.`; } return error.message; } return "Unknown gateway error."; }; export type GatewayConnectionState = { client: GatewayClient; status: GatewayStatus; gatewayUrl: string; token: string; selectedAdapterType: StudioGatewayAdapterType; detectedAdapterType: StudioGatewayAdapterType | null; activeAdapterType: StudioGatewayAdapterType; localGatewayDefaults: StudioGatewaySettings | null; error: string | null; connectPromptReady: boolean; shouldPromptForConnect: boolean; connect: () => Promise; disconnect: () => void; useLocalGatewayDefaults: () => void; setGatewayUrl: (value: string) => void; setToken: (value: string) => void; setSelectedAdapterType: (value: StudioGatewayAdapterType) => void; clearError: () => void; }; type StudioSettingsCoordinatorLike = { loadSettings: ( options?: StudioSettingsLoadOptions, ) => Promise; loadSettingsEnvelope?: ( options?: StudioSettingsLoadOptions, ) => Promise; schedulePatch: (patch: StudioSettingsPatch, debounceMs?: number) => void; flushPending: () => Promise; }; const isAuthError = (errorMessage: string | null): boolean => { if (!errorMessage) return false; const lower = errorMessage.toLowerCase(); return ( lower.includes("auth") || lower.includes("unauthorized") || lower.includes("forbidden") || lower.includes("invalid token") || lower.includes("token required") || (lower.includes("token") && lower.includes("not configured")) || lower.includes("gateway_token_missing") ); }; const MAX_AUTO_RETRY_ATTEMPTS = 20; const INITIAL_RETRY_DELAY_MS = 2_000; const MAX_RETRY_DELAY_MS = 30_000; const NON_RETRYABLE_CONNECT_ERROR_CODES = new Set([ "studio.gateway_url_missing", "studio.gateway_token_missing", "studio.gateway_url_invalid", "studio.settings_load_failed", "studio.upstream_error", "studio.upstream_timeout", "studio.upstream_rejected", ]); const isNonRetryableConnectErrorCode = (code: string | null): boolean => { const normalized = code?.trim().toLowerCase() ?? ""; if (!normalized) return false; return NON_RETRYABLE_CONNECT_ERROR_CODES.has(normalized); }; /** WebSocket close code 1008 = policy violation (rate limit). */ const WS_CLOSE_POLICY_VIOLATION = 1008; const RATE_LIMIT_RETRY_DELAY_MS = 15_000; export const resolveGatewayAutoRetryDelayMs = (params: { status: GatewayStatus; didAutoConnect: boolean; hasConnectedOnce: boolean; wasManualDisconnect: boolean; gatewayUrl: string; errorMessage: string | null; connectErrorCode: string | null; lastDisconnectCode: number | null; attempt: number; }): number | null => { if (params.status !== "disconnected") return null; if (!params.didAutoConnect) return null; if (!params.hasConnectedOnce) return null; if (params.wasManualDisconnect) return null; if (!params.gatewayUrl.trim()) return null; if (params.attempt >= MAX_AUTO_RETRY_ATTEMPTS) return null; if (isNonRetryableConnectErrorCode(params.connectErrorCode)) return null; if (params.connectErrorCode === null && isAuthError(params.errorMessage)) return null; const baseDelay = params.lastDisconnectCode === WS_CLOSE_POLICY_VIOLATION ? Math.max(INITIAL_RETRY_DELAY_MS, RATE_LIMIT_RETRY_DELAY_MS) : INITIAL_RETRY_DELAY_MS; return Math.min( baseDelay * Math.pow(1.5, params.attempt), MAX_RETRY_DELAY_MS ); }; export const useGatewayConnection = ( settingsCoordinator: StudioSettingsCoordinatorLike ): GatewayConnectionState => { const [client] = useState(() => new GatewayClient()); const didAutoConnect = useRef(false); const hasConnectedOnceRef = useRef(false); const loadedGatewaySettings = useRef<{ gatewayUrl: string; token: string; adapterType: StudioGatewayAdapterType; profiles?: Partial>; hasLastKnownGood: boolean; } | null>(null); const retryAttemptRef = useRef(0); const retryTimerRef = useRef | null>(null); const autoConnectTimerRef = useRef(null); const wasManualDisconnectRef = useRef(false); const [gatewayUrl, setGatewayUrl] = useState(DEFAULT_UPSTREAM_GATEWAY_URL); const [token, setToken] = useState(""); const [selectedAdapterType, setSelectedAdapterTypeState] = useState("openclaw"); const [adapterProfiles, setAdapterProfiles] = useState< Partial> >({}); const [detectedAdapterType, setDetectedAdapterType] = useState(null); const [localGatewayDefaults, setLocalGatewayDefaults] = useState( null ); const [status, setStatus] = useState("disconnected"); const [error, setError] = useState(null); const [connectErrorCode, setConnectErrorCode] = useState(null); const [settingsLoaded, setSettingsLoaded] = useState(false); const [hasLastKnownGoodState, setHasLastKnownGoodState] = useState(false); const setSelectedAdapterType = useCallback( (value: StudioGatewayAdapterType) => { setSelectedAdapterTypeState(value); const profile = adapterProfiles[value] ?? resolveDefaultGatewayProfile(value, localGatewayDefaults); setGatewayUrl(profile.url); setToken(profile.token); setError(null); setConnectErrorCode(null); }, [adapterProfiles, localGatewayDefaults] ); useEffect(() => { let cancelled = false; const loadSettings = async () => { try { const envelope = typeof settingsCoordinator.loadSettingsEnvelope === "function" ? await settingsCoordinator.loadSettingsEnvelope({ force: true }) : { settings: await settingsCoordinator.loadSettings({ force: true }), localGatewayDefaults: null, }; const settings = envelope.settings ?? null; const gateway = settings?.gateway ?? null; if (cancelled) return; const normalizedDefaults = normalizeLocalGatewayDefaults(envelope.localGatewayDefaults); setLocalGatewayDefaults(normalizedDefaults); const lastKnownGood = gateway && "lastKnownGood" in gateway && gateway.lastKnownGood ? { url: typeof gateway.lastKnownGood.url === "string" ? gateway.lastKnownGood.url : "", token: "token" in gateway.lastKnownGood && typeof gateway.lastKnownGood.token === "string" ? gateway.lastKnownGood.token : "", adapterType: gateway.lastKnownGood.adapterType === "demo" || gateway.lastKnownGood.adapterType === "hermes" || gateway.lastKnownGood.adapterType === "openclaw" || gateway.lastKnownGood.adapterType === "custom" ? gateway.lastKnownGood.adapterType : "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" || 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) ); const nextGatewayUrl = selectedProfile.url; const nextToken = selectedProfile.token; loadedGatewaySettings.current = { gatewayUrl: nextGatewayUrl.trim(), token: nextToken, adapterType: nextAdapterType, profiles: mergedProfiles, hasLastKnownGood: Boolean(lastKnownGoodForSelectedAdapter?.url), }; setGatewayUrl(nextGatewayUrl); setToken(nextToken); setSelectedAdapterTypeState(nextAdapterType); setAdapterProfiles(mergedProfiles); setHasLastKnownGoodState(Boolean(lastKnownGoodForSelectedAdapter?.url)); } catch (err) { if (!cancelled) { const message = err instanceof Error ? err.message : "Failed to load gateway settings."; setError(message); } } finally { if (!cancelled) { if (!loadedGatewaySettings.current) { loadedGatewaySettings.current = { gatewayUrl: DEFAULT_UPSTREAM_GATEWAY_URL.trim(), token: "", adapterType: "openclaw", profiles: undefined, hasLastKnownGood: false, }; } setSettingsLoaded(true); } } }; void loadSettings(); return () => { cancelled = true; }; }, [settingsCoordinator]); useEffect(() => { return client.onStatus((nextStatus) => { gatewayDebugLog("status", { nextStatus }); setStatus(nextStatus); if (nextStatus !== "connecting") { setError(null); if (nextStatus === "connected") { setConnectErrorCode(null); } else { setDetectedAdapterType(null); } } }); }, [client]); useEffect(() => { return () => { if (autoConnectTimerRef.current) { clearTimeout(autoConnectTimerRef.current); autoConnectTimerRef.current = null; } if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; } client.disconnect(); }; }, [client]); const connect = useCallback(async () => { if (autoConnectTimerRef.current) { clearTimeout(autoConnectTimerRef.current); autoConnectTimerRef.current = null; } if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; } gatewayDebugLog("connect:start", { selectedAdapterType, gatewayUrl, hasToken: Boolean(token), }); setError(null); setConnectErrorCode(null); retryAttemptRef.current = 0; wasManualDisconnectRef.current = false; if (selectedAdapterType === "custom") { setStatus("connecting"); try { await settingsCoordinator.flushPending(); await probeCustomRuntime(gatewayUrl); setDetectedAdapterType("custom"); setStatus("connected"); setConnectErrorCode(null); gatewayDebugLog("connect:custom-success", { gatewayUrl }); } catch (err) { setStatus("disconnected"); setDetectedAdapterType(null); setConnectErrorCode("studio.custom_runtime_probe_failed"); setError(formatGatewayError(err)); gatewayDebugLog("connect:custom-failed", { message: err instanceof Error ? err.message : String(err), }); } return; } try { await settingsCoordinator.flushPending(); const maxAttempts = resolveInitialGatewayConnectAttemptCount( selectedAdapterType, hasConnectedOnceRef.current ); let lastError: unknown = null; for (let attempt = 0; attempt < maxAttempts; attempt += 1) { try { await client.connect({ gatewayUrl: resolveStudioProxyGatewayUrl(), token, authScopeKey: gatewayUrl, clientName: resolveGatewayClientName(selectedAdapterType, gatewayUrl), disableDeviceAuth: selectedAdapterType !== "openclaw", }); lastError = null; break; } catch (err) { lastError = err; gatewayDebugLog("connect:attempt-failed", { selectedAdapterType, attempt: attempt + 1, maxAttempts, message: err instanceof Error ? err.message : String(err), }); if (attempt + 1 >= maxAttempts) { throw err; } client.disconnect(); await new Promise((resolve) => { window.setTimeout(resolve, INITIAL_CONNECT_RETRY_DELAY_MS); }); } } if (lastError) { throw lastError; } await ensureGatewayReloadModeHotForLocalStudio({ client, upstreamGatewayUrl: gatewayUrl, }); const hello = client.getLastHello(); const nextDetectedAdapterType = hello?.adapterType === "demo" || hello?.adapterType === "hermes" || hello?.adapterType === "openclaw" || hello?.adapterType === "custom" ? hello.adapterType : "openclaw"; setDetectedAdapterType(nextDetectedAdapterType); setHasLastKnownGoodState(nextDetectedAdapterType === selectedAdapterType); settingsCoordinator.schedulePatch({ gateway: { lastKnownGood: { url: gatewayUrl.trim(), token, adapterType: nextDetectedAdapterType, }, }, }); gatewayDebugLog("connect:success", { selectedAdapterType, detectedAdapterType: nextDetectedAdapterType, }); } catch (err) { setConnectErrorCode(err instanceof GatewayResponseError ? err.code : null); setError(formatGatewayError(err)); gatewayDebugLog("connect:failed", { selectedAdapterType, code: err instanceof GatewayResponseError ? err.code : null, message: err instanceof Error ? err.message : String(err), }); } }, [client, gatewayUrl, selectedAdapterType, settingsCoordinator, token]); useEffect(() => { if (didAutoConnect.current) return; if (!settingsLoaded) return; if (!hasLastKnownGoodState) return; if (!gatewayUrl.trim()) return; if (!isAutoManagedAdapter(selectedAdapterType)) return; didAutoConnect.current = true; const delayMs = resolveInitialGatewayAutoConnectDelayMs(selectedAdapterType); gatewayDebugLog("auto-connect", { selectedAdapterType, gatewayUrl, delayMs, }); autoConnectTimerRef.current = window.setTimeout(() => { autoConnectTimerRef.current = null; void connect(); }, delayMs); return () => { if (autoConnectTimerRef.current) { window.clearTimeout(autoConnectTimerRef.current); autoConnectTimerRef.current = null; } }; }, [connect, gatewayUrl, hasLastKnownGoodState, selectedAdapterType, settingsLoaded]); // Auto-retry on disconnect (gateway busy, network blip, etc.) useEffect(() => { const attempt = retryAttemptRef.current; const delay = resolveGatewayAutoRetryDelayMs({ status, didAutoConnect: didAutoConnect.current, hasConnectedOnce: hasConnectedOnceRef.current, wasManualDisconnect: wasManualDisconnectRef.current, gatewayUrl, errorMessage: error, connectErrorCode, lastDisconnectCode: client.lastDisconnectCode, attempt, }); if (!isAutoManagedAdapter(selectedAdapterType)) return; if (delay === null) return; gatewayDebugLog("auto-retry-scheduled", { selectedAdapterType, attempt: attempt + 1, delay, gatewayUrl, status, }); retryTimerRef.current = setTimeout(() => { // Call connect first (it synchronously resets retryAttemptRef to 0), // then override with the correct attempt count so the next auto-retry // uses proper exponential backoff. void connect(); retryAttemptRef.current = attempt + 1; gatewayDebugLog("auto-retry-fire", { selectedAdapterType, attempt: retryAttemptRef.current, }); }, delay); return () => { if (retryTimerRef.current) { clearTimeout(retryTimerRef.current); retryTimerRef.current = null; } }; }, [connect, connectErrorCode, error, gatewayUrl, selectedAdapterType, status]); // Reset retry count after the connection has been stable for a minimum // duration. If the upstream drops the connection quickly (e.g. within a // few seconds), keeping the current attempt count lets exponential backoff // work properly instead of hammering the gateway every 2 seconds. useEffect(() => { if (status === "connected") { hasConnectedOnceRef.current = true; const stableTimer = setTimeout(() => { retryAttemptRef.current = 0; }, 10_000); return () => clearTimeout(stableTimer); } }, [status]); useEffect(() => { if (!settingsLoaded) return; setAdapterProfiles((current) => { const nextProfile = { url: gatewayUrl.trim(), token, }; const existing = current[selectedAdapterType]; if ( existing && existing.url === nextProfile.url && existing.token === nextProfile.token ) { return current; } return { ...current, [selectedAdapterType]: nextProfile, }; }); }, [gatewayUrl, selectedAdapterType, settingsLoaded, token]); useEffect(() => { if (!settingsLoaded) return; const baseline = loadedGatewaySettings.current; if (!baseline) return; const nextGatewayUrl = gatewayUrl.trim(); const nextProfiles = { ...adapterProfiles, [selectedAdapterType]: { url: nextGatewayUrl, token, }, }; if ( nextGatewayUrl === baseline.gatewayUrl && token === baseline.token && selectedAdapterType === baseline.adapterType && JSON.stringify(nextProfiles) === JSON.stringify(baseline.profiles ?? {}) ) { return; } settingsCoordinator.schedulePatch( { gateway: { url: nextGatewayUrl, token, adapterType: selectedAdapterType, profiles: nextProfiles, }, }, 400 ); }, [adapterProfiles, gatewayUrl, selectedAdapterType, settingsCoordinator, settingsLoaded, token]); const useLocalGatewayDefaults = useCallback(() => { if (!localGatewayDefaults) { return; } setGatewayUrl(localGatewayDefaults.url); setToken(localGatewayDefaults.token); setAdapterProfiles((current) => ({ ...current, [localGatewayDefaults.adapterType]: { url: localGatewayDefaults.url, token: localGatewayDefaults.token, }, })); setSelectedAdapterTypeState(localGatewayDefaults.adapterType); setError(null); setConnectErrorCode(null); }, [localGatewayDefaults]); const disconnect = useCallback(() => { gatewayDebugLog("disconnect", { selectedAdapterType }); setError(null); setConnectErrorCode(null); wasManualDisconnectRef.current = true; setDetectedAdapterType(null); if (selectedAdapterType === "custom") { setStatus("disconnected"); return; } client.disconnect(); clearGatewayBrowserSessionStorage(); }, [client, selectedAdapterType]); const clearError = useCallback(() => { setError(null); setConnectErrorCode(null); }, []); const connectPromptReady = settingsLoaded; const activeAdapterType = status === "connected" ? detectedAdapterType ?? selectedAdapterType : selectedAdapterType; const shouldPromptForConnect = settingsLoaded && status !== "connected" && (selectedAdapterType === "custom" || !hasLastKnownGoodState || !gatewayUrl.trim() || (selectedAdapterType === "openclaw" && !token.trim()) || wasManualDisconnectRef.current || Boolean(error)); return { client, status, gatewayUrl, token, selectedAdapterType, detectedAdapterType, activeAdapterType, localGatewayDefaults, error, connectPromptReady, shouldPromptForConnect, connect, disconnect, useLocalGatewayDefaults, setGatewayUrl, setToken, setSelectedAdapterType, clearError, }; };