@eeacms/volto-eea-chatbot 3.0.1 → 4.1.0

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 (33) hide show
  1. package/CHANGELOG.md +53 -4
  2. package/README.md +115 -12
  3. package/package.json +1 -1
  4. package/src/ChatBlock/ChatBlockView.jsx +7 -1
  5. package/src/ChatBlock/chat/AIMessage.tsx +79 -45
  6. package/src/ChatBlock/chat/ChatMessage.tsx +17 -10
  7. package/src/ChatBlock/chat/ChatWindow.tsx +11 -0
  8. package/src/ChatBlock/chat/MessageContext.ts +12 -0
  9. package/src/ChatBlock/chat/index.ts +1 -0
  10. package/src/ChatBlock/components/markdown/ClaimModal.jsx +3 -3
  11. package/src/ChatBlock/components/markdown/index.jsx +1 -0
  12. package/src/ChatBlock/hooks/useQualityMarkers.js +21 -4
  13. package/src/ChatBlock/index.js +9 -1
  14. package/src/ChatBlock/packets/RendererComponent.tsx +9 -0
  15. package/src/ChatBlock/packets/renderers/MessageTextRenderer.tsx +15 -3
  16. package/src/ChatBlock/schema.jsx +13 -0
  17. package/src/ChatBlock/services/messageProcessor.ts +9 -12
  18. package/src/ChatBlock/types/interfaces.ts +25 -0
  19. package/src/ChatBlock/utils/index.tsx +10 -0
  20. package/src/ChatBlock/variations.ts +44 -0
  21. package/src/halloumi/README.md +27 -1
  22. package/src/halloumi/middleware.js +41 -50
  23. package/src/halloumi/middleware.test.js +102 -103
  24. package/src/halloumi/filtering.js +0 -149
  25. package/src/halloumi/filtering.test.js +0 -242
  26. package/src/halloumi/generative.js +0 -324
  27. package/src/halloumi/generative.test.js +0 -386
  28. package/src/halloumi/markdown-splitter.js +0 -172
  29. package/src/halloumi/markdown-splitter.test.js +0 -133
  30. package/src/halloumi/postprocessing.js +0 -247
  31. package/src/halloumi/postprocessing.test.js +0 -441
  32. package/src/halloumi/preprocessing.js +0 -117
  33. package/src/halloumi/preprocessing.test.js +0 -184
package/CHANGELOG.md CHANGED
@@ -4,7 +4,59 @@ 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
- ### [3.0.1](https://github.com/eea/volto-eea-chatbot/compare/3.0.0...3.0.1) - 5 August 2026
7
+ ### [4.1.0](https://github.com/eea/volto-eea-chatbot/compare/4.0.0...4.1.0) - 31 August 2026
8
+
9
+ #### :rocket: New Features
10
+
11
+ - feat: expose message context + thread extra markdown into classic message [Tiberiu Ichim - [`b1600ce`](https://github.com/eea/volto-eea-chatbot/commit/b1600cede2221b846d36be42247f9068f22f06c4)]
12
+ - feat: allow presentations to plug custom components into message rendering [Tiberiu Ichim - [`3f7d82b`](https://github.com/eea/volto-eea-chatbot/commit/3f7d82b9231fc2a8a92c72471068998988dadde5)]
13
+ - feat: block presentation variations registry [Tiberiu Ichim - [`bc92ba7`](https://github.com/eea/volto-eea-chatbot/commit/bc92ba7a98b832cf34f644b8eb7294ae4542f04d)]
14
+ - feat: add configurable batch size for fact-checking [Tiberiu Ichim - [`4e81cb9`](https://github.com/eea/volto-eea-chatbot/commit/4e81cb94075f8f31a70affed6f02ac8495863a57)]
15
+
16
+ #### :bug: Bug Fixes
17
+
18
+ - fix: stop hand-declaring the 'variation' schema field [Tiberiu Ichim - [`f0ada7e`](https://github.com/eea/volto-eea-chatbot/commit/f0ada7e7fb1843c562b0af44235f3a07b5253e7d)]
19
+ - fix: skip citations with empty document_id in CITATION_DELTA [Tiberiu Ichim - [`81c9441`](https://github.com/eea/volto-eea-chatbot/commit/81c9441fa041b966f0e05b13fe271d44d9ee1421)]
20
+
21
+ #### :house: Internal changes
22
+
23
+ - chore: bump version to 4.1.0 (block presentation variations) [Tiberiu Ichim - [`45d7d3d`](https://github.com/eea/volto-eea-chatbot/commit/45d7d3db43158664c960d47585ffe749c490782c)]
24
+ - chore: change default batchSize from 30 to 20 [Tiberiu Ichim - [`c5c5f4c`](https://github.com/eea/volto-eea-chatbot/commit/c5c5f4cb90fd74accd534ccd266f79592e38ec3a)]
25
+
26
+ #### :house: Documentation changes
27
+
28
+ - docs: document block presentation variations API [Tiberiu Ichim - [`8e98061`](https://github.com/eea/volto-eea-chatbot/commit/8e98061c3ce53eb701c78d3cb1eb783c8cb47234)]
29
+
30
+ #### :hammer_and_wrench: Others
31
+
32
+ - test: update citation delta test to match restored behavior [Tiberiu Ichim - [`de9f40e`](https://github.com/eea/volto-eea-chatbot/commit/de9f40e4dfd4a69f800aaf9f1b83fdf7e197b9ab)]
33
+ - revert: restore original CITATION_DELTA handling in messageProcessor [Tiberiu Ichim - [`1d19fd3`](https://github.com/eea/volto-eea-chatbot/commit/1d19fd382bda782d7165d34d7edba4310ea65a25)]
34
+ ## [4.0.0](https://github.com/eea/volto-eea-chatbot/compare/3.0.1...4.0.0) - 13 August 2026
35
+
36
+ #### :bug: Bug Fixes
37
+
38
+ - fix: correct 100.00% → High in ClaimModal snapshot [Tiberiu Ichim - [`2f3e620`](https://github.com/eea/volto-eea-chatbot/commit/2f3e620627faae3b050f0cb6ba1e3db4015dc5db)]
39
+ - fix: avoid Betterleaks false positive on LLM_API_KEY example [Tiberiu Ichim - [`44e05bc`](https://github.com/eea/volto-eea-chatbot/commit/44e05bc74a042e7f08cc0e6878930f9acc1964b9)]
40
+ - fix: filter skipped claims in span processor to prevent text duplication [Tiberiu Ichim - [`9477a42`](https://github.com/eea/volto-eea-chatbot/commit/9477a42d363d6961f4def628ed567a2409b316ea)]
41
+
42
+ #### :house: Internal changes
43
+
44
+ - chore: bump version to 4.0.0 (breaking change) [Tiberiu Ichim - [`e1038b6`](https://github.com/eea/volto-eea-chatbot/commit/e1038b6db64612f7c772a898b99ff802b3c133e9)]
45
+ - style: Automated code fix [eea-jenkins - [`03891e2`](https://github.com/eea/volto-eea-chatbot/commit/03891e24e48ccd0eccb55d4ed42aabbcdf34251f)]
46
+
47
+ #### :house: Documentation changes
48
+
49
+ - docs: document rag-facts-check backend dependency for quality checks [Tiberiu Ichim - [`123c7ea`](https://github.com/eea/volto-eea-chatbot/commit/123c7ea5d56ee48e6d5c787049a010b6bebd5aef)]
50
+ - docs: update README for rag-fact-checker integration [Tiberiu Ichim - [`4ae32b4`](https://github.com/eea/volto-eea-chatbot/commit/4ae32b41e838176d4a3ba1ea55367fc21e1a8cae)]
51
+
52
+ #### :hammer_and_wrench: Others
53
+
54
+ - test: update ClaimModal snapshots for categorical score labels [Tiberiu Ichim - [`3794be1`](https://github.com/eea/volto-eea-chatbot/commit/3794be1fc112b9f209a58c6577f99649350b1d59)]
55
+ - Deduplicate sources by text before sending to fact-checker [Tiberiu Ichim - [`67dc793`](https://github.com/eea/volto-eea-chatbot/commit/67dc793f955058d9ba5dbc92ea23a652e4586172)]
56
+ - Fix: use doc.content from final_documents when tool packets lack content [Tiberiu Ichim - [`4174212`](https://github.com/eea/volto-eea-chatbot/commit/4174212a90742c139ad2399f7a07feb99d619fbc)]
57
+ - Replace numeric claim scores with categorical labels [Tiberiu Ichim - [`d0aaf77`](https://github.com/eea/volto-eea-chatbot/commit/d0aaf779a69340386af81e9af4aa5147d93be388)]
58
+ - Send structured sources with document titles to fact-checker [Tiberiu Ichim - [`190c79b`](https://github.com/eea/volto-eea-chatbot/commit/190c79b2a888a7d655157e683633fd9901b8871c)]
59
+ ### [3.0.1](https://github.com/eea/volto-eea-chatbot/compare/3.0.0...3.0.1) - 7 August 2026
8
60
 
9
61
  #### :bug: Bug Fixes
10
62
 
@@ -35,7 +87,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
35
87
  #### :house: Internal changes
36
88
 
37
89
  - chore: bump version to 3.0.0 (EEA design system dependency) [skip ci] [Tiberiu Ichim - [`bc5a8c1`](https://github.com/eea/volto-eea-chatbot/commit/bc5a8c1f72d4e706b096cb96d0d24c2c3f5ac0f8)]
38
- - chore: [JENKINSFILE] sanitize sonarqube command Refs #305084 [valentinab25 - [`83bf08b`](https://github.com/eea/volto-eea-chatbot/commit/83bf08b7188548a50801b8e464a4d113f0cbbac4)]
39
90
 
40
91
  #### :house: Documentation changes
41
92
 
@@ -113,7 +164,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
113
164
  #### :house: Internal changes
114
165
 
115
166
  - style: Automated code fix [eea-jenkins - [`8a8c3c4`](https://github.com/eea/volto-eea-chatbot/commit/8a8c3c4172a4f669661378cf1b5a3569d85609e6)]
116
- - chore: [JENKINSFILE] add package version in sonarqube [valentinab25 - [`535d986`](https://github.com/eea/volto-eea-chatbot/commit/535d986b7adc77743a668bc4ac63f835eef58df3)]
117
167
 
118
168
  #### :hammer_and_wrench: Others
119
169
 
@@ -124,7 +174,6 @@ Generated by [`auto-changelog`](https://github.com/CookPete/auto-changelog).
124
174
  #### :house: Internal changes
125
175
 
126
176
  - style: Automated code fix [eea-jenkins - [`fdcd884`](https://github.com/eea/volto-eea-chatbot/commit/fdcd8848fd4c3f990ca5ea021f407487aebd6010)]
127
- - chore: [JENKINSFILE] use sonarqube branches [EEA Jenkins - [`3d428d7`](https://github.com/eea/volto-eea-chatbot/commit/3d428d72f32c3d05452b0961c76f5db1c416e05c)]
128
177
 
129
178
  #### :hammer_and_wrench: Others
130
179
 
package/README.md CHANGED
@@ -40,7 +40,7 @@ The **Volto Chatbot** block allows the integration of an AI-powered chatbot into
40
40
  | `enableShowTotalFailMessage` | Show total failure message. | Boolean | `false` |
41
41
  | `showAssistantTitle` | Display or hide the assistant's title in the chat interface. | Boolean | `true` |
42
42
  | `showAssistantDescription` | Display or hide the assistant's description in the chat interface. | Boolean | `true` |
43
- | `qualityCheck` | Show Halloumi-based automated quality check. | Dropdown | `Disabled` |
43
+ | `qualityCheck` | Show automated fact-checking of AI answers against source documents. | Dropdown | `Disabled` |
44
44
  | `onDemandInputToggle` | Sets the default state of the fact-check AI toggle. | Boolean | `true` |
45
45
  | `showTools` | Show or hide tools in the chat interface. | Array | `["internal_search_tool_start"]` |
46
46
  | `scrollToInput` | Automatically scroll the page to focus on the chat input when interacting. | Boolean | `false` |
@@ -116,6 +116,116 @@ Go to http://localhost:3000
116
116
 
117
117
  For a legacy Volto 17 project, install the package with `yarn` and restart the frontend as usual.
118
118
 
119
+ ## Block presentation variations
120
+
121
+ Since 4.1.0 the chatbot block's presentation is pluggable. The block ships with the default `classic` presentation (the standard chat window), and other add-ons can register additional presentations ("variations") — e.g. to re-style the chat, replace the sources UI, or render custom elements inline in the assistant's answer. When two or more variations are registered, Volto core automatically adds a **Presentation** choice field to the block's edit sidebar, so editors can pick the variation per block. Old block content without a `variation` value keeps resolving to the default.
122
+
123
+ ### Registering a variation
124
+
125
+ Push your variation onto `blocksConfig.eeaChatbot.variations` from your add-on's `applyConfig` (same cross-add-on pattern as `volto-tabs-block`):
126
+
127
+ ```js
128
+ export default function applyConfig(config) {
129
+ const block = config.blocks.blocksConfig.eeaChatbot;
130
+ if (block) {
131
+ block.variations = block.variations || [];
132
+ if (!block.variations.find((v) => v.id === 'catalogue')) {
133
+ block.variations.push({
134
+ id: 'catalogue',
135
+ title: 'Catalogue',
136
+ isDefault: false,
137
+ view: CatalogueChatView,
138
+ });
139
+ }
140
+ }
141
+ return config;
142
+ }
143
+ ```
144
+
145
+ A variation is `{ id, title, isDefault, view }` (optionally `edit` / `schemaEnhancer`); `view` is the React component rendered for the block.
146
+
147
+ ### Variation view contract
148
+
149
+ The variation `view` receives the block's fields as **top-level props** (there is no `data` prop), plus presentation props:
150
+
151
+ ```
152
+ <View
153
+ persona={assistantData} // the selected assistant
154
+ block_id // the block's internal id
155
+ isEditMode
156
+ isPlaywrightTest // ?playwright=yes query flag
157
+ initialQuery // ?query=… pre-filled question
158
+ initialDeepResearch // ?deepResearch=… flag
159
+ {...blockFields} // assistant, onyxVersion, height, …
160
+ />
161
+ ```
162
+
163
+ If the registry is empty the block falls back to the classic `ChatWindow`, so registering a variation can never break the block.
164
+
165
+ ### Reusing the classic chat window
166
+
167
+ A variation does not have to build a presentation from scratch — it can wrap the classic `ChatWindow` and only change what it needs to:
168
+
169
+ ```jsx
170
+ import { ChatWindow } from '@eeacms/volto-eea-chatbot/ChatBlock/chat';
171
+
172
+ export default function CatalogueChatView(props) {
173
+ return (
174
+ <ChatWindow
175
+ {...props}
176
+ hideSourcesTab
177
+ extraRemarkPlugins={[myRemarkPlugin]}
178
+ extraMarkdownComponents={{ myElement: MyElementComponent }}
179
+ />
180
+ );
181
+ }
182
+ ```
183
+
184
+ - `hideSourcesTab` — suppresses the classic Sources tab, sidebar and inline citation list only (the answer text and the quality-check logic are unaffected).
185
+ - `extraRemarkPlugins` / `extraRehypePlugins` / `extraMarkdownComponents` — additional remark/rehype plugins and react-markdown component overrides, merged with the built-ins (the same mechanism the quality markers use). This lets a variation render custom inline elements inside the streamed answer.
186
+
187
+ Custom markdown components can read the message that owns them through `ChatMessageContext` (also exported from `@eeacms/volto-eea-chatbot/ChatBlock/chat`): `ChatMessage` wraps every message in the context provider, so a component rendered from the answer text can e.g. match a marker against `message.documents`.
188
+
189
+ > **Note:** the variations registry, `hideSourcesTab`, the extra-markdown pass-through props and `ChatMessageContext` are new in 4.1.0. If your project resolves an older published version, these seams don't exist — import the module namespace (e.g. `import * as chat from '…/ChatBlock/chat'`) and guard against `undefined` instead of using named imports.
190
+
191
+ ## Quality Checks (Fact-Checking)
192
+
193
+ When `qualityCheck` is enabled, the chatbot sends AI answers and their source
194
+ documents to a fact-checking backend that extracts claims, verifies each
195
+ against the sources, and returns per-claim verdicts with evidence.
196
+
197
+ ### Backend dependency
198
+
199
+ The fact-checking feature requires the
200
+ [eea/rag-facts-check](https://github.com/eea/rag-facts-check) service running
201
+ and reachable at the URL configured in `RAG_FACT_CHECKER_URL`.
202
+
203
+ **Without this backend, quality checks will fail with a connection error.**
204
+
205
+ The backend exposes a halloumi-compatible endpoint (`POST /halloumi/generate`)
206
+ so the frontend can call it without code changes. It also provides a native
207
+ `POST /check` endpoint with a richer response schema.
208
+
209
+ ### Deploying the backend
210
+
211
+ ```bash
212
+ # Clone and build
213
+ git clone https://github.com/eea/rag-facts-check.git
214
+ cd rag-facts-check
215
+ docker build -t rag-fact-check .
216
+
217
+ # Run (requires an LLM endpoint)
218
+ # Set LLM_API_KEY to your actual API key before running
219
+ docker run -p 8000:8000 \
220
+ -e LLM_API_BASE=http://your-llm:4002/v1 \
221
+ -e LLM_API_KEY \
222
+ -e LLM_MODEL=gemma \
223
+ rag-fact-check
224
+ ```
225
+
226
+ See the [backend README](https://github.com/eea/rag-facts-check#rag-facts-check)
227
+ for full configuration options.
228
+
119
229
  ## Environment Configuration
120
230
 
121
231
  To properly configure the middleware and authenticate with the Onyx service, ensure that the following environment variables are set:
@@ -131,20 +241,13 @@ This document lists the environment variables used in the Volto Chatbot project.
131
241
  - `JEST_USE_SETUP`
132
242
  Used in Jest configuration. When set to 'ON', it enables a specific Jest setup.
133
243
 
134
- - `LLMGW_URL`
135
- The URL for the LLM Gateway service.
136
-
137
- - `LLMGW_TOKEN`
138
- The token for authenticating with the LLM Gateway service.
244
+ - `RAG_FACT_CHECKER_URL`
245
+ The base URL for the [rag-facts-check](https://github.com/eea/rag-facts-check)
246
+ fact-checking backend. Required when `qualityCheck` is enabled.
247
+ Default: `http://localhost:8000`.
139
248
 
140
249
  ### Development-specific environment variables
141
250
 
142
- - `MOCK_HALLOUMI_FILE_PATH`
143
- When set, this specifies the absolute path to the JSON file containing the mocked Halloumi response. Setting this variable enables mocking of Halloumi API calls.
144
-
145
- - `DUMP_HALLOUMI_FILE_PATH`
146
- When set, the Halloumi response will be dumped to the specified absolute file path for debugging or to create new mock files.
147
-
148
251
  - `MOCK_LLM_FILE_PATH`
149
252
  When set, this specifies the absolute path to the JSONL file containing the mocked Onyx stream response. Setting this variable enables mocking of Onyx LLM calls.
150
253
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eeacms/volto-eea-chatbot",
3
- "version": "3.0.1",
3
+ "version": "4.1.0",
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",
@@ -3,6 +3,7 @@ import superagent from 'superagent';
3
3
  import { parse } from 'qs';
4
4
  import withOnyxData from './hocs/withOnyxData';
5
5
  import { ChatWindow } from './chat';
6
+ import { getChatVariation } from './variations';
6
7
 
7
8
  function ChatBlockView(props) {
8
9
  const { id, assistantData, data, isEditMode, location } = props;
@@ -25,8 +26,13 @@ function ChatBlockView(props) {
25
26
  }
26
27
  }, [id, isPlaywrightTest, data]);
27
28
 
29
+ // Resolve the presentation variation for this block. Falls back to the
30
+ // classic ChatWindow so the block keeps working even if the registry is
31
+ // somehow empty.
32
+ const Presentation = getChatVariation(data)?.view || ChatWindow;
33
+
28
34
  return assistantData ? (
29
- <ChatWindow
35
+ <Presentation
30
36
  persona={assistantData}
31
37
  isEditMode={isEditMode}
32
38
  isPlaywrightTest={isPlaywrightTest}
@@ -81,31 +81,21 @@ function addQualityMarkersPlugin() {
81
81
  };
82
82
  }
83
83
 
84
- export function addHalloumiContext(doc: any, text: string) {
85
- // TODO: CLEAN UP
86
- // const updatedDate = doc.updated_at
87
- // ? new Date(doc.updated_at).toLocaleString('en-GB', {
88
- // year: 'numeric',
89
- // month: 'long',
90
- // day: '2-digit',
91
- // hour: '2-digit',
92
- // minute: '2-digit',
93
- // })
94
- // : '';
95
-
96
- // const docIndex = doc.index ? `DOCUMENT ${doc.index}: ` : '';
97
- // const sources: any = { web: 'Website', file: 'File' };
98
-
99
- // const sourceType = doc.source_type
100
- // ? sources[doc.source_type] || capitalize(doc.source_type)
101
- // : '';
102
-
103
- // const header = `${docIndex}${doc.semantic_identifier}${
104
- // sourceType ? `\nSource: ${sourceType}` : ''
105
- // }${updatedDate ? `\nUpdated: ${updatedDate}` : ''}`;
106
-
107
- // return `${header}\n${text}`;
108
- return text.replace(/\u00A0/g, ' ');
84
+ /**
85
+ * Build a structured source object for the fact-checker backend.
86
+ *
87
+ * The backend accepts {text, title, source_type} dicts so it can
88
+ * include document titles in LLM prompts without polluting the raw
89
+ * text (which would break span-offset matching).
90
+ */
91
+ export function buildHalloumiSource(doc: any, text: string) {
92
+ const cleanedText = text.replace(/\u00A0/g, ' ');
93
+ return {
94
+ text: cleanedText,
95
+ title: doc.semantic_identifier || null,
96
+ source_type: doc.source_type || null,
97
+ link: doc.link || null,
98
+ };
109
99
  }
110
100
 
111
101
  function mapToolDocumentsToText(message: any) {
@@ -135,24 +125,36 @@ function getContextSources(
135
125
  const documentIdToText = mapToolDocumentsToText(message);
136
126
 
137
127
  return qualityCheckContext === 'citations'
138
- ? sources.map((doc: any) => ({
139
- ...doc,
140
- id: doc.document_id,
141
- text: documentIdToText[doc.document_id] || '',
142
- halloumiContext: addHalloumiContext(
143
- doc,
144
- documentIdToText[doc.document_id] || '',
145
- ),
146
- }))
128
+ ? sources.map((doc: any) => {
129
+ // Prefer content from tool packets (may have enriched text),
130
+ // but fall back to doc.content from final_documents.
131
+ // Without this fallback, sources sent to the fact-checker are
132
+ // empty strings when tool packets don't carry full content.
133
+ const text = documentIdToText[doc.document_id] || doc.content || '';
134
+ const cleanedText = text.replace(/\u00A0/g, ' ');
135
+ return {
136
+ ...doc,
137
+ id: doc.document_id,
138
+ text,
139
+ // Keep for ClaimSegments span highlighting (plain text)
140
+ halloumiContext: cleanedText,
141
+ // Structured source for the fact-checker backend
142
+ halloumiSource: buildHalloumiSource(doc, text),
143
+ };
144
+ })
147
145
  : (message.toolCalls || []).reduce(
148
146
  (acc: any, cur: any) => [
149
147
  ...acc,
150
- ...(cur.tool_result || []).map((doc: any) => ({
151
- ...doc,
152
- id: doc.document_id,
153
- text: doc.content,
154
- halloumiContext: addHalloumiContext(doc, doc.content),
155
- })),
148
+ ...(cur.tool_result || []).map((doc: any) => {
149
+ const cleanedText = (doc.content || '').replace(/\u00A0/g, ' ');
150
+ return {
151
+ ...doc,
152
+ id: doc.document_id,
153
+ text: doc.content,
154
+ halloumiContext: cleanedText,
155
+ halloumiSource: buildHalloumiSource(doc, doc.content || ''),
156
+ };
157
+ }),
156
158
  ], // TODO: make sure we don't add multiple times the same doc
157
159
  // TODO: this doesn't have the index for source
158
160
  [],
@@ -203,10 +205,15 @@ export function AIMessage({
203
205
  enableMatomoTracking,
204
206
  persona,
205
207
  maxContextSegments,
208
+ batchSize,
206
209
  isLastMessage,
207
210
  className = '',
208
211
  chatWindowEndRef,
209
212
  showTools,
213
+ hideSourcesTab,
214
+ extraRemarkPlugins,
215
+ extraMarkdownComponents,
216
+ extraRehypePlugins,
210
217
  }: ChatMessageProps) {
211
218
  const [allToolsDisplayed, setAllToolsDisplayed] = useState(false);
212
219
  const [messageDisplayed, setMessageDisplayed] = useState(false);
@@ -275,13 +282,36 @@ export function AIMessage({
275
282
 
276
283
  const showSources = messageDisplayed && sources.length > 0;
277
284
 
285
+ // Whether to actually render the classic Sources UI (tab, sidebar, inline
286
+ // list). `hideSourcesTab` lets a variation present the cited documents a
287
+ // different way (e.g. inline catalogue cards) while keeping quality-check
288
+ // logic intact.
289
+ const showSourcesTab = showSources && !hideSourcesTab;
290
+
278
291
  const contextSources = getContextSources(
279
292
  message,
280
293
  sources,
281
294
  qualityCheckContext,
282
295
  );
283
296
 
284
- const stableContextSources = useDeepCompareMemoize(contextSources);
297
+ // Deduplicate sources by text content. The streaming backend often sends
298
+ // the same document in multiple packets (MESSAGE_START, SEARCH_TOOL_DELTA,
299
+ // etc.), resulting in 40+ copies of the same text. Dedup keeps the first
300
+ // occurrence, preserving order. Critical: texts must remain identical
301
+ // between frontend (span highlighting) and backend (evidence spans).
302
+ const dedupedSources = useMemo(() => {
303
+ const seen = new Set<string>();
304
+ return contextSources.filter((src: any) => {
305
+ const key = src.halloumiContext || src.halloumiSource?.text || '';
306
+ if (key && !seen.has(key)) {
307
+ seen.add(key);
308
+ return true;
309
+ }
310
+ return false;
311
+ });
312
+ }, [contextSources]);
313
+
314
+ const stableContextSources = useDeepCompareMemoize(dedupedSources);
285
315
 
286
316
  const doQualityControl =
287
317
  messageDisplayed &&
@@ -300,6 +330,7 @@ export function AIMessage({
300
330
  addCitations(message.message, message),
301
331
  stableContextSources,
302
332
  maxContextSegments,
333
+ batchSize,
303
334
  );
304
335
 
305
336
  const claims = markers?.claims || [];
@@ -365,7 +396,7 @@ export function AIMessage({
365
396
  const answerTab = (
366
397
  <div className="answer-tab">
367
398
  {/* Show first 3 sources inline */}
368
- {showSources && (
399
+ {showSourcesTab && (
369
400
  <div className="sources">
370
401
  {sources.slice(0, 3).map((source: any, i: number) => (
371
402
  <SourceDetails source={source} key={i} index={source.index} />
@@ -429,6 +460,9 @@ export function AIMessage({
429
460
  markers={markers}
430
461
  stableContextSources={stableContextSources}
431
462
  addQualityMarkersPlugin={addQualityMarkersPlugin}
463
+ extraRemarkPlugins={extraRemarkPlugins}
464
+ extraMarkdownComponents={extraMarkdownComponents}
465
+ extraRehypePlugins={extraRehypePlugins}
432
466
  >
433
467
  {({ content }) => (
434
468
  <div className="message-text-wrapper">{content}</div>
@@ -505,7 +539,7 @@ export function AIMessage({
505
539
  menuItem: { key: 'answer', content: 'Answer', className: 'answer-tab' },
506
540
  pane: <Tab.Pane key="answer">{answerTab}</Tab.Pane>,
507
541
  },
508
- ...(showSources && !error
542
+ ...(showSourcesTab && !error
509
543
  ? [
510
544
  {
511
545
  menuItem: {
@@ -554,14 +588,14 @@ export function AIMessage({
554
588
  secondary: true,
555
589
  pointing: true,
556
590
  fluid: true,
557
- className: cx({ 'without-sources': !showSources }),
591
+ className: cx({ 'without-sources': !showSourcesTab }),
558
592
  }}
559
593
  panes={panes}
560
594
  renderActiveOnly={false}
561
595
  />
562
596
 
563
597
  {/* Sources sidebar */}
564
- {showSources && !error && (
598
+ {showSourcesTab && !error && (
565
599
  <Sidebar
566
600
  visible={showSourcesSidebar}
567
601
  animation="overlay"
@@ -1,12 +1,15 @@
1
1
  import { Message as SemanticMessage } from 'semantic-ui-react';
2
2
  import type { ChatMessageProps } from '@eeacms/volto-eea-chatbot/ChatBlock/types/interfaces';
3
3
  import { UserMessage, AIMessage } from '.';
4
+ import { ChatMessageContext } from './MessageContext';
4
5
 
5
6
  export function ChatMessage(props: ChatMessageProps) {
6
7
  const { message, libs, className = '' } = props;
7
8
 
9
+ let content: React.ReactNode = null;
10
+
8
11
  if (message.type === 'user') {
9
- return (
12
+ content = (
10
13
  <UserMessage
11
14
  message={message}
12
15
  libs={libs}
@@ -14,14 +17,10 @@ export function ChatMessage(props: ChatMessageProps) {
14
17
  isLoading={props.isLoading}
15
18
  />
16
19
  );
17
- }
18
-
19
- if (message.type === 'assistant') {
20
- return <AIMessage {...props} />;
21
- }
22
-
23
- if (message.type === 'error') {
24
- return (
20
+ } else if (message.type === 'assistant') {
21
+ content = <AIMessage {...props} />;
22
+ } else if (message.type === 'error') {
23
+ content = (
25
24
  <div className="message-error">
26
25
  <SemanticMessage color="red" className="error-message">
27
26
  <div className="error-title">Error</div>
@@ -31,5 +30,13 @@ export function ChatMessage(props: ChatMessageProps) {
31
30
  );
32
31
  }
33
32
 
34
- return null;
33
+ if (content === null) return null;
34
+
35
+ // Expose the owning message to deeply-nested descendants (e.g. custom
36
+ // markdown components injected by addons via `extraMarkdownComponents`).
37
+ return (
38
+ <ChatMessageContext.Provider value={message}>
39
+ {content}
40
+ </ChatMessageContext.Provider>
41
+ );
35
42
  }
@@ -49,6 +49,7 @@ interface ChatWindowProps {
49
49
  enableMatomoTracking?: boolean;
50
50
  onDemandInputToggle?: boolean;
51
51
  maxContextSegments?: number;
52
+ batchSize?: number;
52
53
  onyxVersion?: '2' | '3';
53
54
  isPlaywrightTest?: boolean;
54
55
  initialQuery?: string | null;
@@ -89,7 +90,12 @@ function ChatWindow({
89
90
  enableMatomoTracking = true,
90
91
  onDemandInputToggle = true,
91
92
  maxContextSegments = 0,
93
+ batchSize,
92
94
  onyxVersion = '2',
95
+ hideSourcesTab,
96
+ extraRemarkPlugins,
97
+ extraMarkdownComponents,
98
+ extraRehypePlugins,
93
99
  } = data;
94
100
  const [qualityCheckEnabled, setQualityCheckEnabled] = useState(
95
101
  onDemandInputToggle ?? true,
@@ -231,6 +237,7 @@ function ChatWindow({
231
237
  enableMatomoTracking={enableMatomoTracking}
232
238
  persona={persona.id}
233
239
  maxContextSegments={maxContextSegments}
240
+ batchSize={batchSize}
234
241
  isLastMessage={index === messages.length - 1}
235
242
  className={
236
243
  index === messages.length - 1 ? 'most-recent' : ''
@@ -238,6 +245,10 @@ function ChatWindow({
238
245
  chatWindowRef={chatWindowRef}
239
246
  chatWindowEndRef={chatWindowEndRef}
240
247
  showTools={showTools}
248
+ hideSourcesTab={hideSourcesTab}
249
+ extraRemarkPlugins={extraRemarkPlugins}
250
+ extraMarkdownComponents={extraMarkdownComponents}
251
+ extraRehypePlugins={extraRehypePlugins}
241
252
  />
242
253
  </React.Fragment>
243
254
  ))}
@@ -0,0 +1,12 @@
1
+ import { createContext } from 'react';
2
+ import type { Message } from '@eeacms/volto-eea-chatbot/ChatBlock/types/interfaces';
3
+
4
+ /**
5
+ * Provides the currently-rendered chat message to deeply-nested descendants
6
+ * (e.g. custom react-markdown components injected by an addon).
7
+ *
8
+ * Set by `ChatMessage`, so any component rendered inside a given message
9
+ * (including custom markdown elements) can read the message that owns it —
10
+ * e.g. to match a `![[doc: Title]]` marker against `message.documents`.
11
+ */
12
+ export const ChatMessageContext = createContext<Message | null>(null);
@@ -1,4 +1,5 @@
1
1
  export { default as ChatWindow } from './ChatWindow';
2
2
  export { ChatMessage } from './ChatMessage';
3
+ export { ChatMessageContext } from './MessageContext';
3
4
  export { AIMessage } from './AIMessage';
4
5
  export { UserMessage } from './UserMessage';
@@ -1,6 +1,6 @@
1
1
  import { Modal, ModalContent, ModalHeader } from 'semantic-ui-react';
2
2
  import cx from 'classnames';
3
- import { convertToPercentage } from '@eeacms/volto-eea-chatbot/ChatBlock/utils';
3
+ import { scoreToLabel } from '@eeacms/volto-eea-chatbot/ChatBlock/utils';
4
4
  import SVGIcon from '@eeacms/volto-eea-chatbot/ChatBlock/components/Icon';
5
5
  import { getSupportedBgColor } from './colors';
6
6
  import { ClaimSegments } from './ClaimSegments';
@@ -70,9 +70,9 @@ export function ClaimModal({ claim, markers, text, citedSources }) {
70
70
  <div className="score-badge-section">
71
71
  <div className="score-badge">
72
72
  <span className="score-percentage">
73
- {convertToPercentage(claim.score)}
73
+ {scoreToLabel(claim.score)}
74
74
  </span>
75
- <span className="score-label">Citation Support</span>
75
+ <span className="score-label">Verification</span>
76
76
  </div>
77
77
  <div className="score-progress-bar">
78
78
  <div
@@ -43,6 +43,7 @@ export function components(message, markers, citedSources) {
43
43
  const end = child.position.end.offset;
44
44
  const claims = markers.claims?.filter(
45
45
  (claim) =>
46
+ !claim.skipped &&
46
47
  claim.score !== null &&
47
48
  ((start >= claim.startOffset && end <= claim.endOffset) ||
48
49
  (start <= claim.endOffset && end >= claim.endOffset) ||