feat: add agent identity avatars (#1329) (thanks @dlauer)

This commit is contained in:
Peter Steinberger
2026-01-22 05:21:47 +00:00
parent a2bea8e366
commit a59ac5cf6f
26 changed files with 477 additions and 22 deletions

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
@@ -50,6 +50,62 @@ export type {
} from "./session-utils.types.js";
const DERIVED_TITLE_MAX_LEN = 60;
const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
const AVATAR_DATA_RE = /^data:/i;
const AVATAR_HTTP_RE = /^https?:\/\//i;
const AVATAR_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
const WINDOWS_ABS_RE = /^[a-zA-Z]:[\\/]/;
const AVATAR_MIME_BY_EXT: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".svg": "image/svg+xml",
".bmp": "image/bmp",
".tif": "image/tiff",
".tiff": "image/tiff",
};
function resolveAvatarMime(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
return AVATAR_MIME_BY_EXT[ext] ?? "application/octet-stream";
}
function isWorkspaceRelativePath(value: string): boolean {
if (!value) return false;
if (value.startsWith("~")) return false;
if (AVATAR_SCHEME_RE.test(value) && !WINDOWS_ABS_RE.test(value)) return false;
return true;
}
function resolveIdentityAvatarUrl(
cfg: ClawdbotConfig,
agentId: string,
avatar: string | undefined,
): string | undefined {
if (!avatar) return undefined;
const trimmed = avatar.trim();
if (!trimmed) return undefined;
if (AVATAR_DATA_RE.test(trimmed) || AVATAR_HTTP_RE.test(trimmed)) return trimmed;
if (!isWorkspaceRelativePath(trimmed)) return undefined;
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const workspaceRoot = path.resolve(workspaceDir);
const resolved = path.resolve(workspaceRoot, trimmed);
const relative = path.relative(workspaceRoot, resolved);
if (relative.startsWith("..") || path.isAbsolute(relative)) return undefined;
try {
const stat = fs.statSync(resolved);
if (!stat.isFile() || stat.size > AVATAR_MAX_BYTES) return undefined;
const buffer = fs.readFileSync(resolved);
const mime = resolveAvatarMime(resolved);
return `data:${mime};base64,${buffer.toString("base64")}`;
} catch {
return undefined;
}
}
function formatSessionIdPrefix(sessionId: string, updatedAt?: number | null): string {
const prefix = sessionId.slice(0, 8);
@@ -189,11 +245,28 @@ export function listAgentsForGateway(cfg: ClawdbotConfig): {
const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg));
const mainKey = normalizeMainKey(cfg.session?.mainKey);
const scope = cfg.session?.scope ?? "per-sender";
const configuredById = new Map<string, { name?: string }>();
const configuredById = new Map<
string,
{ name?: string; identity?: GatewayAgentRow["identity"] }
>();
for (const entry of cfg.agents?.list ?? []) {
if (!entry?.id) continue;
const identity = entry.identity
? {
name: entry.identity.name?.trim() || undefined,
theme: entry.identity.theme?.trim() || undefined,
emoji: entry.identity.emoji?.trim() || undefined,
avatar: entry.identity.avatar?.trim() || undefined,
avatarUrl: resolveIdentityAvatarUrl(
cfg,
normalizeAgentId(entry.id),
entry.identity.avatar?.trim(),
),
}
: undefined;
configuredById.set(normalizeAgentId(entry.id), {
name: typeof entry.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
identity,
});
}
const explicitIds = new Set(
@@ -213,6 +286,7 @@ export function listAgentsForGateway(cfg: ClawdbotConfig): {
return {
id,
name: meta?.name,
identity: meta?.identity,
};
});
return { defaultId, mainKey, scope, agents };