diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bb747f04..e14fb7fc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot ### Changes - Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster +- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. - Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early. - Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting - Docs: add /model allowlist troubleshooting note. (#1405) @@ -97,6 +98,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. @@ -106,6 +108,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..e7df32e52 100644 --- a/docs/cli/agents.md +++ b/docs/cli/agents.md @@ -18,5 +18,54 @@ 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 ``` + +## Identity files + +Each agent workspace can include an `IDENTITY.md` at the workspace root: +- Example path: `~/clawd/IDENTITY.md` +- `set-identity --from-identity` reads from the workspace root (or an explicit `--identity-file`) + +Avatar paths resolve relative to the workspace root. + +## Set identity + +`set-identity` writes fields into `agents.list[].identity`: +- `name` +- `theme` +- `emoji` +- `avatar` (workspace-relative path, http(s) URL, or data URI) + +Load from `IDENTITY.md`: + +```bash +clawdbot agents set-identity --workspace ~/clawd --from-identity +``` + +Override fields explicitly: + +```bash +clawdbot agents set-identity --agent main --name "Clawd" --emoji "🦞" --avatar avatars/clawd.png +``` + +Config sample: + +```json5 +{ + agents: { + list: [ + { + id: "main", + identity: { + name: "Clawd", + theme: "space lobster", + emoji: "🦞", + avatar: "avatars/clawd.png" + } + } + ] + } +} +``` diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 01a64361f..6f2b064bf 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -400,12 +400,26 @@ 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. + +`identity.avatar` accepts: +- Workspace-relative path (must stay within the agent workspace) +- `http(s)` URL +- `data:` URI ```json5 { agents: { list: [ - { id: "main", identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" } } + { + id: "main", + identity: { + name: "Samantha", + theme: "helpful sloth", + emoji: "🦥", + avatar: "avatars/samantha.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..196277776 100644 --- a/docs/reference/templates/IDENTITY.md +++ b/docs/reference/templates/IDENTITY.md @@ -11,7 +11,12 @@ 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, http(s) URL, or data URI)* --- This isn't just metadata. It's the start of figuring out who you are. + +Notes: +- Save this file at the workspace root as `IDENTITY.md`. +- For avatars, use a workspace-relative path like `avatars/clawd.png`. 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..d597efde9 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 path, http(s) URL, or data URI)") .option("--json", "Output JSON summary", false) .addHelpText( "after", @@ -155,6 +156,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent ${theme.heading("Examples:")} ${formatHelpExamples([ ['clawdbot agents set-identity --agent main --name "Clawd" --emoji "🦞"', "Set name + emoji."], + ["clawdbot agents set-identity --agent main --avatar avatars/clawd.png", "Set avatar path."], ["clawdbot agents set-identity --workspace ~/clawd --from-identity", "Load from IDENTITY.md."], [ "clawdbot agents set-identity --identity-file ~/clawd/IDENTITY.md --agent main", @@ -174,6 +176,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..1b214ad19 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/clawd.png", + "", + ].join("\n"), "utf-8", ); @@ -125,7 +138,13 @@ describe("agents set-identity command", () => { }); await agentsSetIdentityCommand( - { workspace, fromIdentity: true, name: "Nova", emoji: "🦞" }, + { + workspace, + fromIdentity: true, + name: "Nova", + emoji: "🦞", + avatar: "https://example.com/override.png", + }, runtime, ); @@ -137,6 +156,7 @@ describe("agents set-identity command", () => { name: "Nova", theme: "space lobster", emoji: "🦞", + avatar: "https://example.com/override.png", }); }); @@ -147,9 +167,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 +192,53 @@ 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", + }); + }); + + it("accepts avatar-only updates via flags", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseSnapshot, + config: { agents: { list: [{ id: "main" }] } }, + }); + + await agentsSetIdentityCommand( + { agent: "main", avatar: "https://example.com/avatar.png" }, + 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: "https://example.com/avatar.png", }); }); diff --git a/src/config/config.identity-avatar.test.ts b/src/config/config.identity-avatar.test.ts new file mode 100644 index 000000000..651ba1dd8 --- /dev/null +++ b/src/config/config.identity-avatar.test.ts @@ -0,0 +1,54 @@ +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { validateConfigObject } from "./config.js"; +import { withTempHome } from "./test-helpers.js"; + +describe("identity avatar validation", () => { + it("accepts workspace-relative avatar paths", async () => { + await withTempHome(async (home) => { + const workspace = path.join(home, "clawd"); + const res = validateConfigObject({ + agents: { + list: [{ id: "main", workspace, identity: { avatar: "avatars/clawd.png" } }], + }, + }); + expect(res.ok).toBe(true); + }); + }); + + it("accepts http(s) and data avatars", async () => { + await withTempHome(async (home) => { + const workspace = path.join(home, "clawd"); + const httpRes = validateConfigObject({ + agents: { + list: [{ id: "main", workspace, identity: { avatar: "https://example.com/avatar.png" } }], + }, + }); + expect(httpRes.ok).toBe(true); + + const dataRes = validateConfigObject({ + agents: { + list: [{ id: "main", workspace, identity: { avatar: "data:image/png;base64,AAA" } }], + }, + }); + expect(dataRes.ok).toBe(true); + }); + }); + + it("rejects avatar paths outside workspace", async () => { + await withTempHome(async (home) => { + const workspace = path.join(home, "clawd"); + const res = validateConfigObject({ + agents: { + list: [{ id: "main", workspace, identity: { avatar: "../oops.png" } }], + }, + }); + expect(res.ok).toBe(false); + if (!res.ok) { + expect(res.issues[0]?.path).toBe("agents.list.0.identity.avatar"); + } + }); + }); +}); diff --git a/src/config/schema.ts b/src/config/schema.ts index 953205cd6..3bb39674d 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -119,6 +119,7 @@ const FIELD_LABELS: Record = { "diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages", "diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt", "diagnostics.cacheTrace.includeSystem": "Cache Trace Include System", + "agents.list.*.identity.avatar": "Identity Avatar", "gateway.remote.url": "Remote Gateway URL", "gateway.remote.sshTarget": "Remote Gateway SSH Target", "gateway.remote.sshIdentity": "Remote Gateway SSH Identity", @@ -315,6 +316,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", @@ -344,6 +346,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": @@ -511,6 +515,8 @@ const FIELD_HELP: Record = { "Resolved install directory (usually ~/.clawdbot/extensions/).", "plugins.installs.*.version": "Version recorded at install time (if available).", "plugins.installs.*.installedAt": "ISO timestamp of last install/update.", + "agents.list.*.identity.avatar": + "Agent avatar (workspace-relative path, http(s) URL, or data URI).", "agents.defaults.model.primary": "Primary model (provider/model).", "agents.defaults.model.fallbacks": "Ordered fallback models (provider/model). Used when the primary model fails.", @@ -616,6 +622,7 @@ const FIELD_PLACEHOLDERS: Record = { "gateway.remote.tlsFingerprint": "sha256:ab12cd34…", "gateway.remote.sshTarget": "user@host", "gateway.controlUi.basePath": "/clawdbot", + "agents.list[].identity.avatar": "avatars/clawd.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..2fe689f95 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: workspace-relative path, http(s) URL, or data URI. */ avatar?: string; }; diff --git a/src/config/validation.ts b/src/config/validation.ts index 7915d8e19..6ee848f1f 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -1,3 +1,5 @@ +import path from "node:path"; + import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { CHANNEL_IDS } from "../channels/registry.js"; import { @@ -13,6 +15,60 @@ import { findLegacyConfigIssues } from "./legacy.js"; import type { ClawdbotConfig, ConfigValidationIssue } from "./types.js"; import { ClawdbotSchema } from "./zod-schema.js"; +const AVATAR_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i; +const AVATAR_DATA_RE = /^data:/i; +const AVATAR_HTTP_RE = /^https?:\/\//i; +const WINDOWS_ABS_RE = /^[a-zA-Z]:[\\/]/; + +function isWorkspaceAvatarPath(value: string, workspaceDir: string): boolean { + const workspaceRoot = path.resolve(workspaceDir); + const resolved = path.resolve(workspaceRoot, value); + const relative = path.relative(workspaceRoot, resolved); + if (relative === "") return true; + if (relative.startsWith("..")) return false; + return !path.isAbsolute(relative); +} + +function validateIdentityAvatar(config: ClawdbotConfig): ConfigValidationIssue[] { + const agents = config.agents?.list; + if (!Array.isArray(agents) || agents.length === 0) return []; + const issues: ConfigValidationIssue[] = []; + for (const [index, entry] of agents.entries()) { + if (!entry || typeof entry !== "object") continue; + const avatarRaw = entry.identity?.avatar; + if (typeof avatarRaw !== "string") continue; + const avatar = avatarRaw.trim(); + if (!avatar) continue; + if (AVATAR_DATA_RE.test(avatar) || AVATAR_HTTP_RE.test(avatar)) continue; + if (avatar.startsWith("~")) { + issues.push({ + path: `agents.list.${index}.identity.avatar`, + message: "identity.avatar must be a workspace-relative path, http(s) URL, or data URI.", + }); + continue; + } + const hasScheme = AVATAR_SCHEME_RE.test(avatar); + if (hasScheme && !WINDOWS_ABS_RE.test(avatar)) { + issues.push({ + path: `agents.list.${index}.identity.avatar`, + message: "identity.avatar must be a workspace-relative path, http(s) URL, or data URI.", + }); + continue; + } + const workspaceDir = resolveAgentWorkspaceDir( + config, + entry.id ?? resolveDefaultAgentId(config), + ); + if (!isWorkspaceAvatarPath(avatar, workspaceDir)) { + issues.push({ + path: `agents.list.${index}.identity.avatar`, + message: "identity.avatar must stay within the agent workspace.", + }); + } + } + return issues; +} + export function validateConfigObject( raw: unknown, ): { ok: true; config: ClawdbotConfig } | { ok: false; issues: ConfigValidationIssue[] } { @@ -48,6 +104,10 @@ export function validateConfigObject( ], }; } + const avatarIssues = validateIdentityAvatar(validated.data as ClawdbotConfig); + if (avatarIssues.length > 0) { + return { ok: false, issues: avatarIssues }; + } return { ok: true, config: applyModelDefaults( 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/protocol/schema/agents-models-skills.ts b/src/gateway/protocol/schema/agents-models-skills.ts index edf655ac5..c3b39257f 100644 --- a/src/gateway/protocol/schema/agents-models-skills.ts +++ b/src/gateway/protocol/schema/agents-models-skills.ts @@ -17,6 +17,18 @@ export const AgentSummarySchema = Type.Object( { id: NonEmptyString, name: Type.Optional(NonEmptyString), + identity: Type.Optional( + Type.Object( + { + name: Type.Optional(NonEmptyString), + theme: Type.Optional(NonEmptyString), + emoji: Type.Optional(NonEmptyString), + avatar: Type.Optional(NonEmptyString), + avatarUrl: Type.Optional(NonEmptyString), + }, + { additionalProperties: false }, + ), + ), }, { additionalProperties: false }, ); 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/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index b77e44817..1c5934aa5 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -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 = { + ".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(); + 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 }; diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 77774d6d7..99491a7f9 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -46,6 +46,13 @@ export type GatewaySessionRow = { export type GatewayAgentRow = { id: string; name?: string; + identity?: { + name?: string; + theme?: string; + emoji?: string; + avatar?: string; + avatarUrl?: string; + }; }; export type SessionsListResult = { diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts index f5ce59f1a..4fea3521a 100644 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; import { createTelegramBot } from "./bot.js"; @@ -151,6 +152,11 @@ describe("createTelegramBot", () => { replySpy.mockReset(); loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, identity: { name: "Bert" }, messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, channels: { @@ -181,8 +187,10 @@ describe("createTelegramBot", () => { expect(payload.WasMentioned).toBe(true); expect(payload.SenderName).toBe("Ada"); expect(payload.SenderId).toBe("9"); + const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); + const timestampPattern = escapeRegExp(expectedTimestamp); expect(payload.Body).toMatch( - /^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/, + new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`), ); }); it("keeps group envelope headers stable (sender identity is separate)", async () => { @@ -191,6 +199,11 @@ describe("createTelegramBot", () => { replySpy.mockReset(); loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, channels: { telegram: { groupPolicy: "open", @@ -224,8 +237,10 @@ describe("createTelegramBot", () => { expect(payload.SenderName).toBe("Ada Lovelace"); expect(payload.SenderId).toBe("99"); expect(payload.SenderUsername).toBe("ada"); + const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); + const timestampPattern = escapeRegExp(expectedTimestamp); expect(payload.Body).toMatch( - /^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/, + new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`), ); }); it("reacts to mention-gated group messages when ackReaction is enabled", async () => { diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts index b11ecb058..ab43c4269 100644 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; import { resolveTelegramFetch } from "./fetch.js"; @@ -128,6 +129,11 @@ describe("createTelegramBot", () => { process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] }, }, @@ -334,8 +340,12 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; + const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); + const timestampPattern = escapeRegExp(expectedTimestamp); expect(payload.Body).toMatch( - /^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09 01:00 [^\]]+\]/, + new RegExp( + `^\\[Telegram Ada Lovelace \\(@ada_bot\\) id:1234 (\\+\\d+[smhd] )?${timestampPattern}\\]`, + ), ); expect(payload.Body).toContain("hello world"); } finally { diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index fadf2af46..7833ded93 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -10,6 +10,7 @@ import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js"; import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; import * as replyModule from "../auto-reply/reply.js"; import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js"; +import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; import { resolveTelegramFetch } from "./fetch.js"; @@ -153,6 +154,11 @@ describe("createTelegramBot", () => { process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] }, }, @@ -455,8 +461,12 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; + const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); + const timestampPattern = escapeRegExp(expectedTimestamp); expect(payload.Body).toMatch( - /^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09 01:00 [^\]]+\]/, + new RegExp( + `^\\[Telegram Ada Lovelace \\(@ada_bot\\) id:1234 (\\+\\d+[smhd] )?${timestampPattern}\\]`, + ), ); expect(payload.Body).toContain("hello world"); } finally { @@ -561,6 +571,11 @@ describe("createTelegramBot", () => { replySpy.mockReset(); loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, identity: { name: "Bert" }, messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } }, channels: { @@ -590,8 +605,10 @@ describe("createTelegramBot", () => { const payload = replySpy.mock.calls[0][0]; expectInboundContextContract(payload); expect(payload.WasMentioned).toBe(true); + const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); + const timestampPattern = escapeRegExp(expectedTimestamp); expect(payload.Body).toMatch( - /^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/, + new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`), ); expect(payload.SenderName).toBe("Ada"); expect(payload.SenderId).toBe("9"); @@ -603,6 +620,11 @@ describe("createTelegramBot", () => { replySpy.mockReset(); loadConfig.mockReturnValue({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, channels: { telegram: { groupPolicy: "open", @@ -634,8 +656,10 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expectInboundContextContract(payload); + const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); + const timestampPattern = escapeRegExp(expectedTimestamp); expect(payload.Body).toMatch( - /^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/, + new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`), ); expect(payload.SenderName).toBe("Ada Lovelace"); expect(payload.SenderId).toBe("99"); diff --git a/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts b/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts index b9a15e869..8995d3ec8 100644 --- a/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts +++ b/src/web/auto-reply.web-auto-reply.reconnects-after-connection-close.test.ts @@ -3,6 +3,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; vi.mock("../agents/pi-embedded.js", () => ({ abortEmbeddedPiRun: vi.fn().mockReturnValue(false), @@ -297,6 +298,11 @@ describe("web auto-reply", () => { }; setLoadConfigMock(() => ({ + agents: { + defaults: { + envelopeTimezone: "utc", + }, + }, session: { store: store.storePath }, })); @@ -328,12 +334,16 @@ describe("web auto-reply", () => { expect(resolver).toHaveBeenCalledTimes(2); const firstArgs = resolver.mock.calls[0][0]; const secondArgs = resolver.mock.calls[1][0]; + const firstTimestamp = formatEnvelopeTimestamp(new Date("2025-01-01T00:00:00Z")); + const secondTimestamp = formatEnvelopeTimestamp(new Date("2025-01-01T01:00:00Z")); + const firstPattern = escapeRegExp(firstTimestamp); + const secondPattern = escapeRegExp(secondTimestamp); expect(firstArgs.Body).toMatch( - /\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01 01:00 [^\]]+\] \[clawdbot\] first/, + new RegExp(`\\[WhatsApp \\+1 (\\+\\d+[smhd] )?${firstPattern}\\] \\[clawdbot\\] first`), ); expect(firstArgs.Body).not.toContain("second"); expect(secondArgs.Body).toMatch( - /\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01 02:00 [^\]]+\] \[clawdbot\] second/, + new RegExp(`\\[WhatsApp \\+1 (\\+\\d+[smhd] )?${secondPattern}\\] \\[clawdbot\\] second`), ); expect(secondArgs.Body).not.toContain("first"); diff --git a/test/helpers/envelope-timestamp.ts b/test/helpers/envelope-timestamp.ts new file mode 100644 index 000000000..934608204 --- /dev/null +++ b/test/helpers/envelope-timestamp.ts @@ -0,0 +1,55 @@ +type EnvelopeTimestampZone = string; + +function formatUtcTimestamp(date: Date): string { + const yyyy = String(date.getUTCFullYear()).padStart(4, "0"); + const mm = String(date.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(date.getUTCDate()).padStart(2, "0"); + const hh = String(date.getUTCHours()).padStart(2, "0"); + const min = String(date.getUTCMinutes()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd}T${hh}:${min}Z`; +} + +function formatZonedTimestamp(date: Date, timeZone?: string): string { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + timeZoneName: "short", + }).formatToParts(date); + + const pick = (type: string) => parts.find((part) => part.type === type)?.value; + const yyyy = pick("year"); + const mm = pick("month"); + const dd = pick("day"); + const hh = pick("hour"); + const min = pick("minute"); + const tz = [...parts] + .reverse() + .find((part) => part.type === "timeZoneName") + ?.value?.trim(); + + if (!yyyy || !mm || !dd || !hh || !min) { + throw new Error("Missing date parts for envelope timestamp formatting."); + } + + return `${yyyy}-${mm}-${dd} ${hh}:${min}${tz ? ` ${tz}` : ""}`; +} + +export function formatEnvelopeTimestamp(date: Date, zone: EnvelopeTimestampZone = "utc"): string { + const normalized = zone.trim().toLowerCase(); + if (normalized === "utc" || normalized === "gmt") return formatUtcTimestamp(date); + if (normalized === "local" || normalized === "host") return formatZonedTimestamp(date); + return formatZonedTimestamp(date, zone); +} + +export function formatLocalEnvelopeTimestamp(date: Date): string { + return formatEnvelopeTimestamp(date, "local"); +} + +export function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} 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-gateway.ts b/ui/src/ui/app-gateway.ts index f300e0b37..d5bc0c5d9 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -1,10 +1,11 @@ import { loadChatHistory } from "./controllers/chat"; import { loadDevices } from "./controllers/devices"; import { loadNodes } from "./controllers/nodes"; +import { loadAgents } from "./controllers/agents"; import type { GatewayEventFrame, GatewayHelloOk } from "./gateway"; import { GatewayBrowserClient } from "./gateway"; import type { EventLogEntry } from "./app-events"; -import type { PresenceEntry, HealthSnapshot, StatusSummary } from "./types"; +import type { AgentsListResult, PresenceEntry, HealthSnapshot, StatusSummary } from "./types"; import type { Tab } from "./navigation"; import type { UiSettings } from "./storage"; import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream"; @@ -38,6 +39,9 @@ type GatewayHost = { presenceEntries: PresenceEntry[]; presenceError: string | null; presenceStatus: StatusSummary | null; + agentsLoading: boolean; + agentsList: AgentsListResult | null; + agentsError: string | null; debugHealth: HealthSnapshot | null; sessionKey: string; chatRunId: string | null; @@ -117,6 +121,7 @@ export function connectGateway(host: GatewayHost) { host.connected = true; host.hello = hello; applySnapshot(host, hello); + void loadAgents(host as unknown as ClawdbotApp); void loadNodes(host as unknown as ClawdbotApp, { quiet: true }); void loadDevices(host as unknown as ClawdbotApp, { quiet: true }); void refreshActiveTab(host as unknown as Parameters[0]); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 4a6342ae0..37f0b6bea 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -2,6 +2,7 @@ import { html, nothing } from "lit"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway"; import type { AppViewState } from "./app-view-state"; +import { parseAgentSessionKey } from "../../../src/routing/session-key.js"; import { TAB_GROUPS, iconForTab, @@ -28,6 +29,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"; @@ -79,6 +81,24 @@ import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } fr import { loadDebug, callDebugMethod } from "./controllers/debug"; import { loadLogs } from "./controllers/logs"; +const AVATAR_DATA_RE = /^data:/i; +const AVATAR_HTTP_RE = /^https?:\/\//i; + +function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { + const list = state.agentsList?.agents ?? []; + const parsed = parseAgentSessionKey(state.sessionKey); + const agentId = + parsed?.agentId ?? + state.agentsList?.defaultId ?? + "main"; + const agent = list.find((entry) => entry.id === agentId); + const identity = agent?.identity; + const candidate = identity?.avatarUrl ?? identity?.avatar; + if (!candidate) return undefined; + if (AVATAR_DATA_RE.test(candidate) || AVATAR_HTTP_RE.test(candidate)) return candidate; + return identity?.avatarUrl; +} + export function renderApp(state: AppViewState) { const presenceCount = state.presenceEntries.length; const sessionsCount = state.sessionsResult?.count ?? null; @@ -86,6 +106,8 @@ export function renderApp(state: AppViewState) { const chatDisabledReason = state.connected ? null : "Disconnected from gateway."; const isChat = state.tab === "chat"; const chatFocus = isChat && state.settings.chatFocusMode; + const assistantAvatarUrl = resolveAssistantAvatarUrl(state); + const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null; return html`
@@ -413,11 +435,13 @@ export function renderApp(state: AppViewState) { lastActiveSessionKey: next, }); void loadChatHistory(state); + void refreshChatAvatar(state); }, thinkingLevel: state.chatThinkingLevel, showThinking: state.settings.chatShowThinking, loading: state.chatLoading, sending: state.chatSending, + assistantAvatarUrl: chatAvatarUrl, messages: state.chatMessages, toolMessages: state.chatToolMessages, stream: state.chatStream, @@ -432,7 +456,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..3f969e5b9 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -4,6 +4,7 @@ import type { UiSettings } from "./storage"; import type { ThemeMode } from "./theme"; import type { ThemeTransitionContext } from "./theme-transition"; import type { + AgentsListResult, ChannelsStatusSnapshot, ConfigSnapshot, CronJob, @@ -48,6 +49,7 @@ export type AppViewState = { chatToolMessages: unknown[]; chatStream: string | null; chatRunId: string | null; + chatAvatarUrl: string | null; chatThinkingLevel: string | null; chatQueue: ChatQueueItem[]; nodesLoading: boolean; @@ -94,6 +96,9 @@ export type AppViewState = { presenceEntries: PresenceEntry[]; presenceError: string | null; presenceStatus: string | null; + agentsLoading: boolean; + agentsList: AgentsListResult | null; + agentsError: string | null; sessionsLoading: boolean; sessionsResult: SessionsListResult | null; sessionsError: string | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 9ae886048..35506e76a 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -7,6 +7,7 @@ import { renderApp } from "./app-render"; import type { Tab } from "./navigation"; import type { ResolvedTheme, ThemeMode } from "./theme"; import type { + AgentsListResult, ConfigSnapshot, ConfigUiHints, CronJob, @@ -106,6 +107,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 @@ -168,6 +170,10 @@ export class ClawdbotApp extends LitElement { @state() presenceError: string | null = null; @state() presenceStatus: string | null = null; + @state() agentsLoading = false; + @state() agentsList: AgentsListResult | null = null; + @state() agentsError: string | null = null; + @state() sessionsLoading = false; @state() sessionsResult: SessionsListResult | null = null; @state() sessionsError: string | null = null; diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts index ca973ce08..ff155a256 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)}