-
Notifications
You must be signed in to change notification settings - Fork 95
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: effect and response extension support (DX-1100) (#115)
- Loading branch information
1 parent
36593c4
commit 6f195b3
Showing
17 changed files
with
443 additions
and
23 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,125 @@ | ||
<!DOCTYPE html> | ||
<html> | ||
|
||
<head> | ||
<title>Embedded Mode</title> | ||
<style> | ||
body { | ||
background-color: #f9f9f9; | ||
} | ||
|
||
#voiceflow-chat-frame { | ||
width: 100vw; | ||
height: 100vh; | ||
position: fixed; | ||
top: 0; | ||
left: 0; | ||
} | ||
|
||
#order-status { | ||
position: absolute; | ||
visibility: hidden; | ||
left: 50%; | ||
top: 8px; | ||
transform: translateX(-50%); | ||
padding: 8px; | ||
border-radius: 10px; | ||
background-color: orangered; | ||
z-index: 10; | ||
|
||
font-family: 'Open Sans', sans-serif; | ||
font-size: 20px; | ||
font-weight: 400; | ||
} | ||
</style> | ||
</head> | ||
|
||
<body> | ||
<span id="order-status" data-testid="status"></span> | ||
<div id="voiceflow-chat-frame"> | ||
</div> | ||
<template id="complex-form"> | ||
<form> | ||
<input name="name" placeholder="What is your name?" /> | ||
<fieldset> | ||
<legend>What kind of hair do you have?</legend> | ||
<div> | ||
<input type="radio" name="hair" id="straight" value="straight" checked /><label | ||
for="straight">Straight</label> | ||
</div> | ||
<div> | ||
<input type="radio" name="hair" id="curly" value="curly" /><label for="curly">Curly</label> | ||
</div> | ||
<div> | ||
<input type="radio" name="hair" id="wavy" value="wavy" /><label for="wavy">Wavy</label> | ||
</div> | ||
</fieldset> | ||
<button>submit</button> | ||
</form> | ||
</template> | ||
|
||
<script type="text/javascript"> | ||
(function (d, t) { | ||
var v = d.createElement(t), s = d.getElementsByTagName(t)[0]; | ||
v.onload = () => { | ||
window.voiceflow.chat.load({ | ||
verify: { projectID: 'projectID' }, | ||
render: { mode: 'embedded' }, | ||
autostart: true, | ||
assistant: { | ||
extensions: [ | ||
{ | ||
name: 'order_tracker', | ||
type: 'effect', | ||
match: ({ trace }) => trace.type === 'update_order_status', | ||
effect({ trace }) { | ||
const element = document.getElementById('order-status'); | ||
const status = trace.payload; | ||
|
||
element.style.visibility = 'visible'; | ||
element.innerText = status; | ||
} | ||
}, | ||
{ | ||
name: 'onboarding_form', | ||
type: 'response', | ||
match: ({ trace }) => trace.type === 'onboarding', | ||
render({ trace, element }) { | ||
const template = document.getElementById('complex-form').content.cloneNode(true); | ||
const id = 'onboarding-form-' + Date.now(); | ||
template.firstElementChild.id = id; | ||
|
||
element.appendChild(template); | ||
|
||
const form = element.querySelector(`#${id}`); | ||
form.addEventListener('submit', async (event) => { | ||
event.preventDefault(); | ||
|
||
await window.voiceflow.chat.interact({ | ||
type: 'submit', | ||
payload: { | ||
name: form.elements.name.value, | ||
hair: form.elements.hair.value | ||
} | ||
}); | ||
while (form.firstChild) { | ||
form.removeChild(form.firstChild); | ||
} | ||
|
||
const confirmation = document.createElement('em'); | ||
confirmation.innerText = `submitted ✅`; | ||
|
||
form.appendChild(confirmation); | ||
}); | ||
} | ||
} | ||
] | ||
} | ||
}); | ||
} | ||
v.src = "../dist/bundle.mjs"; v.type = "text/javascript"; s.parentNode.insertBefore(v, s); | ||
})(document, 'script'); | ||
</script> | ||
</body> | ||
|
||
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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' }); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
export const slateMessage = (text: string) => ({ | ||
type: 'text', | ||
payload: { | ||
slate: { | ||
id: text, | ||
content: [{ children: [{ text }] }], | ||
messageDelayMilliseconds: 100, | ||
}, | ||
message: text, | ||
delay: 100, | ||
}, | ||
}); |
40 changes: 40 additions & 0 deletions
40
packages/react-chat/src/components/SystemResponse/ExtensionMessage.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<ExtensionMessageProps> = ({ extension, trace }) => { | ||
const ref = useRef<HTMLSpanElement>(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 ( | ||
<Message from="system" className={`vfrc-message--extension-${extension.name}`}> | ||
<span ref={ref} /> | ||
</Message> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
28 changes: 28 additions & 0 deletions
28
packages/react-chat/src/contexts/RuntimeContext/traces/EffectExtensions.trace.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<RuntimeMessage, Trace.AnyTrace>[] => { | ||
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; | ||
}, | ||
})); | ||
}; |
Oops, something went wrong.