@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,46 @@
1
+ "use client";
2
+
3
+ import useSWR from "swr";
4
+
5
+ const SETTINGS_KEY = "/api/settings";
6
+
7
+ const fetcher = (url: string) => fetch(url).then((r) => r.json());
8
+
9
+ interface UserSettings {
10
+ displayName?: string;
11
+ avatarPath?: string;
12
+ tooltips?: string;
13
+ }
14
+
15
+ /**
16
+ * Shared hook for user settings (display name, avatar, etc).
17
+ *
18
+ * All consumers share the same SWR cache. Call `refresh()` after
19
+ * updating settings to instantly propagate changes everywhere
20
+ * (user menu, chat headers, message avatars).
21
+ */
22
+ export function useUserSettings() {
23
+ const { data, mutate, isLoading } = useSWR<UserSettings>(
24
+ SETTINGS_KEY,
25
+ fetcher,
26
+ {
27
+ revalidateOnFocus: true,
28
+ dedupingInterval: 5000,
29
+ }
30
+ );
31
+
32
+ const displayName = data?.displayName || "";
33
+ const avatarUrl = data?.avatarPath
34
+ ? `/api/settings/avatar?v=${encodeURIComponent(data.avatarPath)}`
35
+ : null;
36
+ const tooltips = data?.tooltips !== "false"; // default true
37
+
38
+ return {
39
+ displayName,
40
+ avatarUrl,
41
+ tooltips,
42
+ isLoading,
43
+ /** Call after saving settings to refresh all consumers */
44
+ refresh: () => mutate(),
45
+ };
46
+ }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Shared SSE singleton for Castle.
3
+ *
4
+ * Creates a SINGLE EventSource connection to /api/openclaw/events that is
5
+ * shared across all consumers (use-chat, use-openclaw, etc.).
6
+ *
7
+ * Ref-counted: the first subscriber opens the connection, the last
8
+ * unsubscribe closes it. Events are dispatched to handlers by event type.
9
+ *
10
+ * Deduplication: tracks the last seen `seq` number and drops duplicates
11
+ * as a safety net against multiple connections or reconnect replays.
12
+ */
13
+
14
+ export interface SSEEvent {
15
+ event: string;
16
+ payload?: unknown;
17
+ seq?: number;
18
+ }
19
+
20
+ type EventHandler = (evt: SSEEvent) => void;
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Module-level singleton state
24
+ // ---------------------------------------------------------------------------
25
+
26
+ let es: EventSource | null = null;
27
+ let refCount = 0;
28
+ let lastSeq = -1;
29
+ let lastEventTimestamp = Date.now();
30
+
31
+ /** Handlers keyed by event pattern. Exact match or "*" for all events. */
32
+ const handlers = new Map<string, Set<EventHandler>>();
33
+
34
+ /** Error handlers notified on connection loss */
35
+ const errorHandlers = new Set<() => void>();
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Internal helpers
39
+ // ---------------------------------------------------------------------------
40
+
41
+ function matchesPattern(eventType: string, pattern: string): boolean {
42
+ if (pattern === "*") return true;
43
+ if (pattern.endsWith(".*")) {
44
+ return eventType.startsWith(pattern.slice(0, -1));
45
+ }
46
+ return eventType === pattern;
47
+ }
48
+
49
+ function dispatch(evt: SSEEvent) {
50
+ // Update last-event timestamp for heartbeat monitoring
51
+ lastEventTimestamp = Date.now();
52
+
53
+ // Dedup by seq — drop events we've already seen
54
+ if (typeof evt.seq === "number") {
55
+ if (evt.seq <= lastSeq) {
56
+ console.debug("[SSE Client] Dropped duplicate event seq:", evt.seq, "event:", evt.event);
57
+ return;
58
+ }
59
+ lastSeq = evt.seq;
60
+ }
61
+
62
+ const eventType = evt.event;
63
+ for (const [pattern, handlerSet] of handlers) {
64
+ if (matchesPattern(eventType, pattern)) {
65
+ for (const handler of handlerSet) {
66
+ try {
67
+ handler(evt);
68
+ } catch (err) {
69
+ console.error("[SSE] Handler error:", err);
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ function openConnection() {
77
+ if (es) return;
78
+
79
+ console.log("[SSE Client] Opening connection (subscribers:", refCount + ")");
80
+ es = new EventSource("/api/openclaw/events");
81
+ lastEventTimestamp = Date.now();
82
+
83
+ es.onopen = () => {
84
+ console.log("[SSE Client] Connected");
85
+ };
86
+
87
+ es.onmessage = (e) => {
88
+ try {
89
+ const evt: SSEEvent = JSON.parse(e.data);
90
+ dispatch(evt);
91
+ } catch (err) {
92
+ console.warn("[SSE Client] Failed to parse event:", (err as Error).message, "data:", e.data?.slice(0, 100));
93
+ }
94
+ };
95
+
96
+ es.onerror = (e) => {
97
+ console.warn("[SSE Client] Connection error (readyState:", es?.readyState + ")");
98
+ // EventSource auto-reconnects. Notify error handlers so they can
99
+ // update UI state (e.g. show "disconnected").
100
+ for (const handler of errorHandlers) {
101
+ try {
102
+ handler();
103
+ } catch {
104
+ // ignore
105
+ }
106
+ }
107
+ };
108
+ }
109
+
110
+ function closeConnection() {
111
+ if (es) {
112
+ console.log("[SSE Client] Closing connection");
113
+ es.close();
114
+ es = null;
115
+ }
116
+ lastSeq = -1;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Public API
121
+ // ---------------------------------------------------------------------------
122
+
123
+ /**
124
+ * Subscribe to SSE events matching a pattern.
125
+ *
126
+ * Patterns:
127
+ * - `"chat"` — exact match for chat events
128
+ * - `"castle.state"` — exact match for state events
129
+ * - `"agent.*"` — wildcard: matches agent.created, agent.updated, etc.
130
+ * - `"*"` — all events
131
+ *
132
+ * Returns an unsubscribe function. Call it in your useEffect cleanup.
133
+ */
134
+ export function subscribe(pattern: string, handler: EventHandler): () => void {
135
+ if (!handlers.has(pattern)) {
136
+ handlers.set(pattern, new Set());
137
+ }
138
+ handlers.get(pattern)!.add(handler);
139
+
140
+ refCount++;
141
+ if (refCount === 1) {
142
+ openConnection();
143
+ }
144
+
145
+ return () => {
146
+ const set = handlers.get(pattern);
147
+ if (set) {
148
+ set.delete(handler);
149
+ if (set.size === 0) handlers.delete(pattern);
150
+ }
151
+
152
+ refCount--;
153
+ if (refCount <= 0) {
154
+ refCount = 0;
155
+ closeConnection();
156
+ }
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Subscribe to connection error events.
162
+ * Returns an unsubscribe function.
163
+ */
164
+ export function onError(handler: () => void): () => void {
165
+ errorHandlers.add(handler);
166
+ return () => {
167
+ errorHandlers.delete(handler);
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Get the timestamp of the last received SSE event.
173
+ * Used for heartbeat-based timeout detection.
174
+ */
175
+ export function getLastEventTimestamp(): number {
176
+ return lastEventTimestamp;
177
+ }
178
+
179
+ /**
180
+ * Check if the SSE connection is currently open.
181
+ */
182
+ export function isConnected(): boolean {
183
+ return es !== null && es.readyState !== EventSource.CLOSED;
184
+ }
@@ -0,0 +1,202 @@
1
+ // ============================================================================
2
+ // Channel
3
+ // ============================================================================
4
+
5
+ export interface Channel {
6
+ id: string;
7
+ name: string;
8
+ defaultAgentId: string;
9
+ agents: string[]; // Agent IDs in this channel
10
+ createdAt: number; // unix ms
11
+ archivedAt?: number | null; // unix ms — null if active
12
+ }
13
+
14
+ // ============================================================================
15
+ // Session
16
+ // ============================================================================
17
+
18
+ export interface ChannelSession {
19
+ id: string;
20
+ channelId: string;
21
+ sessionKey: string | null; // Gateway session key
22
+ startedAt: number; // unix ms
23
+ endedAt: number | null; // unix ms
24
+ summary: string | null;
25
+ totalInputTokens: number;
26
+ totalOutputTokens: number;
27
+ }
28
+
29
+ // ============================================================================
30
+ // Message
31
+ // ============================================================================
32
+
33
+ export type MessageStatus = "complete" | "interrupted" | "aborted";
34
+
35
+ export interface ChatMessage {
36
+ id: string;
37
+ channelId: string;
38
+ sessionId: string | null;
39
+ senderType: "user" | "agent";
40
+ senderId: string;
41
+ senderName: string | null;
42
+ content: string;
43
+ status: MessageStatus;
44
+ mentionedAgentId: string | null;
45
+ runId: string | null; // Gateway run ID for streaming correlation
46
+ sessionKey: string | null; // Gateway session key
47
+ inputTokens: number | null;
48
+ outputTokens: number | null;
49
+ createdAt: number; // unix ms
50
+ attachments: MessageAttachment[];
51
+ reactions: MessageReaction[];
52
+ }
53
+
54
+ // ============================================================================
55
+ // Attachment
56
+ // ============================================================================
57
+
58
+ export interface MessageAttachment {
59
+ id: string;
60
+ messageId: string;
61
+ attachmentType: "image" | "audio";
62
+ filePath: string;
63
+ mimeType: string | null;
64
+ fileSize: number | null;
65
+ originalName: string | null;
66
+ createdAt: number; // unix ms
67
+ }
68
+
69
+ // ============================================================================
70
+ // Reaction
71
+ // ============================================================================
72
+
73
+ export interface MessageReaction {
74
+ id: string;
75
+ messageId: string;
76
+ agentId: string | null;
77
+ emoji: string;
78
+ emojiChar: string;
79
+ createdAt: number; // unix ms
80
+ }
81
+
82
+ // ============================================================================
83
+ // Streaming
84
+ // ============================================================================
85
+
86
+ /** A message currently being streamed from the Gateway */
87
+ export interface StreamingMessage {
88
+ runId: string;
89
+ agentId: string;
90
+ agentName: string;
91
+ sessionKey: string;
92
+ content: string; // Accumulated text so far
93
+ startedAt: number;
94
+ }
95
+
96
+ /** A single streaming delta from the Gateway SSE */
97
+ export interface ChatDelta {
98
+ runId: string;
99
+ sessionKey: string;
100
+ seq: number;
101
+ state: "delta" | "final" | "error";
102
+ text?: string;
103
+ errorMessage?: string;
104
+ message?: {
105
+ content?: Array<{ type?: string; text?: string }>;
106
+ role?: string;
107
+ inputTokens?: number;
108
+ outputTokens?: number;
109
+ };
110
+ }
111
+
112
+ // ============================================================================
113
+ // Message Queue
114
+ // ============================================================================
115
+
116
+ export interface QueuedMessage {
117
+ id: string; // Temp ID for tracking
118
+ content: string;
119
+ agentId?: string;
120
+ attachments?: File[];
121
+ addedAt: number;
122
+ }
123
+
124
+ // ============================================================================
125
+ // Session Status (read from OpenClaw session store on filesystem)
126
+ // ============================================================================
127
+
128
+ export interface SessionStatus {
129
+ sessionKey: string;
130
+ sessionId: string;
131
+ agentId: string;
132
+ model: string;
133
+ modelProvider: string;
134
+ tokens: {
135
+ input: number;
136
+ output: number;
137
+ total: number;
138
+ };
139
+ context: {
140
+ used: number; // totalTokens (current context window usage)
141
+ limit: number; // effective operating limit (from config, default 200k)
142
+ modelMax: number; // model's theoretical max (e.g. 1M for Sonnet 4.5)
143
+ percentage: number; // used / limit * 100
144
+ };
145
+ compactions: number;
146
+ thinkingLevel: string | null;
147
+ updatedAt: number;
148
+ /** System prompt breakdown — only present when the session has run at least once */
149
+ systemPrompt?: {
150
+ totalChars: number;
151
+ projectContextChars: number;
152
+ nonProjectContextChars: number;
153
+ skills: { promptChars: number; count: number };
154
+ tools: { listChars: number; schemaChars: number; count: number };
155
+ workspaceFiles: Array<{
156
+ name: string;
157
+ injectedChars: number;
158
+ truncated: boolean;
159
+ }>;
160
+ };
161
+ }
162
+
163
+ // ============================================================================
164
+ // Storage Stats
165
+ // ============================================================================
166
+
167
+ export interface StorageStats {
168
+ messages: number;
169
+ channels: number;
170
+ attachments: number;
171
+ totalAttachmentBytes: number;
172
+ dbSizeBytes?: number; // Size of castle.db file
173
+ }
174
+
175
+ // ============================================================================
176
+ // API Payloads
177
+ // ============================================================================
178
+
179
+ export interface ChatSendRequest {
180
+ channelId: string;
181
+ content: string;
182
+ agentId?: string;
183
+ attachmentIds?: string[];
184
+ }
185
+
186
+ export interface ChatSendResponse {
187
+ runId: string;
188
+ messageId: string;
189
+ sessionKey: string;
190
+ }
191
+
192
+ export interface ChatCompleteRequest {
193
+ runId: string;
194
+ channelId: string;
195
+ content: string;
196
+ sessionKey: string;
197
+ agentId: string;
198
+ agentName?: string;
199
+ status: MessageStatus;
200
+ inputTokens?: number;
201
+ outputTokens?: number;
202
+ }
@@ -0,0 +1,60 @@
1
+ // ============================================================================
2
+ // Universal Search — Type System
3
+ // ============================================================================
4
+ //
5
+ // Discriminated union so every result carries its type and the dialog can
6
+ // render + navigate each type differently. Adding a new searchable content
7
+ // type later requires only:
8
+ //
9
+ // 1. Add a new interface extending SearchResultBase
10
+ // 2. Add it to the SearchResult union
11
+ // 3. Add a renderer in the search dialog
12
+ // 4. Add a search function in the API
13
+ //
14
+ // No changes to the dialog, hook, provider, keyboard shortcuts, or routing.
15
+
16
+ // --- Content type registry ---
17
+
18
+ export type SearchResultType = "message" | "task" | "note" | "project";
19
+ // ^ Extend this union as new content types are added
20
+
21
+ // --- Base result shape (shared fields) ---
22
+
23
+ export interface SearchResultBase {
24
+ id: string;
25
+ type: SearchResultType;
26
+ title: string; // Primary display text
27
+ subtitle?: string; // Secondary line (sender, project name, etc.)
28
+ snippet: string; // Content excerpt with match context
29
+ timestamp: number; // For sorting / display
30
+ href: string; // Where to navigate on click
31
+ }
32
+
33
+ // --- Content-specific result types ---
34
+
35
+ export interface MessageSearchResult extends SearchResultBase {
36
+ type: "message";
37
+ channelId: string;
38
+ channelName: string;
39
+ messageId: string;
40
+ senderType: "user" | "agent";
41
+ senderName: string;
42
+ archived?: boolean;
43
+ }
44
+
45
+ // Future examples (not implemented in v1):
46
+ // export interface TaskSearchResult extends SearchResultBase {
47
+ // type: "task";
48
+ // projectId: string;
49
+ // status: "todo" | "in_progress" | "done";
50
+ // }
51
+ //
52
+ // export interface NoteSearchResult extends SearchResultBase {
53
+ // type: "note";
54
+ // notebookId: string;
55
+ // }
56
+
57
+ // --- Discriminated union ---
58
+
59
+ export type SearchResult = MessageSearchResult;
60
+ // ^ Becomes: MessageSearchResult | TaskSearchResult | NoteSearchResult | ...
@@ -0,0 +1,52 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+
3
+ /**
4
+ * Next.js Middleware — applies security checks to all API routes.
5
+ *
6
+ * In production, rejects requests from non-localhost IPs.
7
+ * Castle is a local-first app and should never be exposed to the internet.
8
+ */
9
+
10
+ const LOCALHOST_HOSTS = new Set([
11
+ "localhost",
12
+ "127.0.0.1",
13
+ "::1",
14
+ "::ffff:127.0.0.1",
15
+ ]);
16
+
17
+ export function middleware(request: NextRequest) {
18
+ // Only enforce in production — dev is always allowed
19
+ if (process.env.NODE_ENV === "development") {
20
+ return NextResponse.next();
21
+ }
22
+
23
+ // Check if the request originates from localhost
24
+ const host = request.headers.get("host") || "";
25
+ const hostname = host.split(":")[0];
26
+
27
+ const forwarded = request.headers.get("x-forwarded-for");
28
+ const clientIp = forwarded?.split(",")[0].trim();
29
+
30
+ // If x-forwarded-for is present and it's not localhost, reject
31
+ if (clientIp && !LOCALHOST_HOSTS.has(clientIp)) {
32
+ return NextResponse.json(
33
+ { error: "Forbidden — Castle is only accessible from localhost" },
34
+ { status: 403 }
35
+ );
36
+ }
37
+
38
+ // If the host header isn't a localhost variant, reject
39
+ if (hostname && !LOCALHOST_HOSTS.has(hostname)) {
40
+ return NextResponse.json(
41
+ { error: "Forbidden — Castle is only accessible from localhost" },
42
+ { status: 403 }
43
+ );
44
+ }
45
+
46
+ return NextResponse.next();
47
+ }
48
+
49
+ // Only run middleware on API routes
50
+ export const config = {
51
+ matcher: "/api/:path*",
52
+ };