@newfold/wp-module-ai-chat 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +98 -0
  2. package/package.json +51 -0
  3. package/src/components/chat/ChatHeader.jsx +63 -0
  4. package/src/components/chat/ChatHistoryDropdown.jsx +182 -0
  5. package/src/components/chat/ChatHistoryList.jsx +257 -0
  6. package/src/components/chat/ChatInput.jsx +157 -0
  7. package/src/components/chat/ChatMessage.jsx +157 -0
  8. package/src/components/chat/ChatMessages.jsx +137 -0
  9. package/src/components/chat/WelcomeScreen.jsx +115 -0
  10. package/src/components/icons/CloseIcon.jsx +27 -0
  11. package/src/components/icons/SparklesOutlineIcon.jsx +30 -0
  12. package/src/components/icons/index.js +5 -0
  13. package/src/components/ui/AILogo.jsx +47 -0
  14. package/src/components/ui/BluBetaHeading.jsx +18 -0
  15. package/src/components/ui/ErrorAlert.jsx +30 -0
  16. package/src/components/ui/HeaderBar.jsx +34 -0
  17. package/src/components/ui/SuggestionButton.jsx +28 -0
  18. package/src/components/ui/ToolExecutionList.jsx +264 -0
  19. package/src/components/ui/TypingIndicator.jsx +268 -0
  20. package/src/constants/nfdAgents/input.js +13 -0
  21. package/src/constants/nfdAgents/storageKeys.js +102 -0
  22. package/src/constants/nfdAgents/typingStatus.js +40 -0
  23. package/src/constants/nfdAgents/websocket.js +44 -0
  24. package/src/hooks/useAIChat.js +432 -0
  25. package/src/hooks/useNfdAgentsWebSocket.js +964 -0
  26. package/src/index.js +66 -0
  27. package/src/services/mcpClient.js +433 -0
  28. package/src/services/openaiClient.js +416 -0
  29. package/src/styles/_branding.scss +151 -0
  30. package/src/styles/_history.scss +180 -0
  31. package/src/styles/_input.scss +170 -0
  32. package/src/styles/_messages.scss +272 -0
  33. package/src/styles/_mixins.scss +21 -0
  34. package/src/styles/_typing-indicator.scss +162 -0
  35. package/src/styles/_ui.scss +173 -0
  36. package/src/styles/_vars.scss +103 -0
  37. package/src/styles/_welcome.scss +81 -0
  38. package/src/styles/app.scss +10 -0
  39. package/src/utils/helpers.js +75 -0
  40. package/src/utils/markdownParser.js +319 -0
  41. package/src/utils/nfdAgents/archiveConversation.js +82 -0
  42. package/src/utils/nfdAgents/chatHistoryList.js +130 -0
  43. package/src/utils/nfdAgents/configFetcher.js +137 -0
  44. package/src/utils/nfdAgents/greeting.js +55 -0
  45. package/src/utils/nfdAgents/jwtUtils.js +59 -0
  46. package/src/utils/nfdAgents/messageHandler.js +328 -0
  47. package/src/utils/nfdAgents/storage.js +112 -0
  48. package/src/utils/nfdAgents/typingIndicatorToolDisplay.js +180 -0
  49. package/src/utils/nfdAgents/url.js +101 -0
  50. package/src/utils/restApi.js +87 -0
  51. package/src/utils/sanitizeHtml.js +94 -0
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # WP Module AI Chat
2
+
3
+ Reusable AI chat core for WordPress. Provides NFD Agents WebSocket chat, shared UI components, hooks, and utilities for Help Center, Editor Chat, and other Newfold AI interfaces.
4
+
5
+ ## Overview
6
+
7
+ - **NFD Agents chat**: WebSocket-based chat backed by the Newfold agents gateway (config endpoint, session handling, typing indicators, tool execution display).
8
+ - **Shared UI**: Chat message list, input, header, welcome screen, history list/dropdown, typing indicator, tool execution list.
9
+ - **Optional backends**: MCP (WordPress MCP client) and OpenAI client exports for consumers that need them.
10
+ - **PHP**: REST API config endpoint (`nfd-agents/chat/v1/config`) that returns gateway URL, auth token, and consumer-based capabilities.
11
+
12
+ The module is consumer-agnostic: the host (e.g. Bluehost plugin) mounts the UI and passes a `consumer` (e.g. `help-center`, `editor-chat`) so the backend can enforce capabilities and branding.
13
+
14
+ ## Installation
15
+
16
+ - **PHP**: Installed as a Composer dependency in a Newfold plugin (e.g. `wp-plugin-bluehost`). The module registers with the Newfold container via `bootstrap.php` and exposes the REST API.
17
+ - **Frontend**: Consuming plugins/apps depend on `@newfold-labs/wp-module-ai-chat` and use the built entry point and exports (see **Usage**).
18
+
19
+ ## Usage
20
+
21
+ 1. **Config**: Ensure the gateway URL is set (see **Configuration** below). The frontend calls the REST config endpoint with a `consumer` query parameter.
22
+ 2. **Mount the chat**: Use the exported components (e.g. `ChatMessages`, `ChatInput`, `ChatHeader`, `WelcomeScreen`, `TypingIndicator`, `ToolExecutionList`) and hook `useNfdAgentsWebSocket` with the same `consumer` and config endpoint (full URL or relative path like `nfd-agents/chat/v1/config`).
23
+ 3. **History**: Chat history is keyed by consumer; use `archiveConversation`, `removeConversationFromArchive`, `ChatHistoryList`, and `ChatHistoryDropdown` with the same consumer for consistency.
24
+
25
+ **Example (conceptual):**
26
+
27
+ ```js
28
+ import {
29
+ useNfdAgentsWebSocket,
30
+ ChatMessages,
31
+ ChatInput,
32
+ ChatHeader,
33
+ WelcomeScreen,
34
+ TypingIndicator,
35
+ ToolExecutionList,
36
+ } from "@newfold-labs/wp-module-ai-chat";
37
+
38
+ // In your app: fetch config (e.g. from REST), then:
39
+ // useNfdAgentsWebSocket({ configEndpoint, consumer: "help-center", ... });
40
+ // Render ChatMessages, ChatInput, TypingIndicator, etc.
41
+ ```
42
+
43
+ REST API base URLs are built with `rest_route` (not `/wp-json/`) so the config request works regardless of permalink settings. The module provides `buildRestApiUrl` and `convertWpJsonToRestRoute` in `utils/restApi.js` (used internally by the config fetcher).
44
+
45
+ ## Configuration
46
+
47
+ ### NFD Agents Gateway URL
48
+
49
+ The chat connects to the NFD Agents backend over WebSocket. The gateway URL must be set; it is not set by default.
50
+
51
+ - **`NFD_AGENTS_CHAT_GATEWAY_URL`** (in `wp-config.php`): Base URL for the agents gateway, e.g. `https://agents.example.com` or `http://localhost:8080` for local development.
52
+ - **`nfd_agents_chat_gateway_url` filter**: The host or another plugin can provide the URL:
53
+ ```php
54
+ add_filter( 'nfd_agents_chat_gateway_url', fn() => 'https://agents.example.com' );
55
+ ```
56
+
57
+ If neither is set, the config API returns an error and the chat will not connect.
58
+
59
+ ### Debug token (local / bypass Hiive)
60
+
61
+ For local development or debugging without Hiive, you can supply a JWT instead of fetching it from Hiive.
62
+
63
+ - **`NFD_AGENTS_CHAT_DEBUG_TOKEN`** (in `wp-config.php` only): If defined and non-empty, it is used as the `huapi_token` instead of calling Hiive. Set only in `wp-config.php` or a local, uncommitted config; never commit the value.
64
+
65
+ ```php
66
+ define( 'NFD_AGENTS_CHAT_DEBUG_TOKEN', 'eyJ...' );
67
+ ```
68
+
69
+ ## Development
70
+
71
+ ```bash
72
+ # Install JS dependencies
73
+ npm install
74
+
75
+ # Build (output is consumed by the host plugin’s build or enqueue)
76
+ npm run build
77
+
78
+ # Lint
79
+ npm run lint
80
+ ```
81
+
82
+ PHP linting uses the project’s PHPCS config: `composer run lint` (and `composer run clean` to fix).
83
+
84
+ ## Exports (JavaScript)
85
+
86
+ The package entry point is `src/index.js`. It exports:
87
+
88
+ - **Hooks**: `useAIChat`, `useNfdAgentsWebSocket`, `CHAT_STATUS`
89
+ - **Components**: Chat (e.g. `ChatMessage`, `ChatMessages`, `ChatInput`, `ChatHeader`, `WelcomeScreen`, `ChatHistoryList`, `ChatHistoryDropdown`), UI (`AILogo`, `BluBetaHeading`, `HeaderBar`, `ErrorAlert`, `SuggestionButton`, `ToolExecutionList`, `TypingIndicator`)
90
+ - **Utils**: `simpleHash`, `generateSessionId`, `debounce`; `containsMarkdown`, `parseMarkdown`; `sanitizeHtml`, `containsHtml`; NFD Agents URL helpers (`convertToWebSocketUrl`, `normalizeUrl`, `buildWebSocketUrl`, `isLocalhost`); `isInitialGreeting`; archive helpers (`archiveConversation`, `removeConversationFromArchive`)
91
+ - **Constants**: `NFD_AGENTS_WEBSOCKET`, `getChatHistoryStorageKeys`, `TYPING_STATUS`, `INPUT`
92
+ - **Services** (optional): `WordPressMCPClient`, `createMCPClient`, `mcpClient`, `MCPError`; `CloudflareOpenAIClient`, `createOpenAIClient`, `openaiClient`, `OpenAIError`
93
+
94
+ Subpath exports are available for `./services/*`, `./components/*`, `./hooks/*`, and `./utils/*` as defined in `package.json`.
95
+
96
+ ## License
97
+
98
+ GPL-2.0-or-later
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@newfold/wp-module-ai-chat",
3
+ "version": "1.0.0",
4
+ "description": "Reusable AI Chat core for WordPress modules",
5
+ "license": "GPL-2.0-or-later",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/newfold-labs/wp-module-ai-chat.git"
12
+ },
13
+ "main": "src/index.js",
14
+ "files": [
15
+ "src"
16
+ ],
17
+ "exports": {
18
+ ".": "./src/index.js",
19
+ "./services/*": "./src/services/*.js",
20
+ "./components/*": "./src/components/*.jsx",
21
+ "./hooks/*": "./src/hooks/*.js",
22
+ "./utils/*": "./src/utils/*.js",
23
+ "./styles/*": "./src/styles/*.scss"
24
+ },
25
+ "peerDependencies": {
26
+ "@wordpress/element": "^6.0.0",
27
+ "@wordpress/data": "^10.0.0",
28
+ "@wordpress/i18n": "^5.0.0",
29
+ "@wordpress/components": "^28.0.0",
30
+ "@wordpress/api-fetch": "^6.0.0",
31
+ "react": "^18.0.0"
32
+ },
33
+ "overrides": {
34
+ "doctrine": "2.1.0"
35
+ },
36
+ "devDependencies": {
37
+ "@wordpress/eslint-plugin": "^22.18.0",
38
+ "@wordpress/prettier-config": "^4.32.0",
39
+ "@wordpress/scripts": "^31.3.0",
40
+ "eslint": "^8.57.1",
41
+ "eslint-plugin-prettier": "^5.5.4",
42
+ "prettier": "^3.6.2"
43
+ },
44
+ "dependencies": {
45
+ "@modelcontextprotocol/sdk": "^1.0.0",
46
+ "classnames": "^2.5.1",
47
+ "dompurify": "^3.3.0",
48
+ "lucide-react": "^0.468.0",
49
+ "openai": "^4.68.0"
50
+ }
51
+ }
@@ -0,0 +1,63 @@
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;
@@ -0,0 +1,182 @@
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;
@@ -0,0 +1,257 @@
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;