2
0

(setVariable) Add Transcription system var (#1507)

Closes #1484
This commit is contained in:
Baptiste Arnaud
2024-05-15 14:24:55 +02:00
committed by GitHub
parent ec7ff8d9ca
commit 40f21203b5
102 changed files with 2911 additions and 986 deletions

View File

@ -0,0 +1,397 @@
import {
Answer,
ContinueChatResponse,
Edge,
Group,
InputBlock,
TypebotInSession,
Variable,
} from '@typebot.io/schemas'
import { SetVariableHistoryItem } from '@typebot.io/schemas/features/result'
import { isBubbleBlock, isInputBlock } from '@typebot.io/schemas/helpers'
import { BubbleBlockType } from '@typebot.io/schemas/features/blocks/bubbles/constants'
import { convertRichTextToMarkdown } from '@typebot.io/lib/markdown/convertRichTextToMarkdown'
import { LogicBlockType } from '@typebot.io/schemas/features/blocks/logic/constants'
import { createId } from '@typebot.io/lib/createId'
import { executeCondition } from './executeCondition'
import {
parseBubbleBlock,
BubbleBlockWithDefinedContent,
} from '../bot-engine/parseBubbleBlock'
import { defaultChoiceInputOptions } from '@typebot.io/schemas/features/blocks/inputs/choice/constants'
import { defaultPictureChoiceOptions } from '@typebot.io/schemas/features/blocks/inputs/pictureChoice/constants'
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
import { parseVariables } from '@typebot.io/variables/parseVariables'
type TranscriptMessage =
| {
role: 'bot' | 'user'
} & (
| { type: 'text'; text: string }
| { type: 'image'; image: string }
| { type: 'video'; video: string }
| { type: 'audio'; audio: string }
)
export const parseTranscriptMessageText = (
message: TranscriptMessage
): string => {
switch (message.type) {
case 'text':
return message.text
case 'image':
return message.image
case 'video':
return message.video
case 'audio':
return message.audio
}
}
export const computeResultTranscript = ({
typebot,
answers,
setVariableHistory,
visitedEdges,
stopAtBlockId,
}: {
typebot: TypebotInSession
answers: Pick<Answer, 'blockId' | 'content'>[]
setVariableHistory: Pick<
SetVariableHistoryItem,
'blockId' | 'variableId' | 'value'
>[]
visitedEdges: string[]
stopAtBlockId?: string
}): TranscriptMessage[] => {
const firstEdgeId = getFirstEdgeId(typebot)
if (!firstEdgeId) return []
const firstEdge = typebot.edges.find((edge) => edge.id === firstEdgeId)
if (!firstEdge) return []
const firstGroup = getNextGroup(typebot, firstEdgeId)
if (!firstGroup) return []
return executeGroup({
typebotsQueue: [{ typebot }],
nextGroup: firstGroup,
currentTranscript: [],
answers,
setVariableHistory,
visitedEdges,
stopAtBlockId,
})
}
const getFirstEdgeId = (typebot: TypebotInSession) => {
if (typebot.version === '6') return typebot.events?.[0].outgoingEdgeId
return typebot.groups.at(0)?.blocks.at(0)?.outgoingEdgeId
}
const getNextGroup = (
typebot: TypebotInSession,
edgeId: string
): { group: Group; blockIndex?: number } | undefined => {
const edge = typebot.edges.find((edge) => edge.id === edgeId)
if (!edge) return
const group = typebot.groups.find((group) => group.id === edge.to.groupId)
if (!group) return
const blockIndex = edge.to.blockId
? group.blocks.findIndex((block) => block.id === edge.to.blockId)
: undefined
return { group, blockIndex }
}
const executeGroup = ({
currentTranscript,
typebotsQueue,
answers,
nextGroup,
setVariableHistory,
visitedEdges,
stopAtBlockId,
}: {
currentTranscript: TranscriptMessage[]
nextGroup:
| {
group: Group
blockIndex?: number | undefined
}
| undefined
typebotsQueue: {
typebot: TypebotInSession
resumeEdgeId?: string
}[]
answers: Pick<Answer, 'blockId' | 'content'>[]
setVariableHistory: Pick<
SetVariableHistoryItem,
'blockId' | 'variableId' | 'value'
>[]
visitedEdges: string[]
stopAtBlockId?: string
}): TranscriptMessage[] => {
if (!nextGroup) return currentTranscript
for (const block of nextGroup?.group.blocks.slice(
nextGroup.blockIndex ?? 0
)) {
if (stopAtBlockId && block.id === stopAtBlockId) return currentTranscript
if (setVariableHistory.at(0)?.blockId === block.id)
typebotsQueue[0].typebot.variables = applySetVariable(
setVariableHistory.shift(),
typebotsQueue[0].typebot
)
let nextEdgeId = block.outgoingEdgeId
if (isBubbleBlock(block)) {
if (!block.content) continue
const parsedBubbleBlock = parseBubbleBlock(
block as BubbleBlockWithDefinedContent,
{
version: 2,
variables: typebotsQueue[0].typebot.variables,
typebotVersion: typebotsQueue[0].typebot.version,
}
)
const newMessage =
convertChatMessageToTranscriptMessage(parsedBubbleBlock)
if (newMessage) currentTranscript.push(newMessage)
} else if (isInputBlock(block)) {
const answer = answers.shift()
if (!answer) break
if (block.options?.variableId) {
const variable = typebotsQueue[0].typebot.variables.find(
(variable) => variable.id === block.options?.variableId
)
if (variable) {
typebotsQueue[0].typebot.variables =
typebotsQueue[0].typebot.variables.map((v) =>
v.id === variable.id ? { ...v, value: answer.content } : v
)
}
}
currentTranscript.push({
role: 'user',
type: 'text',
text: answer.content,
})
const outgoingEdge = getOutgoingEdgeId({
block,
answer: answer.content,
variables: typebotsQueue[0].typebot.variables,
})
if (outgoingEdge.isOffDefaultPath) visitedEdges.shift()
nextEdgeId = outgoingEdge.edgeId
} else if (block.type === LogicBlockType.CONDITION) {
const passedCondition = block.items.find(
(item) =>
item.content &&
executeCondition({
variables: typebotsQueue[0].typebot.variables,
condition: item.content,
})
)
if (passedCondition) {
visitedEdges.shift()
nextEdgeId = passedCondition.outgoingEdgeId
}
} else if (block.type === LogicBlockType.AB_TEST) {
nextEdgeId = visitedEdges.shift() ?? nextEdgeId
} else if (block.type === LogicBlockType.JUMP) {
if (!block.options?.groupId) continue
const groupToJumpTo = typebotsQueue[0].typebot.groups.find(
(group) => group.id === block.options?.groupId
)
const blockToJumpTo =
groupToJumpTo?.blocks.find((b) => b.id === block.options?.blockId) ??
groupToJumpTo?.blocks[0]
if (!blockToJumpTo) continue
const portalEdge = {
id: createId(),
from: { blockId: '', groupId: '' },
to: { groupId: block.options.groupId, blockId: blockToJumpTo.id },
}
typebotsQueue[0].typebot.edges.push(portalEdge)
visitedEdges.shift()
nextEdgeId = portalEdge.id
} else if (block.type === LogicBlockType.TYPEBOT_LINK) {
const isLinkingSameTypebot =
block.options &&
(block.options.typebotId === 'current' ||
block.options.typebotId === typebotsQueue[0].typebot.id)
if (!isLinkingSameTypebot) continue
let resumeEdge: Edge | undefined
if (!block.outgoingEdgeId) {
const currentBlockIndex = nextGroup.group.blocks.findIndex(
(b) => b.id === block.id
)
const nextBlockInGroup =
currentBlockIndex === -1
? undefined
: nextGroup.group.blocks.at(currentBlockIndex + 1)
if (nextBlockInGroup)
resumeEdge = {
id: createId(),
from: {
blockId: '',
},
to: {
groupId: nextGroup.group.id,
blockId: nextBlockInGroup.id,
},
}
}
return executeGroup({
typebotsQueue: [
{
typebot: typebotsQueue[0].typebot,
resumeEdgeId: resumeEdge ? resumeEdge.id : block.outgoingEdgeId,
},
{
typebot: resumeEdge
? {
...typebotsQueue[0].typebot,
edges: typebotsQueue[0].typebot.edges.concat([resumeEdge]),
}
: typebotsQueue[0].typebot,
},
],
answers,
setVariableHistory,
currentTranscript,
nextGroup,
visitedEdges,
stopAtBlockId,
})
}
if (nextEdgeId) {
const nextGroup = getNextGroup(typebotsQueue[0].typebot, nextEdgeId)
if (nextGroup) {
return executeGroup({
typebotsQueue,
answers,
setVariableHistory,
currentTranscript,
nextGroup,
visitedEdges,
stopAtBlockId,
})
}
}
}
if (typebotsQueue.length > 1 && typebotsQueue[0].resumeEdgeId) {
return executeGroup({
typebotsQueue: typebotsQueue.slice(1),
answers,
setVariableHistory,
currentTranscript,
nextGroup: getNextGroup(
typebotsQueue[1].typebot,
typebotsQueue[0].resumeEdgeId
),
visitedEdges: visitedEdges.slice(1),
stopAtBlockId,
})
}
return currentTranscript
}
const applySetVariable = (
setVariable:
| Pick<SetVariableHistoryItem, 'blockId' | 'variableId' | 'value'>
| undefined,
typebot: TypebotInSession
): Variable[] => {
if (!setVariable) return typebot.variables
const variable = typebot.variables.find(
(variable) => variable.id === setVariable.variableId
)
if (!variable) return typebot.variables
return typebot.variables.map((v) =>
v.id === variable.id ? { ...v, value: setVariable.value } : v
)
}
const convertChatMessageToTranscriptMessage = (
chatMessage: ContinueChatResponse['messages'][0]
): TranscriptMessage | null => {
switch (chatMessage.type) {
case BubbleBlockType.TEXT: {
if (!chatMessage.content.richText) return null
return {
role: 'bot',
type: 'text',
text: convertRichTextToMarkdown(chatMessage.content.richText),
}
}
case BubbleBlockType.IMAGE: {
if (!chatMessage.content.url) return null
return {
role: 'bot',
type: 'image',
image: chatMessage.content.url,
}
}
case BubbleBlockType.VIDEO: {
if (!chatMessage.content.url) return null
return {
role: 'bot',
type: 'video',
video: chatMessage.content.url,
}
}
case BubbleBlockType.AUDIO: {
if (!chatMessage.content.url) return null
return {
role: 'bot',
type: 'audio',
audio: chatMessage.content.url,
}
}
case 'custom-embed':
case BubbleBlockType.EMBED: {
return null
}
}
}
const getOutgoingEdgeId = ({
block,
answer,
variables,
}: {
block: InputBlock
answer: string | undefined
variables: Variable[]
}): { edgeId: string | undefined; isOffDefaultPath: boolean } => {
if (
block.type === InputBlockType.CHOICE &&
!(
block.options?.isMultipleChoice ??
defaultChoiceInputOptions.isMultipleChoice
) &&
answer
) {
const matchedItem = block.items.find(
(item) =>
parseVariables(variables)(item.content).normalize() ===
answer.normalize()
)
if (matchedItem?.outgoingEdgeId)
return { edgeId: matchedItem.outgoingEdgeId, isOffDefaultPath: true }
}
if (
block.type === InputBlockType.PICTURE_CHOICE &&
!(
block.options?.isMultipleChoice ??
defaultPictureChoiceOptions.isMultipleChoice
) &&
answer
) {
const matchedItem = block.items.find(
(item) =>
parseVariables(variables)(item.title).normalize() === answer.normalize()
)
if (matchedItem?.outgoingEdgeId)
return { edgeId: matchedItem.outgoingEdgeId, isOffDefaultPath: true }
}
return { edgeId: block.outgoingEdgeId, isOffDefaultPath: false }
}

View File

@ -0,0 +1,204 @@
import { isNotDefined, isDefined } from '@typebot.io/lib'
import { Comparison, Condition, Variable } from '@typebot.io/schemas'
import { findUniqueVariableValue } from '@typebot.io/variables/findUniqueVariableValue'
import { parseVariables } from '@typebot.io/variables/parseVariables'
import {
LogicalOperator,
ComparisonOperators,
defaultConditionItemContent,
} from '@typebot.io/schemas/features/blocks/logic/condition/constants'
type Props = {
condition: Condition
variables: Variable[]
}
export const executeCondition = ({ condition, variables }: Props): boolean => {
if (!condition.comparisons) return false
return (condition.logicalOperator ??
defaultConditionItemContent.logicalOperator) === LogicalOperator.AND
? condition.comparisons.every(executeComparison(variables))
: condition.comparisons.some(executeComparison(variables))
}
const executeComparison =
(variables: Variable[]) =>
(comparison: Comparison): boolean => {
if (!comparison?.variableId) return false
const inputValue =
variables.find((v) => v.id === comparison.variableId)?.value ?? null
const value =
comparison.value === 'undefined' || comparison.value === 'null'
? null
: findUniqueVariableValue(variables)(comparison.value) ??
parseVariables(variables)(comparison.value)
if (isNotDefined(comparison.comparisonOperator)) return false
switch (comparison.comparisonOperator) {
case ComparisonOperators.CONTAINS: {
if (Array.isArray(inputValue)) {
const equal = (a: string | null, b: string | null) => {
if (typeof a === 'string' && typeof b === 'string')
return a.normalize() === b.normalize()
return a !== b
}
return compare(equal, inputValue, value, 'some')
}
const contains = (a: string | null, b: string | null) => {
if (b === '' || !b || !a) return false
return a
.toLowerCase()
.trim()
.normalize()
.includes(b.toLowerCase().trim().normalize())
}
return compare(contains, inputValue, value, 'some')
}
case ComparisonOperators.NOT_CONTAINS: {
if (Array.isArray(inputValue)) {
const notEqual = (a: string | null, b: string | null) => {
if (typeof a === 'string' && typeof b === 'string')
return a.normalize() !== b.normalize()
return a !== b
}
return compare(notEqual, inputValue, value)
}
const notContains = (a: string | null, b: string | null) => {
if (b === '' || !b || !a) return true
return !a
.toLowerCase()
.trim()
.normalize()
.includes(b.toLowerCase().trim().normalize())
}
return compare(notContains, inputValue, value)
}
case ComparisonOperators.EQUAL: {
return compare(
(a, b) => {
if (typeof a === 'string' && typeof b === 'string')
return a.normalize() === b.normalize()
return a === b
},
inputValue,
value
)
}
case ComparisonOperators.NOT_EQUAL: {
return compare(
(a, b) => {
if (typeof a === 'string' && typeof b === 'string')
return a.normalize() !== b.normalize()
return a !== b
},
inputValue,
value
)
}
case ComparisonOperators.GREATER: {
if (isNotDefined(inputValue) || isNotDefined(value)) return false
if (typeof inputValue === 'string') {
if (typeof value === 'string')
return parseDateOrNumber(inputValue) > parseDateOrNumber(value)
return Number(inputValue) > value.length
}
if (typeof value === 'string') return inputValue.length > Number(value)
return inputValue.length > value.length
}
case ComparisonOperators.LESS: {
if (isNotDefined(inputValue) || isNotDefined(value)) return false
if (typeof inputValue === 'string') {
if (typeof value === 'string')
return parseDateOrNumber(inputValue) < parseDateOrNumber(value)
return Number(inputValue) < value.length
}
if (typeof value === 'string') return inputValue.length < Number(value)
return inputValue.length < value.length
}
case ComparisonOperators.IS_SET: {
return isDefined(inputValue) && inputValue.length > 0
}
case ComparisonOperators.IS_EMPTY: {
return isNotDefined(inputValue) || inputValue.length === 0
}
case ComparisonOperators.STARTS_WITH: {
const startsWith = (a: string | null, b: string | null) => {
if (b === '' || !b || !a) return false
return a
.toLowerCase()
.trim()
.normalize()
.startsWith(b.toLowerCase().trim().normalize())
}
return compare(startsWith, inputValue, value)
}
case ComparisonOperators.ENDS_WITH: {
const endsWith = (a: string | null, b: string | null) => {
if (b === '' || !b || !a) return false
return a
.toLowerCase()
.trim()
.normalize()
.endsWith(b.toLowerCase().trim().normalize())
}
return compare(endsWith, inputValue, value)
}
case ComparisonOperators.MATCHES_REGEX: {
const matchesRegex = (a: string | null, b: string | null) => {
if (b === '' || !b || !a) return false
const regex = preprocessRegex(b)
if (!regex) return false
return new RegExp(regex.pattern, regex.flags).test(a)
}
return compare(matchesRegex, inputValue, value, 'some')
}
case ComparisonOperators.NOT_MATCH_REGEX: {
const matchesRegex = (a: string | null, b: string | null) => {
if (b === '' || !b || !a) return false
const regex = preprocessRegex(b)
if (!regex) return true
return !new RegExp(regex.pattern, regex.flags).test(a)
}
return compare(matchesRegex, inputValue, value)
}
}
}
const compare = (
compareStrings: (a: string | null, b: string | null) => boolean,
a: Exclude<Variable['value'], undefined>,
b: Exclude<Variable['value'], undefined>,
type: 'every' | 'some' = 'every'
): boolean => {
if (!a || typeof a === 'string') {
if (!b || typeof b === 'string') return compareStrings(a, b)
return type === 'every'
? b.every((b) => compareStrings(a, b))
: b.some((b) => compareStrings(a, b))
}
if (!b || typeof b === 'string') {
return type === 'every'
? a.every((a) => compareStrings(a, b))
: a.some((a) => compareStrings(a, b))
}
if (type === 'every')
return a.every((a) => b.every((b) => compareStrings(a, b)))
return a.some((a) => b.some((b) => compareStrings(a, b)))
}
const parseDateOrNumber = (value: string): number => {
const parsed = Number(value)
if (isNaN(parsed)) {
const time = Date.parse(value)
return time
}
return parsed
}
const preprocessRegex = (regex: string) => {
const regexWithFlags = regex.match(/\/(.+)\/([gimuy]*)$/)
if (regexWithFlags)
return { pattern: regexWithFlags[1], flags: regexWithFlags[2] }
return { pattern: regex }
}

View File

@ -0,0 +1,18 @@
{
"name": "@typebot.io/logic",
"version": "1.0.0",
"description": "",
"scripts": {},
"keywords": [],
"author": "Baptiste Arnaud",
"license": "ISC",
"dependencies": {
"@typebot.io/schemas": "workspace:*",
"@typebot.io/lib": "workspace:*",
"@typebot.io/variables": "workspace:*"
},
"devDependencies": {
"@typebot.io/tsconfig": "workspace:*",
"@udecode/plate-common": "30.4.5"
}
}

View File

@ -0,0 +1,12 @@
{
"extends": "@typebot.io/tsconfig/base.json",
"include": [
"**/*.ts",
"../variables/parseVariables.ts",
"../bot-engine/parseBubbleBlock.ts"
],
"exclude": ["node_modules"],
"compilerOptions": {
"lib": ["ES2021", "DOM"]
}
}