diff --git a/app/components/Chatbot/ChatEntry.tsx b/app/components/Chatbot/ChatEntry.tsx new file mode 100644 index 00000000..b27ef1b0 --- /dev/null +++ b/app/components/Chatbot/ChatEntry.tsx @@ -0,0 +1,170 @@ +import {ComponentType} from 'react' +import {Link} from '@remix-run/react' +import QuestionMarkIcon from '~/components/icons-generated/QuestionMark' +import BotIcon from '~/components/icons-generated/Bot' +import PersonIcon from '~/components/icons-generated/Person' +import StampyIcon from '~/components/icons-generated/Stampy' +import Contents from '~/components/Article/Contents' +import useGlossary from '~/hooks/useGlossary' +import './chat_entry.css' +import type {Entry, AssistantEntry, StampyEntry, Citation} from '~/hooks/useChat' + +const hints = { + bot: 'bla bla bla something bot', + human: 'bla bla bla by humans', +} + +const AnswerInfo = ({answerType}: {answerType?: 'human' | 'bot'}) => { + if (!answerType) return null + return ( + + {answerType === 'human' ? : } + + {answerType === 'human' ? 'Human-written' : 'Bot-generated'} response + + +
{hints[answerType]}
+
+ ) +} + +type TitleProps = { + title: string + Icon: ComponentType + answerType?: 'human' | 'bot' +} +const Title = ({title, Icon, answerType}: TitleProps) => ( +
+ + {title} + +
+) + +const UserQuery = ({content}: Entry) => ( +
+ + <div>{content}</div> + </div> +) + +// FIXME: this id should be unique across the page - I doubt it will be now +const ReferenceLink = ({id, reference}: {id: string; reference: string}) => ( + <Link id={`#${id}-ref`} to={`#${id}`} className="reference-link"> + {reference} + </Link> +) + +const Reference = ({id, title, authors, source, url, reference}: Citation) => { + const referenceSources = { + arxiv: 'Scientific paper', + blogs: 'Blogpost', + 'aisafety.info': 'AISafety.info', + } + + const Authors = ({authors}: {authors?: string[]}) => { + if (!authors || !authors.length || authors.length === 0) return null + return ( + <span className="authors"> + {authors.slice(0, 3).join(', ')} + {authors.length <= 3 ? '' : ' et. al.'} + </span> + ) + } + + return ( + <div key={id} id={`#${id}`} className="reference padding-bottom-32"> + <div className="reference-num small">{reference}</div> + <div> + <div className="title">{title}</div> + <div> + <Authors authors={authors} /> + <span>{' ยท '}</span> + <Link className="source-link" to={url}> + {referenceSources[source as keyof typeof referenceSources] || url} + </Link> + </div> + </div> + </div> + ) +} + +const ChatbotReply = ({phase, content, citationsMap}: AssistantEntry) => { + const citations = [] as Citation[] + citationsMap?.forEach((v) => { + citations.push(v) + }) + + const references = citations.map(({reference}) => reference).join('') + const referencesRegex = new RegExp(`(\\[[${references}]\\])`) + + const PhaseState = () => { + switch (phase) { + case 'started': + return <p>Loading: Sending query...</p> + case 'semantic': + return <p>Loading: Performing semantic search...</p> + case 'history': + return <p>Loading: Processing history...</p> + case 'context': + return <p>Loading: Creating context...</p> + case 'prompt': + return <p>Loading: Creating prompt...</p> + case 'llm': + return <p>Loading: Waiting for LLM...</p> + case 'streaming': + case 'followups': + default: + return null + } + } + + return ( + <div> + <Title title="Stampy" Icon={StampyIcon} answerType="bot" /> + <PhaseState /> + <div> + {content?.split(referencesRegex).map((chunk, i) => { + if (chunk.match(referencesRegex)) { + const ref = citationsMap?.get(chunk[1]) + return <ReferenceLink key={i} id={ref?.id || chunk[i]} reference={chunk[1]} /> + } else { + return <span key={i}>{chunk}</span> + } + })} + </div> + {citations?.map(Reference)} + {phase === 'followups' ? <p>Checking for followups...</p> : undefined} + </div> + ) +} + +const StampyArticle = ({pageid, content}: StampyEntry) => { + const glossary = useGlossary() + + return ( + <div> + <Title title="Stampy" Icon={StampyIcon} answerType="human" /> + <article className="stampy"> + <Contents pageid={pageid || ''} html={content || 'Loading...'} glossary={glossary || {}} /> + </article> + </div> + ) +} + +const ChatEntry = (props: Entry) => { + const roles = { + user: UserQuery, + stampy: StampyArticle, + assistant: ChatbotReply, + } as {[k: string]: ComponentType<Entry>} + const Role = roles[props.role] as ComponentType<Entry> + if (!Role) return null + return ( + <div className="chat-entry padding-bottom-40"> + <Role {...props} /> + </div> + ) +} + +export default ChatEntry diff --git a/app/components/Chatbot/chat_entry.css b/app/components/Chatbot/chat_entry.css new file mode 100644 index 00000000..605ba382 --- /dev/null +++ b/app/components/Chatbot/chat_entry.css @@ -0,0 +1,53 @@ +article.stampy { + background: var(--colors-light-grey); + padding: var(--spacing-40); + border-radius: var(--spacing-6); + max-width: unset; + margin: var(--spacing-16); + margin-left: var(--spacing-56); +} + +.chat-entry .title { + display: flex; + gap: var(--spacing-16); +} + +.chat-entry .info { + display: flex; + gap: var(--spacing-6); + align-items: center; +} + +.chat-entry .hint-contents { + position: absolute; + transform: translateY(var(--spacing-24)); + visibility: hidden; +} + +.chat-entry .hint:hover + .hint-contents { + visibility: visible; + background: red; +} + +.chat-entry .reference-link { + background: red; + width: var(--spacing-16); + height: var(--spacing-16); + display: inline-block; +} + +.reference { + display: flex; + gap: var(--spacing-16); +} +.reference .reference-num { + width: var(--spacing-32); + height: var(--spacing-32); + background: red; + border-radius: 6px; + text-align: center; +} + +.reference .source-link { + color: var(--colors-teal-500); +} diff --git a/app/components/Chatbot/index.tsx b/app/components/Chatbot/index.tsx index b3267174..8dd8292d 100644 --- a/app/components/Chatbot/index.tsx +++ b/app/components/Chatbot/index.tsx @@ -1,11 +1,13 @@ -import {useState} from 'react' -import {Link} from '@remix-run/react' -import PersonIcon from '~/components/icons-generated/Person' +import {useEffect, useState} from 'react' +import {Link, useFetcher} from '@remix-run/react' import StampyIcon from '~/components/icons-generated/Stampy' import SendIcon from '~/components/icons-generated/PlaneSend' import Button from '~/components/Button' -import {queryLLM, Entry, AssistantEntry, Followup} from '~/hooks/useChat' +import {queryLLM, Entry, AssistantEntry, StampyEntry, Followup} from '~/hooks/useChat' +import ChatEntry from './ChatEntry' import './widgit.css' +import {questionUrl} from '~/routesMapper' +import {Question} from '~/server-utils/stampy' export const WidgetStampy = () => { const [question, setQuestion] = useState('') @@ -93,14 +95,15 @@ const QuestionInput = ({initial, onChange, onAsk}: QuestionInputProps) => { type FollowupsProps = { title?: string followups?: Followup[] + onSelect: (followup: Followup) => void } -const Followups = ({title, followups}: FollowupsProps) => ( +const Followups = ({title, followups, onSelect}: FollowupsProps) => ( <> {title && <div className="padding-bottom-24">{title}</div>} - {followups?.map(({text, action}, i) => ( + {followups?.map(({text, pageid}, i) => ( <div key={i} className="padding-bottom-16"> - <Button className="secondary-alt" action={action}> + <Button className="secondary-alt" action={() => onSelect({text, pageid})}> {text} </Button> </div> @@ -123,77 +126,58 @@ const SplashScreen = ({ </div> <Followups title="Popular questions" - followups={questions?.map((text: string) => ({text, action: () => onQuestion(text)}))} + followups={questions?.map((text: string) => ({text}))} + onSelect={({text}: Followup) => onQuestion(text)} /> </> ) -const UserQuery = ({content}: Entry) => ( - <div> - <div> - <PersonIcon /> <span className="default-bold">You</span> - </div> - <div> {content} </div> - </div> -) - -const ChatbotReply = ({content, phase}: AssistantEntry) => { - const PhaseState = () => { - switch (phase) { - case 'started': - return <p>Loading: Sending query...</p> - case 'semantic': - return <p>Loading: Performing semantic search...</p> - case 'history': - return <p>Loading: Processing history...</p> - case 'context': - return <p>Loading: Creating context...</p> - case 'prompt': - return <p>Loading: Creating prompt...</p> - case 'llm': - return <p>Loading: Waiting for LLM...</p> - case 'streaming': - case 'followups': - default: - return null - } - } - - return ( - <div> - <div> - <StampyIcon /> <span className="default-bold">Stampy</span> - </div> - <PhaseState /> - <div>{content}</div> - {phase === 'followups' ? <p>Checking for followups...</p> : undefined} - </div> - ) -} - -const ChatEntry = (props: Entry) => { - switch (props.role) { - case 'user': - return <UserQuery {...props} /> - case 'assistant': - return <ChatbotReply {...props} /> - } -} - export const Chatbot = ({question, questions}: {question?: string; questions?: string[]}) => { const [followups, setFollowups] = useState<Followup[]>() const [sessionId] = useState('asd') const [history, setHistory] = useState([] as Entry[]) const [controller, setController] = useState(() => new AbortController()) - - const showFollowup = (pageid: string) => { - // Fetch and display the given article - console.log(pageid) + const fetcher = useFetcher({key: 'followup-fetcher'}) + + useEffect(() => { + if (!fetcher.data || fetcher.state !== 'idle') return + + const question = (fetcher.data as any)?.question?.data as Question + if (!question || !question.pageid) return + + setHistory((history) => + history.map((item, i) => { + // Ignore non human written entries + if ((item as StampyEntry).pageid !== question.pageid) return item + // this is the current entry, so update it + if (i === history.length - 1) { + setFollowups( + question.relatedQuestions?.slice(0, 3).map(({title, pageid}) => ({text: title, pageid})) + ) + return {...item, content: question.text || ''} + } + // this is a previous human written article that didn't load properly - don't + // update the text as that could cause things to jump around - the user has + // already moved on, anyway + if (!item.content) return {...item, content: '...'} + // Any fully loaded previous human articles should just be returned + return item + }) + ) + }, [fetcher.data, fetcher.state]) + + const showFollowup = async ({text, pageid}: Followup) => { + if (pageid) fetcher.load(questionUrl({pageid})) + setHistory((prev) => [ + ...prev, + {role: 'user', content: text}, + {pageid, role: 'stampy'} as StampyEntry, + ]) + setFollowups(undefined) } - const abortSearch = () => controller.abort() - console.log(abortSearch) // to stop the linter from complaining + const abortSearch = () => controller.abort() // eslint-disable-line @typescript-eslint/no-unused-vars const onQuestion = async (question: string) => { const message = {content: question, role: 'user'} as Entry @@ -212,12 +196,7 @@ export const Chatbot = ({question, questions}: {question?: string; questions?: s controller ) updateReply(result) - setFollowups( - followups?.map(({text, pageid}: Followup) => ({ - text, - action: () => pageid && showFollowup(pageid), - })) - ) + setFollowups(followups) } return ( @@ -229,7 +208,11 @@ export const Chatbot = ({question, questions}: {question?: string; questions?: s <ChatEntry key={`chat-entry-${i}`} {...item} /> ))} {followups ? ( - <Followups title="continue the conversation" followups={followups} /> + <Followups + title="continue the conversation" + followups={followups} + onSelect={showFollowup} + /> ) : undefined} <QuestionInput initial={question} onAsk={onQuestion} /> </div> diff --git a/app/hooks/useChat.ts b/app/hooks/useChat.ts index 234a2090..c99f54ec 100644 --- a/app/hooks/useChat.ts +++ b/app/hooks/useChat.ts @@ -5,11 +5,14 @@ export type Citation = { authors: string[] date: string url: string + source: string index: number text: string + reference: string + id?: string } -export type Entry = UserEntry | AssistantEntry | ErrorMessage | StampyMessage +export type Entry = UserEntry | AssistantEntry | ErrorMessage | StampyEntry export type ChatPhase = | 'started' | 'semantic' @@ -41,14 +44,17 @@ export type ErrorMessage = { deleted?: boolean } -export type StampyMessage = { +export type StampyEntry = { role: 'stampy' + pageid: string content: string - url: string deleted?: boolean } -export type Followup = {text: string; pageid?: string; action: string | (() => void)} +export type Followup = { + text: string + pageid?: string +} export type SearchResult = { followups?: Followup[] result: Entry diff --git a/app/hooks/useGlossary.ts b/app/hooks/useGlossary.ts index e69de29b..e35d4b8c 100644 --- a/app/hooks/useGlossary.ts +++ b/app/hooks/useGlossary.ts @@ -0,0 +1,8 @@ +import {useGlossary as useCachedGlossary} from '~/hooks/useCachedObjects' + +const useGlossary = () => { + const {items} = useCachedGlossary() + return items +} + +export default useGlossary diff --git a/app/newRoot.css b/app/root.css similarity index 99% rename from app/newRoot.css rename to app/root.css index d55d4540..cd8548f8 100644 --- a/app/newRoot.css +++ b/app/root.css @@ -12,6 +12,7 @@ --colors-cool-grey-300: #c7cdd5; --colors-cool-grey-200: #dfe3e9; --colors-cool-grey-100: #f9fafc; + --colors-light-grey: #f9fafc; --colors-white: #ffffff; --colors-teal-900: #115652; diff --git a/app/root.tsx b/app/root.tsx index b1bca492..53cf2118 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -12,7 +12,7 @@ import { } from '@remix-run/react' import type {MetaFunction, LinksFunction, LoaderFunction} from '@remix-run/cloudflare' import {cssBundleHref} from '@remix-run/css-bundle' -import newStyles from '~/newRoot.css' +import newStyles from '~/root.css' import Error from '~/components/Error' import Page from '~/components/Page' import {CachedObjectsProvider} from '~/hooks/useCachedObjects' diff --git a/app/routes/questions.$questionId.$.tsx b/app/routes/questions.$questionId.$.tsx index b2af8c04..a3862bf7 100644 --- a/app/routes/questions.$questionId.$.tsx +++ b/app/routes/questions.$questionId.$.tsx @@ -30,8 +30,10 @@ export const loader = async ({request, params}: LoaderFunctionArgs) => { const tagsPromise = loadTags(request) .then(({data}) => data) .catch(raise500) + return defer({question: dataPromise, tags: tagsPromise}) } catch (error: unknown) { + console.log(error) const msg = `No question found with ID ${questionId}. Please go to <a href="https://discord.com/invite/Bt8PaRTDQC">Discord</a> and report where you found this link.` throw new Response(msg, {status: 404}) }