✨ Introducing The Forge (#1072)
The Forge allows anyone to easily create their own Typebot Block. Closes #380
This commit is contained in:
180
packages/forge/blocks/openai/actions/createChatCompletion.tsx
Normal file
180
packages/forge/blocks/openai/actions/createChatCompletion.tsx
Normal file
@ -0,0 +1,180 @@
|
||||
import { option, createAction } from '@typebot.io/forge'
|
||||
import OpenAI, { ClientOptions } from 'openai'
|
||||
import { defaultOpenAIOptions } from '../constants'
|
||||
import { OpenAIStream } from 'ai'
|
||||
import { parseChatCompletionMessages } from '../helpers/parseChatCompletionMessages'
|
||||
import { isDefined } from '@typebot.io/lib'
|
||||
import { auth } from '../auth'
|
||||
import { baseOptions } from '../baseOptions'
|
||||
|
||||
const nativeMessageContentSchema = {
|
||||
content: option.string.layout({ input: 'textarea', placeholder: 'Content' }),
|
||||
}
|
||||
|
||||
const systemMessageItemSchema = option
|
||||
.object({
|
||||
role: option.literal('system'),
|
||||
})
|
||||
.extend(nativeMessageContentSchema)
|
||||
|
||||
const userMessageItemSchema = option
|
||||
.object({
|
||||
role: option.literal('user'),
|
||||
})
|
||||
.extend(nativeMessageContentSchema)
|
||||
|
||||
const assistantMessageItemSchema = option
|
||||
.object({
|
||||
role: option.literal('assistant'),
|
||||
})
|
||||
.extend(nativeMessageContentSchema)
|
||||
|
||||
const dialogueMessageItemSchema = option.object({
|
||||
role: option.literal('Dialogue'),
|
||||
dialogueVariableId: option.string.layout({
|
||||
input: 'variableDropdown',
|
||||
placeholder: 'Dialogue variable',
|
||||
}),
|
||||
startsBy: option.enum(['user', 'assistant']).layout({
|
||||
label: 'starts by',
|
||||
direction: 'row',
|
||||
defaultValue: 'user',
|
||||
}),
|
||||
})
|
||||
|
||||
export const options = option.object({
|
||||
model: option.string.layout({
|
||||
placeholder: 'Select a model',
|
||||
defaultValue: defaultOpenAIOptions.model,
|
||||
fetcher: 'fetchModels',
|
||||
}),
|
||||
messages: option
|
||||
.array(
|
||||
option.discriminatedUnion('role', [
|
||||
systemMessageItemSchema,
|
||||
userMessageItemSchema,
|
||||
assistantMessageItemSchema,
|
||||
dialogueMessageItemSchema,
|
||||
])
|
||||
)
|
||||
.layout({ accordion: 'Messages', itemLabel: 'message', isOrdered: true }),
|
||||
temperature: option.number.layout({
|
||||
accordion: 'Advanced settings',
|
||||
label: 'Temperature',
|
||||
direction: 'row',
|
||||
defaultValue: defaultOpenAIOptions.temperature,
|
||||
}),
|
||||
responseMapping: option
|
||||
.saveResponseArray(['Message content', 'Total tokens'])
|
||||
.layout({
|
||||
accordion: 'Save response',
|
||||
}),
|
||||
})
|
||||
|
||||
export const createChatCompletion = createAction({
|
||||
name: 'Create chat completion',
|
||||
auth,
|
||||
baseOptions,
|
||||
getSetVariableIds: (options) =>
|
||||
options.responseMapping?.map((res) => res.variableId).filter(isDefined) ??
|
||||
[],
|
||||
fetchers: [
|
||||
{
|
||||
id: 'fetchModels',
|
||||
dependencies: ['baseUrl', 'apiVersion'],
|
||||
fetch: async ({ credentials, options }) => {
|
||||
const baseUrl = options?.baseUrl ?? defaultOpenAIOptions.baseUrl
|
||||
const config = {
|
||||
apiKey: credentials.apiKey,
|
||||
baseURL: baseUrl ?? defaultOpenAIOptions.baseUrl,
|
||||
defaultHeaders: {
|
||||
'api-key': credentials.apiKey,
|
||||
},
|
||||
defaultQuery: options?.apiVersion
|
||||
? {
|
||||
'api-version': options.apiVersion,
|
||||
}
|
||||
: undefined,
|
||||
} satisfies ClientOptions
|
||||
|
||||
const openai = new OpenAI(config)
|
||||
|
||||
const models = await openai.models.list()
|
||||
|
||||
return (
|
||||
models.data
|
||||
.filter((model) => model.id.includes('gpt'))
|
||||
.sort((a, b) => b.created - a.created)
|
||||
.map((model) => model.id) ?? []
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
run: {
|
||||
server: async ({ credentials: { apiKey }, options, variables }) => {
|
||||
const config = {
|
||||
apiKey,
|
||||
baseURL: options.baseUrl,
|
||||
defaultHeaders: {
|
||||
'api-key': apiKey,
|
||||
},
|
||||
defaultQuery: 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: options.temperature
|
||||
? Number(options.temperature)
|
||||
: undefined,
|
||||
messages: parseChatCompletionMessages({ options, variables }),
|
||||
})
|
||||
|
||||
options.responseMapping?.forEach((mapping) => {
|
||||
if (!mapping.variableId) return
|
||||
if (!mapping.item || mapping.item === 'Message content')
|
||||
variables.set(mapping.variableId, response.choices[0].message.content)
|
||||
if (mapping.item === 'Total tokens')
|
||||
variables.set(mapping.variableId, response.usage?.total_tokens)
|
||||
})
|
||||
},
|
||||
stream: {
|
||||
getStreamVariableId: (options) =>
|
||||
options.responseMapping?.find((res) => res.item === 'Message content')
|
||||
?.variableId,
|
||||
run: async ({ credentials: { apiKey }, options, variables }) => {
|
||||
const config = {
|
||||
apiKey,
|
||||
baseURL: options.baseUrl,
|
||||
defaultHeaders: {
|
||||
'api-key': apiKey,
|
||||
},
|
||||
defaultQuery: 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: options.temperature
|
||||
? Number(options.temperature)
|
||||
: undefined,
|
||||
stream: true,
|
||||
messages: parseChatCompletionMessages({ options, variables }),
|
||||
})
|
||||
|
||||
return OpenAIStream(response)
|
||||
},
|
||||
},
|
||||
},
|
||||
options,
|
||||
})
|
105
packages/forge/blocks/openai/actions/createSpeech.tsx
Normal file
105
packages/forge/blocks/openai/actions/createSpeech.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { option, createAction } from '@typebot.io/forge'
|
||||
import { defaultOpenAIOptions, openAIVoices } from '../constants'
|
||||
import OpenAI, { ClientOptions } from 'openai'
|
||||
import { isNotEmpty, createId } from '@typebot.io/lib'
|
||||
import { uploadFileToBucket } from '@typebot.io/lib/s3/uploadFileToBucket'
|
||||
import { auth } from '../auth'
|
||||
import { baseOptions } from '../baseOptions'
|
||||
|
||||
export const createSpeech = createAction({
|
||||
name: 'Create speech',
|
||||
auth,
|
||||
baseOptions,
|
||||
options: option.object({
|
||||
model: option.string.layout({
|
||||
fetcher: 'fetchSpeechModels',
|
||||
defaultValue: 'tts-1',
|
||||
placeholder: 'Select a model',
|
||||
}),
|
||||
input: option.string.layout({
|
||||
label: 'Input',
|
||||
input: 'textarea',
|
||||
}),
|
||||
voice: option.enum(openAIVoices).layout({
|
||||
label: 'Voice',
|
||||
placeholder: 'Select a voice',
|
||||
}),
|
||||
saveUrlInVariableId: option.string.layout({
|
||||
input: 'variableDropdown',
|
||||
label: 'Save URL in variable',
|
||||
}),
|
||||
}),
|
||||
getSetVariableIds: (options) =>
|
||||
options.saveUrlInVariableId ? [options.saveUrlInVariableId] : [],
|
||||
fetchers: [
|
||||
{
|
||||
id: 'fetchSpeechModels',
|
||||
dependencies: ['baseUrl', 'apiVersion'],
|
||||
fetch: async ({ credentials, options }) => {
|
||||
const baseUrl = options?.baseUrl ?? defaultOpenAIOptions.baseUrl
|
||||
const config = {
|
||||
apiKey: credentials.apiKey,
|
||||
baseURL: baseUrl ?? defaultOpenAIOptions.baseUrl,
|
||||
defaultHeaders: {
|
||||
'api-key': credentials.apiKey,
|
||||
},
|
||||
defaultQuery: options?.apiVersion
|
||||
? {
|
||||
'api-version': options.apiVersion,
|
||||
}
|
||||
: undefined,
|
||||
} satisfies ClientOptions
|
||||
|
||||
const openai = new OpenAI(config)
|
||||
|
||||
const models = await openai.models.list()
|
||||
|
||||
return (
|
||||
models.data
|
||||
.filter((model) => model.id.includes('tts'))
|
||||
.sort((a, b) => b.created - a.created)
|
||||
.map((model) => model.id) ?? []
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
run: {
|
||||
server: async ({ credentials: { apiKey }, options, variables, logs }) => {
|
||||
if (!options.input) return logs.add('Create speech input is empty')
|
||||
if (!options.voice) return logs.add('Create speech voice is empty')
|
||||
if (!options.saveUrlInVariableId)
|
||||
return logs.add('Create speech save variable is empty')
|
||||
|
||||
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 model = options.model ?? defaultOpenAIOptions.voiceModel
|
||||
|
||||
const rawAudio = (await openai.audio.speech.create({
|
||||
input: options.input,
|
||||
voice: options.voice,
|
||||
model,
|
||||
})) as any
|
||||
|
||||
const url = await uploadFileToBucket({
|
||||
file: Buffer.from((await rawAudio.arrayBuffer()) as ArrayBuffer),
|
||||
key: `tmp/openai/audio/${createId() + createId()}.mp3`,
|
||||
mimeType: 'audio/mpeg',
|
||||
})
|
||||
|
||||
variables.set(options.saveUrlInVariableId, url)
|
||||
},
|
||||
},
|
||||
})
|
16
packages/forge/blocks/openai/auth.ts
Normal file
16
packages/forge/blocks/openai/auth.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { createAuth, option } from '@typebot.io/forge'
|
||||
|
||||
export const auth = createAuth({
|
||||
type: 'encryptedCredentials',
|
||||
name: 'OpenAI account',
|
||||
schema: option.object({
|
||||
apiKey: option.string.layout({
|
||||
isRequired: true,
|
||||
label: 'API key',
|
||||
placeholder: 'sk-...',
|
||||
helperText:
|
||||
'You can generate an API key [here](https://platform.openai.com/account/api-keys)',
|
||||
withVariableButton: false,
|
||||
}),
|
||||
}),
|
||||
})
|
14
packages/forge/blocks/openai/baseOptions.ts
Normal file
14
packages/forge/blocks/openai/baseOptions.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { option } from '@typebot.io/forge'
|
||||
import { defaultOpenAIOptions } from './constants'
|
||||
|
||||
export const baseOptions = option.object({
|
||||
baseUrl: option.string.layout({
|
||||
accordion: 'Customize provider',
|
||||
label: 'Base URL',
|
||||
defaultValue: defaultOpenAIOptions.baseUrl,
|
||||
}),
|
||||
apiVersion: option.string.layout({
|
||||
accordion: 'Customize provider',
|
||||
label: 'API version',
|
||||
}),
|
||||
})
|
15
packages/forge/blocks/openai/constants.ts
Normal file
15
packages/forge/blocks/openai/constants.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export const openAIVoices = [
|
||||
'alloy',
|
||||
'echo',
|
||||
'fable',
|
||||
'onyx',
|
||||
'nova',
|
||||
'shimmer',
|
||||
] as const
|
||||
|
||||
export const defaultOpenAIOptions = {
|
||||
baseUrl: 'https://api.openai.com/v1',
|
||||
model: 'gpt-3.5-turbo',
|
||||
voiceModel: 'tts-1',
|
||||
temperature: 1,
|
||||
} as const
|
@ -0,0 +1,54 @@
|
||||
import type { OpenAI } from 'openai'
|
||||
import { options as createChatCompletionOption } from '../actions/createChatCompletion'
|
||||
import { ReadOnlyVariableStore } from '@typebot.io/forge'
|
||||
import { isNotEmpty } from '@typebot.io/lib'
|
||||
import { z } from '@typebot.io/forge/zod'
|
||||
|
||||
export const parseChatCompletionMessages = ({
|
||||
options: { messages },
|
||||
variables,
|
||||
}: {
|
||||
options: Pick<z.infer<typeof createChatCompletionOption>, 'messages'>
|
||||
variables: ReadOnlyVariableStore
|
||||
}): OpenAI.Chat.ChatCompletionMessageParam[] => {
|
||||
const parsedMessages = messages
|
||||
?.flatMap((message) => {
|
||||
if (!message.role) return
|
||||
|
||||
if (message.role === 'Dialogue') {
|
||||
if (!message.dialogueVariableId) return
|
||||
const dialogue = variables.get(message.dialogueVariableId) ?? []
|
||||
const dialogueArr = Array.isArray(dialogue) ? dialogue : [dialogue]
|
||||
|
||||
return dialogueArr.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,
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (!message.content) return
|
||||
|
||||
return {
|
||||
role: message.role,
|
||||
content: variables.parse(message.content),
|
||||
} satisfies OpenAI.Chat.ChatCompletionMessageParam
|
||||
})
|
||||
.filter(
|
||||
(message) =>
|
||||
isNotEmpty(message?.role) && isNotEmpty(message?.content?.toString())
|
||||
) as OpenAI.Chat.ChatCompletionMessageParam[]
|
||||
|
||||
return parsedMessages
|
||||
}
|
17
packages/forge/blocks/openai/index.ts
Normal file
17
packages/forge/blocks/openai/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { OpenAILightLogo, OpenAIDarkLogo } from './logo'
|
||||
import { createChatCompletion } from './actions/createChatCompletion'
|
||||
import { createSpeech } from './actions/createSpeech'
|
||||
import { createBlock } from '@typebot.io/forge'
|
||||
import { auth } from './auth'
|
||||
import { baseOptions } from './baseOptions'
|
||||
|
||||
export const openAIBlock = createBlock({
|
||||
id: 'openai' as const,
|
||||
name: 'OpenAI',
|
||||
tags: ['openai'],
|
||||
LightLogo: OpenAILightLogo,
|
||||
DarkLogo: OpenAIDarkLogo,
|
||||
auth,
|
||||
options: baseOptions,
|
||||
actions: [createChatCompletion, createSpeech],
|
||||
})
|
13
packages/forge/blocks/openai/logo.tsx
Normal file
13
packages/forge/blocks/openai/logo.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
|
||||
export const OpenAILightLogo = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" fill="#000100" {...props}>
|
||||
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const OpenAIDarkLogo = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" fill="#FEFFFE" {...props}>
|
||||
<path d="M22.282 9.821a5.985 5.985 0 0 0-.516-4.91 6.046 6.046 0 0 0-6.51-2.9A6.065 6.065 0 0 0 4.981 4.18a5.985 5.985 0 0 0-3.998 2.9 6.046 6.046 0 0 0 .743 7.097 5.98 5.98 0 0 0 .51 4.911 6.051 6.051 0 0 0 6.515 2.9A5.985 5.985 0 0 0 13.26 24a6.056 6.056 0 0 0 5.772-4.206 5.99 5.99 0 0 0 3.997-2.9 6.056 6.056 0 0 0-.747-7.073zM13.26 22.43a4.476 4.476 0 0 1-2.876-1.04l.141-.081 4.779-2.758a.795.795 0 0 0 .392-.681v-6.737l2.02 1.168a.071.071 0 0 1 .038.052v5.583a4.504 4.504 0 0 1-4.494 4.494zM3.6 18.304a4.47 4.47 0 0 1-.535-3.014l.142.085 4.783 2.759a.771.771 0 0 0 .78 0l5.843-3.369v2.332a.08.08 0 0 1-.033.062L9.74 19.95a4.5 4.5 0 0 1-6.14-1.646zM2.34 7.896a4.485 4.485 0 0 1 2.366-1.973V11.6a.766.766 0 0 0 .388.676l5.815 3.355-2.02 1.168a.076.076 0 0 1-.071 0l-4.83-2.786A4.504 4.504 0 0 1 2.34 7.872zm16.597 3.855l-5.833-3.387L15.119 7.2a.076.076 0 0 1 .071 0l4.83 2.791a4.494 4.494 0 0 1-.676 8.105v-5.678a.79.79 0 0 0-.407-.667zm2.01-3.023l-.141-.085-4.774-2.782a.776.776 0 0 0-.785 0L9.409 9.23V6.897a.066.066 0 0 1 .028-.061l4.83-2.787a4.5 4.5 0 0 1 6.68 4.66zm-12.64 4.135l-2.02-1.164a.08.08 0 0 1-.038-.057V6.075a4.5 4.5 0 0 1 7.375-3.453l-.142.08L8.704 5.46a.795.795 0 0 0-.393.681zm1.097-2.365l2.602-1.5 2.607 1.5v2.999l-2.597 1.5-2.607-1.5z" />
|
||||
</svg>
|
||||
)
|
20
packages/forge/blocks/openai/package.json
Normal file
20
packages/forge/blocks/openai/package.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@typebot.io/openai-block",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.ts",
|
||||
"keywords": [],
|
||||
"author": "Baptiste Arnaud",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"ai": "2.2.24",
|
||||
"openai": "4.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typebot.io/forge": "workspace:*",
|
||||
"@typebot.io/tsconfig": "workspace:*",
|
||||
"@types/react": "18.2.15",
|
||||
"typescript": "5.3.2",
|
||||
"@typebot.io/lib": "workspace:*"
|
||||
}
|
||||
}
|
10
packages/forge/blocks/openai/tsconfig.json
Normal file
10
packages/forge/blocks/openai/tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "@typebot.io/tsconfig/base.json",
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"],
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"noEmit": true,
|
||||
"jsx": "react"
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user