import { createEffect, Show, createSignal, onMount, For } from 'solid-js'; import { Avatar } from '../avatars/Avatar'; import { Marked } from '@ts-stack/markdown'; import { FeedbackRatingType, sendFeedbackQuery, sendFileDownloadQuery, updateFeedbackQuery } from '@/queries/sendMessageQuery'; import { FileUpload, IAction, MessageType } from '../Bot'; import { CopyToClipboardButton, ThumbsDownButton, ThumbsUpButton } from '../buttons/FeedbackButtons'; import FeedbackContentDialog from '../FeedbackContentDialog'; import { AgentReasoningBubble } from './AgentReasoningBubble'; import { TickIcon, XIcon } from '../icons'; import { SourceBubble } from '../bubbles/SourceBubble'; import { DateTimeToggleTheme } from '@/features/bubble/types'; import { WorkflowTreeView } from '../treeview/WorkflowTreeView'; import { CircleDotIcon } from '../icons'; type Props = { message: MessageType; chatflowid: string; chatId: string; apiHost?: string; onRequest?: (request: RequestInit) => Promise; fileAnnotations?: any; showAvatar?: boolean; avatarSrc?: string; backgroundColor?: string; textColor?: string; chatFeedbackStatus?: boolean; fontSize?: number; feedbackColor?: string; isLoading: boolean; dateTimeToggle?: DateTimeToggleTheme; showAgentMessages?: boolean; sourceDocsTitle?: string; renderHTML?: boolean; handleActionClick: (elem: any, action: IAction | undefined | null) => void; handleSourceDocumentsClick: (src: any) => void; }; const defaultBackgroundColor = '#f7f8ff'; const defaultTextColor = '#303235'; const defaultFontSize = 16; const defaultFeedbackColor = '#3B81F6'; export const BotBubble = (props: Props) => { let botDetailsEl: HTMLDetailsElement | undefined; Marked.setOptions({ isNoP: true, sanitize: props.renderHTML !== undefined ? !props.renderHTML : true }); const [rating, setRating] = createSignal(''); const [feedbackId, setFeedbackId] = createSignal(''); const [showFeedbackContentDialog, setShowFeedbackContentModal] = createSignal(false); const [copiedMessage, setCopiedMessage] = createSignal(false); const [thumbsUpColor, setThumbsUpColor] = createSignal(props.feedbackColor ?? defaultFeedbackColor); // default color const [thumbsDownColor, setThumbsDownColor] = createSignal(props.feedbackColor ?? defaultFeedbackColor); // default color // Store a reference to the bot message element for the copyMessageToClipboard function const [botMessageElement, setBotMessageElement] = createSignal(null); const setBotMessageRef = (el: HTMLSpanElement) => { if (el) { el.innerHTML = Marked.parse(props.message.message); // Apply textColor to all links, headings, and other markdown elements except code const textColor = props.textColor ?? defaultTextColor; el.querySelectorAll('a, h1, h2, h3, h4, h5, h6, strong, em, blockquote, li').forEach((element) => { (element as HTMLElement).style.color = textColor; }); // Code blocks (with pre) get white text el.querySelectorAll('pre').forEach((element) => { (element as HTMLElement).style.color = '#FFFFFF'; // Also ensure any code elements inside pre have white text element.querySelectorAll('code').forEach((codeElement) => { (codeElement as HTMLElement).style.color = '#FFFFFF'; }); }); // Inline code (not in pre) gets green text el.querySelectorAll('code:not(pre code)').forEach((element) => { (element as HTMLElement).style.color = '#4CAF50'; // Green color }); // Set target="_blank" for links el.querySelectorAll('a').forEach((link) => { link.target = '_blank'; }); // Store the element ref for the copy function setBotMessageElement(el); if (props.message.rating) { setRating(props.message.rating); if (props.message.rating === 'THUMBS_UP') { setThumbsUpColor('#006400'); } else if (props.message.rating === 'THUMBS_DOWN') { setThumbsDownColor('#8B0000'); } } if (props.fileAnnotations && props.fileAnnotations.length) { for (const annotations of props.fileAnnotations) { const button = document.createElement('button'); button.textContent = annotations.fileName; button.className = 'py-2 px-4 mb-2 justify-center font-semibold text-white focus:outline-none flex items-center disabled:opacity-50 disabled:cursor-not-allowed disabled:brightness-100 transition-all filter hover:brightness-90 active:brightness-75 file-annotation-button'; button.addEventListener('click', function () { downloadFile(annotations); }); const svgContainer = document.createElement('div'); svgContainer.className = 'ml-2'; svgContainer.innerHTML = ``; button.appendChild(svgContainer); el.appendChild(button); } } } }; const downloadFile = async (fileAnnotation: any) => { try { const response = await sendFileDownloadQuery({ apiHost: props.apiHost, body: { fileName: fileAnnotation.fileName, chatflowId: props.chatflowid, chatId: props.chatId } as any, onRequest: props.onRequest, }); const blob = new Blob([response.data]); const downloadUrl = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = downloadUrl; link.download = fileAnnotation.fileName; document.body.appendChild(link); link.click(); link.remove(); } catch (error) { console.error('Download failed:', error); } }; const copyMessageToClipboard = async () => { try { const text = botMessageElement() ? botMessageElement()?.textContent : ''; await navigator.clipboard.writeText(text || ''); setCopiedMessage(true); setTimeout(() => { setCopiedMessage(false); }, 2000); // Hide the message after 2 seconds } catch (error) { console.error('Error copying to clipboard:', error); } }; const saveToLocalStorage = (rating: FeedbackRatingType) => { const chatDetails = localStorage.getItem(`${props.chatflowid}_EXTERNAL`); if (!chatDetails) return; try { const parsedDetails = JSON.parse(chatDetails); const messages: MessageType[] = parsedDetails.chatHistory || []; const message = messages.find((msg) => msg.messageId === props.message.messageId); if (!message) return; message.rating = rating; localStorage.setItem(`${props.chatflowid}_EXTERNAL`, JSON.stringify({ ...parsedDetails, chatHistory: messages })); } catch (e) { return; } }; const isValidURL = (url: string): URL | undefined => { try { return new URL(url); } catch (err) { return undefined; } }; const removeDuplicateURL = (message: MessageType) => { const visitedURLs: string[] = []; const newSourceDocuments: any = []; message.sourceDocuments.forEach((source: any) => { if (isValidURL(source.metadata.source) && !visitedURLs.includes(source.metadata.source)) { visitedURLs.push(source.metadata.source); newSourceDocuments.push(source); } else if (!isValidURL(source.metadata.source)) { newSourceDocuments.push(source); } }); return newSourceDocuments; }; const onThumbsUpClick = async () => { if (rating() === '') { const body = { chatflowid: props.chatflowid, chatId: props.chatId, messageId: props.message?.messageId as string, rating: 'THUMBS_UP' as FeedbackRatingType, content: '', }; const result = await sendFeedbackQuery({ chatflowid: props.chatflowid, apiHost: props.apiHost, body, onRequest: props.onRequest, }); if (result.data) { const data = result.data as any; let id = ''; if (data && data.id) id = data.id; setRating('THUMBS_UP'); setFeedbackId(id); setShowFeedbackContentModal(true); // update the thumbs up color state setThumbsUpColor('#006400'); saveToLocalStorage('THUMBS_UP'); } } }; const onThumbsDownClick = async () => { if (rating() === '') { const body = { chatflowid: props.chatflowid, chatId: props.chatId, messageId: props.message?.messageId as string, rating: 'THUMBS_DOWN' as FeedbackRatingType, content: '', }; const result = await sendFeedbackQuery({ chatflowid: props.chatflowid, apiHost: props.apiHost, body, onRequest: props.onRequest, }); if (result.data) { const data = result.data as any; let id = ''; if (data && data.id) id = data.id; setRating('THUMBS_DOWN'); setFeedbackId(id); setShowFeedbackContentModal(true); // update the thumbs down color state setThumbsDownColor('#8B0000'); saveToLocalStorage('THUMBS_DOWN'); } } }; const submitFeedbackContent = async (text: string) => { const body = { content: text, }; const result = await updateFeedbackQuery({ id: feedbackId(), apiHost: props.apiHost, body, onRequest: props.onRequest, }); if (result.data) { setFeedbackId(''); setShowFeedbackContentModal(false); } }; onMount(() => { if (botDetailsEl && props.isLoading) { botDetailsEl.open = true; } }); createEffect(() => { if (botDetailsEl && props.isLoading) { botDetailsEl.open = true; } else if (botDetailsEl && !props.isLoading) { botDetailsEl.open = false; } }); const renderArtifacts = (item: Partial) => { // Instead of onMount, we'll use a callback ref to apply styles const setArtifactRef = (el: HTMLSpanElement) => { if (el) { const textColor = props.textColor ?? defaultTextColor; // Apply textColor to all elements except code blocks el.querySelectorAll('a, h1, h2, h3, h4, h5, h6, strong, em, blockquote, li').forEach((element) => { (element as HTMLElement).style.color = textColor; }); // Code blocks (with pre) get white text el.querySelectorAll('pre').forEach((element) => { (element as HTMLElement).style.color = '#FFFFFF'; // Also ensure any code elements inside pre have white text element.querySelectorAll('code').forEach((codeElement) => { (codeElement as HTMLElement).style.color = '#FFFFFF'; }); }); // Inline code (not in pre) gets green text el.querySelectorAll('code:not(pre code)').forEach((element) => { (element as HTMLElement).style.color = '#4CAF50'; // Green color }); el.querySelectorAll('a').forEach((link) => { link.target = '_blank'; }); } }; return ( <>
{ const isFileStorage = typeof item.data === 'string' && item.data.startsWith('FILE-STORAGE::'); return isFileStorage ? `${props.apiHost}/api/v1/get-upload-file?chatflowId=${props.chatflowid}&chatId=${props.chatId}&fileName=${( item.data as string ).replace('FILE-STORAGE::', '')}` : (item.data as string); })()} />
); }; const formatDateTime = (dateTimeString: string | undefined, showDate: boolean | undefined, showTime: boolean | undefined) => { if (!dateTimeString) return ''; try { const date = new Date(dateTimeString); // Check if the date is valid if (isNaN(date.getTime())) { console.error('Invalid ISO date string:', dateTimeString); return ''; } let formatted = ''; if (showDate) { const dateFormatter = new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, }); const [{ value: month }, , { value: day }, , { value: year }] = dateFormatter.formatToParts(date); formatted = `${month.charAt(0).toUpperCase() + month.slice(1)} ${day}, ${year}`; } if (showTime) { const timeFormatter = new Intl.DateTimeFormat('en-US', { hour: 'numeric', minute: '2-digit', hour12: true, timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, }); const timeString = timeFormatter.format(date).toLowerCase(); formatted = formatted ? `${formatted}, ${timeString}` : timeString; } return formatted; } catch (error) { console.error('Error formatting date:', error); return ''; } }; return (
{props.showAgentMessages && props.message.agentFlowExecutedData && Array.isArray(props.message.agentFlowExecutedData) && props.message.agentFlowExecutedData.length > 0 && (
)} {props.showAgentMessages && props.message.agentReasoning && (
Agent Messages
{(agent) => { const agentMessages = agent.messages ?? []; let msgContent = agent.instructions || (agentMessages.length > 1 ? agentMessages.join('\\n') : agentMessages[0]); if (agentMessages.length === 0 && !agent.instructions) msgContent = `

Finished

`; return ( ); }}
)} {props.message.toolExecutionStatus && (
{(tool) => { const getStatusColor = () => { switch (tool.type) { case 'tool_start': return '#2196F3'; case 'tool_end': return '#4CAF50'; case 'tool_error': return '#F44336'; default: return '#9E9E9E'; } }; const getStatusIcon = () => { switch (tool.type) { case 'tool_start': return ; case 'tool_end': return ; case 'tool_error': return ; default: return null; } }; const getStatusText = () => { switch (tool.type) { case 'tool_start': return `${tool.tool} (executing...)`; case 'tool_end': return `${tool.tool} (completed)`; case 'tool_error': return `${tool.tool} (error)`; default: return tool.tool; } }; return ( {getStatusIcon()} {getStatusText()} ); }}
)} {props.message.artifacts && props.message.artifacts.length > 0 && (
{(item) => { return item !== null ? <>{renderArtifacts(item)} : null; }}
)} {props.message.message && ( )} {props.message.action && (
{(action) => { return ( <> {(action.type === 'approve-button' && action.label === 'Yes') || action.type === 'agentflowv2-approve-button' ? ( ) : (action.type === 'reject-button' && action.label === 'No') || action.type === 'agentflowv2-reject-button' ? ( ) : ( )} ); }}
)}
{props.message.sourceDocuments && props.message.sourceDocuments.length && ( <> {props.sourceDocsTitle}
{(src) => { const URL = isValidURL(src.metadata.source); return ( { if (URL) { window.open(src.metadata.source, '_blank'); } else { props.handleSourceDocumentsClick(src); } }} /> ); }}
)}
{props.chatFeedbackStatus && props.message.messageId && ( <>
copyMessageToClipboard()} />
Copied!
{rating() === '' || rating() === 'THUMBS_UP' ? ( ) : null} {rating() === '' || rating() === 'THUMBS_DOWN' ? ( ) : null}
{formatDateTime(props.message.dateTime, props?.dateTimeToggle?.date, props?.dateTimeToggle?.time)}
setShowFeedbackContentModal(false)} onSubmit={submitFeedbackContent} backgroundColor={props.backgroundColor} textColor={props.textColor} /> )}
); };