@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
@@ -4,17 +4,18 @@ import { cn } from "@/lib/utils";
4
4
  export interface AvatarProps extends HTMLAttributes<HTMLDivElement> {
5
5
  size?: "sm" | "md" | "lg";
6
6
  status?: "online" | "offline" | "busy" | "away";
7
+ statusPulse?: boolean;
7
8
  }
8
9
 
9
10
  const Avatar = forwardRef<HTMLDivElement, AvatarProps>(
10
- ({ className, size = "md", status, children, ...props }, ref) => {
11
+ ({ className, size = "md", status, statusPulse, children, ...props }, ref) => {
11
12
  return (
12
- <div className="relative inline-block">
13
+ <div className="relative inline-block shrink-0">
13
14
  <div
14
15
  className={cn(
15
- "relative flex shrink-0 overflow-hidden rounded-[var(--radius-full)] bg-surface border border-border",
16
+ "relative flex shrink-0 overflow-hidden rounded-[4px] bg-surface border border-border",
16
17
  {
17
- "h-8 w-8": size === "sm",
18
+ "h-9 w-9": size === "sm",
18
19
  "h-10 w-10": size === "md",
19
20
  "h-12 w-12": size === "lg",
20
21
  },
@@ -28,18 +29,19 @@ const Avatar = forwardRef<HTMLDivElement, AvatarProps>(
28
29
  {status && (
29
30
  <span
30
31
  className={cn(
31
- "absolute bottom-0 right-0 block rounded-[var(--radius-full)] ring-2 ring-background",
32
+ "absolute block rounded-full ring-2 ring-background",
32
33
  {
33
- "h-2.5 w-2.5": size === "sm",
34
- "h-3 w-3": size === "md",
35
- "h-3.5 w-3.5": size === "lg",
34
+ "h-2.5 w-2.5 -bottom-0.5 -right-0.5": size === "sm",
35
+ "h-3 w-3 -bottom-0.5 -right-0.5": size === "md",
36
+ "h-3.5 w-3.5 -bottom-0.5 -right-0.5": size === "lg",
36
37
  },
37
38
  {
38
39
  "bg-success": status === "online",
39
40
  "bg-foreground-muted": status === "offline",
40
41
  "bg-error": status === "busy",
41
42
  "bg-warning": status === "away",
42
- }
43
+ },
44
+ statusPulse && "animate-pulse"
43
45
  )}
44
46
  />
45
47
  )}
@@ -7,17 +7,23 @@ import { cn } from "@/lib/utils";
7
7
  export interface DialogProps extends HTMLAttributes<HTMLDivElement> {
8
8
  open?: boolean;
9
9
  onClose?: () => void;
10
+ onOpenChange?: (open: boolean) => void;
10
11
  }
11
12
 
12
13
  const Dialog = forwardRef<HTMLDivElement, DialogProps>(
13
- ({ className, open = false, onClose, children, ...props }, ref) => {
14
+ ({ className, open = false, onClose, onOpenChange, children, ...props }, ref) => {
14
15
  if (!open) return null;
15
16
 
17
+ const handleClose = () => {
18
+ onClose?.();
19
+ onOpenChange?.(false);
20
+ };
21
+
16
22
  return (
17
23
  <div className="fixed inset-0 z-50 flex items-center justify-center">
18
24
  <div
19
25
  className="fixed inset-0 bg-black/50 backdrop-blur-sm"
20
- onClick={onClose}
26
+ onClick={handleClose}
21
27
  />
22
28
  <div
23
29
  className={cn(
@@ -27,9 +33,9 @@ const Dialog = forwardRef<HTMLDivElement, DialogProps>(
27
33
  ref={ref}
28
34
  {...props}
29
35
  >
30
- {onClose && (
36
+ {(onClose || onOpenChange) && (
31
37
  <button
32
- onClick={onClose}
38
+ onClick={handleClose}
33
39
  className="absolute right-4 top-4 p-1 rounded-[var(--radius-sm)] text-foreground-muted hover:text-foreground hover:bg-surface-hover transition-colors"
34
40
  >
35
41
  <X className="h-4 w-4" />
@@ -9,16 +9,20 @@ export interface TooltipProps {
9
9
  content: string;
10
10
  side?: "top" | "right" | "bottom" | "left";
11
11
  className?: string;
12
+ /** Delay in ms before showing the tooltip (default 0) */
13
+ delay?: number;
12
14
  }
13
15
 
14
16
  function Tooltip({
15
17
  children,
16
18
  content,
17
19
  side = "right",
18
- className
20
+ className,
21
+ delay = 0,
19
22
  }: TooltipProps) {
20
23
  const [isHovered, setIsHovered] = useState(false);
21
24
  const [mounted, setMounted] = useState(false);
25
+ const delayRef = useRef<ReturnType<typeof setTimeout> | null>(null);
22
26
  const [position, setPosition] = useState({ x: 0, y: 0 });
23
27
  const [isPositioned, setIsPositioned] = useState(false);
24
28
  const triggerRef = useRef<HTMLDivElement>(null);
@@ -26,6 +30,10 @@ function Tooltip({
26
30
 
27
31
  useEffect(() => {
28
32
  setMounted(true);
33
+ return () => {
34
+ // Clean up delay timer on unmount to prevent setState on unmounted component
35
+ if (delayRef.current) clearTimeout(delayRef.current);
36
+ };
29
37
  }, []);
30
38
 
31
39
  const updatePosition = useCallback(() => {
@@ -83,8 +91,17 @@ function Tooltip({
83
91
  <>
84
92
  <div
85
93
  ref={triggerRef}
86
- onMouseEnter={() => setIsHovered(true)}
87
- onMouseLeave={() => setIsHovered(false)}
94
+ onMouseEnter={() => {
95
+ if (delay > 0) {
96
+ delayRef.current = setTimeout(() => setIsHovered(true), delay);
97
+ } else {
98
+ setIsHovered(true);
99
+ }
100
+ }}
101
+ onMouseLeave={() => {
102
+ if (delayRef.current) { clearTimeout(delayRef.current); delayRef.current = null; }
103
+ setIsHovered(false);
104
+ }}
88
105
  className={className}
89
106
  >
90
107
  {children}
@@ -110,26 +127,26 @@ function Tooltip({
110
127
  side === "bottom" && (isPositioned ? "translate-y-0" : "-translate-y-2")
111
128
  )}
112
129
  >
113
- <div className="relative bg-foreground text-background text-sm font-medium px-3 py-1.5 rounded-[4px] whitespace-nowrap shadow-xl shadow-black/25">
130
+ <div className="relative bg-[#1a1a1a] text-white text-sm font-medium px-3 py-1.5 rounded-[4px] whitespace-nowrap shadow-xl shadow-black/25">
114
131
  {content}
115
132
  {side === "right" && (
116
133
  <div className="absolute -left-[7px] top-1/2 -translate-y-1/2">
117
- <div className="w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-r-[8px] border-r-foreground" />
134
+ <div className="w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-r-[8px] border-r-[#1a1a1a]" />
118
135
  </div>
119
136
  )}
120
137
  {side === "left" && (
121
138
  <div className="absolute -right-[7px] top-1/2 -translate-y-1/2">
122
- <div className="w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-l-[8px] border-l-foreground" />
139
+ <div className="w-0 h-0 border-t-[8px] border-t-transparent border-b-[8px] border-b-transparent border-l-[8px] border-l-[#1a1a1a]" />
123
140
  </div>
124
141
  )}
125
142
  {side === "top" && (
126
143
  <div className="absolute -bottom-[7px] left-1/2 -translate-x-1/2">
127
- <div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-t-[8px] border-t-foreground" />
144
+ <div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-t-[8px] border-t-[#1a1a1a]" />
128
145
  </div>
129
146
  )}
130
147
  {side === "bottom" && (
131
148
  <div className="absolute -top-[7px] left-1/2 -translate-x-1/2">
132
- <div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-b-[8px] border-b-foreground" />
149
+ <div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-b-[8px] border-b-[#1a1a1a]" />
133
150
  </div>
134
151
  )}
135
152
  </div>
@@ -0,0 +1,37 @@
1
+ "use client";
2
+
3
+ import { useRef, useEffect, memo } from "react";
4
+ import twemoji from "@twemoji/api";
5
+
6
+ interface TwemojiTextProps {
7
+ children: React.ReactNode;
8
+ className?: string;
9
+ }
10
+
11
+ /**
12
+ * Wraps children and replaces native Unicode emojis with Twemoji SVG images.
13
+ * Uses twemoji.parse() on the rendered DOM node after mount/update.
14
+ */
15
+ export const TwemojiText = memo(function TwemojiText({
16
+ children,
17
+ className,
18
+ }: TwemojiTextProps) {
19
+ const ref = useRef<HTMLSpanElement>(null);
20
+
21
+ useEffect(() => {
22
+ if (ref.current) {
23
+ twemoji.parse(ref.current, {
24
+ folder: "svg",
25
+ ext: ".svg",
26
+ // Use jsDelivr CDN for the SVG assets
27
+ base: "https://cdn.jsdelivr.net/gh/jdecked/twemoji@latest/assets/",
28
+ });
29
+ }
30
+ });
31
+
32
+ return (
33
+ <span ref={ref} className={className}>
34
+ {children}
35
+ </span>
36
+ );
37
+ });
@@ -1,5 +1,130 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
 
3
+ // ============================================================================
4
+ // Localhost guard — reject requests from non-local IPs
5
+ // ============================================================================
6
+
7
+ const LOCALHOST_IPS = new Set([
8
+ "127.0.0.1",
9
+ "::1",
10
+ "::ffff:127.0.0.1",
11
+ "localhost",
12
+ ]);
13
+
14
+ /**
15
+ * Reject requests that don't originate from localhost.
16
+ * Uses x-forwarded-for (if behind a proxy) or falls back to the host header.
17
+ * Returns a 403 response if the request is from a non-local IP, or null if OK.
18
+ */
19
+ export function checkLocalhost(request: NextRequest): NextResponse | null {
20
+ // If running in development, skip the check
21
+ if (process.env.NODE_ENV === "development") return null;
22
+
23
+ const forwarded = request.headers.get("x-forwarded-for");
24
+ const host = request.headers.get("host") || "";
25
+
26
+ // Check x-forwarded-for first (proxy scenario)
27
+ if (forwarded) {
28
+ const clientIp = forwarded.split(",")[0].trim();
29
+ if (!LOCALHOST_IPS.has(clientIp)) {
30
+ return NextResponse.json(
31
+ { error: "Forbidden — Castle is only accessible from localhost" },
32
+ { status: 403 }
33
+ );
34
+ }
35
+ }
36
+
37
+ // Check host header — reject if it's not a localhost variant
38
+ const hostname = host.split(":")[0];
39
+ if (hostname && !LOCALHOST_IPS.has(hostname)) {
40
+ return NextResponse.json(
41
+ { error: "Forbidden — Castle is only accessible from localhost" },
42
+ { status: 403 }
43
+ );
44
+ }
45
+
46
+ return null;
47
+ }
48
+
49
+ // ============================================================================
50
+ // Rate limiting — in-memory sliding window
51
+ // ============================================================================
52
+
53
+ interface RateLimitEntry {
54
+ timestamps: number[];
55
+ }
56
+
57
+ const rateLimitStore = new Map<string, RateLimitEntry>();
58
+
59
+ // Clean up stale entries every 5 minutes
60
+ let cleanupTimer: ReturnType<typeof setInterval> | null = null;
61
+ function ensureCleanup() {
62
+ if (cleanupTimer) return;
63
+ cleanupTimer = setInterval(() => {
64
+ const now = Date.now();
65
+ for (const [key, entry] of rateLimitStore.entries()) {
66
+ entry.timestamps = entry.timestamps.filter((t) => now - t < 60_000);
67
+ if (entry.timestamps.length === 0) rateLimitStore.delete(key);
68
+ }
69
+ }, 5 * 60_000);
70
+ // Don't prevent process exit
71
+ if (typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
72
+ cleanupTimer.unref();
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Simple in-memory rate limiter using a sliding window.
78
+ *
79
+ * @param key — Unique key for the rate limit bucket (e.g. "chat:send" or IP-based)
80
+ * @param limit — Max requests allowed within the window
81
+ * @param windowMs — Window duration in milliseconds (default: 60 seconds)
82
+ * @returns 429 response if rate limit exceeded, or null if OK
83
+ */
84
+ export function checkRateLimit(
85
+ key: string,
86
+ limit: number,
87
+ windowMs = 60_000
88
+ ): NextResponse | null {
89
+ ensureCleanup();
90
+
91
+ const now = Date.now();
92
+ let entry = rateLimitStore.get(key);
93
+
94
+ if (!entry) {
95
+ entry = { timestamps: [] };
96
+ rateLimitStore.set(key, entry);
97
+ }
98
+
99
+ // Drop timestamps outside the window
100
+ entry.timestamps = entry.timestamps.filter((t) => now - t < windowMs);
101
+
102
+ if (entry.timestamps.length >= limit) {
103
+ return NextResponse.json(
104
+ { error: "Too many requests — please slow down" },
105
+ { status: 429 }
106
+ );
107
+ }
108
+
109
+ entry.timestamps.push(now);
110
+ return null;
111
+ }
112
+
113
+ /**
114
+ * Helper to build a rate-limit key from the request IP + route.
115
+ */
116
+ export function rateLimitKey(request: NextRequest, route: string): string {
117
+ const ip =
118
+ request.headers.get("x-forwarded-for")?.split(",")[0].trim() ||
119
+ request.headers.get("x-real-ip") ||
120
+ "local";
121
+ return `${ip}:${route}`;
122
+ }
123
+
124
+ // ============================================================================
125
+ // CSRF protection
126
+ // ============================================================================
127
+
3
128
  /**
4
129
  * Verify that a mutating API request originated from the Castle UI itself,
5
130
  * not from a cross-origin attacker (CSRF protection).
@@ -0,0 +1,79 @@
1
+ // ============================================================================
2
+ // Shared date formatting — consistent across the app
3
+ // ============================================================================
4
+ //
5
+ // Formats:
6
+ // formatDate(ts) → "9 February 2026"
7
+ // formatDateTime(ts) → "9 February 2026 at 2:24 am"
8
+ // formatTime(ts) → "2:24 am"
9
+ // formatTimeAgo(ts) → "3 hours ago", "2 days ago", etc.
10
+ // formatDateShort(ts) → "9 Feb" (for compact UI like search results)
11
+
12
+ /**
13
+ * "9 February 2026"
14
+ */
15
+ export function formatDate(timestamp: number): string {
16
+ return new Date(timestamp).toLocaleDateString("en-GB", {
17
+ day: "numeric",
18
+ month: "long",
19
+ year: "numeric",
20
+ });
21
+ }
22
+
23
+ /**
24
+ * "9 February 2026 at 2:24 am"
25
+ */
26
+ export function formatDateTime(timestamp: number): string {
27
+ const date = new Date(timestamp);
28
+ const datePart = date.toLocaleDateString("en-GB", {
29
+ day: "numeric",
30
+ month: "long",
31
+ year: "numeric",
32
+ });
33
+ const timePart = date.toLocaleTimeString("en-GB", {
34
+ hour: "numeric",
35
+ minute: "2-digit",
36
+ hour12: true,
37
+ });
38
+ return `${datePart} at ${timePart}`;
39
+ }
40
+
41
+ /**
42
+ * "2:24 am"
43
+ */
44
+ export function formatTime(timestamp: number): string {
45
+ return new Date(timestamp).toLocaleTimeString("en-GB", {
46
+ hour: "numeric",
47
+ minute: "2-digit",
48
+ hour12: true,
49
+ });
50
+ }
51
+
52
+ /**
53
+ * "3 hours ago", "2 days ago", "just now", etc.
54
+ */
55
+ export function formatTimeAgo(timestamp: number): string {
56
+ const now = Date.now();
57
+ const diff = now - timestamp;
58
+
59
+ const seconds = Math.floor(diff / 1000);
60
+ if (seconds < 60) return "just now";
61
+
62
+ const minutes = Math.floor(seconds / 60);
63
+ if (minutes < 60) return `${minutes}m ago`;
64
+
65
+ const hours = Math.floor(minutes / 60);
66
+ if (hours < 24) return `${hours}h ago`;
67
+
68
+ const days = Math.floor(hours / 24);
69
+ if (days < 7) return `${days}d ago`;
70
+
71
+ const weeks = Math.floor(days / 7);
72
+ if (weeks < 5) return `${weeks}w ago`;
73
+
74
+ const months = Math.floor(days / 30);
75
+ if (months < 12) return `${months}mo ago`;
76
+
77
+ const years = Math.floor(days / 365);
78
+ return `${years}y ago`;
79
+ }