fix: resolve control UI avatar URLs (#1457) (thanks @dlauer)

This commit is contained in:
Peter Steinberger
2026-01-22 21:57:02 +00:00
parent 6c7f224ce1
commit 482fcd2f2c
4 changed files with 110 additions and 21 deletions

View File

@@ -7,7 +7,7 @@ Docs: https://docs.clawd.bot
### Fixes ### Fixes
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell. - 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. - 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 ## 2026.1.21-2

View File

@@ -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");
});
});

View File

@@ -98,7 +98,7 @@ function sendJson(res: ServerResponse, status: number, body: unknown) {
res.end(JSON.stringify(body)); 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}`; return basePath ? `${basePath}${AVATAR_PREFIX}/${agentId}` : `${AVATAR_PREFIX}/${agentId}`;
} }
@@ -206,22 +206,49 @@ interface ServeIndexHtmlOpts {
agentId?: string; agentId?: string;
} }
function looksLikeLocalAvatarPath(value: string | undefined): boolean { function looksLikeLocalAvatarPath(value: string): boolean {
if (!value) return false; if (/[\\/]/.test(value)) return true;
if (/^https?:\/\//i.test(value) || /^data:image\//i.test(value)) return false;
return /\.(png|jpe?g|gif|webp|svg|ico)$/i.test(value); 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) { function serveIndexHtml(res: ServerResponse, indexPath: string, opts: ServeIndexHtmlOpts) {
const { basePath, config, agentId } = opts; const { basePath, config, agentId } = opts;
const identity = config const identity = config
? resolveAssistantIdentity({ cfg: config, agentId }) ? resolveAssistantIdentity({ cfg: config, agentId })
: DEFAULT_ASSISTANT_IDENTITY; : DEFAULT_ASSISTANT_IDENTITY;
// Resolve local file avatars to /avatar/{agentId} URL const resolvedAgentId =
let avatarValue = identity.avatar; typeof (identity as { agentId?: string }).agentId === "string"
if (looksLikeLocalAvatarPath(avatarValue) && identity.agentId) { ? (identity as { agentId?: string }).agentId
avatarValue = buildAvatarUrl(basePath, identity.agentId); : agentId;
} const avatarValue =
resolveAssistantAvatarUrl({
avatar: identity.avatar,
agentId: resolvedAgentId,
basePath,
}) ?? identity.avatar;
res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("Cache-Control", "no-cache"); res.setHeader("Cache-Control", "no-cache");
const raw = fs.readFileSync(indexPath, "utf8"); const raw = fs.readFileSync(indexPath, "utf8");

View File

@@ -38,6 +38,7 @@ import {
import { loadSessionEntry } from "../session-utils.js"; import { loadSessionEntry } from "../session-utils.js";
import { formatForLog } from "../ws-log.js"; import { formatForLog } from "../ws-log.js";
import { resolveAssistantIdentity } from "../assistant-identity.js"; import { resolveAssistantIdentity } from "../assistant-identity.js";
import { resolveAssistantAvatarUrl } from "../control-ui.js";
import { waitForAgentJob } from "./agent-job.js"; import { waitForAgentJob } from "./agent-job.js";
import type { GatewayRequestHandlers } from "./types.js"; import type { GatewayRequestHandlers } from "./types.js";
@@ -407,17 +408,12 @@ export const agentHandlers: GatewayRequestHandlers = {
} }
const cfg = loadConfig(); const cfg = loadConfig();
const identity = resolveAssistantIdentity({ cfg, agentId }); const identity = resolveAssistantIdentity({ cfg, agentId });
// Resolve local file avatars to /avatar/{agentId} URL const avatarValue =
let avatarValue = identity.avatar; resolveAssistantAvatarUrl({
if ( avatar: identity.avatar,
avatarValue && agentId: identity.agentId,
!/^https?:\/\//i.test(avatarValue) && basePath: cfg.gateway?.controlUi?.basePath,
!/^data:image\//i.test(avatarValue) && }) ?? identity.avatar;
/\.(png|jpe?g|gif|webp|svg|ico)$/i.test(avatarValue) &&
identity.agentId
) {
avatarValue = `/avatar/${identity.agentId}`;
}
respond(true, { ...identity, avatar: avatarValue }, undefined); respond(true, { ...identity, avatar: avatarValue }, undefined);
}, },
"agent.wait": async ({ params, respond }) => { "agent.wait": async ({ params, respond }) => {