2
0

(engine) Improve engine overall robustness

This commit is contained in:
Baptiste Arnaud
2023-01-25 11:27:47 +01:00
parent ff62b922a0
commit 30baa611e5
210 changed files with 1820 additions and 1919 deletions

View File

@ -1,10 +1,9 @@
import { LiteBadge } from './LiteBadge'
import { createEffect, createSignal, onCleanup, onMount, Show } from 'solid-js'
import { getViewerUrl, injectCustomHeadCode, isEmpty, isNotEmpty } from 'utils'
import { injectCustomHeadCode, isNotEmpty } from 'utils'
import { getInitialChatReplyQuery } from '@/queries/getInitialChatReplyQuery'
import { ConversationContainer } from './ConversationContainer'
import css from '../assets/index.css'
import { StartParams } from 'models'
import type { ChatReply, StartParams } from 'models'
import { setIsMobile } from '@/utils/isMobileSignal'
import { BotContext, InitialChatReply } from '@/types'
import { ErrorMessage } from './ErrorMessage'
@ -20,20 +19,19 @@ export type BotProps = StartParams & {
onAnswer?: (answer: { message: string; blockId: string }) => void
onInit?: () => void
onEnd?: () => void
onNewLogs?: (logs: ChatReply['logs']) => void
}
export const Bot = (props: BotProps) => {
export const Bot = (props: BotProps & { class?: string }) => {
const [initialChatReply, setInitialChatReply] = createSignal<
InitialChatReply | undefined
>()
const [error, setError] = createSignal<Error | undefined>(
// eslint-disable-next-line solid/reactivity
isEmpty(isEmpty(props.apiHost) ? getViewerUrl() : props.apiHost)
? new Error('process.env.NEXT_PUBLIC_VIEWER_URL is missing in env')
: undefined
)
const [customCss, setCustomCss] = createSignal('')
const [isInitialized, setIsInitialized] = createSignal(false)
const [error, setError] = createSignal<Error | undefined>()
const initializeBot = async () => {
setIsInitialized(true)
const urlParams = new URLSearchParams(location.search)
props.onInit?.()
const prefilledVariables: { [key: string]: string } = {}
@ -56,37 +54,51 @@ export const Bot = (props: BotProps) => {
if (error && 'code' in error && typeof error.code === 'string') {
if (['BAD_REQUEST', 'FORBIDDEN'].includes(error.code))
setError(new Error('This bot is now closed.'))
if (error.code === 'NOT_FOUND') setError(new Error('Typebot not found.'))
if (error.code === 'NOT_FOUND')
setError(new Error("The bot you're looking for doesn't exist."))
return
}
if (!data) return setError(new Error("Couldn't initiate the chat"))
if (!data) return setError(new Error("Error! Couldn't initiate the chat."))
if (data.resultId) setResultInSession(data.resultId)
setInitialChatReply(data)
setCustomCss(data.typebot.theme.customCss ?? '')
if (data.input?.id && props.onNewInputBlock)
props.onNewInputBlock({
id: data.input.id,
groupId: data.input.groupId,
})
if (data.logs) props.onNewLogs?.(data.logs)
const customHeadCode = data.typebot.settings.metadata.customHeadCode
if (customHeadCode) injectCustomHeadCode(customHeadCode)
}
onMount(() => {
createEffect(() => {
if (!props.typebot || isInitialized()) return
initializeBot().then()
})
createEffect(() => {
if (typeof props.typebot === 'string') return
setCustomCss(props.typebot.theme.customCss ?? '')
})
onCleanup(() => {
setIsInitialized(false)
})
return (
<>
<style>{css}</style>
<style>{customCss()}</style>
<Show when={error()} keyed>
{(error) => <ErrorMessage error={error} />}
</Show>
<Show when={initialChatReply()} keyed>
{(initialChatReply) => (
<BotContent
class={props.class}
initialChatReply={{
...initialChatReply,
typebot: {
@ -103,11 +115,13 @@ export const Bot = (props: BotProps) => {
}}
context={{
apiHost: props.apiHost,
isPreview: props.isPreview ?? false,
isPreview:
typeof props.typebot !== 'string' || (props.isPreview ?? false),
typebotId: initialChatReply.typebot.id,
resultId: initialChatReply.resultId,
}}
onNewInputBlock={props.onNewInputBlock}
onNewLogs={props.onNewLogs}
onAnswer={props.onAnswer}
onEnd={props.onEnd}
/>
@ -120,9 +134,11 @@ export const Bot = (props: BotProps) => {
type BotContentProps = {
initialChatReply: InitialChatReply
context: BotContext
onNewInputBlock?: (ids: { id: string; groupId: string }) => void
class?: string
onNewInputBlock?: (block: { id: string; groupId: string }) => void
onAnswer?: (answer: { message: string; blockId: string }) => void
onEnd?: () => void
onNewLogs?: (logs: ChatReply['logs']) => void
}
const BotContent = (props: BotContentProps) => {
@ -160,7 +176,10 @@ const BotContent = (props: BotContentProps) => {
return (
<div
ref={botContainer}
class="relative flex w-full h-full text-base overflow-hidden bg-cover flex-col items-center typebot-container"
class={
'relative flex w-full h-full text-base overflow-hidden bg-cover flex-col items-center typebot-container ' +
props.class
}
>
<div class="flex w-full h-full justify-center">
<ConversationContainer
@ -169,6 +188,7 @@ const BotContent = (props: BotContentProps) => {
onNewInputBlock={props.onNewInputBlock}
onAnswer={props.onAnswer}
onEnd={props.onEnd}
onNewLogs={props.onNewLogs}
/>
</div>
<Show

View File

@ -1,6 +1,6 @@
import { createSignal, onCleanup, onMount } from 'solid-js'
import { Avatar } from '@/components/avatars/Avatar'
import { isMobile } from '@/utils/isMobileSignal'
import { Avatar } from '../avatars/Avatar'
type Props = { hostAvatarSrc?: string }
@ -42,7 +42,7 @@ export const AvatarSideContainer = (props: Props) => {
transition: 'top 350ms ease-out',
}}
>
<Avatar avatarSrc={props.hostAvatarSrc} />
<Avatar initialAvatarSrc={props.hostAvatarSrc} />
</div>
</div>
)

View File

@ -1,6 +1,6 @@
import { BotContext } from '@/types'
import { ChatReply, Settings, Theme } from 'models'
import { createSignal, For, Show } from 'solid-js'
import type { ChatReply, Settings, Theme } from 'models'
import { createSignal, For, onMount, Show } from 'solid-js'
import { HostBubble } from '../bubbles/HostBubble'
import { InputChatBlock } from '../InputChatBlock'
import { AvatarSideContainer } from './AvatarSideContainer'
@ -19,6 +19,10 @@ type Props = Pick<ChatReply, 'messages' | 'input'> & {
export const ChatChunk = (props: Props) => {
const [displayedMessageIndex, setDisplayedMessageIndex] = createSignal(0)
onMount(() => {
props.onScrollToBottom()
})
const displayNextMessage = () => {
setDisplayedMessageIndex(
displayedMessageIndex() === props.messages.length
@ -70,6 +74,9 @@ export const ChatChunk = (props: Props) => {
onSkip={props.onSkip}
guestAvatar={props.theme.chat.guestAvatar}
context={props.context}
isInputPrefillEnabled={
props.settings.general.isInputPrefillEnabled ?? true
}
/>
)}
</div>

View File

@ -1,5 +1,5 @@
import { ChatReply, Theme } from 'models'
import { createSignal, For } from 'solid-js'
import type { ChatReply, Theme } from 'models'
import { createEffect, createSignal, For } from 'solid-js'
import { sendMessageQuery } from '@/queries/sendMessageQuery'
import { ChatChunk } from './ChatChunk'
import { BotContext, InitialChatReply } from '@/types'
@ -7,24 +7,26 @@ import { executeIntegrations } from '@/utils/executeIntegrations'
import { executeLogic } from '@/utils/executeLogic'
const parseDynamicTheme = (
theme: Theme,
initialTheme: Theme,
dynamicTheme: ChatReply['dynamicTheme']
): Theme => ({
...theme,
...initialTheme,
chat: {
...theme.chat,
hostAvatar: theme.chat.hostAvatar
? {
...theme.chat.hostAvatar,
url: dynamicTheme?.hostAvatarUrl,
}
: undefined,
guestAvatar: theme.chat.guestAvatar
? {
...theme.chat.guestAvatar,
url: dynamicTheme?.guestAvatarUrl,
}
: undefined,
...initialTheme.chat,
hostAvatar:
initialTheme.chat.hostAvatar && dynamicTheme?.hostAvatarUrl
? {
...initialTheme.chat.hostAvatar,
url: dynamicTheme.hostAvatarUrl,
}
: initialTheme.chat.hostAvatar,
guestAvatar:
initialTheme.chat.guestAvatar && dynamicTheme?.guestAvatarUrl
? {
...initialTheme.chat.guestAvatar,
url: dynamicTheme?.guestAvatarUrl,
}
: initialTheme.chat.guestAvatar,
},
})
@ -34,9 +36,11 @@ type Props = {
onNewInputBlock?: (ids: { id: string; groupId: string }) => void
onAnswer?: (answer: { message: string; blockId: string }) => void
onEnd?: () => void
onNewLogs?: (logs: ChatReply['logs']) => void
}
export const ConversationContainer = (props: Props) => {
let chatContainer: HTMLDivElement | undefined
let bottomSpacer: HTMLDivElement | undefined
const [chatChunks, setChatChunks] = createSignal<ChatReply[]>([
{
@ -44,12 +48,16 @@ export const ConversationContainer = (props: Props) => {
messages: props.initialChatReply.messages,
},
])
const [theme, setTheme] = createSignal(
parseDynamicTheme(
props.initialChatReply.typebot.theme,
props.initialChatReply.dynamicTheme
const [dynamicTheme, setDynamicTheme] = createSignal<
ChatReply['dynamicTheme']
>(props.initialChatReply.dynamicTheme)
const [theme, setTheme] = createSignal(props.initialChatReply.typebot.theme)
createEffect(() => {
setTheme(
parseDynamicTheme(props.initialChatReply.typebot.theme, dynamicTheme())
)
)
})
const sendMessage = async (message: string) => {
const currentBlockId = chatChunks().at(-1)?.input?.id
@ -61,7 +69,8 @@ export const ConversationContainer = (props: Props) => {
message,
})
if (!data) return
if (data.dynamicTheme) applyDynamicTheme(data.dynamicTheme)
if (data.logs) props.onNewLogs?.(data.logs)
if (data.dynamicTheme) setDynamicTheme(data.dynamicTheme)
if (data.input?.id && props.onNewInputBlock) {
props.onNewInputBlock({
id: data.input.id,
@ -83,19 +92,18 @@ export const ConversationContainer = (props: Props) => {
])
}
const applyDynamicTheme = (dynamicTheme: ChatReply['dynamicTheme']) => {
setTheme((theme) => parseDynamicTheme(theme, dynamicTheme))
}
const autoScrollToBottom = () => {
if (!bottomSpacer) return
setTimeout(() => {
bottomSpacer?.scrollIntoView({ behavior: 'smooth' })
}, 200)
chatContainer?.scrollTo(0, chatContainer.scrollHeight)
}, 50)
}
return (
<div class="overflow-y-scroll w-full min-h-full rounded px-3 pt-10 relative scrollable-container typebot-chat-view">
<div
ref={chatContainer}
class="overflow-y-scroll w-full min-h-full rounded px-3 pt-10 relative scrollable-container typebot-chat-view scroll-smooth"
>
<For each={chatChunks()}>
{(chatChunk, index) => (
<ChatChunk

View File

@ -4,7 +4,7 @@ type Props = {
export const ErrorMessage = (props: Props) => {
return (
<div class="h-full flex justify-center items-center flex-col">
<p class="text-5xl">{props.error.message}</p>
<p class="text-2xl text-center">{props.error.message}</p>
</div>
)
}

View File

@ -1,10 +1,9 @@
import {
import type {
ChatReply,
ChoiceInputBlock,
DateInputOptions,
EmailInputBlock,
FileInputBlock,
InputBlockType,
NumberInputBlock,
PaymentInputOptions,
PhoneNumberInputBlock,
@ -14,6 +13,7 @@ import {
Theme,
UrlInputBlock,
} from 'models'
import { InputBlockType } from 'models/features/blocks/inputs/enums'
import { GuestBubble } from './bubbles/GuestBubble'
import { BotContext, InputSubmitContent } from '@/types'
import { TextInput } from '@/features/blocks/inputs/textInput'
@ -35,6 +35,7 @@ type Props = {
guestAvatar?: Theme['chat']['guestAvatar']
inputIndex: number
context: BotContext
isInputPrefillEnabled: boolean
onSubmit: (answer: string) => void
onSkip: () => void
}
@ -72,6 +73,7 @@ export const InputChatBlock = (props: Props) => {
context={props.context}
block={props.block}
inputIndex={props.inputIndex}
isInputPrefillEnabled={props.isInputPrefillEnabled}
onSubmit={handleSubmit}
onSkip={() => props.onSkip()}
hasGuestAvatar={props.guestAvatar?.isEnabled ?? false}
@ -87,17 +89,21 @@ const Input = (props: {
block: NonNullable<ChatReply['input']>
inputIndex: number
hasGuestAvatar: boolean
isInputPrefillEnabled: boolean
onSubmit: (answer: InputSubmitContent) => void
onSkip: () => void
}) => {
const onSubmit = (answer: InputSubmitContent) => props.onSubmit(answer)
const getPrefilledValue = () =>
props.isInputPrefillEnabled ? props.block.prefilledValue : undefined
return (
<Switch>
<Match when={props.block.type === InputBlockType.TEXT}>
<TextInput
block={props.block as TextInputBlock}
defaultValue={props.block.prefilledValue}
defaultValue={getPrefilledValue()}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
@ -105,7 +111,7 @@ const Input = (props: {
<Match when={props.block.type === InputBlockType.NUMBER}>
<NumberInput
block={props.block as NumberInputBlock}
defaultValue={props.block.prefilledValue}
defaultValue={getPrefilledValue()}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
@ -113,7 +119,7 @@ const Input = (props: {
<Match when={props.block.type === InputBlockType.EMAIL}>
<EmailInput
block={props.block as EmailInputBlock}
defaultValue={props.block.prefilledValue}
defaultValue={getPrefilledValue()}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
@ -121,7 +127,7 @@ const Input = (props: {
<Match when={props.block.type === InputBlockType.URL}>
<UrlInput
block={props.block as UrlInputBlock}
defaultValue={props.block.prefilledValue}
defaultValue={getPrefilledValue()}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
@ -129,7 +135,7 @@ const Input = (props: {
<Match when={props.block.type === InputBlockType.PHONE}>
<PhoneInput
block={props.block as PhoneNumberInputBlock}
defaultValue={props.block.prefilledValue}
defaultValue={getPrefilledValue()}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
@ -150,7 +156,7 @@ const Input = (props: {
<Match when={props.block.type === InputBlockType.RATING}>
<RatingForm
block={props.block as RatingInputBlock}
defaultValue={props.block.prefilledValue}
defaultValue={getPrefilledValue()}
onSubmit={onSubmit}
/>
</Match>

View File

@ -1,25 +1,29 @@
import { isMobile } from '@/utils/isMobileSignal'
import { Show } from 'solid-js'
import { createSignal, Show } from 'solid-js'
import { isNotEmpty } from 'utils'
import { DefaultAvatar } from './DefaultAvatar'
export const Avatar = (props: { avatarSrc?: string }) => (
<Show
when={isNotEmpty(props.avatarSrc)}
keyed
fallback={() => <DefaultAvatar />}
>
<figure
class={
'flex justify-center items-center rounded-full text-white relative animate-fade-in ' +
(isMobile() ? 'w-6 h-6 text-sm' : 'w-10 h-10 text-xl')
}
export const Avatar = (props: { initialAvatarSrc?: string }) => {
const [avatarSrc] = createSignal(props.initialAvatarSrc)
return (
<Show
when={isNotEmpty(avatarSrc())}
keyed
fallback={() => <DefaultAvatar />}
>
<img
src={props.avatarSrc}
alt="Bot avatar"
class="rounded-full object-cover w-full h-full"
/>
</figure>
</Show>
)
<figure
class={
'flex justify-center items-center rounded-full text-white relative animate-fade-in ' +
(isMobile() ? 'w-6 h-6 text-sm' : 'w-10 h-10 text-xl')
}
>
<img
src={avatarSrc()}
alt="Bot avatar"
class="rounded-full object-cover w-full h-full"
/>
</figure>
</Show>
)
}

View File

@ -1,5 +1,4 @@
import { Show } from 'solid-js'
import { isDefined } from 'utils'
import { Avatar } from '../avatars/Avatar'
type Props = {
@ -19,8 +18,8 @@ export const GuestBubble = (props: Props) => (
>
{props.message}
</span>
<Show when={isDefined(props.avatarSrc)}>
<Avatar avatarSrc={props.avatarSrc} />
<Show when={props.showAvatar}>
<Avatar initialAvatarSrc={props.avatarSrc} />
</Show>
</div>
)

View File

@ -3,15 +3,15 @@ import { EmbedBubble } from '@/features/blocks/bubbles/embed'
import { ImageBubble } from '@/features/blocks/bubbles/image'
import { TextBubble } from '@/features/blocks/bubbles/textBubble'
import { VideoBubble } from '@/features/blocks/bubbles/video'
import {
import type {
AudioBubbleContent,
BubbleBlockType,
ChatMessage,
EmbedBubbleContent,
ImageBubbleContent,
TextBubbleContent,
VideoBubbleContent,
} from 'models'
import { BubbleBlockType } from 'models/features/blocks/bubbles/enums'
import { Match, Switch } from 'solid-js'
type Props = {

View File

@ -0,0 +1,3 @@
export * from './SendButton'
export * from './TypingBubble'
export * from './inputs'