diff --git a/packages/react-chat/e2e/extensions.html b/packages/react-chat/e2e/extensions.html new file mode 100644 index 0000000000..d3f2815be7 --- /dev/null +++ b/packages/react-chat/e2e/extensions.html @@ -0,0 +1,125 @@ + + + + + Embedded Mode + + + + + +
+
+ + + + + + \ No newline at end of file diff --git a/packages/react-chat/e2e/extensions.spec.ts b/packages/react-chat/e2e/extensions.spec.ts new file mode 100644 index 0000000000..f157047620 --- /dev/null +++ b/packages/react-chat/e2e/extensions.spec.ts @@ -0,0 +1,113 @@ +/* eslint-disable sonarjs/no-duplicate-string */ +import { expect, test } from '@playwright/test'; + +import { slateMessage } from './utils'; + +const RUNTIME_URL = 'https://general-runtime.voiceflow.com/public/projectID/state/user/*/interact'; + +test('trigger effect extension on incoming trace', async ({ page }) => { + const systemMessages = ['Welcome to the pizza palace!', 'What kind of pizza do you want?', 'One cheese pizza coming right up']; + const userMessages = ['I want to order a pizza', 'Cheese please']; + const traceType = 'update_order_status'; + let count = 0; + + // eslint-disable-next-line consistent-return + await page.route(RUNTIME_URL, async (route) => { + count++; + + switch (count) { + case 1: + return route.fulfill({ + json: { + trace: [{ type: traceType, payload: 'idle' }, slateMessage(systemMessages[0])], + }, + }); + + case 2: + return route.fulfill({ + json: { + trace: [{ type: traceType, payload: 'in progress' }, slateMessage(systemMessages[1])], + }, + }); + + case 3: + return route.fulfill({ + json: { + trace: [{ type: traceType, payload: 'ordered' }, slateMessage(systemMessages[2])], + }, + }); + + default: + } + }); + + await page.goto('extensions'); + + const chat = page.locator('.vfrc-chat'); + await chat.waitFor({ state: 'visible' }); + expect(chat).toBeInViewport(); + + await page.locator('[data-testid="status"]', { hasText: 'idle' }).waitFor({ state: 'visible' }); + await page.locator('.vfrc-message', { hasText: systemMessages[0] }).waitFor({ state: 'visible' }); + + const input = page.locator('.vfrc-chat-input textarea'); + await input.waitFor({ state: 'visible' }); + await input.fill(userMessages[0]); + + const submit = page.locator('.vfrc-chat-input .vfrc-bubble'); + await submit.click(); + + await page.locator('.vfrc-message', { hasText: userMessages[0] }).waitFor({ state: 'visible' }); + await page.locator('.vfrc-message', { hasText: systemMessages[1] }).waitFor({ state: 'visible' }); + await page.locator('[data-testid="status"]', { hasText: 'in progress' }).waitFor({ state: 'visible' }); + + await input.fill(userMessages[1]); + await submit.click(); + + await page.locator('.vfrc-message', { hasText: userMessages[1] }).waitFor({ state: 'visible' }); + await page.locator('.vfrc-message', { hasText: systemMessages[2] }).waitFor({ state: 'visible' }); + await page.locator('[data-testid="status"]', { hasText: 'ordered' }).waitFor({ state: 'visible' }); +}); + +test('render response extension from incoming trace', async ({ page }) => { + let count = 0; + + await page.route(RUNTIME_URL, (route) => { + count++; + + switch (count) { + case 1: + return route.fulfill({ + json: { + trace: [slateMessage("Welcome to Sal's Salon! Tell me about yourself."), { type: 'onboarding' }], + }, + }); + case 2: + default: + expect(route.request().postDataJSON()).toEqual({ + action: { + type: 'submit', + payload: { name: 'Alex', hair: 'curly' }, + }, + }); + + return route.fulfill({ json: { trace: [] } }); + } + }); + + await page.goto('extensions'); + + const chat = page.locator('.vfrc-chat'); + await chat.waitFor({ state: 'visible' }); + expect(chat).toBeInViewport(); + + await page.locator('.vfrc-message').waitFor({ state: 'visible' }); + + const extensionMessage = page.locator('.vfrc-message--extension-onboarding_form'); + await extensionMessage.waitFor({ state: 'visible' }); + + await extensionMessage.locator('[name="name"]').fill('Alex'); + await extensionMessage.locator('[name="hair"][id="curly"]').click(); + await extensionMessage.getByRole('button').click(); + await page.locator('.vfrc-message--extension-onboarding_form', { hasText: `submitted ✅` }).waitFor({ state: 'visible' }); +}); diff --git a/packages/react-chat/e2e/utils.ts b/packages/react-chat/e2e/utils.ts new file mode 100644 index 0000000000..2308dc7bff --- /dev/null +++ b/packages/react-chat/e2e/utils.ts @@ -0,0 +1,12 @@ +export const slateMessage = (text: string) => ({ + type: 'text', + payload: { + slate: { + id: text, + content: [{ children: [{ text }] }], + messageDelayMilliseconds: 100, + }, + message: text, + delay: 100, + }, +}); diff --git a/packages/react-chat/src/components/SystemResponse/ExtensionMessage.tsx b/packages/react-chat/src/components/SystemResponse/ExtensionMessage.tsx new file mode 100644 index 0000000000..4711b258ed --- /dev/null +++ b/packages/react-chat/src/components/SystemResponse/ExtensionMessage.tsx @@ -0,0 +1,40 @@ +import { Trace } from '@voiceflow/base-types'; +import { useEffect, useRef } from 'react'; + +import { ResponseExtension } from '@/dtos/Extension.dto'; + +import Message from '../Message'; + +export interface ExtensionMessageProps { + extension: ResponseExtension; + trace: Trace.AnyTrace; +} + +export const ExtensionMessage: React.FC = ({ extension, trace }) => { + const ref = useRef(null); + + useEffect(() => { + try { + // eslint-disable-next-line xss/no-mixed-html + const unmount = extension.render?.({ trace, element: ref.current as HTMLElement }); + if (!unmount) return undefined; + + return () => { + try { + unmount?.(); + } catch (e) { + console.error(`Extension '${extension.name}' threw an error while unmounting: ${e}`); + } + }; + } catch (e) { + console.error(`Extension '${extension.name}' threw an error while mounting: ${e}`); + return undefined; + } + }, []); + + return ( + + + + ); +}; diff --git a/packages/react-chat/src/components/SystemResponse/SystemMessage.tsx b/packages/react-chat/src/components/SystemResponse/SystemMessage.tsx index e4e68d5fb6..8d0e637e30 100644 --- a/packages/react-chat/src/components/SystemResponse/SystemMessage.tsx +++ b/packages/react-chat/src/components/SystemResponse/SystemMessage.tsx @@ -12,6 +12,7 @@ import { RuntimeStateAPIContext } from '@/contexts'; import Feedback, { FeedbackProps } from '../Feedback'; import { MessageType } from './constants'; +import { ExtensionMessage } from './ExtensionMessage'; import EndState from './state/end'; import { Controls, List, MessageContainer } from './styled'; import { MessageProps } from './types'; @@ -68,6 +69,7 @@ const SystemMessage: React.FC = ({ avatar, feedback, timesta .with({ type: MessageType.CAROUSEL }, (props) => ( )) + .with({ type: MessageType.EXTENSION }, ({ payload }) => ) .otherwise(() => null)} {feedback && } diff --git a/packages/react-chat/src/components/SystemResponse/constants.ts b/packages/react-chat/src/components/SystemResponse/constants.ts index 9abd9104b3..35e496fd94 100644 --- a/packages/react-chat/src/components/SystemResponse/constants.ts +++ b/packages/react-chat/src/components/SystemResponse/constants.ts @@ -4,6 +4,7 @@ export enum MessageType { CARD = 'card', CAROUSEL = 'carousel', END = 'END', + EXTENSION = 'EXTENSION', } export const DEFAULT_MESSAGE_DELAY = 1000; diff --git a/packages/react-chat/src/components/SystemResponse/types.ts b/packages/react-chat/src/components/SystemResponse/types.ts index d503a25693..0f8590769f 100644 --- a/packages/react-chat/src/components/SystemResponse/types.ts +++ b/packages/react-chat/src/components/SystemResponse/types.ts @@ -1,6 +1,7 @@ -import { Text } from '@voiceflow/base-types'; +import { Text, Trace } from '@voiceflow/base-types'; import { CardProps } from '@/components/Card/types'; +import { ResponseExtension } from '@/dtos/Extension.dto'; import { StringifiedEnum } from '@/types/util'; import { MessageType } from './constants'; @@ -33,9 +34,24 @@ export interface EndMessage extends BaseMessageProps { type: StringifiedEnum; } +export interface ExtensionMessage extends BaseMessageProps { + type: StringifiedEnum; + payload: { + trace: Trace.AnyTrace; + extension: ResponseExtension; + }; +} + export interface CustomMessage extends BaseMessageProps { type: `custom_${string}`; payload: any; } -export type MessageProps = TextMessageProps | ImageMessageProps | CardMessageProps | CarouselMessageProps | EndMessage | CustomMessage; +export type MessageProps = + | TextMessageProps + | ImageMessageProps + | CardMessageProps + | CarouselMessageProps + | EndMessage + | ExtensionMessage + | CustomMessage; diff --git a/packages/react-chat/src/contexts/RuntimeContext/traces/EffectExtensions.trace.ts b/packages/react-chat/src/contexts/RuntimeContext/traces/EffectExtensions.trace.ts new file mode 100644 index 0000000000..123bae3369 --- /dev/null +++ b/packages/react-chat/src/contexts/RuntimeContext/traces/EffectExtensions.trace.ts @@ -0,0 +1,28 @@ +import { Trace } from '@voiceflow/base-types'; +import { TraceDeclaration } from '@voiceflow/sdk-runtime'; + +import { AnyExtension, EffectExtension, ExtensionType } from '@/dtos/Extension.dto'; + +import { RuntimeMessage } from '../messages'; + +export const EffectExtensions = (extensions: AnyExtension[]): TraceDeclaration[] => { + return extensions + .filter((extension): extension is EffectExtension => extension.type === ExtensionType.EFFECT) + .map((extension) => ({ + canHandle: (trace) => extension.match({ trace }), + + handle: ({ context }, trace) => { + // NOTE: this promise is intentionally left unhandled + // we just want to capture and raise any errors thrown + (async () => { + try { + await extension.effect?.({ trace }); + } catch (e) { + console.error(`Extension '${extension.name}' threw an error: ${e}`); + } + })(); + + return context; + }, + })); +}; diff --git a/packages/react-chat/src/contexts/RuntimeContext/traces/NoReply.trace.ts b/packages/react-chat/src/contexts/RuntimeContext/traces/NoReply.trace.ts new file mode 100644 index 0000000000..c937bbd2f2 --- /dev/null +++ b/packages/react-chat/src/contexts/RuntimeContext/traces/NoReply.trace.ts @@ -0,0 +1,21 @@ +import { Trace } from '@voiceflow/base-types'; +import { ActionType, TraceDeclaration } from '@voiceflow/sdk-runtime'; + +import { DEFAULT_MESSAGE_DELAY } from '@/components/SystemResponse/constants'; + +import { RuntimeMessage } from '../messages'; + +export const NoReply = (callback: (timeout: number) => void): TraceDeclaration => ({ + canHandle: ({ type }) => type === ActionType.NO_REPLY, + handle: ({ context }, trace: Trace.NoReplyTrace) => { + if (trace.payload?.timeout) { + // messages take 1 second to animate in, on top of the delay + const messageDelays = context.messages.reduce((acc, message) => acc + (message.delay ?? 1000) + DEFAULT_MESSAGE_DELAY, 0); + const timeout = trace.payload.timeout * 1000 + messageDelays; + + // eslint-disable-next-line callback-return + callback(timeout); + } + return context; + }, +}); diff --git a/packages/react-chat/src/contexts/RuntimeContext/traces/ResponseExtensions.trace.ts b/packages/react-chat/src/contexts/RuntimeContext/traces/ResponseExtensions.trace.ts new file mode 100644 index 0000000000..3faef73cae --- /dev/null +++ b/packages/react-chat/src/contexts/RuntimeContext/traces/ResponseExtensions.trace.ts @@ -0,0 +1,21 @@ +import { Trace } from '@voiceflow/base-types'; +import { TraceDeclaration } from '@voiceflow/sdk-runtime'; + +import { MessageType } from '@/components/SystemResponse/constants'; +import { AnyExtension, ExtensionType, ResponseExtension } from '@/dtos/Extension.dto'; + +import { RuntimeMessage } from '../messages'; + +export const ResponseExtensions = (extensions: AnyExtension[]): TraceDeclaration[] => { + return extensions + .filter((extension): extension is ResponseExtension => extension.type === ExtensionType.RESPONSE) + .map((extension) => ({ + canHandle: (trace) => extension.match({ trace }), + + handle: ({ context }, trace) => { + context.messages.push({ type: MessageType.EXTENSION, payload: { trace, extension } }); + + return context; + }, + })); +}; diff --git a/packages/react-chat/src/contexts/RuntimeContext/useRuntimeState.ts b/packages/react-chat/src/contexts/RuntimeContext/useRuntimeState.ts index a6dcabac6e..d67bafe9f1 100644 --- a/packages/react-chat/src/contexts/RuntimeContext/useRuntimeState.ts +++ b/packages/react-chat/src/contexts/RuntimeContext/useRuntimeState.ts @@ -1,12 +1,9 @@ import { BaseRequest } from '@voiceflow/base-types'; import { isTextRequest } from '@voiceflow/base-types/build/cjs/request'; -import { ActionType, Trace, TraceDeclaration } from '@voiceflow/sdk-runtime'; import cuid from 'cuid'; import { useState } from 'react'; import { SendMessage, SessionOptions, SessionStatus } from '@/common'; -import { DEFAULT_MESSAGE_DELAY } from '@/components/SystemResponse/constants'; -import type { RuntimeMessage } from '@/contexts/RuntimeContext/messages'; import { AssistantOptions } from '@/dtos/AssistantOptions.dto'; import { ChatConfig } from '@/dtos/ChatConfig.dto'; import { useStateRef } from '@/hooks/useStateRef'; @@ -15,6 +12,9 @@ import { handleActions } from '@/utils/actions'; import { broadcast, BroadcastType } from '@/utils/broadcast'; import { getSession, saveSession } from '@/utils/session'; +import { EffectExtensions } from './traces/EffectExtensions.trace'; +import { NoReply } from './traces/NoReply.trace'; +import { ResponseExtensions } from './traces/ResponseExtensions.trace'; import { useNoReply } from './useNoReply'; import { createContext, useRuntimeAPI } from './useRuntimeAPI'; @@ -41,21 +41,11 @@ export const useRuntimeState = ({ assistant, config }: Settings) => { const [indicator, setIndicator] = useState(false); const { clearNoReplyTimeout, setNoReplyTimeout } = useNoReply(() => ({ interact, isStatus })); - const noReplyHandler: TraceDeclaration = { - canHandle: ({ type }) => type === ActionType.NO_REPLY, - handle: ({ context }, trace: Trace.NoReplyTrace) => { - if (trace.payload?.timeout) { - // messages take 1 second to animate in, on top of the delay - const messageDelays = context.messages.reduce((acc, message) => acc + (message.delay ?? 1000) + DEFAULT_MESSAGE_DELAY, 0); - const timeout = trace.payload.timeout * 1000 + messageDelays; - - setNoReplyTimeout(timeout); - } - return context; - }, - }; - - const runtime = useRuntimeAPI({ ...config, ...session, traceHandlers: [noReplyHandler] }); + const runtime = useRuntimeAPI({ + ...config, + ...session, + traceHandlers: [NoReply(setNoReplyTimeout), ...EffectExtensions(assistant.extensions), ...ResponseExtensions(assistant.extensions)], + }); // status management const setStatus = (status: SessionStatus) => { diff --git a/packages/react-chat/src/dtos/AssistantOptions.dto.ts b/packages/react-chat/src/dtos/AssistantOptions.dto.ts index 80420d9569..ccea0e507a 100644 --- a/packages/react-chat/src/dtos/AssistantOptions.dto.ts +++ b/packages/react-chat/src/dtos/AssistantOptions.dto.ts @@ -3,6 +3,8 @@ import { z } from 'zod'; import { ChatPersistence, ChatPosition } from '@/common'; import { PRIMARY } from '@/styles/color'; +import { AnyExtension } from './Extension.dto'; + export const DEFAULT_AVATAR = 'https://cdn.voiceflow.com/assets/logo.png'; export type AssistantOptions = z.infer; @@ -28,5 +30,7 @@ export const AssistantOptions = z bottom: z.number().default(30), }) .default({}), + + extensions: AnyExtension.array().default([]), }) .default({}); diff --git a/packages/react-chat/src/dtos/Extension.dto.ts b/packages/react-chat/src/dtos/Extension.dto.ts new file mode 100644 index 0000000000..523dd568e3 --- /dev/null +++ b/packages/react-chat/src/dtos/Extension.dto.ts @@ -0,0 +1,34 @@ +import { Trace } from '@voiceflow/base-types'; +import { z } from 'zod'; + +export enum ExtensionType { + EFFECT = 'effect', + RESPONSE = 'response', +} + +export type EffectExtension = z.infer; +export type ResponseExtension = z.infer; +export type AnyExtension = z.infer; + +const Extension = (type: Type) => + z.object({ + name: z.string(), + type: z.literal(type), + match: z.function().transform((f) => f as (context: { trace: Trace.AnyTrace }) => boolean), + }); + +export const EffectExtension = Extension(ExtensionType.EFFECT).extend({ + effect: z + .function() + .transform((f) => f as (context: { trace: Trace.AnyTrace }) => Promise | void) + .optional(), +}); + +export const ResponseExtension = Extension(ExtensionType.RESPONSE).extend({ + render: z + .function() + .transform((f) => f as (context: { trace: Trace.AnyTrace; element: HTMLElement }) => (() => void) | void) + .optional(), +}); + +export const AnyExtension = z.discriminatedUnion('type', [EffectExtension, ResponseExtension]); diff --git a/packages/react-chat/src/utils/assistant.test.ts b/packages/react-chat/src/utils/assistant.test.ts index 4339699fc2..d52bedcc16 100644 --- a/packages/react-chat/src/utils/assistant.test.ts +++ b/packages/react-chat/src/utils/assistant.test.ts @@ -4,6 +4,7 @@ import { vi } from 'vitest'; import { ChatPersistence, ChatPosition } from '@/common'; import { DEFAULT_AVATAR, RawAssistantOptions } from '@/dtos/AssistantOptions.dto'; import { ChatConfig } from '@/dtos/ChatConfig.dto'; +import { ExtensionType } from '@/dtos/Extension.dto'; import { PRIMARY } from '@/styles'; import { mergeAssistantOptions } from './assistant'; @@ -36,6 +37,7 @@ describe('assistant utils', () => { side: 100, bottom: 100, }, + extensions: [{ name: 'remote_extension', type: ExtensionType.EFFECT, match: () => false }], }; it('should fallback to default options when not configured', async () => { @@ -55,6 +57,7 @@ describe('assistant utils', () => { side: 30, bottom: 30, }, + extensions: [], }); }); @@ -64,7 +67,10 @@ describe('assistant utils', () => { const merged = await mergeAssistantOptions(config, undefined); - expect(merged).toEqual(remoteOptions); + expect(merged).toEqual({ + ...remoteOptions, + extensions: [expect.objectContaining({ name: 'remote_extension', type: ExtensionType.EFFECT })], + }); }); it('should prioritize local options over remote options (with some exceptions)', async () => { @@ -82,6 +88,7 @@ describe('assistant utils', () => { side: 150, bottom: 150, }, + extensions: [{ name: 'local_extension', type: ExtensionType.EFFECT, match: () => false }], // setting these locally should have no effect watermark: !remoteOptions.watermark, @@ -94,6 +101,10 @@ describe('assistant utils', () => { expect(merged).toEqual({ ...localOptions, + extensions: [ + expect.objectContaining({ name: 'remote_extension', type: ExtensionType.EFFECT }), + expect.objectContaining({ name: 'local_extension', type: ExtensionType.EFFECT }), + ], // verify these setting have not changed from what is remote watermark: remoteOptions.watermark, @@ -126,6 +137,7 @@ describe('assistant utils', () => { side: 100, bottom: 100, }, + extensions: [], }); }); }); diff --git a/packages/react-chat/src/utils/assistant.ts b/packages/react-chat/src/utils/assistant.ts index 4f78ad8f78..107cc1d97f 100644 --- a/packages/react-chat/src/utils/assistant.ts +++ b/packages/react-chat/src/utils/assistant.ts @@ -28,5 +28,6 @@ export const mergeAssistantOptions = async (config: ChatConfig, overrides: RawAs ...publishing?.spacing, ...overrides?.spacing, }, + extensions: [...(publishing?.extensions ?? []), ...(overrides?.extensions ?? [])], }); }; diff --git a/packages/react-chat/src/utils/chat.ts b/packages/react-chat/src/utils/chat.ts index 889e441c35..4533c6ab0b 100644 --- a/packages/react-chat/src/utils/chat.ts +++ b/packages/react-chat/src/utils/chat.ts @@ -1,5 +1,5 @@ export const createPlaceholderMethods = (createMessage: (method: string) => string): Omit => { - const noopWarn = (method: string) => () => console.warn(createMessage(method)); + const noopWarn = (method: string) => (): any => console.warn(createMessage(method)); return { open: noopWarn('open'), diff --git a/packages/react-chat/typings/global.d.ts b/packages/react-chat/typings/global.d.ts index 1eb8c3f282..36d654bbbc 100644 --- a/packages/react-chat/typings/global.d.ts +++ b/packages/react-chat/typings/global.d.ts @@ -10,7 +10,7 @@ declare global { load: (config: LoadConfig) => Promise; destroy: () => void; - interact: (action: RuntimeAction) => void; + interact: (action: RuntimeAction) => Promise; /* overlay mode controls */ open: VoidFunction;