fix(engine): 🐛 Chat chunk management
This commit is contained in:
@ -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 = (
|
||||||
|
@ -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'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -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>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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 (
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user