@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
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
4
|
+
import { subscribe, type SSEEvent } from "@/lib/sse-singleton";
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Types
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
export interface CompactionEvent {
|
|
11
|
+
phase: "start" | "end";
|
|
12
|
+
willRetry?: boolean;
|
|
13
|
+
timestamp: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface UseCompactionEventsOptions {
|
|
17
|
+
/** The session key to monitor. Null disables monitoring. */
|
|
18
|
+
sessionKey: string | null;
|
|
19
|
+
/** Callback fired when compaction completes (phase: "end"). */
|
|
20
|
+
onCompactionComplete?: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface UseCompactionEventsReturn {
|
|
24
|
+
/** Whether a compaction is currently in progress. */
|
|
25
|
+
isCompacting: boolean;
|
|
26
|
+
/** The last compaction event received. */
|
|
27
|
+
lastCompaction: CompactionEvent | null;
|
|
28
|
+
/** Number of compactions observed in this session (since mount). */
|
|
29
|
+
compactionCount: number;
|
|
30
|
+
/** Whether to show the compaction banner (auto-dismissed after 8s). */
|
|
31
|
+
showBanner: boolean;
|
|
32
|
+
/** Manually dismiss the banner. */
|
|
33
|
+
dismissBanner: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// Hook
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Subscribes to real-time compaction events from the Gateway via SSE singleton.
|
|
42
|
+
*
|
|
43
|
+
* Compaction events arrive as agent events with `stream: "compaction"`:
|
|
44
|
+
* { stream: "compaction", data: { phase: "start"|"end", willRetry?: boolean } }
|
|
45
|
+
*
|
|
46
|
+
* These are forwarded through the SSE system as events with type starting
|
|
47
|
+
* with "agent." or as top-level events. We subscribe to "*" and filter.
|
|
48
|
+
*/
|
|
49
|
+
export function useCompactionEvents({
|
|
50
|
+
sessionKey,
|
|
51
|
+
onCompactionComplete,
|
|
52
|
+
}: UseCompactionEventsOptions): UseCompactionEventsReturn {
|
|
53
|
+
const [isCompacting, setIsCompacting] = useState(false);
|
|
54
|
+
const [lastCompaction, setLastCompaction] = useState<CompactionEvent | null>(null);
|
|
55
|
+
const [compactionCount, setCompactionCount] = useState(0);
|
|
56
|
+
const [showBanner, setShowBanner] = useState(false);
|
|
57
|
+
const bannerTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
58
|
+
const onCompleteRef = useRef(onCompactionComplete);
|
|
59
|
+
onCompleteRef.current = onCompactionComplete;
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!sessionKey) return;
|
|
63
|
+
|
|
64
|
+
// Subscribe to all events and filter for compaction events.
|
|
65
|
+
// Compaction events come through as agent events with stream: "compaction"
|
|
66
|
+
const unsubscribe = subscribe("*", (evt: SSEEvent) => {
|
|
67
|
+
// Check for compaction event payload
|
|
68
|
+
const payload = evt.payload as Record<string, unknown> | undefined;
|
|
69
|
+
if (!payload) return;
|
|
70
|
+
|
|
71
|
+
// Compaction events can arrive in several shapes depending on Gateway version.
|
|
72
|
+
// Look for stream === "compaction" in the payload
|
|
73
|
+
const stream = payload.stream as string | undefined;
|
|
74
|
+
if (stream !== "compaction") return;
|
|
75
|
+
|
|
76
|
+
const data = (payload.data ?? payload) as {
|
|
77
|
+
phase?: string;
|
|
78
|
+
willRetry?: boolean;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const phase = data.phase as "start" | "end" | undefined;
|
|
82
|
+
if (!phase) return;
|
|
83
|
+
|
|
84
|
+
const compEvent: CompactionEvent = {
|
|
85
|
+
phase,
|
|
86
|
+
willRetry: data.willRetry,
|
|
87
|
+
timestamp: Date.now(),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
setLastCompaction(compEvent);
|
|
91
|
+
|
|
92
|
+
if (phase === "start") {
|
|
93
|
+
setIsCompacting(true);
|
|
94
|
+
} else if (phase === "end") {
|
|
95
|
+
setIsCompacting(false);
|
|
96
|
+
setCompactionCount((c) => c + 1);
|
|
97
|
+
|
|
98
|
+
// Show banner
|
|
99
|
+
setShowBanner(true);
|
|
100
|
+
if (bannerTimer.current) clearTimeout(bannerTimer.current);
|
|
101
|
+
bannerTimer.current = setTimeout(() => setShowBanner(false), 8000);
|
|
102
|
+
|
|
103
|
+
// Notify parent (e.g. to refresh session stats)
|
|
104
|
+
onCompleteRef.current?.();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return () => {
|
|
109
|
+
unsubscribe();
|
|
110
|
+
if (bannerTimer.current) {
|
|
111
|
+
clearTimeout(bannerTimer.current);
|
|
112
|
+
bannerTimer.current = null;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
}, [sessionKey]);
|
|
116
|
+
|
|
117
|
+
const dismissBanner = useCallback(() => {
|
|
118
|
+
setShowBanner(false);
|
|
119
|
+
if (bannerTimer.current) {
|
|
120
|
+
clearTimeout(bannerTimer.current);
|
|
121
|
+
bannerTimer.current = null;
|
|
122
|
+
}
|
|
123
|
+
}, []);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
isCompacting,
|
|
127
|
+
lastCompaction,
|
|
128
|
+
compactionCount,
|
|
129
|
+
showBanner,
|
|
130
|
+
dismissBanner,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback } from "react";
|
|
4
|
+
import useSWR from "swr";
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Types
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
interface ContextBoundaryData {
|
|
11
|
+
boundaryMessageId: string | null;
|
|
12
|
+
fresh: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface UseContextBoundaryOptions {
|
|
16
|
+
/** Gateway session key. Null disables fetching. */
|
|
17
|
+
sessionKey: string | null;
|
|
18
|
+
/** Channel ID for matching preview messages to local DB. */
|
|
19
|
+
channelId: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface UseContextBoundaryReturn {
|
|
23
|
+
/** ID of the oldest message still in agent context. Null = no compaction yet or unknown. */
|
|
24
|
+
boundaryMessageId: string | null;
|
|
25
|
+
/** Whether data is loading. */
|
|
26
|
+
isLoading: boolean;
|
|
27
|
+
/** Re-fetch the boundary (call after compaction events). */
|
|
28
|
+
refresh: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// Fetcher
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
const fetcher = async (url: string): Promise<ContextBoundaryData | null> => {
|
|
36
|
+
const res = await fetch(url);
|
|
37
|
+
if (res.status === 204) return null;
|
|
38
|
+
if (!res.ok) return null;
|
|
39
|
+
return res.json();
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// Hook
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Fetches and caches the compaction context boundary for a session.
|
|
48
|
+
*
|
|
49
|
+
* Returns the ID of the oldest message the agent can still "see".
|
|
50
|
+
* Messages before this boundary have been compacted (summarized).
|
|
51
|
+
*
|
|
52
|
+
* Polls infrequently (every 60s) since compaction events trigger explicit refreshes.
|
|
53
|
+
*/
|
|
54
|
+
export function useContextBoundary({
|
|
55
|
+
sessionKey,
|
|
56
|
+
channelId,
|
|
57
|
+
}: UseContextBoundaryOptions): UseContextBoundaryReturn {
|
|
58
|
+
const swrKey = sessionKey
|
|
59
|
+
? `/api/openclaw/session/context?sessionKey=${encodeURIComponent(sessionKey)}&channelId=${encodeURIComponent(channelId)}`
|
|
60
|
+
: null;
|
|
61
|
+
|
|
62
|
+
const { data, isLoading, mutate } = useSWR<ContextBoundaryData | null>(
|
|
63
|
+
swrKey,
|
|
64
|
+
fetcher,
|
|
65
|
+
{
|
|
66
|
+
refreshInterval: 60000, // Refresh every 60s as background check
|
|
67
|
+
dedupingInterval: 10000,
|
|
68
|
+
revalidateOnFocus: false,
|
|
69
|
+
errorRetryCount: 1,
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const refresh = useCallback(() => {
|
|
74
|
+
mutate();
|
|
75
|
+
}, [mutate]);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
boundaryMessageId: data?.boundaryMessageId ?? null,
|
|
79
|
+
isLoading,
|
|
80
|
+
refresh,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useEffect,
|
|
3
|
+
import { useEffect, useCallback } from "react";
|
|
4
4
|
import useSWR from "swr";
|
|
5
|
+
import { subscribe, onError, type SSEEvent } from "@/lib/sse-singleton";
|
|
5
6
|
|
|
6
7
|
// ============================================================================
|
|
7
8
|
// Types
|
|
@@ -27,24 +28,24 @@ export interface OpenClawStatus {
|
|
|
27
28
|
};
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
interface GatewaySSEEvent {
|
|
31
|
-
event: string;
|
|
32
|
-
payload?: unknown;
|
|
33
|
-
seq?: number;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
31
|
// ============================================================================
|
|
37
32
|
// Fetchers
|
|
38
33
|
// ============================================================================
|
|
39
34
|
|
|
40
35
|
const statusFetcher = async (url: string): Promise<OpenClawStatus> => {
|
|
41
36
|
const res = await fetch(url, { method: "POST" });
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
console.warn(`[useOpenClaw] Status fetch returned ${res.status}`);
|
|
39
|
+
}
|
|
42
40
|
return res.json();
|
|
43
41
|
};
|
|
44
42
|
|
|
45
43
|
const agentsFetcher = async (url: string): Promise<OpenClawAgent[]> => {
|
|
46
44
|
const res = await fetch(url);
|
|
47
|
-
if (!res.ok)
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
console.warn(`[useOpenClaw] Agents fetch failed: ${res.status}`);
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
48
49
|
const data = await res.json();
|
|
49
50
|
return data.agents || [];
|
|
50
51
|
};
|
|
@@ -61,7 +62,6 @@ const agentsFetcher = async (url: string): Promise<OpenClawAgent[]> => {
|
|
|
61
62
|
* SSE subscription pushes real-time updates that trigger SWR cache invalidation.
|
|
62
63
|
*/
|
|
63
64
|
export function useOpenClaw() {
|
|
64
|
-
const eventSourceRef = useRef<EventSource | null>(null);
|
|
65
65
|
|
|
66
66
|
// ---------------------------------------------------------------------------
|
|
67
67
|
// SWR: Connection status
|
|
@@ -114,66 +114,53 @@ export function useOpenClaw() {
|
|
|
114
114
|
const refreshAgents = useCallback(() => mutateAgents(), [mutateAgents]);
|
|
115
115
|
|
|
116
116
|
// ---------------------------------------------------------------------------
|
|
117
|
-
// SSE subscription for real-time events
|
|
117
|
+
// SSE subscription for real-time events via shared singleton
|
|
118
118
|
// ---------------------------------------------------------------------------
|
|
119
119
|
useEffect(() => {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
configured: prev?.configured ?? true,
|
|
140
|
-
state: payload.state,
|
|
141
|
-
server: payload.server as OpenClawStatus["server"],
|
|
142
|
-
}),
|
|
143
|
-
{ revalidate: false }
|
|
144
|
-
);
|
|
145
|
-
|
|
146
|
-
// Re-fetch agents when connection state changes to connected
|
|
147
|
-
if (payload.isConnected) {
|
|
148
|
-
mutateAgents();
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Handle agent-related events — re-fetch agent list
|
|
153
|
-
if (evt.event?.startsWith("agent.")) {
|
|
154
|
-
mutateAgents();
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Handle avatar update events
|
|
158
|
-
if (evt.event === "agentAvatarUpdated") {
|
|
159
|
-
mutateAgents();
|
|
160
|
-
}
|
|
161
|
-
} catch {
|
|
162
|
-
// Ignore parse errors
|
|
120
|
+
const handleState = (evt: SSEEvent) => {
|
|
121
|
+
const payload = evt.payload as {
|
|
122
|
+
state: string;
|
|
123
|
+
isConnected: boolean;
|
|
124
|
+
server?: Record<string, unknown>;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
mutateStatus(
|
|
128
|
+
(prev) => ({
|
|
129
|
+
ok: payload.isConnected,
|
|
130
|
+
configured: prev?.configured ?? true,
|
|
131
|
+
state: payload.state,
|
|
132
|
+
server: payload.server as OpenClawStatus["server"],
|
|
133
|
+
}),
|
|
134
|
+
{ revalidate: false }
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
if (payload.isConnected) {
|
|
138
|
+
mutateAgents();
|
|
163
139
|
}
|
|
164
140
|
};
|
|
165
141
|
|
|
166
|
-
|
|
167
|
-
|
|
142
|
+
const handleAgent = (_evt: SSEEvent) => {
|
|
143
|
+
mutateAgents();
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const handleError = () => {
|
|
147
|
+
console.warn("[useOpenClaw] SSE error — marking as disconnected");
|
|
168
148
|
mutateStatus(
|
|
169
149
|
(prev) => prev ? { ...prev, ok: false, state: "disconnected" } : prev,
|
|
170
150
|
{ revalidate: false }
|
|
171
151
|
);
|
|
172
152
|
};
|
|
173
153
|
|
|
154
|
+
const unsub1 = subscribe("castle.state", handleState);
|
|
155
|
+
const unsub2 = subscribe("agent.*", handleAgent);
|
|
156
|
+
const unsub3 = subscribe("agentAvatarUpdated", handleAgent);
|
|
157
|
+
const unsub4 = onError(handleError);
|
|
158
|
+
|
|
174
159
|
return () => {
|
|
175
|
-
|
|
176
|
-
|
|
160
|
+
unsub1();
|
|
161
|
+
unsub2();
|
|
162
|
+
unsub3();
|
|
163
|
+
unsub4();
|
|
177
164
|
};
|
|
178
165
|
}, [mutateStatus, mutateAgents]);
|
|
179
166
|
|
|
@@ -7,7 +7,10 @@ import type { SessionStatus } from "@/lib/types/chat";
|
|
|
7
7
|
const fetcher = async (url: string): Promise<SessionStatus | null> => {
|
|
8
8
|
const res = await fetch(url);
|
|
9
9
|
if (res.status === 204) return null;
|
|
10
|
-
if (!res.ok)
|
|
10
|
+
if (!res.ok) {
|
|
11
|
+
console.error(`[useSessionStats] Fetch failed: ${res.status} ${url}`);
|
|
12
|
+
throw new Error("Failed to fetch session stats");
|
|
13
|
+
}
|
|
11
14
|
return res.json();
|
|
12
15
|
};
|
|
13
16
|
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared SSE singleton for Castle.
|
|
3
|
+
*
|
|
4
|
+
* Creates a SINGLE EventSource connection to /api/openclaw/events that is
|
|
5
|
+
* shared across all consumers (use-chat, use-openclaw, etc.).
|
|
6
|
+
*
|
|
7
|
+
* Ref-counted: the first subscriber opens the connection, the last
|
|
8
|
+
* unsubscribe closes it. Events are dispatched to handlers by event type.
|
|
9
|
+
*
|
|
10
|
+
* Deduplication: tracks the last seen `seq` number and drops duplicates
|
|
11
|
+
* as a safety net against multiple connections or reconnect replays.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface SSEEvent {
|
|
15
|
+
event: string;
|
|
16
|
+
payload?: unknown;
|
|
17
|
+
seq?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type EventHandler = (evt: SSEEvent) => void;
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Module-level singleton state
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
let es: EventSource | null = null;
|
|
27
|
+
let refCount = 0;
|
|
28
|
+
let lastSeq = -1;
|
|
29
|
+
let lastEventTimestamp = Date.now();
|
|
30
|
+
|
|
31
|
+
/** Handlers keyed by event pattern. Exact match or "*" for all events. */
|
|
32
|
+
const handlers = new Map<string, Set<EventHandler>>();
|
|
33
|
+
|
|
34
|
+
/** Error handlers notified on connection loss */
|
|
35
|
+
const errorHandlers = new Set<() => void>();
|
|
36
|
+
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Internal helpers
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
function matchesPattern(eventType: string, pattern: string): boolean {
|
|
42
|
+
if (pattern === "*") return true;
|
|
43
|
+
if (pattern.endsWith(".*")) {
|
|
44
|
+
return eventType.startsWith(pattern.slice(0, -1));
|
|
45
|
+
}
|
|
46
|
+
return eventType === pattern;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function dispatch(evt: SSEEvent) {
|
|
50
|
+
// Update last-event timestamp for heartbeat monitoring
|
|
51
|
+
lastEventTimestamp = Date.now();
|
|
52
|
+
|
|
53
|
+
// Dedup by seq — drop events we've already seen
|
|
54
|
+
if (typeof evt.seq === "number") {
|
|
55
|
+
if (evt.seq <= lastSeq) {
|
|
56
|
+
console.debug("[SSE Client] Dropped duplicate event seq:", evt.seq, "event:", evt.event);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
lastSeq = evt.seq;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const eventType = evt.event;
|
|
63
|
+
for (const [pattern, handlerSet] of handlers) {
|
|
64
|
+
if (matchesPattern(eventType, pattern)) {
|
|
65
|
+
for (const handler of handlerSet) {
|
|
66
|
+
try {
|
|
67
|
+
handler(evt);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
console.error("[SSE] Handler error:", err);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function openConnection() {
|
|
77
|
+
if (es) return;
|
|
78
|
+
|
|
79
|
+
console.log("[SSE Client] Opening connection (subscribers:", refCount + ")");
|
|
80
|
+
es = new EventSource("/api/openclaw/events");
|
|
81
|
+
lastEventTimestamp = Date.now();
|
|
82
|
+
|
|
83
|
+
es.onopen = () => {
|
|
84
|
+
console.log("[SSE Client] Connected");
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
es.onmessage = (e) => {
|
|
88
|
+
try {
|
|
89
|
+
const evt: SSEEvent = JSON.parse(e.data);
|
|
90
|
+
dispatch(evt);
|
|
91
|
+
} catch (err) {
|
|
92
|
+
console.warn("[SSE Client] Failed to parse event:", (err as Error).message, "data:", e.data?.slice(0, 100));
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
es.onerror = (e) => {
|
|
97
|
+
console.warn("[SSE Client] Connection error (readyState:", es?.readyState + ")");
|
|
98
|
+
// EventSource auto-reconnects. Notify error handlers so they can
|
|
99
|
+
// update UI state (e.g. show "disconnected").
|
|
100
|
+
for (const handler of errorHandlers) {
|
|
101
|
+
try {
|
|
102
|
+
handler();
|
|
103
|
+
} catch {
|
|
104
|
+
// ignore
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function closeConnection() {
|
|
111
|
+
if (es) {
|
|
112
|
+
console.log("[SSE Client] Closing connection");
|
|
113
|
+
es.close();
|
|
114
|
+
es = null;
|
|
115
|
+
}
|
|
116
|
+
lastSeq = -1;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Public API
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Subscribe to SSE events matching a pattern.
|
|
125
|
+
*
|
|
126
|
+
* Patterns:
|
|
127
|
+
* - `"chat"` — exact match for chat events
|
|
128
|
+
* - `"castle.state"` — exact match for state events
|
|
129
|
+
* - `"agent.*"` — wildcard: matches agent.created, agent.updated, etc.
|
|
130
|
+
* - `"*"` — all events
|
|
131
|
+
*
|
|
132
|
+
* Returns an unsubscribe function. Call it in your useEffect cleanup.
|
|
133
|
+
*/
|
|
134
|
+
export function subscribe(pattern: string, handler: EventHandler): () => void {
|
|
135
|
+
if (!handlers.has(pattern)) {
|
|
136
|
+
handlers.set(pattern, new Set());
|
|
137
|
+
}
|
|
138
|
+
handlers.get(pattern)!.add(handler);
|
|
139
|
+
|
|
140
|
+
refCount++;
|
|
141
|
+
if (refCount === 1) {
|
|
142
|
+
openConnection();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return () => {
|
|
146
|
+
const set = handlers.get(pattern);
|
|
147
|
+
if (set) {
|
|
148
|
+
set.delete(handler);
|
|
149
|
+
if (set.size === 0) handlers.delete(pattern);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
refCount--;
|
|
153
|
+
if (refCount <= 0) {
|
|
154
|
+
refCount = 0;
|
|
155
|
+
closeConnection();
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Subscribe to connection error events.
|
|
162
|
+
* Returns an unsubscribe function.
|
|
163
|
+
*/
|
|
164
|
+
export function onError(handler: () => void): () => void {
|
|
165
|
+
errorHandlers.add(handler);
|
|
166
|
+
return () => {
|
|
167
|
+
errorHandlers.delete(handler);
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Get the timestamp of the last received SSE event.
|
|
173
|
+
* Used for heartbeat-based timeout detection.
|
|
174
|
+
*/
|
|
175
|
+
export function getLastEventTimestamp(): number {
|
|
176
|
+
return lastEventTimestamp;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Check if the SSE connection is currently open.
|
|
181
|
+
*/
|
|
182
|
+
export function isConnected(): boolean {
|
|
183
|
+
return es !== null && es.readyState !== EventSource.CLOSED;
|
|
184
|
+
}
|
package/src/lib/types/chat.ts
CHANGED
|
@@ -122,26 +122,42 @@ export interface QueuedMessage {
|
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
// ============================================================================
|
|
125
|
-
// Session Status (from
|
|
125
|
+
// Session Status (read from OpenClaw session store on filesystem)
|
|
126
126
|
// ============================================================================
|
|
127
127
|
|
|
128
128
|
export interface SessionStatus {
|
|
129
129
|
sessionKey: string;
|
|
130
|
+
sessionId: string;
|
|
130
131
|
agentId: string;
|
|
131
132
|
model: string;
|
|
133
|
+
modelProvider: string;
|
|
132
134
|
tokens: {
|
|
133
135
|
input: number;
|
|
134
136
|
output: number;
|
|
137
|
+
total: number;
|
|
135
138
|
};
|
|
136
139
|
context: {
|
|
137
|
-
used: number;
|
|
138
|
-
limit: number;
|
|
139
|
-
|
|
140
|
+
used: number; // totalTokens (current context window usage)
|
|
141
|
+
limit: number; // effective operating limit (from config, default 200k)
|
|
142
|
+
modelMax: number; // model's theoretical max (e.g. 1M for Sonnet 4.5)
|
|
143
|
+
percentage: number; // used / limit * 100
|
|
140
144
|
};
|
|
141
145
|
compactions: number;
|
|
142
|
-
|
|
143
|
-
thinking: string;
|
|
146
|
+
thinkingLevel: string | null;
|
|
144
147
|
updatedAt: number;
|
|
148
|
+
/** System prompt breakdown — only present when the session has run at least once */
|
|
149
|
+
systemPrompt?: {
|
|
150
|
+
totalChars: number;
|
|
151
|
+
projectContextChars: number;
|
|
152
|
+
nonProjectContextChars: number;
|
|
153
|
+
skills: { promptChars: number; count: number };
|
|
154
|
+
tools: { listChars: number; schemaChars: number; count: number };
|
|
155
|
+
workspaceFiles: Array<{
|
|
156
|
+
name: string;
|
|
157
|
+
injectedChars: number;
|
|
158
|
+
truncated: boolean;
|
|
159
|
+
}>;
|
|
160
|
+
};
|
|
145
161
|
}
|
|
146
162
|
|
|
147
163
|
// ============================================================================
|