mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +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
|
# Local scratch workspace
|
||||||
.tmp/
|
.tmp/
|
||||||
|
.vmux*
|
||||||
.artifacts/
|
.artifacts/
|
||||||
test/fixtures/openclaw-vitest-unit-report.json
|
test/fixtures/openclaw-vitest-unit-report.json
|
||||||
analysis/
|
analysis/
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)");
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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";
|
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 };
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
Reference in New Issue
Block a user