@thrillee/aegischat 0.1.5 → 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 -330
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +568 -330
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/hooks/useChat.ts +721 -389
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,37 +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
|
-
}
|
|
100
|
-
|
|
101
|
-
useEffect(() => {
|
|
102
|
-
configRef.current = config;
|
|
103
|
-
}, [config]);
|
|
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);
|
|
104
132
|
|
|
105
133
|
useEffect(() => {
|
|
106
134
|
activeChannelIdRef.current = activeChannelId;
|
|
107
135
|
}, [activeChannelId]);
|
|
108
136
|
|
|
109
137
|
useEffect(() => {
|
|
110
|
-
|
|
111
|
-
}, [
|
|
138
|
+
activeChannelIdRef.current = activeChannelId;
|
|
139
|
+
}, [activeChannelId]);
|
|
112
140
|
|
|
113
141
|
const getActiveChannelId = useCallback((): string | null => {
|
|
114
|
-
if (typeof window ===
|
|
142
|
+
if (typeof window === "undefined") return null;
|
|
115
143
|
return sessionStorage.getItem(SESSION_STORAGE_KEY);
|
|
116
144
|
}, []);
|
|
117
145
|
|
|
118
146
|
const setActiveChannelId = useCallback((id: string | null) => {
|
|
119
147
|
setActiveChannelIdState(id);
|
|
120
|
-
if (typeof window !==
|
|
148
|
+
if (typeof window !== "undefined") {
|
|
121
149
|
if (id) {
|
|
122
150
|
sessionStorage.setItem(SESSION_STORAGE_KEY, id);
|
|
123
151
|
} else {
|
|
@@ -126,29 +154,32 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|
|
126
154
|
}
|
|
127
155
|
}, []);
|
|
128
156
|
|
|
129
|
-
const fetchFromComms = useCallback(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
+
}
|
|
134
163
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
if (!response.ok) {
|
|
145
|
-
const error = await response.json().catch(() => ({}));
|
|
146
|
-
throw new Error(error.message || `HTTP ${response.status}`);
|
|
147
|
-
}
|
|
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
|
+
});
|
|
148
172
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
+
);
|
|
152
183
|
|
|
153
184
|
const clearTimers = useCallback(() => {
|
|
154
185
|
if (reconnectTimeout.current) {
|
|
@@ -161,119 +192,172 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|
|
161
192
|
}
|
|
162
193
|
}, []);
|
|
163
194
|
|
|
164
|
-
const handleWebSocketMessage = useCallback(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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,
|
|
175
242
|
);
|
|
176
|
-
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
return
|
|
180
|
-
}
|
|
181
|
-
if (prev.some((m) => m.id === newMessage.id)) return prev;
|
|
182
|
-
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
|
+
});
|
|
183
248
|
});
|
|
184
|
-
|
|
249
|
+
break;
|
|
185
250
|
}
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
ch.id === newMessage.channel_id
|
|
189
|
-
? {
|
|
190
|
-
...ch,
|
|
191
|
-
last_message: {
|
|
192
|
-
id: newMessage.id,
|
|
193
|
-
content: newMessage.content,
|
|
194
|
-
created_at: newMessage.created_at,
|
|
195
|
-
sender: { id: newMessage.sender_id, display_name: 'Unknown', status: 'online' as const },
|
|
196
|
-
} as MessageSummary,
|
|
197
|
-
unread_count: ch.id === currentActiveChannelId ? 0 : ch.unread_count + 1,
|
|
198
|
-
}
|
|
199
|
-
: ch
|
|
200
|
-
);
|
|
201
|
-
return updated.sort((a, b) => {
|
|
202
|
-
const timeA = a.last_message?.created_at || '';
|
|
203
|
-
const timeB = b.last_message?.created_at || '';
|
|
204
|
-
return timeB.localeCompare(timeA);
|
|
205
|
-
});
|
|
206
|
-
});
|
|
207
|
-
break;
|
|
208
|
-
}
|
|
209
|
-
case 'message.updated': {
|
|
210
|
-
const updatedMessage = data.payload as Message;
|
|
211
|
-
setMessages((prev) => prev.map((m) => (m.id === updatedMessage.id ? updatedMessage : m)));
|
|
212
|
-
break;
|
|
213
|
-
}
|
|
214
|
-
case 'message.deleted': {
|
|
215
|
-
const { message_id } = data.payload as { message_id: string };
|
|
216
|
-
setMessages((prev) => prev.map((m) => (m.id === message_id ? { ...m, deleted: true } : m)));
|
|
217
|
-
break;
|
|
218
|
-
}
|
|
219
|
-
case 'message.delivered':
|
|
220
|
-
case 'message.read': {
|
|
221
|
-
const { message_id, channel_id, status } = data.payload as { message_id: string; channel_id: string; status: string };
|
|
222
|
-
if (channel_id === currentActiveChannelId) {
|
|
251
|
+
case "message.updated": {
|
|
252
|
+
const updatedMessage = data.payload as Message;
|
|
223
253
|
setMessages((prev) =>
|
|
224
|
-
prev.map((m) => (m.id ===
|
|
254
|
+
prev.map((m) => (m.id === updatedMessage.id ? updatedMessage : m)),
|
|
225
255
|
);
|
|
256
|
+
break;
|
|
226
257
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
case 'message.delivered.batch':
|
|
230
|
-
case 'message.read.batch': {
|
|
231
|
-
const { channel_id } = data.payload as { channel_id: string };
|
|
232
|
-
if (channel_id === currentActiveChannelId) {
|
|
258
|
+
case "message.deleted": {
|
|
259
|
+
const { message_id } = data.payload as { message_id: string };
|
|
233
260
|
setMessages((prev) =>
|
|
234
|
-
prev.map((m) =>
|
|
261
|
+
prev.map((m) =>
|
|
262
|
+
m.id === message_id ? { ...m, deleted: true } : m,
|
|
263
|
+
),
|
|
235
264
|
);
|
|
265
|
+
break;
|
|
236
266
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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);
|
|
261
346
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
console.log('[AegisChat] Unhandled message type:', data.type);
|
|
266
|
-
}
|
|
267
|
-
}, [onMessage, onTyping]);
|
|
347
|
+
},
|
|
348
|
+
[],
|
|
349
|
+
);
|
|
268
350
|
|
|
269
351
|
const connectWebSocket = useCallback(() => {
|
|
270
352
|
const currentSession = sessionRef.current;
|
|
271
353
|
if (!currentSession?.websocket_url || !currentSession?.access_token) {
|
|
272
|
-
console.warn(
|
|
354
|
+
console.warn(
|
|
355
|
+
"[AegisChat] Cannot connect WebSocket - missing session or token",
|
|
356
|
+
);
|
|
273
357
|
return;
|
|
274
358
|
}
|
|
275
359
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
276
|
-
console.log(
|
|
360
|
+
console.log("[AegisChat] WebSocket already open, skipping connection");
|
|
277
361
|
return;
|
|
278
362
|
}
|
|
279
363
|
|
|
@@ -281,24 +365,29 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|
|
281
365
|
isManualDisconnect.current = false;
|
|
282
366
|
|
|
283
367
|
const wsUrl = `${currentSession.websocket_url}?token=${currentSession.access_token}`;
|
|
284
|
-
console.log(
|
|
368
|
+
console.log("[AegisChat] Creating WebSocket connection to:", wsUrl);
|
|
285
369
|
const ws = new WebSocket(wsUrl);
|
|
286
370
|
|
|
287
371
|
ws.onopen = () => {
|
|
288
|
-
console.log(
|
|
372
|
+
console.log("[AegisChat] WebSocket connected");
|
|
289
373
|
setIsConnected(true);
|
|
290
374
|
setIsConnecting(false);
|
|
291
375
|
reconnectAttempts.current = 0;
|
|
292
|
-
|
|
376
|
+
onConnectionChangeRef.current?.(true);
|
|
293
377
|
|
|
294
378
|
pingInterval.current = setInterval(() => {
|
|
295
379
|
if (ws.readyState === WebSocket.OPEN) {
|
|
296
|
-
ws.send(JSON.stringify({ type:
|
|
380
|
+
ws.send(JSON.stringify({ type: "ping" }));
|
|
297
381
|
}
|
|
298
382
|
}, PING_INTERVAL);
|
|
299
383
|
|
|
300
384
|
if (activeChannelIdRef.current) {
|
|
301
|
-
ws.send(
|
|
385
|
+
ws.send(
|
|
386
|
+
JSON.stringify({
|
|
387
|
+
type: "channel.join",
|
|
388
|
+
payload: { channel_id: activeChannelIdRef.current },
|
|
389
|
+
}),
|
|
390
|
+
);
|
|
302
391
|
}
|
|
303
392
|
};
|
|
304
393
|
|
|
@@ -307,19 +396,25 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|
|
307
396
|
const data = JSON.parse(event.data);
|
|
308
397
|
handleWebSocketMessage(data);
|
|
309
398
|
} catch (error) {
|
|
310
|
-
console.error(
|
|
399
|
+
console.error("[AegisChat] Failed to parse WebSocket message:", error);
|
|
311
400
|
}
|
|
312
401
|
};
|
|
313
402
|
|
|
314
403
|
ws.onclose = () => {
|
|
315
|
-
console.log(
|
|
404
|
+
console.log("[AegisChat] WebSocket disconnected");
|
|
316
405
|
setIsConnected(false);
|
|
317
406
|
setIsConnecting(false);
|
|
318
407
|
clearTimers();
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (
|
|
322
|
-
|
|
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
|
+
);
|
|
323
418
|
console.log(`[AegisChat] Reconnecting in ${delay}ms...`);
|
|
324
419
|
reconnectTimeout.current = setTimeout(() => {
|
|
325
420
|
reconnectAttempts.current++;
|
|
@@ -329,17 +424,21 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|
|
329
424
|
};
|
|
330
425
|
|
|
331
426
|
ws.onerror = (error) => {
|
|
332
|
-
console.error(
|
|
427
|
+
console.error("[AegisChat] WebSocket error:", error);
|
|
333
428
|
};
|
|
334
429
|
|
|
335
430
|
wsRef.current = ws;
|
|
336
|
-
}, [clearTimers, handleWebSocketMessage
|
|
431
|
+
}, [clearTimers, handleWebSocketMessage]);
|
|
337
432
|
|
|
338
433
|
const connect = useCallback(async () => {
|
|
339
|
-
console.log(
|
|
340
|
-
const targetSession = sessionRef.current
|
|
434
|
+
console.log("[AegisChat] connect() called");
|
|
435
|
+
const targetSession = sessionRef.current;
|
|
341
436
|
if (!targetSession) {
|
|
342
|
-
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");
|
|
343
442
|
return;
|
|
344
443
|
}
|
|
345
444
|
connectWebSocket();
|
|
@@ -367,239 +466,401 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|
|
367
466
|
const response = await channelsApi.list({});
|
|
368
467
|
setChannels(response.data.channels || []);
|
|
369
468
|
} catch (error) {
|
|
370
|
-
console.error(
|
|
469
|
+
console.error("[AegisChat] Failed to fetch channels:", error);
|
|
371
470
|
} finally {
|
|
372
471
|
setIsLoadingChannels(false);
|
|
373
472
|
}
|
|
374
473
|
}, []);
|
|
375
474
|
|
|
376
|
-
const selectChannel = useCallback(
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
if (
|
|
385
|
-
|
|
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
|
+
);
|
|
386
498
|
}
|
|
387
|
-
wsRef.current.send(JSON.stringify({ type: 'channel.join', payload: { channel_id: channelId } }));
|
|
388
|
-
}
|
|
389
499
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
+
}
|
|
398
510
|
|
|
399
|
-
|
|
511
|
+
await markAsRead(channelId);
|
|
400
512
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
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
|
+
);
|
|
409
527
|
|
|
410
|
-
const markAsRead = useCallback(
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
+
);
|
|
417
538
|
|
|
418
539
|
const loadMoreMessages = useCallback(async () => {
|
|
419
540
|
if (!activeChannelId || !hasMoreMessages || isLoadingMessages) return;
|
|
420
541
|
|
|
421
542
|
setIsLoadingMessages(true);
|
|
422
543
|
try {
|
|
423
|
-
const params = oldestMessageId.current
|
|
424
|
-
|
|
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
|
+
);
|
|
425
550
|
setMessages((prev) => [...(response.messages || []), ...prev]);
|
|
426
551
|
setHasMoreMessages(response.has_more);
|
|
427
552
|
if (response.oldest_id) {
|
|
428
553
|
oldestMessageId.current = response.oldest_id;
|
|
429
554
|
}
|
|
430
555
|
} catch (error) {
|
|
431
|
-
console.error(
|
|
556
|
+
console.error("[AegisChat] Failed to load more messages:", error);
|
|
432
557
|
} finally {
|
|
433
558
|
setIsLoadingMessages(false);
|
|
434
559
|
}
|
|
435
560
|
}, [activeChannelId, hasMoreMessages, isLoadingMessages, fetchFromComms]);
|
|
436
561
|
|
|
437
|
-
const sendMessage = useCallback(
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
tempId,
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
const updated = prev.map((ch) =>
|
|
466
|
-
ch.id === currentActiveChannelId
|
|
467
|
-
? {
|
|
468
|
-
...ch,
|
|
469
|
-
last_message: {
|
|
470
|
-
id: tempId,
|
|
471
|
-
content: trimmedContent,
|
|
472
|
-
created_at: now,
|
|
473
|
-
sender: { id: currentSession.comms_user_id, display_name: 'You', status: 'online' as const },
|
|
474
|
-
},
|
|
475
|
-
}
|
|
476
|
-
: ch
|
|
477
|
-
);
|
|
478
|
-
return updated.sort((a, b) => {
|
|
479
|
-
const timeA = a.last_message?.created_at || '';
|
|
480
|
-
const timeB = b.last_message?.created_at || '';
|
|
481
|
-
return timeB.localeCompare(timeA);
|
|
482
|
-
});
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
try {
|
|
486
|
-
await fetchFromComms<Message>(`/channels/${currentActiveChannelId}/messages`, {
|
|
487
|
-
method: 'POST',
|
|
488
|
-
body: JSON.stringify({ content: trimmedContent, type: msgOptions.type || 'text', parent_id: msgOptions.parent_id, metadata: msgOptions.metadata }),
|
|
489
|
-
});
|
|
490
|
-
} catch (error) {
|
|
491
|
-
console.error('[AegisChat] Failed to send message:', error);
|
|
492
|
-
setMessages((prev) =>
|
|
493
|
-
prev.map((m) =>
|
|
494
|
-
m.tempId === tempId ? { ...m, status: 'failed', errorMessage: error instanceof Error ? error.message : 'Failed to send' } : m
|
|
495
|
-
)
|
|
496
|
-
);
|
|
497
|
-
throw error;
|
|
498
|
-
}
|
|
499
|
-
}, [fetchFromComms]);
|
|
500
|
-
|
|
501
|
-
const uploadFile = useCallback(async (file: File): Promise<FileAttachment | null> => {
|
|
502
|
-
const currentSession = sessionRef.current;
|
|
503
|
-
if (!currentSession) return null;
|
|
504
|
-
|
|
505
|
-
const fileId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
506
|
-
|
|
507
|
-
setUploadProgress((prev) => [...prev, { fileId, fileName: file.name, progress: 0, status: 'pending' }]);
|
|
508
|
-
|
|
509
|
-
try {
|
|
510
|
-
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
|
+
};
|
|
511
590
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
+
});
|
|
515
617
|
});
|
|
516
618
|
|
|
517
|
-
|
|
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
|
+
);
|
|
518
651
|
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
});
|
|
652
|
+
const uploadFile = useCallback(
|
|
653
|
+
async (file: File): Promise<FileAttachment | null> => {
|
|
654
|
+
const currentSession = sessionRef.current;
|
|
655
|
+
if (!currentSession) return null;
|
|
524
656
|
|
|
525
|
-
|
|
657
|
+
const fileId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
526
658
|
|
|
527
|
-
setUploadProgress((prev) =>
|
|
659
|
+
setUploadProgress((prev) => [
|
|
660
|
+
...prev,
|
|
661
|
+
{ fileId, fileName: file.name, progress: 0, status: "pending" },
|
|
662
|
+
]);
|
|
528
663
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
+
});
|
|
533
685
|
|
|
534
|
-
|
|
535
|
-
|
|
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
|
+
});
|
|
536
699
|
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
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
|
+
};
|
|
545
805
|
|
|
546
|
-
|
|
547
|
-
content: string,
|
|
548
|
-
files: File[],
|
|
549
|
-
msgOptions: { type?: string; parent_id?: string; metadata?: Record<string, unknown> } = {}
|
|
550
|
-
) => {
|
|
551
|
-
const currentActiveChannelId = activeChannelIdRef.current;
|
|
552
|
-
const currentSession = sessionRef.current;
|
|
553
|
-
if (!currentActiveChannelId || (!content.trim() && files.length === 0) || !currentSession) return;
|
|
554
|
-
|
|
555
|
-
const tempId = `temp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
556
|
-
const trimmedContent = content.trim();
|
|
557
|
-
|
|
558
|
-
const optimisticMessage: Message = {
|
|
559
|
-
id: tempId,
|
|
560
|
-
tempId,
|
|
561
|
-
channel_id: currentActiveChannelId,
|
|
562
|
-
sender_id: currentSession.comms_user_id,
|
|
563
|
-
content: trimmedContent || `Uploading ${files.length} file(s)...`,
|
|
564
|
-
type: 'file',
|
|
565
|
-
created_at: new Date().toISOString(),
|
|
566
|
-
updated_at: new Date().toISOString(),
|
|
567
|
-
status: 'sending',
|
|
568
|
-
metadata: { ...msgOptions.metadata, files: files.map((f) => ({ id: `temp-${f.name}`, filename: f.name, mime_type: f.type, size: f.size, url: '' })) },
|
|
569
|
-
};
|
|
806
|
+
setMessages((prev) => [...prev, optimisticMessage]);
|
|
570
807
|
|
|
571
|
-
|
|
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
|
+
}
|
|
572
814
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
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;
|
|
578
850
|
}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
await fetchFromComms<Message>(`/channels/${currentActiveChannelId}/messages`, {
|
|
583
|
-
method: 'POST',
|
|
584
|
-
body: JSON.stringify({
|
|
585
|
-
content: trimmedContent || (uploadedFiles.length > 0 ? `Shared ${uploadedFiles.length} file(s)` : ''),
|
|
586
|
-
type: msgOptions.type || messageType,
|
|
587
|
-
parent_id: msgOptions.parent_id,
|
|
588
|
-
metadata: { ...msgOptions.metadata, files: uploadedFiles },
|
|
589
|
-
file_ids: uploadedFiles.map((f) => f.id),
|
|
590
|
-
}),
|
|
591
|
-
});
|
|
592
|
-
} catch (error) {
|
|
593
|
-
console.error('[AegisChat] Failed to send message with files:', error);
|
|
594
|
-
setMessages((prev) => prev.map((m) => m.tempId === tempId ? { ...m, status: 'failed', errorMessage: error instanceof Error ? error.message : 'Failed to send' } : m));
|
|
595
|
-
throw error;
|
|
596
|
-
}
|
|
597
|
-
}, [fetchFromComms, uploadFile]);
|
|
851
|
+
},
|
|
852
|
+
[fetchFromComms, uploadFile],
|
|
853
|
+
);
|
|
598
854
|
|
|
599
855
|
const stopTyping = useCallback(() => {
|
|
600
856
|
const currentActiveChannelId = activeChannelIdRef.current;
|
|
601
857
|
if (!currentActiveChannelId || !wsRef.current) return;
|
|
602
|
-
wsRef.current.send(
|
|
858
|
+
wsRef.current.send(
|
|
859
|
+
JSON.stringify({
|
|
860
|
+
type: "typing.stop",
|
|
861
|
+
payload: { channel_id: currentActiveChannelId },
|
|
862
|
+
}),
|
|
863
|
+
);
|
|
603
864
|
if (typingTimeout.current) {
|
|
604
865
|
clearTimeout(typingTimeout.current);
|
|
605
866
|
typingTimeout.current = null;
|
|
@@ -609,55 +870,122 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|
|
609
870
|
const startTyping = useCallback(() => {
|
|
610
871
|
const currentActiveChannelId = activeChannelIdRef.current;
|
|
611
872
|
if (!currentActiveChannelId || !wsRef.current) return;
|
|
612
|
-
wsRef.current.send(
|
|
873
|
+
wsRef.current.send(
|
|
874
|
+
JSON.stringify({
|
|
875
|
+
type: "typing.start",
|
|
876
|
+
payload: { channel_id: currentActiveChannelId },
|
|
877
|
+
}),
|
|
878
|
+
);
|
|
613
879
|
if (typingTimeout.current) clearTimeout(typingTimeout.current);
|
|
614
880
|
typingTimeout.current = setTimeout(stopTyping, TYPING_TIMEOUT);
|
|
615
881
|
}, [stopTyping]);
|
|
616
882
|
|
|
617
|
-
const createDMWithUser = useCallback(
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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;
|
|
635
907
|
|
|
636
|
-
|
|
908
|
+
setMessages((prev) =>
|
|
909
|
+
prev.map((m) =>
|
|
910
|
+
m.tempId === tempId
|
|
911
|
+
? { ...m, status: "sending", errorMessage: undefined }
|
|
912
|
+
: m,
|
|
913
|
+
),
|
|
914
|
+
);
|
|
637
915
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
+
);
|
|
648
946
|
|
|
649
947
|
const deleteFailedMessage = useCallback((tempId: string) => {
|
|
650
948
|
setMessages((prev) => prev.filter((m) => m.tempId !== tempId));
|
|
651
949
|
}, []);
|
|
652
950
|
|
|
653
|
-
|
|
654
|
-
|
|
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
|
+
}, []);
|
|
655
983
|
|
|
656
984
|
useEffect(() => {
|
|
657
|
-
if (
|
|
985
|
+
if (session && !isConnected && !isConnecting && autoConnectRef.current) {
|
|
658
986
|
connectWebSocket();
|
|
659
987
|
}
|
|
660
|
-
}, [
|
|
988
|
+
}, [session, isConnected, isConnecting, connectWebSocket]);
|
|
661
989
|
|
|
662
990
|
useEffect(() => {
|
|
663
991
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
|
@@ -666,7 +994,10 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|
|
666
994
|
const data = JSON.parse(event.data);
|
|
667
995
|
handleWebSocketMessage(data);
|
|
668
996
|
} catch (error) {
|
|
669
|
-
console.error(
|
|
997
|
+
console.error(
|
|
998
|
+
"[AegisChat] Failed to parse WebSocket message:",
|
|
999
|
+
error,
|
|
1000
|
+
);
|
|
670
1001
|
}
|
|
671
1002
|
};
|
|
672
1003
|
}
|
|
@@ -723,6 +1054,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
|
|
|
723
1054
|
retryMessage,
|
|
724
1055
|
deleteFailedMessage,
|
|
725
1056
|
markAsRead,
|
|
1057
|
+
setup,
|
|
726
1058
|
};
|
|
727
1059
|
}
|
|
728
1060
|
|