diff --git a/CHANGELOG.md b/CHANGELOG.md index fa1f44d3d..40306679b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Docs: https://docs.clawd.bot ### Fixes - BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell. - Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla. -- Hooks: suppress session-memory confirmation output. (#1464) Thanks @alfranli123. +- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer. ## 2026.1.21-2 diff --git a/src/gateway/control-ui.test.ts b/src/gateway/control-ui.test.ts new file mode 100644 index 000000000..5149830f3 --- /dev/null +++ b/src/gateway/control-ui.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest"; + +import { resolveAssistantAvatarUrl } from "./control-ui.js"; + +describe("resolveAssistantAvatarUrl", () => { + it("keeps remote and data URLs", () => { + expect( + resolveAssistantAvatarUrl({ + avatar: "https://example.com/avatar.png", + agentId: "main", + basePath: "/ui", + }), + ).toBe("https://example.com/avatar.png"); + expect( + resolveAssistantAvatarUrl({ + avatar: "data:image/png;base64,abc", + agentId: "main", + basePath: "/ui", + }), + ).toBe("data:image/png;base64,abc"); + }); + + it("prefixes basePath for /avatar endpoints", () => { + expect( + resolveAssistantAvatarUrl({ + avatar: "/avatar/main", + agentId: "main", + basePath: "/ui", + }), + ).toBe("/ui/avatar/main"); + expect( + resolveAssistantAvatarUrl({ + avatar: "/ui/avatar/main", + agentId: "main", + basePath: "/ui", + }), + ).toBe("/ui/avatar/main"); + }); + + it("maps local avatar paths to the avatar endpoint", () => { + expect( + resolveAssistantAvatarUrl({ + avatar: "avatars/me.png", + agentId: "main", + basePath: "/ui", + }), + ).toBe("/ui/avatar/main"); + expect( + resolveAssistantAvatarUrl({ + avatar: "avatars/profile", + agentId: "main", + basePath: "/ui", + }), + ).toBe("/ui/avatar/main"); + }); + + it("keeps short text avatars", () => { + expect( + resolveAssistantAvatarUrl({ + avatar: "PS", + agentId: "main", + basePath: "/ui", + }), + ).toBe("PS"); + }); +}); diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index 13ee309de..f51a49111 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -98,7 +98,7 @@ function sendJson(res: ServerResponse, status: number, body: unknown) { res.end(JSON.stringify(body)); } -function buildAvatarUrl(basePath: string, agentId: string): string { +export function buildAvatarUrl(basePath: string, agentId: string): string { return basePath ? `${basePath}${AVATAR_PREFIX}/${agentId}` : `${AVATAR_PREFIX}/${agentId}`; } @@ -206,22 +206,49 @@ interface ServeIndexHtmlOpts { agentId?: string; } -function looksLikeLocalAvatarPath(value: string | undefined): boolean { - if (!value) return false; - if (/^https?:\/\//i.test(value) || /^data:image\//i.test(value)) return false; +function looksLikeLocalAvatarPath(value: string): boolean { + if (/[\\/]/.test(value)) return true; return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value); } +export function resolveAssistantAvatarUrl(params: { + avatar?: string | null; + agentId?: string | null; + basePath?: string; +}): string | undefined { + const avatar = params.avatar?.trim(); + if (!avatar) return undefined; + if (/^https?:\/\//i.test(avatar) || /^data:image\//i.test(avatar)) return avatar; + + const basePath = normalizeControlUiBasePath(params.basePath); + const baseAvatarPrefix = basePath ? `${basePath}${AVATAR_PREFIX}/` : `${AVATAR_PREFIX}/`; + if (basePath && avatar.startsWith(`${AVATAR_PREFIX}/`)) { + return `${basePath}${avatar}`; + } + if (avatar.startsWith(baseAvatarPrefix)) return avatar; + + if (!params.agentId) return avatar; + if (looksLikeLocalAvatarPath(avatar)) { + return buildAvatarUrl(basePath, params.agentId); + } + return avatar; +} + function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) { const { basePath, config, agentId } = opts; const identity = config ? resolveAssistantIdentity({ cfg: config, agentId }) : DEFAULT_ASSISTANT_IDENTITY; - // Resolve local file avatars to /avatar/{agentId} URL - let avatarValue = identity.avatar; - if (looksLikeLocalAvatarPath(avatarValue) && identity.agentId) { - avatarValue = buildAvatarUrl(basePath, identity.agentId); - } + const resolvedAgentId = + typeof (identity as { agentId?: string }).agentId === "string" + ? (identity as { agentId?: string }).agentId + : agentId; + const avatarValue = + resolveAssistantAvatarUrl({ + avatar: identity.avatar, + agentId: resolvedAgentId, + basePath, + }) ?? identity.avatar; res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Cache-Control", "no-cache"); const raw = fs.readFileSync(indexPath, "utf8"); diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts index e99d0ecdf..636272845 100644 --- a/src/gateway/server-methods/agent.ts +++ b/src/gateway/server-methods/agent.ts @@ -38,6 +38,7 @@ import { import { loadSessionEntry } from "../session-utils.js"; import { formatForLog } from "../ws-log.js"; import { resolveAssistantIdentity } from "../assistant-identity.js"; +import { resolveAssistantAvatarUrl } from "../control-ui.js"; import { waitForAgentJob } from "./agent-job.js"; import type { GatewayRequestHandlers } from "./types.js"; @@ -407,17 +408,12 @@ export const agentHandlers: GatewayRequestHandlers = { } const cfg = loadConfig(); const identity = resolveAssistantIdentity({ cfg, agentId }); - // Resolve local file avatars to /avatar/{agentId} URL - let avatarValue = identity.avatar; - if ( - avatarValue && - !/^https?:\/\//i.test(avatarValue) && - !/^data:image\//i.test(avatarValue) && - /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(avatarValue) && - identity.agentId - ) { - avatarValue = `/avatar/${identity.agentId}`; - } + const avatarValue = + resolveAssistantAvatarUrl({ + avatar: identity.avatar, + agentId: identity.agentId, + basePath: cfg.gateway?.controlUi?.basePath, + }) ?? identity.avatar; respond(true, { ...identity, avatar: avatarValue }, undefined); }, "agent.wait": async ({ params, respond }) => {