@eeacms/volto-eea-chatbot 2.0.5 → 3.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.
@@ -0,0 +1,130 @@
1
+ import React, { useState } from 'react';
2
+ import { useHistory } from 'react-router-dom';
3
+ import { FormattedMessage } from 'react-intl';
4
+ import { trackEvent } from '@eeacms/volto-matomo/utils';
5
+
6
+ import IntroHeader from './components/IntroHeader';
7
+ import SearchInput from './components/SearchInput';
8
+ import PromptChips from './components/PromptChips';
9
+ import DeepResearchToggle from './components/DeepResearchToggle';
10
+ import DisclaimerText from './components/DisclaimerText';
11
+
12
+ import './styles.less';
13
+
14
+ const AISearchInputView = ({ data }) => {
15
+ const history = useHistory();
16
+ const [query, setQuery] = useState('');
17
+ const [error, setError] = useState(null);
18
+ const [deepResearchEnabled, setDeepResearchEnabled] = useState(
19
+ data.deepResearch === 'always_on' || data.deepResearch === 'user_on',
20
+ );
21
+
22
+ const variant = data.stylingVariant || 'dark';
23
+
24
+ const handleSubmit = (message) => {
25
+ setError(null);
26
+ const q = (message || query).trim();
27
+
28
+ if (!q) return;
29
+
30
+ if (!data.assistantEndpoint) {
31
+ setError(
32
+ <FormattedMessage
33
+ id="Assistant page is not configured. Please contact the site administrator."
34
+ defaultMessage="Assistant page is not configured. Please contact the site administrator."
35
+ />,
36
+ );
37
+ return;
38
+ }
39
+
40
+ // Build target URL
41
+ let targetUrl;
42
+ try {
43
+ targetUrl = new URL(data.assistantEndpoint, window.location.origin);
44
+ } catch {
45
+ setError(
46
+ <FormattedMessage
47
+ id="Invalid assistant page URL."
48
+ defaultMessage="Invalid assistant page URL."
49
+ />,
50
+ );
51
+ return;
52
+ }
53
+
54
+ targetUrl.searchParams.set('query', q);
55
+ if (data.deepResearch !== 'unavailable') {
56
+ targetUrl.searchParams.set(
57
+ 'deepResearch',
58
+ deepResearchEnabled ? 'true' : 'false',
59
+ );
60
+ }
61
+
62
+ const targetString = targetUrl.pathname + targetUrl.search;
63
+
64
+ // Matomo tracking
65
+ trackEvent({
66
+ category: 'Chatbot',
67
+ action: 'AI Search Input: Question submitted',
68
+ name: q.substring(0, 100),
69
+ });
70
+
71
+ // Navigate: client-side for same-origin, full for external
72
+ if (targetUrl.origin === window.location.origin) {
73
+ history.push(targetString);
74
+ } else {
75
+ window.location.href = targetUrl.toString();
76
+ }
77
+ };
78
+
79
+ const handleChipSelect = (message) => {
80
+ handleSubmit(message);
81
+ };
82
+
83
+ return (
84
+ <div className={`ais-search-input ais-${variant}`}>
85
+ <IntroHeader
86
+ showIcon={data.showIcon}
87
+ blockTitle={data.blockTitle}
88
+ introText={data.introText}
89
+ />
90
+
91
+ <SearchInput
92
+ placeholderText={data.placeholderText || 'Ask your question...'}
93
+ onSubmit={() => handleSubmit()}
94
+ value={query}
95
+ onChange={(value) => {
96
+ setQuery(value);
97
+ setError(null);
98
+ }}
99
+ error={error}
100
+ />
101
+
102
+ {(data.examplePromptsEnabled && data.examplePrompts) ||
103
+ data.deepResearch !== 'unavailable' ? (
104
+ <div className="ais-controls-row">
105
+ {data.examplePromptsEnabled && data.examplePrompts && (
106
+ <PromptChips
107
+ prompts={data.examplePrompts}
108
+ onSelect={handleChipSelect}
109
+ />
110
+ )}
111
+ <DeepResearchToggle
112
+ mode={data.deepResearch}
113
+ enabled={deepResearchEnabled}
114
+ onChange={setDeepResearchEnabled}
115
+ />
116
+ </div>
117
+ ) : (
118
+ <DeepResearchToggle
119
+ mode={data.deepResearch}
120
+ enabled={deepResearchEnabled}
121
+ onChange={setDeepResearchEnabled}
122
+ />
123
+ )}
124
+
125
+ <DisclaimerText disclaimerText={data.disclaimerText} />
126
+ </div>
127
+ );
128
+ };
129
+
130
+ export default AISearchInputView;
@@ -0,0 +1,35 @@
1
+ import React from 'react';
2
+ import { FormattedMessage } from 'react-intl';
3
+
4
+ const DeepResearchToggle = ({ mode, enabled, onChange }) => {
5
+ if (mode === 'unavailable' || !mode) return null;
6
+
7
+ if (mode === 'always_on') {
8
+ return (
9
+ <div className="ais-deep-research">
10
+ <small className="ais-deep-research-label">
11
+ <FormattedMessage
12
+ id="Deep research on"
13
+ defaultMessage="Deep research on"
14
+ />
15
+ </small>
16
+ </div>
17
+ );
18
+ }
19
+
20
+ return (
21
+ <div className="ais-deep-research">
22
+ <label className="ais-toggle">
23
+ <input
24
+ type="checkbox"
25
+ checked={enabled}
26
+ onChange={(e) => onChange(e.target.checked)}
27
+ />
28
+ <span className="ais-toggle-slider" />
29
+ <FormattedMessage id="Deep research" defaultMessage="Deep research" />
30
+ </label>
31
+ </div>
32
+ );
33
+ };
34
+
35
+ export default DeepResearchToggle;
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import { serializeNodes } from '@plone/volto-slate/editor/render';
3
+
4
+ const DisclaimerText = ({ disclaimerText }) => {
5
+ if (!disclaimerText) return null;
6
+
7
+ return <div className="ais-disclaimer">{serializeNodes(disclaimerText)}</div>;
8
+ };
9
+
10
+ export default DisclaimerText;
@@ -0,0 +1,24 @@
1
+ import React from 'react';
2
+ import SVGIcon from '@eeacms/volto-eea-chatbot/ChatBlock/components/Icon';
3
+ import SparkleIcon from '@eeacms/volto-eea-chatbot/icons/sparkle.svg';
4
+ import { serializeNodes } from '@plone/volto-slate/editor/render';
5
+
6
+ const IntroHeader = ({ showIcon, blockTitle, introText }) => {
7
+ if (!showIcon && !blockTitle && !introText) return null;
8
+
9
+ return (
10
+ <div className="ais-intro-header">
11
+ {showIcon && (
12
+ <SVGIcon name={SparkleIcon} size={20} className="ais-icon" />
13
+ )}
14
+ <div className="ais-intro-content">
15
+ {blockTitle && <span className="ais-block-title">{blockTitle}</span>}
16
+ {introText && (
17
+ <div className="ais-intro-slate">{serializeNodes(introText)}</div>
18
+ )}
19
+ </div>
20
+ </div>
21
+ );
22
+ };
23
+
24
+ export default IntroHeader;
@@ -0,0 +1,22 @@
1
+ import React from 'react';
2
+
3
+ const PromptChips = ({ prompts, onSelect }) => {
4
+ if (!prompts || prompts.length === 0) return null;
5
+
6
+ return (
7
+ <div className="ais-prompt-chips">
8
+ {prompts.map((prompt, index) => (
9
+ <button
10
+ key={index}
11
+ type="button"
12
+ className="ais-chip"
13
+ onClick={() => onSelect(prompt.message || prompt.label)}
14
+ >
15
+ {prompt.label}
16
+ </button>
17
+ ))}
18
+ </div>
19
+ );
20
+ };
21
+
22
+ export default PromptChips;
@@ -0,0 +1,53 @@
1
+ import React from 'react';
2
+ import { FormattedMessage } from 'react-intl';
3
+ import SVGIcon from '@eeacms/volto-eea-chatbot/ChatBlock/components/Icon';
4
+ import SendIcon from '@eeacms/volto-eea-chatbot/icons/send.svg';
5
+
6
+ const SearchInput = ({ placeholderText, onSubmit, value, onChange, error }) => {
7
+ const handleKeyDown = (e) => {
8
+ if (e.key === 'Enter' && !e.shiftKey) {
9
+ e.preventDefault();
10
+ onSubmit();
11
+ }
12
+ };
13
+
14
+ return (
15
+ <div className="ais-search-input-field">
16
+ <input
17
+ type="text"
18
+ value={value}
19
+ onChange={(e) => onChange(e.target.value)}
20
+ onKeyDown={handleKeyDown}
21
+ placeholder={placeholderText}
22
+ aria-label={
23
+ <FormattedMessage
24
+ id="Ask your question..."
25
+ defaultMessage="Ask your question..."
26
+ />
27
+ }
28
+ aria-invalid={!!error}
29
+ aria-describedby={error ? 'ais-input-error' : undefined}
30
+ />
31
+ <button
32
+ type="button"
33
+ onClick={onSubmit}
34
+ className="ais-submit-btn"
35
+ aria-label={
36
+ <FormattedMessage
37
+ id="Submit question"
38
+ defaultMessage="Submit question"
39
+ />
40
+ }
41
+ >
42
+ <SVGIcon name={SendIcon} size={24} />
43
+ </button>
44
+ {error && (
45
+ <div id="ais-input-error" className="ais-input-error" role="alert">
46
+ {error}
47
+ </div>
48
+ )}
49
+ </div>
50
+ );
51
+ };
52
+
53
+ export default SearchInput;
@@ -0,0 +1,32 @@
1
+ import sparkleSVG from '@eeacms/volto-eea-chatbot/icons/sparkle.svg';
2
+ import AISearchInputView from './AISearchInputView';
3
+ import AISearchInputEdit from './AISearchInputEdit';
4
+ import { AISearchInputSchema } from './schema';
5
+
6
+ export default function installAISearchInputBlock(config) {
7
+ config.blocks.blocksConfig.eeaAISearchInput = {
8
+ id: 'eeaAISearchInput',
9
+ title: 'AI Search Input',
10
+ icon: sparkleSVG,
11
+ group: 'common',
12
+ view: AISearchInputView,
13
+ edit: AISearchInputEdit,
14
+ restricted: ({ user }) => {
15
+ if (user?.roles) {
16
+ return !user.roles.find((role) => role === 'Manager');
17
+ }
18
+ return false;
19
+ },
20
+ mostUsed: false,
21
+ blockHasOwnFocusManagement: false,
22
+ sidebarTab: 1,
23
+ schema: AISearchInputSchema,
24
+ security: {
25
+ addPermission: [],
26
+ view: [],
27
+ },
28
+ variations: [],
29
+ };
30
+
31
+ return config;
32
+ }
@@ -0,0 +1,38 @@
1
+ import { defineMessages } from 'react-intl';
2
+
3
+ export default defineMessages({
4
+ aiSearchInput: {
5
+ id: 'AI Search Input',
6
+ defaultMessage: 'AI Search Input',
7
+ },
8
+ askYourQuestion: {
9
+ id: 'Ask your question...',
10
+ defaultMessage: 'Ask your question...',
11
+ },
12
+ submit: {
13
+ id: 'Submit question',
14
+ defaultMessage: 'Submit question',
15
+ },
16
+ deepResearch: {
17
+ id: 'Deep research',
18
+ defaultMessage: 'Deep research',
19
+ },
20
+ deepResearchOn: {
21
+ id: 'Deep research on',
22
+ defaultMessage: 'Deep research on',
23
+ },
24
+ defaultDisclaimer: {
25
+ id: 'This assistant uses AI. Responses may contain inaccuracies.',
26
+ defaultMessage:
27
+ 'This assistant uses AI. Responses may contain inaccuracies.',
28
+ },
29
+ noEndpointError: {
30
+ id: 'Assistant page is not configured. Please contact the site administrator.',
31
+ defaultMessage:
32
+ 'Assistant page is not configured. Please contact the site administrator.',
33
+ },
34
+ invalidEndpointError: {
35
+ id: 'Invalid assistant page URL.',
36
+ defaultMessage: 'Invalid assistant page URL.',
37
+ },
38
+ });
@@ -0,0 +1,215 @@
1
+ import messages from './messages';
2
+
3
+ export function AISearchInputSchema({ intl, data }) {
4
+ return {
5
+ title: intl.formatMessage(messages.aiSearchInput),
6
+ fieldsets: [
7
+ {
8
+ id: 'default',
9
+ title: intl.formatMessage({
10
+ id: 'Default',
11
+ defaultMessage: 'Default',
12
+ }),
13
+ fields: [
14
+ 'assistantEndpoint',
15
+ 'blockTitle',
16
+ 'introText',
17
+ 'showIcon',
18
+ 'placeholderText',
19
+ 'examplePromptsEnabled',
20
+ ...(data.examplePromptsEnabled ? ['examplePrompts'] : []),
21
+ 'deepResearch',
22
+ 'disclaimerText',
23
+ 'stylingVariant',
24
+ ],
25
+ },
26
+ ],
27
+ properties: {
28
+ assistantEndpoint: {
29
+ title: intl.formatMessage({
30
+ id: 'Assistant page URL',
31
+ defaultMessage: 'Assistant page URL',
32
+ }),
33
+ description: intl.formatMessage({
34
+ id: 'URL of the Plone page that contains the AI Chatbot block (e.g. /ai-assistant).',
35
+ defaultMessage:
36
+ 'URL of the Plone page that contains the AI Chatbot block (e.g. /ai-assistant).',
37
+ }),
38
+ widget: 'url',
39
+ type: 'string',
40
+ },
41
+ blockTitle: {
42
+ title: intl.formatMessage({
43
+ id: 'Block title',
44
+ defaultMessage: 'Block title',
45
+ }),
46
+ description: intl.formatMessage({
47
+ id: 'Optional title displayed inline with the icon.',
48
+ defaultMessage: 'Optional title displayed inline with the icon.',
49
+ }),
50
+ type: 'string',
51
+ },
52
+ introText: {
53
+ title: intl.formatMessage({
54
+ id: 'Introductory text',
55
+ defaultMessage: 'Introductory text',
56
+ }),
57
+ description: intl.formatMessage({
58
+ id: 'Help text shown next to the icon and title.',
59
+ defaultMessage: 'Help text shown next to the icon and title.',
60
+ }),
61
+ widget: 'slate',
62
+ },
63
+ showIcon: {
64
+ title: intl.formatMessage({
65
+ id: 'Show icon',
66
+ defaultMessage: 'Show icon',
67
+ }),
68
+ type: 'boolean',
69
+ default: true,
70
+ },
71
+ placeholderText: {
72
+ title: intl.formatMessage({
73
+ id: 'Placeholder text',
74
+ defaultMessage: 'Placeholder text',
75
+ }),
76
+ type: 'string',
77
+ default: intl.formatMessage(messages.askYourQuestion),
78
+ },
79
+ examplePromptsEnabled: {
80
+ title: intl.formatMessage({
81
+ id: 'Show example prompts',
82
+ defaultMessage: 'Show example prompts',
83
+ }),
84
+ type: 'boolean',
85
+ default: false,
86
+ description: intl.formatMessage({
87
+ id: 'When enabled, editors can define clickable prompt chips below the input.',
88
+ defaultMessage:
89
+ 'When enabled, editors can define clickable prompt chips below the input.',
90
+ }),
91
+ },
92
+ examplePrompts: {
93
+ title: intl.formatMessage({
94
+ id: 'Example prompts',
95
+ defaultMessage: 'Example prompts',
96
+ }),
97
+ widget: 'object_list',
98
+ schema: {
99
+ title: intl.formatMessage({
100
+ id: 'Prompt',
101
+ defaultMessage: 'Prompt',
102
+ }),
103
+ fieldsets: [
104
+ {
105
+ id: 'default',
106
+ title: intl.formatMessage({
107
+ id: 'Default',
108
+ defaultMessage: 'Default',
109
+ }),
110
+ fields: ['label', 'message'],
111
+ },
112
+ ],
113
+ properties: {
114
+ label: {
115
+ title: intl.formatMessage({
116
+ id: 'Label',
117
+ defaultMessage: 'Label',
118
+ }),
119
+ description: intl.formatMessage({
120
+ id: 'Text displayed on the chip button.',
121
+ defaultMessage: 'Text displayed on the chip button.',
122
+ }),
123
+ },
124
+ message: {
125
+ title: intl.formatMessage({
126
+ id: 'Message',
127
+ defaultMessage: 'Message',
128
+ }),
129
+ type: 'string',
130
+ description: intl.formatMessage({
131
+ id: 'Optional — the actual query sent when clicked. If empty, uses the label.',
132
+ defaultMessage:
133
+ 'Optional — the actual query sent when clicked. If empty, uses the label.',
134
+ }),
135
+ },
136
+ },
137
+ required: ['label'],
138
+ },
139
+ },
140
+ deepResearch: {
141
+ title: intl.formatMessage(messages.deepResearch),
142
+ choices: [
143
+ [
144
+ 'unavailable',
145
+ intl.formatMessage({
146
+ id: 'Unavailable',
147
+ defaultMessage: 'Unavailable',
148
+ }),
149
+ ],
150
+ [
151
+ 'always_on',
152
+ intl.formatMessage({
153
+ id: 'Always on',
154
+ defaultMessage: 'Always on',
155
+ }),
156
+ ],
157
+ [
158
+ 'user_on',
159
+ intl.formatMessage({
160
+ id: 'User choice, on by default',
161
+ defaultMessage: 'User choice, on by default',
162
+ }),
163
+ ],
164
+ [
165
+ 'user_off',
166
+ intl.formatMessage({
167
+ id: 'User choice, off by default',
168
+ defaultMessage: 'User choice, off by default',
169
+ }),
170
+ ],
171
+ ],
172
+ default: 'unavailable',
173
+ },
174
+ disclaimerText: {
175
+ title: intl.formatMessage({
176
+ id: 'Disclaimer text',
177
+ defaultMessage: 'Disclaimer text',
178
+ }),
179
+ widget: 'slate',
180
+ description: intl.formatMessage({
181
+ id: 'AI transparency text. Always displayed at the bottom of the block.',
182
+ defaultMessage:
183
+ 'AI transparency text. Always displayed at the bottom of the block.',
184
+ }),
185
+ default: [
186
+ {
187
+ type: 'p',
188
+ children: [
189
+ { text: intl.formatMessage(messages.defaultDisclaimer) },
190
+ ],
191
+ },
192
+ ],
193
+ },
194
+ stylingVariant: {
195
+ title: intl.formatMessage({
196
+ id: 'Styling variant',
197
+ defaultMessage: 'Styling variant',
198
+ }),
199
+ choices: [
200
+ ['dark', intl.formatMessage({ id: 'Dark', defaultMessage: 'Dark' })],
201
+ [
202
+ 'light',
203
+ intl.formatMessage({ id: 'Light', defaultMessage: 'Light' }),
204
+ ],
205
+ [
206
+ 'accent',
207
+ intl.formatMessage({ id: 'Accent', defaultMessage: 'Accent' }),
208
+ ],
209
+ ],
210
+ default: 'dark',
211
+ },
212
+ },
213
+ required: ['assistantEndpoint'],
214
+ };
215
+ }