@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,55 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getAgentStatuses, setAgentStatus, type AgentStatusValue } from "@/lib/db/queries";
3
+ import { checkCsrf } from "@/lib/api-security";
4
+
5
+ const VALID_STATUSES: AgentStatusValue[] = ["idle", "thinking", "active"];
6
+
7
+ // ============================================================================
8
+ // GET /api/openclaw/agents/status — Get all agent statuses
9
+ // ============================================================================
10
+
11
+ export async function GET() {
12
+ try {
13
+ const statuses = getAgentStatuses();
14
+ return NextResponse.json({ statuses });
15
+ } catch (err) {
16
+ console.error("[Agent Status] GET failed:", (err as Error).message);
17
+ return NextResponse.json({ error: "Failed to get agent statuses" }, { status: 500 });
18
+ }
19
+ }
20
+
21
+ // ============================================================================
22
+ // POST /api/openclaw/agents/status — Set an agent's status
23
+ // ============================================================================
24
+
25
+ export async function POST(request: NextRequest) {
26
+ const csrfError = checkCsrf(request);
27
+ if (csrfError) return csrfError;
28
+
29
+ let body: { agentId?: string; status?: string };
30
+
31
+ try {
32
+ body = await request.json();
33
+ } catch {
34
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
35
+ }
36
+
37
+ if (!body.agentId || typeof body.agentId !== "string") {
38
+ return NextResponse.json({ error: "agentId is required" }, { status: 400 });
39
+ }
40
+
41
+ if (!body.status || !VALID_STATUSES.includes(body.status as AgentStatusValue)) {
42
+ return NextResponse.json(
43
+ { error: `status must be one of: ${VALID_STATUSES.join(", ")}` },
44
+ { status: 400 }
45
+ );
46
+ }
47
+
48
+ try {
49
+ setAgentStatus(body.agentId, body.status as AgentStatusValue);
50
+ return NextResponse.json({ ok: true });
51
+ } catch (err) {
52
+ console.error("[Agent Status] POST failed:", (err as Error).message);
53
+ return NextResponse.json({ error: "Failed to set agent status" }, { status: 500 });
54
+ }
55
+ }
@@ -0,0 +1,230 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, statSync } from "fs";
3
+ import { join, resolve } from "path";
4
+ import { platform } from "os";
5
+ import { v4 as uuid } from "uuid";
6
+ import { checkCsrf } from "@/lib/api-security";
7
+ import { getCastleDir } from "@/lib/config";
8
+ import { createAttachment, getAttachmentsByMessage } from "@/lib/db/queries";
9
+
10
+ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
11
+ const ALLOWED_MIME_TYPES = new Set([
12
+ "image/png",
13
+ "image/jpeg",
14
+ "image/webp",
15
+ "image/gif",
16
+ "audio/mpeg",
17
+ "audio/ogg",
18
+ "audio/wav",
19
+ ]);
20
+
21
+ function getAttachmentsDir(): string {
22
+ return join(getCastleDir(), "data", "attachments");
23
+ }
24
+
25
+ /** Validate that resolved path stays within the attachments directory */
26
+ function isPathSafe(filePath: string, baseDir: string): boolean {
27
+ const resolved = resolve(filePath);
28
+ const resolvedBase = resolve(baseDir);
29
+ return resolved.startsWith(resolvedBase);
30
+ }
31
+
32
+ function getMimeForExt(ext: string): string {
33
+ const map: Record<string, string> = {
34
+ ".png": "image/png",
35
+ ".jpg": "image/jpeg",
36
+ ".jpeg": "image/jpeg",
37
+ ".webp": "image/webp",
38
+ ".gif": "image/gif",
39
+ ".mp3": "audio/mpeg",
40
+ ".ogg": "audio/ogg",
41
+ ".wav": "audio/wav",
42
+ };
43
+ return map[ext.toLowerCase()] || "application/octet-stream";
44
+ }
45
+
46
+ // ============================================================================
47
+ // POST /api/openclaw/chat/attachments — Upload attachment
48
+ // ============================================================================
49
+
50
+ export async function POST(request: NextRequest) {
51
+ const csrf = checkCsrf(request);
52
+ if (csrf) return csrf;
53
+
54
+ const formData = await request.formData();
55
+ const file = formData.get("file") as File | null;
56
+ const channelId = formData.get("channelId") as string | null;
57
+ const messageId = formData.get("messageId") as string | null;
58
+
59
+ if (!file || !channelId) {
60
+ return NextResponse.json(
61
+ { error: "file and channelId are required" },
62
+ { status: 400 }
63
+ );
64
+ }
65
+
66
+ // Validate file size
67
+ if (file.size > MAX_FILE_SIZE) {
68
+ return NextResponse.json(
69
+ { error: `File too large (max ${MAX_FILE_SIZE / 1024 / 1024}MB)` },
70
+ { status: 400 }
71
+ );
72
+ }
73
+
74
+ // Validate MIME type
75
+ if (!ALLOWED_MIME_TYPES.has(file.type)) {
76
+ return NextResponse.json(
77
+ { error: `Unsupported file type: ${file.type}` },
78
+ { status: 400 }
79
+ );
80
+ }
81
+
82
+ try {
83
+ // Determine file extension from MIME type
84
+ const extMap: Record<string, string> = {
85
+ "image/png": ".png",
86
+ "image/jpeg": ".jpg",
87
+ "image/webp": ".webp",
88
+ "image/gif": ".gif",
89
+ "audio/mpeg": ".mp3",
90
+ "audio/ogg": ".ogg",
91
+ "audio/wav": ".wav",
92
+ };
93
+ const ext = extMap[file.type] || ".bin";
94
+
95
+ // Create UUID-based filename (never use user-supplied names)
96
+ const filename = `${uuid()}${ext}`;
97
+ const channelDir = join(getAttachmentsDir(), channelId);
98
+
99
+ // Ensure directory exists
100
+ if (!existsSync(channelDir)) {
101
+ mkdirSync(channelDir, { recursive: true, mode: 0o700 });
102
+ }
103
+
104
+ const filePath = join(channelDir, filename);
105
+
106
+ // Path traversal check
107
+ if (!isPathSafe(filePath, getAttachmentsDir())) {
108
+ return NextResponse.json(
109
+ { error: "Invalid path" },
110
+ { status: 400 }
111
+ );
112
+ }
113
+
114
+ // Write file
115
+ const buffer = Buffer.from(await file.arrayBuffer());
116
+ writeFileSync(filePath, buffer);
117
+
118
+ // Secure permissions
119
+ if (platform() !== "win32") {
120
+ try {
121
+ const { chmodSync } = await import("fs");
122
+ chmodSync(filePath, 0o600);
123
+ } catch {
124
+ // May fail on some filesystems
125
+ }
126
+ }
127
+
128
+ // Persist to DB (if messageId provided)
129
+ const attachmentType = file.type.startsWith("image/") ? "image" : "audio";
130
+ let attachmentRecord = null;
131
+
132
+ if (messageId) {
133
+ attachmentRecord = createAttachment({
134
+ messageId,
135
+ attachmentType: attachmentType as "image" | "audio",
136
+ filePath: `${channelId}/${filename}`,
137
+ mimeType: file.type,
138
+ fileSize: file.size,
139
+ originalName: file.name,
140
+ });
141
+ }
142
+
143
+ return NextResponse.json({
144
+ id: attachmentRecord?.id ?? uuid(),
145
+ filePath: `${channelId}/${filename}`,
146
+ mimeType: file.type,
147
+ fileSize: file.size,
148
+ originalName: file.name,
149
+ }, { status: 201 });
150
+ } catch (err) {
151
+ console.error("[Attachments] Upload failed:", (err as Error).message);
152
+ return NextResponse.json(
153
+ { error: "Upload failed" },
154
+ { status: 500 }
155
+ );
156
+ }
157
+ }
158
+
159
+ // ============================================================================
160
+ // GET /api/openclaw/chat/attachments?path=... — Serve attachment
161
+ // ============================================================================
162
+
163
+ export async function GET(request: NextRequest) {
164
+ const { searchParams } = new URL(request.url);
165
+ const filePath = searchParams.get("path");
166
+ const messageId = searchParams.get("messageId");
167
+
168
+ // If messageId provided, return all attachments for that message
169
+ if (messageId) {
170
+ try {
171
+ const attachments = getAttachmentsByMessage(messageId);
172
+ return NextResponse.json({ attachments });
173
+ } catch (err) {
174
+ console.error("[Attachments] List failed:", (err as Error).message);
175
+ return NextResponse.json(
176
+ { error: "Failed to list attachments" },
177
+ { status: 500 }
178
+ );
179
+ }
180
+ }
181
+
182
+ // Serve individual file
183
+ if (!filePath) {
184
+ return NextResponse.json(
185
+ { error: "path or messageId parameter required" },
186
+ { status: 400 }
187
+ );
188
+ }
189
+
190
+ const baseDir = getAttachmentsDir();
191
+ const fullPath = join(baseDir, filePath);
192
+
193
+ // Path traversal check
194
+ if (!isPathSafe(fullPath, baseDir)) {
195
+ return NextResponse.json(
196
+ { error: "Invalid path" },
197
+ { status: 400 }
198
+ );
199
+ }
200
+
201
+ if (!existsSync(fullPath)) {
202
+ return NextResponse.json(
203
+ { error: "Attachment not found" },
204
+ { status: 404 }
205
+ );
206
+ }
207
+
208
+ try {
209
+ const data = readFileSync(fullPath);
210
+ const ext = fullPath.substring(fullPath.lastIndexOf("."));
211
+ const mimeType = getMimeForExt(ext);
212
+ const stat = statSync(fullPath);
213
+
214
+ return new NextResponse(data, {
215
+ status: 200,
216
+ headers: {
217
+ "Content-Type": mimeType,
218
+ "Content-Length": String(stat.size),
219
+ "Content-Disposition": "inline",
220
+ "Cache-Control": "public, max-age=86400", // 24h cache
221
+ },
222
+ });
223
+ } catch (err) {
224
+ console.error("[Attachments] Serve failed:", (err as Error).message);
225
+ return NextResponse.json(
226
+ { error: "Failed to serve attachment" },
227
+ { status: 500 }
228
+ );
229
+ }
230
+ }
@@ -0,0 +1,214 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { checkCsrf, sanitizeForApi } from "@/lib/api-security";
3
+ import {
4
+ createChannel,
5
+ getChannels,
6
+ getChannel,
7
+ updateChannel,
8
+ deleteChannel,
9
+ archiveChannel,
10
+ restoreChannel,
11
+ touchChannel,
12
+ getLastAccessedChannelId,
13
+ } from "@/lib/db/queries";
14
+
15
+ const MAX_CHANNEL_NAME_LENGTH = 100;
16
+
17
+ /** Strip control characters from a string (keep printable + whitespace) */
18
+ function sanitizeName(name: string): string {
19
+ // eslint-disable-next-line no-control-regex
20
+ return name.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "").trim();
21
+ }
22
+
23
+ /** Validate agent ID format */
24
+ function isValidAgentId(id: string): boolean {
25
+ return /^[a-zA-Z0-9._-]+$/.test(id);
26
+ }
27
+
28
+ // ============================================================================
29
+ // GET /api/openclaw/chat/channels — List channels
30
+ // ============================================================================
31
+
32
+ export async function GET(request: NextRequest) {
33
+ const { searchParams } = new URL(request.url);
34
+
35
+ try {
36
+ // GET /api/openclaw/chat/channels?last=1 — get last accessed channel ID
37
+ if (searchParams.get("last")) {
38
+ const lastId = getLastAccessedChannelId();
39
+ return NextResponse.json({ channelId: lastId });
40
+ }
41
+
42
+ const archived = searchParams.get("archived") === "1";
43
+ const all = getChannels(archived);
44
+ return NextResponse.json({ channels: all });
45
+ } catch (err) {
46
+ console.error("[Chat Channels] List failed:", (err as Error).message);
47
+ return NextResponse.json(
48
+ { error: sanitizeForApi((err as Error).message) },
49
+ { status: 500 }
50
+ );
51
+ }
52
+ }
53
+
54
+ // ============================================================================
55
+ // POST /api/openclaw/chat/channels — Create / update / delete channel
56
+ // ============================================================================
57
+
58
+ export async function POST(request: NextRequest) {
59
+ const csrf = checkCsrf(request);
60
+ if (csrf) return csrf;
61
+
62
+ let body: {
63
+ action?: "create" | "update" | "delete" | "archive" | "restore" | "touch";
64
+ id?: string;
65
+ name?: string;
66
+ defaultAgentId?: string;
67
+ agents?: string[];
68
+ };
69
+
70
+ try {
71
+ body = await request.json();
72
+ } catch {
73
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
74
+ }
75
+
76
+ const action = body.action || "create";
77
+
78
+ // ------ TOUCH (mark as last accessed) ------
79
+ if (action === "touch") {
80
+ if (!body.id) {
81
+ return NextResponse.json({ error: "id is required" }, { status: 400 });
82
+ }
83
+ touchChannel(body.id);
84
+ return NextResponse.json({ ok: true });
85
+ }
86
+
87
+ // ------ ARCHIVE ------
88
+ if (action === "archive") {
89
+ if (!body.id) {
90
+ return NextResponse.json({ error: "id is required" }, { status: 400 });
91
+ }
92
+ const archived = archiveChannel(body.id);
93
+ if (!archived) {
94
+ return NextResponse.json({ error: "Channel not found" }, { status: 404 });
95
+ }
96
+ return NextResponse.json({ ok: true });
97
+ }
98
+
99
+ // ------ RESTORE ------
100
+ if (action === "restore") {
101
+ if (!body.id) {
102
+ return NextResponse.json({ error: "id is required" }, { status: 400 });
103
+ }
104
+ const restored = restoreChannel(body.id);
105
+ if (!restored) {
106
+ return NextResponse.json({ error: "Channel not found" }, { status: 404 });
107
+ }
108
+ return NextResponse.json({ ok: true });
109
+ }
110
+
111
+ // ------ DELETE (permanent — only for archived channels) ------
112
+ if (action === "delete") {
113
+ if (!body.id) {
114
+ return NextResponse.json({ error: "id is required" }, { status: 400 });
115
+ }
116
+ // Only allow deleting archived channels
117
+ const ch = getChannel(body.id);
118
+ if (!ch) {
119
+ return NextResponse.json({ error: "Channel not found" }, { status: 404 });
120
+ }
121
+ if (!ch.archivedAt) {
122
+ return NextResponse.json(
123
+ { error: "Channel must be archived before it can be permanently deleted" },
124
+ { status: 400 }
125
+ );
126
+ }
127
+ try {
128
+ const deleted = deleteChannel(body.id);
129
+ if (!deleted) {
130
+ return NextResponse.json({ error: "Channel not found" }, { status: 404 });
131
+ }
132
+ return NextResponse.json({ ok: true });
133
+ } catch (err) {
134
+ console.error("[Chat Channels] Delete failed:", (err as Error).message);
135
+ return NextResponse.json(
136
+ { error: sanitizeForApi((err as Error).message) },
137
+ { status: 500 }
138
+ );
139
+ }
140
+ }
141
+
142
+ // ------ UPDATE ------
143
+ if (action === "update") {
144
+ if (!body.id) {
145
+ return NextResponse.json({ error: "id is required" }, { status: 400 });
146
+ }
147
+ const updates: { name?: string; defaultAgentId?: string } = {};
148
+ if (body.name) {
149
+ const cleanName = sanitizeName(body.name);
150
+ if (!cleanName || cleanName.length > MAX_CHANNEL_NAME_LENGTH) {
151
+ return NextResponse.json(
152
+ { error: `Channel name must be 1-${MAX_CHANNEL_NAME_LENGTH} characters` },
153
+ { status: 400 }
154
+ );
155
+ }
156
+ updates.name = cleanName;
157
+ }
158
+ if (body.defaultAgentId) {
159
+ if (!isValidAgentId(body.defaultAgentId)) {
160
+ return NextResponse.json({ error: "Invalid agent ID format" }, { status: 400 });
161
+ }
162
+ updates.defaultAgentId = body.defaultAgentId;
163
+ }
164
+ const updated = updateChannel(body.id, updates);
165
+ if (!updated) {
166
+ return NextResponse.json({ error: "Channel not found" }, { status: 404 });
167
+ }
168
+ const channel = getChannel(body.id);
169
+ return NextResponse.json({ channel });
170
+ }
171
+
172
+ // ------ CREATE ------
173
+ if (!body.name || typeof body.name !== "string") {
174
+ return NextResponse.json({ error: "name is required" }, { status: 400 });
175
+ }
176
+ if (!body.defaultAgentId || typeof body.defaultAgentId !== "string") {
177
+ return NextResponse.json({ error: "defaultAgentId is required" }, { status: 400 });
178
+ }
179
+
180
+ const cleanName = sanitizeName(body.name);
181
+ if (!cleanName || cleanName.length > MAX_CHANNEL_NAME_LENGTH) {
182
+ return NextResponse.json(
183
+ { error: `Channel name must be 1-${MAX_CHANNEL_NAME_LENGTH} characters` },
184
+ { status: 400 }
185
+ );
186
+ }
187
+
188
+ if (!isValidAgentId(body.defaultAgentId)) {
189
+ return NextResponse.json({ error: "Invalid agent ID format" }, { status: 400 });
190
+ }
191
+
192
+ // Validate all agent IDs
193
+ if (body.agents) {
194
+ for (const agentId of body.agents) {
195
+ if (!isValidAgentId(agentId)) {
196
+ return NextResponse.json(
197
+ { error: `Invalid agent ID format: ${agentId}` },
198
+ { status: 400 }
199
+ );
200
+ }
201
+ }
202
+ }
203
+
204
+ try {
205
+ const channel = createChannel(cleanName, body.defaultAgentId, body.agents);
206
+ return NextResponse.json({ channel }, { status: 201 });
207
+ } catch (err) {
208
+ console.error("[Chat Channels] Create failed:", (err as Error).message);
209
+ return NextResponse.json(
210
+ { error: sanitizeForApi((err as Error).message) },
211
+ { status: 500 }
212
+ );
213
+ }
214
+ }