2
0

(chat) Improve chat API compatibility with preview mode

This commit is contained in:
Baptiste Arnaud
2023-01-16 12:13:21 +01:00
parent dbe5c3cdb1
commit 7311988901
55 changed files with 4133 additions and 465 deletions

View File

@ -1,83 +1,128 @@
import { LiteBadge } from './LiteBadge'
import { createSignal, onCleanup, onMount, Show } from 'solid-js'
import {
getViewerUrl,
injectCustomHeadCode,
isDefined,
isNotEmpty,
} from 'utils'
import { createEffect, createSignal, onCleanup, onMount, Show } from 'solid-js'
import { getViewerUrl, injectCustomHeadCode, isEmpty, isNotEmpty } from 'utils'
import { getInitialChatReplyQuery } from '@/queries/getInitialChatReplyQuery'
import { ConversationContainer } from './ConversationContainer'
import css from '../assets/index.css'
import { InitialChatReply, StartParams } from 'models'
import { StartParams } from 'models'
import { setIsMobile } from '@/utils/isMobileSignal'
import { BotContext } from '@/types'
import { BotContext, InitialChatReply } from '@/types'
import { ErrorMessage } from './ErrorMessage'
import {
getExistingResultIdFromSession,
setResultInSession,
} from '@/utils/sessionStorage'
import { setCssVariablesValue } from '@/utils/setCssVariablesValue'
export type BotProps = StartParams & {
initialChatReply?: InitialChatReply
apiHost?: string
onNewInputBlock?: (ids: { id: string; groupId: string }) => void
onAnswer?: (answer: { message: string; blockId: string }) => void
onInit?: () => void
onEnd?: () => void
}
export const Bot = (props: BotProps) => {
const [initialChatReply, setInitialChatReply] = createSignal<
InitialChatReply | undefined
>(props.initialChatReply)
>()
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 initializeBot = async () => {
const urlParams = new URLSearchParams(location.search)
props.onInit?.()
const prefilledVariables: { [key: string]: string } = {}
urlParams.forEach((value, key) => {
prefilledVariables[key] = value
})
const { data, error } = await getInitialChatReplyQuery({
typebot: props.typebot,
apiHost: props.apiHost,
isPreview: props.isPreview ?? false,
resultId: isNotEmpty(props.resultId)
? props.resultId
: getExistingResultIdFromSession(),
startGroupId: props.startGroupId,
prefilledVariables: {
...prefilledVariables,
...props.prefilledVariables,
},
})
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.'))
return
}
if (!data) return setError(new Error("Couldn't initiate the chat"))
if (data.resultId) setResultInSession(data.resultId)
setInitialChatReply(data)
if (data.input?.id && props.onNewInputBlock)
props.onNewInputBlock({
id: data.input.id,
groupId: data.input.groupId,
})
const customHeadCode = data.typebot.settings.metadata.customHeadCode
if (customHeadCode) injectCustomHeadCode(customHeadCode)
}
onMount(() => {
if (!props.typebotId) return
const initialChatReplyValue = initialChatReply()
if (isDefined(initialChatReplyValue)) {
const customHeadCode =
initialChatReplyValue.typebot.settings.metadata.customHeadCode
if (customHeadCode) injectCustomHeadCode(customHeadCode)
} else {
const urlParams = new URLSearchParams(location.search)
const prefilledVariables: { [key: string]: string } = {}
urlParams.forEach((value, key) => {
prefilledVariables[key] = value
})
getInitialChatReplyQuery({
typebotId: props.typebotId,
apiHost: props.apiHost,
isPreview: props.isPreview ?? false,
resultId: props.resultId,
prefilledVariables: {
...prefilledVariables,
...props.prefilledVariables,
},
}).then((initialChatReply) => {
setInitialChatReply(initialChatReply)
})
}
initializeBot().then()
})
return (
<Show
when={isNotEmpty(props.apiHost ?? getViewerUrl())}
fallback={() => (
<p>process.env.NEXT_PUBLIC_VIEWER_URL is missing in env</p>
)}
>
<>
<style>{css}</style>
<Show when={error()} keyed>
{(error) => <ErrorMessage error={error} />}
</Show>
<Show when={initialChatReply()} keyed>
{(initialChatReply) => (
<BotContent
initialChatReply={initialChatReply}
initialChatReply={{
...initialChatReply,
typebot: {
...initialChatReply.typebot,
settings:
typeof props.typebot === 'string'
? initialChatReply.typebot?.settings
: props.typebot?.settings,
theme:
typeof props.typebot === 'string'
? initialChatReply.typebot?.theme
: props.typebot?.theme,
},
}}
context={{
apiHost: props.apiHost,
isPreview: props.isPreview ?? false,
typebotId: props.typebotId as string,
typebotId: initialChatReply.typebot.id,
resultId: initialChatReply.resultId,
}}
onNewInputBlock={props.onNewInputBlock}
onAnswer={props.onAnswer}
onEnd={props.onEnd}
/>
)}
</Show>
</Show>
</>
)
}
type BotContentProps = {
initialChatReply: InitialChatReply
context: BotContext
onNewInputBlock?: (ids: { id: string; groupId: string }) => void
onAnswer?: (answer: { message: string; blockId: string }) => void
onEnd?: () => void
}
const BotContent = (props: BotContentProps) => {
@ -98,38 +143,39 @@ const BotContent = (props: BotContentProps) => {
onMount(() => {
injectCustomFont()
if (botContainer) {
resizeObserver.observe(botContainer)
}
if (!botContainer) return
resizeObserver.observe(botContainer)
})
createEffect(() => {
if (!botContainer) return
setCssVariablesValue(props.initialChatReply.typebot.theme, botContainer)
})
onCleanup(() => {
if (botContainer) {
resizeObserver.unobserve(botContainer)
}
if (!botContainer) return
resizeObserver.unobserve(botContainer)
})
return (
<>
<style>{css}</style>
<div
ref={botContainer}
class="flex w-full h-full text-base overflow-hidden bg-cover flex-col items-center typebot-container"
>
<div class="flex w-full h-full justify-center">
<ConversationContainer
context={props.context}
initialChatReply={props.initialChatReply}
/>
</div>
<Show
when={
props.initialChatReply.typebot.settings.general.isBrandingEnabled
}
>
<LiteBadge botContainer={botContainer} />
</Show>
<div
ref={botContainer}
class="relative flex w-full h-full text-base overflow-hidden bg-cover flex-col items-center typebot-container"
>
<div class="flex w-full h-full justify-center">
<ConversationContainer
context={props.context}
initialChatReply={props.initialChatReply}
onNewInputBlock={props.onNewInputBlock}
onAnswer={props.onAnswer}
onEnd={props.onEnd}
/>
</div>
</>
<Show
when={props.initialChatReply.typebot.settings.general.isBrandingEnabled}
>
<LiteBadge botContainer={botContainer} />
</Show>
</div>
)
}

View File

@ -10,7 +10,9 @@ type Props = Pick<ChatReply, 'messages' | 'input'> & {
settings: Settings
inputIndex: number
context: BotContext
onScrollToBottom: () => void
onSubmit: (input: string) => void
onEnd?: () => void
onSkip: () => void
}
@ -23,6 +25,9 @@ export const ChatChunk = (props: Props) => {
? displayedMessageIndex()
: displayedMessageIndex() + 1
)
props.onScrollToBottom()
if (!props.input && displayedMessageIndex() === props.messages.length)
return props.onEnd?.()
}
return (

View File

@ -1,31 +1,73 @@
import { ChatReply, InitialChatReply } from 'models'
import { ChatReply, Theme } from 'models'
import { createSignal, For } from 'solid-js'
import { sendMessageQuery } from '@/queries/sendMessageQuery'
import { ChatChunk } from './ChatChunk'
import { BotContext } from '@/types'
import { BotContext, InitialChatReply } from '@/types'
import { executeIntegrations } from '@/utils/executeIntegrations'
import { executeLogic } from '@/utils/executeLogic'
const parseDynamicTheme = (
theme: Theme,
dynamicTheme: ChatReply['dynamicTheme']
): Theme => ({
...theme,
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,
},
})
type Props = {
initialChatReply: InitialChatReply
context: BotContext
onNewInputBlock?: (ids: { id: string; groupId: string }) => void
onAnswer?: (answer: { message: string; blockId: string }) => void
onEnd?: () => void
}
export const ConversationContainer = (props: Props) => {
let bottomSpacer: HTMLDivElement | undefined
const [chatChunks, setChatChunks] = createSignal<ChatReply[]>([
{
input: props.initialChatReply.input,
messages: props.initialChatReply.messages,
},
])
const [theme, setTheme] = createSignal(
parseDynamicTheme(
props.initialChatReply.typebot.theme,
props.initialChatReply.dynamicTheme
)
)
const sendMessage = async (message: string) => {
const currentBlockId = chatChunks().at(-1)?.input?.id
if (currentBlockId && props.onAnswer)
props.onAnswer({ message, blockId: currentBlockId })
const data = await sendMessageQuery({
apiHost: props.context.apiHost,
sessionId: props.initialChatReply.sessionId,
message,
})
if (!data) return
if (data.dynamicTheme) applyDynamicTheme(data.dynamicTheme)
if (data.input?.id && props.onNewInputBlock) {
props.onNewInputBlock({
id: data.input.id,
groupId: data.input.groupId,
})
}
if (data.integrations) {
executeIntegrations(data.integrations)
}
@ -41,6 +83,17 @@ 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)
}
return (
<div class="overflow-y-scroll w-full min-h-full rounded px-3 pt-10 relative scrollable-container typebot-chat-view">
<For each={chatChunks()}>
@ -49,16 +102,26 @@ export const ConversationContainer = (props: Props) => {
inputIndex={index()}
messages={chatChunk.messages}
input={chatChunk.input}
theme={props.initialChatReply.typebot.theme}
theme={theme()}
settings={props.initialChatReply.typebot.settings}
onSubmit={sendMessage}
onScrollToBottom={autoScrollToBottom}
onSkip={() => {
// TODO: implement skip
}}
onEnd={props.onEnd}
context={props.context}
/>
)}
</For>
<BottomSpacer ref={bottomSpacer} />
</div>
)
}
type BottomSpacerProps = {
ref: HTMLDivElement | undefined
}
const BottomSpacer = (props: BottomSpacerProps) => {
return <div ref={props.ref} class="w-full h-32" />
}

View File

@ -0,0 +1,10 @@
type Props = {
error: Error
}
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>
</div>
)
}

View File

@ -44,7 +44,7 @@ export const InputChatBlock = (props: Props) => {
const handleSubmit = async ({ label, value }: InputSubmitContent) => {
setAnswer(label ?? value)
props.onSubmit(value)
props.onSubmit(value ?? label)
}
return (
@ -96,37 +96,40 @@ const Input = (props: {
<Switch>
<Match when={props.block.type === InputBlockType.TEXT}>
<TextInput
block={props.block as TextInputBlock & { prefilledValue?: string }}
block={props.block as TextInputBlock}
defaultValue={props.block.prefilledValue}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
</Match>
<Match when={props.block.type === InputBlockType.NUMBER}>
<NumberInput
block={props.block as NumberInputBlock & { prefilledValue?: string }}
block={props.block as NumberInputBlock}
defaultValue={props.block.prefilledValue}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
</Match>
<Match when={props.block.type === InputBlockType.EMAIL}>
<EmailInput
block={props.block as EmailInputBlock & { prefilledValue?: string }}
block={props.block as EmailInputBlock}
defaultValue={props.block.prefilledValue}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
</Match>
<Match when={props.block.type === InputBlockType.URL}>
<UrlInput
block={props.block as UrlInputBlock & { prefilledValue?: string }}
block={props.block as UrlInputBlock}
defaultValue={props.block.prefilledValue}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
</Match>
<Match when={props.block.type === InputBlockType.PHONE}>
<PhoneInput
block={
props.block as PhoneNumberInputBlock & { prefilledValue?: string }
}
block={props.block as PhoneNumberInputBlock}
defaultValue={props.block.prefilledValue}
onSubmit={onSubmit}
hasGuestAvatar={props.hasGuestAvatar}
/>
@ -146,7 +149,8 @@ const Input = (props: {
</Match>
<Match when={props.block.type === InputBlockType.RATING}>
<RatingForm
block={props.block as RatingInputBlock & { prefilledValue?: string }}
block={props.block as RatingInputBlock}
defaultValue={props.block.prefilledValue}
onSubmit={onSubmit}
/>
</Match>

View File

@ -38,7 +38,7 @@ export const LiteBadge = (props: Props) => {
href={'https://www.typebot.io/?utm_source=litebadge'}
target="_blank"
rel="noopener noreferrer"
class="fixed py-1 px-2 bg-white z-50 rounded shadow-md lite-badge"
class="absolute py-1 px-2 bg-white z-50 rounded shadow-md lite-badge text-gray-900"
style={{ bottom: '20px' }}
id="lite-badge"
>

View File

@ -1,24 +1,25 @@
import { isMobile } from '@/utils/isMobileSignal'
import { Show } from 'solid-js'
import { isNotEmpty } from 'utils'
import { DefaultAvatar } from './DefaultAvatar'
export const Avatar = (props: { avatarSrc?: string }) => (
<Show when={props.avatarSrc !== ''}>
<Show when={props.avatarSrc} keyed fallback={() => <DefaultAvatar />}>
{(currentAvatarSrc) => (
<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={currentAvatarSrc}
alt="Bot avatar"
class="rounded-full object-cover w-full h-full"
/>
</figure>
)}
</Show>
<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')
}
>
<img
src={props.avatarSrc}
alt="Bot avatar"
class="rounded-full object-cover w-full h-full"
/>
</figure>
</Show>
)

View File

@ -1,4 +1,3 @@
import { isMobile } from '@/utils/isMobileSignal'
import { splitProps } from 'solid-js'
import { JSX } from 'solid-js/jsx-runtime'
@ -9,13 +8,13 @@ type ShortTextInputProps = {
export const ShortTextInput = (props: ShortTextInputProps) => {
const [local, others] = splitProps(props, ['ref', 'onInput'])
return (
<input
ref={local.ref}
ref={props.ref}
class="focus:outline-none bg-transparent px-4 py-4 flex-1 w-full text-input"
type="text"
style={{ 'font-size': '16px' }}
autofocus={!isMobile()}
onInput={(e) => local.onInput(e.currentTarget.value)}
{...others}
/>