@eeacms/volto-eea-chatbot 2.0.1 → 2.0.3

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 (84) hide show
  1. package/.eslintrc.js +6 -6
  2. package/CHANGELOG.md +20 -0
  3. package/artifacts/ONYX_V3_INTEGRATION.md +34 -0
  4. package/jest-addon.config.js +2 -1
  5. package/package.json +1 -1
  6. package/src/ChatBlock/ChatBlockEdit.jsx +2 -1
  7. package/src/ChatBlock/chat/AIMessage.tsx +36 -16
  8. package/src/ChatBlock/chat/ChatMessage.tsx +1 -1
  9. package/src/ChatBlock/chat/ChatWindow.tsx +13 -11
  10. package/src/ChatBlock/chat/UserMessage.tsx +4 -4
  11. package/src/ChatBlock/components/AutoResizeTextarea.jsx +1 -1
  12. package/src/ChatBlock/components/ChatMessageFeedback.jsx +2 -2
  13. package/src/ChatBlock/components/EmptyState.jsx +1 -1
  14. package/src/ChatBlock/components/FeedbackModal.jsx +1 -1
  15. package/src/ChatBlock/components/HalloumiFeedback.jsx +2 -2
  16. package/src/ChatBlock/components/Source.jsx +2 -2
  17. package/src/ChatBlock/components/UserActionsToolbar.jsx +3 -3
  18. package/src/ChatBlock/components/WebResultIcon.tsx +2 -2
  19. package/src/ChatBlock/components/markdown/ClaimModal.jsx +3 -3
  20. package/src/ChatBlock/components/markdown/ClaimSegments.jsx +4 -4
  21. package/src/ChatBlock/components/markdown/{index.js → index.jsx} +1 -1
  22. package/src/ChatBlock/hooks/useChatController.ts +67 -14
  23. package/src/ChatBlock/hooks/useChatStreaming.ts +4 -4
  24. package/src/ChatBlock/hooks/useToolDisplayTiming.ts +2 -1
  25. package/src/ChatBlock/packets/MultiToolRenderer.tsx +86 -56
  26. package/src/ChatBlock/packets/RendererComponent.tsx +13 -5
  27. package/src/ChatBlock/packets/renderers/CustomToolRenderer.tsx +3 -3
  28. package/src/ChatBlock/packets/renderers/FetchToolRenderer.tsx +3 -3
  29. package/src/ChatBlock/packets/renderers/ImageToolRenderer.tsx +3 -3
  30. package/src/ChatBlock/packets/renderers/MessageTextRenderer.tsx +14 -9
  31. package/src/ChatBlock/packets/renderers/ReasoningRenderer.tsx +6 -5
  32. package/src/ChatBlock/packets/renderers/SearchToolRenderer.tsx +30 -21
  33. package/src/ChatBlock/{schema.js → schema.jsx} +13 -0
  34. package/src/ChatBlock/services/messageProcessor.ts +72 -17
  35. package/src/ChatBlock/services/packetUtils.ts +13 -3
  36. package/src/ChatBlock/services/streamingService.ts +155 -68
  37. package/src/ChatBlock/types/streamingModels.ts +47 -2
  38. package/src/ChatBlock/utils/citations.ts +1 -1
  39. package/src/halloumi/filtering.test.js +199 -1
  40. package/src/middleware.js +18 -1
  41. package/src/middleware.test.js +14 -0
  42. package/src/ChatBlock/tests/AIMessage.test.jsx +0 -95
  43. package/src/ChatBlock/tests/AutoResizeTextarea.test.jsx +0 -49
  44. package/src/ChatBlock/tests/BlinkingDot.test.jsx +0 -71
  45. package/src/ChatBlock/tests/ChatMessage.test.jsx +0 -75
  46. package/src/ChatBlock/tests/ChatMessageFeedback.test.jsx +0 -73
  47. package/src/ChatBlock/tests/Citation.test.jsx +0 -107
  48. package/src/ChatBlock/tests/ClaimModal.test.jsx +0 -136
  49. package/src/ChatBlock/tests/ClaimSegments.test.jsx +0 -206
  50. package/src/ChatBlock/tests/CustomToolRenderer.test.jsx +0 -241
  51. package/src/ChatBlock/tests/EmptyState.test.jsx +0 -137
  52. package/src/ChatBlock/tests/FeedbackModal.test.jsx +0 -138
  53. package/src/ChatBlock/tests/FetchToolRenderer.test.jsx +0 -161
  54. package/src/ChatBlock/tests/HalloumiFeedback.test.jsx +0 -94
  55. package/src/ChatBlock/tests/ImageToolRenderer.test.jsx +0 -178
  56. package/src/ChatBlock/tests/MessageTextRenderer.test.jsx +0 -227
  57. package/src/ChatBlock/tests/MultiToolRenderer.test.jsx +0 -134
  58. package/src/ChatBlock/tests/QualityCheckToggle.test.jsx +0 -105
  59. package/src/ChatBlock/tests/ReasoningRenderer.test.jsx +0 -163
  60. package/src/ChatBlock/tests/RelatedQuestions.test.jsx +0 -215
  61. package/src/ChatBlock/tests/RenderClaimView.test.jsx +0 -191
  62. package/src/ChatBlock/tests/RendererComponent.test.jsx +0 -139
  63. package/src/ChatBlock/tests/SearchToolRenderer.test.jsx +0 -295
  64. package/src/ChatBlock/tests/Source.test.jsx +0 -79
  65. package/src/ChatBlock/tests/SourceChip.test.jsx +0 -108
  66. package/src/ChatBlock/tests/Spinner.test.jsx +0 -18
  67. package/src/ChatBlock/tests/UserActionsToolbar.test.jsx +0 -135
  68. package/src/ChatBlock/tests/UserMessage.test.jsx +0 -83
  69. package/src/ChatBlock/tests/WebResultIcon.test.jsx +0 -61
  70. package/src/ChatBlock/tests/citations.test.js +0 -114
  71. package/src/ChatBlock/tests/index.test.js +0 -51
  72. package/src/ChatBlock/tests/messageProcessor.test.jsx +0 -438
  73. package/src/ChatBlock/tests/packetUtils.test.js +0 -158
  74. package/src/ChatBlock/tests/schema.test.js +0 -166
  75. package/src/ChatBlock/tests/streamingService.test.js +0 -467
  76. package/src/ChatBlock/tests/useChatController.test.jsx +0 -268
  77. package/src/ChatBlock/tests/useChatStreaming.test.jsx +0 -163
  78. package/src/ChatBlock/tests/useDeepCompareMemoize.test.js +0 -107
  79. package/src/ChatBlock/tests/useMarked.test.jsx +0 -107
  80. package/src/ChatBlock/tests/useQualityMarkers.test.jsx +0 -150
  81. package/src/ChatBlock/tests/useScrollonStream.test.jsx +0 -121
  82. package/src/ChatBlock/tests/useToolDisplayTiming.test.jsx +0 -151
  83. package/src/ChatBlock/tests/utils.test.jsx +0 -241
  84. package/src/ChatBlock/tests/withOnyxData.test.jsx +0 -81
@@ -1,5 +1,5 @@
1
- import type { Packet } from '../types/streamingModels';
2
- import { PacketType } from '../types/streamingModels';
1
+ import type { Packet } from '@eeacms/volto-eea-chatbot/ChatBlock/types/streamingModels';
2
+ import { PacketType } from '@eeacms/volto-eea-chatbot/ChatBlock/types/streamingModels';
3
3
 
4
4
  export function getSynteticPacket(ind: number, type: PacketType): Packet {
5
5
  return {
@@ -11,11 +11,15 @@ export function getSynteticPacket(ind: number, type: PacketType): Packet {
11
11
  export function isToolPacket(packet: Packet): boolean {
12
12
  const toolPacketTypes = [
13
13
  PacketType.SEARCH_TOOL_START,
14
+ PacketType.SEARCH_TOOL_START_V3,
15
+ PacketType.SEARCH_TOOL_QUERIES_DELTA,
16
+ PacketType.SEARCH_TOOL_DOCUMENTS_DELTA,
14
17
  PacketType.SEARCH_TOOL_DELTA,
15
18
  PacketType.CUSTOM_TOOL_START,
16
19
  PacketType.CUSTOM_TOOL_DELTA,
17
20
  PacketType.REASONING_START,
18
21
  PacketType.REASONING_DELTA,
22
+ PacketType.REASONING_DONE,
19
23
  PacketType.FETCH_TOOL_START,
20
24
  ];
21
25
 
@@ -40,9 +44,15 @@ export function isFinalAnswerComplete(packets: Packet[]): boolean {
40
44
  return false;
41
45
  }
42
46
 
43
- return packets.some(
47
+ const hasSectionEnd = packets.some(
44
48
  (packet) =>
45
49
  packet.obj.type === PacketType.SECTION_END &&
46
50
  packet.ind === messageStartPacket.ind,
47
51
  );
52
+
53
+ if (hasSectionEnd) {
54
+ console.log(`[isFinalAnswerComplete] Complete! ind=${messageStartPacket.ind}`);
55
+ }
56
+
57
+ return hasSectionEnd;
48
58
  }
@@ -1,5 +1,11 @@
1
- import { PacketType, type Packet } from '../types/streamingModels';
2
- import type { FileDescriptor, Filters } from '../types/interfaces';
1
+ import {
2
+ PacketType,
3
+ type Packet,
4
+ } from '@eeacms/volto-eea-chatbot/ChatBlock/types/streamingModels';
5
+ import type {
6
+ FileDescriptor,
7
+ Filters,
8
+ } from '@eeacms/volto-eea-chatbot/ChatBlock/types/interfaces';
3
9
 
4
10
  export interface SendMessageParams {
5
11
  regenerate: boolean;
@@ -24,6 +30,7 @@ export interface SendMessageParams {
24
30
  enabledToolIds?: number[];
25
31
  forcedToolIds?: number[];
26
32
  retrieval_options?: any;
33
+ onyxVersion?: '2' | '3';
27
34
  }
28
35
 
29
36
  export interface StreamResponse {
@@ -84,11 +91,131 @@ export const processRawChunkString = (
84
91
  return [parsedChunkSections, currPartialChunk];
85
92
  };
86
93
 
94
+ // ─── Payload Templates ─────────────────────────────────────────────────────
95
+
96
+ /**
97
+ * Build the Onyx 2.x send-message payload
98
+ */
99
+ function buildPayloadV2(params: SendMessageParams): Record<string, unknown> {
100
+ const documentsAreSelected =
101
+ params.selectedDocumentIds && params.selectedDocumentIds.length > 0;
102
+
103
+ return {
104
+ alternate_assistant_id: params.alternateAssistantId,
105
+ chat_session_id: params.chatSessionId,
106
+ parent_message_id: params.parentMessageId,
107
+ message: params.message,
108
+ prompt_id: null,
109
+ search_doc_ids: documentsAreSelected ? params.selectedDocumentIds : null,
110
+ file_descriptors: params.fileDescriptors,
111
+ current_message_files: params.currentMessageFiles,
112
+ regenerate: params.regenerate,
113
+ retrieval_options:
114
+ params.retrieval_options ??
115
+ (!documentsAreSelected
116
+ ? {
117
+ run_search:
118
+ params.queryOverride || params.forceSearch ? 'always' : 'auto',
119
+ real_time: true,
120
+ filters: params.filters,
121
+ }
122
+ : null),
123
+ query_override: params.queryOverride,
124
+ prompt_override: {
125
+ ...(params.systemPromptOverride
126
+ ? { system_prompt: params.systemPromptOverride }
127
+ : {}),
128
+ ...(params.taskPromptOverride
129
+ ? { task_prompt: params.taskPromptOverride }
130
+ : {}),
131
+ },
132
+ llm_override:
133
+ params.temperature || params.modelVersion
134
+ ? {
135
+ temperature: params.temperature,
136
+ model_provider: params.modelProvider,
137
+ model_version: params.modelVersion,
138
+ }
139
+ : null,
140
+ use_existing_user_message: params.useExistingUserMessage,
141
+ use_agentic_search: params.useAgentSearch ?? false,
142
+ allowed_tool_ids: params.enabledToolIds,
143
+ forced_tool_ids: params.forcedToolIds,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Build the Onyx 3.x send-message payload
149
+ */
150
+ function buildPayloadV3(params: SendMessageParams): Record<string, unknown> {
151
+ const payload = {
152
+ message: params.message,
153
+ chat_session_id: params.chatSessionId,
154
+ parent_message_id: params.parentMessageId,
155
+ file_descriptors: params.fileDescriptors ?? [],
156
+ internal_search_filters: {
157
+ source_type: params.filters?.source_type ?? ['web', 'github'],
158
+ document_set: params.filters?.document_set ?? null,
159
+ time_cutoff: params.filters?.time_cutoff ?? null,
160
+ tags: params.filters?.tags ?? [],
161
+ },
162
+ deep_research: params.useAgentSearch ?? false,
163
+ allowed_tool_ids: params.enabledToolIds?.length
164
+ ? params.enabledToolIds
165
+ : [1],
166
+ forced_tool_id: params.forcedToolIds?.[0] ?? null,
167
+ llm_override: {
168
+ temperature: params.temperature ?? 0.5,
169
+ model_provider:
170
+ params.modelProvider || 'Inhouse LiteLLM provider oss 120b',
171
+ model_version: params.modelVersion || 'Inhouse-LLM/gpt-oss-120b',
172
+ },
173
+ llm_overrides: null,
174
+ origin: 'webapp',
175
+ additional_context: null,
176
+ alternate_assistant_id: params.alternateAssistantId ?? null,
177
+ stream: true,
178
+ };
179
+ return payload;
180
+ }
181
+
182
+ /**
183
+ * Normalise a raw Onyx 3.x stream object into the canonical { ind, obj } Packet shape.
184
+ */
185
+ function normaliseV3Chunk(raw: any): Packet | null {
186
+ // Bare identity packet (user/assistant message IDs – no placement wrapper)
187
+ if ('user_message_id' in raw && 'reserved_assistant_message_id' in raw) {
188
+ return {
189
+ ind: -1,
190
+ obj: {
191
+ type: PacketType.MESSAGE_END_ID_INFO,
192
+ user_message_id: raw.user_message_id,
193
+ reserved_assistant_message_id: raw.reserved_assistant_message_id,
194
+ },
195
+ } as Packet;
196
+ }
197
+
198
+ if (!raw.placement || typeof raw.obj !== 'object') return null;
199
+
200
+ const ind: number = raw.placement.turn_index ?? 0;
201
+ const obj = raw.obj;
202
+
203
+ // Map citation_number to citation_num for compatibility with CitationDelta consumers
204
+ if (obj.type === PacketType.CITATION_INFO && 'citation_number' in obj) {
205
+ obj.citation_num = obj.citation_number;
206
+ }
207
+
208
+ const normalised = { ind, obj } as Packet;
209
+ // console.log('[Onyx v3] Normalised packet:', normalised);
210
+ return normalised;
211
+ }
212
+
87
213
  /**
88
214
  * Handle streaming response from the backend
89
215
  */
90
216
  export async function* handleStream(
91
217
  streamingResponse: Response,
218
+ onyxVersion: '2' | '3' = '2',
92
219
  ): AsyncGenerator<Packet[], void, unknown> {
93
220
  const reader = streamingResponse.body?.getReader();
94
221
  if (!reader) {
@@ -114,6 +241,10 @@ export async function* handleStream(
114
241
  previousPartialChunk,
115
242
  );
116
243
 
244
+ if (onyxVersion === '3' && completedChunks.length > 0) {
245
+ // console.log('[Onyx v3] Raw completed chunks:', completedChunks);
246
+ }
247
+
117
248
  if (!completedChunks.length && !partialChunk) {
118
249
  break;
119
250
  }
@@ -124,6 +255,10 @@ export async function* handleStream(
124
255
  const packets: Packet[] = completedChunks
125
256
  .filter((chunk) => chunk && typeof chunk === 'object')
126
257
  .map((chunk) => {
258
+ if (onyxVersion === '3') {
259
+ return normaliseV3Chunk(chunk);
260
+ }
261
+
127
262
  // Onyx v2 format: { ind: number, obj: { type: string, ... } }
128
263
  if ('ind' in chunk && 'obj' in chunk) {
129
264
  return chunk as Packet;
@@ -167,77 +302,24 @@ export async function* handleStream(
167
302
  * Send a message and stream the response
168
303
  */
169
304
  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,
305
+ params: SendMessageParams,
194
306
  isRelatedQuestion: boolean = false,
195
307
  ): AsyncGenerator<Packet[], void, unknown> {
196
- const documentsAreSelected =
197
- selectedDocumentIds && selectedDocumentIds.length > 0;
308
+ const { onyxVersion = '2', signal } = params;
198
309
 
199
- const payload = {
200
- alternate_assistant_id: alternateAssistantId,
201
- chat_session_id: chatSessionId,
202
- parent_message_id: parentMessageId,
203
- message,
204
- prompt_id: null,
205
- search_doc_ids: documentsAreSelected ? selectedDocumentIds : null,
206
- file_descriptors: fileDescriptors,
207
- current_message_files: currentMessageFiles,
208
- regenerate,
209
- retrieval_options:
210
- retrieval_options ??
211
- (!documentsAreSelected
212
- ? {
213
- run_search: queryOverride || forceSearch ? 'always' : 'auto',
214
- real_time: true,
215
- filters: filters,
216
- }
217
- : null),
218
- query_override: queryOverride,
219
- prompt_override: {
220
- ...(systemPromptOverride ? { system_prompt: systemPromptOverride } : {}),
221
- ...(taskPromptOverride ? { task_prompt: taskPromptOverride } : {}),
222
- },
223
- llm_override:
224
- temperature || modelVersion
225
- ? {
226
- temperature,
227
- model_provider: modelProvider,
228
- model_version: modelVersion,
229
- }
230
- : null,
231
- use_existing_user_message: useExistingUserMessage,
232
- use_agentic_search: useAgentSearch ?? false,
233
- allowed_tool_ids: enabledToolIds,
234
- forced_tool_ids: forcedToolIds,
235
- };
310
+ const payload =
311
+ onyxVersion === '3' ? buildPayloadV3(params) : buildPayloadV2(params);
236
312
 
237
313
  const body = JSON.stringify(payload);
238
314
 
239
315
  const middleware = isRelatedQuestion ? '_rq' : '_da';
240
- const sendMessageResponse = await fetch(`/${middleware}/chat/send-message`, {
316
+ const endpoint =
317
+ onyxVersion === '3' ? 'send-chat-message' : 'send-message';
318
+
319
+ console.log(`[sendMessage] Target URL: /${middleware}/chat/${endpoint} (v${onyxVersion})`);
320
+ console.log(`[sendMessage] Payload:`, payload);
321
+
322
+ const sendMessageResponse = await fetch(`/${middleware}/chat/${endpoint}`, {
241
323
  method: 'POST',
242
324
  headers: {
243
325
  'Content-Type': 'application/json',
@@ -252,7 +334,7 @@ export async function* sendMessage(
252
334
  throw new Error(`Failed to send message - ${errorMsg}`);
253
335
  }
254
336
 
255
- yield* handleStream(sendMessageResponse);
337
+ yield* handleStream(sendMessageResponse, onyxVersion);
256
338
  }
257
339
 
258
340
  /**
@@ -261,8 +343,13 @@ export async function* sendMessage(
261
343
  export async function createChatSession(
262
344
  personaId: number,
263
345
  description?: string,
346
+ isRelatedQuestion: boolean = false,
264
347
  ): Promise<string> {
265
- const response = await fetch('/_da/chat/create-chat-session', {
348
+ const middleware = isRelatedQuestion ? '_rq' : '_da';
349
+ const url = `/${middleware}/chat/create-chat-session`;
350
+ console.log(`[createChatSession] URL: ${url}`);
351
+
352
+ const response = await fetch(url, {
266
353
  method: 'POST',
267
354
  headers: {
268
355
  'Content-Type': 'application/json',
@@ -51,6 +51,13 @@ export enum PacketType {
51
51
  MESSAGE_END_ID_INFO = 'message_end_id_info',
52
52
 
53
53
  ERROR = 'error',
54
+
55
+ // Onyx v3 replacements
56
+ SEARCH_TOOL_START_V3 = 'search_tool_start',
57
+ SEARCH_TOOL_QUERIES_DELTA = 'search_tool_queries_delta',
58
+ SEARCH_TOOL_DOCUMENTS_DELTA = 'search_tool_documents_delta',
59
+ CITATION_INFO = 'citation_info',
60
+ REASONING_DONE = 'reasoning_done',
54
61
  }
55
62
 
56
63
  // Basic Message Packets
@@ -174,12 +181,44 @@ export interface ErrorObj extends BaseObj {
174
181
  error: string;
175
182
  }
176
183
 
184
+ // Onyx v3 Specific Packets
185
+ export interface SearchToolStartV3 extends BaseObj {
186
+ type: PacketType.SEARCH_TOOL_START_V3;
187
+ is_internet_search?: boolean;
188
+ }
189
+
190
+ export interface SearchToolQueriesDelta extends BaseObj {
191
+ type: PacketType.SEARCH_TOOL_QUERIES_DELTA;
192
+ queries: string[];
193
+ }
194
+
195
+ export interface SearchToolDocumentsDelta extends BaseObj {
196
+ type: PacketType.SEARCH_TOOL_DOCUMENTS_DELTA;
197
+ documents: OnyxDocument[];
198
+ }
199
+
200
+ export interface CitationInfo extends BaseObj {
201
+ type: PacketType.CITATION_INFO;
202
+ citation_number: number;
203
+ document_id: string;
204
+ }
205
+
206
+ export interface ReasoningDone extends BaseObj {
207
+ type: PacketType.REASONING_DONE;
208
+ }
209
+
177
210
  export type ChatObj = MessageStart | MessageDelta | MessageEnd;
178
211
  export type StopObj = Stop;
179
212
  export type SectionEndObj = SectionEnd;
180
213
 
181
214
  // Specific tool objects
182
- export type SearchToolObj = SearchToolStart | SearchToolDelta | SectionEnd;
215
+ export type SearchToolObj =
216
+ | SearchToolStart
217
+ | SearchToolDelta
218
+ | SearchToolStartV3
219
+ | SearchToolQueriesDelta
220
+ | SearchToolDocumentsDelta
221
+ | SectionEnd;
183
222
  export type ImageGenerationToolObj =
184
223
  | ImageGenerationToolStart
185
224
  | ImageGenerationToolDelta
@@ -196,6 +235,7 @@ export type ReasoningObj =
196
235
  | ReasoningStart
197
236
  | ReasoningDelta
198
237
  | ReasoningEnd
238
+ | ReasoningDone
199
239
  | SectionEnd;
200
240
  export type CitationObj =
201
241
  | CitationStart
@@ -212,7 +252,12 @@ export type ObjTypes =
212
252
  | SectionEndObj
213
253
  | CitationObj
214
254
  | ErrorObj
215
- | MessageEndIdInfo;
255
+ | MessageEndIdInfo
256
+ | SearchToolStartV3
257
+ | SearchToolQueriesDelta
258
+ | SearchToolDocumentsDelta
259
+ | CitationInfo
260
+ | ReasoningDone;
216
261
 
217
262
  // Packet wrapper for streaming objects
218
263
  export interface Packet {
@@ -1,4 +1,4 @@
1
- import type { Message } from '../types/interfaces';
1
+ import type { Message } from '@eeacms/volto-eea-chatbot/ChatBlock/types/interfaces';
2
2
 
3
3
  /**
4
4
  * Regex to match citation markers like [1], [2], etc.
@@ -1,4 +1,12 @@
1
- import { parseExcludeIndices } from './filtering';
1
+ import fetch from 'node-fetch';
2
+
3
+ import {
4
+ parseExcludeIndices,
5
+ callLLM,
6
+ excludeClaimSentences,
7
+ excludeContextSentences,
8
+ } from './filtering';
9
+ jest.mock('node-fetch');
2
10
 
3
11
  describe('parseExcludeIndices', () => {
4
12
  it('parses single indices', () => {
@@ -42,3 +50,193 @@ describe('parseExcludeIndices', () => {
42
50
  expect(result).toEqual(new Set());
43
51
  });
44
52
  });
53
+
54
+ describe('callLLM', () => {
55
+ beforeEach(() => {
56
+ fetch.mockReset();
57
+ });
58
+
59
+ afterEach(() => {
60
+ fetch.mockReset();
61
+ });
62
+
63
+ it('makes POST request with correct headers', async () => {
64
+ const mockResponse = { choices: [{ message: { content: 'result' } }] };
65
+ fetch.mockResolvedValue({
66
+ json: () => Promise.resolve(mockResponse),
67
+ });
68
+
69
+ await callLLM('https://api.example.com', 'my-api-key', { model: 'gpt-4' });
70
+
71
+ expect(fetch).toHaveBeenCalledWith(
72
+ 'https://api.example.com',
73
+ expect.objectContaining({
74
+ method: 'POST',
75
+ headers: expect.objectContaining({
76
+ 'Content-Type': 'application/json',
77
+ Authorization: 'Bearer my-api-key',
78
+ }),
79
+ }),
80
+ );
81
+ });
82
+
83
+ it('omits Authorization header when no apiKey', async () => {
84
+ fetch.mockResolvedValue({
85
+ json: () => Promise.resolve({}),
86
+ });
87
+
88
+ await callLLM('https://api.example.com', null, { model: 'gpt-4' });
89
+
90
+ const headers = fetch.mock.calls[0][1].headers;
91
+ expect(headers['Authorization']).toBeUndefined();
92
+ });
93
+
94
+ it('adds X-Forwarded-For header when ip is provided', async () => {
95
+ fetch.mockResolvedValue({
96
+ json: () => Promise.resolve({}),
97
+ });
98
+
99
+ await callLLM('https://api.example.com', null, {}, { ip: '1.2.3.4' });
100
+
101
+ const headers = fetch.mock.calls[0][1].headers;
102
+ expect(headers['X-Forwarded-For']).toBe('1.2.3.4');
103
+ });
104
+
105
+ it('returns parsed JSON response', async () => {
106
+ const mockResponse = { choices: [{ message: { content: 'hello' } }] };
107
+ fetch.mockResolvedValue({
108
+ json: () => Promise.resolve(mockResponse),
109
+ });
110
+
111
+ const result = await callLLM('https://api.example.com', null, {});
112
+ expect(result).toEqual(mockResponse);
113
+ });
114
+ });
115
+
116
+ describe('excludeClaimSentences', () => {
117
+ beforeEach(() => {
118
+ fetch.mockReset();
119
+ });
120
+
121
+ afterEach(() => {
122
+ fetch.mockReset();
123
+ });
124
+
125
+ it('returns empty set for empty sentences array', async () => {
126
+ const result = await excludeClaimSentences([]);
127
+ expect(result).toEqual(new Set());
128
+ expect(fetch).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it('calls LLM and returns excluded sentence indices', async () => {
132
+ fetch.mockResolvedValue({
133
+ json: () =>
134
+ Promise.resolve({
135
+ choices: [{ message: { content: '1,3' } }],
136
+ }),
137
+ });
138
+
139
+ const sentences = ['Sentence one.', 'Sentence two.', 'Sentence three.'];
140
+ const result = await excludeClaimSentences(sentences);
141
+
142
+ expect(result).toEqual(new Set([1, 3]));
143
+ expect(fetch).toHaveBeenCalledTimes(1);
144
+ });
145
+
146
+ it('returns empty set when LLM returns NONE', async () => {
147
+ fetch.mockResolvedValue({
148
+ json: () =>
149
+ Promise.resolve({
150
+ choices: [{ message: { content: 'NONE' } }],
151
+ }),
152
+ });
153
+
154
+ const sentences = ['Some factual sentence.', 'Another factual sentence.'];
155
+ const result = await excludeClaimSentences(sentences);
156
+
157
+ expect(result).toEqual(new Set());
158
+ });
159
+
160
+ it('returns empty set on LLM error', async () => {
161
+ fetch.mockRejectedValue(new Error('Network error'));
162
+
163
+ const sentences = ['Sentence one.'];
164
+ const result = await excludeClaimSentences(sentences);
165
+
166
+ expect(result).toEqual(new Set());
167
+ });
168
+
169
+ it('passes ip option to LLM call', async () => {
170
+ fetch.mockResolvedValue({
171
+ json: () =>
172
+ Promise.resolve({ choices: [{ message: { content: 'NONE' } }] }),
173
+ });
174
+
175
+ await excludeClaimSentences(['test sentence'], { ip: '10.0.0.1' });
176
+
177
+ const headers = fetch.mock.calls[0][1].headers;
178
+ expect(headers['X-Forwarded-For']).toBe('10.0.0.1');
179
+ });
180
+ });
181
+
182
+ describe('excludeContextSentences', () => {
183
+ const MIN_SENTENCES = 75;
184
+
185
+ beforeEach(() => {
186
+ fetch.mockReset();
187
+ });
188
+
189
+ afterEach(() => {
190
+ fetch.mockReset();
191
+ });
192
+
193
+ it('returns empty set when context sentences <= minimum threshold', async () => {
194
+ const shortContext = Array(10).fill('Short sentence.');
195
+ const claims = ['A claim.'];
196
+ const result = await excludeContextSentences(shortContext, claims);
197
+
198
+ expect(result).toEqual(new Set());
199
+ expect(fetch).not.toHaveBeenCalled();
200
+ });
201
+
202
+ it('calls LLM when context exceeds minimum threshold', async () => {
203
+ fetch.mockResolvedValue({
204
+ json: () =>
205
+ Promise.resolve({
206
+ choices: [{ message: { content: '1,2' } }],
207
+ }),
208
+ });
209
+
210
+ const longContext = Array(MIN_SENTENCES + 1).fill('Context sentence.');
211
+ const claims = ['A claim.'];
212
+ const result = await excludeContextSentences(longContext, claims);
213
+
214
+ expect(result).toEqual(new Set([1, 2]));
215
+ expect(fetch).toHaveBeenCalledTimes(1);
216
+ });
217
+
218
+ it('returns empty set on LLM error', async () => {
219
+ fetch.mockRejectedValue(new Error('timeout'));
220
+
221
+ const longContext = Array(MIN_SENTENCES + 1).fill('Context sentence.');
222
+ const claims = ['A claim.'];
223
+ const result = await excludeContextSentences(longContext, claims);
224
+
225
+ expect(result).toEqual(new Set());
226
+ });
227
+
228
+ it('returns empty set when LLM returns NONE for context', async () => {
229
+ fetch.mockResolvedValue({
230
+ json: () =>
231
+ Promise.resolve({
232
+ choices: [{ message: { content: 'NONE' } }],
233
+ }),
234
+ });
235
+
236
+ const longContext = Array(MIN_SENTENCES + 1).fill('Context sentence.');
237
+ const claims = ['A claim.'];
238
+ const result = await excludeContextSentences(longContext, claims);
239
+
240
+ expect(result).toEqual(new Set());
241
+ });
242
+ });
package/src/middleware.js CHANGED
@@ -186,11 +186,21 @@ async function send_onyx_request(
186
186
  options.body = JSON.stringify(req.body);
187
187
  }
188
188
 
189
+ console.log('[Middleware] Sending request to Onyx:', {
190
+ url,
191
+ method: req.method,
192
+ hasBody: !!req.body,
193
+ body: JSON.stringify(req.body, null, 2),
194
+ });
195
+
189
196
  const mock_file = is_related_question
190
197
  ? process.env.MOCK_LLM_FILE_PATH_RQ
191
198
  : process.env.MOCK_LLM_FILE_PATH;
192
199
 
193
- if (mock_file && req.url.endsWith('send-message')) {
200
+ if (
201
+ mock_file &&
202
+ (req.url.endsWith('send-message') || req.url.endsWith('send-chat-message'))
203
+ ) {
194
204
  try {
195
205
  await new Promise((resolve) => setTimeout(resolve, 2000));
196
206
  mock_send_message(res, is_related_question);
@@ -210,6 +220,13 @@ async function send_onyx_request(
210
220
  log(`Fetching ${url}`);
211
221
  const response = await fetch(url, options, req.body);
212
222
 
223
+ console.log('[Middleware] Received response from Onyx:', {
224
+ url,
225
+ status: response.status,
226
+ statusText: response.statusText,
227
+ headers: response.headers.raw(),
228
+ });
229
+
213
230
  if (process.env.DUMP_LLM_FILE_PATH && !is_related_question) {
214
231
  const filePath = process.env.DUMP_LLM_FILE_PATH;
215
232
  const writer = fs.createWriteStream(filePath);
@@ -18,6 +18,7 @@ jest.mock('node-fetch', () => {
18
18
  status: 200,
19
19
  headers: {
20
20
  get: jest.fn().mockReturnValue('application/json'),
21
+ raw: jest.fn().mockReturnValue({}),
21
22
  },
22
23
  body: { pipe: mockPipe },
23
24
  });
@@ -220,4 +221,17 @@ describe('src/middleware', () => {
220
221
 
221
222
  expect(res.write).toHaveBeenCalled();
222
223
  });
224
+
225
+ it('dumps LLM response when DUMP_LLM_FILE_PATH is set', async () => {
226
+ process.env.ONYX_API_KEY = 'test-key';
227
+ process.env.ONYX_URL = 'http://localhost:3000';
228
+ process.env.DUMP_LLM_FILE_PATH = '/tmp/dumped_response.jsonl';
229
+
230
+ await middleware(req, res, next);
231
+
232
+ const fs = require('fs');
233
+ expect(fs.createWriteStream).toHaveBeenCalledWith(
234
+ '/tmp/dumped_response.jsonl',
235
+ );
236
+ });
223
237
  });