@castlekit/castle 0.3.0 → 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/install.sh +20 -1
- package/package.json +17 -2
- package/src/app/api/openclaw/agents/route.ts +7 -1
- package/src/app/api/openclaw/chat/channels/route.ts +6 -3
- package/src/app/api/openclaw/chat/route.ts +17 -6
- package/src/app/api/openclaw/chat/search/route.ts +2 -1
- 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/ping/route.ts +5 -0
- package/src/app/api/openclaw/session/context/route.ts +163 -0
- package/src/app/api/openclaw/session/status/route.ts +179 -11
- package/src/app/api/openclaw/sessions/route.ts +2 -0
- package/src/app/chat/[channelId]/page.tsx +115 -35
- package/src/app/globals.css +10 -0
- package/src/app/page.tsx +10 -8
- package/src/components/chat/chat-input.tsx +23 -5
- package/src/components/chat/message-bubble.tsx +29 -13
- package/src/components/chat/message-list.tsx +238 -80
- package/src/components/chat/session-stats-panel.tsx +391 -86
- package/src/components/providers/search-provider.tsx +33 -4
- package/src/lib/db/index.ts +12 -2
- package/src/lib/db/queries.ts +199 -72
- package/src/lib/db/schema.ts +4 -0
- package/src/lib/gateway-connection.ts +24 -3
- package/src/lib/hooks/use-chat.ts +219 -241
- 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 +44 -57
- package/src/lib/hooks/use-search.ts +1 -0
- package/src/lib/hooks/use-session-stats.ts +4 -1
- package/src/lib/sse-singleton.ts +184 -0
- package/src/lib/types/chat.ts +22 -6
- package/src/lib/db/__tests__/queries.test.ts +0 -318
- package/vitest.config.ts +0 -13
|
@@ -10,6 +10,7 @@ import type {
|
|
|
10
10
|
ChatCompleteRequest,
|
|
11
11
|
} from "@/lib/types/chat";
|
|
12
12
|
import { setAgentThinking, setAgentActive } from "@/lib/hooks/use-agent-status";
|
|
13
|
+
import { subscribe, getLastEventTimestamp, type SSEEvent } from "@/lib/sse-singleton";
|
|
13
14
|
|
|
14
15
|
// ============================================================================
|
|
15
16
|
// Fetcher
|
|
@@ -17,7 +18,10 @@ import { setAgentThinking, setAgentActive } from "@/lib/hooks/use-agent-status";
|
|
|
17
18
|
|
|
18
19
|
const historyFetcher = async (url: string) => {
|
|
19
20
|
const res = await fetch(url);
|
|
20
|
-
if (!res.ok)
|
|
21
|
+
if (!res.ok) {
|
|
22
|
+
console.error(`[useChat] History fetch failed: ${res.status} ${url}`);
|
|
23
|
+
throw new Error("Failed to load messages");
|
|
24
|
+
}
|
|
21
25
|
return res.json();
|
|
22
26
|
};
|
|
23
27
|
|
|
@@ -44,7 +48,7 @@ interface OrphanedRun {
|
|
|
44
48
|
|
|
45
49
|
// Singleton state — shared across all unmounted chat instances
|
|
46
50
|
const orphanedRuns = new Map<string, OrphanedRun>();
|
|
47
|
-
let
|
|
51
|
+
let orphanedUnsubscribe: (() => void) | null = null;
|
|
48
52
|
let orphanedSafetyTimer: ReturnType<typeof setTimeout> | null = null;
|
|
49
53
|
|
|
50
54
|
function cleanupOrphaned() {
|
|
@@ -52,8 +56,8 @@ function cleanupOrphaned() {
|
|
|
52
56
|
clearTimeout(orphanedSafetyTimer);
|
|
53
57
|
orphanedSafetyTimer = null;
|
|
54
58
|
}
|
|
55
|
-
|
|
56
|
-
|
|
59
|
+
orphanedUnsubscribe?.();
|
|
60
|
+
orphanedUnsubscribe = null;
|
|
57
61
|
orphanedRuns.clear();
|
|
58
62
|
}
|
|
59
63
|
|
|
@@ -85,12 +89,14 @@ function persistOrphanedRun(
|
|
|
85
89
|
}),
|
|
86
90
|
};
|
|
87
91
|
|
|
92
|
+
console.log(`[useChat] Persisting orphaned run ${runId} (channel=${run.channelId}, status=${status})`);
|
|
88
93
|
fetch("/api/openclaw/chat", {
|
|
89
94
|
method: "PUT",
|
|
90
95
|
headers: { "Content-Type": "application/json" },
|
|
91
96
|
body: JSON.stringify(payload),
|
|
92
97
|
})
|
|
93
98
|
.then(() => {
|
|
99
|
+
console.log(`[useChat] Orphaned run ${runId} persisted successfully`);
|
|
94
100
|
// Trigger SWR revalidation so the message shows when user navigates back
|
|
95
101
|
globalMutate(
|
|
96
102
|
(key) =>
|
|
@@ -98,7 +104,7 @@ function persistOrphanedRun(
|
|
|
98
104
|
key.startsWith(`/api/openclaw/chat?channelId=${run.channelId}`),
|
|
99
105
|
);
|
|
100
106
|
})
|
|
101
|
-
.catch((err) => console.error(
|
|
107
|
+
.catch((err) => console.error(`[useChat] Orphan persist failed for run ${runId}:`, err));
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
// All runs done — tear down
|
|
@@ -107,55 +113,40 @@ function persistOrphanedRun(
|
|
|
107
113
|
}
|
|
108
114
|
}
|
|
109
115
|
|
|
110
|
-
function
|
|
111
|
-
if (
|
|
112
|
-
|
|
113
|
-
orphanedES.onmessage = (e) => {
|
|
114
|
-
try {
|
|
115
|
-
const evt = JSON.parse(e.data);
|
|
116
|
-
if (evt.event !== "chat") return;
|
|
116
|
+
function ensureOrphanedSubscription() {
|
|
117
|
+
if (orphanedUnsubscribe) return; // already subscribed
|
|
117
118
|
|
|
118
|
-
|
|
119
|
-
|
|
119
|
+
orphanedUnsubscribe = subscribe("chat", (evt: SSEEvent) => {
|
|
120
|
+
const delta = evt.payload as ChatDelta;
|
|
121
|
+
if (!delta?.runId || !orphanedRuns.has(delta.runId)) return;
|
|
120
122
|
|
|
121
|
-
|
|
123
|
+
const run = orphanedRuns.get(delta.runId)!;
|
|
122
124
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
} catch {
|
|
132
|
-
// Ignore parse errors
|
|
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
133
|
}
|
|
134
|
-
};
|
|
134
|
+
});
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
/**
|
|
138
|
-
* Hand off
|
|
139
|
-
*
|
|
138
|
+
* Hand off active runs to the module-level orphan handler.
|
|
139
|
+
* Uses the shared SSE singleton — no separate EventSource needed.
|
|
140
140
|
*/
|
|
141
|
-
function
|
|
142
|
-
es: EventSource,
|
|
143
|
-
newRuns: Map<string, OrphanedRun>,
|
|
144
|
-
) {
|
|
141
|
+
function orphanRuns(newRuns: Map<string, OrphanedRun>) {
|
|
145
142
|
// Merge new runs into the shared state
|
|
146
143
|
for (const [runId, run] of newRuns) {
|
|
147
144
|
orphanedRuns.set(runId, run);
|
|
148
145
|
}
|
|
149
146
|
|
|
150
|
-
|
|
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();
|
|
147
|
+
ensureOrphanedSubscription();
|
|
157
148
|
|
|
158
|
-
// Reset safety timer (5 min) so stale
|
|
149
|
+
// Reset safety timer (5 min) so stale subscriptions don't leak
|
|
159
150
|
if (orphanedSafetyTimer) clearTimeout(orphanedSafetyTimer);
|
|
160
151
|
orphanedSafetyTimer = setTimeout(cleanupOrphaned, 5 * 60 * 1000);
|
|
161
152
|
}
|
|
@@ -229,7 +220,6 @@ export function useChat({ channelId, defaultAgentId, anchorMessageId }: UseChatO
|
|
|
229
220
|
|
|
230
221
|
// Track active runIds for reconnection timeout
|
|
231
222
|
const activeRunIds = useRef<Set<string>>(new Set());
|
|
232
|
-
const eventSourceRef = useRef<EventSource | null>(null);
|
|
233
223
|
|
|
234
224
|
// Helper: update both streaming state and ref in sync
|
|
235
225
|
const updateStreaming = useCallback((updater: (prev: Map<string, StreamingMessage>) => Map<string, StreamingMessage>) => {
|
|
@@ -281,6 +271,24 @@ export function useChat({ channelId, defaultAgentId, anchorMessageId }: UseChatO
|
|
|
281
271
|
const hasMoreBefore: boolean = historyData?.hasMoreBefore ?? historyData?.hasMore ?? false;
|
|
282
272
|
const hasMoreAfter: boolean = historyData?.hasMoreAfter ?? false;
|
|
283
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
|
+
|
|
284
292
|
// ---------------------------------------------------------------------------
|
|
285
293
|
// Load older (backward pagination)
|
|
286
294
|
// ---------------------------------------------------------------------------
|
|
@@ -374,192 +382,158 @@ export function useChat({ channelId, defaultAgentId, anchorMessageId }: UseChatO
|
|
|
374
382
|
}, [loadingNewer, hasMoreAfter, messages, channelId, mutateHistory]);
|
|
375
383
|
|
|
376
384
|
// ---------------------------------------------------------------------------
|
|
377
|
-
// SSE: Listen for chat events
|
|
385
|
+
// SSE: Listen for chat events via shared singleton
|
|
378
386
|
// ---------------------------------------------------------------------------
|
|
379
387
|
useEffect(() => {
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
es.onmessage = (e) => {
|
|
385
|
-
try {
|
|
386
|
-
const evt = JSON.parse(e.data);
|
|
388
|
+
const unsubscribe = subscribe("chat", (evt: SSEEvent) => {
|
|
389
|
+
const delta = evt.payload as ChatDelta;
|
|
390
|
+
if (!delta?.runId) return;
|
|
387
391
|
|
|
388
|
-
|
|
389
|
-
|
|
392
|
+
// Only process events for our active runs
|
|
393
|
+
if (!activeRunIds.current.has(delta.runId)) return;
|
|
390
394
|
|
|
391
|
-
|
|
392
|
-
|
|
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
|
+
});
|
|
393
412
|
|
|
394
|
-
//
|
|
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
|
|
395
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
|
+
};
|
|
396
454
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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;
|
|
408
469
|
});
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
content: text,
|
|
417
|
-
startedAt: Date.now(),
|
|
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;
|
|
418
477
|
});
|
|
419
|
-
}
|
|
478
|
+
});
|
|
479
|
+
} else {
|
|
480
|
+
updateStreaming((prev) => {
|
|
481
|
+
const next = new Map(prev);
|
|
482
|
+
next.delete(delta.runId);
|
|
420
483
|
return next;
|
|
421
484
|
});
|
|
422
485
|
}
|
|
486
|
+
};
|
|
423
487
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
};
|
|
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);
|
|
467
495
|
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
};
|
|
496
|
+
const sm = streamingRef.current.get(delta.runId);
|
|
497
|
+
const errorContent = sm?.content || "";
|
|
498
|
+
const errorAgentId = sm?.agentId || defaultAgentId || "";
|
|
499
|
+
const errorAgentName = sm?.agentName;
|
|
504
500
|
|
|
505
|
-
|
|
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
|
-
});
|
|
501
|
+
setAgentActive(errorAgentId);
|
|
530
502
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
sessionKey: delta.sessionKey || sm?.sessionKey || "",
|
|
538
|
-
agentId: errorAgentId,
|
|
539
|
-
agentName: errorAgentName,
|
|
540
|
-
status: "interrupted",
|
|
541
|
-
};
|
|
503
|
+
activeRunIds.current.delete(delta.runId);
|
|
504
|
+
updateStreaming((prev) => {
|
|
505
|
+
const next = new Map(prev);
|
|
506
|
+
next.delete(delta.runId);
|
|
507
|
+
return next;
|
|
508
|
+
});
|
|
542
509
|
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
+
};
|
|
550
520
|
|
|
551
|
-
|
|
521
|
+
fetch("/api/openclaw/chat", {
|
|
522
|
+
method: "PUT",
|
|
523
|
+
headers: { "Content-Type": "application/json" },
|
|
524
|
+
body: JSON.stringify(errorPayload),
|
|
525
|
+
}).then(() => mutateHistory())
|
|
526
|
+
.catch(() => {});
|
|
552
527
|
}
|
|
553
|
-
|
|
554
|
-
|
|
528
|
+
|
|
529
|
+
setSendError(delta.errorMessage || "Stream error");
|
|
555
530
|
}
|
|
556
|
-
};
|
|
531
|
+
});
|
|
557
532
|
|
|
558
533
|
return () => {
|
|
559
534
|
if (activeRunIds.current.size > 0) {
|
|
560
|
-
|
|
561
|
-
//
|
|
562
|
-
// even though the component is unmounting.
|
|
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
|
|
563
537
|
const runs = new Map<string, OrphanedRun>();
|
|
564
538
|
for (const runId of activeRunIds.current) {
|
|
565
539
|
const sm = streamingRef.current.get(runId);
|
|
@@ -574,58 +548,59 @@ export function useChat({ channelId, defaultAgentId, anchorMessageId }: UseChatO
|
|
|
574
548
|
});
|
|
575
549
|
}
|
|
576
550
|
}
|
|
577
|
-
|
|
578
|
-
} else {
|
|
579
|
-
es.close();
|
|
551
|
+
orphanRuns(runs);
|
|
580
552
|
}
|
|
581
|
-
|
|
553
|
+
unsubscribe();
|
|
582
554
|
};
|
|
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
555
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
587
556
|
}, [channelId, defaultAgentId, mutateHistory, updateStreaming]);
|
|
588
557
|
|
|
589
558
|
// ---------------------------------------------------------------------------
|
|
590
|
-
//
|
|
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.
|
|
591
561
|
// ---------------------------------------------------------------------------
|
|
592
562
|
useEffect(() => {
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
|
596
575
|
updateStreaming((prev) => {
|
|
576
|
+
if (prev.size === 0) return prev;
|
|
597
577
|
const next = new Map(prev);
|
|
598
|
-
let changed = false;
|
|
599
578
|
for (const [runId, sm] of next) {
|
|
600
|
-
if (
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
}).then(() => mutateHistory())
|
|
616
|
-
.catch(() => {});
|
|
617
|
-
}
|
|
618
|
-
activeRunIds.current.delete(runId);
|
|
619
|
-
next.delete(runId);
|
|
620
|
-
changed = true;
|
|
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(() => {});
|
|
621
594
|
}
|
|
595
|
+
activeRunIds.current.delete(runId);
|
|
596
|
+
next.delete(runId);
|
|
622
597
|
}
|
|
623
|
-
return
|
|
598
|
+
return next;
|
|
624
599
|
});
|
|
625
|
-
},
|
|
600
|
+
}, CHECK_INTERVAL_MS);
|
|
626
601
|
|
|
627
|
-
return () =>
|
|
628
|
-
}, [streamingMessages, channelId, mutateHistory, updateStreaming]);
|
|
602
|
+
return () => clearInterval(interval);
|
|
603
|
+
}, [streamingMessages.size, channelId, mutateHistory, updateStreaming]);
|
|
629
604
|
|
|
630
605
|
// ---------------------------------------------------------------------------
|
|
631
606
|
// Send message
|
|
@@ -663,6 +638,8 @@ export function useChat({ channelId, defaultAgentId, anchorMessageId }: UseChatO
|
|
|
663
638
|
throw new Error(`Invalid response from server: ${(parseErr as Error).message}`);
|
|
664
639
|
}
|
|
665
640
|
|
|
641
|
+
console.log(`[useChat] Send OK — runId=${result.runId} channel=${channelId}`);
|
|
642
|
+
|
|
666
643
|
// Update session key
|
|
667
644
|
if (result.sessionKey) {
|
|
668
645
|
setCurrentSessionKey(result.sessionKey);
|
|
@@ -695,6 +672,7 @@ export function useChat({ channelId, defaultAgentId, anchorMessageId }: UseChatO
|
|
|
695
672
|
return next;
|
|
696
673
|
});
|
|
697
674
|
} catch (err) {
|
|
675
|
+
console.error(`[useChat] Send FAILED — channel=${channelId}:`, (err as Error).message);
|
|
698
676
|
setSendError((err as Error).message);
|
|
699
677
|
} finally {
|
|
700
678
|
setSending(false);
|