@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.
- package/drizzle.config.ts +7 -0
- package/next.config.ts +1 -0
- package/package.json +25 -4
- package/src/app/api/avatars/[id]/route.ts +122 -25
- package/src/app/api/openclaw/agents/[id]/avatar/route.ts +216 -0
- package/src/app/api/openclaw/agents/route.ts +77 -41
- package/src/app/api/openclaw/agents/status/route.ts +55 -0
- package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
- package/src/app/api/openclaw/chat/channels/route.ts +214 -0
- package/src/app/api/openclaw/chat/route.ts +272 -0
- package/src/app/api/openclaw/chat/search/route.ts +149 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/config/route.ts +45 -4
- package/src/app/api/openclaw/events/route.ts +31 -2
- package/src/app/api/openclaw/logs/route.ts +20 -5
- package/src/app/api/openclaw/restart/route.ts +12 -4
- package/src/app/api/openclaw/session/status/route.ts +42 -0
- package/src/app/api/settings/avatar/route.ts +190 -0
- package/src/app/api/settings/route.ts +88 -0
- package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
- package/src/app/chat/[channelId]/page.tsx +305 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +89 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +147 -28
- package/src/app/settings/page.tsx +300 -0
- package/src/cli/onboarding.ts +202 -37
- package/src/components/chat/agent-mention-popup.tsx +89 -0
- package/src/components/chat/archived-channels.tsx +190 -0
- package/src/components/chat/channel-list.tsx +140 -0
- package/src/components/chat/chat-input.tsx +310 -0
- package/src/components/chat/create-channel-dialog.tsx +171 -0
- package/src/components/chat/markdown-content.tsx +205 -0
- package/src/components/chat/message-bubble.tsx +152 -0
- package/src/components/chat/message-list.tsx +508 -0
- package/src/components/chat/message-queue.tsx +68 -0
- package/src/components/chat/session-divider.tsx +61 -0
- package/src/components/chat/session-stats-panel.tsx +139 -0
- package/src/components/chat/storage-indicator.tsx +76 -0
- package/src/components/layout/sidebar.tsx +126 -45
- package/src/components/layout/user-menu.tsx +29 -4
- package/src/components/providers/presence-provider.tsx +8 -0
- package/src/components/providers/search-provider.tsx +81 -0
- package/src/components/search/search-dialog.tsx +269 -0
- package/src/components/ui/avatar.tsx +11 -9
- package/src/components/ui/dialog.tsx +10 -4
- package/src/components/ui/tooltip.tsx +25 -8
- package/src/components/ui/twemoji-text.tsx +37 -0
- package/src/lib/api-security.ts +188 -0
- package/src/lib/config.ts +36 -4
- package/src/lib/date-utils.ts +79 -0
- package/src/lib/db/__tests__/queries.test.ts +318 -0
- package/src/lib/db/index.ts +642 -0
- package/src/lib/db/queries.ts +1017 -0
- package/src/lib/db/schema.ts +160 -0
- package/src/lib/device-identity.ts +303 -0
- package/src/lib/gateway-connection.ts +273 -36
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +775 -0
- package/src/lib/hooks/use-openclaw.ts +105 -70
- package/src/lib/hooks/use-search.ts +113 -0
- package/src/lib/hooks/use-session-stats.ts +57 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/types/chat.ts +186 -0
- package/src/lib/types/search.ts +60 -0
- package/src/middleware.ts +52 -0
- package/vitest.config.ts +13 -0
package/next.config.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@castlekit/castle",
|
|
3
|
-
"version": "0.
|
|
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
|
-
"
|
|
62
|
-
"
|
|
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
|
|
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
|
-
*
|
|
25
|
-
*
|
|
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
|
|
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
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
29
|
-
*
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
//
|
|
48
|
-
if (
|
|
49
|
-
|
|
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
|
-
|
|
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 = (
|
|
78
|
-
const name =
|
|
79
|
-
|
|
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
|
-
|
|
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:
|
|
135
|
+
defaultId: agentsResult?.defaultId,
|
|
100
136
|
});
|
|
101
137
|
} catch (err) {
|
|
102
138
|
return NextResponse.json(
|