├── .circleci ├── config.yml └── continue-config.yml ├── .editorconfig ├── .github └── workflows │ ├── docs.yml │ └── update-pr-title.yaml ├── .gitignore ├── .husky ├── commit-msg ├── pre-commit └── pre-push ├── .prettierignore ├── .vscode └── settings.json ├── .yarn ├── plugins │ └── @yarnpkg │ │ ├── plugin-interactive-tools.cjs │ │ └── plugin-workspace-tools.cjs └── releases │ └── yarn-3.2.1.cjs ├── .yarnrc.yml ├── README.md ├── bors.toml ├── eslint.config.mjs ├── examples └── live-agent │ ├── CHANGELOG.md │ ├── README.md │ ├── example_project.vf │ ├── index.html │ ├── package.json │ ├── server │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── src │ │ ├── intercom │ │ │ ├── intercom-topic.enum.ts │ │ │ ├── intercom.routes.ts │ │ │ └── intercom.service.ts │ │ ├── main.ts │ │ └── sockets.ts │ └── tsconfig.json │ ├── shared │ ├── live-agent-platform.enum.ts │ └── socket-event.enum.ts │ ├── src │ ├── config.ts │ ├── context.tsx │ ├── main.tsx │ ├── traces │ │ └── LiveAgent.trace.ts │ └── use-live-agent.hook.ts │ ├── tsconfig.json │ ├── types │ └── env.d.ts │ └── vite.config.ts ├── lerna.json ├── package.json ├── packages └── react-chat │ ├── --output-dir │ ├── .babelrc.json │ ├── .dependency-cruiser.mjs │ ├── .gitignore │ ├── .storybook │ ├── main.ts │ ├── preview-head.html │ └── preview.tsx │ ├── CHANGELOG.md │ ├── README.md │ ├── __mocks__ │ └── @voiceflow │ │ └── stitches-react.ts │ ├── chromatic.config.json │ ├── config │ └── test │ │ └── setup.ts │ ├── e2e │ ├── embedded.html │ ├── embedded.spec.ts │ ├── extensions.html │ ├── extensions.spec.ts │ ├── overlay.html │ ├── overlay.spec.ts │ ├── proactive.html │ ├── proactive.spec.ts │ └── utils.ts │ ├── examples │ └── index.html │ ├── package.json │ ├── playwright.config.ts │ ├── sonar-project.properties │ ├── src │ ├── assets │ │ └── svg │ │ │ ├── close.svg │ │ │ ├── closeV2.svg │ │ │ ├── index.ts │ │ │ ├── large-arrow-left.svg │ │ │ ├── microphone.svg │ │ │ ├── minus.svg │ │ │ ├── small-arrow-up.svg │ │ │ ├── sound-off.svg │ │ │ ├── sound.svg │ │ │ ├── stop.svg │ │ │ ├── thumbs-up.svg │ │ │ └── top-caret.svg │ ├── common │ │ ├── index.ts │ │ └── utils.ts │ ├── components │ │ ├── AssistantInfo │ │ │ ├── AssistantInfo.story.tsx │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── Avatar │ │ │ ├── Avatar.story.tsx │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── Bubble │ │ │ ├── Bubble.story.tsx │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── Button │ │ │ ├── Button.story.tsx │ │ │ ├── Button.test.tsx │ │ │ ├── Primary.ts │ │ │ ├── Secondary.ts │ │ │ ├── constants.ts │ │ │ ├── index.ts │ │ │ └── styled.ts │ │ ├── Card │ │ │ ├── Card.story.tsx │ │ │ ├── index.tsx │ │ │ ├── styled.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── Carousel │ │ │ ├── Carousel.story.tsx │ │ │ ├── CarouselButton.tsx │ │ │ ├── constants.ts │ │ │ ├── hooks.ts │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── Chat │ │ │ ├── Chat.story.tsx │ │ │ ├── hooks.ts │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── ChatInput │ │ │ ├── AudioInputButton.tsx │ │ │ ├── ChatInput.story.tsx │ │ │ ├── hooks.ts │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── Feedback │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── Footer │ │ │ ├── Footer.story.tsx │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── Header │ │ │ ├── Header.story.tsx │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── Icon │ │ │ ├── Icon.story.tsx │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── Image │ │ │ ├── Default.tsx │ │ │ ├── Image.story.tsx │ │ │ └── index.tsx │ │ ├── Input │ │ │ ├── Input.story.tsx │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── Launcher │ │ │ ├── Launcher.story.tsx │ │ │ ├── index.tsx │ │ │ ├── launch.svg │ │ │ └── styled.ts │ │ ├── Loader │ │ │ ├── Loader.story.tsx │ │ │ └── index.ts │ │ ├── Message │ │ │ ├── ChatMessage.ts │ │ │ ├── DebugMessage │ │ │ │ ├── index.tsx │ │ │ │ └── styled.ts │ │ │ ├── Message.story.tsx │ │ │ ├── constants.ts │ │ │ ├── index.ts │ │ │ └── styled.ts │ │ ├── Proactive │ │ │ ├── Close.tsx │ │ │ ├── Message.tsx │ │ │ └── index.tsx │ │ ├── Prompt │ │ │ ├── Prompt.story.tsx │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── SystemResponse │ │ │ ├── ExtensionMessage.tsx │ │ │ ├── Indicator.tsx │ │ │ ├── SystemMessage.tsx │ │ │ ├── SystemResponse.story.tsx │ │ │ ├── constants.ts │ │ │ ├── hooks.ts │ │ │ ├── index.tsx │ │ │ ├── state │ │ │ │ └── end.tsx │ │ │ ├── styled.ts │ │ │ └── types.ts │ │ ├── Text │ │ │ ├── Default.tsx │ │ │ ├── Markdown.tsx │ │ │ ├── index.tsx │ │ │ └── schema.ts │ │ ├── Textarea │ │ │ ├── Textarea.story.tsx │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── Timestamp │ │ │ ├── Timestamp.story.tsx │ │ │ ├── index.tsx │ │ │ ├── styled.ts │ │ │ └── utils.ts │ │ ├── Tooltip │ │ │ ├── Tooltip.story.tsx │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── TypingIndicator │ │ │ ├── TypingIndicator.story.tsx │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ ├── UserResponse │ │ │ ├── UserResponse.story.tsx │ │ │ ├── index.tsx │ │ │ └── styled.ts │ │ └── index.ts │ ├── constants.ts │ ├── contexts │ │ ├── AutoScrollContext.tsx │ │ ├── RuntimeContext │ │ │ ├── audio-controller.ts │ │ │ ├── index.tsx │ │ │ ├── messages.ts │ │ │ ├── runtime.utils.test.ts │ │ │ ├── runtime.utils.ts │ │ │ ├── silent-audio.ts │ │ │ ├── traces │ │ │ │ ├── EffectExtensions.trace.ts │ │ │ │ ├── NoReply.trace.ts │ │ │ │ └── ResponseExtensions.trace.ts │ │ │ ├── useNoReply.ts │ │ │ ├── useRuntimeAPI.ts │ │ │ └── useRuntimeState.ts │ │ └── index.ts │ ├── device.ts │ ├── dtos │ │ ├── AssistantOptions.dto.ts │ │ ├── ChatConfig.dto.test.ts │ │ ├── ChatConfig.dto.ts │ │ ├── Extension.dto.ts │ │ ├── RenderOptions.dto.test.ts │ │ └── RenderOptions.dto.ts │ ├── fixtures.ts │ ├── hocs │ │ ├── index.ts │ │ └── tag.tsx │ ├── hooks │ │ ├── index.ts │ │ ├── useAutoScroll.ts │ │ ├── useChatAPI.ts │ │ ├── useDidUpdateEffect.ts │ │ ├── useStateRef.ts │ │ ├── useStorage.ts │ │ └── useTheme.ts │ ├── index.tsx │ ├── package.entry.ts │ ├── styles.css │ ├── styles │ │ ├── animation.ts │ │ ├── color.ts │ │ ├── font.ts │ │ ├── fragments.ts │ │ ├── index.ts │ │ ├── shadow.ts │ │ └── theme.ts │ ├── types │ │ ├── index.ts │ │ ├── session.ts │ │ ├── trace.ts │ │ ├── turn.ts │ │ └── util.ts │ ├── utils │ │ ├── actions.ts │ │ ├── assistant.test.ts │ │ ├── assistant.ts │ │ ├── broadcast.ts │ │ ├── chat.ts │ │ ├── controls.tsx │ │ ├── functional.ts │ │ ├── session.ts │ │ ├── stylesheet.ts │ │ ├── url.ts │ │ └── variants.tsx │ └── views │ │ ├── ChatEmbed │ │ └── index.tsx │ │ ├── ChatWidget │ │ ├── index.tsx │ │ └── styled.ts │ │ ├── ChatWindow │ │ ├── index.tsx │ │ └── styled.ts │ │ └── index.ts │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── typings │ ├── env.d.ts │ └── global.d.ts │ ├── vite.config.ts │ ├── vite.package.config.ts │ └── vitest.config.mts ├── renovate.json ├── scripts ├── build_pkg.sh └── vercel_ignore_build.sh ├── sonar-project.properties ├── turbo.json └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | # this allows you to use CircleCI's dynamic configuration feature 4 | setup: true 5 | 6 | orbs: 7 | path-filtering: circleci/path-filtering@0.0.2 8 | 9 | workflows: 10 | generate-config: 11 | jobs: 12 | - path-filtering/filter: 13 | base-revision: master 14 | config-path: .circleci/continue-config.yml 15 | mapping: | 16 | packages/.* build-apps true 17 | package.json build-all true 18 | yarn.lock build-all true 19 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [*.json] 15 | trim_trailing_whitespace = false 16 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | build-and-deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v2.3.1 12 | with: 13 | persist-credentials: false 14 | - uses: volta-cli/action@v4 15 | - name: Build sdk-runtime 16 | working-directory: ./packages/sdk-runtime 17 | run: | 18 | yarn install 19 | yarn build 20 | - name: Install and Build 21 | working-directory: ./packages/react-chat 22 | run: | 23 | yarn install 24 | yarn storybook:publish 25 | - name: Deploy 26 | uses: JamesIves/github-pages-deploy-action@v4.6.8 27 | with: 28 | branch: docs 29 | folder: ./packages/react-chat/docs 30 | clean: true 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.local 3 | .cache 4 | 5 | # Node 6 | node_modules 7 | 8 | # output 9 | build 10 | dist 11 | storybook-static 12 | docs 13 | 14 | # Turborepo 15 | .turbo 16 | 17 | # Dotenv 18 | .env 19 | .env.* 20 | !.env.e2e 21 | !.env.test 22 | 23 | # MacOS 24 | .DS_Store 25 | 26 | # ESlint 27 | .eslintcache 28 | 29 | # Yarn 30 | .pnp.* 31 | .yarn 32 | !.yarn/patches 33 | !.yarn/plugins 34 | !.yarn/releases 35 | !.yarn/sdks 36 | !.yarn/versions 37 | 38 | # Tests 39 | reports 40 | *.report.xml 41 | 42 | # Coverage 43 | sonar 44 | 45 | #jetbrains 46 | .idea 47 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | yarn commitlint --edit "$1" 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | yarn lint-staged 4 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | yarn git-branch-check 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | CHANGELOG.md 2 | 3 | # Node 4 | node_modules 5 | 6 | # output 7 | build 8 | dist 9 | storybook-static 10 | 11 | # Yarn 12 | .pnp.* 13 | .yarn 14 | *.lock 15 | 16 | # Yalc 17 | .yalc 18 | 19 | # Coverage 20 | sonar 21 | 22 | # Tests 23 | *.report.xml 24 | 25 | # Playwright 26 | test-results 27 | 28 | 29 | # Artifacts 30 | apps/documentation/public/bundle 31 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[html]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | }, 5 | "[javascript]": { 6 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 7 | }, 8 | "[javascriptreact]": { 9 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 10 | }, 11 | "[json]": { 12 | "editor.defaultFormatter": "esbenp.prettier-vscode" 13 | }, 14 | "[jsonc]": { 15 | "editor.defaultFormatter": "esbenp.prettier-vscode" 16 | }, 17 | "[markdown]": { 18 | "editor.defaultFormatter": "esbenp.prettier-vscode" 19 | }, 20 | "[typescript]": { 21 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 22 | }, 23 | "[typescriptreact]": { 24 | "editor.defaultFormatter": "dbaeumer.vscode-eslint" 25 | }, 26 | "[yaml]": { 27 | "editor.defaultFormatter": "esbenp.prettier-vscode" 28 | }, 29 | "[css]": { 30 | "editor.defaultFormatter": "esbenp.prettier-vscode" 31 | }, 32 | "editor.codeActionsOnSave": { 33 | "source.fixAll.eslint": "explicit" 34 | }, 35 | "eslint.format.enable": true, 36 | "eslint.experimental.useFlatConfig": true, 37 | "search.exclude": { 38 | "**/node_modules": true, 39 | "**/build": true, 40 | "**/dist": true, 41 | "**/test-results": true, 42 | "**/playwright-report": true, 43 | "**/sonar": true, 44 | "**/.husky": true, 45 | "**/.yarn": true, 46 | "**/.turbo": true 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | enableTelemetry: false 2 | 3 | nodeLinker: node-modules 4 | 5 | plugins: 6 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 7 | spec: '@yarnpkg/plugin-interactive-tools' 8 | - path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs 9 | spec: '@yarnpkg/plugin-workspace-tools' 10 | 11 | yarnPath: .yarn/releases/yarn-3.2.1.cjs 12 | -------------------------------------------------------------------------------- /bors.toml: -------------------------------------------------------------------------------- 1 | status = ["ci/circleci: build", "ci/circleci: test", "ci/circleci: e2e"] 2 | pr_status = ["UI Tests", "Vercel Preview Comments"] 3 | delete_merged_branches = true 4 | use_squash_merge = true 5 | cut_body_after = "### Implementation details. How do you make this change?" 6 | required_approvals = 1 7 | update_base_for_deletes = true 8 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import baseConfig from '@voiceflow/eslint-config'; 2 | 3 | /** @type {import('eslint').Linter.FlatConfig[]} */ 4 | export default [ 5 | ...baseConfig, 6 | { 7 | ignores: ['**/storybook-static/**', '**/public/bundle/*', '**/.next/**'], 8 | }, 9 | 10 | { 11 | rules: { 12 | 'no-console': ['error', { allow: ['info', 'warn', 'error'] }], 13 | }, 14 | }, 15 | ]; 16 | -------------------------------------------------------------------------------- /examples/live-agent/README.md: -------------------------------------------------------------------------------- 1 | # Live Agent Example 2 | 3 | ## Install Dependencies 4 | 5 | ```sh 6 | yarn install 7 | ``` 8 | 9 | ## Import Example Project 10 | 11 | Import this [project with custom actions](example_project.vf) into your Voiceflow workspace. 12 | It includes custom `account_info`, `calendar` and `video` actions. 13 | 14 | ## Configure Environment 15 | 16 | Follow [these instructions](https://docs.voiceflow.com/docs/publishing-environments-backups) to publish your Voiceflow Assistant. 17 | 18 | Copy the project ID from the Assistant Settings and write it to a `.env.local` file. 19 | 20 | ```sh 21 | # replace `XXX` with your project ID key 22 | echo 'VF_PROJECT_ID=XXX` > .env.local 23 | ``` 24 | 25 | ## Run Dev Server 26 | 27 | The demo app will be available locally at . 28 | 29 | This will also start the WebSocket server located in `server/` to interact with Intercom's APIs. 30 | See the [README](server/README.md) for more information. 31 | 32 | For convenience you can run both the chat widget and the WebSocket server at the same time with this command. 33 | 34 | ```sh 35 | yarn dev 36 | ``` 37 | 38 | Screenshot 2024-04-03 at 12 32 06 39 | 40 | ## Invoke Custom Actions 41 | 42 | ### `talk_to_agent` 43 | 44 | - "I want to talk to a human" 45 | - "Please connect me to a human" 46 | 47 | This will switch the conversation into a mode that emulates talking with a live agent. 48 | New messages will skip the Voiceflow logic and be sent directly to the agent. 49 | You can also end the live conversation and return to talking with the Voiceflow bot. 50 | Make sure to run the server in `./server` with the command `yarn dev`. 51 | -------------------------------------------------------------------------------- /examples/live-agent/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Example - Live Agent 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /examples/live-agent/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@voiceflow-example/live-agent", 3 | "version": "1.3.4", 4 | "private": true, 5 | "license": "MIT", 6 | "scripts": { 7 | "dev": "yarn g:run-p dev:app dev:server", 8 | "dev:app": "vite", 9 | "dev:server": "cd server && yarn dev", 10 | "test:types": "yarn g:tsc --noEmit" 11 | }, 12 | "prettier": "@voiceflow/prettier-config", 13 | "dependencies": { 14 | "@voiceflow/exception": "1.4.0", 15 | "@voiceflow/fetch": "1.5.2", 16 | "@voiceflow/react-chat": "workspace:*", 17 | "@voiceflow/slate-serializer": "1.4.2", 18 | "nanoevents": "8.0.0", 19 | "react": "18.2.0", 20 | "react-calendar": "4.3.0", 21 | "react-dom": "18.2.0", 22 | "styled-components": "6.0.3", 23 | "ts-pattern": "4.3.0" 24 | }, 25 | "devDependencies": { 26 | "@types/node": "20.12.7", 27 | "@types/react": "18.2.8", 28 | "@types/react-dom": "18.2.4", 29 | "vite": "4.3.9" 30 | }, 31 | "volta": { 32 | "extends": "../../package.json" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/live-agent/server/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /examples/live-agent/server/README.md: -------------------------------------------------------------------------------- 1 | # Live Agent Demo Server 2 | 3 | ## Usage 4 | 5 | Install dependencies and run locally: 6 | 7 | ```sh 8 | yarn install 9 | yarn dev 10 | ``` 11 | 12 | ## Integrations 13 | 14 | Live agent server supports the following integrations: 15 | 16 | - [intercom](#intercom) 17 | 18 | ### Intercom 19 | 20 | To enable the intercom integration you need to create a `.env` file and set the following environment: 21 | 22 | ```sh 23 | INTERCOM_TOKEN='...' 24 | ``` 25 | -------------------------------------------------------------------------------- /examples/live-agent/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@voiceflow-example/live-agent-server", 3 | "private": true, 4 | "scripts": { 5 | "dev": "tsx --watch src/main.ts", 6 | "test:types": "yarn g:tsc --noEmit" 7 | }, 8 | "dependencies": { 9 | "body-parser": "1.20.2", 10 | "cors": "2.8.5", 11 | "dotenv": "16.3.1", 12 | "express": "4.19.2", 13 | "express-ws": "5.0.2", 14 | "intercom-client": "5.0.0", 15 | "string-strip-html": "13.4.2", 16 | "ts-pattern": "4.3.0", 17 | "ws": "8.18.0" 18 | }, 19 | "devDependencies": { 20 | "@types/cors": "2.8.13", 21 | "@types/express": "4.17.17", 22 | "@types/express-ws": "3.0.1", 23 | "@types/ws": "8.5.11", 24 | "tsx": "4.7.2" 25 | }, 26 | "volta": { 27 | "extends": "../../../package.json" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/live-agent/server/src/intercom/intercom-topic.enum.ts: -------------------------------------------------------------------------------- 1 | export enum IntercomTopic { 2 | ADMIN_ASSIGNED = 'conversation.admin.assigned', 3 | ADMIN_REPLIED = 'conversation.admin.replied', 4 | ADMIN_CLOSED = 'conversation.admin.closed', 5 | } 6 | -------------------------------------------------------------------------------- /examples/live-agent/server/src/intercom/intercom.routes.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable consistent-return, import/no-relative-packages */ 2 | import type { Application } from 'express-ws'; 3 | import { match } from 'ts-pattern'; 4 | 5 | import { LiveAgentPlatform } from '../../../shared/live-agent-platform.enum'; 6 | import { SocketEvent } from '../../../shared/socket-event.enum'; 7 | import { IntercomService } from './intercom.service'; 8 | import { IntercomTopic } from './intercom-topic.enum'; 9 | 10 | let intercom: IntercomService | null = null; 11 | 12 | export const intercomRoutes = (app: Application) => { 13 | app.ws(`/${LiveAgentPlatform.INTERCOM}/user/:userID/conversation/:conversationID/socket`, async (ws, req) => { 14 | if (!intercom) return ws.close(400); 15 | 16 | const { userID, conversationID } = req.params; 17 | 18 | await intercom.subscribeToConversation(conversationID, ws, (event) => 19 | match(event.type) 20 | .with(SocketEvent.USER_MESSAGE, () => intercom?.sendUserReply(userID, conversationID, event.data.message)) 21 | .otherwise(() => console.warn('unknown event', event)) 22 | ); 23 | }); 24 | 25 | app.head(`/${LiveAgentPlatform.INTERCOM}`, (_, res) => { 26 | if (intercom) return res.send('ok'); 27 | 28 | try { 29 | intercom = new IntercomService(); 30 | res.send('ok'); 31 | } catch { 32 | res.status(500).send('invalid API key'); 33 | } 34 | }); 35 | 36 | app.head(`/${LiveAgentPlatform.INTERCOM}/webhook`, (_, res) => res.send('ok')); 37 | 38 | app.post(`/${LiveAgentPlatform.INTERCOM}/webhook`, async (req, res) => { 39 | const { topic, data } = req.body; 40 | 41 | await match(topic) 42 | .with(IntercomTopic.ADMIN_ASSIGNED, () => intercom?.connectAgent(data.item)) 43 | .with(IntercomTopic.ADMIN_REPLIED, () => intercom?.sendAgentReply(data.item)) 44 | .with(IntercomTopic.ADMIN_CLOSED, () => intercom?.disconnectAgent(data.item)) 45 | .otherwise(() => console.warn('unknown topic', topic)); 46 | 47 | res.send('ok'); 48 | }); 49 | 50 | app.post(`/${LiveAgentPlatform.INTERCOM}/conversation`, async (req, res) => { 51 | if (!intercom) return res.status(400).send('intercom not initialized'); 52 | 53 | const { userID, conversationID } = await intercom.createConversation(req.body.userID); 54 | 55 | res.json({ userID, conversationID }); 56 | 57 | await intercom.sendHistory(userID, conversationID, req.body.history); 58 | }); 59 | }; 60 | -------------------------------------------------------------------------------- /examples/live-agent/server/src/main.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | 3 | import bodyParser from 'body-parser'; 4 | import cors from 'cors'; 5 | import express from 'express'; 6 | import expressWS from 'express-ws'; 7 | 8 | import { intercomRoutes } from './intercom/intercom.routes'; 9 | 10 | const { app } = expressWS(express()); 11 | 12 | app.use(cors()); 13 | app.use(bodyParser.json()); 14 | 15 | intercomRoutes(app); 16 | 17 | app.listen(9099); 18 | console.info('server is running on port 9099'); 19 | -------------------------------------------------------------------------------- /examples/live-agent/server/src/sockets.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-relative-packages */ 2 | import { SocketEvent } from '../../shared/socket-event.enum'; 3 | 4 | export const connectLiveAgent = (conversation: any, agent: any) => ({ 5 | type: SocketEvent.LIVE_AGENT_CONNECT, 6 | data: { conversation, agent }, 7 | }); 8 | 9 | export const disconnectLiveAgent = (conversation: any, agent: any) => ({ 10 | type: SocketEvent.LIVE_AGENT_DISCONNECT, 11 | data: { conversation, agent }, 12 | }); 13 | 14 | export const sendLiveAgentMessage = (message: string) => ({ 15 | type: SocketEvent.LIVE_AGENT_MESSAGE, 16 | data: { message }, 17 | }); 18 | -------------------------------------------------------------------------------- /examples/live-agent/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "forceConsistentCasingInFileNames": true, 5 | "strict": true, 6 | "skipLibCheck": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /examples/live-agent/shared/live-agent-platform.enum.ts: -------------------------------------------------------------------------------- 1 | export enum LiveAgentPlatform { 2 | INTERCOM = 'intercom', 3 | } 4 | -------------------------------------------------------------------------------- /examples/live-agent/shared/socket-event.enum.ts: -------------------------------------------------------------------------------- 1 | export enum SocketEvent { 2 | // server-sent events 3 | LIVE_AGENT_CONNECT = 'live_agent.connect', 4 | LIVE_AGENT_DISCONNECT = 'live_agent.disconnect', 5 | LIVE_AGENT_MESSAGE = 'live_agent.message', 6 | 7 | // client-sent events 8 | USER_MESSAGE = 'user.message', 9 | } 10 | -------------------------------------------------------------------------------- /examples/live-agent/src/config.ts: -------------------------------------------------------------------------------- 1 | import { AssistantOptions, ChatConfig } from '@voiceflow/react-chat'; 2 | 3 | const IMAGE = 'https://picsum.photos/seed/1/200/300'; 4 | const AVATAR = 'https://picsum.photos/seed/1/80/80'; 5 | 6 | export const ASSISTANT: AssistantOptions = AssistantOptions.parse({ 7 | title: 'Live Agent Demo', 8 | description: 'Demonstration of integrating Voiceflow with Intercom.', 9 | image: IMAGE, 10 | avatar: AVATAR, 11 | }); 12 | 13 | export const CONFIG = ChatConfig.parse({ 14 | verify: { projectID: import.meta.env.VF_PROJECT_ID }, 15 | }); 16 | -------------------------------------------------------------------------------- /examples/live-agent/src/context.tsx: -------------------------------------------------------------------------------- 1 | import { RuntimeProvider as BaseProvider } from '@voiceflow/react-chat'; 2 | import { createNanoEvents } from 'nanoevents'; 3 | import { useMemo } from 'react'; 4 | 5 | import { ASSISTANT, CONFIG } from './config'; 6 | import { LiveAgent } from './traces/LiveAgent.trace'; 7 | import type { LiveAgentEvents } from './use-live-agent.hook'; 8 | import { useLiveAgent } from './use-live-agent.hook'; 9 | 10 | export const RuntimeProvider: React.FC = ({ children }) => { 11 | const emitter = useMemo(() => createNanoEvents(), []); 12 | const liveAgent = useLiveAgent(emitter); 13 | 14 | return ( 15 | emitter.emit('live_agent', platform))]} 19 | extend={liveAgent.extend} 20 | > 21 | {children} 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /examples/live-agent/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { ChatWidget } from '@voiceflow/react-chat'; 2 | import { createRoot } from 'react-dom/client'; 3 | 4 | import { RuntimeProvider } from './context'; 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /examples/live-agent/src/traces/LiveAgent.trace.ts: -------------------------------------------------------------------------------- 1 | import type { TraceHandler } from '@voiceflow/react-chat'; 2 | 3 | import type { LiveAgentPlatform } from '../../shared/live-agent-platform.enum'; 4 | 5 | export const LiveAgent = (handoff: (platform: LiveAgentPlatform) => void): TraceHandler => ({ 6 | canHandle: ({ type }) => (type as string) === 'talk_to_agent', 7 | handle: ({ context }, trace) => { 8 | handoff(trace.payload.platform); 9 | return context; 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /examples/live-agent/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "lib": ["dom"], 7 | "jsx": "react-jsx", 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "skipLibCheck": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/live-agent/types/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VF_PROJECT_ID: string; 5 | } 6 | 7 | interface ImportMeta { 8 | readonly env: ImportMetaEnv; 9 | } 10 | -------------------------------------------------------------------------------- /examples/live-agent/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | 3 | export default defineConfig({ 4 | build: { 5 | outDir: 'build', 6 | }, 7 | define: { 8 | 'import.meta.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 9 | }, 10 | envPrefix: 'VF_', 11 | server: { 12 | port: 3006, 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["packages/*", "examples/*", "examples/live-agent/server"], 3 | "npmClient": "yarn", 4 | "useWorkspaces": true, 5 | "version": "independent", 6 | "command": { 7 | "publish": { 8 | "conventionalCommits": true, 9 | "createRelease": "github" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/react-chat/--output-dir: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voiceflow/react-chat/364508802686bea3e4e8249dcd84a683c5b7b329/packages/react-chat/--output-dir -------------------------------------------------------------------------------- /packages/react-chat/.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /packages/react-chat/.dependency-cruiser.mjs: -------------------------------------------------------------------------------- 1 | import { createConfig } from '@voiceflow/dependency-cruiser-config'; 2 | 3 | export default createConfig({ allowTypeCycles: true }); 4 | -------------------------------------------------------------------------------- /packages/react-chat/.gitignore: -------------------------------------------------------------------------------- 1 | /test-results/ 2 | -------------------------------------------------------------------------------- /packages/react-chat/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sonarjs/prefer-single-boolean-return */ 2 | import type { StorybookConfig } from '@storybook/react-vite'; 3 | import { mergeConfig } from 'vite'; 4 | import svgr from 'vite-plugin-svgr'; 5 | 6 | import { createPlugins } from '../vite.config'; 7 | 8 | const config: StorybookConfig = { 9 | stories: ['../src/**/*.story.@(js|jsx|ts|tsx)'], 10 | addons: [ 11 | '@storybook/addon-links', 12 | '@storybook/addon-essentials', 13 | '@storybook/addon-interactions', 14 | 'storybook-dark-mode', 15 | ], 16 | framework: '@storybook/react-vite', 17 | core: { 18 | builder: '@storybook/builder-vite', 19 | }, 20 | typescript: { 21 | check: true, 22 | reactDocgen: 'react-docgen-typescript', 23 | reactDocgenTypescriptOptions: { 24 | shouldExtractLiteralValuesFromEnum: true, 25 | propFilter: (prop: { name: string; parent?: { fileName: string } }): boolean => { 26 | if (['ref', 'css'].includes(prop.name)) return false; 27 | if (prop.parent && /node_modules/.test(prop.parent.fileName)) return false; 28 | 29 | return true; 30 | }, 31 | }, 32 | }, 33 | 34 | viteFinal: (config) => { 35 | return mergeConfig(config, { 36 | plugins: [...createPlugins(__dirname), svgr()], 37 | define: { 38 | __USE_SHADOW_ROOT__: false, 39 | }, 40 | }); 41 | }, 42 | }; 43 | 44 | export default config; 45 | -------------------------------------------------------------------------------- /packages/react-chat/.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 7 | 8 | 12 | -------------------------------------------------------------------------------- /packages/react-chat/.storybook/preview.tsx: -------------------------------------------------------------------------------- 1 | import 'regenerator-runtime/runtime'; 2 | 3 | import type { Preview } from '@storybook/react'; 4 | import React from 'react'; 5 | 6 | import { RuntimeProvider } from '../src/contexts/RuntimeContext/index'; 7 | 8 | const MOCK_CONFIG = { render: { mode: 'embedded' }, verify: { projectID: ' ' } } as any; 9 | const MOCK_ASSISTANT = { persistence: {}, extensions: [] } as any; 10 | 11 | const preview: Preview = { 12 | parameters: { 13 | actions: { argTypesRegex: '^on[A-Z].*' }, 14 | controls: { 15 | matchers: { 16 | color: /(background|color)$/i, 17 | date: /Date$/, 18 | }, 19 | }, 20 | }, 21 | decorators: [ 22 | (Story) => ( 23 | 24 | 25 | 26 | ), 27 | ], 28 | }; 29 | 30 | export default preview; 31 | -------------------------------------------------------------------------------- /packages/react-chat/__mocks__/@voiceflow/stitches-react.ts: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest'; 2 | 3 | export const createStitches = vi.fn().mockReturnValue({ 4 | styled: vi.fn().mockImplementation((el) => el), 5 | keyframes: vi.fn(), 6 | }); 7 | 8 | export const keyframes = vi.fn(); 9 | -------------------------------------------------------------------------------- /packages/react-chat/chromatic.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "outputDir": "./storybook-static" 3 | } 4 | -------------------------------------------------------------------------------- /packages/react-chat/config/test/setup.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/vitest'; 2 | 3 | import { vi } from 'vitest'; 4 | 5 | vi.mock('@voiceflow/stitches-react'); 6 | -------------------------------------------------------------------------------- /packages/react-chat/e2e/embedded.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Embedded Mode 5 | 18 | 19 | 20 | 21 |
22 | 23 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /packages/react-chat/e2e/embedded.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test'; 2 | 3 | test('renders embedded webchat and starts automatically', async ({ page }) => { 4 | await page.goto('embedded'); 5 | 6 | const chat = page.locator('.vfrc-chat'); 7 | await chat.waitFor({ state: 'visible' }); 8 | expect(chat).toBeInViewport(); 9 | page.locator('.vfrc-footer .vfrc-button').click(); 10 | 11 | await page.locator('.vfrc-chat-input').waitFor({ state: 'visible' }); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/react-chat/e2e/overlay.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Overlay mode 5 | 10 | 11 | 12 | 13 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /packages/react-chat/e2e/overlay.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test'; 2 | 3 | test('renders launcher and widget appears on click', async ({ page }) => { 4 | await page.goto('overlay'); 5 | 6 | const launcher = page.locator('.vfrc-launcher'); 7 | await launcher.waitFor({ state: 'visible' }); 8 | await launcher.click(); 9 | const chat = page.locator('.vfrc-chat'); 10 | 11 | await chat.waitFor({ state: 'visible' }); 12 | await page.locator('.vfrc-chat-input').waitFor({ state: 'visible' }); 13 | }); 14 | 15 | test('control widget visibility and open state', async ({ page }) => { 16 | await page.goto('overlay'); 17 | 18 | const launcher = page.locator('.vfrc-launcher'); 19 | const chat = page.locator('.vfrc-chat'); 20 | 21 | await launcher.waitFor({ state: 'visible' }); 22 | 23 | await page.evaluate(() => window.voiceflow?.chat?.open()); 24 | 25 | await chat.waitFor({ state: 'visible' }); 26 | 27 | await page.evaluate(() => window.voiceflow?.chat?.close()); 28 | 29 | expect(chat).not.toBeInViewport(); 30 | 31 | await page.evaluate(() => window.voiceflow?.chat?.hide()); 32 | 33 | await launcher.waitFor({ state: 'hidden' }); 34 | 35 | await page.evaluate(() => window.voiceflow?.chat?.show()); 36 | 37 | await launcher.waitFor({ state: 'visible' }); 38 | }); 39 | -------------------------------------------------------------------------------- /packages/react-chat/e2e/proactive.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Overlay mode - proactive messages 5 | 10 | 11 | 12 | 13 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /packages/react-chat/e2e/proactive.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@playwright/test'; 2 | import type { Trace } from '@voiceflow/base-types'; 3 | 4 | test('renders launcher and widget appears on click', async ({ page }) => { 5 | const message = 'Welcome to our chat'; 6 | 7 | await page.goto('proactive'); 8 | 9 | await page.locator('.vfrc-launcher').waitFor({ state: 'visible' }); 10 | 11 | await page.evaluate( 12 | ([message]) => 13 | window.voiceflow?.chat?.proactive.push({ 14 | type: 'text' as Trace.TraceType.TEXT, 15 | payload: { slate: { id: '', content: [] }, message }, 16 | }), 17 | [message] 18 | ); 19 | 20 | await page.waitForSelector(`text=${message}`); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/react-chat/e2e/utils.ts: -------------------------------------------------------------------------------- 1 | export const slateMessage = (text: string) => ({ 2 | type: 'text', 3 | payload: { 4 | slate: { 5 | id: text, 6 | content: [{ children: [{ text }] }], 7 | messageDelayMilliseconds: 100, 8 | }, 9 | message: text, 10 | delay: 100, 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /packages/react-chat/examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Example Page 6 | 18 | 19 | 20 | 21 |
22 |
23 | 24 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /packages/react-chat/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { cpus } from 'node:os'; 2 | 3 | import { defineConfig, devices } from '@playwright/test'; 4 | 5 | export default defineConfig({ 6 | testDir: './e2e', 7 | fullyParallel: true, 8 | forbidOnly: !!process.env.CI, 9 | retries: 0, 10 | workers: process.env.CI ? 1 : cpus().length - 1, 11 | reporter: [['junit', { outputFile: 'e2e.report.xml' }]], 12 | timeout: 5000, 13 | use: { 14 | baseURL: 'http://127.0.0.1:8080/e2e/', 15 | 16 | trace: 'retain-on-failure', 17 | screenshot: 'only-on-failure', 18 | video: 'on', 19 | }, 20 | 21 | projects: [ 22 | { 23 | name: 'chromium', 24 | use: { ...devices['Desktop Chrome'] }, 25 | }, 26 | ], 27 | 28 | webServer: { 29 | command: 'yarn start:e2e', 30 | url: 'http://127.0.0.1:8080', 31 | reuseExistingServer: !process.env.CI, 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /packages/react-chat/sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectName=react-chat-react-chat 2 | sonar.sources=src/ 3 | sonar.tests=src/ 4 | sonar.exclusions=src/**/*.test.*,src/**/*.story.tsx 5 | sonar.test.inclusions=src/**/*.test.* 6 | sonar.cpd.exclusions=src/**/*.test.*,src/**/*.story.tsx 7 | sonar.typescript.tsconfigPath=tsconfig.json 8 | sonar.javascript.lcov.reportPaths=sonar/coverage/lcov.info 9 | -------------------------------------------------------------------------------- /packages/react-chat/src/assets/svg/close.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/react-chat/src/assets/svg/closeV2.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /packages/react-chat/src/assets/svg/index.ts: -------------------------------------------------------------------------------- 1 | export { default as close } from './close.svg?react'; 2 | export { default as closeV2 } from './closeV2.svg?react'; 3 | export { default as largeArrowLeft } from './large-arrow-left.svg?react'; 4 | export { default as microphone } from './microphone.svg?react'; 5 | export { default as minus } from './minus.svg?react'; 6 | export { default as smallArrowUp } from './small-arrow-up.svg?react'; 7 | export { default as sound } from './sound.svg?react'; 8 | export { default as soundOff } from './sound-off.svg?react'; 9 | export { default as stop } from './stop.svg?react'; 10 | export { default as thumbsUp } from './thumbs-up.svg?react'; 11 | export { default as topCaret } from './top-caret.svg?react'; 12 | -------------------------------------------------------------------------------- /packages/react-chat/src/assets/svg/large-arrow-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /packages/react-chat/src/assets/svg/microphone.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | -------------------------------------------------------------------------------- /packages/react-chat/src/assets/svg/minus.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /packages/react-chat/src/assets/svg/small-arrow-up.svg: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /packages/react-chat/src/assets/svg/sound-off.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /packages/react-chat/src/assets/svg/sound.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /packages/react-chat/src/assets/svg/stop.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /packages/react-chat/src/assets/svg/thumbs-up.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | -------------------------------------------------------------------------------- /packages/react-chat/src/assets/svg/top-caret.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /packages/react-chat/src/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './utils'; 2 | -------------------------------------------------------------------------------- /packages/react-chat/src/common/utils.ts: -------------------------------------------------------------------------------- 1 | export { isObject } from 'remeda'; 2 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/AssistantInfo/AssistantInfo.story.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import Chat from '@/components/Chat'; 4 | import { VF_ICON } from '@/fixtures'; 5 | 6 | import type { AssistantInfoProps } from '.'; 7 | import AssistantInfo from '.'; 8 | 9 | type Story = StoryObj; 10 | 11 | const meta: Meta = { 12 | title: 'Components/Chat/AssistantInfo', 13 | component: AssistantInfo, 14 | args: { 15 | title: 'Assistant Name', 16 | description: "Voiceflow's virtual assistant is here to help.", 17 | avatar: VF_ICON, 18 | }, 19 | }; 20 | 21 | export default meta; 22 | 23 | export const Default: Story = { 24 | render: (args: AssistantInfoProps) => ( 25 | 26 | 27 | 28 | ), 29 | }; 30 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/AssistantInfo/index.tsx: -------------------------------------------------------------------------------- 1 | import Avatar from '@/components/Avatar'; 2 | 3 | import { Container, Description, Title } from './styled'; 4 | 5 | export interface AssistantInfoProps { 6 | /** 7 | * The title of the assistant. 8 | */ 9 | title: string; 10 | 11 | /** 12 | * A short description of the assistant to help frame the conversation. 13 | */ 14 | description: string; 15 | 16 | /** 17 | * An image URL that identifies the assistant, such as a brand icon. 18 | */ 19 | avatar: string; 20 | } 21 | 22 | const AssistantInfo: React.FC = ({ title, description, avatar }) => ( 23 | 24 | 25 | {title} 26 | {description} 27 | 28 | ); 29 | 30 | /** 31 | * This component displays introductory information about the assistant. 32 | * It will act as a placeholder before the conversation has started. 33 | * 34 | * @see {@link https://voiceflow.github.io/react-chat/?path=/story/components-chat-assistantinfo--default} 35 | */ 36 | export default Object.assign(AssistantInfo, { 37 | Container, 38 | Title, 39 | Description, 40 | }); 41 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/AssistantInfo/styled.ts: -------------------------------------------------------------------------------- 1 | import Avatar from '@/components/Avatar'; 2 | import { ClassName } from '@/constants'; 3 | import { tagFactory } from '@/hocs'; 4 | import { styled } from '@/styles'; 5 | import { textOverflowStyles } from '@/styles/fragments'; 6 | 7 | const tag = tagFactory(ClassName.ASSISTANT_INFO); 8 | 9 | export const Title = styled(tag('h2', 'title'), { 10 | ...textOverflowStyles, 11 | width: '100%', 12 | margin: 0, 13 | typo: { size: 20, weight: '$2', height: '$3' }, 14 | color: '$black', 15 | }); 16 | 17 | export const Description = styled(tag('p', 'description'), { 18 | display: '-webkit-box', 19 | margin: 0, 20 | typo: {}, 21 | color: '$darkGrey', 22 | '-webkit-line-clamp': 2, 23 | '-webkit-box-orient': 'vertical', 24 | overflow: 'hidden', 25 | wordBreak: 'break-word', 26 | }); 27 | 28 | export const Container = styled(tag('div'), { 29 | display: 'flex', 30 | flexDirection: 'column', 31 | alignItems: 'center', 32 | padding: '48px 32px', 33 | textAlign: 'center', 34 | 35 | [`& ${Avatar.Container}`]: { 36 | marginBottom: '$4', 37 | }, 38 | 39 | [`& ${Title}`]: { 40 | marginBottom: 8, 41 | }, 42 | }); 43 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Avatar/Avatar.story.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { VF_ICON } from '@/fixtures'; 4 | 5 | import Avatar from '.'; 6 | 7 | type Story = StoryObj; 8 | 9 | const meta: Meta = { 10 | title: 'Core/Avatar', 11 | component: Avatar, 12 | argTypes: { 13 | size: { 14 | options: ['small', 'large'], 15 | control: { type: 'radio' }, 16 | defaultValue: 'small', 17 | }, 18 | }, 19 | args: { 20 | avatar: VF_ICON, 21 | }, 22 | }; 23 | export default meta; 24 | 25 | export const Small: Story = { 26 | args: { 27 | size: 'small', 28 | }, 29 | }; 30 | 31 | export const Large: Story = { 32 | args: { 33 | size: 'large', 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Avatar/index.tsx: -------------------------------------------------------------------------------- 1 | import type { VariantProp } from '@/types'; 2 | 3 | import { AvatarContainer } from './styled'; 4 | 5 | export interface AvatarProps extends React.ComponentProps { 6 | /** 7 | * An image URL which will be rendered as the background. 8 | */ 9 | avatar: string; 10 | 11 | /** 12 | * Pre-defined size variants. 13 | * 14 | * @default 'small' 15 | */ 16 | size?: VariantProp; 17 | } 18 | 19 | const Avatar: React.FC = ({ avatar, ...props }) => ( 20 | 21 | ); 22 | 23 | /** 24 | * Displays an image in a circular frame. 25 | * 26 | * @see {@link https://voiceflow.github.io/react-chat/?path=/story/core-avatar--small} 27 | */ 28 | export default Object.assign(Avatar, { 29 | Container: AvatarContainer, 30 | }); 31 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Avatar/styled.ts: -------------------------------------------------------------------------------- 1 | import { ClassName } from '@/constants'; 2 | import { tagFactory } from '@/hocs'; 3 | import { styled } from '@/styles'; 4 | 5 | const tag = tagFactory(ClassName.AVATAR); 6 | 7 | export const AvatarContainer = styled(tag('div'), { 8 | flexShrink: 0, 9 | borderRadius: '$round', 10 | backgroundColor: '$lightGrey', 11 | backgroundPosition: 'center', 12 | backgroundRepeat: 'no-repeat', 13 | backgroundSize: 'cover', 14 | 15 | variants: { 16 | size: { 17 | small: { 18 | height: 26, 19 | width: 26, 20 | }, 21 | 22 | large: { 23 | height: '$xxl', 24 | width: '$xxl', 25 | boxSizing: 'border-box', 26 | boxShadow: '0 4px 16px 0 $shadow4, 0 0 0 1px $shadow2', 27 | }, 28 | }, 29 | }, 30 | defaultVariants: { 31 | size: 'small', 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Bubble/Bubble.story.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import * as SVGs from '@/assets/svg'; 4 | 5 | import Bubble from '.'; 6 | 7 | type Story = StoryObj; 8 | 9 | const meta: Meta = { 10 | title: 'Core/Bubble', 11 | component: Bubble, 12 | args: { 13 | color: '#fff', 14 | }, 15 | argTypes: { 16 | size: { 17 | options: ['small', 'large'], 18 | control: { type: 'radio' }, 19 | defaultValue: 'large', 20 | }, 21 | svg: { 22 | options: Object.keys(SVGs).filter((svg) => svg !== 'topCaret'), 23 | control: { type: 'radio' }, 24 | }, 25 | }, 26 | }; 27 | export default meta; 28 | 29 | export const Small: Story = { 30 | args: { 31 | size: 'small', 32 | svg: 'smallArrowUp', 33 | }, 34 | }; 35 | 36 | export const Large: Story = { 37 | args: { 38 | size: 'large', 39 | svg: 'close', 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Bubble/index.tsx: -------------------------------------------------------------------------------- 1 | import type { IconProps } from '@/components/Icon'; 2 | import Icon from '@/components/Icon'; 3 | import type { VariantProp } from '@/types'; 4 | 5 | import { Container } from './styled'; 6 | 7 | export interface BubbleProps extends React.ComponentProps { 8 | /** 9 | * The name of the SVG icon to be rendered or a React component. 10 | * 11 | * @see {@link https://github.com/voiceflow/react-chat/tree/master/packages/react-chat/src/assets/svg the available icons} 12 | */ 13 | svg: IconProps['svg']; 14 | 15 | /** 16 | * Pre-defined size variants. 17 | * 18 | * @default 'large' 19 | */ 20 | size?: VariantProp; 21 | } 22 | 23 | const Bubble: React.FC = ({ svg, color, ...props }) => ( 24 | 25 | 26 | 27 | ); 28 | 29 | /** 30 | * Call-to-action button with an icon. 31 | * 32 | * @see {@link https://voiceflow.github.io/react-chat/?path=/story/core-bubble--small} 33 | */ 34 | export default Object.assign(Bubble, { 35 | Container, 36 | }); 37 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Bubble/styled.ts: -------------------------------------------------------------------------------- 1 | import Button from '@/components/Button'; 2 | import Icon from '@/components/Icon'; 3 | import { ClassName } from '@/constants'; 4 | import { tagFactory } from '@/hocs'; 5 | import { styled } from '@/styles'; 6 | 7 | const tag = tagFactory(ClassName.BUBBLE); 8 | 9 | export const Container = styled(tag(Button.Reset), { 10 | display: 'flex', 11 | justifyContent: 'center', 12 | alignItems: 'center', 13 | borderRadius: '$round', 14 | backgroundColor: '$primary', 15 | trans: ['background-color', 'box-shadow'], 16 | 17 | '&:hover': { 18 | backgroundColor: '$darkPrimary', 19 | }, 20 | 21 | variants: { 22 | size: { 23 | small: { 24 | height: '$xs', 25 | width: '$xs', 26 | 27 | [`& ${Icon.Frame}`]: { 28 | width: '$xxs', 29 | height: '$xxs', 30 | }, 31 | }, 32 | 33 | large: { 34 | height: '$xl', 35 | width: '$xl', 36 | border: '1px solid $shadow4', 37 | boxShadow: '0 1px 6px $shadow6, 0 2px 24px $shadow8', 38 | 39 | [`& ${Icon.Frame}`]: { 40 | width: '$sm', 41 | height: '$sm', 42 | }, 43 | }, 44 | }, 45 | }, 46 | defaultVariants: { 47 | size: 'large', 48 | }, 49 | }); 50 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Button/Button.story.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import Button from '.'; 4 | 5 | type Story = StoryObj; 6 | const meta: Meta = { 7 | title: 'Core/Button', 8 | component: Button, 9 | argTypes: { 10 | variant: { 11 | options: Object.values(Button.Variant), 12 | control: { type: 'radio' }, 13 | defaultValue: Button.Variant.PRIMARY, 14 | }, 15 | type: { 16 | if: { arg: 'variant', eq: Button.Variant.PRIMARY }, 17 | options: ['info', 'warn', 'subtle'], 18 | control: { type: 'radio' }, 19 | defaultValue: 'info', 20 | }, 21 | }, 22 | args: { 23 | children: 'Button Label', 24 | }, 25 | }; 26 | 27 | export default meta; 28 | 29 | export const PrimaryInfo: Story = { 30 | args: { 31 | variant: Button.Variant.PRIMARY, 32 | type: 'info', 33 | }, 34 | }; 35 | 36 | export const PrimaryWarn: Story = { 37 | args: { 38 | variant: Button.Variant.PRIMARY, 39 | type: 'warn', 40 | }, 41 | }; 42 | 43 | export const PrimarySubtle: Story = { 44 | args: { 45 | variant: Button.Variant.PRIMARY, 46 | type: 'subtle', 47 | }, 48 | }; 49 | 50 | export const Secondary: Story = { 51 | args: { 52 | variant: Button.Variant.SECONDARY, 53 | }, 54 | }; 55 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Button/Button.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from '@testing-library/react'; 2 | import { describe, expect, it } from 'vitest'; 3 | 4 | import Button from '.'; 5 | 6 | describe('Button', () => { 7 | it('should render a button with a label', async () => { 8 | const label = 'Button Label'; 9 | 10 | render(); 11 | 12 | expect(screen.getByText(label)).toBeInTheDocument(); 13 | expect(screen.getByRole('button')).toBeInTheDocument(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Button/Primary.ts: -------------------------------------------------------------------------------- 1 | import { styled } from '@/styles'; 2 | 3 | import { ButtonVariant } from './constants'; 4 | import { Container, tag } from './styled'; 5 | 6 | export const PrimaryButton = styled(tag(Container, ButtonVariant.PRIMARY), { 7 | minHeight: '$md', 8 | color: '$white', 9 | trans: ['background-color'], 10 | padding: '10px 14px', 11 | boxSizing: 'border-box', 12 | whiteSpace: 'break-spaces', 13 | 14 | variants: { 15 | type: { 16 | info: { 17 | backgroundColor: '$primary', 18 | 19 | '&:hover': { 20 | backgroundColor: '$darkPrimary', 21 | }, 22 | }, 23 | 24 | warn: { 25 | backgroundColor: '$warn', 26 | 27 | '&:hover': { 28 | backgroundColor: '$darkWarn', 29 | }, 30 | }, 31 | 32 | subtle: { 33 | color: '$black', 34 | backgroundColor: 'inherit', 35 | trans: ['color'], 36 | 37 | '&:hover': { 38 | color: '#000', 39 | }, 40 | }, 41 | }, 42 | }, 43 | defaultVariants: { 44 | type: 'info', 45 | }, 46 | }); 47 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Button/Secondary.ts: -------------------------------------------------------------------------------- 1 | import { styled } from '@/styles'; 2 | 3 | import { ButtonVariant } from './constants'; 4 | import { Container, tag } from './styled'; 5 | 6 | export const SecondaryButton = styled(tag(Container, ButtonVariant.SECONDARY), { 7 | height: '$sm', 8 | border: '1px solid $fadedPrimary', 9 | color: '$primary', 10 | backgroundColor: '$white', 11 | boxShadow: '0 1px 2px $shadow2', 12 | trans: ['border-color'], 13 | 14 | '&:hover': { 15 | borderColor: '$primary', 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Button/constants.ts: -------------------------------------------------------------------------------- 1 | export enum ButtonVariant { 2 | PRIMARY = 'primary', 3 | SECONDARY = 'secondary', 4 | } 5 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Button/index.ts: -------------------------------------------------------------------------------- 1 | import { bindVariants } from '@/utils/variants'; 2 | 3 | import { ButtonVariant } from './constants'; 4 | import { PrimaryButton } from './Primary'; 5 | import { SecondaryButton } from './Secondary'; 6 | import { Container, Reset } from './styled'; 7 | 8 | const VARIANTS = { 9 | [ButtonVariant.PRIMARY]: PrimaryButton, 10 | [ButtonVariant.SECONDARY]: SecondaryButton, 11 | }; 12 | 13 | const Button = bindVariants(VARIANTS, ButtonVariant.PRIMARY); 14 | 15 | /** 16 | * A button with a label. 17 | * 18 | * @see {@link https://voiceflow.github.io/react-chat/?path=/story/core-button--primary-info} 19 | */ 20 | export default Object.assign(Button, { 21 | Variant: ButtonVariant, 22 | 23 | Reset, 24 | Container, 25 | Primary: PrimaryButton, 26 | Secondary: SecondaryButton, 27 | }); 28 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Button/styled.ts: -------------------------------------------------------------------------------- 1 | import { ClassName } from '@/constants'; 2 | import { tagFactory } from '@/hocs'; 3 | import { styled } from '@/styles'; 4 | 5 | export const tag = tagFactory(ClassName.BUTTON); 6 | 7 | export const Reset = styled('button', { 8 | border: 0, 9 | padding: 0, 10 | 11 | '&:focus': { 12 | outline: 0, 13 | }, 14 | 15 | '&:hover': { 16 | cursor: 'pointer', 17 | }, 18 | }); 19 | 20 | export const Container = styled(tag(Reset), { 21 | display: 'flex', 22 | justifyContent: 'center', 23 | alignItems: 'center', 24 | padding: '0 14px', 25 | borderRadius: '$1', 26 | typo: { weight: '$2' }, 27 | whiteSpace: 'nowrap', 28 | overflowWrap: 'anywhere', 29 | }); 30 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Card/Card.story.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import { MOCK_IMAGE } from '@/fixtures'; 4 | 5 | import Card from '.'; 6 | 7 | type Story = StoryObj; 8 | 9 | const meta: Meta = { 10 | title: 'Components/Card', 11 | component: Card, 12 | args: { 13 | title: 'Card Header', 14 | image: '', 15 | description: 16 | 'Lorem ipsum dolor sit amet consectetur, adipisicing elit. Culpa et aliquam sunt necessitatibus molestiae amet ipsum ut.', 17 | actions: [], 18 | }, 19 | }; 20 | export default meta; 21 | 22 | export const Simple: Story = {}; 23 | 24 | export const WithImage: Story = { 25 | args: { 26 | image: MOCK_IMAGE, 27 | }, 28 | }; 29 | 30 | export const Actionable: Story = { 31 | args: { 32 | ...WithImage.args, 33 | actions: [ 34 | { request: {} as any, name: 'First Button' }, 35 | { request: {} as any, name: 'Second Button' }, 36 | { request: {} as any, name: 'Third Button' }, 37 | ], 38 | }, 39 | }; 40 | 41 | export const WithLongLabels: Story = { 42 | args: { 43 | ...WithImage.args, 44 | actions: [ 45 | { request: {} as any, name: 'First Button with a very long long long wrapping label' }, 46 | { request: {} as any, name: 'Second Button with a shorter text' }, 47 | { request: {} as any, name: 'Third button, also with a shorter text' }, 48 | ], 49 | }, 50 | }; 51 | 52 | export const WithLongTitle: Story = { 53 | args: { 54 | ...WithImage.args, 55 | title: 'Long card title to wrap inside the card. Some more text to test the growth of card.', 56 | actions: [{ request: {} as any, name: 'First Button' }], 57 | }, 58 | }; 59 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Card/index.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useMemo } from 'react'; 2 | 3 | import Button from '@/components/Button'; 4 | import Image from '@/components/Image'; 5 | import { RuntimeStateAPIContext } from '@/contexts'; 6 | 7 | import { Container, Content, Description, Header, Link } from './styled'; 8 | import type { CardProps } from './types'; 9 | import { isValidHttpUrl } from './utils'; 10 | 11 | export type { CardProps } from './types'; 12 | 13 | const Card: React.FC = ({ title, description, image, actions = [] }) => { 14 | const runtime = useContext(RuntimeStateAPIContext); 15 | const isLink = isValidHttpUrl(description); 16 | 17 | const buttons = useMemo(() => actions.filter(({ name }) => !!name), [actions]); 18 | 19 | return ( 20 | 21 | {!!image && } 22 | 23 | {!!title &&
{title}
} 24 | {!!description && 25 | (isLink ? ( 26 | 27 | {description} 28 | 29 | ) : ( 30 | {description} 31 | ))} 32 | {buttons.map(({ request, name }, index) => ( 33 | 36 | ))} 37 |
38 |
39 | ); 40 | }; 41 | 42 | /** 43 | * A titled card with content and optional controls. 44 | * 45 | * @see {@link https://voiceflow.github.io/react-chat/?path=/story/components-card--simple} 46 | */ 47 | export default Object.assign(Card, { 48 | Container, 49 | }); 50 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Card/styled.ts: -------------------------------------------------------------------------------- 1 | import Button from '@/components/Button'; 2 | import { ClassName } from '@/constants'; 3 | import { tagFactory } from '@/hocs'; 4 | import { styled } from '@/styles'; 5 | 6 | export const CARD_WIDTH = 246; 7 | 8 | const tag = tagFactory(ClassName.CARD); 9 | 10 | export const Container = styled(tag('section'), { 11 | display: 'inline-flex', 12 | flexDirection: 'column', 13 | width: CARD_WIDTH, 14 | border: '1px solid #f1f1f1', 15 | borderRadius: '$2', 16 | boxSizing: 'content-box', 17 | overflow: 'hidden', 18 | backgroundColor: '$lightGrey', 19 | 20 | [`& ${Button.Container}`]: { 21 | width: '100%', 22 | color: '$primary', 23 | backgroundColor: '$white', 24 | boxShadow: '0 5px 8px -8px $shadow12, 0 2px 4px -3px $shadow12, 0 0 0 1px $shadow3, 0 1px 3px 1px $shadow1', 25 | marginBottom: '$2', 26 | trans: ['color', 'box-shadow'], 27 | 28 | '&:hover': { 29 | color: '$darkPrimary', 30 | backgroundColor: '$white', 31 | boxShadow: '0 5px 8px -8px $shadow12, 0 2px 4px -3px $shadow12, 0 0 0 1px $shadow4, 0 1px 4px 1px $shadow4', 32 | }, 33 | 34 | '&:first-of-type': { 35 | marginTop: '$3', 36 | }, 37 | 38 | '&:last-of-type': { 39 | marginBottom: 0, 40 | }, 41 | }, 42 | }); 43 | 44 | export const Content = styled(tag('main', 'content'), { 45 | padding: '$3', 46 | }); 47 | 48 | export const Header = styled(tag('h3', 'header'), { 49 | margin: '0 0 $1 0', 50 | typo: { weight: '$2' }, 51 | color: '$black', 52 | wordBreak: 'break-word', 53 | maxWidth: '100%', 54 | whiteSpace: 'break-spaces', 55 | }); 56 | 57 | export const Description = styled(tag('p', 'description'), { 58 | margin: 0, 59 | typo: { size: '$1' }, 60 | color: '$darkGrey', 61 | whiteSpace: 'normal', 62 | wordBreak: 'break-word', 63 | }); 64 | 65 | export const Link = styled(tag('a', 'link'), { 66 | margin: 0, 67 | typo: { size: '$1' }, 68 | whiteSpace: 'normal', 69 | overflow: 'hidden', 70 | textOverflow: 'ellipsis', 71 | color: 'rgb(93, 157, 245)', 72 | textDecoration: 'underline', 73 | pointerEvents: 'all', 74 | wordBreak: 'break-all', 75 | }); 76 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Card/types.ts: -------------------------------------------------------------------------------- 1 | import type { RuntimeAction } from '@voiceflow/sdk-runtime'; 2 | 3 | import type { Link } from './styled'; 4 | 5 | export interface CardActionProps { 6 | /** 7 | * The label that will appear on the button. 8 | */ 9 | name: string; 10 | 11 | /** 12 | * the request that will be sent by the runtime when the button is clicked. 13 | */ 14 | request: RuntimeAction; 15 | } 16 | 17 | export interface CardProps { 18 | /** 19 | * The title of the card. 20 | */ 21 | title: string; 22 | 23 | /** 24 | * Text content of the card. 25 | * If the string is a valid URL it will be rendered in a {@link Link}. 26 | */ 27 | description: string; 28 | 29 | /** 30 | * An image URL that will render at the top of the card if provided. 31 | */ 32 | image?: string | undefined | null; 33 | 34 | /** 35 | * A list of actions that will appear as button controls at the bottom of the card. 36 | */ 37 | actions?: CardActionProps[] | undefined; 38 | } 39 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Card/utils.ts: -------------------------------------------------------------------------------- 1 | export const isValidHttpUrl = (value: string) => { 2 | let url; 3 | 4 | try { 5 | url = new URL(value); 6 | } catch (_) { 7 | return false; 8 | } 9 | return url.protocol === 'http:' || url.protocol === 'https:'; 10 | }; 11 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Carousel/Carousel.story.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { useRef } from 'react'; 3 | 4 | import Avatar from '@/components/Avatar'; 5 | import Chat from '@/components/Chat'; 6 | import SystemResponse from '@/components/SystemResponse'; 7 | import { MOCK_IMAGE, VF_ICON } from '@/fixtures'; 8 | import { ChatWidget } from '@/views'; 9 | 10 | import Carousel from '.'; 11 | 12 | const meta: Meta = { 13 | component: Carousel, 14 | title: 'Components/Carousel', 15 | }; 16 | type Story = StoryObj; 17 | 18 | export default meta; 19 | 20 | const IMAGE = MOCK_IMAGE; 21 | const FIRST_CARD = { 22 | title: 'First Card', 23 | description: 'Lorem ipsum dolor sit amet', 24 | image: IMAGE, 25 | actions: [ 26 | { request: {} as any, name: 'First Button' }, 27 | { request: {} as any, name: 'Second Button' }, 28 | { request: {} as any, name: 'Third Button' }, 29 | ], 30 | }; 31 | 32 | const MULTIPLE_CARDS = [ 33 | FIRST_CARD, 34 | { 35 | title: 'Second Card', 36 | description: 37 | 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Exercitationem voluptas perspiciatis est quis dolores!', 38 | image: IMAGE, 39 | }, 40 | { 41 | title: 'Third Card with a long title that wraps', 42 | description: 'Lorem ipsum dolor sit amet consectetur adipisicing elit.', 43 | actions: [ 44 | { request: {} as any, name: 'Fourth Button with a long label that wraps' }, 45 | { request: {} as any, name: 'Fifth Button' }, 46 | ], 47 | }, 48 | ]; 49 | 50 | export const SingleCard: Story = { 51 | args: { 52 | cards: [FIRST_CARD], 53 | }, 54 | }; 55 | 56 | export const MultipleCards: Story = { 57 | args: { 58 | cards: MULTIPLE_CARDS, 59 | }, 60 | }; 61 | 62 | export const ControlsTemplate: Story = { 63 | args: { 64 | cards: MULTIPLE_CARDS, 65 | }, 66 | 67 | render: (args) => { 68 | const containerRef = useRef(null); 69 | const controlsRef = useRef(null); 70 | 71 | return ( 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | ); 82 | }, 83 | }; 84 | 85 | export const WithControls: Story = { 86 | args: { 87 | cards: MULTIPLE_CARDS, 88 | }, 89 | }; 90 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Carousel/CarouselButton.tsx: -------------------------------------------------------------------------------- 1 | import type { MouseEventHandler } from 'react'; 2 | import { forwardRef } from 'react'; 3 | 4 | import Icon from '@/components/Icon'; 5 | 6 | import { ButtonContainer } from './styled'; 7 | 8 | export interface CarouselButtonProps { 9 | /** 10 | * The end of the container where the button will be rendered. 11 | */ 12 | alignment: 'left' | 'right'; 13 | 14 | /** 15 | * If true then the button will be visible, otherwise hidden. 16 | */ 17 | visible: boolean; 18 | 19 | /** 20 | * The buttons will be centered vertically based on the height of this element. 21 | */ 22 | containerEl: HTMLElement; 23 | 24 | /** 25 | * A click handler for the button. 26 | */ 27 | onClick?: MouseEventHandler; 28 | } 29 | 30 | /** 31 | * A button used to scroll to the previous or next Card in a Carousel. 32 | */ 33 | const CarouselButton = forwardRef( 34 | ({ onClick, alignment, visible, containerEl }, ref) => ( 35 | 44 | 45 | 46 | ) 47 | ); 48 | 49 | export default CarouselButton; 50 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Carousel/constants.ts: -------------------------------------------------------------------------------- 1 | import { CARD_WIDTH } from '@/components/Card/styled'; 2 | 3 | import { CAROUSEL_GUTTER_WIDTH } from './styled'; 4 | 5 | export const CARD_WITH_BORDER_WIDTH = CARD_WIDTH + 2; 6 | export const PREVIOUS_CONTROL_BOUNDARY = CARD_WITH_BORDER_WIDTH / 3; 7 | export const NEXT_CONTROL_BOUNDARY = CARD_WITH_BORDER_WIDTH + CAROUSEL_GUTTER_WIDTH + PREVIOUS_CONTROL_BOUNDARY; 8 | export const CARD_WITH_GUTTER_WIDTH = CARD_WITH_BORDER_WIDTH + CAROUSEL_GUTTER_WIDTH; 9 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Carousel/hooks.ts: -------------------------------------------------------------------------------- 1 | import type { RefObject } from 'react'; 2 | import { useEffect, useRef, useState } from 'react'; 3 | 4 | import type { CardProps } from '../Card'; 5 | import { CARD_WITH_GUTTER_WIDTH, NEXT_CONTROL_BOUNDARY, PREVIOUS_CONTROL_BOUNDARY } from './constants'; 6 | import { CAROUSEL_GUTTER_WIDTH } from './styled'; 7 | 8 | export const useScrollTo = 9 | (ref: RefObject | undefined, getNextIndex: (el: T) => number) => 10 | () => { 11 | const el = ref?.current; 12 | if (!el) return; 13 | 14 | const index = getNextIndex(el); 15 | 16 | el.scrollTo({ 17 | left: index && index * CARD_WITH_GUTTER_WIDTH, 18 | behavior: 'smooth', 19 | }); 20 | }; 21 | 22 | export const useScrollObserver = ( 23 | containerRef: RefObject | undefined, 24 | controlsRef: RefObject | undefined, 25 | cards: CardProps[] 26 | ) => { 27 | const [showPreviousButton, setShowPreviousButton] = useState(false); 28 | const [showNextButton, setShowNextButton] = useState(false); 29 | const previousButtonRef = useRef(null); 30 | const nextButtonRef = useRef(null); 31 | const hasMultipleCards = cards.length > 1; 32 | 33 | useEffect(() => { 34 | if (!controlsRef?.current || !hasMultipleCards) return; 35 | 36 | setShowNextButton(true); 37 | }, []); 38 | 39 | useEffect(() => { 40 | const containerEl = containerRef?.current; 41 | if (!containerEl || !hasMultipleCards) return undefined; 42 | 43 | const trackWidth = CARD_WITH_GUTTER_WIDTH * cards.length - CAROUSEL_GUTTER_WIDTH; 44 | 45 | const handleScroll = (): void => { 46 | const { scrollLeft } = containerEl; 47 | 48 | setShowPreviousButton(scrollLeft >= PREVIOUS_CONTROL_BOUNDARY); 49 | setShowNextButton(scrollLeft <= trackWidth - NEXT_CONTROL_BOUNDARY); 50 | }; 51 | 52 | containerEl.addEventListener('scroll', handleScroll); 53 | 54 | return () => { 55 | containerEl.removeEventListener('scroll', handleScroll); 56 | }; 57 | }, []); 58 | 59 | return { 60 | previousButtonRef, 61 | nextButtonRef, 62 | showPreviousButton, 63 | showNextButton, 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Carousel/styled.ts: -------------------------------------------------------------------------------- 1 | import Card from '@/components/Card'; 2 | import Icon from '@/components/Icon'; 3 | import { ClassName } from '@/constants'; 4 | import { tagFactory } from '@/hocs'; 5 | import { styled } from '@/styles'; 6 | 7 | const BUTTON_SIZE = 42; 8 | export const CAROUSEL_GUTTER_WIDTH = 12; 9 | 10 | const tag = tagFactory(ClassName.CAROUSEL); 11 | 12 | export const ButtonContainer = styled(tag('span', 'button'), { 13 | position: 'absolute', 14 | zIndex: 1, 15 | 16 | display: 'flex', 17 | justifyContent: 'center', 18 | alignItems: 'center', 19 | borderRadius: '$round', 20 | trans: ['background-color', 'box-shadow', 'opacity'], 21 | 22 | height: BUTTON_SIZE, 23 | width: BUTTON_SIZE, 24 | cursor: 'pointer', 25 | backgroundColor: '$white', 26 | color: '$black', 27 | boxShadow: '0 1px 3px 1px $shadow1, 0 0 0 1px $shadow3, 0 2px 4px -3px $shadow12, 0 5px 8px -8px $shadow12', 28 | border: 'none', 29 | 30 | [`& ${Icon.Frame}`]: { 31 | height: '$xxs', 32 | width: '$xxs', 33 | color: 'rgba(0,0,0,0.6)', 34 | trans: ['color'], 35 | }, 36 | 37 | '&:hover': { 38 | boxShadow: '0 1px 4px 1px $shadow4, 0 0 0 1px $shadow4, 0 2px 4px -3px $shadow12, 0 5px 8px -8px $shadow12', 39 | }, 40 | 41 | '&:active': { 42 | boxShadow: '0 1px 4px 1px $shadow8, 0 0 0 1px $shadow4, 0 2px 4px -3px $shadow12, 0 5px 8px -8px $shadow12', 43 | }, 44 | 45 | [` 46 | &:hover ${Icon.Frame}, 47 | &:active ${Icon.Frame} 48 | `]: { 49 | color: 'rgba(0,0,0,0.8)', 50 | }, 51 | 52 | variants: { 53 | visible: { 54 | true: { 55 | opacity: 1, 56 | pointerEvents: 'auto', 57 | }, 58 | false: { 59 | opacity: 0, 60 | pointerEvents: 'none', 61 | }, 62 | }, 63 | alignment: { 64 | left: { 65 | left: 48 - BUTTON_SIZE / 2, 66 | }, 67 | right: { 68 | right: 70 - BUTTON_SIZE / 2, 69 | 70 | [`& ${Icon.Frame}`]: { 71 | transform: 'scaleX(-1)', 72 | }, 73 | }, 74 | }, 75 | }, 76 | }); 77 | 78 | export const Container = styled(tag('div'), { 79 | display: 'flex', 80 | whiteSpace: 'nowrap', 81 | 82 | [`& ${Card.Container}`]: { 83 | height: 'fit-content', 84 | flexShrink: 0, 85 | marginLeft: CAROUSEL_GUTTER_WIDTH, 86 | 87 | '&:first-of-type': { 88 | marginLeft: 0, 89 | }, 90 | }, 91 | }); 92 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Chat/hooks.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import relativeTime from 'dayjs/plugin/relativeTime'; 3 | import { useMemo } from 'react'; 4 | 5 | import type { Nullish } from '@/types'; 6 | 7 | dayjs.extend(relativeTime); 8 | 9 | export const useTimestamp = (startTime?: Nullish) => { 10 | return useMemo(() => { 11 | if (!startTime) return null; 12 | 13 | const start = dayjs(startTime); 14 | const now = dayjs(); 15 | 16 | switch (true) { 17 | case now.isSame(start, 'day'): 18 | return 'Today'; 19 | case now.subtract(1, 'day').isSame(start, 'day'): 20 | return 'Yesterday'; 21 | default: 22 | return start.fromNow(); 23 | } 24 | }, [startTime]); 25 | }; 26 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/ChatInput/AudioInputButton.tsx: -------------------------------------------------------------------------------- 1 | import Icon from '../Icon'; 2 | import { AutoInputButtonContainer } from './styled'; 3 | 4 | interface AudioInputButtonProps { 5 | onStop: () => void; 6 | onStart: () => void; 7 | listening: boolean; 8 | processing: boolean; 9 | initializing: boolean; 10 | } 11 | 12 | export const AudioInputButton: React.FC = ({ 13 | onStop, 14 | onStart, 15 | listening, 16 | processing, 17 | initializing, 18 | }) => { 19 | return ( 20 | 25 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/ChatInput/ChatInput.story.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import ChatInput from '.'; 4 | 5 | type Story = StoryObj; 6 | 7 | const meta: Meta = { 8 | title: 'Components/Chat/ChatInput', 9 | component: ChatInput, 10 | args: { 11 | value: '', 12 | placeholder: '', 13 | }, 14 | parameters: { 15 | controls: { include: ['value', 'placeholder', 'onValueChange'] }, 16 | }, 17 | render: (args) => , 18 | }; 19 | 20 | export default meta; 21 | 22 | export const Default: Story = {}; 23 | 24 | export const Placeholder: Story = { 25 | args: { 26 | placeholder: 'Message…', 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Feedback/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { thumbsUp as ThumbsUp } from '@/assets/svg'; 4 | import { FeedbackName } from '@/contexts/RuntimeContext/useRuntimeAPI'; 5 | 6 | import { Button, ButtonsContainer, Container, Description } from './styled'; 7 | 8 | export interface FeedbackProps extends React.PropsWithChildren { 9 | /** 10 | * Alternative question to ask the user 11 | * 12 | * @default 'Was this helpful?' 13 | */ 14 | question?: string; 15 | 16 | onClick: (feedback: FeedbackName) => void; 17 | } 18 | 19 | const Feedback: React.FC = ({ question = 'Was this helpful?', onClick, ...props }) => { 20 | const [active, setActive] = React.useState(null); 21 | 22 | const handleClick = (feedback: FeedbackName) => { 23 | if (feedback === active) return; 24 | onClick(feedback); 25 | setActive(feedback); 26 | }; 27 | 28 | return ( 29 | 30 | {question} 31 | 32 | 39 | 46 | 47 | 48 | ); 49 | }; 50 | 51 | export default Feedback; 52 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Feedback/styled.ts: -------------------------------------------------------------------------------- 1 | import { ClassName } from '@/constants'; 2 | import { tagFactory } from '@/hocs'; 3 | import { styled } from '@/styles'; 4 | 5 | const tag = tagFactory(ClassName.FEEDBACK); 6 | 7 | export const Container = styled(tag('div'), { 8 | display: 'inline-flex', 9 | alignItems: 'center', 10 | boxSizing: 'border-box', 11 | marginTop: '8.5px', 12 | }); 13 | 14 | export const Description = styled(tag('div', 'description'), { 15 | color: '$darkGrey', 16 | marginRight: 4, 17 | lineHeight: 17, 18 | typo: { 19 | size: 12, 20 | }, 21 | }); 22 | 23 | export const ButtonsContainer = styled(tag('div', 'buttons'), { 24 | display: 'flex', 25 | gap: 4, 26 | }); 27 | 28 | export const Button = styled(tag('button', 'button'), { 29 | display: 'inline-flex', 30 | backgroundColor: 'transparent', 31 | border: 0, 32 | borderRadius: '$round', 33 | 34 | width: 24, 35 | height: 24, 36 | padding: 0, 37 | margin: 0, 38 | cursor: 'pointer', 39 | 40 | variants: { 41 | active: { 42 | false: { 43 | color: 'rgb(115 115 118 / 85%)', 44 | '&:hover': { 45 | color: 'rgb(115 115 118 / 100%)', 46 | }, 47 | }, 48 | 49 | true: { 50 | color: '$white', 51 | backgroundColor: '$primary', 52 | }, 53 | }, 54 | orientation: { 55 | positive: { 56 | transform: 'none', 57 | }, 58 | negative: { 59 | transform: 'rotate(180deg)', 60 | }, 61 | }, 62 | }, 63 | 64 | defaultVariants: { 65 | active: false, 66 | orientation: 'positive', 67 | }, 68 | }); 69 | -------------------------------------------------------------------------------- /packages/react-chat/src/components/Footer/Footer.story.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | 3 | import Chat from '@/components/Chat'; 4 | 5 | import Footer from '.'; 6 | 7 | type Story = StoryObj; 8 | 9 | const meta: Meta = { 10 | title: 'Components/Chat/Footer', 11 | component: Footer, 12 | argTypes: { 13 | onStart: { action: 'onStart' }, 14 | onSend: { action: 'send' }, 15 | }, 16 | args: { 17 | hasEnded: false, 18 | withWatermark: false, 19 | }, 20 | render: (args) => ( 21 | 22 |