@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.
- package/LICENSE +21 -0
- package/drizzle.config.ts +7 -0
- package/install.sh +20 -1
- package/next.config.ts +1 -0
- package/package.json +35 -3
- package/src/app/api/avatars/[id]/route.ts +57 -7
- package/src/app/api/openclaw/agents/route.ts +7 -1
- package/src/app/api/openclaw/agents/status/route.ts +55 -0
- package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
- package/src/app/api/openclaw/chat/channels/route.ts +217 -0
- package/src/app/api/openclaw/chat/route.ts +283 -0
- package/src/app/api/openclaw/chat/search/route.ts +150 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/config/route.ts +2 -0
- package/src/app/api/openclaw/events/route.ts +23 -8
- package/src/app/api/openclaw/logs/route.ts +17 -3
- package/src/app/api/openclaw/ping/route.ts +5 -0
- package/src/app/api/openclaw/restart/route.ts +6 -1
- package/src/app/api/openclaw/session/context/route.ts +163 -0
- package/src/app/api/openclaw/session/status/route.ts +210 -0
- package/src/app/api/openclaw/sessions/route.ts +2 -0
- package/src/app/api/settings/avatar/route.ts +190 -0
- package/src/app/api/settings/route.ts +88 -0
- package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
- package/src/app/chat/[channelId]/page.tsx +385 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +99 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +59 -25
- package/src/app/settings/page.tsx +300 -0
- package/src/components/chat/agent-mention-popup.tsx +89 -0
- package/src/components/chat/archived-channels.tsx +190 -0
- package/src/components/chat/channel-list.tsx +140 -0
- package/src/components/chat/chat-input.tsx +328 -0
- package/src/components/chat/create-channel-dialog.tsx +171 -0
- package/src/components/chat/markdown-content.tsx +205 -0
- package/src/components/chat/message-bubble.tsx +168 -0
- package/src/components/chat/message-list.tsx +666 -0
- package/src/components/chat/message-queue.tsx +68 -0
- package/src/components/chat/session-divider.tsx +61 -0
- package/src/components/chat/session-stats-panel.tsx +444 -0
- package/src/components/chat/storage-indicator.tsx +76 -0
- package/src/components/layout/sidebar.tsx +126 -45
- package/src/components/layout/user-menu.tsx +29 -4
- package/src/components/providers/presence-provider.tsx +8 -0
- package/src/components/providers/search-provider.tsx +110 -0
- package/src/components/search/search-dialog.tsx +269 -0
- package/src/components/ui/avatar.tsx +11 -9
- package/src/components/ui/dialog.tsx +10 -4
- package/src/components/ui/tooltip.tsx +25 -8
- package/src/components/ui/twemoji-text.tsx +37 -0
- package/src/lib/api-security.ts +125 -0
- package/src/lib/date-utils.ts +79 -0
- package/src/lib/db/index.ts +652 -0
- package/src/lib/db/queries.ts +1144 -0
- package/src/lib/db/schema.ts +164 -0
- package/src/lib/gateway-connection.ts +24 -3
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +753 -0
- package/src/lib/hooks/use-compaction-events.ts +132 -0
- package/src/lib/hooks/use-context-boundary.ts +82 -0
- package/src/lib/hooks/use-openclaw.ts +122 -100
- package/src/lib/hooks/use-search.ts +114 -0
- package/src/lib/hooks/use-session-stats.ts +60 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/sse-singleton.ts +184 -0
- package/src/lib/types/chat.ts +202 -0
- package/src/lib/types/search.ts +60 -0
- 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
|
+
}
|