@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.
Files changed (51) hide show
  1. package/README.md +98 -0
  2. package/package.json +51 -0
  3. package/src/components/chat/ChatHeader.jsx +63 -0
  4. package/src/components/chat/ChatHistoryDropdown.jsx +182 -0
  5. package/src/components/chat/ChatHistoryList.jsx +257 -0
  6. package/src/components/chat/ChatInput.jsx +157 -0
  7. package/src/components/chat/ChatMessage.jsx +157 -0
  8. package/src/components/chat/ChatMessages.jsx +137 -0
  9. package/src/components/chat/WelcomeScreen.jsx +115 -0
  10. package/src/components/icons/CloseIcon.jsx +27 -0
  11. package/src/components/icons/SparklesOutlineIcon.jsx +30 -0
  12. package/src/components/icons/index.js +5 -0
  13. package/src/components/ui/AILogo.jsx +47 -0
  14. package/src/components/ui/BluBetaHeading.jsx +18 -0
  15. package/src/components/ui/ErrorAlert.jsx +30 -0
  16. package/src/components/ui/HeaderBar.jsx +34 -0
  17. package/src/components/ui/SuggestionButton.jsx +28 -0
  18. package/src/components/ui/ToolExecutionList.jsx +264 -0
  19. package/src/components/ui/TypingIndicator.jsx +268 -0
  20. package/src/constants/nfdAgents/input.js +13 -0
  21. package/src/constants/nfdAgents/storageKeys.js +102 -0
  22. package/src/constants/nfdAgents/typingStatus.js +40 -0
  23. package/src/constants/nfdAgents/websocket.js +44 -0
  24. package/src/hooks/useAIChat.js +432 -0
  25. package/src/hooks/useNfdAgentsWebSocket.js +964 -0
  26. package/src/index.js +66 -0
  27. package/src/services/mcpClient.js +433 -0
  28. package/src/services/openaiClient.js +416 -0
  29. package/src/styles/_branding.scss +151 -0
  30. package/src/styles/_history.scss +180 -0
  31. package/src/styles/_input.scss +170 -0
  32. package/src/styles/_messages.scss +272 -0
  33. package/src/styles/_mixins.scss +21 -0
  34. package/src/styles/_typing-indicator.scss +162 -0
  35. package/src/styles/_ui.scss +173 -0
  36. package/src/styles/_vars.scss +103 -0
  37. package/src/styles/_welcome.scss +81 -0
  38. package/src/styles/app.scss +10 -0
  39. package/src/utils/helpers.js +75 -0
  40. package/src/utils/markdownParser.js +319 -0
  41. package/src/utils/nfdAgents/archiveConversation.js +82 -0
  42. package/src/utils/nfdAgents/chatHistoryList.js +130 -0
  43. package/src/utils/nfdAgents/configFetcher.js +137 -0
  44. package/src/utils/nfdAgents/greeting.js +55 -0
  45. package/src/utils/nfdAgents/jwtUtils.js +59 -0
  46. package/src/utils/nfdAgents/messageHandler.js +328 -0
  47. package/src/utils/nfdAgents/storage.js +112 -0
  48. package/src/utils/nfdAgents/typingIndicatorToolDisplay.js +180 -0
  49. package/src/utils/nfdAgents/url.js +101 -0
  50. package/src/utils/restApi.js +87 -0
  51. 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;