2
0

Improve new bot engine client side actions

We make sure to save client side actions in an array that will be executed sequentially
This commit is contained in:
Baptiste Arnaud
2023-01-26 15:26:42 +01:00
parent 0fc82cf73b
commit 9aab6ddb2c
15 changed files with 133 additions and 106 deletions

View File

@ -108,17 +108,18 @@ const deleteOutgoingEdgeIdProps = (
const block = typebot.groups[fromGroupIndex].blocks[fromBlockIndex] as const block = typebot.groups[fromGroupIndex].blocks[fromBlockIndex] as
| Block | Block
| undefined | undefined
if (!block) return
const fromItemIndex = const fromItemIndex =
edge.from.itemId && block && blockHasItems(block) edge.from.itemId && blockHasItems(block)
? block.items.findIndex(byId(edge.from.itemId)) ? block.items.findIndex(byId(edge.from.itemId))
: -1 : -1
if (fromBlockIndex !== -1) if (fromItemIndex !== -1) {
typebot.groups[fromGroupIndex].blocks[fromBlockIndex].outgoingEdgeId = ;(
undefined
if (fromItemIndex !== -1)
(
typebot.groups[fromGroupIndex].blocks[fromBlockIndex] as BlockWithItems typebot.groups[fromGroupIndex].blocks[fromBlockIndex] as BlockWithItems
).items[fromItemIndex].outgoingEdgeId = undefined ).items[fromItemIndex].outgoingEdgeId = undefined
} else if (fromBlockIndex !== -1)
typebot.groups[fromGroupIndex].blocks[fromBlockIndex].outgoingEdgeId =
undefined
} }
export const cleanUpEdgeDraft = ( export const cleanUpEdgeDraft = (

View File

@ -56,21 +56,23 @@ export const executeChatwootBlock = (
const chatwootCode = parseChatwootOpenCode(block.options) const chatwootCode = parseChatwootOpenCode(block.options)
return { return {
outgoingEdgeId: block.outgoingEdgeId, outgoingEdgeId: block.outgoingEdgeId,
integrations: { clientSideActions: [
chatwoot: { {
codeToExecute: { chatwoot: {
content: parseVariables(variables, { fieldToParse: 'id' })( codeToExecute: {
chatwootCode content: parseVariables(variables, { fieldToParse: 'id' })(
), chatwootCode
args: extractVariablesFromText(variables)(chatwootCode).map( ),
(variable) => ({ args: extractVariablesFromText(variables)(chatwootCode).map(
id: variable.id, (variable) => ({
value: parseCorrectValueType(variable.value), id: variable.id,
}) value: parseCorrectValueType(variable.value),
), })
),
},
}, },
}, },
}, ],
logs: isPreview logs: isPreview
? [ ? [
{ {

View File

@ -7,7 +7,9 @@ export const executeGoogleAnalyticsBlock = (
block: GoogleAnalyticsBlock block: GoogleAnalyticsBlock
): ExecuteIntegrationResponse => ({ ): ExecuteIntegrationResponse => ({
outgoingEdgeId: block.outgoingEdgeId, outgoingEdgeId: block.outgoingEdgeId,
integrations: { clientSideActions: [
googleAnalytics: deepParseVariable(variables)(block.options), {
}, googleAnalytics: deepParseVariable(variables)(block.options),
},
],
}) })

View File

@ -10,7 +10,6 @@ export const insertRow = async (
options, options,
}: { outgoingEdgeId?: string; options: GoogleSheetsInsertRowOptions } }: { outgoingEdgeId?: string; options: GoogleSheetsInsertRowOptions }
): Promise<ExecuteIntegrationResponse> => { ): Promise<ExecuteIntegrationResponse> => {
console.log('insertRow', options)
if (!options.cellsToInsert || !options.sheetId) return { outgoingEdgeId } if (!options.cellsToInsert || !options.sheetId) return { outgoingEdgeId }
let log: ReplyLog | undefined let log: ReplyLog | undefined

View File

@ -24,11 +24,13 @@ export const executeCode = (
return { return {
outgoingEdgeId: block.outgoingEdgeId, outgoingEdgeId: block.outgoingEdgeId,
logic: { clientSideActions: [
codeToExecute: { {
content, codeToExecute: {
args, content,
args,
},
}, },
}, ],
} }
} }

View File

@ -10,9 +10,11 @@ export const executeRedirect = (
if (!block.options?.url) return { outgoingEdgeId: block.outgoingEdgeId } if (!block.options?.url) return { outgoingEdgeId: block.outgoingEdgeId }
const formattedUrl = sanitizeUrl(parseVariables(variables)(block.options.url)) const formattedUrl = sanitizeUrl(parseVariables(variables)(block.options.url))
return { return {
logic: { clientSideActions: [
redirect: { url: formattedUrl, isNewTab: block.options.isNewTab }, {
}, redirect: { url: formattedUrl, isNewTab: block.options.isNewTab },
},
],
outgoingEdgeId: block.outgoingEdgeId, outgoingEdgeId: block.outgoingEdgeId,
} }
} }

View File

@ -48,6 +48,7 @@ export const sendMessageProcedure = publicProcedure
resultId, resultId,
dynamicTheme, dynamicTheme,
logs, logs,
clientSideActions,
} = await startSession(startParams) } = await startSession(startParams)
return { return {
sessionId, sessionId,
@ -63,9 +64,10 @@ export const sendMessageProcedure = publicProcedure
resultId, resultId,
dynamicTheme, dynamicTheme,
logs, logs,
clientSideActions,
} }
} else { } else {
const { messages, input, logic, newSessionState, integrations, logs } = const { messages, input, clientSideActions, newSessionState, logs } =
await continueBotFlow(session.state)(message) await continueBotFlow(session.state)(message)
await prisma.chatSession.updateMany({ await prisma.chatSession.updateMany({
@ -78,8 +80,7 @@ export const sendMessageProcedure = publicProcedure
return { return {
messages, messages,
input, input,
logic, clientSideActions,
integrations,
dynamicTheme: parseDynamicThemeReply(newSessionState), dynamicTheme: parseDynamicThemeReply(newSessionState),
logs, logs,
} }
@ -133,7 +134,7 @@ const startSession = async (startParams?: StartParams) => {
const { const {
messages, messages,
input, input,
logic, clientSideActions,
newSessionState: newInitialState, newSessionState: newInitialState,
logs, logs,
} = await startBotFlow(initialState, startParams.startGroupId) } = await startBotFlow(initialState, startParams.startGroupId)
@ -141,7 +142,7 @@ const startSession = async (startParams?: StartParams) => {
if (!input) if (!input)
return { return {
messages, messages,
logic, clientSideActions,
typebot: { typebot: {
id: typebot.id, id: typebot.id,
settings: deepParseVariable(newInitialState.typebot.variables)( settings: deepParseVariable(newInitialState.typebot.variables)(
@ -183,7 +184,7 @@ const startSession = async (startParams?: StartParams) => {
}, },
messages, messages,
input, input,
logic, clientSideActions,
dynamicTheme: parseDynamicThemeReply(newInitialState), dynamicTheme: parseDynamicThemeReply(newInitialState),
logs, logs,
} satisfies ChatReply } satisfies ChatReply

View File

@ -25,8 +25,8 @@ export const executeGroup =
group: Group group: Group
): Promise<ChatReply & { newSessionState: SessionState }> => { ): Promise<ChatReply & { newSessionState: SessionState }> => {
const messages: ChatReply['messages'] = currentReply?.messages ?? [] const messages: ChatReply['messages'] = currentReply?.messages ?? []
let logic: ChatReply['logic'] = currentReply?.logic let clientSideActions: ChatReply['clientSideActions'] =
let integrations: ChatReply['integrations'] = currentReply?.integrations currentReply?.clientSideActions
let logs: ChatReply['logs'] = currentReply?.logs let logs: ChatReply['logs'] = currentReply?.logs
let nextEdgeId = null let nextEdgeId = null
@ -59,8 +59,7 @@ export const executeGroup =
blockId: block.id, blockId: block.id,
}, },
}, },
logic, clientSideActions,
integrations,
logs, logs,
} }
const executionResponse = isLogicBlock(block) const executionResponse = isLogicBlock(block)
@ -70,10 +69,14 @@ export const executeGroup =
: null : null
if (!executionResponse) continue if (!executionResponse) continue
if ('logic' in executionResponse && executionResponse.logic) if (
logic = { ...logic, ...executionResponse.logic } 'clientSideActions' in executionResponse &&
if ('integrations' in executionResponse && executionResponse.integrations) executionResponse.clientSideActions
integrations = { ...integrations, ...executionResponse.integrations } )
clientSideActions = [
...(clientSideActions ?? []),
...executionResponse.clientSideActions,
]
if (executionResponse.logs) if (executionResponse.logs)
logs = [...(logs ?? []), ...executionResponse.logs] logs = [...(logs ?? []), ...executionResponse.logs]
if (executionResponse.newSessionState) if (executionResponse.newSessionState)
@ -85,20 +88,19 @@ export const executeGroup =
} }
if (!nextEdgeId) if (!nextEdgeId)
return { messages, newSessionState, logic, integrations, logs } return { messages, newSessionState, clientSideActions, logs }
const nextGroup = getNextGroup(newSessionState)(nextEdgeId) const nextGroup = getNextGroup(newSessionState)(nextEdgeId)
if (nextGroup?.updatedContext) newSessionState = nextGroup.updatedContext if (nextGroup?.updatedContext) newSessionState = nextGroup.updatedContext
if (!nextGroup) { if (!nextGroup) {
return { messages, newSessionState, logic, integrations, logs } return { messages, newSessionState, clientSideActions, logs }
} }
return executeGroup(newSessionState, { return executeGroup(newSessionState, {
messages, messages,
logic, clientSideActions,
integrations,
logs, logs,
})(nextGroup.group) })(nextGroup.group)
} }

View File

@ -5,9 +5,9 @@ export type EdgeId = string
export type ExecuteLogicResponse = { export type ExecuteLogicResponse = {
outgoingEdgeId: EdgeId | undefined outgoingEdgeId: EdgeId | undefined
newSessionState?: SessionState newSessionState?: SessionState
} & Pick<ChatReply, 'logic' | 'logs'> } & Pick<ChatReply, 'clientSideActions' | 'logs'>
export type ExecuteIntegrationResponse = { export type ExecuteIntegrationResponse = {
outgoingEdgeId: EdgeId | undefined outgoingEdgeId: EdgeId | undefined
newSessionState?: SessionState newSessionState?: SessionState
} & Pick<ChatReply, 'integrations' | 'logs'> } & Pick<ChatReply, 'clientSideActions' | 'logs'>

View File

@ -12,14 +12,17 @@ type Props = Pick<ChatReply, 'messages' | 'input'> & {
context: BotContext context: BotContext
onScrollToBottom: () => void onScrollToBottom: () => void
onSubmit: (input: string) => void onSubmit: (input: string) => void
onEnd?: () => void
onSkip: () => void onSkip: () => void
onAllBubblesDisplayed: () => void
} }
export const ChatChunk = (props: Props) => { export const ChatChunk = (props: Props) => {
const [displayedMessageIndex, setDisplayedMessageIndex] = createSignal(0) const [displayedMessageIndex, setDisplayedMessageIndex] = createSignal(0)
onMount(() => { onMount(() => {
if (props.messages.length === 0) {
props.onAllBubblesDisplayed()
}
props.onScrollToBottom() props.onScrollToBottom()
}) })
@ -30,8 +33,9 @@ export const ChatChunk = (props: Props) => {
: displayedMessageIndex() + 1 : displayedMessageIndex() + 1
) )
props.onScrollToBottom() props.onScrollToBottom()
if (!props.input && displayedMessageIndex() === props.messages.length) if (displayedMessageIndex() === props.messages.length) {
return props.onEnd?.() props.onAllBubblesDisplayed()
}
} }
return ( return (

View File

@ -3,8 +3,8 @@ import { createEffect, createSignal, For } from 'solid-js'
import { sendMessageQuery } from '@/queries/sendMessageQuery' import { sendMessageQuery } from '@/queries/sendMessageQuery'
import { ChatChunk } from './ChatChunk' import { ChatChunk } from './ChatChunk'
import { BotContext, InitialChatReply } from '@/types' import { BotContext, InitialChatReply } from '@/types'
import { executeIntegrations } from '@/utils/executeIntegrations' import { isNotDefined } from 'utils'
import { executeLogic } from '@/utils/executeLogic' import { executeClientSideAction } from '@/utils/executeClientSideActions'
const parseDynamicTheme = ( const parseDynamicTheme = (
initialTheme: Theme, initialTheme: Theme,
@ -42,10 +42,13 @@ type Props = {
export const ConversationContainer = (props: Props) => { export const ConversationContainer = (props: Props) => {
let chatContainer: HTMLDivElement | undefined let chatContainer: HTMLDivElement | undefined
let bottomSpacer: HTMLDivElement | undefined let bottomSpacer: HTMLDivElement | undefined
const [chatChunks, setChatChunks] = createSignal<ChatReply[]>([ const [chatChunks, setChatChunks] = createSignal<
Pick<ChatReply, 'messages' | 'input' | 'clientSideActions'>[]
>([
{ {
input: props.initialChatReply.input, input: props.initialChatReply.input,
messages: props.initialChatReply.messages, messages: props.initialChatReply.messages,
clientSideActions: props.initialChatReply.clientSideActions,
}, },
]) ])
const [dynamicTheme, setDynamicTheme] = createSignal< const [dynamicTheme, setDynamicTheme] = createSignal<
@ -77,17 +80,12 @@ export const ConversationContainer = (props: Props) => {
groupId: data.input.groupId, groupId: data.input.groupId,
}) })
} }
if (data.integrations) {
executeIntegrations(data.integrations)
}
if (data.logic) {
await executeLogic(data.logic)
}
setChatChunks((displayedChunks) => [ setChatChunks((displayedChunks) => [
...displayedChunks, ...displayedChunks,
{ {
input: data.input, input: data.input,
messages: data.messages, messages: data.messages,
clientSideActions: data.clientSideActions,
}, },
]) ])
} }
@ -99,6 +97,19 @@ export const ConversationContainer = (props: Props) => {
}, 50) }, 50)
} }
const handleAllBubblesDisplayed = async () => {
const lastChunk = chatChunks().at(-1)
if (!lastChunk) return
if (lastChunk.clientSideActions) {
for (const action of lastChunk.clientSideActions) {
await executeClientSideAction(action)
}
}
if (isNotDefined(lastChunk.input)) {
props.onEnd?.()
}
}
return ( return (
<div <div
ref={chatContainer} ref={chatContainer}
@ -112,12 +123,12 @@ export const ConversationContainer = (props: Props) => {
input={chatChunk.input} input={chatChunk.input}
theme={theme()} theme={theme()}
settings={props.initialChatReply.typebot.settings} settings={props.initialChatReply.typebot.settings}
onAllBubblesDisplayed={handleAllBubblesDisplayed}
onSubmit={sendMessage} onSubmit={sendMessage}
onScrollToBottom={autoScrollToBottom} onScrollToBottom={autoScrollToBottom}
onSkip={() => { onSkip={() => {
// TODO: implement skip // TODO: implement skip
}} }}
onEnd={props.onEnd}
context={props.context} context={props.context}
/> />
)} )}

View File

@ -0,0 +1,22 @@
import { executeChatwoot } from '@/features/blocks/integrations/chatwoot'
import { executeGoogleAnalyticsBlock } from '@/features/blocks/integrations/googleAnalytics/utils/executeGoogleAnalytics'
import { executeCode } from '@/features/blocks/logic/code'
import { executeRedirect } from '@/features/blocks/logic/redirect'
import type { ChatReply } from 'models'
export const executeClientSideAction = async (
clientSideAction: NonNullable<ChatReply['clientSideActions']>[0]
) => {
if ('chatwoot' in clientSideAction) {
executeChatwoot(clientSideAction.chatwoot)
}
if ('googleAnalytics' in clientSideAction) {
executeGoogleAnalyticsBlock(clientSideAction.googleAnalytics)
}
if ('codeToExecute' in clientSideAction) {
await executeCode(clientSideAction.codeToExecute)
}
if ('redirect' in clientSideAction) {
executeRedirect(clientSideAction.redirect)
}
}

View File

@ -1,14 +0,0 @@
import { executeChatwoot } from '@/features/blocks/integrations/chatwoot'
import { executeGoogleAnalyticsBlock } from '@/features/blocks/integrations/googleAnalytics/utils/executeGoogleAnalytics'
import type { ChatReply } from 'models'
export const executeIntegrations = async (
integrations: ChatReply['integrations']
) => {
if (integrations?.chatwoot?.codeToExecute) {
executeChatwoot(integrations.chatwoot)
}
if (integrations?.googleAnalytics) {
executeGoogleAnalyticsBlock(integrations.googleAnalytics)
}
}

View File

@ -1,12 +0,0 @@
import { executeCode } from '@/features/blocks/logic/code'
import { executeRedirect } from '@/features/blocks/logic/redirect'
import type { ChatReply } from 'models'
export const executeLogic = async (logic: ChatReply['logic']) => {
if (logic?.codeToExecute) {
await executeCode(logic.codeToExecute)
}
if (logic?.redirect) {
executeRedirect(logic.redirect)
}
}

View File

@ -165,6 +165,26 @@ const replyLogSchema = logSchema
}) })
.and(z.object({ details: z.unknown().optional() })) .and(z.object({ details: z.unknown().optional() }))
const clientSideActionSchema = z
.object({
codeToExecute: codeToExecuteSchema,
})
.or(
z.object({
redirect: redirectOptionsSchema,
})
)
.or(
z.object({
chatwoot: z.object({ codeToExecute: codeToExecuteSchema }),
})
)
.or(
z.object({
googleAnalytics: googleAnalyticsOptionsSchema,
})
)
export const chatReplySchema = z.object({ export const chatReplySchema = z.object({
messages: z.array(chatMessageSchema), messages: z.array(chatMessageSchema),
input: inputBlockSchema input: inputBlockSchema
@ -175,22 +195,7 @@ export const chatReplySchema = z.object({
}) })
) )
.optional(), .optional(),
logic: z clientSideActions: z.array(clientSideActionSchema).optional(),
.object({
redirect: redirectOptionsSchema.optional(),
codeToExecute: codeToExecuteSchema.optional(),
})
.optional(),
integrations: z
.object({
chatwoot: z
.object({
codeToExecute: codeToExecuteSchema,
})
.optional(),
googleAnalytics: googleAnalyticsOptionsSchema.optional(),
})
.optional(),
sessionId: z.string().optional(), sessionId: z.string().optional(),
typebot: typebotSchema typebot: typebotSchema
.pick({ id: true, theme: true, settings: true }) .pick({ id: true, theme: true, settings: true })