@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,319 @@
1
+ /**
2
+ * Simple Markdown Parser
3
+ *
4
+ * Converts common markdown syntax to HTML for chat messages.
5
+ * Handles: headers, bold, italic, code, lists, links, and line breaks.
6
+ */
7
+
8
+ /**
9
+ * Words to strip from the end of a URL when they were incorrectly included
10
+ * (e.g. ?p=58Is, path/If, or after newline). Primary fix: words *after* the URL.
11
+ * Also used for leading cases: Wordhttp:// and markdown [Word http](url).
12
+ */
13
+ const SENTENCE_STARTER_WORDS_AFTER_URL = [
14
+ "Would",
15
+ "If",
16
+ "Like",
17
+ "And",
18
+ "But",
19
+ "Or",
20
+ "So",
21
+ "Maybe",
22
+ "Perhaps",
23
+ "Well",
24
+ "Yes",
25
+ "No",
26
+ "When",
27
+ "Where",
28
+ "How",
29
+ "Why",
30
+ "It",
31
+ "To",
32
+ "We",
33
+ "Do",
34
+ "Be",
35
+ "As",
36
+ "An",
37
+ "The",
38
+ "You",
39
+ "In",
40
+ "On",
41
+ "At",
42
+ "By",
43
+ "Is",
44
+ ];
45
+
46
+ /** Word immediately followed by http(s) (no space) - pre-pass insert space. */
47
+ const WORD_BEFORE_URL_NO_SPACE = new RegExp(
48
+ `\\b(${SENTENCE_STARTER_WORDS_AFTER_URL.join("|")})(https?:\\/\\/)`,
49
+ "gi"
50
+ );
51
+
52
+ /** One or more prose words + spaces, then rest - for trimming markdown link text. */
53
+ const PROSE_WORDS_THEN_URL = new RegExp(
54
+ `^((${SENTENCE_STARTER_WORDS_AFTER_URL.join("|")})\\s+)+(.+)$`,
55
+ "i"
56
+ );
57
+
58
+ /** Trailing /Word at end of URL (e.g. path/If) - strip Word from URL. */
59
+ const TRAILING_SLASH_WORD = new RegExp(
60
+ `\\/(${SENTENCE_STARTER_WORDS_AFTER_URL.join("|")})$`,
61
+ "i"
62
+ );
63
+
64
+ /** Trailing digit+Word at end of URL (e.g. ?p=58Is) - strip Word from URL. */
65
+ const TRAILING_DIGIT_WORD = new RegExp(
66
+ `(\\d)(${SENTENCE_STARTER_WORDS_AFTER_URL.join("|")})$`,
67
+ "i"
68
+ );
69
+
70
+ const URL_ONLY_PATTERN = /^https?:\/\/[^\s<>"]+$/;
71
+
72
+ /**
73
+ * Check if a string contains markdown syntax
74
+ *
75
+ * @param {string} text - The text to check
76
+ * @return {boolean} True if markdown is detected
77
+ */
78
+ export function containsMarkdown(text) {
79
+ if (!text || typeof text !== "string") {
80
+ return false;
81
+ }
82
+
83
+ // Check for common markdown patterns
84
+ const markdownPatterns = [
85
+ /^#{1,6}\s/m, // Headers
86
+ /\*\*[^*]+\*\*/, // Bold
87
+ /\*[^*]+\*/, // Italic
88
+ /__[^_]+__/, // Bold (underscore)
89
+ /_[^_]+_/, // Italic (underscore)
90
+ /`[^`]+`/, // Inline code
91
+ /```[\s\S]*?```/, // Code blocks
92
+ /^\s*[-*+]\s/m, // Unordered lists
93
+ /^\s*\d+\.\s/m, // Ordered lists
94
+ /\[([^\]]+)\]\(([^)]+)\)/, // Links
95
+ ];
96
+
97
+ return markdownPatterns.some((pattern) => pattern.test(text));
98
+ }
99
+
100
+ /**
101
+ * Parse markdown text to HTML
102
+ *
103
+ * @param {string} text - The markdown text to parse
104
+ * @return {string} HTML string
105
+ */
106
+ export function parseMarkdown(text) {
107
+ if (!text || typeof text !== "string") {
108
+ return "";
109
+ }
110
+
111
+ let html = text;
112
+
113
+ // Escape HTML entities first (but preserve existing HTML)
114
+ html = html
115
+ .replace(/&(?![\w#]+;)/g, "&amp;")
116
+ .replace(/<(?![a-zA-Z/])/g, "&lt;")
117
+ .replace(/(?<![a-zA-Z"])>/g, "&gt;");
118
+
119
+ // Code blocks (``` ... ```) - must be done before other processing
120
+ html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, (match, lang, code) => {
121
+ const escapedCode = code.trim().replace(/</g, "&lt;").replace(/>/g, "&gt;");
122
+ return `<pre><code class="language-${lang || "plaintext"}">${escapedCode}</code></pre>`;
123
+ });
124
+
125
+ // Inline code (` ... `)
126
+ html = html.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>');
127
+
128
+ // Headers (### ... )
129
+ html = html.replace(/^######\s+(.+)$/gm, '<h6 class="chat-h6">$1</h6>');
130
+ html = html.replace(/^#####\s+(.+)$/gm, '<h5 class="chat-h5">$1</h5>');
131
+ html = html.replace(/^####\s+(.+)$/gm, '<h4 class="chat-h4">$1</h4>');
132
+ html = html.replace(/^###\s+(.+)$/gm, '<h3 class="chat-h3">$1</h3>');
133
+ html = html.replace(/^##\s+(.+)$/gm, '<h2 class="chat-h2">$1</h2>');
134
+ html = html.replace(/^#\s+(.+)$/gm, '<h1 class="chat-h1">$1</h1>');
135
+
136
+ // Bold (**text** or __text__)
137
+ html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
138
+ html = html.replace(/__([^_]+)__/g, "<strong>$1</strong>");
139
+
140
+ // Italic (*text* or _text_) - but not inside URLs or code
141
+ html = html.replace(/(?<![*_])\*(?!\*)([^*\n]+)(?<!\*)\*(?!\*)/g, "<em>$1</em>");
142
+ html = html.replace(/(?<![_*])_(?!_)([^_\n]+)(?<!_)_(?!_)/g, "<em>$1</em>");
143
+
144
+ // Links [text](url) - trim leading prose words from link text when rest is a URL
145
+ html = html.replace(
146
+ /\[([^\]]+)\]\(([^)]+)\)/g,
147
+ (match, text, url) => {
148
+ const trimmedText = text.trim();
149
+ const leadingMatch = trimmedText.match(PROSE_WORDS_THEN_URL);
150
+ const rest = leadingMatch ? leadingMatch[3].trim() : "";
151
+ const isRestUrl =
152
+ rest && (URL_ONLY_PATTERN.test(rest) || /^https?:\/\//.test(rest));
153
+ const safeHref = url.replace(/"/g, "&quot;");
154
+ if (leadingMatch && isRestUrl) {
155
+ const leadingWords = leadingMatch[1];
156
+ const safeUrlText = rest
157
+ .replace(/&/g, "&amp;")
158
+ .replace(/</g, "&lt;")
159
+ .replace(/>/g, "&gt;")
160
+ .replace(/"/g, "&quot;");
161
+ return `${leadingWords}<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${safeUrlText}</a>`;
162
+ }
163
+ const safeText = text
164
+ .replace(/&/g, "&amp;")
165
+ .replace(/</g, "&lt;")
166
+ .replace(/>/g, "&gt;")
167
+ .replace(/"/g, "&quot;");
168
+ return `<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${safeText}</a>`;
169
+ }
170
+ );
171
+
172
+ // Unordered lists - collect consecutive list items
173
+ html = html.replace(/^(\s*)[-*+]\s+(.+)$/gm, (match, indent, content) => {
174
+ const level = Math.floor(indent.length / 2);
175
+ return `<li class="chat-li" data-level="${level}">${content}</li>`;
176
+ });
177
+
178
+ // Wrap consecutive list items in <ul>
179
+ html = html.replace(/((?:<li[^>]*>.*?<\/li>\s*)+)/g, (match) => {
180
+ const cleanedItems = match.replace(/(<\/li>)\s+(<li)/g, "$1$2");
181
+ return `<ul class="chat-ul">${cleanedItems}</ul>`;
182
+ });
183
+
184
+ // Ordered lists
185
+ html = html.replace(/^(\s*)\d+\.\s+(.+)$/gm, (match, indent, content) => {
186
+ const level = Math.floor(indent.length / 2);
187
+ return `<oli class="chat-oli" data-level="${level}">${content}</oli>`;
188
+ });
189
+
190
+ // Wrap consecutive ordered list items in <ol>
191
+ html = html.replace(/((?:<oli[^>]*>.*?<\/oli>\s*)+)/g, (match) => {
192
+ const cleanedItems = match
193
+ .replace(/(<\/oli>)\s+(<oli)/g, "$1$2")
194
+ .replace(/<\/?oli/g, (m) => m.replace("oli", "li"));
195
+ return `<ol class="chat-ol">${cleanedItems}</ol>`;
196
+ });
197
+
198
+ // Horizontal rules
199
+ html = html.replace(/^---+$/gm, '<hr class="chat-hr" />');
200
+
201
+ // Blockquotes
202
+ html = html.replace(/^>\s+(.+)$/gm, '<blockquote class="chat-blockquote">$1</blockquote>');
203
+
204
+ // Paragraphs - wrap text blocks that aren't already wrapped
205
+ const blocks = html.split(/\n\n+/);
206
+ html = blocks
207
+ .map((block) => {
208
+ const trimmed = block.trim();
209
+ if (
210
+ trimmed.startsWith("<h") ||
211
+ trimmed.startsWith("<ul") ||
212
+ trimmed.startsWith("<ol") ||
213
+ trimmed.startsWith("<pre") ||
214
+ trimmed.startsWith("<blockquote") ||
215
+ trimmed.startsWith("<hr") ||
216
+ trimmed.startsWith("<p")
217
+ ) {
218
+ return trimmed;
219
+ }
220
+ if (trimmed) {
221
+ return `<p class="chat-p">${trimmed}</p>`;
222
+ }
223
+ return "";
224
+ })
225
+ .filter(Boolean)
226
+ .join("");
227
+
228
+ // Convert single line breaks within paragraphs to <br>
229
+ html = html.replace(/<p([^>]*)>([\s\S]*?)<\/p>/g, (match, attrs, content) => {
230
+ const processedContent = content.trim().replace(/\n/g, "<br>");
231
+ return `<p${attrs}>${processedContent}</p>`;
232
+ });
233
+
234
+ // Clean up stray <br> tags between block elements
235
+ html = html.replace(/<br\s*\/?>\s*(<\/?(ul|ol|li|p|h[1-6]|pre|blockquote|hr))/gi, "$1");
236
+ html = html.replace(/(<\/(ul|ol|li|p|h[1-6]|pre|blockquote)>)\s*<br\s*\/?>/gi, "$1");
237
+
238
+ // Remove empty paragraphs
239
+ html = html.replace(/<p[^>]*>\s*<\/p>/g, "");
240
+
241
+ // Clean up multiple consecutive <br> tags
242
+ html = html.replace(/(<br\s*\/?>){2,}/g, "<br>");
243
+
244
+ // Linkify any remaining bare URLs in text content (e.g. inside list items)
245
+ html = linkifyBareUrlsInHtml(html);
246
+
247
+ return html;
248
+ }
249
+
250
+ /**
251
+ * Linkify bare http(s) URLs that appear in HTML text nodes (between > and <).
252
+ * Avoids touching URLs inside existing href attributes.
253
+ *
254
+ * @param {string} html - HTML string (e.g. from parseMarkdown)
255
+ * @return {string} HTML with bare URLs in text wrapped in <a> tags
256
+ */
257
+ function linkifyBareUrlsInHtml(html) {
258
+ if (!html || typeof html !== "string") {
259
+ return "";
260
+ }
261
+ return html.replace(/>([^<]*)</g, (match, textNode) => ">" + linkifyUrls(textNode) + "<");
262
+ }
263
+
264
+ /**
265
+ * Replace bare http(s) URLs in plain text with clickable anchor tags.
266
+ * Use only on plain text (no existing HTML) so we don't double-wrap or break attributes.
267
+ *
268
+ * @param {string} text - Plain text that may contain URLs
269
+ * @return {string} Text with URLs wrapped in <a href="..." target="_blank" rel="noopener noreferrer">...</a>
270
+ */
271
+ export function linkifyUrls(text) {
272
+ if (!text || typeof text !== "string") {
273
+ return "";
274
+ }
275
+ // Pre-pass: insert space between leading word and "http(s)://" so "Wouldhttp://" -> "Would http://"
276
+ let normalizedText = text.replace(
277
+ WORD_BEFORE_URL_NO_SPACE,
278
+ "$1 $2"
279
+ );
280
+ // Match URL only at start or after whitespace/opening punctuation (word boundary)
281
+ const urlPatternWithBoundary =
282
+ /(^|[\s(\["'])(https?:\/\/[^\s<>"]*(?:\n[^\s<>"]*)*)/g;
283
+ return normalizedText.replace(
284
+ urlPatternWithBoundary,
285
+ (fullMatch, before, url) => {
286
+ // Normalize: remove internal whitespace/newlines so href is valid
287
+ const normalized = url.replace(/\s+/g, "").trim();
288
+ let trimmed = normalized.replace(/[.,;:!?)\]]+$/, "");
289
+ let wordAfterLink = "";
290
+ // Strip trailing /Word or digit+Word (words after URL glued in - e.g. ".../If" or "?p=58Is")
291
+ const slashMatch = trimmed.match(TRAILING_SLASH_WORD);
292
+ if (slashMatch) {
293
+ wordAfterLink = slashMatch[1];
294
+ trimmed = trimmed.replace(TRAILING_SLASH_WORD, "");
295
+ }
296
+ const digitWordMatch = trimmed.match(TRAILING_DIGIT_WORD);
297
+ if (digitWordMatch) {
298
+ wordAfterLink = digitWordMatch[2];
299
+ trimmed = trimmed.replace(TRAILING_DIGIT_WORD, "$1");
300
+ }
301
+ if (!trimmed) {
302
+ return fullMatch;
303
+ }
304
+ const safeHref = trimmed.replace(/"/g, "&quot;");
305
+ const safeText = trimmed
306
+ .replace(/&/g, "&amp;")
307
+ .replace(/</g, "&lt;")
308
+ .replace(/>/g, "&gt;")
309
+ .replace(/"/g, "&quot;");
310
+ return `${before ?? ""}<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${safeText}</a>${wordAfterLink ? " " + wordAfterLink : ""}`;
311
+ }
312
+ );
313
+ }
314
+
315
+ export default {
316
+ containsMarkdown,
317
+ parseMarkdown,
318
+ linkifyUrls,
319
+ };
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Archive a conversation to localStorage for later retrieval in chat history.
3
+ * Called when user clicks "New Chat" to preserve the current conversation.
4
+ *
5
+ * @param {Array} messages - Array of message objects
6
+ * @param {string} sessionId - Session ID for the conversation
7
+ * @param {string} conversationId - Conversation ID from backend
8
+ * @param {string} consumer - Consumer identifier (must match useNfdAgentsWebSocket for same surface)
9
+ * @param {Object} [options] - Optional settings
10
+ * @param {number} [options.maxHistoryItems=3] - Max number of chats to keep in archive
11
+ * @return {void}
12
+ */
13
+ import { getChatHistoryStorageKeys } from "../../constants/nfdAgents/storageKeys";
14
+
15
+ export function archiveConversation(messages, sessionId, conversationId, consumer, options = {}) {
16
+ if (!messages || messages.length === 0) {
17
+ return;
18
+ }
19
+
20
+ const hasMeaningful = messages.some(
21
+ (m) => (m.role === "user" || m.type === "user") && m.content && String(m.content).trim()
22
+ );
23
+ if (!hasMeaningful) {
24
+ return;
25
+ }
26
+
27
+ // Never archive with both ids null; avoids dedupe removing existing entries
28
+ if (sessionId === null && conversationId === null) {
29
+ return;
30
+ }
31
+
32
+ const maxHistoryItems = options.maxHistoryItems ?? 3;
33
+ const keys = getChatHistoryStorageKeys(consumer);
34
+
35
+ try {
36
+ const archive = JSON.parse(window.localStorage.getItem(keys.archive) || "[]");
37
+ const newEntry = {
38
+ sessionId,
39
+ conversationId,
40
+ messages: messages.map((m) => ({
41
+ ...m,
42
+ timestamp: m.timestamp instanceof Date ? m.timestamp.toISOString() : m.timestamp,
43
+ })),
44
+ archivedAt: new Date().toISOString(),
45
+ };
46
+ // Dedupe so the same conversation doesn't appear multiple times: by conversationId when set, else by sessionId.
47
+ // This keeps the latest 3 distinct chats and avoids wiping older history when conversationId is null.
48
+ const filtered =
49
+ conversationId !== null && conversationId !== undefined
50
+ ? archive.filter((entry) => entry.conversationId !== conversationId)
51
+ : archive.filter((entry) => entry.sessionId !== sessionId);
52
+ filtered.unshift(newEntry);
53
+ window.localStorage.setItem(keys.archive, JSON.stringify(filtered.slice(0, maxHistoryItems)));
54
+ } catch (err) {
55
+ // eslint-disable-next-line no-console
56
+ console.warn("[Chat History] Failed to archive conversation:", err);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Remove a conversation from the archive (e.g. when user clears the chat).
62
+ *
63
+ * @param {string} conversationId - Conversation ID to remove (can be null)
64
+ * @param {string} sessionId - Session ID to remove (can be null)
65
+ * @param {string} consumer - Consumer identifier (must match useNfdAgentsWebSocket for same surface)
66
+ * @return {void}
67
+ */
68
+ export function removeConversationFromArchive(conversationId, sessionId, consumer) {
69
+ const keys = getChatHistoryStorageKeys(consumer);
70
+ try {
71
+ const archive = JSON.parse(window.localStorage.getItem(keys.archive) || "[]");
72
+ const filtered = archive.filter(
73
+ (entry) => entry.conversationId !== conversationId || entry.sessionId !== sessionId
74
+ );
75
+ if (filtered.length !== archive.length) {
76
+ window.localStorage.setItem(keys.archive, JSON.stringify(filtered));
77
+ }
78
+ } catch (err) {
79
+ // eslint-disable-next-line no-console
80
+ console.warn("[Chat History] Failed to remove conversation from archive:", err);
81
+ }
82
+ }
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Pure helpers for ChatHistoryList: conversation filtering and extraction.
3
+ * No React or i18n; safe to unit test.
4
+ */
5
+
6
+ /**
7
+ * True if the conversation has at least one user message with non-empty content.
8
+ *
9
+ * @param {Object} conversation - Conversation with messages array
10
+ * @return {boolean} True if at least one meaningful user message exists.
11
+ */
12
+ export function hasMeaningfulUserMessage(conversation) {
13
+ const msgs = conversation.messages || conversation;
14
+ return (
15
+ Array.isArray(msgs) &&
16
+ msgs.some(
17
+ (m) => (m.role === "user" || m.type === "user") && m.content && String(m.content).trim()
18
+ )
19
+ );
20
+ }
21
+
22
+ /**
23
+ * Extract conversations from legacy messages (without sessionId) using time-based grouping.
24
+ * Messages more than 5 minutes apart start a new conversation.
25
+ *
26
+ * @param {Array} messages - Messages array
27
+ * @return {Array} Array of conversation objects with sessionId and messages.
28
+ */
29
+ export function extractLegacyConversations(messages) {
30
+ const conversations = [];
31
+ let currentConversation = [];
32
+ let lastTimestamp = null;
33
+
34
+ messages.forEach((msg) => {
35
+ const msgTimestamp = msg.timestamp ? new Date(msg.timestamp).getTime() : Date.now();
36
+
37
+ if (lastTimestamp && msgTimestamp - lastTimestamp > 5 * 60 * 1000) {
38
+ if (currentConversation.length > 0) {
39
+ conversations.push({
40
+ sessionId: null,
41
+ messages: [...currentConversation],
42
+ });
43
+ currentConversation = [];
44
+ }
45
+ }
46
+
47
+ currentConversation.push(msg);
48
+ lastTimestamp = msgTimestamp;
49
+ });
50
+
51
+ if (currentConversation.length > 0) {
52
+ conversations.push({
53
+ sessionId: null,
54
+ messages: currentConversation,
55
+ });
56
+ }
57
+
58
+ return conversations.reverse();
59
+ }
60
+
61
+ /**
62
+ * Extract conversation sessions from stored messages (by sessionId, with legacy fallback).
63
+ *
64
+ * @param {Array} messages - Array of message objects with timestamps and optional sessionId
65
+ * @param {number} maxHistoryItems - Max conversations to return
66
+ * @return {Array} Array of conversation objects (sessionId, messages).
67
+ */
68
+ export function extractConversations(messages, maxHistoryItems) {
69
+ if (!Array.isArray(messages) || messages.length === 0) {
70
+ return [];
71
+ }
72
+
73
+ const sessionGroups = {};
74
+ const legacyMessages = [];
75
+
76
+ messages.forEach((msg) => {
77
+ if (msg.sessionId) {
78
+ if (!sessionGroups[msg.sessionId]) {
79
+ sessionGroups[msg.sessionId] = {
80
+ sessionId: msg.sessionId,
81
+ messages: [],
82
+ timestamp: msg.timestamp ? new Date(msg.timestamp).getTime() : 0,
83
+ };
84
+ }
85
+ sessionGroups[msg.sessionId].messages.push(msg);
86
+ const msgTime = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
87
+ if (msgTime > sessionGroups[msg.sessionId].timestamp) {
88
+ sessionGroups[msg.sessionId].timestamp = msgTime;
89
+ }
90
+ } else {
91
+ legacyMessages.push(msg);
92
+ }
93
+ });
94
+
95
+ let conversations = Object.values(sessionGroups)
96
+ .sort((a, b) => b.timestamp - a.timestamp)
97
+ .map((session) => ({
98
+ sessionId: session.sessionId,
99
+ messages: session.messages,
100
+ }));
101
+
102
+ if (legacyMessages.length > 0 && conversations.length < maxHistoryItems) {
103
+ const legacyConversations = extractLegacyConversations(legacyMessages);
104
+ conversations = [...conversations, ...legacyConversations];
105
+ }
106
+
107
+ conversations = conversations.filter(hasMeaningfulUserMessage);
108
+ return conversations.slice(0, maxHistoryItems);
109
+ }
110
+
111
+ /**
112
+ * Get the latest message timestamp for relative time (legacy conversations without archivedAt).
113
+ *
114
+ * @param {Object} conversation - Conversation with messages
115
+ * @return {Date|null} Latest message date or null.
116
+ */
117
+ export function getLatestMessageTime(conversation) {
118
+ const messages = conversation.messages || conversation;
119
+ if (!Array.isArray(messages) || messages.length === 0) {
120
+ return null;
121
+ }
122
+ let latest = 0;
123
+ messages.forEach((msg) => {
124
+ const t = msg.timestamp ? new Date(msg.timestamp).getTime() : 0;
125
+ if (t > latest) {
126
+ latest = t;
127
+ }
128
+ });
129
+ return latest ? new Date(latest) : null;
130
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * NFD Agents Config Fetcher
3
+ *
4
+ * Fetches agent configuration from the REST API endpoint.
5
+ * Always uses rest_route parameter for REST API calls (never wp-json directly)
6
+ * so that requests work when permalinks are not set.
7
+ * Handles URL parsing (full URL vs relative REST path), apiFetch calls,
8
+ * and maps all known error codes to i18n error messages.
9
+ */
10
+
11
+ import { __, sprintf } from "@wordpress/i18n";
12
+ import apiFetch from "@wordpress/api-fetch";
13
+ import { buildRestApiUrl, convertWpJsonToRestRoute } from "../restApi.js";
14
+
15
+ /**
16
+ * Get base URL for the current site (origin or home URL including subdirectory).
17
+ *
18
+ * @return {string} Base URL
19
+ */
20
+ function getBaseUrl() {
21
+ if (typeof window === "undefined") {
22
+ return "";
23
+ }
24
+ const config = window.nfdAIChat || {};
25
+ return config.homeUrl || window.location.origin;
26
+ }
27
+
28
+ /**
29
+ * Fetch agent configuration from the backend.
30
+ * Uses rest_route query parameter for the request so it works regardless of permalink settings.
31
+ *
32
+ * @param {Object} options
33
+ * @param {string} options.configEndpoint REST API endpoint (full URL or relative path). Example full URL: 'https://example.com/wp-json/nfd-agents/chat/v1/config'. Example relative path: 'nfd-agents/chat/v1/config'.
34
+ * @param {string} options.consumer Consumer identifier (required). Sent as query param `consumer`. Valid values are defined by the backend.
35
+ * @return {Promise<Object>} Config object from backend
36
+ * @throws {Error} With i18n message on failure
37
+ */
38
+ export async function fetchAgentConfig({ configEndpoint, consumer }) {
39
+ try {
40
+ // Extract REST path from configEndpoint (e.g. 'nfd-agents/chat/v1/config')
41
+ let path = configEndpoint;
42
+ let baseUrl = getBaseUrl();
43
+ let useWpJsonConversion = false;
44
+ let wpJsonBaseUrl = "";
45
+
46
+ if (configEndpoint.startsWith("http://") || configEndpoint.startsWith("https://")) {
47
+ const urlObj = new URL(configEndpoint);
48
+ if (urlObj.searchParams.has("rest_route")) {
49
+ path = urlObj.searchParams.get("rest_route");
50
+ } else if (urlObj.pathname.includes("/wp-json/")) {
51
+ path = urlObj.pathname.replace(/^\/wp-json\//, "").replace(/^\/wp-json/, "");
52
+ useWpJsonConversion = true;
53
+ const beforeWpJson = urlObj.pathname.split("/wp-json")[0].replace(/\/$/, "");
54
+ wpJsonBaseUrl = urlObj.origin + (beforeWpJson || "/");
55
+ } else {
56
+ path = urlObj.pathname.replace(/^\//, "");
57
+ }
58
+ // Base URL for rest_route: origin + pathname (without query), or path before /wp-json
59
+ if (!useWpJsonConversion) {
60
+ if (urlObj.pathname.includes("/wp-json")) {
61
+ const beforeWpJson = urlObj.pathname.split("/wp-json")[0].replace(/\/$/, "");
62
+ baseUrl = urlObj.origin + (beforeWpJson || "/");
63
+ } else {
64
+ baseUrl = urlObj.origin + (urlObj.pathname || "/");
65
+ }
66
+ }
67
+ }
68
+
69
+ const cleanPath = path.replace(/^\//, "");
70
+
71
+ let url;
72
+ if (useWpJsonConversion) {
73
+ url = convertWpJsonToRestRoute(configEndpoint, wpJsonBaseUrl);
74
+ } else {
75
+ const lastSlash = cleanPath.lastIndexOf("/");
76
+ const namespace = lastSlash === -1 ? "" : cleanPath.slice(0, lastSlash);
77
+ const route = lastSlash === -1 ? cleanPath : cleanPath.slice(lastSlash + 1);
78
+ url = buildRestApiUrl(namespace, route, baseUrl);
79
+ }
80
+ url = `${url}&consumer=${encodeURIComponent(consumer)}`;
81
+
82
+ const config = await apiFetch({
83
+ url,
84
+ parse: true,
85
+ });
86
+
87
+ return config;
88
+ } catch (err) {
89
+ // eslint-disable-next-line no-console
90
+ console.error("[AI Chat] Failed to fetch config:", err);
91
+ // eslint-disable-next-line no-console
92
+ console.error("[AI Chat] Error details:", {
93
+ message: err.message,
94
+ code: err.code,
95
+ data: err.data,
96
+ status: err.data?.status,
97
+ statusText: err.data?.statusText,
98
+ });
99
+
100
+ // Handle apiFetch errors
101
+ let errorMessage = err.message || __("Failed to connect", "wp-module-ai-chat");
102
+
103
+ if (err.data?.message) {
104
+ errorMessage = err.data.message;
105
+ } else if (err.message && err.message !== "Could not get a valid response from the server.") {
106
+ errorMessage = err.message;
107
+ }
108
+
109
+ if (err.code === "rest_forbidden" || err.data?.status === 403) {
110
+ errorMessage = __("Access denied. Please check your capabilities.", "wp-module-ai-chat");
111
+ } else if (err.code === "rest_no_route" || err.data?.status === 404) {
112
+ errorMessage = __(
113
+ "Config endpoint not found. Please ensure the backend is deployed.",
114
+ "wp-module-ai-chat"
115
+ );
116
+ } else if (err.code === "gateway_url_not_configured") {
117
+ errorMessage = __(
118
+ "Gateway URL not configured. Set NFD_AGENTS_CHAT_GATEWAY_URL in wp-config.php.",
119
+ "wp-module-ai-chat"
120
+ );
121
+ } else if (err.code === "jarvis_jwt_fetch_failed" || err.code === "huapi_token_fetch_failed") {
122
+ errorMessage = __(
123
+ "Failed to fetch authentication token from Hiive. Check your connection or set NFD_AGENTS_CHAT_DEBUG_TOKEN for local development.",
124
+ "wp-module-ai-chat"
125
+ );
126
+ } else if (err.data?.status) {
127
+ errorMessage = sprintf(
128
+ /* translators: %1$s: HTTP status, %2$s: status text */
129
+ __("Failed to fetch config: %1$s %2$s", "wp-module-ai-chat"),
130
+ err.data.status,
131
+ err.data.statusText || errorMessage
132
+ );
133
+ }
134
+
135
+ throw new Error(errorMessage);
136
+ }
137
+ }