@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,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,6 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useEffect, useCallback } from "react";
|
|
4
|
+
import useSWR from "swr";
|
|
5
|
+
import { subscribe, onError, type SSEEvent } from "@/lib/sse-singleton";
|
|
4
6
|
|
|
5
7
|
// ============================================================================
|
|
6
8
|
// Types
|
|
@@ -26,138 +28,158 @@ export interface OpenClawStatus {
|
|
|
26
28
|
};
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// Fetchers
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
const statusFetcher = async (url: string): Promise<OpenClawStatus> => {
|
|
36
|
+
const res = await fetch(url, { method: "POST" });
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
console.warn(`[useOpenClaw] Status fetch returned ${res.status}`);
|
|
39
|
+
}
|
|
40
|
+
return res.json();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const agentsFetcher = async (url: string): Promise<OpenClawAgent[]> => {
|
|
44
|
+
const res = await fetch(url);
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
console.warn(`[useOpenClaw] Agents fetch failed: ${res.status}`);
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
const data = await res.json();
|
|
50
|
+
return data.agents || [];
|
|
51
|
+
};
|
|
34
52
|
|
|
35
53
|
// ============================================================================
|
|
36
54
|
// Hook
|
|
37
55
|
// ============================================================================
|
|
38
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Shared OpenClaw connection status and agents hook.
|
|
59
|
+
* Uses SWR for cache-based sharing across all components —
|
|
60
|
+
* any component calling useOpenClaw() gets the same cached data without extra fetches.
|
|
61
|
+
*
|
|
62
|
+
* SSE subscription pushes real-time updates that trigger SWR cache invalidation.
|
|
63
|
+
*/
|
|
39
64
|
export function useOpenClaw() {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
setIsLoading(false);
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// SWR: Connection status
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
const {
|
|
70
|
+
data: status,
|
|
71
|
+
error: statusError,
|
|
72
|
+
isLoading: statusLoading,
|
|
73
|
+
mutate: mutateStatus,
|
|
74
|
+
} = useSWR<OpenClawStatus>(
|
|
75
|
+
"/api/openclaw/ping",
|
|
76
|
+
statusFetcher,
|
|
77
|
+
{
|
|
78
|
+
refreshInterval: 60000, // Background refresh every 60s
|
|
79
|
+
revalidateOnFocus: true,
|
|
80
|
+
dedupingInterval: 10000, // Dedup rapid calls within 10s
|
|
81
|
+
errorRetryCount: 2,
|
|
58
82
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const isConnected = status?.ok ?? false;
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// SWR: Agents (conditional — only fetch when connected)
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
const {
|
|
91
|
+
data: agents,
|
|
92
|
+
isLoading: agentsLoading,
|
|
93
|
+
mutate: mutateAgents,
|
|
94
|
+
} = useSWR<OpenClawAgent[]>(
|
|
95
|
+
isConnected ? "/api/openclaw/agents" : null,
|
|
96
|
+
agentsFetcher,
|
|
97
|
+
{
|
|
98
|
+
refreshInterval: 300000, // Refresh agents every 5 min
|
|
99
|
+
revalidateOnFocus: false,
|
|
100
|
+
dedupingInterval: 30000, // Dedup within 30s
|
|
76
101
|
}
|
|
77
|
-
|
|
102
|
+
);
|
|
78
103
|
|
|
79
|
-
//
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// Refresh helpers
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
80
107
|
const refresh = useCallback(async () => {
|
|
81
|
-
|
|
82
|
-
if (
|
|
83
|
-
await
|
|
84
|
-
}
|
|
85
|
-
}, [fetchStatus, fetchAgents]);
|
|
86
|
-
|
|
87
|
-
// Initial data fetch
|
|
88
|
-
useEffect(() => {
|
|
89
|
-
let cancelled = false;
|
|
90
|
-
|
|
91
|
-
async function init() {
|
|
92
|
-
const s = await fetchStatus();
|
|
93
|
-
if (!cancelled && s?.ok) {
|
|
94
|
-
await fetchAgents();
|
|
95
|
-
}
|
|
108
|
+
await mutateStatus();
|
|
109
|
+
if (isConnected) {
|
|
110
|
+
await mutateAgents();
|
|
96
111
|
}
|
|
112
|
+
}, [mutateStatus, mutateAgents, isConnected]);
|
|
97
113
|
|
|
98
|
-
|
|
99
|
-
return () => { cancelled = true; };
|
|
100
|
-
}, [fetchStatus, fetchAgents]);
|
|
114
|
+
const refreshAgents = useCallback(() => mutateAgents(), [mutateAgents]);
|
|
101
115
|
|
|
102
|
-
//
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// SSE subscription for real-time events via shared singleton
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
103
119
|
useEffect(() => {
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
fetchAgents();
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Handle agent-related events -- re-fetch agent list
|
|
128
|
-
if (evt.event?.startsWith("agent.")) {
|
|
129
|
-
fetchAgents();
|
|
130
|
-
}
|
|
131
|
-
} catch {
|
|
132
|
-
// 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();
|
|
133
139
|
}
|
|
134
140
|
};
|
|
135
141
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
setStatus((prev) => prev ? { ...prev, ok: false, state: "disconnected" } : prev);
|
|
142
|
+
const handleAgent = (_evt: SSEEvent) => {
|
|
143
|
+
mutateAgents();
|
|
139
144
|
};
|
|
140
145
|
|
|
146
|
+
const handleError = () => {
|
|
147
|
+
console.warn("[useOpenClaw] SSE error — marking as disconnected");
|
|
148
|
+
mutateStatus(
|
|
149
|
+
(prev) => prev ? { ...prev, ok: false, state: "disconnected" } : prev,
|
|
150
|
+
{ revalidate: false }
|
|
151
|
+
);
|
|
152
|
+
};
|
|
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
|
+
|
|
141
159
|
return () => {
|
|
142
|
-
|
|
143
|
-
|
|
160
|
+
unsub1();
|
|
161
|
+
unsub2();
|
|
162
|
+
unsub3();
|
|
163
|
+
unsub4();
|
|
144
164
|
};
|
|
145
|
-
}, [
|
|
165
|
+
}, [mutateStatus, mutateAgents]);
|
|
146
166
|
|
|
147
167
|
return {
|
|
148
168
|
// Status
|
|
149
169
|
status,
|
|
150
|
-
isLoading,
|
|
151
|
-
|
|
170
|
+
isLoading: statusLoading,
|
|
171
|
+
isError: !!statusError,
|
|
172
|
+
isConnected,
|
|
152
173
|
isConfigured: status?.configured ?? false,
|
|
153
174
|
latency: status?.latency_ms,
|
|
154
175
|
serverVersion: status?.server?.version,
|
|
155
176
|
|
|
156
177
|
// Agents
|
|
157
|
-
agents,
|
|
178
|
+
agents: agents ?? [],
|
|
158
179
|
agentsLoading,
|
|
159
180
|
|
|
160
181
|
// Actions
|
|
161
182
|
refresh,
|
|
183
|
+
refreshAgents,
|
|
162
184
|
};
|
|
163
185
|
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useRef, useEffect } from "react";
|
|
4
|
+
import type { SearchResult } from "@/lib/types/search";
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Constants
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
const DEBOUNCE_MS = 300;
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// Hook
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
export function useSearch() {
|
|
17
|
+
const [query, setQuery] = useState("");
|
|
18
|
+
const [results, setResults] = useState<SearchResult[]>([]);
|
|
19
|
+
const [isSearching, setIsSearching] = useState(false);
|
|
20
|
+
const [recentSearches, setRecentSearches] = useState<string[]>([]);
|
|
21
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
22
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
23
|
+
const recentLoaded = useRef(false);
|
|
24
|
+
|
|
25
|
+
// Load recent searches from DB on first mount
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (recentLoaded.current) return;
|
|
28
|
+
recentLoaded.current = true;
|
|
29
|
+
fetch("/api/openclaw/chat/search?recent=1")
|
|
30
|
+
.then((res) => (res.ok ? res.json() : { recent: [] }))
|
|
31
|
+
.then((data) => setRecentSearches(data.recent ?? []))
|
|
32
|
+
.catch(() => {});
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
// Debounced search
|
|
36
|
+
const search = useCallback((q: string) => {
|
|
37
|
+
setQuery(q);
|
|
38
|
+
|
|
39
|
+
// Cancel pending request
|
|
40
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
41
|
+
if (abortRef.current) abortRef.current.abort();
|
|
42
|
+
|
|
43
|
+
if (!q.trim()) {
|
|
44
|
+
setResults([]);
|
|
45
|
+
setIsSearching(false);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
setIsSearching(true);
|
|
50
|
+
timerRef.current = setTimeout(async () => {
|
|
51
|
+
const controller = new AbortController();
|
|
52
|
+
abortRef.current = controller;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(
|
|
56
|
+
`/api/openclaw/chat/search?q=${encodeURIComponent(q.trim())}`,
|
|
57
|
+
{ signal: controller.signal }
|
|
58
|
+
);
|
|
59
|
+
if (res.ok) {
|
|
60
|
+
const data = await res.json();
|
|
61
|
+
const searchResults: SearchResult[] = data.results ?? [];
|
|
62
|
+
setResults(searchResults);
|
|
63
|
+
|
|
64
|
+
// Save to recent searches in DB if results found
|
|
65
|
+
if (searchResults.length > 0) {
|
|
66
|
+
const trimmed = q.trim();
|
|
67
|
+
fetch("/api/openclaw/chat/search", {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: { "Content-Type": "application/json" },
|
|
70
|
+
body: JSON.stringify({ query: trimmed }),
|
|
71
|
+
})
|
|
72
|
+
.then(() => {
|
|
73
|
+
// Update local state to reflect the save
|
|
74
|
+
setRecentSearches((prev) => {
|
|
75
|
+
const filtered = prev.filter((s) => s !== trimmed);
|
|
76
|
+
return [trimmed, ...filtered].slice(0, 15);
|
|
77
|
+
});
|
|
78
|
+
})
|
|
79
|
+
.catch(() => {});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if ((err as Error).name !== "AbortError") {
|
|
84
|
+
console.error("[useSearch] Search failed:", (err as Error).message);
|
|
85
|
+
setResults([]);
|
|
86
|
+
}
|
|
87
|
+
} finally {
|
|
88
|
+
setIsSearching(false);
|
|
89
|
+
}
|
|
90
|
+
}, DEBOUNCE_MS);
|
|
91
|
+
}, []);
|
|
92
|
+
|
|
93
|
+
const clearRecentSearches = useCallback(() => {
|
|
94
|
+
setRecentSearches([]);
|
|
95
|
+
fetch("/api/openclaw/chat/search", { method: "DELETE" }).catch(() => {});
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
// Cleanup on unmount
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
return () => {
|
|
101
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
102
|
+
if (abortRef.current) abortRef.current.abort();
|
|
103
|
+
};
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
query,
|
|
108
|
+
setQuery: search,
|
|
109
|
+
results,
|
|
110
|
+
isSearching,
|
|
111
|
+
recentSearches,
|
|
112
|
+
clearRecentSearches,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback } from "react";
|
|
4
|
+
import useSWR from "swr";
|
|
5
|
+
import type { SessionStatus } from "@/lib/types/chat";
|
|
6
|
+
|
|
7
|
+
const fetcher = async (url: string): Promise<SessionStatus | null> => {
|
|
8
|
+
const res = await fetch(url);
|
|
9
|
+
if (res.status === 204) return null;
|
|
10
|
+
if (!res.ok) {
|
|
11
|
+
console.error(`[useSessionStats] Fetch failed: ${res.status} ${url}`);
|
|
12
|
+
throw new Error("Failed to fetch session stats");
|
|
13
|
+
}
|
|
14
|
+
return res.json();
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
interface UseSessionStatsOptions {
|
|
18
|
+
sessionKey: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface UseSessionStatsReturn {
|
|
22
|
+
stats: SessionStatus | null;
|
|
23
|
+
isLoading: boolean;
|
|
24
|
+
isError: boolean;
|
|
25
|
+
refresh: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* SWR-based hook for fetching session statistics from the Gateway.
|
|
30
|
+
* Conditional fetching: only when sessionKey is non-null.
|
|
31
|
+
* Polls every 30s while active, dedupes within 5s.
|
|
32
|
+
*/
|
|
33
|
+
export function useSessionStats({ sessionKey }: UseSessionStatsOptions): UseSessionStatsReturn {
|
|
34
|
+
const {
|
|
35
|
+
data,
|
|
36
|
+
isLoading,
|
|
37
|
+
error,
|
|
38
|
+
mutate,
|
|
39
|
+
} = useSWR<SessionStatus | null>(
|
|
40
|
+
sessionKey ? `/api/openclaw/session/status?sessionKey=${sessionKey}` : null,
|
|
41
|
+
fetcher,
|
|
42
|
+
{
|
|
43
|
+
refreshInterval: 30000,
|
|
44
|
+
dedupingInterval: 5000,
|
|
45
|
+
revalidateOnFocus: false,
|
|
46
|
+
errorRetryCount: 1,
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const refresh = useCallback(() => {
|
|
51
|
+
mutate();
|
|
52
|
+
}, [mutate]);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
stats: data ?? null,
|
|
56
|
+
isLoading,
|
|
57
|
+
isError: !!error,
|
|
58
|
+
refresh,
|
|
59
|
+
};
|
|
60
|
+
}
|