@eeacms/volto-eea-chatbot 1.0.9
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/.coverage.babel.config.js +9 -0
- package/.eslintrc.js +68 -0
- package/.husky/pre-commit +2 -0
- package/.release-it.json +17 -0
- package/AGENTS.md +89 -0
- package/CHANGELOG.md +770 -0
- package/DEVELOP.md +124 -0
- package/LICENSE.md +9 -0
- package/README.md +170 -0
- package/RELEASE.md +74 -0
- package/TESTING.md +5 -0
- package/babel.config.js +17 -0
- package/bootstrap +41 -0
- package/cypress.config.js +27 -0
- package/docker-compose.yml +32 -0
- package/jest-addon.config.js +465 -0
- package/jest.setup.js +65 -0
- package/locales/de/LC_MESSAGES/volto.po +14 -0
- package/locales/en/LC_MESSAGES/volto.po +14 -0
- package/locales/it/LC_MESSAGES/volto.po +14 -0
- package/locales/ro/LC_MESSAGES/volto.po +14 -0
- package/locales/volto.pot +16 -0
- package/package.json +98 -0
- package/razzle.extend.js +40 -0
- package/src/ChatBlock/ChatBlockEdit.jsx +46 -0
- package/src/ChatBlock/ChatBlockView.jsx +21 -0
- package/src/ChatBlock/chat/AIMessage.tsx +566 -0
- package/src/ChatBlock/chat/ChatMessage.tsx +35 -0
- package/src/ChatBlock/chat/ChatWindow.tsx +288 -0
- package/src/ChatBlock/chat/UserMessage.tsx +27 -0
- package/src/ChatBlock/chat/index.ts +4 -0
- package/src/ChatBlock/components/AutoResizeTextarea.jsx +67 -0
- package/src/ChatBlock/components/BlinkingDot.tsx +3 -0
- package/src/ChatBlock/components/ChatMessageFeedback.jsx +77 -0
- package/src/ChatBlock/components/EmptyState.jsx +70 -0
- package/src/ChatBlock/components/FeedbackModal.jsx +125 -0
- package/src/ChatBlock/components/HalloumiFeedback.jsx +126 -0
- package/src/ChatBlock/components/Icon.tsx +35 -0
- package/src/ChatBlock/components/QualityCheckToggle.jsx +26 -0
- package/src/ChatBlock/components/RelatedQuestions.jsx +59 -0
- package/src/ChatBlock/components/Source.jsx +93 -0
- package/src/ChatBlock/components/SourceChip.tsx +55 -0
- package/src/ChatBlock/components/Spinner.jsx +3 -0
- package/src/ChatBlock/components/UserActionsToolbar.jsx +44 -0
- package/src/ChatBlock/components/WebResultIcon.tsx +42 -0
- package/src/ChatBlock/components/markdown/Citation.jsx +70 -0
- package/src/ChatBlock/components/markdown/ClaimModal.jsx +98 -0
- package/src/ChatBlock/components/markdown/ClaimSegments.jsx +172 -0
- package/src/ChatBlock/components/markdown/RenderClaimView.jsx +96 -0
- package/src/ChatBlock/components/markdown/colors.js +29 -0
- package/src/ChatBlock/components/markdown/colors.less +52 -0
- package/src/ChatBlock/components/markdown/colors.test.js +69 -0
- package/src/ChatBlock/components/markdown/index.js +115 -0
- package/src/ChatBlock/fonts/DejaVuSans.ttf +0 -0
- package/src/ChatBlock/hocs/withOnyxData.jsx +46 -0
- package/src/ChatBlock/hooks/index.ts +7 -0
- package/src/ChatBlock/hooks/useChatController.ts +333 -0
- package/src/ChatBlock/hooks/useChatStreaming.ts +82 -0
- package/src/ChatBlock/hooks/useDeepCompareMemoize.js +17 -0
- package/src/ChatBlock/hooks/useMarked.js +44 -0
- package/src/ChatBlock/hooks/useQualityMarkers.js +119 -0
- package/src/ChatBlock/hooks/useScrollonStream.ts +131 -0
- package/src/ChatBlock/hooks/useToolDisplayTiming.ts +80 -0
- package/src/ChatBlock/index.js +32 -0
- package/src/ChatBlock/packets/MultiToolRenderer.tsx +235 -0
- package/src/ChatBlock/packets/RendererComponent.tsx +115 -0
- package/src/ChatBlock/packets/index.ts +4 -0
- package/src/ChatBlock/packets/renderers/CustomToolRenderer.tsx +63 -0
- package/src/ChatBlock/packets/renderers/FetchToolRenderer.tsx +59 -0
- package/src/ChatBlock/packets/renderers/ImageToolRenderer.tsx +62 -0
- package/src/ChatBlock/packets/renderers/MessageTextRenderer.tsx +172 -0
- package/src/ChatBlock/packets/renderers/ReasoningRenderer.tsx +122 -0
- package/src/ChatBlock/packets/renderers/SearchToolRenderer.tsx +323 -0
- package/src/ChatBlock/packets/renderers/index.ts +6 -0
- package/src/ChatBlock/schema.js +403 -0
- package/src/ChatBlock/services/index.ts +3 -0
- package/src/ChatBlock/services/messageProcessor.ts +348 -0
- package/src/ChatBlock/services/packetUtils.ts +48 -0
- package/src/ChatBlock/services/streamingService.ts +342 -0
- package/src/ChatBlock/style.less +1881 -0
- package/src/ChatBlock/tests/AIMessage.test.jsx +95 -0
- package/src/ChatBlock/tests/AutoResizeTextarea.test.jsx +49 -0
- package/src/ChatBlock/tests/BlinkingDot.test.jsx +71 -0
- package/src/ChatBlock/tests/ChatMessageFeedback.test.jsx +73 -0
- package/src/ChatBlock/tests/Citation.test.jsx +107 -0
- package/src/ChatBlock/tests/EmptyState.test.jsx +137 -0
- package/src/ChatBlock/tests/FeedbackModal.test.jsx +138 -0
- package/src/ChatBlock/tests/HalloumiFeedback.test.jsx +94 -0
- package/src/ChatBlock/tests/QualityCheckToggle.test.jsx +105 -0
- package/src/ChatBlock/tests/RelatedQuestions.test.jsx +215 -0
- package/src/ChatBlock/tests/Source.test.jsx +79 -0
- package/src/ChatBlock/tests/Spinner.test.jsx +18 -0
- package/src/ChatBlock/tests/index.test.js +51 -0
- package/src/ChatBlock/tests/messageProcessor.test.jsx +154 -0
- package/src/ChatBlock/tests/schema.test.js +166 -0
- package/src/ChatBlock/tests/useDeepCompareMemoize.test.js +107 -0
- package/src/ChatBlock/tests/useToolDisplayTiming.test.jsx +151 -0
- package/src/ChatBlock/types/cssmodules.d.ts +7 -0
- package/src/ChatBlock/types/interfaces.ts +154 -0
- package/src/ChatBlock/types/slate.d.ts +3 -0
- package/src/ChatBlock/types/streamingModels.ts +267 -0
- package/src/ChatBlock/types/volto.d.ts +3 -0
- package/src/ChatBlock/utils/citations.ts +25 -0
- package/src/ChatBlock/utils/index.tsx +114 -0
- package/src/halloumi/README.md +1 -0
- package/src/halloumi/generative.js +219 -0
- package/src/halloumi/generative.test.js +88 -0
- package/src/halloumi/middleware.js +70 -0
- package/src/halloumi/postprocessing.js +273 -0
- package/src/halloumi/postprocessing.test.js +441 -0
- package/src/halloumi/preprocessing.js +115 -0
- package/src/halloumi/preprocessing.test.js +245 -0
- package/src/icons/bot.svg +1 -0
- package/src/icons/check.svg +1 -0
- package/src/icons/chevron.svg +3 -0
- package/src/icons/clear.svg +1 -0
- package/src/icons/copy.svg +1 -0
- package/src/icons/done.svg +5 -0
- package/src/icons/external-link.svg +1 -0
- package/src/icons/file.svg +1 -0
- package/src/icons/glasses.svg +1 -0
- package/src/icons/globe.svg +1 -0
- package/src/icons/rotate.svg +1 -0
- package/src/icons/search.svg +5 -0
- package/src/icons/send.svg +1 -0
- package/src/icons/square-pen.svg +1 -0
- package/src/icons/stop.svg +9 -0
- package/src/icons/thumbs-down.svg +1 -0
- package/src/icons/thumbs-up.svg +1 -0
- package/src/icons/user.svg +1 -0
- package/src/index.js +58 -0
- package/src/middleware.js +250 -0
- package/tsconfig.json +40 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createHalloumiPrompt,
|
|
3
|
+
splitIntoSentences,
|
|
4
|
+
annotate,
|
|
5
|
+
getOffsets,
|
|
6
|
+
} from './preprocessing';
|
|
7
|
+
|
|
8
|
+
describe('splitIntoSentences', () => {
|
|
9
|
+
it('should split a basic text into sentences', () => {
|
|
10
|
+
const text =
|
|
11
|
+
'This is sentence one. This is sentence two. This is sentence three.';
|
|
12
|
+
const expected = [
|
|
13
|
+
'This is sentence one. ',
|
|
14
|
+
'This is sentence two. ',
|
|
15
|
+
'This is sentence three.',
|
|
16
|
+
];
|
|
17
|
+
expect(splitIntoSentences(text)).toEqual(expected);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should handle short sentences by merging them', () => {
|
|
21
|
+
const text = 'Short. This is a longer sentence. Also short.';
|
|
22
|
+
const expected = ['Short. This is a longer sentence. ', 'Also short.'];
|
|
23
|
+
expect(splitIntoSentences(text)).toEqual(expected);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should return an empty array for an empty string', () => {
|
|
27
|
+
expect(splitIntoSentences('')).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should handle a single sentence', () => {
|
|
31
|
+
const text = 'This is a single sentence.';
|
|
32
|
+
expect(splitIntoSentences(text)).toEqual(['This is a single sentence.']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should handle text without punctuation', () => {
|
|
36
|
+
const text = 'This is a sentence without punctuation';
|
|
37
|
+
expect(splitIntoSentences(text)).toEqual([
|
|
38
|
+
'This is a sentence without punctuation',
|
|
39
|
+
]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should not merge sentences when maxSegments is 0', () => {
|
|
43
|
+
const text = 'One. Two. Three. Four. Five.';
|
|
44
|
+
const expected = ['One. Two. ', 'Three. Four. ', 'Five.'];
|
|
45
|
+
expect(splitIntoSentences(text, 0)).toEqual(expected);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should not merge sentences when finalSentences.length <= maxSegments', () => {
|
|
49
|
+
const text = 'One. Two. Three.';
|
|
50
|
+
const expected = ['One. Two. ', 'Three.'];
|
|
51
|
+
expect(splitIntoSentences(text, 3)).toEqual(expected);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should merge sentences when finalSentences.length > maxSegments', () => {
|
|
55
|
+
const text = 'One. Two. Three. Four. Five.';
|
|
56
|
+
const expected = ['One. Two. Three. Four. ', 'Five.'];
|
|
57
|
+
expect(splitIntoSentences(text, 2)).toEqual(expected);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should merge sentences into a single segment if maxSegments is 1', () => {
|
|
61
|
+
const text = 'One. Two. Three. Four. Five.';
|
|
62
|
+
const expected = ['One. Two. Three. Four. Five.'];
|
|
63
|
+
expect(splitIntoSentences(text, 1)).toEqual(expected);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('annotate', () => {
|
|
68
|
+
it('should annotate multiple sentences correctly', () => {
|
|
69
|
+
const sentences = ['Sentence one.', 'Sentence two.'];
|
|
70
|
+
const annotationChar = 's';
|
|
71
|
+
const expected =
|
|
72
|
+
'<|s1|><Sentence one.><end||s><|s2|><Sentence two.><end||s>';
|
|
73
|
+
expect(annotate(sentences, annotationChar)).toEqual(expected);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should handle an empty array of sentences', () => {
|
|
77
|
+
const sentences = [];
|
|
78
|
+
const annotationChar = 's';
|
|
79
|
+
expect(annotate(sentences, annotationChar)).toEqual('');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should annotate a single sentence', () => {
|
|
83
|
+
const sentences = ['Single sentence.'];
|
|
84
|
+
const annotationChar = 'r';
|
|
85
|
+
const expected = '<|r1|><Single sentence.><end||r>';
|
|
86
|
+
expect(annotate(sentences, annotationChar)).toEqual(expected);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should use different annotation characters', () => {
|
|
90
|
+
const sentences = ['Hello.'];
|
|
91
|
+
const annotationChar = 'x';
|
|
92
|
+
const expected = '<|x1|><Hello.><end||x>';
|
|
93
|
+
expect(annotate(sentences, annotationChar)).toEqual(expected);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('getOffsets', () => {
|
|
98
|
+
it('should calculate correct offsets for multiple sentences', () => {
|
|
99
|
+
const originalString = 'First sentence. Second sentence. Third sentence.';
|
|
100
|
+
const sentences = [
|
|
101
|
+
'First sentence. ',
|
|
102
|
+
'Second sentence. ',
|
|
103
|
+
'Third sentence.',
|
|
104
|
+
];
|
|
105
|
+
const expected = new Map([
|
|
106
|
+
[1, { startOffset: 0, endOffset: 16 }],
|
|
107
|
+
[2, { startOffset: 16, endOffset: 33 }],
|
|
108
|
+
[3, { startOffset: 33, endOffset: 48 }],
|
|
109
|
+
]);
|
|
110
|
+
expect(getOffsets(originalString, sentences)).toEqual(expected);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should handle empty original string and sentences', () => {
|
|
114
|
+
const originalString = '';
|
|
115
|
+
const sentences = [];
|
|
116
|
+
expect(getOffsets(originalString, sentences)).toEqual(new Map());
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should handle original string matching sentences exactly', () => {
|
|
120
|
+
const originalString = 'Hello world.';
|
|
121
|
+
const sentences = ['Hello world.'];
|
|
122
|
+
const expected = new Map([[1, { startOffset: 0, endOffset: 12 }]]);
|
|
123
|
+
expect(getOffsets(originalString, sentences)).toEqual(expected);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should handle original string with leading/trailing spaces', () => {
|
|
127
|
+
const originalString = ' Sentence one. Sentence two. ';
|
|
128
|
+
const sentences = ['Sentence one. ', 'Sentence two. '];
|
|
129
|
+
const expected = new Map([
|
|
130
|
+
[1, { startOffset: 2, endOffset: 17 }],
|
|
131
|
+
[2, { startOffset: 17, endOffset: 32 }],
|
|
132
|
+
]);
|
|
133
|
+
expect(getOffsets(originalString, sentences)).toEqual(expected);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should handle sentences with special characters', () => {
|
|
137
|
+
const originalString = 'Sentence with !@#$%^&*() special characters.';
|
|
138
|
+
const sentences = ['Sentence with !@#$%^&*() special characters.'];
|
|
139
|
+
const expected = new Map([[1, { startOffset: 0, endOffset: 44 }]]);
|
|
140
|
+
expect(getOffsets(originalString, sentences)).toEqual(expected);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('createHalloumiPrompt', () => {
|
|
145
|
+
it('should create a Halloumi prompt with annotated context and response', () => {
|
|
146
|
+
const sources = [
|
|
147
|
+
'This is the first source. This is its second sentence.',
|
|
148
|
+
'This is the second source.',
|
|
149
|
+
];
|
|
150
|
+
const response = 'This is the response. It has two sentences.';
|
|
151
|
+
const request = 'Test request.';
|
|
152
|
+
|
|
153
|
+
const result = createHalloumiPrompt({ sources, response, request });
|
|
154
|
+
|
|
155
|
+
// Expect the prompt to contain annotated context and response
|
|
156
|
+
expect(result.prompt).toContain(
|
|
157
|
+
'<|context|><|s1|><This is the first source. ><end||s><|s2|><This is its second sentence.><end||s><|s3|><This is the second source.><end||s><end||context>',
|
|
158
|
+
);
|
|
159
|
+
expect(result.prompt).toContain('<|request|><Test request.><end||request>');
|
|
160
|
+
expect(result.prompt).toContain(
|
|
161
|
+
'<|response|><|r1|><This is the response. ><end||r><|r2|><It has two sentences.><end||r><end||response>',
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Expect contextOffsets and responseOffsets to be correctly populated
|
|
165
|
+
expect(result.contextOffsets).toBeInstanceOf(Map);
|
|
166
|
+
const s1 = 'This is the first source. ';
|
|
167
|
+
const s2 = 'This is its second sentence.';
|
|
168
|
+
const s3 = 'This is the second source.';
|
|
169
|
+
|
|
170
|
+
expect(result.contextOffsets.get(1)).toEqual({
|
|
171
|
+
startOffset: 0,
|
|
172
|
+
endOffset: s1.length,
|
|
173
|
+
});
|
|
174
|
+
expect(result.contextOffsets.get(2)).toEqual({
|
|
175
|
+
startOffset: s1.length,
|
|
176
|
+
endOffset: s1.length + s2.length,
|
|
177
|
+
});
|
|
178
|
+
expect(result.contextOffsets.get(3)).toEqual({
|
|
179
|
+
startOffset: s1.length + s2.length + 1, // +1 for the space between sentences
|
|
180
|
+
endOffset: s1.length + s2.length + 1 + s3.length,
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(result.responseOffsets).toBeInstanceOf(Map);
|
|
184
|
+
const r1 = 'This is the response. ';
|
|
185
|
+
const r2 = 'It has two sentences.';
|
|
186
|
+
|
|
187
|
+
expect(result.responseOffsets.get(1)).toEqual({
|
|
188
|
+
startOffset: 0,
|
|
189
|
+
endOffset: r1.length,
|
|
190
|
+
});
|
|
191
|
+
expect(result.responseOffsets.get(2)).toEqual({
|
|
192
|
+
startOffset: r1.length,
|
|
193
|
+
endOffset: r1.length + r2.length,
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should handle empty sources, response, and request', () => {
|
|
198
|
+
const sources = [];
|
|
199
|
+
const response = '';
|
|
200
|
+
const request = '';
|
|
201
|
+
|
|
202
|
+
const result = createHalloumiPrompt({ sources, response, request });
|
|
203
|
+
|
|
204
|
+
expect(result.prompt).toBe(
|
|
205
|
+
'<|context|><end||context><|request|><Make one or more claims about information in the documents.><end||request><|response|><end||response>',
|
|
206
|
+
);
|
|
207
|
+
expect(result.contextOffsets).toBeInstanceOf(Map);
|
|
208
|
+
expect(result.contextOffsets.size).toBe(0);
|
|
209
|
+
expect(result.responseOffsets).toBeInstanceOf(Map);
|
|
210
|
+
expect(result.responseOffsets.size).toBe(0);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should handle maxContextSegments correctly', () => {
|
|
214
|
+
const sources = [
|
|
215
|
+
'Sentence one. Sentence two. Sentence three. Sentence four.',
|
|
216
|
+
];
|
|
217
|
+
const response = 'Response one. Response two.';
|
|
218
|
+
const request = 'Test request.';
|
|
219
|
+
const maxContextSegments = 2;
|
|
220
|
+
|
|
221
|
+
const result = createHalloumiPrompt({
|
|
222
|
+
sources,
|
|
223
|
+
response,
|
|
224
|
+
request,
|
|
225
|
+
maxContextSegments,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// With maxContextSegments = 2, the 4 sentences should be merged into 2.
|
|
229
|
+
// "Sentence one. Sentence two." and "Sentence three. Sentence four."
|
|
230
|
+
expect(result.prompt).toContain(
|
|
231
|
+
'<|context|><|s1|><Sentence one. Sentence two. ><end||s><|s2|><Sentence three. Sentence four.><end||s><end||context>',
|
|
232
|
+
);
|
|
233
|
+
const mergedS1 = 'Sentence one. Sentence two. ';
|
|
234
|
+
const mergedS2 = 'Sentence three. Sentence four.';
|
|
235
|
+
|
|
236
|
+
expect(result.contextOffsets.get(1)).toEqual({
|
|
237
|
+
startOffset: 0,
|
|
238
|
+
endOffset: mergedS1.length,
|
|
239
|
+
});
|
|
240
|
+
expect(result.contextOffsets.get(2)).toEqual({
|
|
241
|
+
startOffset: mergedS1.length,
|
|
242
|
+
endOffset: mergedS1.length + mergedS2.length,
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-bot"><path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check-icon lucide-check"><path d="M20 6 9 17l-5-5"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-copy-icon lucide-copy"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round"
|
|
2
|
+
stroke-linejoin="round" class="w-3 h-3 rounded-full" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg">
|
|
3
|
+
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
|
4
|
+
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-external-link-icon lucide-external-link"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-glasses-icon lucide-glasses"><circle cx="6" cy="15" r="4"/><circle cx="18" cy="15" r="4"/><path d="M14 15a2 2 0 0 0-2-2 2 2 0 0 0-2 2"/><path d="M2.5 13 5 7c.7-1.3 1.4-2 3-2"/><path d="M21.5 13 19 7c-.7-1.3-1.5-2-3-2"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-globe"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-ccw-icon lucide-rotate-ccw"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px"
|
|
2
|
+
y="0px" width="24" height="24" viewBox="0 0 20 20" enable-background="new 0 0 20 20" xml:space="preserve">
|
|
3
|
+
<g id="stop_1_">
|
|
4
|
+
<g>
|
|
5
|
+
<path fill-rule="evenodd" clip-rule="evenodd"
|
|
6
|
+
d="M16,3H4C3.45,3,3,3.45,3,4v12c0,0.55,0.45,1,1,1h12c0.55,0,1-0.45,1-1V4 C17,3.45,16.55,3,16,3z" />
|
|
7
|
+
</g>
|
|
8
|
+
</g>
|
|
9
|
+
</svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-thumbs-down-icon lucide-thumbs-down"><path d="M17 14V2"/><path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-thumbs-up-icon lucide-thumbs-up"><path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
package/src/index.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import installChatBlock from './ChatBlock';
|
|
2
|
+
import loadable from '@loadable/component';
|
|
3
|
+
|
|
4
|
+
const applyConfig = (config) => {
|
|
5
|
+
if (__SERVER__) {
|
|
6
|
+
const express = require('express');
|
|
7
|
+
const middleware = express.Router();
|
|
8
|
+
|
|
9
|
+
middleware.use(express.json({ limit: config.settings.maxResponseSize }));
|
|
10
|
+
middleware.use(express.urlencoded({ extended: true }));
|
|
11
|
+
|
|
12
|
+
const proxyMiddleware = require('./middleware').default;
|
|
13
|
+
const halloumiMiddleware = require('./halloumi/middleware').default;
|
|
14
|
+
|
|
15
|
+
middleware.all('**/_da/**', proxyMiddleware);
|
|
16
|
+
middleware.all('**/_ha/**', halloumiMiddleware);
|
|
17
|
+
|
|
18
|
+
middleware.id = 'chatbot';
|
|
19
|
+
|
|
20
|
+
config.settings.expressMiddleware = [
|
|
21
|
+
...config.settings.expressMiddleware,
|
|
22
|
+
middleware,
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
config.settings.loadables = {
|
|
27
|
+
...config.settings.loadables,
|
|
28
|
+
rehypePrism: loadable.lib(() => import('rehype-prism-plus')),
|
|
29
|
+
remarkGfm: loadable.lib(() => import('remark-gfm')),
|
|
30
|
+
luxon: loadable.lib(() => import('luxon')),
|
|
31
|
+
|
|
32
|
+
// highlightJs: loadable.lib(() => import('highlight.js')),
|
|
33
|
+
// marked: loadable.lib(() => import('marked')),
|
|
34
|
+
|
|
35
|
+
// fastJsonPatch: loadable.lib(() => import('fast-json-patch')),
|
|
36
|
+
// fetchEventSource: loadable.lib(() =>
|
|
37
|
+
// import('@microsoft/fetch-event-source'),
|
|
38
|
+
// ),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
installChatBlock(config);
|
|
42
|
+
|
|
43
|
+
return config;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
createChatSession,
|
|
48
|
+
sendMessage,
|
|
49
|
+
} from './ChatBlock/services/streamingService';
|
|
50
|
+
export { MessageProcessor } from './ChatBlock/services/messageProcessor';
|
|
51
|
+
export { default as withOnyxData } from './ChatBlock/hocs/withOnyxData';
|
|
52
|
+
export * from './ChatBlock/packets';
|
|
53
|
+
|
|
54
|
+
export { default as UserActionsToolbar } from './ChatBlock/components/UserActionsToolbar';
|
|
55
|
+
export { default as FeedbackModal } from './ChatBlock/components/FeedbackModal';
|
|
56
|
+
export { default as ChatMessageFeedback } from './ChatBlock/components/ChatMessageFeedback';
|
|
57
|
+
|
|
58
|
+
export default applyConfig;
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import superagent from 'superagent';
|
|
2
|
+
import fetch from 'node-fetch';
|
|
3
|
+
import debug from 'debug';
|
|
4
|
+
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
|
|
7
|
+
const log = debug('volto-eea-chatbot');
|
|
8
|
+
// import readline from 'readline';
|
|
9
|
+
|
|
10
|
+
const MOCK_STREAM_DELAY = parseInt(process.env.MOCK_STREAM_DELAY || '0');
|
|
11
|
+
|
|
12
|
+
let cached_auth_cookie = null;
|
|
13
|
+
let last_fetched = null;
|
|
14
|
+
let maxAge;
|
|
15
|
+
|
|
16
|
+
const MSG_INVALID_CONFIGURATION = 'Invalid configuration: missing ONYX api key';
|
|
17
|
+
const MSG_FETCH_COOKIE = 'Error while fetching authentication cookie';
|
|
18
|
+
const MSG_ERROR_REQUEST = 'Error in processing request to Onyx';
|
|
19
|
+
|
|
20
|
+
async function get_login_cookie(username, password) {
|
|
21
|
+
const url = `${process.env.ONYX_URL}/api/auth/login`;
|
|
22
|
+
const data = {
|
|
23
|
+
username,
|
|
24
|
+
password,
|
|
25
|
+
scope: '',
|
|
26
|
+
client_id: '',
|
|
27
|
+
client_secret: '',
|
|
28
|
+
grant_type: '',
|
|
29
|
+
};
|
|
30
|
+
try {
|
|
31
|
+
const response = await superagent.post(url).type('form').send(data);
|
|
32
|
+
const header = response.headers['set-cookie'][0];
|
|
33
|
+
return header;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
// eslint-disable-next-line no-console
|
|
36
|
+
console.error(MSG_FETCH_COOKIE, error);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function getAuthCookie(username, password) {
|
|
41
|
+
cached_auth_cookie = await get_login_cookie(username, password);
|
|
42
|
+
if (cached_auth_cookie) {
|
|
43
|
+
const maxAgeMatch = cached_auth_cookie.match(/Max-Age=(\d+)/);
|
|
44
|
+
maxAge = parseInt(maxAgeMatch[1]);
|
|
45
|
+
last_fetched = new Date();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function login(username, password) {
|
|
50
|
+
const diff = maxAge - (new Date() - last_fetched) / 1000;
|
|
51
|
+
// eslint-disable-next-line no-console
|
|
52
|
+
log('onyx auth still valid for seconds: ', diff);
|
|
53
|
+
if (!cached_auth_cookie || diff < 0) {
|
|
54
|
+
await getAuthCookie(username, password);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function check_credentials() {
|
|
59
|
+
const reqUrl = `${process.env.ONYX_URL}/api/persona/-1`;
|
|
60
|
+
|
|
61
|
+
const options = {
|
|
62
|
+
method: 'GET',
|
|
63
|
+
headers: {
|
|
64
|
+
Cookie: cached_auth_cookie,
|
|
65
|
+
'Content-Type': 'application/json',
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
log(`Fetching ${reqUrl}`);
|
|
70
|
+
return await fetch(reqUrl, options);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function mock_create_chat(res) {
|
|
74
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
75
|
+
res.setHeader('Transfer-Encoding', 'chunked');
|
|
76
|
+
res.write(`{"chat_session_id":"46277749-db60-44f2-9de1-c5dca2eb68fa"}`);
|
|
77
|
+
res.end();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function mock_send_message(res) {
|
|
81
|
+
const filePath = process.env.MOCK_LLM_FILE_PATH;
|
|
82
|
+
if (!filePath) {
|
|
83
|
+
log('MOCK_LLM_FILE_PATH is not set. Cannot mock send message.');
|
|
84
|
+
res.status(500).send('Internal Server Error: MOCK_LLM_FILE_PATH not set.');
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const readStream = fs.createReadStream(filePath, { encoding: 'utf8' });
|
|
88
|
+
|
|
89
|
+
let buffer = '';
|
|
90
|
+
let lineIndex = 0;
|
|
91
|
+
|
|
92
|
+
// Set appropriate headers for streaming
|
|
93
|
+
res.setHeader('Content-Type', 'text/plain');
|
|
94
|
+
res.setHeader('Transfer-Encoding', 'chunked');
|
|
95
|
+
|
|
96
|
+
const sendLineWithDelay = (line, index) => {
|
|
97
|
+
if (MOCK_STREAM_DELAY === 0) {
|
|
98
|
+
res.write(line + '\n');
|
|
99
|
+
log(`Sent line ${index + 1}: ${line.substring(0, 50)}...`);
|
|
100
|
+
return;
|
|
101
|
+
} else {
|
|
102
|
+
setTimeout(() => {
|
|
103
|
+
res.write(line + '\n');
|
|
104
|
+
log(`Sent line ${index + 1}: ${line.substring(0, 50)}...`);
|
|
105
|
+
}, index * MOCK_STREAM_DELAY);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
readStream.on('data', (chunk) => {
|
|
110
|
+
buffer += chunk;
|
|
111
|
+
const lines = buffer.split('\n');
|
|
112
|
+
|
|
113
|
+
// Keep the last incomplete line in buffer
|
|
114
|
+
buffer = lines.pop() || '';
|
|
115
|
+
|
|
116
|
+
// Process complete lines
|
|
117
|
+
lines.forEach((line) => {
|
|
118
|
+
if (line.trim()) {
|
|
119
|
+
// Only send non-empty lines
|
|
120
|
+
sendLineWithDelay(line.trim(), lineIndex);
|
|
121
|
+
lineIndex++;
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
readStream.on('end', () => {
|
|
127
|
+
// Handle any remaining content in buffer
|
|
128
|
+
if (buffer.trim()) {
|
|
129
|
+
sendLineWithDelay(buffer.trim(), lineIndex);
|
|
130
|
+
lineIndex++;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// End the response after all lines are sent
|
|
134
|
+
setTimeout(() => {
|
|
135
|
+
res.end();
|
|
136
|
+
log('File stream ended - all lines sent');
|
|
137
|
+
}, lineIndex * MOCK_STREAM_DELAY);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
readStream.on('error', (err) => {
|
|
141
|
+
log('Error reading file:', err);
|
|
142
|
+
res.status(500).send('Internal Server Error');
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function send_onyx_request(
|
|
147
|
+
req,
|
|
148
|
+
res,
|
|
149
|
+
{ username, password, api_key, url },
|
|
150
|
+
) {
|
|
151
|
+
let headers = {};
|
|
152
|
+
if (!api_key) {
|
|
153
|
+
await login(username, password);
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
const resp = await check_credentials();
|
|
157
|
+
if (resp.status !== 200) {
|
|
158
|
+
await getAuthCookie(username, password);
|
|
159
|
+
}
|
|
160
|
+
} catch (error) {
|
|
161
|
+
await getAuthCookie(username, password);
|
|
162
|
+
}
|
|
163
|
+
headers = {
|
|
164
|
+
Cookie: cached_auth_cookie,
|
|
165
|
+
'Content-Type': 'application/json',
|
|
166
|
+
};
|
|
167
|
+
} else {
|
|
168
|
+
headers = {
|
|
169
|
+
Authorization: 'Bearer ' + api_key,
|
|
170
|
+
'Content-Type': 'application/json',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const options = {
|
|
175
|
+
method: req.method,
|
|
176
|
+
headers: headers,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
if (req.body && req.method === 'POST') {
|
|
180
|
+
options.body = JSON.stringify(req.body);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (process.env.MOCK_LLM_FILE_PATH && req.url.endsWith('send-message')) {
|
|
184
|
+
try {
|
|
185
|
+
mock_send_message(res);
|
|
186
|
+
} catch (e) {
|
|
187
|
+
log(e);
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (
|
|
193
|
+
process.env.MOCK_LLM_FILE_PATH &&
|
|
194
|
+
req.url.endsWith('create-chat-session')
|
|
195
|
+
) {
|
|
196
|
+
mock_create_chat(res);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
try {
|
|
201
|
+
log(`Fetching ${url}`);
|
|
202
|
+
const response = await fetch(url, options, req.body);
|
|
203
|
+
|
|
204
|
+
if (process.env.DUMP_LLM_FILE_PATH) {
|
|
205
|
+
const filePath = process.env.DUMP_LLM_FILE_PATH;
|
|
206
|
+
const writer = fs.createWriteStream(filePath);
|
|
207
|
+
response.body.pipe(writer);
|
|
208
|
+
log(`Dumped LLM response to: ${filePath}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!api_key) {
|
|
212
|
+
if (response.headers.get('transfer-encoding') === 'chunked') {
|
|
213
|
+
res.set('Content-Type', 'text/event-stream');
|
|
214
|
+
} else {
|
|
215
|
+
res.set('Content-Type', 'application/json');
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
res.set('Content-Type', response.headers.get('Content-Type'));
|
|
219
|
+
}
|
|
220
|
+
response.body.pipe(res);
|
|
221
|
+
} catch (error) {
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export default async function middleware(req, res, next) {
|
|
227
|
+
const path = req.url.replace('/_da/', '/');
|
|
228
|
+
|
|
229
|
+
const reqUrl = `${process.env.ONYX_URL || ''}/api${path}`;
|
|
230
|
+
|
|
231
|
+
const api_key = process.env.ONYX_API_KEY;
|
|
232
|
+
if (!api_key) {
|
|
233
|
+
res.send({
|
|
234
|
+
error: MSG_INVALID_CONFIGURATION,
|
|
235
|
+
});
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
await send_onyx_request(req, res, {
|
|
241
|
+
url: reqUrl,
|
|
242
|
+
api_key: api_key,
|
|
243
|
+
});
|
|
244
|
+
} catch (error) {
|
|
245
|
+
// eslint-disable-next-line
|
|
246
|
+
console.error(MSG_ERROR_REQUEST, error?.response?.text);
|
|
247
|
+
|
|
248
|
+
res.send({ error: `Onyx error: ${error?.response?.text || 'error'}` });
|
|
249
|
+
}
|
|
250
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json.schemastore.org/tsconfig",
|
|
3
|
+
"display": "React Library",
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"esModuleInterop": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"target": "es2022",
|
|
8
|
+
"allowJs": true,
|
|
9
|
+
"resolveJsonModule": true,
|
|
10
|
+
"moduleDetection": "force",
|
|
11
|
+
"isolatedModules": true,
|
|
12
|
+
"verbatimModuleSyntax": true,
|
|
13
|
+
|
|
14
|
+
"strict": true,
|
|
15
|
+
"noImplicitOverride": true,
|
|
16
|
+
|
|
17
|
+
"lib": ["es2022", "dom", "dom.iterable"],
|
|
18
|
+
"module": "preserve",
|
|
19
|
+
"noEmit": true,
|
|
20
|
+
|
|
21
|
+
"allowSyntheticDefaultImports": true,
|
|
22
|
+
"forceConsistentCasingInFileNames": true,
|
|
23
|
+
|
|
24
|
+
"paths": {
|
|
25
|
+
"@plone/volto/*": ["../../../node_modules/@plone/volto/src/*"],
|
|
26
|
+
"@eeacms/volto-matomo/*": [
|
|
27
|
+
"../../../node_modules/@eeacms/volto-matomo/src/*"
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
"jsx": "react-jsx"
|
|
31
|
+
},
|
|
32
|
+
"include": ["src"],
|
|
33
|
+
"exclude": [
|
|
34
|
+
"node_modules",
|
|
35
|
+
"build",
|
|
36
|
+
"public",
|
|
37
|
+
"coverage",
|
|
38
|
+
"src/**/*.test.{js,jsx,ts,tsx}"
|
|
39
|
+
]
|
|
40
|
+
}
|