@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,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
|
+
};
|