@eeacms/volto-eea-chatbot 1.0.9 → 1.0.11

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 (47) hide show
  1. package/CHANGELOG.md +15 -752
  2. package/package.json +1 -1
  3. package/razzle.extend.js +8 -4
  4. package/src/ChatBlock/ChatBlockView.jsx +26 -2
  5. package/src/ChatBlock/chat/AIMessage.tsx +5 -1
  6. package/src/ChatBlock/chat/ChatWindow.tsx +12 -3
  7. package/src/ChatBlock/components/AutoResizeTextarea.jsx +2 -1
  8. package/src/ChatBlock/components/QualityCheckToggle.jsx +1 -1
  9. package/src/ChatBlock/hooks/useChatController.ts +10 -2
  10. package/src/ChatBlock/index.js +1 -1
  11. package/src/ChatBlock/packets/renderers/MessageTextRenderer.tsx +8 -0
  12. package/src/ChatBlock/services/streamingService.ts +30 -26
  13. package/src/ChatBlock/style.less +3 -1
  14. package/src/ChatBlock/tests/ChatMessage.test.jsx +75 -0
  15. package/src/ChatBlock/tests/ClaimModal.test.jsx +136 -0
  16. package/src/ChatBlock/tests/ClaimSegments.test.jsx +206 -0
  17. package/src/ChatBlock/tests/CustomToolRenderer.test.jsx +241 -0
  18. package/src/ChatBlock/tests/FetchToolRenderer.test.jsx +161 -0
  19. package/src/ChatBlock/tests/ImageToolRenderer.test.jsx +178 -0
  20. package/src/ChatBlock/tests/MessageTextRenderer.test.jsx +227 -0
  21. package/src/ChatBlock/tests/MultiToolRenderer.test.jsx +134 -0
  22. package/src/ChatBlock/tests/ReasoningRenderer.test.jsx +163 -0
  23. package/src/ChatBlock/tests/RenderClaimView.test.jsx +191 -0
  24. package/src/ChatBlock/tests/RendererComponent.test.jsx +139 -0
  25. package/src/ChatBlock/tests/SearchToolRenderer.test.jsx +295 -0
  26. package/src/ChatBlock/tests/SourceChip.test.jsx +108 -0
  27. package/src/ChatBlock/tests/UserActionsToolbar.test.jsx +135 -0
  28. package/src/ChatBlock/tests/UserMessage.test.jsx +83 -0
  29. package/src/ChatBlock/tests/WebResultIcon.test.jsx +61 -0
  30. package/src/ChatBlock/tests/citations.test.js +114 -0
  31. package/src/ChatBlock/tests/messageProcessor.test.jsx +285 -1
  32. package/src/ChatBlock/tests/packetUtils.test.js +158 -0
  33. package/src/ChatBlock/tests/streamingService.test.js +467 -0
  34. package/src/ChatBlock/tests/useChatController.test.jsx +268 -0
  35. package/src/ChatBlock/tests/useChatStreaming.test.jsx +163 -0
  36. package/src/ChatBlock/tests/useMarked.test.jsx +107 -0
  37. package/src/ChatBlock/tests/useQualityMarkers.test.jsx +150 -0
  38. package/src/ChatBlock/tests/useScrollonStream.test.jsx +121 -0
  39. package/src/ChatBlock/tests/utils.test.jsx +241 -0
  40. package/src/ChatBlock/tests/withOnyxData.test.jsx +81 -0
  41. package/src/ChatBlock/utils/citations.ts +1 -1
  42. package/src/halloumi/generative.js +1 -0
  43. package/src/halloumi/generative.test.js +278 -0
  44. package/src/halloumi/middleware.test.js +69 -0
  45. package/src/index.js +1 -0
  46. package/src/middleware.js +21 -13
  47. package/src/middleware.test.js +221 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-eea-chatbot",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "@eeacms/volto-eea-chatbot: Volto add-on",
5
5
  "main": "src/index.js",
6
6
  "author": "European Environment Agency: IDM2 A-Team",
package/razzle.extend.js CHANGED
@@ -26,10 +26,14 @@ const modify = (config, { target, dev }, webpack) => {
26
26
  include.push(markedPath);
27
27
  // include.push(nodeFetch);
28
28
 
29
- babelLoader.use[0].options.plugins = [
30
- ...(babelLoader.use[0].options.plugins || []),
31
- '@babel/plugin-proposal-private-methods',
32
- ];
29
+ const plugs = babelLoader.use[0].options.plugins || [];
30
+
31
+ if (plugs.indexOf('@babel/plugin-proposal-private-methods') < 0) {
32
+ babelLoader.use[0].options.plugins = [
33
+ ...(plugs || []),
34
+ '@babel/plugin-proposal-private-methods',
35
+ ];
36
+ }
33
37
 
34
38
  return config;
35
39
  };
@@ -1,12 +1,36 @@
1
+ import { useMemo, useEffect } from 'react';
1
2
  import superagent from 'superagent';
3
+ import { parse } from 'qs';
2
4
  import withOnyxData from './hocs/withOnyxData';
3
5
  import { ChatWindow } from './chat';
4
6
 
5
7
  function ChatBlockView(props) {
6
- const { assistantData, data, isEditMode } = props;
8
+ const { id, assistantData, data, isEditMode, location } = props;
9
+
10
+ const query = useMemo(
11
+ () => parse(location?.search.replace('?', '')) || {},
12
+ [location],
13
+ );
14
+
15
+ const isPlaywrightTest = query.playwright === 'yes';
16
+
17
+ useEffect(() => {
18
+ if (isPlaywrightTest) {
19
+ window.__EEA_CHATBOT_TEST_CONFIG__ = {
20
+ block_id: id,
21
+ ...data,
22
+ };
23
+ }
24
+ }, [id, isPlaywrightTest, data]);
7
25
 
8
26
  return assistantData ? (
9
- <ChatWindow persona={assistantData} isEditMode={isEditMode} {...data} />
27
+ <ChatWindow
28
+ persona={assistantData}
29
+ isEditMode={isEditMode}
30
+ isPlaywrightTest={isPlaywrightTest}
31
+ block_id={id}
32
+ {...data}
33
+ />
10
34
  ) : (
11
35
  <div>Chatbot</div>
12
36
  );
@@ -474,7 +474,10 @@ export function AIMessage({
474
474
 
475
475
  // Tab panes - conditionally include Sources tab
476
476
  const panes = [
477
- { menuItem: 'Answer', pane: <Tab.Pane key="answer">{answerTab}</Tab.Pane> },
477
+ {
478
+ menuItem: { key: 'answer', content: 'Answer', className: 'answer-tab' },
479
+ pane: <Tab.Pane key="answer">{answerTab}</Tab.Pane>,
480
+ },
478
481
  ...(showSources && !error
479
482
  ? [
480
483
  {
@@ -486,6 +489,7 @@ export function AIMessage({
486
489
  <span className="sources-count">({sources.length})</span>
487
490
  </span>
488
491
  ),
492
+ className: 'sources-tab',
489
493
  },
490
494
  pane: (
491
495
  <Tab.Pane key="sources">
@@ -22,6 +22,7 @@ import PenIcon from '../../icons/square-pen.svg';
22
22
  import '../style.less';
23
23
 
24
24
  interface ChatWindowProps {
25
+ block_id?: string;
25
26
  persona: Persona;
26
27
  rehypePrism?: any;
27
28
  remarkGfm?: any;
@@ -47,15 +48,18 @@ interface ChatWindowProps {
47
48
  enableMatomoTracking?: boolean;
48
49
  onDemandInputToggle?: boolean;
49
50
  maxContextSegments?: number;
51
+ isPlaywrightTest?: boolean;
50
52
  [key: string]: any;
51
53
  }
52
54
 
53
55
  function ChatWindow({
56
+ block_id,
54
57
  persona,
55
58
  rehypePrism,
56
59
  remarkGfm,
57
60
  placeholderPrompt = 'Ask a question',
58
61
  isEditMode,
62
+ isPlaywrightTest,
59
63
  ...data
60
64
  }: ChatWindowProps) {
61
65
  const {
@@ -141,7 +145,10 @@ function ChatWindow({
141
145
  );
142
146
 
143
147
  return (
144
- <div className="chat-window">
148
+ <div
149
+ className="chat-window"
150
+ data-playwright-block-id={isPlaywrightTest ? block_id : undefined}
151
+ >
145
152
  <div className="messages">
146
153
  {showLandingPage ? (
147
154
  <>
@@ -211,7 +218,7 @@ function ChatWindow({
211
218
 
212
219
  {isStreaming &&
213
220
  !isFetchingRelatedQuestions &&
214
- !messages[messages.length - 1].isFinalMessageComing && (
221
+ !messages[messages.length - 1]?.isFinalMessageComing && (
215
222
  <div className="comment">
216
223
  <div className="circle assistant placeholder"></div>
217
224
  <div className="comment-content">
@@ -269,7 +276,9 @@ function ChatWindow({
269
276
  </div>
270
277
  )}
271
278
 
272
- {deepResearch === 'always_on' && <small>Deep research on</small>}
279
+ {deepResearch === 'always_on' && (
280
+ <small className="deep-research-toggle">Deep research on</small>
281
+ )}
273
282
  </div>
274
283
  <div ref={chatWindowEndRef} /> {/* End div to mark the bottom */}
275
284
  </div>
@@ -40,6 +40,7 @@ export default React.forwardRef(function AutoResizeTextarea(props, ref) {
40
40
  setInput(input + '\n');
41
41
  }
42
42
  }}
43
+ disabled={isStreaming}
43
44
  {...rest}
44
45
  ref={ref}
45
46
  />
@@ -52,7 +53,7 @@ export default React.forwardRef(function AutoResizeTextarea(props, ref) {
52
53
  onKeyDown={(e) => {
53
54
  handleSubmit(e);
54
55
  }}
55
- disabled={isStreaming}
56
+ disabled={isStreaming || input.trim() === ''}
56
57
  onClick={(e) => {
57
58
  handleSubmit(e);
58
59
  }}
@@ -10,7 +10,7 @@ const QualityCheckToggle = ({ isEditMode, enabled, setEnabled }) => {
10
10
  content="Checks the AI's statements against cited sources to highlight possible inaccuracies and hallucinations."
11
11
  trigger={
12
12
  <Checkbox
13
- id="fact-check-toggle"
13
+ id="quality-check-toggle"
14
14
  toggle
15
15
  label="Fact-check AI answer"
16
16
  disabled={isEditMode}
@@ -18,6 +18,10 @@ interface RelatedQuestion {
18
18
 
19
19
  // Extract JSON array from related questions response
20
20
  function extractRelatedQuestions(str: string): RelatedQuestion[] {
21
+ if (str.toLowerCase().includes('no_response')) {
22
+ throw new Error('Related questions were not generated properly');
23
+ }
24
+
21
25
  const regex = /\[[\s\S]*?\]/;
22
26
  const match = str.match(regex);
23
27
 
@@ -65,7 +69,7 @@ async function fetchRelatedQuestions(
65
69
  };
66
70
 
67
71
  let result = '';
68
- for await (const packets of sendMessage(params)) {
72
+ for await (const packets of sendMessage(params, true)) {
69
73
  for (const packet of packets) {
70
74
  if (packet.obj.type === PacketType.MESSAGE_DELTA) {
71
75
  result += packet.obj.content;
@@ -88,6 +92,7 @@ export function useChatController({
88
92
  }: UseChatControllerProps) {
89
93
  const [messages, setMessages] = useState<Message[]>([]);
90
94
  const [chatSessionId, setChatSessionId] = useState<string | null>(null);
95
+ const [chatSessionLoading, setChatSessionLoading] = useState(false);
91
96
  const [isDeepResearchEnabled, setIsDeepResearchEnabled] = useState(
92
97
  deepResearch === 'always_on' || deepResearch === 'user_on',
93
98
  );
@@ -183,6 +188,7 @@ export function useChatController({
183
188
  let sessionId = chatSessionId;
184
189
 
185
190
  if (!sessionId) {
191
+ setChatSessionLoading(true);
186
192
  sessionId = await createChatSession(personaId, 'Chat session');
187
193
  setChatSessionId(sessionId);
188
194
  }
@@ -241,6 +247,8 @@ export function useChatController({
241
247
  );
242
248
  } catch (error) {
243
249
  console.error('Failed to submit message:', error);
250
+ } finally {
251
+ setChatSessionLoading(false);
244
252
  }
245
253
  },
246
254
  [
@@ -319,7 +327,7 @@ export function useChatController({
319
327
 
320
328
  return {
321
329
  messages,
322
- isStreaming,
330
+ isStreaming: isStreaming || chatSessionLoading,
323
331
  isCancelled,
324
332
  isFetchingRelatedQuestions,
325
333
  onSubmit,
@@ -5,7 +5,7 @@ import { ChatBlockSchema } from './schema';
5
5
 
6
6
  export default function installChatBlock(config) {
7
7
  config.blocks.blocksConfig.eeaChatbot = {
8
- id: 'eea_chatbot',
8
+ id: 'eeaChatbot',
9
9
  title: 'AI Chatbot',
10
10
  icon: codeSVG,
11
11
  group: 'common',
@@ -46,6 +46,14 @@ export const MessageTextRenderer: MessageRenderer<ChatPacket> = ({
46
46
  const [displayedPacketCount, setDisplayedPacketCount] =
47
47
  useState(initialPacketCount);
48
48
 
49
+ useEffect(() => {
50
+ const audioCtx = new AudioContext();
51
+
52
+ return () => {
53
+ audioCtx.close();
54
+ };
55
+ }, []);
56
+
49
57
  // Animation effect - gradually increase displayed packets at controlled rate
50
58
  // Adaptive animation: ensures visible typing effect even for fast streams
51
59
  useEffect(() => {
@@ -8,7 +8,7 @@ export interface SendMessageParams {
8
8
  parentMessageId: number | null;
9
9
  chatSessionId: string;
10
10
  filters: Filters | null;
11
- selectedDocumentIds: number[] | null;
11
+ selectedDocumentIds: number[] | string[] | null;
12
12
  queryOverride?: string;
13
13
  forceSearch?: boolean;
14
14
  modelProvider?: string;
@@ -166,30 +166,33 @@ export async function* handleStream(
166
166
  /**
167
167
  * Send a message and stream the response
168
168
  */
169
- export async function* sendMessage({
170
- regenerate,
171
- retrieval_options,
172
- message,
173
- fileDescriptors,
174
- currentMessageFiles,
175
- parentMessageId,
176
- chatSessionId,
177
- filters,
178
- selectedDocumentIds,
179
- queryOverride,
180
- forceSearch,
181
- modelProvider,
182
- modelVersion,
183
- temperature,
184
- systemPromptOverride,
185
- taskPromptOverride,
186
- useExistingUserMessage,
187
- alternateAssistantId,
188
- signal,
189
- useAgentSearch,
190
- enabledToolIds,
191
- forcedToolIds,
192
- }: SendMessageParams): AsyncGenerator<Packet[], void, unknown> {
169
+ export async function* sendMessage(
170
+ {
171
+ regenerate,
172
+ retrieval_options,
173
+ message,
174
+ fileDescriptors,
175
+ currentMessageFiles,
176
+ parentMessageId,
177
+ chatSessionId,
178
+ filters,
179
+ selectedDocumentIds,
180
+ queryOverride,
181
+ forceSearch,
182
+ modelProvider,
183
+ modelVersion,
184
+ temperature,
185
+ systemPromptOverride,
186
+ taskPromptOverride,
187
+ useExistingUserMessage,
188
+ alternateAssistantId,
189
+ signal,
190
+ useAgentSearch,
191
+ enabledToolIds,
192
+ forcedToolIds,
193
+ }: SendMessageParams,
194
+ isRelatedQuestion: boolean = false,
195
+ ): AsyncGenerator<Packet[], void, unknown> {
193
196
  const documentsAreSelected =
194
197
  selectedDocumentIds && selectedDocumentIds.length > 0;
195
198
 
@@ -233,7 +236,8 @@ export async function* sendMessage({
233
236
 
234
237
  const body = JSON.stringify(payload);
235
238
 
236
- const sendMessageResponse = await fetch('/_da/chat/send-message', {
239
+ const middleware = isRelatedQuestion ? '_rq' : '_da';
240
+ const sendMessageResponse = await fetch(`/${middleware}/chat/send-message`, {
237
241
  method: 'POST',
238
242
  headers: {
239
243
  'Content-Type': 'application/json',
@@ -610,6 +610,8 @@ mark {
610
610
  }
611
611
 
612
612
  .quality-check-toggle {
613
+ margin-top: 0;
614
+
613
615
  .ui.toggle.checkbox {
614
616
  input:checked ~ label {
615
617
  color: @grey !important;
@@ -1306,7 +1308,7 @@ mark {
1306
1308
  }
1307
1309
  }
1308
1310
 
1309
- #fact-check-toggle[disabled] ~ label {
1311
+ #quality-check-toggle[disabled] ~ label {
1310
1312
  opacity: 1;
1311
1313
  }
1312
1314
 
@@ -0,0 +1,75 @@
1
+ import { MemoryRouter } from 'react-router-dom';
2
+ import configureStore from 'redux-mock-store';
3
+ import renderer from 'react-test-renderer';
4
+
5
+ import '@testing-library/jest-dom/extend-expect';
6
+ import { Provider } from 'react-intl-redux';
7
+ import { ChatMessage } from '../chat/ChatMessage';
8
+
9
+ const mockStore = configureStore();
10
+
11
+ // Mock loadable components
12
+ jest.mock('@loadable/component', () => {
13
+ const loadable = () => {
14
+ const MockComponent = ({ children }) => <div>{children}</div>;
15
+ return MockComponent;
16
+ };
17
+ loadable.lib = () => {
18
+ const MockComponent = ({ children }) =>
19
+ children ? children({ default: {} }) : null;
20
+ return MockComponent;
21
+ };
22
+ return { __esModule: true, default: loadable };
23
+ });
24
+
25
+ describe('ChatMessage', () => {
26
+ let store;
27
+
28
+ beforeEach(() => {
29
+ store = mockStore({
30
+ userSession: { token: '1234' },
31
+ intl: { locale: 'en', messages: {} },
32
+ });
33
+ });
34
+
35
+ const renderComponent = (props) =>
36
+ renderer.create(
37
+ <Provider store={store}>
38
+ <MemoryRouter>
39
+ <ChatMessage {...props} />
40
+ </MemoryRouter>
41
+ </Provider>,
42
+ );
43
+
44
+ it('renders error message correctly', () => {
45
+ const props = {
46
+ message: {
47
+ messageId: 3,
48
+ type: 'error',
49
+ error: 'Something went wrong',
50
+ },
51
+ libs: { remarkGfm: { default: [] } },
52
+ isLoading: false,
53
+ };
54
+
55
+ const component = renderComponent(props);
56
+ const json = component.toJSON();
57
+ expect(json).toMatchSnapshot();
58
+ });
59
+
60
+ it('returns null for unknown message type', () => {
61
+ const props = {
62
+ message: {
63
+ messageId: 4,
64
+ message: 'Unknown type',
65
+ type: 'unknown',
66
+ },
67
+ libs: { remarkGfm: { default: [] } },
68
+ isLoading: false,
69
+ };
70
+
71
+ const component = renderComponent(props);
72
+ const json = component.toJSON();
73
+ expect(json).toBeNull();
74
+ });
75
+ });
@@ -0,0 +1,136 @@
1
+ import React from 'react';
2
+ import renderer from 'react-test-renderer';
3
+ import '@testing-library/jest-dom/extend-expect';
4
+ import { ClaimModal } from '../components/markdown/ClaimModal';
5
+
6
+ // Mock semantic-ui-react Modal
7
+ jest.mock('semantic-ui-react', () => ({
8
+ Modal: ({ children, trigger, className }) => (
9
+ <div className={className} data-testid="modal">
10
+ <div data-testid="trigger">{trigger}</div>
11
+ <div data-testid="content">{children}</div>
12
+ </div>
13
+ ),
14
+ ModalHeader: ({ children }) => <div data-testid="header">{children}</div>,
15
+ ModalContent: ({ children }) => (
16
+ <div data-testid="modal-content">{children}</div>
17
+ ),
18
+ }));
19
+
20
+ // Mock ClaimSegments
21
+ jest.mock('../components/markdown/ClaimSegments', () => ({
22
+ ClaimSegments: () => <div data-testid="claim-segments">ClaimSegments</div>,
23
+ }));
24
+
25
+ describe('ClaimModal', () => {
26
+ const defaultProps = {
27
+ claim: {
28
+ score: 0.85,
29
+ claimString: 'This is a claim about something important.',
30
+ rationale: 'The claim is supported by multiple sources.',
31
+ segmentIds: [1, 2, 3],
32
+ },
33
+ markers: {
34
+ segments: {
35
+ 1: { id: 1, text: 'segment 1' },
36
+ 2: { id: 2, text: 'segment 2' },
37
+ 3: { id: 3, text: 'segment 3' },
38
+ },
39
+ },
40
+ text: ['something important'],
41
+ citedSources: [
42
+ { id: 1, semantic_identifier: 'Source 1', link: 'https://example.com' },
43
+ ],
44
+ };
45
+
46
+ it('renders the claim modal with high score', () => {
47
+ const component = renderer.create(<ClaimModal {...defaultProps} />);
48
+ const json = component.toJSON();
49
+ expect(json).toMatchSnapshot();
50
+ });
51
+
52
+ it('renders with low score', () => {
53
+ const props = {
54
+ ...defaultProps,
55
+ claim: {
56
+ ...defaultProps.claim,
57
+ score: 0.3,
58
+ },
59
+ };
60
+ const component = renderer.create(<ClaimModal {...props} />);
61
+ const json = component.toJSON();
62
+ expect(json).toMatchSnapshot();
63
+ });
64
+
65
+ it('renders with medium score', () => {
66
+ const props = {
67
+ ...defaultProps,
68
+ claim: {
69
+ ...defaultProps.claim,
70
+ score: 0.6,
71
+ },
72
+ };
73
+ const component = renderer.create(<ClaimModal {...props} />);
74
+ const json = component.toJSON();
75
+ expect(json).toMatchSnapshot();
76
+ });
77
+
78
+ it('handles empty text array', () => {
79
+ const props = {
80
+ ...defaultProps,
81
+ text: [],
82
+ };
83
+ const component = renderer.create(<ClaimModal {...props} />);
84
+ const json = component.toJSON();
85
+ expect(json).toMatchSnapshot();
86
+ });
87
+
88
+ it('handles claim with markdown formatting', () => {
89
+ const props = {
90
+ ...defaultProps,
91
+ claim: {
92
+ ...defaultProps.claim,
93
+ claimString: '**Bold claim** with *italic* and [[1]](url)',
94
+ },
95
+ };
96
+ const component = renderer.create(<ClaimModal {...props} />);
97
+ const json = component.toJSON();
98
+ expect(json).toMatchSnapshot();
99
+ });
100
+
101
+ it('renders with empty markers', () => {
102
+ const props = {
103
+ ...defaultProps,
104
+ markers: {},
105
+ };
106
+ const component = renderer.create(<ClaimModal {...props} />);
107
+ const json = component.toJSON();
108
+ expect(json).toMatchSnapshot();
109
+ });
110
+
111
+ it('handles zero score', () => {
112
+ const props = {
113
+ ...defaultProps,
114
+ claim: {
115
+ ...defaultProps.claim,
116
+ score: 0,
117
+ },
118
+ };
119
+ const component = renderer.create(<ClaimModal {...props} />);
120
+ const json = component.toJSON();
121
+ expect(json).toMatchSnapshot();
122
+ });
123
+
124
+ it('handles perfect score', () => {
125
+ const props = {
126
+ ...defaultProps,
127
+ claim: {
128
+ ...defaultProps.claim,
129
+ score: 1.0,
130
+ },
131
+ };
132
+ const component = renderer.create(<ClaimModal {...props} />);
133
+ const json = component.toJSON();
134
+ expect(json).toMatchSnapshot();
135
+ });
136
+ });