2
0

(openai) Enable setVariable function in tools

Closes #1178
This commit is contained in:
Baptiste Arnaud
2024-01-22 09:22:28 +01:00
parent b438c174c4
commit 42008f8c18
24 changed files with 258 additions and 43 deletions

View File

@ -50,6 +50,7 @@ const nextConfig = {
if (nextRuntime === 'edge') { if (nextRuntime === 'edge') {
config.resolve.alias['minio'] = false config.resolve.alias['minio'] = false
config.resolve.alias['got'] = false config.resolve.alias['got'] = false
config.resolve.alias['qrcode'] = false
return config return config
} }
// These packages are imports from the integrations definition files that can be ignored for the client. // These packages are imports from the integrations definition files that can be ignored for the client.

View File

@ -1,6 +1,7 @@
import React from 'react' import React from 'react'
import { Text } from '@chakra-ui/react' import { Text } from '@chakra-ui/react'
import { ScriptBlock } from '@typebot.io/schemas' import { ScriptBlock } from '@typebot.io/schemas'
import { defaultScriptOptions } from '@typebot.io/schemas/features/blocks/logic/script/constants'
type Props = { type Props = {
options: ScriptBlock['options'] options: ScriptBlock['options']
@ -10,6 +11,6 @@ export const ScriptNodeContent = ({
options: { name, content } = {}, options: { name, content } = {},
}: Props) => ( }: Props) => (
<Text color={content ? 'currentcolor' : 'gray.500'} noOfLines={1}> <Text color={content ? 'currentcolor' : 'gray.500'} noOfLines={1}>
{content ? `Run ${name}` : 'Configure...'} {content ? `Run ${name ?? defaultScriptOptions.name}` : 'Configure...'}
</Text> </Text>
) )

View File

@ -4,6 +4,7 @@ import React from 'react'
import { TextInput } from '@/components/inputs' import { TextInput } from '@/components/inputs'
import { ScriptBlock } from '@typebot.io/schemas' import { ScriptBlock } from '@typebot.io/schemas'
import { defaultScriptOptions } from '@typebot.io/schemas/features/blocks/logic/script/constants' import { defaultScriptOptions } from '@typebot.io/schemas/features/blocks/logic/script/constants'
import { SwitchWithLabel } from '@/components/inputs/SwitchWithLabel'
type Props = { type Props = {
options: ScriptBlock['options'] options: ScriptBlock['options']
@ -13,9 +14,13 @@ type Props = {
export const ScriptSettings = ({ options, onOptionsChange }: Props) => { export const ScriptSettings = ({ options, onOptionsChange }: Props) => {
const handleNameChange = (name: string) => const handleNameChange = (name: string) =>
onOptionsChange({ ...options, name }) onOptionsChange({ ...options, name })
const handleCodeChange = (content: string) => const handleCodeChange = (content: string) =>
onOptionsChange({ ...options, content }) onOptionsChange({ ...options, content })
const updateClientExecution = (isExecutedOnClient: boolean) =>
onOptionsChange({ ...options, isExecutedOnClient })
return ( return (
<Stack spacing={4}> <Stack spacing={4}>
<TextInput <TextInput
@ -31,6 +36,15 @@ export const ScriptSettings = ({ options, onOptionsChange }: Props) => {
lang="javascript" lang="javascript"
onChange={handleCodeChange} onChange={handleCodeChange}
/> />
<SwitchWithLabel
label="Execute on client?"
moreInfoContent="Check this if you need access to client variables like `window` or `document`."
initialValue={
options?.isExecutedOnClient ??
defaultScriptOptions.isExecutedOnClient
}
onCheckChange={updateClientExecution}
/>
</Stack> </Stack>
</Stack> </Stack>
) )

View File

@ -137,11 +137,6 @@ const SetVariableValue = ({
case undefined: case undefined:
return ( return (
<> <>
<CodeEditor
defaultValue={options?.expressionToEvaluate ?? ''}
onChange={updateExpression}
lang="javascript"
/>
<SwitchWithLabel <SwitchWithLabel
label="Execute on client?" label="Execute on client?"
moreInfoContent="Check this if you need access to client-only variables like `window` or `document`." moreInfoContent="Check this if you need access to client-only variables like `window` or `document`."
@ -151,6 +146,11 @@ const SetVariableValue = ({
} }
onCheckChange={updateClientExecution} onCheckChange={updateClientExecution}
/> />
<CodeEditor
defaultValue={options?.expressionToEvaluate ?? ''}
onChange={updateExpression}
lang="javascript"
/>
</> </>
) )
case 'Map item with same index': { case 'Map item with same index': {

View File

@ -43,7 +43,9 @@ A more useful example would be, of course, to call an API to get the weather of
<img src="/images/blocks/integrations/openai/tools.png" alt="OpenAI tools" /> <img src="/images/blocks/integrations/openai/tools.png" alt="OpenAI tools" />
</Frame> </Frame>
As you can see, the code block expects the body of the Javascript function. You can use the `return` keyword to return values. As you can see, the code block expects the body of the Javascript function. You should use the `return` keyword to return value to give back to OpenAI as the result of the function.
If you'd like to set variables directly in this code block, you can use the [`setVariable` function](../logic/script#setvariable-function).
## Ask assistant ## Ask assistant

View File

@ -3,9 +3,9 @@ title: Script block
icon: code icon: code
--- ---
The "Script" block allows you to execute Javascript code. If you want to set a variable value with Javascript, use the [Set variable block](./set-variable) instead. You can't set a variable with the script block. The "Script" block allows you to execute Javascript code.
**It doesn't allow you to create a custom visual block** <Info>This block doesn't allow you to create a custom visual block</Info>
<Frame> <Frame>
<img src="/images/blocks/logic/code.png" width="600" alt="Code block" /> <img src="/images/blocks/logic/code.png" width="600" alt="Code block" />
@ -18,6 +18,22 @@ You need to write `console.log({{My variable}})` instead of `console.log("{{My v
</Info> </Info>
## `setVariable` function
If you want to set a variable value with Javascript, the [Set variable block](./set-variable) is more appropriate for most cases.
However, if you'd like to set variables with the script blocks, you can use the `setVariable` function in your script:
```js
if({{My variable}} === 'foo') {
setVariable('My variable', 'bar')
} else {
setVariable('My variable', 'other')
}
```
The `setVariable` function is only available in script executed on the server, so it won't work if the `Execute on client?` is checked.
## Examples ## Examples
### Reload page ### Reload page

View File

@ -371,6 +371,9 @@
"content": { "content": {
"type": "string" "type": "string"
}, },
"isExecutedOnClient": {
"type": "boolean"
},
"shouldExecuteInParentContext": { "shouldExecuteInParentContext": {
"type": "boolean" "type": "boolean"
} }
@ -6580,6 +6583,9 @@
"content": { "content": {
"type": "string" "type": "string"
}, },
"isExecutedOnClient": {
"type": "boolean"
},
"shouldExecuteInParentContext": { "shouldExecuteInParentContext": {
"type": "boolean" "type": "boolean"
} }
@ -11101,6 +11107,9 @@
"content": { "content": {
"type": "string" "type": "string"
}, },
"isExecutedOnClient": {
"type": "boolean"
},
"shouldExecuteInParentContext": { "shouldExecuteInParentContext": {
"type": "boolean" "type": "boolean"
} }
@ -24814,6 +24823,9 @@
"content": { "content": {
"type": "string" "type": "string"
}, },
"isExecutedOnClient": {
"type": "boolean"
},
"shouldExecuteInParentContext": { "shouldExecuteInParentContext": {
"type": "boolean" "type": "boolean"
} }
@ -28073,6 +28085,9 @@
"content": { "content": {
"type": "string" "type": "string"
}, },
"isExecutedOnClient": {
"type": "boolean"
},
"shouldExecuteInParentContext": { "shouldExecuteInParentContext": {
"type": "boolean" "type": "boolean"
} }
@ -30882,6 +30897,9 @@
"content": { "content": {
"type": "string" "type": "string"
}, },
"isExecutedOnClient": {
"type": "boolean"
},
"shouldExecuteInParentContext": { "shouldExecuteInParentContext": {
"type": "boolean" "type": "boolean"
} }

View File

@ -4527,6 +4527,9 @@
"content": { "content": {
"type": "string" "type": "string"
}, },
"isExecutedOnClient": {
"type": "boolean"
},
"shouldExecuteInParentContext": { "shouldExecuteInParentContext": {
"type": "boolean" "type": "boolean"
} }
@ -8289,6 +8292,9 @@
"content": { "content": {
"type": "string" "type": "string"
}, },
"isExecutedOnClient": {
"type": "boolean"
},
"shouldExecuteInParentContext": { "shouldExecuteInParentContext": {
"type": "boolean" "type": "boolean"
} }

View File

@ -50,6 +50,7 @@ const nextConfig = {
if (nextRuntime === 'edge') { if (nextRuntime === 'edge') {
config.resolve.alias['minio'] = false config.resolve.alias['minio'] = false
config.resolve.alias['got'] = false config.resolve.alias['got'] = false
config.resolve.alias['qrcode'] = false
return config return config
} }
// These packages are imports from the integrations definition files that can be ignored for the client. // These packages are imports from the integrations definition files that can be ignored for the client.

View File

@ -134,6 +134,7 @@ export async function POST(req: Request) {
credentials.iv credentials.iv
) )
const variables: ReadOnlyVariableStore = { const variables: ReadOnlyVariableStore = {
list: () => state.typebotsQueue[0].typebot.variables,
get: (id: string) => { get: (id: string) => {
const variable = state.typebotsQueue[0].typebot.variables.find( const variable = state.typebotsQueue[0].typebot.variables.find(
(variable) => variable.id === id (variable) => variable.id === id

View File

@ -3,15 +3,38 @@ import { ScriptBlock, SessionState, Variable } from '@typebot.io/schemas'
import { extractVariablesFromText } from '@typebot.io/variables/extractVariablesFromText' import { extractVariablesFromText } from '@typebot.io/variables/extractVariablesFromText'
import { parseGuessedValueType } from '@typebot.io/variables/parseGuessedValueType' import { parseGuessedValueType } from '@typebot.io/variables/parseGuessedValueType'
import { parseVariables } from '@typebot.io/variables/parseVariables' import { parseVariables } from '@typebot.io/variables/parseVariables'
import { defaultScriptOptions } from '@typebot.io/schemas/features/blocks/logic/script/constants'
import { executeFunction } from '@typebot.io/variables/executeFunction'
import { updateVariablesInSession } from '@typebot.io/variables/updateVariablesInSession'
export const executeScript = ( export const executeScript = async (
state: SessionState, state: SessionState,
block: ScriptBlock block: ScriptBlock
): ExecuteLogicResponse => { ): Promise<ExecuteLogicResponse> => {
const { variables } = state.typebotsQueue[0].typebot const { variables } = state.typebotsQueue[0].typebot
if (!block.options?.content || state.whatsApp) if (!block.options?.content || state.whatsApp)
return { outgoingEdgeId: block.outgoingEdgeId } return { outgoingEdgeId: block.outgoingEdgeId }
const isExecutedOnClient =
block.options.isExecutedOnClient ?? defaultScriptOptions.isExecutedOnClient
if (!isExecutedOnClient) {
const { newVariables, error } = await executeFunction({
variables,
body: block.options.content,
})
const newSessionState = newVariables
? updateVariablesInSession(state)(newVariables)
: state
return {
outgoingEdgeId: block.outgoingEdgeId,
logs: error ? [{ status: 'error', description: error }] : [],
newSessionState,
}
}
const scriptToExecute = parseScriptToExecuteClientSideAction( const scriptToExecute = parseScriptToExecuteClientSideAction(
variables, variables,
block.options.content block.options.content

View File

@ -101,6 +101,7 @@ export const executeForgedBlock = async (
newSessionState.typebotsQueue[0].typebot.variables, newSessionState.typebotsQueue[0].typebot.variables,
params params
)(text), )(text),
list: () => newSessionState.typebotsQueue[0].typebot.variables,
} }
let logs: NonNullable<ContinueChatResponse['logs']> = [] let logs: NonNullable<ContinueChatResponse['logs']> = []
const logsStore: LogsStore = { const logsStore: LogsStore = {

View File

@ -1,6 +1,6 @@
{ {
"name": "@typebot.io/js", "name": "@typebot.io/js",
"version": "0.2.34", "version": "0.2.35",
"description": "Javascript library to display typebots on your website", "description": "Javascript library to display typebots on your website",
"type": "module", "type": "module",
"main": "dist/index.js", "main": "dist/index.js",

View File

@ -284,8 +284,7 @@ export const ConversationContainer = (props: Props) => {
hideAvatar={ hideAvatar={
!chatChunk.input && !chatChunk.input &&
((chatChunks()[index() + 1]?.messages ?? 0).length > 0 || ((chatChunks()[index() + 1]?.messages ?? 0).length > 0 ||
chatChunks()[index() + 1]?.streamingMessageId !== undefined || chatChunks()[index() + 1]?.streamingMessageId !== undefined)
isSending())
} }
hasError={hasError() && index() === chatChunks().length - 1} hasError={hasError() && index() === chatChunks().length - 1}
onNewBubbleDisplayed={handleNewBubbleDisplayed} onNewBubbleDisplayed={handleNewBubbleDisplayed}

View File

@ -1,6 +1,6 @@
{ {
"name": "@typebot.io/nextjs", "name": "@typebot.io/nextjs",
"version": "0.2.34", "version": "0.2.35",
"description": "Convenient library to display typebots on your Next.js website", "description": "Convenient library to display typebots on your Next.js website",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@ -1,6 +1,6 @@
{ {
"name": "@typebot.io/react", "name": "@typebot.io/react",
"version": "0.2.34", "version": "0.2.35",
"description": "Convenient library to display typebots on your React app", "description": "Convenient library to display typebots on your React app",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@ -3,6 +3,7 @@ import { isDefined, isEmpty } from '@typebot.io/lib'
import { auth } from '../auth' import { auth } from '../auth'
import { ClientOptions, OpenAI } from 'openai' import { ClientOptions, OpenAI } from 'openai'
import { baseOptions } from '../baseOptions' import { baseOptions } from '../baseOptions'
import { executeFunction } from '@typebot.io/variables/executeFunction'
export const askAssistant = createAction({ export const askAssistant = createAction({
auth, auth,
@ -206,12 +207,17 @@ export const askAssistant = createAction({
if (!functionToExecute) return if (!functionToExecute) return
const name = toolCall.function.name const name = toolCall.function.name
if (!name) return if (!name || !functionToExecute.code) return
const func = AsyncFunction(
...Object.keys(parameters), const { output, newVariables } = await executeFunction({
functionToExecute.code variables: variables.list(),
) body: functionToExecute.code,
const output = await func(...Object.values(parameters)) args: parameters,
})
newVariables?.forEach((variable) => {
variables.set(variable.id, variable.value)
})
return { return {
tool_call_id: toolCall.id, tool_call_id: toolCall.id,
@ -262,5 +268,3 @@ export const askAssistant = createAction({
}, },
}, },
}) })
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor

View File

@ -8,6 +8,7 @@ import { auth } from '../auth'
import { baseOptions } from '../baseOptions' import { baseOptions } from '../baseOptions'
import { ChatCompletionTool } from 'openai/resources/chat/completions' import { ChatCompletionTool } from 'openai/resources/chat/completions'
import { parseToolParameters } from '../helpers/parseToolParameters' import { parseToolParameters } from '../helpers/parseToolParameters'
import { executeFunction } from '@typebot.io/variables/executeFunction'
const nativeMessageContentSchema = { const nativeMessageContentSchema = {
content: option.string.layout({ content: option.string.layout({
@ -213,17 +214,21 @@ export const createChatCompletion = createAction({
if (!name) continue if (!name) continue
const toolDefinition = options.tools?.find((t) => t.name === name) const toolDefinition = options.tools?.find((t) => t.name === name)
if (!toolDefinition?.code || !toolDefinition.parameters) continue if (!toolDefinition?.code || !toolDefinition.parameters) continue
const func = AsyncFunction( const toolArgs = toolCall.function?.arguments
...toolDefinition.parameters?.map((p) => p.name), ? JSON.parse(toolCall.function?.arguments)
toolDefinition.code : undefined
) if (!toolArgs) continue
const content = await func( const { output, newVariables } = await executeFunction({
...Object.values(JSON.parse(toolCall.function.arguments)) variables: variables.list(),
) args: toolArgs,
body: toolDefinition.code,
})
newVariables?.forEach((v) => variables.set(v.id, v.value))
messages.push({ messages.push({
tool_call_id: toolCall.id, tool_call_id: toolCall.id,
role: 'tool', role: 'tool',
content, content: output,
}) })
} }
@ -304,18 +309,23 @@ export const createChatCompletion = createAction({
if (!name) continue if (!name) continue
const toolDefinition = options.tools?.find((t) => t.name === name) const toolDefinition = options.tools?.find((t) => t.name === name)
if (!toolDefinition?.code || !toolDefinition.parameters) continue if (!toolDefinition?.code || !toolDefinition.parameters) continue
const func = AsyncFunction(
...toolDefinition.parameters?.map((p) => p.name), const { output } = await executeFunction({
toolDefinition.code variables: variables.list(),
) args:
const content = await func( typeof toolCall.func.arguments === 'string'
...Object.values(JSON.parse(toolCall.func.arguments as any)) ? JSON.parse(toolCall.func.arguments)
) : toolCall.func.arguments,
body: toolDefinition.code,
})
// TO-DO: enable once we're out of edge runtime.
// newVariables?.forEach((v) => variables.set(v.id, v.value))
const newMessages = appendToolCallMessage({ const newMessages = appendToolCallMessage({
tool_call_id: toolCall.id, tool_call_id: toolCall.id,
function_name: toolCall.func.name, function_name: toolCall.func.name,
tool_call_result: content, tool_call_result: output,
}) })
return openai.chat.completions.create({ return openai.chat.completions.create({
@ -334,5 +344,3 @@ export const createChatCompletion = createAction({
}, },
}, },
}) })
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor

View File

@ -15,6 +15,7 @@
"@typebot.io/tsconfig": "workspace:*", "@typebot.io/tsconfig": "workspace:*",
"@types/react": "18.2.15", "@types/react": "18.2.15",
"typescript": "5.3.2", "typescript": "5.3.2",
"@typebot.io/lib": "workspace:*" "@typebot.io/lib": "workspace:*",
"@typebot.io/variables": "workspace:*"
} }
} }

View File

@ -6,6 +6,11 @@ export type VariableStore = {
get: (variableId: string) => string | (string | null)[] | null | undefined get: (variableId: string) => string | (string | null)[] | null | undefined
set: (variableId: string, value: unknown) => void set: (variableId: string, value: unknown) => void
parse: (value: string) => string parse: (value: string) => string
list: () => {
id: string
name: string
value?: string | (string | null)[] | null | undefined
}[]
} }
export type LogsStore = { export type LogsStore = {

View File

@ -2,4 +2,5 @@ import { ScriptBlock } from './schema'
export const defaultScriptOptions = { export const defaultScriptOptions = {
name: 'Script', name: 'Script',
isExecutedOnClient: true,
} as const satisfies ScriptBlock['options'] } as const satisfies ScriptBlock['options']

View File

@ -5,6 +5,7 @@ import { LogicBlockType } from '../constants'
export const scriptOptionsSchema = z.object({ export const scriptOptionsSchema = z.object({
name: z.string().optional(), name: z.string().optional(),
content: z.string().optional(), content: z.string().optional(),
isExecutedOnClient: z.boolean().optional(),
shouldExecuteInParentContext: z.boolean().optional(), shouldExecuteInParentContext: z.boolean().optional(),
}) })

View File

@ -0,0 +1,109 @@
import { Variable } from '@typebot.io/schemas'
import { parseVariables } from './parseVariables'
import { extractVariablesFromText } from './extractVariablesFromText'
import { parseGuessedValueType } from './parseGuessedValueType'
import { isDefined } from '@typebot.io/lib'
import { defaultTimeout } from '@typebot.io/schemas/features/blocks/integrations/webhook/constants'
type Props = {
variables: Variable[]
body: string
args?: Record<string, unknown>
}
export const executeFunction = async ({
variables,
body,
args: initialArgs,
}: Props) => {
const parsedBody = parseVariables(variables, { fieldToParse: 'id' })(body)
const args = (
extractVariablesFromText(variables)(body).map((variable) => ({
id: variable.id,
value: parseGuessedValueType(variable.value),
})) as { id: string; value: unknown }[]
).concat(
initialArgs
? Object.entries(initialArgs).map(([id, value]) => ({ id, value }))
: []
)
const func = AsyncFunction(
...args.map(({ id }) => id),
'setVariable',
parsedBody
)
let updatedVariables: Record<string, any> = {}
const setVariable = (key: string, value: any) => {
updatedVariables[key] = value
}
const timeout = new Timeout()
try {
const output = await timeout.wrap(
func(...args.map(({ value }) => value), setVariable),
defaultTimeout * 1000
)
timeout.clear()
return {
output,
newVariables: Object.entries(updatedVariables)
.map(([name, value]) => {
const existingVariable = variables.find((v) => v.name === name)
if (!existingVariable) return
return {
id: existingVariable.id,
name: existingVariable.name,
value,
}
})
.filter(isDefined),
}
} catch (e) {
console.log('Error while executing script')
console.error(e)
return {
error:
typeof e === 'string'
? e
: e instanceof Error
? e.message
: JSON.stringify(e),
}
}
}
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
class Timeout {
private ids: NodeJS.Timeout[]
constructor() {
this.ids = []
}
private set = (delay: number) =>
new Promise((_, reject) => {
const id = setTimeout(() => {
reject(`Script ${defaultTimeout}s timeout reached`)
this.clear(id)
}, delay)
this.ids.push(id)
})
wrap = (promise: Promise<any>, delay: number) =>
Promise.race([promise, this.set(delay)])
clear = (...ids: NodeJS.Timeout[]) => {
this.ids = this.ids.filter((id) => {
if (ids.includes(id)) {
clearTimeout(id)
return false
}
return true
})
}
}

3
pnpm-lock.yaml generated
View File

@ -1224,6 +1224,9 @@ importers:
'@typebot.io/tsconfig': '@typebot.io/tsconfig':
specifier: workspace:* specifier: workspace:*
version: link:../../../tsconfig version: link:../../../tsconfig
'@typebot.io/variables':
specifier: workspace:*
version: link:../../../variables
'@types/react': '@types/react':
specifier: 18.2.15 specifier: 18.2.15
version: 18.2.15 version: 18.2.15