@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,753 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback, useRef, useEffect } from "react";
4
+ import useSWR, { mutate as globalMutate } from "swr";
5
+ import type {
6
+ ChatMessage,
7
+ StreamingMessage,
8
+ ChatDelta,
9
+ ChatSendResponse,
10
+ ChatCompleteRequest,
11
+ } from "@/lib/types/chat";
12
+ import { setAgentThinking, setAgentActive } from "@/lib/hooks/use-agent-status";
13
+ import { subscribe, getLastEventTimestamp, type SSEEvent } from "@/lib/sse-singleton";
14
+
15
+ // ============================================================================
16
+ // Fetcher
17
+ // ============================================================================
18
+
19
+ const historyFetcher = async (url: string) => {
20
+ const res = await fetch(url);
21
+ if (!res.ok) {
22
+ console.error(`[useChat] History fetch failed: ${res.status} ${url}`);
23
+ throw new Error("Failed to load messages");
24
+ }
25
+ return res.json();
26
+ };
27
+
28
+ // ============================================================================
29
+ // Orphaned run handler (module-level singleton)
30
+ // ============================================================================
31
+ //
32
+ // When a chat component unmounts while an agent is still processing,
33
+ // we hand off its EventSource and active runs to this module-level handler.
34
+ // It continues processing SSE events so "final"/"error" events persist
35
+ // the completed message to the DB. Without this, messages are lost on nav.
36
+ //
37
+ // Uses a single shared EventSource to avoid exhausting the browser's
38
+ // per-origin connection limit when the user navigates between many channels.
39
+
40
+ interface OrphanedRun {
41
+ channelId: string;
42
+ agentId: string;
43
+ agentName: string;
44
+ sessionKey: string;
45
+ content: string;
46
+ startedAt: number;
47
+ }
48
+
49
+ // Singleton state — shared across all unmounted chat instances
50
+ const orphanedRuns = new Map<string, OrphanedRun>();
51
+ let orphanedUnsubscribe: (() => void) | null = null;
52
+ let orphanedSafetyTimer: ReturnType<typeof setTimeout> | null = null;
53
+
54
+ function cleanupOrphaned() {
55
+ if (orphanedSafetyTimer) {
56
+ clearTimeout(orphanedSafetyTimer);
57
+ orphanedSafetyTimer = null;
58
+ }
59
+ orphanedUnsubscribe?.();
60
+ orphanedUnsubscribe = null;
61
+ orphanedRuns.clear();
62
+ }
63
+
64
+ function persistOrphanedRun(
65
+ runId: string,
66
+ run: OrphanedRun,
67
+ delta: ChatDelta,
68
+ status: "complete" | "interrupted",
69
+ ) {
70
+ orphanedRuns.delete(runId);
71
+ setAgentActive(run.agentId);
72
+
73
+ const content = status === "complete"
74
+ ? (delta.text || delta.message?.content?.[0]?.text || run.content)
75
+ : run.content;
76
+
77
+ if (content) {
78
+ const payload: ChatCompleteRequest = {
79
+ runId,
80
+ channelId: run.channelId,
81
+ content,
82
+ sessionKey: delta.sessionKey || run.sessionKey,
83
+ agentId: run.agentId,
84
+ agentName: run.agentName,
85
+ status,
86
+ ...(status === "complete" && {
87
+ inputTokens: delta.message?.inputTokens,
88
+ outputTokens: delta.message?.outputTokens,
89
+ }),
90
+ };
91
+
92
+ console.log(`[useChat] Persisting orphaned run ${runId} (channel=${run.channelId}, status=${status})`);
93
+ fetch("/api/openclaw/chat", {
94
+ method: "PUT",
95
+ headers: { "Content-Type": "application/json" },
96
+ body: JSON.stringify(payload),
97
+ })
98
+ .then(() => {
99
+ console.log(`[useChat] Orphaned run ${runId} persisted successfully`);
100
+ // Trigger SWR revalidation so the message shows when user navigates back
101
+ globalMutate(
102
+ (key) =>
103
+ typeof key === "string" &&
104
+ key.startsWith(`/api/openclaw/chat?channelId=${run.channelId}`),
105
+ );
106
+ })
107
+ .catch((err) => console.error(`[useChat] Orphan persist failed for run ${runId}:`, err));
108
+ }
109
+
110
+ // All runs done — tear down
111
+ if (orphanedRuns.size === 0) {
112
+ cleanupOrphaned();
113
+ }
114
+ }
115
+
116
+ function ensureOrphanedSubscription() {
117
+ if (orphanedUnsubscribe) return; // already subscribed
118
+
119
+ orphanedUnsubscribe = subscribe("chat", (evt: SSEEvent) => {
120
+ const delta = evt.payload as ChatDelta;
121
+ if (!delta?.runId || !orphanedRuns.has(delta.runId)) return;
122
+
123
+ const run = orphanedRuns.get(delta.runId)!;
124
+
125
+ if (delta.state === "delta") {
126
+ // Skip delta accumulation — text is cumulative (full text so far),
127
+ // so we just wait for the "final" event which has the complete response.
128
+ return;
129
+ } else if (delta.state === "final") {
130
+ persistOrphanedRun(delta.runId, run, delta, "complete");
131
+ } else if (delta.state === "error") {
132
+ persistOrphanedRun(delta.runId, run, delta, "interrupted");
133
+ }
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Hand off active runs to the module-level orphan handler.
139
+ * Uses the shared SSE singleton — no separate EventSource needed.
140
+ */
141
+ function orphanRuns(newRuns: Map<string, OrphanedRun>) {
142
+ // Merge new runs into the shared state
143
+ for (const [runId, run] of newRuns) {
144
+ orphanedRuns.set(runId, run);
145
+ }
146
+
147
+ ensureOrphanedSubscription();
148
+
149
+ // Reset safety timer (5 min) so stale subscriptions don't leak
150
+ if (orphanedSafetyTimer) clearTimeout(orphanedSafetyTimer);
151
+ orphanedSafetyTimer = setTimeout(cleanupOrphaned, 5 * 60 * 1000);
152
+ }
153
+
154
+ // ============================================================================
155
+ // Hook
156
+ // ============================================================================
157
+
158
+ /** Max messages to keep in the SWR cache. When exceeded, evict from the
159
+ * opposite end to prevent unbounded memory growth during long scroll sessions. */
160
+ const MAX_CACHED_MESSAGES = 500;
161
+
162
+ interface UseChatOptions {
163
+ channelId: string;
164
+ defaultAgentId?: string;
165
+ /** When set, loads a window of messages around this ID instead of the latest. */
166
+ anchorMessageId?: string;
167
+ }
168
+
169
+ /** Shape of the SWR cache for message history. */
170
+ interface HistoryData {
171
+ messages: ChatMessage[];
172
+ hasMoreBefore: boolean;
173
+ hasMoreAfter: boolean;
174
+ }
175
+
176
+ interface UseChatReturn {
177
+ // Messages
178
+ messages: ChatMessage[];
179
+ isLoading: boolean;
180
+ hasMoreBefore: boolean;
181
+ hasMoreAfter: boolean;
182
+ loadOlder: () => void;
183
+ loadNewer: () => void;
184
+ loadingOlder: boolean;
185
+ loadingNewer: boolean;
186
+
187
+ // Backward-compat aliases
188
+ hasMore: boolean;
189
+ loadMore: () => void;
190
+ loadingMore: boolean;
191
+
192
+ // Streaming
193
+ streamingMessages: Map<string, StreamingMessage>;
194
+ isStreaming: boolean;
195
+
196
+ // Session
197
+ currentSessionKey: string | null;
198
+
199
+ // Actions
200
+ sendMessage: (content: string, agentId?: string) => Promise<void>;
201
+ abortResponse: () => Promise<void>;
202
+ sending: boolean;
203
+
204
+ // Errors
205
+ sendError: string | null;
206
+ clearSendError: () => void;
207
+ }
208
+
209
+ export function useChat({ channelId, defaultAgentId, anchorMessageId }: UseChatOptions): UseChatReturn {
210
+ // ---------------------------------------------------------------------------
211
+ // State
212
+ // ---------------------------------------------------------------------------
213
+ const [streamingMessages, setStreamingMessages] = useState<Map<string, StreamingMessage>>(new Map());
214
+ const streamingRef = useRef<Map<string, StreamingMessage>>(new Map());
215
+ const [currentSessionKey, setCurrentSessionKey] = useState<string | null>(null);
216
+ const [sending, setSending] = useState(false);
217
+ const [sendError, setSendError] = useState<string | null>(null);
218
+ const [loadingOlder, setLoadingOlder] = useState(false);
219
+ const [loadingNewer, setLoadingNewer] = useState(false);
220
+
221
+ // Track active runIds for reconnection timeout
222
+ const activeRunIds = useRef<Set<string>>(new Set());
223
+
224
+ // Helper: update both streaming state and ref in sync
225
+ const updateStreaming = useCallback((updater: (prev: Map<string, StreamingMessage>) => Map<string, StreamingMessage>) => {
226
+ setStreamingMessages((prev) => {
227
+ const next = updater(prev);
228
+ streamingRef.current = next;
229
+ return next;
230
+ });
231
+ }, []);
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // SWR: Message history
235
+ // ---------------------------------------------------------------------------
236
+ // When anchorMessageId is set, fetch a window around it.
237
+ // Otherwise fetch the latest messages (default behavior).
238
+ const swrKey = channelId
239
+ ? anchorMessageId
240
+ ? `/api/openclaw/chat?channelId=${channelId}&limit=50&around=${anchorMessageId}`
241
+ : `/api/openclaw/chat?channelId=${channelId}&limit=50`
242
+ : null;
243
+
244
+ const {
245
+ data: historyData,
246
+ isLoading,
247
+ mutate: mutateHistory,
248
+ } = useSWR(
249
+ swrKey,
250
+ historyFetcher,
251
+ {
252
+ revalidateOnFocus: false,
253
+ dedupingInterval: 5000,
254
+ }
255
+ );
256
+
257
+ // Deduplicate by message ID — race conditions between SWR revalidation,
258
+ // loadMore pagination, and orphan EventSource can produce duplicate entries.
259
+ const messages: ChatMessage[] = (() => {
260
+ const raw = historyData?.messages ?? [];
261
+ const seen = new Set<string>();
262
+ return raw.filter((m: ChatMessage) => {
263
+ if (seen.has(m.id)) return false;
264
+ seen.add(m.id);
265
+ return true;
266
+ });
267
+ })();
268
+
269
+ // Derive bidirectional pagination flags.
270
+ // Default mode returns { hasMore }, anchor mode returns { hasMoreBefore, hasMoreAfter }.
271
+ const hasMoreBefore: boolean = historyData?.hasMoreBefore ?? historyData?.hasMore ?? false;
272
+ const hasMoreAfter: boolean = historyData?.hasMoreAfter ?? false;
273
+
274
+ // ---------------------------------------------------------------------------
275
+ // Initialize session key from loaded messages
276
+ // ---------------------------------------------------------------------------
277
+ // On page load, currentSessionKey is null until a message is sent.
278
+ // Derive it from the most recent message that has a sessionKey so the
279
+ // stats panel shows immediately without waiting for a new send.
280
+ useEffect(() => {
281
+ if (currentSessionKey) return; // already set by sendMessage or SSE
282
+ if (messages.length === 0) return;
283
+
284
+ for (let i = messages.length - 1; i >= 0; i--) {
285
+ if (messages[i].sessionKey) {
286
+ setCurrentSessionKey(messages[i].sessionKey);
287
+ return;
288
+ }
289
+ }
290
+ }, [currentSessionKey, messages]);
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Load older (backward pagination)
294
+ // ---------------------------------------------------------------------------
295
+ const loadOlder = useCallback(async () => {
296
+ if (loadingOlder || !hasMoreBefore || messages.length === 0) return;
297
+ setLoadingOlder(true);
298
+
299
+ const oldestId = messages[0]?.id;
300
+ try {
301
+ const res = await fetch(
302
+ `/api/openclaw/chat?channelId=${channelId}&limit=50&before=${oldestId}`
303
+ );
304
+ if (res.ok) {
305
+ const data = await res.json();
306
+ const olderMessages: ChatMessage[] = data.messages ?? [];
307
+ if (olderMessages.length > 0) {
308
+ // Prepend older messages to the SWR cache (dedup on merge)
309
+ // Evict from the newer end if cache exceeds MAX_CACHED_MESSAGES
310
+ mutateHistory(
311
+ (current: HistoryData | undefined) => {
312
+ const existing = current?.messages ?? [];
313
+ const existingIds = new Set(existing.map((m) => m.id));
314
+ const unique = olderMessages.filter((m: ChatMessage) => !existingIds.has(m.id));
315
+ let merged = [...unique, ...existing];
316
+ let hasMoreAfter = current?.hasMoreAfter ?? false;
317
+ if (merged.length > MAX_CACHED_MESSAGES) {
318
+ merged = merged.slice(0, MAX_CACHED_MESSAGES);
319
+ hasMoreAfter = true;
320
+ }
321
+ return {
322
+ messages: merged,
323
+ hasMoreBefore: data.hasMore,
324
+ hasMoreAfter,
325
+ };
326
+ },
327
+ { revalidate: false }
328
+ );
329
+ }
330
+ }
331
+ } catch (err) {
332
+ console.error("[useChat] Load older failed:", err);
333
+ } finally {
334
+ setLoadingOlder(false);
335
+ }
336
+ }, [loadingOlder, hasMoreBefore, messages, channelId, mutateHistory]);
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // Load newer (forward pagination) — only used in anchor mode
340
+ // ---------------------------------------------------------------------------
341
+ const loadNewer = useCallback(async () => {
342
+ if (loadingNewer || !hasMoreAfter || messages.length === 0) return;
343
+ setLoadingNewer(true);
344
+
345
+ const newestId = messages[messages.length - 1]?.id;
346
+ try {
347
+ const res = await fetch(
348
+ `/api/openclaw/chat?channelId=${channelId}&limit=50&after=${newestId}`
349
+ );
350
+ if (res.ok) {
351
+ const data = await res.json();
352
+ const newerMessages: ChatMessage[] = data.messages ?? [];
353
+ if (newerMessages.length > 0) {
354
+ // Append newer messages to the SWR cache (dedup on merge)
355
+ // Evict from the older end if cache exceeds MAX_CACHED_MESSAGES
356
+ mutateHistory(
357
+ (current: HistoryData | undefined) => {
358
+ const existing = current?.messages ?? [];
359
+ const existingIds = new Set(existing.map((m) => m.id));
360
+ const unique = newerMessages.filter((m: ChatMessage) => !existingIds.has(m.id));
361
+ let merged = [...existing, ...unique];
362
+ let hasMoreBefore = current?.hasMoreBefore ?? false;
363
+ if (merged.length > MAX_CACHED_MESSAGES) {
364
+ merged = merged.slice(merged.length - MAX_CACHED_MESSAGES);
365
+ hasMoreBefore = true;
366
+ }
367
+ return {
368
+ messages: merged,
369
+ hasMoreBefore,
370
+ hasMoreAfter: data.hasMore,
371
+ };
372
+ },
373
+ { revalidate: false }
374
+ );
375
+ }
376
+ }
377
+ } catch (err) {
378
+ console.error("[useChat] Load newer failed:", err);
379
+ } finally {
380
+ setLoadingNewer(false);
381
+ }
382
+ }, [loadingNewer, hasMoreAfter, messages, channelId, mutateHistory]);
383
+
384
+ // ---------------------------------------------------------------------------
385
+ // SSE: Listen for chat events via shared singleton
386
+ // ---------------------------------------------------------------------------
387
+ useEffect(() => {
388
+ const unsubscribe = subscribe("chat", (evt: SSEEvent) => {
389
+ const delta = evt.payload as ChatDelta;
390
+ if (!delta?.runId) return;
391
+
392
+ // Only process events for our active runs
393
+ if (!activeRunIds.current.has(delta.runId)) return;
394
+
395
+ if (delta.state === "delta") {
396
+ // Skip delta text accumulation — Gateway sends cumulative text
397
+ // (full response so far) in each delta, NOT incremental chunks.
398
+ // Just ensure a placeholder exists so UI shows the loading indicator.
399
+ updateStreaming((prev) => {
400
+ if (prev.has(delta.runId)) return prev; // already tracked
401
+ const next = new Map(prev);
402
+ next.set(delta.runId, {
403
+ runId: delta.runId,
404
+ agentId: defaultAgentId || "",
405
+ agentName: "",
406
+ sessionKey: delta.sessionKey,
407
+ content: "",
408
+ startedAt: Date.now(),
409
+ });
410
+ return next;
411
+ });
412
+
413
+ // Update sessionKey if provided
414
+ if (delta.sessionKey) {
415
+ setCurrentSessionKey(delta.sessionKey);
416
+ }
417
+ } else if (delta.state === "final") {
418
+ // Guard: if we already processed this runId's final, skip
419
+ if (!activeRunIds.current.has(delta.runId)) return;
420
+ activeRunIds.current.delete(delta.runId);
421
+
422
+ // Read streaming state from ref (always current, no stale closure)
423
+ const sm = streamingRef.current.get(delta.runId);
424
+ const finalContent =
425
+ delta.text ||
426
+ delta.message?.content?.[0]?.text ||
427
+ sm?.content ||
428
+ "";
429
+ const streamAgentId = sm?.agentId || defaultAgentId || "";
430
+ const streamAgentName = sm?.agentName;
431
+ const streamSessionKey =
432
+ delta.sessionKey || sm?.sessionKey || "";
433
+
434
+ // Ensure typing indicator shows for at least 800ms
435
+ const MIN_INDICATOR_MS = 800;
436
+ const elapsed = sm?.startedAt ? Date.now() - sm.startedAt : MIN_INDICATOR_MS;
437
+ const remaining = Math.max(0, MIN_INDICATOR_MS - elapsed);
438
+
439
+ const persistAndCleanup = () => {
440
+ setAgentActive(streamAgentId);
441
+
442
+ if (finalContent) {
443
+ const completePayload: ChatCompleteRequest = {
444
+ runId: delta.runId,
445
+ channelId,
446
+ content: finalContent,
447
+ sessionKey: streamSessionKey,
448
+ agentId: streamAgentId,
449
+ agentName: streamAgentName,
450
+ status: "complete",
451
+ inputTokens: delta.message?.inputTokens,
452
+ outputTokens: delta.message?.outputTokens,
453
+ };
454
+
455
+ fetch("/api/openclaw/chat", {
456
+ method: "PUT",
457
+ headers: { "Content-Type": "application/json" },
458
+ body: JSON.stringify(completePayload),
459
+ })
460
+ .then((res) => {
461
+ if (!res.ok) console.error("[useChat] PUT failed:", res.status);
462
+ return mutateHistory();
463
+ })
464
+ .then(() => {
465
+ updateStreaming((prev) => {
466
+ const next = new Map(prev);
467
+ next.delete(delta.runId);
468
+ return next;
469
+ });
470
+ })
471
+ .catch((err) => {
472
+ console.error("[useChat] Complete persist failed:", err);
473
+ updateStreaming((prev) => {
474
+ const next = new Map(prev);
475
+ next.delete(delta.runId);
476
+ return next;
477
+ });
478
+ });
479
+ } else {
480
+ updateStreaming((prev) => {
481
+ const next = new Map(prev);
482
+ next.delete(delta.runId);
483
+ return next;
484
+ });
485
+ }
486
+ };
487
+
488
+ if (remaining > 0) {
489
+ setTimeout(persistAndCleanup, remaining);
490
+ } else {
491
+ persistAndCleanup();
492
+ }
493
+ } else if (delta.state === "error") {
494
+ console.error("[useChat] Stream error:", delta.errorMessage);
495
+
496
+ const sm = streamingRef.current.get(delta.runId);
497
+ const errorContent = sm?.content || "";
498
+ const errorAgentId = sm?.agentId || defaultAgentId || "";
499
+ const errorAgentName = sm?.agentName;
500
+
501
+ setAgentActive(errorAgentId);
502
+
503
+ activeRunIds.current.delete(delta.runId);
504
+ updateStreaming((prev) => {
505
+ const next = new Map(prev);
506
+ next.delete(delta.runId);
507
+ return next;
508
+ });
509
+
510
+ if (errorContent) {
511
+ const errorPayload: ChatCompleteRequest = {
512
+ runId: delta.runId,
513
+ channelId,
514
+ content: errorContent,
515
+ sessionKey: delta.sessionKey || sm?.sessionKey || "",
516
+ agentId: errorAgentId,
517
+ agentName: errorAgentName,
518
+ status: "interrupted",
519
+ };
520
+
521
+ fetch("/api/openclaw/chat", {
522
+ method: "PUT",
523
+ headers: { "Content-Type": "application/json" },
524
+ body: JSON.stringify(errorPayload),
525
+ }).then(() => mutateHistory())
526
+ .catch(() => {});
527
+ }
528
+
529
+ setSendError(delta.errorMessage || "Stream error");
530
+ }
531
+ });
532
+
533
+ return () => {
534
+ if (activeRunIds.current.size > 0) {
535
+ console.log(`[useChat] Unmounting with ${activeRunIds.current.size} active run(s) — handing off to orphan handler`);
536
+ // Agent is still processing — hand off to orphan handler
537
+ const runs = new Map<string, OrphanedRun>();
538
+ for (const runId of activeRunIds.current) {
539
+ const sm = streamingRef.current.get(runId);
540
+ if (sm) {
541
+ runs.set(runId, {
542
+ channelId,
543
+ agentId: sm.agentId,
544
+ agentName: sm.agentName,
545
+ sessionKey: sm.sessionKey,
546
+ content: sm.content,
547
+ startedAt: sm.startedAt,
548
+ });
549
+ }
550
+ }
551
+ orphanRuns(runs);
552
+ }
553
+ unsubscribe();
554
+ };
555
+ // eslint-disable-next-line react-hooks/exhaustive-deps
556
+ }, [channelId, defaultAgentId, mutateHistory, updateStreaming]);
557
+
558
+ // ---------------------------------------------------------------------------
559
+ // Heartbeat-based timeout: if no SSE events arrive for 60s while we have
560
+ // active streaming runs, the connection is likely dead — mark as interrupted.
561
+ // ---------------------------------------------------------------------------
562
+ useEffect(() => {
563
+ if (streamingMessages.size === 0) return;
564
+
565
+ const HEARTBEAT_TIMEOUT_MS = 60_000;
566
+ const CHECK_INTERVAL_MS = 10_000;
567
+
568
+ const interval = setInterval(() => {
569
+ const timeSinceLastEvent = Date.now() - getLastEventTimestamp();
570
+ if (timeSinceLastEvent < HEARTBEAT_TIMEOUT_MS) return;
571
+
572
+ console.warn(`[useChat] SSE heartbeat timeout — no events for ${Math.round(timeSinceLastEvent / 1000)}s, interrupting ${streamingMessages.size} active run(s)`);
573
+
574
+ // SSE connection appears dead — mark all active runs as interrupted
575
+ updateStreaming((prev) => {
576
+ if (prev.size === 0) return prev;
577
+ const next = new Map(prev);
578
+ for (const [runId, sm] of next) {
579
+ if (sm.content) {
580
+ fetch("/api/openclaw/chat", {
581
+ method: "PUT",
582
+ headers: { "Content-Type": "application/json" },
583
+ body: JSON.stringify({
584
+ runId,
585
+ channelId,
586
+ content: sm.content,
587
+ sessionKey: sm.sessionKey,
588
+ agentId: sm.agentId,
589
+ agentName: sm.agentName,
590
+ status: "interrupted",
591
+ } satisfies ChatCompleteRequest),
592
+ }).then(() => mutateHistory())
593
+ .catch(() => {});
594
+ }
595
+ activeRunIds.current.delete(runId);
596
+ next.delete(runId);
597
+ }
598
+ return next;
599
+ });
600
+ }, CHECK_INTERVAL_MS);
601
+
602
+ return () => clearInterval(interval);
603
+ }, [streamingMessages.size, channelId, mutateHistory, updateStreaming]);
604
+
605
+ // ---------------------------------------------------------------------------
606
+ // Send message
607
+ // ---------------------------------------------------------------------------
608
+ const sendMessage = useCallback(
609
+ async (content: string, agentId?: string) => {
610
+ if (!content.trim() || sending) return;
611
+
612
+ setSending(true);
613
+ setSendError(null);
614
+
615
+ try {
616
+ const res = await fetch("/api/openclaw/chat", {
617
+ method: "POST",
618
+ headers: { "Content-Type": "application/json" },
619
+ body: JSON.stringify({
620
+ channelId,
621
+ content,
622
+ agentId: agentId || defaultAgentId,
623
+ }),
624
+ });
625
+
626
+ // Safely parse the JSON response
627
+ let result: ChatSendResponse;
628
+ try {
629
+ const data = await res.json();
630
+ if (!res.ok) {
631
+ throw new Error(data.error || `Send failed (${res.status})`);
632
+ }
633
+ result = data;
634
+ } catch (parseErr) {
635
+ if (!res.ok) {
636
+ throw new Error(`Send failed (${res.status})`);
637
+ }
638
+ throw new Error(`Invalid response from server: ${(parseErr as Error).message}`);
639
+ }
640
+
641
+ console.log(`[useChat] Send OK — runId=${result.runId} channel=${channelId}`);
642
+
643
+ // Update session key
644
+ if (result.sessionKey) {
645
+ setCurrentSessionKey(result.sessionKey);
646
+ }
647
+
648
+ // Track this run for streaming
649
+ activeRunIds.current.add(result.runId);
650
+
651
+ // Refresh history to show the persisted user message
652
+ mutateHistory();
653
+
654
+ // Random delay before showing typing indicator — feels natural
655
+ const delay = 800 + Math.random() * 400;
656
+ await new Promise((resolve) => setTimeout(resolve, delay));
657
+
658
+ // Add streaming placeholder — shows typing indicator
659
+ const resolvedAgentId = agentId || defaultAgentId || "";
660
+ setAgentThinking(resolvedAgentId, channelId);
661
+
662
+ updateStreaming((prev) => {
663
+ const next = new Map(prev);
664
+ next.set(result.runId, {
665
+ runId: result.runId,
666
+ agentId: resolvedAgentId,
667
+ agentName: "",
668
+ sessionKey: result.sessionKey,
669
+ content: "",
670
+ startedAt: Date.now(),
671
+ });
672
+ return next;
673
+ });
674
+ } catch (err) {
675
+ console.error(`[useChat] Send FAILED — channel=${channelId}:`, (err as Error).message);
676
+ setSendError((err as Error).message);
677
+ } finally {
678
+ setSending(false);
679
+ }
680
+ },
681
+ [channelId, defaultAgentId, sending, mutateHistory, updateStreaming]
682
+ );
683
+
684
+ // ---------------------------------------------------------------------------
685
+ // Abort response
686
+ // ---------------------------------------------------------------------------
687
+ const abortResponse = useCallback(async () => {
688
+ try {
689
+ await fetch("/api/openclaw/chat", { method: "DELETE" });
690
+
691
+ // Save partial content for all active streams
692
+ updateStreaming((prev) => {
693
+ for (const [runId, sm] of prev) {
694
+ if (sm.content) {
695
+ fetch("/api/openclaw/chat", {
696
+ method: "PUT",
697
+ headers: { "Content-Type": "application/json" },
698
+ body: JSON.stringify({
699
+ runId,
700
+ channelId,
701
+ content: sm.content,
702
+ sessionKey: sm.sessionKey,
703
+ agentId: sm.agentId,
704
+ agentName: sm.agentName,
705
+ status: "aborted",
706
+ } satisfies ChatCompleteRequest),
707
+ }).then(() => mutateHistory())
708
+ .catch(() => {});
709
+ }
710
+ activeRunIds.current.delete(runId);
711
+ }
712
+ return new Map();
713
+ });
714
+ } catch (err) {
715
+ console.error("[useChat] Abort failed:", err);
716
+ }
717
+ }, [channelId, mutateHistory, updateStreaming]);
718
+
719
+ // ---------------------------------------------------------------------------
720
+ // Clear error
721
+ // ---------------------------------------------------------------------------
722
+ const clearSendError = useCallback(() => setSendError(null), []);
723
+
724
+ // Auto-dismiss error after 5s
725
+ useEffect(() => {
726
+ if (!sendError) return;
727
+ const t = setTimeout(() => setSendError(null), 5000);
728
+ return () => clearTimeout(t);
729
+ }, [sendError]);
730
+
731
+ return {
732
+ messages,
733
+ isLoading,
734
+ hasMoreBefore,
735
+ hasMoreAfter,
736
+ loadOlder,
737
+ loadNewer,
738
+ loadingOlder,
739
+ loadingNewer,
740
+ // Backward-compat aliases (used by MessageList's existing scroll-up logic)
741
+ hasMore: hasMoreBefore,
742
+ loadMore: loadOlder,
743
+ loadingMore: loadingOlder,
744
+ streamingMessages,
745
+ isStreaming: streamingMessages.size > 0,
746
+ currentSessionKey,
747
+ sendMessage,
748
+ abortResponse,
749
+ sending,
750
+ sendError,
751
+ clearSendError,
752
+ };
753
+ }