@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,164 @@
|
|
|
1
|
+
import { sqliteTable, text, integer, primaryKey, index } from "drizzle-orm/sqlite-core";
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// channels
|
|
5
|
+
// ============================================================================
|
|
6
|
+
|
|
7
|
+
export const channels = sqliteTable("channels", {
|
|
8
|
+
id: text("id").primaryKey(),
|
|
9
|
+
name: text("name").notNull(),
|
|
10
|
+
defaultAgentId: text("default_agent_id").notNull(),
|
|
11
|
+
createdAt: integer("created_at").notNull(), // unix ms
|
|
12
|
+
updatedAt: integer("updated_at"), // unix ms
|
|
13
|
+
lastAccessedAt: integer("last_accessed_at"), // unix ms — last time user opened this channel
|
|
14
|
+
archivedAt: integer("archived_at"), // unix ms — null if active, set when archived
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// settings (key-value store for user preferences)
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
export const settings = sqliteTable("settings", {
|
|
22
|
+
key: text("key").primaryKey(),
|
|
23
|
+
value: text("value").notNull(),
|
|
24
|
+
updatedAt: integer("updated_at").notNull(), // unix ms
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// agent_statuses (live agent activity state)
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
export const agentStatuses = sqliteTable("agent_statuses", {
|
|
32
|
+
agentId: text("agent_id").primaryKey(),
|
|
33
|
+
status: text("status").notNull().default("idle"), // "idle" | "thinking" | "active"
|
|
34
|
+
updatedAt: integer("updated_at").notNull(), // unix ms
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// ============================================================================
|
|
38
|
+
// channel_agents (many-to-many junction)
|
|
39
|
+
// ============================================================================
|
|
40
|
+
|
|
41
|
+
export const channelAgents = sqliteTable(
|
|
42
|
+
"channel_agents",
|
|
43
|
+
{
|
|
44
|
+
channelId: text("channel_id")
|
|
45
|
+
.notNull()
|
|
46
|
+
.references(() => channels.id, { onDelete: "cascade" }),
|
|
47
|
+
agentId: text("agent_id").notNull(),
|
|
48
|
+
},
|
|
49
|
+
(table) => [
|
|
50
|
+
primaryKey({ columns: [table.channelId, table.agentId] }),
|
|
51
|
+
]
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// ============================================================================
|
|
55
|
+
// sessions
|
|
56
|
+
// ============================================================================
|
|
57
|
+
|
|
58
|
+
export const sessions = sqliteTable(
|
|
59
|
+
"sessions",
|
|
60
|
+
{
|
|
61
|
+
id: text("id").primaryKey(),
|
|
62
|
+
channelId: text("channel_id")
|
|
63
|
+
.notNull()
|
|
64
|
+
.references(() => channels.id, { onDelete: "cascade" }),
|
|
65
|
+
sessionKey: text("session_key"), // Gateway session key
|
|
66
|
+
startedAt: integer("started_at").notNull(), // unix ms
|
|
67
|
+
endedAt: integer("ended_at"), // unix ms, nullable
|
|
68
|
+
summary: text("summary"),
|
|
69
|
+
totalInputTokens: integer("total_input_tokens").default(0),
|
|
70
|
+
totalOutputTokens: integer("total_output_tokens").default(0),
|
|
71
|
+
// Compaction tracking: ID of the oldest message still in the agent's context.
|
|
72
|
+
// Messages before this boundary have been compacted (summarized).
|
|
73
|
+
// Updated when compaction events are detected.
|
|
74
|
+
compactionBoundaryMessageId: text("compaction_boundary_message_id"),
|
|
75
|
+
},
|
|
76
|
+
(table) => [
|
|
77
|
+
index("idx_sessions_channel").on(table.channelId, table.startedAt),
|
|
78
|
+
]
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// ============================================================================
|
|
82
|
+
// messages
|
|
83
|
+
// ============================================================================
|
|
84
|
+
|
|
85
|
+
export const messages = sqliteTable(
|
|
86
|
+
"messages",
|
|
87
|
+
{
|
|
88
|
+
id: text("id").primaryKey(),
|
|
89
|
+
channelId: text("channel_id")
|
|
90
|
+
.notNull()
|
|
91
|
+
.references(() => channels.id, { onDelete: "cascade" }),
|
|
92
|
+
sessionId: text("session_id").references(() => sessions.id),
|
|
93
|
+
senderType: text("sender_type").notNull(), // "user" | "agent"
|
|
94
|
+
senderId: text("sender_id").notNull(),
|
|
95
|
+
senderName: text("sender_name"),
|
|
96
|
+
content: text("content").notNull().default(""),
|
|
97
|
+
status: text("status").notNull().default("complete"), // "complete" | "interrupted" | "aborted"
|
|
98
|
+
mentionedAgentId: text("mentioned_agent_id"),
|
|
99
|
+
runId: text("run_id"), // Gateway run ID for streaming correlation
|
|
100
|
+
sessionKey: text("session_key"), // Gateway session key
|
|
101
|
+
inputTokens: integer("input_tokens"),
|
|
102
|
+
outputTokens: integer("output_tokens"),
|
|
103
|
+
createdAt: integer("created_at").notNull(), // unix ms
|
|
104
|
+
},
|
|
105
|
+
(table) => [
|
|
106
|
+
index("idx_messages_channel").on(table.channelId, table.createdAt),
|
|
107
|
+
index("idx_messages_session").on(table.sessionId, table.createdAt),
|
|
108
|
+
index("idx_messages_run_id").on(table.runId),
|
|
109
|
+
]
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// ============================================================================
|
|
113
|
+
// message_attachments
|
|
114
|
+
// ============================================================================
|
|
115
|
+
|
|
116
|
+
export const messageAttachments = sqliteTable(
|
|
117
|
+
"message_attachments",
|
|
118
|
+
{
|
|
119
|
+
id: text("id").primaryKey(),
|
|
120
|
+
messageId: text("message_id")
|
|
121
|
+
.notNull()
|
|
122
|
+
.references(() => messages.id, { onDelete: "cascade" }),
|
|
123
|
+
attachmentType: text("attachment_type").notNull(), // "image" | "audio"
|
|
124
|
+
filePath: text("file_path").notNull(),
|
|
125
|
+
mimeType: text("mime_type"),
|
|
126
|
+
fileSize: integer("file_size"),
|
|
127
|
+
originalName: text("original_name"),
|
|
128
|
+
createdAt: integer("created_at").notNull(), // unix ms
|
|
129
|
+
},
|
|
130
|
+
(table) => [
|
|
131
|
+
index("idx_attachments_message").on(table.messageId),
|
|
132
|
+
]
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
// ============================================================================
|
|
136
|
+
// recent_searches
|
|
137
|
+
// ============================================================================
|
|
138
|
+
|
|
139
|
+
export const recentSearches = sqliteTable("recent_searches", {
|
|
140
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
141
|
+
query: text("query").notNull(),
|
|
142
|
+
createdAt: integer("created_at").notNull(), // unix ms
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ============================================================================
|
|
146
|
+
// message_reactions
|
|
147
|
+
// ============================================================================
|
|
148
|
+
|
|
149
|
+
export const messageReactions = sqliteTable(
|
|
150
|
+
"message_reactions",
|
|
151
|
+
{
|
|
152
|
+
id: text("id").primaryKey(),
|
|
153
|
+
messageId: text("message_id")
|
|
154
|
+
.notNull()
|
|
155
|
+
.references(() => messages.id, { onDelete: "cascade" }),
|
|
156
|
+
agentId: text("agent_id"),
|
|
157
|
+
emoji: text("emoji").notNull(),
|
|
158
|
+
emojiChar: text("emoji_char").notNull(),
|
|
159
|
+
createdAt: integer("created_at").notNull(), // unix ms
|
|
160
|
+
},
|
|
161
|
+
(table) => [
|
|
162
|
+
index("idx_reactions_message").on(table.messageId),
|
|
163
|
+
]
|
|
164
|
+
);
|
|
@@ -463,7 +463,8 @@ class GatewayConnection extends EventEmitter {
|
|
|
463
463
|
let msg: GatewayFrame;
|
|
464
464
|
try {
|
|
465
465
|
msg = JSON.parse(data.toString());
|
|
466
|
-
} catch {
|
|
466
|
+
} catch (err) {
|
|
467
|
+
console.warn("[Gateway] Failed to parse message:", (err as Error).message);
|
|
467
468
|
return;
|
|
468
469
|
}
|
|
469
470
|
|
|
@@ -502,16 +503,31 @@ class GatewayConnection extends EventEmitter {
|
|
|
502
503
|
|
|
503
504
|
const id = randomUUID();
|
|
504
505
|
const frame: RequestFrame = { type: "req", id, method, params };
|
|
506
|
+
const startTime = Date.now();
|
|
505
507
|
|
|
506
508
|
return new Promise<T>((resolve, reject) => {
|
|
507
509
|
const timer = setTimeout(() => {
|
|
508
510
|
this.pending.delete(id);
|
|
511
|
+
const elapsed = Date.now() - startTime;
|
|
512
|
+
console.error(`[Gateway RPC] ${method} TIMEOUT after ${elapsed}ms`);
|
|
509
513
|
reject(new Error(`Request timeout: ${method}`));
|
|
510
514
|
}, this.requestTimeout);
|
|
511
515
|
|
|
512
516
|
this.pending.set(id, {
|
|
513
|
-
resolve:
|
|
514
|
-
|
|
517
|
+
resolve: (payload: unknown) => {
|
|
518
|
+
const elapsed = Date.now() - startTime;
|
|
519
|
+
if (elapsed > 2000) {
|
|
520
|
+
console.warn(`[Gateway RPC] ${method} OK (slow: ${elapsed}ms)`);
|
|
521
|
+
} else {
|
|
522
|
+
console.log(`[Gateway RPC] ${method} OK (${elapsed}ms)`);
|
|
523
|
+
}
|
|
524
|
+
resolve(payload as T);
|
|
525
|
+
},
|
|
526
|
+
reject: (error: Error) => {
|
|
527
|
+
const elapsed = Date.now() - startTime;
|
|
528
|
+
console.error(`[Gateway RPC] ${method} FAILED (${elapsed}ms): ${sanitize(error.message)}`);
|
|
529
|
+
reject(error);
|
|
530
|
+
},
|
|
515
531
|
timer,
|
|
516
532
|
});
|
|
517
533
|
|
|
@@ -519,6 +535,8 @@ class GatewayConnection extends EventEmitter {
|
|
|
519
535
|
if (err) {
|
|
520
536
|
clearTimeout(timer);
|
|
521
537
|
this.pending.delete(id);
|
|
538
|
+
const elapsed = Date.now() - startTime;
|
|
539
|
+
console.error(`[Gateway RPC] ${method} SEND ERROR (${elapsed}ms): ${err.message}`);
|
|
522
540
|
reject(new Error(`Send failed: ${err.message}`));
|
|
523
541
|
}
|
|
524
542
|
});
|
|
@@ -556,6 +574,9 @@ class GatewayConnection extends EventEmitter {
|
|
|
556
574
|
}
|
|
557
575
|
|
|
558
576
|
// Reject all pending requests
|
|
577
|
+
if (this.pending.size > 0) {
|
|
578
|
+
console.warn(`[Gateway] Rejecting ${this.pending.size} pending request(s) due to connection close`);
|
|
579
|
+
}
|
|
559
580
|
for (const [id, pending] of this.pending) {
|
|
560
581
|
clearTimeout(pending.timer);
|
|
561
582
|
pending.reject(new Error("Connection closed"));
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect } from "react";
|
|
4
|
+
import useSWR, { mutate as globalMutate } from "swr";
|
|
5
|
+
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Types
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
export type AgentStatus = "idle" | "thinking" | "active";
|
|
11
|
+
|
|
12
|
+
interface AgentStatusRow {
|
|
13
|
+
agentId: string;
|
|
14
|
+
status: AgentStatus;
|
|
15
|
+
updatedAt: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type StatusMap = Record<string, AgentStatus>;
|
|
19
|
+
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Constants
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
const SWR_KEY = "/api/openclaw/agents/status";
|
|
25
|
+
const CHANNEL_NAME = "agent-status";
|
|
26
|
+
|
|
27
|
+
// ============================================================================
|
|
28
|
+
// Fetcher
|
|
29
|
+
// ============================================================================
|
|
30
|
+
|
|
31
|
+
const statusFetcher = async (url: string): Promise<StatusMap> => {
|
|
32
|
+
const res = await fetch(url);
|
|
33
|
+
if (!res.ok) return {};
|
|
34
|
+
const data = await res.json();
|
|
35
|
+
const map: StatusMap = {};
|
|
36
|
+
for (const row of (data.statuses || []) as AgentStatusRow[]) {
|
|
37
|
+
map[row.agentId] = row.status;
|
|
38
|
+
}
|
|
39
|
+
return map;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ============================================================================
|
|
43
|
+
// BroadcastChannel: cross-tab real-time sync (no polling needed)
|
|
44
|
+
// ============================================================================
|
|
45
|
+
|
|
46
|
+
let broadcast: BroadcastChannel | null = null;
|
|
47
|
+
|
|
48
|
+
function getBroadcast(): BroadcastChannel | null {
|
|
49
|
+
if (typeof window === "undefined") return null;
|
|
50
|
+
if (!broadcast) {
|
|
51
|
+
try {
|
|
52
|
+
broadcast = new BroadcastChannel(CHANNEL_NAME);
|
|
53
|
+
} catch {
|
|
54
|
+
// BroadcastChannel not supported (e.g. some older browsers)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return broadcast;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ============================================================================
|
|
61
|
+
// Client-side active → idle timers (mirrors the server-side 2 min expiry)
|
|
62
|
+
// ============================================================================
|
|
63
|
+
|
|
64
|
+
const ACTIVE_TIMEOUT_MS = 2 * 60 * 1000;
|
|
65
|
+
const activeTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
66
|
+
|
|
67
|
+
function scheduleIdleTransition(agentId: string) {
|
|
68
|
+
// Clear any existing timer for this agent
|
|
69
|
+
const existing = activeTimers.get(agentId);
|
|
70
|
+
if (existing) clearTimeout(existing);
|
|
71
|
+
|
|
72
|
+
activeTimers.set(
|
|
73
|
+
agentId,
|
|
74
|
+
setTimeout(() => {
|
|
75
|
+
activeTimers.delete(agentId);
|
|
76
|
+
// Explicitly transition to idle — triggers SWR + BroadcastChannel
|
|
77
|
+
updateStatus(agentId, "idle");
|
|
78
|
+
}, ACTIVE_TIMEOUT_MS)
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function clearIdleTimer(agentId: string) {
|
|
83
|
+
const existing = activeTimers.get(agentId);
|
|
84
|
+
if (existing) {
|
|
85
|
+
clearTimeout(existing);
|
|
86
|
+
activeTimers.delete(agentId);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ============================================================================
|
|
91
|
+
// Channel tracking: which channel each agent is currently thinking in
|
|
92
|
+
// ============================================================================
|
|
93
|
+
|
|
94
|
+
const thinkingChannels = new Map<string, string>();
|
|
95
|
+
|
|
96
|
+
/** Returns the channelId the agent is currently thinking in, or undefined. */
|
|
97
|
+
export function getThinkingChannel(agentId: string): string | undefined {
|
|
98
|
+
return thinkingChannels.get(agentId);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ============================================================================
|
|
102
|
+
// Exported setters (optimistic SWR + DB persist + cross-tab broadcast)
|
|
103
|
+
// ============================================================================
|
|
104
|
+
|
|
105
|
+
function updateStatus(agentId: string, status: AgentStatus) {
|
|
106
|
+
// 1. Optimistic: update SWR cache immediately (current tab)
|
|
107
|
+
globalMutate(
|
|
108
|
+
SWR_KEY,
|
|
109
|
+
(prev: StatusMap | undefined) => ({ ...prev, [agentId]: status }),
|
|
110
|
+
{ revalidate: false }
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
// 2. Broadcast to other tabs (instant, no server round-trip)
|
|
114
|
+
getBroadcast()?.postMessage({ agentId, status });
|
|
115
|
+
|
|
116
|
+
// 3. Persist to DB via API (fire-and-forget)
|
|
117
|
+
fetch(SWR_KEY, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { "Content-Type": "application/json" },
|
|
120
|
+
body: JSON.stringify({ agentId, status }),
|
|
121
|
+
}).catch(() => {
|
|
122
|
+
// Silent — DB will be stale but next page load corrects it
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function setAgentThinking(agentId: string, channelId?: string) {
|
|
127
|
+
clearIdleTimer(agentId);
|
|
128
|
+
if (channelId) thinkingChannels.set(agentId, channelId);
|
|
129
|
+
updateStatus(agentId, "thinking");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function setAgentActive(agentId: string) {
|
|
133
|
+
thinkingChannels.delete(agentId);
|
|
134
|
+
updateStatus(agentId, "active");
|
|
135
|
+
// Schedule automatic idle transition after 2 minutes
|
|
136
|
+
scheduleIdleTransition(agentId);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function setAgentIdle(agentId: string) {
|
|
140
|
+
thinkingChannels.delete(agentId);
|
|
141
|
+
clearIdleTimer(agentId);
|
|
142
|
+
updateStatus(agentId, "idle");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ============================================================================
|
|
146
|
+
// User presence constants
|
|
147
|
+
// ============================================================================
|
|
148
|
+
|
|
149
|
+
export const USER_STATUS_ID = "__user__";
|
|
150
|
+
const USER_IDLE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes of inactivity → idle
|
|
151
|
+
|
|
152
|
+
// ============================================================================
|
|
153
|
+
// Hook
|
|
154
|
+
// ============================================================================
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Shared agent status hook backed by the database.
|
|
158
|
+
* Uses BroadcastChannel for instant cross-tab sync — no polling.
|
|
159
|
+
* On mount, fetches current state from DB (catches up after page load).
|
|
160
|
+
* The server auto-expires "active" statuses older than 2 minutes to "idle".
|
|
161
|
+
*
|
|
162
|
+
* Status lifecycle: idle → thinking → active (2 min server-side) → idle
|
|
163
|
+
*/
|
|
164
|
+
export function useAgentStatus() {
|
|
165
|
+
const { data: statuses } = useSWR<StatusMap>(SWR_KEY, statusFetcher, {
|
|
166
|
+
fallbackData: {},
|
|
167
|
+
revalidateOnFocus: true,
|
|
168
|
+
revalidateOnReconnect: true,
|
|
169
|
+
refreshInterval: 120_000, // Light background poll every 2 min just to catch expired "active" → "idle"
|
|
170
|
+
dedupingInterval: 5000,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Listen for cross-tab broadcasts and update SWR cache instantly
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
const bc = getBroadcast();
|
|
176
|
+
if (!bc) return;
|
|
177
|
+
|
|
178
|
+
const handler = (e: MessageEvent) => {
|
|
179
|
+
const { agentId, status } = e.data as { agentId: string; status: AgentStatus };
|
|
180
|
+
if (agentId && status) {
|
|
181
|
+
globalMutate(
|
|
182
|
+
SWR_KEY,
|
|
183
|
+
(prev: StatusMap | undefined) => ({ ...prev, [agentId]: status }),
|
|
184
|
+
{ revalidate: false }
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
bc.addEventListener("message", handler);
|
|
190
|
+
return () => bc.removeEventListener("message", handler);
|
|
191
|
+
}, []);
|
|
192
|
+
|
|
193
|
+
const getStatus = useCallback(
|
|
194
|
+
(agentId: string): AgentStatus => {
|
|
195
|
+
return statuses?.[agentId] ?? "idle";
|
|
196
|
+
},
|
|
197
|
+
[statuses]
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
statuses: statuses ?? {},
|
|
202
|
+
getStatus,
|
|
203
|
+
setThinking: setAgentThinking,
|
|
204
|
+
setActive: setAgentActive,
|
|
205
|
+
setIdle: setAgentIdle,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ============================================================================
|
|
210
|
+
// User Presence Hook
|
|
211
|
+
// ============================================================================
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Tracks user activity and sets active/idle status.
|
|
215
|
+
* Call once at the app layout level — listens for mouse, keyboard,
|
|
216
|
+
* scroll, and touch events. After 2 minutes of inactivity, sets idle.
|
|
217
|
+
*/
|
|
218
|
+
export function useUserPresence() {
|
|
219
|
+
useEffect(() => {
|
|
220
|
+
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
221
|
+
let isActive = false;
|
|
222
|
+
|
|
223
|
+
const setActive = () => {
|
|
224
|
+
if (!isActive) {
|
|
225
|
+
isActive = true;
|
|
226
|
+
updateStatus(USER_STATUS_ID, "active");
|
|
227
|
+
}
|
|
228
|
+
// Reset idle timer on every interaction
|
|
229
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
230
|
+
idleTimer = setTimeout(() => {
|
|
231
|
+
isActive = false;
|
|
232
|
+
updateStatus(USER_STATUS_ID, "idle");
|
|
233
|
+
}, USER_IDLE_TIMEOUT_MS);
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// Set active immediately on mount
|
|
237
|
+
setActive();
|
|
238
|
+
|
|
239
|
+
const events = ["mousemove", "keydown", "scroll", "click", "touchstart"] as const;
|
|
240
|
+
for (const event of events) {
|
|
241
|
+
document.addEventListener(event, setActive, { passive: true });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return () => {
|
|
245
|
+
for (const event of events) {
|
|
246
|
+
document.removeEventListener(event, setActive);
|
|
247
|
+
}
|
|
248
|
+
if (idleTimer) clearTimeout(idleTimer);
|
|
249
|
+
};
|
|
250
|
+
}, []);
|
|
251
|
+
}
|