@eeacms/volto-chatbot
Version:
@eeacms/volto-chatbot: Volto add-on
525 lines (475 loc) • 15.2 kB
JSX
import React from 'react';
import { visit } from 'unist-util-visit';
import loadable from '@loadable/component';
import { Button, Message, Tab, Sidebar } from 'semantic-ui-react';
import { SourceDetails } from './Source';
import Spinner from './Spinner';
import UserActionsToolbar from './UserActionsToolbar';
import RelatedQuestions from './RelatedQuestions';
import HalloumiFeedback from './HalloumiFeedback';
import useQualityMarkers from './useQualityMarkers';
import { SVGIcon } from './utils';
import { useDeepCompareMemoize } from './useDeepCompareMemoize';
import { components } from './MarkdownComponents';
import { serializeNodes } from '@plone/volto-slate/editor/render';
import BotIcon from './../icons/bot.svg';
import UserIcon from './../icons/user.svg';
import ClearIcon from './../icons/clear.svg';
// function useBufferedValue(value, delay) {
// const [bufferedValue, setBufferedValue] = React.useState(value);
// const latestValue = React.useRef(value);
// const timeoutRef = React.useRef(null);
// const lastExecuted = React.useRef(Date.now());
//
// React.useEffect(() => {
// latestValue.current = value;
//
// const now = Date.now();
// const remaining = delay - (now - lastExecuted.current);
//
// if (remaining <= 0) {
// setBufferedValue(value);
// lastExecuted.current = now;
// } else {
// if (timeoutRef.current) {
// clearTimeout(timeoutRef.current);
// }
// timeoutRef.current = setTimeout(() => {
// setBufferedValue(latestValue.current);
// lastExecuted.current = Date.now();
// timeoutRef.current = null;
// }, remaining);
// }
//
// return () => {
// if (timeoutRef.current) {
// clearTimeout(timeoutRef.current);
// }
// };
// }, [value, delay]);
//
// return bufferedValue;
// }
const CITATION_MATCH = /\[\d+\](?![[(\])])/gm;
const Markdown = loadable(() => import('react-markdown'));
function addCitations(text) {
return text.replaceAll(CITATION_MATCH, (match) => {
const number = match.match(/\d+/)[0];
return `${match}(${number})`;
});
}
function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
export function ToolCall({ tool_args, tool_name, showShimmer }) {
if (tool_name === 'run_search') {
return (
<div className={`tool_info ${showShimmer ? 'loading-text' : ''}`}>
Searched for: <em>{tool_args?.query || ''}</em>
</div>
);
}
return null;
}
function addQualityMarkersPlugin() {
return function (tree) {
visit(tree, 'element', function (node) {
node.children?.forEach((child, cidx) => {
if (child.type === 'raw' && child.value?.trim() === '<br>') {
const newNode = {
...child,
type: 'element',
tagName: 'br',
children: [],
value: '',
};
node.children[cidx] = newNode;
}
});
});
visit(tree, 'text', function (node, idx, parent) {
if (node.value?.trim()) {
const newNode = {
type: 'element',
tagName: 'span',
children: [node],
};
parent.children[idx] = newNode;
}
});
};
}
export function addHalloumiContext(doc, text) {
const updatedDate = doc.updated_at
? new Date(doc.updated_at).toLocaleString('en-GB', {
year: 'numeric',
month: 'long',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
: '';
const docIndex = doc.index ? `DOCUMENT ${doc.index}: ` : '';
const sourceType = doc.source_type
? { web: 'Website', file: 'File' }[doc.source_type] ||
capitalize(doc.source_type)
: '';
const header = `${docIndex}${doc.semantic_identifier}${
sourceType ? `\nSource: ${sourceType}` : ''
}${updatedDate ? `\nUpdated: ${updatedDate}` : ''}`;
return `${header}\n${text}`;
}
function mapToolDocumentsToText(message) {
// make a map of document_id: text
return message.toolCalls?.reduce((acc, cur) => {
return {
...acc,
...Object.assign(
{},
...(cur.tool_result || []).map((doc) => ({
[doc.document_id]: doc.content,
})),
),
};
}, {});
}
function getContextSources(message, sources, qualityCheckContext) {
const documentIdToText = mapToolDocumentsToText(message);
return qualityCheckContext === 'citations'
? sources.map((doc) => ({
...doc,
id: doc.document_id,
text: documentIdToText[doc.document_id] || '',
halloumiContext: addHalloumiContext(
doc,
documentIdToText[doc.document_id] || '',
),
}))
: (message.toolCalls || []).reduce(
(acc, cur) => [
...acc,
...(cur.tool_result || []).map((doc) => ({
...doc,
id: doc.document_id,
text: doc.content,
halloumiContext: addHalloumiContext(doc, doc.content),
})),
], // TODO: make sure we don't add multiple times the same doc
// TODO: this doesn't have the index for source
[],
);
}
function getScoreDetails(claims, qualityCheckStages) {
const score = (
(claims.length > 0
? claims.reduce((acc, { score }) => acc + score, 0) / claims.length
: 1) * 100
).toFixed(0);
const scoreStage = qualityCheckStages?.find(
({ start, end }) => start <= score && score <= end,
);
const isFirstScoreStage =
qualityCheckStages?.reduce(
(acc, { start, end }, curIx) =>
start <= score && score <= end ? curIx : acc,
-1,
) ?? -1;
const scoreColor = scoreStage?.color || 'black';
return { score, scoreStage, isFirstScoreStage, scoreColor };
}
export function ChatMessageBubble(props) {
const {
message,
isLoading,
libs,
onChoice,
showToolCalls,
enableFeedback,
feedbackReasons,
qualityCheck,
qualityCheckStages,
qualityCheckContext,
qualityCheckEnabled,
noSupportDocumentsMessage,
totalFailMessage,
isFetchingRelatedQuestions,
enableShowTotalFailMessage,
enableMatomoTracking,
persona,
maxContextSegments,
} = props;
const { remarkGfm } = libs; // , rehypePrism
const { citations = {}, documents = [], type } = message;
const isUser = type === 'user';
const [forceHalloumi, setForceHallomi] = React.useState(
qualityCheck === 'enabled',
);
const [verificationTriggered, setVerificationTriggered] =
React.useState(false);
const [isMessageVerified, setIsMessageVerified] = React.useState(false);
const [showShimmer, setShowShimmer] = React.useState(true);
const [activeTab, setActiveTab] = React.useState(0);
const [showSourcesSidebar, setShowSourcesSidebar] = React.useState(false);
React.useEffect(() => {
if (qualityCheck === 'ondemand_toggle' && qualityCheckEnabled) {
setForceHallomi(true);
} else {
setForceHallomi(false);
}
}, [qualityCheck, qualityCheckEnabled]);
const inverseMap = Object.entries(citations).reduce((acc, [k, v]) => {
return { ...acc, [v]: k };
}, {});
const sources = Object.values(citations).map((doc_id) => ({
...(documents.find((doc) => doc.db_doc_id === doc_id) || {}),
index: inverseMap[doc_id],
}));
const showSources = sources.length > 0;
const contextSources = getContextSources(
message,
sources,
qualityCheckContext,
);
const stableContextSources = useDeepCompareMemoize(contextSources);
const doQualityControl =
!isUser &&
qualityCheck &&
qualityCheck !== 'disabled' &&
forceHalloumi &&
showSources &&
(qualityCheckEnabled || verificationTriggered) &&
message.messageId > -1;
const { markers, isLoadingHalloumi, retryHalloumi } = useQualityMarkers(
doQualityControl,
addCitations(message.message),
stableContextSources,
maxContextSegments,
);
const claims = markers?.claims || [];
const { score, scoreStage, scoreColor, isFirstScoreStage } = getScoreDetails(
claims,
qualityCheckStages,
);
const isFetching = isLoadingHalloumi || isLoading;
const halloumiMessage =
isMessageVerified || doQualityControl ? scoreStage?.label : '';
const showVerifyClaimsButton =
sources.length > 0 &&
!isFetching &&
!markers &&
(qualityCheck === 'ondemand' ||
(qualityCheck === 'ondemand_toggle' && !qualityCheckEnabled));
const showTotalFailMessage =
sources.length === 0 && !isFetching && enableShowTotalFailMessage;
React.useEffect(() => {
if (markers?.claims?.length > 0) {
setIsMessageVerified(true);
}
}, [markers]);
React.useEffect(() => {
if (!isUser) {
if (message.message?.length > 0) {
setShowShimmer(false);
} else {
setShowShimmer(true);
}
}
}, [message.message, isUser]);
// const bufferedMessage = useBufferedValue(message.message, 150);
if (claims.length > 0) {
// eslint-disable-next-line no-console
console.log('claims', claims);
}
const formattedText = (
<Markdown
components={components(message, markers, stableContextSources)}
remarkPlugins={[remarkGfm.default]}
rehypePlugins={[addQualityMarkersPlugin]}
>
{addCitations(message.message)}
</Markdown>
);
const answerTab = (
<div className="answer-tab">
{showSources && (
<div className="sources">
{sources.slice(0, 3).map((source, i) => (
<SourceDetails source={source} key={i} index={source.index} />
))}
{sources.length > 3 && (
<Button
className="source show-all-sources-btn"
onClick={() => setShowSourcesSidebar(true)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setShowSourcesSidebar(true);
}
if (e.key === 'Escape') {
setShowSourcesSidebar(false);
}
}}
>
<div className="source-header">
<div>
{Array.from({ length: 3 }).map((_, i) => (
<span key={i} className="chat-citation"></span>
))}
</div>
<div className="source-title">See all sources</div>
</div>
</Button>
)}
</div>
)}
{formattedText}
{!isUser && showTotalFailMessage && (
<Message color="red">{serializeNodes(totalFailMessage)}</Message>
)}
{!isUser && (
<HalloumiFeedback
sources={sources}
halloumiMessage={halloumiMessage}
isLoadingHalloumi={isLoadingHalloumi}
markers={markers}
score={score}
scoreColor={scoreColor}
onManualVerify={() => {
setForceHallomi(true);
setVerificationTriggered(true);
}}
showVerifyClaimsButton={showVerifyClaimsButton}
retryHalloumi={retryHalloumi}
/>
)}
{!isUser && !isLoading && (
<UserActionsToolbar
message={message}
enableFeedback={enableFeedback}
feedbackReasons={feedbackReasons}
enableMatomoTracking={enableMatomoTracking}
persona={persona}
/>
)}
{isFirstScoreStage === -1 && serializeNodes(noSupportDocumentsMessage)}
{!isUser && isFetchingRelatedQuestions && (
<Message color="blue">
<div className="related-questions-loader">
<Spinner />
Finding related questions...
</div>
</Message>
)}
<RelatedQuestions
persona={persona}
message={message}
isLoading={isLoading}
onChoice={onChoice}
enableMatomoTracking={enableMatomoTracking}
/>
</div>
);
const panes = [
{ menuItem: 'Answer', render: () => <Tab.Pane>{answerTab}</Tab.Pane> },
{
menuItem: {
key: 'sources',
content: (
<span>
Sources <span className="sources-count">({sources.length})</span>
</span>
),
},
render: () => (
<Tab.Pane>
<div className="sources-listing">
{showSources && (
<div className="sources">
{sources.map((source, i) => (
<SourceDetails source={source} key={i} index={source.index} />
))}
</div>
)}
</div>
</Tab.Pane>
),
},
];
return (
<div>
<div className="comment">
{isUser ? (
<div className="circle user">
<SVGIcon name={UserIcon} size="20" color="white" />
</div>
) : (
<div className="circle assistant">
<SVGIcon name={BotIcon} size="20" color="white" />
</div>
)}
<div>
{showToolCalls &&
message.toolCalls?.map((info, index) => (
<ToolCall key={index} {...info} showShimmer={showShimmer} />
))}
{!isUser ? (
<div className="comment-tabs">
{showSources ? (
<>
<Tab
activeIndex={activeTab}
onTabChange={(_, data) => setActiveTab(data.activeIndex)}
menu={{ secondary: true, pointing: true, fluid: true }}
panes={panes}
/>
<Sidebar
visible={showSourcesSidebar}
animation="overlay"
icon="labeled"
width="wide"
direction="right"
className="sources-sidebar"
onHide={() => setShowSourcesSidebar(false)}
>
<div className="sources-sidebar-heading">
<h4>Sources</h4>
<Button
basic
onClick={() => {
setShowSourcesSidebar(false);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setShowSourcesSidebar(false);
}
}}
>
<SVGIcon name={ClearIcon} size="24" />
</Button>
</div>
<div className="sources-listing">
{showSources && (
<div className="sources">
{sources.map((source, i) => (
<SourceDetails
source={source}
key={i}
index={source.index}
/>
))}
</div>
)}
</div>
</Sidebar>
</>
) : (
answerTab
)}
</div>
) : (
formattedText
)}
</div>
</div>
</div>
);
}