✨ Introducing The Forge (#1072)
The Forge allows anyone to easily create their own Typebot Block. Closes #380
This commit is contained in:
@@ -263,7 +263,7 @@ pre {
|
||||
}
|
||||
|
||||
.typebot-chat-view {
|
||||
max-width: 800px;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.ping span {
|
||||
|
||||
@@ -15,6 +15,7 @@ import immutableCss from '../assets/immutable.css'
|
||||
import { InputBlock } from '@typebot.io/schemas'
|
||||
import { StartFrom } from '@typebot.io/schemas'
|
||||
import { defaultTheme } from '@typebot.io/schemas/features/typebot/theme/constants'
|
||||
import { clsx } from 'clsx'
|
||||
|
||||
export type BotProps = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -213,10 +214,10 @@ const BotContent = (props: BotContentProps) => {
|
||||
return (
|
||||
<div
|
||||
ref={botContainer}
|
||||
class={
|
||||
'relative flex w-full h-full text-base overflow-hidden bg-cover bg-center flex-col items-center typebot-container ' +
|
||||
class={clsx(
|
||||
'relative flex w-full h-full text-base overflow-hidden bg-cover bg-center flex-col items-center typebot-container @container',
|
||||
props.class
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div class="flex w-full h-full justify-center">
|
||||
<ConversationContainer
|
||||
|
||||
@@ -19,7 +19,7 @@ type Props = Pick<ContinueChatResponse, 'messages' | 'input'> & {
|
||||
streamingMessageId: ChatChunkType['streamingMessageId']
|
||||
onNewBubbleDisplayed: (blockId: string) => Promise<void>
|
||||
onScrollToBottom: (top?: number) => void
|
||||
onSubmit: (input: string) => void
|
||||
onSubmit: (input?: string) => void
|
||||
onSkip: () => void
|
||||
onAllBubblesDisplayed: () => void
|
||||
}
|
||||
@@ -91,6 +91,7 @@ export const ChatChunk = (props: Props) => {
|
||||
message={message}
|
||||
typingEmulation={props.settings.typingEmulation}
|
||||
onTransitionEnd={displayNextMessage}
|
||||
onCompleted={props.onSubmit}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { AudioBubble } from '@/features/blocks/bubbles/audio'
|
||||
import { EmbedBubble } from '@/features/blocks/bubbles/embed'
|
||||
import { CustomEmbedBubble } from '@/features/blocks/bubbles/embed/components/CustomEmbedBubble'
|
||||
import { ImageBubble } from '@/features/blocks/bubbles/image'
|
||||
import { TextBubble } from '@/features/blocks/bubbles/textBubble'
|
||||
import { VideoBubble } from '@/features/blocks/bubbles/video'
|
||||
import type {
|
||||
AudioBubbleBlock,
|
||||
ChatMessage,
|
||||
CustomEmbedBubble as CustomEmbedBubbleProps,
|
||||
EmbedBubbleBlock,
|
||||
ImageBubbleBlock,
|
||||
Settings,
|
||||
@@ -19,6 +21,7 @@ type Props = {
|
||||
message: ChatMessage
|
||||
typingEmulation: Settings['typingEmulation']
|
||||
onTransitionEnd: (offsetTop?: number) => void
|
||||
onCompleted: (reply?: string) => void
|
||||
}
|
||||
|
||||
export const HostBubble = (props: Props) => {
|
||||
@@ -26,6 +29,10 @@ export const HostBubble = (props: Props) => {
|
||||
props.onTransitionEnd(offsetTop)
|
||||
}
|
||||
|
||||
const onCompleted = (reply?: string) => {
|
||||
props.onCompleted(reply)
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.message.type === BubbleBlockType.TEXT}>
|
||||
@@ -53,6 +60,13 @@ export const HostBubble = (props: Props) => {
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.message.type === 'custom-embed'}>
|
||||
<CustomEmbedBubble
|
||||
content={props.message.content as CustomEmbedBubbleProps['content']}
|
||||
onTransitionEnd={onTransitionEnd}
|
||||
onCompleted={onCompleted}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={props.message.type === BubbleBlockType.AUDIO}>
|
||||
<AudioBubble
|
||||
content={props.message.content as AudioBubbleBlock['content']}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { TypingBubble } from '@/components'
|
||||
import { isMobile } from '@/utils/isMobileSignal'
|
||||
import { createSignal, onCleanup, onMount } from 'solid-js'
|
||||
import { clsx } from 'clsx'
|
||||
import { CustomEmbedBubble as CustomEmbedBubbleProps } from '@typebot.io/schemas'
|
||||
import { executeCode } from '@/features/blocks/logic/script/executeScript'
|
||||
|
||||
type Props = {
|
||||
content: CustomEmbedBubbleProps['content']
|
||||
onTransitionEnd: (offsetTop?: number) => void
|
||||
onCompleted: (reply?: string) => void
|
||||
}
|
||||
|
||||
let typingTimeout: NodeJS.Timeout
|
||||
|
||||
export const showAnimationDuration = 400
|
||||
|
||||
export const CustomEmbedBubble = (props: Props) => {
|
||||
let ref: HTMLDivElement | undefined
|
||||
const [isTyping, setIsTyping] = createSignal(true)
|
||||
let containerRef: HTMLDivElement | undefined
|
||||
|
||||
onMount(() => {
|
||||
console.log(
|
||||
props.content.initFunction.content,
|
||||
props.content.initFunction.args
|
||||
)
|
||||
executeCode({
|
||||
args: {
|
||||
...props.content.initFunction.args,
|
||||
typebotElement: containerRef,
|
||||
},
|
||||
content: props.content.initFunction.content,
|
||||
})
|
||||
|
||||
if (props.content.waitForEventFunction)
|
||||
executeCode({
|
||||
args: {
|
||||
...props.content.waitForEventFunction.args,
|
||||
continueFlow: props.onCompleted,
|
||||
},
|
||||
content: props.content.waitForEventFunction.content,
|
||||
})
|
||||
|
||||
typingTimeout = setTimeout(() => {
|
||||
setIsTyping(false)
|
||||
setTimeout(
|
||||
() => props.onTransitionEnd(ref?.offsetTop),
|
||||
showAnimationDuration
|
||||
)
|
||||
}, 2000)
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (typingTimeout) clearTimeout(typingTimeout)
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="flex flex-col w-full animate-fade-in" ref={ref}>
|
||||
<div class="flex w-full items-center">
|
||||
<div class="flex relative z-10 items-start typebot-host-bubble w-full max-w-full">
|
||||
<div
|
||||
class="flex items-center absolute px-4 py-2 bubble-typing z-10 "
|
||||
style={{
|
||||
width: isTyping() ? '64px' : '100%',
|
||||
height: isTyping() ? '32px' : '100%',
|
||||
}}
|
||||
>
|
||||
{isTyping() && <TypingBubble />}
|
||||
</div>
|
||||
<div
|
||||
class={clsx(
|
||||
'p-2 z-20 text-fade-in w-full',
|
||||
isTyping() ? 'opacity-0' : 'opacity-100'
|
||||
)}
|
||||
style={{
|
||||
height: isTyping() ? (isMobile() ? '32px' : '36px') : undefined,
|
||||
}}
|
||||
>
|
||||
<div class="w-full h-full overflow-scroll" ref={containerRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,15 +9,16 @@ const maxRetryAttempts = 3
|
||||
|
||||
export const streamChat =
|
||||
(context: ClientSideActionContext & { retryAttempt?: number }) =>
|
||||
async (
|
||||
messages: {
|
||||
async ({
|
||||
messages,
|
||||
onMessageStream,
|
||||
}: {
|
||||
messages?: {
|
||||
content?: string | undefined
|
||||
role?: 'system' | 'user' | 'assistant' | undefined
|
||||
}[],
|
||||
{
|
||||
onMessageStream,
|
||||
}: { onMessageStream?: (props: { id: string; message: string }) => void }
|
||||
): Promise<{ message?: string; error?: object }> => {
|
||||
}[]
|
||||
onMessageStream?: (props: { id: string; message: string }) => void
|
||||
}): Promise<{ message?: string; error?: object }> => {
|
||||
try {
|
||||
abortController = new AbortController()
|
||||
|
||||
@@ -51,7 +52,7 @@ export const streamChat =
|
||||
return streamChat({
|
||||
...context,
|
||||
retryAttempt: (context.retryAttempt ?? 0) + 1,
|
||||
})(messages, { onMessageStream })
|
||||
})({ messages, onMessageStream })
|
||||
}
|
||||
return {
|
||||
error: (await res.json()) || 'Failed to fetch the chat response.',
|
||||
|
||||
@@ -21,3 +21,18 @@ const parseContent = (content: string) => {
|
||||
.replace(/<\/script>/g, '')
|
||||
return contentWithoutScriptTags
|
||||
}
|
||||
|
||||
export const executeCode = async ({
|
||||
args,
|
||||
content,
|
||||
}: {
|
||||
content: string
|
||||
args: Record<string, unknown>
|
||||
}) => {
|
||||
try {
|
||||
const func = AsyncFunction(...Object.keys(args), content)
|
||||
await func(...Object.keys(args).map((key) => args[key]))
|
||||
} catch (err) {
|
||||
console.warn('Script threw an error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,10 @@ import { executeChatwoot } from '@/features/blocks/integrations/chatwoot'
|
||||
import { executeGoogleAnalyticsBlock } from '@/features/blocks/integrations/googleAnalytics/utils/executeGoogleAnalytics'
|
||||
import { streamChat } from '@/features/blocks/integrations/openai/streamChat'
|
||||
import { executeRedirect } from '@/features/blocks/logic/redirect'
|
||||
import { executeScript } from '@/features/blocks/logic/script/executeScript'
|
||||
import {
|
||||
executeScript,
|
||||
executeCode,
|
||||
} from '@/features/blocks/logic/script/executeScript'
|
||||
import { executeSetVariable } from '@/features/blocks/logic/setVariable/executeSetVariable'
|
||||
import { executeWait } from '@/features/blocks/logic/wait/utils/executeWait'
|
||||
import { executeWebhook } from '@/features/blocks/integrations/webhook/executeWebhook'
|
||||
@@ -47,20 +50,24 @@ export const executeClientSideAction = async ({
|
||||
if ('setVariable' in clientSideAction) {
|
||||
return executeSetVariable(clientSideAction.setVariable.scriptToExecute)
|
||||
}
|
||||
if ('streamOpenAiChatCompletion' in clientSideAction) {
|
||||
const { error, message } = await streamChat(context)(
|
||||
clientSideAction.streamOpenAiChatCompletion.messages,
|
||||
{
|
||||
onMessageStream,
|
||||
}
|
||||
)
|
||||
if (
|
||||
'streamOpenAiChatCompletion' in clientSideAction ||
|
||||
'stream' in clientSideAction
|
||||
) {
|
||||
const { error, message } = await streamChat(context)({
|
||||
messages:
|
||||
'streamOpenAiChatCompletion' in clientSideAction
|
||||
? clientSideAction.streamOpenAiChatCompletion?.messages
|
||||
: undefined,
|
||||
onMessageStream,
|
||||
})
|
||||
if (error)
|
||||
return {
|
||||
replyToSend: undefined,
|
||||
logs: [
|
||||
{
|
||||
status: 'error',
|
||||
description: 'OpenAI returned an error',
|
||||
description: 'Message streaming returned an error',
|
||||
details: JSON.stringify(error, null, 2),
|
||||
},
|
||||
],
|
||||
@@ -77,4 +84,7 @@ export const executeClientSideAction = async ({
|
||||
if ('pixel' in clientSideAction) {
|
||||
return executePixel(clientSideAction.pixel)
|
||||
}
|
||||
if ('codeToExecute' in clientSideAction) {
|
||||
return executeCode(clientSideAction.codeToExecute)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"typescript": "5.3.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"next": "12.x || 13.x",
|
||||
"next": "12.x || 13.x || 14.x",
|
||||
"react": "18.x"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user