@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,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
+ }