fix(ui): load Control UI bootstrap config via JSON endpoint

This commit is contained in:
Peter Steinberger
2026-02-16 03:04:58 +01:00
parent adc818db4a
commit 3b4096e02e
5 changed files with 122 additions and 22 deletions

View File

@@ -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<typeof applySettingsFromUrl>[0]);
syncTabWithLocation(host as unknown as Parameters<typeof syncTabWithLocation>[0], true);
syncThemeWithSettings(host as unknown as Parameters<typeof syncThemeWithSettings>[0]);

View File

@@ -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;

View File

@@ -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__,
});
}

View File

@@ -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();
});
});

View File

@@ -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.
}
}