UNPKG

@eeacms/volto-chatbot

Version:

@eeacms/volto-chatbot: Volto add-on

347 lines (320 loc) 9.99 kB
import React from 'react'; import { Modal, ModalContent, ModalHeader, Tab, TabPane, Button, Menu, } from 'semantic-ui-react'; import { Citation } from './Citation'; import { convertToPercentage, transformEmailsToLinks, SVGIcon } from './utils'; import { getSupportedBgColor, getSupportedTextColor } from './colors'; import BotIcon from './../icons/bot.svg'; import LinkIcon from './../icons/external-link.svg'; import './colors.less'; // const EXPAND = 100; const VISIBLE_CITATIONS = 50; // Number of citations to show by default const RenderClaimView = (props) => { const { contextText, value, visibleCitationId, spanRefs, sourceStartIndex = 0, citationContainerRef, } = props; const citations = props.citations || []; const sortedCitations = citations.sort( (a, b) => a.startOffset - b.startOffset, ); const citationSpans = sortedCitations.map((citation, ind) => { const isSelectedCitation = citation.id === visibleCitationId; const Tag = isSelectedCitation ? 'mark' : 'span'; return ( <span key={ind} ref={(el) => { if (el) spanRefs.current[citation.id] = el; }} > <Tag> {contextText.slice(citation.startOffset, citation.endOffset)} <sup>{citation.id}</sup> </Tag> </span> ); }); let startIndex = sourceStartIndex; let currentInd = startIndex; let currentKey = citations.length; const allSpans = []; while (currentInd < startIndex + value.length) { const ix = currentInd; const nextCitation = sortedCitations.findIndex( (citation) => citation.startOffset === ix, ); if (nextCitation >= 0) { // Push our current text before the citation starts. if (startIndex < currentInd) { allSpans.push( <span key={currentKey}> {value.slice( startIndex - sourceStartIndex, currentInd - sourceStartIndex, )} </span>, ); currentKey++; } allSpans.push(citationSpans[nextCitation]); currentInd = sortedCitations[nextCitation].endOffset; startIndex = currentInd; } else { currentInd++; } } // Push the remaining text. if (startIndex < currentInd) { allSpans.push( <span key={currentKey}> {value.slice( startIndex - sourceStartIndex, currentInd - sourceStartIndex, )} </span>, ); } return ( <div className="citation-text" ref={citationContainerRef}> {allSpans} </div> ); }; export function ClaimCitations(props) { const { ids, citations, citedSources } = props; let joinedSources = ''; citedSources.forEach((source) => { source.startIndex = joinedSources.length; joinedSources += source.halloumiContext + '\n---\n'; }); const snippets = (ids || []) .map((id) => citations[id]) .map((cit) => { const text = joinedSources.slice(cit.startOffset, cit.endOffset); const source = citedSources.find((cit) => cit.text.indexOf(text) > -1); return { ...cit, text, // expandedText, source_id: source?.id, }; }); const sourcesWithSnippets = citedSources .map((source) => ({ ...source, snippets: snippets.filter((s) => s.source_id === source.id), })) .filter((source) => source.snippets.length > 0) .sort((sa, sb) => sa.index - sb.index); const [activeTab, setActiveTab] = React.useState(0); const [visibleCitationId, setVisibleCitation] = React.useState(); const [showAllButtons, setShowAllButtons] = React.useState(false); const citationContainerRef = React.useRef(null); const spanRefs = React.useRef({}); const panes = sourcesWithSnippets.map((source, i) => { const snippetButtons = source.snippets || []; const citationButtons = showAllButtons ? snippetButtons : snippetButtons.slice(0, VISIBLE_CITATIONS); return { menuItem: () => ( <Menu.Item key={i} className={`${activeTab === i ? 'active' : ''}`} onClick={() => { setActiveTab(i); }} > <span title={source?.semantic_identifier}> {source?.semantic_identifier} </span> </Menu.Item> ), render: () => ( <TabPane> <div className="claim-source-header"> {source?.link ? ( <a href={source.link} rel="noreferrer" target="_blank" className="claim-source-link" > <h5 className="claim-source-title"> {source.semantic_identifier} <SVGIcon name={LinkIcon} size="20" /> </h5> </a> ) : ( <h5 className="claim-source-title"> {source?.semantic_identifier} </h5> )} </div> <div className="citation-buttons"> <h5 className="citations-header">Citations:</h5> <div className="citation-buttons-container"> {citationButtons.map(({ id }) => ( <Button key={id} onClick={() => { const container = citationContainerRef.current; const target = spanRefs.current[id]; if (container && target) { const containerTop = container.getBoundingClientRect().top; const targetTop = target.getBoundingClientRect().top; const scrollOffset = targetTop - containerTop + container.scrollTop; container.scrollTo({ top: scrollOffset - 50, behavior: 'smooth', }); } setVisibleCitation(id); }} > Line {id} </Button> ))} {snippetButtons.length > VISIBLE_CITATIONS && ( <Button className="toggle-text" role="button" tabIndex={0} onClick={() => setShowAllButtons(!showAllButtons)} > {showAllButtons ? 'Less' : '... More'} </Button> )} </div> </div> <RenderClaimView contextText={joinedSources} value={source.halloumiContext} visibleCitationId={visibleCitationId} citations={source.snippets} citationContainerRef={citationContainerRef} spanRefs={spanRefs} sourceStartIndex={source.startIndex} /> </TabPane> ), }; }); return ( <div className="chat-window"> <Tab menu={{ secondary: true, pointing: true }} attached panes={panes} activeIndex={activeTab} /> </div> ); } export function components(message, markers, citedSources) { return { span: (props) => { const { node, ...rest } = props; const child = node.children[0]; let claim; if ( child.type === 'text' && child.position && child.value?.length > 10 && // we don't show for short text markers ) { const start = child.position.start.offset; const end = child.position.end.offset; claim = markers.claims?.find( (claim) => (start >= claim.startOffset && end <= claim.endOffset) || (claim.startOffset >= start && end <= claim.endOffset), ); } return !claim || claim?.score === null ? ( rest.children || [] ) : ( <Modal className="claim-modal" trigger={ <span className={`claim ${getSupportedBgColor(claim.score)}`}> {rest.children} </span> } > <ModalHeader> <div className="circle assistant"> <SVGIcon name={BotIcon} size="20" color="white" /> </div> <h5 className={`claim claim-text ${getSupportedBgColor(claim.score)}`} > &ldquo;{rest.children}&rdquo; </h5> </ModalHeader> <ModalContent> <div className="claim-source"> <p className="claim-score"> Supported by citations:{' '} <span className={getSupportedTextColor(claim.score)}> {convertToPercentage(claim.score)} </span> </p> <p className="claim-rationale"> <strong>Rationale: </strong> {claim.rationale} </p> </div> <ClaimCitations ids={claim.citationIds} citations={markers?.citations || []} citedSources={citedSources} /> </ModalContent> </Modal> ); }, a: (props) => { const { node, ...rest } = props; const value = node.children?.[0]?.children?.[0]?.value || ''; // we assume a <a><span/></a> if (value?.toString().startsWith('*')) { return <div className="" />; } else { return ( <Citation link={rest?.href} value={value} message={message}> {rest.children} </Citation> ); } }, p: ({ node, ...props }) => { // TODO: reimplement this with rehype const children = props.children; const text = React.Children.map(children, (child) => { if (typeof child === 'string') { return transformEmailsToLinks(child); } return child; }); return ( <p {...props} className="text-default"> {text} </p> ); }, }; }