feat: add agent avatar support (#1329) (thanks @dlauer)
This commit is contained in:
110
src/agents/identity-avatar.test.ts
Normal file
110
src/agents/identity-avatar.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveAgentAvatar } from "./identity-avatar.js";
|
||||
|
||||
async function writeFile(filePath: string, contents = "avatar") {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, contents, "utf-8");
|
||||
}
|
||||
|
||||
describe("resolveAgentAvatar", () => {
|
||||
it("resolves local avatar from config when inside workspace", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-avatar-"));
|
||||
const workspace = path.join(root, "work");
|
||||
const avatarPath = path.join(workspace, "avatars", "main.png");
|
||||
await writeFile(avatarPath);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
workspace,
|
||||
identity: { avatar: "avatars/main.png" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const expectedPath = await fs.realpath(avatarPath);
|
||||
const resolved = resolveAgentAvatar(cfg, "main");
|
||||
expect(resolved.kind).toBe("local");
|
||||
if (resolved.kind === "local") {
|
||||
expect(resolved.filePath).toBe(expectedPath);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects avatars outside the workspace", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-avatar-"));
|
||||
const workspace = path.join(root, "work");
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
const outsidePath = path.join(root, "outside.png");
|
||||
await writeFile(outsidePath);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{
|
||||
id: "main",
|
||||
workspace,
|
||||
identity: { avatar: outsidePath },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = resolveAgentAvatar(cfg, "main");
|
||||
expect(resolved.kind).toBe("none");
|
||||
if (resolved.kind === "none") {
|
||||
expect(resolved.reason).toBe("outside_workspace");
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to IDENTITY.md when config has no avatar", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-avatar-"));
|
||||
const workspace = path.join(root, "work");
|
||||
const avatarPath = path.join(workspace, "avatars", "fallback.png");
|
||||
await writeFile(avatarPath);
|
||||
await fs.mkdir(workspace, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(workspace, "IDENTITY.md"),
|
||||
"- Avatar: avatars/fallback.png\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
list: [{ id: "main", workspace }],
|
||||
},
|
||||
};
|
||||
|
||||
const expectedPath = await fs.realpath(avatarPath);
|
||||
const resolved = resolveAgentAvatar(cfg, "main");
|
||||
expect(resolved.kind).toBe("local");
|
||||
if (resolved.kind === "local") {
|
||||
expect(resolved.filePath).toBe(expectedPath);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts remote and data avatars", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: {
|
||||
list: [
|
||||
{ id: "main", identity: { avatar: "https://example.com/avatar.png" } },
|
||||
{ id: "data", identity: { avatar: "data:image/png;base64,aaaa" } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const remote = resolveAgentAvatar(cfg, "main");
|
||||
expect(remote.kind).toBe("remote");
|
||||
|
||||
const data = resolveAgentAvatar(cfg, "data");
|
||||
expect(data.kind).toBe("data");
|
||||
});
|
||||
});
|
||||
99
src/agents/identity-avatar.ts
Normal file
99
src/agents/identity-avatar.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { resolveAgentWorkspaceDir } from "./agent-scope.js";
|
||||
import { loadAgentIdentityFromWorkspace } from "./identity-file.js";
|
||||
import { resolveAgentIdentity } from "./identity.js";
|
||||
|
||||
export type AgentAvatarResolution =
|
||||
| { kind: "none"; reason: string }
|
||||
| { kind: "local"; filePath: string }
|
||||
| { kind: "remote"; url: string }
|
||||
| { kind: "data"; url: string };
|
||||
|
||||
const ALLOWED_AVATAR_EXTS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
|
||||
|
||||
function normalizeAvatarValue(value: string | undefined | null): string | null {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function resolveAvatarSource(cfg: ClawdbotConfig, agentId: string): string | null {
|
||||
const fromConfig = normalizeAvatarValue(resolveAgentIdentity(cfg, agentId)?.avatar);
|
||||
if (fromConfig) return fromConfig;
|
||||
const workspace = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const fromIdentity = normalizeAvatarValue(loadAgentIdentityFromWorkspace(workspace)?.avatar);
|
||||
return fromIdentity;
|
||||
}
|
||||
|
||||
function isRemoteAvatar(value: string): boolean {
|
||||
const lower = value.toLowerCase();
|
||||
return lower.startsWith("http://") || lower.startsWith("https://");
|
||||
}
|
||||
|
||||
function isDataAvatar(value: string): boolean {
|
||||
return value.toLowerCase().startsWith("data:");
|
||||
}
|
||||
|
||||
function resolveExistingPath(value: string): string {
|
||||
try {
|
||||
return fs.realpathSync(value);
|
||||
} catch {
|
||||
return path.resolve(value);
|
||||
}
|
||||
}
|
||||
|
||||
function isPathWithin(root: string, target: string): boolean {
|
||||
const relative = path.relative(root, target);
|
||||
if (!relative) return true;
|
||||
return !relative.startsWith("..") && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
function resolveLocalAvatarPath(params: {
|
||||
raw: string;
|
||||
workspaceDir: string;
|
||||
}): { ok: true; filePath: string } | { ok: false; reason: string } {
|
||||
const workspaceRoot = resolveExistingPath(params.workspaceDir);
|
||||
const raw = params.raw;
|
||||
const resolved =
|
||||
raw.startsWith("~") || path.isAbsolute(raw)
|
||||
? resolveUserPath(raw)
|
||||
: path.resolve(workspaceRoot, raw);
|
||||
const realPath = resolveExistingPath(resolved);
|
||||
if (!isPathWithin(workspaceRoot, realPath)) {
|
||||
return { ok: false, reason: "outside_workspace" };
|
||||
}
|
||||
const ext = path.extname(realPath).toLowerCase();
|
||||
if (!ALLOWED_AVATAR_EXTS.has(ext)) {
|
||||
return { ok: false, reason: "unsupported_extension" };
|
||||
}
|
||||
try {
|
||||
if (!fs.statSync(realPath).isFile()) {
|
||||
return { ok: false, reason: "missing" };
|
||||
}
|
||||
} catch {
|
||||
return { ok: false, reason: "missing" };
|
||||
}
|
||||
return { ok: true, filePath: realPath };
|
||||
}
|
||||
|
||||
export function resolveAgentAvatar(cfg: ClawdbotConfig, agentId: string): AgentAvatarResolution {
|
||||
const source = resolveAvatarSource(cfg, agentId);
|
||||
if (!source) {
|
||||
return { kind: "none", reason: "missing" };
|
||||
}
|
||||
if (isRemoteAvatar(source)) {
|
||||
return { kind: "remote", url: source };
|
||||
}
|
||||
if (isDataAvatar(source)) {
|
||||
return { kind: "data", url: source };
|
||||
}
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const resolved = resolveLocalAvatarPath({ raw: source, workspaceDir });
|
||||
if (!resolved.ok) {
|
||||
return { kind: "none", reason: resolved.reason };
|
||||
}
|
||||
return { kind: "local", filePath: resolved.filePath };
|
||||
}
|
||||
63
src/agents/identity-file.ts
Normal file
63
src/agents/identity-file.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { DEFAULT_IDENTITY_FILENAME } from "./workspace.js";
|
||||
|
||||
export type AgentIdentityFile = {
|
||||
name?: string;
|
||||
emoji?: string;
|
||||
theme?: string;
|
||||
creature?: string;
|
||||
vibe?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
export function parseIdentityMarkdown(content: string): AgentIdentityFile {
|
||||
const identity: AgentIdentityFile = {};
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const cleaned = line.trim().replace(/^\s*-\s*/, "");
|
||||
const colonIndex = cleaned.indexOf(":");
|
||||
if (colonIndex === -1) continue;
|
||||
const label = cleaned.slice(0, colonIndex).replace(/[*_]/g, "").trim().toLowerCase();
|
||||
const value = cleaned
|
||||
.slice(colonIndex + 1)
|
||||
.replace(/^[*_]+|[*_]+$/g, "")
|
||||
.trim();
|
||||
if (!value) continue;
|
||||
if (label === "name") identity.name = value;
|
||||
if (label === "emoji") identity.emoji = value;
|
||||
if (label === "creature") identity.creature = value;
|
||||
if (label === "vibe") identity.vibe = value;
|
||||
if (label === "theme") identity.theme = value;
|
||||
if (label === "avatar") identity.avatar = value;
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
export function identityHasValues(identity: AgentIdentityFile): boolean {
|
||||
return Boolean(
|
||||
identity.name ||
|
||||
identity.emoji ||
|
||||
identity.theme ||
|
||||
identity.creature ||
|
||||
identity.vibe ||
|
||||
identity.avatar,
|
||||
);
|
||||
}
|
||||
|
||||
export function loadIdentityFromFile(identityPath: string): AgentIdentityFile | null {
|
||||
try {
|
||||
const content = fs.readFileSync(identityPath, "utf-8");
|
||||
const parsed = parseIdentityMarkdown(content);
|
||||
if (!identityHasValues(parsed)) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadAgentIdentityFromWorkspace(workspace: string): AgentIdentityFile | null {
|
||||
const identityPath = path.join(workspace, DEFAULT_IDENTITY_FILENAME);
|
||||
return loadIdentityFromFile(identityPath);
|
||||
}
|
||||
Reference in New Issue
Block a user