2
0

fix(engine): 🐛 Chat chunk management

This commit is contained in:
Baptiste Arnaud
2022-03-03 10:15:20 +01:00
parent 831150e040
commit 4714e8046a
5 changed files with 143 additions and 68 deletions

View File

@ -90,15 +90,16 @@ export const deleteEdgeDraft = (
) => { ) => {
const edgeIndex = typebot.edges.findIndex(byId(edgeId)) const edgeIndex = typebot.edges.findIndex(byId(edgeId))
if (edgeIndex === -1) return if (edgeIndex === -1) return
deleteOutgoingEdgeIdProps(typebot, edgeIndex) deleteOutgoingEdgeIdProps(typebot, edgeId)
typebot.edges.splice(edgeIndex, 1) typebot.edges.splice(edgeIndex, 1)
} }
const deleteOutgoingEdgeIdProps = ( const deleteOutgoingEdgeIdProps = (
typebot: WritableDraft<Typebot>, typebot: WritableDraft<Typebot>,
edgeIndex: number edgeId: string
) => { ) => {
const edge = typebot.edges[edgeIndex] const edge = typebot.edges.find(byId(edgeId))
if (!edge) return
const fromBlockIndex = typebot.blocks.findIndex(byId(edge.from.blockId)) const fromBlockIndex = typebot.blocks.findIndex(byId(edge.from.blockId))
const fromStepIndex = typebot.blocks[fromBlockIndex].steps.findIndex( const fromStepIndex = typebot.blocks[fromBlockIndex].steps.findIndex(
byId(edge.from.stepId) byId(edge.from.stepId)
@ -122,16 +123,16 @@ export const cleanUpEdgeDraft = (
typebot: WritableDraft<Typebot>, typebot: WritableDraft<Typebot>,
deletedNodeId: string deletedNodeId: string
) => { ) => {
typebot.edges = typebot.edges.filter( const edgesToDelete = typebot.edges.filter((edge) =>
(edge) => [
![ edge.from.blockId,
edge.from.blockId, edge.from.stepId,
edge.from.stepId, edge.from.itemId,
edge.from.itemId, edge.to.blockId,
edge.to.blockId, edge.to.stepId,
edge.to.stepId, ].includes(deletedNodeId)
].includes(deletedNodeId)
) )
edgesToDelete.forEach((edge) => deleteEdgeDraft(typebot, edge.id))
} }
const removeExistingEdge = ( const removeExistingEdge = (

View File

@ -35,7 +35,7 @@ test.describe.parallel('Image bubble step', () => {
await expect(page.locator('img')).toHaveAttribute( await expect(page.locator('img')).toHaveAttribute(
'src', 'src',
new RegExp( new RegExp(
`https://s3.eu-west-3.amazonaws.com/typebot/typebots/${typebotId}/avatar.jpg`, `http://localhost:9000/typebot/public/typebots/${typebotId}/avatar.jpg`,
'gm' 'gm'
) )
) )

View File

@ -4,7 +4,9 @@ import { AvatarSideContainer } from './AvatarSideContainer'
import { useTypebot } from '../../contexts/TypebotContext' import { useTypebot } from '../../contexts/TypebotContext'
import { import {
isBubbleStep, isBubbleStep,
isBubbleStepType,
isChoiceInput, isChoiceInput,
isDefined,
isInputStep, isInputStep,
isIntegrationStep, isIntegrationStep,
isLogicStep, isLogicStep,
@ -17,6 +19,7 @@ import { useAnswers } from 'contexts/AnswersContext'
import { BubbleStep, InputStep, Step } from 'models' import { BubbleStep, InputStep, Step } from 'models'
import { HostBubble } from './ChatStep/bubbles/HostBubble' import { HostBubble } from './ChatStep/bubbles/HostBubble'
import { InputChatStep } from './ChatStep/InputChatStep' import { InputChatStep } from './ChatStep/InputChatStep'
import { getLastChatStepType } from 'services/chat'
type ChatBlockProps = { type ChatBlockProps = {
steps: Step[] steps: Step[]
@ -25,6 +28,8 @@ type ChatBlockProps = {
onBlockEnd: (edgeId?: string) => void onBlockEnd: (edgeId?: string) => void
} }
type ChatDisplayChunk = { bubbles: BubbleStep[]; input?: InputStep }
export const ChatBlock = ({ export const ChatBlock = ({
steps, steps,
startStepIndex, startStepIndex,
@ -40,30 +45,52 @@ export const ChatBlock = ({
onNewLog, onNewLog,
} = useTypebot() } = useTypebot()
const { resultValues } = useAnswers() const { resultValues } = useAnswers()
const [displayedSteps, setDisplayedSteps] = useState<Step[]>([]) const [processedSteps, setProcessedSteps] = useState<Step[]>([])
const bubbleSteps = displayedSteps.filter((step) => const [displayedChunks, setDisplayedChunks] = useState<ChatDisplayChunk[]>([])
isBubbleStep(step)
) as BubbleStep[] const insertStepInStack = (nextStep: Step) => {
const inputSteps = displayedSteps.filter((step) => setProcessedSteps([...processedSteps, nextStep])
isInputStep(step) if (isBubbleStep(nextStep)) {
) as InputStep[] const lastStepType = getLastChatStepType(processedSteps)
const avatarSideContainerRef = useRef<any>() lastStepType && isBubbleStepType(lastStepType)
? setDisplayedChunks(
displayedChunks.map((c, idx) =>
idx === displayedChunks.length - 1
? { bubbles: [...c.bubbles, nextStep] }
: c
)
)
: setDisplayedChunks([...displayedChunks, { bubbles: [nextStep] }])
}
if (isInputStep(nextStep)) {
return displayedChunks.length === 0 ||
isDefined(displayedChunks[displayedChunks.length - 1].input)
? setDisplayedChunks([
...displayedChunks,
{ bubbles: [], input: nextStep },
])
: setDisplayedChunks(
displayedChunks.map((c, idx) =>
idx === displayedChunks.length - 1 ? { ...c, input: nextStep } : c
)
)
}
}
useEffect(() => { useEffect(() => {
const nextStep = steps[startStepIndex] const nextStep = steps[startStepIndex]
if (nextStep) setDisplayedSteps([...displayedSteps, nextStep]) if (nextStep) insertStepInStack(nextStep)
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) }, [])
useEffect(() => { useEffect(() => {
avatarSideContainerRef.current?.refreshTopOffset()
onScroll() onScroll()
onNewStepDisplayed() onNewStepDisplayed()
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [displayedSteps]) }, [processedSteps])
const onNewStepDisplayed = async () => { const onNewStepDisplayed = async () => {
const currentStep = [...displayedSteps].pop() const currentStep = [...processedSteps].pop()
if (!currentStep) return if (!currentStep) return
if (isLogicStep(currentStep)) { if (isLogicStep(currentStep)) {
const nextEdgeId = executeLogic( const nextEdgeId = executeLogic(
@ -95,13 +122,12 @@ export const ChatBlock = ({
const displayNextStep = (answerContent?: string, isRetry?: boolean) => { const displayNextStep = (answerContent?: string, isRetry?: boolean) => {
onScroll() onScroll()
const currentStep = [...displayedSteps].pop() const currentStep = [...processedSteps].pop()
if (currentStep) { if (currentStep) {
if (isRetry && stepCanBeRetried(currentStep)) if (isRetry && stepCanBeRetried(currentStep))
return setDisplayedSteps([ return insertStepInStack(
...displayedSteps, parseRetryStep(currentStep, typebot.variables, createEdge)
parseRetryStep(currentStep, typebot.variables, createEdge), )
])
if ( if (
isInputStep(currentStep) && isInputStep(currentStep) &&
currentStep.options?.variableId && currentStep.options?.variableId &&
@ -118,11 +144,11 @@ export const ChatBlock = ({
if (nextEdgeId) return onBlockEnd(nextEdgeId) if (nextEdgeId) return onBlockEnd(nextEdgeId)
} }
if (currentStep?.outgoingEdgeId || displayedSteps.length === steps.length) if (currentStep?.outgoingEdgeId || processedSteps.length === steps.length)
return onBlockEnd(currentStep.outgoingEdgeId) return onBlockEnd(currentStep.outgoingEdgeId)
} }
const nextStep = steps[displayedSteps.length] const nextStep = steps[processedSteps.length]
if (nextStep) setDisplayedSteps([...displayedSteps, nextStep]) if (nextStep) insertStepInStack(nextStep)
} }
const avatarSrc = typebot.theme.chat.hostAvatar?.url const avatarSrc = typebot.theme.chat.hostAvatar?.url
@ -130,46 +156,75 @@ export const ChatBlock = ({
return ( return (
<div className="flex w-full"> <div className="flex w-full">
<div className="flex flex-col w-full min-w-0"> <div className="flex flex-col w-full min-w-0">
<div className="flex"> {displayedChunks.map((chunk, idx) => (
{bubbleSteps.length > 0 && <ChatChunks
(typebot.theme.chat.hostAvatar?.isEnabled ?? true) && ( key={idx}
<AvatarSideContainer displayChunk={chunk}
ref={avatarSideContainerRef} hostAvatar={{
hostAvatarSrc={ isEnabled: typebot.theme.chat.hostAvatar?.isEnabled ?? true,
avatarSrc && parseVariables(typebot.variables)(avatarSrc) src: avatarSrc && parseVariables(typebot.variables)(avatarSrc),
} }}
/> onDisplayNextStep={displayNextStep}
)} />
<TransitionGroup> ))}
{bubbleSteps.map((step) => ( </div>
<CSSTransition </div>
key={step.id} )
classNames="bubble" }
timeout={500}
unmountOnExit type Props = {
> displayChunk: ChatDisplayChunk
<HostBubble step={step} onTransitionEnd={displayNextStep} /> hostAvatar: { isEnabled: boolean; src?: string }
</CSSTransition> onDisplayNextStep: (answerContent?: string, isRetry?: boolean) => void
))} }
</TransitionGroup> const ChatChunks = ({
</div> displayChunk: { bubbles, input },
hostAvatar,
onDisplayNextStep,
}: Props) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const avatarSideContainerRef = useRef<any>()
useEffect(() => {
avatarSideContainerRef.current?.refreshTopOffset()
})
return (
<>
<div className="flex">
{hostAvatar.isEnabled && (
<AvatarSideContainer
ref={avatarSideContainerRef}
hostAvatarSrc={hostAvatar.src}
/>
)}
<TransitionGroup> <TransitionGroup>
{inputSteps.map((step) => ( {bubbles.map((step) => (
<CSSTransition <CSSTransition
key={step.id} key={step.id}
classNames="bubble" classNames="bubble"
timeout={500} timeout={500}
unmountOnExit unmountOnExit
> >
<InputChatStep <HostBubble step={step} onTransitionEnd={onDisplayNextStep} />
step={step}
onTransitionEnd={displayNextStep}
hasAvatar={typebot.theme.chat.hostAvatar?.isEnabled ?? true}
/>
</CSSTransition> </CSSTransition>
))} ))}
</TransitionGroup> </TransitionGroup>
</div> </div>
</div> <CSSTransition
classNames="bubble"
timeout={500}
unmountOnExit
in={isDefined(input)}
>
{input && (
<InputChatStep
step={input}
onTransitionEnd={onDisplayNextStep}
hasAvatar={hostAvatar.isEnabled}
/>
)}
</CSSTransition>
</>
) )
} }

View File

@ -75,10 +75,12 @@ export const ConversationContainer = ({
const autoScrollToBottom = () => { const autoScrollToBottom = () => {
if (!scrollableContainer.current) return if (!scrollableContainer.current) return
scroll.scrollToBottom({ setTimeout(() => {
duration: 500, scroll.scrollToBottom({
container: scrollableContainer.current, duration: 500,
}) container: scrollableContainer.current,
})
}, 1)
} }
return ( return (

View File

@ -1,4 +1,12 @@
import { TypingEmulation } from 'models' import {
BubbleStep,
BubbleStepType,
InputStep,
InputStepType,
Step,
TypingEmulation,
} from 'models'
import { isBubbleStep, isInputStep } from 'utils'
export const computeTypingTimeout = ( export const computeTypingTimeout = (
bubbleContent: string, bubbleContent: string,
@ -14,3 +22,12 @@ export const computeTypingTimeout = (
typingTimeout = typingSettings.maxDelay * 1000 typingTimeout = typingSettings.maxDelay * 1000
return typingTimeout return typingTimeout
} }
export const getLastChatStepType = (
steps: Step[]
): BubbleStepType | InputStepType | undefined => {
const displayedSteps = steps.filter(
(s) => isBubbleStep(s) || isInputStep(s)
) as (BubbleStep | InputStep)[]
return displayedSteps.pop()?.type
}