diff --git a/ui/src/ui/app-lifecycle.ts b/ui/src/ui/app-lifecycle.ts index 9a9826103a8..a40f5d5ee28 100644 --- a/ui/src/ui/app-lifecycle.ts +++ b/ui/src/ui/app-lifecycle.ts @@ -17,10 +17,14 @@ import { syncTabWithLocation, syncThemeWithSettings, } from "./app-settings.ts"; +import { loadControlUiBootstrapConfig } from "./controllers/control-ui-bootstrap.ts"; type LifecycleHost = { basePath: string; tab: Tab; + assistantName: string; + assistantAvatar: string | null; + assistantAgentId: string | null; chatHasAutoScrolled: boolean; chatManualRefreshInFlight: boolean; chatLoading: boolean; @@ -36,6 +40,7 @@ type LifecycleHost = { export function handleConnected(host: LifecycleHost) { host.basePath = inferBasePath(); + void loadControlUiBootstrapConfig(host); applySettingsFromUrl(host as unknown as Parameters[0]); syncTabWithLocation(host as unknown as Parameters[0], true); syncThemeWithSettings(host as unknown as Parameters[0]); diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index bb2c9f1540f..8709cbd32d0 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -76,7 +76,7 @@ import { type ToolStreamEntry, type CompactionStatus, } from "./app-tool-stream.ts"; -import { resolveInjectedAssistantIdentity } from "./assistant-identity.ts"; +import { normalizeAssistantIdentity } from "./assistant-identity.ts"; import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts"; import { loadSettings, type UiSettings } from "./storage.ts"; import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types.ts"; @@ -87,7 +87,7 @@ declare global { } } -const injectedAssistantIdentity = resolveInjectedAssistantIdentity(); +const bootAssistantIdentity = normalizeAssistantIdentity({}); function resolveOnboardingMode(): boolean { if (!window.location.search) { @@ -118,9 +118,9 @@ export class OpenClawApp extends LitElement { private toolStreamSyncTimer: number | null = null; private sidebarCloseTimer: number | null = null; - @state() assistantName = injectedAssistantIdentity.name; - @state() assistantAvatar = injectedAssistantIdentity.avatar; - @state() assistantAgentId = injectedAssistantIdentity.agentId ?? null; + @state() assistantName = bootAssistantIdentity.name; + @state() assistantAvatar = bootAssistantIdentity.avatar; + @state() assistantAgentId = bootAssistantIdentity.agentId ?? null; @state() sessionKey = this.settings.sessionKey; @state() chatLoading = false; diff --git a/ui/src/ui/assistant-identity.ts b/ui/src/ui/assistant-identity.ts index 4b9fef54e10..3f6e14fa925 100644 --- a/ui/src/ui/assistant-identity.ts +++ b/ui/src/ui/assistant-identity.ts @@ -10,13 +10,6 @@ export type AssistantIdentity = { avatar: string | null; }; -declare global { - interface Window { - __OPENCLAW_ASSISTANT_NAME__?: string; - __OPENCLAW_ASSISTANT_AVATAR__?: string; - } -} - function coerceIdentityValue(value: string | undefined, maxLength: number): string | undefined { if (typeof value !== "string") { return undefined; @@ -40,13 +33,3 @@ export function normalizeAssistantIdentity( typeof input?.agentId === "string" && input.agentId.trim() ? input.agentId.trim() : null; return { agentId, name, avatar }; } - -export function resolveInjectedAssistantIdentity(): AssistantIdentity { - if (typeof window === "undefined") { - return normalizeAssistantIdentity({}); - } - return normalizeAssistantIdentity({ - name: window.__OPENCLAW_ASSISTANT_NAME__, - avatar: window.__OPENCLAW_ASSISTANT_AVATAR__, - }); -} diff --git a/ui/src/ui/controllers/control-ui-bootstrap.test.ts b/ui/src/ui/controllers/control-ui-bootstrap.test.ts new file mode 100644 index 00000000000..3c9e73fa092 --- /dev/null +++ b/ui/src/ui/controllers/control-ui-bootstrap.test.ts @@ -0,0 +1,60 @@ +/* @vitest-environment jsdom */ + +import { describe, expect, it, vi } from "vitest"; +import { loadControlUiBootstrapConfig } from "./control-ui-bootstrap.ts"; + +describe("loadControlUiBootstrapConfig", () => { + it("loads assistant identity from the bootstrap endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + basePath: "/openclaw", + assistantName: "Ops", + assistantAvatar: "O", + assistantAgentId: "main", + }), + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const state = { + basePath: "/openclaw", + assistantName: "Assistant", + assistantAvatar: null, + assistantAgentId: null, + }; + + await loadControlUiBootstrapConfig(state); + + expect(fetchMock).toHaveBeenCalledWith( + "/openclaw/__openclaw/control-ui-config.json", + expect.objectContaining({ method: "GET" }), + ); + expect(state.assistantName).toBe("Ops"); + expect(state.assistantAvatar).toBe("O"); + expect(state.assistantAgentId).toBe("main"); + + vi.unstubAllGlobals(); + }); + + it("ignores failures", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: false }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + const state = { + basePath: "", + assistantName: "Assistant", + assistantAvatar: null, + assistantAgentId: null, + }; + + await loadControlUiBootstrapConfig(state); + + expect(fetchMock).toHaveBeenCalledWith( + "/__openclaw/control-ui-config.json", + expect.objectContaining({ method: "GET" }), + ); + expect(state.assistantName).toBe("Assistant"); + + vi.unstubAllGlobals(); + }); +}); diff --git a/ui/src/ui/controllers/control-ui-bootstrap.ts b/ui/src/ui/controllers/control-ui-bootstrap.ts new file mode 100644 index 00000000000..b161cf150d6 --- /dev/null +++ b/ui/src/ui/controllers/control-ui-bootstrap.ts @@ -0,0 +1,52 @@ +import { normalizeAssistantIdentity } from "../assistant-identity.ts"; +import { normalizeBasePath } from "../navigation.ts"; + +type ControlUiBootstrapConfig = { + basePath?: string; + assistantName?: string; + assistantAvatar?: string; + assistantAgentId?: string; +}; + +export type ControlUiBootstrapState = { + basePath: string; + assistantName: string; + assistantAvatar: string | null; + assistantAgentId: string | null; +}; + +export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapState) { + if (typeof window === "undefined") { + return; + } + if (typeof fetch !== "function") { + return; + } + + const basePath = normalizeBasePath(state.basePath ?? ""); + const url = basePath + ? `${basePath}/__openclaw/control-ui-config.json` + : "/__openclaw/control-ui-config.json"; + + try { + const res = await fetch(url, { + method: "GET", + headers: { Accept: "application/json" }, + credentials: "same-origin", + }); + if (!res.ok) { + return; + } + const parsed = (await res.json()) as ControlUiBootstrapConfig; + const normalized = normalizeAssistantIdentity({ + agentId: parsed.assistantAgentId ?? null, + name: parsed.assistantName, + avatar: parsed.assistantAvatar ?? null, + }); + state.assistantName = normalized.name; + state.assistantAvatar = normalized.avatar; + state.assistantAgentId = normalized.agentId ?? null; + } catch { + // Ignore bootstrap failures; UI will update identity after connecting. + } +}