projects/deliberation_at_scale/packages/frontend/components/ChatFlow/index.tsx (298 lines of code) (raw):

"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useParams, useSearchParams } from "next/navigation"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { draw, isEmpty } from "radash"; import { AnimatePresence, motion } from "framer-motion"; import { v4 as uuid } from "uuid"; import dayjs from "dayjs"; import { msg } from "@lingui/macro"; import { ChatFlowConfig as FlowType, Message, UserInput, MessagesOptions, MessageTemplate, OnInputHelpers, FlowStep } from "../../types/flows"; import sleep from "@/utilities/sleep"; import ChatInput from "../ChatInput"; import ChatMessageList from "../ChatMessageList"; import Button from "../Button"; import { aiSolid } from "../EntityIcons"; import { useAppDispatch, useAppSelector } from "@/state/store"; import { addFlowMessages, resetFlowMessages, resetFlowPosition, setCurrentFlow, setFlowPosition, setFlowStateEntry as setFlowStateEntryAction } from "@/state/slices/flow"; import useChatFlowMessages from "@/hooks/useChatFlowMessages"; import useScrollToBottom from "@/hooks/useScrollToBottom"; import { useLocalMedia } from "@/hooks/useLocalMedia"; import { useLingui } from "@lingui/react"; import useLocalizedPush from "@/hooks/useLocalizedPush"; import useProfile from "@/hooks/useProfile"; import { replaceTextVariables } from "@/utilities/text"; interface Props { flow: FlowType; } const defaultUserMessageTemplate: MessageTemplate = { name: "[nickName]", }; export default function ChatFlow(props: Props) { const { _ } = useLingui(); const defaultBotMessageTemplate: MessageTemplate = useMemo(() => { return { name: _(msg`AI Moderator`), nameIcon: aiSolid, highlighted: true, } satisfies MessageTemplate; }, [_]); const { flow } = props; const { id: flowId, steps: unfilteredSteps, userMessageTemplate = defaultUserMessageTemplate, botMessageTemplate = defaultBotMessageTemplate, } = flow; const steps = unfilteredSteps.filter((step) => { return step.active ?? true; }); const { push } = useLocalizedPush(); const searchParams = useSearchParams(); const params = useParams(); const mediaContext = useLocalMedia({ redirect: false, request: false }); const isVideoEnabled = mediaContext?.state?.isVideoEnabled ?? false; const isAudioEnabled = mediaContext?.state?.isAudioEnabled ?? false; const flowStateEntries = useAppSelector((state) => state.flow.flowStateLookup[flowId]); const positionIndex = useAppSelector((state) => state.flow.flowPositionLookup[flowId] ?? 0); const [lastHandledPositionIndex, setLastHandledPositionIndex] = useState<number>(-1); const roomState = useAppSelector((state) => state.room); const dispatch = useAppDispatch(); const { flowMessages } = useChatFlowMessages({ flowId, }); const [inputDisabled, setInputDisabled] = useState(false); const currentStep = steps?.[positionIndex] as (FlowStep | undefined); const { hideInput = false, quickReplies = [], onTimeout } = currentStep ?? {}; const hasQuickReplies = !isEmpty(quickReplies); const isTextInputDisabled = useMemo(() => { const { onInput, hideInput } = currentStep ?? {}; return inputDisabled || !onInput || hideInput; }, [inputDisabled, currentStep]); const inputPlaceholder = useMemo(() => { if (isTextInputDisabled) { return hasQuickReplies ? _(msg`Select a button above...`) : _(msg`Waiting...`); } return undefined; }, [hasQuickReplies, isTextInputDisabled, _]); const { nickName } = useProfile(); const replaceMessageVariables = useCallback((text: string) => { return replaceTextVariables(text, { nickName, }); }, [nickName]); /** State helpers */ const reset = useCallback(() => { dispatch(resetFlowPosition({ flowId, })); dispatch(resetFlowMessages({ flowId, })); }, [dispatch, flowId]); const setFlowStateEntry = useCallback((key: string, value: any) => { dispatch(setFlowStateEntryAction({ flowId, key, value, })); }, [dispatch, flowId]); /* Navigation helpers */ const goTo = useCallback((deltaPosition: number) => { dispatch(setFlowPosition({ flowId, deltaPosition, maxPosition: (flow.steps.length - 1) ?? 0, })); }, [dispatch, flow.steps.length, flowId]); const goToNext = useCallback(() => { goTo(1); }, [goTo]); const goToPrevious = useCallback(() => { goTo(-1); }, [goTo]); const goToPage = useCallback((path: string) => { push(path); }, [push]); /* This will advance to a subflow of choice */ const goToName = useCallback((name: string) => { const index = flow.steps.findIndex((step) => { return step?.name === name; }); const deltaIndex = index - positionIndex; goTo(deltaIndex); }, [flow, goTo, positionIndex]); /* This will add a message to the log */ const postMessages = useCallback((messages: Message[]) => { // Append the messages to the array dispatch(addFlowMessages({ flowId, messages, })); }, [dispatch, flowId]); const getMessageFromTemplate = useCallback((messagesOptions: MessagesOptions, messageTemplate: MessageTemplate) => { const selectedMessages = draw(messagesOptions) ?? []; const messages = selectedMessages.map((message) => { const messageId = `${message}-${currentStep?.name ?? uuid()}}`; return { id: messageId, ...messageTemplate, content: message, date: dayjs().toISOString(), } satisfies Message; }); return messages; }, [currentStep]); const postBotMessages = useCallback((messagesOptions: MessagesOptions) => { const messages = getMessageFromTemplate(messagesOptions, botMessageTemplate); postMessages(messages); }, [getMessageFromTemplate, postMessages, botMessageTemplate]); const postUserMessages = useCallback((messagesOptions: MessagesOptions) => { const messages = getMessageFromTemplate(messagesOptions, userMessageTemplate); postMessages(messages); }, [getMessageFromTemplate, postMessages, userMessageTemplate]); const onInputHelpers: OnInputHelpers = useMemo(() => { return { goToPage, goToName, goToPrevious, goToNext, postBotMessages, postUserMessages, setFlowStateEntry, flowStateEntries: flowStateEntries ?? {}, roomState, reset, searchParams, params, isVideoEnabled, isAudioEnabled, waitFor: async (timeoutMs: number) => { return new Promise((resolve) => { setTimeout(resolve, timeoutMs); }); } } satisfies OnInputHelpers; // eslint-disable-next-line react-hooks/exhaustive-deps }, [goToPage, goToName, goToPrevious, goToNext, postBotMessages, postUserMessages, setFlowStateEntry, flowStateEntries, roomState, reset, searchParams, isVideoEnabled, isAudioEnabled, JSON.stringify(params)]); /* Handler for any user input */ const handleInput = useCallback(async (input: UserInput) => { const { onInput } = currentStep ?? {}; // Run the onInput function setInputDisabled(true); try { const onInputResult = await onInput?.(input, onInputHelpers); // return the result to the onSubmit of the chatinput so it can determine // whether the text should be cleared or not. if (typeof onInputResult === 'boolean') { setInputDisabled(false); return onInputResult; } } catch (error) { postBotMessages([[_(msg`Something went wrong! Please try again.`)]]); setInputDisabled(false); // eslint-disable-next-line no-console console.error('An error occurred when handling onInput: ', error); return false; } setInputDisabled(false); return true; }, [currentStep, onInputHelpers, postBotMessages, _]); // on initial render set the current flow ID useEffect(() => { dispatch(setCurrentFlow(flowId)); }, [dispatch, flowId]); useEffect(() => { // use an AbortController to prevent acting on timeout if user input resolves it const controller = new AbortController(); const signal = controller.signal; async function timeoutHandler() { const { timeoutMs } = currentStep ?? {}; if (timeoutMs) { // Await the timeout await sleep(timeoutMs); // GUARD: double-check that the signal hasn't been aborted yet if(!signal.aborted){ // If sleep is over and we haven't been aborted, handle the onTimeout or go to the next flow if (onTimeout) { onTimeout(onInputHelpers); } else { goToNext(); } } } } async function skipHandler() { let skipResult = currentStep?.skip?.(onInputHelpers); if (skipResult instanceof Promise) { setInputDisabled(true); skipResult = await skipResult; setInputDisabled(false); } return skipResult; } async function handler() { // GUARD: Check if we need to skip this step if (await skipHandler()) { return; } const { messageOptions } = currentStep ?? {}; // check if message options is a function if (typeof messageOptions === 'function') { // if so, await the result and post the messages const messagesOptions = await messageOptions(onInputHelpers); postBotMessages(messagesOptions); } else { // if not, post the messages postBotMessages(messageOptions ?? []); } await timeoutHandler(); } handler(); // clean up on unmount return () => { // Whenever we progress in the flow before all timeouts can occur, // we throw an abort signal so we don't trigger any resolving promises. controller.abort(); // abort timer functionality }; }, [currentStep, goToName, goToNext, onInputHelpers, onTimeout, postBotMessages, setInputDisabled, positionIndex, flowId, lastHandledPositionIndex, setLastHandledPositionIndex]); // scroll when new messages appear useScrollToBottom({ data: flowMessages }); return ( <motion.div layoutId={`chat-flow-${flowId}`} className="flex flex-col-reverse gap-2 mt-auto h-full pb-2 px-2 md:px-4 pt-20" initial={{ opacity: 0 }} animate={{ opacity: 1 }} > <div className="flex flex-col gap-2 z-20 shrink-0"> <AnimatePresence> {!isEmpty(quickReplies) && ( <motion.div key="quickReplies" className="flex flex-col gap-2" initial={{ opacity: 0 }} animate={{ opacity: 1 }} > {quickReplies.map((quickReply) => { const { id, onClick, content, icon, enabled, hidden } = quickReply; const key = `${id}-${content}`; const formattedContent = replaceMessageVariables(content); const isEnabled = enabled?.(onInputHelpers) ?? true; const isHidden = hidden?.(onInputHelpers) ?? false; if (isHidden) { return null; } return ( <Button key={key} disabled={inputDisabled || !isEnabled} onClick={async () => { setInputDisabled(true); try { postUserMessages([[ content ]]); await onClick(onInputHelpers); } catch (error) { postBotMessages([[_(msg`Something went wrong! Please try again.`)]]); // eslint-disable-next-line no-console console.error(error); } setInputDisabled(false); }} > {icon && ( <FontAwesomeIcon icon={icon} /> )} <span>{formattedContent}</span> </Button> ); })} </motion.div> )} {!hideInput && ( <ChatInput key="chatInput" onSubmit={handleInput} disabled={isTextInputDisabled} placeholder={inputPlaceholder} /> )} </AnimatePresence> </div> <ChatMessageList messages={flowMessages} className="pt-16" /> </motion.div> ); }