@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,164 @@
1
+ import { sqliteTable, text, integer, primaryKey, index } from "drizzle-orm/sqlite-core";
2
+
3
+ // ============================================================================
4
+ // channels
5
+ // ============================================================================
6
+
7
+ export const channels = sqliteTable("channels", {
8
+ id: text("id").primaryKey(),
9
+ name: text("name").notNull(),
10
+ defaultAgentId: text("default_agent_id").notNull(),
11
+ createdAt: integer("created_at").notNull(), // unix ms
12
+ updatedAt: integer("updated_at"), // unix ms
13
+ lastAccessedAt: integer("last_accessed_at"), // unix ms — last time user opened this channel
14
+ archivedAt: integer("archived_at"), // unix ms — null if active, set when archived
15
+ });
16
+
17
+ // ============================================================================
18
+ // settings (key-value store for user preferences)
19
+ // ============================================================================
20
+
21
+ export const settings = sqliteTable("settings", {
22
+ key: text("key").primaryKey(),
23
+ value: text("value").notNull(),
24
+ updatedAt: integer("updated_at").notNull(), // unix ms
25
+ });
26
+
27
+ // ============================================================================
28
+ // agent_statuses (live agent activity state)
29
+ // ============================================================================
30
+
31
+ export const agentStatuses = sqliteTable("agent_statuses", {
32
+ agentId: text("agent_id").primaryKey(),
33
+ status: text("status").notNull().default("idle"), // "idle" | "thinking" | "active"
34
+ updatedAt: integer("updated_at").notNull(), // unix ms
35
+ });
36
+
37
+ // ============================================================================
38
+ // channel_agents (many-to-many junction)
39
+ // ============================================================================
40
+
41
+ export const channelAgents = sqliteTable(
42
+ "channel_agents",
43
+ {
44
+ channelId: text("channel_id")
45
+ .notNull()
46
+ .references(() => channels.id, { onDelete: "cascade" }),
47
+ agentId: text("agent_id").notNull(),
48
+ },
49
+ (table) => [
50
+ primaryKey({ columns: [table.channelId, table.agentId] }),
51
+ ]
52
+ );
53
+
54
+ // ============================================================================
55
+ // sessions
56
+ // ============================================================================
57
+
58
+ export const sessions = sqliteTable(
59
+ "sessions",
60
+ {
61
+ id: text("id").primaryKey(),
62
+ channelId: text("channel_id")
63
+ .notNull()
64
+ .references(() => channels.id, { onDelete: "cascade" }),
65
+ sessionKey: text("session_key"), // Gateway session key
66
+ startedAt: integer("started_at").notNull(), // unix ms
67
+ endedAt: integer("ended_at"), // unix ms, nullable
68
+ summary: text("summary"),
69
+ totalInputTokens: integer("total_input_tokens").default(0),
70
+ totalOutputTokens: integer("total_output_tokens").default(0),
71
+ // Compaction tracking: ID of the oldest message still in the agent's context.
72
+ // Messages before this boundary have been compacted (summarized).
73
+ // Updated when compaction events are detected.
74
+ compactionBoundaryMessageId: text("compaction_boundary_message_id"),
75
+ },
76
+ (table) => [
77
+ index("idx_sessions_channel").on(table.channelId, table.startedAt),
78
+ ]
79
+ );
80
+
81
+ // ============================================================================
82
+ // messages
83
+ // ============================================================================
84
+
85
+ export const messages = sqliteTable(
86
+ "messages",
87
+ {
88
+ id: text("id").primaryKey(),
89
+ channelId: text("channel_id")
90
+ .notNull()
91
+ .references(() => channels.id, { onDelete: "cascade" }),
92
+ sessionId: text("session_id").references(() => sessions.id),
93
+ senderType: text("sender_type").notNull(), // "user" | "agent"
94
+ senderId: text("sender_id").notNull(),
95
+ senderName: text("sender_name"),
96
+ content: text("content").notNull().default(""),
97
+ status: text("status").notNull().default("complete"), // "complete" | "interrupted" | "aborted"
98
+ mentionedAgentId: text("mentioned_agent_id"),
99
+ runId: text("run_id"), // Gateway run ID for streaming correlation
100
+ sessionKey: text("session_key"), // Gateway session key
101
+ inputTokens: integer("input_tokens"),
102
+ outputTokens: integer("output_tokens"),
103
+ createdAt: integer("created_at").notNull(), // unix ms
104
+ },
105
+ (table) => [
106
+ index("idx_messages_channel").on(table.channelId, table.createdAt),
107
+ index("idx_messages_session").on(table.sessionId, table.createdAt),
108
+ index("idx_messages_run_id").on(table.runId),
109
+ ]
110
+ );
111
+
112
+ // ============================================================================
113
+ // message_attachments
114
+ // ============================================================================
115
+
116
+ export const messageAttachments = sqliteTable(
117
+ "message_attachments",
118
+ {
119
+ id: text("id").primaryKey(),
120
+ messageId: text("message_id")
121
+ .notNull()
122
+ .references(() => messages.id, { onDelete: "cascade" }),
123
+ attachmentType: text("attachment_type").notNull(), // "image" | "audio"
124
+ filePath: text("file_path").notNull(),
125
+ mimeType: text("mime_type"),
126
+ fileSize: integer("file_size"),
127
+ originalName: text("original_name"),
128
+ createdAt: integer("created_at").notNull(), // unix ms
129
+ },
130
+ (table) => [
131
+ index("idx_attachments_message").on(table.messageId),
132
+ ]
133
+ );
134
+
135
+ // ============================================================================
136
+ // recent_searches
137
+ // ============================================================================
138
+
139
+ export const recentSearches = sqliteTable("recent_searches", {
140
+ id: integer("id").primaryKey({ autoIncrement: true }),
141
+ query: text("query").notNull(),
142
+ createdAt: integer("created_at").notNull(), // unix ms
143
+ });
144
+
145
+ // ============================================================================
146
+ // message_reactions
147
+ // ============================================================================
148
+
149
+ export const messageReactions = sqliteTable(
150
+ "message_reactions",
151
+ {
152
+ id: text("id").primaryKey(),
153
+ messageId: text("message_id")
154
+ .notNull()
155
+ .references(() => messages.id, { onDelete: "cascade" }),
156
+ agentId: text("agent_id"),
157
+ emoji: text("emoji").notNull(),
158
+ emojiChar: text("emoji_char").notNull(),
159
+ createdAt: integer("created_at").notNull(), // unix ms
160
+ },
161
+ (table) => [
162
+ index("idx_reactions_message").on(table.messageId),
163
+ ]
164
+ );
@@ -463,7 +463,8 @@ class GatewayConnection extends EventEmitter {
463
463
  let msg: GatewayFrame;
464
464
  try {
465
465
  msg = JSON.parse(data.toString());
466
- } catch {
466
+ } catch (err) {
467
+ console.warn("[Gateway] Failed to parse message:", (err as Error).message);
467
468
  return;
468
469
  }
469
470
 
@@ -502,16 +503,31 @@ class GatewayConnection extends EventEmitter {
502
503
 
503
504
  const id = randomUUID();
504
505
  const frame: RequestFrame = { type: "req", id, method, params };
506
+ const startTime = Date.now();
505
507
 
506
508
  return new Promise<T>((resolve, reject) => {
507
509
  const timer = setTimeout(() => {
508
510
  this.pending.delete(id);
511
+ const elapsed = Date.now() - startTime;
512
+ console.error(`[Gateway RPC] ${method} TIMEOUT after ${elapsed}ms`);
509
513
  reject(new Error(`Request timeout: ${method}`));
510
514
  }, this.requestTimeout);
511
515
 
512
516
  this.pending.set(id, {
513
- resolve: resolve as (payload: unknown) => void,
514
- reject,
517
+ resolve: (payload: unknown) => {
518
+ const elapsed = Date.now() - startTime;
519
+ if (elapsed > 2000) {
520
+ console.warn(`[Gateway RPC] ${method} OK (slow: ${elapsed}ms)`);
521
+ } else {
522
+ console.log(`[Gateway RPC] ${method} OK (${elapsed}ms)`);
523
+ }
524
+ resolve(payload as T);
525
+ },
526
+ reject: (error: Error) => {
527
+ const elapsed = Date.now() - startTime;
528
+ console.error(`[Gateway RPC] ${method} FAILED (${elapsed}ms): ${sanitize(error.message)}`);
529
+ reject(error);
530
+ },
515
531
  timer,
516
532
  });
517
533
 
@@ -519,6 +535,8 @@ class GatewayConnection extends EventEmitter {
519
535
  if (err) {
520
536
  clearTimeout(timer);
521
537
  this.pending.delete(id);
538
+ const elapsed = Date.now() - startTime;
539
+ console.error(`[Gateway RPC] ${method} SEND ERROR (${elapsed}ms): ${err.message}`);
522
540
  reject(new Error(`Send failed: ${err.message}`));
523
541
  }
524
542
  });
@@ -556,6 +574,9 @@ class GatewayConnection extends EventEmitter {
556
574
  }
557
575
 
558
576
  // Reject all pending requests
577
+ if (this.pending.size > 0) {
578
+ console.warn(`[Gateway] Rejecting ${this.pending.size} pending request(s) due to connection close`);
579
+ }
559
580
  for (const [id, pending] of this.pending) {
560
581
  clearTimeout(pending.timer);
561
582
  pending.reject(new Error("Connection closed"));
@@ -0,0 +1,251 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect } from "react";
4
+ import useSWR, { mutate as globalMutate } from "swr";
5
+
6
+ // ============================================================================
7
+ // Types
8
+ // ============================================================================
9
+
10
+ export type AgentStatus = "idle" | "thinking" | "active";
11
+
12
+ interface AgentStatusRow {
13
+ agentId: string;
14
+ status: AgentStatus;
15
+ updatedAt: number;
16
+ }
17
+
18
+ type StatusMap = Record<string, AgentStatus>;
19
+
20
+ // ============================================================================
21
+ // Constants
22
+ // ============================================================================
23
+
24
+ const SWR_KEY = "/api/openclaw/agents/status";
25
+ const CHANNEL_NAME = "agent-status";
26
+
27
+ // ============================================================================
28
+ // Fetcher
29
+ // ============================================================================
30
+
31
+ const statusFetcher = async (url: string): Promise<StatusMap> => {
32
+ const res = await fetch(url);
33
+ if (!res.ok) return {};
34
+ const data = await res.json();
35
+ const map: StatusMap = {};
36
+ for (const row of (data.statuses || []) as AgentStatusRow[]) {
37
+ map[row.agentId] = row.status;
38
+ }
39
+ return map;
40
+ };
41
+
42
+ // ============================================================================
43
+ // BroadcastChannel: cross-tab real-time sync (no polling needed)
44
+ // ============================================================================
45
+
46
+ let broadcast: BroadcastChannel | null = null;
47
+
48
+ function getBroadcast(): BroadcastChannel | null {
49
+ if (typeof window === "undefined") return null;
50
+ if (!broadcast) {
51
+ try {
52
+ broadcast = new BroadcastChannel(CHANNEL_NAME);
53
+ } catch {
54
+ // BroadcastChannel not supported (e.g. some older browsers)
55
+ }
56
+ }
57
+ return broadcast;
58
+ }
59
+
60
+ // ============================================================================
61
+ // Client-side active → idle timers (mirrors the server-side 2 min expiry)
62
+ // ============================================================================
63
+
64
+ const ACTIVE_TIMEOUT_MS = 2 * 60 * 1000;
65
+ const activeTimers = new Map<string, ReturnType<typeof setTimeout>>();
66
+
67
+ function scheduleIdleTransition(agentId: string) {
68
+ // Clear any existing timer for this agent
69
+ const existing = activeTimers.get(agentId);
70
+ if (existing) clearTimeout(existing);
71
+
72
+ activeTimers.set(
73
+ agentId,
74
+ setTimeout(() => {
75
+ activeTimers.delete(agentId);
76
+ // Explicitly transition to idle — triggers SWR + BroadcastChannel
77
+ updateStatus(agentId, "idle");
78
+ }, ACTIVE_TIMEOUT_MS)
79
+ );
80
+ }
81
+
82
+ function clearIdleTimer(agentId: string) {
83
+ const existing = activeTimers.get(agentId);
84
+ if (existing) {
85
+ clearTimeout(existing);
86
+ activeTimers.delete(agentId);
87
+ }
88
+ }
89
+
90
+ // ============================================================================
91
+ // Channel tracking: which channel each agent is currently thinking in
92
+ // ============================================================================
93
+
94
+ const thinkingChannels = new Map<string, string>();
95
+
96
+ /** Returns the channelId the agent is currently thinking in, or undefined. */
97
+ export function getThinkingChannel(agentId: string): string | undefined {
98
+ return thinkingChannels.get(agentId);
99
+ }
100
+
101
+ // ============================================================================
102
+ // Exported setters (optimistic SWR + DB persist + cross-tab broadcast)
103
+ // ============================================================================
104
+
105
+ function updateStatus(agentId: string, status: AgentStatus) {
106
+ // 1. Optimistic: update SWR cache immediately (current tab)
107
+ globalMutate(
108
+ SWR_KEY,
109
+ (prev: StatusMap | undefined) => ({ ...prev, [agentId]: status }),
110
+ { revalidate: false }
111
+ );
112
+
113
+ // 2. Broadcast to other tabs (instant, no server round-trip)
114
+ getBroadcast()?.postMessage({ agentId, status });
115
+
116
+ // 3. Persist to DB via API (fire-and-forget)
117
+ fetch(SWR_KEY, {
118
+ method: "POST",
119
+ headers: { "Content-Type": "application/json" },
120
+ body: JSON.stringify({ agentId, status }),
121
+ }).catch(() => {
122
+ // Silent — DB will be stale but next page load corrects it
123
+ });
124
+ }
125
+
126
+ export function setAgentThinking(agentId: string, channelId?: string) {
127
+ clearIdleTimer(agentId);
128
+ if (channelId) thinkingChannels.set(agentId, channelId);
129
+ updateStatus(agentId, "thinking");
130
+ }
131
+
132
+ export function setAgentActive(agentId: string) {
133
+ thinkingChannels.delete(agentId);
134
+ updateStatus(agentId, "active");
135
+ // Schedule automatic idle transition after 2 minutes
136
+ scheduleIdleTransition(agentId);
137
+ }
138
+
139
+ export function setAgentIdle(agentId: string) {
140
+ thinkingChannels.delete(agentId);
141
+ clearIdleTimer(agentId);
142
+ updateStatus(agentId, "idle");
143
+ }
144
+
145
+ // ============================================================================
146
+ // User presence constants
147
+ // ============================================================================
148
+
149
+ export const USER_STATUS_ID = "__user__";
150
+ const USER_IDLE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes of inactivity → idle
151
+
152
+ // ============================================================================
153
+ // Hook
154
+ // ============================================================================
155
+
156
+ /**
157
+ * Shared agent status hook backed by the database.
158
+ * Uses BroadcastChannel for instant cross-tab sync — no polling.
159
+ * On mount, fetches current state from DB (catches up after page load).
160
+ * The server auto-expires "active" statuses older than 2 minutes to "idle".
161
+ *
162
+ * Status lifecycle: idle → thinking → active (2 min server-side) → idle
163
+ */
164
+ export function useAgentStatus() {
165
+ const { data: statuses } = useSWR<StatusMap>(SWR_KEY, statusFetcher, {
166
+ fallbackData: {},
167
+ revalidateOnFocus: true,
168
+ revalidateOnReconnect: true,
169
+ refreshInterval: 120_000, // Light background poll every 2 min just to catch expired "active" → "idle"
170
+ dedupingInterval: 5000,
171
+ });
172
+
173
+ // Listen for cross-tab broadcasts and update SWR cache instantly
174
+ useEffect(() => {
175
+ const bc = getBroadcast();
176
+ if (!bc) return;
177
+
178
+ const handler = (e: MessageEvent) => {
179
+ const { agentId, status } = e.data as { agentId: string; status: AgentStatus };
180
+ if (agentId && status) {
181
+ globalMutate(
182
+ SWR_KEY,
183
+ (prev: StatusMap | undefined) => ({ ...prev, [agentId]: status }),
184
+ { revalidate: false }
185
+ );
186
+ }
187
+ };
188
+
189
+ bc.addEventListener("message", handler);
190
+ return () => bc.removeEventListener("message", handler);
191
+ }, []);
192
+
193
+ const getStatus = useCallback(
194
+ (agentId: string): AgentStatus => {
195
+ return statuses?.[agentId] ?? "idle";
196
+ },
197
+ [statuses]
198
+ );
199
+
200
+ return {
201
+ statuses: statuses ?? {},
202
+ getStatus,
203
+ setThinking: setAgentThinking,
204
+ setActive: setAgentActive,
205
+ setIdle: setAgentIdle,
206
+ };
207
+ }
208
+
209
+ // ============================================================================
210
+ // User Presence Hook
211
+ // ============================================================================
212
+
213
+ /**
214
+ * Tracks user activity and sets active/idle status.
215
+ * Call once at the app layout level — listens for mouse, keyboard,
216
+ * scroll, and touch events. After 2 minutes of inactivity, sets idle.
217
+ */
218
+ export function useUserPresence() {
219
+ useEffect(() => {
220
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
221
+ let isActive = false;
222
+
223
+ const setActive = () => {
224
+ if (!isActive) {
225
+ isActive = true;
226
+ updateStatus(USER_STATUS_ID, "active");
227
+ }
228
+ // Reset idle timer on every interaction
229
+ if (idleTimer) clearTimeout(idleTimer);
230
+ idleTimer = setTimeout(() => {
231
+ isActive = false;
232
+ updateStatus(USER_STATUS_ID, "idle");
233
+ }, USER_IDLE_TIMEOUT_MS);
234
+ };
235
+
236
+ // Set active immediately on mount
237
+ setActive();
238
+
239
+ const events = ["mousemove", "keydown", "scroll", "click", "touchstart"] as const;
240
+ for (const event of events) {
241
+ document.addEventListener(event, setActive, { passive: true });
242
+ }
243
+
244
+ return () => {
245
+ for (const event of events) {
246
+ document.removeEventListener(event, setActive);
247
+ }
248
+ if (idleTimer) clearTimeout(idleTimer);
249
+ };
250
+ }, []);
251
+ }