⚡ (openai) Stream chat completion to avoid serverless timeout (#526)
Closes #520
This commit is contained in:
@@ -55,11 +55,6 @@
|
|||||||
"task": "Create chat completion",
|
"task": "Create chat completion",
|
||||||
"model": "gpt-3.5-turbo",
|
"model": "gpt-3.5-turbo",
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
|
||||||
"id": "fxg16pnlnwuhfpz1r51xslbd",
|
|
||||||
"role": "system",
|
|
||||||
"content": "You are ChatGPT, a large language model trained by OpenAI."
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "vexqydoltfc5fkdrcednlvjz",
|
"id": "vexqydoltfc5fkdrcednlvjz",
|
||||||
"role": "Messages sequence ✨",
|
"role": "Messages sequence ✨",
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ test.describe.parallel('Google sheets integration', () => {
|
|||||||
.press('Enter')
|
.press('Enter')
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('typebot-standard').locator('text=Your name is:')
|
page.locator('typebot-standard').locator('text=Your name is:')
|
||||||
).toHaveText(`Your name is: Georges2 Smith2`)
|
).toHaveText(`Your name is: Georges2 Last name`)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export const createCredentials = authenticatedProcedure
|
|||||||
if (!workspace)
|
if (!workspace)
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Workspace not found' })
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Workspace not found' })
|
||||||
|
|
||||||
const { encryptedData, iv } = encrypt(credentials.data)
|
const { encryptedData, iv } = await encrypt(credentials.data)
|
||||||
const createdCredentials = await prisma.credentials.create({
|
const createdCredentials = await prisma.credentials.create({
|
||||||
data: {
|
data: {
|
||||||
...credentials,
|
...credentials,
|
||||||
|
|||||||
@@ -174,8 +174,8 @@ test('Rename and icon change should work', async ({ page }) => {
|
|||||||
])
|
])
|
||||||
|
|
||||||
await page.goto(`/typebots/${typebotId}/edit`)
|
await page.goto(`/typebots/${typebotId}/edit`)
|
||||||
|
|
||||||
await page.click('[data-testid="editable-icon"]')
|
await page.click('[data-testid="editable-icon"]')
|
||||||
|
await page.getByRole('button', { name: 'Emoji' }).click()
|
||||||
await expect(page.locator('text="My awesome typebot"')).toBeVisible()
|
await expect(page.locator('text="My awesome typebot"')).toBeVisible()
|
||||||
await page.fill('input[placeholder="Search..."]', 'love')
|
await page.fill('input[placeholder="Search..."]', 'love')
|
||||||
await page.click('text="😍"')
|
await page.click('text="😍"')
|
||||||
|
|||||||
@@ -19,10 +19,7 @@ test.describe.parallel('Settings page', () => {
|
|||||||
await page.click('text="Typebot.io branding"')
|
await page.click('text="Typebot.io branding"')
|
||||||
await expect(page.locator('a:has-text("Made with Typebot")')).toBeHidden()
|
await expect(page.locator('a:has-text("Made with Typebot")')).toBeHidden()
|
||||||
|
|
||||||
await page.click('text="Remember session"')
|
await page.click('text="Remember user"')
|
||||||
await expect(
|
|
||||||
page.locator('input[type="checkbox"] >> nth=-3')
|
|
||||||
).toHaveAttribute('checked', '')
|
|
||||||
|
|
||||||
await expect(page.getByPlaceholder('Type your answer...')).toHaveValue(
|
await expect(page.getByPlaceholder('Type your answer...')).toHaveValue(
|
||||||
'Baptiste'
|
'Baptiste'
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ test('can update workspace info', async ({ page }) => {
|
|||||||
await page.click('text=Settings & Members')
|
await page.click('text=Settings & Members')
|
||||||
await page.click('text="Settings"')
|
await page.click('text="Settings"')
|
||||||
await page.click('[data-testid="editable-icon"]')
|
await page.click('[data-testid="editable-icon"]')
|
||||||
|
await page.getByRole('button', { name: 'Emoji' }).click()
|
||||||
await page.fill('input[placeholder="Search..."]', 'building')
|
await page.fill('input[placeholder="Search..."]', 'building')
|
||||||
await page.click('text="🏦"')
|
await page.click('text="🏦"')
|
||||||
await page.waitForTimeout(500)
|
await page.waitForTimeout(500)
|
||||||
@@ -92,13 +93,13 @@ test('can manage members', async ({ page }) => {
|
|||||||
page.getByRole('heading', { name: 'Members (1/5)' })
|
page.getByRole('heading', { name: 'Members (1/5)' })
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
await expect(page.locator('text="user@email.com"').nth(1)).toBeVisible()
|
await expect(page.locator('text="user@email.com"').nth(1)).toBeVisible()
|
||||||
await expect(page.locator('button >> text="Invite"')).toBeEnabled()
|
await expect(page.locator('button >> text="Invite"')).toBeDisabled()
|
||||||
await page.fill(
|
await page.fill(
|
||||||
'input[placeholder="colleague@company.com"]',
|
'input[placeholder="colleague@company.com"]',
|
||||||
'guest@email.com'
|
'guest@email.com'
|
||||||
)
|
)
|
||||||
await page.click('button >> text="Invite"')
|
await page.click('button >> text="Invite"')
|
||||||
await expect(page.locator('button >> text="Invite"')).toBeEnabled()
|
await expect(page.locator('button >> text="Invite"')).toBeVisible()
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('input[placeholder="colleague@company.com"]')
|
page.locator('input[placeholder="colleague@company.com"]')
|
||||||
).toHaveAttribute('value', '')
|
).toHaveAttribute('value', '')
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ export const getAuthenticatedGoogleClient = async (
|
|||||||
where: { id: credentialsId, workspace: { members: { some: { userId } } } },
|
where: { id: credentialsId, workspace: { members: { some: { userId } } } },
|
||||||
})) as CredentialsFromDb | undefined
|
})) as CredentialsFromDb | undefined
|
||||||
if (!credentials) return
|
if (!credentials) return
|
||||||
const data = decrypt(
|
const data = (await decrypt(
|
||||||
credentials.data,
|
credentials.data,
|
||||||
credentials.iv
|
credentials.iv
|
||||||
) as GoogleSheetsCredentials['data']
|
)) as GoogleSheetsCredentials['data']
|
||||||
|
|
||||||
oauth2Client.setCredentials(data)
|
oauth2Client.setCredentials(data)
|
||||||
oauth2Client.on('tokens', updateTokens(credentials.id, data))
|
oauth2Client.on('tokens', updateTokens(credentials.id, data))
|
||||||
@@ -47,7 +47,7 @@ const updateTokens =
|
|||||||
expiry_date: credentials.expiry_date,
|
expiry_date: credentials.expiry_date,
|
||||||
access_token: credentials.access_token,
|
access_token: credentials.access_token,
|
||||||
}
|
}
|
||||||
const { encryptedData, iv } = encrypt(newCredentials)
|
const { encryptedData, iv } = await encrypt(newCredentials)
|
||||||
await prisma.credentials.update({
|
await prisma.credentials.update({
|
||||||
where: { id: credentialsId },
|
where: { id: credentialsId },
|
||||||
data: { data: encryptedData, iv },
|
data: { data: encryptedData, iv },
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
const data = (
|
const data = (
|
||||||
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
|
typeof req.body === 'string' ? JSON.parse(req.body) : req.body
|
||||||
) as Credentials
|
) as Credentials
|
||||||
const { encryptedData, iv } = encrypt(data.data)
|
const { encryptedData, iv } = await encrypt(data.data)
|
||||||
const workspace = await prisma.workspace.findFirst({
|
const workspace = await prisma.workspace.findFirst({
|
||||||
where: { id: workspaceId, members: { some: { userId: user.id } } },
|
where: { id: workspaceId, members: { some: { userId: user.id } } },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
return res
|
return res
|
||||||
.status(400)
|
.status(400)
|
||||||
.send({ message: "User didn't accepted required scopes" })
|
.send({ message: "User didn't accepted required scopes" })
|
||||||
const { encryptedData, iv } = encrypt(tokens)
|
const { encryptedData, iv } = await encrypt(tokens)
|
||||||
const credentials = {
|
const credentials = {
|
||||||
name: email,
|
name: email,
|
||||||
type: 'google sheets',
|
type: 'google sheets',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dqbd/tiktoken": "^1.0.7",
|
"@dqbd/tiktoken": "^1.0.7",
|
||||||
|
"@planetscale/database": "^1.7.0",
|
||||||
"@sentry/nextjs": "7.50.0",
|
"@sentry/nextjs": "7.50.0",
|
||||||
"@trpc/server": "10.23.0",
|
"@trpc/server": "10.23.0",
|
||||||
"@typebot.io/js": "workspace:*",
|
"@typebot.io/js": "workspace:*",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"aws-sdk": "2.1369.0",
|
"aws-sdk": "2.1369.0",
|
||||||
"bot-engine": "workspace:*",
|
"bot-engine": "workspace:*",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
|
"eventsource-parser": "^1.0.0",
|
||||||
"google-spreadsheet": "3.3.0",
|
"google-spreadsheet": "3.3.0",
|
||||||
"got": "12.6.0",
|
"got": "12.6.0",
|
||||||
"libphonenumber-js": "1.10.28",
|
"libphonenumber-js": "1.10.28",
|
||||||
|
|||||||
@@ -88,7 +88,10 @@ const getStripeInfo = async (
|
|||||||
where: { id: credentialsId },
|
where: { id: credentialsId },
|
||||||
})
|
})
|
||||||
if (!credentials) return
|
if (!credentials) return
|
||||||
return decrypt(credentials.data, credentials.iv) as StripeCredentials['data']
|
return (await decrypt(
|
||||||
|
credentials.data,
|
||||||
|
credentials.iv
|
||||||
|
)) as StripeCredentials['data']
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://stripe.com/docs/currencies#zero-decimal
|
// https://stripe.com/docs/currencies#zero-decimal
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
ChatReply,
|
ChatReply,
|
||||||
SessionState,
|
SessionState,
|
||||||
Variable,
|
Variable,
|
||||||
VariableWithUnknowValue,
|
|
||||||
VariableWithValue,
|
VariableWithValue,
|
||||||
} from '@typebot.io/schemas'
|
} from '@typebot.io/schemas'
|
||||||
import {
|
import {
|
||||||
@@ -13,17 +12,25 @@ import {
|
|||||||
OpenAICredentials,
|
OpenAICredentials,
|
||||||
modelLimit,
|
modelLimit,
|
||||||
} from '@typebot.io/schemas/features/blocks/integrations/openai'
|
} from '@typebot.io/schemas/features/blocks/integrations/openai'
|
||||||
import { OpenAIApi, Configuration, ChatCompletionRequestMessage } from 'openai'
|
import type {
|
||||||
import { isDefined, byId, isNotEmpty, isEmpty } from '@typebot.io/lib'
|
ChatCompletionRequestMessage,
|
||||||
import { decrypt } from '@typebot.io/lib/api/encryption'
|
CreateChatCompletionRequest,
|
||||||
|
CreateChatCompletionResponse,
|
||||||
|
} from 'openai'
|
||||||
|
import { byId, isNotEmpty, isEmpty } from '@typebot.io/lib'
|
||||||
|
import { decrypt, isCredentialsV2 } from '@typebot.io/lib/api/encryption'
|
||||||
import { saveErrorLog } from '@/features/logs/saveErrorLog'
|
import { saveErrorLog } from '@/features/logs/saveErrorLog'
|
||||||
import { updateVariables } from '@/features/variables/updateVariables'
|
import { updateVariables } from '@/features/variables/updateVariables'
|
||||||
import { parseVariables } from '@/features/variables/parseVariables'
|
import { parseVariables } from '@/features/variables/parseVariables'
|
||||||
import { saveSuccessLog } from '@/features/logs/saveSuccessLog'
|
|
||||||
import { parseVariableNumber } from '@/features/variables/parseVariableNumber'
|
import { parseVariableNumber } from '@/features/variables/parseVariableNumber'
|
||||||
import { encoding_for_model } from '@dqbd/tiktoken'
|
import { encoding_for_model } from '@dqbd/tiktoken'
|
||||||
|
import got from 'got'
|
||||||
|
import { resumeChatCompletion } from './resumeChatCompletion'
|
||||||
|
import { isPlaneteScale } from '@/helpers/api/isPlanetScale'
|
||||||
|
import { isVercel } from '@/helpers/api/isVercel'
|
||||||
|
|
||||||
const minTokenCompletion = 200
|
const minTokenCompletion = 200
|
||||||
|
const createChatEndpoint = 'https://api.openai.com/v1/chat/completions'
|
||||||
|
|
||||||
export const createChatCompletionOpenAI = async (
|
export const createChatCompletionOpenAI = async (
|
||||||
state: SessionState,
|
state: SessionState,
|
||||||
@@ -52,13 +59,10 @@ export const createChatCompletionOpenAI = async (
|
|||||||
console.error('Could not find credentials in database')
|
console.error('Could not find credentials in database')
|
||||||
return { outgoingEdgeId, logs: [noCredentialsError] }
|
return { outgoingEdgeId, logs: [noCredentialsError] }
|
||||||
}
|
}
|
||||||
const { apiKey } = decrypt(
|
const { apiKey } = (await decrypt(
|
||||||
credentials.data,
|
credentials.data,
|
||||||
credentials.iv
|
credentials.iv
|
||||||
) as OpenAICredentials['data']
|
)) as OpenAICredentials['data']
|
||||||
const configuration = new Configuration({
|
|
||||||
apiKey,
|
|
||||||
})
|
|
||||||
const { variablesTransformedToList, messages } = parseMessages(
|
const { variablesTransformedToList, messages } = parseMessages(
|
||||||
newSessionState.typebot.variables,
|
newSessionState.typebot.variables,
|
||||||
options.model
|
options.model
|
||||||
@@ -71,52 +75,39 @@ export const createChatCompletionOpenAI = async (
|
|||||||
)
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const openai = new OpenAIApi(configuration)
|
if (
|
||||||
const response = await openai.createChatCompletion({
|
isPlaneteScale() &&
|
||||||
model: options.model,
|
isVercel() &&
|
||||||
messages,
|
isCredentialsV2(credentials) &&
|
||||||
temperature,
|
newSessionState.isStreamEnabled
|
||||||
})
|
)
|
||||||
const messageContent = response.data.choices.at(0)?.message?.content
|
return {
|
||||||
const totalTokens = response.data.usage?.total_tokens
|
clientSideActions: [{ streamOpenAiChatCompletion: { messages } }],
|
||||||
|
outgoingEdgeId,
|
||||||
|
newSessionState,
|
||||||
|
}
|
||||||
|
const response = await got
|
||||||
|
.post(createChatEndpoint, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
json: {
|
||||||
|
model: options.model,
|
||||||
|
messages,
|
||||||
|
temperature,
|
||||||
|
} satisfies CreateChatCompletionRequest,
|
||||||
|
})
|
||||||
|
.json<CreateChatCompletionResponse>()
|
||||||
|
const messageContent = response.choices.at(0)?.message?.content
|
||||||
|
const totalTokens = response.usage?.total_tokens
|
||||||
if (isEmpty(messageContent)) {
|
if (isEmpty(messageContent)) {
|
||||||
console.error('OpenAI block returned empty message', response)
|
console.error('OpenAI block returned empty message', response)
|
||||||
return { outgoingEdgeId, newSessionState }
|
return { outgoingEdgeId, newSessionState }
|
||||||
}
|
}
|
||||||
const newVariables = options.responseMapping.reduce<
|
return resumeChatCompletion(newSessionState, {
|
||||||
VariableWithUnknowValue[]
|
options,
|
||||||
>((newVariables, mapping) => {
|
|
||||||
const existingVariable = newSessionState.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(messageContent)
|
|
||||||
: messageContent,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (mapping.valueToExtract === 'Total tokens' && isDefined(totalTokens)) {
|
|
||||||
newVariables.push({
|
|
||||||
...existingVariable,
|
|
||||||
value: totalTokens,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return newVariables
|
|
||||||
}, [])
|
|
||||||
if (newVariables.length > 0)
|
|
||||||
newSessionState = await updateVariables(newSessionState)(newVariables)
|
|
||||||
state.result &&
|
|
||||||
(await saveSuccessLog({
|
|
||||||
resultId: state.result.id,
|
|
||||||
message: 'OpenAI block successfully executed',
|
|
||||||
}))
|
|
||||||
return {
|
|
||||||
outgoingEdgeId,
|
outgoingEdgeId,
|
||||||
newSessionState,
|
})(messageContent, totalTokens)
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const log: NonNullable<ChatReply['logs']>[number] = {
|
const log: NonNullable<ChatReply['logs']>[number] = {
|
||||||
status: 'error',
|
status: 'error',
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { parseVariableNumber } from '@/features/variables/parseVariableNumber'
|
||||||
|
import { Connection } from '@planetscale/database'
|
||||||
|
import { decrypt } from '@typebot.io/lib/api/encryption'
|
||||||
|
import {
|
||||||
|
ChatCompletionOpenAIOptions,
|
||||||
|
OpenAICredentials,
|
||||||
|
} from '@typebot.io/schemas/features/blocks/integrations/openai'
|
||||||
|
import { SessionState } from '@typebot.io/schemas/features/chat'
|
||||||
|
import {
|
||||||
|
ParsedEvent,
|
||||||
|
ReconnectInterval,
|
||||||
|
createParser,
|
||||||
|
} from 'eventsource-parser'
|
||||||
|
import type {
|
||||||
|
ChatCompletionRequestMessage,
|
||||||
|
CreateChatCompletionRequest,
|
||||||
|
} from 'openai'
|
||||||
|
|
||||||
|
export const getChatCompletionStream =
|
||||||
|
(conn: Connection) =>
|
||||||
|
async (
|
||||||
|
state: SessionState,
|
||||||
|
options: ChatCompletionOpenAIOptions,
|
||||||
|
messages: ChatCompletionRequestMessage[]
|
||||||
|
) => {
|
||||||
|
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 decrypt(
|
||||||
|
credentials.data,
|
||||||
|
credentials.iv
|
||||||
|
)) as OpenAICredentials['data']
|
||||||
|
|
||||||
|
const temperature = parseVariableNumber(state.typebot.variables)(
|
||||||
|
options.advancedSettings?.temperature
|
||||||
|
)
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
|
||||||
|
let counter = 0
|
||||||
|
|
||||||
|
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${apiKey}`,
|
||||||
|
},
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
messages,
|
||||||
|
model: options.model,
|
||||||
|
temperature,
|
||||||
|
stream: true,
|
||||||
|
} satisfies CreateChatCompletionRequest),
|
||||||
|
})
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
function onParse(event: ParsedEvent | ReconnectInterval) {
|
||||||
|
if (event.type === 'event') {
|
||||||
|
const data = event.data
|
||||||
|
if (data === '[DONE]') {
|
||||||
|
controller.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const json = JSON.parse(data) as {
|
||||||
|
choices: { delta: { content: string } }[]
|
||||||
|
}
|
||||||
|
const text = json.choices.at(0)?.delta.content
|
||||||
|
if (counter < 2 && (text?.match(/\n/) || []).length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const queue = encoder.encode(text)
|
||||||
|
controller.enqueue(queue)
|
||||||
|
counter++
|
||||||
|
} catch (e) {
|
||||||
|
controller.error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stream response (SSE) from OpenAI may be fragmented into multiple chunks
|
||||||
|
// this ensures we properly read chunks & invoke an event for each SSE event stream
|
||||||
|
const parser = createParser(onParse)
|
||||||
|
|
||||||
|
// https://web.dev/streams/#asynchronous-iteration
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
for await (const chunk of res.body as any) {
|
||||||
|
parser.feed(decoder.decode(chunk))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return stream
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { saveSuccessLog } from '@/features/logs/saveSuccessLog'
|
||||||
|
import { updateVariables } from '@/features/variables/updateVariables'
|
||||||
|
import { byId, isDefined } from '@typebot.io/lib'
|
||||||
|
import { SessionState } from '@typebot.io/schemas'
|
||||||
|
import { ChatCompletionOpenAIOptions } from '@typebot.io/schemas/features/blocks/integrations/openai'
|
||||||
|
import { VariableWithUnknowValue } from '@typebot.io/schemas/features/typebot/variable'
|
||||||
|
|
||||||
|
export const resumeChatCompletion =
|
||||||
|
(
|
||||||
|
state: SessionState,
|
||||||
|
{
|
||||||
|
outgoingEdgeId,
|
||||||
|
options,
|
||||||
|
}: { outgoingEdgeId?: string; options: ChatCompletionOpenAIOptions }
|
||||||
|
) =>
|
||||||
|
async (message: string, totalTokens?: number) => {
|
||||||
|
let newSessionState = state
|
||||||
|
const newVariables = options.responseMapping.reduce<
|
||||||
|
VariableWithUnknowValue[]
|
||||||
|
>((newVariables, mapping) => {
|
||||||
|
const existingVariable = newSessionState.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.length > 0)
|
||||||
|
newSessionState = await updateVariables(newSessionState)(newVariables)
|
||||||
|
state.result &&
|
||||||
|
(await saveSuccessLog({
|
||||||
|
resultId: state.result.id,
|
||||||
|
message: 'OpenAI block successfully executed',
|
||||||
|
}))
|
||||||
|
return {
|
||||||
|
outgoingEdgeId,
|
||||||
|
newSessionState,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -193,7 +193,10 @@ const getEmailInfo = async (
|
|||||||
where: { id: credentialsId },
|
where: { id: credentialsId },
|
||||||
})
|
})
|
||||||
if (!credentials) return
|
if (!credentials) return
|
||||||
return decrypt(credentials.data, credentials.iv) as SmtpCredentials['data']
|
return (await decrypt(
|
||||||
|
credentials.data,
|
||||||
|
credentials.iv
|
||||||
|
)) as SmtpCredentials['data']
|
||||||
}
|
}
|
||||||
|
|
||||||
const getEmailBody = async ({
|
const getEmailBody = async ({
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ const evaluateSetVariableExpression =
|
|||||||
const evaluating = parseVariables(variables, { fieldToParse: 'id' })(
|
const evaluating = parseVariables(variables, { fieldToParse: 'id' })(
|
||||||
str.includes('return ') ? str : `return ${str}`
|
str.includes('return ') ? str : `return ${str}`
|
||||||
)
|
)
|
||||||
|
console.log(
|
||||||
|
variables.map((v) => v.id),
|
||||||
|
...variables.map((v) => parseGuessedValueType(v.value)),
|
||||||
|
evaluating
|
||||||
|
)
|
||||||
try {
|
try {
|
||||||
const func = Function(...variables.map((v) => v.id), evaluating)
|
const func = Function(...variables.map((v) => v.id), evaluating)
|
||||||
return func(...variables.map((v) => parseGuessedValueType(v.value)))
|
return func(...variables.map((v) => parseGuessedValueType(v.value)))
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ const startSession = async (startParams?: StartParams, userId?: string) => {
|
|||||||
},
|
},
|
||||||
currentTypebotId: typebot.id,
|
currentTypebotId: typebot.id,
|
||||||
dynamicTheme: parseDynamicThemeInState(typebot.theme),
|
dynamicTheme: parseDynamicThemeInState(typebot.theme),
|
||||||
|
isStreamEnabled: startParams.isStreamEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
const { messages, input, clientSideActions, newSessionState, logs } =
|
const { messages, input, clientSideActions, newSessionState, logs } =
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ import {
|
|||||||
ChatReply,
|
ChatReply,
|
||||||
InputBlock,
|
InputBlock,
|
||||||
InputBlockType,
|
InputBlockType,
|
||||||
|
IntegrationBlockType,
|
||||||
LogicBlockType,
|
LogicBlockType,
|
||||||
ResultInSession,
|
ResultInSession,
|
||||||
SessionState,
|
SessionState,
|
||||||
SetVariableBlock,
|
SetVariableBlock,
|
||||||
} from '@typebot.io/schemas'
|
} from '@typebot.io/schemas'
|
||||||
import { isInputBlock, isNotDefined, byId } from '@typebot.io/lib'
|
import { isInputBlock, isNotDefined, byId, isDefined } from '@typebot.io/lib'
|
||||||
import { executeGroup } from './executeGroup'
|
import { executeGroup } from './executeGroup'
|
||||||
import { getNextGroup } from './getNextGroup'
|
import { getNextGroup } from './getNextGroup'
|
||||||
import { validateEmail } from '@/features/blocks/inputs/email/validateEmail'
|
import { validateEmail } from '@/features/blocks/inputs/email/validateEmail'
|
||||||
@@ -23,6 +24,8 @@ import { validatePhoneNumber } from '@/features/blocks/inputs/phone/validatePhon
|
|||||||
import { validateUrl } from '@/features/blocks/inputs/url/validateUrl'
|
import { validateUrl } from '@/features/blocks/inputs/url/validateUrl'
|
||||||
import { updateVariables } from '@/features/variables/updateVariables'
|
import { updateVariables } from '@/features/variables/updateVariables'
|
||||||
import { parseVariables } from '@/features/variables/parseVariables'
|
import { parseVariables } from '@/features/variables/parseVariables'
|
||||||
|
import { OpenAIBlock } from '@typebot.io/schemas/features/blocks/integrations/openai'
|
||||||
|
import { resumeChatCompletion } from '@/features/blocks/integrations/openai/resumeChatCompletion'
|
||||||
|
|
||||||
export const continueBotFlow =
|
export const continueBotFlow =
|
||||||
(state: SessionState) =>
|
(state: SessionState) =>
|
||||||
@@ -57,6 +60,16 @@ export const continueBotFlow =
|
|||||||
}
|
}
|
||||||
newSessionState = await updateVariables(state)([newVariable])
|
newSessionState = await updateVariables(state)([newVariable])
|
||||||
}
|
}
|
||||||
|
} else if (
|
||||||
|
isDefined(reply) &&
|
||||||
|
block.type === IntegrationBlockType.OPEN_AI &&
|
||||||
|
block.options.task === 'Create chat completion'
|
||||||
|
) {
|
||||||
|
const result = await resumeChatCompletion(state, {
|
||||||
|
options: block.options,
|
||||||
|
outgoingEdgeId: block.outgoingEdgeId,
|
||||||
|
})(reply)
|
||||||
|
newSessionState = result.newSessionState
|
||||||
} else if (!isInputBlock(block))
|
} else if (!isInputBlock(block))
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'INTERNAL_SERVER_ERROR',
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
@@ -236,7 +249,10 @@ const computeStorageUsed = async (reply: string) => {
|
|||||||
|
|
||||||
const getOutgoingEdgeId =
|
const getOutgoingEdgeId =
|
||||||
({ typebot: { variables } }: Pick<SessionState, 'typebot'>) =>
|
({ typebot: { variables } }: Pick<SessionState, 'typebot'>) =>
|
||||||
(block: InputBlock | SetVariableBlock, reply: string | null) => {
|
(
|
||||||
|
block: InputBlock | SetVariableBlock | OpenAIBlock,
|
||||||
|
reply: string | null
|
||||||
|
) => {
|
||||||
if (
|
if (
|
||||||
block.type === InputBlockType.CHOICE &&
|
block.type === InputBlockType.CHOICE &&
|
||||||
!block.options.isMultipleChoice &&
|
!block.options.isMultipleChoice &&
|
||||||
|
|||||||
@@ -73,6 +73,10 @@ export const executeGroup =
|
|||||||
: null
|
: null
|
||||||
|
|
||||||
if (!executionResponse) continue
|
if (!executionResponse) continue
|
||||||
|
if (executionResponse.logs)
|
||||||
|
logs = [...(logs ?? []), ...executionResponse.logs]
|
||||||
|
if (executionResponse.newSessionState)
|
||||||
|
newSessionState = executionResponse.newSessionState
|
||||||
if (
|
if (
|
||||||
'clientSideActions' in executionResponse &&
|
'clientSideActions' in executionResponse &&
|
||||||
executionResponse.clientSideActions
|
executionResponse.clientSideActions
|
||||||
@@ -83,7 +87,8 @@ export const executeGroup =
|
|||||||
]
|
]
|
||||||
if (
|
if (
|
||||||
executionResponse.clientSideActions?.find(
|
executionResponse.clientSideActions?.find(
|
||||||
(action) => 'setVariable' in action
|
(action) =>
|
||||||
|
'setVariable' in action || 'streamOpenAiChatCompletion' in action
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
@@ -101,10 +106,6 @@ export const executeGroup =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (executionResponse.logs)
|
|
||||||
logs = [...(logs ?? []), ...executionResponse.logs]
|
|
||||||
if (executionResponse.newSessionState)
|
|
||||||
newSessionState = executionResponse.newSessionState
|
|
||||||
if (executionResponse.outgoingEdgeId) {
|
if (executionResponse.outgoingEdgeId) {
|
||||||
nextEdgeId = executionResponse.outgoingEdgeId
|
nextEdgeId = executionResponse.outgoingEdgeId
|
||||||
break
|
break
|
||||||
|
|||||||
2
apps/viewer/src/helpers/api/isPlanetScale.ts
Normal file
2
apps/viewer/src/helpers/api/isPlanetScale.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const isPlaneteScale = () =>
|
||||||
|
process.env.DATABASE_URL?.includes('pscale_pw')
|
||||||
3
apps/viewer/src/helpers/api/isVercel.ts
Normal file
3
apps/viewer/src/helpers/api/isVercel.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { isDefined } from '@typebot.io/lib/utils'
|
||||||
|
|
||||||
|
export const isVercel = () => isDefined(process.env.NEXT_PUBLIC_VERCEL_ENV)
|
||||||
@@ -12,10 +12,10 @@ export const getAuthenticatedGoogleClient = async (
|
|||||||
where: { id: credentialsId },
|
where: { id: credentialsId },
|
||||||
})) as CredentialsFromDb | undefined
|
})) as CredentialsFromDb | undefined
|
||||||
if (!credentials) return
|
if (!credentials) return
|
||||||
const data = decrypt(
|
const data = (await decrypt(
|
||||||
credentials.data,
|
credentials.data,
|
||||||
credentials.iv
|
credentials.iv
|
||||||
) as GoogleSheetsCredentials['data']
|
)) as GoogleSheetsCredentials['data']
|
||||||
|
|
||||||
const oauth2Client = new OAuth2Client(
|
const oauth2Client = new OAuth2Client(
|
||||||
process.env.GOOGLE_CLIENT_ID,
|
process.env.GOOGLE_CLIENT_ID,
|
||||||
@@ -43,7 +43,7 @@ const updateTokens =
|
|||||||
expiry_date: credentials.expiry_date,
|
expiry_date: credentials.expiry_date,
|
||||||
access_token: credentials.access_token,
|
access_token: credentials.access_token,
|
||||||
}
|
}
|
||||||
const { encryptedData, iv } = encrypt(newCredentials)
|
const { encryptedData, iv } = await encrypt(newCredentials)
|
||||||
await prisma.credentials.update({
|
await prisma.credentials.update({
|
||||||
where: { id: credentialsId },
|
where: { id: credentialsId },
|
||||||
data: { data: encryptedData, iv },
|
data: { data: encryptedData, iv },
|
||||||
|
|||||||
78
apps/viewer/src/pages/api/integrations/openai/streamer.ts
Normal file
78
apps/viewer/src/pages/api/integrations/openai/streamer.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { getChatCompletionStream } from '@/features/blocks/integrations/openai/getChatCompletionStream'
|
||||||
|
import { connect } from '@planetscale/database'
|
||||||
|
import { IntegrationBlockType, SessionState } from '@typebot.io/schemas'
|
||||||
|
import { ChatCompletionRequestMessage } from 'openai'
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
runtime: 'edge',
|
||||||
|
regions: ['lhr1'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = async (req: Request) => {
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
return new Response('ok', {
|
||||||
|
headers: {
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Access-Control-Allow-Methods': 'POST',
|
||||||
|
'Access-Control-Expose-Headers': 'Content-Length, X-JSON',
|
||||||
|
'Access-Control-Allow-Headers':
|
||||||
|
'apikey,X-Client-Info, Content-Type, Authorization, Accept, Accept-Language, X-Authorization',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const { sessionId, messages } = (await req.json()) as {
|
||||||
|
sessionId: string
|
||||||
|
messages: ChatCompletionRequestMessage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessionId) return new Response('No session ID provided', { status: 400 })
|
||||||
|
|
||||||
|
if (!messages) return new Response('No messages provided', { status: 400 })
|
||||||
|
|
||||||
|
const conn = connect({ url: process.env.DATABASE_URL })
|
||||||
|
|
||||||
|
const chatSession = await conn.execute(
|
||||||
|
'select state from ChatSession where id=?',
|
||||||
|
[sessionId]
|
||||||
|
)
|
||||||
|
|
||||||
|
const state = (chatSession.rows.at(0) as { state: SessionState } | undefined)
|
||||||
|
?.state
|
||||||
|
|
||||||
|
if (!state) return new Response('No state found', { status: 400 })
|
||||||
|
|
||||||
|
const group = state.typebot.groups.find(
|
||||||
|
(group) => group.id === state.currentBlock?.groupId
|
||||||
|
)
|
||||||
|
const blockIndex =
|
||||||
|
group?.blocks.findIndex(
|
||||||
|
(block) => block.id === state.currentBlock?.blockId
|
||||||
|
) ?? -1
|
||||||
|
|
||||||
|
const block = blockIndex >= 0 ? group?.blocks[blockIndex ?? 0] : null
|
||||||
|
|
||||||
|
if (!block || !group)
|
||||||
|
return new Response('Current block not found', { status: 400 })
|
||||||
|
|
||||||
|
if (
|
||||||
|
block.type !== IntegrationBlockType.OPEN_AI ||
|
||||||
|
block.options.task !== 'Create chat completion'
|
||||||
|
)
|
||||||
|
return new Response('Current block is not an OpenAI block', { status: 400 })
|
||||||
|
|
||||||
|
const stream = await getChatCompletionStream(conn)(
|
||||||
|
state,
|
||||||
|
block.options,
|
||||||
|
messages
|
||||||
|
)
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default handler
|
||||||
@@ -112,7 +112,10 @@ const getStripeInfo = async (
|
|||||||
where: { id: credentialsId },
|
where: { id: credentialsId },
|
||||||
})
|
})
|
||||||
if (!credentials) return
|
if (!credentials) return
|
||||||
return decrypt(credentials.data, credentials.iv) as StripeCredentials['data']
|
return (await decrypt(
|
||||||
|
credentials.data,
|
||||||
|
credentials.iv
|
||||||
|
)) as StripeCredentials['data']
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://stripe.com/docs/currencies#zero-decimal
|
// https://stripe.com/docs/currencies#zero-decimal
|
||||||
|
|||||||
@@ -170,7 +170,10 @@ const getEmailInfo = async (
|
|||||||
where: { id: credentialsId },
|
where: { id: credentialsId },
|
||||||
})
|
})
|
||||||
if (!credentials) return
|
if (!credentials) return
|
||||||
return decrypt(credentials.data, credentials.iv) as SmtpCredentials['data']
|
return (await decrypt(
|
||||||
|
credentials.data,
|
||||||
|
credentials.iv
|
||||||
|
)) as SmtpCredentials['data']
|
||||||
}
|
}
|
||||||
|
|
||||||
const getEmailBody = async ({
|
const getEmailBody = async ({
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { proWorkspaceId } from '@typebot.io/lib/playwright/databaseSetup'
|
|||||||
|
|
||||||
const prisma = new PrismaClient()
|
const prisma = new PrismaClient()
|
||||||
|
|
||||||
export const createSmtpCredentials = (
|
export const createSmtpCredentials = async (
|
||||||
id: string,
|
id: string,
|
||||||
smtpData: SmtpCredentials['data']
|
smtpData: SmtpCredentials['data']
|
||||||
) => {
|
) => {
|
||||||
const { encryptedData, iv } = encrypt(smtpData)
|
const { encryptedData, iv } = await encrypt(smtpData)
|
||||||
return prisma.credentials.create({
|
return prisma.credentials.create({
|
||||||
data: {
|
data: {
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@typebot.io/js",
|
"name": "@typebot.io/js",
|
||||||
"version": "0.0.54",
|
"version": "0.0.55",
|
||||||
"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",
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ type Props = Pick<ChatReply, 'messages' | 'input'> & {
|
|||||||
settings: Settings
|
settings: Settings
|
||||||
inputIndex: number
|
inputIndex: number
|
||||||
context: BotContext
|
context: BotContext
|
||||||
isLoadingBubbleDisplayed: boolean
|
|
||||||
hasError: boolean
|
hasError: boolean
|
||||||
hideAvatar: boolean
|
hideAvatar: boolean
|
||||||
onNewBubbleDisplayed: (blockId: string) => Promise<void>
|
onNewBubbleDisplayed: (blockId: string) => Promise<void>
|
||||||
|
|||||||
@@ -69,7 +69,11 @@ export const ConversationContainer = (props: Props) => {
|
|||||||
(action) => isNotDefined(action.lastBubbleBlockId)
|
(action) => isNotDefined(action.lastBubbleBlockId)
|
||||||
)
|
)
|
||||||
for (const action of actionsBeforeFirstBubble) {
|
for (const action of actionsBeforeFirstBubble) {
|
||||||
const response = await executeClientSideAction(action)
|
if ('streamOpenAiChatCompletion' in action) setIsSending(true)
|
||||||
|
const response = await executeClientSideAction(action, {
|
||||||
|
apiHost: props.context.apiHost,
|
||||||
|
sessionId: props.initialChatReply.sessionId,
|
||||||
|
})
|
||||||
if (response && 'replyToSend' in response) {
|
if (response && 'replyToSend' in response) {
|
||||||
sendMessage(response.replyToSend)
|
sendMessage(response.replyToSend)
|
||||||
return
|
return
|
||||||
@@ -133,7 +137,11 @@ export const ConversationContainer = (props: Props) => {
|
|||||||
isNotDefined(action.lastBubbleBlockId)
|
isNotDefined(action.lastBubbleBlockId)
|
||||||
)
|
)
|
||||||
for (const action of actionsBeforeFirstBubble) {
|
for (const action of actionsBeforeFirstBubble) {
|
||||||
const response = await executeClientSideAction(action)
|
if ('streamOpenAiChatCompletion' in action) setIsSending(true)
|
||||||
|
const response = await executeClientSideAction(action, {
|
||||||
|
apiHost: props.context.apiHost,
|
||||||
|
sessionId: props.initialChatReply.sessionId,
|
||||||
|
})
|
||||||
if (response && 'replyToSend' in response) {
|
if (response && 'replyToSend' in response) {
|
||||||
sendMessage(response.replyToSend)
|
sendMessage(response.replyToSend)
|
||||||
return
|
return
|
||||||
@@ -174,7 +182,11 @@ export const ConversationContainer = (props: Props) => {
|
|||||||
(action) => action.lastBubbleBlockId === blockId
|
(action) => action.lastBubbleBlockId === blockId
|
||||||
)
|
)
|
||||||
for (const action of actionsToExecute) {
|
for (const action of actionsToExecute) {
|
||||||
const response = await executeClientSideAction(action)
|
if ('streamOpenAiChatCompletion' in action) setIsSending(true)
|
||||||
|
const response = await executeClientSideAction(action, {
|
||||||
|
apiHost: props.context.apiHost,
|
||||||
|
sessionId: props.initialChatReply.sessionId,
|
||||||
|
})
|
||||||
if (response && 'replyToSend' in response) {
|
if (response && 'replyToSend' in response) {
|
||||||
sendMessage(response.replyToSend)
|
sendMessage(response.replyToSend)
|
||||||
return
|
return
|
||||||
@@ -200,7 +212,6 @@ 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}
|
||||||
isLoadingBubbleDisplayed={isSending()}
|
|
||||||
onNewBubbleDisplayed={handleNewBubbleDisplayed}
|
onNewBubbleDisplayed={handleNewBubbleDisplayed}
|
||||||
onAllBubblesDisplayed={handleAllBubblesDisplayed}
|
onAllBubblesDisplayed={handleAllBubblesDisplayed}
|
||||||
onSubmit={sendMessage}
|
onSubmit={sendMessage}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export async function getInitialChatReplyQuery({
|
|||||||
prefilledVariables,
|
prefilledVariables,
|
||||||
startGroupId,
|
startGroupId,
|
||||||
resultId,
|
resultId,
|
||||||
|
isStreamEnabled: true,
|
||||||
},
|
},
|
||||||
} satisfies SendMessageInput,
|
} satisfies SendMessageInput,
|
||||||
})
|
})
|
||||||
|
|||||||
33
packages/embeds/js/src/queries/getOpenAiStreamerQuery.ts
Normal file
33
packages/embeds/js/src/queries/getOpenAiStreamerQuery.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { guessApiHost } from '@/utils/guessApiHost'
|
||||||
|
import { isNotEmpty } from '@typebot.io/lib'
|
||||||
|
|
||||||
|
export const getOpenAiStreamerQuery =
|
||||||
|
({ apiHost, sessionId }: { apiHost?: string; sessionId: string }) =>
|
||||||
|
async (
|
||||||
|
messages: {
|
||||||
|
content?: string | undefined
|
||||||
|
role?: 'system' | 'user' | 'assistant' | undefined
|
||||||
|
}[]
|
||||||
|
) => {
|
||||||
|
const response = await fetch(
|
||||||
|
`${
|
||||||
|
isNotEmpty(apiHost) ? apiHost : guessApiHost()
|
||||||
|
}/api/integrations/openai/streamer`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
sessionId,
|
||||||
|
messages,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.statusText)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = response.body
|
||||||
|
return data
|
||||||
|
}
|
||||||
@@ -4,10 +4,18 @@ import { executeRedirect } from '@/features/blocks/logic/redirect'
|
|||||||
import { executeScript } from '@/features/blocks/logic/script/executeScript'
|
import { executeScript } from '@/features/blocks/logic/script/executeScript'
|
||||||
import { executeSetVariable } from '@/features/blocks/logic/setVariable/executeSetVariable'
|
import { executeSetVariable } from '@/features/blocks/logic/setVariable/executeSetVariable'
|
||||||
import { executeWait } from '@/features/blocks/logic/wait/utils/executeWait'
|
import { executeWait } from '@/features/blocks/logic/wait/utils/executeWait'
|
||||||
|
import { getOpenAiStreamerQuery } from '@/queries/getOpenAiStreamerQuery'
|
||||||
import type { ChatReply } from '@typebot.io/schemas'
|
import type { ChatReply } from '@typebot.io/schemas'
|
||||||
|
|
||||||
|
type ClientSideActionContext = {
|
||||||
|
apiHost?: string
|
||||||
|
sessionId: string
|
||||||
|
}
|
||||||
|
|
||||||
export const executeClientSideAction = async (
|
export const executeClientSideAction = async (
|
||||||
clientSideAction: NonNullable<ChatReply['clientSideActions']>[0]
|
clientSideAction: NonNullable<ChatReply['clientSideActions']>[0],
|
||||||
|
context: ClientSideActionContext,
|
||||||
|
onStreamedMessage?: (message: string) => void
|
||||||
): Promise<
|
): Promise<
|
||||||
{ blockedPopupUrl: string } | { replyToSend: string | undefined } | void
|
{ blockedPopupUrl: string } | { replyToSend: string | undefined } | void
|
||||||
> => {
|
> => {
|
||||||
@@ -29,4 +37,41 @@ export const executeClientSideAction = async (
|
|||||||
if ('setVariable' in clientSideAction) {
|
if ('setVariable' in clientSideAction) {
|
||||||
return executeSetVariable(clientSideAction.setVariable.scriptToExecute)
|
return executeSetVariable(clientSideAction.setVariable.scriptToExecute)
|
||||||
}
|
}
|
||||||
|
if ('streamOpenAiChatCompletion' in clientSideAction) {
|
||||||
|
const text = await streamChat(context)(
|
||||||
|
clientSideAction.streamOpenAiChatCompletion.messages,
|
||||||
|
{ onStreamedMessage }
|
||||||
|
)
|
||||||
|
return { replyToSend: text }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const streamChat =
|
||||||
|
(context: ClientSideActionContext) =>
|
||||||
|
async (
|
||||||
|
messages: {
|
||||||
|
content?: string | undefined
|
||||||
|
role?: 'system' | 'user' | 'assistant' | undefined
|
||||||
|
}[],
|
||||||
|
{ onStreamedMessage }: { onStreamedMessage?: (message: string) => void }
|
||||||
|
) => {
|
||||||
|
const data = await getOpenAiStreamerQuery(context)(messages)
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = data.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let done = false
|
||||||
|
|
||||||
|
let message = ''
|
||||||
|
while (!done) {
|
||||||
|
const { value, done: doneReading } = await reader.read()
|
||||||
|
done = doneReading
|
||||||
|
const chunkValue = decoder.decode(value)
|
||||||
|
message += chunkValue
|
||||||
|
onStreamedMessage?.(message)
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@typebot.io/react",
|
"name": "@typebot.io/react",
|
||||||
"version": "0.0.54",
|
"version": "0.0.55",
|
||||||
"description": "React library to display typebots on your website",
|
"description": "React library to display typebots on your website",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -1,36 +1,75 @@
|
|||||||
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto'
|
import { Credentials } from '@typebot.io/schemas/features/credentials'
|
||||||
|
import { decryptV1 } from './encryptionV1'
|
||||||
|
|
||||||
const algorithm = 'aes-256-gcm'
|
const algorithm = 'AES-GCM'
|
||||||
const secretKey = process.env.ENCRYPTION_SECRET
|
const secretKey = process.env.ENCRYPTION_SECRET
|
||||||
|
|
||||||
export const encrypt = (
|
export const encrypt = async (
|
||||||
data: object
|
data: object
|
||||||
): { encryptedData: string; iv: string } => {
|
): Promise<{ encryptedData: string; iv: string }> => {
|
||||||
if (!secretKey) throw new Error(`ENCRYPTION_SECRET is not in environment`)
|
if (!secretKey) throw new Error('ENCRYPTION_SECRET is not in environment')
|
||||||
const iv = randomBytes(16)
|
const iv = crypto.getRandomValues(new Uint8Array(12))
|
||||||
const cipher = createCipheriv(algorithm, secretKey, iv)
|
const encodedData = new TextEncoder().encode(JSON.stringify(data))
|
||||||
const dataString = JSON.stringify(data)
|
|
||||||
const encryptedData =
|
const key = await crypto.subtle.importKey(
|
||||||
cipher.update(dataString, 'utf8', 'hex') + cipher.final('hex')
|
'raw',
|
||||||
const tag = cipher.getAuthTag()
|
new TextEncoder().encode(secretKey),
|
||||||
|
algorithm,
|
||||||
|
false,
|
||||||
|
['encrypt']
|
||||||
|
)
|
||||||
|
|
||||||
|
const encryptedBuffer = await crypto.subtle.encrypt(
|
||||||
|
{ name: algorithm, iv },
|
||||||
|
key,
|
||||||
|
encodedData
|
||||||
|
)
|
||||||
|
|
||||||
|
const encryptedData = btoa(
|
||||||
|
String.fromCharCode.apply(null, Array.from(new Uint8Array(encryptedBuffer)))
|
||||||
|
)
|
||||||
|
|
||||||
|
const ivHex = Array.from(iv)
|
||||||
|
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||||
|
.join('')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
encryptedData,
|
encryptedData,
|
||||||
iv: iv.toString('hex') + '.' + tag.toString('hex'),
|
iv: ivHex,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const decrypt = (encryptedData: string, auth: string): object => {
|
export const decrypt = async (
|
||||||
if (!secretKey) throw new Error(`ENCRYPTION_SECRET is not in environment`)
|
encryptedData: string,
|
||||||
const [iv, tag] = auth.split('.')
|
ivHex: string
|
||||||
const decipher = createDecipheriv(
|
): Promise<object> => {
|
||||||
|
if (ivHex.length !== 24) return decryptV1(encryptedData, ivHex)
|
||||||
|
if (!secretKey) throw new Error('ENCRYPTION_SECRET is not in environment')
|
||||||
|
const iv = new Uint8Array(
|
||||||
|
ivHex.match(/.{1,2}/g)?.map((byte) => parseInt(byte, 16)) ?? []
|
||||||
|
)
|
||||||
|
|
||||||
|
const key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
new TextEncoder().encode(secretKey),
|
||||||
algorithm,
|
algorithm,
|
||||||
secretKey,
|
false,
|
||||||
Buffer.from(iv, 'hex')
|
['decrypt']
|
||||||
)
|
)
|
||||||
decipher.setAuthTag(Buffer.from(tag, 'hex'))
|
|
||||||
return JSON.parse(
|
const encryptedBuffer = new Uint8Array(
|
||||||
(
|
Array.from(atob(encryptedData)).map((char) => char.charCodeAt(0))
|
||||||
decipher.update(Buffer.from(encryptedData, 'hex')) + decipher.final('hex')
|
|
||||||
).toString()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const decryptedBuffer = await crypto.subtle.decrypt(
|
||||||
|
{ name: algorithm, iv },
|
||||||
|
key,
|
||||||
|
encryptedBuffer
|
||||||
|
)
|
||||||
|
|
||||||
|
const decryptedData = new TextDecoder().decode(decryptedBuffer)
|
||||||
|
return JSON.parse(decryptedData)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isCredentialsV2 = (credentials: Pick<Credentials, 'iv'>) =>
|
||||||
|
credentials.iv.length === 24
|
||||||
|
|||||||
20
packages/lib/api/encryptionV1.ts
Normal file
20
packages/lib/api/encryptionV1.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { createDecipheriv } from 'crypto'
|
||||||
|
|
||||||
|
const algorithm = 'aes-256-gcm'
|
||||||
|
const secretKey = process.env.ENCRYPTION_SECRET
|
||||||
|
|
||||||
|
export const decryptV1 = (encryptedData: string, auth: string): object => {
|
||||||
|
if (!secretKey) throw new Error(`ENCRYPTION_SECRET is not in environment`)
|
||||||
|
const [iv, tag] = auth.split('.')
|
||||||
|
const decipher = createDecipheriv(
|
||||||
|
algorithm,
|
||||||
|
secretKey,
|
||||||
|
Buffer.from(iv, 'hex')
|
||||||
|
)
|
||||||
|
decipher.setAuthTag(Buffer.from(tag, 'hex'))
|
||||||
|
return JSON.parse(
|
||||||
|
(
|
||||||
|
decipher.update(Buffer.from(encryptedData, 'hex')) + decipher.final('hex')
|
||||||
|
).toString()
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -125,8 +125,8 @@ export const setupUsers = async () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const setupCredentials = () => {
|
const setupCredentials = async () => {
|
||||||
const { encryptedData, iv } = encrypt({
|
const { encryptedData, iv } = await encrypt({
|
||||||
expiry_date: 1642441058842,
|
expiry_date: 1642441058842,
|
||||||
access_token:
|
access_token:
|
||||||
'ya29.A0ARrdaM--PV_87ebjywDJpXKb77NBFJl16meVUapYdfNv6W6ZzqqC47fNaPaRjbDbOIIcp6f49cMaX5ndK9TAFnKwlVqz3nrK9nLKqgyDIhYsIq47smcAIZkK56SWPx3X3DwAFqRu2UPojpd2upWwo-3uJrod',
|
'ya29.A0ARrdaM--PV_87ebjywDJpXKb77NBFJl16meVUapYdfNv6W6ZzqqC47fNaPaRjbDbOIIcp6f49cMaX5ndK9TAFnKwlVqz3nrK9nLKqgyDIhYsIq47smcAIZkK56SWPx3X3DwAFqRu2UPojpd2upWwo-3uJrod',
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ const initialOptionsSchema = z
|
|||||||
})
|
})
|
||||||
.merge(openAIBaseOptionsSchema)
|
.merge(openAIBaseOptionsSchema)
|
||||||
|
|
||||||
const chatCompletionMessageSchema = z.object({
|
export const chatCompletionMessageSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
role: z.enum(chatCompletionMessageRoles).optional(),
|
role: z.enum(chatCompletionMessageRoles).optional(),
|
||||||
content: z.string().optional(),
|
content: z.string().optional(),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ZodDiscriminatedUnion, z } from 'zod'
|
import { z } from 'zod'
|
||||||
import {
|
import {
|
||||||
googleAnalyticsOptionsSchema,
|
googleAnalyticsOptionsSchema,
|
||||||
paymentInputRuntimeOptionsSchema,
|
paymentInputRuntimeOptionsSchema,
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
import { answerSchema } from './answer'
|
import { answerSchema } from './answer'
|
||||||
import { BubbleBlockType } from './blocks/bubbles/enums'
|
import { BubbleBlockType } from './blocks/bubbles/enums'
|
||||||
import { inputBlockSchemas } from './blocks/schemas'
|
import { inputBlockSchemas } from './blocks/schemas'
|
||||||
|
import { chatCompletionMessageSchema } from './blocks/integrations/openai'
|
||||||
|
|
||||||
const typebotInSessionStateSchema = publicTypebotSchema.pick({
|
const typebotInSessionStateSchema = publicTypebotSchema.pick({
|
||||||
id: true,
|
id: true,
|
||||||
@@ -62,6 +63,7 @@ export const sessionStateSchema = z.object({
|
|||||||
groupId: z.string(),
|
groupId: z.string(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
isStreamEnabled: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const chatSessionSchema = z.object({
|
const chatSessionSchema = z.object({
|
||||||
@@ -162,6 +164,7 @@ const startParamsSchema = z.object({
|
|||||||
.describe(
|
.describe(
|
||||||
'[More info about prefilled variables.](https://docs.typebot.io/editor/variables#prefilled-variables)'
|
'[More info about prefilled variables.](https://docs.typebot.io/editor/variables#prefilled-variables)'
|
||||||
),
|
),
|
||||||
|
isStreamEnabled: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const sendMessageInputSchema = z.object({
|
export const sendMessageInputSchema = z.object({
|
||||||
@@ -225,6 +228,15 @@ const clientSideActionSchema = z
|
|||||||
setVariable: z.object({ scriptToExecute: scriptToExecuteSchema }),
|
setVariable: z.object({ scriptToExecute: scriptToExecuteSchema }),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
.or(
|
||||||
|
z.object({
|
||||||
|
streamOpenAiChatCompletion: z.object({
|
||||||
|
messages: z.array(
|
||||||
|
chatCompletionMessageSchema.pick({ content: true, role: true })
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
export const chatReplySchema = z.object({
|
export const chatReplySchema = z.object({
|
||||||
|
|||||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@@ -502,6 +502,9 @@ importers:
|
|||||||
'@dqbd/tiktoken':
|
'@dqbd/tiktoken':
|
||||||
specifier: ^1.0.7
|
specifier: ^1.0.7
|
||||||
version: 1.0.7
|
version: 1.0.7
|
||||||
|
'@planetscale/database':
|
||||||
|
specifier: ^1.7.0
|
||||||
|
version: 1.7.0
|
||||||
'@sentry/nextjs':
|
'@sentry/nextjs':
|
||||||
specifier: 7.50.0
|
specifier: 7.50.0
|
||||||
version: 7.50.0(next@13.3.4)(react@18.2.0)
|
version: 7.50.0(next@13.3.4)(react@18.2.0)
|
||||||
@@ -526,6 +529,9 @@ importers:
|
|||||||
cors:
|
cors:
|
||||||
specifier: 2.8.5
|
specifier: 2.8.5
|
||||||
version: 2.8.5
|
version: 2.8.5
|
||||||
|
eventsource-parser:
|
||||||
|
specifier: ^1.0.0
|
||||||
|
version: 1.0.0
|
||||||
google-spreadsheet:
|
google-spreadsheet:
|
||||||
specifier: 3.3.0
|
specifier: 3.3.0
|
||||||
version: 3.3.0
|
version: 3.3.0
|
||||||
@@ -7569,6 +7575,11 @@ packages:
|
|||||||
tslib: 2.5.0
|
tslib: 2.5.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@planetscale/database@1.7.0:
|
||||||
|
resolution: {integrity: sha512-lWR6biXChUyQnxsT4RT1CIeR3ZJvwTQXiQ+158MnY3VjLwjHEGakDzdH9kwUGPk6CHvu6UeqRXp1DgUOVHJFTw==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@playwright/test@1.33.0:
|
/@playwright/test@1.33.0:
|
||||||
resolution: {integrity: sha512-YunBa2mE7Hq4CfPkGzQRK916a4tuZoVx/EpLjeWlTVOnD4S2+fdaQZE0LJkbfhN5FTSKNLdcl7MoT5XB37bTkg==}
|
resolution: {integrity: sha512-YunBa2mE7Hq4CfPkGzQRK916a4tuZoVx/EpLjeWlTVOnD4S2+fdaQZE0LJkbfhN5FTSKNLdcl7MoT5XB37bTkg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -13627,6 +13638,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||||
engines: {node: '>=0.8.x'}
|
engines: {node: '>=0.8.x'}
|
||||||
|
|
||||||
|
/eventsource-parser@1.0.0:
|
||||||
|
resolution: {integrity: sha512-9jgfSCa3dmEme2ES3mPByGXfgZ87VbP97tng1G2nWwWx6bV2nYxm2AWCrbQjXToSe+yYlqaZNtxffR9IeQr95g==}
|
||||||
|
engines: {node: '>=14.18'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/evp_bytestokey@1.0.3:
|
/evp_bytestokey@1.0.3:
|
||||||
resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==}
|
resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user