@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,333 @@
|
|
|
1
|
+
import type { Message } from '../types/interfaces';
|
|
2
|
+
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
3
|
+
import { useChatStreaming } from './useChatStreaming';
|
|
4
|
+
import { createChatSession, sendMessage } from '../services/streamingService';
|
|
5
|
+
import { PacketType } from '../types/streamingModels';
|
|
6
|
+
import { ResearchType } from '../types/interfaces';
|
|
7
|
+
|
|
8
|
+
interface UseChatControllerProps {
|
|
9
|
+
personaId: number;
|
|
10
|
+
enableQgen?: boolean;
|
|
11
|
+
qgenAsistantId?: number;
|
|
12
|
+
deepResearch?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface RelatedQuestion {
|
|
16
|
+
question: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Extract JSON array from related questions response
|
|
20
|
+
function extractRelatedQuestions(str: string): RelatedQuestion[] {
|
|
21
|
+
const regex = /\[[\s\S]*?\]/;
|
|
22
|
+
const match = str.match(regex);
|
|
23
|
+
|
|
24
|
+
if (match) {
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(match[0]);
|
|
27
|
+
} catch {
|
|
28
|
+
// Fallback to line-by-line parsing
|
|
29
|
+
return str
|
|
30
|
+
.split('\n')
|
|
31
|
+
.filter((line) => line.trim())
|
|
32
|
+
.map((question) => ({ question }));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return str
|
|
37
|
+
.split('\n')
|
|
38
|
+
.filter((line) => line.trim())
|
|
39
|
+
.map((question) => ({ question }));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Fetch related questions using the qgen assistant
|
|
43
|
+
async function fetchRelatedQuestions(
|
|
44
|
+
query: string,
|
|
45
|
+
answer: string,
|
|
46
|
+
qgenAsistantId: number,
|
|
47
|
+
): Promise<RelatedQuestion[]> {
|
|
48
|
+
try {
|
|
49
|
+
const chatSessionId = await createChatSession(
|
|
50
|
+
qgenAsistantId,
|
|
51
|
+
`Q: ${query}`,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const params = {
|
|
55
|
+
message: `Question: ${query}\nAnswer:\n${answer}`,
|
|
56
|
+
alternateAssistantId: qgenAsistantId,
|
|
57
|
+
fileDescriptors: [],
|
|
58
|
+
parentMessageId: null,
|
|
59
|
+
chatSessionId,
|
|
60
|
+
promptId: 0,
|
|
61
|
+
filters: null,
|
|
62
|
+
selectedDocumentIds: [],
|
|
63
|
+
use_agentic_search: false,
|
|
64
|
+
regenerate: false,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
let result = '';
|
|
68
|
+
for await (const packets of sendMessage(params)) {
|
|
69
|
+
for (const packet of packets) {
|
|
70
|
+
if (packet.obj.type === PacketType.MESSAGE_DELTA) {
|
|
71
|
+
result += packet.obj.content;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return extractRelatedQuestions(result);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.error('Error fetching related questions:', error);
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function useChatController({
|
|
84
|
+
personaId,
|
|
85
|
+
enableQgen = false,
|
|
86
|
+
qgenAsistantId,
|
|
87
|
+
deepResearch,
|
|
88
|
+
}: UseChatControllerProps) {
|
|
89
|
+
const [messages, setMessages] = useState<Message[]>([]);
|
|
90
|
+
const [chatSessionId, setChatSessionId] = useState<string | null>(null);
|
|
91
|
+
const [isDeepResearchEnabled, setIsDeepResearchEnabled] = useState(
|
|
92
|
+
deepResearch === 'always_on' || deepResearch === 'user_on',
|
|
93
|
+
);
|
|
94
|
+
const [isFetchingRelatedQuestions, setIsFetchingRelatedQuestions] =
|
|
95
|
+
useState(false);
|
|
96
|
+
const [isCancelled, setIsCancelled] = useState(false);
|
|
97
|
+
|
|
98
|
+
const messagesRef = useRef(messages);
|
|
99
|
+
const nodeIdCounter = useRef(1);
|
|
100
|
+
const isCancelledRef = useRef(isCancelled);
|
|
101
|
+
|
|
102
|
+
// Keep ref in sync with state
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
isCancelledRef.current = isCancelled;
|
|
105
|
+
}, [isCancelled]);
|
|
106
|
+
|
|
107
|
+
const { isStreaming, startStreaming, cancelStreaming } = useChatStreaming({
|
|
108
|
+
onMessageUpdate: (message) => {
|
|
109
|
+
setMessages((prev) => {
|
|
110
|
+
const existingIndex = prev.findIndex(
|
|
111
|
+
(m) => m.nodeId === message.nodeId,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
if (existingIndex >= 0) {
|
|
115
|
+
const updated = [...prev];
|
|
116
|
+
updated[existingIndex] = { ...message };
|
|
117
|
+
return updated;
|
|
118
|
+
}
|
|
119
|
+
messagesRef.current = [...prev, message];
|
|
120
|
+
return messagesRef.current;
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
onComplete: (completedMessage, processor) => {
|
|
124
|
+
// Get real database IDs from backend
|
|
125
|
+
const { userMessageId, assistantMessageId } = processor.messageIds;
|
|
126
|
+
|
|
127
|
+
// Update messages with real IDs
|
|
128
|
+
if (userMessageId || assistantMessageId) {
|
|
129
|
+
setMessages((prev) => {
|
|
130
|
+
const updatedMessages = prev.map((msg) => {
|
|
131
|
+
// Update user message with real ID
|
|
132
|
+
if (
|
|
133
|
+
userMessageId &&
|
|
134
|
+
msg.type === 'user' &&
|
|
135
|
+
msg.nodeId === completedMessage.parentNodeId
|
|
136
|
+
) {
|
|
137
|
+
return { ...msg, messageId: userMessageId };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Update assistant message with real ID
|
|
141
|
+
if (
|
|
142
|
+
assistantMessageId &&
|
|
143
|
+
msg.type === 'assistant' &&
|
|
144
|
+
msg.nodeId === completedMessage.nodeId
|
|
145
|
+
) {
|
|
146
|
+
return { ...msg, messageId: assistantMessageId };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return msg;
|
|
150
|
+
});
|
|
151
|
+
messagesRef.current = updatedMessages;
|
|
152
|
+
return updatedMessages;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
onError: (error) => {
|
|
157
|
+
const errorMessage: Message = {
|
|
158
|
+
messageId: Date.now(),
|
|
159
|
+
nodeId: nodeIdCounter.current++,
|
|
160
|
+
message: '',
|
|
161
|
+
error: `Error: ${error.message}`,
|
|
162
|
+
type: 'error',
|
|
163
|
+
parentNodeId:
|
|
164
|
+
messages.length > 0 ? messages[messages.length - 1].nodeId : null,
|
|
165
|
+
packets: [],
|
|
166
|
+
groupedPackets: [],
|
|
167
|
+
toolPackets: [],
|
|
168
|
+
displayPackets: [],
|
|
169
|
+
files: [],
|
|
170
|
+
toolCall: null,
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
setMessages((prev) => [...prev, errorMessage]);
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const onSubmit = useCallback(
|
|
178
|
+
async ({ message }: { message?: string }) => {
|
|
179
|
+
if (isStreaming) return;
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
// Create session if needed
|
|
183
|
+
let sessionId = chatSessionId;
|
|
184
|
+
|
|
185
|
+
if (!sessionId) {
|
|
186
|
+
sessionId = await createChatSession(personaId, 'Chat session');
|
|
187
|
+
setChatSessionId(sessionId);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let messageText = message;
|
|
191
|
+
let parentNodeId: number | null = null;
|
|
192
|
+
let parentMessageId: number | null = null;
|
|
193
|
+
|
|
194
|
+
// For new messages, set parent to the last assistant message
|
|
195
|
+
const lastMessage = messages
|
|
196
|
+
.filter((m) => m.type === 'assistant')
|
|
197
|
+
.pop();
|
|
198
|
+
|
|
199
|
+
const lastPacket = lastMessage?.packets.pop();
|
|
200
|
+
|
|
201
|
+
if (lastMessage && lastPacket?.obj.type !== PacketType.ERROR) {
|
|
202
|
+
parentNodeId = lastMessage.nodeId;
|
|
203
|
+
parentMessageId = lastMessage.messageId || null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (!messageText?.trim()) return;
|
|
207
|
+
|
|
208
|
+
// Add user message
|
|
209
|
+
const userNodeId = nodeIdCounter.current++;
|
|
210
|
+
const userMessage: Message = {
|
|
211
|
+
messageId: Date.now(),
|
|
212
|
+
nodeId: userNodeId,
|
|
213
|
+
message: messageText,
|
|
214
|
+
type: 'user',
|
|
215
|
+
parentNodeId,
|
|
216
|
+
packets: [],
|
|
217
|
+
time_sent: new Date().toISOString(),
|
|
218
|
+
files: [],
|
|
219
|
+
toolCall: null,
|
|
220
|
+
researchType: isDeepResearchEnabled
|
|
221
|
+
? ResearchType.Deep
|
|
222
|
+
: ResearchType.Fast,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
setMessages((prev) => [...prev, userMessage]);
|
|
226
|
+
|
|
227
|
+
// Start streaming assistant response
|
|
228
|
+
const assistantNodeId = nodeIdCounter.current++;
|
|
229
|
+
await startStreaming(
|
|
230
|
+
{
|
|
231
|
+
message: messageText,
|
|
232
|
+
chatSessionId: sessionId,
|
|
233
|
+
parentMessageId: parentMessageId || null,
|
|
234
|
+
useAgentSearch: isDeepResearchEnabled,
|
|
235
|
+
regenerate: false,
|
|
236
|
+
filters: null,
|
|
237
|
+
selectedDocumentIds: [],
|
|
238
|
+
},
|
|
239
|
+
assistantNodeId,
|
|
240
|
+
userNodeId,
|
|
241
|
+
);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.error('Failed to submit message:', error);
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
[
|
|
247
|
+
chatSessionId,
|
|
248
|
+
personaId,
|
|
249
|
+
isStreaming,
|
|
250
|
+
isDeepResearchEnabled,
|
|
251
|
+
startStreaming,
|
|
252
|
+
messages,
|
|
253
|
+
],
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
const onFetchRelatedQuestions = useCallback(async () => {
|
|
257
|
+
const latestAssistantMessage = messages
|
|
258
|
+
.filter((m) => m.type === 'assistant')
|
|
259
|
+
.pop();
|
|
260
|
+
|
|
261
|
+
if (
|
|
262
|
+
enableQgen &&
|
|
263
|
+
qgenAsistantId &&
|
|
264
|
+
latestAssistantMessage?.type === 'assistant'
|
|
265
|
+
) {
|
|
266
|
+
if (isDeepResearchEnabled) {
|
|
267
|
+
setMessages((prev) => {
|
|
268
|
+
return prev.map((m) =>
|
|
269
|
+
m.nodeId === latestAssistantMessage.nodeId
|
|
270
|
+
? { ...m, relatedQuestions: null }
|
|
271
|
+
: m,
|
|
272
|
+
);
|
|
273
|
+
});
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let relatedQuestions: RelatedQuestion[] | null = null;
|
|
278
|
+
setIsFetchingRelatedQuestions(true);
|
|
279
|
+
|
|
280
|
+
try {
|
|
281
|
+
// Get the parent user message directly from the latest assistant message
|
|
282
|
+
const userMessage = messages.find(
|
|
283
|
+
(m) => m.nodeId === latestAssistantMessage.parentNodeId,
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
if (userMessage && latestAssistantMessage.message) {
|
|
287
|
+
relatedQuestions = await fetchRelatedQuestions(
|
|
288
|
+
userMessage.message,
|
|
289
|
+
latestAssistantMessage.message,
|
|
290
|
+
qgenAsistantId,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
} catch (error) {
|
|
294
|
+
console.error('Failed to fetch related questions:', error);
|
|
295
|
+
} finally {
|
|
296
|
+
setMessages((prev) => {
|
|
297
|
+
return prev.map((m) =>
|
|
298
|
+
m.nodeId === latestAssistantMessage.nodeId
|
|
299
|
+
? { ...m, relatedQuestions }
|
|
300
|
+
: m,
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
setIsFetchingRelatedQuestions(false);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}, [messages, enableQgen, qgenAsistantId, isDeepResearchEnabled]);
|
|
307
|
+
|
|
308
|
+
const clearChat = useCallback(() => {
|
|
309
|
+
setMessages([]);
|
|
310
|
+
setChatSessionId(null);
|
|
311
|
+
nodeIdCounter.current = 1;
|
|
312
|
+
setIsCancelled(false);
|
|
313
|
+
}, []);
|
|
314
|
+
|
|
315
|
+
const handleCancel = useCallback(() => {
|
|
316
|
+
setIsCancelled(true);
|
|
317
|
+
cancelStreaming();
|
|
318
|
+
}, [cancelStreaming]);
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
messages,
|
|
322
|
+
isStreaming,
|
|
323
|
+
isCancelled,
|
|
324
|
+
isFetchingRelatedQuestions,
|
|
325
|
+
onSubmit,
|
|
326
|
+
onFetchRelatedQuestions,
|
|
327
|
+
clearChat,
|
|
328
|
+
cancelStreaming: handleCancel,
|
|
329
|
+
isDeepResearchEnabled,
|
|
330
|
+
setIsDeepResearchEnabled,
|
|
331
|
+
chatSessionId,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Message } from '../types/interfaces';
|
|
2
|
+
import type { SendMessageParams } from '../services/streamingService';
|
|
3
|
+
import { useState, useCallback, useRef } from 'react';
|
|
4
|
+
import { sendMessage } from '../services/streamingService';
|
|
5
|
+
import { MessageProcessor } from '../services/messageProcessor';
|
|
6
|
+
|
|
7
|
+
interface UseChatStreamingProps {
|
|
8
|
+
onMessageUpdate?: (message: Message, processor: MessageProcessor) => void;
|
|
9
|
+
onComplete?: (message: Message, processor: MessageProcessor) => void;
|
|
10
|
+
onError?: (error: Error, processor: MessageProcessor) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useChatStreaming({
|
|
14
|
+
onMessageUpdate,
|
|
15
|
+
onComplete,
|
|
16
|
+
onError,
|
|
17
|
+
}: UseChatStreamingProps = {}) {
|
|
18
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
19
|
+
const [currentMessage, setCurrentMessage] = useState<Message | null>(null);
|
|
20
|
+
const abortControllerRef = useRef<AbortController | null>(null);
|
|
21
|
+
const processorRef = useRef<MessageProcessor | null>(null);
|
|
22
|
+
|
|
23
|
+
const startStreaming = useCallback(
|
|
24
|
+
async (
|
|
25
|
+
params: SendMessageParams,
|
|
26
|
+
nodeId: number,
|
|
27
|
+
parentNodeId: number | null,
|
|
28
|
+
) => {
|
|
29
|
+
// Cancel any existing streaming
|
|
30
|
+
if (abortControllerRef.current) {
|
|
31
|
+
abortControllerRef.current.abort();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Reset previous processor state
|
|
35
|
+
setIsStreaming(true);
|
|
36
|
+
abortControllerRef.current = new AbortController();
|
|
37
|
+
processorRef.current = new MessageProcessor(nodeId, parentNodeId);
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
for await (const packets of sendMessage({
|
|
41
|
+
...params,
|
|
42
|
+
signal: abortControllerRef.current.signal,
|
|
43
|
+
})) {
|
|
44
|
+
processorRef.current.addPackets(packets);
|
|
45
|
+
const message = processorRef.current.getMessage();
|
|
46
|
+
|
|
47
|
+
setCurrentMessage(message);
|
|
48
|
+
onMessageUpdate?.(message, processorRef.current);
|
|
49
|
+
|
|
50
|
+
if (processorRef.current.isComplete) {
|
|
51
|
+
onComplete?.(message, processorRef.current);
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if ((error as Error).name !== 'AbortError') {
|
|
57
|
+
console.error('Streaming error:', error);
|
|
58
|
+
onError?.(error as Error, processorRef.current);
|
|
59
|
+
}
|
|
60
|
+
} finally {
|
|
61
|
+
setIsStreaming(false);
|
|
62
|
+
abortControllerRef.current = null;
|
|
63
|
+
processorRef.current = null;
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
[onMessageUpdate, onComplete, onError],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const cancelStreaming = useCallback(() => {
|
|
70
|
+
if (abortControllerRef.current) {
|
|
71
|
+
abortControllerRef.current.abort();
|
|
72
|
+
setIsStreaming(false);
|
|
73
|
+
}
|
|
74
|
+
}, []);
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
isStreaming,
|
|
78
|
+
currentMessage,
|
|
79
|
+
startStreaming,
|
|
80
|
+
cancelStreaming,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { dequal } from 'dequal';
|
|
3
|
+
|
|
4
|
+
export function useDeepCompareMemoize(dependencies) {
|
|
5
|
+
const dependenciesRef = React.useRef(dependencies);
|
|
6
|
+
const signalRef = React.useRef(0);
|
|
7
|
+
|
|
8
|
+
if (!dequal(dependencies, dependenciesRef.current)) {
|
|
9
|
+
dependenciesRef.current = dependencies;
|
|
10
|
+
signalRef.current += 1;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const currentSignal = signalRef.current;
|
|
14
|
+
|
|
15
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
16
|
+
return React.useMemo(() => dependenciesRef.current, [currentSignal]);
|
|
17
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
// import { marked } from 'marked';
|
|
4
|
+
// import { Renderer } from 'marked';
|
|
5
|
+
// import hljs from 'highlight.js';
|
|
6
|
+
|
|
7
|
+
export function useMarked(libs) {
|
|
8
|
+
const hljs = libs.highlightJs.default;
|
|
9
|
+
const { marked, Renderer } = libs.marked;
|
|
10
|
+
|
|
11
|
+
const renderer = React.useMemo(() => new Renderer(), [Renderer]);
|
|
12
|
+
|
|
13
|
+
renderer.paragraph = (text) => {
|
|
14
|
+
return text + '\n';
|
|
15
|
+
};
|
|
16
|
+
renderer.list = (text) => {
|
|
17
|
+
return `${text}\n\n`;
|
|
18
|
+
};
|
|
19
|
+
renderer.listitem = (text) => {
|
|
20
|
+
return `\n• ${text}`;
|
|
21
|
+
};
|
|
22
|
+
renderer.code = (code, language) => {
|
|
23
|
+
const validLanguage = hljs.getLanguage(language || '')
|
|
24
|
+
? language
|
|
25
|
+
: 'plaintext';
|
|
26
|
+
const lang = validLanguage || 'plaintext';
|
|
27
|
+
const highlightedCode = hljs.highlight(lang, code).value;
|
|
28
|
+
return `<pre class="highlight bg-gray-700" style="padding: 5px; border-radius: 5px; overflow: auto; overflow-wrap: anywhere; white-space: pre-wrap; max-width: 100%; display: block; line-height: 1.2">
|
|
29
|
+
<code class="${language}" style="color: #d6e2ef; font-size: 12px; ">${highlightedCode}</code>
|
|
30
|
+
</pre>`;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
marked.setOptions({ renderer });
|
|
34
|
+
|
|
35
|
+
const parser = React.useCallback(
|
|
36
|
+
async (msg) => {
|
|
37
|
+
const res = await marked.parse(msg);
|
|
38
|
+
return res;
|
|
39
|
+
},
|
|
40
|
+
[marked],
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
return { parser };
|
|
44
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import loadable from '@loadable/component';
|
|
3
|
+
|
|
4
|
+
const Sentry = loadable.lib(
|
|
5
|
+
() => import(/* webpackChunkName: "s_entry-browser" */ '@sentry/browser'), // chunk name avoids ad blockers
|
|
6
|
+
);
|
|
7
|
+
|
|
8
|
+
async function fetchHalloumi(answer, sources, maxContextSegments) {
|
|
9
|
+
const halloumiResponse = await fetch('/_ha/generate', {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: {
|
|
12
|
+
'Content-Type': 'application/json',
|
|
13
|
+
},
|
|
14
|
+
body: JSON.stringify({ answer, sources, maxContextSegments }),
|
|
15
|
+
});
|
|
16
|
+
return halloumiResponse;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const FAILURE_RATIONALE = 'Answer cannot be verified due to empty sources.';
|
|
20
|
+
// const TOOLARGE_RATIONALE = 'Verification failed: Too many sources provided.';
|
|
21
|
+
const TIMEOUT_RATIONALE =
|
|
22
|
+
'Verification failed: Halloumi service is unreachable or timed out.';
|
|
23
|
+
|
|
24
|
+
const empty = (message, rationale, score = 0) => ({
|
|
25
|
+
claims: [
|
|
26
|
+
{
|
|
27
|
+
startOffset: 0,
|
|
28
|
+
endOffset: message.length,
|
|
29
|
+
score,
|
|
30
|
+
rationale,
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
segments: {},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export function useQualityMarkers(
|
|
37
|
+
doQualityControl,
|
|
38
|
+
message,
|
|
39
|
+
sources,
|
|
40
|
+
maxContextSegments = 0,
|
|
41
|
+
) {
|
|
42
|
+
const [halloumiResponse, setHalloumiResponse] = React.useState(null);
|
|
43
|
+
const [isLoading, setIsLoading] = React.useState(false);
|
|
44
|
+
|
|
45
|
+
const retryHalloumi = React.useCallback(() => {
|
|
46
|
+
setHalloumiResponse(null);
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
React.useEffect(() => {
|
|
50
|
+
async function handler() {
|
|
51
|
+
const textSources = sources.map(({ halloumiContext }) => halloumiContext);
|
|
52
|
+
if (sources.length === 0) {
|
|
53
|
+
setHalloumiResponse(empty(message, FAILURE_RATIONALE));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// // console.log('Halloumi sources:', sources.length, sources);
|
|
58
|
+
// if (sources.length > 40) {
|
|
59
|
+
// // eslint-disable-next-line no-console
|
|
60
|
+
// console.warn(
|
|
61
|
+
// `Warning: Too many sources (${sources.length}). Skipping quality control.`,
|
|
62
|
+
// );
|
|
63
|
+
//
|
|
64
|
+
// setHalloumiResponse(empty(message, TOOLARGE_RATIONALE));
|
|
65
|
+
// return;
|
|
66
|
+
// }
|
|
67
|
+
|
|
68
|
+
setIsLoading(true);
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const feedback = await fetchHalloumi(
|
|
72
|
+
message,
|
|
73
|
+
textSources,
|
|
74
|
+
maxContextSegments,
|
|
75
|
+
);
|
|
76
|
+
const body = await feedback.json();
|
|
77
|
+
// console.log({ message, sources, body });
|
|
78
|
+
|
|
79
|
+
if (body.error) {
|
|
80
|
+
setHalloumiResponse(empty(message, TIMEOUT_RATIONALE, null));
|
|
81
|
+
Sentry.load().then((mod) => mod.captureException(body.error));
|
|
82
|
+
} else {
|
|
83
|
+
setHalloumiResponse(body);
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
setHalloumiResponse(empty(message, TIMEOUT_RATIONALE, null));
|
|
87
|
+
throw new Error(`Unknown error fetching halloumi response`);
|
|
88
|
+
} finally {
|
|
89
|
+
setIsLoading(false);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (doQualityControl && !halloumiResponse) {
|
|
94
|
+
handler();
|
|
95
|
+
}
|
|
96
|
+
}, [
|
|
97
|
+
doQualityControl,
|
|
98
|
+
halloumiResponse,
|
|
99
|
+
message,
|
|
100
|
+
sources,
|
|
101
|
+
maxContextSegments,
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
if (halloumiResponse !== null) {
|
|
105
|
+
halloumiResponse.claims = halloumiResponse.claims.filter((claim) => {
|
|
106
|
+
const claim_text = message.substring(claim.startOffset, claim.endOffset);
|
|
107
|
+
const hasSpace = claim_text.trim().includes(' ');
|
|
108
|
+
const hasSpecialCharacters = /[^a-zA-Z0-9 ]/.test(claim_text);
|
|
109
|
+
const hasSmallScore = claim.score < 0.07;
|
|
110
|
+
|
|
111
|
+
return hasSpace || !hasSpecialCharacters || !hasSmallScore;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
markers: halloumiResponse,
|
|
116
|
+
isLoadingHalloumi: isLoading,
|
|
117
|
+
retryHalloumi,
|
|
118
|
+
};
|
|
119
|
+
}
|