@eeacms/volto-eea-chatbot 2.0.2 → 2.0.4

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 CHANGED
@@ -4,7 +4,35 @@ All notable changes to this project will be documented in this file. Dates are d
4
4
 
5
5
  Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
6
6
 
7
- ### [2.0.2](https://github.com/eea/volto-eea-chatbot/compare/2.0.1...2.0.2) - 8 April 2026
7
+ ### [2.0.4](https://github.com/eea/volto-eea-chatbot/compare/2.0.3...2.0.4) - 28 May 2026
8
+
9
+ #### :bug: Bug Fixes
10
+
11
+ - fix: Remove console.logs [Alin Voinea - [`fed3b08`](https://github.com/eea/volto-eea-chatbot/commit/fed3b086b35fb74bbf03d39d679c8167d3a2e29b)]
12
+
13
+ #### :hammer_and_wrench: Others
14
+
15
+ - Revert "fixed summary while streaming" [Alin Voinea - [`9c59b72`](https://github.com/eea/volto-eea-chatbot/commit/9c59b7210bd1dc49b61ba90ae7e218b858ebc360)]
16
+ - fixed summary while streaming [Zoltan Szabo - [`3ff7468`](https://github.com/eea/volto-eea-chatbot/commit/3ff74680c716be39de75f5dee2cdf92096edfd5a)]
17
+ ### [2.0.3](https://github.com/eea/volto-eea-chatbot/compare/2.0.2...2.0.3) - 19 May 2026
18
+
19
+ #### :rocket: New Features
20
+
21
+ - feat: integrate Onyx 3.x support [Zoltan Szabo - [`6b0d9d5`](https://github.com/eea/volto-eea-chatbot/commit/6b0d9d5a847995953680a5a9a46281bfc3997b70)]
22
+
23
+ #### :house: Internal changes
24
+
25
+ - style: Automated code fix [eea-jenkins - [`ba195ce`](https://github.com/eea/volto-eea-chatbot/commit/ba195cec917b06faa70211b05c7def1d025d5356)]
26
+
27
+ #### :hammer_and_wrench: Others
28
+
29
+ - fixes for eslint [Zoltan Szabo - [`ec926dd`](https://github.com/eea/volto-eea-chatbot/commit/ec926dd32106105fb87cff226b5f92a495a1eadf)]
30
+ - increase test coverage [Zoltan Szabo - [`d4d1178`](https://github.com/eea/volto-eea-chatbot/commit/d4d1178fd52d215a755480c710b300f71f674f3f)]
31
+ - Rather than dealing with complex and fragile Webpack/Babel config transpilation rules for ES Modules, the cleanest solution is to remove the dependency entirely [Zoltan Szabo - [`3cdedee`](https://github.com/eea/volto-eea-chatbot/commit/3cdedee16ecddf695779b179eb6beeecde16295d)]
32
+ - updated snapshots [Zoltan Szabo - [`58dd3b8`](https://github.com/eea/volto-eea-chatbot/commit/58dd3b8e7d795032338b3580e33cd10c24576911)]
33
+ - fixed jest-addon.config.js [Zoltan Szabo - [`95c4c6f`](https://github.com/eea/volto-eea-chatbot/commit/95c4c6f494c71a9d5e9171b7d56bb1609dccc9f3)]
34
+ - updated dependencies [Zoltan Szabo - [`44e174e`](https://github.com/eea/volto-eea-chatbot/commit/44e174e4c3634d11241f78b46d86deefe0ed6191)]
35
+ ### [2.0.2](https://github.com/eea/volto-eea-chatbot/compare/2.0.1...2.0.2) - 9 April 2026
8
36
 
9
37
  ### [2.0.1](https://github.com/eea/volto-eea-chatbot/compare/2.0.0...2.0.1) - 2 April 2026
10
38
 
@@ -0,0 +1,34 @@
1
+ # Onyx v3 Integration for Volto EEA Chatbot
2
+
3
+ This document summarizes the major architectural changes and features implemented to support **Onyx 3.x** while maintaining full backward compatibility with **Onyx 2.x**.
4
+
5
+ ## 1. Version Switching & Configuration
6
+ - **Onyx Version Toggle**: Added a configuration setting in the Chat Block schema to switch between Onyx 2 and 3.
7
+ - **Dynamic Routing**: The `streamingService` now branches logic based on the selected version, targeting the appropriate API endpoints:
8
+ - **v2**: `/send-message`
9
+ - **v3**: `/send-chat-message`
10
+
11
+ ## 2. Packet-Based Architecture (v3)
12
+ Implemented support for the new Onyx 3.x packet schema, which utilizes turn indices (`ind`) for better synchronization:
13
+ - **New Packet Types**: Integrated support for `search_tool_start`, `search_tool_queries_delta`, `search_tool_documents_delta`, `citation_info`, and `reasoning_done`.
14
+ - **Packet Normalization**: Developed a normalization layer in `streamingService.ts` to map v3 packets into the chatbot's internal `Packet` structure.
15
+ - **Turn Index Mapping**: Updated `MessageProcessor` to use explicit `ind` values from the backend for reliable turn completion detection, replacing the synthetic index fallback used in v2.
16
+
17
+ ## 3. Streaming & UI Visibility
18
+ - **Eager Rendering**: Updated `MultiToolRenderer` and `SearchToolRenderer` to display search queries and documents as they arrive via delta packets, rather than waiting for tool completion.
19
+ - **Improved Context**: Added distinct labels for "Web Queries" and "Internal Search Queries" in the Search Tool UI.
20
+ - **Reasoning Support**: Enhanced `ReasoningRenderer` to handle v3 reasoning packets and provide a smoother transition between "thinking" and "answering" phases.
21
+
22
+ ## 4. Related Questions (RQ) Restoration
23
+ - **Middleware Routing**: Updated the RQ workflow to route through the specialized `/_rq/` middleware proxy for both session creation and message sending.
24
+ - **Dedicated Sessions**: Ensured that Related Questions use a fresh chat session correctly targeting the QGen assistant on the appropriate version backend.
25
+ - **Completion Resiliency**: Patched a race condition where missing `section_end` packets for the main message turn would block RQ interrogation. The system now automatically synthesizes turn completion when the stream ends.
26
+
27
+ ## 5. Middleware & Proxying
28
+ - **Proxy Expansion**: Synchronized `middleware.js` to handle both standard (`/_da/`) and related questions (`/_rq/`) paths for all v3 endpoints.
29
+ - **Payload Alignment**: Updated the payload builders to support v3-specific fields like `alternate_assistant_id`, `file_descriptors`, and `internal_search_filters`.
30
+
31
+ ## 6. Bug Fixes & Stability
32
+ - **Citation Fallback**: Added a fallback for citations in v3 if explicit `citation_info` packets are missing, leveraging the `final_documents` array in `message_start`.
33
+ - **Typewriter Synchronization**: Adjusted `MessageTextRenderer` to coordinate with the new turn-based completion logic, ensuring the UI remains interactive.
34
+ - **Logging & Observability**: Injected comprehensive tracing logs (`[RQ]`, `[sendMessage]`, `[MessageProcessor]`) to monitor the end-to-end flow of packets.
@@ -377,7 +377,7 @@ const getCoveragePatterns = () => {
377
377
  arg === reserved || arg.startsWith(reserved.split('=')[0] + '='),
378
378
  ) &&
379
379
  process.argv.indexOf(arg) >
380
- process.argv.findIndex((item) => item === 'test'),
380
+ process.argv.findIndex((item) => item === 'test'),
381
381
  );
382
382
 
383
383
  if (directoryArg) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-eea-chatbot",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
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",
@@ -1,7 +1,7 @@
1
1
  import type { ChatMessageProps } from '@eeacms/volto-eea-chatbot/ChatBlock/types/interfaces';
2
2
  import { useState, useMemo, useEffect } from 'react';
3
3
  import cx from 'classnames';
4
- import { visit } from 'unist-util-visit';
4
+
5
5
  import loadable from '@loadable/component';
6
6
  import {
7
7
  Tab,
@@ -41,6 +41,17 @@ const HalloumiFeedback: any = loadable(
41
41
  import('@eeacms/volto-eea-chatbot/ChatBlock/components/HalloumiFeedback'),
42
42
  );
43
43
 
44
+ function visit(node: any, type: string, visitor: (node: any, idx?: number, parent?: any) => void, idx?: number, parent?: any) {
45
+ if (node.type === type) {
46
+ visitor(node, idx, parent);
47
+ }
48
+ if (node.children && Array.isArray(node.children)) {
49
+ node.children.forEach((child: any, cidx: number) => {
50
+ visit(child, type, visitor, cidx, node);
51
+ });
52
+ }
53
+ }
54
+
44
55
  function addQualityMarkersPlugin() {
45
56
  return function (tree: any) {
46
57
  visit(tree, 'element', function (node: any) {
@@ -320,8 +331,11 @@ export function AIMessage({
320
331
  if (isFetchingRelatedQuestions || typeof relatedQuestions !== 'undefined') {
321
332
  return;
322
333
  }
323
- if (messageDisplayed && isComplete && onFetchRelatedQuestions) {
324
- onFetchRelatedQuestions();
334
+ if (isLastMessage && isComplete && onFetchRelatedQuestions) {
335
+ console.log(`[AIMessage] Triggering RQ: messageDisplayed=${messageDisplayed}, isComplete=${isComplete}, hasContent=${!!message.message}`);
336
+ if (messageDisplayed) {
337
+ onFetchRelatedQuestions();
338
+ }
325
339
  }
326
340
  }, [
327
341
  messageDisplayed,
@@ -329,6 +343,8 @@ export function AIMessage({
329
343
  isComplete,
330
344
  onFetchRelatedQuestions,
331
345
  isFetchingRelatedQuestions,
346
+ isLastMessage,
347
+ message.message,
332
348
  ]);
333
349
 
334
350
  useEffect(() => {
@@ -48,6 +48,7 @@ interface ChatWindowProps {
48
48
  enableMatomoTracking?: boolean;
49
49
  onDemandInputToggle?: boolean;
50
50
  maxContextSegments?: number;
51
+ onyxVersion?: '2' | '3';
51
52
  isPlaywrightTest?: boolean;
52
53
  [key: string]: any;
53
54
  }
@@ -83,6 +84,7 @@ function ChatWindow({
83
84
  enableMatomoTracking = true,
84
85
  onDemandInputToggle = true,
85
86
  maxContextSegments = 0,
87
+ onyxVersion = '2',
86
88
  } = data;
87
89
  const [qualityCheckEnabled, setQualityCheckEnabled] = useState(
88
90
  onDemandInputToggle ?? true,
@@ -117,6 +119,7 @@ function ChatWindow({
117
119
  qgenAsistantId,
118
120
  enableQgen,
119
121
  deepResearch,
122
+ onyxVersion,
120
123
  });
121
124
 
122
125
  const [showLandingPage, setShowLandingPage] = useState(true);
@@ -13,6 +13,7 @@ interface UseChatControllerProps {
13
13
  enableQgen?: boolean;
14
14
  qgenAsistantId?: number;
15
15
  deepResearch?: string;
16
+ onyxVersion?: '2' | '3';
16
17
  }
17
18
 
18
19
  interface RelatedQuestion {
@@ -21,14 +22,42 @@ interface RelatedQuestion {
21
22
 
22
23
  // Extract JSON array from related questions response
23
24
  function extractRelatedQuestions(str: string): RelatedQuestion[] {
24
- if (str.toLowerCase().includes('no_response')) {
25
- throw new Error('Related questions were not generated properly');
25
+ if (!str || str.toLowerCase().includes('no_response')) {
26
+ return [];
27
+ }
28
+
29
+ // Try to parse as JSON first if it looks like JSON
30
+ const trimmed = str.trim();
31
+ if (trimmed.startsWith('[') || trimmed.startsWith('{')) {
32
+ try {
33
+ const parsed = JSON.parse(trimmed);
34
+ const items = Array.isArray(parsed) ? parsed : parsed.questions || [];
35
+ if (Array.isArray(items)) {
36
+ return items
37
+ .map((item) => {
38
+ if (typeof item === 'string') return { question: item };
39
+ if (item && typeof item === 'object' && item.question)
40
+ return { question: item.question };
41
+ return null;
42
+ })
43
+ .filter((i): i is RelatedQuestion => i !== null);
44
+ }
45
+ } catch (e) {
46
+ // Fallback to line parsing
47
+ }
26
48
  }
27
49
 
50
+ // Fallback: split by lines and clean up common list formats
28
51
  return str
29
52
  .split('\n')
30
- .filter((line) => line.trim())
31
- .map((question) => ({ question }));
53
+ .map((line) => line.trim())
54
+ .filter((line) => line.length > 0)
55
+ .map((line) => {
56
+ // Remove leading numbers or bullets like "1. ", "- ", "* ", etc.
57
+ const cleaned = line.replace(/^[\d\.\-\*\s]+/, '').trim();
58
+ return cleaned ? { question: cleaned } : null;
59
+ })
60
+ .filter((i): i is RelatedQuestion => i !== null);
32
61
  }
33
62
 
34
63
  // Fetch related questions using the qgen assistant
@@ -36,12 +65,16 @@ async function fetchRelatedQuestions(
36
65
  query: string,
37
66
  answer: string,
38
67
  qgenAsistantId: number,
68
+ onyxVersion: '2' | '3' = '2',
39
69
  ): Promise<RelatedQuestion[]> {
40
70
  try {
71
+ console.log(`[RQ] Creating session for assistant ${qgenAsistantId} (Onyx v${onyxVersion})`);
41
72
  const chatSessionId = await createChatSession(
42
73
  qgenAsistantId,
43
74
  `Q: ${query}`,
75
+ true,
44
76
  );
77
+ console.log(`[RQ] Session created: ${chatSessionId}`);
45
78
 
46
79
  const params = {
47
80
  message: `Question: ${query}\nAnswer:\n${answer}`,
@@ -49,23 +82,35 @@ async function fetchRelatedQuestions(
49
82
  fileDescriptors: [],
50
83
  parentMessageId: null,
51
84
  chatSessionId,
52
- promptId: 0,
53
85
  filters: null,
54
86
  selectedDocumentIds: [],
55
87
  use_agentic_search: false,
56
88
  regenerate: false,
89
+ onyxVersion,
57
90
  };
58
91
 
92
+ if (onyxVersion === '3') {
93
+ console.log('[Onyx v3] Sending RQ prompt:', params.message);
94
+ }
95
+
59
96
  let result = '';
60
97
  for await (const packets of sendMessage(params, true)) {
61
98
  for (const packet of packets) {
62
- if (packet.obj.type === PacketType.MESSAGE_DELTA) {
63
- result += packet.obj.content;
99
+ if (onyxVersion === '3') {
100
+ // console.log('[Onyx v3] RQ Packet:', packet);
101
+ }
102
+ if (
103
+ packet.obj.type === PacketType.MESSAGE_DELTA ||
104
+ packet.obj.type === PacketType.MESSAGE_START
105
+ ) {
106
+ result += (packet.obj as any).content || '';
64
107
  }
65
108
  }
66
109
  }
67
-
68
- return extractRelatedQuestions(result);
110
+ console.log(`[RQ] Final response string: "${result}"`);
111
+ const extracted = extractRelatedQuestions(result);
112
+ console.log(`[RQ] Extracted ${extracted.length} questions`);
113
+ return extracted;
69
114
  } catch (error) {
70
115
  console.error('Error fetching related questions:', error);
71
116
  return [];
@@ -77,6 +122,7 @@ export function useChatController({
77
122
  enableQgen = false,
78
123
  qgenAsistantId,
79
124
  deepResearch,
125
+ onyxVersion = '2',
80
126
  }: UseChatControllerProps) {
81
127
  const [messages, setMessages] = useState<Message[]>([]);
82
128
  const [chatSessionId, setChatSessionId] = useState<string | null>(null);
@@ -229,6 +275,7 @@ export function useChatController({
229
275
  regenerate: false,
230
276
  filters: null,
231
277
  selectedDocumentIds: [],
278
+ onyxVersion,
232
279
  },
233
280
  assistantNodeId,
234
281
  userNodeId,
@@ -250,6 +297,7 @@ export function useChatController({
250
297
  );
251
298
 
252
299
  const onFetchRelatedQuestions = useCallback(async () => {
300
+ console.log('[RQ] onFetchRelatedQuestions triggered');
253
301
  const latestAssistantMessage = messages
254
302
  .filter((m) => m.type === 'assistant')
255
303
  .pop();
@@ -259,6 +307,7 @@ export function useChatController({
259
307
  qgenAsistantId &&
260
308
  latestAssistantMessage?.type === 'assistant'
261
309
  ) {
310
+ console.log(`[RQ] Criteria met: assistantNodeId=${latestAssistantMessage.nodeId}, qgenAssistant=${qgenAsistantId}`);
262
311
  if (isDeepResearchEnabled) {
263
312
  setMessages((prev) => {
264
313
  return prev.map((m) =>
@@ -284,6 +333,7 @@ export function useChatController({
284
333
  userMessage.message,
285
334
  latestAssistantMessage.message,
286
335
  qgenAsistantId,
336
+ onyxVersion,
287
337
  );
288
338
  }
289
339
  } catch (error) {
@@ -299,7 +349,7 @@ export function useChatController({
299
349
  setIsFetchingRelatedQuestions(false);
300
350
  }
301
351
  }
302
- }, [messages, enableQgen, qgenAsistantId, isDeepResearchEnabled]);
352
+ }, [messages, enableQgen, qgenAsistantId, isDeepResearchEnabled, onyxVersion]);
303
353
 
304
354
  const clearChat = useCallback(() => {
305
355
  setMessages([]);
@@ -76,5 +76,6 @@ export function useToolDisplayTiming(
76
76
  visibleTools,
77
77
  handleToolComplete,
78
78
  allToolsDisplayed,
79
+ toolStates,
79
80
  };
80
81
  }
@@ -19,7 +19,7 @@ interface MultiToolRendererProps {
19
19
 
20
20
  export function MultiToolRenderer({
21
21
  toolGroups,
22
- showTools = [PacketType.SEARCH_TOOL_START],
22
+ showTools = [PacketType.SEARCH_TOOL_START, PacketType.REASONING_START],
23
23
  message,
24
24
  libs,
25
25
  onAllToolsDisplayed,
@@ -28,22 +28,33 @@ export function MultiToolRenderer({
28
28
  const { isFinalMessageComing = false, isComplete = false } = message;
29
29
 
30
30
  // Filter tool groups based on allowed tool types
31
- const filteredToolGroups = useMemo(
32
- () =>
33
- toolGroups.filter((group) =>
34
- group.packets?.some((packet) =>
35
- showTools?.includes(packet.obj.type as PacketType),
36
- ),
31
+ const filteredToolGroups = useMemo(() => {
32
+ const expandedShowTools = [...(showTools || [])];
33
+ if (showTools?.includes(PacketType.SEARCH_TOOL_START)) {
34
+ expandedShowTools.push(
35
+ PacketType.SEARCH_TOOL_START_V3,
36
+ PacketType.SEARCH_TOOL_QUERIES_DELTA,
37
+ PacketType.SEARCH_TOOL_DOCUMENTS_DELTA,
38
+ PacketType.SEARCH_TOOL_DELTA,
39
+ );
40
+ }
41
+ if (showTools?.includes(PacketType.REASONING_START)) {
42
+ expandedShowTools.push(
43
+ PacketType.REASONING_DELTA,
44
+ PacketType.REASONING_DONE,
45
+ PacketType.REASONING_END as any,
46
+ );
47
+ }
48
+ return toolGroups.filter((group) =>
49
+ group.packets?.some((packet) =>
50
+ expandedShowTools.includes(packet.obj.type as PacketType),
37
51
  ),
38
- [toolGroups, showTools],
39
- );
52
+ );
53
+ }, [toolGroups, showTools]);
40
54
 
41
55
  // Manage tool display timing
42
- const { allToolsDisplayed, handleToolComplete } = useToolDisplayTiming(
43
- filteredToolGroups,
44
- isFinalMessageComing,
45
- isComplete,
46
- );
56
+ const { allToolsDisplayed, handleToolComplete, toolStates } =
57
+ useToolDisplayTiming(filteredToolGroups, isFinalMessageComing, isComplete);
47
58
 
48
59
  // Notify parent when all tools are displayed
49
60
  useEffect(() => {
@@ -64,27 +75,29 @@ export function MultiToolRenderer({
64
75
 
65
76
  if (filteredToolGroups.length === 0) return null;
66
77
 
67
- const isStreaming = !allToolsDisplayed;
78
+ const isOverallStreaming = !allToolsDisplayed;
68
79
 
69
80
  const count = filteredToolGroups.length;
70
81
 
71
- const ariaLabel = `${count} ${isStreaming ? 'processing' : 'completed'} ${
72
- count === 1 ? 'step' : 'steps'
73
- }, ${isExpanded ? 'expanded' : 'collapsed'}`;
82
+ const ariaLabel = `${count} ${
83
+ isOverallStreaming ? 'processing' : 'completed'
84
+ } ${count === 1 ? 'step' : 'steps'}, ${isExpanded ? 'expanded' : 'collapsed'}`;
74
85
 
75
86
  return (
76
87
  <div
77
88
  className={cx('multi-tool-renderer', {
78
- streaming: isStreaming,
79
- complete: !isStreaming,
89
+ streaming: isOverallStreaming,
90
+ complete: !isOverallStreaming,
80
91
  })}
81
92
  >
82
93
  {/* Header */}
83
- <div className={cx({ 'tools-container collapsed-view': isStreaming })}>
94
+ <div
95
+ className={cx({ 'tools-container collapsed-view': isOverallStreaming })}
96
+ >
84
97
  <div
85
98
  className={cx({
86
- 'tools-collapsed-header': isStreaming,
87
- 'tools-summary-header': !isStreaming,
99
+ 'tools-collapsed-header': isOverallStreaming,
100
+ 'tools-summary-header': !isOverallStreaming,
88
101
  })}
89
102
  onClick={toggleExpanded}
90
103
  role="button"
@@ -109,21 +122,25 @@ export function MultiToolRenderer({
109
122
  {/* Tools List */}
110
123
  <div
111
124
  className={cx({
112
- 'tools-collapsed-list': isStreaming,
113
- 'tools-expanded-content': !isStreaming,
114
- expanded: isExpanded && isStreaming,
115
- visible: isExpanded && !isStreaming,
125
+ 'tools-collapsed-list': isOverallStreaming,
126
+ 'tools-expanded-content': !isOverallStreaming,
127
+ expanded: isExpanded && isOverallStreaming,
128
+ visible: isExpanded && !isOverallStreaming,
116
129
  })}
117
130
  >
118
- <div className={cx({ 'tools-list': isStreaming })}>
131
+ <div className={cx({ 'tools-list': isOverallStreaming })}>
119
132
  <div>
120
133
  {filteredToolGroups.map((toolGroup, index) => {
121
134
  const isLastItem = index === filteredToolGroups.length - 1;
135
+ const toolState = toolStates.get(toolGroup.ind);
136
+ const isToolCompleted = toolState?.isCompleted;
122
137
 
123
138
  return (
124
139
  <div
125
140
  key={toolGroup.ind}
126
- className={cx({ 'tool-collaps ed-wrapper': isStreaming })}
141
+ className={cx({
142
+ 'tool-collapsed-wrapper': isOverallStreaming,
143
+ })}
127
144
  >
128
145
  <RendererComponent
129
146
  packets={toolGroup.packets}
@@ -143,32 +160,46 @@ export function MultiToolRenderer({
143
160
  ) : (
144
161
  <span
145
162
  className={cx({
146
- 'tool-icon-dot': isStreaming,
147
- 'tool-icon-default': !isStreaming,
163
+ 'tool-icon-dot': isOverallStreaming,
164
+ 'tool-icon-default': !isOverallStreaming,
148
165
  })}
149
166
  />
150
167
  );
151
168
 
152
- // Streaming: collapsed view (status only)
153
- if (isStreaming) {
154
- return (
155
- <div
156
- className={cx('tool-item-collapsed', {
157
- active: isLastItem,
158
- completed: !isLastItem,
159
- })}
160
- >
161
- <div className="tool-collapsed-icon">
162
- {finalIcon}
163
- </div>
164
- <span className="tool-collapsed-status">
165
- {status}
166
- </span>
167
- </div>
169
+ // If tool is not completed and we are overall streaming, show collapsed view
170
+ // EXCEPT for reasoning and search which we want to see while they stream/progress
171
+ if (isOverallStreaming && !isToolCompleted) {
172
+ const isDetailedTool = toolGroup.packets.some(
173
+ (p) =>
174
+ p.obj.type === PacketType.REASONING_START ||
175
+ p.obj.type === PacketType.REASONING_DELTA ||
176
+ p.obj.type === PacketType.SEARCH_TOOL_START ||
177
+ p.obj.type === PacketType.SEARCH_TOOL_START_V3 ||
178
+ p.obj.type === PacketType.SEARCH_TOOL_DELTA ||
179
+ p.obj.type === PacketType.SEARCH_TOOL_QUERIES_DELTA ||
180
+ p.obj.type === PacketType.SEARCH_TOOL_DOCUMENTS_DELTA,
168
181
  );
182
+
183
+ if (!isDetailedTool || !content) {
184
+ return (
185
+ <div
186
+ className={cx('tool-item-collapsed', {
187
+ active: isLastItem,
188
+ completed: isToolCompleted,
189
+ })}
190
+ >
191
+ <div className="tool-collapsed-icon">
192
+ {finalIcon}
193
+ </div>
194
+ <span className="tool-collapsed-status">
195
+ {status}
196
+ </span>
197
+ </div>
198
+ );
199
+ }
169
200
  }
170
201
 
171
- // Complete: expanded view (full content)
202
+ // Expanded view (full content) - used for completed tools or when overall complete
172
203
  return (
173
204
  <div className="tool-item-expanded">
174
205
  <div className="tool-connector-line" />
@@ -26,7 +26,10 @@ function isChatPacket(packet: Packet): boolean {
26
26
  }
27
27
 
28
28
  function isSearchToolPacket(packet: Packet): boolean {
29
- return packet.obj.type === PacketType.SEARCH_TOOL_START;
29
+ return (
30
+ packet.obj.type === PacketType.SEARCH_TOOL_START ||
31
+ packet.obj.type === PacketType.SEARCH_TOOL_START_V3
32
+ );
30
33
  }
31
34
 
32
35
  function isImageToolPacket(packet: Packet): boolean {
@@ -44,7 +47,9 @@ function isFetchToolPacket(packet: Packet): boolean {
44
47
  function isReasoningPacket(packet: Packet): boolean {
45
48
  return (
46
49
  packet.obj.type === PacketType.REASONING_START ||
47
- packet.obj.type === PacketType.REASONING_DELTA
50
+ packet.obj.type === PacketType.REASONING_DELTA ||
51
+ packet.obj.type === PacketType.REASONING_DONE ||
52
+ packet.obj.type === PacketType.REASONING_END
48
53
  );
49
54
  }
50
55
 
@@ -90,7 +90,11 @@ export const MessageTextRenderer: MessageRenderer<ChatPacket> = ({
90
90
  // If we're far behind, catch up faster
91
91
  const increment =
92
92
  remaining > CATCH_UP_THRESHOLD ? PACKETS_PER_TICK : 1;
93
- return Math.min(prev + increment, packets.length);
93
+ const next = Math.min(prev + increment, packets.length);
94
+ if (isStreamFinished && next === packets.length) {
95
+ console.log(`[MessageTextRenderer] Animation finished: ${next}/${packets.length}`);
96
+ }
97
+ return next;
94
98
  });
95
99
  }, PACKET_DELAY_MS);
96
100
 
@@ -121,6 +125,7 @@ export const MessageTextRenderer: MessageRenderer<ChatPacket> = ({
121
125
  ) {
122
126
  return;
123
127
  }
128
+ console.log(`[MessageTextRenderer] Calling onComplete: packets=${packets.length}, finished=${isStreamFinished}`);
124
129
  onComplete();
125
130
  }
126
131
  }, [
@@ -21,6 +21,7 @@ function constructCurrentReasoningState(packets: ReasoningPacket[]) {
21
21
  const hasEnd = packets.some(
22
22
  (p) =>
23
23
  p.obj.type === PacketType.SECTION_END ||
24
+ p.obj.type === PacketType.REASONING_DONE ||
24
25
  // Support either convention for reasoning completion
25
26
  (p.obj as any).type === PacketType.REASONING_END,
26
27
  );
@@ -55,26 +55,33 @@ const constructCurrentSearchState = (
55
55
  isInternetSearch: boolean;
56
56
  } => {
57
57
  const searchStart = packets.find(
58
- (packet) => packet.obj.type === PacketType.SEARCH_TOOL_START,
58
+ (packet) =>
59
+ packet.obj.type === PacketType.SEARCH_TOOL_START ||
60
+ packet.obj.type === PacketType.SEARCH_TOOL_START_V3,
59
61
  )?.obj as SearchToolStart | null;
60
62
 
61
63
  const searchDeltas = packets
62
- .filter((packet) => packet.obj.type === PacketType.SEARCH_TOOL_DELTA)
63
- .map((packet) => packet.obj as SearchToolDelta);
64
+ .filter(
65
+ (packet) =>
66
+ packet.obj.type === PacketType.SEARCH_TOOL_DELTA ||
67
+ packet.obj.type === PacketType.SEARCH_TOOL_QUERIES_DELTA ||
68
+ packet.obj.type === PacketType.SEARCH_TOOL_DOCUMENTS_DELTA,
69
+ )
70
+ .map((packet) => packet.obj);
64
71
 
65
72
  const searchEnd = packets.find(
66
73
  (packet) => packet.obj.type === PacketType.SECTION_END,
67
74
  )?.obj as SectionEnd | null;
68
75
 
69
- // Extract queries from ToolDelta packets
76
+ // Extract queries from various delta packets
70
77
  const queries = searchDeltas
71
- .flatMap((delta) => delta?.queries || [])
78
+ .flatMap((delta: any) => delta?.queries || [])
72
79
  .filter((query, index, arr) => arr.indexOf(query) === index); // Remove duplicates
73
80
 
74
81
  const seenDocIds = new Set<string>();
75
82
  const results = searchDeltas
76
- .flatMap((delta) => delta?.documents || [])
77
- .filter((doc) => {
83
+ .flatMap((delta: any) => delta?.documents || [])
84
+ .filter((doc: OnyxDocument) => {
78
85
  if (!doc || !doc.document_id) return false;
79
86
  if (seenDocIds.has(doc.document_id)) return false;
80
87
  seenDocIds.add(doc.document_id);
@@ -202,8 +209,8 @@ export const SearchToolRenderer: MessageRenderer<SearchToolPacket> = ({
202
209
  <SVGIcon name={isInternetSearch ? GlobeIcon : SearchIcon} size={size} />
203
210
  );
204
211
 
205
- // Don't render anything if search hasn't started
206
- if (queries.length === 0) {
212
+ // Don't render anything if search hasn't started or has no data yet
213
+ if (queries.length === 0 && results.length === 0) {
207
214
  return children({
208
215
  icon: IconComponent,
209
216
  status: status,
@@ -218,7 +225,9 @@ export const SearchToolRenderer: MessageRenderer<SearchToolPacket> = ({
218
225
  <div className="search-tool-renderer">
219
226
  <div className="queries-section">
220
227
  <div className="queries-header">
221
- <strong>Queries</strong>
228
+ <strong>
229
+ {isInternetSearch ? 'Web Queries' : 'Internal Search Queries'}
230
+ </strong>
222
231
  </div>
223
232
  <div className="queries-list">
224
233
  {queries.slice(0, queriesToShow).map((query, index) => (
@@ -262,7 +271,7 @@ export const SearchToolRenderer: MessageRenderer<SearchToolPacket> = ({
262
271
 
263
272
  <div className="results-section">
264
273
  <div className="results-header">
265
- <strong>{isInternetSearch ? 'Results' : 'Documents'}</strong>
274
+ <strong>{isInternetSearch ? 'Web Results' : 'Documents'}</strong>
266
275
  </div>
267
276
 
268
277
  <div className="results-list">
@@ -104,6 +104,7 @@ export function ChatBlockSchema({ assistants, data }) {
104
104
  'scrollToInput',
105
105
  'showAssistantTitle',
106
106
  'showAssistantDescription',
107
+ 'onyxVersion',
107
108
  ],
108
109
  },
109
110
  ],
@@ -368,6 +369,18 @@ range is from 0 to 100`,
368
369
  type: 'boolean',
369
370
  default: true,
370
371
  },
372
+ onyxVersion: {
373
+ title: 'Onyx API version',
374
+ choices: [
375
+ ['2', 'Onyx 2.x'],
376
+ ['3', 'Onyx 3.x'],
377
+ ],
378
+ default: '2',
379
+ description:
380
+ 'Select which Onyx API version the backend is running. ' +
381
+ 'Onyx 2.x uses the legacy send-message payload; ' +
382
+ 'Onyx 3.x uses the new send-message payload with placement-based streaming.',
383
+ },
371
384
  showAssistantPrompts: {
372
385
  title: 'Show predefined prompts',
373
386
  type: 'boolean',
@@ -111,17 +111,26 @@ export class MessageProcessor {
111
111
  this.indicesStarted.push(packet.ind);
112
112
  }
113
113
 
114
- // Send synthetic SECTION_END when needed
115
- if (
116
- packet.obj.type === PacketType.SECTION_END &&
117
- this.indicesStarted.length > 0
118
- ) {
119
- processedPacket = getSynteticPacket(
120
- this.indicesStarted.shift()!,
121
- PacketType.SECTION_END,
122
- );
123
- } else if (packet.obj.type === PacketType.SECTION_END) {
124
- return;
114
+ // Handle SECTION_END (v2/v3 compatible)
115
+ if (packet.obj.type === PacketType.SECTION_END) {
116
+ let targetInd = packet.ind;
117
+
118
+ // If ind is -1 (common in v2), use synthetic logic
119
+ if (targetInd === -1 && this.indicesStarted.length > 0) {
120
+ targetInd = this.indicesStarted.shift()!;
121
+ } else if (targetInd !== -1) {
122
+ // If ind is provided (v3), remove it from indicesStarted if present
123
+ const startIdx = this.indicesStarted.indexOf(targetInd);
124
+ if (startIdx > -1) {
125
+ this.indicesStarted.splice(startIdx, 1);
126
+ }
127
+ }
128
+
129
+ if (targetInd === -1) {
130
+ return; // Skip if we can't map it to a turn
131
+ }
132
+
133
+ processedPacket = getSynteticPacket(targetInd, PacketType.SECTION_END);
125
134
  }
126
135
 
127
136
  const { ind } = processedPacket;
@@ -129,6 +138,10 @@ export class MessageProcessor {
129
138
  // Store processed packet for later aggregation
130
139
  this.packets.push(processedPacket);
131
140
 
141
+ if (processedPacket.obj.type === PacketType.MESSAGE_START || processedPacket.obj.type === PacketType.SECTION_END) {
142
+ console.log(`[MessageProcessor] Processed ${processedPacket.obj.type} for ind=${processedPacket.ind}`);
143
+ }
144
+
132
145
  // Group packets by index for later processing
133
146
  if (!this.groupedPackets.has(ind)) {
134
147
  this.groupedPackets.set(ind, []);
@@ -174,6 +187,7 @@ export class MessageProcessor {
174
187
  PacketType.MESSAGE_START,
175
188
  PacketType.SEARCH_TOOL_DELTA,
176
189
  PacketType.FETCH_TOOL_START,
190
+ PacketType.SEARCH_TOOL_DOCUMENTS_DELTA,
177
191
  ].includes(packet.obj.type)
178
192
  ) {
179
193
  return;
@@ -181,7 +195,8 @@ export class MessageProcessor {
181
195
  let newDocuments = false;
182
196
  const data = packet.obj as any;
183
197
  const documents = data.final_documents || data.documents;
184
- if (documents) {
198
+
199
+ if (documents && Array.isArray(documents)) {
185
200
  documents.forEach((doc: OnyxDocument) => {
186
201
  const docId = doc.document_id;
187
202
  if (docId && !this.documentMap.has(docId)) {
@@ -190,8 +205,23 @@ export class MessageProcessor {
190
205
  }
191
206
  });
192
207
  }
208
+
193
209
  if (newDocuments) {
194
210
  this._documents = Array.from(this.documentMap.values());
211
+
212
+ // If we have final_documents and no citations yet, create a fallback mapping
213
+ // This ensures the Sources tab shows up in v3 when citation_info is missing
214
+ if (
215
+ packet.obj.type === PacketType.MESSAGE_START &&
216
+ data.final_documents &&
217
+ this._citations.size === 0
218
+ ) {
219
+ data.final_documents.forEach((doc: OnyxDocument, index: number) => {
220
+ if (doc.document_id) {
221
+ this._citations.set(index + 1, doc.document_id);
222
+ }
223
+ });
224
+ }
195
225
  }
196
226
  }
197
227
 
@@ -200,6 +230,16 @@ export class MessageProcessor {
200
230
  * Updates the internal citation collection and notifies when new citations are added
201
231
  */
202
232
  private processCitations(packet: Packet) {
233
+ if (packet.obj.type === PacketType.CITATION_INFO) {
234
+ const citationInfo = packet.obj as any;
235
+ if (citationInfo.citation_number && citationInfo.document_id) {
236
+ this._citations.set(
237
+ citationInfo.citation_number,
238
+ citationInfo.document_id,
239
+ );
240
+ }
241
+ return;
242
+ }
203
243
  if (packet.obj.type !== PacketType.CITATION_DELTA) {
204
244
  return;
205
245
  }
@@ -237,6 +277,13 @@ export class MessageProcessor {
237
277
  */
238
278
  private processStreamEnd(packet: Packet) {
239
279
  if ([PacketType.STOP, PacketType.ERROR].includes(packet.obj.type)) {
280
+ // Close any remaining open sections (especially the last one)
281
+ while (this.indicesStarted.length > 0) {
282
+ const ind = this.indicesStarted.shift()!;
283
+ console.log(`[MessageProcessor] Stream ended. Synthesizing section_end for ind=${ind}`);
284
+ const synthetic = getSynteticPacket(ind, PacketType.SECTION_END);
285
+ this.processPacket(synthetic);
286
+ }
240
287
  this._isComplete = true;
241
288
  }
242
289
  }
@@ -248,7 +295,9 @@ export class MessageProcessor {
248
295
  private extractToolCall(packets: Packet[]): ToolCallMetadata | null {
249
296
  // Look for search tool packets
250
297
  const searchToolStart = packets.find(
251
- (p) => p.obj.type === PacketType.SEARCH_TOOL_START,
298
+ (p) =>
299
+ p.obj.type === PacketType.SEARCH_TOOL_START ||
300
+ p.obj.type === PacketType.SEARCH_TOOL_START_V3,
252
301
  );
253
302
 
254
303
  if (!searchToolStart) {
@@ -260,7 +309,10 @@ export class MessageProcessor {
260
309
  const processedDocs: Record<string, any> = {};
261
310
 
262
311
  for (const packet of packets) {
263
- if (packet.obj.type === PacketType.SEARCH_TOOL_DELTA) {
312
+ if (
313
+ packet.obj.type === PacketType.SEARCH_TOOL_DELTA ||
314
+ packet.obj.type === PacketType.SEARCH_TOOL_DOCUMENTS_DELTA
315
+ ) {
264
316
  const delta = packet.obj as any;
265
317
  if (delta.documents && Array.isArray(delta.documents)) {
266
318
  delta.documents.forEach((doc: any) => {
@@ -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
  }
@@ -30,6 +30,7 @@ export interface SendMessageParams {
30
30
  enabledToolIds?: number[];
31
31
  forcedToolIds?: number[];
32
32
  retrieval_options?: any;
33
+ onyxVersion?: '2' | '3';
33
34
  }
34
35
 
35
36
  export interface StreamResponse {
@@ -90,11 +91,131 @@ export const processRawChunkString = (
90
91
  return [parsedChunkSections, currPartialChunk];
91
92
  };
92
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
+
93
213
  /**
94
214
  * Handle streaming response from the backend
95
215
  */
96
216
  export async function* handleStream(
97
217
  streamingResponse: Response,
218
+ onyxVersion: '2' | '3' = '2',
98
219
  ): AsyncGenerator<Packet[], void, unknown> {
99
220
  const reader = streamingResponse.body?.getReader();
100
221
  if (!reader) {
@@ -120,6 +241,10 @@ export async function* handleStream(
120
241
  previousPartialChunk,
121
242
  );
122
243
 
244
+ if (onyxVersion === '3' && completedChunks.length > 0) {
245
+ // console.log('[Onyx v3] Raw completed chunks:', completedChunks);
246
+ }
247
+
123
248
  if (!completedChunks.length && !partialChunk) {
124
249
  break;
125
250
  }
@@ -130,6 +255,10 @@ export async function* handleStream(
130
255
  const packets: Packet[] = completedChunks
131
256
  .filter((chunk) => chunk && typeof chunk === 'object')
132
257
  .map((chunk) => {
258
+ if (onyxVersion === '3') {
259
+ return normaliseV3Chunk(chunk);
260
+ }
261
+
133
262
  // Onyx v2 format: { ind: number, obj: { type: string, ... } }
134
263
  if ('ind' in chunk && 'obj' in chunk) {
135
264
  return chunk as Packet;
@@ -173,77 +302,24 @@ export async function* handleStream(
173
302
  * Send a message and stream the response
174
303
  */
175
304
  export async function* sendMessage(
176
- {
177
- regenerate,
178
- retrieval_options,
179
- message,
180
- fileDescriptors,
181
- currentMessageFiles,
182
- parentMessageId,
183
- chatSessionId,
184
- filters,
185
- selectedDocumentIds,
186
- queryOverride,
187
- forceSearch,
188
- modelProvider,
189
- modelVersion,
190
- temperature,
191
- systemPromptOverride,
192
- taskPromptOverride,
193
- useExistingUserMessage,
194
- alternateAssistantId,
195
- signal,
196
- useAgentSearch,
197
- enabledToolIds,
198
- forcedToolIds,
199
- }: SendMessageParams,
305
+ params: SendMessageParams,
200
306
  isRelatedQuestion: boolean = false,
201
307
  ): AsyncGenerator<Packet[], void, unknown> {
202
- const documentsAreSelected =
203
- selectedDocumentIds && selectedDocumentIds.length > 0;
308
+ const { onyxVersion = '2', signal } = params;
204
309
 
205
- const payload = {
206
- alternate_assistant_id: alternateAssistantId,
207
- chat_session_id: chatSessionId,
208
- parent_message_id: parentMessageId,
209
- message,
210
- prompt_id: null,
211
- search_doc_ids: documentsAreSelected ? selectedDocumentIds : null,
212
- file_descriptors: fileDescriptors,
213
- current_message_files: currentMessageFiles,
214
- regenerate,
215
- retrieval_options:
216
- retrieval_options ??
217
- (!documentsAreSelected
218
- ? {
219
- run_search: queryOverride || forceSearch ? 'always' : 'auto',
220
- real_time: true,
221
- filters: filters,
222
- }
223
- : null),
224
- query_override: queryOverride,
225
- prompt_override: {
226
- ...(systemPromptOverride ? { system_prompt: systemPromptOverride } : {}),
227
- ...(taskPromptOverride ? { task_prompt: taskPromptOverride } : {}),
228
- },
229
- llm_override:
230
- temperature || modelVersion
231
- ? {
232
- temperature,
233
- model_provider: modelProvider,
234
- model_version: modelVersion,
235
- }
236
- : null,
237
- use_existing_user_message: useExistingUserMessage,
238
- use_agentic_search: useAgentSearch ?? false,
239
- allowed_tool_ids: enabledToolIds,
240
- forced_tool_ids: forcedToolIds,
241
- };
310
+ const payload =
311
+ onyxVersion === '3' ? buildPayloadV3(params) : buildPayloadV2(params);
242
312
 
243
313
  const body = JSON.stringify(payload);
244
314
 
245
315
  const middleware = isRelatedQuestion ? '_rq' : '_da';
246
- 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}`, {
247
323
  method: 'POST',
248
324
  headers: {
249
325
  'Content-Type': 'application/json',
@@ -258,7 +334,7 @@ export async function* sendMessage(
258
334
  throw new Error(`Failed to send message - ${errorMsg}`);
259
335
  }
260
336
 
261
- yield* handleStream(sendMessageResponse);
337
+ yield* handleStream(sendMessageResponse, onyxVersion);
262
338
  }
263
339
 
264
340
  /**
@@ -267,8 +343,13 @@ export async function* sendMessage(
267
343
  export async function createChatSession(
268
344
  personaId: number,
269
345
  description?: string,
346
+ isRelatedQuestion: boolean = false,
270
347
  ): Promise<string> {
271
- 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, {
272
353
  method: 'POST',
273
354
  headers: {
274
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 {
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
  });