fix(control-ui): persist assistant avatar override locally (#71639)

* fix(control-ui): rebalance quick settings into stable 3-col bento

Pair Appearance with Automations and let Channels stand alone in the
middle column so all three top-row columns reach similar heights.
Promote Personal to a full-width row with a horizontal body
(identity tiles | emoji + actions) so the avatar block stops fighting
for half-width space. Drops the unused .qs-stack--wide hook.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(control-ui): rebalance Personal card with symmetric User↔Assistant identity pair

Restructure Personal card layout to present User and Assistant as 2 balanced identity cards instead of separate User tile + form controls. Mirrors the visual hierarchy and UI pattern across both identities.

Changes:
- Move User avatar text input into User identity card's .__repair section (mirroring Assistant's structure)
- Inline "Choose image" and "Clear avatar" buttons as flex-wrapped action group
- Remove .qs-personal-body and .qs-personal-form wrapper divs
- Update Personal card's .qs-identity-grid to 2-column layout with balanced spacing
- Responsive collapse to 1-column at ≤760px

Tests:
- config-quick.test.ts updated to expect 2 stacks (no longer wrapping Personal in form)
- config-quick.test.ts validates identity card layout now has symmetric User↔Assistant structure
- All 10 quick settings view tests passing
- All 20 schema regression tests passing

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

* chore: ignore .vmux worktree paths

* fix(control-ui): persist assistant avatar override locally instead of via gateway config

Mirrors the user-avatar pattern: assistant avatar uploads now go to
localStorage and overlay the gateway-resolved identity at bootstrap and on
agent.identity.get refreshes. Sidesteps the ui.assistant.avatar zod cap
that rejected uploaded data URLs as 'Too big: expected string to have
<=200 characters', removes one config.patch RPC from the avatar path, and
collapses the upload handler from a 44-line async/loadConfig dance into a
plain synchronous setter.

Also lifts the gateway-side ui.assistant.avatar schema cap from 200 to
2,000,000 to match the user-avatar size budget for non-UI clients writing
the field directly, and adds a content-aware text/image normalizer in
ui/src/ui/assistant-identity.ts so short-text avatars stay short while
data URLs survive round-tripping.

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Val Alexander
2026-04-25 11:17:48 -05:00
committed by GitHub
parent 95b7a85f06
commit c65aa1d2a6
17 changed files with 290 additions and 188 deletions

1
.gitignore vendored
View File

@@ -146,6 +146,7 @@ changelog/fragments/
# Local scratch workspace # Local scratch workspace
.tmp/ .tmp/
.vmux*
.artifacts/ .artifacts/
test/fixtures/openclaw-vitest-unit-report.json test/fixtures/openclaw-vitest-unit-report.json
analysis/ analysis/

View File

@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
### Fixes ### Fixes
- Control UI/Quick Settings: persist the assistant avatar override to browser local storage (mirroring the user avatar) so uploaded image data URLs no longer fail config validation with "Too big: expected string to have <=200 characters". Also lift the gateway-side `ui.assistant.avatar` length cap to match the user avatar size budget for non-UI clients writing the field directly. Thanks @BunsDev.
- Browser/CDP: make readiness diagnostics use the same discovery-first fallback as reachability for bare `ws://` Browserless and Browserbase CDP URLs. Fixes #69532. - Browser/CDP: make readiness diagnostics use the same discovery-first fallback as reachability for bare `ws://` Browserless and Browserbase CDP URLs. Fixes #69532.
- ACP/OpenCode: update the bundled acpx runtime to 0.6.0 and cover the OpenCode ACP bind path in Docker live tests. - ACP/OpenCode: update the bundled acpx runtime to 0.6.0 and cover the OpenCode ACP bind path in Docker live tests.
- Browser/existing-session: support per-profile Chrome MCP command/args, map `cdpUrl` to `--browserUrl` or `--wsEndpoint`, and avoid combining endpoint flags with `--userDataDir`. Fixes #47879, #48037, and #62706. Thanks @puneet1409, @zhehao, and @madkow1001. - Browser/existing-session: support per-profile Chrome MCP command/args, map `cdpUrl` to `--browserUrl` or `--wsEndpoint`, and avoid combining endpoint flags with `--userDataDir`. Fixes #47879, #48037, and #62706. Thanks @puneet1409, @zhehao, and @madkow1001.

View File

@@ -878,7 +878,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
}, },
avatar: { avatar: {
type: "string", type: "string",
maxLength: 200, maxLength: 2000000,
title: "Assistant Avatar", title: "Assistant Avatar",
description: description:
"Assistant avatar image source used in UI surfaces (URL, path, or data URI depending on runtime support). Use trusted assets and consistent branding dimensions for clean rendering.", "Assistant avatar image source used in UI surfaces (URL, path, or data URI depending on runtime support). Use trusted assets and consistent branding dimensions for clean rendering.",

View File

@@ -457,7 +457,7 @@ export const OpenClawSchema = z
assistant: z assistant: z
.object({ .object({
name: z.string().max(50).optional(), name: z.string().max(50).optional(),
avatar: z.string().max(200).optional(), avatar: z.string().max(2_000_000).optional(),
}) })
.strict() .strict()
.optional(), .optional(),

View File

@@ -40,4 +40,17 @@ describe("resolveAssistantIdentity avatar normalization", () => {
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("avatars/openclaw.png"); expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe("avatars/openclaw.png");
}); });
it("preserves long image data URLs without truncating past 200 chars", () => {
const dataUrl = `data:image/png;base64,${"A".repeat(50_000)}`;
const cfg: OpenClawConfig = {
ui: {
assistant: {
avatar: dataUrl,
},
},
};
expect(resolveAssistantIdentity({ cfg, workspaceDir: "" }).avatar).toBe(dataUrl);
});
}); });

View File

@@ -11,7 +11,10 @@ import {
} from "../shared/avatar-policy.js"; } from "../shared/avatar-policy.js";
const MAX_ASSISTANT_NAME = 50; const MAX_ASSISTANT_NAME = 50;
const MAX_ASSISTANT_AVATAR = 200; // Image-bearing avatars (data: URLs, paths) need to round-trip through
// coerceIdentityValue without truncation. Sized to match
// MAX_LOCAL_USER_IMAGE_AVATAR / AVATAR_MAX_BYTES expansion.
const MAX_ASSISTANT_AVATAR = 2_000_000;
const MAX_ASSISTANT_EMOJI = 16; const MAX_ASSISTANT_EMOJI = 16;
export const DEFAULT_ASSISTANT_IDENTITY: AssistantIdentity = { export const DEFAULT_ASSISTANT_IDENTITY: AssistantIdentity = {

View File

@@ -56,10 +56,6 @@
min-width: 0; min-width: 0;
} }
.qs-stack--wide {
grid-column: span 2;
}
/* ── Card ── */ /* ── Card ── */
.qs-card { .qs-card {
@@ -83,7 +79,12 @@
} }
.qs-card--personal { .qs-card--personal {
grid-column: span 2; grid-column: 1 / -1;
}
.qs-card--personal .qs-identity-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 14px 16px 16px;
} }
.qs-card__header { .qs-card__header {
@@ -325,7 +326,7 @@
.qs-identity-card__repair { .qs-identity-card__repair {
display: grid; display: grid;
gap: 6px; gap: 8px;
margin-top: 10px; margin-top: 10px;
} }
@@ -333,6 +334,29 @@
width: fit-content; width: fit-content;
} }
.qs-identity-card__repair .qs-field {
gap: 4px;
}
.qs-identity-card__repair .qs-row__label {
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
}
.qs-identity-card__repair .qs-field__input {
font-size: 0.78rem;
padding: 6px 9px;
}
.qs-identity-card__actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.qs-identity-card__repair .muted { .qs-identity-card__repair .muted {
font-size: 0.68rem; font-size: 0.68rem;
line-height: 1.35; line-height: 1.35;
@@ -1054,11 +1078,6 @@
.qs-grid { .qs-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.qs-card--personal,
.qs-stack--wide {
grid-column: span 1;
}
} }
@media (max-width: 760px) { @media (max-width: 760px) {
@@ -1066,9 +1085,8 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.qs-card--personal, .qs-card--personal .qs-identity-grid {
.qs-stack--wide { grid-template-columns: 1fr;
grid-column: 1 / -1;
} }
} }

View File

@@ -18,7 +18,7 @@ describe("config-quick styles", () => {
it("includes the stacked quick-settings density layout", () => { it("includes the stacked quick-settings density layout", () => {
expect(css).toContain(".qs-stack"); expect(css).toContain(".qs-stack");
expect(css).toContain(".qs-stack--wide"); expect(css).toContain(".qs-identity-card__actions");
expect(css).toContain("grid-template-columns: repeat(3, minmax(0, 1fr));"); expect(css).toContain("grid-template-columns: repeat(3, minmax(0, 1fr));");
expect(css).toContain("grid-template-columns: repeat(2, minmax(0, 1fr));"); expect(css).toContain("grid-template-columns: repeat(2, minmax(0, 1fr));");
expect(css).toContain("@media (max-width: 760px)"); expect(css).toContain("@media (max-width: 760px)");

View File

@@ -1,7 +1,7 @@
import { html, nothing } from "lit"; import { html, nothing } from "lit";
import { t } from "../i18n/index.ts"; import { t } from "../i18n/index.ts";
import { getSafeLocalStorage } from "../local-storage.ts"; import { getSafeLocalStorage } from "../local-storage.ts";
import { refreshChat, refreshChatAvatar } from "./app-chat.ts"; import { refreshChat } from "./app-chat.ts";
import { DEFAULT_CRON_FORM } from "./app-defaults.ts"; import { DEFAULT_CRON_FORM } from "./app-defaults.ts";
import { renderUsageTab } from "./app-render-usage-tab.ts"; import { renderUsageTab } from "./app-render-usage-tab.ts";
import { import {
@@ -1038,49 +1038,23 @@ export function renderApp(state: AppViewState) {
assistantAvatarOverride, assistantAvatarOverride,
assistantAvatarUploadBusy: state.assistantAvatarUploadBusy, assistantAvatarUploadBusy: state.assistantAvatarUploadBusy,
assistantAvatarUploadError: state.assistantAvatarUploadError, assistantAvatarUploadError: state.assistantAvatarUploadError,
onAssistantAvatarOverrideChange: async (dataUrl) => { onAssistantAvatarOverrideChange: (dataUrl) => {
state.assistantAvatarUploadBusy = true; setAssistantAvatarOverride(state, dataUrl);
state.chatAvatarUrl = dataUrl;
state.chatAvatarSource = dataUrl;
state.chatAvatarStatus = "data";
state.chatAvatarReason = null;
state.assistantAvatarUploadError = null; state.assistantAvatarUploadError = null;
requestHostUpdate?.(); requestHostUpdate?.();
try {
await setAssistantAvatarOverride(state, dataUrl);
state.assistantAvatar = dataUrl;
state.assistantAvatarSource = dataUrl;
state.assistantAvatarStatus = "data";
state.assistantAvatarReason = null;
state.chatAvatarUrl = dataUrl;
state.chatAvatarSource = dataUrl;
state.chatAvatarStatus = "data";
state.chatAvatarReason = null;
await loadConfig(state);
await state.loadAssistantIdentity();
await refreshChatAvatar(state);
} catch (err) {
state.assistantAvatarUploadError = err instanceof Error ? err.message : String(err);
} finally {
state.assistantAvatarUploadBusy = false;
requestHostUpdate?.();
}
}, },
onAssistantAvatarClearOverride: async () => { onAssistantAvatarClearOverride: () => {
state.assistantAvatarUploadBusy = true; setAssistantAvatarOverride(state, null);
state.chatAvatarUrl = null;
state.chatAvatarSource = null;
state.chatAvatarStatus = null;
state.chatAvatarReason = null;
state.assistantAvatarUploadError = null; state.assistantAvatarUploadError = null;
requestHostUpdate?.(); requestHostUpdate?.();
try {
await setAssistantAvatarOverride(state, null);
state.chatAvatarUrl = null;
state.chatAvatarSource = null;
state.chatAvatarStatus = null;
state.chatAvatarReason = null;
await loadConfig(state);
await state.loadAssistantIdentity();
await refreshChatAvatar(state);
} catch (err) {
state.assistantAvatarUploadError = err instanceof Error ? err.message : String(err);
} finally {
state.assistantAvatarUploadBusy = false;
requestHostUpdate?.();
}
}, },
basePath: state.basePath ?? "", basePath: state.basePath ?? "",
configObject: configObj, configObject: configObj,

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { normalizeAssistantIdentity } from "./assistant-identity.ts";
describe("normalizeAssistantIdentity", () => {
it("preserves long image data URLs without truncating past 200 chars", () => {
const dataUrl = `data:image/png;base64,${"A".repeat(50_000)}`;
expect(normalizeAssistantIdentity({ avatar: dataUrl }).avatar).toBe(dataUrl);
});
it("preserves same-origin Control UI avatar routes", () => {
expect(normalizeAssistantIdentity({ avatar: "/avatar/main" }).avatar).toBe("/avatar/main");
});
it("keeps short text avatars", () => {
expect(normalizeAssistantIdentity({ avatar: "PS" }).avatar).toBe("PS");
expect(normalizeAssistantIdentity({ avatar: "🦞" }).avatar).toBe("🦞");
});
it("drops sentence-like text that exceeds the text-avatar limit", () => {
const longText = "this is a description, not an emoji or url ".repeat(4);
expect(normalizeAssistantIdentity({ avatar: longText }).avatar).toBeNull();
});
it("drops avatars containing newlines", () => {
expect(normalizeAssistantIdentity({ avatar: "line1\nline2" }).avatar).toBeNull();
});
});

View File

@@ -1,8 +1,18 @@
import { coerceIdentityValue } from "../../../src/shared/assistant-identity-values.js"; import { coerceIdentityValue } from "../../../src/shared/assistant-identity-values.js";
const MAX_ASSISTANT_NAME = 50; const MAX_ASSISTANT_NAME = 50;
const MAX_ASSISTANT_AVATAR = 200; // Short text/emoji avatars (e.g. "A", "PS", "🦞"). Anything longer that is not
// a renderable image URL is dropped during normalization.
const MAX_ASSISTANT_TEXT_AVATAR = 64;
// Image-bearing avatars (data: URLs, same-origin Control UI routes). Sized to
// match MAX_LOCAL_USER_IMAGE_AVATAR so an uploaded image data URL survives
// round-tripping through config without truncation.
const MAX_ASSISTANT_IMAGE_AVATAR = 2_000_000;
const MAX_ASSISTANT_AVATAR_SOURCE = 500; const MAX_ASSISTANT_AVATAR_SOURCE = 500;
const MAX_ASSISTANT_AVATAR_REASON = 200;
// Mirrors agents-utils.CONTROL_UI_AVATAR_URL_RE — duplicated locally to keep
// this module free of UI view imports (avoids an import cycle).
const RENDERABLE_AVATAR_URL_RE = /^(data:image\/|\/(?!\/))/i;
export const DEFAULT_ASSISTANT_NAME = "Assistant"; export const DEFAULT_ASSISTANT_NAME = "Assistant";
export const DEFAULT_ASSISTANT_AVATAR = "A"; export const DEFAULT_ASSISTANT_AVATAR = "A";
@@ -16,11 +26,25 @@ export type AssistantIdentity = {
avatarReason?: string | null; avatarReason?: string | null;
}; };
function normalizeAssistantAvatar(value: string | null | undefined): string | null {
const trimmed = coerceIdentityValue(value ?? undefined, MAX_ASSISTANT_IMAGE_AVATAR);
if (!trimmed) {
return null;
}
if (RENDERABLE_AVATAR_URL_RE.test(trimmed)) {
return trimmed;
}
if (/[\r\n]/.test(trimmed)) {
return null;
}
return trimmed.length <= MAX_ASSISTANT_TEXT_AVATAR ? trimmed : null;
}
export function normalizeAssistantIdentity( export function normalizeAssistantIdentity(
input?: Partial<AssistantIdentity> | null, input?: Partial<AssistantIdentity> | null,
): AssistantIdentity { ): AssistantIdentity {
const name = coerceIdentityValue(input?.name, MAX_ASSISTANT_NAME) ?? DEFAULT_ASSISTANT_NAME; const name = coerceIdentityValue(input?.name, MAX_ASSISTANT_NAME) ?? DEFAULT_ASSISTANT_NAME;
const avatar = coerceIdentityValue(input?.avatar ?? undefined, MAX_ASSISTANT_AVATAR) ?? null; const avatar = normalizeAssistantAvatar(input?.avatar);
const avatarSource = const avatarSource =
coerceIdentityValue(input?.avatarSource ?? undefined, MAX_ASSISTANT_AVATAR_SOURCE) ?? null; coerceIdentityValue(input?.avatarSource ?? undefined, MAX_ASSISTANT_AVATAR_SOURCE) ?? null;
const avatarStatus = const avatarStatus =
@@ -31,7 +55,7 @@ export function normalizeAssistantIdentity(
? input.avatarStatus ? input.avatarStatus
: null; : null;
const avatarReason = const avatarReason =
coerceIdentityValue(input?.avatarReason ?? undefined, MAX_ASSISTANT_AVATAR) ?? null; coerceIdentityValue(input?.avatarReason ?? undefined, MAX_ASSISTANT_AVATAR_REASON) ?? null;
const agentId = const agentId =
typeof input?.agentId === "string" && input.agentId.trim() ? input.agentId.trim() : null; typeof input?.agentId === "string" && input.agentId.trim() ? input.agentId.trim() : null;
return { agentId, name, avatar, avatarSource, avatarStatus, avatarReason }; return { agentId, name, avatar, avatarSource, avatarStatus, avatarReason };

View File

@@ -1,45 +1,42 @@
import { describe, expect, it, vi } from "vitest"; // @vitest-environment node
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createStorageMock } from "../../test-helpers/storage.ts";
import { loadLocalAssistantIdentity } from "../storage.ts";
import { setAssistantAvatarOverride } from "./assistant-identity.ts"; import { setAssistantAvatarOverride } from "./assistant-identity.ts";
describe("setAssistantAvatarOverride", () => { describe("setAssistantAvatarOverride", () => {
it("writes the assistant avatar override through config.patch", async () => { beforeEach(() => {
const request = vi.fn().mockResolvedValue({}); vi.stubGlobal("localStorage", createStorageMock());
});
await setAssistantAvatarOverride( afterEach(() => {
{ vi.unstubAllGlobals();
client: { request } as never,
connected: true,
applySessionKey: "agent:main",
configSnapshot: { hash: "config-hash" },
},
"data:image/png;base64,YXZhdGFy",
);
expect(request).toHaveBeenCalledWith("config.patch", {
baseHash: "config-hash",
raw: JSON.stringify({ ui: { assistant: { avatar: "data:image/png;base64,YXZhdGFy" } } }),
sessionKey: "agent:main",
note: "Assistant avatar override updated from Control UI.",
});
}); });
it("clears the assistant avatar override through config.patch", async () => { it("persists the assistant avatar locally and mirrors the user avatar pattern", () => {
const request = vi.fn().mockResolvedValue({}); const state: Parameters<typeof setAssistantAvatarOverride>[0] = {};
await setAssistantAvatarOverride( setAssistantAvatarOverride(state, "data:image/png;base64,YXZhdGFy");
{
client: { request } as never,
connected: true,
configSnapshot: { hash: "config-hash" },
},
null,
);
expect(request).toHaveBeenCalledWith("config.patch", { expect(state.assistantAvatar).toBe("data:image/png;base64,YXZhdGFy");
baseHash: "config-hash", expect(state.assistantAvatarSource).toBe("data:image/png;base64,YXZhdGFy");
raw: JSON.stringify({ ui: { assistant: { avatar: null } } }), expect(state.assistantAvatarStatus).toBe("data");
sessionKey: undefined, expect(state.assistantAvatarReason).toBeNull();
note: "Assistant avatar override cleared from Control UI.", expect(loadLocalAssistantIdentity().avatar).toBe("data:image/png;base64,YXZhdGFy");
}); });
it("clears the local override", () => {
const state: Parameters<typeof setAssistantAvatarOverride>[0] = {
assistantAvatar: "data:image/png;base64,YXZhdGFy",
assistantAvatarSource: "data:image/png;base64,YXZhdGFy",
assistantAvatarStatus: "data",
};
setAssistantAvatarOverride(state, "data:image/png;base64,YXZhdGFy");
setAssistantAvatarOverride(state, null);
expect(state.assistantAvatarSource).toBeNull();
expect(state.assistantAvatarStatus).toBeNull();
expect(state.assistantAvatarReason).toBeNull();
expect(loadLocalAssistantIdentity().avatar).toBeNull();
}); });
}); });

View File

@@ -1,5 +1,6 @@
import { normalizeAssistantIdentity } from "../assistant-identity.ts"; import { normalizeAssistantIdentity } from "../assistant-identity.ts";
import type { GatewayBrowserClient } from "../gateway.ts"; import type { GatewayBrowserClient } from "../gateway.ts";
import { loadLocalAssistantIdentity, saveLocalAssistantIdentity } from "../storage.ts";
export type AssistantIdentityState = { export type AssistantIdentityState = {
client: GatewayBrowserClient | null; client: GatewayBrowserClient | null;
@@ -14,10 +15,10 @@ export type AssistantIdentityState = {
}; };
export type AssistantAvatarOverrideState = { export type AssistantAvatarOverrideState = {
client: GatewayBrowserClient | null; assistantAvatar?: string | null;
connected: boolean; assistantAvatarSource?: string | null;
applySessionKey?: string; assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null;
configSnapshot?: { hash?: string | null } | null; assistantAvatarReason?: string | null;
}; };
export async function loadAssistantIdentity( export async function loadAssistantIdentity(
@@ -41,28 +42,32 @@ export async function loadAssistantIdentity(
state.assistantAvatarStatus = normalized.avatarStatus ?? null; state.assistantAvatarStatus = normalized.avatarStatus ?? null;
state.assistantAvatarReason = normalized.avatarReason ?? null; state.assistantAvatarReason = normalized.avatarReason ?? null;
state.assistantAgentId = normalized.agentId ?? null; state.assistantAgentId = normalized.agentId ?? null;
// Local override always wins — same pattern as the user avatar.
const localAvatar = loadLocalAssistantIdentity().avatar;
if (localAvatar) {
state.assistantAvatar = localAvatar;
state.assistantAvatarSource = localAvatar;
state.assistantAvatarStatus = "data";
state.assistantAvatarReason = null;
}
} catch { } catch {
// Ignore errors; keep last known identity. // Ignore errors; keep last known identity.
} }
} }
export async function setAssistantAvatarOverride( export function setAssistantAvatarOverride(
state: AssistantAvatarOverrideState, state: AssistantAvatarOverrideState,
avatar: string | null, avatar: string | null,
) { ) {
if (!state.client || !state.connected) { saveLocalAssistantIdentity({ avatar });
throw new Error("Gateway is not connected."); if (avatar) {
state.assistantAvatar = avatar;
state.assistantAvatarSource = avatar;
state.assistantAvatarStatus = "data";
state.assistantAvatarReason = null;
} else {
state.assistantAvatarSource = null;
state.assistantAvatarStatus = null;
state.assistantAvatarReason = null;
} }
const baseHash = state.configSnapshot?.hash;
if (!baseHash) {
throw new Error("Config hash missing; refresh and retry.");
}
await state.client.request("config.patch", {
baseHash,
raw: JSON.stringify({ ui: { assistant: { avatar } } }),
sessionKey: state.applySessionKey,
note: avatar
? "Assistant avatar override updated from Control UI."
: "Assistant avatar override cleared from Control UI.",
});
} }

View File

@@ -6,6 +6,7 @@ import {
import { normalizeAssistantIdentity } from "../assistant-identity.ts"; import { normalizeAssistantIdentity } from "../assistant-identity.ts";
import { resolveControlUiAuthCandidates } from "../control-ui-auth.ts"; import { resolveControlUiAuthCandidates } from "../control-ui-auth.ts";
import { normalizeBasePath } from "../navigation.ts"; import { normalizeBasePath } from "../navigation.ts";
import { loadLocalAssistantIdentity } from "../storage.ts";
export type ControlUiBootstrapState = { export type ControlUiBootstrapState = {
basePath: string; basePath: string;
@@ -79,6 +80,14 @@ export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapStat
state.assistantAvatarStatus = normalized.avatarStatus ?? null; state.assistantAvatarStatus = normalized.avatarStatus ?? null;
state.assistantAvatarReason = normalized.avatarReason ?? null; state.assistantAvatarReason = normalized.avatarReason ?? null;
state.assistantAgentId = normalized.agentId ?? null; state.assistantAgentId = normalized.agentId ?? null;
// Local override always wins — same pattern as the user avatar.
const localAvatar = loadLocalAssistantIdentity().avatar;
if (localAvatar) {
state.assistantAvatar = localAvatar;
state.assistantAvatarSource = localAvatar;
state.assistantAvatarStatus = "data";
state.assistantAvatarReason = null;
}
state.serverVersion = parsed.serverVersion ?? null; state.serverVersion = parsed.serverVersion ?? null;
state.localMediaPreviewRoots = Array.isArray(parsed.localMediaPreviewRoots) state.localMediaPreviewRoots = Array.isArray(parsed.localMediaPreviewRoots)
? parsed.localMediaPreviewRoots.filter((value): value is string => typeof value === "string") ? parsed.localMediaPreviewRoots.filter((value): value is string => typeof value === "string")

View File

@@ -1,6 +1,7 @@
const SETTINGS_KEY_PREFIX = "openclaw.control.settings.v1:"; const SETTINGS_KEY_PREFIX = "openclaw.control.settings.v1:";
const LEGACY_SETTINGS_KEY = "openclaw.control.settings.v1"; const LEGACY_SETTINGS_KEY = "openclaw.control.settings.v1";
const LOCAL_USER_IDENTITY_KEY = "openclaw.control.user.v1"; const LOCAL_USER_IDENTITY_KEY = "openclaw.control.user.v1";
const LOCAL_ASSISTANT_IDENTITY_KEY = "openclaw.control.assistant.v1";
const LEGACY_TOKEN_SESSION_KEY = "openclaw.control.token.v1"; const LEGACY_TOKEN_SESSION_KEY = "openclaw.control.token.v1";
const TOKEN_SESSION_KEY_PREFIX = "openclaw.control.token.v1:"; const TOKEN_SESSION_KEY_PREFIX = "openclaw.control.token.v1:";
const MAX_SCOPED_SESSION_ENTRIES = 10; const MAX_SCOPED_SESSION_ENTRIES = 10;
@@ -310,6 +311,36 @@ export function saveLocalUserIdentity(next: LocalUserIdentity) {
} }
} }
export type LocalAssistantIdentity = { avatar: string | null };
export function loadLocalAssistantIdentity(): LocalAssistantIdentity {
const storage = getSafeLocalStorage();
try {
const raw = storage?.getItem(LOCAL_ASSISTANT_IDENTITY_KEY);
if (!raw) {
return { avatar: null };
}
const parsed = JSON.parse(raw) as Partial<LocalAssistantIdentity>;
return { avatar: typeof parsed.avatar === "string" ? parsed.avatar : null };
} catch {
return { avatar: null };
}
}
export function saveLocalAssistantIdentity(next: LocalAssistantIdentity) {
const storage = getSafeLocalStorage();
try {
if (!next.avatar) {
storage?.removeItem(LOCAL_ASSISTANT_IDENTITY_KEY);
return;
}
storage?.setItem(LOCAL_ASSISTANT_IDENTITY_KEY, JSON.stringify({ avatar: next.avatar }));
} catch {
// best-effort — quota exceeded or security restrictions should not
// prevent in-memory identity updates from being applied
}
}
function persistSettings(next: UiSettings) { function persistSettings(next: UiSettings) {
persistSessionToken(next.gatewayUrl, next.token); persistSessionToken(next.gatewayUrl, next.token);
const storage = getSafeLocalStorage(); const storage = getSafeLocalStorage();

View File

@@ -67,7 +67,7 @@ describe("renderQuickSettings", () => {
render(renderQuickSettings(createProps()), container); render(renderQuickSettings(createProps()), container);
expect(container.querySelectorAll(".qs-stack")).toHaveLength(3); expect(container.querySelectorAll(".qs-stack")).toHaveLength(2);
expect(container.querySelector(".qs-card--personal")).not.toBeNull(); expect(container.querySelector(".qs-card--personal")).not.toBeNull();
expect(container.querySelectorAll(".qs-card--span-all")).toHaveLength(1); expect(container.querySelectorAll(".qs-card--span-all")).toHaveLength(1);
}); });

View File

@@ -639,6 +639,44 @@ function renderPersonalCard(props: QuickSettingsProps) {
<div class="qs-identity-card__eyebrow">User</div> <div class="qs-identity-card__eyebrow">User</div>
<div class="qs-identity-card__title">${LOCAL_USER_LABEL}</div> <div class="qs-identity-card__title">${LOCAL_USER_LABEL}</div>
<div class="qs-identity-card__sub">Avatar is browser-local</div> <div class="qs-identity-card__sub">Avatar is browser-local</div>
<div class="qs-identity-card__repair">
<label class="qs-field">
<span class="qs-row__label">Avatar text / emoji</span>
<input
class="qs-field__input"
type="text"
maxlength="16"
.value=${avatarText}
placeholder="JD or 🦞"
@input=${(e: Event) => {
const value = (e.target as HTMLInputElement).value;
props.onUserAvatarChange?.(value.trim() ? value : null);
}}
/>
</label>
<div class="qs-identity-card__actions">
<label class="btn btn--sm">
Choose image
<input
type="file"
accept="image/*"
hidden
@change=${(e: Event) => handleLocalUserAvatarFileSelect(e, props)}
/>
</label>
<button
type="button"
class="btn btn--sm btn--ghost"
?disabled=${!identity.avatar}
@click=${() => {
props.onUserAvatarChange?.(null);
}}
>
Clear avatar
</button>
</div>
<div class="muted">Stored in this browser only.</div>
</div>
</div> </div>
</section> </section>
<section <section
@@ -667,34 +705,36 @@ function renderPersonalCard(props: QuickSettingsProps) {
${canOverrideAssistantAvatar ${canOverrideAssistantAvatar
? html` ? html`
<div class="qs-identity-card__repair"> <div class="qs-identity-card__repair">
<label class="btn btn--sm"> <div class="qs-identity-card__actions">
${props.assistantAvatarUploadBusy <label class="btn btn--sm">
? "Saving..." ${props.assistantAvatarUploadBusy
: assistantAvatarOverride ? "Saving..."
? "Replace image" : assistantAvatarOverride
: "Choose image"} ? "Replace image"
<input : "Choose image"}
type="file" <input
accept="image/*" type="file"
hidden accept="image/*"
?disabled=${props.assistantAvatarUploadBusy === true} hidden
@change=${(e: Event) => handleAssistantAvatarFileSelect(e, props)} ?disabled=${props.assistantAvatarUploadBusy === true}
/> @change=${(e: Event) => handleAssistantAvatarFileSelect(e, props)}
</label> />
${assistantAvatarOverride </label>
? html` ${assistantAvatarOverride
<button ? html`
type="button" <button
class="btn btn--sm btn--ghost" type="button"
?disabled=${props.assistantAvatarUploadBusy === true} class="btn btn--sm btn--ghost"
@click=${() => { ?disabled=${props.assistantAvatarUploadBusy === true}
void props.onAssistantAvatarClearOverride?.(); @click=${() => {
}} void props.onAssistantAvatarClearOverride?.();
> }}
Clear override >
</button> Clear override
` </button>
: nothing} `
: nothing}
</div>
<div class="muted"> <div class="muted">
Stores a Control UI override. Clear it to return to IDENTITY.md. Stores a Control UI override. Clear it to return to IDENTITY.md.
</div> </div>
@@ -709,43 +749,6 @@ function renderPersonalCard(props: QuickSettingsProps) {
</div> </div>
</section> </section>
</div> </div>
<div class="qs-row">
<label class="qs-field">
<span class="qs-row__label">Avatar text / emoji</span>
<input
class="qs-field__input"
type="text"
maxlength="16"
.value=${avatarText}
placeholder="JD or 🦞"
@input=${(e: Event) => {
const value = (e.target as HTMLInputElement).value;
props.onUserAvatarChange?.(value.trim() ? value : null);
}}
/>
</label>
</div>
<div class="qs-personal-actions">
<label class="btn btn--sm">
Choose image
<input
type="file"
accept="image/*"
hidden
@change=${(e: Event) => handleLocalUserAvatarFileSelect(e, props)}
/>
</label>
<button
type="button"
class="btn btn--sm btn--ghost"
?disabled=${!identity.avatar}
@click=${() => {
props.onUserAvatarChange?.(null);
}}
>
Clear avatar
</button>
</div>
</div> </div>
</div> </div>
`; `;
@@ -977,10 +980,6 @@ function renderStack(...cards: TemplateResult[]) {
return html`<div class="qs-stack">${cards}</div>`; return html`<div class="qs-stack">${cards}</div>`;
} }
function renderWideStack(...cards: TemplateResult[]) {
return html`<div class="qs-stack qs-stack--wide">${cards}</div>`;
}
// ── Main render ── // ── Main render ──
export function renderQuickSettings(props: QuickSettingsProps) { export function renderQuickSettings(props: QuickSettingsProps) {
@@ -995,9 +994,9 @@ export function renderQuickSettings(props: QuickSettingsProps) {
<div class="qs-grid"> <div class="qs-grid">
${renderStack(renderModelCard(props), renderSecurityCard(props))} ${renderStack(renderModelCard(props), renderSecurityCard(props))}
${renderPersonalCard(props)} ${renderChannelsCard(props)}
${renderStack(renderChannelsCard(props), renderAutomationsCard(props))} ${renderStack(renderAppearanceCard(props), renderAutomationsCard(props))}
${renderWideStack(renderAppearanceCard(props))} ${renderPresetsCard(props)} ${renderPersonalCard(props)} ${renderPresetsCard(props)}
</div> </div>
${renderConnectionFooter(props)} ${renderConnectionFooter(props)}