fix: resolve control UI avatar URLs (#1457) (thanks @dlauer)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
66
src/gateway/control-ui.test.ts
Normal file
66
src/gateway/control-ui.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user