├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── docs ├── ai-assistants-light.png └── ai-assistants.png ├── examples └── next-js-example │ ├── .eslintrc.json │ ├── README.md │ ├── next.config.mjs │ ├── package-lock.json │ ├── package.json │ ├── postcss.config.js │ ├── project.json │ ├── public │ ├── next.svg │ └── vercel.svg │ ├── src │ ├── app │ │ ├── favicon.ico │ │ ├── globals.css │ │ ├── layout.tsx │ │ ├── page.tsx │ │ └── token │ │ │ └── route.ts │ └── components │ │ └── ExampleForm.tsx │ ├── tailwind.config.ts │ └── tsconfig.json ├── nx.json ├── package-lock.json ├── package.json ├── packages ├── assistants-react │ ├── README.md │ ├── package.json │ ├── src │ │ ├── components │ │ │ ├── AssistantChat.tsx │ │ │ ├── AssistantChatDialog.tsx │ │ │ ├── AssistantChatLogProps.tsx │ │ │ ├── ChatMessageWrapper.tsx │ │ │ ├── TypingIndicator.tsx │ │ │ └── chat-composer-plugins │ │ │ │ ├── EnterKeySubmitPlugin.tsx │ │ │ │ └── SendButtonPlugin.tsx │ │ ├── hooks │ │ │ └── useAssistant.ts │ │ └── index.ts │ ├── tests │ │ └── index.test.ts │ └── tsconfig.json └── assistants │ ├── README.md │ ├── package.json │ ├── src │ └── index.ts │ ├── tests │ └── index.test.ts │ └── tsconfig.json ├── tools └── build.js └── vitest.workspace.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | 133 | # dependencies 134 | /node_modules 135 | /.pnp 136 | .pnp.js 137 | .yarn/install-state.gz 138 | 139 | # testing 140 | /coverage 141 | 142 | # next.js 143 | .next/ 144 | out/ 145 | 146 | # production 147 | /build 148 | 149 | # misc 150 | .DS_Store 151 | *.pem 152 | 153 | # debug 154 | npm-debug.log* 155 | yarn-debug.log* 156 | yarn-error.log* 157 | 158 | # local env files 159 | .env*.local 160 | 161 | # vercel 162 | .vercel 163 | 164 | # typescript 165 | *.tsbuildinfo 166 | next-env.d.ts 167 | 168 | .nx/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "tabWidth": 2 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Twilio Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Twilio AI AssistantsTwilio AI Assistants

2 |

Twilio AI Assistants — JavaScript & React SDK

3 | 4 | > [!NOTE] 5 | > Twilio AI Assistants is a [Twilio Alpha](https://twilioalpha.com) project that is currently in Developer Preview. If you would like to try AI Assistants, [join the waitlist](https://twilioalpha.com/ai-assistants). 6 | 7 | ## Packages 8 | 9 | - [`@twilio-alpha/assistants-react`](./packages/assistants-react/) — Library to add an AI Assistant to your React application 10 | - [`@twilio-alpha/assistants`](./packages/assistants/) — Base JavaScript library to bring your own framework 11 | 12 | ## Examples 13 | 14 | - [Next.js demo](./examples/assistant-js-example/src/app/page.tsx); 15 | 16 | ## Contributing 17 | 18 | This repo uses npm workspaces. 19 | -------------------------------------------------------------------------------- /docs/ai-assistants-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/ai-assistants-js/e32f67e0a4ea7a0082589bcc3f76b101c0d82ee3/docs/ai-assistants-light.png -------------------------------------------------------------------------------- /docs/ai-assistants.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/ai-assistants-js/e32f67e0a4ea7a0082589bcc3f76b101c0d82ee3/docs/ai-assistants.png -------------------------------------------------------------------------------- /examples/next-js-example/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /examples/next-js-example/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | # or 14 | bun dev 15 | ``` 16 | 17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 18 | 19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /examples/next-js-example/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | async headers() { 4 | return [ 5 | { 6 | // matching all API routes 7 | source: "/token", 8 | headers: [ 9 | { key: "Access-Control-Allow-Credentials", value: "true" }, 10 | { key: "Access-Control-Allow-Origin", value: "*" }, 11 | { 12 | key: "Access-Control-Allow-Methods", 13 | value: "GET,OPTIONS,PATCH,DELETE,POST,PUT", 14 | }, 15 | { 16 | key: "Access-Control-Allow-Headers", 17 | value: 18 | "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version", 19 | }, 20 | ], 21 | }, 22 | ]; 23 | }, 24 | }; 25 | 26 | export default nextConfig; 27 | -------------------------------------------------------------------------------- /examples/next-js-example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-js-example", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@twilio-alpha/assistants-react": "^0.0.1", 13 | "next": "14.1.3", 14 | "react": "^18.2", 15 | "react-dom": "^18.2", 16 | "twilio": "^5.0.0" 17 | }, 18 | "devDependencies": { 19 | "@types/node": "^20", 20 | "@types/react": "^18", 21 | "@types/react-dom": "^18", 22 | "autoprefixer": "^10.0.1", 23 | "eslint": "^8", 24 | "eslint-config-next": "14.1.3", 25 | "postcss": "^8", 26 | "tailwindcss": "^3.3.0", 27 | "typescript": "^5" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /examples/next-js-example/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /examples/next-js-example/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 3 | "name": "next-js-example", 4 | "projectType": "application", 5 | "sourceRoot": "examples/next-js-example", 6 | "targets": { 7 | "watch": { 8 | "executor": "nx:run-commands", 9 | "options": { 10 | "commands": [ 11 | "next dev", 12 | "nx watch --projects=next-js-example --includeDependentProjects -- nx run-many -t build -p \\$NX_PROJECT_NAME --exclude=next-js-example" 13 | ], 14 | "cwd": "examples/next-js-example", 15 | "parallel": true 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /examples/next-js-example/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/next-js-example/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/next-js-example/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/twilio-labs/ai-assistants-js/e32f67e0a4ea7a0082589bcc3f76b101c0d82ee3/examples/next-js-example/src/app/favicon.ico -------------------------------------------------------------------------------- /examples/next-js-example/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /examples/next-js-example/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const inter = Inter({ subsets: ["latin"] }); 6 | 7 | export const metadata: Metadata = { 8 | title: "Create Next App", 9 | description: "Generated by create next app", 10 | }; 11 | 12 | export default function RootLayout({ 13 | children, 14 | }: Readonly<{ 15 | children: React.ReactNode; 16 | }>) { 17 | return ( 18 | 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /examples/next-js-example/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ExampleForm, ExampleFormData } from "@/components/ExampleForm"; 4 | import { AssistantChat } from "@twilio-alpha/assistants-react"; 5 | import { useEffect, useState } from "react"; 6 | 7 | export default function Home() { 8 | const [token, setToken] = useState(""); 9 | const [conversationSid, setConversationSid] = useState(); 10 | const [formData, setFormData] = useState({ 11 | firstName: "", 12 | lastName: "", 13 | email: "", 14 | interest: "", 15 | company: "", 16 | }); 17 | 18 | useEffect(() => { 19 | setConversationSid(localStorage.getItem("CONVERSATIONS_SID") || undefined); 20 | fetch("/token") 21 | .then((resp) => resp.json()) 22 | .then(({ token }) => { 23 | setToken(token); 24 | }); 25 | }, []); 26 | 27 | function saveConversationSid(sid: string) { 28 | localStorage.setItem("CONVERSATIONS_SID", sid); 29 | } 30 | 31 | function fillForm(data: ExampleFormData) { 32 | console.log("new data", data); 33 | setFormData(data); 34 | } 35 | 36 | return ( 37 |
38 |

Assistants Demo

39 |
40 | 47 |
48 | 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /examples/next-js-example/src/app/token/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import * as Twilio from "twilio"; 3 | 4 | export async function GET(req: NextRequest) { 5 | if (process.env.NODE_ENV === "production") { 6 | return NextResponse.json({ error: "not for production" }, { status: 401 }); 7 | } 8 | 9 | if ( 10 | typeof process.env.TWILIO_ACCOUNT_SID !== "string" || 11 | typeof process.env.TWILIO_API_KEY_SID !== "string" || 12 | typeof process.env.TWILIO_API_KEY_SECRET !== "string" 13 | ) { 14 | return NextResponse.json({ error: "Missing credentials" }, { status: 500 }); 15 | } 16 | 17 | let AccessToken = Twilio.jwt.AccessToken; 18 | let token = new AccessToken( 19 | process.env.TWILIO_ACCOUNT_SID, 20 | process.env.TWILIO_API_KEY_SID, 21 | process.env.TWILIO_API_KEY_SECRET, 22 | { 23 | identity: "email:demo-chat@example.com", 24 | ttl: 3600, 25 | } 26 | ); 27 | 28 | let grant = new AccessToken.ChatGrant({ 29 | serviceSid: process.env.SERVICE_SID, 30 | }); 31 | token.addGrant(grant); 32 | return NextResponse.json({ token: token.toJwt() }); 33 | } 34 | -------------------------------------------------------------------------------- /examples/next-js-example/src/components/ExampleForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | export type ExampleFormData = { 4 | firstName: string; 5 | lastName: string; 6 | email: string; 7 | interest: string; 8 | company: string; 9 | }; 10 | 11 | export type ExampleFormProps = { 12 | data: ExampleFormData; 13 | }; 14 | 15 | export function ExampleForm({ data }: ExampleFormProps) { 16 | return ( 17 |
18 |
19 |
20 | 26 |
27 | 35 |
36 |
37 | 38 |
39 | 45 |
46 | 54 |
55 |
56 | 57 |
58 | 64 |
65 | 73 |
74 |
75 | 76 |
77 | 83 |
84 | 91 |
92 |
93 | 94 |
95 | 101 |
102 | 109 |
110 |
111 |
112 |
113 | ); 114 | } 115 | -------------------------------------------------------------------------------- /examples/next-js-example/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | content: [ 5 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 8 | ], 9 | theme: { 10 | extend: { 11 | backgroundImage: { 12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", 13 | "gradient-conic": 14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", 15 | }, 16 | }, 17 | }, 18 | plugins: [], 19 | }; 20 | export default config; 21 | -------------------------------------------------------------------------------- /examples/next-js-example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/nx/schemas/nx-schema.json", 3 | "targetDefaults": { 4 | "dev": { 5 | "cache": true, 6 | "dependsOn": ["^build"] 7 | }, 8 | "build": { 9 | "cache": true, 10 | "dependsOn": ["^build"] 11 | }, 12 | "test": { 13 | "cache": true, 14 | "dependsOn": ["^build"] 15 | }, 16 | "lib-pack": { 17 | "dependsOn": ["^clean", "^build"] 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "assistants", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "vitest", 8 | "start:nextjs": "npm run dev -w next-js-example", 9 | "lib-pack": "nx run-many -t lib-pack --exclude=next-js-example" 10 | }, 11 | "author": "", 12 | "license": "MIT", 13 | "workspaces": [ 14 | "packages/*", 15 | "examples/*" 16 | ], 17 | "devDependencies": { 18 | "nx": "^18.2.3", 19 | "react": "18.2", 20 | "react-dom": "18.2", 21 | "rimraf": "^5.0.5", 22 | "vitest": "^1.4.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/assistants-react/README.md: -------------------------------------------------------------------------------- 1 | # Twilio AI Assistants - React SDK 2 | 3 | > [!NOTE] 4 | > Twilio AI Assistants is a [Twilio Alpha](https://twilioalpha.com) project that is currently in Developer Preview. If you would like to try AI Assistants, [join the waitlist](https://twilioalpha.com/ai-assistants). 5 | 6 | ## Installation 7 | 8 | > [!NOTE] 9 | > Requires `react` & `react-dom` to be installed 10 | 11 | ```bash 12 | npm install @twilio-alpha/assistants-react @twilio/conversations 13 | ``` 14 | 15 | ## Usage 16 | 17 | ### Basic usage 18 | 19 | ```jsx 20 | import { AssistantChat } from "@twilio-alpha/assistants-react"; 21 | 22 | export function App() { 23 | return ; 24 | } 25 | ``` 26 | 27 | ### Using with your own UI 28 | 29 | If you want to reuse your own UI you can use the `useAssistant` hook instead. 30 | 31 | ```jsx 32 | import { useAssistant } from "@twilio-alpha/assistants-react"; 33 | 34 | export function App() { 35 | const { messages, sendMessage } = useAssistant("", { 36 | assistantSid: "", 37 | }); 38 | 39 | function send(evt) { 40 | evt.preventDefault(); 41 | sendMessage(evt.target.message.value); 42 | } 43 | 44 | return ( 45 | <> 46 |
    47 | {messages.map((message) => { 48 | return ( 49 |
  • 50 | {message.author}: {message.body} 51 |
  • 52 | ); 53 | })} 54 |
55 |
56 | 57 | 58 |
59 | 60 | ); 61 | } 62 | ``` 63 | 64 | ### Reusing existing session 65 | 66 | By default every time the `AssistantChat` component gets rendered it will create a new Twilio Conversation (aka a session). If you want to re-use the existing one between page refreshes you can use the `conversationSid` property and the `onConversationSetup` handler to persist the session. This gives you full control on how you want to manage the session. If there is no `conversationSid`, the component will automatically create one. 67 | 68 | Example code: 69 | 70 | ```jsx 71 | import { useEffect, useState } from "react"; 72 | 73 | export function App() { 74 | const [conversationSid, setConversationSid] = useState(); 75 | 76 | useEffect(() => { 77 | // fetches an existing conversation SID from the local storage if it exists 78 | setConversationSid(localStorage.getItem("CONVERSATIONS_SID") || undefined); 79 | }, []); 80 | 81 | function saveConversationSid(sid: string) { 82 | localStorage.setItem("CONVERSATIONS_SID", sid); 83 | } 84 | 85 | return ( 86 | 93 | ); 94 | } 95 | ``` 96 | 97 | ## License 98 | 99 | MIT 100 | -------------------------------------------------------------------------------- /packages/assistants-react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@twilio-alpha/assistants-react", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "module": "dist/index.mjs", 8 | "files": [ 9 | "README.md", 10 | "dist/" 11 | ], 12 | "scripts": { 13 | "clean": "rimraf dist/", 14 | "build": "node ../../tools/build.js src/index.ts && tsc", 15 | "test": "vitest", 16 | "lib-pack": "npm pack" 17 | }, 18 | "keywords": [], 19 | "author": "Twilio Alpha ", 20 | "license": "MIT", 21 | "devDependencies": { 22 | "@types/react": "^18.2.65", 23 | "@types/react-dom": "^18.2.21", 24 | "esbuild": "^0.20.1", 25 | "esbuild-node-externals": "^1.13.0", 26 | "react": "^18.2.0", 27 | "react-dom": "^18.2.0", 28 | "typescript": "^5.4.2" 29 | }, 30 | "peerDependencies": { 31 | "react": ">=18" 32 | }, 33 | "dependencies": { 34 | "@twilio-alpha/assistants": "^0.0.1", 35 | "@twilio-paste/anchor": "^12.1.0", 36 | "@twilio-paste/avatar": "^9.1.0", 37 | "@twilio-paste/box": "^10.3.0", 38 | "@twilio-paste/button": "^14.1.0", 39 | "@twilio-paste/chat-composer": "^5.1.1", 40 | "@twilio-paste/chat-log": "^5.2.1", 41 | "@twilio-paste/flex": "^8.1.0", 42 | "@twilio-paste/form": "^11.1.1", 43 | "@twilio-paste/icons": "^12.3.0", 44 | "@twilio-paste/input": "^9.1.2", 45 | "@twilio-paste/lexical-library": "^4.1.0", 46 | "@twilio-paste/minimizable-dialog": "^4.1.1", 47 | "@twilio-paste/skeleton-loader": "^6.1.1", 48 | "@twilio-paste/stack": "^8.1.0", 49 | "@twilio-paste/status": "^2.1.1", 50 | "@twilio-paste/text": "^10.1.1", 51 | "@twilio-paste/theme": "^11.1.0", 52 | "react-markdown": "^9.0.1", 53 | "zod": "^3.22.4" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/assistants-react/src/components/AssistantChat.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { AssistantChatDialog } from "./AssistantChatDialog"; 3 | import useAssistant, { UseAssistantOptions } from "../hooks/useAssistant"; 4 | 5 | export interface AssistantChatProps { 6 | /** 7 | * A valid JWT with a Twilio Conversations grant 8 | * - {@link https://www.twilio.com/docs/iam/access-tokens#create-an-access-token-for-conversations | Learn more} 9 | */ 10 | token: string; 11 | 12 | /** 13 | * The SID of your Twilio AI Assistant 14 | */ 15 | assistantSid: string; 16 | 17 | /** 18 | * A conversation SID if you want to connect to an existing conversation 19 | */ 20 | conversationSid?: string; 21 | 22 | /** 23 | * Callback for when the client has connected to a conversation. 24 | * This can be used in conjunction with the `conversationSid` property if 25 | * you want to save & reconnect to a conversation between page reloads 26 | * 27 | * @param conversationSid 28 | * @returns 29 | */ 30 | onConversationSetup?: (conversationSid: string) => void; 31 | 32 | /** 33 | * Handlers for any UI Tools you want the Twilio AI Assistant to be able to use. 34 | * This requires for the UI Tools to be properly configured. 35 | * - {@link https://www.twilio.com/docs/alpha/ai-assistants/code-samples/ui-tools | Learn more about UI tools} 36 | */ 37 | toolHandlers?: UseAssistantOptions["toolHandlers"]; 38 | } 39 | 40 | /** 41 | * Renders a full popup chat for you AI Assistant, incl. both a button and 42 | * the chat dialog when the button gets pressed. 43 | * 44 | * @example 45 | * 46 | * 47 | * @param {AssistantChatProps} props 48 | * @returns 49 | */ 50 | export function AssistantChat({ 51 | token, 52 | assistantSid, 53 | toolHandlers, 54 | conversationSid, 55 | onConversationSetup, 56 | }: AssistantChatProps) { 57 | if (!token || !assistantSid) { 58 | return <>; 59 | } 60 | 61 | const assistant = useAssistant(token, { 62 | assistantSid, 63 | toolHandlers, 64 | conversationSid, 65 | }); 66 | 67 | useEffect(() => { 68 | if ( 69 | conversationSid !== assistant.conversationSid && 70 | typeof assistant.conversationSid === "string" && 71 | typeof onConversationSetup === "function" 72 | ) { 73 | onConversationSetup(assistant.conversationSid); 74 | } 75 | }, [conversationSid, assistant.conversationSid]); 76 | 77 | return ; 78 | } 79 | -------------------------------------------------------------------------------- /packages/assistants-react/src/components/AssistantChatDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Theme } from "@twilio-paste/theme"; 2 | import { ChatIcon } from "@twilio-paste/icons/esm/ChatIcon"; 3 | import { 4 | MinimizableDialog, 5 | MinimizableDialogButton, 6 | MinimizableDialogContainer, 7 | MinimizableDialogContent, 8 | MinimizableDialogHeader, 9 | } from "@twilio-paste/minimizable-dialog"; 10 | import React, { useState } from "react"; 11 | import { Flex } from "@twilio-paste/flex"; 12 | import { StatusBadge, StatusBadgeVariants } from "@twilio-paste/status"; 13 | import { Box } from "@twilio-paste/box"; 14 | import { ConnectionState } from "@twilio/conversations"; 15 | import { match } from "ts-pattern"; 16 | 17 | import { ChatComposer, ChatComposerProps } from "@twilio-paste/chat-composer"; 18 | import { $getRoot, ClearEditorPlugin } from "@twilio-paste/lexical-library"; 19 | import { SendButtonPlugin } from "./chat-composer-plugins/SendButtonPlugin"; 20 | import { EnterKeySubmitPlugin } from "./chat-composer-plugins/EnterKeySubmitPlugin"; 21 | import { 22 | AssistantChatLogProps, 23 | AssistantChatLog, 24 | } from "./AssistantChatLogProps"; 25 | 26 | export type AssistantChatDialogProps = {} & AssistantChatLogProps; 27 | 28 | export function AssistantChatDialog(props: AssistantChatDialogProps) { 29 | const { sendMessage, state } = props; 30 | 31 | const [messageInput, setMessageInput] = useState(""); 32 | 33 | const connectionString = match(state) 34 | .with("connected", () => "ConnectivityAvailable") 35 | .with("disconnected", () => "ConnectivityOffline") 36 | .otherwise(() => "ConnectivityBusy"); 37 | 38 | const isLoading = state === "connected" ? false : true; 39 | 40 | const handleComposerChange: ChatComposerProps["onChange"] = (editorState) => { 41 | editorState.read(() => { 42 | const text = $getRoot().getTextContent(); 43 | setMessageInput(text); 44 | }); 45 | }; 46 | 47 | const submitMessage = (): void => { 48 | if (messageInput === "") return; 49 | sendMessage(messageInput); 50 | }; 51 | 52 | return ( 53 | 54 | 55 | 60 | 61 | 62 | 63 | 64 | 65 | Live chat 66 | 67 | {state} 68 | 69 | 70 | 71 | 72 | 73 | 84 | { 89 | throw error; 90 | }, 91 | }} 92 | ariaLabel="Message" 93 | placeholder="Type here..." 94 | onChange={handleComposerChange} 95 | > 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /packages/assistants-react/src/components/AssistantChatLogProps.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { Box, BoxProps } from "@twilio-paste/box"; 3 | import { ChatBookend, ChatBookendItem, ChatLog } from "@twilio-paste/chat-log"; 4 | import { UseAssistantOutput } from "../hooks/useAssistant"; 5 | import { ChatMessageWrapper } from "./ChatMessageWrapper"; 6 | import { TypingIndicator } from "./TypingIndicator"; 7 | 8 | export type AssistantChatLogProps = { 9 | /** 10 | * Enables additional debug output in the Chat UI such as the 11 | * Conversations SID and the identity of the user 12 | */ 13 | _debug?: boolean; 14 | } & UseAssistantOutput & 15 | BoxProps; 16 | 17 | /** 18 | * Renders a full chat log (without input) based on the output of `useAssistant`. 19 | * To modify the container behavior you can use {@link https://paste.twilio.design/primitives/box | Twilio Paste's Box properties} 20 | * 21 | * @example Regular use 22 | * const assistant = useAssistant(...) 23 | * return 24 | * 25 | * @example Custom styling: Removing max height 26 | * return 27 | * 28 | * @param {AssistantChatLogProps} props 29 | */ 30 | export function AssistantChatLog({ 31 | _debug, 32 | conversationSid, 33 | identity, 34 | isTyping, 35 | messages, 36 | ...props 37 | }: AssistantChatLogProps) { 38 | const scrollerRef = useRef(null); 39 | const loggerRef = useRef(null); 40 | const [mounted, setMounted] = useState(false); 41 | 42 | useEffect(() => { 43 | setMounted(true); 44 | }, []); 45 | 46 | useEffect(() => { 47 | if (!mounted || !loggerRef.current) return; 48 | scrollerRef.current?.scrollTo({ 49 | top: loggerRef.current.scrollHeight, 50 | behavior: "smooth", 51 | }); 52 | }, [messages, isTyping, mounted]); 53 | 54 | return ( 55 | 64 | 65 | {_debug && ( 66 | 67 | SID: {conversationSid} 68 | Identity: {identity} 69 | 70 | )} 71 | {messages.map((message) => { 72 | return ; 73 | })} 74 | {isTyping ? : null} 75 | 76 | 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /packages/assistants-react/src/components/ChatMessageWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | ChatBubble, 4 | ChatMessage, 5 | ChatMessageMeta, 6 | ChatMessageMetaItem, 7 | MessageVariants, 8 | } from "@twilio-paste/chat-log"; 9 | import { Message } from "@twilio/conversations"; 10 | import Markdown from "react-markdown"; 11 | import { Box } from "@twilio-paste/box"; 12 | import { Anchor } from "@twilio-paste/anchor"; 13 | import { JsxRuntimeComponents } from "react-markdown/lib"; 14 | 15 | export interface ChatMessageProps { 16 | message: Message; 17 | } 18 | 19 | function markdownComponents( 20 | variant: "inbound" | "outbound" 21 | ): Partial { 22 | return { 23 | a: ({ node, ...props }) => { 24 | return ( 25 | // @ts-ignore 26 | 30 | ); 31 | }, 32 | blockquote: ({ node, ...props }) => { 33 | return ( 34 | // @ts-ignore 35 | 46 | ); 47 | }, 48 | }; 49 | } 50 | 51 | /** 52 | * Renders both an incoming our outgoing message to be used in a Twilio Paste ChatLog 53 | * Message bodies get treated as markdown using {@link https://www.npmjs.com/package/react-markdown | react-markdown} 54 | * to enable formatting. 55 | * 56 | * @param {ChatMessageProps} props 57 | */ 58 | export function ChatMessageWrapper({ message }: ChatMessageProps) { 59 | const variant: MessageVariants = 60 | message.author === "system" ? "inbound" : "outbound"; 61 | 62 | const timeString = message.dateUpdated 63 | ? new Intl.DateTimeFormat(undefined, { timeStyle: "short" }).format( 64 | message.dateUpdated 65 | ) 66 | : ""; 67 | 68 | return ( 69 | 70 | 71 | 72 | {message.body} 73 | 74 | 75 | 76 | {timeString} 77 | 78 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /packages/assistants-react/src/components/TypingIndicator.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | ChatMessage, 4 | ChatMessageMeta, 5 | ChatMessageMetaItem, 6 | } from "@twilio-paste/chat-log"; 7 | import { SkeletonLoader } from "@twilio-paste/skeleton-loader"; 8 | import { Stack } from "@twilio-paste/stack"; 9 | 10 | /** 11 | * Typing indicator to be used in conjunction with a Twilio Paste ChatLog component 12 | */ 13 | export function TypingIndicator() { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | Typing... 23 | 24 | 25 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /packages/assistants-react/src/components/chat-composer-plugins/EnterKeySubmitPlugin.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | CLEAR_EDITOR_COMMAND, 4 | COMMAND_PRIORITY_HIGH, 5 | KEY_ENTER_COMMAND, 6 | useLexicalComposerContext, 7 | } from "@twilio-paste/lexical-library"; 8 | 9 | /** 10 | * Plugin for the Twilio Paste ChatComposer component to handle "hit enter to send" 11 | * {@link https://paste.twilio.design/components/chat-composer#adding-interactivity-with-plugins | Twilio Paste Docs} 12 | */ 13 | export const EnterKeySubmitPlugin = ({ 14 | onKeyDown, 15 | }: { 16 | onKeyDown: () => void; 17 | }): null => { 18 | const [editor] = useLexicalComposerContext(); 19 | 20 | const handleEnterKey = React.useCallback( 21 | (event: KeyboardEvent) => { 22 | const { shiftKey, ctrlKey } = event; 23 | if (shiftKey || ctrlKey) return false; 24 | event.preventDefault(); 25 | event.stopPropagation(); 26 | onKeyDown(); 27 | editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined); 28 | return true; 29 | }, 30 | [editor, onKeyDown] 31 | ); 32 | 33 | React.useEffect(() => { 34 | return editor.registerCommand( 35 | KEY_ENTER_COMMAND, 36 | handleEnterKey, 37 | COMMAND_PRIORITY_HIGH 38 | ); 39 | }, [editor, handleEnterKey]); 40 | return null; 41 | }; 42 | -------------------------------------------------------------------------------- /packages/assistants-react/src/components/chat-composer-plugins/SendButtonPlugin.tsx: -------------------------------------------------------------------------------- 1 | import { SendIcon } from "@twilio-paste/icons/esm/SendIcon"; 2 | import React from "react"; 3 | import { Box } from "@twilio-paste/box"; 4 | import { 5 | CLEAR_EDITOR_COMMAND, 6 | useLexicalComposerContext, 7 | } from "@twilio-paste/lexical-library"; 8 | import { Button } from "@twilio-paste/button"; 9 | 10 | /** 11 | * Component rendering a send button for the Twilio Paste ChatComposer component 12 | * {@link https://paste.twilio.design/components/chat-composer#adding-interactivity-with-plugins | Twilio Paste Docs} 13 | */ 14 | export const SendButtonPlugin = ({ 15 | onClick, 16 | }: { 17 | onClick: () => void; 18 | }): JSX.Element => { 19 | const [editor] = useLexicalComposerContext(); 20 | 21 | const handleSend = (): void => { 22 | onClick(); 23 | editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined); 24 | }; 25 | 26 | return ( 27 | 28 | 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /packages/assistants-react/src/hooks/useAssistant.ts: -------------------------------------------------------------------------------- 1 | import { ConnectionState, Message } from "@twilio/conversations"; 2 | import { useEffect, useRef, useState } from "react"; 3 | 4 | import Assistant, { AssistantOptions } from "@twilio-alpha/assistants"; 5 | 6 | export type UseAssistantOptions = Partial & { 7 | /** 8 | * The SID of your Twilio AI Assistant 9 | */ 10 | assistantSid: string; 11 | 12 | /** 13 | * A conversation SID if you want to connect to an existing conversation 14 | */ 15 | conversationSid?: string; 16 | 17 | /** 18 | * Handlers for any UI Tools you want the Twilio AI Assistant to be able to use. 19 | * This requires for the UI Tools to be properly configured. 20 | * - {@link https://www.twilio.com/docs/alpha/ai-assistants/code-samples/ui-tools | Learn more about UI tools} 21 | */ 22 | toolHandlers?: { 23 | [key: string]: (data: any) => void; 24 | }; 25 | }; 26 | 27 | export type UseAssistantOutput = { 28 | /** 29 | * A list of all the messages in the conversation 30 | */ 31 | messages: Message[]; 32 | 33 | /** 34 | * Connection state to the conversation 35 | */ 36 | state: ConnectionState; 37 | 38 | /** 39 | * Function to send a new message to the AI Assistant / Conversation 40 | * 41 | * @param message The new message to send 42 | * @returns success 43 | */ 44 | sendMessage: (message: string) => Promise; 45 | 46 | /** 47 | * 48 | */ 49 | conversationSid?: string; 50 | 51 | /** 52 | * Indicates when the AI Assistant is generating a new response. 53 | * Only works in combination with the {@link https://www.twilio.com/docs/alpha/ai-assistants/code-samples/conversations | AI Assistants Conversations integration} 54 | */ 55 | isTyping: boolean; 56 | 57 | /** 58 | * The Twilio Conversations identity of the user that's logged in 59 | */ 60 | identity?: string; 61 | }; 62 | 63 | export function useAssistant( 64 | /** 65 | * A valid JWT with a Twilio Conversations grant 66 | * - {@link https://www.twilio.com/docs/iam/access-tokens#create-an-access-token-for-conversations | Learn more} 67 | */ 68 | token: string, 69 | options: UseAssistantOptions 70 | ): UseAssistantOutput { 71 | const assistantClient = useRef(); 72 | const [messages, setMessages] = useState([]); 73 | const [state, setConnectionState] = useState("unknown"); 74 | const [conversationSid, setConversationSid] = useState(); 75 | const [identity, setIdentity] = useState(); 76 | const [isTyping, setIsTyping] = useState(false); 77 | 78 | useEffect(() => { 79 | if (typeof token !== "string" || token.length === 0) { 80 | return; 81 | } 82 | 83 | assistantClient.current = new Assistant(token, {}); 84 | assistantClient.current.on("messagesChanged", (messages) => { 85 | setMessages(messages); 86 | }); 87 | assistantClient.current.on("statusChanged", (state) => { 88 | setConnectionState(state); 89 | }); 90 | assistantClient.current.on( 91 | "joinedConversation", 92 | ({ conversationSid, identity }) => { 93 | setConversationSid(conversationSid); 94 | setIdentity(identity); 95 | } 96 | ); 97 | assistantClient.current.on("assistantTypingStarted", () => { 98 | setIsTyping(true); 99 | }); 100 | assistantClient.current.on("assistantTypingEnded", () => { 101 | setIsTyping(false); 102 | }); 103 | assistantClient.current.on("uiToolTriggered", (payload) => { 104 | if ( 105 | options.toolHandlers && 106 | typeof options.toolHandlers[payload.name] === "function" 107 | ) { 108 | options.toolHandlers[payload.name](payload.data); 109 | } 110 | }); 111 | 112 | return () => { 113 | assistantClient.current?.removeAllListeners(); 114 | assistantClient.current?.destroy(); 115 | assistantClient.current = undefined; 116 | }; 117 | }, [token, options.conversationSid]); 118 | 119 | useEffect(() => { 120 | if ( 121 | state === "connected" && 122 | options.assistantSid && 123 | assistantClient.current && 124 | !assistantClient.current.conversation 125 | ) { 126 | assistantClient.current.start( 127 | options.assistantSid, 128 | options.conversationSid 129 | ); 130 | } 131 | }, [state, options.assistantSid]); 132 | 133 | async function sendMessage(message: string) { 134 | return assistantClient.current?.sendMessage(message) || false; 135 | } 136 | 137 | return { state, messages, sendMessage, conversationSid, isTyping, identity }; 138 | } 139 | 140 | export default useAssistant; 141 | -------------------------------------------------------------------------------- /packages/assistants-react/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./components/AssistantChatDialog"; 2 | export * from "./components/AssistantChat"; 3 | export * from "./hooks/useAssistant"; 4 | -------------------------------------------------------------------------------- /packages/assistants-react/tests/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, expectTypeOf, test } from "vitest"; 2 | import { FC } from "react"; 3 | import { useAssistant, AssistantChat, AssistantChatProps } from "../src/index"; 4 | 5 | // TODO: add more tests 6 | 7 | test("exports right items", () => { 8 | expectTypeOf(useAssistant).toBeCallableWith("", { 9 | assistantSid: "", 10 | }); 11 | expectTypeOf(AssistantChat).toMatchTypeOf>(); 12 | }); 13 | -------------------------------------------------------------------------------- /packages/assistants-react/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "target": "esnext", 6 | "lib": ["dom", "dom.iterable", "esnext"], 7 | "declaration": true, 8 | "strict": true, 9 | "moduleResolution": "node", 10 | "jsx": "react", 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "emitDeclarationOnly": true, 14 | "outDir": "dist", 15 | "rootDir": "src" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/assistants/README.md: -------------------------------------------------------------------------------- 1 | # Twilio AI Assistants - JavaScript SDK 2 | 3 | > [!NOTE] 4 | > Twilio AI Assistants is a [Twilio Alpha](https://twilioalpha.com) project that is currently in Developer Preview. If you would like to try AI Assistants, [join the waitlist](https://twilioalpha.com/ai-assistants). 5 | 6 | ## Installation 7 | 8 | ```bash 9 | npm install @twilio-alpha/assistants @twilio/conversations 10 | ``` 11 | 12 | ## Usage 13 | 14 | ```js 15 | import Assistant from "@twilio-alpha/assistants-react"; 16 | 17 | const assistant = new Assistant(""); 18 | let messages = await assistant.start(); 19 | assistant.on("messagesChanged", (newMessages) => { 20 | messages = newMessages; 21 | }); 22 | assistant.sendMessage("Ahoy"); 23 | ``` 24 | 25 | ## License 26 | 27 | MIT 28 | -------------------------------------------------------------------------------- /packages/assistants/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@twilio-alpha/assistants", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "module": "dist/index.mjs", 8 | "files": [ 9 | "dist/" 10 | ], 11 | "scripts": { 12 | "clean": "rimraf dist", 13 | "build": "node ../../tools/build.js src/index.ts && tsc", 14 | "test": "vitest", 15 | "lib-pack": "npm pack" 16 | }, 17 | "author": "Twilio Alpha ", 18 | "license": "MIT", 19 | "dependencies": { 20 | "events": "^3.3.0", 21 | "ts-pattern": "^5.0.8", 22 | "zod": "^3.22.4" 23 | }, 24 | "devDependencies": { 25 | "@twilio/conversations": "^2.5.0", 26 | "@types/events": "^3.0.3" 27 | }, 28 | "peerDependencies": { 29 | "@twilio/conversations": "^2.5.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /packages/assistants/src/index.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "events"; 2 | import { 3 | ConnectionState, 4 | Conversation, 5 | Client as ConversationClient, 6 | ConversationUpdateReason, 7 | JSONObject, 8 | Message, 9 | MessageUpdateReason, 10 | } from "@twilio/conversations"; 11 | import { match } from "ts-pattern"; 12 | import z from "zod"; 13 | 14 | export type AssistantOptions = {}; 15 | 16 | export const UiToolData = z.object({ 17 | name: z.string(), 18 | data: z.record(z.any()), 19 | }); 20 | 21 | export type UiToolData = z.infer; 22 | 23 | export type JoinedConversationEventData = { 24 | conversationSid: string; 25 | identity: string; 26 | }; 27 | 28 | export declare interface Assistant extends EventEmitter { 29 | destroy(): void; 30 | emit(event: "messagesChanged", data: Message[]): any; 31 | on(event: "messagesChanged", handler: (state: Message[]) => void): this; 32 | emit(event: "statusChanged", data: ConnectionState): any; 33 | on(event: "statusChanged", handler: (state: ConnectionState) => void): this; 34 | emit(event: "joinedConversation", data: JoinedConversationEventData): any; 35 | on( 36 | event: "joinedConversation", 37 | handler: (data: JoinedConversationEventData) => void 38 | ): this; 39 | emit(event: "assistantTypingStarted"): any; 40 | on(event: "assistantTypingStarted", handler: () => void): this; 41 | emit(event: "assistantTypingEnded"): any; 42 | on(event: "assistantTypingEnded", handler: () => void): this; 43 | emit(event: "uiToolTriggered", data: UiToolData): any; 44 | on(event: "uiToolTriggered", handler: (data: UiToolData) => void): this; 45 | on(event: string, handler: Function): this; 46 | } 47 | 48 | export class Assistant extends EventEmitter { 49 | private conversationsClient: ConversationClient; 50 | conversation?: Conversation; 51 | private options: AssistantOptions; 52 | private messages: Message[] = []; 53 | 54 | constructor(token: string, options: AssistantOptions = {}) { 55 | super(); 56 | this.options = options; 57 | this.conversationsClient = new ConversationClient(token); 58 | this.conversationsClient.on( 59 | "connectionStateChanged", 60 | this.handleConnectionStateChanged.bind(this) 61 | ); 62 | } 63 | 64 | private handleConnectionStateChanged(state: ConnectionState) { 65 | this.emit("statusChanged", state); 66 | return this; 67 | } 68 | 69 | destroy() { 70 | this.conversation?.removeAllListeners(); 71 | this.conversationsClient.shutdown(); 72 | } 73 | 74 | updateToken(token: string) { 75 | this.conversationsClient.updateToken(token); 76 | } 77 | 78 | async start( 79 | assistantSid: string, 80 | conversationSid?: string 81 | ): Promise { 82 | this.conversation = await match(conversationSid) 83 | .with(undefined, () => 84 | this.conversationsClient.createConversation({ 85 | attributes: { 86 | assistantSid: assistantSid, 87 | }, 88 | friendlyName: `Assistant Conversation ${ 89 | this.conversationsClient.user.identity 90 | } - ${new Date().toISOString()}`, 91 | }) 92 | ) 93 | .otherwise((conversationSid) => 94 | this.conversationsClient.getConversationBySid(conversationSid) 95 | ); 96 | 97 | try { 98 | await this.conversation.join(); 99 | this.emit("joinedConversation", { 100 | conversationSid: this.conversation.sid, 101 | identity: this.conversationsClient.user.identity, 102 | }); 103 | } catch (err) { 104 | if (this.conversation.state?.current === "active") { 105 | this.emit("joinedConversation", { 106 | conversationSid: this.conversation.sid, 107 | identity: this.conversationsClient.user.identity, 108 | }); 109 | } 110 | } 111 | this.conversation.addListener( 112 | "messageAdded", 113 | this.handleMessageAdded.bind(this) 114 | ); 115 | this.conversation.addListener( 116 | "messageUpdated", 117 | this.handleMessageUpdated.bind(this) 118 | ); 119 | this.conversation.addListener( 120 | "messageRemoved", 121 | this.handleMessageRemoved.bind(this) 122 | ); 123 | this.conversation.addListener( 124 | "updated", 125 | this.handleConversationUpdate.bind(this) 126 | ); 127 | const messages = await this.conversation.getMessages(); 128 | this.messages = messages.items; 129 | this.emit("messagesChanged", this.messages); 130 | return this.messages; 131 | } 132 | 133 | async sendMessage(message: string) { 134 | if (this.conversation) { 135 | await this.conversation.sendMessage(message); 136 | return true; 137 | } 138 | return false; 139 | } 140 | 141 | private handleConversationUpdate(data: { 142 | conversation: Conversation; 143 | updateReasons: ConversationUpdateReason[]; 144 | }) { 145 | if (data.updateReasons.includes("attributes")) { 146 | if ( 147 | typeof data.conversation.attributes === "object" && 148 | data.conversation.attributes !== null && 149 | !Array.isArray(data.conversation.attributes) && 150 | typeof data.conversation.attributes.assistantIsTyping === "boolean" 151 | ) { 152 | if (data.conversation.attributes.assistantIsTyping) { 153 | this.emit("assistantTypingStarted"); 154 | } else { 155 | this.emit("assistantTypingEnded"); 156 | } 157 | } 158 | } 159 | } 160 | 161 | private handleMessageAdded(message: Message) { 162 | if ( 163 | typeof message.attributes === "object" && 164 | message.attributes !== null && 165 | !Array.isArray(message.attributes) && 166 | message.attributes.assistantMessageType === "ui-tool" 167 | ) { 168 | if (message.body) { 169 | try { 170 | const toolData = UiToolData.parse(JSON.parse(message.body)); 171 | this.emit("uiToolTriggered", toolData); 172 | } catch (err) { 173 | console.error(err); 174 | } 175 | } 176 | return; 177 | } 178 | this.messages = [...this.messages, message]; 179 | this.emit("messagesChanged", this.messages); 180 | } 181 | 182 | private handleMessageUpdated(data: { 183 | message: Message; 184 | updateReasons: MessageUpdateReason[]; 185 | }) { 186 | this.messages = this.messages.map((msg) => 187 | msg.sid === data.message.sid ? data.message : msg 188 | ); 189 | this.emit("messagesChanged", this.messages); 190 | } 191 | 192 | private handleMessageRemoved(message: Message) { 193 | this.messages = this.messages.filter((msg) => msg.sid !== message.sid); 194 | } 195 | } 196 | 197 | export default Assistant; 198 | -------------------------------------------------------------------------------- /packages/assistants/tests/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, expectTypeOf, test } from "vitest"; 2 | import Assistants from "../src/index"; 3 | 4 | // TODO: add more tests 5 | 6 | test("exports Assistants as a class", () => { 7 | expectTypeOf(Assistants).toBeConstructibleWith("", { 8 | assistantSid: "", 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/assistants/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "target": "esnext", 6 | "lib": ["dom", "dom.iterable", "esnext"], 7 | "declaration": true, 8 | "strict": true, 9 | "moduleResolution": "node", 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "emitDeclarationOnly": true, 13 | "outDir": "dist", 14 | "rootDir": "src" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /tools/build.js: -------------------------------------------------------------------------------- 1 | const esbuild = require("esbuild"); 2 | const { nodeExternalsPlugin } = require("esbuild-node-externals"); 3 | const { argv, cwd } = require("node:process"); 4 | const { join } = require("node:path"); 5 | 6 | let [_nodeFilePath, _program, ...entryPoints] = argv; 7 | 8 | let generateUmd = false; 9 | if (entryPoints.includes("--umd")) { 10 | generateUmd = true; 11 | entryPoints = entryPoints 12 | .map((x) => (x === "--umd" ? undefined : x)) 13 | .filter((x) => !!x); 14 | } 15 | 16 | function buildOne(mode, fileName, overrideOptions = {}) { 17 | return esbuild.build({ 18 | color: true, 19 | entryPoints: entryPoints, 20 | outfile: join(cwd(), "dist", fileName), 21 | bundle: true, 22 | minifyIdentifiers: false, 23 | minifySyntax: true, 24 | minifyWhitespace: true, 25 | treeShaking: true, 26 | platform: "browser", 27 | format: mode, 28 | /** 29 | * From docs: 30 | * The main fields setting is set to main,module. This means tree shaking 31 | * will likely not happen for packages that provide both module and main 32 | * since tree shaking works with ECMAScript modules but not with CommonJS 33 | * modules. 34 | * Unfortunately some packages incorrectly treat module as meaning 35 | * "browser code" instead of "ECMAScript module code" so this default 36 | * behavior is required for compatibility. You can manually configure the 37 | * main fields setting to module,main if you want to enable tree shaking 38 | * and know it is safe to do so. 39 | */ 40 | mainFields: ["module", "main", "browser"], 41 | target: ["chrome100", "firefox100", "safari14", "edge100", "node18.16.0"], 42 | external: ["react", "react-dom", "@twilio/conversations"], 43 | ...overrideOptions, 44 | }); 45 | } 46 | 47 | async function build() { 48 | await buildOne("esm", "index.mjs"); 49 | await buildOne("cjs", "index.js"); 50 | if (generateUmd) { 51 | await buildOne("iife", "ai-assistants.bundled.js", { 52 | external: undefined, 53 | globalName: "Twilio.Alpha.AiAssistants", 54 | target: ["chrome100", "firefox100", "safari14", "edge100"], 55 | }); 56 | } 57 | } 58 | 59 | build().catch(console.error); 60 | -------------------------------------------------------------------------------- /vitest.workspace.ts: -------------------------------------------------------------------------------- 1 | export default ["packages/*"]; 2 | --------------------------------------------------------------------------------