2
0

Add "Generate variables" actions in AI blocks

Closes #1586
This commit is contained in:
Baptiste Arnaud
2024-06-18 12:13:00 +02:00
parent bec9cb68ca
commit 76fcf7ee93
25 changed files with 860 additions and 165 deletions

View File

@@ -20,7 +20,7 @@
"@typebot.io/variables": "workspace:*",
"@udecode/plate-common": "30.4.5",
"@typebot.io/logic": "workspace:*",
"ai": "3.1.12",
"ai": "3.1.34",
"chrono-node": "2.7.6",
"date-fns": "2.30.0",
"date-fns-tz": "2.0.0",

View File

@@ -0,0 +1,42 @@
import { createAction } from '@typebot.io/forge'
import { auth } from '../auth'
import { isDefined } from '@typebot.io/lib'
import { createAnthropic } from '@ai-sdk/anthropic'
import { parseGenerateVariablesOptions } from '@typebot.io/openai-block/shared/parseGenerateVariablesOptions'
import { runGenerateVariables } from '@typebot.io/openai-block/shared/runGenerateVariables'
import { anthropicModels } from '../constants'
export const generateVariables = createAction({
name: 'Generate variables',
auth,
options: parseGenerateVariablesOptions({ modelFetch: anthropicModels }),
turnableInto: [
{
blockId: 'openai',
},
{
blockId: 'mistral',
},
],
getSetVariableIds: (options) =>
options.variablesToExtract?.map((v) => v.variableId).filter(isDefined) ??
[],
run: {
server: ({ credentials, options, variables, logs }) => {
if (credentials?.apiKey === undefined)
return logs.add('No API key provided')
if (options.model === undefined) return logs.add('No model provided')
return runGenerateVariables({
model: createAnthropic({
apiKey: credentials.apiKey,
})(options.model),
prompt: options.prompt,
variablesToExtract: options.variablesToExtract,
variables,
logs,
})
},
},
})

View File

@@ -2,6 +2,7 @@ import { createBlock } from '@typebot.io/forge'
import { AnthropicLogo } from './logo'
import { auth } from './auth'
import { createChatMessage } from './actions/createChatMessage'
import { generateVariables } from './actions/generateVariables'
export const anthropicBlock = createBlock({
id: 'anthropic',
@@ -9,5 +10,5 @@ export const anthropicBlock = createBlock({
tags: ['ai', 'chat', 'completion', 'claude', 'anthropic'],
LightLogo: AnthropicLogo,
auth,
actions: [createChatMessage],
actions: [createChatMessage, generateVariables],
})

View File

@@ -16,7 +16,9 @@
},
"dependencies": {
"@anthropic-ai/sdk": "0.20.6",
"ai": "3.1.12",
"@ai-sdk/anthropic": "0.0.21",
"@typebot.io/openai-block": "workspace:*",
"ai": "3.1.34",
"ky": "1.2.4"
}
}
}

View File

@@ -14,6 +14,6 @@
"typescript": "5.4.5"
},
"dependencies": {
"ai": "3.1.12"
"ai": "3.1.34"
}
}

View File

@@ -3,9 +3,8 @@ import { isDefined } from '@typebot.io/lib'
import { auth } from '../auth'
import { parseMessages } from '../helpers/parseMessages'
import { createMistral } from '@ai-sdk/mistral'
import { apiBaseUrl } from '../constants'
import ky from 'ky'
import { generateText, streamText } from 'ai'
import { fetchModels } from '../helpers/fetchModels'
const nativeMessageContentSchema = {
content: option.string.layout({
@@ -98,19 +97,7 @@ export const createChatCompletion = createAction({
{
id: 'fetchModels',
dependencies: [],
fetch: async ({ credentials }) => {
if (!credentials?.apiKey) return []
const { data } = await ky
.get(apiBaseUrl + '/v1/models', {
headers: {
Authorization: `Bearer ${credentials.apiKey}`,
},
})
.json<{ data: { id: string }[] }>()
return data.map((model) => model.id)
},
fetch: fetchModels,
},
],
run: {

View File

@@ -0,0 +1,53 @@
import { createAction } from '@typebot.io/forge'
import { auth } from '../auth'
import { isDefined } from '@typebot.io/lib'
import { createMistral } from '@ai-sdk/mistral'
import { fetchModels } from '../helpers/fetchModels'
import { parseGenerateVariablesOptions } from '@typebot.io/openai-block/shared/parseGenerateVariablesOptions'
import { runGenerateVariables } from '@typebot.io/openai-block/shared/runGenerateVariables'
export const generateVariables = createAction({
name: 'Generate variables',
auth,
options: parseGenerateVariablesOptions({ modelFetch: 'fetchModels' }),
fetchers: [
{
id: 'fetchModels',
dependencies: [],
fetch: fetchModels,
},
],
turnableInto: [
{
blockId: 'openai',
},
{
blockId: 'anthropic',
transform: (options) => ({
...options,
model: undefined,
}),
},
],
getSetVariableIds: (options) =>
options.variablesToExtract?.map((v) => v.variableId).filter(isDefined) ??
[],
run: {
server: ({ credentials, options, variables, logs }) => {
if (credentials?.apiKey === undefined)
return logs.add('No API key provided')
if (options.model === undefined) return logs.add('No model provided')
return runGenerateVariables({
model: createMistral({
apiKey: credentials.apiKey,
})(options.model),
variablesToExtract: options.variablesToExtract,
prompt: options.prompt,
variables,
logs,
})
},
},
})

View File

@@ -0,0 +1,20 @@
import ky from 'ky'
import { apiBaseUrl } from '../constants'
export const fetchModels = async ({
credentials,
}: {
credentials?: { apiKey?: string }
}) => {
if (!credentials?.apiKey) return []
const { data } = await ky
.get(apiBaseUrl + '/v1/models', {
headers: {
Authorization: `Bearer ${credentials.apiKey}`,
},
})
.json<{ data: { id: string }[] }>()
return data.map((model) => model.id)
}

View File

@@ -2,6 +2,7 @@ import { createBlock } from '@typebot.io/forge'
import { MistralLogo } from './logo'
import { auth } from './auth'
import { createChatCompletion } from './actions/createChatCompletion'
import { generateVariables } from './actions/generateVariables'
export const mistralBlock = createBlock({
id: 'mistral',
@@ -9,6 +10,6 @@ export const mistralBlock = createBlock({
tags: ['ai', 'chat', 'completion'],
LightLogo: MistralLogo,
auth,
actions: [createChatCompletion],
actions: [createChatCompletion, generateVariables],
docsUrl: 'https://docs.typebot.io/forge/blocks/mistral',
})

View File

@@ -14,8 +14,9 @@
"typescript": "5.4.5"
},
"dependencies": {
"@ai-sdk/mistral": "0.0.11",
"ai": "3.1.12",
"@ai-sdk/mistral": "0.0.18",
"@typebot.io/openai-block": "workspace:*",
"ai": "3.1.34",
"ky": "1.2.4"
}
}
}

View File

@@ -1,5 +1,4 @@
import { createAction } from '@typebot.io/forge'
import OpenAI, { ClientOptions } from 'openai'
import { defaultOpenAIOptions } from '../constants'
import { auth } from '../auth'
import { baseOptions } from '../baseOptions'
@@ -8,6 +7,7 @@ import { getChatCompletionSetVarIds } from '../shared/getChatCompletionSetVarIds
import { runChatCompletion } from '../shared/runChatCompletion'
import { runChatCompletionStream } from '../shared/runChatCompletionStream'
import { getChatCompletionStreamVarId } from '../shared/getChatCompletionStreamVarId'
import { fetchGPTModels } from '../helpers/fetchModels'
export const createChatCompletion = createAction({
name: 'Create chat completion',
@@ -45,34 +45,12 @@ export const createChatCompletion = createAction({
{
id: 'fetchModels',
dependencies: ['baseUrl', 'apiVersion'],
fetch: async ({ credentials, options }) => {
if (!credentials?.apiKey) return []
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) ?? []
)
},
fetch: ({ credentials, options }) =>
fetchGPTModels({
apiKey: credentials?.apiKey,
baseUrl: options.baseUrl,
apiVersion: options.apiVersion,
}),
},
],
run: {

View File

@@ -0,0 +1,61 @@
import { createAction } from '@typebot.io/forge'
import { auth } from '../auth'
import { baseOptions } from '../baseOptions'
import { fetchGPTModels } from '../helpers/fetchModels'
import { isDefined } from '@typebot.io/lib'
import { runGenerateVariables } from '../shared/runGenerateVariables'
import { parseGenerateVariablesOptions } from '../shared/parseGenerateVariablesOptions'
import { createOpenAI } from '@ai-sdk/openai'
export const generateVariables = createAction({
name: 'Generate variables',
auth,
baseOptions,
options: parseGenerateVariablesOptions({ modelFetch: 'fetchModels' }),
fetchers: [
{
id: 'fetchModels',
dependencies: ['baseUrl', 'apiVersion'],
fetch: ({ credentials, options }) =>
fetchGPTModels({
apiKey: credentials?.apiKey,
baseUrl: options.baseUrl,
apiVersion: options.apiVersion,
}),
},
],
turnableInto: [
{
blockId: 'mistral',
},
{
blockId: 'anthropic',
transform: (options) => ({
...options,
model: undefined,
}),
},
],
getSetVariableIds: (options) =>
options.variablesToExtract?.map((v) => v.variableId).filter(isDefined) ??
[],
run: {
server: ({ credentials, options, variables, logs }) => {
if (credentials?.apiKey === undefined)
return logs.add('No API key provided')
if (options.model === undefined) return logs.add('No model provided')
return runGenerateVariables({
model: createOpenAI({
apiKey: credentials.apiKey,
compatibility: 'strict',
})(options.model),
prompt: options.prompt,
variablesToExtract: options.variablesToExtract,
variables,
logs,
})
},
},
})

View File

@@ -0,0 +1,40 @@
import OpenAI, { ClientOptions } from 'openai'
import { defaultOpenAIOptions } from '../constants'
type Props = {
apiKey?: string
baseUrl?: string
apiVersion?: string
}
export const fetchGPTModels = async ({
apiKey,
baseUrl = defaultOpenAIOptions.baseUrl,
apiVersion,
}: Props) => {
if (!apiKey) return []
const config = {
apiKey: apiKey,
baseURL: baseUrl ?? defaultOpenAIOptions.baseUrl,
defaultHeaders: {
'api-key': apiKey,
},
defaultQuery: apiVersion
? {
'api-version': 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) ?? []
)
}

View File

@@ -5,6 +5,7 @@ import { createBlock } from '@typebot.io/forge'
import { auth } from './auth'
import { baseOptions } from './baseOptions'
import { askAssistant } from './actions/askAssistant'
import { generateVariables } from './actions/generateVariables'
export const openAIBlock = createBlock({
id: 'openai' as const,
@@ -14,6 +15,11 @@ export const openAIBlock = createBlock({
DarkLogo: OpenAIDarkLogo,
auth,
options: baseOptions,
actions: [createChatCompletion, askAssistant, createSpeech],
actions: [
createChatCompletion,
askAssistant,
generateVariables,
createSpeech,
],
docsUrl: 'https://docs.typebot.io/forge/blocks/openai',
})

View File

@@ -7,16 +7,17 @@
"author": "Baptiste Arnaud",
"license": "AGPL-3.0-or-later",
"dependencies": {
"ai": "3.1.12",
"@ai-sdk/openai": "0.0.31",
"ai": "3.1.34",
"openai": "4.47.1"
},
"devDependencies": {
"@typebot.io/forge": "workspace:*",
"@typebot.io/tsconfig": "workspace:*",
"@types/react": "18.2.15",
"typescript": "5.4.5",
"@typebot.io/lib": "workspace:*",
"@typebot.io/tsconfig": "workspace:*",
"@typebot.io/variables": "workspace:*",
"ky": "1.2.4"
"@types/react": "18.2.15",
"ky": "1.2.4",
"typescript": "5.4.5"
}
}
}

View File

@@ -0,0 +1,96 @@
import { option } from '@typebot.io/forge'
import { z } from '@typebot.io/forge/zod'
import { baseOptions } from '../baseOptions'
const extractInfoBaseShape = {
variableId: option.string.layout({
inputType: 'variableDropdown',
}),
description: option.string.layout({
label: 'Description',
accordion: 'Advanced',
}),
isRequired: option.boolean.layout({
label: 'Is required',
moreInfoTooltip:
'If set to false, there is a chance the variable will be empty',
accordion: 'Advanced',
defaultValue: true,
}),
}
export const toolParametersSchema = option
.array(
option.discriminatedUnion('type', [
option
.object({
type: option.literal('string'),
})
.extend(extractInfoBaseShape),
option
.object({
type: option.literal('number'),
})
.extend(extractInfoBaseShape),
option
.object({
type: option.literal('boolean'),
})
.extend(extractInfoBaseShape),
option
.object({
type: option.literal('enum'),
values: option
.array(option.string)
.layout({ itemLabel: 'possible value', mergeWithLastField: true }),
})
.extend(extractInfoBaseShape),
])
)
.layout({
itemLabel: 'variable mapping',
accordion: 'Schema',
})
type Props = {
defaultModel?: string
modelFetch: string | readonly [string, ...string[]]
modelHelperText?: string
}
export const parseGenerateVariablesOptions = ({
defaultModel,
modelFetch,
modelHelperText,
}: Props) =>
option.object({
model:
typeof modelFetch === 'string'
? option.string.layout({
placeholder: 'Select a model',
label: 'Model',
defaultValue: defaultModel,
fetcher: modelFetch,
helperText: modelHelperText,
})
: option.enum(modelFetch).layout({
placeholder: 'Select a model',
label: 'Model',
defaultValue: defaultModel,
helperText: modelHelperText,
}),
prompt: option.string.layout({
label: 'Prompt',
placeholder: 'Type your text here',
inputType: 'textarea',
isRequired: true,
moreInfoTooltip:
'Meant to guide the model on what to generate. i.e. "Generate a role-playing game character", "Extract the company name from this text", etc.',
}),
variablesToExtract: toolParametersSchema,
})
export type GenerateVariablesOptions = z.infer<
ReturnType<typeof parseGenerateVariablesOptions>
> &
z.infer<typeof baseOptions>

View File

@@ -0,0 +1,101 @@
import { LogsStore, VariableStore } from '@typebot.io/forge/types'
import {
GenerateVariablesOptions,
toolParametersSchema,
} from './parseGenerateVariablesOptions'
import { generateObject, LanguageModel } from 'ai'
import { Variable } from '@typebot.io/variables/types'
import { z } from '@typebot.io/forge/zod'
import { isNotEmpty } from '@typebot.io/lib/utils'
type Props = {
model: LanguageModel
variables: VariableStore
logs: LogsStore
} & Pick<GenerateVariablesOptions, 'variablesToExtract' | 'prompt'>
export const runGenerateVariables = async ({
variablesToExtract,
model,
prompt,
variables: variablesStore,
logs,
}: Props) => {
if (!prompt) return logs.add('No prompt provided')
const variables = variablesStore.list()
const schema = convertVariablesToExtractToSchema({
variablesToExtract,
variables,
})
if (!schema) {
logs.add('Could not parse variables to extract')
return
}
const hasOptionalVariables = variablesToExtract?.some(
(variableToExtract) => variableToExtract.isRequired === false
)
const { object } = await generateObject({
model,
schema,
prompt:
`${prompt}\n\nYou should generate a JSON object` +
(hasOptionalVariables
? ' and provide empty values if the information is not there or if you are unsure.'
: '.'),
})
Object.entries(object).forEach(([key, value]) => {
if (value === null) return
const existingVariable = variables.find((v) => v.name === key)
if (!existingVariable) return
variablesStore.set(existingVariable.id, value)
})
}
const convertVariablesToExtractToSchema = ({
variablesToExtract,
variables,
}: {
variablesToExtract: z.infer<typeof toolParametersSchema> | undefined
variables: Variable[]
}): z.ZodTypeAny | undefined => {
if (!variablesToExtract || variablesToExtract?.length === 0) return
const shape: z.ZodRawShape = {}
variablesToExtract.forEach((variableToExtract) => {
if (!variableToExtract) return
const matchingVariable = variables.find(
(v) => v.id === variableToExtract.variableId
)
if (!matchingVariable) return
switch (variableToExtract.type) {
case 'string':
shape[matchingVariable.name] = z.string()
break
case 'number':
shape[matchingVariable.name] = z.number()
break
case 'boolean':
shape[matchingVariable.name] = z.boolean()
break
case 'enum': {
if (!variableToExtract.values || variableToExtract.values.length === 0)
return
shape[matchingVariable.name] = z.enum(variableToExtract.values as any)
break
}
}
if (variableToExtract.isRequired === false)
shape[matchingVariable.name] = shape[matchingVariable.name].optional()
if (isNotEmpty(variableToExtract.description))
shape[matchingVariable.name] = shape[matchingVariable.name].describe(
variableToExtract.description
)
})
return z.object(shape)
}

View File

@@ -23,6 +23,7 @@ export interface ZodLayoutMetadata<
isHidden?: boolean | ((currentObj: Record<string, any>) => boolean)
isDebounceDisabled?: boolean
hiddenItems?: string[]
mergeWithLastField?: boolean
}
declare module 'zod' {

View File

@@ -0,0 +1,13 @@
export const convertStrToList = (str: string): string[] => {
const splittedBreakLines = str.split('\n')
const splittedCommas = str.split(',')
const isPastingMultipleItems =
str.length > 1 &&
(splittedBreakLines.length >= 2 || splittedCommas.length >= 2)
if (isPastingMultipleItems) {
const values =
splittedBreakLines.length >= 2 ? splittedBreakLines : splittedCommas
return values.map((v) => v.trim())
}
return [str.trim()]
}