@thrillee/aegischat 0.1.4 → 0.1.6
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/dist/index.d.mts +3 -2
- package/dist/index.d.ts +3 -2
- package/dist/index.js +568 -327
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +568 -327
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/hooks/useChat.ts +721 -386
package/src/hooks/useChat.ts
CHANGED
|
@@ -2,8 +2,14 @@
|
|
|
2
2
|
// AegisChat React SDK - useChat Hook
|
|
3
3
|
// ============================================================================
|
|
4
4
|
|
|
5
|
-
import { useCallback, useEffect, useRef, useState } from
|
|
6
|
-
import {
|
|
5
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
6
|
+
import {
|
|
7
|
+
chatApi,
|
|
8
|
+
channelsApi,
|
|
9
|
+
messagesApi,
|
|
10
|
+
filesApi,
|
|
11
|
+
configureApiClient,
|
|
12
|
+
} from "../services/api";
|
|
7
13
|
import type {
|
|
8
14
|
AegisConfig,
|
|
9
15
|
ChatSession,
|
|
@@ -15,20 +21,20 @@ import type {
|
|
|
15
21
|
FileAttachment,
|
|
16
22
|
UploadProgress,
|
|
17
23
|
MessageSummary,
|
|
18
|
-
} from
|
|
24
|
+
} from "../types";
|
|
19
25
|
|
|
20
26
|
const TYPING_TIMEOUT = 3000;
|
|
21
27
|
const RECONNECT_INTERVAL = 3000;
|
|
22
28
|
const MAX_RECONNECT_ATTEMPTS = 5;
|
|
23
29
|
const MAX_RECONNECT_DELAY = 30000;
|
|
24
30
|
const PING_INTERVAL = 30000;
|
|
25
|
-
const SESSION_STORAGE_KEY =
|
|
31
|
+
const SESSION_STORAGE_KEY = "@aegischat/activeChannel";
|
|
26
32
|
|
|
27
33
|
export interface UseChatOptions {
|
|
28
34
|
config?: AegisConfig;
|
|
29
|
-
role
|
|
35
|
+
role?: "lawyer" | "client";
|
|
30
36
|
clientId?: string;
|
|
31
|
-
|
|
37
|
+
|
|
32
38
|
initialSession?: ChatSession | null;
|
|
33
39
|
autoConnect?: boolean;
|
|
34
40
|
onMessage?: (message: Message) => void;
|
|
@@ -51,8 +57,23 @@ export interface UseChatReturn {
|
|
|
51
57
|
connect: () => Promise<void>;
|
|
52
58
|
disconnect: () => void;
|
|
53
59
|
selectChannel: (channelId: string) => void;
|
|
54
|
-
sendMessage: (
|
|
55
|
-
|
|
60
|
+
sendMessage: (
|
|
61
|
+
content: string,
|
|
62
|
+
options?: {
|
|
63
|
+
type?: string;
|
|
64
|
+
parent_id?: string;
|
|
65
|
+
metadata?: Record<string, unknown>;
|
|
66
|
+
},
|
|
67
|
+
) => Promise<void>;
|
|
68
|
+
sendMessageWithFiles: (
|
|
69
|
+
content: string,
|
|
70
|
+
files: File[],
|
|
71
|
+
options?: {
|
|
72
|
+
type?: string;
|
|
73
|
+
parent_id?: string;
|
|
74
|
+
metadata?: Record<string, unknown>;
|
|
75
|
+
},
|
|
76
|
+
) => Promise<void>;
|
|
56
77
|
uploadFile: (file: File) => Promise<FileAttachment | null>;
|
|
57
78
|
loadMoreMessages: () => Promise<void>;
|
|
58
79
|
startTyping: () => void;
|
|
@@ -62,18 +83,32 @@ export interface UseChatReturn {
|
|
|
62
83
|
retryMessage: (tempId: string) => Promise<void>;
|
|
63
84
|
deleteFailedMessage: (tempId: string) => void;
|
|
64
85
|
markAsRead: (channelId: string) => Promise<void>;
|
|
86
|
+
setup: (options: UseChatOptions) => void;
|
|
65
87
|
}
|
|
66
88
|
|
|
67
|
-
export function useChat(options: UseChatOptions): UseChatReturn {
|
|
68
|
-
const {
|
|
69
|
-
|
|
70
|
-
|
|
89
|
+
export function useChat(options: Partial<UseChatOptions> = {}): UseChatReturn {
|
|
90
|
+
const {
|
|
91
|
+
config,
|
|
92
|
+
role,
|
|
93
|
+
clientId,
|
|
94
|
+
initialSession,
|
|
95
|
+
autoConnect = true,
|
|
96
|
+
onMessage,
|
|
97
|
+
onTyping,
|
|
98
|
+
onConnectionChange,
|
|
99
|
+
} = options;
|
|
100
|
+
|
|
101
|
+
const [session, setSession] = useState<ChatSession | null>(null);
|
|
71
102
|
const [isConnected, setIsConnected] = useState(false);
|
|
72
103
|
const [isConnecting, setIsConnecting] = useState(false);
|
|
73
|
-
const [activeChannelId, setActiveChannelIdState] = useState<string | null>(
|
|
104
|
+
const [activeChannelId, setActiveChannelIdState] = useState<string | null>(
|
|
105
|
+
null,
|
|
106
|
+
);
|
|
74
107
|
const [channels, setChannels] = useState<ChannelListItem[]>([]);
|
|
75
108
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
76
|
-
const [typingUsers, setTypingUsers] = useState<Record<string, TypingUser[]>>(
|
|
109
|
+
const [typingUsers, setTypingUsers] = useState<Record<string, TypingUser[]>>(
|
|
110
|
+
{},
|
|
111
|
+
);
|
|
77
112
|
const [isLoadingChannels, setIsLoadingChannels] = useState(false);
|
|
78
113
|
const [isLoadingMessages, setIsLoadingMessages] = useState(false);
|
|
79
114
|
const [hasMoreMessages, setHasMoreMessages] = useState(true);
|
|
@@ -87,34 +122,30 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|
|
87
122
|
const isManualDisconnect = useRef(false);
|
|
88
123
|
const oldestMessageId = useRef<string | null>(null);
|
|
89
124
|
const activeChannelIdRef = useRef<string | null>(null);
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
getAccessToken: async () => sessionRef.current?.access_token || "",
|
|
98
|
-
});
|
|
99
|
-
}
|
|
125
|
+
const sessionRef = useRef<ChatSession | null>(null);
|
|
126
|
+
const roleRef = useRef<string | undefined>(undefined);
|
|
127
|
+
const clientIdRef = useRef<string | undefined>(undefined);
|
|
128
|
+
const autoConnectRef = useRef(true);
|
|
129
|
+
const onMessageRef = useRef<(message: Message) => void | undefined>(undefined);
|
|
130
|
+
const onTypingRef = useRef<((channelId: string, user: TypingUser) => void) | undefined>(undefined);
|
|
131
|
+
const onConnectionChangeRef = useRef<((connected: boolean) => void) | undefined>(undefined);
|
|
100
132
|
|
|
101
133
|
useEffect(() => {
|
|
102
|
-
|
|
103
|
-
}, [
|
|
134
|
+
activeChannelIdRef.current = activeChannelId;
|
|
135
|
+
}, [activeChannelId]);
|
|
104
136
|
|
|
105
137
|
useEffect(() => {
|
|
106
138
|
activeChannelIdRef.current = activeChannelId;
|
|
107
139
|
}, [activeChannelId]);
|
|
108
140
|
|
|
109
|
-
|
|
110
141
|
const getActiveChannelId = useCallback((): string | null => {
|
|
111
|
-
if (typeof window ===
|
|
142
|
+
if (typeof window === "undefined") return null;
|
|
112
143
|
return sessionStorage.getItem(SESSION_STORAGE_KEY);
|
|
113
144
|
}, []);
|
|
114
145
|
|
|
115
146
|
const setActiveChannelId = useCallback((id: string | null) => {
|
|
116
147
|
setActiveChannelIdState(id);
|
|
117
|
-
if (typeof window !==
|
|
148
|
+
if (typeof window !== "undefined") {
|
|
118
149
|
if (id) {
|
|
119
150
|
sessionStorage.setItem(SESSION_STORAGE_KEY, id);
|
|
120
151
|
} else {
|
|
@@ -123,29 +154,32 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|
|
123
154
|
}
|
|
124
155
|
}, []);
|
|
125
156
|
|
|
126
|
-
const fetchFromComms = useCallback(
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
157
|
+
const fetchFromComms = useCallback(
|
|
158
|
+
async <T>(path: string, fetchOptions: RequestInit = {}): Promise<T> => {
|
|
159
|
+
const currentSession = sessionRef.current;
|
|
160
|
+
if (!currentSession) {
|
|
161
|
+
throw new Error("Chat session not initialized");
|
|
162
|
+
}
|
|
131
163
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (!response.ok) {
|
|
142
|
-
const error = await response.json().catch(() => ({}));
|
|
143
|
-
throw new Error(error.message || `HTTP ${response.status}`);
|
|
144
|
-
}
|
|
164
|
+
const response = await fetch(`${currentSession.api_url}${path}`, {
|
|
165
|
+
...fetchOptions,
|
|
166
|
+
headers: {
|
|
167
|
+
"Content-Type": "application/json",
|
|
168
|
+
Authorization: `Bearer ${currentSession.access_token}`,
|
|
169
|
+
...fetchOptions.headers,
|
|
170
|
+
},
|
|
171
|
+
});
|
|
145
172
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
const error = await response.json().catch(() => ({}));
|
|
175
|
+
throw new Error(error.message || `HTTP ${response.status}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const data = await response.json();
|
|
179
|
+
return data.data || data;
|
|
180
|
+
},
|
|
181
|
+
[],
|
|
182
|
+
);
|
|
149
183
|
|
|
150
184
|
const clearTimers = useCallback(() => {
|
|
151
185
|
if (reconnectTimeout.current) {
|
|
@@ -158,119 +192,172 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|
|
158
192
|
}
|
|
159
193
|
}, []);
|
|
160
194
|
|
|
161
|
-
const handleWebSocketMessage = useCallback(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
195
|
+
const handleWebSocketMessage = useCallback(
|
|
196
|
+
(data: { type: string; payload: unknown }) => {
|
|
197
|
+
const currentActiveChannelId = activeChannelIdRef.current;
|
|
198
|
+
console.log("[AegisChat] WebSocket message received:", data.type, data);
|
|
199
|
+
|
|
200
|
+
switch (data.type) {
|
|
201
|
+
case "message.new": {
|
|
202
|
+
const newMessage = data.payload as Message;
|
|
203
|
+
if (newMessage.channel_id === currentActiveChannelId) {
|
|
204
|
+
setMessages((prev) => {
|
|
205
|
+
const existingIndex = prev.findIndex(
|
|
206
|
+
(m) =>
|
|
207
|
+
m.tempId &&
|
|
208
|
+
m.content === newMessage.content &&
|
|
209
|
+
m.status === "sending",
|
|
210
|
+
);
|
|
211
|
+
if (existingIndex !== -1) {
|
|
212
|
+
const updated = [...prev];
|
|
213
|
+
updated[existingIndex] = { ...newMessage, status: "sent" };
|
|
214
|
+
return updated;
|
|
215
|
+
}
|
|
216
|
+
if (prev.some((m) => m.id === newMessage.id)) return prev;
|
|
217
|
+
return [...prev, { ...newMessage, status: "delivered" }];
|
|
218
|
+
});
|
|
219
|
+
onMessageRef.current?.(newMessage);
|
|
220
|
+
}
|
|
221
|
+
setChannels((prev) => {
|
|
222
|
+
const updated = prev.map((ch) =>
|
|
223
|
+
ch.id === newMessage.channel_id
|
|
224
|
+
? {
|
|
225
|
+
...ch,
|
|
226
|
+
last_message: {
|
|
227
|
+
id: newMessage.id,
|
|
228
|
+
content: newMessage.content,
|
|
229
|
+
created_at: newMessage.created_at,
|
|
230
|
+
sender: {
|
|
231
|
+
id: newMessage.sender_id,
|
|
232
|
+
display_name: "Unknown",
|
|
233
|
+
status: "online" as const,
|
|
234
|
+
},
|
|
235
|
+
} as MessageSummary,
|
|
236
|
+
unread_count:
|
|
237
|
+
ch.id === currentActiveChannelId
|
|
238
|
+
? 0
|
|
239
|
+
: ch.unread_count + 1,
|
|
240
|
+
}
|
|
241
|
+
: ch,
|
|
172
242
|
);
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
return
|
|
177
|
-
}
|
|
178
|
-
if (prev.some((m) => m.id === newMessage.id)) return prev;
|
|
179
|
-
return [...prev, { ...newMessage, status: 'delivered' }];
|
|
243
|
+
return updated.sort((a, b) => {
|
|
244
|
+
const timeA = a.last_message?.created_at || "";
|
|
245
|
+
const timeB = b.last_message?.created_at || "";
|
|
246
|
+
return timeB.localeCompare(timeA);
|
|
247
|
+
});
|
|
180
248
|
});
|
|
181
|
-
|
|
249
|
+
break;
|
|
182
250
|
}
|
|
183
|
-
|
|
184
|
-
const
|
|
185
|
-
ch.id === newMessage.channel_id
|
|
186
|
-
? {
|
|
187
|
-
...ch,
|
|
188
|
-
last_message: {
|
|
189
|
-
id: newMessage.id,
|
|
190
|
-
content: newMessage.content,
|
|
191
|
-
created_at: newMessage.created_at,
|
|
192
|
-
sender: { id: newMessage.sender_id, display_name: 'Unknown', status: 'online' as const },
|
|
193
|
-
} as MessageSummary,
|
|
194
|
-
unread_count: ch.id === currentActiveChannelId ? 0 : ch.unread_count + 1,
|
|
195
|
-
}
|
|
196
|
-
: ch
|
|
197
|
-
);
|
|
198
|
-
return updated.sort((a, b) => {
|
|
199
|
-
const timeA = a.last_message?.created_at || '';
|
|
200
|
-
const timeB = b.last_message?.created_at || '';
|
|
201
|
-
return timeB.localeCompare(timeA);
|
|
202
|
-
});
|
|
203
|
-
});
|
|
204
|
-
break;
|
|
205
|
-
}
|
|
206
|
-
case 'message.updated': {
|
|
207
|
-
const updatedMessage = data.payload as Message;
|
|
208
|
-
setMessages((prev) => prev.map((m) => (m.id === updatedMessage.id ? updatedMessage : m)));
|
|
209
|
-
break;
|
|
210
|
-
}
|
|
211
|
-
case 'message.deleted': {
|
|
212
|
-
const { message_id } = data.payload as { message_id: string };
|
|
213
|
-
setMessages((prev) => prev.map((m) => (m.id === message_id ? { ...m, deleted: true } : m)));
|
|
214
|
-
break;
|
|
215
|
-
}
|
|
216
|
-
case 'message.delivered':
|
|
217
|
-
case 'message.read': {
|
|
218
|
-
const { message_id, channel_id, status } = data.payload as { message_id: string; channel_id: string; status: string };
|
|
219
|
-
if (channel_id === currentActiveChannelId) {
|
|
251
|
+
case "message.updated": {
|
|
252
|
+
const updatedMessage = data.payload as Message;
|
|
220
253
|
setMessages((prev) =>
|
|
221
|
-
prev.map((m) => (m.id ===
|
|
254
|
+
prev.map((m) => (m.id === updatedMessage.id ? updatedMessage : m)),
|
|
222
255
|
);
|
|
256
|
+
break;
|
|
223
257
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
case 'message.delivered.batch':
|
|
227
|
-
case 'message.read.batch': {
|
|
228
|
-
const { channel_id } = data.payload as { channel_id: string };
|
|
229
|
-
if (channel_id === currentActiveChannelId) {
|
|
258
|
+
case "message.deleted": {
|
|
259
|
+
const { message_id } = data.payload as { message_id: string };
|
|
230
260
|
setMessages((prev) =>
|
|
231
|
-
prev.map((m) =>
|
|
261
|
+
prev.map((m) =>
|
|
262
|
+
m.id === message_id ? { ...m, deleted: true } : m,
|
|
263
|
+
),
|
|
232
264
|
);
|
|
265
|
+
break;
|
|
233
266
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
267
|
+
case "message.delivered":
|
|
268
|
+
case "message.read": {
|
|
269
|
+
const { message_id, channel_id, status } = data.payload as {
|
|
270
|
+
message_id: string;
|
|
271
|
+
channel_id: string;
|
|
272
|
+
status: string;
|
|
273
|
+
};
|
|
274
|
+
if (channel_id === currentActiveChannelId) {
|
|
275
|
+
setMessages((prev) =>
|
|
276
|
+
prev.map((m) =>
|
|
277
|
+
m.id === message_id
|
|
278
|
+
? {
|
|
279
|
+
...m,
|
|
280
|
+
status: (status as Message["status"]) || "delivered",
|
|
281
|
+
}
|
|
282
|
+
: m,
|
|
283
|
+
),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
break;
|
|
287
|
+
}
|
|
288
|
+
case "message.delivered.batch":
|
|
289
|
+
case "message.read.batch": {
|
|
290
|
+
const { channel_id } = data.payload as { channel_id: string };
|
|
291
|
+
if (channel_id === currentActiveChannelId) {
|
|
292
|
+
setMessages((prev) =>
|
|
293
|
+
prev.map((m) =>
|
|
294
|
+
m.status === "sent" || m.status === "delivered"
|
|
295
|
+
? {
|
|
296
|
+
...m,
|
|
297
|
+
status:
|
|
298
|
+
data.type === "message.delivered.batch"
|
|
299
|
+
? "delivered"
|
|
300
|
+
: "read",
|
|
301
|
+
}
|
|
302
|
+
: m,
|
|
303
|
+
),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
case "typing.start": {
|
|
309
|
+
const { channel_id, user } = data.payload as {
|
|
310
|
+
channel_id: string;
|
|
311
|
+
user: UserSummary;
|
|
312
|
+
};
|
|
313
|
+
const typingUser: TypingUser = {
|
|
314
|
+
id: user.id,
|
|
315
|
+
displayName: user.display_name,
|
|
316
|
+
avatarUrl: user.avatar_url,
|
|
317
|
+
startedAt: Date.now(),
|
|
318
|
+
};
|
|
319
|
+
setTypingUsers((prev) => ({
|
|
320
|
+
...prev,
|
|
321
|
+
[channel_id]: [
|
|
322
|
+
...(prev[channel_id] || []).filter((u) => u.id !== user.id),
|
|
323
|
+
typingUser,
|
|
324
|
+
],
|
|
325
|
+
}));
|
|
326
|
+
onTypingRef.current?.(channel_id, typingUser);
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
case "typing.stop": {
|
|
330
|
+
const { channel_id, user_id } = data.payload as {
|
|
331
|
+
channel_id: string;
|
|
332
|
+
user_id: string;
|
|
333
|
+
};
|
|
334
|
+
setTypingUsers((prev) => ({
|
|
335
|
+
...prev,
|
|
336
|
+
[channel_id]: (prev[channel_id] || []).filter(
|
|
337
|
+
(u) => u.id !== user_id,
|
|
338
|
+
),
|
|
339
|
+
}));
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
case "pong":
|
|
343
|
+
break;
|
|
344
|
+
default:
|
|
345
|
+
console.log("[AegisChat] Unhandled message type:", data.type);
|
|
258
346
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
console.log('[AegisChat] Unhandled message type:', data.type);
|
|
263
|
-
}
|
|
264
|
-
}, [onMessage, onTyping]);
|
|
347
|
+
},
|
|
348
|
+
[],
|
|
349
|
+
);
|
|
265
350
|
|
|
266
351
|
const connectWebSocket = useCallback(() => {
|
|
267
352
|
const currentSession = sessionRef.current;
|
|
268
353
|
if (!currentSession?.websocket_url || !currentSession?.access_token) {
|
|
269
|
-
console.warn(
|
|
354
|
+
console.warn(
|
|
355
|
+
"[AegisChat] Cannot connect WebSocket - missing session or token",
|
|
356
|
+
);
|
|
270
357
|
return;
|
|
271
358
|
}
|
|
272
359
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
273
|
-
console.log(
|
|
360
|
+
console.log("[AegisChat] WebSocket already open, skipping connection");
|
|
274
361
|
return;
|
|
275
362
|
}
|
|
276
363
|
|
|
@@ -278,24 +365,29 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|
|
278
365
|
isManualDisconnect.current = false;
|
|
279
366
|
|
|
280
367
|
const wsUrl = `${currentSession.websocket_url}?token=${currentSession.access_token}`;
|
|
281
|
-
console.log(
|
|
368
|
+
console.log("[AegisChat] Creating WebSocket connection to:", wsUrl);
|
|
282
369
|
const ws = new WebSocket(wsUrl);
|
|
283
370
|
|
|
284
371
|
ws.onopen = () => {
|
|
285
|
-
console.log(
|
|
372
|
+
console.log("[AegisChat] WebSocket connected");
|
|
286
373
|
setIsConnected(true);
|
|
287
374
|
setIsConnecting(false);
|
|
288
375
|
reconnectAttempts.current = 0;
|
|
289
|
-
|
|
376
|
+
onConnectionChangeRef.current?.(true);
|
|
290
377
|
|
|
291
378
|
pingInterval.current = setInterval(() => {
|
|
292
379
|
if (ws.readyState === WebSocket.OPEN) {
|
|
293
|
-
ws.send(JSON.stringify({ type:
|
|
380
|
+
ws.send(JSON.stringify({ type: "ping" }));
|
|
294
381
|
}
|
|
295
382
|
}, PING_INTERVAL);
|
|
296
383
|
|
|
297
384
|
if (activeChannelIdRef.current) {
|
|
298
|
-
ws.send(
|
|
385
|
+
ws.send(
|
|
386
|
+
JSON.stringify({
|
|
387
|
+
type: "channel.join",
|
|
388
|
+
payload: { channel_id: activeChannelIdRef.current },
|
|
389
|
+
}),
|
|
390
|
+
);
|
|
299
391
|
}
|
|
300
392
|
};
|
|
301
393
|
|
|
@@ -304,19 +396,25 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|
|
304
396
|
const data = JSON.parse(event.data);
|
|
305
397
|
handleWebSocketMessage(data);
|
|
306
398
|
} catch (error) {
|
|
307
|
-
console.error(
|
|
399
|
+
console.error("[AegisChat] Failed to parse WebSocket message:", error);
|
|
308
400
|
}
|
|
309
401
|
};
|
|
310
402
|
|
|
311
403
|
ws.onclose = () => {
|
|
312
|
-
console.log(
|
|
404
|
+
console.log("[AegisChat] WebSocket disconnected");
|
|
313
405
|
setIsConnected(false);
|
|
314
406
|
setIsConnecting(false);
|
|
315
407
|
clearTimers();
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
if (
|
|
319
|
-
|
|
408
|
+
onConnectionChangeRef.current?.(false);
|
|
409
|
+
|
|
410
|
+
if (
|
|
411
|
+
!isManualDisconnect.current &&
|
|
412
|
+
reconnectAttempts.current < MAX_RECONNECT_ATTEMPTS
|
|
413
|
+
) {
|
|
414
|
+
const delay = Math.min(
|
|
415
|
+
RECONNECT_INTERVAL * Math.pow(2, reconnectAttempts.current),
|
|
416
|
+
MAX_RECONNECT_DELAY,
|
|
417
|
+
);
|
|
320
418
|
console.log(`[AegisChat] Reconnecting in ${delay}ms...`);
|
|
321
419
|
reconnectTimeout.current = setTimeout(() => {
|
|
322
420
|
reconnectAttempts.current++;
|
|
@@ -326,17 +424,21 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|
|
326
424
|
};
|
|
327
425
|
|
|
328
426
|
ws.onerror = (error) => {
|
|
329
|
-
console.error(
|
|
427
|
+
console.error("[AegisChat] WebSocket error:", error);
|
|
330
428
|
};
|
|
331
429
|
|
|
332
430
|
wsRef.current = ws;
|
|
333
|
-
}, [clearTimers, handleWebSocketMessage
|
|
431
|
+
}, [clearTimers, handleWebSocketMessage]);
|
|
334
432
|
|
|
335
433
|
const connect = useCallback(async () => {
|
|
336
|
-
console.log(
|
|
337
|
-
const targetSession = sessionRef.current
|
|
434
|
+
console.log("[AegisChat] connect() called");
|
|
435
|
+
const targetSession = sessionRef.current;
|
|
338
436
|
if (!targetSession) {
|
|
339
|
-
console.log(
|
|
437
|
+
console.log("[AegisChat] No session available, skipping connect");
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (!autoConnectRef.current) {
|
|
441
|
+
console.log("[AegisChat] autoConnect is false, skipping connect");
|
|
340
442
|
return;
|
|
341
443
|
}
|
|
342
444
|
connectWebSocket();
|
|
@@ -364,239 +466,401 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|
|
364
466
|
const response = await channelsApi.list({});
|
|
365
467
|
setChannels(response.data.channels || []);
|
|
366
468
|
} catch (error) {
|
|
367
|
-
console.error(
|
|
469
|
+
console.error("[AegisChat] Failed to fetch channels:", error);
|
|
368
470
|
} finally {
|
|
369
471
|
setIsLoadingChannels(false);
|
|
370
472
|
}
|
|
371
473
|
}, []);
|
|
372
474
|
|
|
373
|
-
const selectChannel = useCallback(
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
if (
|
|
382
|
-
|
|
475
|
+
const selectChannel = useCallback(
|
|
476
|
+
async (channelId: string) => {
|
|
477
|
+
const currentActiveChannelId = activeChannelIdRef.current;
|
|
478
|
+
setActiveChannelId(channelId);
|
|
479
|
+
setMessages([]);
|
|
480
|
+
setHasMoreMessages(true);
|
|
481
|
+
oldestMessageId.current = null;
|
|
482
|
+
|
|
483
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
484
|
+
if (currentActiveChannelId) {
|
|
485
|
+
wsRef.current.send(
|
|
486
|
+
JSON.stringify({
|
|
487
|
+
type: "channel.leave",
|
|
488
|
+
payload: { channel_id: currentActiveChannelId },
|
|
489
|
+
}),
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
wsRef.current.send(
|
|
493
|
+
JSON.stringify({
|
|
494
|
+
type: "channel.join",
|
|
495
|
+
payload: { channel_id: channelId },
|
|
496
|
+
}),
|
|
497
|
+
);
|
|
383
498
|
}
|
|
384
|
-
wsRef.current.send(JSON.stringify({ type: 'channel.join', payload: { channel_id: channelId } }));
|
|
385
|
-
}
|
|
386
499
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
500
|
+
setIsLoadingMessages(true);
|
|
501
|
+
try {
|
|
502
|
+
const response = await fetchFromComms<MessagesResponse>(
|
|
503
|
+
`/channels/${channelId}/messages?limit=50`,
|
|
504
|
+
);
|
|
505
|
+
setMessages(response.messages || []);
|
|
506
|
+
setHasMoreMessages(response.has_more);
|
|
507
|
+
if (response.oldest_id) {
|
|
508
|
+
oldestMessageId.current = response.oldest_id;
|
|
509
|
+
}
|
|
395
510
|
|
|
396
|
-
|
|
511
|
+
await markAsRead(channelId);
|
|
397
512
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
513
|
+
setChannels((prev) =>
|
|
514
|
+
prev.map((ch) =>
|
|
515
|
+
ch.id === channelId ? { ...ch, unread_count: 0 } : ch,
|
|
516
|
+
),
|
|
517
|
+
);
|
|
518
|
+
} catch (error) {
|
|
519
|
+
console.error("[AegisChat] Failed to load messages:", error);
|
|
520
|
+
setMessages([]);
|
|
521
|
+
} finally {
|
|
522
|
+
setIsLoadingMessages(false);
|
|
523
|
+
}
|
|
524
|
+
},
|
|
525
|
+
[setActiveChannelId, fetchFromComms],
|
|
526
|
+
);
|
|
406
527
|
|
|
407
|
-
const markAsRead = useCallback(
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
528
|
+
const markAsRead = useCallback(
|
|
529
|
+
async (channelId: string) => {
|
|
530
|
+
try {
|
|
531
|
+
await fetchFromComms(`/channels/${channelId}/read`, { method: "POST" });
|
|
532
|
+
} catch (error) {
|
|
533
|
+
console.error("[AegisChat] Failed to mark as read:", error);
|
|
534
|
+
}
|
|
535
|
+
},
|
|
536
|
+
[fetchFromComms],
|
|
537
|
+
);
|
|
414
538
|
|
|
415
539
|
const loadMoreMessages = useCallback(async () => {
|
|
416
540
|
if (!activeChannelId || !hasMoreMessages || isLoadingMessages) return;
|
|
417
541
|
|
|
418
542
|
setIsLoadingMessages(true);
|
|
419
543
|
try {
|
|
420
|
-
const params = oldestMessageId.current
|
|
421
|
-
|
|
544
|
+
const params = oldestMessageId.current
|
|
545
|
+
? `?before=${oldestMessageId.current}&limit=50`
|
|
546
|
+
: "?limit=50";
|
|
547
|
+
const response = await fetchFromComms<MessagesResponse>(
|
|
548
|
+
`/channels/${activeChannelId}/messages${params}`,
|
|
549
|
+
);
|
|
422
550
|
setMessages((prev) => [...(response.messages || []), ...prev]);
|
|
423
551
|
setHasMoreMessages(response.has_more);
|
|
424
552
|
if (response.oldest_id) {
|
|
425
553
|
oldestMessageId.current = response.oldest_id;
|
|
426
554
|
}
|
|
427
555
|
} catch (error) {
|
|
428
|
-
console.error(
|
|
556
|
+
console.error("[AegisChat] Failed to load more messages:", error);
|
|
429
557
|
} finally {
|
|
430
558
|
setIsLoadingMessages(false);
|
|
431
559
|
}
|
|
432
560
|
}, [activeChannelId, hasMoreMessages, isLoadingMessages, fetchFromComms]);
|
|
433
561
|
|
|
434
|
-
const sendMessage = useCallback(
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
tempId,
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
const updated = prev.map((ch) =>
|
|
463
|
-
ch.id === currentActiveChannelId
|
|
464
|
-
? {
|
|
465
|
-
...ch,
|
|
466
|
-
last_message: {
|
|
467
|
-
id: tempId,
|
|
468
|
-
content: trimmedContent,
|
|
469
|
-
created_at: now,
|
|
470
|
-
sender: { id: currentSession.comms_user_id, display_name: 'You', status: 'online' as const },
|
|
471
|
-
},
|
|
472
|
-
}
|
|
473
|
-
: ch
|
|
474
|
-
);
|
|
475
|
-
return updated.sort((a, b) => {
|
|
476
|
-
const timeA = a.last_message?.created_at || '';
|
|
477
|
-
const timeB = b.last_message?.created_at || '';
|
|
478
|
-
return timeB.localeCompare(timeA);
|
|
479
|
-
});
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
try {
|
|
483
|
-
await fetchFromComms<Message>(`/channels/${currentActiveChannelId}/messages`, {
|
|
484
|
-
method: 'POST',
|
|
485
|
-
body: JSON.stringify({ content: trimmedContent, type: msgOptions.type || 'text', parent_id: msgOptions.parent_id, metadata: msgOptions.metadata }),
|
|
486
|
-
});
|
|
487
|
-
} catch (error) {
|
|
488
|
-
console.error('[AegisChat] Failed to send message:', error);
|
|
489
|
-
setMessages((prev) =>
|
|
490
|
-
prev.map((m) =>
|
|
491
|
-
m.tempId === tempId ? { ...m, status: 'failed', errorMessage: error instanceof Error ? error.message : 'Failed to send' } : m
|
|
492
|
-
)
|
|
493
|
-
);
|
|
494
|
-
throw error;
|
|
495
|
-
}
|
|
496
|
-
}, [fetchFromComms]);
|
|
497
|
-
|
|
498
|
-
const uploadFile = useCallback(async (file: File): Promise<FileAttachment | null> => {
|
|
499
|
-
const currentSession = sessionRef.current;
|
|
500
|
-
if (!currentSession) return null;
|
|
501
|
-
|
|
502
|
-
const fileId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
503
|
-
|
|
504
|
-
setUploadProgress((prev) => [...prev, { fileId, fileName: file.name, progress: 0, status: 'pending' }]);
|
|
505
|
-
|
|
506
|
-
try {
|
|
507
|
-
setUploadProgress((prev) => prev.map((p) => p.fileId === fileId ? { ...p, status: 'uploading', progress: 10 } : p));
|
|
562
|
+
const sendMessage = useCallback(
|
|
563
|
+
async (
|
|
564
|
+
content: string,
|
|
565
|
+
msgOptions: {
|
|
566
|
+
type?: string;
|
|
567
|
+
parent_id?: string;
|
|
568
|
+
metadata?: Record<string, unknown>;
|
|
569
|
+
} = {},
|
|
570
|
+
) => {
|
|
571
|
+
const currentActiveChannelId = activeChannelIdRef.current;
|
|
572
|
+
const currentSession = sessionRef.current;
|
|
573
|
+
if (!currentActiveChannelId || !content.trim() || !currentSession) return;
|
|
574
|
+
|
|
575
|
+
const tempId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
576
|
+
const trimmedContent = content.trim();
|
|
577
|
+
|
|
578
|
+
const optimisticMessage: Message = {
|
|
579
|
+
id: tempId,
|
|
580
|
+
tempId,
|
|
581
|
+
channel_id: currentActiveChannelId,
|
|
582
|
+
sender_id: currentSession.comms_user_id,
|
|
583
|
+
content: trimmedContent,
|
|
584
|
+
type: (msgOptions.type as Message["type"]) || "text",
|
|
585
|
+
created_at: new Date().toISOString(),
|
|
586
|
+
updated_at: new Date().toISOString(),
|
|
587
|
+
status: "sending",
|
|
588
|
+
metadata: msgOptions.metadata || {},
|
|
589
|
+
};
|
|
508
590
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
591
|
+
setMessages((prev) => [...prev, optimisticMessage]);
|
|
592
|
+
|
|
593
|
+
const now = new Date().toISOString();
|
|
594
|
+
setChannels((prev) => {
|
|
595
|
+
const updated = prev.map((ch) =>
|
|
596
|
+
ch.id === currentActiveChannelId
|
|
597
|
+
? {
|
|
598
|
+
...ch,
|
|
599
|
+
last_message: {
|
|
600
|
+
id: tempId,
|
|
601
|
+
content: trimmedContent,
|
|
602
|
+
created_at: now,
|
|
603
|
+
sender: {
|
|
604
|
+
id: currentSession.comms_user_id,
|
|
605
|
+
display_name: "You",
|
|
606
|
+
status: "online" as const,
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
}
|
|
610
|
+
: ch,
|
|
611
|
+
);
|
|
612
|
+
return updated.sort((a, b) => {
|
|
613
|
+
const timeA = a.last_message?.created_at || "";
|
|
614
|
+
const timeB = b.last_message?.created_at || "";
|
|
615
|
+
return timeB.localeCompare(timeA);
|
|
616
|
+
});
|
|
512
617
|
});
|
|
513
618
|
|
|
514
|
-
|
|
619
|
+
try {
|
|
620
|
+
await fetchFromComms<Message>(
|
|
621
|
+
`/channels/${currentActiveChannelId}/messages`,
|
|
622
|
+
{
|
|
623
|
+
method: "POST",
|
|
624
|
+
body: JSON.stringify({
|
|
625
|
+
content: trimmedContent,
|
|
626
|
+
type: msgOptions.type || "text",
|
|
627
|
+
parent_id: msgOptions.parent_id,
|
|
628
|
+
metadata: msgOptions.metadata,
|
|
629
|
+
}),
|
|
630
|
+
},
|
|
631
|
+
);
|
|
632
|
+
} catch (error) {
|
|
633
|
+
console.error("[AegisChat] Failed to send message:", error);
|
|
634
|
+
setMessages((prev) =>
|
|
635
|
+
prev.map((m) =>
|
|
636
|
+
m.tempId === tempId
|
|
637
|
+
? {
|
|
638
|
+
...m,
|
|
639
|
+
status: "failed",
|
|
640
|
+
errorMessage:
|
|
641
|
+
error instanceof Error ? error.message : "Failed to send",
|
|
642
|
+
}
|
|
643
|
+
: m,
|
|
644
|
+
),
|
|
645
|
+
);
|
|
646
|
+
throw error;
|
|
647
|
+
}
|
|
648
|
+
},
|
|
649
|
+
[fetchFromComms],
|
|
650
|
+
);
|
|
515
651
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
});
|
|
652
|
+
const uploadFile = useCallback(
|
|
653
|
+
async (file: File): Promise<FileAttachment | null> => {
|
|
654
|
+
const currentSession = sessionRef.current;
|
|
655
|
+
if (!currentSession) return null;
|
|
521
656
|
|
|
522
|
-
|
|
657
|
+
const fileId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
523
658
|
|
|
524
|
-
setUploadProgress((prev) =>
|
|
659
|
+
setUploadProgress((prev) => [
|
|
660
|
+
...prev,
|
|
661
|
+
{ fileId, fileName: file.name, progress: 0, status: "pending" },
|
|
662
|
+
]);
|
|
525
663
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
664
|
+
try {
|
|
665
|
+
setUploadProgress((prev) =>
|
|
666
|
+
prev.map((p) =>
|
|
667
|
+
p.fileId === fileId
|
|
668
|
+
? { ...p, status: "uploading", progress: 10 }
|
|
669
|
+
: p,
|
|
670
|
+
),
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
const uploadUrlResponse = await fetchFromComms<{
|
|
674
|
+
upload_url: string;
|
|
675
|
+
file_id: string;
|
|
676
|
+
expires_at: string;
|
|
677
|
+
}>("/files/upload-url", {
|
|
678
|
+
method: "POST",
|
|
679
|
+
body: JSON.stringify({
|
|
680
|
+
file_name: file.name,
|
|
681
|
+
file_type: file.type || "application/octet-stream",
|
|
682
|
+
file_size: file.size,
|
|
683
|
+
}),
|
|
684
|
+
});
|
|
530
685
|
|
|
531
|
-
|
|
532
|
-
|
|
686
|
+
setUploadProgress((prev) =>
|
|
687
|
+
prev.map((p) =>
|
|
688
|
+
p.fileId === fileId
|
|
689
|
+
? { ...p, fileId: uploadUrlResponse.file_id, progress: 30 }
|
|
690
|
+
: p,
|
|
691
|
+
),
|
|
692
|
+
);
|
|
693
|
+
|
|
694
|
+
const uploadResponse = await fetch(uploadUrlResponse.upload_url, {
|
|
695
|
+
method: "PUT",
|
|
696
|
+
body: file,
|
|
697
|
+
headers: { "Content-Type": file.type || "application/octet-stream" },
|
|
698
|
+
});
|
|
533
699
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
700
|
+
if (!uploadResponse.ok)
|
|
701
|
+
throw new Error(`Upload failed: ${uploadResponse.statusText}`);
|
|
702
|
+
|
|
703
|
+
setUploadProgress((prev) =>
|
|
704
|
+
prev.map((p) =>
|
|
705
|
+
p.fileId === uploadUrlResponse.file_id
|
|
706
|
+
? { ...p, status: "confirming", progress: 70 }
|
|
707
|
+
: p,
|
|
708
|
+
),
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
const confirmResponse = await fetchFromComms<{ file: FileAttachment }>(
|
|
712
|
+
"/files",
|
|
713
|
+
{
|
|
714
|
+
method: "POST",
|
|
715
|
+
body: JSON.stringify({ file_id: uploadUrlResponse.file_id }),
|
|
716
|
+
},
|
|
717
|
+
);
|
|
718
|
+
|
|
719
|
+
setUploadProgress((prev) =>
|
|
720
|
+
prev.map((p) =>
|
|
721
|
+
p.fileId === uploadUrlResponse.file_id
|
|
722
|
+
? { ...p, status: "complete", progress: 100 }
|
|
723
|
+
: p,
|
|
724
|
+
),
|
|
725
|
+
);
|
|
726
|
+
setTimeout(
|
|
727
|
+
() =>
|
|
728
|
+
setUploadProgress((prev) =>
|
|
729
|
+
prev.filter((p) => p.fileId !== uploadUrlResponse.file_id),
|
|
730
|
+
),
|
|
731
|
+
2000,
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
return confirmResponse.file;
|
|
735
|
+
} catch (error) {
|
|
736
|
+
console.error("[AegisChat] Failed to upload file:", error);
|
|
737
|
+
setUploadProgress((prev) =>
|
|
738
|
+
prev.map((p) =>
|
|
739
|
+
p.fileId === fileId
|
|
740
|
+
? {
|
|
741
|
+
...p,
|
|
742
|
+
status: "error",
|
|
743
|
+
error:
|
|
744
|
+
error instanceof Error ? error.message : "Upload failed",
|
|
745
|
+
}
|
|
746
|
+
: p,
|
|
747
|
+
),
|
|
748
|
+
);
|
|
749
|
+
setTimeout(
|
|
750
|
+
() =>
|
|
751
|
+
setUploadProgress((prev) =>
|
|
752
|
+
prev.filter((p) => p.fileId !== fileId),
|
|
753
|
+
),
|
|
754
|
+
5000,
|
|
755
|
+
);
|
|
756
|
+
return null;
|
|
757
|
+
}
|
|
758
|
+
},
|
|
759
|
+
[fetchFromComms],
|
|
760
|
+
);
|
|
761
|
+
|
|
762
|
+
const sendMessageWithFiles = useCallback(
|
|
763
|
+
async (
|
|
764
|
+
content: string,
|
|
765
|
+
files: File[],
|
|
766
|
+
msgOptions: {
|
|
767
|
+
type?: string;
|
|
768
|
+
parent_id?: string;
|
|
769
|
+
metadata?: Record<string, unknown>;
|
|
770
|
+
} = {},
|
|
771
|
+
) => {
|
|
772
|
+
const currentActiveChannelId = activeChannelIdRef.current;
|
|
773
|
+
const currentSession = sessionRef.current;
|
|
774
|
+
if (
|
|
775
|
+
!currentActiveChannelId ||
|
|
776
|
+
(!content.trim() && files.length === 0) ||
|
|
777
|
+
!currentSession
|
|
778
|
+
)
|
|
779
|
+
return;
|
|
780
|
+
|
|
781
|
+
const tempId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
782
|
+
const trimmedContent = content.trim();
|
|
783
|
+
|
|
784
|
+
const optimisticMessage: Message = {
|
|
785
|
+
id: tempId,
|
|
786
|
+
tempId,
|
|
787
|
+
channel_id: currentActiveChannelId,
|
|
788
|
+
sender_id: currentSession.comms_user_id,
|
|
789
|
+
content: trimmedContent || `Uploading ${files.length} file(s)...`,
|
|
790
|
+
type: "file",
|
|
791
|
+
created_at: new Date().toISOString(),
|
|
792
|
+
updated_at: new Date().toISOString(),
|
|
793
|
+
status: "sending",
|
|
794
|
+
metadata: {
|
|
795
|
+
...msgOptions.metadata,
|
|
796
|
+
files: files.map((f) => ({
|
|
797
|
+
id: `temp-${f.name}`,
|
|
798
|
+
filename: f.name,
|
|
799
|
+
mime_type: f.type,
|
|
800
|
+
size: f.size,
|
|
801
|
+
url: "",
|
|
802
|
+
})),
|
|
803
|
+
},
|
|
804
|
+
};
|
|
542
805
|
|
|
543
|
-
|
|
544
|
-
content: string,
|
|
545
|
-
files: File[],
|
|
546
|
-
msgOptions: { type?: string; parent_id?: string; metadata?: Record<string, unknown> } = {}
|
|
547
|
-
) => {
|
|
548
|
-
const currentActiveChannelId = activeChannelIdRef.current;
|
|
549
|
-
const currentSession = sessionRef.current;
|
|
550
|
-
if (!currentActiveChannelId || (!content.trim() && files.length === 0) || !currentSession) return;
|
|
551
|
-
|
|
552
|
-
const tempId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
553
|
-
const trimmedContent = content.trim();
|
|
554
|
-
|
|
555
|
-
const optimisticMessage: Message = {
|
|
556
|
-
id: tempId,
|
|
557
|
-
tempId,
|
|
558
|
-
channel_id: currentActiveChannelId,
|
|
559
|
-
sender_id: currentSession.comms_user_id,
|
|
560
|
-
content: trimmedContent || `Uploading ${files.length} file(s)...`,
|
|
561
|
-
type: 'file',
|
|
562
|
-
created_at: new Date().toISOString(),
|
|
563
|
-
updated_at: new Date().toISOString(),
|
|
564
|
-
status: 'sending',
|
|
565
|
-
metadata: { ...msgOptions.metadata, files: files.map((f) => ({ id: `temp-${f.name}`, filename: f.name, mime_type: f.type, size: f.size, url: '' })) },
|
|
566
|
-
};
|
|
806
|
+
setMessages((prev) => [...prev, optimisticMessage]);
|
|
567
807
|
|
|
568
|
-
|
|
808
|
+
try {
|
|
809
|
+
const uploadedFiles: FileAttachment[] = [];
|
|
810
|
+
for (const file of files) {
|
|
811
|
+
const attachment = await uploadFile(file);
|
|
812
|
+
if (attachment) uploadedFiles.push(attachment);
|
|
813
|
+
}
|
|
569
814
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
815
|
+
const messageType =
|
|
816
|
+
uploadedFiles.length > 0 && !trimmedContent ? "file" : "text";
|
|
817
|
+
|
|
818
|
+
await fetchFromComms<Message>(
|
|
819
|
+
`/channels/${currentActiveChannelId}/messages`,
|
|
820
|
+
{
|
|
821
|
+
method: "POST",
|
|
822
|
+
body: JSON.stringify({
|
|
823
|
+
content:
|
|
824
|
+
trimmedContent ||
|
|
825
|
+
(uploadedFiles.length > 0
|
|
826
|
+
? `Shared ${uploadedFiles.length} file(s)`
|
|
827
|
+
: ""),
|
|
828
|
+
type: msgOptions.type || messageType,
|
|
829
|
+
parent_id: msgOptions.parent_id,
|
|
830
|
+
metadata: { ...msgOptions.metadata, files: uploadedFiles },
|
|
831
|
+
file_ids: uploadedFiles.map((f) => f.id),
|
|
832
|
+
}),
|
|
833
|
+
},
|
|
834
|
+
);
|
|
835
|
+
} catch (error) {
|
|
836
|
+
console.error("[AegisChat] Failed to send message with files:", error);
|
|
837
|
+
setMessages((prev) =>
|
|
838
|
+
prev.map((m) =>
|
|
839
|
+
m.tempId === tempId
|
|
840
|
+
? {
|
|
841
|
+
...m,
|
|
842
|
+
status: "failed",
|
|
843
|
+
errorMessage:
|
|
844
|
+
error instanceof Error ? error.message : "Failed to send",
|
|
845
|
+
}
|
|
846
|
+
: m,
|
|
847
|
+
),
|
|
848
|
+
);
|
|
849
|
+
throw error;
|
|
575
850
|
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
await fetchFromComms<Message>(`/channels/${currentActiveChannelId}/messages`, {
|
|
580
|
-
method: 'POST',
|
|
581
|
-
body: JSON.stringify({
|
|
582
|
-
content: trimmedContent || (uploadedFiles.length > 0 ? `Shared ${uploadedFiles.length} file(s)` : ''),
|
|
583
|
-
type: msgOptions.type || messageType,
|
|
584
|
-
parent_id: msgOptions.parent_id,
|
|
585
|
-
metadata: { ...msgOptions.metadata, files: uploadedFiles },
|
|
586
|
-
file_ids: uploadedFiles.map((f) => f.id),
|
|
587
|
-
}),
|
|
588
|
-
});
|
|
589
|
-
} catch (error) {
|
|
590
|
-
console.error('[AegisChat] Failed to send message with files:', error);
|
|
591
|
-
setMessages((prev) => prev.map((m) => m.tempId === tempId ? { ...m, status: 'failed', errorMessage: error instanceof Error ? error.message : 'Failed to send' } : m));
|
|
592
|
-
throw error;
|
|
593
|
-
}
|
|
594
|
-
}, [fetchFromComms, uploadFile]);
|
|
851
|
+
},
|
|
852
|
+
[fetchFromComms, uploadFile],
|
|
853
|
+
);
|
|
595
854
|
|
|
596
855
|
const stopTyping = useCallback(() => {
|
|
597
856
|
const currentActiveChannelId = activeChannelIdRef.current;
|
|
598
857
|
if (!currentActiveChannelId || !wsRef.current) return;
|
|
599
|
-
wsRef.current.send(
|
|
858
|
+
wsRef.current.send(
|
|
859
|
+
JSON.stringify({
|
|
860
|
+
type: "typing.stop",
|
|
861
|
+
payload: { channel_id: currentActiveChannelId },
|
|
862
|
+
}),
|
|
863
|
+
);
|
|
600
864
|
if (typingTimeout.current) {
|
|
601
865
|
clearTimeout(typingTimeout.current);
|
|
602
866
|
typingTimeout.current = null;
|
|
@@ -606,55 +870,122 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|
|
606
870
|
const startTyping = useCallback(() => {
|
|
607
871
|
const currentActiveChannelId = activeChannelIdRef.current;
|
|
608
872
|
if (!currentActiveChannelId || !wsRef.current) return;
|
|
609
|
-
wsRef.current.send(
|
|
873
|
+
wsRef.current.send(
|
|
874
|
+
JSON.stringify({
|
|
875
|
+
type: "typing.start",
|
|
876
|
+
payload: { channel_id: currentActiveChannelId },
|
|
877
|
+
}),
|
|
878
|
+
);
|
|
610
879
|
if (typingTimeout.current) clearTimeout(typingTimeout.current);
|
|
611
880
|
typingTimeout.current = setTimeout(stopTyping, TYPING_TIMEOUT);
|
|
612
881
|
}, [stopTyping]);
|
|
613
882
|
|
|
614
|
-
const createDMWithUser = useCallback(
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
883
|
+
const createDMWithUser = useCallback(
|
|
884
|
+
async (userId: string): Promise<string | null> => {
|
|
885
|
+
try {
|
|
886
|
+
const channel = await fetchFromComms<{ id: string }>("/channels/dm", {
|
|
887
|
+
method: "POST",
|
|
888
|
+
body: JSON.stringify({ user_id: userId }),
|
|
889
|
+
});
|
|
890
|
+
await refreshChannels();
|
|
891
|
+
return channel.id;
|
|
892
|
+
} catch (error) {
|
|
893
|
+
console.error("[AegisChat] Failed to create DM:", error);
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
},
|
|
897
|
+
[fetchFromComms, refreshChannels],
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
const retryMessage = useCallback(
|
|
901
|
+
async (tempId: string) => {
|
|
902
|
+
const failedMessage = messages.find(
|
|
903
|
+
(m) => m.tempId === tempId && m.status === "failed",
|
|
904
|
+
);
|
|
905
|
+
const currentActiveChannelId = activeChannelIdRef.current;
|
|
906
|
+
if (!failedMessage || !currentActiveChannelId) return;
|
|
632
907
|
|
|
633
|
-
|
|
908
|
+
setMessages((prev) =>
|
|
909
|
+
prev.map((m) =>
|
|
910
|
+
m.tempId === tempId
|
|
911
|
+
? { ...m, status: "sending", errorMessage: undefined }
|
|
912
|
+
: m,
|
|
913
|
+
),
|
|
914
|
+
);
|
|
634
915
|
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
916
|
+
try {
|
|
917
|
+
await fetchFromComms<Message>(
|
|
918
|
+
`/channels/${currentActiveChannelId}/messages`,
|
|
919
|
+
{
|
|
920
|
+
method: "POST",
|
|
921
|
+
body: JSON.stringify({
|
|
922
|
+
content: failedMessage.content,
|
|
923
|
+
type: failedMessage.type,
|
|
924
|
+
metadata: failedMessage.metadata,
|
|
925
|
+
}),
|
|
926
|
+
},
|
|
927
|
+
);
|
|
928
|
+
} catch (error) {
|
|
929
|
+
console.error("[AegisChat] Failed to retry message:", error);
|
|
930
|
+
setMessages((prev) =>
|
|
931
|
+
prev.map((m) =>
|
|
932
|
+
m.tempId === tempId
|
|
933
|
+
? {
|
|
934
|
+
...m,
|
|
935
|
+
status: "failed",
|
|
936
|
+
errorMessage:
|
|
937
|
+
error instanceof Error ? error.message : "Failed to send",
|
|
938
|
+
}
|
|
939
|
+
: m,
|
|
940
|
+
),
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
},
|
|
944
|
+
[messages, fetchFromComms],
|
|
945
|
+
);
|
|
645
946
|
|
|
646
947
|
const deleteFailedMessage = useCallback((tempId: string) => {
|
|
647
948
|
setMessages((prev) => prev.filter((m) => m.tempId !== tempId));
|
|
648
949
|
}, []);
|
|
649
950
|
|
|
650
|
-
|
|
651
|
-
|
|
951
|
+
const setup = useCallback((options: UseChatOptions) => {
|
|
952
|
+
const {
|
|
953
|
+
config,
|
|
954
|
+
role,
|
|
955
|
+
clientId,
|
|
956
|
+
initialSession,
|
|
957
|
+
autoConnect = true,
|
|
958
|
+
onMessage,
|
|
959
|
+
onTyping,
|
|
960
|
+
onConnectionChange,
|
|
961
|
+
} = options;
|
|
962
|
+
|
|
963
|
+
roleRef.current = role;
|
|
964
|
+
clientIdRef.current = clientId;
|
|
965
|
+
autoConnectRef.current = autoConnect;
|
|
966
|
+
onMessageRef.current = onMessage;
|
|
967
|
+
onTypingRef.current = onTyping;
|
|
968
|
+
onConnectionChangeRef.current = onConnectionChange;
|
|
969
|
+
|
|
970
|
+
if (initialSession) {
|
|
971
|
+
sessionRef.current = initialSession;
|
|
972
|
+
|
|
973
|
+
if (!config) {
|
|
974
|
+
configureApiClient({
|
|
975
|
+
baseUrl: initialSession.api_url,
|
|
976
|
+
getAccessToken: async () => sessionRef.current?.access_token || "",
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
setSession(initialSession);
|
|
981
|
+
}
|
|
982
|
+
}, []);
|
|
652
983
|
|
|
653
984
|
useEffect(() => {
|
|
654
|
-
if (session && !isConnected && !isConnecting &&
|
|
985
|
+
if (session && !isConnected && !isConnecting && autoConnectRef.current) {
|
|
655
986
|
connectWebSocket();
|
|
656
987
|
}
|
|
657
|
-
}, [session, isConnected, isConnecting,
|
|
988
|
+
}, [session, isConnected, isConnecting, connectWebSocket]);
|
|
658
989
|
|
|
659
990
|
useEffect(() => {
|
|
660
991
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
|
@@ -663,7 +994,10 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|
|
663
994
|
const data = JSON.parse(event.data);
|
|
664
995
|
handleWebSocketMessage(data);
|
|
665
996
|
} catch (error) {
|
|
666
|
-
console.error(
|
|
997
|
+
console.error(
|
|
998
|
+
"[AegisChat] Failed to parse WebSocket message:",
|
|
999
|
+
error,
|
|
1000
|
+
);
|
|
667
1001
|
}
|
|
668
1002
|
};
|
|
669
1003
|
}
|
|
@@ -720,6 +1054,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|
|
720
1054
|
retryMessage,
|
|
721
1055
|
deleteFailedMessage,
|
|
722
1056
|
markAsRead,
|
|
1057
|
+
setup,
|
|
723
1058
|
};
|
|
724
1059
|
}
|
|
725
1060
|
|