mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:20:43 +00:00
fix(ui): render assistant identity avatars in chat
Render assistant text avatars from IDENTITY.md consistently in the Control UI chat welcome state and transcript groups. Also supports authenticated blob avatar URLs in grouped messages and rejects bidi/invisible controls in assistant text avatars. Verification: - pnpm test ui/src/ui/chat/grouped-render.test.ts ui/src/ui/views/chat.test.ts ui/src/styles/chat/layout.test.ts - pnpm check:changed - GitHub CI green - Review threads resolved
This commit is contained in:
@@ -1115,6 +1115,19 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agent-chat__avatar--text {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--secondary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--foreground);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.agent-chat__avatar--logo img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
@@ -2,18 +2,30 @@ import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("chat steer styles", () => {
|
||||
function readLayoutCss(): string {
|
||||
const cssPath = [
|
||||
resolve(process.cwd(), "src/styles/chat/layout.css"),
|
||||
resolve(process.cwd(), "ui/src/styles/chat/layout.css"),
|
||||
].find((candidate) => existsSync(candidate));
|
||||
expect(cssPath).toBeTruthy();
|
||||
return readFileSync(cssPath!, "utf8");
|
||||
}
|
||||
|
||||
describe("chat layout styles", () => {
|
||||
it("styles queued-message steering controls and pending indicators", () => {
|
||||
const cssPath = [
|
||||
resolve(process.cwd(), "src/styles/chat/layout.css"),
|
||||
resolve(process.cwd(), "ui/src/styles/chat/layout.css"),
|
||||
].find((candidate) => existsSync(candidate));
|
||||
expect(cssPath).toBeTruthy();
|
||||
const css = readFileSync(cssPath!, "utf8");
|
||||
const css = readLayoutCss();
|
||||
|
||||
expect(css).toContain(".chat-queue__steer");
|
||||
expect(css).toContain(".chat-queue__actions");
|
||||
expect(css).toContain(".chat-queue__item--steered");
|
||||
expect(css).toContain(".chat-queue__badge");
|
||||
});
|
||||
|
||||
it("includes assistant text avatar styles for configured IDENTITY avatars", () => {
|
||||
const css = readLayoutCss();
|
||||
|
||||
expect(css).toContain(".agent-chat__avatar--text");
|
||||
expect(css).toContain("font-size: 20px;");
|
||||
expect(css).toContain("place-items: center;");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getSafeLocalStorage } from "../../local-storage.ts";
|
||||
import type { MessageGroup } from "../types/chat-types.ts";
|
||||
import {
|
||||
renderMessageGroup,
|
||||
resolveAssistantTextAvatar,
|
||||
resetAssistantAttachmentAvailabilityCacheForTest,
|
||||
} from "./grouped-render.ts";
|
||||
import { normalizeMessage } from "./message-normalizer.ts";
|
||||
@@ -260,6 +261,52 @@ describe("grouped chat rendering", () => {
|
||||
expect(avatar?.getAttribute("src")).toBe("/openclaw-logo.svg");
|
||||
});
|
||||
|
||||
it("renders a blob: assistant avatar as an image", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
role: "assistant",
|
||||
content: "hello",
|
||||
timestamp: 1000,
|
||||
},
|
||||
{ assistantAvatar: "blob:managed-image", assistantName: "Val" },
|
||||
);
|
||||
|
||||
const avatar = container.querySelector<HTMLImageElement>(".chat-avatar.assistant");
|
||||
expect(avatar).not.toBeNull();
|
||||
expect(avatar?.tagName).toBe("IMG");
|
||||
expect(avatar?.getAttribute("src")).toBe("blob:managed-image");
|
||||
});
|
||||
|
||||
it("renders a configured assistant text avatar", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
renderAssistantMessage(
|
||||
container,
|
||||
{
|
||||
role: "assistant",
|
||||
content: "hello",
|
||||
timestamp: 1000,
|
||||
},
|
||||
{ assistantAvatar: "VC", assistantName: "Val" },
|
||||
);
|
||||
|
||||
const avatar = container.querySelector<HTMLElement>(".chat-avatar.assistant");
|
||||
expect(avatar).not.toBeNull();
|
||||
expect(avatar?.tagName).toBe("DIV");
|
||||
expect(avatar?.textContent).toContain("VC");
|
||||
expect(avatar?.getAttribute("aria-label")).toBe("Val");
|
||||
});
|
||||
|
||||
it("rejects unsafe invisible controls in assistant text avatars", () => {
|
||||
expect(resolveAssistantTextAvatar("VC")).toBe("VC");
|
||||
expect(resolveAssistantTextAvatar("\u{1F43E}")).toBe("\u{1F43E}");
|
||||
expect(resolveAssistantTextAvatar("V\u202eC")).toBeNull();
|
||||
expect(resolveAssistantTextAvatar("V\u200bC")).toBeNull();
|
||||
});
|
||||
|
||||
it("includes cache tokens when rendering assistant context usage", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { html, nothing } from "lit";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import { until } from "lit/directives/until.js";
|
||||
import { getSafeLocalStorage } from "../../local-storage.ts";
|
||||
import type { AssistantIdentity } from "../assistant-identity.ts";
|
||||
import { DEFAULT_ASSISTANT_AVATAR, type AssistantIdentity } from "../assistant-identity.ts";
|
||||
import type { EmbedSandboxMode } from "../embed-sandbox.ts";
|
||||
import { icons } from "../icons.ts";
|
||||
import { toSanitizedMarkdownHtml } from "../markdown.ts";
|
||||
@@ -638,6 +638,7 @@ function renderAvatar(
|
||||
const normalized = normalizeRoleForGrouping(role);
|
||||
const assistantName = assistant?.name?.trim() || "Assistant";
|
||||
const assistantAvatar = assistant?.avatar?.trim() || "";
|
||||
const assistantAvatarText = resolveAssistantTextAvatar(assistantAvatar);
|
||||
const userName = resolveLocalUserName(user);
|
||||
const userAvatarUrl = resolveLocalUserAvatarUrl(user);
|
||||
const userAvatarText = resolveLocalUserAvatarText(user);
|
||||
@@ -712,6 +713,11 @@ function renderAvatar(
|
||||
alt="${assistantName}"
|
||||
/>`;
|
||||
}
|
||||
if (assistantAvatarText) {
|
||||
return html`<div class="chat-avatar ${className}" aria-label="${assistantName}">
|
||||
${assistantAvatarText}
|
||||
</div>`;
|
||||
}
|
||||
return html`<img
|
||||
class="chat-avatar ${className} chat-avatar--logo"
|
||||
src="${agentLogoUrl(basePath ?? "")}"
|
||||
@@ -733,7 +739,29 @@ function renderAvatar(
|
||||
}
|
||||
|
||||
function isAvatarUrl(value: string): boolean {
|
||||
return isRenderableControlUiAvatarUrl(value);
|
||||
const trimmed = value.trim();
|
||||
return trimmed.startsWith("blob:") || isRenderableControlUiAvatarUrl(trimmed);
|
||||
}
|
||||
|
||||
const UNSAFE_ASSISTANT_TEXT_AVATAR_CHARS = /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]/u;
|
||||
|
||||
export function resolveAssistantTextAvatar(value: string | null | undefined): string | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed || trimmed === DEFAULT_ASSISTANT_AVATAR) {
|
||||
return null;
|
||||
}
|
||||
if (isAvatarUrl(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
trimmed.length > 8 ||
|
||||
/\s/.test(trimmed) ||
|
||||
/[\\/.:]/.test(trimmed) ||
|
||||
UNSAFE_ASSISTANT_TEXT_AVATAR_CHARS.test(trimmed)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function resolveRenderableMessageImages(
|
||||
|
||||
@@ -105,3 +105,33 @@ describe("chat view queue steering", () => {
|
||||
expect(container.querySelector(".chat-queue__steer")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderChat", () => {
|
||||
afterEach(() => {
|
||||
cleanupChatModuleState();
|
||||
});
|
||||
|
||||
it("renders configured assistant text avatars in transcript groups", () => {
|
||||
const container = document.createElement("div");
|
||||
|
||||
render(
|
||||
renderChat(
|
||||
createProps({
|
||||
assistantName: "Val",
|
||||
assistantAvatar: "VC",
|
||||
assistantAvatarUrl: null,
|
||||
messages: [{ role: "assistant", content: "hello", timestamp: 1000 }],
|
||||
stream: null,
|
||||
streamStartedAt: null,
|
||||
}),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const avatar = container.querySelector<HTMLElement>(".chat-group.assistant .chat-avatar");
|
||||
expect(avatar).not.toBeNull();
|
||||
expect(avatar?.tagName).toBe("DIV");
|
||||
expect(avatar?.textContent).toContain("VC");
|
||||
expect(avatar?.getAttribute("aria-label")).toBe("Val");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
renderMessageGroup,
|
||||
renderReadingIndicatorGroup,
|
||||
renderStreamingGroup,
|
||||
resolveAssistantTextAvatar,
|
||||
} from "../chat/grouped-render.ts";
|
||||
import { InputHistory } from "../chat/input-history.ts";
|
||||
import { PinnedMessages } from "../chat/pinned-messages.ts";
|
||||
@@ -475,12 +476,8 @@ const WELCOME_SUGGESTIONS = [
|
||||
|
||||
function renderWelcomeState(props: ChatProps): TemplateResult {
|
||||
const name = props.assistantName || "Assistant";
|
||||
const avatar = resolveChatAvatarRenderUrl(props.assistantAvatarUrl, {
|
||||
identity: {
|
||||
avatar: props.assistantAvatar ?? undefined,
|
||||
avatarUrl: props.assistantAvatarUrl ?? undefined,
|
||||
},
|
||||
});
|
||||
const avatar = resolveAssistantAvatarUrl(props);
|
||||
const avatarText = avatar ? null : resolveAssistantTextAvatar(props.assistantAvatar);
|
||||
const logoUrl = agentLogoUrl(props.basePath ?? "");
|
||||
|
||||
return html`
|
||||
@@ -492,9 +489,13 @@ function renderWelcomeState(props: ChatProps): TemplateResult {
|
||||
alt=${name}
|
||||
style="width:56px; height:56px; border-radius:50%; object-fit:cover;"
|
||||
/>`
|
||||
: html`<div class="agent-chat__avatar agent-chat__avatar--logo">
|
||||
<img src=${logoUrl} alt="OpenClaw" />
|
||||
</div>`}
|
||||
: avatarText
|
||||
? html`<div class="agent-chat__avatar agent-chat__avatar--text" aria-label=${name}>
|
||||
${avatarText}
|
||||
</div>`
|
||||
: html`<div class="agent-chat__avatar agent-chat__avatar--logo">
|
||||
<img src=${logoUrl} alt="OpenClaw" />
|
||||
</div>`}
|
||||
<h2>${name}</h2>
|
||||
<div class="agent-chat__badges">
|
||||
<span class="agent-chat__badge"><img src=${logoUrl} alt="" /> Ready to chat</span>
|
||||
@@ -520,6 +521,23 @@ function renderWelcomeState(props: ChatProps): TemplateResult {
|
||||
`;
|
||||
}
|
||||
|
||||
function resolveAssistantAvatarUrl(
|
||||
props: Pick<ChatProps, "assistantAvatar" | "assistantAvatarUrl">,
|
||||
): string | null {
|
||||
return resolveChatAvatarRenderUrl(props.assistantAvatarUrl, {
|
||||
identity: {
|
||||
avatar: props.assistantAvatar ?? undefined,
|
||||
avatarUrl: props.assistantAvatarUrl ?? undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function resolveAssistantDisplayAvatar(
|
||||
props: Pick<ChatProps, "assistantAvatar" | "assistantAvatarUrl">,
|
||||
): string | null {
|
||||
return resolveAssistantAvatarUrl(props) ?? resolveAssistantTextAvatar(props.assistantAvatar);
|
||||
}
|
||||
|
||||
function renderSearchBar(requestUpdate: () => void): TemplateResult | typeof nothing {
|
||||
if (!vs.searchOpen) {
|
||||
return nothing;
|
||||
@@ -755,13 +773,7 @@ export function renderChat(props: ChatProps) {
|
||||
const showReasoning = props.showThinking && reasoningLevel !== "off";
|
||||
const assistantIdentity = {
|
||||
name: props.assistantName,
|
||||
avatar:
|
||||
resolveChatAvatarRenderUrl(props.assistantAvatarUrl, {
|
||||
identity: {
|
||||
avatar: props.assistantAvatar ?? undefined,
|
||||
avatarUrl: props.assistantAvatarUrl ?? undefined,
|
||||
},
|
||||
}) ?? null,
|
||||
avatar: resolveAssistantDisplayAvatar(props),
|
||||
};
|
||||
const pinned = getPinnedMessages(props.sessionKey);
|
||||
const deleted = getDeletedMessages(props.sessionKey);
|
||||
|
||||
Reference in New Issue
Block a user