From a59ac5cf6f948b3277511698069d4dacb6c85b34 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 22 Jan 2026 05:21:47 +0000 Subject: [PATCH] feat: add agent identity avatars (#1329) (thanks @dlauer) --- CHANGELOG.md | 1 + docs/cli/agents.md | 48 ++++++++++++ docs/gateway/configuration.md | 12 ++- docs/reference/templates/IDENTITY.md | 6 +- src/cli/program/register.agent.ts | 3 +- src/commands/agents.identity.test.ts | 32 +++++++- src/config/config.identity-avatar.test.ts | 54 +++++++++++++ src/config/schema.ts | 5 +- src/config/types.base.ts | 2 +- src/config/validation.ts | 60 ++++++++++++++ .../protocol/schema/agents-models-skills.ts | 12 +++ src/gateway/session-utils.ts | 78 ++++++++++++++++++- src/gateway/session-utils.types.ts | 7 ++ ...patterns-match-without-botusername.test.ts | 16 +++- ...gram-bot.installs-grammy-throttler.test.ts | 10 ++- src/telegram/bot.test.ts | 22 +++++- ....reconnects-after-connection-close.test.ts | 12 ++- test/helpers/envelope-timestamp.ts | 32 ++++++++ ui/src/ui/app-gateway.ts | 7 +- ui/src/ui/app-render.ts | 23 +++++- ui/src/ui/app-view-state.ts | 4 + ui/src/ui/app.ts | 5 ++ ui/src/ui/chat/grouped-render.ts | 2 +- ui/src/ui/controllers/agents.ts | 25 ++++++ ui/src/ui/types.ts | 19 +++++ ui/src/ui/views/chat.ts | 2 +- 26 files changed, 477 insertions(+), 22 deletions(-) create mode 100644 src/config/config.identity-avatar.test.ts create mode 100644 test/helpers/envelope-timestamp.ts create mode 100644 ui/src/ui/controllers/agents.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0062e5205..da54152b6 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) 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 per-message Gmail search example for gog. (#1220) Thanks @mbelinky. diff --git a/docs/cli/agents.md b/docs/cli/agents.md index 6a388b4b6..e7df32e52 100644 --- a/docs/cli/agents.md +++ b/docs/cli/agents.md @@ -21,3 +21,51 @@ 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 d6110d4dc..389f2f223 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -402,13 +402,23 @@ If set, Clawdbot derives defaults (only when you haven’t set them explicitly): - `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: "🦥", avatar: "avatars/sam.png" } + identity: { + name: "Samantha", + theme: "helpful sloth", + emoji: "🦥", + avatar: "avatars/samantha.png" + } } ] } diff --git a/docs/reference/templates/IDENTITY.md b/docs/reference/templates/IDENTITY.md index b1645be1e..196277776 100644 --- a/docs/reference/templates/IDENTITY.md +++ b/docs/reference/templates/IDENTITY.md @@ -11,8 +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, or a URL/data URL)* +- **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/cli/program/register.agent.ts b/src/cli/program/register.agent.ts index bf8970bc3..d597efde9 100644 --- a/src/cli/program/register.agent.ts +++ b/src/cli/program/register.agent.ts @@ -147,7 +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("--avatar ", "Identity avatar (workspace path, http(s) URL, or data URI)") .option("--json", "Output JSON summary", false) .addHelpText( "after", @@ -156,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", diff --git a/src/commands/agents.identity.test.ts b/src/commands/agents.identity.test.ts index a89f1147c..1b214ad19 100644 --- a/src/commands/agents.identity.test.ts +++ b/src/commands/agents.identity.test.ts @@ -126,7 +126,7 @@ describe("agents set-identity command", () => { "- Name: Clawd", "- Theme: space lobster", "- Emoji: :)", - "- Avatar: avatars/base.png", + "- Avatar: avatars/clawd.png", "", ].join("\n"), "utf-8", @@ -138,7 +138,13 @@ describe("agents set-identity command", () => { }); await agentsSetIdentityCommand( - { workspace, fromIdentity: true, name: "Nova", emoji: "🦞", avatar: "avatars/custom.png" }, + { + workspace, + fromIdentity: true, + name: "Nova", + emoji: "🦞", + avatar: "https://example.com/override.png", + }, runtime, ); @@ -150,7 +156,7 @@ describe("agents set-identity command", () => { name: "Nova", theme: "space lobster", emoji: "🦞", - avatar: "avatars/custom.png", + avatar: "https://example.com/override.png", }); }); @@ -216,6 +222,26 @@ describe("agents set-identity command", () => { }); }); + 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", + }); + }); + it("errors when identity data is missing", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-identity-")); const workspace = path.join(root, "work"); 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 a72021cfc..4fca28504 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", @@ -511,6 +512,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,7 +619,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", + "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 91eeac1ab..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; - /** Avatar image path (workspace-relative) or a URL/data URL. Local files must live in the workspace. */ + /** 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/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/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 7134d7d3b..1379b6e6f 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,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + escapeRegExp, + formatLocalEnvelopeTimestamp, +} from "../../test/helpers/envelope-timestamp.js"; import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; import { createTelegramBot } from "./bot.js"; @@ -176,7 +180,11 @@ describe("createTelegramBot", () => { expect(payload.WasMentioned).toBe(true); expect(payload.SenderName).toBe("Ada"); expect(payload.SenderId).toBe("9"); - expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/); + const expectedTimestamp = formatLocalEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); + const timestampPattern = escapeRegExp(expectedTimestamp); + expect(payload.Body).toMatch( + new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`), + ); }); it("keeps group envelope headers stable (sender identity is separate)", async () => { onSpy.mockReset(); @@ -217,7 +225,11 @@ describe("createTelegramBot", () => { expect(payload.SenderName).toBe("Ada Lovelace"); expect(payload.SenderId).toBe("99"); expect(payload.SenderUsername).toBe("ada"); - expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/); + const expectedTimestamp = formatLocalEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); + const timestampPattern = escapeRegExp(expectedTimestamp); + expect(payload.Body).toMatch( + new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`), + ); }); it("reacts to mention-gated group messages when ackReaction is enabled", async () => { onSpy.mockReset(); 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 de7f6b62b..141532796 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,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + escapeRegExp, + formatLocalEnvelopeTimestamp, +} 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"; @@ -328,8 +332,12 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; + const expectedTimestamp = formatLocalEnvelopeTimestamp(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-09T00:00Z\]/, + 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 77f50b41f..2d04d6aa4 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -10,6 +10,10 @@ 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, + formatLocalEnvelopeTimestamp, +} from "../../test/helpers/envelope-timestamp.js"; import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; import { resolveTelegramFetch } from "./fetch.js"; @@ -450,8 +454,12 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; + const expectedTimestamp = formatLocalEnvelopeTimestamp(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-09T00:00Z\]/, + new RegExp( + `^\\[Telegram Ada Lovelace \\(@ada_bot\\) id:1234 (\\+\\d+[smhd] )?${timestampPattern}\\]`, + ), ); expect(payload.Body).toContain("hello world"); } finally { @@ -585,7 +593,11 @@ describe("createTelegramBot", () => { const payload = replySpy.mock.calls[0][0]; expectInboundContextContract(payload); expect(payload.WasMentioned).toBe(true); - expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/); + const expectedTimestamp = formatLocalEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); + const timestampPattern = escapeRegExp(expectedTimestamp); + expect(payload.Body).toMatch( + new RegExp(`^\\[Telegram Test Group id:7 (\\+\\d+[smhd] )?${timestampPattern}\\]`), + ); expect(payload.SenderName).toBe("Ada"); expect(payload.SenderId).toBe("9"); }); @@ -627,7 +639,11 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expectInboundContextContract(payload); - expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/); + const expectedTimestamp = formatLocalEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); + const timestampPattern = escapeRegExp(expectedTimestamp); + expect(payload.Body).toMatch( + new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`), + ); expect(payload.SenderName).toBe("Ada Lovelace"); expect(payload.SenderId).toBe("99"); expect(payload.SenderUsername).toBe("ada"); 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 411875e21..870a018d5 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,10 @@ 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, + formatLocalEnvelopeTimestamp, +} from "../../test/helpers/envelope-timestamp.js"; vi.mock("../agents/pi-embedded.js", () => ({ abortEmbeddedPiRun: vi.fn().mockReturnValue(false), @@ -328,12 +332,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 = formatLocalEnvelopeTimestamp(new Date("2025-01-01T00:00:00Z")); + const secondTimestamp = formatLocalEnvelopeTimestamp(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-01T00:00Z\] \[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-01T01:00Z\] \[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..135063e41 --- /dev/null +++ b/test/helpers/envelope-timestamp.ts @@ -0,0 +1,32 @@ +export function formatLocalEnvelopeTimestamp(date: Date): string { + const parts = new Intl.DateTimeFormat("en-US", { + 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 escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} 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 8275ca147..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, @@ -80,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; @@ -87,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`
@@ -420,11 +441,11 @@ export function renderApp(state: AppViewState) { showThinking: state.settings.chatShowThinking, loading: state.chatLoading, sending: state.chatSending, + assistantAvatarUrl: chatAvatarUrl, messages: state.chatMessages, toolMessages: state.chatToolMessages, stream: state.chatStream, streamStartedAt: state.chatStreamStartedAt, - assistantAvatarUrl: state.chatAvatarUrl, draft: state.chatMessage, queue: state.chatQueue, connected: state.connected, diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 5af2d0a8e..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, @@ -95,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 53f21316c..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, @@ -169,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 c90493219..ff155a256 100644 --- a/ui/src/ui/chat/grouped-render.ts +++ b/ui/src/ui/chat/grouped-render.ts @@ -30,8 +30,8 @@ export function renderReadingIndicatorGroup(assistantAvatarUrl?: string | null) export function renderStreamingGroup( text: string, startedAt: number, - onOpenSidebar?: (content: string) => void, assistantAvatarUrl?: string | null, + onOpenSidebar?: (content: string) => void, ) { const timestamp = new Date(startedAt).toLocaleTimeString([], { hour: "numeric", diff --git a/ui/src/ui/controllers/agents.ts b/ui/src/ui/controllers/agents.ts new file mode 100644 index 000000000..deb79ef6b --- /dev/null +++ b/ui/src/ui/controllers/agents.ts @@ -0,0 +1,25 @@ +import type { GatewayBrowserClient } from "../gateway"; +import type { AgentsListResult } from "../types"; + +export type AgentsState = { + client: GatewayBrowserClient | null; + connected: boolean; + agentsLoading: boolean; + agentsError: string | null; + agentsList: AgentsListResult | null; +}; + +export async function loadAgents(state: AgentsState) { + if (!state.client || !state.connected) return; + if (state.agentsLoading) return; + state.agentsLoading = true; + state.agentsError = null; + try { + const res = (await state.client.request("agents.list", {})) as AgentsListResult | undefined; + if (res) state.agentsList = res; + } catch (err) { + state.agentsError = String(err); + } finally { + state.agentsLoading = false; + } +} diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 6cdbfb029..be278b8e5 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -294,6 +294,25 @@ export type GatewaySessionsDefaults = { contextTokens: number | null; }; +export type GatewayAgentRow = { + id: string; + name?: string; + identity?: { + name?: string; + theme?: string; + emoji?: string; + avatar?: string; + avatarUrl?: string; + }; +}; + +export type AgentsListResult = { + defaultId: string; + mainKey: string; + scope: string; + agents: GatewayAgentRow[]; +}; + export type GatewaySessionRow = { key: string; kind: "direct" | "group" | "global" | "unknown"; diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts index fddc98b68..c8938521e 100644 --- a/ui/src/ui/views/chat.ts +++ b/ui/src/ui/views/chat.ts @@ -122,8 +122,8 @@ export function renderChat(props: ChatProps) { return renderStreamingGroup( item.text, item.startedAt, - props.onOpenSidebar, props.assistantAvatarUrl ?? null, + props.onOpenSidebar, ); }