diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css index 50e511937d5..ca4e4230e13 100644 --- a/ui/src/styles/chat/layout.css +++ b/ui/src/styles/chat/layout.css @@ -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; diff --git a/ui/src/styles/chat/layout.test.ts b/ui/src/styles/chat/layout.test.ts index bfbeb7b9c93..9c0a001651c 100644 --- a/ui/src/styles/chat/layout.test.ts +++ b/ui/src/styles/chat/layout.test.ts @@ -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;"); + }); }); diff --git a/ui/src/ui/chat/grouped-render.test.ts b/ui/src/ui/chat/grouped-render.test.ts index 0c4ea05696e..62be0cd51f0 100644 --- a/ui/src/ui/chat/grouped-render.test.ts +++ b/ui/src/ui/chat/grouped-render.test.ts @@ -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(".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(".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"); diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index 6a04e3f5ec2..cc89d7f61e5 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -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`
+ ${assistantAvatarText} +
`; + } return html` 8 || + /\s/.test(trimmed) || + /[\\/.:]/.test(trimmed) || + UNSAFE_ASSISTANT_TEXT_AVATAR_CHARS.test(trimmed) + ) { + return null; + } + return trimmed; } function resolveRenderableMessageImages( diff --git a/ui/src/ui/views/chat.test.ts b/ui/src/ui/views/chat.test.ts index 938246be3ae..a1acbaf183d 100644 --- a/ui/src/ui/views/chat.test.ts +++ b/ui/src/ui/views/chat.test.ts @@ -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(".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"); + }); +}); diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index ae8621787f3..3be87867d90 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -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``} + : avatarText + ? html`
+ ${avatarText} +
` + : html``}

${name}

Ready to chat @@ -520,6 +521,23 @@ function renderWelcomeState(props: ChatProps): TemplateResult { `; } +function resolveAssistantAvatarUrl( + props: Pick, +): string | null { + return resolveChatAvatarRenderUrl(props.assistantAvatarUrl, { + identity: { + avatar: props.assistantAvatar ?? undefined, + avatarUrl: props.assistantAvatarUrl ?? undefined, + }, + }); +} + +function resolveAssistantDisplayAvatar( + props: Pick, +): 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);