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