@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,55 @@
1
+ /**
2
+ * NFD Agents Greeting Utilities
3
+ *
4
+ * Utilities for detecting initial greeting messages from agents.
5
+ * Used to filter out system greetings that shouldn't be shown to users.
6
+ */
7
+
8
+ /**
9
+ * Check if a message is an initial greeting that should be filtered out
10
+ *
11
+ * @param {string} content Message content to check
12
+ * @return {boolean} True if message is an initial greeting
13
+ */
14
+ export const isInitialGreeting = (content) => {
15
+ if (!content || typeof content !== "string") {
16
+ return false;
17
+ }
18
+
19
+ const normalized = content.toLowerCase().trim();
20
+
21
+ // Common greeting patterns - more comprehensive matching
22
+ const greetingPatterns = [
23
+ /^hello!?\s+how\s+can\s+i\s+assist\s+you/i,
24
+ /^hi!?\s+how\s+can\s+i\s+help/i,
25
+ /^hello!?\s+how\s+can\s+i\s+help/i,
26
+ /^hi\s+there!?\s+how\s+can/i,
27
+ /^greetings!?\s+how\s+can/i,
28
+ /^how\s+can\s+i\s+assist\s+you\s+today/i,
29
+ /^how\s+can\s+i\s+help\s+you\s+today/i,
30
+ // Match "Hello! How can I assist you today? Feel free to ask me anything..."
31
+ /^hello!?\s+how\s+can\s+i\s+assist\s+you\s+today/i,
32
+ /^hello!?\s+how\s+can\s+i\s+assist\s+you.*feel\s+free/i,
33
+ ];
34
+
35
+ // Check if message matches greeting patterns
36
+ const isGreeting = greetingPatterns.some((pattern) => pattern.test(normalized));
37
+
38
+ // Also check for messages that contain greeting keywords and are likely initial greetings
39
+ // This catches variations like "Hello! How can I assist you today? Feel free to ask me anything..."
40
+ const hasGreetingKeywords =
41
+ normalized.includes("hello") &&
42
+ (normalized.includes("assist") || normalized.includes("help")) &&
43
+ (normalized.includes("today") ||
44
+ normalized.includes("feel free") ||
45
+ normalized.includes("ask"));
46
+
47
+ // Check for very short messages that are likely greetings
48
+ const isShortGreeting =
49
+ normalized.length < 150 &&
50
+ ((normalized.includes("hello") &&
51
+ (normalized.includes("assist") || normalized.includes("help"))) ||
52
+ (normalized.includes("hi") && normalized.includes("help")));
53
+
54
+ return isGreeting || hasGreetingKeywords || isShortGreeting;
55
+ };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * JWT utilities for NFD Agents.
3
+ *
4
+ * Decodes JWT payload (no signature verification) for scheduling only.
5
+ * Do not use for security decisions.
6
+ */
7
+
8
+ /**
9
+ * Base64url-decode a string (JWT segment).
10
+ *
11
+ * @param {string} str Base64url-encoded string
12
+ * @return {string} Decoded string
13
+ */
14
+ function base64UrlDecode(str) {
15
+ let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
16
+ // Base64url often omits padding; atob requires length % 4 === 0.
17
+ const pad = base64.length % 4;
18
+ if (pad) {
19
+ base64 += "===".slice(0, 4 - pad);
20
+ }
21
+ try {
22
+ return decodeURIComponent(
23
+ atob(base64)
24
+ .split("")
25
+ .map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
26
+ .join("")
27
+ );
28
+ } catch (e) {
29
+ return atob(base64);
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Get JWT expiration timestamp in milliseconds (Unix ms).
35
+ * Decodes payload without verification; for scheduling only.
36
+ *
37
+ * @param {string} jwt JWT string (header.payload.signature)
38
+ * @return {number|null} exp * 1000, or null if missing/invalid
39
+ */
40
+ export function getJwtExpirationMs(jwt) {
41
+ if (!jwt || typeof jwt !== "string") {
42
+ return null;
43
+ }
44
+ const parts = jwt.split(".");
45
+ if (parts.length !== 3) {
46
+ return null;
47
+ }
48
+ try {
49
+ const payloadJson = base64UrlDecode(parts[1]);
50
+ const payload = JSON.parse(payloadJson);
51
+ const exp = payload?.exp;
52
+ if (typeof exp !== "number" || !Number.isFinite(exp)) {
53
+ return null;
54
+ }
55
+ return exp * 1000;
56
+ } catch (e) {
57
+ return null;
58
+ }
59
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * NFD Agents WebSocket Message Handler
3
+ *
4
+ * Extracts the entire ws.onmessage protocol handling from useNfdAgentsWebSocket.
5
+ * Factory returns a handleMessage(data) function wired to the hook's state setters and refs.
6
+ */
7
+
8
+ import { getStatusForEventType } from "../../constants/nfdAgents/typingStatus";
9
+ import { isInitialGreeting } from "./greeting";
10
+
11
+ /**
12
+ * Helper: extract and filter a message string from a WebSocket payload.
13
+ * Returns null when the content should be suppressed (system noise, empty, filtered greeting).
14
+ *
15
+ * @param {string|undefined} raw Raw message string
16
+ * @param {boolean} hasUserMessage Whether user has sent a message yet
17
+ * @return {string|null} Cleaned message or null to suppress
18
+ */
19
+ const filterMessage = (raw, hasUserMessage) => {
20
+ const trimmed = raw?.trim();
21
+ if (
22
+ !trimmed ||
23
+ trimmed === "No content provided" ||
24
+ trimmed === "sales_requested" ||
25
+ trimmed.toLowerCase() === "sales_requested"
26
+ ) {
27
+ return null;
28
+ }
29
+ if (!hasUserMessage && trimmed.length < 150 && isInitialGreeting(trimmed)) {
30
+ return null;
31
+ }
32
+ return trimmed;
33
+ };
34
+
35
+ /**
36
+ * Helper: clear the typing timeout ref.
37
+ *
38
+ * @param {Object} typingTimeoutRef React ref holding the timeout ID
39
+ */
40
+ const clearTypingTimeout = (typingTimeoutRef) => {
41
+ if (typingTimeoutRef.current) {
42
+ clearTimeout(typingTimeoutRef.current);
43
+ typingTimeoutRef.current = null;
44
+ }
45
+ };
46
+
47
+ /**
48
+ * Helper: finalize typing state after content is received.
49
+ *
50
+ * @param {Object} deps Subset of handler deps
51
+ * @param {Function} deps.setIsTyping State setter
52
+ * @param {Function} deps.setStatus State setter
53
+ * @param {Function} deps.setCurrentResponse State setter
54
+ * @param {Object} deps.typingTimeoutRef React ref holding the timeout ID
55
+ */
56
+ const finalizeTyping = ({ setIsTyping, setStatus, setCurrentResponse, typingTimeoutRef }) => {
57
+ setIsTyping(false);
58
+ setStatus(null);
59
+ setCurrentResponse("");
60
+ clearTypingTimeout(typingTimeoutRef);
61
+ };
62
+
63
+ /**
64
+ * Helper: create and append an assistant message to state.
65
+ *
66
+ * @param {Function} setMessages State setter
67
+ * @param {string} content Message content
68
+ * @param {string} [idSuffix] Optional suffix for message ID
69
+ */
70
+ const addAssistantMsg = (setMessages, content, idSuffix = "") => {
71
+ setMessages((prev) => [
72
+ ...prev,
73
+ {
74
+ id: `msg-${Date.now()}${idSuffix}`,
75
+ role: "assistant",
76
+ type: "assistant",
77
+ content,
78
+ timestamp: new Date(),
79
+ animateTyping: true,
80
+ },
81
+ ]);
82
+ };
83
+
84
+ /**
85
+ * Create a WebSocket message handler wired to the hook's state.
86
+ *
87
+ * @param {Object} deps Dependencies from the hook
88
+ * @param {Object} deps.isStoppedRef Ref — skip messages after stop
89
+ * @param {Object} deps.hasUserMessageRef Ref — controls greeting filtering
90
+ * @param {Object} deps.typingTimeoutRef Ref — typing indicator timeout
91
+ * @param {number} deps.typingTimeout TYPING_TIMEOUT constant
92
+ * @param {Function} deps.setIsTyping State setter
93
+ * @param {Function} deps.setStatus State setter
94
+ * @param {Function} deps.setCurrentResponse State setter
95
+ * @param {Function} deps.setMessages State setter
96
+ * @param {Function} deps.setConversationId State setter
97
+ * @param {Function} deps.setError State setter
98
+ * @param {Function} deps.saveSessionId callback(sessionId) — persist to ref + localStorage
99
+ * @param {Function} deps.saveConversationId callback(id) — persist to localStorage
100
+ * @return {Function} handleMessage(data) — call with parsed JSON from ws.onmessage
101
+ */
102
+ export function createMessageHandler(deps) {
103
+ const {
104
+ isStoppedRef,
105
+ hasUserMessageRef,
106
+ typingTimeoutRef,
107
+ typingTimeout,
108
+ setIsTyping,
109
+ setStatus,
110
+ setCurrentResponse,
111
+ setMessages,
112
+ setConversationId,
113
+ setError,
114
+ saveSessionId,
115
+ saveConversationId,
116
+ } = deps;
117
+
118
+ return function handleMessage(data) {
119
+ // If user has stopped generation, ignore all messages except session_established
120
+ if (isStoppedRef.current && data.type !== "session_established") {
121
+ return;
122
+ }
123
+
124
+ // --- session_established ---
125
+ if (data.type === "session_established") {
126
+ if (data.session_id) {
127
+ saveSessionId(data.session_id);
128
+ }
129
+ return;
130
+ }
131
+
132
+ // --- typing_start ---
133
+ if (data.type === "typing_start") {
134
+ setIsTyping(true);
135
+ setStatus(getStatusForEventType("typing_start"));
136
+ clearTypingTimeout(typingTimeoutRef);
137
+ return;
138
+ }
139
+
140
+ // --- typing_stop ---
141
+ if (data.type === "typing_stop") {
142
+ setIsTyping(false);
143
+ setStatus(null);
144
+ setCurrentResponse("");
145
+ clearTypingTimeout(typingTimeoutRef);
146
+ return;
147
+ }
148
+
149
+ // --- streaming_chunk / chunk ---
150
+ if (data.type === "streaming_chunk" || data.type === "chunk") {
151
+ if (isStoppedRef.current) {
152
+ return;
153
+ }
154
+ const content = data.content || data.chunk || data.text || "";
155
+ if (content) {
156
+ setCurrentResponse((prev) => {
157
+ const newContent = prev + content;
158
+ if (
159
+ !hasUserMessageRef.current &&
160
+ newContent.length < 100 &&
161
+ isInitialGreeting(newContent)
162
+ ) {
163
+ return "";
164
+ }
165
+ setIsTyping(true);
166
+ if (typingTimeoutRef.current) {
167
+ clearTimeout(typingTimeoutRef.current);
168
+ }
169
+ typingTimeoutRef.current = setTimeout(() => {
170
+ setIsTyping(false);
171
+ setStatus(null);
172
+ typingTimeoutRef.current = null;
173
+ }, typingTimeout);
174
+ return newContent;
175
+ });
176
+ }
177
+ return;
178
+ }
179
+
180
+ // --- structured_output ---
181
+ if (data.type === "structured_output") {
182
+ const humanInputRequest = data.response_content?.content?.human_input_request;
183
+
184
+ if (humanInputRequest) {
185
+ const inputType = (
186
+ humanInputRequest.input_type ||
187
+ humanInputRequest.inputType ||
188
+ ""
189
+ ).toUpperCase();
190
+
191
+ if (inputType === "APPROVAL_REQUEST") {
192
+ if (data.conversation_id || data.conversationId) {
193
+ const newConversationId = data.conversation_id || data.conversationId;
194
+ setConversationId(newConversationId);
195
+ saveConversationId(newConversationId);
196
+ }
197
+ return;
198
+ }
199
+ }
200
+
201
+ const structuredMessage = data.message || data.response_content?.message;
202
+ const filtered = filterMessage(structuredMessage, hasUserMessageRef.current);
203
+
204
+ if (filtered) {
205
+ // Finalize any current streaming response first
206
+ setCurrentResponse((prev) => {
207
+ if (prev) {
208
+ addAssistantMsg(setMessages, prev, "-streaming");
209
+ }
210
+ return "";
211
+ });
212
+
213
+ addAssistantMsg(setMessages, filtered);
214
+ finalizeTyping(deps);
215
+ }
216
+ return;
217
+ }
218
+
219
+ // --- tool_call ---
220
+ if (data.type === "tool_call") {
221
+ setStatus(getStatusForEventType("tool_call"));
222
+ return;
223
+ }
224
+
225
+ // --- tool_result ---
226
+ if (data.type === "tool_result") {
227
+ setStatus(getStatusForEventType("tool_result"));
228
+ if (data.conversation_id || data.conversationId) {
229
+ const newConversationId = data.conversation_id || data.conversationId;
230
+ setConversationId(newConversationId);
231
+ saveConversationId(newConversationId);
232
+ }
233
+ return;
234
+ }
235
+
236
+ // --- message / complete ---
237
+ if (data.type === "message" || data.type === "complete") {
238
+ let hasContent = false;
239
+ setCurrentResponse((prev) => {
240
+ if (prev) {
241
+ const trimmedContent = prev.trim();
242
+ if (
243
+ trimmedContent === "No content provided" ||
244
+ trimmedContent === "sales_requested" ||
245
+ trimmedContent.toLowerCase() === "sales_requested"
246
+ ) {
247
+ return "";
248
+ }
249
+ if (!hasUserMessageRef.current && prev.length < 150 && isInitialGreeting(prev)) {
250
+ return "";
251
+ }
252
+ setMessages((prevMessages) => [
253
+ ...prevMessages,
254
+ {
255
+ id: `msg-${Date.now()}`,
256
+ role: "assistant",
257
+ type: "assistant",
258
+ content: prev,
259
+ timestamp: new Date(),
260
+ animateTyping: true,
261
+ },
262
+ ]);
263
+ hasContent = true;
264
+ }
265
+ return "";
266
+ });
267
+
268
+ if (!hasContent) {
269
+ const payloadMessage = data.message || data.response_content?.message;
270
+ const filtered = filterMessage(payloadMessage, hasUserMessageRef.current);
271
+ if (filtered) {
272
+ addAssistantMsg(setMessages, filtered);
273
+ hasContent = true;
274
+ }
275
+ }
276
+
277
+ if (hasContent) {
278
+ finalizeTyping(deps);
279
+ }
280
+ return;
281
+ }
282
+
283
+ // --- handoff_accept ---
284
+ if (data.type === "handoff_accept") {
285
+ setStatus(getStatusForEventType("handoff_accept"));
286
+ return;
287
+ }
288
+
289
+ // --- handoff_request ---
290
+ if (data.type === "handoff_request") {
291
+ setStatus(getStatusForEventType("handoff_request"));
292
+ const messageContent = data.message || data.response_content?.message;
293
+ const filtered = filterMessage(messageContent, hasUserMessageRef.current);
294
+
295
+ if (!filtered) {
296
+ setCurrentResponse("");
297
+ return;
298
+ }
299
+
300
+ addAssistantMsg(setMessages, filtered);
301
+ finalizeTyping(deps);
302
+ return;
303
+ }
304
+
305
+ // --- error ---
306
+ if (data.type === "error") {
307
+ setError(data.message || data.error || "An error occurred");
308
+ setIsTyping(false);
309
+ setStatus(null);
310
+ setCurrentResponse("");
311
+ return;
312
+ }
313
+
314
+ // --- generic fallback (message with content) ---
315
+ if (data.message || data.response_content?.message) {
316
+ const messageContent = data.message || data.response_content?.message;
317
+ const filtered = filterMessage(messageContent, hasUserMessageRef.current);
318
+
319
+ if (!filtered) {
320
+ setCurrentResponse("");
321
+ return;
322
+ }
323
+
324
+ addAssistantMsg(setMessages, filtered);
325
+ finalizeTyping(deps);
326
+ }
327
+ };
328
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * NFD Agents Storage Utilities
3
+ *
4
+ * localStorage persistence operations for chat history, conversation ID,
5
+ * and session ID. Extracted from useNfdAgentsWebSocket for reuse and testability.
6
+ */
7
+
8
+ /* global localStorage */
9
+
10
+ /**
11
+ * Check if a messages array contains at least one user message with non-empty content.
12
+ *
13
+ * @param {Array} msgs Messages array
14
+ * @return {boolean} True if at least one user message has non-empty content
15
+ */
16
+ export const hasMeaningfulUserMessage = (msgs) =>
17
+ Array.isArray(msgs) &&
18
+ msgs.some(
19
+ (m) => (m.role === "user" || m.type === "user") && m.content && String(m.content).trim()
20
+ );
21
+
22
+ /**
23
+ * Restore chat history, conversation ID, and session ID from localStorage.
24
+ *
25
+ * @param {string} storageKey Key for messages
26
+ * @param {string} conversationKey Key for conversation ID
27
+ * @param {string} sessionKey Key for session ID
28
+ * @return {{ messages: Array, conversationId: string|null, sessionId: string|null }} Restored messages and IDs, or empty defaults on failure or missing data
29
+ */
30
+ export const restoreChat = (storageKey, conversationKey, sessionKey) => {
31
+ try {
32
+ const storedMessages = localStorage.getItem(storageKey);
33
+ const storedConversationId = localStorage.getItem(conversationKey);
34
+ const storedSessionId = localStorage.getItem(sessionKey);
35
+
36
+ if (storedMessages) {
37
+ const parsedMessages = JSON.parse(storedMessages);
38
+ if (Array.isArray(parsedMessages) && parsedMessages.length > 0) {
39
+ const restoredMessages = parsedMessages.map((msg) => ({
40
+ ...msg,
41
+ timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
42
+ animateTyping: false,
43
+ }));
44
+ return {
45
+ messages: restoredMessages,
46
+ conversationId: storedConversationId || null,
47
+ sessionId: storedSessionId || null,
48
+ };
49
+ }
50
+ }
51
+ } catch (err) {
52
+ // eslint-disable-next-line no-console
53
+ console.warn("[AI Chat] Failed to restore chat history from localStorage:", err);
54
+ }
55
+ return { messages: [], conversationId: null, sessionId: null };
56
+ };
57
+
58
+ /**
59
+ * Persist messages to localStorage. Removes the key if messages are not meaningful.
60
+ *
61
+ * @param {string} storageKey Key for messages
62
+ * @param {Array} messages Messages array
63
+ */
64
+ export const persistMessages = (storageKey, messages) => {
65
+ try {
66
+ if (hasMeaningfulUserMessage(messages)) {
67
+ localStorage.setItem(storageKey, JSON.stringify(messages));
68
+ } else {
69
+ localStorage.removeItem(storageKey);
70
+ }
71
+ } catch (err) {
72
+ // eslint-disable-next-line no-console
73
+ console.warn("[AI Chat] Failed to save messages to localStorage:", err);
74
+ }
75
+ };
76
+
77
+ /**
78
+ * Persist conversation ID to localStorage. Removes the key when null.
79
+ *
80
+ * @param {string} conversationKey Key for conversation ID
81
+ * @param {string|null} conversationId Conversation ID or null
82
+ */
83
+ export const persistConversationId = (conversationKey, conversationId) => {
84
+ try {
85
+ if (conversationId) {
86
+ localStorage.setItem(conversationKey, conversationId);
87
+ } else {
88
+ localStorage.removeItem(conversationKey);
89
+ }
90
+ } catch (err) {
91
+ // eslint-disable-next-line no-console
92
+ console.warn("[AI Chat] Failed to save conversation ID to localStorage:", err);
93
+ }
94
+ };
95
+
96
+ /**
97
+ * Clear all chat-related keys from localStorage.
98
+ *
99
+ * @param {string} storageKey Key for messages
100
+ * @param {string} conversationKey Key for conversation ID
101
+ * @param {string} sessionKey Key for session ID
102
+ */
103
+ export const clearChatStorage = (storageKey, conversationKey, sessionKey) => {
104
+ try {
105
+ localStorage.removeItem(storageKey);
106
+ localStorage.removeItem(conversationKey);
107
+ localStorage.removeItem(sessionKey);
108
+ } catch (err) {
109
+ // eslint-disable-next-line no-console
110
+ console.warn("[AI Chat] Failed to clear chat storage:", err);
111
+ }
112
+ };