@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
@@ -0,0 +1,467 @@
1
+ import { TextEncoder, TextDecoder } from 'util';
2
+
3
+ import {
4
+ processRawChunkString,
5
+ handleStream,
6
+ createChatSession,
7
+ submitFeedback,
8
+ regenerateMessage,
9
+ } from '../services/streamingService';
10
+ import { PacketType } from '../types/streamingModels';
11
+
12
+ global.TextEncoder = TextEncoder;
13
+ global.TextDecoder = TextDecoder;
14
+
15
+ // ── processRawChunkString ──────────────────────────────────────────────
16
+
17
+ describe('processRawChunkString', () => {
18
+ it('returns empty array for empty string', () => {
19
+ const [chunks, partial] = processRawChunkString('', null);
20
+ expect(chunks).toEqual([]);
21
+ expect(partial).toBeNull();
22
+ });
23
+
24
+ it('parses a single complete JSON chunk', () => {
25
+ const raw = JSON.stringify({ ind: 1, obj: { type: 'message_delta' } });
26
+ const [chunks, partial] = processRawChunkString(raw, null);
27
+ expect(chunks).toHaveLength(1);
28
+ expect(chunks[0]).toEqual({ ind: 1, obj: { type: 'message_delta' } });
29
+ expect(partial).toBeNull();
30
+ });
31
+
32
+ it('parses multiple newline-separated JSON chunks', () => {
33
+ const c1 = JSON.stringify({ ind: 1, obj: { type: 'message_start' } });
34
+ const c2 = JSON.stringify({ ind: 2, obj: { type: 'message_delta' } });
35
+ const raw = `${c1}\n${c2}`;
36
+ const [chunks, partial] = processRawChunkString(raw, null);
37
+ expect(chunks).toHaveLength(2);
38
+ expect(chunks[0].ind).toBe(1);
39
+ expect(chunks[1].ind).toBe(2);
40
+ expect(partial).toBeNull();
41
+ });
42
+
43
+ it('handles incomplete chunk and returns it as partial', () => {
44
+ const raw = '{"ind": 1, "obj": {"type"';
45
+ const [chunks, partial] = processRawChunkString(raw, null);
46
+ expect(chunks).toHaveLength(0);
47
+ expect(partial).toBe(raw);
48
+ });
49
+
50
+ it('combines previous partial chunk with new data', () => {
51
+ const prev = '{"ind": 1, "obj": {"type"';
52
+ const rest = ': "message_delta"}}';
53
+ const [chunks, partial] = processRawChunkString(rest, prev);
54
+ expect(chunks).toHaveLength(1);
55
+ expect(chunks[0]).toEqual({ ind: 1, obj: { type: 'message_delta' } });
56
+ expect(partial).toBeNull();
57
+ });
58
+
59
+ it('filters out empty lines', () => {
60
+ const c1 = JSON.stringify({ ind: 1, obj: { type: 'stop' } });
61
+ const raw = `\n${c1}\n\n`;
62
+ const [chunks, partial] = processRawChunkString(raw, null);
63
+ expect(chunks).toHaveLength(1);
64
+ expect(partial).toBeNull();
65
+ });
66
+
67
+ it('returns null for falsy input', () => {
68
+ const [chunks, partial] = processRawChunkString(null, null);
69
+ expect(chunks).toEqual([]);
70
+ expect(partial).toBeNull();
71
+ });
72
+ });
73
+
74
+ // ── handleStream ───────────────────────────────────────────────────────
75
+
76
+ describe('handleStream', () => {
77
+ function makeReadableStream(chunks) {
78
+ let index = 0;
79
+ return {
80
+ body: {
81
+ getReader() {
82
+ return {
83
+ async read() {
84
+ if (index >= chunks.length) {
85
+ return { done: true, value: undefined };
86
+ }
87
+ const value = new TextEncoder().encode(chunks[index++]);
88
+ return { done: false, value };
89
+ },
90
+ };
91
+ },
92
+ },
93
+ };
94
+ }
95
+
96
+ it('yields packets from a stream with ind/obj format', async () => {
97
+ const packet = JSON.stringify({
98
+ ind: 1,
99
+ obj: { type: PacketType.MESSAGE_DELTA, content: 'hello' },
100
+ });
101
+ const response = makeReadableStream([packet]);
102
+ const allPackets = [];
103
+ for await (const packets of handleStream(response)) {
104
+ allPackets.push(...packets);
105
+ }
106
+ expect(allPackets).toHaveLength(1);
107
+ expect(allPackets[0].obj.type).toBe(PacketType.MESSAGE_DELTA);
108
+ });
109
+
110
+ it('handles MessageResponseIDInfo format', async () => {
111
+ const chunk = JSON.stringify({
112
+ user_message_id: 42,
113
+ reserved_assistant_message_id: 43,
114
+ });
115
+ const response = makeReadableStream([chunk]);
116
+ const allPackets = [];
117
+ for await (const packets of handleStream(response)) {
118
+ allPackets.push(...packets);
119
+ }
120
+ expect(allPackets).toHaveLength(1);
121
+ expect(allPackets[0].obj.type).toBe(PacketType.MESSAGE_END_ID_INFO);
122
+ expect(allPackets[0].obj.user_message_id).toBe(42);
123
+ });
124
+
125
+ it('handles error format', async () => {
126
+ const chunk = JSON.stringify({ error: 'something went wrong' });
127
+ const response = makeReadableStream([chunk]);
128
+ const allPackets = [];
129
+ for await (const packets of handleStream(response)) {
130
+ allPackets.push(...packets);
131
+ }
132
+ expect(allPackets).toHaveLength(1);
133
+ expect(allPackets[0].obj.type).toBe(PacketType.ERROR);
134
+ expect(allPackets[0].obj.error).toBe('something went wrong');
135
+ });
136
+
137
+ it('throws when no reader is available', async () => {
138
+ const response = { body: null };
139
+ const gen = handleStream(response);
140
+ await expect(gen.next()).rejects.toThrow('No reader available');
141
+ });
142
+
143
+ it('handles multiple chunks across reads', async () => {
144
+ const c1 = JSON.stringify({
145
+ ind: 1,
146
+ obj: { type: PacketType.MESSAGE_START, content: 'hi' },
147
+ });
148
+ const c2 = JSON.stringify({
149
+ ind: 2,
150
+ obj: { type: PacketType.MESSAGE_DELTA, content: ' world' },
151
+ });
152
+ const response = makeReadableStream([c1, c2]);
153
+ const allPackets = [];
154
+ for await (const packets of handleStream(response)) {
155
+ allPackets.push(...packets);
156
+ }
157
+ expect(allPackets).toHaveLength(2);
158
+ });
159
+
160
+ it('skips non-object chunks', async () => {
161
+ // A string that parses to a non-object (number)
162
+ const response = makeReadableStream(['42']);
163
+ const allPackets = [];
164
+ for await (const packets of handleStream(response)) {
165
+ allPackets.push(...packets);
166
+ }
167
+ expect(allPackets).toHaveLength(0);
168
+ });
169
+
170
+ it('handles split chunk across two reads', async () => {
171
+ const full = JSON.stringify({
172
+ ind: 1,
173
+ obj: { type: PacketType.STOP },
174
+ });
175
+ const half1 = full.slice(0, 10);
176
+ const half2 = full.slice(10);
177
+ const response = makeReadableStream([half1, half2]);
178
+ const allPackets = [];
179
+ for await (const packets of handleStream(response)) {
180
+ allPackets.push(...packets);
181
+ }
182
+ expect(allPackets).toHaveLength(1);
183
+ expect(allPackets[0].obj.type).toBe(PacketType.STOP);
184
+ });
185
+ });
186
+
187
+ // ── createChatSession ──────────────────────────────────────────────────
188
+
189
+ describe('createChatSession', () => {
190
+ beforeEach(() => {
191
+ global.fetch = jest.fn();
192
+ });
193
+
194
+ afterEach(() => {
195
+ jest.restoreAllMocks();
196
+ });
197
+
198
+ it('returns chat session id on success', async () => {
199
+ global.fetch.mockResolvedValue({
200
+ ok: true,
201
+ json: () => Promise.resolve({ chat_session_id: 'abc-123' }),
202
+ });
203
+
204
+ const result = await createChatSession(1, 'Test session');
205
+ expect(result).toBe('abc-123');
206
+ expect(global.fetch).toHaveBeenCalledWith(
207
+ '/_da/chat/create-chat-session',
208
+ expect.objectContaining({
209
+ method: 'POST',
210
+ body: JSON.stringify({ persona_id: 1, description: 'Test session' }),
211
+ }),
212
+ );
213
+ });
214
+
215
+ it('throws on failed response', async () => {
216
+ global.fetch.mockResolvedValue({ ok: false });
217
+ await expect(createChatSession(1)).rejects.toThrow(
218
+ 'Failed to create chat session',
219
+ );
220
+ });
221
+ });
222
+
223
+ // ── submitFeedback ─────────────────────────────────────────────────────
224
+
225
+ describe('submitFeedback', () => {
226
+ beforeEach(() => {
227
+ global.fetch = jest.fn();
228
+ });
229
+
230
+ afterEach(() => {
231
+ jest.restoreAllMocks();
232
+ });
233
+
234
+ it('sends positive feedback', async () => {
235
+ global.fetch.mockResolvedValue({
236
+ ok: true,
237
+ json: () => Promise.resolve({}),
238
+ });
239
+
240
+ await submitFeedback({
241
+ chatMessageId: 1,
242
+ isPositive: true,
243
+ feedbackText: 'Great!',
244
+ });
245
+
246
+ const body = JSON.parse(global.fetch.mock.calls[0][1].body);
247
+ expect(body.is_positive).toBe(true);
248
+ expect(body.chat_message_id).toBe(1);
249
+ expect(body.predefined_feedback).toBeUndefined();
250
+ });
251
+
252
+ it('sends negative feedback with predefined reason', async () => {
253
+ global.fetch.mockResolvedValue({
254
+ ok: true,
255
+ json: () => Promise.resolve({}),
256
+ });
257
+
258
+ await submitFeedback({
259
+ chatMessageId: 2,
260
+ isPositive: false,
261
+ predefinedFeedback: 'Inaccurate',
262
+ });
263
+
264
+ const body = JSON.parse(global.fetch.mock.calls[0][1].body);
265
+ expect(body.is_positive).toBe(false);
266
+ expect(body.predefined_feedback).toBe('Inaccurate');
267
+ });
268
+
269
+ it('throws on failed response', async () => {
270
+ global.fetch.mockResolvedValue({ ok: false });
271
+ await expect(
272
+ submitFeedback({ chatMessageId: 1, isPositive: true }),
273
+ ).rejects.toThrow('Failed to submit feedback');
274
+ });
275
+ });
276
+
277
+ // ── sendMessage ──────────────────────────────────────────────────────
278
+
279
+ describe('sendMessage', () => {
280
+ const { sendMessage } = require('../services/streamingService');
281
+
282
+ beforeEach(() => {
283
+ global.fetch = jest.fn();
284
+ });
285
+
286
+ afterEach(() => {
287
+ jest.restoreAllMocks();
288
+ });
289
+
290
+ function makeStreamResponse(chunks) {
291
+ let index = 0;
292
+ return {
293
+ ok: true,
294
+ body: {
295
+ getReader() {
296
+ return {
297
+ async read() {
298
+ if (index >= chunks.length) {
299
+ return { done: true, value: undefined };
300
+ }
301
+ const value = new TextEncoder().encode(chunks[index++]);
302
+ return { done: false, value };
303
+ },
304
+ };
305
+ },
306
+ },
307
+ };
308
+ }
309
+
310
+ it('sends message and yields packets', async () => {
311
+ const chunk = JSON.stringify({
312
+ ind: 1,
313
+ obj: { type: 'message_delta', content: 'hello' },
314
+ });
315
+ global.fetch.mockResolvedValue(makeStreamResponse([chunk]));
316
+
317
+ const allPackets = [];
318
+ for await (const packets of sendMessage({
319
+ message: 'test',
320
+ chatSessionId: 'session-1',
321
+ parentMessageId: null,
322
+ regenerate: false,
323
+ filters: null,
324
+ selectedDocumentIds: [],
325
+ })) {
326
+ allPackets.push(...packets);
327
+ }
328
+
329
+ expect(allPackets).toHaveLength(1);
330
+ expect(allPackets[0].obj.content).toBe('hello');
331
+ expect(global.fetch).toHaveBeenCalledWith(
332
+ '/_da/chat/send-message',
333
+ expect.objectContaining({ method: 'POST' }),
334
+ );
335
+ });
336
+
337
+ it('uses _rq middleware for related questions', async () => {
338
+ const chunk = JSON.stringify({
339
+ ind: 1,
340
+ obj: { type: 'stop' },
341
+ });
342
+ global.fetch.mockResolvedValue(makeStreamResponse([chunk]));
343
+
344
+ const allPackets = [];
345
+ for await (const packets of sendMessage(
346
+ {
347
+ message: 'test',
348
+ chatSessionId: 'session-1',
349
+ parentMessageId: null,
350
+ regenerate: false,
351
+ filters: null,
352
+ selectedDocumentIds: [],
353
+ },
354
+ true,
355
+ )) {
356
+ allPackets.push(...packets);
357
+ }
358
+
359
+ expect(global.fetch).toHaveBeenCalledWith(
360
+ '/_rq/chat/send-message',
361
+ expect.objectContaining({ method: 'POST' }),
362
+ );
363
+ });
364
+
365
+ it('throws on failed response', async () => {
366
+ global.fetch.mockResolvedValue({
367
+ ok: false,
368
+ json: () => Promise.resolve({ message: 'Bad request' }),
369
+ });
370
+
371
+ const gen = sendMessage({
372
+ message: 'test',
373
+ chatSessionId: 'session-1',
374
+ parentMessageId: null,
375
+ regenerate: false,
376
+ filters: null,
377
+ selectedDocumentIds: [],
378
+ });
379
+
380
+ await expect(gen.next()).rejects.toThrow('Failed to send message');
381
+ });
382
+
383
+ it('includes selectedDocumentIds in payload when provided', async () => {
384
+ const chunk = JSON.stringify({
385
+ ind: 1,
386
+ obj: { type: 'stop' },
387
+ });
388
+ global.fetch.mockResolvedValue(makeStreamResponse([chunk]));
389
+
390
+ // eslint-disable-next-line no-unused-vars
391
+ for await (const _ of sendMessage({
392
+ message: 'test',
393
+ chatSessionId: 'session-1',
394
+ parentMessageId: null,
395
+ regenerate: false,
396
+ filters: null,
397
+ selectedDocumentIds: ['doc1', 'doc2'],
398
+ })) {
399
+ // consume
400
+ }
401
+
402
+ const body = JSON.parse(global.fetch.mock.calls[0][1].body);
403
+ expect(body.search_doc_ids).toEqual(['doc1', 'doc2']);
404
+ });
405
+
406
+ it('includes LLM override when temperature is set', async () => {
407
+ const chunk = JSON.stringify({
408
+ ind: 1,
409
+ obj: { type: 'stop' },
410
+ });
411
+ global.fetch.mockResolvedValue(makeStreamResponse([chunk]));
412
+
413
+ // eslint-disable-next-line no-unused-vars
414
+ for await (const _ of sendMessage({
415
+ message: 'test',
416
+ chatSessionId: 'session-1',
417
+ parentMessageId: null,
418
+ regenerate: false,
419
+ filters: null,
420
+ selectedDocumentIds: [],
421
+ temperature: 0.7,
422
+ modelVersion: 'gpt-4',
423
+ })) {
424
+ // consume
425
+ }
426
+
427
+ const body = JSON.parse(global.fetch.mock.calls[0][1].body);
428
+ expect(body.llm_override).toEqual(
429
+ expect.objectContaining({
430
+ temperature: 0.7,
431
+ model_version: 'gpt-4',
432
+ }),
433
+ );
434
+ });
435
+ });
436
+
437
+ // ── regenerateMessage ──────────────────────────────────────────────────
438
+
439
+ describe('regenerateMessage', () => {
440
+ beforeEach(() => {
441
+ global.fetch = jest.fn();
442
+ });
443
+
444
+ afterEach(() => {
445
+ jest.restoreAllMocks();
446
+ });
447
+
448
+ it('sends regenerate request', async () => {
449
+ global.fetch.mockResolvedValue({ ok: true });
450
+ await regenerateMessage(1, 2);
451
+
452
+ expect(global.fetch).toHaveBeenCalledWith(
453
+ '/_da/chat/regenerate-message',
454
+ expect.objectContaining({
455
+ method: 'POST',
456
+ body: JSON.stringify({ message_id: 1, chat_session_id: 2 }),
457
+ }),
458
+ );
459
+ });
460
+
461
+ it('throws on failed response', async () => {
462
+ global.fetch.mockResolvedValue({ ok: false });
463
+ await expect(regenerateMessage(1, 2)).rejects.toThrow(
464
+ 'Failed to regenerate message',
465
+ );
466
+ });
467
+ });