fix: resolve control UI avatar URLs (#1457) (thanks @dlauer)
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
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));
|
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");
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user