@castlekit/castle 0.1.6 → 0.3.1

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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/drizzle.config.ts +7 -0
  3. package/install.sh +20 -1
  4. package/next.config.ts +1 -0
  5. package/package.json +35 -3
  6. package/src/app/api/avatars/[id]/route.ts +57 -7
  7. package/src/app/api/openclaw/agents/route.ts +7 -1
  8. package/src/app/api/openclaw/agents/status/route.ts +55 -0
  9. package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
  10. package/src/app/api/openclaw/chat/channels/route.ts +217 -0
  11. package/src/app/api/openclaw/chat/route.ts +283 -0
  12. package/src/app/api/openclaw/chat/search/route.ts +150 -0
  13. package/src/app/api/openclaw/chat/storage/route.ts +75 -0
  14. package/src/app/api/openclaw/config/route.ts +2 -0
  15. package/src/app/api/openclaw/events/route.ts +23 -8
  16. package/src/app/api/openclaw/logs/route.ts +17 -3
  17. package/src/app/api/openclaw/ping/route.ts +5 -0
  18. package/src/app/api/openclaw/restart/route.ts +6 -1
  19. package/src/app/api/openclaw/session/context/route.ts +163 -0
  20. package/src/app/api/openclaw/session/status/route.ts +210 -0
  21. package/src/app/api/openclaw/sessions/route.ts +2 -0
  22. package/src/app/api/settings/avatar/route.ts +190 -0
  23. package/src/app/api/settings/route.ts +88 -0
  24. package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
  25. package/src/app/chat/[channelId]/page.tsx +385 -0
  26. package/src/app/chat/layout.tsx +96 -0
  27. package/src/app/chat/page.tsx +52 -0
  28. package/src/app/globals.css +99 -2
  29. package/src/app/layout.tsx +7 -1
  30. package/src/app/page.tsx +59 -25
  31. package/src/app/settings/page.tsx +300 -0
  32. package/src/components/chat/agent-mention-popup.tsx +89 -0
  33. package/src/components/chat/archived-channels.tsx +190 -0
  34. package/src/components/chat/channel-list.tsx +140 -0
  35. package/src/components/chat/chat-input.tsx +328 -0
  36. package/src/components/chat/create-channel-dialog.tsx +171 -0
  37. package/src/components/chat/markdown-content.tsx +205 -0
  38. package/src/components/chat/message-bubble.tsx +168 -0
  39. package/src/components/chat/message-list.tsx +666 -0
  40. package/src/components/chat/message-queue.tsx +68 -0
  41. package/src/components/chat/session-divider.tsx +61 -0
  42. package/src/components/chat/session-stats-panel.tsx +444 -0
  43. package/src/components/chat/storage-indicator.tsx +76 -0
  44. package/src/components/layout/sidebar.tsx +126 -45
  45. package/src/components/layout/user-menu.tsx +29 -4
  46. package/src/components/providers/presence-provider.tsx +8 -0
  47. package/src/components/providers/search-provider.tsx +110 -0
  48. package/src/components/search/search-dialog.tsx +269 -0
  49. package/src/components/ui/avatar.tsx +11 -9
  50. package/src/components/ui/dialog.tsx +10 -4
  51. package/src/components/ui/tooltip.tsx +25 -8
  52. package/src/components/ui/twemoji-text.tsx +37 -0
  53. package/src/lib/api-security.ts +125 -0
  54. package/src/lib/date-utils.ts +79 -0
  55. package/src/lib/db/index.ts +652 -0
  56. package/src/lib/db/queries.ts +1144 -0
  57. package/src/lib/db/schema.ts +164 -0
  58. package/src/lib/gateway-connection.ts +24 -3
  59. package/src/lib/hooks/use-agent-status.ts +251 -0
  60. package/src/lib/hooks/use-chat.ts +753 -0
  61. package/src/lib/hooks/use-compaction-events.ts +132 -0
  62. package/src/lib/hooks/use-context-boundary.ts +82 -0
  63. package/src/lib/hooks/use-openclaw.ts +122 -100
  64. package/src/lib/hooks/use-search.ts +114 -0
  65. package/src/lib/hooks/use-session-stats.ts +60 -0
  66. package/src/lib/hooks/use-user-settings.ts +46 -0
  67. package/src/lib/sse-singleton.ts +184 -0
  68. package/src/lib/types/chat.ts +202 -0
  69. package/src/lib/types/search.ts +60 -0
  70. package/src/middleware.ts +52 -0
@@ -0,0 +1,190 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, unlinkSync } from "fs";
3
+ import { join } from "path";
4
+ import { homedir } from "os";
5
+ import sharp from "sharp";
6
+ import { setSetting } from "@/lib/db/queries";
7
+ import { checkCsrf, checkRateLimit, rateLimitKey } from "@/lib/api-security";
8
+
9
+ export const dynamic = "force-dynamic";
10
+
11
+ const MAX_UPLOAD_SIZE = 5 * 1024 * 1024; // 5MB raw upload limit
12
+ const AVATAR_SIZE = 256; // px (square)
13
+ const MAX_OUTPUT_SIZE = 100 * 1024; // 100KB after processing
14
+
15
+ const ALLOWED_TYPES = new Set([
16
+ "image/png",
17
+ "image/jpeg",
18
+ "image/webp",
19
+ "image/gif",
20
+ ]);
21
+
22
+ const AVATARS_DIR = join(homedir(), ".castle", "avatars");
23
+
24
+ /**
25
+ * Resize and compress an avatar image to 256x256, under 100KB.
26
+ */
27
+ async function processAvatar(input: Buffer): Promise<{ data: Buffer; ext: string }> {
28
+ const processed = sharp(input).resize(AVATAR_SIZE, AVATAR_SIZE, {
29
+ fit: "cover",
30
+ position: "centre",
31
+ });
32
+
33
+ // Try PNG first (transparency support)
34
+ let data = await processed.png({ quality: 80, compressionLevel: 9 }).toBuffer();
35
+ if (data.length <= MAX_OUTPUT_SIZE) return { data, ext: ".png" };
36
+
37
+ // WebP (better compression, keeps transparency)
38
+ data = await processed.webp({ quality: 80 }).toBuffer();
39
+ if (data.length <= MAX_OUTPUT_SIZE) return { data, ext: ".webp" };
40
+
41
+ // JPEG with lower quality
42
+ data = await processed.jpeg({ quality: 70 }).toBuffer();
43
+ if (data.length <= MAX_OUTPUT_SIZE) return { data, ext: ".jpg" };
44
+
45
+ // Last resort
46
+ data = await processed.jpeg({ quality: 40 }).toBuffer();
47
+ return { data, ext: ".jpg" };
48
+ }
49
+
50
+ // ============================================================================
51
+ // GET /api/settings/avatar — Serve user avatar
52
+ // ============================================================================
53
+
54
+ const MIME_TYPES: Record<string, string> = {
55
+ ".png": "image/png",
56
+ ".jpg": "image/jpeg",
57
+ ".jpeg": "image/jpeg",
58
+ ".gif": "image/gif",
59
+ ".webp": "image/webp",
60
+ };
61
+
62
+ export async function GET() {
63
+ if (!existsSync(AVATARS_DIR)) {
64
+ return new NextResponse(null, { status: 404 });
65
+ }
66
+
67
+ // Find user avatar file
68
+ const files = readdirSync(AVATARS_DIR).filter((f) => f.startsWith("user."));
69
+ if (files.length === 0) {
70
+ return new NextResponse(null, { status: 404 });
71
+ }
72
+
73
+ const filePath = join(AVATARS_DIR, files[0]);
74
+ const data = readFileSync(filePath);
75
+ const ext = filePath.substring(filePath.lastIndexOf(".")).toLowerCase();
76
+ const contentType = MIME_TYPES[ext] || "image/png";
77
+
78
+ return new NextResponse(data, {
79
+ headers: {
80
+ "Content-Type": contentType,
81
+ "Cache-Control": "public, max-age=60",
82
+ },
83
+ });
84
+ }
85
+
86
+ // ============================================================================
87
+ // POST /api/settings/avatar — Upload user avatar
88
+ // ============================================================================
89
+
90
+ export async function POST(request: NextRequest) {
91
+ const csrfError = checkCsrf(request);
92
+ if (csrfError) return csrfError;
93
+
94
+ // Rate limit: 10 avatar uploads per minute
95
+ const rl = checkRateLimit(rateLimitKey(request, "avatar:upload"), 10);
96
+ if (rl) return rl;
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
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
+ // Remove any existing user avatar files
132
+ try {
133
+ mkdirSync(AVATARS_DIR, { recursive: true, mode: 0o755 });
134
+ if (existsSync(AVATARS_DIR)) {
135
+ const existing = readdirSync(AVATARS_DIR).filter((f) => f.startsWith("user."));
136
+ for (const f of existing) {
137
+ unlinkSync(join(AVATARS_DIR, f));
138
+ }
139
+ }
140
+ } catch {
141
+ // silent
142
+ }
143
+
144
+ // Save new avatar
145
+ const fileName = `user${processed.ext}`;
146
+ const filePath = join(AVATARS_DIR, fileName);
147
+
148
+ try {
149
+ writeFileSync(filePath, processed.data);
150
+ } catch (err) {
151
+ return NextResponse.json(
152
+ { error: `Failed to save avatar: ${err instanceof Error ? err.message : "unknown"}` },
153
+ { status: 500 }
154
+ );
155
+ }
156
+
157
+ // Store the avatar path with a version timestamp for cache-busting
158
+ setSetting("avatarPath", `${fileName}?t=${Date.now()}`);
159
+
160
+ return NextResponse.json({
161
+ success: true,
162
+ avatar: `/api/settings/avatar?t=${Date.now()}`,
163
+ size: processed.data.length,
164
+ });
165
+ }
166
+
167
+ // ============================================================================
168
+ // DELETE /api/settings/avatar — Remove user avatar
169
+ // ============================================================================
170
+
171
+ export async function DELETE(request: NextRequest) {
172
+ const csrfError = checkCsrf(request);
173
+ if (csrfError) return csrfError;
174
+
175
+ try {
176
+ if (existsSync(AVATARS_DIR)) {
177
+ const existing = readdirSync(AVATARS_DIR).filter((f) => f.startsWith("user."));
178
+ for (const f of existing) {
179
+ unlinkSync(join(AVATARS_DIR, f));
180
+ }
181
+ }
182
+ setSetting("avatarPath", "");
183
+ return NextResponse.json({ ok: true });
184
+ } catch (err) {
185
+ return NextResponse.json(
186
+ { error: `Failed to remove avatar: ${err instanceof Error ? err.message : "unknown"}` },
187
+ { status: 500 }
188
+ );
189
+ }
190
+ }
@@ -0,0 +1,88 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getAllSettings, setSetting } from "@/lib/db/queries";
3
+ import { checkCsrf } from "@/lib/api-security";
4
+
5
+ // Known setting keys and their validation
6
+ const VALID_KEYS: Record<string, { maxLength: number }> = {
7
+ displayName: { maxLength: 100 },
8
+ avatarPath: { maxLength: 255 },
9
+ tooltips: { maxLength: 5 }, // "true" or "false"
10
+ };
11
+
12
+ // ============================================================================
13
+ // GET /api/settings — Get all settings
14
+ // ============================================================================
15
+
16
+ export async function GET() {
17
+ try {
18
+ const all = getAllSettings();
19
+ return NextResponse.json(all);
20
+ } catch (err) {
21
+ console.error("[Settings] GET failed:", (err as Error).message);
22
+ return NextResponse.json(
23
+ { error: "Failed to load settings" },
24
+ { status: 500 }
25
+ );
26
+ }
27
+ }
28
+
29
+ // ============================================================================
30
+ // POST /api/settings — Update one or more settings
31
+ // ============================================================================
32
+
33
+ export async function POST(request: NextRequest) {
34
+ const csrfError = checkCsrf(request);
35
+ if (csrfError) return csrfError;
36
+
37
+ let body: Record<string, string>;
38
+
39
+ try {
40
+ body = await request.json();
41
+ } catch {
42
+ return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
43
+ }
44
+
45
+ if (!body || typeof body !== "object") {
46
+ return NextResponse.json({ error: "Body must be an object" }, { status: 400 });
47
+ }
48
+
49
+ try {
50
+ for (const [key, value] of Object.entries(body)) {
51
+ // Validate key
52
+ if (!VALID_KEYS[key]) {
53
+ return NextResponse.json(
54
+ { error: `Unknown setting: ${key}` },
55
+ { status: 400 }
56
+ );
57
+ }
58
+
59
+ // Validate value type
60
+ if (typeof value !== "string") {
61
+ return NextResponse.json(
62
+ { error: `Setting "${key}" must be a string` },
63
+ { status: 400 }
64
+ );
65
+ }
66
+
67
+ // Validate length
68
+ const trimmed = value.trim();
69
+ if (trimmed.length > VALID_KEYS[key].maxLength) {
70
+ return NextResponse.json(
71
+ { error: `Setting "${key}" must be at most ${VALID_KEYS[key].maxLength} characters` },
72
+ { status: 400 }
73
+ );
74
+ }
75
+
76
+ setSetting(key, trimmed);
77
+ }
78
+
79
+ const all = getAllSettings();
80
+ return NextResponse.json(all);
81
+ } catch (err) {
82
+ console.error("[Settings] POST failed:", (err as Error).message);
83
+ return NextResponse.json(
84
+ { error: "Failed to save settings" },
85
+ { status: 500 }
86
+ );
87
+ }
88
+ }
@@ -0,0 +1,64 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { AlertTriangle, RefreshCw } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+
7
+ interface ErrorBoundaryState {
8
+ hasError: boolean;
9
+ error?: Error;
10
+ }
11
+
12
+ interface ErrorBoundaryProps {
13
+ children: React.ReactNode;
14
+ }
15
+
16
+ export class ChatErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
17
+ constructor(props: ErrorBoundaryProps) {
18
+ super(props);
19
+ this.state = { hasError: false };
20
+ }
21
+
22
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState {
23
+ return { hasError: true, error };
24
+ }
25
+
26
+ componentDidCatch(error: Error, info: React.ErrorInfo) {
27
+ console.error("[Chat Error Boundary]", error, info.componentStack);
28
+ }
29
+
30
+ handleRetry = () => {
31
+ this.setState({ hasError: false, error: undefined });
32
+ };
33
+
34
+ render() {
35
+ if (this.state.hasError) {
36
+ return (
37
+ <div className="flex-1 flex items-center justify-center p-8">
38
+ <div className="text-center space-y-4 max-w-md">
39
+ <AlertTriangle className="h-10 w-10 mx-auto text-warning" />
40
+ <div>
41
+ <h3 className="text-lg font-semibold text-foreground">
42
+ Something went wrong
43
+ </h3>
44
+ <p className="text-sm text-foreground-secondary mt-1">
45
+ The chat encountered an unexpected error. Your messages are safe in the database.
46
+ </p>
47
+ {this.state.error && (
48
+ <p className="text-xs text-foreground-secondary/60 mt-2 font-mono">
49
+ {this.state.error.message}
50
+ </p>
51
+ )}
52
+ </div>
53
+ <Button onClick={this.handleRetry} variant="primary" size="sm">
54
+ <RefreshCw className="h-4 w-4 mr-2" />
55
+ Try Again
56
+ </Button>
57
+ </div>
58
+ </div>
59
+ );
60
+ }
61
+
62
+ return this.props.children;
63
+ }
64
+ }