@newfold/wp-module-ai-chat 1.0.1 → 1.0.2
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/build/index-rtl.css +1 -0
- package/build/index.asset.php +1 -0
- package/build/index.css +1 -0
- package/build/index.js +8 -0
- package/package.json +11 -9
- package/src/components/chat/ChatHeader.jsx +0 -63
- package/src/components/chat/ChatHistoryDropdown.jsx +0 -182
- package/src/components/chat/ChatHistoryList.jsx +0 -257
- package/src/components/chat/ChatInput.jsx +0 -157
- package/src/components/chat/ChatMessage.jsx +0 -157
- package/src/components/chat/ChatMessages.jsx +0 -137
- package/src/components/chat/WelcomeScreen.jsx +0 -115
- package/src/components/icons/CloseIcon.jsx +0 -27
- package/src/components/icons/SparklesOutlineIcon.jsx +0 -30
- package/src/components/icons/index.js +0 -5
- package/src/components/ui/AILogo.jsx +0 -47
- package/src/components/ui/BluBetaHeading.jsx +0 -18
- package/src/components/ui/ErrorAlert.jsx +0 -30
- package/src/components/ui/HeaderBar.jsx +0 -34
- package/src/components/ui/SuggestionButton.jsx +0 -28
- package/src/components/ui/ToolExecutionList.jsx +0 -264
- package/src/components/ui/TypingIndicator.jsx +0 -268
- package/src/constants/nfdAgents/input.js +0 -13
- package/src/constants/nfdAgents/storageKeys.js +0 -102
- package/src/constants/nfdAgents/typingStatus.js +0 -40
- package/src/constants/nfdAgents/websocket.js +0 -44
- package/src/hooks/useAIChat.js +0 -432
- package/src/hooks/useNfdAgentsWebSocket.js +0 -964
- package/src/index.js +0 -66
- package/src/services/mcpClient.js +0 -433
- package/src/services/openaiClient.js +0 -416
- package/src/styles/_branding.scss +0 -151
- package/src/styles/_history.scss +0 -180
- package/src/styles/_input.scss +0 -170
- package/src/styles/_messages.scss +0 -272
- package/src/styles/_mixins.scss +0 -21
- package/src/styles/_typing-indicator.scss +0 -162
- package/src/styles/_ui.scss +0 -173
- package/src/styles/_vars.scss +0 -103
- package/src/styles/_welcome.scss +0 -81
- package/src/styles/app.scss +0 -10
- package/src/utils/helpers.js +0 -75
- package/src/utils/markdownParser.js +0 -319
- package/src/utils/nfdAgents/archiveConversation.js +0 -82
- package/src/utils/nfdAgents/chatHistoryList.js +0 -130
- package/src/utils/nfdAgents/configFetcher.js +0 -137
- package/src/utils/nfdAgents/greeting.js +0 -55
- package/src/utils/nfdAgents/jwtUtils.js +0 -59
- package/src/utils/nfdAgents/messageHandler.js +0 -328
- package/src/utils/nfdAgents/storage.js +0 -112
- package/src/utils/nfdAgents/typingIndicatorToolDisplay.js +0 -180
- package/src/utils/nfdAgents/url.js +0 -101
- package/src/utils/restApi.js +0 -87
- package/src/utils/sanitizeHtml.js +0 -94
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@newfold/wp-module-ai-chat",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Reusable AI Chat core for WordPress modules",
|
|
5
5
|
"license": "GPL-2.0-or-later",
|
|
6
6
|
"publishConfig": {
|
|
@@ -10,17 +10,19 @@
|
|
|
10
10
|
"type": "git",
|
|
11
11
|
"url": "git+https://github.com/newfold-labs/wp-module-ai-chat.git"
|
|
12
12
|
},
|
|
13
|
-
"main": "
|
|
13
|
+
"main": "build/index.js",
|
|
14
|
+
"style": "build/index.css",
|
|
14
15
|
"files": [
|
|
15
|
-
"
|
|
16
|
+
"build"
|
|
16
17
|
],
|
|
17
18
|
"exports": {
|
|
18
|
-
".": "./
|
|
19
|
-
"./
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
"
|
|
23
|
-
"
|
|
19
|
+
".": "./build/index.js",
|
|
20
|
+
"./style.css": "./build/index.css"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "wp-scripts build ./src/index.js --config ./scripts/webpack.config.js",
|
|
24
|
+
"lint": "wp-scripts lint-js ./src",
|
|
25
|
+
"prepublishOnly": "npm run build"
|
|
24
26
|
},
|
|
25
27
|
"peerDependencies": {
|
|
26
28
|
"@wordpress/element": "^6.0.0",
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* WordPress dependencies
|
|
3
|
-
*/
|
|
4
|
-
import { __ } from "@wordpress/i18n";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Internal dependencies
|
|
8
|
-
*/
|
|
9
|
-
import { CloseIcon, SparklesOutlineIcon } from "../icons";
|
|
10
|
-
import BluBetaHeading from "../ui/BluBetaHeading";
|
|
11
|
-
import HeaderBar from "../ui/HeaderBar";
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* ChatHeader Component
|
|
15
|
-
*
|
|
16
|
-
* Header for the chat panel: white background; left = outline sparkles icon + title + BETA pill.
|
|
17
|
-
* New chat (+) and Close (×) on the right. Built on shared HeaderBar layout.
|
|
18
|
-
*
|
|
19
|
-
* @param {Object} props - Component props.
|
|
20
|
-
* @param {string} [props.title] - Title text next to logo (e.g. "Blu Chat"). Default "Blu Chat".
|
|
21
|
-
* @param {Function} props.onNewChat - Called when user clicks New chat (+).
|
|
22
|
-
* @param {Function} props.onClose - Called when user clicks Close (×).
|
|
23
|
-
* @param {import('react').ReactNode} [props.extraActions] - Optional node(s) rendered between + and × (e.g. history dropdown trigger).
|
|
24
|
-
* @param {boolean} [props.newChatDisabled] - When true, the New chat (+) button is disabled (e.g. when already on welcome screen).
|
|
25
|
-
* @return {JSX.Element} The ChatHeader component.
|
|
26
|
-
*/
|
|
27
|
-
const ChatHeader = ({ title, onNewChat, onClose, extraActions, newChatDisabled = false }) => (
|
|
28
|
-
<HeaderBar
|
|
29
|
-
logo={<SparklesOutlineIcon width={20} height={20} />}
|
|
30
|
-
title={title || __("Blu Chat", "wp-module-ai-chat")}
|
|
31
|
-
badge={<BluBetaHeading />}
|
|
32
|
-
rightActions={
|
|
33
|
-
<>
|
|
34
|
-
{typeof onNewChat === "function" && (
|
|
35
|
-
<button
|
|
36
|
-
type="button"
|
|
37
|
-
className="nfd-ai-chat-header__btn nfd-ai-chat-header__btn--new"
|
|
38
|
-
onClick={newChatDisabled ? undefined : onNewChat}
|
|
39
|
-
disabled={newChatDisabled}
|
|
40
|
-
aria-label={__("New chat", "wp-module-ai-chat")}
|
|
41
|
-
title={__("New chat", "wp-module-ai-chat")}
|
|
42
|
-
>
|
|
43
|
-
+
|
|
44
|
-
</button>
|
|
45
|
-
)}
|
|
46
|
-
{extraActions}
|
|
47
|
-
{typeof onClose === "function" && (
|
|
48
|
-
<button
|
|
49
|
-
type="button"
|
|
50
|
-
className="nfd-ai-chat-header__btn nfd-ai-chat-header__btn--close"
|
|
51
|
-
onClick={onClose}
|
|
52
|
-
aria-label={__("Close", "wp-module-ai-chat")}
|
|
53
|
-
title={__("Close", "wp-module-ai-chat")}
|
|
54
|
-
>
|
|
55
|
-
<CloseIcon />
|
|
56
|
-
</button>
|
|
57
|
-
)}
|
|
58
|
-
</>
|
|
59
|
-
}
|
|
60
|
-
/>
|
|
61
|
-
);
|
|
62
|
-
|
|
63
|
-
export default ChatHeader;
|
|
@@ -1,182 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Chat History Dropdown Component
|
|
3
|
-
*
|
|
4
|
-
* Clock icon button that toggles a dropdown panel containing ChatHistoryList.
|
|
5
|
-
* Click outside and Escape close the dropdown.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
useState,
|
|
10
|
-
useRef,
|
|
11
|
-
useEffect,
|
|
12
|
-
useLayoutEffect,
|
|
13
|
-
useCallback,
|
|
14
|
-
createPortal,
|
|
15
|
-
} from "@wordpress/element";
|
|
16
|
-
import { __ } from "@wordpress/i18n";
|
|
17
|
-
import ChatHistoryList from "./ChatHistoryList";
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Clock / history icon - inline SVG
|
|
21
|
-
*
|
|
22
|
-
* @param {Object} props - Props to spread onto the SVG element.
|
|
23
|
-
*/
|
|
24
|
-
const ClockIcon = (props) => (
|
|
25
|
-
<svg
|
|
26
|
-
width="16"
|
|
27
|
-
height="16"
|
|
28
|
-
viewBox="0 0 24 24"
|
|
29
|
-
fill="none"
|
|
30
|
-
xmlns="http://www.w3.org/2000/svg"
|
|
31
|
-
aria-hidden="true"
|
|
32
|
-
focusable="false"
|
|
33
|
-
{...props}
|
|
34
|
-
>
|
|
35
|
-
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" />
|
|
36
|
-
<path d="M12 7v5l3 3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
37
|
-
</svg>
|
|
38
|
-
);
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Dropdown trigger and portal-rendered panel with ChatHistoryList.
|
|
42
|
-
*
|
|
43
|
-
* @param {Object} props
|
|
44
|
-
* @param {string} props.consumer - Must match useNfdAgentsWebSocket for same consumer
|
|
45
|
-
* @param {boolean} props.open
|
|
46
|
-
* @param {Function} props.onOpenChange
|
|
47
|
-
* @param {Function} props.onSelectConversation
|
|
48
|
-
* @param {number} [props.refreshTrigger=0]
|
|
49
|
-
* @param {boolean} [props.disabled=false]
|
|
50
|
-
* @param {number} [props.maxHistoryItems]
|
|
51
|
-
* @return {JSX.Element} Dropdown trigger and portal-rendered history panel.
|
|
52
|
-
*/
|
|
53
|
-
const ChatHistoryDropdown = ({
|
|
54
|
-
consumer,
|
|
55
|
-
open,
|
|
56
|
-
onOpenChange,
|
|
57
|
-
onSelectConversation,
|
|
58
|
-
refreshTrigger = 0,
|
|
59
|
-
disabled = false,
|
|
60
|
-
maxHistoryItems,
|
|
61
|
-
}) => {
|
|
62
|
-
const triggerRef = useRef(null);
|
|
63
|
-
const panelRef = useRef(null);
|
|
64
|
-
const [position, setPosition] = useState({ top: 0, left: 0, openUp: false });
|
|
65
|
-
|
|
66
|
-
const updatePosition = useCallback(() => {
|
|
67
|
-
if (!triggerRef.current) {
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
const rect = triggerRef.current.getBoundingClientRect();
|
|
71
|
-
const panelHeight = 240;
|
|
72
|
-
const spaceBelow = window.innerHeight - rect.bottom;
|
|
73
|
-
const openUp = spaceBelow < panelHeight && rect.top > spaceBelow;
|
|
74
|
-
setPosition({
|
|
75
|
-
top: openUp ? rect.top : rect.bottom,
|
|
76
|
-
left: rect.right,
|
|
77
|
-
openUp,
|
|
78
|
-
});
|
|
79
|
-
}, []);
|
|
80
|
-
|
|
81
|
-
useLayoutEffect(() => {
|
|
82
|
-
if (open) {
|
|
83
|
-
updatePosition();
|
|
84
|
-
}
|
|
85
|
-
}, [open, updatePosition]);
|
|
86
|
-
|
|
87
|
-
useEffect(() => {
|
|
88
|
-
if (!open) {
|
|
89
|
-
return;
|
|
90
|
-
}
|
|
91
|
-
const handleResize = () => updatePosition();
|
|
92
|
-
window.addEventListener("resize", handleResize);
|
|
93
|
-
return () => window.removeEventListener("resize", handleResize);
|
|
94
|
-
}, [open, updatePosition]);
|
|
95
|
-
|
|
96
|
-
useEffect(() => {
|
|
97
|
-
if (!open) {
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
const handleClickOutside = (e) => {
|
|
101
|
-
if (triggerRef.current?.contains(e.target) || panelRef.current?.contains(e.target)) {
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
onOpenChange(false);
|
|
105
|
-
};
|
|
106
|
-
const handleEscape = (e) => {
|
|
107
|
-
if (e.key === "Escape") {
|
|
108
|
-
onOpenChange(false);
|
|
109
|
-
}
|
|
110
|
-
};
|
|
111
|
-
document.addEventListener("mousedown", handleClickOutside);
|
|
112
|
-
document.addEventListener("keydown", handleEscape);
|
|
113
|
-
return () => {
|
|
114
|
-
document.removeEventListener("mousedown", handleClickOutside);
|
|
115
|
-
document.removeEventListener("keydown", handleEscape);
|
|
116
|
-
};
|
|
117
|
-
}, [open, onOpenChange]);
|
|
118
|
-
|
|
119
|
-
const handleSelect = useCallback(
|
|
120
|
-
(conversation) => {
|
|
121
|
-
onSelectConversation(conversation);
|
|
122
|
-
onOpenChange(false);
|
|
123
|
-
},
|
|
124
|
-
[onSelectConversation, onOpenChange]
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
const handleTriggerClick = useCallback(() => {
|
|
128
|
-
if (disabled) {
|
|
129
|
-
return;
|
|
130
|
-
}
|
|
131
|
-
onOpenChange(!open);
|
|
132
|
-
}, [disabled, open, onOpenChange]);
|
|
133
|
-
|
|
134
|
-
const dropdownPanel = (
|
|
135
|
-
<div
|
|
136
|
-
ref={panelRef}
|
|
137
|
-
className="nfd-ai-chat-history-dropdown"
|
|
138
|
-
role="dialog"
|
|
139
|
-
aria-label={__("Chat history", "wp-module-ai-chat")}
|
|
140
|
-
style={{
|
|
141
|
-
position: "fixed",
|
|
142
|
-
top: position.openUp ? "auto" : position.top,
|
|
143
|
-
bottom: position.openUp ? window.innerHeight - position.top : "auto",
|
|
144
|
-
left: "auto",
|
|
145
|
-
right: window.innerWidth - position.left,
|
|
146
|
-
zIndex: 100000,
|
|
147
|
-
}}
|
|
148
|
-
>
|
|
149
|
-
<div className="nfd-ai-chat-history-dropdown-inner">
|
|
150
|
-
<ChatHistoryList
|
|
151
|
-
consumer={consumer}
|
|
152
|
-
onSelectConversation={handleSelect}
|
|
153
|
-
disabled={disabled}
|
|
154
|
-
refreshTrigger={open ? refreshTrigger : 0}
|
|
155
|
-
emptyMessage={__("No conversations yet.", "wp-module-ai-chat")}
|
|
156
|
-
maxHistoryItems={maxHistoryItems}
|
|
157
|
-
/>
|
|
158
|
-
</div>
|
|
159
|
-
</div>
|
|
160
|
-
);
|
|
161
|
-
|
|
162
|
-
return (
|
|
163
|
-
<div className="nfd-ai-chat-history-dropdown-wrapper">
|
|
164
|
-
<button
|
|
165
|
-
ref={triggerRef}
|
|
166
|
-
type="button"
|
|
167
|
-
className={`nfd-ai-chat-history-dropdown-trigger ${open ? "is-open" : ""}`}
|
|
168
|
-
onClick={handleTriggerClick}
|
|
169
|
-
disabled={disabled}
|
|
170
|
-
aria-expanded={open}
|
|
171
|
-
aria-haspopup="true"
|
|
172
|
-
aria-label={__("Chat history", "wp-module-ai-chat")}
|
|
173
|
-
title={__("Chat history", "wp-module-ai-chat")}
|
|
174
|
-
>
|
|
175
|
-
<ClockIcon />
|
|
176
|
-
</button>
|
|
177
|
-
{open && createPortal(dropdownPanel, document.body)}
|
|
178
|
-
</div>
|
|
179
|
-
);
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
export default ChatHistoryDropdown;
|
|
@@ -1,257 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Chat History List Component
|
|
3
|
-
*
|
|
4
|
-
* Displays previous chat sessions from localStorage. Shows archived conversations
|
|
5
|
-
* (from "+" new chat) and falls back to current history key for legacy.
|
|
6
|
-
* Use consumer that matches useNfdAgentsWebSocket for the same consumer.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { useState, useEffect, useCallback } from "@wordpress/element";
|
|
10
|
-
import { __ } from "@wordpress/i18n";
|
|
11
|
-
import { History, Trash2 } from "lucide-react";
|
|
12
|
-
import { getChatHistoryStorageKeys } from "../../constants/nfdAgents/storageKeys";
|
|
13
|
-
import {
|
|
14
|
-
hasMeaningfulUserMessage,
|
|
15
|
-
extractConversations,
|
|
16
|
-
getLatestMessageTime,
|
|
17
|
-
} from "../../utils/nfdAgents/chatHistoryList";
|
|
18
|
-
|
|
19
|
-
const DEFAULT_MAX_HISTORY_ITEMS = 3;
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Human-readable relative time (e.g. 2m, 2h, 2d). Uses i18n for "Just now".
|
|
23
|
-
*
|
|
24
|
-
* @param {Date|string} dateOrString - Date or ISO string
|
|
25
|
-
* @return {string} Relative time string (e.g. "2m", "2h", "Just now").
|
|
26
|
-
*/
|
|
27
|
-
const getRelativeTime = (dateOrString) => {
|
|
28
|
-
const date = dateOrString instanceof Date ? dateOrString : new Date(dateOrString);
|
|
29
|
-
const now = Date.now();
|
|
30
|
-
const diffMs = now - date.getTime();
|
|
31
|
-
const diffM = Math.floor(diffMs / 60000);
|
|
32
|
-
if (diffM < 1) {
|
|
33
|
-
return __("Just now", "wp-module-ai-chat");
|
|
34
|
-
}
|
|
35
|
-
if (diffM < 60) {
|
|
36
|
-
return `${diffM}m`;
|
|
37
|
-
}
|
|
38
|
-
const diffH = Math.floor(diffMs / 3600000);
|
|
39
|
-
if (diffH < 24) {
|
|
40
|
-
return `${diffH}h`;
|
|
41
|
-
}
|
|
42
|
-
const diffD = Math.floor(diffMs / 86400000);
|
|
43
|
-
if (diffD < 7) {
|
|
44
|
-
return `${diffD}d`;
|
|
45
|
-
}
|
|
46
|
-
return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Get the title for a conversation (first user message). Uses i18n for fallback.
|
|
51
|
-
*
|
|
52
|
-
* @param {Object} conversation - Conversation with messages
|
|
53
|
-
* @return {string} Title string (first user message content or fallback).
|
|
54
|
-
*/
|
|
55
|
-
const getConversationTitle = (conversation) => {
|
|
56
|
-
const messages = conversation.messages || conversation;
|
|
57
|
-
const firstUserMessage = messages.find((msg) => msg.role === "user" || msg.type === "user");
|
|
58
|
-
|
|
59
|
-
if (firstUserMessage && firstUserMessage.content) {
|
|
60
|
-
const content = firstUserMessage.content;
|
|
61
|
-
return content.length > 50 ? content.substring(0, 50) + "..." : content;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
return __("Previous conversation", "wp-module-ai-chat");
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Chat history list UI: load from storage, render items, handle select/delete.
|
|
69
|
-
*
|
|
70
|
-
* @param {Object} props
|
|
71
|
-
* @param {string} props.consumer - Must match useNfdAgentsWebSocket for same consumer
|
|
72
|
-
* @param {Function} props.onSelectConversation
|
|
73
|
-
* @param {number} [props.refreshTrigger=0]
|
|
74
|
-
* @param {boolean} [props.disabled=false]
|
|
75
|
-
* @param {string} [props.emptyMessage]
|
|
76
|
-
* @param {number} [props.maxHistoryItems=3]
|
|
77
|
-
* @return {JSX.Element|null} List of history items or empty state or null.
|
|
78
|
-
*/
|
|
79
|
-
const ChatHistoryList = ({
|
|
80
|
-
consumer,
|
|
81
|
-
onSelectConversation,
|
|
82
|
-
refreshTrigger = 0,
|
|
83
|
-
disabled = false,
|
|
84
|
-
emptyMessage = null,
|
|
85
|
-
maxHistoryItems = DEFAULT_MAX_HISTORY_ITEMS,
|
|
86
|
-
}) => {
|
|
87
|
-
const [conversations, setConversations] = useState([]);
|
|
88
|
-
const keys = getChatHistoryStorageKeys(consumer);
|
|
89
|
-
|
|
90
|
-
useEffect(() => {
|
|
91
|
-
try {
|
|
92
|
-
const rawArchive = window.localStorage.getItem(keys.archive);
|
|
93
|
-
if (rawArchive) {
|
|
94
|
-
const archive = JSON.parse(rawArchive);
|
|
95
|
-
if (Array.isArray(archive) && archive.length > 0) {
|
|
96
|
-
const trimmed = archive.slice(0, maxHistoryItems);
|
|
97
|
-
if (trimmed.length < archive.length) {
|
|
98
|
-
window.localStorage.setItem(keys.archive, JSON.stringify(trimmed));
|
|
99
|
-
}
|
|
100
|
-
const list = trimmed
|
|
101
|
-
.map((entry) => {
|
|
102
|
-
const messages = (entry.messages || []).map((msg) => ({
|
|
103
|
-
...msg,
|
|
104
|
-
timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
|
|
105
|
-
}));
|
|
106
|
-
const archivedAt = entry.archivedAt ? new Date(entry.archivedAt) : null;
|
|
107
|
-
return {
|
|
108
|
-
sessionId: entry.sessionId ?? null,
|
|
109
|
-
conversationId: entry.conversationId ?? null,
|
|
110
|
-
messages,
|
|
111
|
-
archivedAt,
|
|
112
|
-
};
|
|
113
|
-
})
|
|
114
|
-
.filter(hasMeaningfulUserMessage);
|
|
115
|
-
setConversations(list);
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const storedMessages = window.localStorage.getItem(keys.history);
|
|
121
|
-
if (storedMessages) {
|
|
122
|
-
const parsedMessages = JSON.parse(storedMessages);
|
|
123
|
-
if (Array.isArray(parsedMessages) && parsedMessages.length > 0) {
|
|
124
|
-
const messages = parsedMessages.map((msg) => ({
|
|
125
|
-
...msg,
|
|
126
|
-
timestamp: msg.timestamp ? new Date(msg.timestamp) : new Date(),
|
|
127
|
-
}));
|
|
128
|
-
const extractedConversations = extractConversations(messages, maxHistoryItems);
|
|
129
|
-
setConversations(extractedConversations);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
} catch (err) {
|
|
133
|
-
// eslint-disable-next-line no-console
|
|
134
|
-
console.warn("[Chat History] Failed to load chat history:", err);
|
|
135
|
-
}
|
|
136
|
-
}, [consumer, refreshTrigger, maxHistoryItems, keys.archive, keys.history]);
|
|
137
|
-
|
|
138
|
-
const handleHistoryClick = useCallback(
|
|
139
|
-
(conversation) => {
|
|
140
|
-
if (disabled) {
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
try {
|
|
144
|
-
const messages = conversation.messages || conversation;
|
|
145
|
-
const messagesToStore = messages.map((msg) => ({
|
|
146
|
-
...msg,
|
|
147
|
-
timestamp: msg.timestamp instanceof Date ? msg.timestamp.toISOString() : msg.timestamp,
|
|
148
|
-
}));
|
|
149
|
-
|
|
150
|
-
window.localStorage.setItem(keys.history, JSON.stringify(messagesToStore));
|
|
151
|
-
|
|
152
|
-
if (conversation.sessionId) {
|
|
153
|
-
window.localStorage.setItem(keys.sessionId, conversation.sessionId);
|
|
154
|
-
}
|
|
155
|
-
if (conversation.conversationId) {
|
|
156
|
-
window.localStorage.setItem(keys.conversationId, conversation.conversationId);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
if (onSelectConversation) {
|
|
160
|
-
onSelectConversation(conversation);
|
|
161
|
-
}
|
|
162
|
-
} catch (err) {
|
|
163
|
-
// eslint-disable-next-line no-console
|
|
164
|
-
console.warn("[Chat History] Failed to restore conversation:", err);
|
|
165
|
-
}
|
|
166
|
-
},
|
|
167
|
-
[disabled, keys, onSelectConversation]
|
|
168
|
-
);
|
|
169
|
-
|
|
170
|
-
const handleDelete = useCallback(
|
|
171
|
-
(e, index) => {
|
|
172
|
-
e.stopPropagation();
|
|
173
|
-
e.preventDefault();
|
|
174
|
-
if (disabled) {
|
|
175
|
-
return;
|
|
176
|
-
}
|
|
177
|
-
try {
|
|
178
|
-
const rawArchive = window.localStorage.getItem(keys.archive);
|
|
179
|
-
if (rawArchive) {
|
|
180
|
-
const archive = JSON.parse(rawArchive);
|
|
181
|
-
const filtered = archive.filter((_, i) => i !== index);
|
|
182
|
-
window.localStorage.setItem(keys.archive, JSON.stringify(filtered));
|
|
183
|
-
}
|
|
184
|
-
setConversations((prev) => prev.filter((_, i) => i !== index));
|
|
185
|
-
} catch (err) {
|
|
186
|
-
// eslint-disable-next-line no-console
|
|
187
|
-
console.warn("[Chat History] Failed to delete conversation:", err);
|
|
188
|
-
}
|
|
189
|
-
},
|
|
190
|
-
[disabled, keys]
|
|
191
|
-
);
|
|
192
|
-
|
|
193
|
-
if (conversations.length === 0) {
|
|
194
|
-
if (emptyMessage) {
|
|
195
|
-
return (
|
|
196
|
-
<div className="nfd-ai-chat-history-list nfd-ai-chat-history-list--empty">
|
|
197
|
-
{emptyMessage}
|
|
198
|
-
</div>
|
|
199
|
-
);
|
|
200
|
-
}
|
|
201
|
-
return null;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
return (
|
|
205
|
-
<div className="nfd-ai-chat-history-list">
|
|
206
|
-
{conversations.map((conversation, index) => {
|
|
207
|
-
const title = getConversationTitle(conversation);
|
|
208
|
-
const key = conversation.sessionId || `legacy-${index}`;
|
|
209
|
-
const timeDate = conversation.archivedAt || getLatestMessageTime(conversation) || null;
|
|
210
|
-
const timeLabel = timeDate ? getRelativeTime(timeDate) : null;
|
|
211
|
-
return (
|
|
212
|
-
<div
|
|
213
|
-
key={key}
|
|
214
|
-
className={`nfd-ai-chat-history-item${disabled ? " nfd-ai-chat-history-item--disabled" : ""}`}
|
|
215
|
-
role="button"
|
|
216
|
-
tabIndex={disabled ? -1 : 0}
|
|
217
|
-
aria-disabled={disabled}
|
|
218
|
-
onClick={() => handleHistoryClick(conversation)}
|
|
219
|
-
onKeyDown={(e) => {
|
|
220
|
-
if (disabled) {
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
224
|
-
e.preventDefault();
|
|
225
|
-
handleHistoryClick(conversation);
|
|
226
|
-
}
|
|
227
|
-
}}
|
|
228
|
-
>
|
|
229
|
-
<History width={14} height={14} aria-hidden />
|
|
230
|
-
<div className="nfd-ai-chat-history-item__content">
|
|
231
|
-
<span className="nfd-ai-chat-history-item__title">{title}</span>
|
|
232
|
-
<div className="nfd-ai-chat-history-item__meta">
|
|
233
|
-
{timeLabel && <span className="nfd-ai-chat-history-item__time">{timeLabel}</span>}
|
|
234
|
-
<button
|
|
235
|
-
type="button"
|
|
236
|
-
className="nfd-ai-chat-history-item__delete"
|
|
237
|
-
onClick={(e) => handleDelete(e, index)}
|
|
238
|
-
aria-label={__("Delete conversation", "wp-module-ai-chat")}
|
|
239
|
-
title={__("Delete", "wp-module-ai-chat")}
|
|
240
|
-
>
|
|
241
|
-
<Trash2
|
|
242
|
-
width={14}
|
|
243
|
-
height={14}
|
|
244
|
-
className="nfd-ai-chat-history-item__delete-icon"
|
|
245
|
-
aria-hidden
|
|
246
|
-
/>
|
|
247
|
-
</button>
|
|
248
|
-
</div>
|
|
249
|
-
</div>
|
|
250
|
-
</div>
|
|
251
|
-
);
|
|
252
|
-
})}
|
|
253
|
-
</div>
|
|
254
|
-
);
|
|
255
|
-
};
|
|
256
|
-
|
|
257
|
-
export default ChatHistoryList;
|
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* WordPress dependencies
|
|
3
|
-
*/
|
|
4
|
-
import { Button } from "@wordpress/components";
|
|
5
|
-
import { useCallback, useEffect, useRef, useState } from "@wordpress/element";
|
|
6
|
-
import { __ } from "@wordpress/i18n";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* External dependencies
|
|
10
|
-
*/
|
|
11
|
-
import classnames from "classnames";
|
|
12
|
-
import { ArrowUp, CircleStop } from "lucide-react";
|
|
13
|
-
import { INPUT } from "../../constants/nfdAgents/input";
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* ChatInput Component
|
|
17
|
-
*
|
|
18
|
-
* Context-agnostic chat input field. Accepts optional contextComponent prop
|
|
19
|
-
* for consumers to inject their own context indicators (e.g., selected block).
|
|
20
|
-
*
|
|
21
|
-
* @param {Object} props - The component props.
|
|
22
|
-
* @param {Function} props.onSendMessage - Function to call when message is sent.
|
|
23
|
-
* @param {Function} props.onStopRequest - Function to call when stop button is clicked.
|
|
24
|
-
* @param {boolean} props.disabled - Whether the input is disabled.
|
|
25
|
-
* @param {boolean} props.showStopButton - When true, show stop button instead of send (e.g. when generating). When false, show send even if disabled.
|
|
26
|
-
* @param {string} props.placeholder - Input placeholder text.
|
|
27
|
-
* @param {import('react').ReactNode} [props.contextComponent] - Optional context component to render (e.g. selected block).
|
|
28
|
-
* @param {boolean} [props.showTopBorder] - When false, omits the top border. Default true.
|
|
29
|
-
* @return {JSX.Element} The ChatInput component.
|
|
30
|
-
*/
|
|
31
|
-
const ChatInput = ({
|
|
32
|
-
onSendMessage,
|
|
33
|
-
onStopRequest,
|
|
34
|
-
disabled = false,
|
|
35
|
-
showStopButton,
|
|
36
|
-
placeholder,
|
|
37
|
-
contextComponent = null,
|
|
38
|
-
showTopBorder = true,
|
|
39
|
-
}) => {
|
|
40
|
-
// Show stop button only when explicitly requested (e.g. generating), not when disabled for connecting/failed
|
|
41
|
-
const showStop = showStopButton === true;
|
|
42
|
-
const [message, setMessage] = useState("");
|
|
43
|
-
const [isStopping, setIsStopping] = useState(false);
|
|
44
|
-
const textareaRef = useRef(null);
|
|
45
|
-
|
|
46
|
-
const defaultPlaceholder = __("How can I help you today?", "wp-module-ai-chat");
|
|
47
|
-
|
|
48
|
-
// Auto-resize textarea as user types
|
|
49
|
-
useEffect(() => {
|
|
50
|
-
if (textareaRef.current) {
|
|
51
|
-
textareaRef.current.style.height = "auto";
|
|
52
|
-
const scrollHeight = textareaRef.current.scrollHeight;
|
|
53
|
-
const newHeight = Math.min(scrollHeight, INPUT.MAX_HEIGHT);
|
|
54
|
-
textareaRef.current.style.height = `${newHeight}px`;
|
|
55
|
-
|
|
56
|
-
// Only show scrollbar when content actually overflows
|
|
57
|
-
// This prevents the disabled scrollbar from appearing when empty
|
|
58
|
-
if (scrollHeight > INPUT.MAX_HEIGHT) {
|
|
59
|
-
textareaRef.current.style.overflowY = "auto";
|
|
60
|
-
} else {
|
|
61
|
-
textareaRef.current.style.overflowY = "hidden";
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}, [message]);
|
|
65
|
-
|
|
66
|
-
// Focus textarea when it becomes enabled again
|
|
67
|
-
useEffect(() => {
|
|
68
|
-
if (!disabled && textareaRef.current) {
|
|
69
|
-
setTimeout(() => {
|
|
70
|
-
textareaRef.current.focus();
|
|
71
|
-
}, INPUT.FOCUS_DELAY);
|
|
72
|
-
}
|
|
73
|
-
}, [disabled]);
|
|
74
|
-
|
|
75
|
-
const handleSubmit = useCallback(() => {
|
|
76
|
-
if (message.trim() && !disabled) {
|
|
77
|
-
onSendMessage(message);
|
|
78
|
-
setMessage("");
|
|
79
|
-
if (textareaRef.current) {
|
|
80
|
-
textareaRef.current.style.height = "auto";
|
|
81
|
-
textareaRef.current.style.overflowY = "hidden";
|
|
82
|
-
textareaRef.current.focus();
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}, [message, disabled, onSendMessage]);
|
|
86
|
-
|
|
87
|
-
const handleKeyDown = useCallback(
|
|
88
|
-
(e) => {
|
|
89
|
-
if (e.key === "Enter" && !e.shiftKey) {
|
|
90
|
-
e.preventDefault();
|
|
91
|
-
handleSubmit();
|
|
92
|
-
}
|
|
93
|
-
},
|
|
94
|
-
[handleSubmit]
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
const handleStopRequest = useCallback(() => {
|
|
98
|
-
if (isStopping) {
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
setIsStopping(true);
|
|
102
|
-
if (onStopRequest) {
|
|
103
|
-
onStopRequest();
|
|
104
|
-
}
|
|
105
|
-
setTimeout(() => {
|
|
106
|
-
setIsStopping(false);
|
|
107
|
-
}, INPUT.STOP_DEBOUNCE);
|
|
108
|
-
}, [isStopping, onStopRequest]);
|
|
109
|
-
|
|
110
|
-
const rootClassName = classnames("nfd-ai-chat-input", {
|
|
111
|
-
"nfd-ai-chat-input--no-top-border": !showTopBorder,
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
return (
|
|
115
|
-
<div className={rootClassName}>
|
|
116
|
-
<div className="nfd-ai-chat-input__container">
|
|
117
|
-
<textarea
|
|
118
|
-
name="nfd-ai-chat-input"
|
|
119
|
-
ref={textareaRef}
|
|
120
|
-
value={message}
|
|
121
|
-
onChange={(e) => setMessage(e.target.value)}
|
|
122
|
-
onKeyDown={handleKeyDown}
|
|
123
|
-
placeholder={placeholder || defaultPlaceholder}
|
|
124
|
-
className="nfd-ai-chat-input__textarea"
|
|
125
|
-
rows={1}
|
|
126
|
-
disabled={disabled}
|
|
127
|
-
/>
|
|
128
|
-
<div className="nfd-ai-chat-input__actions">
|
|
129
|
-
{contextComponent}
|
|
130
|
-
{showStop ? (
|
|
131
|
-
<Button
|
|
132
|
-
icon={<CircleStop width={16} height={16} />}
|
|
133
|
-
label={__("Stop generating", "wp-module-ai-chat")}
|
|
134
|
-
onClick={handleStopRequest}
|
|
135
|
-
className="nfd-ai-chat-input__stop"
|
|
136
|
-
disabled={isStopping}
|
|
137
|
-
aria-busy={isStopping}
|
|
138
|
-
/>
|
|
139
|
-
) : (
|
|
140
|
-
<Button
|
|
141
|
-
icon={<ArrowUp width={16} height={16} />}
|
|
142
|
-
label={__("Send message", "wp-module-ai-chat")}
|
|
143
|
-
onClick={handleSubmit}
|
|
144
|
-
className="nfd-ai-chat-input__submit"
|
|
145
|
-
disabled={!message.trim() || disabled}
|
|
146
|
-
/>
|
|
147
|
-
)}
|
|
148
|
-
</div>
|
|
149
|
-
</div>
|
|
150
|
-
<div className="nfd-ai-chat-input__disclaimer">
|
|
151
|
-
{__("AI-generated content is not guaranteed for accuracy.", "wp-module-ai-chat")}
|
|
152
|
-
</div>
|
|
153
|
-
</div>
|
|
154
|
-
);
|
|
155
|
-
};
|
|
156
|
-
|
|
157
|
-
export default ChatInput;
|