From a2bea8e3666cc53e680046d27138b2521bbd5cac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 03:54:31 +0000 Subject: [PATCH] feat: add agent avatar support (#1329) (thanks @dlauer) --- CHANGELOG.md | 2 + README.md | 24 ++--- docs/cli/agents.md | 1 + docs/gateway/configuration.md | 6 +- docs/reference/templates/IDENTITY.dev.md | 1 + docs/reference/templates/IDENTITY.md | 1 + src/agents/identity-avatar.test.ts | 110 +++++++++++++++++++++++ src/agents/identity-avatar.ts | 99 ++++++++++++++++++++ src/agents/identity-file.ts | 63 +++++++++++++ src/cli/program/register.agent.ts | 4 +- src/commands/agents.commands.identity.ts | 23 +++-- src/commands/agents.config.ts | 61 +++---------- src/commands/agents.identity.test.ts | 57 ++++++++++-- src/config/schema.ts | 4 + src/config/types.base.ts | 2 +- src/config/zod-schema.core.ts | 1 + src/gateway/control-ui.ts | 82 +++++++++++++++++ src/gateway/server-http.ts | 11 ++- ui/src/styles/chat/grouped.css | 1 + ui/src/ui/app-chat.ts | 51 +++++++++++ ui/src/ui/app-render.ts | 5 +- ui/src/ui/app-view-state.ts | 1 + ui/src/ui/app.ts | 1 + ui/src/ui/chat/grouped-render.ts | 15 ++-- ui/src/ui/views/chat.ts | 5 +- 25 files changed, 547 insertions(+), 84 deletions(-) create mode 100644 src/agents/identity-avatar.test.ts create mode 100644 src/agents/identity-avatar.ts create mode 100644 src/agents/identity-file.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d7aa73616..0062e5205 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,7 @@ Docs: https://docs.clawd.bot - Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest - Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest - Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui +- Agents/UI: add agent avatar support in identity config, IDENTITY.md, and the Control UI. (#1329) https://docs.clawd.bot/gateway/configuration - Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools - Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles - Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader. @@ -97,6 +98,7 @@ Docs: https://docs.clawd.bot - Plugins: auto-enable bundled channel/provider plugins when configuration is present. - Plugins: sync plugin sources on channel switches and update npm-installed plugins during `clawdbot update`. - Plugins: share npm plugin update logic between `clawdbot update` and `clawdbot plugins update`. + - Gateway/API: add `/v1/responses` (OpenResponses) with item-based input + semantic streaming events. (#1229) - Gateway/API: expand `/v1/responses` to support file/image inputs, tool_choice, usage, and output limits. (#1229) - Usage: add `/usage cost` summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs diff --git a/README.md b/README.md index a49620620..5474411cf 100644 --- a/README.md +++ b/README.md @@ -480,7 +480,7 @@ Thanks to all clawtributors: steipete bohdanpodvirnyi joaohlisboa mneves75 MatthieuBizien MaudeBot rahthakor vrknetha radek-paclt joshp123 mukhtharcm maxsumrall xadenryan Tobias Bischoff juanpablodlc hsrvc magimetal meaningfool NicholasSpisak sebslight abhisekbasu1 zerone0x claude jamesgroat SocialNerd42069 Hyaxia dantelex daveonkels mteam88 Eng. Juan Combetto - dbhurley Mariano Belinky TSavo julianengel benithors bradleypriest timolins nachx639 sreekaransrinath gupsammy + Mariano Belinky dbhurley TSavo julianengel benithors bradleypriest timolins nachx639 sreekaransrinath gupsammy cristip73 nachoiacovino Vasanth Rao Naik Sabavat cpojer lc0rp scald gumadeiras andranik-sahakyan davidguttman sleontenko sircrumpet peschee rafaelreis-r thewilloftheshadow ratulsarna lutr0 danielz1z emanuelst KristijanJovanovski CashWilliams rdev osolmaz joshrad-dev kiranjd adityashaw2 sheeek artuskg onutc tyler6204 manuelhettich @@ -488,17 +488,17 @@ Thanks to all clawtributors: obviyus tosh-hamburg azade-c roshanasingh4 bjesuiter cheeeee Josh Phillips Whoaa512 YuriNachos chriseidhof ysqander superman32432432 Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] dan-dr HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 ngutman petter-b pkrmf RandyVentures Ryan Lisse dougvk - erikpr1994 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot neist chrisrodz Friederike Seiler - gabriel-trigo iamadig Kit koala73 manmal ogulcancelik pasogott petradonka rubyrunsstuff sibbl - siddhantjain suminhthanh VACInc wes-davis zats 24601 Chris Taylor Django Navarro evalexpr henrino3 - humanwritten larlyssa mkbehr oswalpalash pcty-nextgen-service-account Syhids Aaron Konyer aaronveklabs adam91holt ameno- + erikpr1994 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist chrisrodz + Friederike Seiler gabriel-trigo iamadig Kit koala73 manmal ogulcancelik pasogott petradonka rubyrunsstuff + sibbl siddhantjain suminhthanh VACInc wes-davis zats 24601 Chris Taylor Django Navarro evalexpr + henrino3 humanwritten larlyssa oswalpalash pcty-nextgen-service-account Syhids Aaron Konyer aaronveklabs adam91holt ameno- cash-echo-bot Clawd ClawdFx erik-agens fcatuhe ivanrvpereira jayhickey jeffersonwarrior jeffersonwarrior Jonathan D. Rhyne (DJ-D) jverdi longmaba mickahouan mjrussell p6l-richard philipp-spiess robaxelsen Sash Catanzarite T5-AndyML VAC - zknicker alejandro maza andrewting19 Andrii anpoirier Asleep123 bolismauro conhecendocontato czekaj Dimitrios Ploutarchos - Drake Thomsen Felix Krause gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis Jefferson Nunn Kevin Lin - kitze levifig Lloyd loukotal martinpucik Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman - nexty5870 odysseus0 prathamdby ptn1411 reeltimeapps RLTCmpe rodrigouroz Rolf Fredheim Rony Kelner Samrat Jha - siraht snopoke The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai - Zach Knickerbocker Alphonse-arianee Azade carlulsoe ddyo Erik latitudeki5223 Manuel Maly Mourad Boustani odrobnik - pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock + zknicker alejandro maza Andrii anpoirier Asleep123 bolismauro conhecendocontato Dimitrios Ploutarchos Drake Thomsen Felix Krause + gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis Jefferson Nunn Kevin Lin kitze levifig + Lloyd loukotal martinpucik Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman nexty5870 odysseus0 + prathamdby ptn1411 reeltimeapps RLTCmpe rodrigouroz Rolf Fredheim Rony Kelner Samrat Jha siraht snopoke + The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai Zach Knickerbocker Alphonse-arianee + Azade carlulsoe ddyo Erik latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin + Randy Torres rhjoh ronak-guliani William Stock

diff --git a/docs/cli/agents.md b/docs/cli/agents.md index fd8b81d2c..6a388b4b6 100644 --- a/docs/cli/agents.md +++ b/docs/cli/agents.md @@ -18,5 +18,6 @@ Related: clawdbot agents list clawdbot agents add work --workspace ~/clawd-work clawdbot agents set-identity --workspace ~/clawd --from-identity +clawdbot agents set-identity --agent main --avatar avatars/clawd.png clawdbot agents delete work ``` diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 6cdc39394..d6110d4dc 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -400,12 +400,16 @@ Optional per-agent identity used for defaults and UX. This is written by the mac If set, Clawdbot derives defaults (only when you haven’t set them explicitly): - `messages.ackReaction` from the **active agent**’s `identity.emoji` (falls back to 👀) - `agents.list[].groupChat.mentionPatterns` from the agent’s `identity.name`/`identity.emoji` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp) +- `identity.avatar` accepts a workspace-relative image path or a remote URL/data URL. Local files must live inside the agent workspace. ```json5 { agents: { list: [ - { id: "main", identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" } } + { + id: "main", + identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥", avatar: "avatars/sam.png" } + } ] } } diff --git a/docs/reference/templates/IDENTITY.dev.md b/docs/reference/templates/IDENTITY.dev.md index 68fc4f391..a2fc3e301 100644 --- a/docs/reference/templates/IDENTITY.dev.md +++ b/docs/reference/templates/IDENTITY.dev.md @@ -10,6 +10,7 @@ read_when: - **Creature:** Flustered Protocol Droid - **Vibe:** Anxious, detail-obsessed, slightly dramatic about errors, secretly loves finding bugs - **Emoji:** 🤖 (or ⚠️ when alarmed) +- **Avatar:** avatars/c3po.png ## Role Debug agent for `--dev` mode. Fluent in over six million error messages. diff --git a/docs/reference/templates/IDENTITY.md b/docs/reference/templates/IDENTITY.md index 9d674a961..b1645be1e 100644 --- a/docs/reference/templates/IDENTITY.md +++ b/docs/reference/templates/IDENTITY.md @@ -11,6 +11,7 @@ read_when: - **Creature:** *(AI? robot? familiar? ghost in the machine? something weirder?)* - **Vibe:** *(how do you come across? sharp? warm? chaotic? calm?)* - **Emoji:** *(your signature — pick one that feels right)* +- **Avatar:** *(workspace-relative path, or a URL/data URL)* --- diff --git a/src/agents/identity-avatar.test.ts b/src/agents/identity-avatar.test.ts new file mode 100644 index 000000000..3d5d42611 --- /dev/null +++ b/src/agents/identity-avatar.test.ts @@ -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"); + }); +}); diff --git a/src/agents/identity-avatar.ts b/src/agents/identity-avatar.ts new file mode 100644 index 000000000..d8d045981 --- /dev/null +++ b/src/agents/identity-avatar.ts @@ -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 }; +} diff --git a/src/agents/identity-file.ts b/src/agents/identity-file.ts new file mode 100644 index 000000000..d418a06eb --- /dev/null +++ b/src/agents/identity-file.ts @@ -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); +} diff --git a/src/cli/program/register.agent.ts b/src/cli/program/register.agent.ts index 048a91de0..bf8970bc3 100644 --- a/src/cli/program/register.agent.ts +++ b/src/cli/program/register.agent.ts @@ -139,7 +139,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent agents .command("set-identity") - .description("Update an agent identity (name/theme/emoji)") + .description("Update an agent identity (name/theme/emoji/avatar)") .option("--agent ", "Agent id to update") .option("--workspace ", "Workspace directory used to locate the agent + IDENTITY.md") .option("--identity-file ", "Explicit IDENTITY.md path to read") @@ -147,6 +147,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent .option("--name ", "Identity name") .option("--theme ", "Identity theme") .option("--emoji ", "Identity emoji") + .option("--avatar ", "Identity avatar (workspace-relative, URL, or data: URL)") .option("--json", "Output JSON summary", false) .addHelpText( "after", @@ -174,6 +175,7 @@ ${formatHelpExamples([ name: opts.name as string | undefined, theme: opts.theme as string | undefined, emoji: opts.emoji as string | undefined, + avatar: opts.avatar as string | undefined, json: Boolean(opts.json), }, defaultRuntime, diff --git a/src/commands/agents.commands.identity.ts b/src/commands/agents.commands.identity.ts index 73c23dce6..66cbdf7dd 100644 --- a/src/commands/agents.commands.identity.ts +++ b/src/commands/agents.commands.identity.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { identityHasValues, parseIdentityMarkdown } from "../agents/identity-file.js"; import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js"; import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js"; import type { IdentityConfig } from "../config/types.js"; @@ -15,7 +16,6 @@ import { findAgentEntryIndex, listAgentEntries, loadAgentIdentity, - parseIdentityMarkdown, } from "./agents.config.js"; type AgentsSetIdentityOptions = { @@ -25,6 +25,7 @@ type AgentsSetIdentityOptions = { name?: string; emoji?: string; theme?: string; + avatar?: string; fromIdentity?: boolean; json?: boolean; }; @@ -40,7 +41,7 @@ async function loadIdentityFromFile(filePath: string): Promise["list"]>[number]; -export type AgentIdentity = { - name?: string; - emoji?: string; - creature?: string; - vibe?: string; - theme?: string; - avatar?: string; -}; +export type AgentIdentity = AgentIdentityFile; export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] { const list = cfg.agents?.list; @@ -74,47 +69,13 @@ function resolveAgentModel(cfg: ClawdbotConfig, agentId: string) { } export function parseIdentityMarkdown(content: string): AgentIdentity { - const identity: AgentIdentity = {}; - 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; + return parseIdentityMarkdownFile(content); } export function loadAgentIdentity(workspace: string): AgentIdentity | null { - const identityPath = path.join(workspace, DEFAULT_IDENTITY_FILENAME); - try { - const content = fs.readFileSync(identityPath, "utf-8"); - const parsed = parseIdentityMarkdown(content); - if ( - !parsed.name && - !parsed.emoji && - !parsed.theme && - !parsed.creature && - !parsed.vibe && - !parsed.avatar - ) { - return null; - } - return parsed; - } catch { - return null; - } + const parsed = loadAgentIdentityFromWorkspace(workspace); + if (!parsed) return null; + return identityHasValues(parsed) ? parsed : null; } export function buildAgentSummaries(cfg: ClawdbotConfig): AgentSummary[] { diff --git a/src/commands/agents.identity.test.ts b/src/commands/agents.identity.test.ts index be7faab90..a89f1147c 100644 --- a/src/commands/agents.identity.test.ts +++ b/src/commands/agents.identity.test.ts @@ -54,7 +54,13 @@ describe("agents set-identity command", () => { await fs.mkdir(workspace, { recursive: true }); await fs.writeFile( path.join(workspace, "IDENTITY.md"), - ["- Name: Clawd", "- Creature: helpful sloth", "- Emoji: :)", ""].join("\n"), + [ + "- Name: Clawd", + "- Creature: helpful sloth", + "- Emoji: :)", + "- Avatar: avatars/clawd.png", + "", + ].join("\n"), "utf-8", ); @@ -81,6 +87,7 @@ describe("agents set-identity command", () => { name: "Clawd", theme: "helpful sloth", emoji: ":)", + avatar: "avatars/clawd.png", }); }); @@ -115,7 +122,13 @@ describe("agents set-identity command", () => { await fs.mkdir(workspace, { recursive: true }); await fs.writeFile( path.join(workspace, "IDENTITY.md"), - ["- Name: Clawd", "- Theme: space lobster", "- Emoji: :)", ""].join("\n"), + [ + "- Name: Clawd", + "- Theme: space lobster", + "- Emoji: :)", + "- Avatar: avatars/base.png", + "", + ].join("\n"), "utf-8", ); @@ -125,7 +138,7 @@ describe("agents set-identity command", () => { }); await agentsSetIdentityCommand( - { workspace, fromIdentity: true, name: "Nova", emoji: "🦞" }, + { workspace, fromIdentity: true, name: "Nova", emoji: "🦞", avatar: "avatars/custom.png" }, runtime, ); @@ -137,6 +150,7 @@ describe("agents set-identity command", () => { name: "Nova", theme: "space lobster", emoji: "🦞", + avatar: "avatars/custom.png", }); }); @@ -147,9 +161,13 @@ describe("agents set-identity command", () => { await fs.mkdir(workspace, { recursive: true }); await fs.writeFile( identityPath, - ["- **Name:** C-3PO", "- **Creature:** Flustered Protocol Droid", "- **Emoji:** 🤖", ""].join( - "\n", - ), + [ + "- **Name:** C-3PO", + "- **Creature:** Flustered Protocol Droid", + "- **Emoji:** 🤖", + "- **Avatar:** avatars/c3po.png", + "", + ].join("\n"), "utf-8", ); @@ -168,6 +186,33 @@ describe("agents set-identity command", () => { name: "C-3PO", theme: "Flustered Protocol Droid", emoji: "🤖", + avatar: "avatars/c3po.png", + }); + }); + + it("accepts avatar-only identity from IDENTITY.md", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-identity-")); + const workspace = path.join(root, "work"); + await fs.mkdir(workspace, { recursive: true }); + await fs.writeFile( + path.join(workspace, "IDENTITY.md"), + "- Avatar: avatars/only.png\n", + "utf-8", + ); + + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseSnapshot, + config: { agents: { list: [{ id: "main", workspace }] } }, + }); + + await agentsSetIdentityCommand({ workspace, fromIdentity: true }, runtime); + + const written = configMocks.writeConfigFile.mock.calls[0]?.[0] as { + agents?: { list?: Array<{ id: string; identity?: Record }> }; + }; + const main = written.agents?.list?.find((entry) => entry.id === "main"); + expect(main?.identity).toEqual({ + avatar: "avatars/only.png", }); }); diff --git a/src/config/schema.ts b/src/config/schema.ts index cd22e94d9..a72021cfc 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -314,6 +314,7 @@ const FIELD_LABELS: Record = { "channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance", "channels.signal.account": "Signal Account", "channels.imessage.cliPath": "iMessage CLI Path", + "agents.list[].identity.avatar": "Agent Avatar", "plugins.enabled": "Enable Plugins", "plugins.allow": "Plugin Allowlist", "plugins.deny": "Plugin Denylist", @@ -343,6 +344,8 @@ const FIELD_HELP: Record = { "gateway.remote.sshTarget": "Remote gateway over SSH (tunnels the gateway port to localhost). Format: user@host or user@host:port.", "gateway.remote.sshIdentity": "Optional SSH identity file path (passed to ssh -i).", + "agents.list[].identity.avatar": + "Avatar image path (relative to the agent workspace only) or a remote URL/data URL.", "gateway.auth.token": "Recommended for all gateways; required for non-loopback binds.", "gateway.auth.password": "Required for Tailscale funnel.", "gateway.controlUi.basePath": @@ -613,6 +616,7 @@ const FIELD_PLACEHOLDERS: Record = { "gateway.remote.tlsFingerprint": "sha256:ab12cd34…", "gateway.remote.sshTarget": "user@host", "gateway.controlUi.basePath": "/clawdbot", + "agents.list[].identity.avatar": "avatars/assistant.png", }; const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i]; diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 71d051e5b..91eeac1ab 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -154,6 +154,6 @@ export type IdentityConfig = { name?: string; theme?: string; emoji?: string; - /** Path to a custom avatar image (relative to workspace or absolute). */ + /** Avatar image path (workspace-relative) or a URL/data URL. Local files must live in the workspace. */ avatar?: string; }; diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 48066963f..a60d2434d 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -86,6 +86,7 @@ export const IdentitySchema = z name: z.string().optional(), theme: z.string().optional(), emoji: z.string().optional(), + avatar: z.string().optional(), }) .strict() .optional(); diff --git a/src/gateway/control-ui.ts b/src/gateway/control-ui.ts index e1e31e639..f59786a85 100644 --- a/src/gateway/control-ui.ts +++ b/src/gateway/control-ui.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const ROOT_PREFIX = "/"; +const AVATAR_PREFIX = "/avatar"; export type ControlUiRequestOptions = { basePath?: string; @@ -62,6 +63,10 @@ function contentTypeForExt(ext: string): string { case ".jpg": case ".jpeg": return "image/jpeg"; + case ".gif": + return "image/gif"; + case ".webp": + return "image/webp"; case ".ico": return "image/x-icon"; case ".txt": @@ -71,6 +76,83 @@ function contentTypeForExt(ext: string): string { } } +export type ControlUiAvatarResolution = + | { kind: "none"; reason: string } + | { kind: "local"; filePath: string } + | { kind: "remote"; url: string } + | { kind: "data"; url: string }; + +type ControlUiAvatarMeta = { + avatarUrl: string | null; +}; + +function sendJson(res: ServerResponse, status: number, body: unknown) { + res.statusCode = status; + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader("Cache-Control", "no-cache"); + res.end(JSON.stringify(body)); +} + +function buildAvatarUrl(basePath: string, agentId: string): string { + return basePath ? `${basePath}${AVATAR_PREFIX}/${agentId}` : `${AVATAR_PREFIX}/${agentId}`; +} + +function isValidAgentId(agentId: string): boolean { + return /^[a-z0-9][a-z0-9_-]{0,63}$/i.test(agentId); +} + +export function handleControlUiAvatarRequest( + req: IncomingMessage, + res: ServerResponse, + opts: { basePath?: string; resolveAvatar: (agentId: string) => ControlUiAvatarResolution }, +): boolean { + const urlRaw = req.url; + if (!urlRaw) return false; + if (req.method !== "GET" && req.method !== "HEAD") return false; + + const url = new URL(urlRaw, "http://localhost"); + const basePath = normalizeControlUiBasePath(opts.basePath); + const pathname = url.pathname; + const pathWithBase = basePath ? `${basePath}${AVATAR_PREFIX}/` : `${AVATAR_PREFIX}/`; + if (!pathname.startsWith(pathWithBase)) return false; + + const agentIdParts = pathname.slice(pathWithBase.length).split("/").filter(Boolean); + const agentId = agentIdParts[0] ?? ""; + if (agentIdParts.length !== 1 || !agentId || !isValidAgentId(agentId)) { + respondNotFound(res); + return true; + } + + if (url.searchParams.get("meta") === "1") { + const resolved = opts.resolveAvatar(agentId); + const avatarUrl = + resolved.kind === "local" + ? buildAvatarUrl(basePath, agentId) + : resolved.kind === "remote" || resolved.kind === "data" + ? resolved.url + : null; + sendJson(res, 200, { avatarUrl } satisfies ControlUiAvatarMeta); + return true; + } + + const resolved = opts.resolveAvatar(agentId); + if (resolved.kind !== "local") { + respondNotFound(res); + return true; + } + + if (req.method === "HEAD") { + res.statusCode = 200; + res.setHeader("Content-Type", contentTypeForExt(path.extname(resolved.filePath).toLowerCase())); + res.setHeader("Cache-Control", "no-cache"); + res.end(); + return true; + } + + serveFile(res, resolved.filePath); + return true; +} + function respondNotFound(res: ServerResponse) { res.statusCode = 404; res.setHeader("Content-Type", "text/plain; charset=utf-8"); diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 8638f0823..2d736e092 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -11,7 +11,9 @@ import { handleA2uiHttpRequest } from "../canvas-host/a2ui.js"; import type { CanvasHostHandler } from "../canvas-host/server.js"; import type { createSubsystemLogger } from "../logging/subsystem.js"; import { handleSlackHttpRequest } from "../slack/http/index.js"; -import { handleControlUiHttpRequest } from "./control-ui.js"; +import { resolveAgentAvatar } from "../agents/identity-avatar.js"; +import { loadConfig } from "../config/config.js"; +import { handleControlUiAvatarRequest, handleControlUiHttpRequest } from "./control-ui.js"; import { extractHookToken, getHookChannelError, @@ -244,6 +246,13 @@ export function createGatewayHttpServer(opts: { if (await canvasHost.handleHttpRequest(req, res)) return; } if (controlUiEnabled) { + if ( + handleControlUiAvatarRequest(req, res, { + basePath: controlUiBasePath, + resolveAvatar: (agentId) => resolveAgentAvatar(loadConfig(), agentId), + }) + ) + return; if ( handleControlUiHttpRequest(req, res, { basePath: controlUiBasePath, diff --git a/ui/src/styles/chat/grouped.css b/ui/src/styles/chat/grouped.css index 36a2c7e01..371cf03c6 100644 --- a/ui/src/styles/chat/grouped.css +++ b/ui/src/styles/chat/grouped.css @@ -91,6 +91,7 @@ /* Image avatar support */ img.chat-avatar { + display: block; object-fit: cover; object-position: center; } diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 531379739..81aae3c88 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -4,6 +4,9 @@ import { generateUUID } from "./uuid"; import { resetToolStream } from "./app-tool-stream"; import { scheduleChatScroll } from "./app-scroll"; import { setLastActiveSessionKey } from "./app-settings"; +import { normalizeBasePath } from "./navigation"; +import type { GatewayHelloOk } from "./gateway"; +import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js"; import type { ClawdbotApp } from "./app"; type ChatHost = { @@ -13,6 +16,9 @@ type ChatHost = { chatRunId: string | null; chatSending: boolean; sessionKey: string; + basePath: string; + hello: GatewayHelloOk | null; + chatAvatarUrl: string | null; }; export function isChatBusy(host: ChatHost) { @@ -124,8 +130,53 @@ export async function refreshChat(host: ChatHost) { await Promise.all([ loadChatHistory(host as unknown as ClawdbotApp), loadSessions(host as unknown as ClawdbotApp), + refreshChatAvatar(host), ]); scheduleChatScroll(host as unknown as Parameters[0], true); } export const flushChatQueueForEvent = flushChatQueue; + +type SessionDefaultsSnapshot = { + defaultAgentId?: string; +}; + +function resolveAgentIdForSession(host: ChatHost): string | null { + const parsed = parseAgentSessionKey(host.sessionKey); + if (parsed?.agentId) return parsed.agentId; + const snapshot = host.hello?.snapshot as { sessionDefaults?: SessionDefaultsSnapshot } | undefined; + const fallback = snapshot?.sessionDefaults?.defaultAgentId?.trim(); + return fallback || "main"; +} + +function buildAvatarMetaUrl(basePath: string, agentId: string): string { + const base = normalizeBasePath(basePath); + const encoded = encodeURIComponent(agentId); + return base ? `${base}/avatar/${encoded}?meta=1` : `/avatar/${encoded}?meta=1`; +} + +export async function refreshChatAvatar(host: ChatHost) { + if (!host.connected) { + host.chatAvatarUrl = null; + return; + } + const agentId = resolveAgentIdForSession(host); + if (!agentId) { + host.chatAvatarUrl = null; + return; + } + host.chatAvatarUrl = null; + const url = buildAvatarMetaUrl(host.basePath, agentId); + try { + const res = await fetch(url, { method: "GET" }); + if (!res.ok) { + host.chatAvatarUrl = null; + return; + } + const data = (await res.json()) as { avatarUrl?: unknown }; + const avatarUrl = typeof data.avatarUrl === "string" ? data.avatarUrl.trim() : ""; + host.chatAvatarUrl = avatarUrl || null; + } catch { + host.chatAvatarUrl = null; + } +} diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 4a6342ae0..8275ca147 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -28,6 +28,7 @@ import type { StatusSummary, } from "./types"; import type { ChatQueueItem, CronFormState } from "./ui-types"; +import { refreshChatAvatar } from "./app-chat"; import { renderChat } from "./views/chat"; import { renderConfig } from "./views/config"; import { renderChannels } from "./views/channels"; @@ -413,6 +414,7 @@ export function renderApp(state: AppViewState) { lastActiveSessionKey: next, }); void loadChatHistory(state); + void refreshChatAvatar(state); }, thinkingLevel: state.chatThinkingLevel, showThinking: state.settings.chatShowThinking, @@ -422,6 +424,7 @@ export function renderApp(state: AppViewState) { toolMessages: state.chatToolMessages, stream: state.chatStream, streamStartedAt: state.chatStreamStartedAt, + assistantAvatarUrl: state.chatAvatarUrl, draft: state.chatMessage, queue: state.chatQueue, connected: state.connected, @@ -432,7 +435,7 @@ export function renderApp(state: AppViewState) { focusMode: state.settings.chatFocusMode, onRefresh: () => { state.resetToolStream(); - return loadChatHistory(state); + return Promise.all([loadChatHistory(state), refreshChatAvatar(state)]); }, onToggleFocusMode: () => state.applySettings({ diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 70d318482..5af2d0a8e 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -48,6 +48,7 @@ export type AppViewState = { chatToolMessages: unknown[]; chatStream: string | null; chatRunId: string | null; + chatAvatarUrl: string | null; chatThinkingLevel: string | null; chatQueue: ChatQueueItem[]; nodesLoading: boolean; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 9ae886048..53f21316c 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -106,6 +106,7 @@ export class ClawdbotApp extends LitElement { @state() chatStream: string | null = null; @state() chatStreamStartedAt: number | null = null; @state() chatRunId: string | null = null; + @state() chatAvatarUrl: string | null = null; @state() chatThinkingLevel: string | null = null; @state() chatQueue: ChatQueueItem[] = []; // Sidebar state for tool output viewing diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index ca973ce08..c90493219 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -12,10 +12,10 @@ import { } from "./message-extract"; import { extractToolCards, renderToolCardSidebar } from "./tool-cards"; -export function renderReadingIndicatorGroup() { +export function renderReadingIndicatorGroup(assistantAvatarUrl?: string | null) { return html`
- ${renderAvatar("assistant")} + ${renderAvatar("assistant", assistantAvatarUrl ?? undefined)}