@eeacms/volto-eea-chatbot 2.0.5 → 3.0.1

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 (36) hide show
  1. package/.github/workflows/betterleaks.yml +133 -0
  2. package/.gitleaks.toml +89 -0
  3. package/AGENTS.md +230 -0
  4. package/CHANGELOG.md +40 -0
  5. package/README.md +59 -0
  6. package/RELEASE.md +1 -1
  7. package/locales/de/LC_MESSAGES/volto.po +44 -0
  8. package/locales/en/LC_MESSAGES/volto.po +44 -0
  9. package/locales/it/LC_MESSAGES/volto.po +44 -0
  10. package/locales/ro/LC_MESSAGES/volto.po +44 -0
  11. package/locales/volto.pot +46 -2
  12. package/package.json +2 -1
  13. package/src/AISearchInput/AISearchInputEdit.jsx +37 -0
  14. package/src/AISearchInput/AISearchInputView.jsx +130 -0
  15. package/src/AISearchInput/components/DeepResearchToggle.jsx +35 -0
  16. package/src/AISearchInput/components/DisclaimerText.jsx +10 -0
  17. package/src/AISearchInput/components/IntroHeader.jsx +24 -0
  18. package/src/AISearchInput/components/PromptChips.jsx +22 -0
  19. package/src/AISearchInput/components/SearchInput.jsx +53 -0
  20. package/src/AISearchInput/index.js +32 -0
  21. package/src/AISearchInput/messages.js +38 -0
  22. package/src/AISearchInput/schema.jsx +215 -0
  23. package/src/AISearchInput/styles.less +320 -0
  24. package/src/ChatBlock/ChatBlockView.jsx +4 -0
  25. package/src/ChatBlock/chat/ChatWindow.tsx +23 -0
  26. package/src/ChatBlock/packets/MultiToolRenderer.tsx +20 -11
  27. package/src/ChatBlock/services/messageProcessor.ts +32 -14
  28. package/src/ChatBlock/style.less +41 -51
  29. package/src/halloumi/filtering.js +2 -2
  30. package/src/halloumi/generative.test.js +1 -1
  31. package/src/halloumi/middleware.js +2 -2
  32. package/src/halloumi/middleware.test.js +5 -5
  33. package/src/icons/sparkle.svg +3 -0
  34. package/src/index.js +2 -0
  35. package/src/middleware.js +2 -2
  36. package/src/middleware.test.js +11 -11
@@ -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
+ }
@@ -0,0 +1,320 @@
1
+ // AI Search Input styles — scoped under .ais-search-input
2
+ // Uses EEA Design System tokens
3
+
4
+ @import '@eeacms/volto-design-tokens/src/colors';
5
+ @import '@eeacms/volto-design-tokens/src/shapes';
6
+ @import '@eeacms/volto-design-tokens/src/borders';
7
+ @import '@eeacms/volto-design-tokens/src/fonts';
8
+
9
+ // Spacing tokens from @eeacms/volto-design-tokens/src/sizes
10
+ // (imported manually to avoid @1px Semantic UI dependency)
11
+ @spacer: 4px;
12
+ @space-025: @spacer * 0.25;
13
+ @space-05: @spacer * 0.5;
14
+ @space-1: @spacer * 1;
15
+ @space-2: @spacer * 2;
16
+ @space-3: @spacer * 3;
17
+ @space-4: @spacer * 4;
18
+ @space-5: @spacer * 5;
19
+ @space-6: @spacer * 6;
20
+ @space-8: @spacer * 8;
21
+
22
+ // Size tokens
23
+ @size-10: 120px;
24
+
25
+ .ais-search-input {
26
+ display: flex;
27
+ flex-direction: column;
28
+ padding: @space-6;
29
+ gap: @space-3;
30
+
31
+ // --- Dark variant (default) ---
32
+ &.ais-dark {
33
+ background-color: @blue-6;
34
+ color: @grey-0;
35
+
36
+ .ais-chip {
37
+ background-color: @blue-5;
38
+ }
39
+
40
+ .ais-search-input-field {
41
+ border-bottom-color: @grey-0;
42
+ }
43
+
44
+ .ais-input-error {
45
+ color: @red-2;
46
+ }
47
+ }
48
+
49
+ // --- Light variant ---
50
+ &.ais-light {
51
+ background-color: @grey-1;
52
+ color: @grey-5;
53
+
54
+ .ais-chip {
55
+ background-color: @grey-2;
56
+ }
57
+
58
+ .ais-search-input-field {
59
+ border-bottom-color: @grey-5;
60
+ }
61
+
62
+ .ais-input-error {
63
+ color: @red-5;
64
+ }
65
+ }
66
+
67
+ // --- Accent variant (EEA green/teal) ---
68
+ &.ais-accent {
69
+ background-color: @green-5;
70
+ color: @grey-0;
71
+
72
+ .ais-chip {
73
+ background-color: @green-4;
74
+ }
75
+
76
+ .ais-search-input-field {
77
+ border-bottom-color: @grey-0;
78
+ }
79
+
80
+ .ais-input-error {
81
+ color: @red-1;
82
+ }
83
+ }
84
+
85
+ // --- Intro header (Row 1) ---
86
+ .ais-intro-header {
87
+ display: flex;
88
+ align-items: flex-start;
89
+ gap: @space-2;
90
+
91
+ .ais-icon {
92
+ flex-shrink: 0;
93
+ margin-top: @space-05;
94
+ }
95
+
96
+ .ais-intro-content {
97
+ display: flex;
98
+ flex-direction: column;
99
+ gap: @space-05;
100
+ }
101
+
102
+ .ais-block-title {
103
+ font-size: @font-size-1;
104
+ font-weight: @font-weight-6;
105
+ }
106
+
107
+ // Slate-rendered intro text
108
+ :global(.slate-editor) {
109
+ margin: 0;
110
+ font-size: @font-size-0;
111
+ line-height: @font-lineheight-2;
112
+
113
+ p {
114
+ margin: 0;
115
+ }
116
+
117
+ a {
118
+ color: inherit;
119
+ text-decoration: underline;
120
+ }
121
+ }
122
+ }
123
+
124
+ // --- Search input (Row 2) ---
125
+ .ais-search-input-field {
126
+ display: flex;
127
+ align-items: center;
128
+ padding-bottom: @space-1;
129
+ border-bottom: @border-size-2 solid currentColor;
130
+
131
+ input {
132
+ flex: 1;
133
+ padding: @space-1 0;
134
+ border: none;
135
+ background: transparent;
136
+ color: inherit;
137
+ font-family: inherit;
138
+ font-size: @font-size-1;
139
+ outline: none;
140
+
141
+ &::placeholder {
142
+ color: inherit;
143
+ opacity: 0.6;
144
+ }
145
+ }
146
+
147
+ .ais-submit-btn {
148
+ display: flex;
149
+ flex-shrink: 0;
150
+ align-items: center;
151
+ padding: @space-1;
152
+ border: none;
153
+ background: none;
154
+ color: inherit;
155
+ cursor: pointer;
156
+ transition: opacity 0.2s;
157
+
158
+ &:hover {
159
+ opacity: 0.8;
160
+ }
161
+
162
+ &:focus-visible {
163
+ border-radius: @radius-1;
164
+ outline: @border-size-2 solid currentColor;
165
+ outline-offset: @space-05;
166
+ }
167
+ }
168
+ }
169
+
170
+ .ais-input-error {
171
+ width: 100%;
172
+ margin-top: @space-1;
173
+ font-size: @font-size-00;
174
+ }
175
+
176
+ // --- Controls row: chips (left) + deep research toggle (right) ---
177
+ .ais-controls-row {
178
+ display: flex;
179
+ flex-wrap: wrap;
180
+ align-items: center;
181
+ gap: @space-2;
182
+ }
183
+
184
+ // --- Prompt chips ---
185
+ .ais-prompt-chips {
186
+ display: flex;
187
+ flex: 1;
188
+ flex-wrap: wrap;
189
+ gap: @space-2;
190
+
191
+ .ais-chip {
192
+ padding: @space-1 @space-3;
193
+ border: none;
194
+ border-radius: @radius-0;
195
+ color: inherit;
196
+ cursor: pointer;
197
+ font-family: inherit;
198
+ font-size: @font-size-0;
199
+ transition: opacity 0.2s;
200
+
201
+ &:hover {
202
+ opacity: 0.85;
203
+ }
204
+
205
+ &:focus-visible {
206
+ outline: @border-size-2 solid currentColor;
207
+ outline-offset: @space-05;
208
+ }
209
+ }
210
+ }
211
+
212
+ // --- Deep research toggle (right-aligned) ---
213
+ .ais-deep-research {
214
+ display: flex;
215
+ flex-shrink: 0;
216
+ align-items: center;
217
+ margin-left: auto;
218
+ }
219
+
220
+ .ais-deep-research-label {
221
+ font-size: @font-size-00;
222
+ opacity: 0.8;
223
+ }
224
+
225
+ // Custom toggle switch
226
+ .ais-toggle {
227
+ display: inline-flex;
228
+ align-items: center;
229
+ cursor: pointer;
230
+ font-size: @font-size-0;
231
+ gap: @space-2;
232
+
233
+ input {
234
+ display: none;
235
+ }
236
+
237
+ .ais-toggle-slider {
238
+ position: relative;
239
+ display: inline-block;
240
+ width: 28px;
241
+ height: 16px;
242
+ border: @border-size-1 solid currentColor;
243
+ border-radius: @radius-3;
244
+ background-color: rgba(currentColor, 0.15);
245
+ transition: background-color 0.2s;
246
+
247
+ &::before {
248
+ position: absolute;
249
+ top: 1px;
250
+ left: 2px;
251
+ display: block;
252
+ width: 12px;
253
+ height: 12px;
254
+ border-radius: 50%;
255
+ background-color: currentColor;
256
+ content: '';
257
+ transition: left 0.2s;
258
+ }
259
+ }
260
+
261
+ input:checked ~ .ais-toggle-slider {
262
+ border-color: @green-4;
263
+ background-color: @green-4;
264
+
265
+ &::before {
266
+ left: 13px;
267
+ }
268
+ }
269
+ }
270
+
271
+ // --- Disclaimer (Row 5) ---
272
+ .ais-disclaimer {
273
+ margin-top: @space-1;
274
+ font-size: @font-size-00;
275
+ opacity: 0.7;
276
+
277
+ :global(.slate-editor) {
278
+ font-size: inherit;
279
+
280
+ p {
281
+ margin: 0;
282
+ }
283
+
284
+ a {
285
+ color: inherit;
286
+ text-decoration: underline;
287
+ }
288
+ }
289
+ }
290
+
291
+ // --- Responsive ---
292
+ @media (max-width: 768px) {
293
+ padding: @space-4;
294
+ gap: @space-2;
295
+
296
+ .ais-intro-header {
297
+ .ais-icon {
298
+ width: @space-5;
299
+ height: @space-5;
300
+ }
301
+
302
+ .ais-block-title {
303
+ font-size: @font-size-0;
304
+ }
305
+
306
+ :global(.slate-editor) {
307
+ font-size: @font-size-00;
308
+ }
309
+ }
310
+
311
+ .ais-search-input-field input {
312
+ font-size: @font-size-0;
313
+ }
314
+
315
+ .ais-prompt-chips .ais-chip {
316
+ min-width: @size-10;
317
+ flex: 1 1 auto;
318
+ }
319
+ }
320
+ }
@@ -13,6 +13,8 @@ function ChatBlockView(props) {
13
13
  );
14
14
 
15
15
  const isPlaywrightTest = query.playwright === 'yes';
16
+ const initialQuery = query.query || null;
17
+ const initialDeepResearch = query.deepResearch || null;
16
18
 
17
19
  useEffect(() => {
18
20
  if (isPlaywrightTest) {
@@ -29,6 +31,8 @@ function ChatBlockView(props) {
29
31
  isEditMode={isEditMode}
30
32
  isPlaywrightTest={isPlaywrightTest}
31
33
  block_id={id}
34
+ initialQuery={initialQuery}
35
+ initialDeepResearch={initialDeepResearch}
32
36
  {...data}
33
37
  />
34
38
  ) : (
@@ -5,6 +5,7 @@ import React, {
5
5
  useMemo,
6
6
  useCallback,
7
7
  } from 'react';
8
+ import { useHistory } from 'react-router-dom';
8
9
  import type { Persona } from '@eeacms/volto-eea-chatbot/ChatBlock/types/interfaces';
9
10
  import { Button, Form, Segment, Checkbox } from 'semantic-ui-react';
10
11
  import { injectLazyLibs } from '@plone/volto/helpers/Loadable';
@@ -50,6 +51,8 @@ interface ChatWindowProps {
50
51
  maxContextSegments?: number;
51
52
  onyxVersion?: '2' | '3';
52
53
  isPlaywrightTest?: boolean;
54
+ initialQuery?: string | null;
55
+ initialDeepResearch?: string | null;
53
56
  [key: string]: any;
54
57
  }
55
58
 
@@ -61,6 +64,8 @@ function ChatWindow({
61
64
  placeholderPrompt = 'Ask a question',
62
65
  isEditMode,
63
66
  isPlaywrightTest,
67
+ initialQuery,
68
+ initialDeepResearch,
64
69
  ...data
65
70
  }: ChatWindowProps) {
66
71
  const {
@@ -124,6 +129,7 @@ function ChatWindow({
124
129
 
125
130
  const [showLandingPage, setShowLandingPage] = useState(true);
126
131
 
132
+ const history = useHistory();
127
133
  const textareaRef = useRef<HTMLTextAreaElement | null>(null);
128
134
  const chatWindowRef = useRef(null);
129
135
  const chatWindowEndRef = useRef(null);
@@ -132,6 +138,23 @@ function ChatWindow({
132
138
  setShowLandingPage(messages.length === 0);
133
139
  }, [messages]);
134
140
 
141
+ // Set deep research state from URL parameter
142
+ useEffect(() => {
143
+ if (initialDeepResearch !== null && initialDeepResearch !== undefined) {
144
+ const enabled = initialDeepResearch.toLowerCase() === 'true';
145
+ setIsDeepResearchEnabled(enabled);
146
+ }
147
+ }, []);
148
+
149
+ // Auto-submit query from URL parameter on mount
150
+ useEffect(() => {
151
+ if (initialQuery && messages.length === 0 && !isStreaming) {
152
+ onSubmit({ message: initialQuery });
153
+ setShowLandingPage(false);
154
+ history.replace(window.location.pathname);
155
+ }
156
+ }, []);
157
+
135
158
  const handleStarterPromptChoice = useCallback(
136
159
  (message: string) => {
137
160
  if (enableMatomoTracking) {
@@ -133,7 +133,14 @@ export function MultiToolRenderer({
133
133
  {filteredToolGroups.map((toolGroup, index) => {
134
134
  const isLastItem = index === filteredToolGroups.length - 1;
135
135
  const toolState = toolStates.get(toolGroup.ind);
136
- const isToolCompleted = toolState?.isCompleted;
136
+ const isToolCompleted = toolState?.isCompleted === true;
137
+ const isToolActive =
138
+ isOverallStreaming && !isToolCompleted && isLastItem;
139
+ const toolDisplayState = isToolActive
140
+ ? 'active'
141
+ : isToolCompleted
142
+ ? 'completed'
143
+ : 'pending';
137
144
 
138
145
  return (
139
146
  <div
@@ -141,6 +148,8 @@ export function MultiToolRenderer({
141
148
  className={cx({
142
149
  'tool-collapsed-wrapper': isOverallStreaming,
143
150
  })}
151
+ data-state={toolDisplayState}
152
+ aria-current={isToolActive ? 'step' : undefined}
144
153
  >
145
154
  <RendererComponent
146
155
  packets={toolGroup.packets}
@@ -158,12 +167,7 @@ export function MultiToolRenderer({
158
167
  const finalIcon = icon ? (
159
168
  React.createElement(icon, { size: 14 })
160
169
  ) : (
161
- <span
162
- className={cx({
163
- 'tool-icon-dot': isOverallStreaming,
164
- 'tool-icon-default': !isOverallStreaming,
165
- })}
166
- />
170
+ <span className="tool-icon-dot" />
167
171
  );
168
172
 
169
173
  // If tool is not completed and we are overall streaming, show collapsed view
@@ -184,14 +188,14 @@ export function MultiToolRenderer({
184
188
  return (
185
189
  <div
186
190
  className={cx('tool-item-collapsed', {
187
- active: isLastItem,
191
+ active: isToolActive,
188
192
  completed: isToolCompleted,
189
193
  })}
190
194
  >
191
195
  <div className="tool-collapsed-icon">
192
196
  {finalIcon}
193
197
  </div>
194
- <span className="tool-collapsed-status">
198
+ <span className="tool-status tool-collapsed-status">
195
199
  {status}
196
200
  </span>
197
201
  </div>
@@ -199,9 +203,14 @@ export function MultiToolRenderer({
199
203
  }
200
204
  }
201
205
 
202
- // Expanded view (full content) - used for completed tools or when overall complete
206
+ // Detailed view for streaming reasoning/search tools and completed tools
203
207
  return (
204
- <div className="tool-item-expanded">
208
+ <div
209
+ className={cx('tool-item-expanded', {
210
+ active: isToolActive,
211
+ completed: isToolCompleted,
212
+ })}
213
+ >
205
214
  <div className="tool-connector-line" />
206
215
 
207
216
  <div className="tool-item-row">