@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,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, "&")
|
|
116
|
+
.replace(/<(?![a-zA-Z/])/g, "<")
|
|
117
|
+
.replace(/(?<![a-zA-Z"])>/g, ">");
|
|
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, "<").replace(/>/g, ">");
|
|
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, """);
|
|
154
|
+
if (leadingMatch && isRestUrl) {
|
|
155
|
+
const leadingWords = leadingMatch[1];
|
|
156
|
+
const safeUrlText = rest
|
|
157
|
+
.replace(/&/g, "&")
|
|
158
|
+
.replace(/</g, "<")
|
|
159
|
+
.replace(/>/g, ">")
|
|
160
|
+
.replace(/"/g, """);
|
|
161
|
+
return `${leadingWords}<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${safeUrlText}</a>`;
|
|
162
|
+
}
|
|
163
|
+
const safeText = text
|
|
164
|
+
.replace(/&/g, "&")
|
|
165
|
+
.replace(/</g, "<")
|
|
166
|
+
.replace(/>/g, ">")
|
|
167
|
+
.replace(/"/g, """);
|
|
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, """);
|
|
305
|
+
const safeText = trimmed
|
|
306
|
+
.replace(/&/g, "&")
|
|
307
|
+
.replace(/</g, "<")
|
|
308
|
+
.replace(/>/g, ">")
|
|
309
|
+
.replace(/"/g, """);
|
|
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
|
+
}
|