@castlekit/castle 0.1.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/drizzle.config.ts +7 -0
  2. package/next.config.ts +1 -0
  3. package/package.json +25 -4
  4. package/src/app/api/avatars/[id]/route.ts +122 -25
  5. package/src/app/api/openclaw/agents/[id]/avatar/route.ts +216 -0
  6. package/src/app/api/openclaw/agents/route.ts +77 -41
  7. package/src/app/api/openclaw/agents/status/route.ts +55 -0
  8. package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
  9. package/src/app/api/openclaw/chat/channels/route.ts +214 -0
  10. package/src/app/api/openclaw/chat/route.ts +272 -0
  11. package/src/app/api/openclaw/chat/search/route.ts +149 -0
  12. package/src/app/api/openclaw/chat/storage/route.ts +75 -0
  13. package/src/app/api/openclaw/config/route.ts +45 -4
  14. package/src/app/api/openclaw/events/route.ts +31 -2
  15. package/src/app/api/openclaw/logs/route.ts +20 -5
  16. package/src/app/api/openclaw/restart/route.ts +12 -4
  17. package/src/app/api/openclaw/session/status/route.ts +42 -0
  18. package/src/app/api/settings/avatar/route.ts +190 -0
  19. package/src/app/api/settings/route.ts +88 -0
  20. package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
  21. package/src/app/chat/[channelId]/page.tsx +305 -0
  22. package/src/app/chat/layout.tsx +96 -0
  23. package/src/app/chat/page.tsx +52 -0
  24. package/src/app/globals.css +89 -2
  25. package/src/app/layout.tsx +7 -1
  26. package/src/app/page.tsx +147 -28
  27. package/src/app/settings/page.tsx +300 -0
  28. package/src/cli/onboarding.ts +202 -37
  29. package/src/components/chat/agent-mention-popup.tsx +89 -0
  30. package/src/components/chat/archived-channels.tsx +190 -0
  31. package/src/components/chat/channel-list.tsx +140 -0
  32. package/src/components/chat/chat-input.tsx +310 -0
  33. package/src/components/chat/create-channel-dialog.tsx +171 -0
  34. package/src/components/chat/markdown-content.tsx +205 -0
  35. package/src/components/chat/message-bubble.tsx +152 -0
  36. package/src/components/chat/message-list.tsx +508 -0
  37. package/src/components/chat/message-queue.tsx +68 -0
  38. package/src/components/chat/session-divider.tsx +61 -0
  39. package/src/components/chat/session-stats-panel.tsx +139 -0
  40. package/src/components/chat/storage-indicator.tsx +76 -0
  41. package/src/components/layout/sidebar.tsx +126 -45
  42. package/src/components/layout/user-menu.tsx +29 -4
  43. package/src/components/providers/presence-provider.tsx +8 -0
  44. package/src/components/providers/search-provider.tsx +81 -0
  45. package/src/components/search/search-dialog.tsx +269 -0
  46. package/src/components/ui/avatar.tsx +11 -9
  47. package/src/components/ui/dialog.tsx +10 -4
  48. package/src/components/ui/tooltip.tsx +25 -8
  49. package/src/components/ui/twemoji-text.tsx +37 -0
  50. package/src/lib/api-security.ts +188 -0
  51. package/src/lib/config.ts +36 -4
  52. package/src/lib/date-utils.ts +79 -0
  53. package/src/lib/db/__tests__/queries.test.ts +318 -0
  54. package/src/lib/db/index.ts +642 -0
  55. package/src/lib/db/queries.ts +1017 -0
  56. package/src/lib/db/schema.ts +160 -0
  57. package/src/lib/device-identity.ts +303 -0
  58. package/src/lib/gateway-connection.ts +273 -36
  59. package/src/lib/hooks/use-agent-status.ts +251 -0
  60. package/src/lib/hooks/use-chat.ts +775 -0
  61. package/src/lib/hooks/use-openclaw.ts +105 -70
  62. package/src/lib/hooks/use-search.ts +113 -0
  63. package/src/lib/hooks/use-session-stats.ts +57 -0
  64. package/src/lib/hooks/use-user-settings.ts +46 -0
  65. package/src/lib/types/chat.ts +186 -0
  66. package/src/lib/types/search.ts +60 -0
  67. package/src/middleware.ts +52 -0
  68. package/vitest.config.ts +13 -0
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "drizzle-kit";
2
+
3
+ export default defineConfig({
4
+ schema: "./src/lib/db/schema.ts",
5
+ out: "./src/lib/db/migrations",
6
+ dialect: "sqlite",
7
+ });
package/next.config.ts CHANGED
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
2
2
 
3
3
  const nextConfig: NextConfig = {
4
4
  devIndicators: false,
5
+ serverExternalPackages: ["better-sqlite3"],
5
6
  };
6
7
 
7
8
  export default nextConfig;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@castlekit/castle",
3
- "version": "0.1.5",
3
+ "version": "0.3.0",
4
4
  "description": "The multi-agent workspace",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,9 @@
10
10
  "dev": "next dev -p 3333",
11
11
  "build": "next build",
12
12
  "start": "next start -p 3333",
13
- "lint": "next lint"
13
+ "lint": "next lint",
14
+ "test": "vitest run",
15
+ "test:watch": "vitest"
14
16
  },
15
17
  "repository": {
16
18
  "type": "git",
@@ -39,11 +41,15 @@
39
41
  "@dnd-kit/utilities": "^3.2.2",
40
42
  "@radix-ui/react-slider": "^1.3.6",
41
43
  "@tailwindcss/postcss": "^4.1.18",
44
+ "@twemoji/api": "^17.0.2",
42
45
  "@types/node": "^25.2.1",
43
46
  "@types/react": "^19.2.13",
44
47
  "@types/react-dom": "^19.2.3",
48
+ "@types/ws": "^8.18.1",
49
+ "better-sqlite3": "^12.6.2",
45
50
  "clsx": "^2.1.1",
46
51
  "commander": "^14.0.3",
52
+ "drizzle-orm": "^0.45.1",
47
53
  "json5": "^2.2.3",
48
54
  "lucide-react": "^0.563.0",
49
55
  "next": "^16.1.6",
@@ -52,13 +58,28 @@
52
58
  "picocolors": "^1.1.1",
53
59
  "react": "^19.2.4",
54
60
  "react-dom": "^19.2.4",
61
+ "react-markdown": "^10.1.0",
62
+ "react-syntax-highlighter": "^16.1.0",
55
63
  "recharts": "^3.7.0",
64
+ "remark-gfm": "^4.0.1",
65
+ "sharp": "^0.34.5",
56
66
  "swr": "^2.4.0",
57
67
  "tailwind-merge": "^3.4.0",
58
68
  "tailwindcss": "^4.1.18",
59
69
  "tsx": "^4.21.0",
60
70
  "typescript": "^5.9.3",
61
- "ws": "^8.19.0",
62
- "@types/ws": "^8.18.1"
71
+ "uuid": "^13.0.0",
72
+ "ws": "^8.19.0"
73
+ },
74
+ "devDependencies": {
75
+ "@testing-library/jest-dom": "^6.9.1",
76
+ "@testing-library/react": "^16.3.2",
77
+ "@types/better-sqlite3": "^7.6.13",
78
+ "@types/react-syntax-highlighter": "^15.5.13",
79
+ "@types/sharp": "^0.31.1",
80
+ "@types/uuid": "^10.0.0",
81
+ "drizzle-kit": "^0.31.8",
82
+ "jsdom": "^28.0.0",
83
+ "vitest": "^4.0.18"
63
84
  }
64
85
  }
@@ -1,14 +1,12 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { readFileSync, existsSync } from "fs";
3
- import { join } from "path";
3
+ import { join, resolve } from "path";
4
4
  import { homedir } from "os";
5
+ import { ensureGateway } from "@/lib/gateway-connection";
5
6
 
6
7
  export const dynamic = "force-dynamic";
7
8
 
8
- const AVATAR_DIRS = [
9
- join(homedir(), ".castle", "avatars"),
10
- join(homedir(), ".openclaw", "avatars"),
11
- ];
9
+ const EXTENSIONS = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"];
12
10
 
13
11
  const MIME_TYPES: Record<string, string> = {
14
12
  ".png": "image/png",
@@ -19,10 +17,17 @@ const MIME_TYPES: Record<string, string> = {
19
17
  ".svg": "image/svg+xml",
20
18
  };
21
19
 
20
+ const CACHE_HEADERS = { "Cache-Control": "public, max-age=3600" };
21
+
22
22
  /**
23
23
  * GET /api/avatars/[id]
24
- * Serves avatar images from ~/.castle/avatars/ or ~/.openclaw/avatars/
25
- * Supports IDs with or without extension (tries .png, .jpg, .webp)
24
+ *
25
+ * Proxies avatar images so they work from any device (mobile, Tailscale, etc).
26
+ *
27
+ * Resolution order:
28
+ * 1. Stored URL from Gateway → workspace path or HTTP fetch
29
+ * 2. Local file in ~/.castle/avatars/ or ~/.openclaw/avatars/
30
+ * 3. 404
26
31
  */
27
32
  export async function GET(
28
33
  _request: NextRequest,
@@ -30,46 +35,138 @@ export async function GET(
30
35
  ) {
31
36
  const { id } = await params;
32
37
 
33
- // Sanitize -- prevent path traversal
38
+ // Sanitize
34
39
  const safeId = id.replace(/[^a-zA-Z0-9._-]/g, "");
35
40
  if (!safeId || safeId.includes("..")) {
36
41
  return new NextResponse("Not found", { status: 404 });
37
42
  }
38
43
 
39
- // Try each avatar directory
40
- for (const dir of AVATAR_DIRS) {
44
+ // 1. Try stored URL from Gateway
45
+ try {
46
+ const gw = ensureGateway();
47
+ const storedUrl = gw.getAvatarUrl(safeId);
48
+
49
+ if (storedUrl) {
50
+ // Workspace-relative: workspace:///absolute/path/to/workspace/avatars/file.png
51
+ if (storedUrl.startsWith("workspace://")) {
52
+ const pathPart = storedUrl.slice("workspace://".length);
53
+ // Resolve ~ in workspace path
54
+ const resolved = resolve(
55
+ pathPart.startsWith("~") ? join(homedir(), pathPart.slice(1)) : pathPart
56
+ );
57
+ // Prevent traversal: resolved path must not escape the workspace
58
+ // and must be a simple file path (no symlink-based escapes for known dirs)
59
+ if (
60
+ !resolved.includes("..") &&
61
+ !resolved.includes("\0") &&
62
+ existsSync(resolved)
63
+ ) {
64
+ return serveFile(resolved);
65
+ }
66
+ }
67
+ // HTTP(S) URL: fetch server-side and proxy
68
+ else if (storedUrl.startsWith("http://") || storedUrl.startsWith("https://")) {
69
+ const response = await fetchAvatar(storedUrl);
70
+ if (response) return response;
71
+ }
72
+ }
73
+ } catch {
74
+ // Gateway unavailable
75
+ }
76
+
77
+ // 2. Local avatar directories
78
+ const localDirs = [
79
+ join(homedir(), ".castle", "avatars"),
80
+ join(homedir(), ".openclaw", "avatars"),
81
+ ];
82
+
83
+ for (const dir of localDirs) {
41
84
  if (!existsSync(dir)) continue;
42
85
 
43
- // Try exact filename first (if it has an extension)
44
- const hasExtension = /\.\w+$/.test(safeId);
45
- if (hasExtension) {
86
+ if (/\.\w+$/.test(safeId)) {
46
87
  const filePath = join(dir, safeId);
47
- if (existsSync(filePath)) {
48
- return serveFile(filePath);
49
- }
88
+ if (existsSync(filePath)) return serveFile(filePath);
50
89
  }
51
90
 
52
- // Try common extensions
53
- for (const ext of [".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"]) {
91
+ for (const ext of EXTENSIONS) {
54
92
  const filePath = join(dir, `${safeId}${ext}`);
55
- if (existsSync(filePath)) {
56
- return serveFile(filePath);
57
- }
93
+ if (existsSync(filePath)) return serveFile(filePath);
58
94
  }
59
95
  }
60
96
 
61
97
  return new NextResponse("Not found", { status: 404 });
62
98
  }
63
99
 
100
+ /**
101
+ * Check if a URL resolves to a private/internal IP range (SSRF protection).
102
+ * Blocks access to localhost, private networks, link-local, and cloud metadata.
103
+ */
104
+ function isPrivateUrl(urlStr: string): boolean {
105
+ try {
106
+ const parsed = new URL(urlStr);
107
+ const hostname = parsed.hostname;
108
+
109
+ // Block cloud metadata endpoints
110
+ if (hostname === "169.254.169.254" || hostname === "metadata.google.internal") {
111
+ return true;
112
+ }
113
+
114
+ // Block localhost variants
115
+ if (
116
+ hostname === "localhost" ||
117
+ hostname === "127.0.0.1" ||
118
+ hostname === "::1" ||
119
+ hostname === "0.0.0.0"
120
+ ) {
121
+ return true;
122
+ }
123
+
124
+ // Block private IP ranges (10.x, 172.16-31.x, 192.168.x)
125
+ const parts = hostname.split(".").map(Number);
126
+ if (parts.length === 4 && parts.every((p) => !isNaN(p))) {
127
+ if (parts[0] === 10) return true;
128
+ if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
129
+ if (parts[0] === 192 && parts[1] === 168) return true;
130
+ if (parts[0] === 169 && parts[1] === 254) return true; // link-local
131
+ }
132
+
133
+ return false;
134
+ } catch {
135
+ return true; // Reject malformed URLs
136
+ }
137
+ }
138
+
139
+ /** Fetch an avatar from an HTTP URL with a short timeout and SSRF protection */
140
+ async function fetchAvatar(url: string): Promise<NextResponse | null> {
141
+ // Block requests to private/internal networks
142
+ if (isPrivateUrl(url)) return null;
143
+
144
+ try {
145
+ const resp = await fetch(url, { signal: AbortSignal.timeout(2000) });
146
+ if (!resp.ok) return null;
147
+
148
+ const contentType = resp.headers.get("content-type") || "image/png";
149
+ if (!contentType.startsWith("image/")) return null;
150
+
151
+ // Limit response size to prevent memory exhaustion
152
+ const data = Buffer.from(await resp.arrayBuffer());
153
+ if (data.length > 5 * 1024 * 1024) return null; // 5MB max
154
+
155
+ return new NextResponse(data, {
156
+ headers: { "Content-Type": contentType, ...CACHE_HEADERS },
157
+ });
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+
163
+ /** Serve a local file with appropriate content type */
64
164
  function serveFile(filePath: string): NextResponse {
65
165
  const data = readFileSync(filePath);
66
166
  const ext = filePath.substring(filePath.lastIndexOf(".")).toLowerCase();
67
167
  const contentType = MIME_TYPES[ext] || "application/octet-stream";
68
168
 
69
169
  return new NextResponse(data, {
70
- headers: {
71
- "Content-Type": contentType,
72
- "Cache-Control": "public, max-age=86400, immutable",
73
- },
170
+ headers: { "Content-Type": contentType, ...CACHE_HEADERS },
74
171
  });
75
172
  }
@@ -0,0 +1,216 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
3
+ import { join } from "path";
4
+ import { homedir } from "os";
5
+ import JSON5 from "json5";
6
+ import sharp from "sharp";
7
+ import { ensureGateway } from "@/lib/gateway-connection";
8
+ import { checkCsrf } from "@/lib/api-security";
9
+
10
+ export const dynamic = "force-dynamic";
11
+
12
+ const MAX_UPLOAD_SIZE = 5 * 1024 * 1024; // 5MB raw upload limit
13
+ const AVATAR_SIZE = 256; // px (square)
14
+ const MAX_OUTPUT_SIZE = 100 * 1024; // 100KB after processing
15
+
16
+ const ALLOWED_TYPES = new Set([
17
+ "image/png",
18
+ "image/jpeg",
19
+ "image/webp",
20
+ "image/gif",
21
+ ]);
22
+
23
+ interface AgentConfig {
24
+ id: string;
25
+ workspace?: string;
26
+ identity?: Record<string, unknown>;
27
+ [key: string]: unknown;
28
+ }
29
+
30
+ interface OpenClawConfig {
31
+ agents?: {
32
+ list?: AgentConfig[];
33
+ [key: string]: unknown;
34
+ };
35
+ [key: string]: unknown;
36
+ }
37
+
38
+ /**
39
+ * Resize and compress an avatar image to 256x256, under 100KB.
40
+ */
41
+ async function processAvatar(input: Buffer): Promise<{ data: Buffer; ext: string }> {
42
+ const processed = sharp(input)
43
+ .resize(AVATAR_SIZE, AVATAR_SIZE, { fit: "cover", position: "centre" });
44
+
45
+ // Try PNG first (transparency support)
46
+ let data = await processed.png({ quality: 80, compressionLevel: 9 }).toBuffer();
47
+ if (data.length <= MAX_OUTPUT_SIZE) return { data, ext: ".png" };
48
+
49
+ // WebP (better compression, keeps transparency)
50
+ data = await processed.webp({ quality: 80 }).toBuffer();
51
+ if (data.length <= MAX_OUTPUT_SIZE) return { data, ext: ".webp" };
52
+
53
+ // JPEG with lower quality
54
+ data = await processed.jpeg({ quality: 70 }).toBuffer();
55
+ if (data.length <= MAX_OUTPUT_SIZE) return { data, ext: ".jpg" };
56
+
57
+ // Last resort
58
+ data = await processed.jpeg({ quality: 40 }).toBuffer();
59
+ return { data, ext: ".jpg" };
60
+ }
61
+
62
+ /**
63
+ * Find the OpenClaw config file path.
64
+ */
65
+ function getOpenClawConfigPath(): string | null {
66
+ const paths = [
67
+ join(homedir(), ".openclaw", "openclaw.json"),
68
+ join(homedir(), ".openclaw", "openclaw.json5"),
69
+ ];
70
+ for (const p of paths) {
71
+ if (existsSync(p)) return p;
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * POST /api/openclaw/agents/[id]/avatar
78
+ *
79
+ * Upload a new avatar image for an agent.
80
+ * - Resizes to 256x256 and compresses under 100KB
81
+ * - Saves to the agent's workspace directory
82
+ * - Writes config file directly (Gateway hot-reloads identity changes, no restart needed)
83
+ */
84
+ export async function POST(
85
+ request: NextRequest,
86
+ { params }: { params: Promise<{ id: string }> }
87
+ ) {
88
+ const csrfError = checkCsrf(request);
89
+ if (csrfError) return csrfError;
90
+
91
+ const { id: agentId } = await params;
92
+
93
+ const safeId = agentId.replace(/[^a-zA-Z0-9._-]/g, "");
94
+ if (!safeId) {
95
+ return NextResponse.json({ error: "Invalid agent ID" }, { status: 400 });
96
+ }
97
+
98
+ const formData = await request.formData();
99
+ const file = formData.get("avatar") as File | null;
100
+
101
+ if (!file) {
102
+ return NextResponse.json({ error: "No file provided" }, { status: 400 });
103
+ }
104
+
105
+ if (!ALLOWED_TYPES.has(file.type)) {
106
+ return NextResponse.json(
107
+ { error: "Unsupported format. Use PNG, JPEG, WebP, or GIF." },
108
+ { status: 400 }
109
+ );
110
+ }
111
+
112
+ if (file.size > MAX_UPLOAD_SIZE) {
113
+ return NextResponse.json(
114
+ { error: "File too large (max 5MB)" },
115
+ { status: 400 }
116
+ );
117
+ }
118
+
119
+ // Process image: resize to 256x256, compress to <100KB
120
+ let processed: { data: Buffer; ext: string };
121
+ try {
122
+ const rawBuffer = Buffer.from(await file.arrayBuffer());
123
+ processed = await processAvatar(rawBuffer);
124
+ } catch (err) {
125
+ return NextResponse.json(
126
+ { error: `Failed to process image: ${err instanceof Error ? err.message : "unknown"}` },
127
+ { status: 400 }
128
+ );
129
+ }
130
+
131
+ // Read the OpenClaw config file directly
132
+ const configPath = getOpenClawConfigPath();
133
+ if (!configPath) {
134
+ return NextResponse.json(
135
+ { error: "OpenClaw config file not found" },
136
+ { status: 500 }
137
+ );
138
+ }
139
+
140
+ let config: OpenClawConfig;
141
+ try {
142
+ config = JSON5.parse(readFileSync(configPath, "utf-8"));
143
+ } catch (err) {
144
+ return NextResponse.json(
145
+ { error: `Failed to read config: ${err instanceof Error ? err.message : "unknown"}` },
146
+ { status: 500 }
147
+ );
148
+ }
149
+
150
+ const agents = config.agents?.list || [];
151
+ const agent = agents.find((a) => a.id === safeId);
152
+
153
+ if (!agent) {
154
+ return NextResponse.json({ error: "Agent not found" }, { status: 404 });
155
+ }
156
+
157
+ if (!agent.workspace) {
158
+ return NextResponse.json(
159
+ { error: "Agent has no workspace configured" },
160
+ { status: 400 }
161
+ );
162
+ }
163
+
164
+ const workspacePath = agent.workspace.startsWith("~")
165
+ ? join(homedir(), agent.workspace.slice(1))
166
+ : agent.workspace;
167
+
168
+ // Save processed avatar to workspace
169
+ const avatarsDir = join(workspacePath, "avatars");
170
+ const fileName = `avatar${processed.ext}`;
171
+ const filePath = join(avatarsDir, fileName);
172
+
173
+ try {
174
+ mkdirSync(avatarsDir, { recursive: true, mode: 0o755 });
175
+ writeFileSync(filePath, processed.data);
176
+ } catch (err) {
177
+ return NextResponse.json(
178
+ { error: `Failed to save avatar: ${err instanceof Error ? err.message : "unknown"}` },
179
+ { status: 500 }
180
+ );
181
+ }
182
+
183
+ // Update config file directly — Gateway's file watcher hot-reloads identity changes
184
+ // No config.patch, no restart, no WebSocket disconnect
185
+ try {
186
+ agent.identity = agent.identity || {};
187
+ agent.identity.avatar = `avatars/${fileName}`;
188
+ writeFileSync(configPath, JSON5.stringify(config, null, 2), "utf-8");
189
+ } catch (err) {
190
+ return NextResponse.json(
191
+ { error: `Failed to update config: ${err instanceof Error ? err.message : "unknown"}` },
192
+ { status: 500 }
193
+ );
194
+ }
195
+
196
+ // Wait briefly for Gateway's file watcher to hot-reload (~300ms debounce)
197
+ await new Promise((resolve) => setTimeout(resolve, 500));
198
+
199
+ // Refresh Castle's agent list from the Gateway
200
+ try {
201
+ const gw = ensureGateway();
202
+ if (gw.isConnected) {
203
+ // Emit a signal so SSE clients re-fetch agents
204
+ gw.emit("agentAvatarUpdated", { agentId: safeId });
205
+ }
206
+ } catch {
207
+ // Non-critical
208
+ }
209
+
210
+ return NextResponse.json({
211
+ success: true,
212
+ avatar: `avatars/${fileName}`,
213
+ size: processed.data.length,
214
+ message: "Avatar updated",
215
+ });
216
+ }
@@ -24,34 +24,66 @@ interface AgentsListPayload {
24
24
  agents: GatewayAgent[];
25
25
  }
26
26
 
27
+ interface AgentConfig {
28
+ id: string;
29
+ workspace?: string;
30
+ }
31
+
32
+ interface ConfigGetPayload {
33
+ hash: string;
34
+ parsed: {
35
+ agents?: {
36
+ list?: AgentConfig[];
37
+ };
38
+ };
39
+ }
40
+
41
+ /** Avatar URL patterns containing a hash */
42
+ const AVATAR_HASH_PATTERNS = [
43
+ /\/api\/v\d+\/avatars\/([a-f0-9]+)/i,
44
+ /\/avatars\/([a-f0-9]+)/i,
45
+ ];
46
+
27
47
  /**
28
- * Rewrite avatar URLs to local /api/avatars/ endpoint.
29
- * Handles URLs like "http://localhost:8787/api/v1/avatars/HASH" -> "/api/avatars/HASH"
48
+ * Resolve an avatar value into a Castle-proxied URL.
49
+ *
50
+ * Handles three formats:
51
+ * 1. Data URI → pass through (self-contained)
52
+ * 2. HTTP(S) URL → proxy via /api/avatars/:key
53
+ * 3. Relative path → proxy via /api/avatars/:key (resolved server-side against workspace)
30
54
  */
31
- function rewriteAvatarUrl(url: string | null): string | null {
32
- if (!url) return null;
33
-
34
- // Extract hash from known avatar URL patterns
35
- const patterns = [
36
- /\/api\/v\d+\/avatars\/([a-f0-9]+)/i,
37
- /\/avatars\/([a-f0-9]+)/i,
38
- ];
39
-
40
- for (const pattern of patterns) {
41
- const match = url.match(pattern);
42
- if (match) {
43
- return `/api/avatars/${match[1]}`;
55
+ function resolveAvatarUrl(
56
+ avatar: string | null,
57
+ agentId: string,
58
+ workspace: string | undefined,
59
+ gw: ReturnType<typeof ensureGateway>,
60
+ ): string | null {
61
+ if (!avatar) return null;
62
+
63
+ // Data URIs are self-contained
64
+ if (avatar.startsWith("data:")) return avatar;
65
+
66
+ // HTTP(S) URL — proxy through Castle
67
+ if (avatar.startsWith("http://") || avatar.startsWith("https://")) {
68
+ // Try to extract a hash for a cleaner key
69
+ for (const pattern of AVATAR_HASH_PATTERNS) {
70
+ const match = avatar.match(pattern);
71
+ if (match) {
72
+ gw.setAvatarUrl(match[1], avatar);
73
+ return `/api/avatars/${match[1]}`;
74
+ }
44
75
  }
76
+ // No hash — use agent ID as key
77
+ const key = `agent-${agentId}`;
78
+ gw.setAvatarUrl(key, avatar);
79
+ return `/api/avatars/${key}`;
45
80
  }
46
81
 
47
- // If it's already a relative path or data URI, pass through
48
- if (url.startsWith("/") || url.startsWith("data:")) {
49
- return url;
50
- }
51
-
52
- // Only allow http/https URLs through; reject file:, javascript:, etc.
53
- if (url.startsWith("http://") || url.startsWith("https://")) {
54
- return url;
82
+ // Relative path (e.g. "avatars/sam.png") resolve against workspace
83
+ if (workspace && !avatar.startsWith("/")) {
84
+ const key = `agent-${agentId}`;
85
+ gw.setAvatarUrl(key, `workspace://${workspace}/${avatar}`);
86
+ return `/api/avatars/${key}`;
55
87
  }
56
88
 
57
89
  return null;
@@ -59,7 +91,8 @@ function rewriteAvatarUrl(url: string | null): string | null {
59
91
 
60
92
  /**
61
93
  * GET /api/openclaw/agents
62
- * Discover agents from OpenClaw Gateway via agents.list
94
+ * Discover agents from OpenClaw Gateway via agents.list,
95
+ * with workspace info from config.get for avatar resolution.
63
96
  */
64
97
  export async function GET() {
65
98
  const gw = ensureGateway();
@@ -72,31 +105,34 @@ export async function GET() {
72
105
  }
73
106
 
74
107
  try {
75
- const result = await gw.request<AgentsListPayload>("agents.list", {});
108
+ // Fetch agents and config in parallel
109
+ const [agentsResult, configResult] = await Promise.all([
110
+ gw.request<AgentsListPayload>("agents.list", {}),
111
+ gw.request<ConfigGetPayload>("config.get", {}).catch(() => null),
112
+ ]);
113
+
114
+ // Build workspace lookup from config
115
+ const workspaceMap = new Map<string, string>();
116
+ if (configResult?.parsed?.agents?.list) {
117
+ for (const a of configResult.parsed.agents.list) {
118
+ if (a.workspace) workspaceMap.set(a.id, a.workspace);
119
+ }
120
+ }
76
121
 
77
- const agents = (result?.agents || []).map((agent: GatewayAgent) => {
78
- const name =
79
- agent.identity?.name || agent.name || agent.id;
80
- const rawAvatar =
81
- agent.identity?.avatarUrl || agent.identity?.avatar || null;
122
+ const agents = (agentsResult?.agents || []).map((agent: GatewayAgent) => {
123
+ const name = agent.identity?.name || agent.name || agent.id;
124
+ const rawAvatar = agent.identity?.avatarUrl || agent.identity?.avatar || null;
82
125
  const emoji = agent.identity?.emoji || null;
83
126
  const description = agent.identity?.theme || null;
127
+ const workspace = workspaceMap.get(agent.id);
128
+ const avatar = resolveAvatarUrl(rawAvatar, agent.id, workspace, gw);
84
129
 
85
- // Rewrite external avatar URLs to our local /api/avatars/ endpoint
86
- const avatar = rewriteAvatarUrl(rawAvatar);
87
-
88
- return {
89
- id: agent.id,
90
- name,
91
- description,
92
- avatar,
93
- emoji,
94
- };
130
+ return { id: agent.id, name, description, avatar, emoji };
95
131
  });
96
132
 
97
133
  return NextResponse.json({
98
134
  agents,
99
- defaultId: result?.defaultId,
135
+ defaultId: agentsResult?.defaultId,
100
136
  });
101
137
  } catch (err) {
102
138
  return NextResponse.json(