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;