mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-21 07:01:04 +08:00
Introduce comprehensive floating widget customization: add new widget settings (title, subtitle, footer, colors, mute, streaming) with types and defaults, and expose them via EmbedDialog UI (split into Embed Setup and Widget Customization tabs). Persist and load settings through Agent page by reading/writing globals and wiring an onSaveWidgetSettings handler to setAgent; show a loading ButtonLoading for saving. Update embed iframe query params and FloatingChatWidget to honor URL params (colors, text, mute/streaming) with validation/normalization, color darkening for gradients, footer link normalization, and improved styling. Also add copy-to-clipboard in message toolbar, adjust syntax highlighter layout and Copy button, and add i18n key for muteWidget. ### What problem does this PR solve? Adds a few fields to the embed widget modal to customize the appearance of the floating widget when embedded into a page. ### Type of change - [x] New Feature (non-breaking change which adds functionality) --------- Co-authored-by: Noah <Noah.Thompson@ecn.forces.gc.ca>
963 lines
33 KiB
TypeScript
963 lines
33 KiB
TypeScript
import CopyToClipboard from '@/components/copy-to-clipboard';
|
|
import PdfSheet from '@/components/pdf-drawer';
|
|
import { useClickDrawer } from '@/components/pdf-drawer/hooks';
|
|
import { MessageType, SharedFrom } from '@/constants/chat';
|
|
import { useFetchExternalAgentInputs } from '@/hooks/use-agent-request';
|
|
import { useFetchExternalChatInfo } from '@/hooks/use-chat-request';
|
|
import i18n, { changeLanguageAsync } from '@/locales/config';
|
|
import { useSendNextSharedMessage } from '@/pages/agent/hooks/use-send-shared-message';
|
|
import { MessageCircle, Minimize2, Send, X } from 'lucide-react';
|
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
useGetSharedChatSearchParams,
|
|
useSendSharedMessage,
|
|
} from '../pages/next-chats/hooks/use-send-shared-message';
|
|
import FloatingChatWidgetMarkdown from './floating-chat-widget-markdown';
|
|
|
|
/**
|
|
* Normalizes a hex color input and falls back to a safe default when invalid.
|
|
*/
|
|
const normalizeHexColor = (value: string | null, fallback: string) => {
|
|
return value && /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(value)
|
|
? value
|
|
: fallback;
|
|
};
|
|
|
|
/**
|
|
* Darkens a hex color to derive hover and gradient variants for the widget chrome.
|
|
*/
|
|
const darkenHexColor = (hexColor: string, amount = 0.12) => {
|
|
const normalizedHex = hexColor.replace('#', '');
|
|
const expandedHex =
|
|
normalizedHex.length === 3
|
|
? normalizedHex
|
|
.split('')
|
|
.map((char) => `${char}${char}`)
|
|
.join('')
|
|
: normalizedHex;
|
|
const channels = expandedHex.match(/.{2}/g);
|
|
|
|
if (!channels) {
|
|
return hexColor;
|
|
}
|
|
|
|
return `#${channels
|
|
.map((channel) => {
|
|
const value = parseInt(channel, 16);
|
|
const adjustedValue = Math.max(
|
|
0,
|
|
Math.min(255, Math.round(value * (1 - amount))),
|
|
);
|
|
return adjustedValue.toString(16).padStart(2, '0');
|
|
})
|
|
.join('')}`;
|
|
};
|
|
|
|
/**
|
|
* Accepts a footer link from the widget query string and returns a safe HTTP(S) URL.
|
|
*/
|
|
const normalizeWidgetFooterLink = (value: string | null) => {
|
|
const normalizedValue = value?.trim();
|
|
|
|
if (!normalizedValue) {
|
|
return undefined;
|
|
}
|
|
|
|
const candidate = /^[a-z][a-z\d+.-]*:/i.test(normalizedValue)
|
|
? normalizedValue
|
|
: `https://${normalizedValue}`;
|
|
|
|
try {
|
|
const url = new URL(candidate);
|
|
|
|
if (url.protocol === 'http:' || url.protocol === 'https:') {
|
|
return url.toString();
|
|
}
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
|
|
return undefined;
|
|
};
|
|
|
|
/**
|
|
* Renders the embeddable floating chat widget and applies URL-driven widget settings.
|
|
*/
|
|
const FloatingChatWidget = () => {
|
|
const { t } = useTranslation();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isMinimized, setIsMinimized] = useState(false);
|
|
const [inputValue, setInputValue] = useState('');
|
|
const [lastResponseId, setLastResponseId] = useState<string | null>(null);
|
|
const [displayMessages, setDisplayMessages] = useState<any[]>([]);
|
|
const [isLoaded, setIsLoaded] = useState(false);
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
const {
|
|
sharedId: conversationId,
|
|
locale,
|
|
from,
|
|
} = useGetSharedChatSearchParams();
|
|
|
|
const isFromAgent = from === SharedFrom.Agent;
|
|
|
|
// Check if we're in button-only mode or window-only mode
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const mode = urlParams.get('mode') || 'full'; // 'button', 'window', or 'full'
|
|
const enableStreaming = urlParams.get('streaming') === 'true'; // Only enable if explicitly set to true
|
|
const isMuted = urlParams.get('muted') === 'true';
|
|
const widgetTitle = urlParams.get('widget_title')?.trim();
|
|
const widgetSubtitle = urlParams.get('widget_subtitle')?.trim();
|
|
const widgetFooter = urlParams.get('widget_footer')?.trim();
|
|
const widgetFooterLink = normalizeWidgetFooterLink(
|
|
urlParams.get('widget_footer_link'),
|
|
);
|
|
const widgetAccentColor = normalizeHexColor(
|
|
urlParams.get('widget_accent_color'),
|
|
'#2563eb',
|
|
);
|
|
const widgetAccentColorStrong = darkenHexColor(widgetAccentColor);
|
|
const widgetBackgroundColor = normalizeHexColor(
|
|
urlParams.get('widget_background_color'),
|
|
'#ffffff',
|
|
);
|
|
const widgetTextColor = normalizeHexColor(
|
|
urlParams.get('widget_text_color'),
|
|
'#111827',
|
|
);
|
|
const widgetHeaderTextColor = normalizeHexColor(
|
|
urlParams.get('widget_header_text_color'),
|
|
'#ffffff',
|
|
);
|
|
const widgetFooterTextColor = normalizeHexColor(
|
|
urlParams.get('widget_footer_text_color'),
|
|
'#111827',
|
|
);
|
|
|
|
const {
|
|
handlePressEnter,
|
|
handleInputChange,
|
|
value: hookValue,
|
|
sendLoading,
|
|
derivedMessages,
|
|
hasError,
|
|
} = (isFromAgent ? useSendNextSharedMessage : useSendSharedMessage)(() => {});
|
|
|
|
// Sync our local input with the hook's value when needed
|
|
useEffect(() => {
|
|
if (hookValue && hookValue !== inputValue) {
|
|
setInputValue(hookValue);
|
|
}
|
|
}, [hookValue, inputValue]);
|
|
|
|
const { data } = (
|
|
isFromAgent ? useFetchExternalAgentInputs : useFetchExternalChatInfo
|
|
)();
|
|
|
|
const title = data.title;
|
|
const displayTitle = widgetTitle || title || t('chat.chatSupport');
|
|
const displaySubtitle = widgetSubtitle || t('chat.replyInstantly');
|
|
const displayFooter = widgetFooter || '';
|
|
const renderFooter = () => {
|
|
if (!displayFooter) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="mt-3 -mx-4 -mb-4 px-4 py-3 text-center text-xs"
|
|
style={{
|
|
backgroundColor: widgetAccentColor,
|
|
color: widgetFooterTextColor,
|
|
}}
|
|
>
|
|
{widgetFooterLink ? (
|
|
<a
|
|
href={widgetFooterLink}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="underline underline-offset-2 hover:opacity-90"
|
|
style={{ color: widgetFooterTextColor }}
|
|
>
|
|
{displayFooter}
|
|
</a>
|
|
) : (
|
|
displayFooter
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
const bodyContainerStyle: React.CSSProperties = {
|
|
borderRadius: '0 0 16px 16px',
|
|
backgroundColor: widgetBackgroundColor,
|
|
color: widgetTextColor,
|
|
};
|
|
const inputStyle: React.CSSProperties = {
|
|
minHeight: '44px',
|
|
maxHeight: '120px',
|
|
color: widgetTextColor,
|
|
backgroundColor: widgetBackgroundColor,
|
|
};
|
|
|
|
const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } =
|
|
useClickDrawer();
|
|
|
|
// PDF drawer state tracking
|
|
useEffect(() => {
|
|
// Drawer state management
|
|
}, [visible, documentId, selectedChunk]);
|
|
|
|
// Play sound when opening
|
|
const playNotificationSound = useCallback(() => {
|
|
if (isMuted) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const audioContext = new (
|
|
window.AudioContext || (window as any).webkitAudioContext
|
|
)();
|
|
const oscillator = audioContext.createOscillator();
|
|
const gainNode = audioContext.createGain();
|
|
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(audioContext.destination);
|
|
|
|
oscillator.frequency.value = 800;
|
|
oscillator.type = 'sine';
|
|
|
|
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
|
gainNode.gain.exponentialRampToValueAtTime(
|
|
0.01,
|
|
audioContext.currentTime + 0.3,
|
|
);
|
|
|
|
oscillator.start(audioContext.currentTime);
|
|
oscillator.stop(audioContext.currentTime + 0.3);
|
|
} catch (error) {
|
|
console.warn(error);
|
|
// Silent fail if audio not supported
|
|
}
|
|
}, [isMuted]);
|
|
|
|
// Play sound for AI responses (Intercom-style)
|
|
const playResponseSound = useCallback(() => {
|
|
if (isMuted) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const audioContext = new (
|
|
window.AudioContext || (window as any).webkitAudioContext
|
|
)();
|
|
const oscillator = audioContext.createOscillator();
|
|
const gainNode = audioContext.createGain();
|
|
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(audioContext.destination);
|
|
|
|
oscillator.frequency.value = 600;
|
|
oscillator.type = 'sine';
|
|
|
|
gainNode.gain.setValueAtTime(0.2, audioContext.currentTime);
|
|
gainNode.gain.exponentialRampToValueAtTime(
|
|
0.01,
|
|
audioContext.currentTime + 0.2,
|
|
);
|
|
|
|
oscillator.start(audioContext.currentTime);
|
|
oscillator.stop(audioContext.currentTime + 0.2);
|
|
} catch (error) {
|
|
console.warn(error);
|
|
|
|
// Silent fail if audio not supported
|
|
}
|
|
}, [isMuted]);
|
|
|
|
// Set loaded state and locale
|
|
useEffect(() => {
|
|
// Set component as loaded after a brief moment to prevent flash
|
|
const timer = setTimeout(() => {
|
|
setIsLoaded(true);
|
|
// Tell parent window that we're ready to be shown
|
|
window.parent.postMessage(
|
|
{
|
|
type: 'WIDGET_READY',
|
|
},
|
|
'*',
|
|
);
|
|
}, 50);
|
|
|
|
if (locale && i18n.language !== locale) {
|
|
changeLanguageAsync(locale);
|
|
}
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [locale]);
|
|
|
|
// Handle message display based on streaming preference
|
|
useEffect(() => {
|
|
if (!derivedMessages) {
|
|
setDisplayMessages([]);
|
|
return;
|
|
}
|
|
|
|
if (enableStreaming) {
|
|
// Show messages as they stream
|
|
setDisplayMessages(derivedMessages);
|
|
} else {
|
|
// Only show complete messages (non-streaming mode)
|
|
const completeMessages = derivedMessages.filter((msg, index) => {
|
|
// Always show user messages immediately
|
|
if (msg.role === MessageType.User) return true;
|
|
|
|
// For AI messages, only show when response is complete (not loading)
|
|
if (msg.role === MessageType.Assistant) {
|
|
return !sendLoading || index < derivedMessages.length - 1;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
setDisplayMessages(completeMessages);
|
|
}
|
|
}, [derivedMessages, enableStreaming, sendLoading]);
|
|
|
|
// Auto-scroll to bottom when display messages change
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
}, [displayMessages]);
|
|
|
|
// Render different content based on mode
|
|
// Master mode - handles everything and creates second iframe dynamically
|
|
useEffect(() => {
|
|
if (mode !== 'master') return;
|
|
|
|
const isInIframe = window.self !== window.top;
|
|
|
|
if (isInIframe) {
|
|
// Embedded: tell parent to create chat window iframe
|
|
window.parent.postMessage(
|
|
{
|
|
type: 'CREATE_CHAT_WINDOW',
|
|
src: window.location.href.replace('mode=master', 'mode=window'),
|
|
},
|
|
'*',
|
|
);
|
|
} else {
|
|
// Standalone: create chat window iframe ourselves
|
|
if (!document.getElementById('chat-win')) {
|
|
const i = document.createElement('iframe');
|
|
i.id = 'chat-win';
|
|
i.src = window.location.href.replace('mode=master', 'mode=window');
|
|
i.style.cssText =
|
|
'position:fixed;bottom:104px;right:24px;width:380px;height:500px;border:none;background:transparent;z-index:9998;display:none';
|
|
i.frameBorder = '0';
|
|
i.allow = 'microphone;camera';
|
|
document.body.appendChild(i);
|
|
}
|
|
}
|
|
|
|
// Listen for toggle messages to show/hide the chat window iframe
|
|
const handleToggle = (e: MessageEvent) => {
|
|
if (e.data.type === 'TOGGLE_CHAT') {
|
|
const chatWindow = document.getElementById(
|
|
'chat-win',
|
|
) as HTMLIFrameElement;
|
|
if (chatWindow) {
|
|
chatWindow.style.display = e.data.isOpen ? 'block' : 'none';
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener('message', handleToggle);
|
|
return () => window.removeEventListener('message', handleToggle);
|
|
}, [mode]);
|
|
|
|
// Play sound only when AI response is complete (not streaming chunks)
|
|
useEffect(() => {
|
|
if (derivedMessages && derivedMessages.length > 0 && !sendLoading) {
|
|
const lastMessage = derivedMessages[derivedMessages.length - 1];
|
|
if (
|
|
lastMessage.role === MessageType.Assistant &&
|
|
lastMessage.id !== lastResponseId &&
|
|
derivedMessages.length > 1
|
|
) {
|
|
setLastResponseId(lastMessage.id || '');
|
|
playResponseSound();
|
|
}
|
|
}
|
|
}, [derivedMessages, sendLoading, lastResponseId, playResponseSound]);
|
|
|
|
const toggleChat = useCallback(() => {
|
|
if (mode === 'button') {
|
|
// In button mode, communicate with parent window to show/hide chat window
|
|
window.parent.postMessage(
|
|
{
|
|
type: 'TOGGLE_CHAT',
|
|
isOpen: !isOpen,
|
|
},
|
|
'*',
|
|
);
|
|
setIsOpen(!isOpen);
|
|
if (!isOpen) {
|
|
playNotificationSound();
|
|
}
|
|
} else {
|
|
// In full mode, handle locally
|
|
if (!isOpen) {
|
|
setIsOpen(true);
|
|
setIsMinimized(false);
|
|
playNotificationSound();
|
|
} else {
|
|
setIsOpen(false);
|
|
setIsMinimized(false);
|
|
}
|
|
}
|
|
}, [isOpen, mode, playNotificationSound]);
|
|
|
|
const minimizeChat = useCallback(() => {
|
|
setIsMinimized(true);
|
|
}, []);
|
|
|
|
const handleSendMessage = useCallback(() => {
|
|
if (!inputValue.trim() || sendLoading) return;
|
|
|
|
// Update the hook's internal state first
|
|
const syntheticEvent = {
|
|
target: { value: inputValue },
|
|
currentTarget: { value: inputValue },
|
|
preventDefault: () => {},
|
|
} as any;
|
|
|
|
handleInputChange(syntheticEvent);
|
|
|
|
// Wait for state to update, then send
|
|
setTimeout(() => {
|
|
handlePressEnter({ enableThinking: false, enableInternet: false });
|
|
// Clear our local input after sending
|
|
setInputValue('');
|
|
}, 50);
|
|
}, [inputValue, sendLoading, handleInputChange, handlePressEnter]);
|
|
|
|
const handleKeyPress = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSendMessage();
|
|
}
|
|
},
|
|
[handleSendMessage],
|
|
);
|
|
|
|
if (!conversationId) {
|
|
return (
|
|
<div className="fixed bottom-5 right-5 z-50">
|
|
<div className="bg-red-500 text-white p-4 rounded-lg shadow-lg">
|
|
Error: No conversation ID provided
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Remove the blocking return - we'll handle visibility with CSS instead
|
|
|
|
const messageCount = displayMessages?.length || 0;
|
|
|
|
// Show just the button in master mode
|
|
if (mode === 'master') {
|
|
return (
|
|
<div
|
|
className={`fixed bottom-6 right-6 z-50 transition-opacity duration-300 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const newIsOpen = !isOpen;
|
|
setIsOpen(newIsOpen);
|
|
if (newIsOpen) playNotificationSound();
|
|
|
|
// Send toggle message to parent (if embedded) or self (if standalone)
|
|
const target = window.self !== window.top ? window.parent : window;
|
|
target.postMessage(
|
|
{
|
|
type: 'TOGGLE_CHAT',
|
|
isOpen: newIsOpen,
|
|
},
|
|
'*',
|
|
);
|
|
}}
|
|
className={`w-14 h-14 text-white rounded-full transition-all duration-300 flex items-center justify-center group ${
|
|
isOpen ? 'scale-95' : 'scale-100 hover:scale-105'
|
|
}`}
|
|
style={{ backgroundColor: widgetAccentColor }}
|
|
>
|
|
<div
|
|
className={`transition-transform duration-300 ${isOpen ? 'rotate-45' : 'rotate-0'}`}
|
|
>
|
|
{isOpen ? <X size={24} /> : <MessageCircle size={24} />}
|
|
</div>
|
|
</button>
|
|
|
|
{/* Unread Badge */}
|
|
{!isOpen && messageCount > 0 && (
|
|
<div className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center animate-pulse">
|
|
{messageCount > 9 ? '9+' : messageCount}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (mode === 'button') {
|
|
// Only render the floating button
|
|
return (
|
|
<div
|
|
className={`fixed bottom-6 right-6 z-50 transition-opacity duration-300 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={toggleChat}
|
|
className={`w-14 h-14 text-white rounded-full transition-all duration-300 flex items-center justify-center group ${
|
|
isOpen ? 'scale-95' : 'scale-100 hover:scale-105'
|
|
}`}
|
|
style={{ backgroundColor: widgetAccentColor }}
|
|
>
|
|
<div
|
|
className={`transition-transform duration-300 ${isOpen ? 'rotate-45' : 'rotate-0'}`}
|
|
>
|
|
{isOpen ? <X size={24} /> : <MessageCircle size={24} />}
|
|
</div>
|
|
</button>
|
|
|
|
{/* Unread Badge */}
|
|
{!isOpen && messageCount > 0 && (
|
|
<div className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center animate-pulse">
|
|
{messageCount > 9 ? '9+' : messageCount}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (mode === 'window') {
|
|
// Only render the chat window (always open)
|
|
return (
|
|
<>
|
|
<div
|
|
className={`fixed top-0 left-0 z-50 rounded-2xl transition-all duration-300 ease-out h-[500px] w-[380px] overflow-hidden ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
|
style={{ backgroundColor: widgetAccentColor }}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
className="flex items-center justify-between p-4 text-white rounded-t-2xl"
|
|
style={{
|
|
background: `linear-gradient(to right, ${widgetAccentColor}, ${widgetAccentColorStrong})`,
|
|
}}
|
|
>
|
|
<div className="flex items-center space-x-3">
|
|
<div className="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center">
|
|
<MessageCircle size={18} />
|
|
</div>
|
|
<div>
|
|
<h3
|
|
className="font-semibold text-sm"
|
|
style={{ color: widgetHeaderTextColor }}
|
|
>
|
|
{displayTitle}
|
|
</h3>
|
|
<p className="text-xs" style={{ color: widgetHeaderTextColor }}>
|
|
{displaySubtitle}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages and Input */}
|
|
<div className="flex flex-col h-[436px]" style={bodyContainerStyle}>
|
|
<div
|
|
className="flex-1 overflow-y-auto p-4 space-y-4"
|
|
onWheel={(e) => {
|
|
const element = e.currentTarget;
|
|
const isAtTop = element.scrollTop === 0;
|
|
const isAtBottom =
|
|
element.scrollTop + element.clientHeight >=
|
|
element.scrollHeight - 1;
|
|
|
|
// Allow scroll to pass through to parent when at boundaries
|
|
if ((isAtTop && e.deltaY < 0) || (isAtBottom && e.deltaY > 0)) {
|
|
e.preventDefault();
|
|
// Let the parent handle the scroll
|
|
window.parent.postMessage(
|
|
{
|
|
type: 'SCROLL_PASSTHROUGH',
|
|
deltaY: e.deltaY,
|
|
},
|
|
'*',
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
{displayMessages?.map((message, index) => (
|
|
<div
|
|
key={index}
|
|
className={`flex ${message.role === MessageType.User ? 'justify-end' : 'justify-start'}`}
|
|
>
|
|
<div
|
|
className={`group max-w-[280px] px-4 py-2 rounded-2xl ${
|
|
message.role === MessageType.User
|
|
? 'text-white rounded-br-md'
|
|
: 'rounded-bl-md'
|
|
}`}
|
|
style={
|
|
message.role === MessageType.User
|
|
? { backgroundColor: widgetAccentColor }
|
|
: {
|
|
backgroundColor: 'rgba(148, 163, 184, 0.14)',
|
|
color: widgetTextColor,
|
|
}
|
|
}
|
|
>
|
|
{message.role === MessageType.User ? (
|
|
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
|
{message.content}
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<FloatingChatWidgetMarkdown
|
|
loading={false}
|
|
content={message.content}
|
|
reference={
|
|
message.reference || {
|
|
doc_aggs: [],
|
|
chunks: [],
|
|
total: 0,
|
|
}
|
|
}
|
|
clickDocumentButton={clickDocumentButton}
|
|
/>
|
|
<div
|
|
className="flex justify-end opacity-0 transition-opacity group-hover:opacity-100"
|
|
role="toolbar"
|
|
>
|
|
<CopyToClipboard
|
|
text={message.content}
|
|
className="border-0"
|
|
size="icon-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Clean Typing Indicator */}
|
|
{sendLoading && !enableStreaming && (
|
|
<div className="flex justify-start pl-4">
|
|
<div className="flex space-x-1">
|
|
<div
|
|
className="w-2 h-2 rounded-full animate-bounce"
|
|
style={{ backgroundColor: widgetAccentColor }}
|
|
></div>
|
|
<div
|
|
className="w-2 h-2 rounded-full animate-bounce"
|
|
style={{
|
|
backgroundColor: widgetAccentColor,
|
|
animationDelay: '0.1s',
|
|
}}
|
|
></div>
|
|
<div
|
|
className="w-2 h-2 rounded-full animate-bounce"
|
|
style={{
|
|
backgroundColor: widgetAccentColor,
|
|
animationDelay: '0.2s',
|
|
}}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Input Area */}
|
|
<div
|
|
className="border-t border-gray-200 p-4"
|
|
style={bodyContainerStyle}
|
|
>
|
|
<div className="flex items-end space-x-3">
|
|
<div className="flex-1">
|
|
<textarea
|
|
value={inputValue}
|
|
onChange={(e) => {
|
|
const newValue = e.target.value;
|
|
setInputValue(newValue);
|
|
handleInputChange(e);
|
|
}}
|
|
onKeyPress={handleKeyPress}
|
|
placeholder={t('chat.typeYourMessage')}
|
|
rows={1}
|
|
className="w-full resize-none border border-gray-300 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:border-transparent"
|
|
style={inputStyle}
|
|
disabled={hasError || sendLoading}
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleSendMessage}
|
|
disabled={!inputValue.trim() || sendLoading}
|
|
className="p-3 text-white rounded-full disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
style={{ backgroundColor: widgetAccentColor }}
|
|
>
|
|
<Send size={18} />
|
|
</button>
|
|
</div>
|
|
{renderFooter()}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{visible && (
|
|
<PdfSheet
|
|
visible={visible}
|
|
hideModal={hideModal}
|
|
documentId={documentId}
|
|
chunk={selectedChunk}
|
|
width={'100vw'}
|
|
height={'100vh'}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
} // Full mode - render everything together (original behavior)
|
|
return (
|
|
<div
|
|
className={`transition-opacity duration-300 ${isLoaded ? 'opacity-100' : 'opacity-0'}`}
|
|
>
|
|
{/* Chat Widget Container */}
|
|
{isOpen && (
|
|
<div
|
|
className={`fixed bottom-24 right-6 z-50 rounded-2xl transition-all duration-300 ease-out ${
|
|
isMinimized ? 'h-16' : 'h-[500px]'
|
|
} w-[380px] overflow-hidden`}
|
|
style={{ backgroundColor: widgetAccentColor }}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
className="flex items-center justify-between p-4 text-white rounded-t-2xl"
|
|
style={{
|
|
background: `linear-gradient(to right, ${widgetAccentColor}, ${widgetAccentColorStrong})`,
|
|
}}
|
|
>
|
|
<div className="flex items-center space-x-3">
|
|
<div className="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center">
|
|
<MessageCircle size={18} />
|
|
</div>
|
|
<div>
|
|
<h3
|
|
className="font-semibold text-sm"
|
|
style={{ color: widgetHeaderTextColor }}
|
|
>
|
|
{displayTitle}
|
|
</h3>
|
|
<p className="text-xs" style={{ color: widgetHeaderTextColor }}>
|
|
{displaySubtitle}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center space-x-1">
|
|
<button
|
|
type="button"
|
|
onClick={minimizeChat}
|
|
className="p-1.5 hover:bg-white hover:bg-opacity-20 rounded-full transition-colors"
|
|
>
|
|
<Minimize2 size={16} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={toggleChat}
|
|
className="p-1.5 hover:bg-white hover:bg-opacity-20 rounded-full transition-colors"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages Container */}
|
|
{!isMinimized && (
|
|
<div className="flex flex-col h-[436px]" style={bodyContainerStyle}>
|
|
<div
|
|
className="flex-1 overflow-y-auto p-4 space-y-4"
|
|
onWheel={(e) => {
|
|
const element = e.currentTarget;
|
|
const isAtTop = element.scrollTop === 0;
|
|
const isAtBottom =
|
|
element.scrollTop + element.clientHeight >=
|
|
element.scrollHeight - 1;
|
|
|
|
// Allow scroll to pass through to parent when at boundaries
|
|
if (
|
|
(isAtTop && e.deltaY < 0) ||
|
|
(isAtBottom && e.deltaY > 0)
|
|
) {
|
|
e.preventDefault();
|
|
// Let the parent handle the scroll
|
|
window.parent.postMessage(
|
|
{
|
|
type: 'SCROLL_PASSTHROUGH',
|
|
deltaY: e.deltaY,
|
|
},
|
|
'*',
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
{displayMessages?.map((message, index) => (
|
|
<div
|
|
key={index}
|
|
className={`flex ${message.role === MessageType.User ? 'justify-end' : 'justify-start'}`}
|
|
>
|
|
<div
|
|
className={`group max-w-[280px] px-4 py-2 rounded-2xl ${
|
|
message.role === MessageType.User
|
|
? 'text-white rounded-br-md'
|
|
: 'rounded-bl-md'
|
|
}`}
|
|
style={
|
|
message.role === MessageType.User
|
|
? { backgroundColor: widgetAccentColor }
|
|
: {
|
|
backgroundColor: 'rgba(148, 163, 184, 0.14)',
|
|
color: widgetTextColor,
|
|
}
|
|
}
|
|
>
|
|
{message.role === MessageType.User ? (
|
|
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
|
{message.content}
|
|
</p>
|
|
) : (
|
|
<div className="space-y-2">
|
|
<FloatingChatWidgetMarkdown
|
|
loading={false}
|
|
content={message.content}
|
|
reference={
|
|
message.reference || {
|
|
doc_aggs: [],
|
|
chunks: [],
|
|
total: 0,
|
|
}
|
|
}
|
|
clickDocumentButton={clickDocumentButton}
|
|
/>
|
|
<div
|
|
className="flex justify-end opacity-0 transition-opacity group-hover:opacity-100"
|
|
role="toolbar"
|
|
>
|
|
<CopyToClipboard
|
|
text={message.content}
|
|
className="border-0"
|
|
size="icon-xs"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Typing Indicator */}
|
|
{sendLoading && (
|
|
<div className="flex justify-start">
|
|
<div className="bg-gray-100 rounded-2xl rounded-bl-md px-4 py-3">
|
|
<div className="flex space-x-1">
|
|
<div className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"></div>
|
|
<div
|
|
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
|
style={{ animationDelay: '0.1s' }}
|
|
></div>
|
|
<div
|
|
className="w-2 h-2 bg-gray-400 rounded-full animate-bounce"
|
|
style={{ animationDelay: '0.2s' }}
|
|
></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Input Area */}
|
|
<div className="border-t border-gray-200 p-4">
|
|
<div className="flex items-end space-x-3">
|
|
<div className="flex-1">
|
|
<textarea
|
|
value={inputValue}
|
|
onChange={(e) => {
|
|
const newValue = e.target.value;
|
|
setInputValue(newValue);
|
|
// Also update the hook's state
|
|
handleInputChange(e);
|
|
}}
|
|
onKeyPress={handleKeyPress}
|
|
placeholder={t('chat.typeYourMessage')}
|
|
rows={1}
|
|
className="w-full resize-none border border-gray-300 rounded-2xl px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:border-transparent"
|
|
style={inputStyle}
|
|
disabled={hasError || sendLoading}
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={handleSendMessage}
|
|
disabled={!inputValue.trim() || sendLoading}
|
|
className="p-3 text-white rounded-full disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
style={{ backgroundColor: widgetAccentColor }}
|
|
>
|
|
<Send size={18} />
|
|
</button>
|
|
</div>
|
|
{renderFooter()}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Floating Button */}
|
|
<div className="fixed bottom-6 right-6 z-50">
|
|
<button
|
|
type="button"
|
|
onClick={toggleChat}
|
|
className={`w-14 h-14 text-white rounded-full transition-all duration-300 flex items-center justify-center group ${
|
|
isOpen ? 'scale-95' : 'scale-100 hover:scale-105'
|
|
}`}
|
|
style={{ backgroundColor: widgetAccentColor }}
|
|
>
|
|
<div
|
|
className={`transition-transform duration-300 ${isOpen ? 'rotate-45' : 'rotate-0'}`}
|
|
>
|
|
{isOpen ? <X size={24} /> : <MessageCircle size={24} />}
|
|
</div>
|
|
</button>
|
|
|
|
{/* Unread Badge */}
|
|
{!isOpen && messageCount > 0 && (
|
|
<div className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center animate-pulse">
|
|
{messageCount > 9 ? '9+' : messageCount}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<PdfSheet
|
|
visible={visible}
|
|
hideModal={hideModal}
|
|
documentId={documentId}
|
|
chunk={selectedChunk}
|
|
/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default FloatingChatWidget;
|