@newfold/wp-module-ai-chat 1.0.0
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/README.md +98 -0
- package/package.json +51 -0
- package/src/components/chat/ChatHeader.jsx +63 -0
- package/src/components/chat/ChatHistoryDropdown.jsx +182 -0
- package/src/components/chat/ChatHistoryList.jsx +257 -0
- package/src/components/chat/ChatInput.jsx +157 -0
- package/src/components/chat/ChatMessage.jsx +157 -0
- package/src/components/chat/ChatMessages.jsx +137 -0
- package/src/components/chat/WelcomeScreen.jsx +115 -0
- package/src/components/icons/CloseIcon.jsx +27 -0
- package/src/components/icons/SparklesOutlineIcon.jsx +30 -0
- package/src/components/icons/index.js +5 -0
- package/src/components/ui/AILogo.jsx +47 -0
- package/src/components/ui/BluBetaHeading.jsx +18 -0
- package/src/components/ui/ErrorAlert.jsx +30 -0
- package/src/components/ui/HeaderBar.jsx +34 -0
- package/src/components/ui/SuggestionButton.jsx +28 -0
- package/src/components/ui/ToolExecutionList.jsx +264 -0
- package/src/components/ui/TypingIndicator.jsx +268 -0
- package/src/constants/nfdAgents/input.js +13 -0
- package/src/constants/nfdAgents/storageKeys.js +102 -0
- package/src/constants/nfdAgents/typingStatus.js +40 -0
- package/src/constants/nfdAgents/websocket.js +44 -0
- package/src/hooks/useAIChat.js +432 -0
- package/src/hooks/useNfdAgentsWebSocket.js +964 -0
- package/src/index.js +66 -0
- package/src/services/mcpClient.js +433 -0
- package/src/services/openaiClient.js +416 -0
- package/src/styles/_branding.scss +151 -0
- package/src/styles/_history.scss +180 -0
- package/src/styles/_input.scss +170 -0
- package/src/styles/_messages.scss +272 -0
- package/src/styles/_mixins.scss +21 -0
- package/src/styles/_typing-indicator.scss +162 -0
- package/src/styles/_ui.scss +173 -0
- package/src/styles/_vars.scss +103 -0
- package/src/styles/_welcome.scss +81 -0
- package/src/styles/app.scss +10 -0
- package/src/utils/helpers.js +75 -0
- package/src/utils/markdownParser.js +319 -0
- package/src/utils/nfdAgents/archiveConversation.js +82 -0
- package/src/utils/nfdAgents/chatHistoryList.js +130 -0
- package/src/utils/nfdAgents/configFetcher.js +137 -0
- package/src/utils/nfdAgents/greeting.js +55 -0
- package/src/utils/nfdAgents/jwtUtils.js +59 -0
- package/src/utils/nfdAgents/messageHandler.js +328 -0
- package/src/utils/nfdAgents/storage.js +112 -0
- package/src/utils/nfdAgents/typingIndicatorToolDisplay.js +180 -0
- package/src/utils/nfdAgents/url.js +101 -0
- package/src/utils/restApi.js +87 -0
- package/src/utils/sanitizeHtml.js +94 -0
|
@@ -0,0 +1,964 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket hook for NFD Agents backend.
|
|
3
|
+
*
|
|
4
|
+
* Connects via WebSocket, handles message streaming and reconnection.
|
|
5
|
+
* Used by Help Center, Editor Chat, and other AI chat UIs.
|
|
6
|
+
* Delegates to: messageHandler, configFetcher, storage, url.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/* global WebSocket localStorage sessionStorage */
|
|
10
|
+
/* eslint-disable no-console -- Connection and storage warnings only. */
|
|
11
|
+
|
|
12
|
+
import { useState, useEffect, useRef, useCallback } from "@wordpress/element";
|
|
13
|
+
import { __ } from "@wordpress/i18n";
|
|
14
|
+
import {
|
|
15
|
+
NFD_AGENTS_WEBSOCKET,
|
|
16
|
+
WS_CLOSE_AUTH_FAILED,
|
|
17
|
+
WS_CLOSE_MISSING_TOKEN,
|
|
18
|
+
} from "../constants/nfdAgents/websocket";
|
|
19
|
+
import { getJwtExpirationMs } from "../utils/nfdAgents/jwtUtils";
|
|
20
|
+
import {
|
|
21
|
+
getSiteId,
|
|
22
|
+
setSiteId,
|
|
23
|
+
migrateStorageKeys,
|
|
24
|
+
getChatHistoryStorageKeys,
|
|
25
|
+
} from "../constants/nfdAgents/storageKeys";
|
|
26
|
+
import { buildWebSocketUrl } from "../utils/nfdAgents/url";
|
|
27
|
+
import { createMessageHandler } from "../utils/nfdAgents/messageHandler";
|
|
28
|
+
import { fetchAgentConfig } from "../utils/nfdAgents/configFetcher";
|
|
29
|
+
import {
|
|
30
|
+
restoreChat,
|
|
31
|
+
persistMessages,
|
|
32
|
+
persistConversationId,
|
|
33
|
+
clearChatStorage,
|
|
34
|
+
hasMeaningfulUserMessage,
|
|
35
|
+
} from "../utils/nfdAgents/storage";
|
|
36
|
+
import { generateSessionId } from "../utils/helpers";
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* useNfdAgentsWebSocket Hook
|
|
40
|
+
*
|
|
41
|
+
* Manages WebSocket connection to NFD Agents backend with automatic reconnection
|
|
42
|
+
* and message handling.
|
|
43
|
+
*
|
|
44
|
+
* @param {Object} options Hook options
|
|
45
|
+
* @param {string} options.configEndpoint REST API endpoint for fetching config
|
|
46
|
+
* @param {string} options.consumer Consumer identifier. Required. Used for localStorage keys and sent to backend as query param. Valid values are defined by the backend.
|
|
47
|
+
* @param {boolean} [options.autoConnect=false] Whether to connect automatically
|
|
48
|
+
* @param {string} [options.consumerType] Consumer type; passed to backend as `wordpress_${consumerType}`. Defaults to 'editor_chat'.
|
|
49
|
+
* @param {boolean} [options.autoLoadHistory=true] Whether to auto-load chat history from localStorage on mount.
|
|
50
|
+
* Set to false to start with empty chat but keep history in storage for later access.
|
|
51
|
+
* @param {Function} [options.getConnectionFailedFallbackMessage] Optional. When connection has failed (e.g. after max
|
|
52
|
+
* retries) and the user sends a message, the hook will add an assistant message with the returned string.
|
|
53
|
+
* Called as getConnectionFailedFallbackMessage(userMessage). Use for exact copy (e.g. NoResults-style) with i18n.
|
|
54
|
+
* @return {Object} Hook return value with connection state and methods
|
|
55
|
+
*/
|
|
56
|
+
const useNfdAgentsWebSocket = ({
|
|
57
|
+
configEndpoint,
|
|
58
|
+
consumer,
|
|
59
|
+
autoConnect = false,
|
|
60
|
+
consumerType = "editor_chat",
|
|
61
|
+
autoLoadHistory = true,
|
|
62
|
+
getConnectionFailedFallbackMessage,
|
|
63
|
+
} = {}) => {
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Storage keys (site-scoped; single source of truth from storageKeys.js)
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
const storageKeys = getChatHistoryStorageKeys(consumer);
|
|
68
|
+
const STORAGE_KEY = storageKeys.history;
|
|
69
|
+
const CONVERSATION_STORAGE_KEY = storageKeys.conversationId;
|
|
70
|
+
const SESSION_STORAGE_KEY = storageKeys.sessionId;
|
|
71
|
+
const keyPrefix = STORAGE_KEY.replace(/-history$/, "");
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// State (lazy-init from localStorage)
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
const [messages, setMessages] = useState(() => {
|
|
77
|
+
if (!autoLoadHistory) {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
return restoreChat(STORAGE_KEY, CONVERSATION_STORAGE_KEY, SESSION_STORAGE_KEY).messages;
|
|
81
|
+
});
|
|
82
|
+
const [conversationId, setConversationId] = useState(() => {
|
|
83
|
+
if (!autoLoadHistory) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return restoreChat(STORAGE_KEY, CONVERSATION_STORAGE_KEY, SESSION_STORAGE_KEY).conversationId;
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const [isConnected, setIsConnected] = useState(false);
|
|
90
|
+
const [isConnecting, setIsConnecting] = useState(false);
|
|
91
|
+
const [error, setError] = useState(null);
|
|
92
|
+
const [isTyping, setIsTyping] = useState(false);
|
|
93
|
+
const [status, setStatus] = useState(null);
|
|
94
|
+
const [currentResponse, setCurrentResponse] = useState("");
|
|
95
|
+
const [approvalRequest, setApprovalRequest] = useState(null);
|
|
96
|
+
// Restore "failed" from sessionStorage so UI shows error state across navigations within the tab
|
|
97
|
+
const [connectionState, setConnectionState] = useState(() => {
|
|
98
|
+
try {
|
|
99
|
+
if (sessionStorage.getItem(`${keyPrefix}-connection-failed`) === "1") {
|
|
100
|
+
return "failed";
|
|
101
|
+
}
|
|
102
|
+
} catch (e) {
|
|
103
|
+
// ignore
|
|
104
|
+
}
|
|
105
|
+
return "disconnected";
|
|
106
|
+
});
|
|
107
|
+
const [retryAttempt, setRetryAttempt] = useState(0);
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Refs — wsRef: current WebSocket. reconnectTimeoutRef/Attempts: backoff. configRef: cached config.
|
|
111
|
+
// previousAutoConnectRef/connectingRef: avoid duplicate connect. sessionIdRef: current session (lazy init below).
|
|
112
|
+
// hasUserMessageRef/isStoppedRef: read by messageHandler. typingTimeoutRef: clear on stop/close.
|
|
113
|
+
// messagesRef/connectionStateRef/prevConnectionStateRef: latest state for callbacks.
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
const wsRef = useRef(null);
|
|
116
|
+
const reconnectTimeoutRef = useRef(null);
|
|
117
|
+
const reconnectAttempts = useRef(0);
|
|
118
|
+
const configRef = useRef(null);
|
|
119
|
+
const jwtRefreshTimeoutRef = useRef(null);
|
|
120
|
+
const lastProactiveRefreshAt = useRef(null);
|
|
121
|
+
const lastAuthRefreshAt = useRef(null);
|
|
122
|
+
const connectRef = useRef(null);
|
|
123
|
+
const disconnectRef = useRef(null);
|
|
124
|
+
const previousAutoConnectRef = useRef(null);
|
|
125
|
+
const connectingRef = useRef(false);
|
|
126
|
+
const sessionIdRef = useRef(() => {
|
|
127
|
+
if (autoLoadHistory) {
|
|
128
|
+
return restoreChat(STORAGE_KEY, CONVERSATION_STORAGE_KEY, SESSION_STORAGE_KEY).sessionId;
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
});
|
|
132
|
+
// Unwrap lazy initializer for ref (refs don't support lazy init like useState)
|
|
133
|
+
if (typeof sessionIdRef.current === "function") {
|
|
134
|
+
sessionIdRef.current = sessionIdRef.current();
|
|
135
|
+
}
|
|
136
|
+
const hasUserMessageRef = useRef(false);
|
|
137
|
+
const isStoppedRef = useRef(false);
|
|
138
|
+
const isTypingRef = useRef(false);
|
|
139
|
+
const typingTimeoutRef = useRef(null);
|
|
140
|
+
const isInitialMount = useRef(true);
|
|
141
|
+
const messagesRef = useRef([]);
|
|
142
|
+
const connectionStateRef = useRef(connectionState);
|
|
143
|
+
const prevConnectionStateRef = useRef(connectionState);
|
|
144
|
+
|
|
145
|
+
const MAX_RECONNECT_ATTEMPTS = NFD_AGENTS_WEBSOCKET.MAX_RECONNECT_ATTEMPTS;
|
|
146
|
+
const RECONNECT_DELAY = NFD_AGENTS_WEBSOCKET.RECONNECT_DELAY;
|
|
147
|
+
const TYPING_TIMEOUT = NFD_AGENTS_WEBSOCKET.TYPING_TIMEOUT;
|
|
148
|
+
const JWT_REFRESH_BUFFER_MS = NFD_AGENTS_WEBSOCKET.JWT_REFRESH_BUFFER_MS;
|
|
149
|
+
const JWT_REFRESH_MIN_DELAY_MS = NFD_AGENTS_WEBSOCKET.JWT_REFRESH_MIN_DELAY_MS;
|
|
150
|
+
const JWT_PROACTIVE_REFRESH_COOLDOWN_MS =
|
|
151
|
+
NFD_AGENTS_WEBSOCKET.JWT_PROACTIVE_REFRESH_COOLDOWN_MS;
|
|
152
|
+
const AUTH_REFRESH_COOLDOWN_MS = NFD_AGENTS_WEBSOCKET.AUTH_REFRESH_COOLDOWN_MS;
|
|
153
|
+
const JWT_EXPIRED_BUFFER_MS = NFD_AGENTS_WEBSOCKET.JWT_EXPIRED_BUFFER_MS;
|
|
154
|
+
const JWT_PROACTIVE_REFRESH_DEFER_MS =
|
|
155
|
+
NFD_AGENTS_WEBSOCKET.JWT_PROACTIVE_REFRESH_DEFER_MS;
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Callbacks passed to messageHandler (persist session/conversation ID to ref + localStorage)
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
const saveSessionId = useCallback(
|
|
161
|
+
(sid) => {
|
|
162
|
+
sessionIdRef.current = sid;
|
|
163
|
+
try {
|
|
164
|
+
localStorage.setItem(SESSION_STORAGE_KEY, sid);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
// eslint-disable-next-line no-console
|
|
167
|
+
console.warn("[AI Chat] Failed to save session ID to localStorage:", err);
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
[SESSION_STORAGE_KEY]
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const saveConversationId = useCallback(
|
|
174
|
+
(cid) => {
|
|
175
|
+
try {
|
|
176
|
+
localStorage.setItem(CONVERSATION_STORAGE_KEY, cid);
|
|
177
|
+
} catch (err) {
|
|
178
|
+
// eslint-disable-next-line no-console
|
|
179
|
+
console.warn("[AI Chat] Failed to save conversation ID to localStorage:", err);
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
[CONVERSATION_STORAGE_KEY]
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// ---------------------------------------------------------------------------
|
|
186
|
+
// connect() — Idempotent. Fetches config (cached), ensures site ID + storage migration,
|
|
187
|
+
// opens WebSocket, wires message handler. On close, schedules reconnect with backoff.
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
const connect = useCallback(async () => {
|
|
190
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (wsRef.current?.readyState === WebSocket.CONNECTING) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (connectingRef.current) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Clear "connection failed" flag
|
|
201
|
+
try {
|
|
202
|
+
sessionStorage.removeItem(`${keyPrefix}-connection-failed`);
|
|
203
|
+
} catch (e) {
|
|
204
|
+
// ignore
|
|
205
|
+
}
|
|
206
|
+
connectingRef.current = true;
|
|
207
|
+
setIsConnecting(true);
|
|
208
|
+
setConnectionState("connecting");
|
|
209
|
+
setError(null);
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
// Fetch config if not cached
|
|
213
|
+
if (!configRef.current) {
|
|
214
|
+
configRef.current = await fetchAgentConfig({ configEndpoint, consumer });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let config = configRef.current;
|
|
218
|
+
if (!config) {
|
|
219
|
+
throw new Error(__("No configuration available", "wp-module-ai-chat"));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Pre-connect: if JWT is already expired or within buffer, refetch config once
|
|
223
|
+
let refetchedForExpiry = false;
|
|
224
|
+
while (config?.jarvis_jwt) {
|
|
225
|
+
const expMs = getJwtExpirationMs(config.jarvis_jwt);
|
|
226
|
+
if (expMs == null) break;
|
|
227
|
+
if (expMs >= Date.now() + JWT_EXPIRED_BUFFER_MS) break;
|
|
228
|
+
if (refetchedForExpiry) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
__("Token expired, please refresh the page.", "wp-module-ai-chat")
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
configRef.current = null;
|
|
234
|
+
configRef.current = await fetchAgentConfig({ configEndpoint, consumer });
|
|
235
|
+
config = configRef.current;
|
|
236
|
+
refetchedForExpiry = true;
|
|
237
|
+
if (!config) {
|
|
238
|
+
throw new Error(__("No configuration available", "wp-module-ai-chat"));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Cache site ID and migrate old storage keys if needed
|
|
243
|
+
if (config.site_id) {
|
|
244
|
+
const currentSiteId = getSiteId();
|
|
245
|
+
if (currentSiteId !== config.site_id) {
|
|
246
|
+
setSiteId(config.site_id);
|
|
247
|
+
migrateStorageKeys(currentSiteId, config.site_id, consumer);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Generate or reuse session ID
|
|
252
|
+
if (!sessionIdRef.current) {
|
|
253
|
+
sessionIdRef.current = generateSessionId();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Schedule proactive JWT refresh only for jarvis_jwt (exclude huapi_token / debug path)
|
|
257
|
+
if (config.jarvis_jwt) {
|
|
258
|
+
if (jwtRefreshTimeoutRef.current) {
|
|
259
|
+
clearTimeout(jwtRefreshTimeoutRef.current);
|
|
260
|
+
jwtRefreshTimeoutRef.current = null;
|
|
261
|
+
}
|
|
262
|
+
const expMs = getJwtExpirationMs(config.jarvis_jwt);
|
|
263
|
+
if (expMs != null) {
|
|
264
|
+
let refreshAt = expMs - JWT_REFRESH_BUFFER_MS;
|
|
265
|
+
refreshAt = Math.max(refreshAt, Date.now() + JWT_REFRESH_MIN_DELAY_MS);
|
|
266
|
+
const now = Date.now();
|
|
267
|
+
const insideCooldown =
|
|
268
|
+
lastProactiveRefreshAt.current != null &&
|
|
269
|
+
refreshAt < lastProactiveRefreshAt.current + JWT_PROACTIVE_REFRESH_COOLDOWN_MS;
|
|
270
|
+
if (!insideCooldown) {
|
|
271
|
+
const delay = Math.max(0, refreshAt - now);
|
|
272
|
+
const runRefresh = () => {
|
|
273
|
+
if (isTypingRef.current) {
|
|
274
|
+
jwtRefreshTimeoutRef.current = setTimeout(
|
|
275
|
+
runRefresh,
|
|
276
|
+
JWT_PROACTIVE_REFRESH_DEFER_MS
|
|
277
|
+
);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
jwtRefreshTimeoutRef.current = null;
|
|
281
|
+
lastProactiveRefreshAt.current = Date.now();
|
|
282
|
+
configRef.current = null;
|
|
283
|
+
if (disconnectRef.current) {
|
|
284
|
+
disconnectRef.current();
|
|
285
|
+
}
|
|
286
|
+
if (connectRef.current) {
|
|
287
|
+
connectRef.current();
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
jwtRefreshTimeoutRef.current = setTimeout(runRefresh, delay);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Build WebSocket URL from config (site_url comes from config endpoint)
|
|
296
|
+
const wsUrl = buildWebSocketUrl(config, sessionIdRef.current, consumerType);
|
|
297
|
+
|
|
298
|
+
const ws = new WebSocket(wsUrl);
|
|
299
|
+
wsRef.current = ws;
|
|
300
|
+
|
|
301
|
+
// Connected: reset reconnection state and sync "has user message" from current messages
|
|
302
|
+
ws.onopen = () => {
|
|
303
|
+
connectingRef.current = false;
|
|
304
|
+
setIsConnected(true);
|
|
305
|
+
setIsConnecting(false);
|
|
306
|
+
setConnectionState("connected");
|
|
307
|
+
setRetryAttempt(0);
|
|
308
|
+
setError(null);
|
|
309
|
+
reconnectAttempts.current = 0;
|
|
310
|
+
hasUserMessageRef.current = messagesRef.current && messagesRef.current.length > 0;
|
|
311
|
+
isStoppedRef.current = false;
|
|
312
|
+
setCurrentResponse("");
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// Wire message handler
|
|
316
|
+
const handleMessage = createMessageHandler({
|
|
317
|
+
isStoppedRef,
|
|
318
|
+
hasUserMessageRef,
|
|
319
|
+
typingTimeoutRef,
|
|
320
|
+
typingTimeout: TYPING_TIMEOUT,
|
|
321
|
+
setIsTyping,
|
|
322
|
+
setStatus,
|
|
323
|
+
setCurrentResponse,
|
|
324
|
+
setMessages,
|
|
325
|
+
setConversationId,
|
|
326
|
+
setError,
|
|
327
|
+
saveSessionId,
|
|
328
|
+
saveConversationId,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
ws.onmessage = (event) => {
|
|
332
|
+
try {
|
|
333
|
+
const data = JSON.parse(event.data);
|
|
334
|
+
handleMessage(data);
|
|
335
|
+
} catch (err) {
|
|
336
|
+
// eslint-disable-next-line no-console
|
|
337
|
+
console.error("[AI Chat] Error parsing WebSocket message:", err);
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
ws.onerror = () => {
|
|
342
|
+
connectingRef.current = false;
|
|
343
|
+
setIsConnecting(false);
|
|
344
|
+
// Do not set "failed" here; onclose will set "reconnecting" or "failed" after retries.
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
ws.onclose = (event) => {
|
|
348
|
+
connectingRef.current = false;
|
|
349
|
+
setIsConnected(false);
|
|
350
|
+
setIsConnecting(false);
|
|
351
|
+
setIsTyping(false);
|
|
352
|
+
setStatus(null);
|
|
353
|
+
if (wsRef.current === ws) {
|
|
354
|
+
wsRef.current = null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Auth failure (4000/4001) or client-side detected token expiry: clear config so next connect fetches fresh JWT (throttled by cooldown)
|
|
358
|
+
const isAuthClose = event.code === WS_CLOSE_AUTH_FAILED || event.code === WS_CLOSE_MISSING_TOKEN;
|
|
359
|
+
const jwt = configRef.current?.jarvis_jwt;
|
|
360
|
+
const expMs = jwt ? getJwtExpirationMs(jwt) : null;
|
|
361
|
+
const tokenExpired =
|
|
362
|
+
expMs != null && expMs < Date.now() + JWT_EXPIRED_BUFFER_MS;
|
|
363
|
+
if (isAuthClose || tokenExpired) {
|
|
364
|
+
const now = Date.now();
|
|
365
|
+
const outsideAuthCooldown =
|
|
366
|
+
lastAuthRefreshAt.current == null ||
|
|
367
|
+
now - lastAuthRefreshAt.current >= AUTH_REFRESH_COOLDOWN_MS;
|
|
368
|
+
if (outsideAuthCooldown) {
|
|
369
|
+
configRef.current = null;
|
|
370
|
+
reconnectAttempts.current = 0;
|
|
371
|
+
lastAuthRefreshAt.current = now;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Exponential backoff: reconnect only if not normal close and under max attempts
|
|
376
|
+
if (event.code !== 1000 && reconnectAttempts.current < MAX_RECONNECT_ATTEMPTS) {
|
|
377
|
+
reconnectAttempts.current++;
|
|
378
|
+
setRetryAttempt(reconnectAttempts.current);
|
|
379
|
+
setConnectionState("reconnecting");
|
|
380
|
+
const delay = RECONNECT_DELAY * Math.pow(2, reconnectAttempts.current - 1);
|
|
381
|
+
reconnectTimeoutRef.current = setTimeout(() => {
|
|
382
|
+
connect();
|
|
383
|
+
}, delay);
|
|
384
|
+
} else if (reconnectAttempts.current >= MAX_RECONNECT_ATTEMPTS) {
|
|
385
|
+
setConnectionState("failed");
|
|
386
|
+
try {
|
|
387
|
+
sessionStorage.setItem(`${keyPrefix}-connection-failed`, "1");
|
|
388
|
+
} catch (e) {
|
|
389
|
+
// ignore
|
|
390
|
+
}
|
|
391
|
+
} else {
|
|
392
|
+
setConnectionState("disconnected");
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
} catch (connectError) {
|
|
396
|
+
connectingRef.current = false;
|
|
397
|
+
// Config/token failures expected when Hiive unavailable or debug token not set
|
|
398
|
+
if (typeof console !== "undefined" && console.warn) {
|
|
399
|
+
// eslint-disable-next-line no-console
|
|
400
|
+
console.warn("[AI Chat] Connection failed:", connectError?.message || connectError);
|
|
401
|
+
}
|
|
402
|
+
setIsConnecting(false);
|
|
403
|
+
setConnectionState("failed");
|
|
404
|
+
try {
|
|
405
|
+
sessionStorage.setItem(`${keyPrefix}-connection-failed`, "1");
|
|
406
|
+
} catch (e) {
|
|
407
|
+
// ignore
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}, [
|
|
411
|
+
configEndpoint,
|
|
412
|
+
consumer,
|
|
413
|
+
consumerType,
|
|
414
|
+
keyPrefix,
|
|
415
|
+
saveSessionId,
|
|
416
|
+
saveConversationId,
|
|
417
|
+
MAX_RECONNECT_ATTEMPTS,
|
|
418
|
+
RECONNECT_DELAY,
|
|
419
|
+
TYPING_TIMEOUT,
|
|
420
|
+
JWT_REFRESH_BUFFER_MS,
|
|
421
|
+
JWT_REFRESH_MIN_DELAY_MS,
|
|
422
|
+
JWT_PROACTIVE_REFRESH_COOLDOWN_MS,
|
|
423
|
+
AUTH_REFRESH_COOLDOWN_MS,
|
|
424
|
+
JWT_EXPIRED_BUFFER_MS,
|
|
425
|
+
JWT_PROACTIVE_REFRESH_DEFER_MS,
|
|
426
|
+
]);
|
|
427
|
+
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
// Ref sync effects — Keep refs in sync so callbacks (e.g. sendMessage) see latest
|
|
430
|
+
// values without needing them in dependency arrays.
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
useEffect(() => {
|
|
433
|
+
messagesRef.current = messages;
|
|
434
|
+
}, [messages]);
|
|
435
|
+
|
|
436
|
+
useEffect(() => {
|
|
437
|
+
connectionStateRef.current = connectionState;
|
|
438
|
+
}, [connectionState]);
|
|
439
|
+
|
|
440
|
+
useEffect(() => {
|
|
441
|
+
isTypingRef.current = isTyping;
|
|
442
|
+
}, [isTyping]);
|
|
443
|
+
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
// Unmount cleanup — Clear timers so no callbacks run after unmount.
|
|
446
|
+
// ---------------------------------------------------------------------------
|
|
447
|
+
useEffect(() => {
|
|
448
|
+
return () => {
|
|
449
|
+
if (reconnectTimeoutRef.current) {
|
|
450
|
+
clearTimeout(reconnectTimeoutRef.current);
|
|
451
|
+
reconnectTimeoutRef.current = null;
|
|
452
|
+
}
|
|
453
|
+
if (jwtRefreshTimeoutRef.current) {
|
|
454
|
+
clearTimeout(jwtRefreshTimeoutRef.current);
|
|
455
|
+
jwtRefreshTimeoutRef.current = null;
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
}, []);
|
|
459
|
+
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
// On transition to "failed", append assistant fallback message so user sees error state
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
useEffect(() => {
|
|
464
|
+
if (connectionState !== "failed" || prevConnectionStateRef.current === "failed") {
|
|
465
|
+
prevConnectionStateRef.current = connectionState;
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
prevConnectionStateRef.current = connectionState;
|
|
469
|
+
|
|
470
|
+
const defaultFallback = __(
|
|
471
|
+
"Sorry, we couldn't connect. Please try again later or contact support.",
|
|
472
|
+
"wp-module-ai-chat"
|
|
473
|
+
);
|
|
474
|
+
setMessages((prev) => {
|
|
475
|
+
const last = prev.length > 0 ? prev[prev.length - 1] : null;
|
|
476
|
+
const isLastUser = last && (last.role === "user" || last.type === "user");
|
|
477
|
+
const fallbackContent =
|
|
478
|
+
typeof getConnectionFailedFallbackMessage === "function"
|
|
479
|
+
? getConnectionFailedFallbackMessage(isLastUser ? last.content : "")
|
|
480
|
+
: defaultFallback;
|
|
481
|
+
return [
|
|
482
|
+
...prev,
|
|
483
|
+
{
|
|
484
|
+
id: `msg-${Date.now()}-fallback`,
|
|
485
|
+
role: "assistant",
|
|
486
|
+
type: "assistant",
|
|
487
|
+
content: fallbackContent,
|
|
488
|
+
timestamp: new Date(),
|
|
489
|
+
},
|
|
490
|
+
];
|
|
491
|
+
});
|
|
492
|
+
setError(null);
|
|
493
|
+
setCurrentResponse("");
|
|
494
|
+
setIsTyping(false);
|
|
495
|
+
setStatus(null);
|
|
496
|
+
if (typingTimeoutRef.current) {
|
|
497
|
+
clearTimeout(typingTimeoutRef.current);
|
|
498
|
+
typingTimeoutRef.current = null;
|
|
499
|
+
}
|
|
500
|
+
}, [connectionState, getConnectionFailedFallbackMessage]);
|
|
501
|
+
|
|
502
|
+
// ---------------------------------------------------------------------------
|
|
503
|
+
// sendMessage(message, convId?) — If connection failed: append user + fallback message.
|
|
504
|
+
// If not connected: append user message (if new), then connect. If connected: append
|
|
505
|
+
// user message (if new), set typing, send { type: 'chat', message } with conversationId.
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
const sendMessage = useCallback(
|
|
508
|
+
(message, convId = null) => {
|
|
509
|
+
isStoppedRef.current = false;
|
|
510
|
+
hasUserMessageRef.current = true;
|
|
511
|
+
|
|
512
|
+
const isFailed =
|
|
513
|
+
connectionStateRef.current === "failed" ||
|
|
514
|
+
(reconnectAttempts.current >= MAX_RECONNECT_ATTEMPTS &&
|
|
515
|
+
(!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN));
|
|
516
|
+
if (isFailed && !convId) {
|
|
517
|
+
const userMessage = {
|
|
518
|
+
id: `msg-${Date.now()}`,
|
|
519
|
+
role: "user",
|
|
520
|
+
type: "user",
|
|
521
|
+
content: message,
|
|
522
|
+
timestamp: new Date(),
|
|
523
|
+
sessionId: sessionIdRef.current,
|
|
524
|
+
};
|
|
525
|
+
const fallbackContent =
|
|
526
|
+
typeof getConnectionFailedFallbackMessage === "function"
|
|
527
|
+
? getConnectionFailedFallbackMessage(message)
|
|
528
|
+
: __(
|
|
529
|
+
"Sorry, we couldn't connect. Please try again later or contact support.",
|
|
530
|
+
"wp-module-ai-chat"
|
|
531
|
+
);
|
|
532
|
+
setMessages((prev) => [
|
|
533
|
+
...prev,
|
|
534
|
+
userMessage,
|
|
535
|
+
{
|
|
536
|
+
id: `msg-${Date.now()}-fallback`,
|
|
537
|
+
role: "assistant",
|
|
538
|
+
type: "assistant",
|
|
539
|
+
content: fallbackContent,
|
|
540
|
+
timestamp: new Date(),
|
|
541
|
+
},
|
|
542
|
+
]);
|
|
543
|
+
setError(null);
|
|
544
|
+
setCurrentResponse("");
|
|
545
|
+
setIsTyping(false);
|
|
546
|
+
setStatus(null);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
|
551
|
+
if (!convId) {
|
|
552
|
+
const userMessage = {
|
|
553
|
+
id: `msg-${Date.now()}`,
|
|
554
|
+
role: "user",
|
|
555
|
+
type: "user",
|
|
556
|
+
content: message,
|
|
557
|
+
timestamp: new Date(),
|
|
558
|
+
sessionId: sessionIdRef.current,
|
|
559
|
+
};
|
|
560
|
+
setMessages((prev) => [...prev, userMessage]);
|
|
561
|
+
}
|
|
562
|
+
// Trigger connect when not connected (disconnected or reconnecting) so message can be sent once open
|
|
563
|
+
connect();
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
if (!convId) {
|
|
568
|
+
const userMessage = {
|
|
569
|
+
id: `msg-${Date.now()}`,
|
|
570
|
+
role: "user",
|
|
571
|
+
type: "user",
|
|
572
|
+
content: message,
|
|
573
|
+
timestamp: new Date(),
|
|
574
|
+
sessionId: sessionIdRef.current,
|
|
575
|
+
};
|
|
576
|
+
setMessages((prev) => [...prev, userMessage]);
|
|
577
|
+
setCurrentResponse("");
|
|
578
|
+
setIsTyping(true);
|
|
579
|
+
|
|
580
|
+
if (typingTimeoutRef.current) {
|
|
581
|
+
clearTimeout(typingTimeoutRef.current);
|
|
582
|
+
}
|
|
583
|
+
typingTimeoutRef.current = setTimeout(() => {
|
|
584
|
+
setIsTyping(false);
|
|
585
|
+
setStatus(null);
|
|
586
|
+
typingTimeoutRef.current = null;
|
|
587
|
+
}, TYPING_TIMEOUT);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const payload = { type: "chat", message };
|
|
591
|
+
|
|
592
|
+
if (convId) {
|
|
593
|
+
payload.conversationId = convId;
|
|
594
|
+
} else if (conversationId) {
|
|
595
|
+
payload.conversationId = conversationId;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
wsRef.current.send(JSON.stringify(payload));
|
|
599
|
+
},
|
|
600
|
+
[
|
|
601
|
+
conversationId,
|
|
602
|
+
connect,
|
|
603
|
+
getConnectionFailedFallbackMessage,
|
|
604
|
+
MAX_RECONNECT_ATTEMPTS,
|
|
605
|
+
TYPING_TIMEOUT,
|
|
606
|
+
]
|
|
607
|
+
);
|
|
608
|
+
|
|
609
|
+
// ---------------------------------------------------------------------------
|
|
610
|
+
// sendSystemMessage(message) — Sends a system/backend message over the open socket
|
|
611
|
+
// (e.g. for handoff or context). Requires connection; sets typing until response.
|
|
612
|
+
// ---------------------------------------------------------------------------
|
|
613
|
+
const sendSystemMessage = useCallback(
|
|
614
|
+
(message) => {
|
|
615
|
+
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
|
616
|
+
// eslint-disable-next-line no-console
|
|
617
|
+
console.warn("[AI Chat] Cannot send system message - not connected");
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const payload = { type: "chat", message };
|
|
622
|
+
|
|
623
|
+
if (conversationId) {
|
|
624
|
+
payload.conversationId = conversationId;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
setIsTyping(true);
|
|
628
|
+
setCurrentResponse("");
|
|
629
|
+
|
|
630
|
+
if (typingTimeoutRef.current) {
|
|
631
|
+
clearTimeout(typingTimeoutRef.current);
|
|
632
|
+
}
|
|
633
|
+
typingTimeoutRef.current = setTimeout(() => {
|
|
634
|
+
setIsTyping(false);
|
|
635
|
+
setStatus(null);
|
|
636
|
+
typingTimeoutRef.current = null;
|
|
637
|
+
}, TYPING_TIMEOUT);
|
|
638
|
+
|
|
639
|
+
wsRef.current.send(JSON.stringify(payload));
|
|
640
|
+
},
|
|
641
|
+
[conversationId, TYPING_TIMEOUT]
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
// ---------------------------------------------------------------------------
|
|
645
|
+
// disconnect() — Close WebSocket, clear reconnect and typing timeouts, set state to disconnected.
|
|
646
|
+
// ---------------------------------------------------------------------------
|
|
647
|
+
const disconnect = useCallback(() => {
|
|
648
|
+
if (reconnectTimeoutRef.current) {
|
|
649
|
+
clearTimeout(reconnectTimeoutRef.current);
|
|
650
|
+
reconnectTimeoutRef.current = null;
|
|
651
|
+
}
|
|
652
|
+
if (jwtRefreshTimeoutRef.current) {
|
|
653
|
+
clearTimeout(jwtRefreshTimeoutRef.current);
|
|
654
|
+
jwtRefreshTimeoutRef.current = null;
|
|
655
|
+
}
|
|
656
|
+
if (typingTimeoutRef.current) {
|
|
657
|
+
clearTimeout(typingTimeoutRef.current);
|
|
658
|
+
typingTimeoutRef.current = null;
|
|
659
|
+
}
|
|
660
|
+
if (wsRef.current) {
|
|
661
|
+
wsRef.current.close(1000, "User disconnected");
|
|
662
|
+
wsRef.current = null;
|
|
663
|
+
}
|
|
664
|
+
setIsConnected(false);
|
|
665
|
+
setIsConnecting(false);
|
|
666
|
+
setConnectionState("disconnected");
|
|
667
|
+
}, []);
|
|
668
|
+
|
|
669
|
+
// Ref sync — Keep connect/disconnect refs in sync so callbacks see latest without dependency arrays.
|
|
670
|
+
useEffect(() => {
|
|
671
|
+
connectRef.current = connect;
|
|
672
|
+
}, [connect]);
|
|
673
|
+
useEffect(() => {
|
|
674
|
+
disconnectRef.current = disconnect;
|
|
675
|
+
}, [disconnect]);
|
|
676
|
+
|
|
677
|
+
// ---------------------------------------------------------------------------
|
|
678
|
+
// setSessionId(sid) / getSessionId() — Update or read current session ID (used by messageHandler and history).
|
|
679
|
+
// ---------------------------------------------------------------------------
|
|
680
|
+
const setSessionId = useCallback((sid) => {
|
|
681
|
+
sessionIdRef.current = sid ?? null;
|
|
682
|
+
}, []);
|
|
683
|
+
|
|
684
|
+
const getSessionId = useCallback(() => sessionIdRef.current ?? null, []);
|
|
685
|
+
|
|
686
|
+
// ---------------------------------------------------------------------------
|
|
687
|
+
// loadConversation(msgs, convId, sessId) — Replace messages, conversationId, sessionId
|
|
688
|
+
// with loaded history. If already connected, persist new ids and reconnect so backend uses them.
|
|
689
|
+
// ---------------------------------------------------------------------------
|
|
690
|
+
const loadConversation = useCallback(
|
|
691
|
+
(msgs, convId, sessId) => {
|
|
692
|
+
if (Array.isArray(msgs)) {
|
|
693
|
+
const withTimestamps = msgs.map((msg) => ({
|
|
694
|
+
...msg,
|
|
695
|
+
timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
|
|
696
|
+
animateTyping: false,
|
|
697
|
+
}));
|
|
698
|
+
setMessages(withTimestamps);
|
|
699
|
+
}
|
|
700
|
+
setConversationId(convId ?? null);
|
|
701
|
+
sessionIdRef.current = sessId ?? null;
|
|
702
|
+
setError(null);
|
|
703
|
+
setIsTyping(false);
|
|
704
|
+
setStatus(null);
|
|
705
|
+
setCurrentResponse("");
|
|
706
|
+
|
|
707
|
+
// If we're connected, persist the loaded session/conv and reconnect so the backend uses them
|
|
708
|
+
if (sessId !== null && sessId !== undefined && wsRef.current?.readyState === WebSocket.OPEN) {
|
|
709
|
+
try {
|
|
710
|
+
localStorage.setItem(SESSION_STORAGE_KEY, sessId);
|
|
711
|
+
if (convId !== null && convId !== undefined) {
|
|
712
|
+
localStorage.setItem(CONVERSATION_STORAGE_KEY, convId);
|
|
713
|
+
}
|
|
714
|
+
} catch (err) {
|
|
715
|
+
// eslint-disable-next-line no-console
|
|
716
|
+
console.warn("[AI Chat] Failed to persist session for history load:", err);
|
|
717
|
+
}
|
|
718
|
+
disconnect();
|
|
719
|
+
connect();
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
[connect, disconnect, SESSION_STORAGE_KEY, CONVERSATION_STORAGE_KEY]
|
|
723
|
+
);
|
|
724
|
+
|
|
725
|
+
// ---------------------------------------------------------------------------
|
|
726
|
+
// stopRequest() — Set stopped flag and clear typing; messageHandler checks isStoppedRef and stops appending.
|
|
727
|
+
// ---------------------------------------------------------------------------
|
|
728
|
+
const stopRequest = useCallback(() => {
|
|
729
|
+
isStoppedRef.current = true;
|
|
730
|
+
setIsTyping(false);
|
|
731
|
+
setStatus(null);
|
|
732
|
+
setCurrentResponse("");
|
|
733
|
+
if (typingTimeoutRef.current) {
|
|
734
|
+
clearTimeout(typingTimeoutRef.current);
|
|
735
|
+
typingTimeoutRef.current = null;
|
|
736
|
+
}
|
|
737
|
+
}, []);
|
|
738
|
+
|
|
739
|
+
// ---------------------------------------------------------------------------
|
|
740
|
+
// clearApprovalRequest() — Clear any pending tool-approval UI. clearTyping() — Clear typing state and timeout.
|
|
741
|
+
// ---------------------------------------------------------------------------
|
|
742
|
+
const clearApprovalRequest = useCallback(() => {
|
|
743
|
+
setApprovalRequest(null);
|
|
744
|
+
}, []);
|
|
745
|
+
|
|
746
|
+
const clearTyping = useCallback(() => {
|
|
747
|
+
setIsTyping(false);
|
|
748
|
+
setStatus(null);
|
|
749
|
+
if (typingTimeoutRef.current) {
|
|
750
|
+
clearTimeout(typingTimeoutRef.current);
|
|
751
|
+
typingTimeoutRef.current = null;
|
|
752
|
+
}
|
|
753
|
+
}, []);
|
|
754
|
+
|
|
755
|
+
// ---------------------------------------------------------------------------
|
|
756
|
+
// addAssistantMessage(content) — Append an assistant message (e.g. error or notice).
|
|
757
|
+
// Normalizes content to string (handles object/undefined).
|
|
758
|
+
// ---------------------------------------------------------------------------
|
|
759
|
+
const addAssistantMessage = useCallback((content) => {
|
|
760
|
+
let contentString;
|
|
761
|
+
if (content === null || content === undefined) {
|
|
762
|
+
contentString = __("No content provided.", "wp-module-ai-chat");
|
|
763
|
+
} else if (typeof content === "object") {
|
|
764
|
+
try {
|
|
765
|
+
contentString = JSON.stringify(content, null, 2);
|
|
766
|
+
} catch (e) {
|
|
767
|
+
// eslint-disable-next-line no-console
|
|
768
|
+
console.warn("[useNfdAgentsWebSocket] Failed to stringify content object:", e);
|
|
769
|
+
contentString = String(content);
|
|
770
|
+
}
|
|
771
|
+
} else {
|
|
772
|
+
contentString = String(content);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
setMessages((prev) => [
|
|
776
|
+
...prev,
|
|
777
|
+
{
|
|
778
|
+
id: `msg-${Date.now()}`,
|
|
779
|
+
role: "assistant",
|
|
780
|
+
type: "assistant",
|
|
781
|
+
content: contentString,
|
|
782
|
+
timestamp: new Date(),
|
|
783
|
+
},
|
|
784
|
+
]);
|
|
785
|
+
setIsTyping(false);
|
|
786
|
+
setStatus(null);
|
|
787
|
+
if (typingTimeoutRef.current) {
|
|
788
|
+
clearTimeout(typingTimeoutRef.current);
|
|
789
|
+
typingTimeoutRef.current = null;
|
|
790
|
+
}
|
|
791
|
+
}, []);
|
|
792
|
+
|
|
793
|
+
// ---------------------------------------------------------------------------
|
|
794
|
+
// updateMessage(messageIdOrPredicate, updater) — Update message(s): pass id or (msg) => boolean, then (msg) => newMsg.
|
|
795
|
+
// ---------------------------------------------------------------------------
|
|
796
|
+
const updateMessage = useCallback((messageIdOrPredicate, updater) => {
|
|
797
|
+
setMessages((prev) =>
|
|
798
|
+
prev.map((msg) => {
|
|
799
|
+
const shouldUpdate =
|
|
800
|
+
typeof messageIdOrPredicate === "function"
|
|
801
|
+
? messageIdOrPredicate(msg)
|
|
802
|
+
: msg.id === messageIdOrPredicate;
|
|
803
|
+
return shouldUpdate ? updater(msg) : msg;
|
|
804
|
+
})
|
|
805
|
+
);
|
|
806
|
+
}, []);
|
|
807
|
+
|
|
808
|
+
// ---------------------------------------------------------------------------
|
|
809
|
+
// clearChatHistory() — Clear localStorage, reset all state/refs, close socket, cancel timeouts.
|
|
810
|
+
// ---------------------------------------------------------------------------
|
|
811
|
+
const clearChatHistory = useCallback(() => {
|
|
812
|
+
try {
|
|
813
|
+
clearChatStorage(STORAGE_KEY, CONVERSATION_STORAGE_KEY, SESSION_STORAGE_KEY);
|
|
814
|
+
|
|
815
|
+
setMessages([]);
|
|
816
|
+
setConversationId(null);
|
|
817
|
+
setApprovalRequest(null);
|
|
818
|
+
setIsTyping(false);
|
|
819
|
+
setStatus(null);
|
|
820
|
+
setCurrentResponse("");
|
|
821
|
+
setError(null);
|
|
822
|
+
|
|
823
|
+
sessionIdRef.current = null;
|
|
824
|
+
|
|
825
|
+
if (wsRef.current) {
|
|
826
|
+
wsRef.current.close();
|
|
827
|
+
wsRef.current = null;
|
|
828
|
+
setIsConnected(false);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
hasUserMessageRef.current = false;
|
|
832
|
+
isStoppedRef.current = false;
|
|
833
|
+
|
|
834
|
+
if (typingTimeoutRef.current) {
|
|
835
|
+
clearTimeout(typingTimeoutRef.current);
|
|
836
|
+
typingTimeoutRef.current = null;
|
|
837
|
+
}
|
|
838
|
+
if (reconnectTimeoutRef.current) {
|
|
839
|
+
clearTimeout(reconnectTimeoutRef.current);
|
|
840
|
+
reconnectTimeoutRef.current = null;
|
|
841
|
+
}
|
|
842
|
+
if (jwtRefreshTimeoutRef.current) {
|
|
843
|
+
clearTimeout(jwtRefreshTimeoutRef.current);
|
|
844
|
+
jwtRefreshTimeoutRef.current = null;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
reconnectAttempts.current = 0;
|
|
848
|
+
} catch (err) {
|
|
849
|
+
// eslint-disable-next-line no-console
|
|
850
|
+
console.warn("[AI Chat] Failed to clear chat history:", err);
|
|
851
|
+
}
|
|
852
|
+
}, [STORAGE_KEY, CONVERSATION_STORAGE_KEY, SESSION_STORAGE_KEY]);
|
|
853
|
+
|
|
854
|
+
// ---------------------------------------------------------------------------
|
|
855
|
+
// manualRetry() — Reset reconnect count and call connect() (e.g. after "Retry" button).
|
|
856
|
+
// ---------------------------------------------------------------------------
|
|
857
|
+
const manualRetry = useCallback(() => {
|
|
858
|
+
reconnectAttempts.current = 0;
|
|
859
|
+
setRetryAttempt(0);
|
|
860
|
+
setError(null);
|
|
861
|
+
connect();
|
|
862
|
+
}, [connect]);
|
|
863
|
+
|
|
864
|
+
// ---------------------------------------------------------------------------
|
|
865
|
+
// Effects: autoConnect — On mount or when autoConnect/connectionState changes, connect or disconnect.
|
|
866
|
+
// Skips connect when already failed or connecting; uses ref to avoid running connect on every state change.
|
|
867
|
+
// ---------------------------------------------------------------------------
|
|
868
|
+
useEffect(() => {
|
|
869
|
+
const previousAutoConnect = previousAutoConnectRef.current;
|
|
870
|
+
|
|
871
|
+
if (previousAutoConnect === null) {
|
|
872
|
+
previousAutoConnectRef.current = autoConnect;
|
|
873
|
+
if (autoConnect && connectionState !== "failed" && !connectingRef.current) {
|
|
874
|
+
if (
|
|
875
|
+
!wsRef.current ||
|
|
876
|
+
(wsRef.current.readyState !== WebSocket.OPEN &&
|
|
877
|
+
wsRef.current.readyState !== WebSocket.CONNECTING)
|
|
878
|
+
) {
|
|
879
|
+
connect();
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
if (previousAutoConnect === autoConnect) {
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
previousAutoConnectRef.current = autoConnect;
|
|
890
|
+
|
|
891
|
+
if (autoConnect) {
|
|
892
|
+
if (
|
|
893
|
+
!connectingRef.current &&
|
|
894
|
+
(!wsRef.current ||
|
|
895
|
+
(wsRef.current.readyState !== WebSocket.OPEN &&
|
|
896
|
+
wsRef.current.readyState !== WebSocket.CONNECTING))
|
|
897
|
+
) {
|
|
898
|
+
connect();
|
|
899
|
+
}
|
|
900
|
+
} else {
|
|
901
|
+
disconnect();
|
|
902
|
+
}
|
|
903
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
904
|
+
}, [autoConnect, connectionState]);
|
|
905
|
+
|
|
906
|
+
// ---------------------------------------------------------------------------
|
|
907
|
+
// Effects: persist messages — On messages change, write to localStorage. Skip full persist on first
|
|
908
|
+
// mount (initial load already in state) but do persist if there are meaningful user messages.
|
|
909
|
+
// ---------------------------------------------------------------------------
|
|
910
|
+
useEffect(() => {
|
|
911
|
+
if (isInitialMount.current) {
|
|
912
|
+
isInitialMount.current = false;
|
|
913
|
+
if (messages.length > 0 && hasMeaningfulUserMessage(messages)) {
|
|
914
|
+
persistMessages(STORAGE_KEY, messages);
|
|
915
|
+
}
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
persistMessages(STORAGE_KEY, messages);
|
|
919
|
+
}, [messages, STORAGE_KEY]);
|
|
920
|
+
|
|
921
|
+
// ---------------------------------------------------------------------------
|
|
922
|
+
// Effects: persist conversation ID — Sync conversationId to localStorage when it changes.
|
|
923
|
+
// ---------------------------------------------------------------------------
|
|
924
|
+
useEffect(() => {
|
|
925
|
+
persistConversationId(CONVERSATION_STORAGE_KEY, conversationId);
|
|
926
|
+
}, [conversationId, CONVERSATION_STORAGE_KEY]);
|
|
927
|
+
|
|
928
|
+
// ---------------------------------------------------------------------------
|
|
929
|
+
// Public API
|
|
930
|
+
// ---------------------------------------------------------------------------
|
|
931
|
+
return {
|
|
932
|
+
messages,
|
|
933
|
+
setMessages,
|
|
934
|
+
setConversationId,
|
|
935
|
+
setSessionId,
|
|
936
|
+
loadConversation,
|
|
937
|
+
getSessionId,
|
|
938
|
+
sendMessage,
|
|
939
|
+
sendSystemMessage,
|
|
940
|
+
isConnected,
|
|
941
|
+
isConnecting,
|
|
942
|
+
error,
|
|
943
|
+
isTyping,
|
|
944
|
+
status,
|
|
945
|
+
currentResponse,
|
|
946
|
+
approvalRequest,
|
|
947
|
+
conversationId,
|
|
948
|
+
clearApprovalRequest,
|
|
949
|
+
clearTyping,
|
|
950
|
+
addAssistantMessage,
|
|
951
|
+
updateMessage,
|
|
952
|
+
connect,
|
|
953
|
+
disconnect,
|
|
954
|
+
stopRequest,
|
|
955
|
+
clearChatHistory,
|
|
956
|
+
brandId: configRef.current?.brand_id || null,
|
|
957
|
+
connectionState,
|
|
958
|
+
retryAttempt,
|
|
959
|
+
maxRetries: MAX_RECONNECT_ATTEMPTS,
|
|
960
|
+
manualRetry,
|
|
961
|
+
};
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
export default useNfdAgentsWebSocket;
|