@vezlo/assistant-chat 1.6.0 → 1.8.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.
- package/lib/api/citation.d.ts +10 -0
- package/lib/api/citation.js +18 -0
- package/lib/api/index.d.ts +1 -0
- package/lib/api/index.js +1 -0
- package/lib/api/message.d.ts +19 -1
- package/lib/api/message.js +1 -1
- package/lib/components/Widget.js +69 -5
- package/lib/components/ui/CitationView.d.ts +10 -0
- package/lib/components/ui/CitationView.js +79 -0
- package/lib/types/index.d.ts +25 -1
- package/lib/utils/markdown.d.ts +4 -0
- package/lib/utils/markdown.js +50 -0
- package/package.json +4 -1
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Citation API Service
|
|
3
|
+
*/
|
|
4
|
+
export interface CitationContext {
|
|
5
|
+
document_title: string;
|
|
6
|
+
document_type: string;
|
|
7
|
+
file_type?: string;
|
|
8
|
+
content: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function getCitationContext(documentUuid: string, chunkIndices: number[], apiUrl?: string): Promise<CitationContext>;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Citation API Service
|
|
3
|
+
*/
|
|
4
|
+
const DEFAULT_API_BASE_URL = import.meta.env.VITE_ASSISTANT_SERVER_URL || 'http://localhost:3000';
|
|
5
|
+
export async function getCitationContext(documentUuid, chunkIndices, apiUrl) {
|
|
6
|
+
const API_BASE_URL = apiUrl || DEFAULT_API_BASE_URL;
|
|
7
|
+
const indicesParam = chunkIndices.join(',');
|
|
8
|
+
const response = await fetch(`${API_BASE_URL}/api/knowledge/citations/${documentUuid}/context?chunk_indices=${indicesParam}`, {
|
|
9
|
+
method: 'GET',
|
|
10
|
+
headers: {
|
|
11
|
+
'Accept': 'application/json',
|
|
12
|
+
},
|
|
13
|
+
});
|
|
14
|
+
if (!response.ok) {
|
|
15
|
+
throw new Error('Failed to fetch citation context');
|
|
16
|
+
}
|
|
17
|
+
return await response.json();
|
|
18
|
+
}
|
package/lib/api/index.d.ts
CHANGED
package/lib/api/index.js
CHANGED
package/lib/api/message.d.ts
CHANGED
|
@@ -24,6 +24,16 @@ export interface StreamChunkEvent {
|
|
|
24
24
|
type: 'chunk';
|
|
25
25
|
content: string;
|
|
26
26
|
done?: boolean;
|
|
27
|
+
sources?: Array<{
|
|
28
|
+
document_uuid: string;
|
|
29
|
+
document_title: string;
|
|
30
|
+
chunk_indices: number[];
|
|
31
|
+
}>;
|
|
32
|
+
validation?: {
|
|
33
|
+
confidence: number;
|
|
34
|
+
valid: boolean;
|
|
35
|
+
status: string;
|
|
36
|
+
};
|
|
27
37
|
}
|
|
28
38
|
export interface StreamCompletionEvent {
|
|
29
39
|
type: 'completion';
|
|
@@ -39,7 +49,15 @@ export interface StreamErrorEvent {
|
|
|
39
49
|
}
|
|
40
50
|
export type StreamEvent = StreamChunkEvent | StreamCompletionEvent | StreamErrorEvent;
|
|
41
51
|
export interface StreamCallbacks {
|
|
42
|
-
onChunk?: (content: string, isDone?: boolean
|
|
52
|
+
onChunk?: (content: string, isDone?: boolean, sources?: Array<{
|
|
53
|
+
document_uuid: string;
|
|
54
|
+
document_title: string;
|
|
55
|
+
chunk_indices: number[];
|
|
56
|
+
}>, validation?: {
|
|
57
|
+
confidence: number;
|
|
58
|
+
valid: boolean;
|
|
59
|
+
status: string;
|
|
60
|
+
}) => void;
|
|
43
61
|
onCompletion?: (data: StreamCompletionEvent) => void;
|
|
44
62
|
onError?: (error: StreamErrorEvent) => void;
|
|
45
63
|
onDone?: () => void;
|
package/lib/api/message.js
CHANGED
|
@@ -121,7 +121,7 @@ export async function streamAIResponse(userMessageUuid, callbacks, apiUrl) {
|
|
|
121
121
|
const event = JSON.parse(data);
|
|
122
122
|
switch (event.type) {
|
|
123
123
|
case 'chunk':
|
|
124
|
-
callbacks.onChunk?.(event.content, event.done);
|
|
124
|
+
callbacks.onChunk?.(event.content, event.done, event.sources, event.validation);
|
|
125
125
|
break;
|
|
126
126
|
case 'completion':
|
|
127
127
|
callbacks.onCompletion?.(event);
|
package/lib/components/Widget.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect, useRef } from 'react';
|
|
3
3
|
import { createPortal } from 'react-dom';
|
|
4
|
-
import { Send, X, MessageCircle, Bot, ThumbsUp, ThumbsDown } from 'lucide-react';
|
|
4
|
+
import { Send, X, MessageCircle, Bot, ThumbsUp, ThumbsDown, Copy, Check } from 'lucide-react';
|
|
5
5
|
import { generateId, formatTimestamp } from '../utils/index.js';
|
|
6
|
+
import { markdownToHtml } from '../utils/markdown.js';
|
|
6
7
|
import { VezloFooter } from './ui/VezloFooter.js';
|
|
8
|
+
import { CitationView } from './ui/CitationView.js';
|
|
7
9
|
import { createConversation, createUserMessage, streamAIResponse } from '../api/index.js';
|
|
8
10
|
import { submitFeedback, deleteFeedback } from '../api/message.js';
|
|
9
11
|
import { subscribeToConversations } from '../services/conversationRealtime.js';
|
|
@@ -22,6 +24,7 @@ export function Widget({ config, isPlayground = false, onOpen, onClose, onMessag
|
|
|
22
24
|
const [isLoading, setIsLoading] = useState(false);
|
|
23
25
|
const [messageFeedback, setMessageFeedback] = useState({});
|
|
24
26
|
const [messageFeedbackUuids, setMessageFeedbackUuids] = useState({});
|
|
27
|
+
const [copiedMessageId, setCopiedMessageId] = useState(null);
|
|
25
28
|
const [streamingMessage, setStreamingMessage] = useState('');
|
|
26
29
|
const [conversationUuid, setConversationUuid] = useState(null);
|
|
27
30
|
const [companyUuid, setCompanyUuid] = useState(null);
|
|
@@ -83,6 +86,7 @@ export function Widget({ config, isPlayground = false, onOpen, onClose, onMessag
|
|
|
83
86
|
id: generateId(),
|
|
84
87
|
content: config.welcomeMessage || 'Hello! I\'m your AI assistant. How can I help you today?',
|
|
85
88
|
role: 'assistant',
|
|
89
|
+
type: 'assistant', // Set type for button visibility
|
|
86
90
|
timestamp: new Date(),
|
|
87
91
|
};
|
|
88
92
|
setMessages([welcomeMsg]);
|
|
@@ -96,6 +100,7 @@ export function Widget({ config, isPlayground = false, onOpen, onClose, onMessag
|
|
|
96
100
|
id: generateId(),
|
|
97
101
|
content: config.welcomeMessage || 'Hello! I\'m your AI assistant. How can I help you today?',
|
|
98
102
|
role: 'assistant',
|
|
103
|
+
type: 'assistant', // Set type for button visibility
|
|
99
104
|
timestamp: new Date(),
|
|
100
105
|
};
|
|
101
106
|
setMessages([welcomeMsg]);
|
|
@@ -184,7 +189,7 @@ export function Widget({ config, isPlayground = false, onOpen, onClose, onMessag
|
|
|
184
189
|
const tempMessageId = `streaming-${userMessageResponse.uuid}`;
|
|
185
190
|
// Stream AI response using SSE
|
|
186
191
|
await streamAIResponse(userMessageResponse.uuid, {
|
|
187
|
-
onChunk: (chunk, isDone) => {
|
|
192
|
+
onChunk: (chunk, isDone, sources, validation) => {
|
|
188
193
|
// Hide loading indicator on first chunk (streaming started)
|
|
189
194
|
if (!hasReceivedChunks) {
|
|
190
195
|
hasReceivedChunks = true;
|
|
@@ -198,12 +203,20 @@ export function Widget({ config, isPlayground = false, onOpen, onClose, onMessag
|
|
|
198
203
|
// If this is the final chunk (done=true), convert to message immediately
|
|
199
204
|
if (isDone && !streamingComplete) {
|
|
200
205
|
streamingComplete = true;
|
|
201
|
-
|
|
206
|
+
console.log('Stream complete, sources:', sources, 'validation:', validation);
|
|
207
|
+
// Add message to array with temp ID, sources, and validation
|
|
202
208
|
const tempMessage = {
|
|
203
209
|
id: tempMessageId,
|
|
204
210
|
content: accumulatedContent,
|
|
205
211
|
role: 'assistant',
|
|
212
|
+
type: 'assistant', // Set type for button visibility
|
|
206
213
|
timestamp: new Date(),
|
|
214
|
+
sources: sources && sources.length > 0 ? sources.map(s => ({
|
|
215
|
+
document_uuid: s.document_uuid,
|
|
216
|
+
document_title: s.document_title,
|
|
217
|
+
chunk_indices: s.chunk_indices
|
|
218
|
+
})) : undefined,
|
|
219
|
+
validation: validation
|
|
207
220
|
};
|
|
208
221
|
setStreamingMessage('');
|
|
209
222
|
setMessages((prev) => [...prev, tempMessage]);
|
|
@@ -219,6 +232,7 @@ export function Widget({ config, isPlayground = false, onOpen, onClose, onMessag
|
|
|
219
232
|
id: completionData.uuid,
|
|
220
233
|
content: accumulatedContent,
|
|
221
234
|
role: 'assistant',
|
|
235
|
+
type: 'assistant', // Set type for button visibility
|
|
222
236
|
timestamp: new Date(completionData.created_at),
|
|
223
237
|
};
|
|
224
238
|
onMessage?.(finalMessage);
|
|
@@ -262,6 +276,7 @@ export function Widget({ config, isPlayground = false, onOpen, onClose, onMessag
|
|
|
262
276
|
id: generateId(),
|
|
263
277
|
content: 'Sorry, I encountered an error processing your message. Please try again.',
|
|
264
278
|
role: 'assistant',
|
|
279
|
+
type: 'assistant', // Set type for button visibility
|
|
265
280
|
timestamp: new Date(),
|
|
266
281
|
};
|
|
267
282
|
setMessages((prev) => [...prev, errorMessage]);
|
|
@@ -289,6 +304,19 @@ export function Widget({ config, isPlayground = false, onOpen, onClose, onMessag
|
|
|
289
304
|
handleSendMessage();
|
|
290
305
|
}
|
|
291
306
|
};
|
|
307
|
+
const handleCopyMessage = async (messageId) => {
|
|
308
|
+
const message = messages.find(m => m.id === messageId);
|
|
309
|
+
if (!message || message.role !== 'assistant')
|
|
310
|
+
return;
|
|
311
|
+
try {
|
|
312
|
+
await navigator.clipboard.writeText(message.content);
|
|
313
|
+
setCopiedMessageId(messageId);
|
|
314
|
+
setTimeout(() => setCopiedMessageId(null), 2000);
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
console.error('Failed to copy message:', err);
|
|
318
|
+
}
|
|
319
|
+
};
|
|
292
320
|
const handleFeedback = async (messageId, type) => {
|
|
293
321
|
// Find the message to get the real UUID (might be stored in _realUuid)
|
|
294
322
|
const message = messages.find(m => m.id === messageId);
|
|
@@ -465,13 +493,49 @@ export function Widget({ config, isPlayground = false, onOpen, onClose, onMessag
|
|
|
465
493
|
boxShadow: message.role === 'user'
|
|
466
494
|
? `0 4px 12px ${(config.themeColor || THEME.primary.hex)}4D` // 4D is ~30% opacity
|
|
467
495
|
: '0 2px 8px rgba(0, 0, 0, 0.1)'
|
|
468
|
-
}, children: _jsx("p", { className: "text-sm whitespace-pre-wrap break-words leading-relaxed", children: message.content }) }), _jsxs("div", { className: "flex items-center justify-between mt-1", children: [_jsx("p", { className: `text-xs ${message.role === 'user' ? 'text-emerald-100' : 'text-gray-500'}`, children: formatTimestamp(message.timestamp) }), message.role === 'assistant' && (() => {
|
|
496
|
+
}, children: message.role === 'user' ? (_jsx("p", { className: "text-sm whitespace-pre-wrap break-words leading-relaxed", children: message.content })) : (_jsxs("div", { children: [_jsx("div", { className: "text-sm prose prose-sm max-w-none prose-p:my-2 prose-headings:my-2 prose-ul:my-2 prose-ol:my-2 prose-li:my-1 prose-pre:my-2 prose-code:text-xs [&_pre]:overflow-x-auto [&_pre]:max-w-full [&_code]:break-words", dangerouslySetInnerHTML: { __html: markdownToHtml(message.content) } }), message.sources && message.sources.length > 0 && (_jsx(CitationView, { sources: message.sources }))] })) }), _jsxs("div", { className: "flex items-center justify-between mt-1", children: [_jsx("p", { className: `text-xs ${message.role === 'user' ? 'text-emerald-100' : 'text-gray-500'}`, children: formatTimestamp(message.timestamp) }), message.role === 'assistant' && message.type === 'assistant' && (() => {
|
|
469
497
|
// Check if message has real UUID (not temp ID)
|
|
470
498
|
// Disable if: message has temp ID (starts with 'streaming-') AND no _realUuid yet
|
|
471
499
|
const isTempId = message.id?.startsWith('streaming-') || false;
|
|
472
500
|
const hasRealUuid = !!message._realUuid;
|
|
473
501
|
const isDisabled = isTempId && !hasRealUuid;
|
|
474
|
-
return (_jsxs("div", { className: "flex items-center gap-1 ml-2", children: [_jsx("
|
|
502
|
+
return (_jsxs("div", { className: "flex items-center gap-1 ml-2", children: [message.validation && (_jsx("div", { className: "relative w-5 h-5 cursor-pointer", title: `AI Validation\nConfidence: ${(message.validation.confidence * 100).toFixed(1)}%\nStatus: ${message.validation.status}${message.validation.accuracy ? `\n\nAccuracy:\n Verified: ${message.validation.accuracy.verified ? 'Yes' : 'No'}\n Rate: ${(message.validation.accuracy.verification_rate * 100).toFixed(1)}%` : ''}${message.validation.hallucination ? `\n\nHallucination:\n Detected: ${message.validation.hallucination.detected ? 'Yes' : 'No'}\n Risk: ${(message.validation.hallucination.risk * 100).toFixed(1)}%` : ''}${message.validation.context ? `\n\nContext:\n Relevance: ${(message.validation.context.source_relevance * 100).toFixed(1)}%\n Usage: ${(message.validation.context.source_usage_rate * 100).toFixed(1)}%` : ''}`, children: _jsxs("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { cx: "12", cy: "12", r: "9.5", className: message.validation.confidence > 0.7
|
|
503
|
+
? 'fill-green-500'
|
|
504
|
+
: message.validation.confidence > 0.5
|
|
505
|
+
? 'fill-amber-500'
|
|
506
|
+
: 'fill-red-500' }), _jsx("path", { d: "M12 0.5 L13.5 3 L10.5 3 Z", className: message.validation.confidence > 0.7
|
|
507
|
+
? 'fill-green-500'
|
|
508
|
+
: message.validation.confidence > 0.5
|
|
509
|
+
? 'fill-amber-500'
|
|
510
|
+
: 'fill-red-500' }), _jsx("path", { d: "M20.5 4.5 L19 7 L18 5 Z", className: message.validation.confidence > 0.7
|
|
511
|
+
? 'fill-green-500'
|
|
512
|
+
: message.validation.confidence > 0.5
|
|
513
|
+
? 'fill-amber-500'
|
|
514
|
+
: 'fill-red-500' }), _jsx("path", { d: "M23.5 12 L21 13.5 L21 10.5 Z", className: message.validation.confidence > 0.7
|
|
515
|
+
? 'fill-green-500'
|
|
516
|
+
: message.validation.confidence > 0.5
|
|
517
|
+
? 'fill-amber-500'
|
|
518
|
+
: 'fill-red-500' }), _jsx("path", { d: "M20.5 19.5 L18 19 L19 17 Z", className: message.validation.confidence > 0.7
|
|
519
|
+
? 'fill-green-500'
|
|
520
|
+
: message.validation.confidence > 0.5
|
|
521
|
+
? 'fill-amber-500'
|
|
522
|
+
: 'fill-red-500' }), _jsx("path", { d: "M12 23.5 L10.5 21 L13.5 21 Z", className: message.validation.confidence > 0.7
|
|
523
|
+
? 'fill-green-500'
|
|
524
|
+
: message.validation.confidence > 0.5
|
|
525
|
+
? 'fill-amber-500'
|
|
526
|
+
: 'fill-red-500' }), _jsx("path", { d: "M3.5 19.5 L5 17 L6 19 Z", className: message.validation.confidence > 0.7
|
|
527
|
+
? 'fill-green-500'
|
|
528
|
+
: message.validation.confidence > 0.5
|
|
529
|
+
? 'fill-amber-500'
|
|
530
|
+
: 'fill-red-500' }), _jsx("path", { d: "M0.5 12 L3 10.5 L3 13.5 Z", className: message.validation.confidence > 0.7
|
|
531
|
+
? 'fill-green-500'
|
|
532
|
+
: message.validation.confidence > 0.5
|
|
533
|
+
? 'fill-amber-500'
|
|
534
|
+
: 'fill-red-500' }), _jsx("path", { d: "M3.5 4.5 L6 5 L5 7 Z", className: message.validation.confidence > 0.7
|
|
535
|
+
? 'fill-green-500'
|
|
536
|
+
: message.validation.confidence > 0.5
|
|
537
|
+
? 'fill-amber-500'
|
|
538
|
+
: 'fill-red-500' }), _jsx("path", { d: "M8.5 12L10.5 14L15.5 9", stroke: "white", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", fill: "none" })] }) })), _jsx("button", { onClick: () => handleCopyMessage(message.id), className: "p-1 rounded transition-all duration-200 hover:scale-110 cursor-pointer text-gray-400 hover:text-gray-600", title: "Copy response", children: copiedMessageId === message.id ? (_jsx(Check, { className: "w-4 h-4 text-green-600" })) : (_jsx(Copy, { className: "w-4 h-4" })) }), _jsx("button", { onClick: () => !isDisabled && handleFeedback(message.id, 'like'), disabled: isDisabled, className: `p-1 rounded transition-all duration-200 ${isDisabled
|
|
475
539
|
? 'opacity-40 cursor-not-allowed'
|
|
476
540
|
: 'hover:scale-110 cursor-pointer'} ${messageFeedback[message.id] === 'like'
|
|
477
541
|
? 'text-green-600'
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
interface Source {
|
|
2
|
+
document_uuid: string;
|
|
3
|
+
document_title: string;
|
|
4
|
+
chunk_indices: number[];
|
|
5
|
+
}
|
|
6
|
+
interface CitationViewProps {
|
|
7
|
+
sources: Source[];
|
|
8
|
+
}
|
|
9
|
+
export declare function CitationView({ sources }: CitationViewProps): import("react/jsx-runtime").JSX.Element;
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Download, Loader2 } from 'lucide-react';
|
|
4
|
+
import { getCitationContext } from '../../api/citation';
|
|
5
|
+
// MIME type to extension mapping
|
|
6
|
+
const mimeToExtension = {
|
|
7
|
+
'text/javascript': '.js',
|
|
8
|
+
'application/javascript': '.js',
|
|
9
|
+
'text/jsx': '.jsx',
|
|
10
|
+
'text/typescript': '.ts',
|
|
11
|
+
'application/typescript': '.ts',
|
|
12
|
+
'text/tsx': '.tsx',
|
|
13
|
+
'text/x-python': '.py',
|
|
14
|
+
'text/python': '.py',
|
|
15
|
+
'text/markdown': '.md',
|
|
16
|
+
'text/x-markdown': '.md',
|
|
17
|
+
'application/json': '.json',
|
|
18
|
+
'text/html': '.html',
|
|
19
|
+
'text/css': '.css',
|
|
20
|
+
'text/plain': '.txt',
|
|
21
|
+
'text/xml': '.xml',
|
|
22
|
+
};
|
|
23
|
+
function getFileExtension(title, fileType) {
|
|
24
|
+
// First try to extract from filename
|
|
25
|
+
const match = title.match(/\.([a-zA-Z0-9]+)$/);
|
|
26
|
+
if (match) {
|
|
27
|
+
return `.${match[1]}`;
|
|
28
|
+
}
|
|
29
|
+
// Fallback to MIME type mapping
|
|
30
|
+
if (fileType && mimeToExtension[fileType]) {
|
|
31
|
+
return mimeToExtension[fileType];
|
|
32
|
+
}
|
|
33
|
+
// Default to .txt
|
|
34
|
+
return '.txt';
|
|
35
|
+
}
|
|
36
|
+
export function CitationView({ sources }) {
|
|
37
|
+
const [loadingUuid, setLoadingUuid] = useState(null);
|
|
38
|
+
const handleDownloadSource = async (source) => {
|
|
39
|
+
setLoadingUuid(source.document_uuid);
|
|
40
|
+
try {
|
|
41
|
+
if (!source.document_uuid || !source.chunk_indices || source.chunk_indices.length === 0) {
|
|
42
|
+
setLoadingUuid(null);
|
|
43
|
+
alert('Invalid source data. UUID or chunk indices are missing.');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const context = await getCitationContext(source.document_uuid, source.chunk_indices);
|
|
47
|
+
if (!context || !context.content) {
|
|
48
|
+
setLoadingUuid(null);
|
|
49
|
+
alert('No content available for this source.');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// Determine file extension
|
|
53
|
+
const extension = getFileExtension(context.document_title, context.file_type);
|
|
54
|
+
const filename = context.document_title.endsWith(extension)
|
|
55
|
+
? context.document_title
|
|
56
|
+
: `${context.document_title}${extension}`;
|
|
57
|
+
// Create blob and download
|
|
58
|
+
const blob = new Blob([context.content], { type: 'text/plain' });
|
|
59
|
+
const url = URL.createObjectURL(blob);
|
|
60
|
+
const a = document.createElement('a');
|
|
61
|
+
a.href = url;
|
|
62
|
+
a.download = filename;
|
|
63
|
+
document.body.appendChild(a);
|
|
64
|
+
a.click();
|
|
65
|
+
document.body.removeChild(a);
|
|
66
|
+
URL.revokeObjectURL(url);
|
|
67
|
+
setLoadingUuid(null);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
console.error('Failed to download source:', err);
|
|
71
|
+
setLoadingUuid(null);
|
|
72
|
+
alert('Failed to download source content. Please try again.');
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
return (_jsxs("div", { className: "mt-2 border-t border-gray-100 pt-2", children: [_jsx("div", { className: "text-xs text-gray-500 mb-1 font-medium", children: "Sources:" }), _jsx("div", { className: "space-y-1", children: sources.map((source, index) => {
|
|
76
|
+
const isLoading = loadingUuid === source.document_uuid;
|
|
77
|
+
return (_jsxs("button", { onClick: () => handleDownloadSource(source), disabled: isLoading, className: "w-full text-left px-2 py-1.5 text-xs text-emerald-700 hover:text-emerald-800 hover:bg-emerald-50 rounded flex items-center gap-1 transition-colors group cursor-pointer disabled:opacity-70 disabled:cursor-wait", children: [isLoading ? (_jsx(Loader2, { className: "w-3 h-3 flex-shrink-0 animate-spin" })) : (_jsx(Download, { className: "w-3 h-3 flex-shrink-0" })), _jsx("span", { className: "truncate", children: source.document_title })] }, index));
|
|
78
|
+
}) })] }));
|
|
79
|
+
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -21,12 +21,36 @@ export interface ChatMessage {
|
|
|
21
21
|
id: string;
|
|
22
22
|
content: string;
|
|
23
23
|
role: 'user' | 'assistant' | 'system';
|
|
24
|
+
type?: 'user' | 'assistant' | 'agent' | 'system';
|
|
24
25
|
timestamp: Date;
|
|
25
26
|
sources?: ChatSource[];
|
|
27
|
+
validation?: {
|
|
28
|
+
confidence: number;
|
|
29
|
+
valid: boolean;
|
|
30
|
+
status: string;
|
|
31
|
+
accuracy?: {
|
|
32
|
+
verified: boolean;
|
|
33
|
+
verification_rate: number;
|
|
34
|
+
reason?: string;
|
|
35
|
+
};
|
|
36
|
+
hallucination?: {
|
|
37
|
+
detected: boolean;
|
|
38
|
+
risk: number;
|
|
39
|
+
reason?: string;
|
|
40
|
+
};
|
|
41
|
+
context?: {
|
|
42
|
+
source_relevance: number;
|
|
43
|
+
source_usage_rate: number;
|
|
44
|
+
valid: boolean;
|
|
45
|
+
};
|
|
46
|
+
};
|
|
26
47
|
_realUuid?: string;
|
|
27
48
|
}
|
|
28
49
|
export interface ChatSource {
|
|
29
|
-
|
|
50
|
+
document_uuid: string;
|
|
51
|
+
document_title: string;
|
|
52
|
+
chunk_indices: number[];
|
|
53
|
+
title?: string;
|
|
30
54
|
url?: string;
|
|
31
55
|
content?: string;
|
|
32
56
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { marked } from 'marked';
|
|
2
|
+
import DOMPurify from 'dompurify';
|
|
3
|
+
/**
|
|
4
|
+
* Check if content contains markdown syntax
|
|
5
|
+
*/
|
|
6
|
+
function hasMarkdownSyntax(content) {
|
|
7
|
+
// Check for common markdown patterns
|
|
8
|
+
const mdPatterns = [
|
|
9
|
+
/#{1,6}\s/, // Headers
|
|
10
|
+
/\*\*.*?\*\*/, // Bold
|
|
11
|
+
/__.*?__/, // Bold (alt)
|
|
12
|
+
/\*.*?\*/, // Italic
|
|
13
|
+
/_.*?_/, // Italic (alt)
|
|
14
|
+
/\[.*?\]\(.*?\)/, // Links
|
|
15
|
+
/`.*?`/, // Inline code
|
|
16
|
+
/```[\s\S]*?```/, // Code blocks
|
|
17
|
+
/^\s*[-*+]\s/m, // Unordered lists
|
|
18
|
+
/^\s*\d+\.\s/m, // Ordered lists
|
|
19
|
+
/^\s*>\s/m, // Blockquotes
|
|
20
|
+
];
|
|
21
|
+
return mdPatterns.some(pattern => pattern.test(content));
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Convert markdown to sanitized HTML
|
|
25
|
+
*/
|
|
26
|
+
export function markdownToHtml(content) {
|
|
27
|
+
// If no markdown syntax detected, return as-is wrapped in paragraph
|
|
28
|
+
if (!hasMarkdownSyntax(content)) {
|
|
29
|
+
return `<p>${DOMPurify.sanitize(content)}</p>`;
|
|
30
|
+
}
|
|
31
|
+
// Configure marked for safe rendering
|
|
32
|
+
marked.setOptions({
|
|
33
|
+
breaks: true,
|
|
34
|
+
gfm: true,
|
|
35
|
+
});
|
|
36
|
+
// Convert markdown to HTML
|
|
37
|
+
const rawHtml = marked(content);
|
|
38
|
+
// Sanitize HTML to prevent XSS
|
|
39
|
+
return DOMPurify.sanitize(rawHtml, {
|
|
40
|
+
ALLOWED_TAGS: [
|
|
41
|
+
'p', 'br', 'strong', 'em', 'u', 'code', 'pre',
|
|
42
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
43
|
+
'ul', 'ol', 'li',
|
|
44
|
+
'blockquote',
|
|
45
|
+
'a',
|
|
46
|
+
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
|
47
|
+
],
|
|
48
|
+
ALLOWED_ATTR: ['href', 'target', 'rel'],
|
|
49
|
+
});
|
|
50
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vezlo/assistant-chat",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "React component library for AI-powered chat widgets with RAG knowledge base integration, realtime updates, and human agent support",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -34,7 +34,9 @@
|
|
|
34
34
|
"@supabase/supabase-js": "^2.84.0",
|
|
35
35
|
"clsx": "^2.1.1",
|
|
36
36
|
"date-fns": "^4.1.0",
|
|
37
|
+
"dompurify": "^3.3.1",
|
|
37
38
|
"lucide-react": "^0.544.0",
|
|
39
|
+
"marked": "^17.0.1",
|
|
38
40
|
"react-router-dom": "^7.9.3",
|
|
39
41
|
"tailwindcss": "^4.1.14"
|
|
40
42
|
},
|
|
@@ -42,6 +44,7 @@
|
|
|
42
44
|
"@eslint/js": "^9.36.0",
|
|
43
45
|
"@tailwindcss/postcss": "^4.1.14",
|
|
44
46
|
"@tailwindcss/typography": "^0.5.19",
|
|
47
|
+
"@types/dompurify": "^3.0.5",
|
|
45
48
|
"@types/node": "^24.6.2",
|
|
46
49
|
"@types/react": "^19.1.16",
|
|
47
50
|
"@types/react-dom": "^19.1.9",
|