mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -146,6 +146,7 @@ changelog/fragments/
|
||||
|
||||
# Local scratch workspace
|
||||
.tmp/
|
||||
.vmux*
|
||||
.artifacts/
|
||||
test/fixtures/openclaw-vitest-unit-report.json
|
||||
analysis/
|
||||
|
||||
@@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### 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.
|
||||
- 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.
|
||||
|
||||
@@ -878,7 +878,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = {
|
||||
},
|
||||
avatar: {
|
||||
type: "string",
|
||||
maxLength: 200,
|
||||
maxLength: 2000000,
|
||||
title: "Assistant Avatar",
|
||||
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.",
|
||||
|
||||
@@ -457,7 +457,7 @@ export const OpenClawSchema = z
|
||||
assistant: z
|
||||
.object({
|
||||
name: z.string().max(50).optional(),
|
||||
avatar: z.string().max(200).optional(),
|
||||
avatar: z.string().max(2_000_000).optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@@ -40,4 +40,17 @@ describe("resolveAssistantIdentity avatar normalization", () => {
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
} from "../shared/avatar-policy.js";
|
||||
|
||||
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;
|
||||
|
||||
export const DEFAULT_ASSISTANT_IDENTITY: AssistantIdentity = {
|
||||
|
||||
@@ -56,10 +56,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.qs-stack--wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
|
||||
.qs-card {
|
||||
@@ -83,7 +79,12 @@
|
||||
}
|
||||
|
||||
.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 {
|
||||
@@ -325,7 +326,7 @@
|
||||
|
||||
.qs-identity-card__repair {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
@@ -333,6 +334,29 @@
|
||||
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 {
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.35;
|
||||
@@ -1054,11 +1078,6 @@
|
||||
.qs-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.qs-card--personal,
|
||||
.qs-stack--wide {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
@@ -1066,9 +1085,8 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.qs-card--personal,
|
||||
.qs-stack--wide {
|
||||
grid-column: 1 / -1;
|
||||
.qs-card--personal .qs-identity-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("config-quick styles", () => {
|
||||
|
||||
it("includes the stacked quick-settings density layout", () => {
|
||||
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(2, minmax(0, 1fr));");
|
||||
expect(css).toContain("@media (max-width: 760px)");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { t } from "../i18n/index.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 { renderUsageTab } from "./app-render-usage-tab.ts";
|
||||
import {
|
||||
@@ -1038,49 +1038,23 @@ export function renderApp(state: AppViewState) {
|
||||
assistantAvatarOverride,
|
||||
assistantAvatarUploadBusy: state.assistantAvatarUploadBusy,
|
||||
assistantAvatarUploadError: state.assistantAvatarUploadError,
|
||||
onAssistantAvatarOverrideChange: async (dataUrl) => {
|
||||
state.assistantAvatarUploadBusy = true;
|
||||
onAssistantAvatarOverrideChange: (dataUrl) => {
|
||||
setAssistantAvatarOverride(state, dataUrl);
|
||||
state.chatAvatarUrl = dataUrl;
|
||||
state.chatAvatarSource = dataUrl;
|
||||
state.chatAvatarStatus = "data";
|
||||
state.chatAvatarReason = null;
|
||||
state.assistantAvatarUploadError = null;
|
||||
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 () => {
|
||||
state.assistantAvatarUploadBusy = true;
|
||||
onAssistantAvatarClearOverride: () => {
|
||||
setAssistantAvatarOverride(state, null);
|
||||
state.chatAvatarUrl = null;
|
||||
state.chatAvatarSource = null;
|
||||
state.chatAvatarStatus = null;
|
||||
state.chatAvatarReason = null;
|
||||
state.assistantAvatarUploadError = null;
|
||||
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 ?? "",
|
||||
configObject: configObj,
|
||||
|
||||
27
ui/src/ui/assistant-identity.test.ts
Normal file
27
ui/src/ui/assistant-identity.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,18 @@
|
||||
import { coerceIdentityValue } from "../../../src/shared/assistant-identity-values.js";
|
||||
|
||||
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_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_AVATAR = "A";
|
||||
@@ -16,11 +26,25 @@ export type AssistantIdentity = {
|
||||
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(
|
||||
input?: Partial<AssistantIdentity> | null,
|
||||
): AssistantIdentity {
|
||||
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 =
|
||||
coerceIdentityValue(input?.avatarSource ?? undefined, MAX_ASSISTANT_AVATAR_SOURCE) ?? null;
|
||||
const avatarStatus =
|
||||
@@ -31,7 +55,7 @@ export function normalizeAssistantIdentity(
|
||||
? input.avatarStatus
|
||||
: null;
|
||||
const avatarReason =
|
||||
coerceIdentityValue(input?.avatarReason ?? undefined, MAX_ASSISTANT_AVATAR) ?? null;
|
||||
coerceIdentityValue(input?.avatarReason ?? undefined, MAX_ASSISTANT_AVATAR_REASON) ?? null;
|
||||
const agentId =
|
||||
typeof input?.agentId === "string" && input.agentId.trim() ? input.agentId.trim() : null;
|
||||
return { agentId, name, avatar, avatarSource, avatarStatus, avatarReason };
|
||||
|
||||
@@ -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";
|
||||
|
||||
describe("setAssistantAvatarOverride", () => {
|
||||
it("writes the assistant avatar override through config.patch", async () => {
|
||||
const request = vi.fn().mockResolvedValue({});
|
||||
|
||||
await setAssistantAvatarOverride(
|
||||
{
|
||||
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.",
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("localStorage", createStorageMock());
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("clears the assistant avatar override through config.patch", async () => {
|
||||
const request = vi.fn().mockResolvedValue({});
|
||||
it("persists the assistant avatar locally and mirrors the user avatar pattern", () => {
|
||||
const state: Parameters<typeof setAssistantAvatarOverride>[0] = {};
|
||||
|
||||
await setAssistantAvatarOverride(
|
||||
{
|
||||
client: { request } as never,
|
||||
connected: true,
|
||||
configSnapshot: { hash: "config-hash" },
|
||||
},
|
||||
null,
|
||||
);
|
||||
setAssistantAvatarOverride(state, "data:image/png;base64,YXZhdGFy");
|
||||
|
||||
expect(request).toHaveBeenCalledWith("config.patch", {
|
||||
baseHash: "config-hash",
|
||||
raw: JSON.stringify({ ui: { assistant: { avatar: null } } }),
|
||||
sessionKey: undefined,
|
||||
note: "Assistant avatar override cleared from Control UI.",
|
||||
});
|
||||
expect(state.assistantAvatar).toBe("data:image/png;base64,YXZhdGFy");
|
||||
expect(state.assistantAvatarSource).toBe("data:image/png;base64,YXZhdGFy");
|
||||
expect(state.assistantAvatarStatus).toBe("data");
|
||||
expect(state.assistantAvatarReason).toBeNull();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { normalizeAssistantIdentity } from "../assistant-identity.ts";
|
||||
import type { GatewayBrowserClient } from "../gateway.ts";
|
||||
import { loadLocalAssistantIdentity, saveLocalAssistantIdentity } from "../storage.ts";
|
||||
|
||||
export type AssistantIdentityState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
@@ -14,10 +15,10 @@ export type AssistantIdentityState = {
|
||||
};
|
||||
|
||||
export type AssistantAvatarOverrideState = {
|
||||
client: GatewayBrowserClient | null;
|
||||
connected: boolean;
|
||||
applySessionKey?: string;
|
||||
configSnapshot?: { hash?: string | null } | null;
|
||||
assistantAvatar?: string | null;
|
||||
assistantAvatarSource?: string | null;
|
||||
assistantAvatarStatus?: "none" | "local" | "remote" | "data" | null;
|
||||
assistantAvatarReason?: string | null;
|
||||
};
|
||||
|
||||
export async function loadAssistantIdentity(
|
||||
@@ -41,28 +42,32 @@ export async function loadAssistantIdentity(
|
||||
state.assistantAvatarStatus = normalized.avatarStatus ?? null;
|
||||
state.assistantAvatarReason = normalized.avatarReason ?? 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 {
|
||||
// Ignore errors; keep last known identity.
|
||||
}
|
||||
}
|
||||
|
||||
export async function setAssistantAvatarOverride(
|
||||
export function setAssistantAvatarOverride(
|
||||
state: AssistantAvatarOverrideState,
|
||||
avatar: string | null,
|
||||
) {
|
||||
if (!state.client || !state.connected) {
|
||||
throw new Error("Gateway is not connected.");
|
||||
saveLocalAssistantIdentity({ avatar });
|
||||
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.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { normalizeAssistantIdentity } from "../assistant-identity.ts";
|
||||
import { resolveControlUiAuthCandidates } from "../control-ui-auth.ts";
|
||||
import { normalizeBasePath } from "../navigation.ts";
|
||||
import { loadLocalAssistantIdentity } from "../storage.ts";
|
||||
|
||||
export type ControlUiBootstrapState = {
|
||||
basePath: string;
|
||||
@@ -79,6 +80,14 @@ export async function loadControlUiBootstrapConfig(state: ControlUiBootstrapStat
|
||||
state.assistantAvatarStatus = normalized.avatarStatus ?? null;
|
||||
state.assistantAvatarReason = normalized.avatarReason ?? 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.localMediaPreviewRoots = Array.isArray(parsed.localMediaPreviewRoots)
|
||||
? parsed.localMediaPreviewRoots.filter((value): value is string => typeof value === "string")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const SETTINGS_KEY_PREFIX = "openclaw.control.settings.v1:";
|
||||
const LEGACY_SETTINGS_KEY = "openclaw.control.settings.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 TOKEN_SESSION_KEY_PREFIX = "openclaw.control.token.v1:";
|
||||
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) {
|
||||
persistSessionToken(next.gatewayUrl, next.token);
|
||||
const storage = getSafeLocalStorage();
|
||||
|
||||
@@ -67,7 +67,7 @@ describe("renderQuickSettings", () => {
|
||||
|
||||
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.querySelectorAll(".qs-card--span-all")).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -639,6 +639,44 @@ function renderPersonalCard(props: QuickSettingsProps) {
|
||||
<div class="qs-identity-card__eyebrow">User</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__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>
|
||||
</section>
|
||||
<section
|
||||
@@ -667,34 +705,36 @@ function renderPersonalCard(props: QuickSettingsProps) {
|
||||
${canOverrideAssistantAvatar
|
||||
? html`
|
||||
<div class="qs-identity-card__repair">
|
||||
<label class="btn btn--sm">
|
||||
${props.assistantAvatarUploadBusy
|
||||
? "Saving..."
|
||||
: assistantAvatarOverride
|
||||
? "Replace image"
|
||||
: "Choose image"}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
?disabled=${props.assistantAvatarUploadBusy === true}
|
||||
@change=${(e: Event) => handleAssistantAvatarFileSelect(e, props)}
|
||||
/>
|
||||
</label>
|
||||
${assistantAvatarOverride
|
||||
? html`
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm btn--ghost"
|
||||
?disabled=${props.assistantAvatarUploadBusy === true}
|
||||
@click=${() => {
|
||||
void props.onAssistantAvatarClearOverride?.();
|
||||
}}
|
||||
>
|
||||
Clear override
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
<div class="qs-identity-card__actions">
|
||||
<label class="btn btn--sm">
|
||||
${props.assistantAvatarUploadBusy
|
||||
? "Saving..."
|
||||
: assistantAvatarOverride
|
||||
? "Replace image"
|
||||
: "Choose image"}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
?disabled=${props.assistantAvatarUploadBusy === true}
|
||||
@change=${(e: Event) => handleAssistantAvatarFileSelect(e, props)}
|
||||
/>
|
||||
</label>
|
||||
${assistantAvatarOverride
|
||||
? html`
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn--sm btn--ghost"
|
||||
?disabled=${props.assistantAvatarUploadBusy === true}
|
||||
@click=${() => {
|
||||
void props.onAssistantAvatarClearOverride?.();
|
||||
}}
|
||||
>
|
||||
Clear override
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
<div class="muted">
|
||||
Stores a Control UI override. Clear it to return to IDENTITY.md.
|
||||
</div>
|
||||
@@ -709,43 +749,6 @@ function renderPersonalCard(props: QuickSettingsProps) {
|
||||
</div>
|
||||
</section>
|
||||
</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>
|
||||
`;
|
||||
@@ -977,10 +980,6 @@ function renderStack(...cards: TemplateResult[]) {
|
||||
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 ──
|
||||
|
||||
export function renderQuickSettings(props: QuickSettingsProps) {
|
||||
@@ -995,9 +994,9 @@ export function renderQuickSettings(props: QuickSettingsProps) {
|
||||
|
||||
<div class="qs-grid">
|
||||
${renderStack(renderModelCard(props), renderSecurityCard(props))}
|
||||
${renderPersonalCard(props)}
|
||||
${renderStack(renderChannelsCard(props), renderAutomationsCard(props))}
|
||||
${renderWideStack(renderAppearanceCard(props))} ${renderPresetsCard(props)}
|
||||
${renderChannelsCard(props)}
|
||||
${renderStack(renderAppearanceCard(props), renderAutomationsCard(props))}
|
||||
${renderPersonalCard(props)} ${renderPresetsCard(props)}
|
||||
</div>
|
||||
|
||||
${renderConnectionFooter(props)}
|
||||
|
||||
Reference in New Issue
Block a user