@eeacms/volto-eea-chatbot 2.0.4 → 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,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) {
@@ -1,11 +1,19 @@
1
1
  // import debug from 'debug';
2
2
  import { getVerifyClaimResponse } from './generative';
3
+ import { isPathAllowed } from '../middleware';
3
4
 
4
5
  // const log = debug('halloumi');
5
6
 
6
7
  const MSG_INVALID_CONFIGURATION =
7
8
  'Invalid configuration: missing LLMGW_TOKEN or LLMGW_URL';
8
9
 
10
+ // Allowed paths for _ha (Halloumi) proxy.
11
+ // When adding new endpoints, update this list.
12
+ const ALLOWED_HALLOUMI_PATHS = [
13
+ { path: '/generate', methods: ['POST'] },
14
+ { path: '/classify', methods: ['POST'] },
15
+ ];
16
+
9
17
  const LLMGW_URL = process.env.LLMGW_URL;
10
18
  const LLMGW_TOKEN = process.env.LLMGW_TOKEN;
11
19
 
@@ -32,6 +40,14 @@ const classifyModel = {
32
40
  export default async function middleware(req, res, next) {
33
41
  const path = req.url.replace('/_ha/', '/');
34
42
 
43
+ // Reject paths not on the allowlist — prevents Confused Deputy attacks
44
+ if (!isPathAllowed(path, req.method, ALLOWED_HALLOUMI_PATHS)) {
45
+ res.statusCode = 404;
46
+ res.statusMessage = 'Not Found';
47
+ res.send({ error: 'Not Found' });
48
+ return;
49
+ }
50
+
35
51
  if (!(LLMGW_TOKEN && LLMGW_URL)) {
36
52
  res.send({
37
53
  error: MSG_INVALID_CONFIGURATION,
@@ -1,4 +1,5 @@
1
1
  import middleware from './middleware';
2
+ import { isPathAllowed } from '../middleware';
2
3
 
3
4
  jest.mock('./generative');
4
5
 
@@ -8,11 +9,14 @@ describe('halloumi middleware', () => {
8
9
  beforeEach(() => {
9
10
  req = {
10
11
  url: '/_ha/generate',
12
+ method: 'POST',
11
13
  body: {
12
14
  sources: ['source1', 'source2'],
13
15
  answer: 'test answer',
14
16
  maxContextSegments: 3,
15
17
  },
18
+ headers: {},
19
+ ip: '127.0.0.1',
16
20
  };
17
21
  res = {
18
22
  send: jest.fn(),
@@ -42,28 +46,144 @@ describe('halloumi middleware', () => {
42
46
  process.env.LLMGW_URL = origUrl;
43
47
  });
44
48
 
45
- it('calls getVerifyClaimResponse and sends response on success', async () => {
49
+ it('rejects disallowed paths with 404', async () => {
50
+ req.url = '/_ha/admin/config';
51
+ req.method = 'POST';
52
+
53
+ await middleware(req, res, next);
54
+
55
+ expect(res.statusCode).toBe(404);
56
+ expect(res.send).toHaveBeenCalledWith({ error: 'Not Found' });
57
+ });
58
+
59
+ it('rejects allowed path with wrong HTTP method', async () => {
60
+ req.url = '/_ha/generate';
61
+ req.method = 'GET';
62
+
63
+ await middleware(req, res, next);
64
+
65
+ expect(res.statusCode).toBe(404);
66
+ expect(res.send).toHaveBeenCalledWith({ error: 'Not Found' });
67
+ });
68
+ });
69
+
70
+ // These tests need a fresh module loaded with env vars set so that
71
+ // LLMGW_TOKEN and LLMGW_URL are defined at module load time,
72
+ // allowing the code to reach getVerifyClaimResponse.
73
+ describe('halloumi middleware - dynamic import', () => {
74
+ const buildReq = () => ({
75
+ url: '/_ha/generate',
76
+ method: 'POST',
77
+ body: {
78
+ sources: ['source1', 'source2'],
79
+ answer: 'test answer',
80
+ maxContextSegments: 3,
81
+ },
82
+ headers: {},
83
+ ip: '127.0.0.1',
84
+ });
85
+
86
+ const buildRes = () => ({
87
+ send: jest.fn(),
88
+ set: jest.fn(),
89
+ status: jest.fn().mockReturnThis(),
90
+ });
91
+
92
+ it('sends response on successful getVerifyClaimResponse', async () => {
46
93
  const origToken = process.env.LLMGW_TOKEN;
47
94
  const origUrl = process.env.LLMGW_URL;
48
95
  process.env.LLMGW_TOKEN = 'test-token';
49
96
  process.env.LLMGW_URL = 'http://test-url';
50
97
 
51
- // Need to re-import since env vars are read at module level
52
98
  jest.resetModules();
53
- const genMod = require('./generative');
54
- const middlewareMod = require('./middleware').default;
55
99
 
56
- genMod.getVerifyClaimResponse = jest
100
+ const mockGetVerifyClaimResponse = jest
57
101
  .fn()
58
102
  .mockResolvedValue({ claims: [], segments: {} });
59
103
 
60
- await middlewareMod(req, res, next);
104
+ jest.setMock('./generative', {
105
+ getVerifyClaimResponse: mockGetVerifyClaimResponse,
106
+ });
107
+
108
+ const middlewareMod = require('./middleware').default;
109
+
110
+ const req = buildReq();
111
+ const res = buildRes();
61
112
 
62
- // It may send error if env vars weren't set before module load,
63
- // but we verify the middleware doesn't crash
64
- expect(res.send).toHaveBeenCalled();
113
+ await middlewareMod(req, res, jest.fn());
114
+
115
+ expect(mockGetVerifyClaimResponse).toHaveBeenCalled();
116
+ expect(res.set).toHaveBeenCalledWith('Content-Type', 'application/json');
117
+ expect(res.send).toHaveBeenCalledWith({ claims: [], segments: {} });
65
118
 
66
119
  process.env.LLMGW_TOKEN = origToken;
67
120
  process.env.LLMGW_URL = origUrl;
68
121
  });
122
+
123
+ it('handles errors from getVerifyClaimResponse', async () => {
124
+ const origToken = process.env.LLMGW_TOKEN;
125
+ const origUrl = process.env.LLMGW_URL;
126
+ process.env.LLMGW_TOKEN = 'test-token';
127
+ process.env.LLMGW_URL = 'http://test-url';
128
+
129
+ jest.resetModules();
130
+
131
+ const mockGetVerifyClaimResponse = jest
132
+ .fn()
133
+ .mockRejectedValue(new Error('LLM error'));
134
+
135
+ jest.setMock('./generative', {
136
+ getVerifyClaimResponse: mockGetVerifyClaimResponse,
137
+ });
138
+
139
+ const middlewareMod = require('./middleware').default;
140
+
141
+ const req = buildReq();
142
+ const res = buildRes();
143
+
144
+ await middlewareMod(req, res, jest.fn());
145
+
146
+ expect(res.status).toHaveBeenCalledWith(500);
147
+ expect(res.send).toHaveBeenCalledWith(
148
+ expect.objectContaining({ error: expect.stringContaining('LLM error') }),
149
+ );
150
+
151
+ process.env.LLMGW_TOKEN = origToken;
152
+ process.env.LLMGW_URL = origUrl;
153
+ });
154
+ });
155
+
156
+ describe('halloumi path allowlist', () => {
157
+ // Mirror of ALLOWED_HALLOUMI_PATHS from middleware.js
158
+ const ALLOWED_HALLOUMI_PATHS = [
159
+ { path: '/generate', methods: ['POST'] },
160
+ { path: '/classify', methods: ['POST'] },
161
+ ];
162
+
163
+ it('allows /generate with POST', () => {
164
+ expect(isPathAllowed('/generate', 'POST', ALLOWED_HALLOUMI_PATHS)).toBe(
165
+ true,
166
+ );
167
+ });
168
+
169
+ it('allows /classify with POST', () => {
170
+ expect(isPathAllowed('/classify', 'POST', ALLOWED_HALLOUMI_PATHS)).toBe(
171
+ true,
172
+ );
173
+ });
174
+
175
+ it('rejects /generate with wrong method', () => {
176
+ expect(isPathAllowed('/generate', 'GET', ALLOWED_HALLOUMI_PATHS)).toBe(
177
+ false,
178
+ );
179
+ });
180
+
181
+ it('rejects disallowed paths', () => {
182
+ expect(isPathAllowed('/admin/config', 'POST', ALLOWED_HALLOUMI_PATHS)).toBe(
183
+ false,
184
+ );
185
+ expect(
186
+ isPathAllowed('/../../etc/passwd', 'POST', ALLOWED_HALLOUMI_PATHS),
187
+ ).toBe(false);
188
+ });
69
189
  });
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2
+ <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"/>
3
+ </svg>
package/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import installChatBlock from './ChatBlock';
2
+ import installAISearchInputBlock from './AISearchInput';
2
3
  import loadable from '@loadable/component';
3
4
 
4
5
  const applyConfig = (config) => {
@@ -40,6 +41,7 @@ const applyConfig = (config) => {
40
41
  };
41
42
 
42
43
  installChatBlock(config);
44
+ installAISearchInputBlock(config);
43
45
 
44
46
  return config;
45
47
  };
package/src/middleware.js CHANGED
@@ -9,6 +9,31 @@ const log = debug('volto-eea-chatbot');
9
9
 
10
10
  const MOCK_STREAM_DELAY = parseInt(process.env.MOCK_STREAM_DELAY || '0');
11
11
 
12
+ // Allowed paths for _da and _rq proxy (shared).
13
+ // When adding new endpoints, update this list.
14
+ const ALLOWED_PROXY_PATHS = [
15
+ { path: '/persona', methods: ['GET'] },
16
+ { pathPattern: /^\/persona\/\d+$/, methods: ['GET'] },
17
+ { path: '/chat/create-chat-session', methods: ['POST'] },
18
+ { path: '/chat/send-message', methods: ['POST'] },
19
+ { path: '/chat/send-chat-message', methods: ['POST'] },
20
+ { path: '/chat/create-chat-message-feedback', methods: ['POST'] },
21
+ ];
22
+
23
+ /**
24
+ * Check whether a stripped path matches the allowlist.
25
+ * Strips query strings before comparison.
26
+ */
27
+ function isPathAllowed(strippedPath, method, allowedPaths) {
28
+ const cleanPath = strippedPath.split('?')[0];
29
+ return allowedPaths.some(
30
+ (entry) =>
31
+ (entry.path === cleanPath ||
32
+ (entry.pathPattern && entry.pathPattern.test(cleanPath))) &&
33
+ entry.methods.includes(method),
34
+ );
35
+ }
36
+
12
37
  let cached_auth_cookie = null;
13
38
  let last_fetched = null;
14
39
  let maxAge;
@@ -249,10 +274,19 @@ async function send_onyx_request(
249
274
  }
250
275
  }
251
276
 
277
+ export { isPathAllowed, ALLOWED_PROXY_PATHS };
252
278
  export default async function middleware(req, res, next) {
253
279
  const is_related_question = req.url.includes('/_rq/');
254
280
  const path = req.url.replace('/_da/', '/').replace('/_rq/', '/');
255
281
 
282
+ // Reject paths not on the allowlist — prevents Confused Deputy attacks
283
+ if (!isPathAllowed(path, req.method, ALLOWED_PROXY_PATHS)) {
284
+ res.statusCode = 404;
285
+ res.statusMessage = 'Not Found';
286
+ res.send({ error: 'Not Found' });
287
+ return;
288
+ }
289
+
256
290
  const reqUrl = `${process.env.ONYX_URL || ''}/api${path}`;
257
291
 
258
292
  const api_key = process.env.ONYX_API_KEY;