✨ Automatically parse markdown from variables in text bubbles
Closes #539
This commit is contained in:
@ -11,6 +11,7 @@ import {
|
|||||||
} from '@typebot.io/schemas'
|
} from '@typebot.io/schemas'
|
||||||
import {
|
import {
|
||||||
isBubbleBlock,
|
isBubbleBlock,
|
||||||
|
isEmpty,
|
||||||
isInputBlock,
|
isInputBlock,
|
||||||
isIntegrationBlock,
|
isIntegrationBlock,
|
||||||
isLogicBlock,
|
isLogicBlock,
|
||||||
@ -26,6 +27,12 @@ import { getPrefilledInputValue } from './getPrefilledValue'
|
|||||||
import { parseDateInput } from './blocks/inputs/date/parseDateInput'
|
import { parseDateInput } from './blocks/inputs/date/parseDateInput'
|
||||||
import { deepParseVariables } from './variables/deepParseVariables'
|
import { deepParseVariables } from './variables/deepParseVariables'
|
||||||
import { parseVideoUrl } from '@typebot.io/lib/parseVideoUrl'
|
import { parseVideoUrl } from '@typebot.io/lib/parseVideoUrl'
|
||||||
|
import { TDescendant, createPlateEditor } from '@udecode/plate-common'
|
||||||
|
import {
|
||||||
|
createDeserializeMdPlugin,
|
||||||
|
deserializeMd,
|
||||||
|
} from '@udecode/plate-serializer-md'
|
||||||
|
import { getVariablesToParseInfoInText } from './variables/parseVariables'
|
||||||
|
|
||||||
export const executeGroup =
|
export const executeGroup =
|
||||||
(
|
(
|
||||||
@ -158,12 +165,19 @@ const parseBubbleBlock =
|
|||||||
(variables: Variable[]) =>
|
(variables: Variable[]) =>
|
||||||
(block: BubbleBlock): ChatReply['messages'][0] => {
|
(block: BubbleBlock): ChatReply['messages'][0] => {
|
||||||
switch (block.type) {
|
switch (block.type) {
|
||||||
case BubbleBlockType.TEXT:
|
case BubbleBlockType.TEXT: {
|
||||||
return deepParseVariables(
|
return {
|
||||||
variables,
|
...block,
|
||||||
{},
|
content: {
|
||||||
{ takeLatestIfList: true }
|
...block.content,
|
||||||
)(block)
|
richText: parseVariablesInRichText(
|
||||||
|
block.content.richText,
|
||||||
|
variables
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case BubbleBlockType.EMBED: {
|
case BubbleBlockType.EMBED: {
|
||||||
const message = deepParseVariables(variables)(block)
|
const message = deepParseVariables(variables)(block)
|
||||||
return {
|
return {
|
||||||
@ -189,6 +203,82 @@ const parseBubbleBlock =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseVariablesInRichText = (
|
||||||
|
elements: TDescendant[],
|
||||||
|
variables: Variable[]
|
||||||
|
): TDescendant[] => {
|
||||||
|
const parsedElements: TDescendant[] = []
|
||||||
|
for (const element of elements) {
|
||||||
|
if ('text' in element) {
|
||||||
|
const text = element.text as string
|
||||||
|
if (isEmpty(text)) {
|
||||||
|
parsedElements.push(element)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const variablesInText = getVariablesToParseInfoInText(text, variables)
|
||||||
|
if (variablesInText.length === 0) {
|
||||||
|
parsedElements.push(element)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for (const variableInText of variablesInText) {
|
||||||
|
const textBeforeVariable = text.substring(0, variableInText.startIndex)
|
||||||
|
const textAfterVariable = text.substring(variableInText.endIndex)
|
||||||
|
const isStandaloneElement =
|
||||||
|
isEmpty(textBeforeVariable) && isEmpty(textAfterVariable)
|
||||||
|
const variableElements = convertMarkdownToRichText(
|
||||||
|
isStandaloneElement
|
||||||
|
? variableInText.value
|
||||||
|
: variableInText.value.replace(/[\n]+/g, ' ')
|
||||||
|
)
|
||||||
|
if (isStandaloneElement) {
|
||||||
|
parsedElements.push(...variableElements)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const children: TDescendant[] = []
|
||||||
|
if (isNotEmpty(textBeforeVariable))
|
||||||
|
children.push({
|
||||||
|
text: textBeforeVariable,
|
||||||
|
})
|
||||||
|
children.push({
|
||||||
|
type: 'inline-variable',
|
||||||
|
children: variableElements,
|
||||||
|
})
|
||||||
|
if (isNotEmpty(textAfterVariable))
|
||||||
|
children.push({
|
||||||
|
...element,
|
||||||
|
text: textAfterVariable,
|
||||||
|
})
|
||||||
|
parsedElements.push(...children)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const type =
|
||||||
|
element.children.length === 1 &&
|
||||||
|
'text' in element.children[0] &&
|
||||||
|
(element.children[0].text as string).startsWith('{{') &&
|
||||||
|
(element.children[0].text as string).endsWith('}}')
|
||||||
|
? 'variable'
|
||||||
|
: element.type
|
||||||
|
|
||||||
|
parsedElements.push({
|
||||||
|
...element,
|
||||||
|
type,
|
||||||
|
children: parseVariablesInRichText(
|
||||||
|
element.children as TDescendant[],
|
||||||
|
variables
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return parsedElements
|
||||||
|
}
|
||||||
|
|
||||||
|
const convertMarkdownToRichText = (text: string): TDescendant[] => {
|
||||||
|
const plugins = [createDeserializeMdPlugin()]
|
||||||
|
//@ts-ignore
|
||||||
|
return deserializeMd(createPlateEditor({ plugins }), text)
|
||||||
|
}
|
||||||
|
|
||||||
export const parseInput =
|
export const parseInput =
|
||||||
(state: SessionState) =>
|
(state: SessionState) =>
|
||||||
async (block: InputBlock): Promise<ChatReply['input']> => {
|
async (block: InputBlock): Promise<ChatReply['input']> => {
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
"@typebot.io/schemas": "workspace:*",
|
"@typebot.io/schemas": "workspace:*",
|
||||||
"@typebot.io/tsconfig": "workspace:*",
|
"@typebot.io/tsconfig": "workspace:*",
|
||||||
"@udecode/plate-common": "^21.1.5",
|
"@udecode/plate-common": "^21.1.5",
|
||||||
|
"@udecode/plate-serializer-md": "^24.4.0",
|
||||||
"ai": "2.1.32",
|
"ai": "2.1.32",
|
||||||
"chrono-node": "2.6.6",
|
"chrono-node": "2.6.6",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
|
@ -55,6 +55,35 @@ export const parseVariables =
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VariableToParseInformation = {
|
||||||
|
startIndex: number
|
||||||
|
endIndex: number
|
||||||
|
textToReplace: string
|
||||||
|
value: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getVariablesToParseInfoInText = (
|
||||||
|
text: string,
|
||||||
|
variables: Variable[]
|
||||||
|
): VariableToParseInformation[] => {
|
||||||
|
const pattern = /\{\{([^{}]+)\}\}|(\$)\{\{([^{}]+)\}\}/g
|
||||||
|
const variablesToParseInfo: VariableToParseInformation[] = []
|
||||||
|
let match
|
||||||
|
while ((match = pattern.exec(text)) !== null) {
|
||||||
|
const matchedVarName = match[1] ?? match[3]
|
||||||
|
const variable = variables.find((variable) => {
|
||||||
|
return matchedVarName === variable.name && isDefined(variable.value)
|
||||||
|
}) as VariableWithValue | undefined
|
||||||
|
variablesToParseInfo.push({
|
||||||
|
startIndex: match.index,
|
||||||
|
endIndex: match.index + match[0].length,
|
||||||
|
textToReplace: match[0],
|
||||||
|
value: safeStringify(variable?.value) ?? '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return variablesToParseInfo
|
||||||
|
}
|
||||||
|
|
||||||
const parseVariableValueInJson = (value: VariableWithValue['value']) => {
|
const parseVariableValueInJson = (value: VariableWithValue['value']) => {
|
||||||
const stringifiedValue = JSON.stringify(value)
|
const stringifiedValue = JSON.stringify(value)
|
||||||
if (typeof value === 'string') return stringifiedValue.slice(1, -1)
|
if (typeof value === 'string') return stringifiedValue.slice(1, -1)
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@typebot.io/js",
|
"name": "@typebot.io/js",
|
||||||
"version": "0.1.33",
|
"version": "0.1.34",
|
||||||
"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",
|
||||||
@ -14,7 +14,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@stripe/stripe-js": "1.54.1",
|
"@stripe/stripe-js": "1.54.1",
|
||||||
"@udecode/plate-common": "^21.1.5",
|
"@udecode/plate-common": "^21.1.5",
|
||||||
|
"dompurify": "^3.0.6",
|
||||||
"eventsource-parser": "^1.0.0",
|
"eventsource-parser": "^1.0.0",
|
||||||
|
"marked": "^9.0.3",
|
||||||
"solid-element": "1.7.1",
|
"solid-element": "1.7.1",
|
||||||
"solid-js": "1.7.8"
|
"solid-js": "1.7.8"
|
||||||
},
|
},
|
||||||
@ -24,11 +26,12 @@
|
|||||||
"@rollup/plugin-node-resolve": "15.1.0",
|
"@rollup/plugin-node-resolve": "15.1.0",
|
||||||
"@rollup/plugin-terser": "0.4.3",
|
"@rollup/plugin-terser": "0.4.3",
|
||||||
"@rollup/plugin-typescript": "11.1.2",
|
"@rollup/plugin-typescript": "11.1.2",
|
||||||
"@typebot.io/lib": "workspace:*",
|
"@typebot.io/bot-engine": "workspace:*",
|
||||||
"@typebot.io/env": "workspace:*",
|
"@typebot.io/env": "workspace:*",
|
||||||
|
"@typebot.io/lib": "workspace:*",
|
||||||
"@typebot.io/schemas": "workspace:*",
|
"@typebot.io/schemas": "workspace:*",
|
||||||
"@typebot.io/tsconfig": "workspace:*",
|
"@typebot.io/tsconfig": "workspace:*",
|
||||||
"@typebot.io/bot-engine": "workspace:*",
|
"@types/dompurify": "^3.0.3",
|
||||||
"autoprefixer": "10.4.14",
|
"autoprefixer": "10.4.14",
|
||||||
"babel-preset-solid": "1.7.7",
|
"babel-preset-solid": "1.7.7",
|
||||||
"clsx": "2.0.0",
|
"clsx": "2.0.0",
|
||||||
|
@ -94,14 +94,10 @@ textarea {
|
|||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slate-a {
|
a {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slate-html-container > div {
|
|
||||||
min-height: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slate-bold {
|
.slate-bold {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
@ -1,21 +1,32 @@
|
|||||||
import { streamingMessage } from '@/utils/streamingMessageSignal'
|
import { streamingMessage } from '@/utils/streamingMessageSignal'
|
||||||
import { createEffect, createSignal } from 'solid-js'
|
import { createEffect, createSignal } from 'solid-js'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import domPurify from 'dompurify'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
streamingMessageId: string
|
streamingMessageId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
marked.use({
|
||||||
|
renderer: {
|
||||||
|
link: (href, _title, text) => {
|
||||||
|
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
export const StreamingBubble = (props: Props) => {
|
export const StreamingBubble = (props: Props) => {
|
||||||
let ref: HTMLDivElement | undefined
|
|
||||||
const [content, setContent] = createSignal<string>('')
|
const [content, setContent] = createSignal<string>('')
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (streamingMessage()?.id === props.streamingMessageId)
|
if (streamingMessage()?.id === props.streamingMessageId)
|
||||||
setContent(streamingMessage()?.content ?? '')
|
setContent(
|
||||||
|
domPurify.sanitize(marked.parse(streamingMessage()?.content ?? ''))
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="flex flex-col animate-fade-in" ref={ref}>
|
<div class="flex flex-col animate-fade-in">
|
||||||
<div class="flex w-full items-center">
|
<div class="flex w-full items-center">
|
||||||
<div class="flex relative items-start typebot-host-bubble">
|
<div class="flex relative items-start typebot-host-bubble">
|
||||||
<div
|
<div
|
||||||
@ -28,11 +39,10 @@ export const StreamingBubble = (props: Props) => {
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
class={
|
class={
|
||||||
'overflow-hidden text-fade-in mx-4 my-2 whitespace-pre-wrap slate-html-container relative text-ellipsis opacity-100 h-full'
|
'flex flex-col overflow-hidden text-fade-in mx-4 my-2 relative text-ellipsis h-full gap-6'
|
||||||
}
|
}
|
||||||
>
|
innerHTML={content()}
|
||||||
{content()}
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { TypingBubble } from '@/components'
|
import { TypingBubble } from '@/components'
|
||||||
import type { TextBubbleContent, TypingEmulation } from '@typebot.io/schemas'
|
import type { TextBubbleContent, TypingEmulation } from '@typebot.io/schemas'
|
||||||
import { For, createSignal, onCleanup, onMount } from 'solid-js'
|
import { For, createSignal, onCleanup, onMount } from 'solid-js'
|
||||||
import { PlateBlock } from './plate/PlateBlock'
|
import { PlateElement } from './plate/PlateBlock'
|
||||||
import { computePlainText } from '../helpers/convertRichTextToPlainText'
|
import { computePlainText } from '../helpers/convertRichTextToPlainText'
|
||||||
import { clsx } from 'clsx'
|
import { clsx } from 'clsx'
|
||||||
import { isMobile } from '@/utils/isMobileSignal'
|
import { isMobile } from '@/utils/isMobileSignal'
|
||||||
@ -70,7 +70,7 @@ export const TextBubble = (props: Props) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<For each={props.content.richText}>
|
<For each={props.content.richText}>
|
||||||
{(element) => <PlateBlock element={element} />}
|
{(element) => <PlateElement element={element} />}
|
||||||
</For>
|
</For>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,30 +1,89 @@
|
|||||||
import type { TElement, TText, TDescendant } from '@udecode/plate-common'
|
import type { TElement, TText, TDescendant } from '@udecode/plate-common'
|
||||||
import { PlateText, PlateTextProps } from './PlateText'
|
import { PlateText, PlateTextProps } from './PlateText'
|
||||||
import { For, Match, Show, Switch } from 'solid-js'
|
import { For, Match, Switch, JSXElement } from 'solid-js'
|
||||||
|
import { isDefined } from '@typebot.io/lib/utils'
|
||||||
|
import clsx from 'clsx'
|
||||||
|
|
||||||
type Props = { element: TElement | TText }
|
type Props = {
|
||||||
|
element: TElement | TText
|
||||||
|
isUniqueChild?: boolean
|
||||||
|
inElement?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export const PlateBlock = (props: Props) => (
|
export const PlateElement = (props: Props) => (
|
||||||
<Show
|
<Switch>
|
||||||
when={!props.element.text}
|
<Match when={isDefined(props.element.text)}>
|
||||||
fallback={<PlateText {...(props.element as PlateTextProps)} />}
|
<PlateText
|
||||||
>
|
{...(props.element as PlateTextProps)}
|
||||||
<Switch
|
isUniqueChild={props.isUniqueChild ?? false}
|
||||||
fallback={
|
/>
|
||||||
<div>
|
</Match>
|
||||||
<For each={props.element.children as TDescendant[]}>
|
<Match when={props.element.type}>
|
||||||
{(child) => <PlateBlock element={child} />}
|
<Switch>
|
||||||
</For>
|
<Match when={props.element.type === 'a'}>
|
||||||
|
<a
|
||||||
|
href={props.element.url as string}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<For each={props.element.children as TDescendant[]}>
|
||||||
|
{(child) => (
|
||||||
|
<PlateElement
|
||||||
|
element={child}
|
||||||
|
isUniqueChild={
|
||||||
|
(props.element.children as TDescendant[])?.length === 1
|
||||||
|
}
|
||||||
|
inElement={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</a>
|
||||||
|
</Match>
|
||||||
|
<Match when={props.element.type !== 'a'}>
|
||||||
|
<ElementRoot
|
||||||
|
element={props.element as TElement}
|
||||||
|
inElement={props.inElement ?? false}
|
||||||
|
>
|
||||||
|
<For each={props.element.children as TDescendant[]}>
|
||||||
|
{(child) => (
|
||||||
|
<PlateElement
|
||||||
|
element={child}
|
||||||
|
isUniqueChild={
|
||||||
|
(props.element.children as TDescendant[])?.length === 1
|
||||||
|
}
|
||||||
|
inElement={true}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</ElementRoot>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
)
|
||||||
|
|
||||||
|
type ElementRootProps = {
|
||||||
|
element: TElement
|
||||||
|
inElement: boolean
|
||||||
|
children: JSXElement
|
||||||
|
}
|
||||||
|
|
||||||
|
const ElementRoot = (props: ElementRootProps) => {
|
||||||
|
return (
|
||||||
|
<Switch>
|
||||||
|
<Match when={props.inElement}>
|
||||||
|
<span data-element-type={props.element.type}>{props.children}</span>
|
||||||
|
</Match>
|
||||||
|
<Match when={!props.inElement}>
|
||||||
|
<div
|
||||||
|
data-element-type={props.element.type}
|
||||||
|
class={clsx(
|
||||||
|
props.element.type === 'variable' && 'flex flex-col gap-6'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
}
|
|
||||||
>
|
|
||||||
<Match when={props.element.type === 'a'}>
|
|
||||||
<a href={props.element.url as string} target="_blank" class="slate-a">
|
|
||||||
<For each={props.element.children as TDescendant[]}>
|
|
||||||
{(child) => <PlateBlock element={child} />}
|
|
||||||
</For>
|
|
||||||
</a>
|
|
||||||
</Match>
|
</Match>
|
||||||
</Switch>
|
</Switch>
|
||||||
</Show>
|
)
|
||||||
)
|
}
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
|
import { isEmpty } from '@typebot.io/lib'
|
||||||
import { Show } from 'solid-js'
|
import { Show } from 'solid-js'
|
||||||
|
|
||||||
export type PlateTextProps = {
|
export type PlateTextProps = {
|
||||||
text: string
|
text: string
|
||||||
|
isUniqueChild: boolean
|
||||||
bold?: boolean
|
bold?: boolean
|
||||||
italic?: boolean
|
italic?: boolean
|
||||||
underline?: boolean
|
underline?: boolean
|
||||||
@ -20,11 +22,10 @@ const computeClassNames = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PlateText = (props: PlateTextProps) => (
|
export const PlateText = (props: PlateTextProps) => (
|
||||||
<Show
|
<span class={computeClassNames(props.bold, props.italic, props.underline)}>
|
||||||
when={computeClassNames(props.bold, props.italic, props.underline)}
|
{props.text}
|
||||||
keyed
|
<Show when={props.isUniqueChild && isEmpty(props.text)}>
|
||||||
fallback={<>{props.text}</>}
|
<br />
|
||||||
>
|
</Show>
|
||||||
{(className) => <span class={className}>{props.text}</span>}
|
</span>
|
||||||
</Show>
|
|
||||||
)
|
)
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@typebot.io/nextjs",
|
"name": "@typebot.io/nextjs",
|
||||||
"version": "0.1.33",
|
"version": "0.1.34",
|
||||||
"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",
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@typebot.io/react",
|
"name": "@typebot.io/react",
|
||||||
"version": "0.1.33",
|
"version": "0.1.34",
|
||||||
"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",
|
||||||
|
877
pnpm-lock.yaml
generated
877
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user