feat: add agent identity avatars (#1329) (thanks @dlauer)
This commit is contained in:
@@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot
|
|||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
- 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.
|
- 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 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.
|
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||||
|
|||||||
@@ -21,3 +21,51 @@ clawdbot agents set-identity --workspace ~/clawd --from-identity
|
|||||||
clawdbot agents set-identity --agent main --avatar avatars/clawd.png
|
clawdbot agents set-identity --agent main --avatar avatars/clawd.png
|
||||||
clawdbot agents delete work
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|||||||
@@ -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)
|
- `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 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
|
```json5
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
list: [
|
list: [
|
||||||
{
|
{
|
||||||
id: "main",
|
id: "main",
|
||||||
identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥", avatar: "avatars/sam.png" }
|
identity: {
|
||||||
|
name: "Samantha",
|
||||||
|
theme: "helpful sloth",
|
||||||
|
emoji: "🦥",
|
||||||
|
avatar: "avatars/samantha.png"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ read_when:
|
|||||||
- **Creature:** *(AI? robot? familiar? ghost in the machine? something weirder?)*
|
- **Creature:** *(AI? robot? familiar? ghost in the machine? something weirder?)*
|
||||||
- **Vibe:** *(how do you come across? sharp? warm? chaotic? calm?)*
|
- **Vibe:** *(how do you come across? sharp? warm? chaotic? calm?)*
|
||||||
- **Emoji:** *(your signature — pick one that feels right)*
|
- **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.
|
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`.
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
|
|||||||
.option("--name <name>", "Identity name")
|
.option("--name <name>", "Identity name")
|
||||||
.option("--theme <theme>", "Identity theme")
|
.option("--theme <theme>", "Identity theme")
|
||||||
.option("--emoji <emoji>", "Identity emoji")
|
.option("--emoji <emoji>", "Identity emoji")
|
||||||
.option("--avatar <path>", "Identity avatar (workspace-relative, URL, or data: URL)")
|
.option("--avatar <value>", "Identity avatar (workspace path, http(s) URL, or data URI)")
|
||||||
.option("--json", "Output JSON summary", false)
|
.option("--json", "Output JSON summary", false)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
@@ -156,6 +156,7 @@ ${theme.muted("Docs:")} ${formatDocsLink("/cli/agent", "docs.clawd.bot/cli/agent
|
|||||||
${theme.heading("Examples:")}
|
${theme.heading("Examples:")}
|
||||||
${formatHelpExamples([
|
${formatHelpExamples([
|
||||||
['clawdbot agents set-identity --agent main --name "Clawd" --emoji "🦞"', "Set name + emoji."],
|
['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 --workspace ~/clawd --from-identity", "Load from IDENTITY.md."],
|
||||||
[
|
[
|
||||||
"clawdbot agents set-identity --identity-file ~/clawd/IDENTITY.md --agent main",
|
"clawdbot agents set-identity --identity-file ~/clawd/IDENTITY.md --agent main",
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ describe("agents set-identity command", () => {
|
|||||||
"- Name: Clawd",
|
"- Name: Clawd",
|
||||||
"- Theme: space lobster",
|
"- Theme: space lobster",
|
||||||
"- Emoji: :)",
|
"- Emoji: :)",
|
||||||
"- Avatar: avatars/base.png",
|
"- Avatar: avatars/clawd.png",
|
||||||
"",
|
"",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
@@ -138,7 +138,13 @@ describe("agents set-identity command", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await agentsSetIdentityCommand(
|
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,
|
runtime,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -150,7 +156,7 @@ describe("agents set-identity command", () => {
|
|||||||
name: "Nova",
|
name: "Nova",
|
||||||
theme: "space lobster",
|
theme: "space lobster",
|
||||||
emoji: "🦞",
|
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<string, string> }> };
|
||||||
|
};
|
||||||
|
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 () => {
|
it("errors when identity data is missing", async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-identity-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-identity-"));
|
||||||
const workspace = path.join(root, "work");
|
const workspace = path.join(root, "work");
|
||||||
|
|||||||
54
src/config/config.identity-avatar.test.ts
Normal file
54
src/config/config.identity-avatar.test.ts
Normal file
@@ -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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -119,6 +119,7 @@ const FIELD_LABELS: Record<string, string> = {
|
|||||||
"diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages",
|
"diagnostics.cacheTrace.includeMessages": "Cache Trace Include Messages",
|
||||||
"diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt",
|
"diagnostics.cacheTrace.includePrompt": "Cache Trace Include Prompt",
|
||||||
"diagnostics.cacheTrace.includeSystem": "Cache Trace Include System",
|
"diagnostics.cacheTrace.includeSystem": "Cache Trace Include System",
|
||||||
|
"agents.list.*.identity.avatar": "Identity Avatar",
|
||||||
"gateway.remote.url": "Remote Gateway URL",
|
"gateway.remote.url": "Remote Gateway URL",
|
||||||
"gateway.remote.sshTarget": "Remote Gateway SSH Target",
|
"gateway.remote.sshTarget": "Remote Gateway SSH Target",
|
||||||
"gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
|
"gateway.remote.sshIdentity": "Remote Gateway SSH Identity",
|
||||||
@@ -511,6 +512,8 @@ const FIELD_HELP: Record<string, string> = {
|
|||||||
"Resolved install directory (usually ~/.clawdbot/extensions/<id>).",
|
"Resolved install directory (usually ~/.clawdbot/extensions/<id>).",
|
||||||
"plugins.installs.*.version": "Version recorded at install time (if available).",
|
"plugins.installs.*.version": "Version recorded at install time (if available).",
|
||||||
"plugins.installs.*.installedAt": "ISO timestamp of last install/update.",
|
"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.primary": "Primary model (provider/model).",
|
||||||
"agents.defaults.model.fallbacks":
|
"agents.defaults.model.fallbacks":
|
||||||
"Ordered fallback models (provider/model). Used when the primary model fails.",
|
"Ordered fallback models (provider/model). Used when the primary model fails.",
|
||||||
@@ -616,7 +619,7 @@ const FIELD_PLACEHOLDERS: Record<string, string> = {
|
|||||||
"gateway.remote.tlsFingerprint": "sha256:ab12cd34…",
|
"gateway.remote.tlsFingerprint": "sha256:ab12cd34…",
|
||||||
"gateway.remote.sshTarget": "user@host",
|
"gateway.remote.sshTarget": "user@host",
|
||||||
"gateway.controlUi.basePath": "/clawdbot",
|
"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];
|
const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i];
|
||||||
|
|||||||
@@ -154,6 +154,6 @@ export type IdentityConfig = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
theme?: string;
|
theme?: string;
|
||||||
emoji?: 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;
|
avatar?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
import { CHANNEL_IDS } from "../channels/registry.js";
|
import { CHANNEL_IDS } from "../channels/registry.js";
|
||||||
import {
|
import {
|
||||||
@@ -13,6 +15,60 @@ import { findLegacyConfigIssues } from "./legacy.js";
|
|||||||
import type { ClawdbotConfig, ConfigValidationIssue } from "./types.js";
|
import type { ClawdbotConfig, ConfigValidationIssue } from "./types.js";
|
||||||
import { ClawdbotSchema } from "./zod-schema.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(
|
export function validateConfigObject(
|
||||||
raw: unknown,
|
raw: unknown,
|
||||||
): { ok: true; config: ClawdbotConfig } | { ok: false; issues: ConfigValidationIssue[] } {
|
): { 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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
config: applyModelDefaults(
|
config: applyModelDefaults(
|
||||||
|
|||||||
@@ -17,6 +17,18 @@ export const AgentSummarySchema = Type.Object(
|
|||||||
{
|
{
|
||||||
id: NonEmptyString,
|
id: NonEmptyString,
|
||||||
name: Type.Optional(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 },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
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 { lookupContextTokens } from "../agents/context.js";
|
||||||
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||||
@@ -50,6 +50,62 @@ export type {
|
|||||||
} from "./session-utils.types.js";
|
} from "./session-utils.types.js";
|
||||||
|
|
||||||
const DERIVED_TITLE_MAX_LEN = 60;
|
const DERIVED_TITLE_MAX_LEN = 60;
|
||||||
|
const AVATAR_MAX_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
const AVATAR_DATA_RE = /^data:/i;
|
||||||
|
const AVATAR_HTTP_RE = /^https?:\/\//i;
|
||||||
|
const AVATAR_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
||||||
|
const WINDOWS_ABS_RE = /^[a-zA-Z]:[\\/]/;
|
||||||
|
|
||||||
|
const AVATAR_MIME_BY_EXT: Record<string, string> = {
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".bmp": "image/bmp",
|
||||||
|
".tif": "image/tiff",
|
||||||
|
".tiff": "image/tiff",
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveAvatarMime(filePath: string): string {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
return AVATAR_MIME_BY_EXT[ext] ?? "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWorkspaceRelativePath(value: string): boolean {
|
||||||
|
if (!value) return false;
|
||||||
|
if (value.startsWith("~")) return false;
|
||||||
|
if (AVATAR_SCHEME_RE.test(value) && !WINDOWS_ABS_RE.test(value)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveIdentityAvatarUrl(
|
||||||
|
cfg: ClawdbotConfig,
|
||||||
|
agentId: string,
|
||||||
|
avatar: string | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!avatar) return undefined;
|
||||||
|
const trimmed = avatar.trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
if (AVATAR_DATA_RE.test(trimmed) || AVATAR_HTTP_RE.test(trimmed)) return trimmed;
|
||||||
|
if (!isWorkspaceRelativePath(trimmed)) return undefined;
|
||||||
|
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||||
|
const workspaceRoot = path.resolve(workspaceDir);
|
||||||
|
const resolved = path.resolve(workspaceRoot, trimmed);
|
||||||
|
const relative = path.relative(workspaceRoot, resolved);
|
||||||
|
if (relative.startsWith("..") || path.isAbsolute(relative)) return undefined;
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(resolved);
|
||||||
|
if (!stat.isFile() || stat.size > AVATAR_MAX_BYTES) return undefined;
|
||||||
|
const buffer = fs.readFileSync(resolved);
|
||||||
|
const mime = resolveAvatarMime(resolved);
|
||||||
|
return `data:${mime};base64,${buffer.toString("base64")}`;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatSessionIdPrefix(sessionId: string, updatedAt?: number | null): string {
|
function formatSessionIdPrefix(sessionId: string, updatedAt?: number | null): string {
|
||||||
const prefix = sessionId.slice(0, 8);
|
const prefix = sessionId.slice(0, 8);
|
||||||
@@ -189,11 +245,28 @@ export function listAgentsForGateway(cfg: ClawdbotConfig): {
|
|||||||
const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg));
|
const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg));
|
||||||
const mainKey = normalizeMainKey(cfg.session?.mainKey);
|
const mainKey = normalizeMainKey(cfg.session?.mainKey);
|
||||||
const scope = cfg.session?.scope ?? "per-sender";
|
const scope = cfg.session?.scope ?? "per-sender";
|
||||||
const configuredById = new Map<string, { name?: string }>();
|
const configuredById = new Map<
|
||||||
|
string,
|
||||||
|
{ name?: string; identity?: GatewayAgentRow["identity"] }
|
||||||
|
>();
|
||||||
for (const entry of cfg.agents?.list ?? []) {
|
for (const entry of cfg.agents?.list ?? []) {
|
||||||
if (!entry?.id) continue;
|
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), {
|
configuredById.set(normalizeAgentId(entry.id), {
|
||||||
name: typeof entry.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
|
name: typeof entry.name === "string" && entry.name.trim() ? entry.name.trim() : undefined,
|
||||||
|
identity,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const explicitIds = new Set(
|
const explicitIds = new Set(
|
||||||
@@ -213,6 +286,7 @@ export function listAgentsForGateway(cfg: ClawdbotConfig): {
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
name: meta?.name,
|
name: meta?.name,
|
||||||
|
identity: meta?.identity,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return { defaultId, mainKey, scope, agents };
|
return { defaultId, mainKey, scope, agents };
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ export type GatewaySessionRow = {
|
|||||||
export type GatewayAgentRow = {
|
export type GatewayAgentRow = {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
identity?: {
|
||||||
|
name?: string;
|
||||||
|
theme?: string;
|
||||||
|
emoji?: string;
|
||||||
|
avatar?: string;
|
||||||
|
avatarUrl?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SessionsListResult = {
|
export type SessionsListResult = {
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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 { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||||
import { createTelegramBot } from "./bot.js";
|
import { createTelegramBot } from "./bot.js";
|
||||||
|
|
||||||
@@ -176,7 +180,11 @@ describe("createTelegramBot", () => {
|
|||||||
expect(payload.WasMentioned).toBe(true);
|
expect(payload.WasMentioned).toBe(true);
|
||||||
expect(payload.SenderName).toBe("Ada");
|
expect(payload.SenderName).toBe("Ada");
|
||||||
expect(payload.SenderId).toBe("9");
|
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 () => {
|
it("keeps group envelope headers stable (sender identity is separate)", async () => {
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
@@ -217,7 +225,11 @@ describe("createTelegramBot", () => {
|
|||||||
expect(payload.SenderName).toBe("Ada Lovelace");
|
expect(payload.SenderName).toBe("Ada Lovelace");
|
||||||
expect(payload.SenderId).toBe("99");
|
expect(payload.SenderId).toBe("99");
|
||||||
expect(payload.SenderUsername).toBe("ada");
|
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 () => {
|
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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 { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||||
import { createTelegramBot, getTelegramSequentialKey } from "./bot.js";
|
import { createTelegramBot, getTelegramSequentialKey } from "./bot.js";
|
||||||
import { resolveTelegramFetch } from "./fetch.js";
|
import { resolveTelegramFetch } from "./fetch.js";
|
||||||
@@ -328,8 +332,12 @@ describe("createTelegramBot", () => {
|
|||||||
|
|
||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
const payload = replySpy.mock.calls[0][0];
|
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(
|
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");
|
expect(payload.Body).toContain("hello world");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import { listSkillCommandsForAgents } from "../auto-reply/skill-commands.js";
|
|||||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||||
import * as replyModule from "../auto-reply/reply.js";
|
import * as replyModule from "../auto-reply/reply.js";
|
||||||
import { expectInboundContextContract } from "../../test/helpers/inbound-contract.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 { createTelegramBot, getTelegramSequentialKey } from "./bot.js";
|
||||||
import { resolveTelegramFetch } from "./fetch.js";
|
import { resolveTelegramFetch } from "./fetch.js";
|
||||||
|
|
||||||
@@ -450,8 +454,12 @@ describe("createTelegramBot", () => {
|
|||||||
|
|
||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
const payload = replySpy.mock.calls[0][0];
|
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(
|
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");
|
expect(payload.Body).toContain("hello world");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -585,7 +593,11 @@ describe("createTelegramBot", () => {
|
|||||||
const payload = replySpy.mock.calls[0][0];
|
const payload = replySpy.mock.calls[0][0];
|
||||||
expectInboundContextContract(payload);
|
expectInboundContextContract(payload);
|
||||||
expect(payload.WasMentioned).toBe(true);
|
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.SenderName).toBe("Ada");
|
||||||
expect(payload.SenderId).toBe("9");
|
expect(payload.SenderId).toBe("9");
|
||||||
});
|
});
|
||||||
@@ -627,7 +639,11 @@ describe("createTelegramBot", () => {
|
|||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
const payload = replySpy.mock.calls[0][0];
|
const payload = replySpy.mock.calls[0][0];
|
||||||
expectInboundContextContract(payload);
|
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.SenderName).toBe("Ada Lovelace");
|
||||||
expect(payload.SenderId).toBe("99");
|
expect(payload.SenderId).toBe("99");
|
||||||
expect(payload.SenderUsername).toBe("ada");
|
expect(payload.SenderUsername).toBe("ada");
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
escapeRegExp,
|
||||||
|
formatLocalEnvelopeTimestamp,
|
||||||
|
} from "../../test/helpers/envelope-timestamp.js";
|
||||||
|
|
||||||
vi.mock("../agents/pi-embedded.js", () => ({
|
vi.mock("../agents/pi-embedded.js", () => ({
|
||||||
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
|
||||||
@@ -328,12 +332,16 @@ describe("web auto-reply", () => {
|
|||||||
expect(resolver).toHaveBeenCalledTimes(2);
|
expect(resolver).toHaveBeenCalledTimes(2);
|
||||||
const firstArgs = resolver.mock.calls[0][0];
|
const firstArgs = resolver.mock.calls[0][0];
|
||||||
const secondArgs = resolver.mock.calls[1][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(
|
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(firstArgs.Body).not.toContain("second");
|
||||||
expect(secondArgs.Body).toMatch(
|
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");
|
expect(secondArgs.Body).not.toContain("first");
|
||||||
|
|
||||||
|
|||||||
32
test/helpers/envelope-timestamp.ts
Normal file
32
test/helpers/envelope-timestamp.ts
Normal file
@@ -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, "\\$&");
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { loadChatHistory } from "./controllers/chat";
|
import { loadChatHistory } from "./controllers/chat";
|
||||||
import { loadDevices } from "./controllers/devices";
|
import { loadDevices } from "./controllers/devices";
|
||||||
import { loadNodes } from "./controllers/nodes";
|
import { loadNodes } from "./controllers/nodes";
|
||||||
|
import { loadAgents } from "./controllers/agents";
|
||||||
import type { GatewayEventFrame, GatewayHelloOk } from "./gateway";
|
import type { GatewayEventFrame, GatewayHelloOk } from "./gateway";
|
||||||
import { GatewayBrowserClient } from "./gateway";
|
import { GatewayBrowserClient } from "./gateway";
|
||||||
import type { EventLogEntry } from "./app-events";
|
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 { Tab } from "./navigation";
|
||||||
import type { UiSettings } from "./storage";
|
import type { UiSettings } from "./storage";
|
||||||
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream";
|
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream";
|
||||||
@@ -38,6 +39,9 @@ type GatewayHost = {
|
|||||||
presenceEntries: PresenceEntry[];
|
presenceEntries: PresenceEntry[];
|
||||||
presenceError: string | null;
|
presenceError: string | null;
|
||||||
presenceStatus: StatusSummary | null;
|
presenceStatus: StatusSummary | null;
|
||||||
|
agentsLoading: boolean;
|
||||||
|
agentsList: AgentsListResult | null;
|
||||||
|
agentsError: string | null;
|
||||||
debugHealth: HealthSnapshot | null;
|
debugHealth: HealthSnapshot | null;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
chatRunId: string | null;
|
chatRunId: string | null;
|
||||||
@@ -117,6 +121,7 @@ export function connectGateway(host: GatewayHost) {
|
|||||||
host.connected = true;
|
host.connected = true;
|
||||||
host.hello = hello;
|
host.hello = hello;
|
||||||
applySnapshot(host, hello);
|
applySnapshot(host, hello);
|
||||||
|
void loadAgents(host as unknown as ClawdbotApp);
|
||||||
void loadNodes(host as unknown as ClawdbotApp, { quiet: true });
|
void loadNodes(host as unknown as ClawdbotApp, { quiet: true });
|
||||||
void loadDevices(host as unknown as ClawdbotApp, { quiet: true });
|
void loadDevices(host as unknown as ClawdbotApp, { quiet: true });
|
||||||
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
|
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { html, nothing } from "lit";
|
|||||||
|
|
||||||
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
|
import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
|
||||||
import type { AppViewState } from "./app-view-state";
|
import type { AppViewState } from "./app-view-state";
|
||||||
|
import { parseAgentSessionKey } from "../../../src/routing/session-key.js";
|
||||||
import {
|
import {
|
||||||
TAB_GROUPS,
|
TAB_GROUPS,
|
||||||
iconForTab,
|
iconForTab,
|
||||||
@@ -80,6 +81,24 @@ import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } fr
|
|||||||
import { loadDebug, callDebugMethod } from "./controllers/debug";
|
import { loadDebug, callDebugMethod } from "./controllers/debug";
|
||||||
import { loadLogs } from "./controllers/logs";
|
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) {
|
export function renderApp(state: AppViewState) {
|
||||||
const presenceCount = state.presenceEntries.length;
|
const presenceCount = state.presenceEntries.length;
|
||||||
const sessionsCount = state.sessionsResult?.count ?? null;
|
const sessionsCount = state.sessionsResult?.count ?? null;
|
||||||
@@ -87,6 +106,8 @@ export function renderApp(state: AppViewState) {
|
|||||||
const chatDisabledReason = state.connected ? null : "Disconnected from gateway.";
|
const chatDisabledReason = state.connected ? null : "Disconnected from gateway.";
|
||||||
const isChat = state.tab === "chat";
|
const isChat = state.tab === "chat";
|
||||||
const chatFocus = isChat && state.settings.chatFocusMode;
|
const chatFocus = isChat && state.settings.chatFocusMode;
|
||||||
|
const assistantAvatarUrl = resolveAssistantAvatarUrl(state);
|
||||||
|
const chatAvatarUrl = state.chatAvatarUrl ?? assistantAvatarUrl ?? null;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""}">
|
<div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""} ${state.settings.navCollapsed ? "shell--nav-collapsed" : ""}">
|
||||||
@@ -420,11 +441,11 @@ export function renderApp(state: AppViewState) {
|
|||||||
showThinking: state.settings.chatShowThinking,
|
showThinking: state.settings.chatShowThinking,
|
||||||
loading: state.chatLoading,
|
loading: state.chatLoading,
|
||||||
sending: state.chatSending,
|
sending: state.chatSending,
|
||||||
|
assistantAvatarUrl: chatAvatarUrl,
|
||||||
messages: state.chatMessages,
|
messages: state.chatMessages,
|
||||||
toolMessages: state.chatToolMessages,
|
toolMessages: state.chatToolMessages,
|
||||||
stream: state.chatStream,
|
stream: state.chatStream,
|
||||||
streamStartedAt: state.chatStreamStartedAt,
|
streamStartedAt: state.chatStreamStartedAt,
|
||||||
assistantAvatarUrl: state.chatAvatarUrl,
|
|
||||||
draft: state.chatMessage,
|
draft: state.chatMessage,
|
||||||
queue: state.chatQueue,
|
queue: state.chatQueue,
|
||||||
connected: state.connected,
|
connected: state.connected,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { UiSettings } from "./storage";
|
|||||||
import type { ThemeMode } from "./theme";
|
import type { ThemeMode } from "./theme";
|
||||||
import type { ThemeTransitionContext } from "./theme-transition";
|
import type { ThemeTransitionContext } from "./theme-transition";
|
||||||
import type {
|
import type {
|
||||||
|
AgentsListResult,
|
||||||
ChannelsStatusSnapshot,
|
ChannelsStatusSnapshot,
|
||||||
ConfigSnapshot,
|
ConfigSnapshot,
|
||||||
CronJob,
|
CronJob,
|
||||||
@@ -95,6 +96,9 @@ export type AppViewState = {
|
|||||||
presenceEntries: PresenceEntry[];
|
presenceEntries: PresenceEntry[];
|
||||||
presenceError: string | null;
|
presenceError: string | null;
|
||||||
presenceStatus: string | null;
|
presenceStatus: string | null;
|
||||||
|
agentsLoading: boolean;
|
||||||
|
agentsList: AgentsListResult | null;
|
||||||
|
agentsError: string | null;
|
||||||
sessionsLoading: boolean;
|
sessionsLoading: boolean;
|
||||||
sessionsResult: SessionsListResult | null;
|
sessionsResult: SessionsListResult | null;
|
||||||
sessionsError: string | null;
|
sessionsError: string | null;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { renderApp } from "./app-render";
|
|||||||
import type { Tab } from "./navigation";
|
import type { Tab } from "./navigation";
|
||||||
import type { ResolvedTheme, ThemeMode } from "./theme";
|
import type { ResolvedTheme, ThemeMode } from "./theme";
|
||||||
import type {
|
import type {
|
||||||
|
AgentsListResult,
|
||||||
ConfigSnapshot,
|
ConfigSnapshot,
|
||||||
ConfigUiHints,
|
ConfigUiHints,
|
||||||
CronJob,
|
CronJob,
|
||||||
@@ -169,6 +170,10 @@ export class ClawdbotApp extends LitElement {
|
|||||||
@state() presenceError: string | null = null;
|
@state() presenceError: string | null = null;
|
||||||
@state() presenceStatus: 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() sessionsLoading = false;
|
||||||
@state() sessionsResult: SessionsListResult | null = null;
|
@state() sessionsResult: SessionsListResult | null = null;
|
||||||
@state() sessionsError: string | null = null;
|
@state() sessionsError: string | null = null;
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export function renderReadingIndicatorGroup(assistantAvatarUrl?: string | null)
|
|||||||
export function renderStreamingGroup(
|
export function renderStreamingGroup(
|
||||||
text: string,
|
text: string,
|
||||||
startedAt: number,
|
startedAt: number,
|
||||||
onOpenSidebar?: (content: string) => void,
|
|
||||||
assistantAvatarUrl?: string | null,
|
assistantAvatarUrl?: string | null,
|
||||||
|
onOpenSidebar?: (content: string) => void,
|
||||||
) {
|
) {
|
||||||
const timestamp = new Date(startedAt).toLocaleTimeString([], {
|
const timestamp = new Date(startedAt).toLocaleTimeString([], {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
|
|||||||
25
ui/src/ui/controllers/agents.ts
Normal file
25
ui/src/ui/controllers/agents.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -294,6 +294,25 @@ export type GatewaySessionsDefaults = {
|
|||||||
contextTokens: number | null;
|
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 = {
|
export type GatewaySessionRow = {
|
||||||
key: string;
|
key: string;
|
||||||
kind: "direct" | "group" | "global" | "unknown";
|
kind: "direct" | "group" | "global" | "unknown";
|
||||||
|
|||||||
@@ -122,8 +122,8 @@ export function renderChat(props: ChatProps) {
|
|||||||
return renderStreamingGroup(
|
return renderStreamingGroup(
|
||||||
item.text,
|
item.text,
|
||||||
item.startedAt,
|
item.startedAt,
|
||||||
props.onOpenSidebar,
|
|
||||||
props.assistantAvatarUrl ?? null,
|
props.assistantAvatarUrl ?? null,
|
||||||
|
props.onOpenSidebar,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user