@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
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;
|