2
0

Introducing The Forge (#1072)

The Forge allows anyone to easily create their own Typebot Block.

Closes #380
This commit is contained in:
Baptiste Arnaud
2023-12-13 10:22:02 +01:00
committed by GitHub
parent c373108b55
commit 5e019bbb22
184 changed files with 42659 additions and 37411 deletions

View File

@@ -0,0 +1,24 @@
import { ExecuteIntegrationResponse } from '../../../../types'
import { GoogleAnalyticsBlock, SessionState } from '@typebot.io/schemas'
import { deepParseVariables } from '@typebot.io/variables/deepParseVariables'
export const executeGoogleAnalyticsBlock = (
state: SessionState,
block: GoogleAnalyticsBlock
): ExecuteIntegrationResponse => {
const { typebot, resultId } = state.typebotsQueue[0]
if (!resultId || state.whatsApp || !block.options)
return { outgoingEdgeId: block.outgoingEdgeId }
const googleAnalytics = deepParseVariables(typebot.variables, {
guessCorrectTypes: true,
removeEmptyStrings: true,
})(block.options)
return {
outgoingEdgeId: block.outgoingEdgeId,
clientSideActions: [
{
googleAnalytics,
},
],
}
}

View File

@@ -0,0 +1,122 @@
import { SessionState } from '@typebot.io/schemas'
import {
CreateSpeechOpenAIOptions,
OpenAICredentials,
} from '@typebot.io/schemas/features/blocks/integrations/openai'
import { isNotEmpty } from '@typebot.io/lib'
import { decrypt } from '@typebot.io/lib/api/encryption/decrypt'
import prisma from '@typebot.io/lib/prisma'
import { defaultOpenAIOptions } from '@typebot.io/schemas/features/blocks/integrations/openai/constants'
import { ExecuteIntegrationResponse } from '../../../../../types'
import OpenAI, { ClientOptions } from 'openai'
import { uploadFileToBucket } from '@typebot.io/lib/s3/uploadFileToBucket'
import { updateVariablesInSession } from '@typebot.io/variables/updateVariablesInSession'
import { createId } from '@paralleldrive/cuid2'
import { parseVariables } from '@typebot.io/variables/parseVariables'
export const createSpeechOpenAI = async (
state: SessionState,
{
outgoingEdgeId,
options,
}: {
outgoingEdgeId?: string
options: CreateSpeechOpenAIOptions
}
): Promise<ExecuteIntegrationResponse> => {
let newSessionState = state
const noCredentialsError = {
status: 'error',
description: 'Make sure to select an OpenAI account',
}
if (!options.input || !options.voice || !options.saveUrlInVariableId) {
return {
outgoingEdgeId,
logs: [
{
status: 'error',
description:
'Make sure to enter an input, select a voice and select a variable to save the URL in',
},
],
}
}
if (!options.credentialsId) {
return {
outgoingEdgeId,
logs: [noCredentialsError],
}
}
const credentials = await prisma.credentials.findUnique({
where: {
id: options.credentialsId,
},
})
if (!credentials) {
console.error('Could not find credentials in database')
return { outgoingEdgeId, logs: [noCredentialsError] }
}
const { apiKey } = (await decrypt(
credentials.data,
credentials.iv
)) as OpenAICredentials['data']
const config = {
apiKey,
baseURL: options.baseUrl ?? defaultOpenAIOptions.baseUrl,
defaultHeaders: {
'api-key': apiKey,
},
defaultQuery: isNotEmpty(options.apiVersion)
? {
'api-version': options.apiVersion,
}
: undefined,
} satisfies ClientOptions
const openai = new OpenAI(config)
const variables = newSessionState.typebotsQueue[0].typebot.variables
const saveUrlInVariable = variables.find(
(v) => v.id === options.saveUrlInVariableId
)
if (!saveUrlInVariable) {
return {
outgoingEdgeId,
logs: [
{
status: 'error',
description: 'Could not find variable to save URL in',
},
],
}
}
const rawAudio = (await openai.audio.speech.create({
input: parseVariables(variables)(options.input),
voice: options.voice,
model: options.model as 'tts-1' | 'tts-1-hd',
})) as any
const url = await uploadFileToBucket({
file: Buffer.from((await rawAudio.arrayBuffer()) as ArrayBuffer),
key: `tmp/openai/audio/${createId() + createId()}.mp3`,
mimeType: 'audio/mpeg',
})
newSessionState = updateVariablesInSession(newSessionState)([
{
...saveUrlInVariable,
value: url,
},
])
return {
startTimeShouldBeUpdated: true,
outgoingEdgeId,
newSessionState,
}
}

View File

@@ -0,0 +1,181 @@
import {
Block,
Credentials,
SessionState,
TypebotInSession,
} from '@typebot.io/schemas'
import {
ChatCompletionOpenAIOptions,
OpenAICredentials,
} from '@typebot.io/schemas/features/blocks/integrations/openai'
import { byId, isEmpty } from '@typebot.io/lib'
import { decrypt } from '@typebot.io/lib/api/encryption/decrypt'
import { resumeChatCompletion } from './resumeChatCompletion'
import { parseChatCompletionMessages } from './parseChatCompletionMessages'
import { executeChatCompletionOpenAIRequest } from './executeChatCompletionOpenAIRequest'
import { isPlaneteScale } from '@typebot.io/lib/isPlanetScale'
import prisma from '@typebot.io/lib/prisma'
import { ExecuteIntegrationResponse } from '../../../../types'
import { parseVariableNumber } from '@typebot.io/variables/parseVariableNumber'
import { updateVariablesInSession } from '@typebot.io/variables/updateVariablesInSession'
import {
chatCompletionMessageRoles,
defaultOpenAIOptions,
} from '@typebot.io/schemas/features/blocks/integrations/openai/constants'
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
export const createChatCompletionOpenAI = async (
state: SessionState,
{
outgoingEdgeId,
options,
blockId,
}: {
outgoingEdgeId?: string
options: ChatCompletionOpenAIOptions
blockId: string
}
): Promise<ExecuteIntegrationResponse> => {
let newSessionState = state
const noCredentialsError = {
status: 'error',
description: 'Make sure to select an OpenAI account',
}
if (!options.credentialsId) {
return {
outgoingEdgeId,
logs: [noCredentialsError],
}
}
const credentials = await prisma.credentials.findUnique({
where: {
id: options.credentialsId,
},
})
if (!credentials) {
console.error('Could not find credentials in database')
return { outgoingEdgeId, logs: [noCredentialsError] }
}
const { apiKey } = (await decrypt(
credentials.data,
credentials.iv
)) as OpenAICredentials['data']
const { typebot } = newSessionState.typebotsQueue[0]
const { variablesTransformedToList, messages } = parseChatCompletionMessages(
typebot.variables
)(options.messages)
if (variablesTransformedToList.length > 0)
newSessionState = updateVariablesInSession(state)(
variablesTransformedToList
)
const temperature = parseVariableNumber(typebot.variables)(
options.advancedSettings?.temperature
)
const assistantMessageVariableName = typebot.variables.find(
(variable) =>
options.responseMapping?.find(
(m) => m.valueToExtract === 'Message content'
)?.variableId === variable.id
)?.name
if (
isPlaneteScale() &&
isCredentialsV2(credentials) &&
newSessionState.isStreamEnabled &&
!newSessionState.whatsApp &&
isNextBubbleMessageWithAssistantMessage(typebot)(
blockId,
assistantMessageVariableName
)
) {
return {
clientSideActions: [
{
streamOpenAiChatCompletion: {
messages: messages as {
content?: string
role: (typeof chatCompletionMessageRoles)[number]
}[],
},
expectsDedicatedReply: true,
},
],
outgoingEdgeId,
newSessionState,
}
}
const { chatCompletion, logs } = await executeChatCompletionOpenAIRequest({
apiKey,
messages,
model: options.model ?? defaultOpenAIOptions.model,
temperature,
baseUrl: options.baseUrl,
apiVersion: options.apiVersion,
})
if (!chatCompletion)
return {
startTimeShouldBeUpdated: true,
outgoingEdgeId,
logs,
}
const messageContent = chatCompletion.choices.at(0)?.message?.content
const totalTokens = chatCompletion.usage?.total_tokens
if (isEmpty(messageContent)) {
console.error('OpenAI block returned empty message', chatCompletion.choices)
return { outgoingEdgeId, newSessionState, startTimeShouldBeUpdated: true }
}
return {
...(await resumeChatCompletion(newSessionState, {
options,
outgoingEdgeId,
logs,
})(messageContent, totalTokens)),
startTimeShouldBeUpdated: true,
}
}
const isNextBubbleMessageWithAssistantMessage =
(typebot: TypebotInSession) =>
(blockId: string, assistantVariableName?: string): boolean => {
if (!assistantVariableName) return false
const nextBlock = getNextBlock(typebot)(blockId)
if (!nextBlock) return false
return (
nextBlock.type === BubbleBlockType.TEXT &&
(nextBlock.content?.richText?.length ?? 0) > 0 &&
nextBlock.content?.richText?.at(0)?.children.at(0).text ===
`{{${assistantVariableName}}}`
)
}
const getNextBlock =
(typebot: TypebotInSession) =>
(blockId: string): Block | undefined => {
const group = typebot.groups.find((group) =>
group.blocks.find(byId(blockId))
)
if (!group) return
const blockIndex = group.blocks.findIndex(byId(blockId))
const nextBlockInGroup = group.blocks.at(blockIndex + 1)
if (nextBlockInGroup) return nextBlockInGroup
const outgoingEdgeId = group.blocks.at(blockIndex)?.outgoingEdgeId
if (!outgoingEdgeId) return
const outgoingEdge = typebot.edges.find(byId(outgoingEdgeId))
if (!outgoingEdge) return
const connectedGroup = typebot.groups.find(byId(outgoingEdge?.to.groupId))
if (!connectedGroup) return
return outgoingEdge.to.blockId
? connectedGroup.blocks.find(
(block) => block.id === outgoingEdge.to.blockId
)
: connectedGroup?.blocks.at(0)
}
const isCredentialsV2 = (credentials: Pick<Credentials, 'iv'>) =>
credentials.iv.length === 24

View File

@@ -0,0 +1,108 @@
import { isNotEmpty } from '@typebot.io/lib/utils'
import { ContinueChatResponse } from '@typebot.io/schemas'
import { OpenAIBlock } from '@typebot.io/schemas/features/blocks/integrations/openai'
import { HTTPError } from 'got'
import { ClientOptions, OpenAI } from 'openai'
type Props = Pick<
OpenAI.Chat.ChatCompletionCreateParams,
'messages' | 'model'
> & {
apiKey: string
temperature: number | undefined
currentLogs?: ContinueChatResponse['logs']
isRetrying?: boolean
} & Pick<NonNullable<OpenAIBlock['options']>, 'apiVersion' | 'baseUrl'>
export const executeChatCompletionOpenAIRequest = async ({
apiKey,
model,
messages,
temperature,
baseUrl,
apiVersion,
isRetrying,
currentLogs = [],
}: Props): Promise<{
chatCompletion?: OpenAI.Chat.Completions.ChatCompletion
logs?: ContinueChatResponse['logs']
}> => {
const logs: ContinueChatResponse['logs'] = currentLogs
if (messages.length === 0) return { logs }
try {
const config = {
apiKey,
baseURL: baseUrl,
defaultHeaders: {
'api-key': apiKey,
},
defaultQuery: isNotEmpty(apiVersion)
? {
'api-version': apiVersion,
}
: undefined,
} satisfies ClientOptions
const openai = new OpenAI(config)
const chatCompletion = await openai.chat.completions.create({
model,
messages,
temperature,
})
return { chatCompletion, logs }
} catch (error) {
if (error instanceof HTTPError) {
if (
(error.response.statusCode === 503 ||
error.response.statusCode === 500 ||
error.response.statusCode === 403) &&
!isRetrying
) {
console.log('OpenAI API error - 503, retrying in 3 seconds')
await new Promise((resolve) => setTimeout(resolve, 3000))
return executeChatCompletionOpenAIRequest({
apiKey,
model,
messages,
temperature,
currentLogs: logs,
baseUrl,
apiVersion,
isRetrying: true,
})
}
if (error.response.statusCode === 400) {
const log = {
status: 'info',
description:
'Max tokens limit reached, automatically trimming first message.',
}
logs.push(log)
return executeChatCompletionOpenAIRequest({
apiKey,
model,
messages: messages.slice(1),
temperature,
currentLogs: logs,
baseUrl,
apiVersion,
})
}
logs.push({
status: 'error',
description: `OpenAI API error - ${error.response.statusCode}`,
details: error.response.body,
})
return { logs }
}
logs.push({
status: 'error',
description: `Internal error`,
details: error,
})
return { logs }
}
}

View File

@@ -0,0 +1,27 @@
import { SessionState } from '@typebot.io/schemas'
import { OpenAIBlock } from '@typebot.io/schemas/features/blocks/integrations/openai'
import { createChatCompletionOpenAI } from './createChatCompletionOpenAI'
import { ExecuteIntegrationResponse } from '../../../../types'
import { createSpeechOpenAI } from './audio/createSpeechOpenAI'
export const executeOpenAIBlock = async (
state: SessionState,
block: OpenAIBlock
): Promise<ExecuteIntegrationResponse> => {
switch (block.options?.task) {
case 'Create chat completion':
return createChatCompletionOpenAI(state, {
options: block.options,
outgoingEdgeId: block.outgoingEdgeId,
blockId: block.id,
})
case 'Create speech':
return createSpeechOpenAI(state, {
options: block.options,
outgoingEdgeId: block.outgoingEdgeId,
})
case 'Create image':
case undefined:
return { outgoingEdgeId: block.outgoingEdgeId }
}
}

View File

@@ -0,0 +1,64 @@
import { Connection } from '@planetscale/database'
import { decryptV2 } from '@typebot.io/lib/api/encryption/decryptV2'
import { isNotEmpty } from '@typebot.io/lib/utils'
import {
ChatCompletionOpenAIOptions,
OpenAICredentials,
} from '@typebot.io/schemas/features/blocks/integrations/openai'
import { SessionState } from '@typebot.io/schemas/features/chat/sessionState'
import { OpenAIStream } from 'ai'
import { parseVariableNumber } from '@typebot.io/variables/parseVariableNumber'
import { ClientOptions, OpenAI } from 'openai'
import { defaultOpenAIOptions } from '@typebot.io/schemas/features/blocks/integrations/openai/constants'
export const getChatCompletionStream =
(conn: Connection) =>
async (
state: SessionState,
options: ChatCompletionOpenAIOptions,
messages: OpenAI.Chat.ChatCompletionMessageParam[]
) => {
if (!options.credentialsId) return
const credentials = (
await conn.execute('select data, iv from Credentials where id=?', [
options.credentialsId,
])
).rows.at(0) as { data: string; iv: string } | undefined
if (!credentials) {
console.error('Could not find credentials in database')
return
}
const { apiKey } = (await decryptV2(
credentials.data,
credentials.iv
)) as OpenAICredentials['data']
const { typebot } = state.typebotsQueue[0]
const temperature = parseVariableNumber(typebot.variables)(
options.advancedSettings?.temperature
)
const config = {
apiKey,
baseURL: options.baseUrl,
defaultHeaders: {
'api-key': apiKey,
},
defaultQuery: isNotEmpty(options.apiVersion)
? {
'api-version': options.apiVersion,
}
: undefined,
} satisfies ClientOptions
const openai = new OpenAI(config)
const response = await openai.chat.completions.create({
model: options.model ?? defaultOpenAIOptions.model,
temperature,
stream: true,
messages,
})
return OpenAIStream(response)
}

View File

@@ -0,0 +1,114 @@
import { byId, isNotEmpty } from '@typebot.io/lib'
import { Variable, VariableWithValue } from '@typebot.io/schemas'
import { ChatCompletionOpenAIOptions } from '@typebot.io/schemas/features/blocks/integrations/openai'
import type { OpenAI } from 'openai'
import { parseVariables } from '@typebot.io/variables/parseVariables'
import { transformVariablesToList } from '@typebot.io/variables/transformVariablesToList'
export const parseChatCompletionMessages =
(variables: Variable[]) =>
(
messages: ChatCompletionOpenAIOptions['messages']
): {
variablesTransformedToList: VariableWithValue[]
messages: OpenAI.Chat.ChatCompletionMessageParam[]
} => {
const variablesTransformedToList: VariableWithValue[] = []
const parsedMessages = messages
?.flatMap((message) => {
if (!message.role) return
if (message.role === 'Messages sequence ✨') {
if (
!message.content?.assistantMessagesVariableId ||
!message.content?.userMessagesVariableId
)
return
variablesTransformedToList.push(
...transformVariablesToList(variables)([
message.content.assistantMessagesVariableId,
message.content.userMessagesVariableId,
])
)
const updatedVariables = variables.map((variable) => {
const variableTransformedToList = variablesTransformedToList.find(
byId(variable.id)
)
if (variableTransformedToList) return variableTransformedToList
return variable
})
const userMessages = (updatedVariables.find(
(variable) =>
variable.id === message.content?.userMessagesVariableId
)?.value ?? []) as string[]
const assistantMessages = (updatedVariables.find(
(variable) =>
variable.id === message.content?.assistantMessagesVariableId
)?.value ?? []) as string[]
let allMessages: OpenAI.Chat.ChatCompletionMessageParam[] = []
if (userMessages.length > assistantMessages.length)
allMessages = userMessages.flatMap((userMessage, index) => [
{
role: 'user',
content: userMessage,
},
{ role: 'assistant', content: assistantMessages.at(index) ?? '' },
]) satisfies OpenAI.Chat.ChatCompletionMessageParam[]
else {
allMessages = assistantMessages.flatMap(
(assistantMessage, index) => [
{ role: 'assistant', content: assistantMessage },
{
role: 'user',
content: userMessages.at(index) ?? '',
},
]
) satisfies OpenAI.Chat.ChatCompletionMessageParam[]
}
return allMessages
}
if (message.role === 'Dialogue') {
if (!message.dialogueVariableId) return
const dialogue = (variables.find(
(variable) => variable.id === message.dialogueVariableId
)?.value ?? []) as string[]
return dialogue.map<OpenAI.Chat.ChatCompletionMessageParam>(
(dialogueItem, index) => {
if (index === 0 && message.startsBy === 'assistant')
return {
role: 'assistant',
content: dialogueItem,
}
return {
role:
index % (message.startsBy === 'assistant' ? 1 : 2) === 0
? 'user'
: 'assistant',
content: dialogueItem,
}
}
)
}
return {
role: message.role,
content: parseVariables(variables)(message.content),
name: message.name
? parseVariables(variables)(message.name)
: undefined,
} satisfies OpenAI.Chat.ChatCompletionMessageParam
})
.filter(
(message) =>
isNotEmpty(message?.role) && isNotEmpty(message?.content?.toString())
) as OpenAI.Chat.ChatCompletionMessageParam[]
return {
variablesTransformedToList,
messages: parsedMessages,
}
}

View File

@@ -0,0 +1,51 @@
import { byId, isDefined } from '@typebot.io/lib'
import { ContinueChatResponse, SessionState } from '@typebot.io/schemas'
import { ChatCompletionOpenAIOptions } from '@typebot.io/schemas/features/blocks/integrations/openai'
import { VariableWithUnknowValue } from '@typebot.io/schemas/features/typebot/variable'
import { updateVariablesInSession } from '@typebot.io/variables/updateVariablesInSession'
export const resumeChatCompletion =
(
state: SessionState,
{
outgoingEdgeId,
options,
logs = [],
}: {
outgoingEdgeId?: string
options: ChatCompletionOpenAIOptions
logs?: ContinueChatResponse['logs']
}
) =>
async (message: string, totalTokens?: number) => {
let newSessionState = state
const newVariables = options.responseMapping?.reduce<
VariableWithUnknowValue[]
>((newVariables, mapping) => {
const { typebot } = newSessionState.typebotsQueue[0]
const existingVariable = typebot.variables.find(byId(mapping.variableId))
if (!existingVariable) return newVariables
if (mapping.valueToExtract === 'Message content') {
newVariables.push({
...existingVariable,
value: Array.isArray(existingVariable.value)
? existingVariable.value.concat(message)
: message,
})
}
if (mapping.valueToExtract === 'Total tokens' && isDefined(totalTokens)) {
newVariables.push({
...existingVariable,
value: totalTokens,
})
}
return newVariables
}, [])
if (newVariables && newVariables.length > 0)
newSessionState = updateVariablesInSession(newSessionState)(newVariables)
return {
outgoingEdgeId,
newSessionState,
logs,
}
}