Add audio clips option on text input block

Closes #157
This commit is contained in:
Baptiste Arnaud
2024-08-20 14:35:20 +02:00
parent 984c2bf387
commit 135251d3f7
55 changed files with 1535 additions and 366 deletions

View File

@@ -38,6 +38,7 @@ import { CorsError } from '@/utils/CorsError'
import { Toaster, Toast } from '@ark-ui/solid'
import { CloseIcon } from './icons/CloseIcon'
import { toaster } from '@/utils/toaster'
import { setBotContainer } from '@/utils/botContainerSignal'
export type BotProps = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -285,16 +286,18 @@ const BotContent = (props: BotContentProps) => {
key: `typebot-${props.context.typebot.id}-progressValue`,
}
)
let botContainer: HTMLDivElement | undefined
let botContainerElement: HTMLDivElement | undefined
const resizeObserver = new ResizeObserver((entries) => {
return setIsMobile(entries[0].target.clientWidth < 400)
})
onMount(() => {
if (!botContainer) return
resizeObserver.observe(botContainer)
setBotContainerHeight(`${botContainer.clientHeight}px`)
if (!botContainerElement) return
console.log('yes')
setBotContainer(botContainerElement)
resizeObserver.observe(botContainerElement)
setBotContainerHeight(`${botContainerElement.clientHeight}px`)
})
createEffect(() => {
@@ -304,22 +307,22 @@ const BotContent = (props: BotContentProps) => {
family: defaultFontFamily,
}
)
if (!botContainer) return
if (!botContainerElement) return
setCssVariablesValue(
props.initialChatReply.typebot.theme,
botContainer,
botContainerElement,
props.context.isPreview
)
})
onCleanup(() => {
if (!botContainer) return
resizeObserver.unobserve(botContainer)
if (!botContainerElement) return
resizeObserver.unobserve(botContainerElement)
})
return (
<div
ref={botContainer}
ref={botContainerElement}
class={clsx(
'relative flex w-full h-full text-base overflow-hidden flex-col justify-center items-center typebot-container',
props.class
@@ -358,7 +361,7 @@ const BotContent = (props: BotContentProps) => {
props.initialChatReply.typebot.settings.general?.isBrandingEnabled
}
>
<LiteBadge botContainer={botContainer} />
<LiteBadge botContainer={botContainerElement} />
</Show>
<Toaster toaster={toaster}>
{(toast) => (

View File

@@ -1,4 +1,8 @@
import { Answer, BotContext, ChatChunk as ChatChunkType } from '@/types'
import {
InputSubmitContent,
BotContext,
ChatChunk as ChatChunkType,
} from '@/types'
import { isMobile } from '@/utils/isMobileSignal'
import { ContinueChatResponse, Settings, Theme } from '@typebot.io/schemas'
import { createSignal, For, onMount, Show } from 'solid-js'
@@ -23,7 +27,7 @@ type Props = Pick<ContinueChatResponse, 'messages' | 'input'> & {
isTransitionDisabled?: boolean
onNewBubbleDisplayed: (blockId: string) => Promise<void>
onScrollToBottom: (ref?: HTMLDivElement, offset?: number) => void
onSubmit: (answer?: string, attachments?: Answer['attachments']) => void
onSubmit: (answer?: InputSubmitContent) => void
onSkip: () => void
onAllBubblesDisplayed: () => void
}

View File

@@ -4,6 +4,7 @@ import {
Theme,
ChatLog,
StartChatResponse,
Message,
} from '@typebot.io/schemas'
import {
createEffect,
@@ -16,9 +17,9 @@ import {
import { continueChatQuery } from '@/queries/continueChatQuery'
import { ChatChunk } from './ChatChunk'
import {
Answer,
BotContext,
ChatChunk as ChatChunkType,
InputSubmitContent,
OutgoingLog,
} from '@/types'
import { isNotDefined } from '@typebot.io/lib'
@@ -33,6 +34,7 @@ import {
import { saveClientLogsQuery } from '@/queries/saveClientLogsQuery'
import { HTTPError } from 'ky'
import { persist } from '@/utils/persist'
import { getAnswerContent } from '@/utils/getAnswerContent'
const autoScrollBottomToleranceScreenPercent = 0.6
const bottomSpacerHeight = 128
@@ -142,15 +144,15 @@ export const ConversationContainer = (props: Props) => {
})
}
const sendMessage = async (
message?: string,
attachments?: Answer['attachments']
) => {
const sendMessage = async (answer?: InputSubmitContent) => {
setIsRecovered(false)
setHasError(false)
const currentInputBlock = [...chatChunks()].pop()?.input
if (currentInputBlock?.id && props.onAnswer && message)
props.onAnswer({ message, blockId: currentInputBlock.id })
if (currentInputBlock?.id && props.onAnswer && answer)
props.onAnswer({
message: getAnswerContent(answer),
blockId: currentInputBlock.id,
})
const longRequest = setTimeout(() => {
setIsSending(true)
}, 1000)
@@ -158,13 +160,7 @@ export const ConversationContainer = (props: Props) => {
const { data, error } = await continueChatQuery({
apiHost: props.context.apiHost,
sessionId: props.initialChatReply.sessionId,
message: message
? {
type: 'text',
text: message,
attachedFileUrls: attachments?.map((attachment) => attachment.url),
}
: undefined,
message: convertSubmitContentToMessage(answer),
})
clearTimeout(longRequest)
setIsSending(false)
@@ -294,7 +290,11 @@ export const ConversationContainer = (props: Props) => {
if (response && 'logs' in response) saveLogs(response.logs)
if (response && 'replyToSend' in response) {
setIsSending(false)
sendMessage(response.replyToSend)
sendMessage(
response.replyToSend
? { type: 'text', value: response.replyToSend }
: undefined
)
return
}
if (response && 'blockedPopupUrl' in response)
@@ -364,3 +364,16 @@ const BottomSpacer = () => (
style={{ height: bottomSpacerHeight + 'px' }}
/>
)
const convertSubmitContentToMessage = (
answer: InputSubmitContent | undefined
): Message | undefined => {
if (!answer) return
if (answer.type === 'text')
return {
type: 'text',
text: answer.value,
attachedFileUrls: answer.attachments?.map((attachment) => attachment.url),
}
if (answer.type === 'recording') return { type: 'audio', url: answer.url }
}

View File

@@ -15,7 +15,7 @@ import type {
DateInputBlock,
} from '@typebot.io/schemas'
import { GuestBubble } from './bubbles/GuestBubble'
import { Answer, BotContext, InputSubmitContent } from '@/types'
import { BotContext, InputSubmitContent } from '@/types'
import { TextInput } from '@/features/blocks/inputs/textInput'
import { NumberInput } from '@/features/blocks/inputs/number'
import { EmailInput } from '@/features/blocks/inputs/email'
@@ -48,33 +48,24 @@ type Props = {
isInputPrefillEnabled: boolean
hasError: boolean
onTransitionEnd: () => void
onSubmit: (answer: string, attachments?: Answer['attachments']) => void
onSubmit: (content: InputSubmitContent) => void
onSkip: () => void
}
export const InputChatBlock = (props: Props) => {
const [answer, setAnswer] = persist(createSignal<Answer>(), {
const [answer, setAnswer] = persist(createSignal<InputSubmitContent>(), {
key: `typebot-${props.context.typebot.id}-input-${props.chunkIndex}`,
storage: props.context.storage,
})
const handleSubmit = async ({
label,
value,
attachments,
}: InputSubmitContent & Pick<Answer, 'attachments'>) => {
setAnswer({
text: props.block.type !== InputBlockType.FILE ? label ?? value : '',
attachments,
})
props.onSubmit(
value ?? label,
props.block.type === InputBlockType.FILE ? undefined : attachments
)
const handleSubmit = async (content: InputSubmitContent) => {
console.log(content)
setAnswer(content)
props.onSubmit(content)
}
const handleSkip = (label: string) => {
setAnswer({ text: label })
setAnswer({ type: 'text', value: label })
props.onSkip()
}
@@ -83,14 +74,18 @@ export const InputChatBlock = (props: Props) => {
(message) => props.chunkIndex === message.inputIndex
)?.formattedMessage
if (formattedMessage && props.block.type !== InputBlockType.FILE)
setAnswer((answer) => ({ ...answer, text: formattedMessage }))
setAnswer((answer) =>
answer?.type === 'text'
? { ...answer, label: formattedMessage }
: answer
)
})
return (
<Switch>
<Match when={answer() && !props.hasError}>
<GuestBubble
message={answer() as Answer}
answer={answer()}
showAvatar={
props.guestAvatar?.isEnabled ?? defaultGuestAvatarIsEnabled
}
@@ -117,7 +112,9 @@ export const InputChatBlock = (props: Props) => {
block={props.block}
chunkIndex={props.chunkIndex}
isInputPrefillEnabled={props.isInputPrefillEnabled}
existingAnswer={props.hasError ? answer()?.text : undefined}
existingAnswer={
props.hasError ? getAnswerValue(answer()!) : undefined
}
onTransitionEnd={props.onTransitionEnd}
onSubmit={handleSubmit}
onSkip={handleSkip}
@@ -128,6 +125,11 @@ export const InputChatBlock = (props: Props) => {
)
}
const getAnswerValue = (answer?: InputSubmitContent) => {
if (!answer) return
return answer.type === 'text' ? answer.value : answer.url
}
const Input = (props: {
context: BotContext
block: NonNullable<ContinueChatResponse['input']>
@@ -146,6 +148,7 @@ const Input = (props: {
const submitPaymentSuccess = () =>
props.onSubmit({
type: 'text',
value:
(props.block.options as PaymentInputBlock['options'])?.labels
?.success ?? defaultPaymentInputOptions.labels.success,

View File

@@ -1,22 +1,24 @@
import { createSignal, For, Show } from 'solid-js'
import { createSignal, For, Show, Switch, Match } from 'solid-js'
import { Avatar } from '../avatars/Avatar'
import { isMobile } from '@/utils/isMobileSignal'
import { Answer } from '@/types'
import {
InputSubmitContent,
RecordingInputSubmitContent,
TextInputSubmitContent,
} from '@/types'
import { Modal } from '../Modal'
import { isNotEmpty } from '@typebot.io/lib'
import { FilePreview } from '@/features/blocks/inputs/fileUpload/components/FilePreview'
import clsx from 'clsx'
type Props = {
message: Answer
answer?: InputSubmitContent
showAvatar: boolean
avatarSrc?: string
hasHostAvatar: boolean
}
export const GuestBubble = (props: Props) => {
const [clickedImageSrc, setClickedImageSrc] = createSignal<string>()
return (
<div
class="flex justify-end items-end animate-fade-in gap-2 guest-container"
@@ -28,65 +30,87 @@ export const GuestBubble = (props: Props) => {
: undefined,
}}
>
<div class="flex flex-col gap-1 items-end">
<Show when={(props.message.attachments ?? []).length > 0}>
<div
class={clsx(
'flex gap-1 overflow-auto max-w-[350px]',
isMobile() ? 'flex-wrap justify-end' : 'items-center'
)}
>
<For
each={props.message.attachments?.filter((attachment) =>
attachment.type.startsWith('image')
)}
>
{(attachment, idx) => (
<img
src={attachment.url}
alt={`Attached image ${idx() + 1}`}
class={clsx(
'typebot-guest-bubble-image-attachment cursor-pointer',
props.message.attachments!.filter((attachment) =>
attachment.type.startsWith('image')
).length > 1 && 'max-w-[90%]'
)}
onClick={() => setClickedImageSrc(attachment.url)}
/>
)}
</For>
</div>
<div
class={clsx(
'flex gap-1 overflow-auto max-w-[350px]',
isMobile() ? 'flex-wrap justify-end' : 'items-center'
)}
>
<For
each={props.message.attachments?.filter(
(attachment) => !attachment.type.startsWith('image')
)}
>
{(attachment) => (
<FilePreview
file={{
name: attachment.url.split('/').at(-1)!,
}}
/>
)}
</For>
</div>
</Show>
<div
class="p-[1px] whitespace-pre-wrap max-w-full typebot-guest-bubble flex flex-col"
data-testid="guest-bubble"
>
<Show when={isNotEmpty(props.message.text)}>
<span class="px-[15px] py-[7px]">{props.message.text}</span>
</Show>
</div>
</div>
<Switch>
<Match when={props.answer?.type === 'text'}>
<TextGuestBubble answer={props.answer as TextInputSubmitContent} />
</Match>
<Match when={props.answer?.type === 'recording'}>
<AudioGuestBubble
answer={props.answer as RecordingInputSubmitContent}
/>
</Match>
</Switch>
<Show when={props.showAvatar}>
<Avatar initialAvatarSrc={props.avatarSrc} />
</Show>
</div>
)
}
const TextGuestBubble = (props: { answer: TextInputSubmitContent }) => {
const [clickedImageSrc, setClickedImageSrc] = createSignal<string>()
return (
<div class="flex flex-col gap-1 items-end">
<Show when={(props.answer.attachments ?? []).length > 0}>
<div
class={clsx(
'flex gap-1 overflow-auto max-w-[350px]',
isMobile() ? 'flex-wrap justify-end' : 'items-center'
)}
>
<For
each={props.answer.attachments?.filter((attachment) =>
attachment.type.startsWith('image')
)}
>
{(attachment, idx) => (
<img
src={attachment.url}
alt={`Attached image ${idx() + 1}`}
class={clsx(
'typebot-guest-bubble-image-attachment cursor-pointer',
props.answer.attachments!.filter((attachment) =>
attachment.type.startsWith('image')
).length > 1 && 'max-w-[90%]'
)}
onClick={() => setClickedImageSrc(attachment.url)}
/>
)}
</For>
</div>
<div
class={clsx(
'flex gap-1 overflow-auto max-w-[350px]',
isMobile() ? 'flex-wrap justify-end' : 'items-center'
)}
>
<For
each={props.answer.attachments?.filter(
(attachment) => !attachment.type.startsWith('image')
)}
>
{(attachment) => (
<FilePreview
file={{
name: attachment.url.split('/').at(-1)!,
}}
/>
)}
</For>
</div>
</Show>
<div
class="p-[1px] whitespace-pre-wrap max-w-full typebot-guest-bubble flex flex-col"
data-testid="guest-bubble"
>
<Show when={isNotEmpty(props.answer.label ?? props.answer.value)}>
<span class="px-[15px] py-[7px]">
{props.answer.label ?? props.answer.value}
</span>
</Show>
</div>
<Modal
isOpen={clickedImageSrc() !== undefined}
onClose={() => setClickedImageSrc(undefined)}
@@ -97,9 +121,19 @@ export const GuestBubble = (props: Props) => {
style={{ 'border-radius': '6px' }}
/>
</Modal>
<Show when={props.showAvatar}>
<Avatar initialAvatarSrc={props.avatarSrc} />
</Show>
</div>
)
}
const AudioGuestBubble = (props: { answer: RecordingInputSubmitContent }) => {
return (
<div class="flex flex-col gap-1 items-end w-full">
<div
class="p-2 w-full whitespace-pre-wrap typebot-guest-bubble flex flex-col max-w-[316px]"
data-testid="guest-bubble"
>
<audio controls src={props.answer.url} class="w-full h-[54px]" />
</div>
</div>
)
}

View File

@@ -4,6 +4,7 @@ import { CustomEmbedBubble } from '@/features/blocks/bubbles/embed/components/Cu
import { ImageBubble } from '@/features/blocks/bubbles/image'
import { TextBubble } from '@/features/blocks/bubbles/textBubble'
import { VideoBubble } from '@/features/blocks/bubbles/video'
import { InputSubmitContent } from '@/types'
import type {
AudioBubbleBlock,
ChatMessage,
@@ -22,7 +23,7 @@ type Props = {
typingEmulation: Settings['typingEmulation']
isTypingSkipped: boolean
onTransitionEnd?: (ref?: HTMLDivElement) => void
onCompleted: (reply?: string) => void
onCompleted: (reply?: InputSubmitContent) => void
}
export const HostBubble = (props: Props) => (

View File

@@ -0,0 +1,12 @@
import { JSX } from 'solid-js/jsx-runtime'
export const MicrophoneIcon = (props: JSX.SvgSVGAttributes<SVGSVGElement>) => (
<svg
viewBox="0 0 384 512"
stroke="currentColor"
fill="currentColor"
{...props}
>
<path d="M192 0C139 0 96 43 96 96l0 160c0 53 43 96 96 96s96-43 96-96l0-160c0-53-43-96-96-96zM64 216c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 40c0 89.1 66.2 162.7 152 174.4l0 33.6-48 0c-13.3 0-24 10.7-24 24s10.7 24 24 24l72 0 72 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-48 0 0-33.6c85.8-11.7 152-85.3 152-174.4l0-40c0-13.3-10.7-24-24-24s-24 10.7-24 24l0 40c0 70.7-57.3 128-128 128s-128-57.3-128-128l0-40z" />
</svg>
)