mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:10:44 +00:00
fix(ui): keep assistant avatar overrides authoritative
Summary: - Make browser-local assistant avatar overrides win over stale missing IDENTITY.md avatar metadata. - Show the selected assistant image in Personal settings and chat instead of a false File not found state. - Add focused Control UI coverage for assistant avatar override and clear behavior. Validation: - pnpm test ui/src/ui/app-render.assistant-avatar.test.ts ui/src/ui/views/config-quick.test.ts ui/src/ui/controllers/assistant-identity.test.ts -- --reporter=verbose - pnpm tsgo:core:test - pnpm deadcode:dependencies - pnpm deadcode:unused-files - CI green on PR #74260
This commit is contained in:
230
ui/src/ui/app-render.assistant-avatar.test.ts
Normal file
230
ui/src/ui/app-render.assistant-avatar.test.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { html } from "lit";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AppViewState } from "./app-view-state.ts";
|
||||
import type { QuickSettingsProps } from "./views/config-quick.ts";
|
||||
|
||||
const quickSettingsProps = vi.hoisted(() => ({
|
||||
current: null as QuickSettingsProps | null,
|
||||
}));
|
||||
const localStorageValues = vi.hoisted(() => new Map<string, string>());
|
||||
|
||||
vi.mock("../local-storage.ts", () => ({
|
||||
getSafeLocalStorage: () => ({
|
||||
getItem: (key: string) => localStorageValues.get(key) ?? null,
|
||||
removeItem: (key: string) => localStorageValues.delete(key),
|
||||
setItem: (key: string, value: string) => localStorageValues.set(key, value),
|
||||
}),
|
||||
getSafeSessionStorage: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./views/config-quick.ts", () => ({
|
||||
renderQuickSettings: (props: QuickSettingsProps) => {
|
||||
quickSettingsProps.current = props;
|
||||
return html`<div data-testid="quick-settings"></div>`;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./views/chat.ts", () => ({
|
||||
renderChat: () => html`<div data-testid="chat"></div>`,
|
||||
}));
|
||||
|
||||
vi.mock("./icons.ts", () => ({
|
||||
icons: {},
|
||||
}));
|
||||
|
||||
import { renderApp } from "./app-render.ts";
|
||||
import { saveLocalAssistantIdentity } from "./storage.ts";
|
||||
|
||||
function createState(overrides: Partial<AppViewState> = {}): AppViewState {
|
||||
return {
|
||||
settings: {
|
||||
gatewayUrl: "ws://localhost:18789",
|
||||
token: "",
|
||||
locale: "en",
|
||||
sessionKey: "main",
|
||||
lastActiveSessionKey: "main",
|
||||
theme: "claw",
|
||||
themeMode: "dark",
|
||||
splitRatio: 0.6,
|
||||
navWidth: 280,
|
||||
navCollapsed: false,
|
||||
navGroupsCollapsed: {},
|
||||
borderRadius: 50,
|
||||
chatFocusMode: false,
|
||||
chatShowThinking: false,
|
||||
chatShowToolCalls: true,
|
||||
},
|
||||
password: "",
|
||||
loginShowGatewayToken: false,
|
||||
loginShowGatewayPassword: false,
|
||||
tab: "config",
|
||||
onboarding: false,
|
||||
basePath: "",
|
||||
connected: true,
|
||||
theme: "claw",
|
||||
themeMode: "dark",
|
||||
themeResolved: "dark",
|
||||
themeOrder: ["claw", "knot", "dash"],
|
||||
customThemeImportUrl: "",
|
||||
customThemeImportBusy: false,
|
||||
customThemeImportMessage: null,
|
||||
customThemeImportExpanded: false,
|
||||
customThemeImportFocusToken: 0,
|
||||
hello: null,
|
||||
lastError: null,
|
||||
lastErrorCode: null,
|
||||
eventLog: [],
|
||||
assistantName: "Nova",
|
||||
assistantAvatar: "/avatar/main",
|
||||
assistantAvatarSource: "avatars/missing.png",
|
||||
assistantAvatarStatus: "none",
|
||||
assistantAvatarReason: "missing",
|
||||
assistantAvatarUploadBusy: false,
|
||||
assistantAvatarUploadError: null,
|
||||
assistantAgentId: "main",
|
||||
userName: null,
|
||||
userAvatar: null,
|
||||
localMediaPreviewRoots: [],
|
||||
embedSandboxMode: "scripts",
|
||||
allowExternalEmbedUrls: false,
|
||||
sessionKey: "main",
|
||||
chatLoading: false,
|
||||
chatSending: false,
|
||||
chatMessage: "",
|
||||
chatAttachments: [],
|
||||
chatMessages: [],
|
||||
chatToolMessages: [],
|
||||
chatStreamSegments: [],
|
||||
chatStream: null,
|
||||
chatStreamStartedAt: null,
|
||||
chatRunId: null,
|
||||
chatSideResult: null,
|
||||
chatSideResultTerminalRuns: new Set(),
|
||||
compactionStatus: null,
|
||||
fallbackStatus: null,
|
||||
chatAvatarUrl: null,
|
||||
chatAvatarSource: null,
|
||||
chatAvatarStatus: null,
|
||||
chatAvatarReason: null,
|
||||
chatThinkingLevel: null,
|
||||
chatModelOverrides: {},
|
||||
chatModelsLoading: false,
|
||||
chatModelCatalog: [],
|
||||
chatQueue: [],
|
||||
chatQueueBySession: {},
|
||||
chatLocalInputHistoryBySession: {},
|
||||
chatInputHistorySessionKey: null,
|
||||
chatInputHistoryItems: null,
|
||||
chatInputHistoryIndex: -1,
|
||||
chatDraftBeforeHistory: null,
|
||||
realtimeTalkActive: false,
|
||||
realtimeTalkStatus: "idle",
|
||||
realtimeTalkDetail: null,
|
||||
realtimeTalkTranscript: null,
|
||||
chatManualRefreshInFlight: false,
|
||||
nodesLoading: false,
|
||||
nodes: [],
|
||||
chatNewMessagesBelow: false,
|
||||
navDrawerOpen: false,
|
||||
sidebarOpen: false,
|
||||
sidebarContent: null,
|
||||
sidebarError: null,
|
||||
splitRatio: 0.6,
|
||||
scrollToBottom: vi.fn(),
|
||||
presenceEntries: [],
|
||||
sessionsResult: null,
|
||||
cronStatus: null,
|
||||
configSettingsMode: "quick",
|
||||
configForm: {},
|
||||
configSnapshot: { config: {}, hash: "hash" } as AppViewState["configSnapshot"],
|
||||
configFormDirty: false,
|
||||
configSaving: false,
|
||||
configApplying: false,
|
||||
cronJobs: [],
|
||||
skillsReport: {
|
||||
skills: [],
|
||||
workspaceDir: "",
|
||||
managedSkillsDir: "",
|
||||
} as AppViewState["skillsReport"],
|
||||
configActiveSection: null,
|
||||
configActiveSubsection: null,
|
||||
communicationsActiveSection: null,
|
||||
communicationsActiveSubsection: null,
|
||||
appearanceActiveSection: null,
|
||||
appearanceActiveSubsection: null,
|
||||
appearanceFormMode: "form",
|
||||
appearanceSearchQuery: "",
|
||||
automationActiveSection: null,
|
||||
automationActiveSubsection: null,
|
||||
infrastructureActiveSection: null,
|
||||
infrastructureActiveSubsection: null,
|
||||
aiAgentsActiveSection: null,
|
||||
aiAgentsActiveSubsection: null,
|
||||
configReady: true,
|
||||
configRaw: "",
|
||||
configRawOriginal: "",
|
||||
configValid: true,
|
||||
configIssues: [],
|
||||
configLoading: false,
|
||||
configSchema: null,
|
||||
configSchemaLoading: false,
|
||||
configUiHints: null,
|
||||
configFormOriginal: {},
|
||||
updateRunning: false,
|
||||
agentsList: null,
|
||||
agentsSelectedId: null,
|
||||
cronModelSuggestions: [],
|
||||
cronForm: { deliveryChannel: "", deliveryMode: "last" },
|
||||
cronFieldErrors: {},
|
||||
cronError: null,
|
||||
cronQuickCreateOpen: false,
|
||||
cronQuickCreateStep: "what",
|
||||
cronQuickCreateDraft: null,
|
||||
cronEditingJobId: null,
|
||||
channelsSnapshot: null,
|
||||
execApprovalQueue: [],
|
||||
dreamingRestartConfirmOpen: false,
|
||||
dreamingRestartConfirmLoading: false,
|
||||
dreamingStatusError: null,
|
||||
client: null,
|
||||
refreshSessionsAfterChat: new Set(),
|
||||
connect: vi.fn(),
|
||||
setTab: vi.fn(),
|
||||
setTheme: vi.fn(),
|
||||
setThemeMode: vi.fn(),
|
||||
setCustomThemeImportUrl: vi.fn(),
|
||||
openCustomThemeImport: vi.fn(),
|
||||
importCustomTheme: vi.fn(),
|
||||
clearCustomTheme: vi.fn(),
|
||||
setBorderRadius: vi.fn(),
|
||||
applySettings: vi.fn(),
|
||||
applyLocalUserIdentity: vi.fn(),
|
||||
loadOverview: vi.fn(),
|
||||
loadAssistantIdentity: vi.fn(),
|
||||
loadCron: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as AppViewState;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageValues.clear();
|
||||
quickSettingsProps.current = null;
|
||||
});
|
||||
|
||||
describe("renderApp assistant avatar routing", () => {
|
||||
it("passes the browser-local assistant override to Quick Settings ahead of stale identity metadata", () => {
|
||||
const dataUrl = "data:image/png;base64,bG9jYWwtYXNzaXN0YW50";
|
||||
saveLocalAssistantIdentity({ avatar: dataUrl });
|
||||
|
||||
renderApp(createState());
|
||||
|
||||
expect(quickSettingsProps.current?.assistantAvatar).toBe(dataUrl);
|
||||
expect(quickSettingsProps.current?.assistantAvatarUrl).toBe(dataUrl);
|
||||
expect(quickSettingsProps.current?.assistantAvatarSource).toBe(dataUrl);
|
||||
expect(quickSettingsProps.current?.assistantAvatarStatus).toBe("data");
|
||||
expect(quickSettingsProps.current?.assistantAvatarReason).toBeNull();
|
||||
expect(quickSettingsProps.current?.assistantAvatarOverride).toBe(dataUrl);
|
||||
});
|
||||
});
|
||||
@@ -118,13 +118,14 @@ import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "./external-link.ts";
|
||||
import { icons } from "./icons.ts";
|
||||
import { createLazyView, renderLazyView } from "./lazy-view.ts";
|
||||
import { normalizeBasePath, TAB_GROUPS, subtitleForTab, titleForTab } from "./navigation.ts";
|
||||
import "./components/dashboard-header.ts";
|
||||
import { isPluginEnabledInConfigSnapshot } from "./plugin-activation.ts";
|
||||
import "./components/dashboard-header.ts";
|
||||
import {
|
||||
buildAgentMainSessionKey,
|
||||
parseAgentSessionKey,
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "./session-key.ts";
|
||||
import { loadLocalAssistantIdentity } from "./storage.ts";
|
||||
import { normalizeOptionalString } from "./string-coerce.ts";
|
||||
import { isRenderableControlUiAvatarUrl } from "./views/agents-utils.ts";
|
||||
import { agentLogoUrl } from "./views/agents-utils.ts";
|
||||
@@ -637,28 +638,44 @@ export function renderApp(state: AppViewState) {
|
||||
const navCollapsed = state.settings.navCollapsed && !navDrawerOpen;
|
||||
const showThinking = state.onboarding ? false : state.settings.chatShowThinking;
|
||||
const showToolCalls = state.onboarding ? true : state.settings.chatShowToolCalls;
|
||||
const localAssistantAvatarOverride =
|
||||
normalizeOptionalString(loadLocalAssistantIdentity().avatar) ?? null;
|
||||
const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
|
||||
const chatAssistantAvatarStatus = state.chatAvatarStatus ?? state.assistantAvatarStatus ?? null;
|
||||
const chatAssistantAvatarReason = state.chatAvatarReason ?? state.assistantAvatarReason ?? null;
|
||||
const chatAssistantAvatarStatus = localAssistantAvatarOverride
|
||||
? "data"
|
||||
: (state.chatAvatarStatus ?? state.assistantAvatarStatus ?? null);
|
||||
const chatAssistantAvatarReason = localAssistantAvatarOverride
|
||||
? null
|
||||
: (state.chatAvatarReason ?? state.assistantAvatarReason ?? null);
|
||||
const chatAssistantAvatarMissing =
|
||||
chatAssistantAvatarStatus === "none" && chatAssistantAvatarReason === "missing";
|
||||
const effectiveAssistantAvatar = chatAssistantAvatarMissing ? null : state.assistantAvatar;
|
||||
const effectiveAssistantAvatar =
|
||||
localAssistantAvatarOverride ?? (chatAssistantAvatarMissing ? null : state.assistantAvatar);
|
||||
const chatAvatarUrl =
|
||||
state.chatAvatarUrl ?? (chatAssistantAvatarMissing ? null : (assistantAvatarUrl ?? null));
|
||||
const configAssistantAvatarStatus = state.assistantAvatarStatus ?? state.chatAvatarStatus ?? null;
|
||||
const configAssistantAvatarReason = state.assistantAvatarReason ?? state.chatAvatarReason ?? null;
|
||||
const configAssistantAvatarSource = state.assistantAvatarSource ?? state.chatAvatarSource ?? null;
|
||||
localAssistantAvatarOverride ??
|
||||
state.chatAvatarUrl ??
|
||||
(chatAssistantAvatarMissing ? null : (assistantAvatarUrl ?? null));
|
||||
const configAssistantAvatarStatus = localAssistantAvatarOverride
|
||||
? "data"
|
||||
: (state.assistantAvatarStatus ?? state.chatAvatarStatus ?? null);
|
||||
const configAssistantAvatarReason = localAssistantAvatarOverride
|
||||
? null
|
||||
: (state.assistantAvatarReason ?? state.chatAvatarReason ?? null);
|
||||
const configAssistantAvatarSource =
|
||||
localAssistantAvatarOverride ?? state.assistantAvatarSource ?? state.chatAvatarSource ?? null;
|
||||
const configAssistantAvatarMissing =
|
||||
configAssistantAvatarStatus === "none" && configAssistantAvatarReason === "missing";
|
||||
const configAssistantAvatar =
|
||||
configAssistantAvatarMissing || configAssistantAvatarStatus === "local"
|
||||
localAssistantAvatarOverride ??
|
||||
(configAssistantAvatarMissing || configAssistantAvatarStatus === "local"
|
||||
? null
|
||||
: state.assistantAvatar;
|
||||
: state.assistantAvatar);
|
||||
const configAssistantAvatarUrl =
|
||||
configAssistantAvatarStatus === "local" && state.assistantAgentId
|
||||
localAssistantAvatarOverride ??
|
||||
(configAssistantAvatarStatus === "local" && state.assistantAgentId
|
||||
? buildAssistantAvatarRoute(state.basePath, state.assistantAgentId)
|
||||
: (state.chatAvatarUrl ??
|
||||
(configAssistantAvatarMissing ? null : (assistantAvatarUrl ?? null)));
|
||||
(configAssistantAvatarMissing ? null : (assistantAvatarUrl ?? null))));
|
||||
const configValue =
|
||||
state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null);
|
||||
const configuredDreaming = resolveConfiguredDreaming(configValue);
|
||||
@@ -956,7 +973,8 @@ export function renderApp(state: AppViewState) {
|
||||
// Quick Settings mode — opinionated card layout
|
||||
if (state.configSettingsMode === "quick") {
|
||||
const configObj = state.configForm ?? state.configSnapshot?.config ?? {};
|
||||
const assistantAvatarOverride = resolveAssistantAvatarOverride(configObj);
|
||||
const assistantAvatarOverride =
|
||||
localAssistantAvatarOverride ?? resolveAssistantAvatarOverride(configObj);
|
||||
const agentsDefaults = ((configObj.agents as Record<string, unknown> | undefined)
|
||||
?.defaults ?? {}) as Record<string, unknown>;
|
||||
const activeSession = resolveQuickSettingsSessionRow(state);
|
||||
@@ -1069,6 +1087,7 @@ export function renderApp(state: AppViewState) {
|
||||
state.chatAvatarStatus = null;
|
||||
state.chatAvatarReason = null;
|
||||
state.assistantAvatarUploadError = null;
|
||||
void state.loadAssistantIdentity?.().finally(() => requestHostUpdate?.());
|
||||
requestHostUpdate?.();
|
||||
},
|
||||
basePath: state.basePath ?? "",
|
||||
|
||||
@@ -87,6 +87,7 @@ describe("setAssistantAvatarOverride", () => {
|
||||
|
||||
setAssistantAvatarOverride(state, null);
|
||||
|
||||
expect(state.assistantAvatar).toBeNull();
|
||||
expect(state.assistantAvatarSource).toBeNull();
|
||||
expect(state.assistantAvatarStatus).toBeNull();
|
||||
expect(state.assistantAvatarReason).toBeNull();
|
||||
|
||||
@@ -90,6 +90,7 @@ export function setAssistantAvatarOverride(
|
||||
state.assistantAvatarStatus = "data";
|
||||
state.assistantAvatarReason = null;
|
||||
} else {
|
||||
state.assistantAvatar = null;
|
||||
state.assistantAvatarSource = null;
|
||||
state.assistantAvatarStatus = null;
|
||||
state.assistantAvatarReason = null;
|
||||
|
||||
@@ -253,6 +253,42 @@ describe("renderQuickSettings", () => {
|
||||
expect(onAssistantAvatarClearOverride).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lets the browser-local assistant avatar override stale missing IDENTITY.md metadata", () => {
|
||||
const dataUrl = "data:image/png;base64,bG9jYWwtYXNzaXN0YW50";
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(
|
||||
renderQuickSettings(
|
||||
createProps({
|
||||
assistantName: "Nova",
|
||||
assistantAvatar: "/avatar/main",
|
||||
assistantAvatarUrl: null,
|
||||
assistantAvatarSource: "avatars/missing.png",
|
||||
assistantAvatarStatus: "none",
|
||||
assistantAvatarReason: "missing",
|
||||
assistantAvatarOverride: dataUrl,
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".qs-assistant-avatar")?.getAttribute("src")).toBe(dataUrl);
|
||||
expect(container.querySelector(".qs-identity-card__source")?.textContent).toContain(
|
||||
"UI override",
|
||||
);
|
||||
expect(container.querySelector(".qs-identity-card__issue")).toBeNull();
|
||||
expect(
|
||||
Array.from(container.querySelectorAll("label.btn")).some(
|
||||
(label) => label.textContent?.trim() === "Replace image",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
Array.from(container.querySelectorAll("button")).some(
|
||||
(button) => button.textContent?.trim() === "Clear override",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects oversized avatar uploads before reading them", () => {
|
||||
const onUserAvatarChange = vi.fn();
|
||||
const fileReader = vi.fn();
|
||||
|
||||
@@ -171,6 +171,15 @@ function renderLocalUserAvatarPreview(avatar: string | null | undefined) {
|
||||
}
|
||||
|
||||
function resolveAssistantPreviewAvatarUrl(props: QuickSettingsProps): string | null {
|
||||
const override = normalizeOptionalString(props.assistantAvatarOverride);
|
||||
if (override) {
|
||||
return resolveChatAvatarRenderUrl(override, {
|
||||
identity: {
|
||||
avatar: override,
|
||||
avatarUrl: override,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (props.assistantAvatarStatus === "none" && props.assistantAvatarReason === "missing") {
|
||||
return null;
|
||||
}
|
||||
@@ -198,7 +207,11 @@ function formatAssistantAvatarIssue(
|
||||
status: QuickSettingsProps["assistantAvatarStatus"],
|
||||
reason: string | null | undefined,
|
||||
_rendered: boolean,
|
||||
hasOverride = false,
|
||||
): string | null {
|
||||
if (hasOverride) {
|
||||
return null;
|
||||
}
|
||||
if (status === "remote") {
|
||||
return "Remote URLs are blocked by Control UI image policy";
|
||||
}
|
||||
@@ -219,11 +232,14 @@ function formatAssistantAvatarIssue(
|
||||
|
||||
function renderAssistantAvatarPreview(props: QuickSettingsProps) {
|
||||
const assistantName = normalizeOptionalString(props.assistantName) ?? "Assistant";
|
||||
const assistantAvatarOverride = normalizeOptionalString(props.assistantAvatarOverride);
|
||||
const assistantAvatarUrl = resolveAssistantPreviewAvatarUrl(props);
|
||||
if (assistantAvatarUrl) {
|
||||
return html`<img class="qs-assistant-avatar" src=${assistantAvatarUrl} alt=${assistantName} />`;
|
||||
}
|
||||
const assistantAvatarText = resolveAssistantTextAvatar(props.assistantAvatar);
|
||||
const assistantAvatarText = resolveAssistantTextAvatar(
|
||||
assistantAvatarOverride ?? props.assistantAvatar,
|
||||
);
|
||||
if (assistantAvatarText) {
|
||||
return html`<div
|
||||
class="qs-assistant-avatar qs-assistant-avatar--text"
|
||||
@@ -616,15 +632,19 @@ function renderPersonalCard(props: QuickSettingsProps) {
|
||||
const assistantName = normalizeOptionalString(props.assistantName) ?? "Assistant";
|
||||
const assistantAvatarUrl = resolveAssistantPreviewAvatarUrl(props);
|
||||
const assistantAvatarRendered = Boolean(
|
||||
assistantAvatarUrl || resolveAssistantTextAvatar(props.assistantAvatar),
|
||||
assistantAvatarUrl ||
|
||||
resolveAssistantTextAvatar(props.assistantAvatarOverride ?? props.assistantAvatar),
|
||||
);
|
||||
const assistantAvatarOverride = normalizeOptionalString(props.assistantAvatarOverride);
|
||||
const assistantAvatarSource = formatAssistantAvatarSource(
|
||||
assistantAvatarOverride ?? props.assistantAvatarSource,
|
||||
);
|
||||
const assistantAvatarSource = formatAssistantAvatarSource(props.assistantAvatarSource);
|
||||
const assistantAvatarIssue = formatAssistantAvatarIssue(
|
||||
props.assistantAvatarStatus ?? null,
|
||||
props.assistantAvatarReason,
|
||||
assistantAvatarRendered,
|
||||
Boolean(assistantAvatarOverride),
|
||||
);
|
||||
const assistantAvatarOverride = normalizeOptionalString(props.assistantAvatarOverride);
|
||||
const assistantAvatarSourceLabel = assistantAvatarOverride ? "UI override" : "IDENTITY.md";
|
||||
const canOverrideAssistantAvatar = Boolean(props.onAssistantAvatarOverrideChange);
|
||||
const assistantAvatarSubtitle = assistantAvatarOverride
|
||||
|
||||
Reference in New Issue
Block a user