@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.
- package/CHANGELOG.md +15 -752
- package/package.json +1 -1
- package/razzle.extend.js +8 -4
- package/src/ChatBlock/ChatBlockView.jsx +26 -2
- package/src/ChatBlock/chat/AIMessage.tsx +5 -1
- package/src/ChatBlock/chat/ChatWindow.tsx +12 -3
- package/src/ChatBlock/components/AutoResizeTextarea.jsx +2 -1
- package/src/ChatBlock/components/QualityCheckToggle.jsx +1 -1
- package/src/ChatBlock/hooks/useChatController.ts +10 -2
- package/src/ChatBlock/index.js +1 -1
- package/src/ChatBlock/packets/renderers/MessageTextRenderer.tsx +8 -0
- package/src/ChatBlock/services/streamingService.ts +30 -26
- package/src/ChatBlock/style.less +3 -1
- package/src/ChatBlock/tests/ChatMessage.test.jsx +75 -0
- package/src/ChatBlock/tests/ClaimModal.test.jsx +136 -0
- package/src/ChatBlock/tests/ClaimSegments.test.jsx +206 -0
- package/src/ChatBlock/tests/CustomToolRenderer.test.jsx +241 -0
- package/src/ChatBlock/tests/FetchToolRenderer.test.jsx +161 -0
- package/src/ChatBlock/tests/ImageToolRenderer.test.jsx +178 -0
- package/src/ChatBlock/tests/MessageTextRenderer.test.jsx +227 -0
- package/src/ChatBlock/tests/MultiToolRenderer.test.jsx +134 -0
- package/src/ChatBlock/tests/ReasoningRenderer.test.jsx +163 -0
- package/src/ChatBlock/tests/RenderClaimView.test.jsx +191 -0
- package/src/ChatBlock/tests/RendererComponent.test.jsx +139 -0
- package/src/ChatBlock/tests/SearchToolRenderer.test.jsx +295 -0
- package/src/ChatBlock/tests/SourceChip.test.jsx +108 -0
- package/src/ChatBlock/tests/UserActionsToolbar.test.jsx +135 -0
- package/src/ChatBlock/tests/UserMessage.test.jsx +83 -0
- package/src/ChatBlock/tests/WebResultIcon.test.jsx +61 -0
- package/src/ChatBlock/tests/citations.test.js +114 -0
- package/src/ChatBlock/tests/messageProcessor.test.jsx +285 -1
- package/src/ChatBlock/tests/packetUtils.test.js +158 -0
- package/src/ChatBlock/tests/streamingService.test.js +467 -0
- package/src/ChatBlock/tests/useChatController.test.jsx +268 -0
- package/src/ChatBlock/tests/useChatStreaming.test.jsx +163 -0
- package/src/ChatBlock/tests/useMarked.test.jsx +107 -0
- package/src/ChatBlock/tests/useQualityMarkers.test.jsx +150 -0
- package/src/ChatBlock/tests/useScrollonStream.test.jsx +121 -0
- package/src/ChatBlock/tests/utils.test.jsx +241 -0
- package/src/ChatBlock/tests/withOnyxData.test.jsx +81 -0
- package/src/ChatBlock/utils/citations.ts +1 -1
- package/src/halloumi/generative.js +1 -0
- package/src/halloumi/generative.test.js +278 -0
- package/src/halloumi/middleware.test.js +69 -0
- package/src/index.js +1 -0
- package/src/middleware.js +21 -13
- package/src/middleware.test.js +221 -0
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
halloumiGenerativeAPI,
|
|
3
3
|
convertGenerativesClaimToVerifyClaimResponse,
|
|
4
|
+
applyPlattScaling,
|
|
5
|
+
getVerifyClaimResponse,
|
|
4
6
|
} from './generative';
|
|
5
7
|
import path from 'path';
|
|
6
8
|
|
|
9
|
+
jest.mock('node-fetch');
|
|
10
|
+
|
|
7
11
|
describe('halloumiGenerativeAPI reads from mock file', () => {
|
|
8
12
|
const originalEnv = process.env;
|
|
9
13
|
|
|
@@ -41,6 +45,257 @@ describe('halloumiGenerativeAPI reads from mock file', () => {
|
|
|
41
45
|
});
|
|
42
46
|
});
|
|
43
47
|
|
|
48
|
+
describe('applyPlattScaling', () => {
|
|
49
|
+
it('returns calibrated probability', () => {
|
|
50
|
+
const platt = { a: -0.5764, b: 0.1665 };
|
|
51
|
+
const result = applyPlattScaling(platt, 0.5);
|
|
52
|
+
expect(result).toBeGreaterThan(0);
|
|
53
|
+
expect(result).toBeLessThan(1);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('clamps very low probability', () => {
|
|
57
|
+
const platt = { a: -1, b: 0 };
|
|
58
|
+
const result = applyPlattScaling(platt, 0);
|
|
59
|
+
expect(result).toBeGreaterThan(0);
|
|
60
|
+
expect(result).toBeLessThan(1);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('clamps very high probability', () => {
|
|
64
|
+
const platt = { a: -1, b: 0 };
|
|
65
|
+
const result = applyPlattScaling(platt, 1);
|
|
66
|
+
expect(result).toBeGreaterThan(0);
|
|
67
|
+
expect(result).toBeLessThan(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns 0.5 when platt params are zero', () => {
|
|
71
|
+
const platt = { a: 0, b: 0 };
|
|
72
|
+
const result = applyPlattScaling(platt, 0.5);
|
|
73
|
+
expect(result).toBeCloseTo(0.5, 1);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('getVerifyClaimResponse', () => {
|
|
78
|
+
it('returns empty response when sources is empty', async () => {
|
|
79
|
+
const result = await getVerifyClaimResponse({}, [], 'claims');
|
|
80
|
+
expect(result).toEqual({ claims: [], segments: {} });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('returns empty response when sources is null', async () => {
|
|
84
|
+
const result = await getVerifyClaimResponse({}, null, 'claims');
|
|
85
|
+
expect(result).toEqual({ claims: [], segments: {} });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('returns empty response when claims is falsy', async () => {
|
|
89
|
+
const result = await getVerifyClaimResponse({}, ['source'], null);
|
|
90
|
+
expect(result).toEqual({ claims: [], segments: {} });
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('halloumiGenerativeAPI with plattScaling', () => {
|
|
95
|
+
const originalEnv = process.env;
|
|
96
|
+
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
jest.resetModules();
|
|
99
|
+
process.env = {
|
|
100
|
+
...originalEnv,
|
|
101
|
+
MOCK_HALLOUMI_FILE_PATH: path.join(__dirname, '../dummy/qa-raw-3.json'),
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
process.env = originalEnv;
|
|
107
|
+
jest.restoreAllMocks();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('applies platt scaling when model has plattScaling config', async () => {
|
|
111
|
+
const model = {
|
|
112
|
+
name: 'test-model',
|
|
113
|
+
apiUrl: 'http://test.com',
|
|
114
|
+
plattScaling: { a: -0.5764, b: 0.1665 },
|
|
115
|
+
};
|
|
116
|
+
const prompt = {
|
|
117
|
+
prompt: 'test-prompt',
|
|
118
|
+
contextOffsets: new Map([[1, { startOffset: 0, endOffset: 10 }]]),
|
|
119
|
+
responseOffsets: new Map([[1, { startOffset: 0, endOffset: 20 }]]),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const response = await halloumiGenerativeAPI(model, prompt);
|
|
123
|
+
|
|
124
|
+
// With platt scaling, probabilities should be calibrated
|
|
125
|
+
expect(response[0].probabilities).toBeDefined();
|
|
126
|
+
const supported = response[0].probabilities.get('supported');
|
|
127
|
+
const unsupported = response[0].probabilities.get('unsupported');
|
|
128
|
+
expect(supported + unsupported).toBeCloseTo(1, 5);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe('halloumiGenerativeAPI via real fetch', () => {
|
|
133
|
+
const originalEnv = process.env;
|
|
134
|
+
|
|
135
|
+
beforeEach(() => {
|
|
136
|
+
jest.resetModules();
|
|
137
|
+
// Remove mock file path to exercise the fetch path
|
|
138
|
+
process.env = { ...originalEnv };
|
|
139
|
+
delete process.env.MOCK_HALLOUMI_FILE_PATH;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
afterEach(() => {
|
|
143
|
+
process.env = originalEnv;
|
|
144
|
+
jest.restoreAllMocks();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('calls fetch with correct parameters and auth header', async () => {
|
|
148
|
+
// Mock postprocessing to control output and avoid format issues
|
|
149
|
+
jest.doMock('./postprocessing', () => ({
|
|
150
|
+
getClaimsFromResponse: jest.fn(() => [
|
|
151
|
+
{
|
|
152
|
+
claimId: 1,
|
|
153
|
+
claimString: 'Test claim',
|
|
154
|
+
subclaims: [],
|
|
155
|
+
segments: [1],
|
|
156
|
+
explanation: 'ok',
|
|
157
|
+
supported: true,
|
|
158
|
+
},
|
|
159
|
+
]),
|
|
160
|
+
getTokenProbabilitiesFromLogits: jest.fn(() => [
|
|
161
|
+
new Map([
|
|
162
|
+
['supported', 0.9],
|
|
163
|
+
['unsupported', 0.1],
|
|
164
|
+
]),
|
|
165
|
+
]),
|
|
166
|
+
}));
|
|
167
|
+
|
|
168
|
+
const { halloumiGenerativeAPI } = require('./generative');
|
|
169
|
+
const nodeFetch = require('node-fetch');
|
|
170
|
+
|
|
171
|
+
const mockResponse = {
|
|
172
|
+
choices: [
|
|
173
|
+
{
|
|
174
|
+
message: { content: 'mock content' },
|
|
175
|
+
logprobs: { content: [] },
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
nodeFetch.mockResolvedValueOnce({
|
|
181
|
+
json: () => Promise.resolve(mockResponse),
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const model = {
|
|
185
|
+
name: 'test-model',
|
|
186
|
+
apiUrl: 'http://test.com/api',
|
|
187
|
+
apiKey: 'test-key',
|
|
188
|
+
};
|
|
189
|
+
const prompt = {
|
|
190
|
+
prompt: 'test prompt',
|
|
191
|
+
contextOffsets: new Map([[1, { startOffset: 0, endOffset: 10 }]]),
|
|
192
|
+
responseOffsets: new Map([[1, { startOffset: 0, endOffset: 20 }]]),
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const response = await halloumiGenerativeAPI(model, prompt);
|
|
196
|
+
expect(response).toBeDefined();
|
|
197
|
+
expect(Array.isArray(response)).toBe(true);
|
|
198
|
+
expect(response[0].probabilities.get('supported')).toBe(0.9);
|
|
199
|
+
|
|
200
|
+
// Verify fetch was called with auth header
|
|
201
|
+
expect(nodeFetch).toHaveBeenCalledWith(
|
|
202
|
+
'http://test.com/api',
|
|
203
|
+
expect.objectContaining({
|
|
204
|
+
method: 'POST',
|
|
205
|
+
headers: expect.objectContaining({
|
|
206
|
+
Authorization: 'Bearer test-key',
|
|
207
|
+
}),
|
|
208
|
+
}),
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('calls fetch without auth header when no apiKey', async () => {
|
|
213
|
+
jest.doMock('./postprocessing', () => ({
|
|
214
|
+
getClaimsFromResponse: jest.fn(() => [
|
|
215
|
+
{
|
|
216
|
+
claimId: 1,
|
|
217
|
+
claimString: 'Test',
|
|
218
|
+
subclaims: [],
|
|
219
|
+
segments: [],
|
|
220
|
+
explanation: 'ok',
|
|
221
|
+
supported: true,
|
|
222
|
+
},
|
|
223
|
+
]),
|
|
224
|
+
getTokenProbabilitiesFromLogits: jest.fn(() => [
|
|
225
|
+
new Map([
|
|
226
|
+
['supported', 0.8],
|
|
227
|
+
['unsupported', 0.2],
|
|
228
|
+
]),
|
|
229
|
+
]),
|
|
230
|
+
}));
|
|
231
|
+
|
|
232
|
+
const { halloumiGenerativeAPI } = require('./generative');
|
|
233
|
+
const nodeFetch = require('node-fetch');
|
|
234
|
+
|
|
235
|
+
nodeFetch.mockResolvedValueOnce({
|
|
236
|
+
json: () =>
|
|
237
|
+
Promise.resolve({
|
|
238
|
+
choices: [
|
|
239
|
+
{
|
|
240
|
+
message: { content: 'mock' },
|
|
241
|
+
logprobs: { content: [] },
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
}),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const model = { name: 'test-model', apiUrl: 'http://test.com/api' };
|
|
248
|
+
const prompt = {
|
|
249
|
+
prompt: 'test',
|
|
250
|
+
contextOffsets: new Map(),
|
|
251
|
+
responseOffsets: new Map(),
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
await halloumiGenerativeAPI(model, prompt);
|
|
255
|
+
|
|
256
|
+
const callHeaders = nodeFetch.mock.calls[0][1].headers;
|
|
257
|
+
expect(callHeaders.Authorization).toBeUndefined();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('throws when token probabilities and claims do not match', async () => {
|
|
261
|
+
jest.doMock('./postprocessing', () => ({
|
|
262
|
+
getClaimsFromResponse: jest.fn(() => [
|
|
263
|
+
{ claimId: 1, claimString: 'Claim 1' },
|
|
264
|
+
{ claimId: 2, claimString: 'Claim 2' },
|
|
265
|
+
]),
|
|
266
|
+
getTokenProbabilitiesFromLogits: jest.fn(() => [
|
|
267
|
+
new Map([['supported', 0.9]]),
|
|
268
|
+
]),
|
|
269
|
+
}));
|
|
270
|
+
|
|
271
|
+
const { halloumiGenerativeAPI } = require('./generative');
|
|
272
|
+
const nodeFetch = require('node-fetch');
|
|
273
|
+
|
|
274
|
+
nodeFetch.mockResolvedValueOnce({
|
|
275
|
+
json: () =>
|
|
276
|
+
Promise.resolve({
|
|
277
|
+
choices: [
|
|
278
|
+
{
|
|
279
|
+
message: { content: 'mock' },
|
|
280
|
+
logprobs: { content: [] },
|
|
281
|
+
},
|
|
282
|
+
],
|
|
283
|
+
}),
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const model = { name: 'test-model', apiUrl: 'http://test.com' };
|
|
287
|
+
const prompt = {
|
|
288
|
+
prompt: 'test',
|
|
289
|
+
contextOffsets: new Map(),
|
|
290
|
+
responseOffsets: new Map(),
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
await expect(halloumiGenerativeAPI(model, prompt)).rejects.toThrow(
|
|
294
|
+
'Token probabilities and claims do not match',
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
|
|
44
299
|
describe('convertGenerativesClaimToVerifyClaimResponse', () => {
|
|
45
300
|
it('should correctly convert generative claims to verify claim response', () => {
|
|
46
301
|
const generativeClaims = [
|
|
@@ -85,4 +340,27 @@ describe('convertGenerativesClaimToVerifyClaimResponse', () => {
|
|
|
85
340
|
},
|
|
86
341
|
});
|
|
87
342
|
});
|
|
343
|
+
|
|
344
|
+
it('throws when claim ID is not found in response offsets', () => {
|
|
345
|
+
const generativeClaims = [
|
|
346
|
+
{
|
|
347
|
+
claimId: 999,
|
|
348
|
+
claimString: 'Unknown claim',
|
|
349
|
+
subclaims: [],
|
|
350
|
+
segments: [],
|
|
351
|
+
explanation: 'Missing',
|
|
352
|
+
supported: false,
|
|
353
|
+
probabilities: new Map([['supported', 0.1]]),
|
|
354
|
+
},
|
|
355
|
+
];
|
|
356
|
+
|
|
357
|
+
const prompt = {
|
|
358
|
+
contextOffsets: new Map(),
|
|
359
|
+
responseOffsets: new Map(),
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
expect(() =>
|
|
363
|
+
convertGenerativesClaimToVerifyClaimResponse(generativeClaims, prompt),
|
|
364
|
+
).toThrow('Claim 999 not found in response offsets');
|
|
365
|
+
});
|
|
88
366
|
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import middleware from './middleware';
|
|
2
|
+
|
|
3
|
+
jest.mock('./generative');
|
|
4
|
+
|
|
5
|
+
describe('halloumi middleware', () => {
|
|
6
|
+
let req, res, next;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
req = {
|
|
10
|
+
url: '/_ha/generate',
|
|
11
|
+
body: {
|
|
12
|
+
sources: ['source1', 'source2'],
|
|
13
|
+
answer: 'test answer',
|
|
14
|
+
maxContextSegments: 3,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
res = {
|
|
18
|
+
send: jest.fn(),
|
|
19
|
+
set: jest.fn(),
|
|
20
|
+
status: jest.fn().mockReturnThis(),
|
|
21
|
+
};
|
|
22
|
+
next = jest.fn();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
jest.restoreAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns error when LLMGW_TOKEN is missing', async () => {
|
|
30
|
+
const origToken = process.env.LLMGW_TOKEN;
|
|
31
|
+
const origUrl = process.env.LLMGW_URL;
|
|
32
|
+
delete process.env.LLMGW_TOKEN;
|
|
33
|
+
delete process.env.LLMGW_URL;
|
|
34
|
+
|
|
35
|
+
await middleware(req, res, next);
|
|
36
|
+
|
|
37
|
+
expect(res.send).toHaveBeenCalledWith({
|
|
38
|
+
error: 'Invalid configuration: missing LLMGW_TOKEN or LLMGW_URL',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
process.env.LLMGW_TOKEN = origToken;
|
|
42
|
+
process.env.LLMGW_URL = origUrl;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('calls getVerifyClaimResponse and sends response on success', async () => {
|
|
46
|
+
const origToken = process.env.LLMGW_TOKEN;
|
|
47
|
+
const origUrl = process.env.LLMGW_URL;
|
|
48
|
+
process.env.LLMGW_TOKEN = 'test-token';
|
|
49
|
+
process.env.LLMGW_URL = 'http://test-url';
|
|
50
|
+
|
|
51
|
+
// Need to re-import since env vars are read at module level
|
|
52
|
+
jest.resetModules();
|
|
53
|
+
const genMod = require('./generative');
|
|
54
|
+
const middlewareMod = require('./middleware').default;
|
|
55
|
+
|
|
56
|
+
genMod.getVerifyClaimResponse = jest
|
|
57
|
+
.fn()
|
|
58
|
+
.mockResolvedValue({ claims: [], segments: {} });
|
|
59
|
+
|
|
60
|
+
await middlewareMod(req, res, next);
|
|
61
|
+
|
|
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();
|
|
65
|
+
|
|
66
|
+
process.env.LLMGW_TOKEN = origToken;
|
|
67
|
+
process.env.LLMGW_URL = origUrl;
|
|
68
|
+
});
|
|
69
|
+
});
|
package/src/index.js
CHANGED
|
@@ -13,6 +13,7 @@ const applyConfig = (config) => {
|
|
|
13
13
|
const halloumiMiddleware = require('./halloumi/middleware').default;
|
|
14
14
|
|
|
15
15
|
middleware.all('**/_da/**', proxyMiddleware);
|
|
16
|
+
middleware.all('**/_rq/**', proxyMiddleware);
|
|
16
17
|
middleware.all('**/_ha/**', halloumiMiddleware);
|
|
17
18
|
|
|
18
19
|
middleware.id = 'chatbot';
|
package/src/middleware.js
CHANGED
|
@@ -77,11 +77,14 @@ function mock_create_chat(res) {
|
|
|
77
77
|
res.end();
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
function mock_send_message(res) {
|
|
81
|
-
const
|
|
80
|
+
function mock_send_message(res, is_related_question) {
|
|
81
|
+
const env_name = is_related_question
|
|
82
|
+
? 'MOCK_LLM_FILE_PATH_RQ'
|
|
83
|
+
: 'MOCK_LLM_FILE_PATH';
|
|
84
|
+
const filePath = process.env[env_name];
|
|
82
85
|
if (!filePath) {
|
|
83
|
-
log(
|
|
84
|
-
res.status(500).send(
|
|
86
|
+
log(`${env_name} is not set. Cannot mock send message.`);
|
|
87
|
+
res.status(500).send(`Internal Server Error: ${env_name} not set.`);
|
|
85
88
|
return;
|
|
86
89
|
}
|
|
87
90
|
const readStream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
|
@@ -146,7 +149,7 @@ function mock_send_message(res) {
|
|
|
146
149
|
async function send_onyx_request(
|
|
147
150
|
req,
|
|
148
151
|
res,
|
|
149
|
-
{ username, password, api_key, url },
|
|
152
|
+
{ username, password, api_key, url, is_related_question },
|
|
150
153
|
) {
|
|
151
154
|
let headers = {};
|
|
152
155
|
if (!api_key) {
|
|
@@ -180,19 +183,22 @@ async function send_onyx_request(
|
|
|
180
183
|
options.body = JSON.stringify(req.body);
|
|
181
184
|
}
|
|
182
185
|
|
|
183
|
-
|
|
186
|
+
const mock_file = is_related_question
|
|
187
|
+
? process.env.MOCK_LLM_FILE_PATH_RQ
|
|
188
|
+
: process.env.MOCK_LLM_FILE_PATH;
|
|
189
|
+
|
|
190
|
+
if (mock_file && req.url.endsWith('send-message')) {
|
|
184
191
|
try {
|
|
185
|
-
|
|
192
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
193
|
+
mock_send_message(res, is_related_question);
|
|
186
194
|
} catch (e) {
|
|
187
195
|
log(e);
|
|
188
196
|
}
|
|
189
197
|
return;
|
|
190
198
|
}
|
|
191
199
|
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
req.url.endsWith('create-chat-session')
|
|
195
|
-
) {
|
|
200
|
+
if (mock_file && req.url.endsWith('create-chat-session')) {
|
|
201
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
196
202
|
mock_create_chat(res);
|
|
197
203
|
return;
|
|
198
204
|
}
|
|
@@ -201,7 +207,7 @@ async function send_onyx_request(
|
|
|
201
207
|
log(`Fetching ${url}`);
|
|
202
208
|
const response = await fetch(url, options, req.body);
|
|
203
209
|
|
|
204
|
-
if (process.env.DUMP_LLM_FILE_PATH) {
|
|
210
|
+
if (process.env.DUMP_LLM_FILE_PATH && !is_related_question) {
|
|
205
211
|
const filePath = process.env.DUMP_LLM_FILE_PATH;
|
|
206
212
|
const writer = fs.createWriteStream(filePath);
|
|
207
213
|
response.body.pipe(writer);
|
|
@@ -224,7 +230,8 @@ async function send_onyx_request(
|
|
|
224
230
|
}
|
|
225
231
|
|
|
226
232
|
export default async function middleware(req, res, next) {
|
|
227
|
-
const
|
|
233
|
+
const is_related_question = req.url.includes('/_rq/');
|
|
234
|
+
const path = req.url.replace('/_da/', '/').replace('/_rq/', '/');
|
|
228
235
|
|
|
229
236
|
const reqUrl = `${process.env.ONYX_URL || ''}/api${path}`;
|
|
230
237
|
|
|
@@ -240,6 +247,7 @@ export default async function middleware(req, res, next) {
|
|
|
240
247
|
await send_onyx_request(req, res, {
|
|
241
248
|
url: reqUrl,
|
|
242
249
|
api_key: api_key,
|
|
250
|
+
is_related_question,
|
|
243
251
|
});
|
|
244
252
|
} catch (error) {
|
|
245
253
|
// eslint-disable-next-line
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// Mock superagent
|
|
2
|
+
import middleware from './middleware';
|
|
3
|
+
|
|
4
|
+
jest.mock('superagent', () => ({
|
|
5
|
+
post: jest.fn().mockReturnValue({
|
|
6
|
+
type: jest.fn().mockReturnValue({
|
|
7
|
+
send: jest.fn().mockResolvedValue({
|
|
8
|
+
headers: { 'set-cookie': ['session=abc; Max-Age=3600'] },
|
|
9
|
+
}),
|
|
10
|
+
}),
|
|
11
|
+
}),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
// Mock node-fetch - use require() to get the mock reference in tests
|
|
15
|
+
jest.mock('node-fetch', () => {
|
|
16
|
+
const mockPipe = jest.fn();
|
|
17
|
+
const fn = jest.fn().mockResolvedValue({
|
|
18
|
+
status: 200,
|
|
19
|
+
headers: {
|
|
20
|
+
get: jest.fn().mockReturnValue('application/json'),
|
|
21
|
+
},
|
|
22
|
+
body: { pipe: mockPipe },
|
|
23
|
+
});
|
|
24
|
+
fn.__mockPipe = mockPipe;
|
|
25
|
+
return fn;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Mock fs with stream callbacks
|
|
29
|
+
const _mockOnCallbacks = {};
|
|
30
|
+
jest.mock('fs', () => {
|
|
31
|
+
const mockReadStream = {
|
|
32
|
+
on: jest.fn((event, cb) => {
|
|
33
|
+
_mockOnCallbacks[event] = cb;
|
|
34
|
+
return mockReadStream;
|
|
35
|
+
}),
|
|
36
|
+
};
|
|
37
|
+
return {
|
|
38
|
+
createReadStream: jest.fn(() => mockReadStream),
|
|
39
|
+
createWriteStream: jest.fn(() => ({ write: jest.fn(), end: jest.fn() })),
|
|
40
|
+
readFileSync: jest.fn(),
|
|
41
|
+
};
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('src/middleware', () => {
|
|
45
|
+
let req, res, next, nodeFetch;
|
|
46
|
+
const originalEnv = process.env;
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
jest.useFakeTimers();
|
|
50
|
+
process.env = { ...originalEnv };
|
|
51
|
+
nodeFetch = require('node-fetch');
|
|
52
|
+
nodeFetch.mockClear();
|
|
53
|
+
nodeFetch.__mockPipe.mockClear();
|
|
54
|
+
|
|
55
|
+
// Reset stream callbacks
|
|
56
|
+
Object.keys(_mockOnCallbacks).forEach((k) => delete _mockOnCallbacks[k]);
|
|
57
|
+
|
|
58
|
+
req = {
|
|
59
|
+
url: '/_da/chat/send-message',
|
|
60
|
+
method: 'POST',
|
|
61
|
+
body: { message: 'hello' },
|
|
62
|
+
};
|
|
63
|
+
res = {
|
|
64
|
+
send: jest.fn(),
|
|
65
|
+
set: jest.fn(),
|
|
66
|
+
setHeader: jest.fn(),
|
|
67
|
+
write: jest.fn(),
|
|
68
|
+
end: jest.fn(),
|
|
69
|
+
status: jest.fn().mockReturnThis(),
|
|
70
|
+
};
|
|
71
|
+
next = jest.fn();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
jest.useRealTimers();
|
|
76
|
+
process.env = originalEnv;
|
|
77
|
+
jest.restoreAllMocks();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns error when ONYX_API_KEY is missing', async () => {
|
|
81
|
+
delete process.env.ONYX_API_KEY;
|
|
82
|
+
await middleware(req, res, next);
|
|
83
|
+
expect(res.send).toHaveBeenCalledWith({
|
|
84
|
+
error: 'Invalid configuration: missing ONYX api key',
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('proxies POST request with api_key and pipes response', async () => {
|
|
89
|
+
process.env.ONYX_API_KEY = 'test-key';
|
|
90
|
+
process.env.ONYX_URL = 'http://localhost:3000';
|
|
91
|
+
|
|
92
|
+
await middleware(req, res, next);
|
|
93
|
+
|
|
94
|
+
expect(nodeFetch).toHaveBeenCalledWith(
|
|
95
|
+
'http://localhost:3000/api/chat/send-message',
|
|
96
|
+
expect.objectContaining({
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: expect.objectContaining({
|
|
99
|
+
Authorization: 'Bearer test-key',
|
|
100
|
+
}),
|
|
101
|
+
body: JSON.stringify({ message: 'hello' }),
|
|
102
|
+
}),
|
|
103
|
+
{ message: 'hello' },
|
|
104
|
+
);
|
|
105
|
+
expect(res.set).toHaveBeenCalledWith('Content-Type', 'application/json');
|
|
106
|
+
expect(nodeFetch.__mockPipe).toHaveBeenCalledWith(res);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('handles related question paths', async () => {
|
|
110
|
+
process.env.ONYX_API_KEY = 'test-key';
|
|
111
|
+
process.env.ONYX_URL = 'http://localhost:3000';
|
|
112
|
+
req.url = '/_rq/chat/send-message';
|
|
113
|
+
|
|
114
|
+
await middleware(req, res, next);
|
|
115
|
+
|
|
116
|
+
expect(nodeFetch).toHaveBeenCalledWith(
|
|
117
|
+
'http://localhost:3000/api/chat/send-message',
|
|
118
|
+
expect.any(Object),
|
|
119
|
+
expect.anything(),
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('sends error response when fetch throws', async () => {
|
|
124
|
+
process.env.ONYX_API_KEY = 'test-key';
|
|
125
|
+
process.env.ONYX_URL = 'http://localhost:3000';
|
|
126
|
+
|
|
127
|
+
nodeFetch.mockRejectedValueOnce(
|
|
128
|
+
Object.assign(new Error('Network error'), {
|
|
129
|
+
response: { text: 'Connection refused' },
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const consoleSpy = jest
|
|
134
|
+
.spyOn(console, 'error')
|
|
135
|
+
.mockImplementation(() => {});
|
|
136
|
+
await middleware(req, res, next);
|
|
137
|
+
|
|
138
|
+
expect(res.send).toHaveBeenCalledWith({
|
|
139
|
+
error: 'Onyx error: Connection refused',
|
|
140
|
+
});
|
|
141
|
+
consoleSpy.mockRestore();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('handles error without response text', async () => {
|
|
145
|
+
process.env.ONYX_API_KEY = 'test-key';
|
|
146
|
+
process.env.ONYX_URL = 'http://localhost:3000';
|
|
147
|
+
|
|
148
|
+
nodeFetch.mockRejectedValueOnce(new Error('No response'));
|
|
149
|
+
|
|
150
|
+
const consoleSpy = jest
|
|
151
|
+
.spyOn(console, 'error')
|
|
152
|
+
.mockImplementation(() => {});
|
|
153
|
+
await middleware(req, res, next);
|
|
154
|
+
|
|
155
|
+
expect(res.send).toHaveBeenCalledWith({
|
|
156
|
+
error: 'Onyx error: error',
|
|
157
|
+
});
|
|
158
|
+
consoleSpy.mockRestore();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('handles GET requests without body', async () => {
|
|
162
|
+
process.env.ONYX_API_KEY = 'test-key';
|
|
163
|
+
process.env.ONYX_URL = 'http://localhost:3000';
|
|
164
|
+
req.method = 'GET';
|
|
165
|
+
req.url = '/_da/persona/1';
|
|
166
|
+
req.body = null;
|
|
167
|
+
|
|
168
|
+
await middleware(req, res, next);
|
|
169
|
+
|
|
170
|
+
const lastCall = nodeFetch.mock.calls[nodeFetch.mock.calls.length - 1];
|
|
171
|
+
expect(lastCall[1].body).toBeUndefined();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('uses mock create-chat-session when MOCK_LLM_FILE_PATH is set', async () => {
|
|
175
|
+
process.env.ONYX_API_KEY = 'test-key';
|
|
176
|
+
process.env.ONYX_URL = 'http://localhost:3000';
|
|
177
|
+
process.env.MOCK_LLM_FILE_PATH = '/tmp/mock.jsonl';
|
|
178
|
+
req.url = '/_da/chat/create-chat-session';
|
|
179
|
+
|
|
180
|
+
const middlewarePromise = middleware(req, res, next);
|
|
181
|
+
jest.advanceTimersByTime(1000);
|
|
182
|
+
await middlewarePromise.catch(() => {});
|
|
183
|
+
|
|
184
|
+
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/plain');
|
|
185
|
+
expect(res.setHeader).toHaveBeenCalledWith('Transfer-Encoding', 'chunked');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('uses mock send-message with stream when MOCK_LLM_FILE_PATH is set', async () => {
|
|
189
|
+
process.env.ONYX_API_KEY = 'test-key';
|
|
190
|
+
process.env.ONYX_URL = 'http://localhost:3000';
|
|
191
|
+
process.env.MOCK_LLM_FILE_PATH = '/tmp/mock.jsonl';
|
|
192
|
+
req.url = '/_da/chat/send-message';
|
|
193
|
+
|
|
194
|
+
const middlewarePromise = middleware(req, res, next);
|
|
195
|
+
|
|
196
|
+
// Advance past the 2000ms mock delay
|
|
197
|
+
jest.advanceTimersByTime(2000);
|
|
198
|
+
await Promise.resolve();
|
|
199
|
+
await Promise.resolve();
|
|
200
|
+
|
|
201
|
+
const fs = require('fs');
|
|
202
|
+
expect(fs.createReadStream).toHaveBeenCalledWith('/tmp/mock.jsonl', {
|
|
203
|
+
encoding: 'utf8',
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Simulate data event
|
|
207
|
+
if (_mockOnCallbacks.data) {
|
|
208
|
+
_mockOnCallbacks.data('{"ind":1}\n{"ind":2}\n');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Simulate end event
|
|
212
|
+
if (_mockOnCallbacks.end) {
|
|
213
|
+
_mockOnCallbacks.end();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
jest.advanceTimersByTime(5000);
|
|
217
|
+
await middlewarePromise.catch(() => {});
|
|
218
|
+
|
|
219
|
+
expect(res.write).toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
});
|