@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,157 @@
|
|
|
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;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WordPress dependencies
|
|
3
|
+
*/
|
|
4
|
+
import { useMemo, useState, useEffect, useRef } from "@wordpress/element";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Internal dependencies
|
|
8
|
+
*/
|
|
9
|
+
import classnames from "classnames";
|
|
10
|
+
import { containsHtml, sanitizeHtml } from "../../utils/sanitizeHtml";
|
|
11
|
+
import { containsMarkdown, parseMarkdown, linkifyUrls } from "../../utils/markdownParser";
|
|
12
|
+
import { unescapeAiResponse } from "../../utils/helpers";
|
|
13
|
+
import ToolExecutionList from "../ui/ToolExecutionList";
|
|
14
|
+
|
|
15
|
+
/** Typing animation: chars to reveal per tick, tick interval ms */
|
|
16
|
+
const TYPING_CHARS_PER_TICK = 2;
|
|
17
|
+
const TYPING_TICK_MS = 35;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* ChatMessage Component
|
|
21
|
+
*
|
|
22
|
+
* Displays a single message in the chat with appropriate styling.
|
|
23
|
+
* Supports HTML and Markdown rendering for assistant messages.
|
|
24
|
+
*
|
|
25
|
+
* @param {Object} props - The component props.
|
|
26
|
+
* @param {string} props.message - The message content to display.
|
|
27
|
+
* @param {string} [props.type="assistant"] - The message type ("user" or "assistant").
|
|
28
|
+
* @param {boolean} [props.animateTyping] - When true, reveal assistant content with a typing effect.
|
|
29
|
+
* @param {Function} [props.onContentGrow] - Called when displayed content grows (e.g. for scroll-into-view).
|
|
30
|
+
* @param {Array} [props.executedTools=[]] - List of executed tools to show inline.
|
|
31
|
+
* @param {Array} [props.toolResults=[]] - Results from tool executions.
|
|
32
|
+
* @return {JSX.Element} The ChatMessage component.
|
|
33
|
+
*/
|
|
34
|
+
const ChatMessage = ({
|
|
35
|
+
message,
|
|
36
|
+
type = "assistant",
|
|
37
|
+
animateTyping = false,
|
|
38
|
+
onContentGrow,
|
|
39
|
+
executedTools = [],
|
|
40
|
+
toolResults = [],
|
|
41
|
+
}) => {
|
|
42
|
+
// Treat approval_request as assistant so the thread still displays
|
|
43
|
+
const displayType = type === "approval_request" ? "assistant" : type;
|
|
44
|
+
const isUser = displayType === "user";
|
|
45
|
+
|
|
46
|
+
const fullLength = (message || "").length;
|
|
47
|
+
const [displayedLength, setDisplayedLength] = useState(() => (animateTyping ? 0 : fullLength));
|
|
48
|
+
|
|
49
|
+
// When not animating, show full message; when animating, drive displayedLength toward fullLength and clear interval when done
|
|
50
|
+
const intervalRef = useRef(null);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!animateTyping) {
|
|
53
|
+
setDisplayedLength(fullLength);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
intervalRef.current = setInterval(() => {
|
|
57
|
+
setDisplayedLength((prev) => {
|
|
58
|
+
if (prev >= fullLength) {
|
|
59
|
+
if (intervalRef.current) {
|
|
60
|
+
clearInterval(intervalRef.current);
|
|
61
|
+
intervalRef.current = null;
|
|
62
|
+
}
|
|
63
|
+
return prev;
|
|
64
|
+
}
|
|
65
|
+
return Math.min(prev + TYPING_CHARS_PER_TICK, fullLength);
|
|
66
|
+
});
|
|
67
|
+
}, TYPING_TICK_MS);
|
|
68
|
+
return () => {
|
|
69
|
+
if (intervalRef.current) {
|
|
70
|
+
clearInterval(intervalRef.current);
|
|
71
|
+
intervalRef.current = null;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}, [animateTyping, fullLength]);
|
|
75
|
+
|
|
76
|
+
// Notify parent when content grows (for auto-scroll), throttled to avoid excessive updates
|
|
77
|
+
const lastGrowCallRef = useRef(0);
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!onContentGrow || !animateTyping || displayedLength === 0) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const now = Date.now();
|
|
83
|
+
if (now - lastGrowCallRef.current < 80) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
lastGrowCallRef.current = now;
|
|
87
|
+
onContentGrow();
|
|
88
|
+
}, [displayedLength, animateTyping, onContentGrow]);
|
|
89
|
+
|
|
90
|
+
const effectiveMessage = animateTyping
|
|
91
|
+
? (message || "").slice(0, displayedLength)
|
|
92
|
+
: message || "";
|
|
93
|
+
|
|
94
|
+
// Choose rendering path: plain user text, sanitized HTML, parsed markdown, or linkified plain text.
|
|
95
|
+
const { content, isRichContent } = useMemo(() => {
|
|
96
|
+
if (!effectiveMessage) {
|
|
97
|
+
return { content: "", isRichContent: false };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const raw = unescapeAiResponse(effectiveMessage);
|
|
101
|
+
|
|
102
|
+
if (isUser) {
|
|
103
|
+
return { content: raw, isRichContent: false };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (containsHtml(raw)) {
|
|
107
|
+
return { content: sanitizeHtml(raw), isRichContent: true };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (containsMarkdown(raw)) {
|
|
111
|
+
const parsed = parseMarkdown(raw);
|
|
112
|
+
return { content: sanitizeHtml(parsed), isRichContent: true };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Plain text: linkify bare URLs and preserve newlines
|
|
116
|
+
const linkified = linkifyUrls(raw);
|
|
117
|
+
if (linkified !== raw) {
|
|
118
|
+
const withBreaks = linkified.replace(/\n/g, "<br>");
|
|
119
|
+
return { content: sanitizeHtml(withBreaks), isRichContent: true };
|
|
120
|
+
}
|
|
121
|
+
return { content: raw, isRichContent: false };
|
|
122
|
+
}, [effectiveMessage, isUser]);
|
|
123
|
+
|
|
124
|
+
// Don't render if there's no content and no tools.
|
|
125
|
+
if (!content && (!executedTools || executedTools.length === 0)) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const rootClassName = classnames("nfd-ai-chat-message", `nfd-ai-chat-message--${displayType}`);
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div className={rootClassName}>
|
|
133
|
+
{content &&
|
|
134
|
+
(isRichContent ? (
|
|
135
|
+
<>
|
|
136
|
+
{/* content is sanitized in the useMemo above (sanitizeHtml) */}
|
|
137
|
+
<div
|
|
138
|
+
className="nfd-ai-chat-message__content nfd-ai-chat-message__content--rich"
|
|
139
|
+
dangerouslySetInnerHTML={{ __html: content }}
|
|
140
|
+
/>
|
|
141
|
+
</>
|
|
142
|
+
) : (
|
|
143
|
+
<div
|
|
144
|
+
className="nfd-ai-chat-message__content nfd-ai-chat-message__content--pre-wrap"
|
|
145
|
+
style={{ whiteSpace: "pre-wrap" }}
|
|
146
|
+
>
|
|
147
|
+
{content}
|
|
148
|
+
</div>
|
|
149
|
+
))}
|
|
150
|
+
{executedTools && executedTools.length > 0 && (
|
|
151
|
+
<ToolExecutionList executedTools={executedTools} toolResults={toolResults} />
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
);
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
export default ChatMessage;
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WordPress dependencies
|
|
3
|
+
*/
|
|
4
|
+
import { useEffect, useRef, useCallback, useState } from "@wordpress/element";
|
|
5
|
+
import { __ } from "@wordpress/i18n";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Internal dependencies
|
|
9
|
+
*/
|
|
10
|
+
import classnames from "classnames";
|
|
11
|
+
import ErrorAlert from "../ui/ErrorAlert";
|
|
12
|
+
import TypingIndicator from "../ui/TypingIndicator";
|
|
13
|
+
import ChatMessage from "./ChatMessage";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* ChatMessages Component
|
|
17
|
+
*
|
|
18
|
+
* Scrollable container for all chat messages.
|
|
19
|
+
* Auto-scrolls to bottom when new messages arrive or when the last message content grows (e.g. typing animation).
|
|
20
|
+
*
|
|
21
|
+
* @param {Object} props - The component props.
|
|
22
|
+
* @param {Array} props.messages - The messages to display.
|
|
23
|
+
* @param {boolean} props.isLoading - Whether the AI is generating a response.
|
|
24
|
+
* @param {string} props.error - Error message to display (optional).
|
|
25
|
+
* @param {string} props.status - The current status.
|
|
26
|
+
* @param {Object} props.activeToolCall - The currently executing tool call (optional).
|
|
27
|
+
* @param {string} props.toolProgress - Real-time progress message (optional).
|
|
28
|
+
* @param {Array} props.executedTools - List of completed tool executions (optional).
|
|
29
|
+
* @param {Array} props.pendingTools - List of pending tools to execute (optional).
|
|
30
|
+
* @param {Function} [props.onRetry] - Callback when user clicks Retry (e.g. after connection failed).
|
|
31
|
+
* @param {boolean} [props.connectionFailed] - Whether connection has failed (show Retry without red error).
|
|
32
|
+
* @param {boolean} [props.isConnectingOrReconnecting] - When true, show a single "Connecting...." assistant message.
|
|
33
|
+
* @param {string} [props.messageBubbleStyle] - 'bubbles' (default) or 'minimal'. Controls container and message bubble styling.
|
|
34
|
+
* @return {JSX.Element} The ChatMessages component.
|
|
35
|
+
*/
|
|
36
|
+
const ChatMessages = ({
|
|
37
|
+
messages = [],
|
|
38
|
+
isLoading = false,
|
|
39
|
+
error = null,
|
|
40
|
+
status = null,
|
|
41
|
+
activeToolCall = null,
|
|
42
|
+
toolProgress = null,
|
|
43
|
+
executedTools = [],
|
|
44
|
+
pendingTools = [],
|
|
45
|
+
onRetry,
|
|
46
|
+
connectionFailed = false,
|
|
47
|
+
isConnectingOrReconnecting = false,
|
|
48
|
+
messageBubbleStyle = "bubbles",
|
|
49
|
+
}) => {
|
|
50
|
+
const scrollContainerRef = useRef(null);
|
|
51
|
+
const [scrollTrigger, setScrollTrigger] = useState(0);
|
|
52
|
+
|
|
53
|
+
const scrollToBottom = useCallback(() => {
|
|
54
|
+
const el = scrollContainerRef.current;
|
|
55
|
+
if (!el) {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
el.scrollTo({
|
|
59
|
+
top: el.scrollHeight - el.clientHeight,
|
|
60
|
+
behavior: "smooth",
|
|
61
|
+
});
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
scrollToBottom();
|
|
66
|
+
}, [messages, isLoading, toolProgress, scrollTrigger, scrollToBottom]);
|
|
67
|
+
|
|
68
|
+
// Bump scroll trigger so the scroll effect runs again when the last message's content grows (e.g. typing).
|
|
69
|
+
const onContentGrow = useCallback(() => {
|
|
70
|
+
setScrollTrigger((t) => t + 1);
|
|
71
|
+
}, []);
|
|
72
|
+
|
|
73
|
+
// Only show executed tools in TypingIndicator when there is active or pending tool work.
|
|
74
|
+
const hasActiveToolExecution =
|
|
75
|
+
activeToolCall || executedTools.length > 0 || pendingTools.length > 0;
|
|
76
|
+
|
|
77
|
+
const messagesClassName = classnames("nfd-ai-chat-messages", {
|
|
78
|
+
"nfd-ai-chat-messages--minimal": messageBubbleStyle === "minimal",
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div ref={scrollContainerRef} className={messagesClassName}>
|
|
83
|
+
{messages.length > 0 &&
|
|
84
|
+
messages.map((msg, index) => {
|
|
85
|
+
// Animate typing only for the last assistant message that was received live (not loaded from history/restore)
|
|
86
|
+
const isLastAssistant =
|
|
87
|
+
index === messages.length - 1 && (msg.type === "assistant" || msg.role === "assistant");
|
|
88
|
+
return (
|
|
89
|
+
<ChatMessage
|
|
90
|
+
key={msg.id || index}
|
|
91
|
+
message={msg.content}
|
|
92
|
+
type={msg.type}
|
|
93
|
+
animateTyping={isLastAssistant && msg.animateTyping === true}
|
|
94
|
+
onContentGrow={isLastAssistant ? onContentGrow : undefined}
|
|
95
|
+
executedTools={msg.executedTools}
|
|
96
|
+
toolResults={msg.toolResults}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
})}
|
|
100
|
+
{isConnectingOrReconnecting && !connectionFailed && (
|
|
101
|
+
<div className="nfd-ai-chat-message nfd-ai-chat-message--assistant">
|
|
102
|
+
<div className="nfd-ai-chat-message__content">
|
|
103
|
+
<div className="nfd-ai-chat-typing-indicator">
|
|
104
|
+
<span className="nfd-ai-chat-typing-indicator__dots" aria-hidden="true">
|
|
105
|
+
<span></span>
|
|
106
|
+
<span></span>
|
|
107
|
+
<span></span>
|
|
108
|
+
</span>
|
|
109
|
+
<span className="nfd-ai-chat-typing-indicator__text">
|
|
110
|
+
{__("Connecting....", "wp-module-ai-chat")}
|
|
111
|
+
</span>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
)}
|
|
116
|
+
{error && <ErrorAlert message={error} />}
|
|
117
|
+
{onRetry && (error || connectionFailed) && (
|
|
118
|
+
<p className="nfd-ai-chat-messages__retry">
|
|
119
|
+
<button type="button" className="nfd-ai-chat-messages__retry-button" onClick={onRetry}>
|
|
120
|
+
{__("Retry", "wp-module-ai-chat")}
|
|
121
|
+
</button>
|
|
122
|
+
</p>
|
|
123
|
+
)}
|
|
124
|
+
{isLoading && (
|
|
125
|
+
<TypingIndicator
|
|
126
|
+
status={status}
|
|
127
|
+
activeToolCall={activeToolCall}
|
|
128
|
+
toolProgress={toolProgress}
|
|
129
|
+
executedTools={hasActiveToolExecution ? executedTools : []}
|
|
130
|
+
pendingTools={pendingTools}
|
|
131
|
+
/>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export default ChatMessages;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WordPress dependencies
|
|
3
|
+
*/
|
|
4
|
+
import { __ } from "@wordpress/i18n";
|
|
5
|
+
import { useState, useEffect, useRef } from "@wordpress/element";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Internal dependencies
|
|
9
|
+
*/
|
|
10
|
+
import AILogo from "../ui/AILogo";
|
|
11
|
+
import SuggestionButton from "../ui/SuggestionButton";
|
|
12
|
+
|
|
13
|
+
const TYPING_SPEED_MS = 40;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* WelcomeScreen Component
|
|
17
|
+
*
|
|
18
|
+
* Displays the welcome screen with AI avatar, introduction message, and suggestion tags.
|
|
19
|
+
*
|
|
20
|
+
* @param {Object} props - The component props.
|
|
21
|
+
* @param {Function} props.onSendMessage - Function to call when a suggestion is clicked.
|
|
22
|
+
* @param {string} props.title - Custom welcome title (optional).
|
|
23
|
+
* @param {string} props.subtitle - Custom welcome subtitle (optional).
|
|
24
|
+
* @param {Array} props.suggestions - Custom suggestions array (optional).
|
|
25
|
+
* @param {boolean} props.showSuggestions - Whether to show suggestions (default: false).
|
|
26
|
+
* @param {boolean} props.animateWelcome - Whether to type the welcome text (default: false).
|
|
27
|
+
* @return {JSX.Element} The WelcomeScreen component.
|
|
28
|
+
*/
|
|
29
|
+
const WelcomeScreen = ({
|
|
30
|
+
onSendMessage,
|
|
31
|
+
title,
|
|
32
|
+
subtitle,
|
|
33
|
+
suggestions = [],
|
|
34
|
+
showSuggestions = false,
|
|
35
|
+
animateWelcome = false,
|
|
36
|
+
}) => {
|
|
37
|
+
const defaultTitle = __("Hi, I'm your AI assistant.", "wp-module-ai-chat");
|
|
38
|
+
const defaultSubtitle = __("How can I help you today?", "wp-module-ai-chat");
|
|
39
|
+
|
|
40
|
+
const titleText = title || defaultTitle;
|
|
41
|
+
const subtitleText = subtitle || defaultSubtitle;
|
|
42
|
+
const fullText = `${titleText} ${subtitleText}`;
|
|
43
|
+
|
|
44
|
+
const [displayedLength, setDisplayedLength] = useState(0);
|
|
45
|
+
const timeoutRef = useRef(null);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (!animateWelcome) {
|
|
49
|
+
setDisplayedLength(fullText.length);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
setDisplayedLength(0);
|
|
53
|
+
const animate = () => {
|
|
54
|
+
setDisplayedLength((prev) => {
|
|
55
|
+
if (prev >= fullText.length) {
|
|
56
|
+
return prev;
|
|
57
|
+
}
|
|
58
|
+
timeoutRef.current = setTimeout(animate, TYPING_SPEED_MS);
|
|
59
|
+
return prev + 1;
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
timeoutRef.current = setTimeout(animate, TYPING_SPEED_MS);
|
|
63
|
+
return () => {
|
|
64
|
+
if (timeoutRef.current) {
|
|
65
|
+
clearTimeout(timeoutRef.current);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}, [animateWelcome, fullText]);
|
|
69
|
+
|
|
70
|
+
const showTyping = animateWelcome && displayedLength < fullText.length;
|
|
71
|
+
const displayedTitle =
|
|
72
|
+
showTyping && displayedLength <= titleText.length
|
|
73
|
+
? titleText.slice(0, displayedLength)
|
|
74
|
+
: titleText;
|
|
75
|
+
// fullText is "title + space + subtitle"; the -1 below accounts for the space when slicing into subtitle.
|
|
76
|
+
let displayedSubtitle = subtitleText;
|
|
77
|
+
if (showTyping && displayedLength > titleText.length) {
|
|
78
|
+
displayedSubtitle = subtitleText.slice(0, displayedLength - titleText.length - 1);
|
|
79
|
+
} else if (showTyping) {
|
|
80
|
+
displayedSubtitle = "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className="nfd-ai-chat-welcome">
|
|
85
|
+
<div className="nfd-ai-chat-welcome__content">
|
|
86
|
+
<div className="nfd-ai-chat-welcome__avatar">
|
|
87
|
+
<AILogo width={64} height={64} />
|
|
88
|
+
</div>
|
|
89
|
+
<div className="nfd-ai-chat-welcome__message">
|
|
90
|
+
<div className="nfd-ai-chat-welcome__title">{displayedTitle}</div>
|
|
91
|
+
<div className="nfd-ai-chat-welcome__subtitle">
|
|
92
|
+
{displayedSubtitle}
|
|
93
|
+
{showTyping && displayedLength < fullText.length && (
|
|
94
|
+
<span className="nfd-ai-chat-welcome__cursor" aria-hidden="true" />
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
{showSuggestions && suggestions.length > 0 && (
|
|
100
|
+
<div className="nfd-ai-chat-suggestions">
|
|
101
|
+
{suggestions.map((suggestion, index) => (
|
|
102
|
+
<SuggestionButton
|
|
103
|
+
key={suggestion.action ?? suggestion.text ?? index}
|
|
104
|
+
icon={suggestion.icon}
|
|
105
|
+
text={suggestion.text}
|
|
106
|
+
onClick={() => onSendMessage(suggestion.action || suggestion.text)}
|
|
107
|
+
/>
|
|
108
|
+
))}
|
|
109
|
+
</div>
|
|
110
|
+
)}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
export default WelcomeScreen;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Close (×) icon for actions. Inline SVG for accessibility.
|
|
3
|
+
*
|
|
4
|
+
* @param {Object} props - SVG props (e.g. className, aria-*).
|
|
5
|
+
* @return {JSX.Element} Close icon element.
|
|
6
|
+
*/
|
|
7
|
+
const CloseIcon = (props) => (
|
|
8
|
+
<svg
|
|
9
|
+
width="16"
|
|
10
|
+
height="16"
|
|
11
|
+
viewBox="0 0 24 24"
|
|
12
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
13
|
+
aria-hidden="true"
|
|
14
|
+
focusable="false"
|
|
15
|
+
{...props}
|
|
16
|
+
>
|
|
17
|
+
<path
|
|
18
|
+
d="M18 6L6 18M6 6l12 12"
|
|
19
|
+
stroke="currentColor"
|
|
20
|
+
strokeWidth="2"
|
|
21
|
+
strokeLinecap="round"
|
|
22
|
+
strokeLinejoin="round"
|
|
23
|
+
/>
|
|
24
|
+
</svg>
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
export default CloseIcon;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Outline sparkles icon (decorative). Inline SVG for accessibility.
|
|
3
|
+
*
|
|
4
|
+
* @param {Object} props - SVG props (e.g. width, height, className).
|
|
5
|
+
* @return {JSX.Element} Sparkles icon element.
|
|
6
|
+
*/
|
|
7
|
+
const SparklesOutlineIcon = (props) => (
|
|
8
|
+
<svg
|
|
9
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
10
|
+
width="24"
|
|
11
|
+
height="24"
|
|
12
|
+
viewBox="0 0 24 24"
|
|
13
|
+
fill="none"
|
|
14
|
+
stroke="currentColor"
|
|
15
|
+
strokeWidth="2"
|
|
16
|
+
strokeLinecap="round"
|
|
17
|
+
strokeLinejoin="round"
|
|
18
|
+
aria-hidden="true"
|
|
19
|
+
focusable="false"
|
|
20
|
+
{...props}
|
|
21
|
+
>
|
|
22
|
+
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
|
|
23
|
+
<path d="M5 3v4" />
|
|
24
|
+
<path d="M19 17v4" />
|
|
25
|
+
<path d="M3 5h4" />
|
|
26
|
+
<path d="M17 19h4" />
|
|
27
|
+
</svg>
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
export default SparklesOutlineIcon;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filled sparks icon (matches editor-chat sparks.svg).
|
|
3
|
+
* Inlined to avoid SVG loader dependency across consuming projects.
|
|
4
|
+
*
|
|
5
|
+
* @param {Object} props - Props to spread onto the SVG element.
|
|
6
|
+
* @param {number} [props.width=24] - SVG width in pixels.
|
|
7
|
+
* @param {number} [props.height=24] - SVG height in pixels.
|
|
8
|
+
* @return {JSX.Element} Inline SVG sparks icon.
|
|
9
|
+
*/
|
|
10
|
+
const SparksIcon = ({ width = 24, height = 24, ...props }) => (
|
|
11
|
+
<svg
|
|
12
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
13
|
+
viewBox="0 0 30 30"
|
|
14
|
+
width={width}
|
|
15
|
+
height={height}
|
|
16
|
+
{...props}
|
|
17
|
+
>
|
|
18
|
+
<path d="M14.217,19.707l-1.112,2.547c-0.427,0.979-1.782,0.979-2.21,0l-1.112-2.547c-0.99-2.267-2.771-4.071-4.993-5.057L1.73,13.292c-0.973-0.432-0.973-1.848,0-2.28l2.965-1.316C6.974,8.684,8.787,6.813,9.76,4.47l1.126-2.714c0.418-1.007,1.81-1.007,2.228,0L14.24,4.47c0.973,2.344,2.786,4.215,5.065,5.226l2.965,1.316c0.973,0.432,0.973,1.848,0,2.28l-3.061,1.359C16.988,15.637,15.206,17.441,14.217,19.707z" />
|
|
19
|
+
<path d="M24.481,27.796l-0.339,0.777c-0.248,0.569-1.036,0.569-1.284,0l-0.339-0.777c-0.604-1.385-1.693-2.488-3.051-3.092l-1.044-0.464c-0.565-0.251-0.565-1.072,0-1.323l0.986-0.438c1.393-0.619,2.501-1.763,3.095-3.195l0.348-0.84c0.243-0.585,1.052-0.585,1.294,0l0.348,0.84c0.594,1.432,1.702,2.576,3.095,3.195l0.986,0.438c0.565,0.251,0.565,1.072,0,1.323l-1.044,0.464C26.174,25.308,25.085,26.411,24.481,27.796z" />
|
|
20
|
+
</svg>
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* AILogo Component
|
|
25
|
+
*
|
|
26
|
+
* Displays the filled sparks icon inside a gradient circle.
|
|
27
|
+
* Used on welcome screens and as the main AI assistant avatar.
|
|
28
|
+
*
|
|
29
|
+
* @param {Object} props - The component props.
|
|
30
|
+
* @param {number} props.width - The width of the logo (default: 24).
|
|
31
|
+
* @param {number} props.height - The height of the logo (default: 24).
|
|
32
|
+
* @return {JSX.Element} Avatar wrapper with gradient circle and sparks icon.
|
|
33
|
+
*/
|
|
34
|
+
const AILogo = ({ width = 24, height = 24 }) => (
|
|
35
|
+
<div
|
|
36
|
+
className="nfd-ai-chat-avatar"
|
|
37
|
+
style={{
|
|
38
|
+
width,
|
|
39
|
+
height,
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
{/* Scale icon to ~62.5% of avatar so it fits inside the gradient circle. */}
|
|
43
|
+
<SparksIcon width={width * 0.625} height={height * 0.625} />
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
export default AILogo;
|