├── .dockerignore ├── .eslintrc.cjs ├── .gitignore ├── .prettierrc ├── README.md ├── env.example ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── color-wash-bg.png ├── silero_vad.onnx └── social.png ├── src ├── App.tsx ├── Splash.tsx ├── actions.ts ├── assets │ ├── logo.svg │ └── logos │ │ ├── cerebrium.png │ │ ├── daily.png │ │ ├── deepgram.png │ │ └── llama3.png ├── components │ ├── AudioIndicator │ │ ├── index.tsx │ │ └── styles.module.css │ ├── Configuration │ │ ├── LanguageSelect.tsx │ │ ├── ModelSelect.tsx │ │ ├── VoiceSelect.tsx │ │ └── index.tsx │ ├── Latency │ │ ├── index.tsx │ │ ├── styles.module.css │ │ └── utils.ts │ ├── Session │ │ ├── Agent │ │ │ ├── avatar.tsx │ │ │ ├── face.svg │ │ │ ├── index.tsx │ │ │ ├── model.tsx │ │ │ └── styles.module.css │ │ ├── ExpiryTimer │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ ├── TranscriptOverlay │ │ │ ├── index.tsx │ │ │ └── styles.module.css │ │ └── index.tsx │ ├── Setup │ │ ├── Configure.tsx │ │ ├── DeviceSelect.tsx │ │ ├── RoomInput.tsx │ │ ├── RoomSetup.tsx │ │ ├── SettingsList.tsx │ │ └── index.tsx │ ├── Stats │ │ ├── index.tsx │ │ └── styles.module.css │ ├── UserMicBubble │ │ ├── index.tsx │ │ └── styles.module.css │ ├── logo.tsx │ └── ui │ │ ├── alert.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── field.tsx │ │ ├── header.tsx │ │ ├── helptip.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── select.tsx │ │ ├── switch.tsx │ │ └── tooltip.tsx ├── config.ts ├── global.css ├── main.tsx ├── types │ └── stats_aggregator.d.ts ├── utils │ ├── stats_aggregator.ts │ └── tailwind.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json ├── vercel.json ├── vite.config.ts └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.DS_Store 2 | node_modules 3 | dist 4 | dist-ssr 5 | *.lock 6 | *.local 7 | *.*.local -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:react-hooks/recommended", 8 | ], 9 | ignorePatterns: ["dist", ".eslintrc.cjs"], 10 | parser: "@typescript-eslint/parser", 11 | plugins: ["react-refresh", "simple-import-sort"], 12 | rules: { 13 | "simple-import-sort/imports": [ 14 | "error", 15 | { 16 | groups: [ 17 | // Packages `react` related packages come first. 18 | ["^react", "^@?\\w"], 19 | // Internal packages. 20 | ["^(@|components)(/.*|$)"], 21 | // Side effect imports. 22 | ["^\\u0000"], 23 | // Parent imports. Put `..` last. 24 | ["^\\.\\.(?!/?$)", "^\\.\\./?$"], 25 | // Other relative imports. Put same-folder imports and `.` last. 26 | ["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"], 27 | // Style imports. 28 | ["^.+\\.?(css)$"], 29 | ], 30 | }, 31 | ], 32 | "simple-import-sort/exports": "error", 33 | "react-refresh/only-export-components": [ 34 | "warn", 35 | { allowConstantExport: true }, 36 | ], 37 | }, 38 | }; 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | /test-results/ 26 | /playwright-report/ 27 | /blob-report/ 28 | /playwright/.cache/ 29 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "singleQuote": false 6 | } 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > Temporarily archived while examples are updated. 2 | # RTVI Web Demo 3 | 4 | 5 | ## Getting setup 6 | 7 | Setup your .env.local 8 | 9 | ``` 10 | cp env.example .env.local 11 | ``` 12 | 13 | Install deps and build the UI: 14 | 15 | ``` 16 | yarn 17 | yarn run dev 18 | ``` 19 | 20 | Navigate to the URL shown in your terminal window. 21 | 22 | 23 | 24 | ## Configuring your env 25 | 26 | `VITE_BASE_URL` 27 | 28 | The location of your bot running infrastructure. A default is provided for you to test. 29 | 30 | If you want to run your own infrastructure, please be aware that `navigator` requires SSL / HTTPS when not targeting `localhost`. 31 | 32 | ## Regarding HMR 33 | 34 | Whilst this app works well with hot reloading, the underlying WebRTC dependencies on some transports will throw errors if they are reinitialized. Check your console for warnings if something doesn't appear to be working. 35 | 36 | ## User intent 37 | 38 | When not in local development, browsers require user intent before allowing access to media devices (such as webcams or mics.) We show an initial splash page which requires a user to click a button before requesting any devices from the navigator object. Removing the `Splash.tsx` willcause an error when the app is not served locally which you can see in the web console. Be sure to get user intent in your apps first! 39 | 40 | ## What libraries does this use? 41 | 42 | ### Vite / React 43 | 44 | We've used [Vite](https://vitejs.dev/) to simplify the development and build experience. 45 | 46 | ### Tailwind CSS 47 | 48 | We use [Tailwind](https://tailwindcss.com/) so the UI is easy to theme quickly, and reduce the number of CSS classes used throughout the project. 49 | 50 | ### Radix 51 | 52 | For interactive components, we make use of [Radix](https://www.radix-ui.com/). 53 | 54 | -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | VITE_BASE_URL=https://rtvi.pipecat.bot/ -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | RTVI Web Demo 19 | 20 | 21 |
22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rtvi-web-demo", 3 | "private": true, 4 | "version": "0.0.0", 5 | "license": "BSD-2-Clause", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 11 | "test": "npx playwright test", 12 | "preview": "vite preview" 13 | }, 14 | "dependencies": { 15 | "@daily-co/daily-js": ">=0.67.0", 16 | "@daily-co/realtime-ai-daily": "^0.2.1", 17 | "@radix-ui/react-switch": "^1.0.3", 18 | "@radix-ui/react-tooltip": "^1.0.7", 19 | "class-variance-authority": "^0.7.0", 20 | "clsx": "^2.1.1", 21 | "lucide-react": "^0.378.0", 22 | "onnxruntime-web": "^1.18.0", 23 | "react": "^18.2.0", 24 | "react-dom": "^18.2.0", 25 | "react-sparklines": "^1.7.0", 26 | "realtime-ai": "^0.2.1", 27 | "realtime-ai-react": "^0.2.1", 28 | "recoil": "^0.7.7", 29 | "tailwind-merge": "^2.3.0", 30 | "web-vad": "^0.0.6" 31 | }, 32 | "devDependencies": { 33 | "@types/audioworklet": "^0.0.55", 34 | "@types/node": "^20.14.5", 35 | "@types/react": "^18.2.66", 36 | "@types/react-dom": "^18.2.22", 37 | "@types/react-sparklines": "^1.7.5", 38 | "@typescript-eslint/eslint-plugin": "^7.2.0", 39 | "@typescript-eslint/parser": "^7.2.0", 40 | "@vitejs/plugin-react": "^4.2.1", 41 | "autoprefixer": "^10.4.19", 42 | "eslint": "^8.57.0", 43 | "eslint-plugin-react-hooks": "^4.6.0", 44 | "eslint-plugin-react-refresh": "^0.4.6", 45 | "eslint-plugin-simple-import-sort": "^12.1.0", 46 | "i": "^0.3.7", 47 | "npm": "^10.8.0", 48 | "postcss": "^8.4.38", 49 | "tailwindcss": "^3.4.4", 50 | "typescript": "^5.2.2", 51 | "vite": "^5.2.0", 52 | "vite-plugin-static-copy": "^1.0.5", 53 | "vite-plugin-webfont-dl": "^3.9.4" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/color-wash-bg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipecat-ai/rtvi-web-demo/9fb104e9cb454262616bc425597eda4cbef313f0/public/color-wash-bg.png -------------------------------------------------------------------------------- /public/silero_vad.onnx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipecat-ai/rtvi-web-demo/9fb104e9cb454262616bc425597eda4cbef313f0/public/silero_vad.onnx -------------------------------------------------------------------------------- /public/social.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipecat-ai/rtvi-web-demo/9fb104e9cb454262616bc425597eda4cbef313f0/public/social.png -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Ear, Loader2 } from "lucide-react"; 3 | import { RTVIError } from "realtime-ai"; 4 | import { useRTVIClient, useRTVIClientTransportState } from "realtime-ai-react"; 5 | 6 | import Session from "./components/Session"; 7 | import { Configure } from "./components/Setup"; 8 | import { Alert } from "./components/ui/alert"; 9 | import { Button } from "./components/ui/button"; 10 | import * as Card from "./components/ui/card"; 11 | import { BOT_READY_TIMEOUT } from "./config"; 12 | 13 | const status_text = { 14 | disconnected: "Initializing...", 15 | initializing: "Initializing...", 16 | initialized: "Start", 17 | authenticating: "Requesting agent...", 18 | connecting: "Connecting...", 19 | }; 20 | 21 | export default function App() { 22 | const rtviClient = useRTVIClient()!; 23 | 24 | const transportState = useRTVIClientTransportState(); 25 | const [appState, setAppState] = useState< 26 | "idle" | "ready" | "connecting" | "connected" 27 | >("idle"); 28 | const [error, setError] = useState(null); 29 | const [startAudioOff, setStartAudioOff] = useState(false); 30 | 31 | useEffect(() => { 32 | // Initialize local audio devices 33 | if (!rtviClient || transportState !== "disconnected") return; 34 | rtviClient.initDevices(); 35 | }, [transportState, rtviClient]); 36 | 37 | useEffect(() => { 38 | // Update the app state based on the transport state 39 | // We only need a substate of states for the different view states 40 | // so this method helps avoid inline conditionals. 41 | switch (transportState) { 42 | case "initialized": 43 | setAppState("ready"); 44 | break; 45 | case "authenticating": 46 | case "connecting": 47 | setAppState("connecting"); 48 | break; 49 | case "connected": 50 | case "ready": 51 | setAppState("connected"); 52 | break; 53 | default: 54 | setAppState("idle"); 55 | } 56 | }, [transportState]); 57 | 58 | async function start() { 59 | if (!rtviClient) return; 60 | 61 | // Set a timeout and check for join state, incase under heavy load 62 | setTimeout(() => { 63 | if (rtviClient.state !== "ready") { 64 | setError( 65 | "Bot failed to join or enter ready state. Server may be busy. Please try again later." 66 | ); 67 | rtviClient.disconnect(); 68 | } 69 | }, BOT_READY_TIMEOUT); 70 | 71 | // Join the session 72 | try { 73 | rtviClient.enableMic(false); 74 | await rtviClient.connect(); 75 | } catch (e) { 76 | if (e instanceof RTVIError && e.status === 429) { 77 | // Changed from RateLimitError 78 | setError("Demo is currently at capacity. Please try again later."); 79 | } else { 80 | setError( 81 | "Unable to authenticate. Server may be offline or busy. Please try again later." 82 | ); 83 | } 84 | return; 85 | } 86 | } 87 | 88 | async function leave() { 89 | await rtviClient.disconnect(); 90 | // Reload the page to reset the app (this avoids transport specific reinitalizing issues) 91 | window.location.reload(); 92 | } 93 | 94 | if (error) { 95 | return ( 96 | 97 | {error} 98 | 99 | ); 100 | } 101 | 102 | if (appState === "connected") { 103 | return ( 104 | leave()} 107 | startAudioOff={startAudioOff} 108 | /> 109 | ); 110 | } 111 | 112 | const isReady = appState === "ready"; 113 | 114 | return ( 115 | 116 | 117 | Configuration 118 | 119 | Please configure your devices and pipeline settings below 120 | 121 | 122 | 123 |
124 | 125 | Works best in a quiet environment with a good internet. 126 |
127 | setStartAudioOff(!startAudioOff)} 130 | /> 131 |
132 | 133 | 142 | 143 |
144 | ); 145 | } 146 | -------------------------------------------------------------------------------- /src/Splash.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { Book, Info } from "lucide-react"; 3 | import { VAD } from "web-vad"; 4 | 5 | import { Button } from "./components/ui/button"; 6 | 7 | type SplashProps = { 8 | handleReady: () => void; 9 | }; 10 | 11 | export const Splash: React.FC = ({ handleReady }) => { 12 | const [isReady, setIsReady] = React.useState(false); 13 | 14 | useEffect(() => { 15 | const cacheVAD = async () => { 16 | await VAD.precacheModels("silero_vad.onnx"); 17 | setIsReady(true); 18 | }; 19 | cacheVAD(); 20 | }, []); 21 | 22 | return ( 23 |
24 |
25 |

26 | Groq & 27 |
28 | Llama 3.1 & 29 |
30 | Daily & 31 |
32 | RTVI 33 |
34 | Voice-to-Voice Demo 35 |

36 | 37 | 40 | 41 | 62 |
63 | ); 64 | }; 65 | 66 | export default Splash; 67 | -------------------------------------------------------------------------------- /src/actions.ts: -------------------------------------------------------------------------------- 1 | export const fetch_start_agent = async ( 2 | roomUrl: string | null, 3 | serverUrl: string 4 | ) => { 5 | const req = await fetch(`${serverUrl}`, { 6 | method: "POST", 7 | headers: { 8 | "Content-Type": "application/json", 9 | }, 10 | body: JSON.stringify({ room_url: roomUrl }), 11 | }); 12 | 13 | const data = await req.json(); 14 | 15 | if (!req.ok) { 16 | return { error: true, detail: data.detail }; 17 | } 18 | return data; 19 | }; 20 | -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/assets/logos/cerebrium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipecat-ai/rtvi-web-demo/9fb104e9cb454262616bc425597eda4cbef313f0/src/assets/logos/cerebrium.png -------------------------------------------------------------------------------- /src/assets/logos/daily.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipecat-ai/rtvi-web-demo/9fb104e9cb454262616bc425597eda4cbef313f0/src/assets/logos/daily.png -------------------------------------------------------------------------------- /src/assets/logos/deepgram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipecat-ai/rtvi-web-demo/9fb104e9cb454262616bc425597eda4cbef313f0/src/assets/logos/deepgram.png -------------------------------------------------------------------------------- /src/assets/logos/llama3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pipecat-ai/rtvi-web-demo/9fb104e9cb454262616bc425597eda4cbef313f0/src/assets/logos/llama3.png -------------------------------------------------------------------------------- /src/components/AudioIndicator/index.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from "react"; 2 | import { RTVIEvent } from "realtime-ai"; 3 | import { useRTVIClientEvent } from "realtime-ai-react"; 4 | 5 | import styles from "./styles.module.css"; 6 | 7 | export const AudioIndicatorBar: React.FC = () => { 8 | const volRef = useRef(null); 9 | 10 | useRTVIClientEvent( 11 | RTVIEvent.LocalAudioLevel, 12 | useCallback((volume: number) => { 13 | if (volRef.current) 14 | volRef.current.style.width = Math.max(2, volume * 100) + "%"; 15 | }, []) 16 | ); 17 | 18 | return ( 19 |
20 |
21 |
22 | ); 23 | }; 24 | 25 | export default AudioIndicatorBar; 26 | -------------------------------------------------------------------------------- /src/components/AudioIndicator/styles.module.css: -------------------------------------------------------------------------------- 1 | .bar { 2 | background: theme(colors.gray.200); 3 | height: 8px; 4 | width: 100%; 5 | border-radius: 999px; 6 | overflow: hidden; 7 | 8 | > div { 9 | background: theme(colors.green.500); 10 | height: 8px; 11 | width: 0px; 12 | border-radius: 999px; 13 | transition: width 0.1s ease; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/components/Configuration/LanguageSelect.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MessageCircle } from "lucide-react"; 3 | 4 | import { ttsVoices, Voice } from "../../config"; 5 | import { Field } from "../ui/field"; 6 | import { Select } from "../ui/select"; 7 | 8 | type LanguageSelectProps = { 9 | onSelect: (voice: Voice) => void; 10 | }; 11 | 12 | const LanguageSelect: React.FC = ({ onSelect }) => { 13 | return ( 14 | 15 | 25 | 26 | ); 27 | }; 28 | 29 | export default LanguageSelect; 30 | -------------------------------------------------------------------------------- /src/components/Configuration/ModelSelect.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Package } from "lucide-react"; 3 | 4 | import { LLMModel, llmModels } from "../../config"; 5 | import { Field } from "../ui/field"; 6 | import { Select } from "../ui/select"; 7 | 8 | type ModelSelectProps = { 9 | onSelect: (model: string) => void; 10 | }; 11 | 12 | const ModelSelect: React.FC = ({ onSelect }) => { 13 | return ( 14 | 15 | 25 | 26 | ); 27 | }; 28 | 29 | export default ModelSelect; 30 | -------------------------------------------------------------------------------- /src/components/Configuration/VoiceSelect.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { MessageCircle } from "lucide-react"; 3 | 4 | import { ttsVoices, Voice } from "../../config"; 5 | import { Field } from "../ui/field"; 6 | import { Select } from "../ui/select"; 7 | 8 | type VoiceSelectProps = { 9 | onSelect: (voice: Voice) => void; 10 | }; 11 | 12 | const VoiceSelect: React.FC = ({ onSelect }) => { 13 | return ( 14 | 15 | 25 | 26 | ); 27 | }; 28 | 29 | export default VoiceSelect; 30 | -------------------------------------------------------------------------------- /src/components/Configuration/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { LLMHelper, RTVIClientConfigOption } from "realtime-ai"; 3 | import { useRTVIClient } from "realtime-ai-react"; 4 | 5 | import { Voice } from "@/config"; 6 | 7 | import ModelSelect from "./ModelSelect"; 8 | import VoiceSelect from "./VoiceSelect"; 9 | 10 | const Configuration: React.FC<{ showAllOptions: boolean }> = ({ 11 | showAllOptions = false, 12 | }) => { 13 | const rtviClient = useRTVIClient()!; 14 | const llmHelper = rtviClient.getHelper("llm"); 15 | 16 | const updateConfig = async (serviceConfig: RTVIClientConfigOption[]) => { 17 | const shouldInterrupt = rtviClient.state === "ready"; 18 | try { 19 | await rtviClient.updateConfig(serviceConfig, shouldInterrupt); 20 | } catch (error) { 21 | console.error("Error updating config:", error); 22 | } 23 | }; 24 | 25 | const handleVoiceChange = async (voice: Voice) => { 26 | await updateConfig([ 27 | { 28 | service: "tts", 29 | options: [{ name: "voice", value: voice.id }], 30 | }, 31 | ]); 32 | 33 | // Prompt the LLM to speak 34 | await llmHelper?.appendToMessages( 35 | { 36 | role: "assistant", 37 | content: "Ask if the user prefers the new voice you have been given.", 38 | }, 39 | true 40 | ); // runImmediately = true 41 | }; 42 | 43 | const handleModelChange = async (model: string) => { 44 | await updateConfig([ 45 | { 46 | service: "llm", 47 | options: [{ name: "model", value: model }], 48 | }, 49 | ]); 50 | 51 | if (rtviClient.state === "ready") { 52 | // Instead of separate interrupt call, we'll use the interrupt parameter in appendToMessages 53 | await llmHelper?.appendToMessages( 54 | { 55 | role: "user", 56 | content: `I just changed your model to use ${model}! Thank me for the change.`, 57 | }, 58 | true 59 | ); // runImmediately = true 60 | } 61 | }; 62 | 63 | return ( 64 |
65 | handleModelChange(model)} /> 66 | {showAllOptions && ( 67 | handleVoiceChange(voice)} /> 68 | )} 69 |
70 | ); 71 | }; 72 | 73 | export default Configuration; 74 | -------------------------------------------------------------------------------- /src/components/Latency/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useCallback, useEffect, useRef, useState } from "react"; 2 | import clsx from "clsx"; 3 | import { RTVIEvent } from "realtime-ai"; 4 | import { useRTVIClientMediaTrack } from "realtime-ai-react"; 5 | import { useRTVIClientEvent } from "realtime-ai-react"; 6 | import { VAD, VADState } from "web-vad"; 7 | import AudioWorkletURL from "web-vad/dist/worklet?worker&url"; 8 | 9 | import { 10 | LATENCY_MAX, 11 | LATENCY_MIN, 12 | VAD_MIN_SPEECH_FRAMES, 13 | VAD_NEGATIVE_SPEECH_THRESHOLD, 14 | VAD_POSITIVE_SPEECH_THRESHOLD, 15 | VAD_PRESPEECH_PAD_FRAMES, 16 | VAD_REDEMPTION_FRAMES, 17 | } from "@/config"; 18 | 19 | import { calculateMedian } from "./utils"; 20 | 21 | import styles from "./styles.module.css"; 22 | 23 | enum State { 24 | SPEAKING = "Speaking", 25 | SILENT = "Silent", 26 | } 27 | 28 | const REMOTE_AUDIO_THRESHOLD = 0; 29 | 30 | const Latency: React.FC<{ 31 | started: boolean; 32 | botStatus: string; 33 | statsAggregator: StatsAggregator; 34 | }> = memo( 35 | ({ started = false, botStatus, statsAggregator }) => { 36 | const localMediaTrack = useRTVIClientMediaTrack("audio", "local"); 37 | const [vadInstance, setVadInstance] = useState(null); 38 | const [currentState, setCurrentState] = useState(State.SILENT); 39 | const [botTalkingState, setBotTalkingState] = useState( 40 | undefined 41 | ); 42 | const [lastDelta, setLastDelta] = useState(null); 43 | const [median, setMedian] = useState(null); 44 | const [hasSpokenOnce, setHasSpokenOnce] = useState(false); 45 | 46 | const deltaRef = useRef(0); 47 | const deltaArrayRef = useRef([]); 48 | const startTimeRef = useRef(null); 49 | const mountedRef = useRef(false); 50 | 51 | /* ---- Timer actions ---- */ 52 | const startTimer = useCallback(() => { 53 | startTimeRef.current = new Date(); 54 | }, []); 55 | 56 | const stopTimer = useCallback(() => { 57 | if (!startTimeRef.current) { 58 | return; 59 | } 60 | 61 | const now = new Date(); 62 | const diff = now.getTime() - startTimeRef.current.getTime(); 63 | 64 | // Ignore any values that are obviously wrong 65 | // These may be triggered by small noises such as coughs etc 66 | if (diff < LATENCY_MIN || diff > LATENCY_MAX) { 67 | startTimeRef.current = null; 68 | return; 69 | } 70 | 71 | deltaArrayRef.current = [...deltaArrayRef.current, diff]; 72 | setMedian(calculateMedian(deltaArrayRef.current)); 73 | setLastDelta(diff); 74 | startTimeRef.current = null; 75 | 76 | // Increment turns 77 | if (statsAggregator) { 78 | statsAggregator.turns++; 79 | } 80 | }, [statsAggregator]); 81 | 82 | // Stop timer when bot starts talking 83 | useRTVIClientEvent( 84 | RTVIEvent.RemoteAudioLevel, 85 | useCallback( 86 | (level: number) => { 87 | if (level > REMOTE_AUDIO_THRESHOLD && startTimeRef.current) { 88 | stopTimer(); 89 | } 90 | }, 91 | [stopTimer] 92 | ) 93 | ); 94 | 95 | useRTVIClientEvent( 96 | RTVIEvent.BotStoppedSpeaking, 97 | useCallback(() => { 98 | setBotTalkingState(State.SILENT); 99 | }, []) 100 | ); 101 | 102 | useRTVIClientEvent( 103 | RTVIEvent.BotStartedSpeaking, 104 | useCallback(() => { 105 | setBotTalkingState(State.SPEAKING); 106 | }, []) 107 | ); 108 | 109 | useRTVIClientEvent( 110 | RTVIEvent.UserStartedSpeaking, 111 | useCallback(() => { 112 | if (!hasSpokenOnce) { 113 | setHasSpokenOnce(true); 114 | } 115 | }, [hasSpokenOnce]) 116 | ); 117 | 118 | /* ---- Effects ---- */ 119 | 120 | // Reset state on mount 121 | useEffect(() => { 122 | startTimeRef.current = null; 123 | deltaRef.current = 0; 124 | deltaArrayRef.current = []; 125 | setVadInstance(null); 126 | setHasSpokenOnce(false); 127 | }, []); 128 | 129 | // Start timer after user has spoken once 130 | useEffect(() => { 131 | if ( 132 | !started || 133 | !hasSpokenOnce || 134 | !vadInstance || 135 | vadInstance.state !== VADState.listening || 136 | currentState !== State.SILENT 137 | ) { 138 | return; 139 | } 140 | startTimer(); 141 | }, [started, vadInstance, currentState, startTimer, hasSpokenOnce]); 142 | 143 | useEffect(() => { 144 | if (mountedRef.current || !localMediaTrack) { 145 | return; 146 | } 147 | 148 | async function loadVad() { 149 | const stream = new MediaStream([localMediaTrack!]); 150 | 151 | const vad = new VAD({ 152 | workletURL: AudioWorkletURL, 153 | stream, 154 | positiveSpeechThreshold: VAD_POSITIVE_SPEECH_THRESHOLD, 155 | negativeSpeechThreshold: VAD_NEGATIVE_SPEECH_THRESHOLD, 156 | minSpeechFrames: VAD_MIN_SPEECH_FRAMES, 157 | redemptionFrames: VAD_REDEMPTION_FRAMES, 158 | preSpeechPadFrames: VAD_PRESPEECH_PAD_FRAMES, 159 | onSpeechStart: () => { 160 | setCurrentState(State.SPEAKING); 161 | }, 162 | onVADMisfire: () => { 163 | setCurrentState(State.SILENT); 164 | }, 165 | onSpeechEnd: () => { 166 | setCurrentState(State.SILENT); 167 | }, 168 | }); 169 | await vad.init(); 170 | vad.start(); 171 | setVadInstance(vad); 172 | } 173 | 174 | // Load VAD 175 | loadVad(); 176 | 177 | mountedRef.current = true; 178 | }, [localMediaTrack]); 179 | 180 | // Cleanup VAD 181 | useEffect( 182 | () => () => { 183 | if (vadInstance && vadInstance.state !== VADState.destroyed) { 184 | setVadInstance(null); 185 | vadInstance?.destroy(); 186 | } 187 | }, 188 | [vadInstance] 189 | ); 190 | 191 | /* ---- Render ---- */ 192 | 193 | const userCx = clsx( 194 | styles.statusColumn, 195 | currentState === State.SPEAKING && styles.speaking 196 | ); 197 | 198 | const botCx = clsx( 199 | styles.statusColumn, 200 | botTalkingState === State.SPEAKING && styles.speaking 201 | ); 202 | 203 | const userStatusCx = clsx( 204 | styles.status, 205 | currentState === State.SPEAKING && styles.statusSpeaking 206 | ); 207 | 208 | const boxStatusCx = clsx( 209 | styles.status, 210 | botStatus === "initializing" && styles.statusLoading, 211 | botStatus === "disconnected" && styles.statusDisconnected, 212 | botTalkingState === State.SPEAKING && styles.statusSpeaking 213 | ); 214 | 215 | return ( 216 | <> 217 |
218 |
219 | 220 | User status 221 | 222 | 223 | {started && currentState === State.SPEAKING 224 | ? "Speaking" 225 | : "Connected"} 226 | 227 |
228 |
229 | Latency 230 | 231 | {lastDelta || "---"} 232 | ms 233 | 234 | 235 | avg {median?.toFixed() || "0"} 236 | ms 237 | 238 |
239 |
240 | 241 | Bot status 242 | 243 | 244 | {botStatus === "disconnected" 245 | ? "Disconnected" 246 | : botTalkingState === State.SPEAKING 247 | ? "Speaking" 248 | : botStatus} 249 | 250 |
251 |
252 | 253 | ); 254 | }, 255 | (prevState, nextState) => 256 | prevState.started === nextState.started && 257 | prevState.botStatus === nextState.botStatus 258 | ); 259 | 260 | export default Latency; 261 | -------------------------------------------------------------------------------- /src/components/Latency/styles.module.css: -------------------------------------------------------------------------------- 1 | .statusContainer { 2 | width: 100%; 3 | display: flex; 4 | flex-flow: row nowrap; 5 | padding: theme(spacing.2) 0 0 0; 6 | } 7 | 8 | .statusColumn, 9 | .latencyColumn { 10 | display: flex; 11 | flex-flow: column nowrap; 12 | background-color: white; 13 | padding: theme(spacing.2); 14 | border-radius: theme(borderRadius.2xl); 15 | transition: border-color 200ms ease, background-color 200ms ease; 16 | 17 | @screen md { 18 | padding: theme(spacing.3); 19 | } 20 | } 21 | 22 | .statusColumn { 23 | display: flex; 24 | flex: 1; 25 | border: 1px solid theme(colors.primary.200); 26 | justify-content: space-between; 27 | background-color: theme(colors.primary.50); 28 | 29 | &:first-child { 30 | border-top-right-radius: 0px; 31 | border-bottom-right-radius: 0px; 32 | mask: linear-gradient(to left, transparent, black 32px); 33 | } 34 | &:last-child { 35 | border-top-left-radius: 0px; 36 | border-bottom-left-radius: 0px; 37 | mask: linear-gradient(to right, transparent, black 32px); 38 | } 39 | 40 | @screen md { 41 | display: flex; 42 | } 43 | } 44 | 45 | .status { 46 | margin: 0 auto; 47 | align-self: flex-start; 48 | background-color: theme(colors.sky.100); 49 | border-radius: theme(borderRadius.md); 50 | font-size: theme(fontSize.xs); 51 | font-weight: theme(fontWeight.bold); 52 | padding: theme(spacing.1) theme(spacing.2); 53 | color: theme(colors.sky.700); 54 | text-transform: capitalize; 55 | } 56 | 57 | .statusSpeaking { 58 | background-color: theme(colors.emerald.100); 59 | color: theme(colors.emerald.700); 60 | } 61 | 62 | .statusDisconnected { 63 | background-color: theme(colors.red.100); 64 | color: theme(colors.red.700); 65 | } 66 | 67 | .statusLoading { 68 | background-color: theme(colors.orange.100); 69 | color: theme(colors.orange.700); 70 | } 71 | 72 | .speaking { 73 | border-color: theme(colors.emerald.300); 74 | background-color: theme(colors.emerald.50); 75 | box-shadow: 0 0 0 2px theme(colors.emerald.50); 76 | } 77 | 78 | .latencyColumn { 79 | width: 90px; 80 | padding-left: 0; 81 | padding-right: 0; 82 | margin: 0 auto; 83 | } 84 | 85 | .header { 86 | font-family: theme(fontFamily.mono); 87 | font-size: 10px; 88 | text-transform: uppercase; 89 | letter-spacing: theme(letterSpacing.wider); 90 | 91 | > span { 92 | display: none; 93 | } 94 | 95 | @screen md { 96 | > span { 97 | display: inline; 98 | } 99 | } 100 | } 101 | 102 | .lastDelta { 103 | font-size: theme(fontSize.sm); 104 | font-weight: theme(fontWeight.bold); 105 | } 106 | 107 | .medianDelta { 108 | font-size: 11px; 109 | color: theme(colors.gray.400); 110 | } 111 | -------------------------------------------------------------------------------- /src/components/Latency/utils.ts: -------------------------------------------------------------------------------- 1 | export function calculateMedian(deltaArray: number[]) { 2 | // sort array ascending 3 | const asc = (arr: number[]) => arr.sort((a, b) => a - b); 4 | 5 | const quantile = (arr: number[], q: number) => { 6 | const sorted = asc(arr); 7 | const pos = (sorted.length - 1) * q; 8 | const base = Math.floor(pos); 9 | const rest = pos - base; 10 | if (sorted[base + 1] !== undefined) { 11 | return sorted[base] + rest * (sorted[base + 1] - sorted[base]); 12 | } else { 13 | return sorted[base]; 14 | } 15 | }; 16 | 17 | //const q25 = (arr) => quantile(arr, 0.25); 18 | 19 | const q50 = (arr: number[]) => quantile(arr, 0.5); 20 | 21 | //const q75 = (arr) => quantile(arr, 0.75); 22 | 23 | const median = (arr: number[]) => q50(arr); 24 | 25 | return median(deltaArray); 26 | } 27 | -------------------------------------------------------------------------------- /src/components/Session/Agent/avatar.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from "react"; 2 | import { RTVIEvent } from "realtime-ai"; 3 | import { useRTVIClientEvent } from "realtime-ai-react"; 4 | 5 | import FaceSVG from "./face.svg"; 6 | 7 | import styles from "./styles.module.css"; 8 | 9 | export const Avatar: React.FC = () => { 10 | const volRef = useRef(null); 11 | 12 | useRTVIClientEvent( 13 | RTVIEvent.RemoteAudioLevel, 14 | useCallback((volume: number) => { 15 | if (!volRef.current) return; 16 | volRef.current.style.transform = `scale(${Math.max(1, 1 + volume)})`; 17 | }, []) 18 | ); 19 | 20 | return ( 21 | <> 22 | Face 23 |
24 | 25 | ); 26 | }; 27 | 28 | export default Avatar; 29 | -------------------------------------------------------------------------------- /src/components/Session/Agent/face.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/components/Session/Agent/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { memo, useCallback, useEffect, useState } from "react"; 2 | import clsx from "clsx"; 3 | import { Loader2 } from "lucide-react"; 4 | import { RTVIEvent } from "realtime-ai"; 5 | import { useRTVIClientEvent } from "realtime-ai-react"; 6 | 7 | import Latency from "@/components/Latency"; 8 | 9 | //import TranscriptOverlay from "../TranscriptOverlay"; 10 | import Avatar from "./avatar"; 11 | import ModelBadge from "./model"; 12 | 13 | import styles from "./styles.module.css"; 14 | 15 | export const Agent: React.FC<{ 16 | isReady: boolean; 17 | statsAggregator: StatsAggregator; 18 | }> = memo( 19 | ({ isReady, statsAggregator }) => { 20 | const [hasStarted, setHasStarted] = useState(false); 21 | const [botStatus, setBotStatus] = useState< 22 | "initializing" | "connected" | "disconnected" 23 | >("initializing"); 24 | 25 | useEffect(() => { 26 | // Update the started state when the transport enters the ready state 27 | if (!isReady) return; 28 | setHasStarted(true); 29 | setBotStatus("connected"); 30 | }, [isReady]); 31 | 32 | useRTVIClientEvent( 33 | RTVIEvent.BotDisconnected, 34 | useCallback(() => { 35 | setHasStarted(false); 36 | setBotStatus("disconnected"); 37 | }, []) 38 | ); 39 | 40 | // Cleanup 41 | useEffect(() => () => setHasStarted(false), []); 42 | 43 | const cx = clsx(styles.agentWindow, hasStarted && styles.ready); 44 | 45 | return ( 46 |
47 |
48 | 49 | {!hasStarted ? ( 50 | 51 | 52 | 53 | ) : ( 54 | 55 | )} 56 | {/**/} 57 |
58 |
59 | 64 |
65 |
66 | ); 67 | }, 68 | (p, n) => p.isReady === n.isReady 69 | ); 70 | 71 | export default Agent; 72 | -------------------------------------------------------------------------------- /src/components/Session/Agent/model.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from "react"; 2 | import { RTVIClientConfigOption, RTVIEvent } from "realtime-ai"; 3 | import { useRTVIClient, useRTVIClientEvent } from "realtime-ai-react"; 4 | 5 | import styles from "./styles.module.css"; 6 | 7 | const ModelBadge: React.FC = () => { 8 | const rtviClient = useRTVIClient()!; 9 | const [model, setModel] = React.useState(); 10 | 11 | React.useEffect(() => { 12 | const getInitialModel = async () => { 13 | const modelValue = await rtviClient.getServiceOptionValueFromConfig( 14 | "llm", 15 | "model" 16 | ); 17 | setModel(modelValue as string); 18 | }; 19 | getInitialModel(); 20 | }, [rtviClient]); 21 | 22 | useRTVIClientEvent( 23 | RTVIEvent.Config, 24 | useCallback((config: RTVIClientConfigOption[]) => { 25 | const llmConfig = config.find((c) => c.service === "llm"); 26 | const modelOption = llmConfig?.options.find( 27 | (opt) => opt.name === "model" 28 | ); 29 | setModel(modelOption?.value as string); 30 | }, []) 31 | ); 32 | 33 | return
{model}
; 34 | }; 35 | 36 | export default ModelBadge; 37 | -------------------------------------------------------------------------------- /src/components/Session/Agent/styles.module.css: -------------------------------------------------------------------------------- 1 | .agent { 2 | padding: theme(spacing.2); 3 | position: relative; 4 | } 5 | 6 | .agentWindow { 7 | min-width: 400px; 8 | aspect-ratio: 1; 9 | background: theme(colors.primary.300); 10 | border-radius: theme(borderRadius.2xl); 11 | position: relative; 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | transition: background-color 2.5s; 16 | overflow: hidden; 17 | 18 | @media (max-width: 768px) { 19 | min-width: 0; 20 | } 21 | } 22 | 23 | .connected { 24 | background: theme(colors.primary.600); 25 | } 26 | 27 | .agentFooter { 28 | display: flex; 29 | flex-flow: row nowrap; 30 | justify-content: space-between; 31 | width: 100%; 32 | 33 | > :first-child { 34 | border-right: 1px solid var(--color-gray-200); 35 | } 36 | } 37 | 38 | .status { 39 | flex: 1; 40 | line-height: 1; 41 | display: flex; 42 | flex-flow: column wrap; 43 | align-items: center; 44 | gap: 0.5rem; 45 | 46 | > span { 47 | font-family: var(--font-mono); 48 | font-size: 11px; 49 | text-transform: uppercase; 50 | line-height: 1; 51 | letter-spacing: 0.7px; 52 | } 53 | } 54 | 55 | .statusIndicator { 56 | display: inline-flex; 57 | align-items: center; 58 | gap: 0.5rem; 59 | font-size: 0.75rem; 60 | font-weight: 700; 61 | padding: 0.5rem 0.75rem; 62 | border-radius: var(--borderRadius-xs); 63 | 64 | > span { 65 | display: block; 66 | width: 9px; 67 | height: 9px; 68 | border-radius: 9px; 69 | background: red; 70 | } 71 | } 72 | 73 | .statusDefault { 74 | color: var(--color-gray-500); 75 | background: var(--color-gray-100); 76 | 77 | > span { 78 | background: var(--color-gray-400); 79 | } 80 | } 81 | .statusOrange { 82 | color: var(--color-orange-800); 83 | background: var(--color-orange-100); 84 | 85 | > span { 86 | background: var(--color-orange-400); 87 | } 88 | } 89 | .statusRed { 90 | color: var(--color-red-800); 91 | background: var(--color-red-100); 92 | 93 | > span { 94 | background: var(--color-red-400); 95 | } 96 | } 97 | 98 | .statusGreen { 99 | color: var(--color-green-800); 100 | background: var(--color-green-100); 101 | 102 | > span { 103 | background: var(--color-green-400); 104 | } 105 | } 106 | 107 | .loader { 108 | padding: 12px; 109 | display: inline-block; 110 | line-height: 0; 111 | background-color: theme(colors.primary.600); 112 | border-radius: 99px; 113 | color: white; 114 | position: absolute; 115 | } 116 | 117 | .modelBadge { 118 | position: absolute; 119 | top: 12px; 120 | left: 12px; 121 | right: 12px; 122 | text-align: center; 123 | z-index: 99; 124 | text-transform: uppercase; 125 | font-size: 11px; 126 | font-weight: theme(fontWeight.semibold); 127 | color: theme(colors.primary.500); 128 | } 129 | 130 | .face { 131 | animation: faceAppear 1s ease-out forwards; 132 | position: relative; 133 | z-index: 2; 134 | } 135 | 136 | .faceBubble { 137 | position: absolute; 138 | width: 220px; 139 | height: 220px; 140 | border-radius: 999px; 141 | z-index: 1; 142 | background-color: theme(colors.primary.700); 143 | transition: transform 0.1s ease; 144 | } 145 | 146 | @keyframes faceAppear { 147 | from { 148 | opacity: 0; 149 | transform: translateY(1rem); 150 | } 151 | to { 152 | opacity: 1; 153 | transform: translateY(0); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/components/Session/ExpiryTimer/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useState } from "react"; 2 | import { Timer } from "lucide-react"; 3 | import { RTVIEvent } from "realtime-ai"; 4 | import { useRTVIClient, useRTVIClientEvent } from "realtime-ai-react"; 5 | 6 | import { 7 | Tooltip, 8 | TooltipContent, 9 | TooltipTrigger, 10 | } from "@/components/ui/tooltip"; 11 | import { cn } from "@/utils/tailwind"; 12 | 13 | import styles from "./styles.module.css"; 14 | 15 | type TimeState = { 16 | minutes: number; 17 | seconds: number; 18 | }; 19 | 20 | const ExpiryTimer: React.FC = () => { 21 | const rtviClient = useRTVIClient(); 22 | const [exp, setExp] = useState(undefined); 23 | const [time, setTime] = useState({ minutes: 0, seconds: 0 }); 24 | 25 | useRTVIClientEvent( 26 | RTVIEvent.Connected, 27 | useCallback(() => { 28 | if (rtviClient) { 29 | setExp(rtviClient.transportExpiry); 30 | } 31 | }, [rtviClient]) 32 | ); 33 | 34 | const noExpiry = !exp || exp === 0; 35 | 36 | useEffect(() => { 37 | if (noExpiry) return; 38 | 39 | const futureTimestamp = exp; 40 | 41 | const updateTime = () => { 42 | try { 43 | const currentTimestamp = Math.floor(Date.now() / 1000); 44 | const differenceInSeconds = Math.max( 45 | 0, 46 | futureTimestamp! - currentTimestamp 47 | ); 48 | const minutes = Math.floor(differenceInSeconds / 60); 49 | const seconds = differenceInSeconds % 60; 50 | setTime({ minutes, seconds }); 51 | } catch (error) { 52 | console.error("Error updating expiry time:", error); 53 | setTime({ minutes: 0, seconds: 0 }); 54 | } 55 | }; 56 | 57 | const interval = setInterval(updateTime, 1000); 58 | updateTime(); 59 | 60 | return () => clearInterval(interval); 61 | }, [noExpiry, exp]); 62 | 63 | if (noExpiry) return null; 64 | 65 | const isExpired = time.minutes <= 0 && time.seconds <= 0; 66 | 67 | return ( 68 | 69 | 70 |
71 | 72 | 73 | {isExpired 74 | ? "--:--" 75 | : `${time.minutes}m ${time.seconds.toString().padStart(2, "0")}s`} 76 | 77 |
78 |
79 | 80 |

Remaining session time before expiry

81 |
82 |
83 | ); 84 | }; 85 | 86 | export default ExpiryTimer; 87 | -------------------------------------------------------------------------------- /src/components/Session/ExpiryTimer/styles.module.css: -------------------------------------------------------------------------------- 1 | .expiry { 2 | margin-left: auto; 3 | display: flex; 4 | flex-flow: row nowrap; 5 | font-size: theme(fontSize.sm); 6 | background-color: theme(colors.primary.100); 7 | border-radius: theme(borderRadius.lg); 8 | padding: theme(spacing.2) theme(spacing.3); 9 | border-top: 1px solid theme(colors.primary.200); 10 | gap: theme(spacing[1.5]); 11 | 12 | svg { 13 | color: theme(colors.primary.400); 14 | } 15 | } 16 | 17 | .expired { 18 | color: theme(colors.primary.400); 19 | } 20 | 21 | .time { 22 | font-weight: theme(fontWeight.semibold); 23 | letter-spacing: theme(letterSpacing.wider); 24 | width: 60px; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/Session/TranscriptOverlay/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useRef, useState } from "react"; 2 | import { BotLLMTextData, RTVIEvent } from "realtime-ai"; 3 | import { useRTVIClientEvent } from "realtime-ai-react"; 4 | 5 | import styles from "./styles.module.css"; 6 | 7 | const TranscriptOverlay: React.FC = () => { 8 | const [sentences, setSentences] = useState([]); 9 | const intervalRef = useRef(null); 10 | 11 | useEffect(() => { 12 | clearInterval(intervalRef.current!); 13 | 14 | intervalRef.current = setInterval(() => { 15 | if (sentences.length > 2) { 16 | setSentences((s) => s.slice(1)); 17 | } 18 | }, 2500); 19 | 20 | return () => clearInterval(intervalRef.current!); 21 | }, [sentences]); 22 | 23 | useRTVIClientEvent( 24 | RTVIEvent.BotTranscript, 25 | useCallback((data: BotLLMTextData) => { 26 | setSentences((s) => [...s, data.text]); 27 | }, []) 28 | ); 29 | 30 | return ( 31 |
32 | {sentences.map((sentence, index) => ( 33 | 34 | {sentence} 35 | 36 | ))} 37 |
38 | ); 39 | }; 40 | 41 | export default TranscriptOverlay; 42 | -------------------------------------------------------------------------------- /src/components/Session/TranscriptOverlay/styles.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: absolute; 3 | left: 1rem; 4 | right: 1rem; 5 | bottom: 1.5rem; 6 | top: 1.5rem; 7 | color: white; 8 | z-index: 50; 9 | margin: 0 auto; 10 | display: flex; 11 | flex-direction: column; 12 | gap: 8px; 13 | align-items: center; 14 | justify-content: end; 15 | text-align: center; 16 | } 17 | 18 | .transcript { 19 | font-weight: 600; 20 | font-size: theme(fontSize.sm); 21 | max-width: 320px; 22 | } 23 | 24 | .transcript span { 25 | box-decoration-break: clone; 26 | -webkit-box-decoration-break: clone; 27 | -moz-box-decoration-break: clone; 28 | background-color: color-mix( 29 | in srgb, 30 | theme(colors.primary.800), 31 | transparent 30% 32 | ); 33 | border-radius: theme(borderRadius.md); 34 | padding: 4px 8px; 35 | line-height: 1; 36 | } 37 | 38 | .sentence { 39 | opacity: 1; 40 | margin: 0px; 41 | animation: fadeOut 2.5s linear forwards; 42 | animation-delay: 1s; 43 | line-height: 2; 44 | } 45 | 46 | @keyframes fadeOut { 47 | 0% { 48 | opacity: 1; 49 | transform: scale(1); 50 | } 51 | 20% { 52 | transform: scale(1); 53 | filter: blur(0); 54 | } 55 | 100% { 56 | transform: scale(0.8) translateY(-50%); 57 | filter: blur(25px); 58 | opacity: 0; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/components/Session/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useRef, useState } from "react"; 2 | import { createPortal } from "react-dom"; 3 | import { LineChart, LogOut, Settings, StopCircle } from "lucide-react"; 4 | import { 5 | LLMHelper, 6 | PipecatMetricsData, 7 | RTVIEvent, 8 | TransportState, 9 | } from "realtime-ai"; 10 | import { useRTVIClient, useRTVIClientEvent } from "realtime-ai-react"; 11 | 12 | import StatsAggregator from "../../utils/stats_aggregator"; 13 | import Configuration from "../Configuration"; 14 | import Stats from "../Stats"; 15 | import { Button } from "../ui/button"; 16 | import * as Card from "../ui/card"; 17 | import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; 18 | import UserMicBubble from "../UserMicBubble"; 19 | 20 | import Agent from "./Agent"; 21 | 22 | let stats_aggregator: StatsAggregator; 23 | 24 | interface SessionProps { 25 | state: TransportState; 26 | onLeave: () => void; 27 | openMic?: boolean; 28 | startAudioOff?: boolean; 29 | } 30 | 31 | export const Session = React.memo( 32 | ({ state, onLeave, startAudioOff = false }: SessionProps) => { 33 | const rtviClient = useRTVIClient()!; 34 | const llmHelper = rtviClient.getHelper("llm"); 35 | const [hasStarted, setHasStarted] = useState(false); 36 | const [showDevices, setShowDevices] = useState(false); 37 | const [showStats, setShowStats] = useState(false); 38 | const [muted, setMuted] = useState(startAudioOff); 39 | const modalRef = useRef(null); 40 | 41 | // ---- Voice Client Events 42 | 43 | // Wait for the bot to enter a ready state and trigger it to say hello 44 | useRTVIClientEvent( 45 | RTVIEvent.BotReady, 46 | useCallback(() => { 47 | llmHelper?.appendToMessages({ 48 | role: "assistant", 49 | content: "Greet the user", 50 | }); 51 | }, [llmHelper]) 52 | ); 53 | 54 | useRTVIClientEvent( 55 | RTVIEvent.Metrics, 56 | useCallback((metrics: PipecatMetricsData) => { 57 | metrics?.ttfb?.map((m: { processor: string; value: number }) => { 58 | stats_aggregator.addStat([m.processor, "ttfb", m.value, Date.now()]); 59 | }); 60 | }, []) 61 | ); 62 | 63 | useRTVIClientEvent( 64 | RTVIEvent.BotStoppedSpeaking, 65 | useCallback(() => { 66 | if (hasStarted) return; 67 | setHasStarted(true); 68 | }, [hasStarted]) 69 | ); 70 | 71 | // ---- Effects 72 | 73 | useEffect(() => { 74 | // Reset started state on mount 75 | setHasStarted(false); 76 | }, []); 77 | 78 | useEffect(() => { 79 | // If we joined unmuted, enable the mic once in ready state 80 | if (!hasStarted || startAudioOff) return; 81 | rtviClient.enableMic(true); 82 | }, [rtviClient, startAudioOff, hasStarted]); 83 | 84 | useEffect(() => { 85 | // Create new stats aggregator on mount (removes stats from previous session) 86 | stats_aggregator = new StatsAggregator(); 87 | }, []); 88 | 89 | useEffect(() => { 90 | // Leave the meeting if there is an error 91 | if (state === "error") { 92 | onLeave(); 93 | } 94 | }, [state, onLeave]); 95 | 96 | useEffect(() => { 97 | // Modal effect 98 | // Note: backdrop doesn't currently work with dialog open, so we use setModal instead 99 | const current = modalRef.current; 100 | 101 | if (current && showDevices) { 102 | current.inert = true; 103 | current.showModal(); 104 | current.inert = false; 105 | } 106 | return () => current?.close(); 107 | }, [showDevices]); 108 | 109 | function toggleMute() { 110 | rtviClient.enableMic(muted); 111 | setMuted(!muted); 112 | } 113 | 114 | // Helper function to handle interruption 115 | const handleInterrupt = async () => { 116 | try { 117 | await rtviClient.action({ 118 | service: "tts", 119 | action: "interrupt", 120 | arguments: [], // No arguments needed for interrupt 121 | }); 122 | } catch (error) { 123 | console.error("Error interrupting:", error); 124 | } 125 | }; 126 | 127 | return ( 128 | <> 129 | 130 | 131 | 132 | Configuration 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | {showStats && 144 | createPortal( 145 | setShowStats(false)} 148 | />, 149 | document.getElementById("tray")! 150 | )} 151 | 152 |
153 | 157 | 161 | 162 | toggleMute()} 166 | /> 167 |
168 | 169 |
170 |
171 | 172 | Interrupt bot 173 | 174 | 177 | 178 | 179 | 180 | 181 | Show bot statistics panel 182 | 183 | 190 | 191 | 192 | 193 | Configure 194 | 195 | 202 | 203 | 204 | 208 |
209 |
210 | 211 | ); 212 | }, 213 | (p, n) => p.state === n.state 214 | ); 215 | 216 | export default Session; 217 | -------------------------------------------------------------------------------- /src/components/Setup/Configure.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import Configuration from "../Configuration"; 4 | import HelpTip from "../ui/helptip"; 5 | import { Label } from "../ui/label"; 6 | import { Switch } from "../ui/switch"; 7 | 8 | import DeviceSelect from "./DeviceSelect"; 9 | 10 | interface ConfigureProps { 11 | startAudioOff: boolean; 12 | handleStartAudioOff: () => void; 13 | } 14 | 15 | export const Configure: React.FC = ({ 16 | startAudioOff, 17 | handleStartAudioOff, 18 | }) => { 19 | return ( 20 | <> 21 |
22 | 23 | 24 |
25 | 26 |
27 |
28 | 32 | 36 |
37 |
38 | 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /src/components/Setup/DeviceSelect.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { Mic } from "lucide-react"; 3 | import { useRTVIClientMediaDevices } from "realtime-ai-react"; 4 | 5 | import { AudioIndicatorBar } from "../AudioIndicator"; 6 | import { Field } from "../ui/field"; 7 | import { Select } from "../ui/select"; 8 | 9 | interface DeviceSelectProps { 10 | hideMeter: boolean; 11 | } 12 | 13 | export const DeviceSelect: React.FC = ({ 14 | hideMeter = false, 15 | }) => { 16 | const { availableMics, selectedMic, updateMic } = useRTVIClientMediaDevices(); 17 | 18 | useEffect(() => { 19 | updateMic(selectedMic?.deviceId); 20 | }, [updateMic, selectedMic]); 21 | 22 | return ( 23 |
24 | 25 | 40 | {!hideMeter && } 41 | 42 | 43 | {/*} 44 | 45 | 60 | 61 | */} 62 |
63 | ); 64 | }; 65 | 66 | export default DeviceSelect; 67 | -------------------------------------------------------------------------------- /src/components/Setup/RoomInput.tsx: -------------------------------------------------------------------------------- 1 | import { Field } from "../ui/field"; 2 | import { Input } from "../ui/input"; 3 | 4 | interface RoomInputProps { 5 | onChange: (url: string) => void; 6 | error?: string | undefined | boolean; 7 | } 8 | 9 | export const RoomInput: React.FC = ({ onChange, error }) => { 10 | return ( 11 | 12 | onChange(e.target.value)} 17 | className="w-full" 18 | /> 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/Setup/RoomSetup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | import { Alert } from "../ui/alert"; 4 | 5 | import { RoomInput } from "./RoomInput"; 6 | import { SettingsList } from "./SettingsList"; 7 | 8 | interface RoomSetupProps { 9 | serverUrl: string; 10 | roomQs: string | null; 11 | roomQueryStringValid: boolean; 12 | roomError: boolean; 13 | handleCheckRoomUrl: (url: string) => void; 14 | } 15 | 16 | const serverURL = import.meta.env.VITE_SERVER_URL; 17 | const manualRoomCreation = !!import.meta.env.VITE_MANUAL_ROOM_ENTRY; 18 | 19 | export const RoomSetup: React.FC = ({ 20 | serverUrl, 21 | roomQs, 22 | roomQueryStringValid, 23 | roomError, 24 | handleCheckRoomUrl, 25 | }) => { 26 | return ( 27 | <> 28 | {import.meta.env.DEV && !serverURL && ( 29 | 30 |

31 | You have not set a server URL for local development. Please set{" "} 32 | VITE_SERVER_URL in .env.local. You will 33 | need to launch your bot manually at the same room URL. 34 |

35 |
36 | )} 37 | {import.meta.env.DEV && !serverURL && !manualRoomCreation && ( 38 | 42 |

43 | You have not set `VITE_MANUAL_ROOM_ENTRY` (auto room creation mode) 44 | without setting `VITE_SERVER_URL` 45 |

46 |
47 | )} 48 | 54 | 55 | {manualRoomCreation && !roomQs && ( 56 | 60 | )} 61 | 62 | ); 63 | }; 64 | -------------------------------------------------------------------------------- /src/components/Setup/SettingsList.tsx: -------------------------------------------------------------------------------- 1 | import { Check, X } from "lucide-react"; 2 | 3 | import { cn } from "@/utils/tailwind"; 4 | 5 | interface SettingsList { 6 | serverUrl: string; 7 | manualRoomCreation?: boolean; 8 | roomQueryString: string | null; 9 | roomQueryStringValid: boolean | null; 10 | } 11 | 12 | const rowCx = 13 | "grid grid-cols-subgrid col-span-2 gap-6 p-2 text-xs items-center overflow-hidden [&:nth-child(even)]:bg-white bg-primary-50"; 14 | const titleCx = "font-semibold w-max"; 15 | const valueCx = 16 | "text-right font-mono truncate text-primary-600 [&>svg]:ml-auto"; 17 | 18 | export const SettingsList: React.FC = ({ 19 | serverUrl, 20 | manualRoomCreation = false, 21 | roomQueryString, 22 | roomQueryStringValid, 23 | }) => { 24 | return ( 25 |
26 | {import.meta.env.VITE_SERVER_URL ? ( 27 |
28 | Server URL 29 | {serverUrl} 30 |
31 | ) : ( 32 |
33 | Start bot manually 34 | 35 | 36 | 37 |
38 | )} 39 |
40 | Auto room creation 41 | 42 | {!manualRoomCreation ? : } 43 | 44 |
45 | {import.meta.env.VITE_MANUAL_ROOM_ENTRY && ( 46 |
47 | Room URL 48 | 49 | {roomQueryString ? <>{roomQueryString} : } 50 | 51 |
52 | )} 53 | {roomQueryString && ( 54 |
55 | Valid room URL 56 | 57 | {roomQueryStringValid ? ( 58 | 59 | ) : ( 60 | 61 | )} 62 | 63 |
64 | )} 65 |
66 | Mic input mode 67 | 68 | {import.meta.env.VITE_OPEN_MIC ? "Open Mic" : "Round-robin"} 69 | 70 |
71 |
72 | User video enabled: 73 | 74 | {import.meta.env.VITE_USER_VIDEO ? ( 75 | 76 | ) : ( 77 | 78 | )} 79 | 80 |
81 |
82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /src/components/Setup/index.tsx: -------------------------------------------------------------------------------- 1 | import { Configure } from "./Configure"; 2 | import { RoomInput } from "./RoomInput"; 3 | import { RoomSetup } from "./RoomSetup"; 4 | import { SettingsList } from "./SettingsList"; 5 | 6 | export { Configure, RoomInput, RoomSetup, SettingsList }; 7 | -------------------------------------------------------------------------------- /src/components/Stats/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { 3 | Sparklines, 4 | SparklinesBars, 5 | SparklinesLine, 6 | SparklinesReferenceLine, 7 | } from "react-sparklines"; 8 | import { X } from "lucide-react"; 9 | 10 | import { Button } from "../ui/button"; 11 | import HelpTip from "../ui/helptip"; 12 | 13 | import styles from "./styles.module.css"; 14 | 15 | const StatsHeader: React.FC<{ title: string }> = ({ title }) => { 16 | return
{title}
; 17 | }; 18 | 19 | interface StatsProps { 20 | statsAggregator: StatsAggregator; 21 | handleClose: () => void; 22 | } 23 | 24 | export const Stats = React.memo( 25 | ({ statsAggregator, handleClose }: StatsProps) => { 26 | const [currentStats, setCurrentStats] = useState( 27 | statsAggregator.statsMap 28 | ); 29 | //const [ping, setPing] = useState(null); 30 | const intervalRef = useRef(null); 31 | 32 | /* 33 | const sendAppMessage = useAppMessage({ 34 | onAppMessage: useCallback((ev: DailyEventObjectAppMessage) => { 35 | // Aggregate metrics from pipecat 36 | if (ev.data?.type === "latency-pong-pipeline-delivery") { 37 | setPing(Date.now() - ev.data.ts); 38 | } 39 | }, []), 40 | });*/ 41 | 42 | useEffect(() => { 43 | if (intervalRef.current) { 44 | clearInterval(intervalRef.current!); 45 | } 46 | 47 | intervalRef.current = setInterval(async () => { 48 | // Get latest stats from aggregator 49 | const newStats = statsAggregator.getStats(); 50 | if (newStats) { 51 | setCurrentStats({ ...newStats }); 52 | } 53 | }, 2500); 54 | 55 | return () => clearInterval(intervalRef.current!); 56 | }, [statsAggregator]); 57 | 58 | const numTurns = statsAggregator.turns; 59 | 60 | /* 61 | function sendPingRequest() { 62 | // Send ping to get latency 63 | sendAppMessage({ "latency-ping": { ts: Date.now() } }, "*"); 64 | } 65 | 66 | const sumOfServices = Object.values(currentStats).reduce( 67 | (acc, service) => acc + (service.ttfb.latest || 0), 68 | 0 69 | );*/ 70 | 71 | return ( 72 |
73 |
74 | 82 |
83 |
84 |
85 | 86 |
87 |
88 |

Turns

89 | {numTurns} 90 |
91 |
92 |
93 | 94 |
95 | 96 | {currentStats && 97 | Object.entries(currentStats).map(([service, data]) => { 98 | return ( 99 |
100 |
101 |
102 | {service} TTFB 103 |
104 |
105 | Latest 106 | 107 | {data.ttfb?.latest?.toFixed(3)} 108 | s 109 | 110 |
111 |
112 |
113 | 119 | 122 | 125 | 126 | 127 |
128 |
129 |
130 | H: 131 | 132 | {data.ttfb?.high?.toFixed(3)} 133 | s 134 | 135 |
136 |
137 | M: 138 | 139 | {data.ttfb?.median?.toFixed(3)} 140 | s 141 | 142 |
143 |
144 | L: 145 | 146 | {data.ttfb?.low?.toFixed(3)} 147 | s 148 | 149 |
150 |
151 |
152 | ); 153 | })} 154 |
155 |
156 |
157 | ); 158 | }, 159 | () => true 160 | ); 161 | 162 | export default Stats; 163 | -------------------------------------------------------------------------------- /src/components/Stats/styles.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | position: absolute; 3 | width: var(--layout-aside-width); 4 | z-index: 9999; 5 | left: 0px; 6 | right: 0px; 7 | bottom: 0px; 8 | background: white; 9 | border-top: 1px solid theme(colors.primary.200); 10 | animation: appear 0.3s ease-in-out; 11 | text-align: left; 12 | box-shadow: theme(boxShadow.stats); 13 | 14 | @screen md { 15 | box-shadow: none; 16 | z-index: 1; 17 | height: 100%; 18 | position: relative; 19 | background: transparent; 20 | border-top: 0px; 21 | border-left: 1px solid theme(colors.primary.200); 22 | } 23 | } 24 | 25 | .inner { 26 | user-select: none; 27 | padding: theme(spacing.4) theme(spacing.3); 28 | padding-top: 0px; 29 | overflow-x: scroll; 30 | display: flex; 31 | flex-flow: row nowrap; 32 | gap: theme(spacing.8); 33 | 34 | @screen md { 35 | gap: theme(spacing.8); 36 | height: 100%; 37 | overflow-x: visible; 38 | overflow-y: scroll; 39 | flex-flow: column wrap; 40 | } 41 | } 42 | 43 | .close { 44 | text-align: center; 45 | 46 | @screen md { 47 | text-align: right; 48 | } 49 | } 50 | 51 | .networkStats { 52 | display: flex; 53 | flex-flow: column nowrap; 54 | gap: theme(spacing.3); 55 | height: 100%; 56 | 57 | > div { 58 | display: flex; 59 | flex-flow: column wrap; 60 | flex: 1; 61 | min-width: 100px; 62 | padding: theme(spacing.3) theme(spacing.2); 63 | align-items: center; 64 | justify-content: center; 65 | border-radius: theme(borderRadius.md); 66 | background-color: theme(colors.primary.100); 67 | flex-shrink: 0; 68 | overflow: hidden; 69 | 70 | span { 71 | color: theme(colors.primary.900); 72 | font-weight: theme(fontWeight.semibold); 73 | flex-shrink: 0; 74 | } 75 | } 76 | 77 | @screen md { 78 | height: auto; 79 | flex-flow: row nowrap; 80 | 81 | > div { 82 | min-width: auto; 83 | } 84 | } 85 | } 86 | 87 | .statsHeader { 88 | font-weight: 600; 89 | display: none; 90 | 91 | @screen md { 92 | display: block; 93 | } 94 | } 95 | 96 | .monoHeader { 97 | font-family: theme(fontFamily.mono); 98 | font-size: theme(fontSize.xs); 99 | text-transform: uppercase; 100 | letter-spacing: theme(letterSpacing.wide); 101 | color: theme(colors.primary.600); 102 | } 103 | 104 | .section { 105 | @screen md { 106 | } 107 | } 108 | 109 | .section, 110 | .sectionServices { 111 | display: flex; 112 | flex-flow: row; 113 | gap: theme(spacing.3); 114 | 115 | @screen md { 116 | display: flex; 117 | flex-flow: column wrap; 118 | gap: theme(spacing.3); 119 | } 120 | } 121 | 122 | sub { 123 | vertical-align: baseline; 124 | margin-left: 2px; 125 | font-size: 50%; 126 | bottom: 0 !important; 127 | color: theme(colors.primary.500); 128 | } 129 | 130 | .serviceStat { 131 | font-size: theme(fontSize.sm); 132 | background-color: white; 133 | border: 1px solid theme(colors.primary.200); 134 | border-radius: theme(borderRadius.md); 135 | 136 | > header { 137 | padding: theme(spacing.3); 138 | } 139 | 140 | > footer { 141 | border-top: 1px solid theme(colors.primary.200); 142 | display: flex; 143 | flex-flow: row nowrap; 144 | font-size: 11px; 145 | font-family: theme(fontFamily.mono); 146 | justify-content: space-between; 147 | padding: theme(spacing.2) theme(spacing.3); 148 | } 149 | } 150 | 151 | .serviceName { 152 | font-weight: theme(fontWeight.semibold); 153 | font-size: theme(fontSize.base); 154 | margin-bottom: theme(spacing.3); 155 | display: flex; 156 | flex-flow: row nowrap; 157 | gap: 5px; 158 | align-items: center; 159 | } 160 | 161 | .latest { 162 | background-color: theme(colors.primary.50); 163 | border-radius: theme(borderRadius.md); 164 | font-size: theme(fontSize.xs); 165 | text-transform: uppercase; 166 | letter-spacing: theme(letterSpacing.wide); 167 | display: flex; 168 | flex-flow: row wrap; 169 | align-items: center; 170 | justify-content: center; 171 | gap: theme(spacing.2); 172 | padding: theme(spacing.2) theme(spacing.3); 173 | } 174 | 175 | .statValue { 176 | line-height: 1; 177 | font-family: theme(fontFamily.mono); 178 | font-weight: theme(fontWeight.bold); 179 | text-transform: uppercase; 180 | display: inline-flex; 181 | flex-flow: row nowrap; 182 | gap: 5px; 183 | line-height: 1; 184 | 185 | > span { 186 | font-weight: theme(fontWeight.normal); 187 | } 188 | } 189 | 190 | .chart { 191 | width: auto; 192 | line-height: 1; 193 | margin: 0 theme(spacing.3); 194 | } 195 | -------------------------------------------------------------------------------- /src/components/UserMicBubble/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useRef } from "react"; 2 | import clsx from "clsx"; 3 | import { Mic, MicOff, Pause } from "lucide-react"; 4 | import { RTVIEvent } from "realtime-ai"; 5 | import { useRTVIClientEvent } from "realtime-ai-react"; 6 | 7 | import styles from "./styles.module.css"; 8 | 9 | const AudioIndicatorBubble: React.FC = () => { 10 | const volRef = useRef(null); 11 | 12 | useRTVIClientEvent( 13 | RTVIEvent.LocalAudioLevel, 14 | useCallback((volume: number) => { 15 | if (volRef.current) { 16 | const v = Number(volume) * 1.75; 17 | volRef.current.style.transform = `scale(${Math.max(0.1, v)})`; 18 | } 19 | }, []) 20 | ); 21 | 22 | return
; 23 | }; 24 | 25 | interface Props { 26 | active: boolean; 27 | muted: boolean; 28 | handleMute: () => void; 29 | } 30 | 31 | export default function UserMicBubble({ 32 | active, 33 | muted = false, 34 | handleMute, 35 | }: Props) { 36 | const canTalk = !muted && active; 37 | 38 | const cx = clsx( 39 | muted && active && styles.muted, 40 | !active && styles.blocked, 41 | canTalk && styles.canTalk 42 | ); 43 | 44 | return ( 45 |
46 |
handleMute()}> 47 |
48 | {!active ? ( 49 | 50 | ) : canTalk ? ( 51 | 52 | ) : ( 53 | 54 | )} 55 |
56 | {canTalk && } 57 |
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /src/components/UserMicBubble/styles.module.css: -------------------------------------------------------------------------------- 1 | .bubbleContainer { 2 | color: #ffffff; 3 | position: relative; 4 | z-index: 20; 5 | display: flex; 6 | flex-direction: column; 7 | margin: auto; 8 | position: relative; 9 | z-index: 20; 10 | padding-top: 20px; 11 | @screen md { 12 | padding-top: 0px; 13 | } 14 | } 15 | 16 | .bubble { 17 | position: relative; 18 | cursor: pointer; 19 | box-sizing: border-box; 20 | -moz-box-sizing: border-box; 21 | -webkit-box-sizing: border-box; 22 | width: 100px; 23 | height: 100px; 24 | border-radius: 100px; 25 | display: flex; 26 | justify-content: center; 27 | align-items: center; 28 | margin: 0 auto; 29 | z-index: 20; 30 | transition: all 0.5s ease, opacity 2s ease; 31 | border: 6px solid 32 | color-mix(in srgb, theme(colors.primary.300), transparent 70%); 33 | outline: 6px solid 34 | color-mix(in srgb, theme(colors.primary.300), transparent 70%); 35 | outline-offset: 0px; 36 | opacity: 0.5; 37 | 38 | background-color: theme(colors.primary.500); 39 | background-image: radial-gradient( 40 | theme(colors.primary.300), 41 | theme(colors.primary.400) 42 | ); 43 | 44 | @screen md { 45 | width: 120px; 46 | height: 120px; 47 | border-radius: 120px; 48 | } 49 | } 50 | 51 | @keyframes pulse { 52 | 0% { 53 | outline-width: 6px; 54 | } 55 | 50% { 56 | outline-width: 24px; 57 | } 58 | 100% { 59 | outline-width: 6px; 60 | } 61 | } 62 | 63 | .icon { 64 | position: relative; 65 | z-index: 20; 66 | opacity: 0.3; 67 | transition: opacity 0.5s ease; 68 | line-height: 1; 69 | } 70 | 71 | .canTalk { 72 | opacity: 1; 73 | background-color: theme(colors.primary.500); 74 | background-image: radial-gradient( 75 | theme(colors.primary.500), 76 | theme(colors.primary.600) 77 | ); 78 | border: 6px solid 79 | color-mix(in srgb, theme(colors.primary.200), transparent 60%); 80 | outline: 6px solid 81 | color-mix(in srgb, theme(colors.primary.400), transparent 70%); 82 | outline-offset: 4px; 83 | 84 | .icon { 85 | opacity: 1; 86 | } 87 | } 88 | 89 | .blocked { 90 | pointer-events: none; 91 | cursor: disabled; 92 | } 93 | 94 | .muted { 95 | opacity: 1; 96 | background-color: theme(colors.red.500); 97 | background-image: radial-gradient( 98 | theme(colors.red.500), 99 | theme(colors.red.600) 100 | ); 101 | animation: pulseText 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; 102 | border: 6px solid color-mix(in srgb, theme(colors.red.200), transparent 60%); 103 | outline: 6px solid color-mix(in srgb, theme(colors.red.400), transparent 70%); 104 | 105 | &:after { 106 | content: "Unmute"; 107 | position: absolute; 108 | inset: 0; 109 | display: flex; 110 | justify-content: center; 111 | align-items: center; 112 | font-size: 12px; 113 | font-weight: theme(fontWeight.bold); 114 | font-family: theme(fontFamily.mono); 115 | text-transform: uppercase; 116 | letter-spacing: theme(letterSpacing.wider); 117 | color: theme(colors.red.100); 118 | } 119 | } 120 | 121 | @keyframes pulseText { 122 | 0%, 123 | 100% { 124 | opacity: 1; 125 | } 126 | 50% { 127 | opacity: 0.5; 128 | } 129 | } 130 | 131 | .volume { 132 | position: absolute; 133 | overflow: hidden; 134 | inset: 0px; 135 | z-index: 0; 136 | border-radius: 999px; 137 | transition: all 0.1s ease; 138 | transform: scale(0); 139 | opacity: 0.5; 140 | background-color: theme(colors.green.300); 141 | } 142 | 143 | /* Transcript */ 144 | 145 | .transcript { 146 | pointer-events: none; 147 | user-select: none; 148 | position: absolute; 149 | bottom: 0px; 150 | flex: 0; 151 | align-self: center; 152 | opacity: 0.25; 153 | font-size: var(--font-size-xs); 154 | font-weight: 600; 155 | z-index: 999; 156 | color: white; 157 | background-color: color-mix(in srgb, theme(colors.gray.800), transparent 30%); 158 | border-radius: theme(borderRadius.sm); 159 | padding: 4px 8px; 160 | 161 | &:global(.active) { 162 | opacity: 1; 163 | } 164 | } 165 | 166 | .typewriter { 167 | display: flex; 168 | } 169 | 170 | .word { 171 | display: inline-block; 172 | } 173 | -------------------------------------------------------------------------------- /src/components/logo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const Logo: React.FC<{ className: string }> = ({ className }) => { 4 | return ( 5 | 13 | 17 | 18 | 19 | 20 | 24 | 28 | 29 | 30 | 31 | 36 | 41 | 42 | 43 | ); 44 | }; 45 | 46 | export default Logo; 47 | -------------------------------------------------------------------------------- /src/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { cva, VariantProps } from "class-variance-authority"; 3 | import { CircleAlert } from "lucide-react"; 4 | 5 | import { cn } from "@/utils/tailwind"; 6 | 7 | const alertVariants = cva("text-left border border-black rounded-lg p-4", { 8 | variants: { 9 | intent: { 10 | info: "alert-info", 11 | danger: "border-red-200 text-red-600 bg-red-50", 12 | }, 13 | }, 14 | defaultVariants: { 15 | intent: "info", 16 | }, 17 | }); 18 | 19 | export interface AlertProps 20 | extends React.HTMLAttributes, 21 | VariantProps {} 22 | 23 | export const Alert: React.FC = ({ children, intent, title }) => { 24 | return ( 25 |
26 | 27 | {intent === "danger" && } 28 | {title} 29 | 30 | {children} 31 |
32 | ); 33 | }; 34 | 35 | export const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |

47 | )); 48 | AlertTitle.displayName = "AlertTitle"; 49 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/utils/tailwind"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex gap-2 items-center justify-center whitespace-nowrap rounded-xl border text-base font-semibold ring-ring transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&>svg]:size-5", 9 | { 10 | variants: { 11 | variant: { 12 | primary: 13 | "border-primary bg-primary text-primary-foreground hover:bg-primary/90 disabled:text-primary-foreground/50", 14 | ghost: 15 | "border-primary-200 bg-white text-primary hover:border-primary-300 hover:bg-white/0 disabled:text-primary-foreground/50", 16 | outline: "button-outline", 17 | light: "border-transparent bg-transparent hover:bg-primary-50/20", 18 | icon: "bg-transparent border-0 hover:bg-primary-200", 19 | }, 20 | size: { 21 | default: "h-12 px-6 py-2", 22 | sm: "h-9 rounded-md px-3", 23 | lg: "h-11 rounded-md px-8", 24 | icon: "h-12 w-12", 25 | iconSm: "h-9 w-9", 26 | }, 27 | }, 28 | defaultVariants: { 29 | variant: "primary", 30 | size: "default", 31 | }, 32 | } 33 | ); 34 | 35 | export interface ButtonProps 36 | extends React.ButtonHTMLAttributes, 37 | VariantProps { 38 | fullWidthMobile?: boolean; 39 | asChild?: boolean; 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ( 44 | { variant, size, fullWidthMobile, className, asChild = false, ...props }, 45 | ref 46 | ) => { 47 | const Comp = asChild ? Slot : "button"; 48 | return ( 49 | 58 | ); 59 | } 60 | ); 61 | Button.displayName = "Button"; 62 | 63 | export { Button }; 64 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/utils/tailwind"; 4 | 5 | interface CardProps extends React.HTMLAttributes { 6 | shadow?: boolean; 7 | fullWidthMobile?: boolean; 8 | } 9 | 10 | const Card = React.forwardRef( 11 | ({ className, shadow, fullWidthMobile = true, ...props }, ref) => ( 12 |

22 | ) 23 | ); 24 | 25 | Card.displayName = "Card"; 26 | 27 | const CardHeader = React.forwardRef< 28 | HTMLDivElement, 29 | React.HTMLAttributes 30 | >(({ className, ...props }, ref) => ( 31 |
36 | )); 37 | CardHeader.displayName = "CardHeader"; 38 | 39 | const CardTitle = React.forwardRef< 40 | HTMLParagraphElement, 41 | React.HTMLAttributes 42 | >(({ className, ...props }, ref) => ( 43 |

51 | )); 52 | CardTitle.displayName = "CardTitle"; 53 | 54 | const CardDescription = React.forwardRef< 55 | HTMLParagraphElement, 56 | React.HTMLAttributes 57 | >(({ className, ...props }, ref) => ( 58 |

63 | )); 64 | CardDescription.displayName = "CardDescription"; 65 | 66 | interface CardContentProps extends React.HTMLAttributes { 67 | stack?: boolean; 68 | } 69 | 70 | const CardContent = React.forwardRef( 71 | ({ className, stack = false, ...props }, ref) => ( 72 |

81 | ) 82 | ); 83 | CardContent.displayName = "CardContent"; 84 | 85 | const CardFooter = React.forwardRef< 86 | HTMLDivElement, 87 | React.HTMLAttributes 88 | >(({ className, ...props }, ref) => ( 89 |
97 | )); 98 | CardFooter.displayName = "CardFooter"; 99 | 100 | export { 101 | Card, 102 | CardContent, 103 | CardDescription, 104 | CardFooter, 105 | CardHeader, 106 | CardTitle, 107 | }; 108 | -------------------------------------------------------------------------------- /src/components/ui/field.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/utils/tailwind"; 4 | 5 | import { Label } from "./label"; 6 | 7 | interface FieldProps extends React.HTMLAttributes { 8 | label: string; 9 | error?: string | undefined | boolean; 10 | } 11 | 12 | export const Field: React.FC = ({ 13 | className, 14 | label, 15 | error, 16 | children, 17 | }) => ( 18 |
19 | 20 | {children} 21 | {error &&

{error}

} 22 |
23 | ); 24 | 25 | Field.displayName = "Field"; 26 | -------------------------------------------------------------------------------- /src/components/ui/header.tsx: -------------------------------------------------------------------------------- 1 | import ExpiryTimer from "../Session/ExpiryTimer"; 2 | 3 | export function Header() { 4 | return ( 5 | 12 | ); 13 | } 14 | 15 | export default Header; 16 | -------------------------------------------------------------------------------- /src/components/ui/helptip.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { CircleHelp } from "lucide-react"; 3 | 4 | import { cn } from "@/utils/tailwind"; 5 | 6 | import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; 7 | 8 | interface HelpTipProps { 9 | text: string; 10 | className?: string; 11 | } 12 | 13 | const HelpTip: React.FC = ({ text, className }) => { 14 | return ( 15 | 16 | 17 | 22 | 23 | {text} 24 | 25 | 26 | ); 27 | }; 28 | 29 | export default HelpTip; 30 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@/utils/tailwind"; 5 | 6 | const inputVariants = cva( 7 | "flex h-12 px-3 w-full rounded-xl border border-primary-200 bg-background text-sm ring-ring file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", 8 | { 9 | variants: { 10 | variant: { 11 | default: "", 12 | danger: 13 | "border-red-500 text-red-500 focus-visible:ring-red-500 placeholder:text-red-300", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ); 21 | 22 | export interface InputProps 23 | extends React.InputHTMLAttributes, 24 | VariantProps {} 25 | 26 | export const Input: React.FC = ({ 27 | variant, 28 | className, 29 | type, 30 | ...props 31 | }) => { 32 | return ( 33 | 38 | ); 39 | }; 40 | Input.displayName = "Input"; 41 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/utils/tailwind"; 4 | 5 | export const Label: React.FC<{ 6 | className?: string; 7 | children: React.ReactNode; 8 | }> = ({ className, children }) => ( 9 | 10 | ); 11 | 12 | Label.displayName = "Label"; 13 | -------------------------------------------------------------------------------- /src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { cva, VariantProps } from "class-variance-authority"; 3 | 4 | import { cn } from "@/utils/tailwind"; 5 | 6 | const selectVariants = cva( 7 | "appearance-none bg-none bg-white bg-selectArrow bg-no-repeat bg-selectArrow flex h-12 px-3 pr-10 w-full rounded-xl border border-primary-200 text-sm ring-ring file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", 8 | { 9 | variants: { 10 | variant: { 11 | default: "", 12 | danger: 13 | "border-red-500 text-red-500 focus-visible:ring-red-500 placeholder:text-red-300", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ); 21 | 22 | export interface SelectProps 23 | extends React.SelectHTMLAttributes, 24 | VariantProps { 25 | icon: React.ReactNode; 26 | } 27 | 28 | export const Select: React.FC = ({ 29 | variant, 30 | className, 31 | children, 32 | icon, 33 | ...props 34 | }) => { 35 | return ( 36 |
37 | {icon && ( 38 |
39 | {icon} 40 |
41 | )} 42 | 52 |
53 | ); 54 | }; 55 | Select.displayName = "Input"; 56 | -------------------------------------------------------------------------------- /src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as SwitchPrimitives from "@radix-ui/react-switch"; 3 | 4 | import { cn } from "@/utils/tailwind"; 5 | 6 | const Switch = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 23 | 24 | )); 25 | Switch.displayName = SwitchPrimitives.Root.displayName; 26 | 27 | export { Switch }; 28 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 3 | 4 | import { cn } from "../../utils/tailwind"; 5 | 6 | const TooltipProvider = TooltipPrimitive.Provider; 7 | 8 | const Tooltip = TooltipPrimitive.Root; 9 | 10 | const TooltipTrigger = TooltipPrimitive.Trigger; 11 | 12 | const TooltipContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, sideOffset = 4, ...props }, ref) => ( 16 | 25 | )); 26 | TooltipContent.displayName = TooltipPrimitive.Content.displayName; 27 | 28 | export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }; 29 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | //const defaultLanguage = "English"; 2 | /* 3 | export function composeSystemPrompt(language: string) { 4 | return `You are a helpful assistant named Gary. Keep responses short and legible. Respond in ${language}.`; 5 | }*/ 6 | 7 | export const BOT_READY_TIMEOUT = 30 * 1000; // 20 seconds 8 | export const LATENCY_MIN = 300; 9 | export const LATENCY_MAX = 3000; 10 | export const VAD_POSITIVE_SPEECH_THRESHOLD = 0.8; 11 | export const VAD_NEGATIVE_SPEECH_THRESHOLD = 0.8 - 0.15; 12 | export const VAD_MIN_SPEECH_FRAMES = 5; 13 | export const VAD_REDEMPTION_FRAMES = 3; 14 | export const VAD_PRESPEECH_PAD_FRAMES = 1; 15 | 16 | export type Language = { 17 | language: string; 18 | model_id: string; 19 | code: string; 20 | voice: string; 21 | }; 22 | 23 | export type Voice = { 24 | label: string; 25 | id: string; 26 | }; 27 | 28 | export type LLMModel = { 29 | label: string; 30 | id: string; 31 | }; 32 | 33 | export const ttsVoices: Voice[] = [ 34 | { label: "Default", id: "79a125e8-cd45-4c13-8a67-188112f4dd22" }, 35 | { label: "California Girl", id: "b7d50908-b17c-442d-ad8d-810c63997ed9" }, 36 | { label: "Friendly Reading Man", id: "69267136-1bdc-412f-ad78-0caad210fb40" }, 37 | { label: "Kentucky Man", id: "726d5ae5-055f-4c3d-8355-d9677de68937" }, 38 | ]; 39 | 40 | export const languages: Language[] = [ 41 | { 42 | language: "English", 43 | model_id: "sonic-english", 44 | code: "en", 45 | voice: "79a125e8-cd45-4c13-8a67-188112f4dd22", 46 | }, 47 | { 48 | language: "French", 49 | model_id: "sonic-multilingual", 50 | code: "fr", 51 | voice: "a8a1eb38-5f15-4c1d-8722-7ac0f329727d", 52 | }, 53 | ]; 54 | 55 | export const llmModels: LLMModel[] = [ 56 | { label: "LLama3 70b", id: "llama-3.1-70b-versatile" }, 57 | { label: "Llama3 8b", id: "llama-3.1-8b-instant" }, 58 | ]; 59 | 60 | export const defaultLLMPrompt = `You are a assistant called ExampleBot. You can ask me anything. 61 | Keep responses brief and legible. 62 | Your responses will converted to audio. Please do not include any special characters in your response other than '!' or '?'. 63 | Start by briefly introducing yourself.`; 64 | 65 | export const defaultConfig = [ 66 | { 67 | service: "llm", 68 | options: [ 69 | { name: "model", value: "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" }, 70 | { 71 | name: "initial_messages", 72 | value: [ 73 | { 74 | role: "system", 75 | content: defaultLLMPrompt, 76 | }, 77 | ], 78 | }, 79 | { name: "run_on_config", value: true }, 80 | ], 81 | }, 82 | ]; 83 | -------------------------------------------------------------------------------- /src/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --default-font-size: theme(fontSize.sm); 7 | --app-padding: 12px; 8 | --layout-aside-width: 100%; 9 | 10 | @screen md { 11 | --app-padding: 1rem; 12 | --default-font-size: theme(fontSize.base); 13 | --layout-aside-width: 320px; 14 | } 15 | @screen lg { 16 | --layout-aside-width: 380px; 17 | } 18 | } 19 | 20 | * { 21 | box-sizing: border-box; 22 | } 23 | 24 | *:focus-visible { 25 | outline-color: black; 26 | outline-offset: 2px; 27 | outline-width: 2px; 28 | } 29 | 30 | html { 31 | font-size: var(--default-font-size); 32 | } 33 | 34 | html, 35 | body { 36 | background-color: theme(colors.primary.50); 37 | color: theme(colors.primary.900); 38 | overscroll-behavior: none; 39 | } 40 | 41 | body { 42 | font-family: theme(fontFamily.sans); 43 | font-synthesis: none; 44 | text-rendering: optimizeLegibility; 45 | -webkit-font-smoothing: antialiased; 46 | -moz-osx-font-smoothing: grayscale; 47 | } 48 | 49 | /* App layout */ 50 | 51 | body { 52 | display: flex; 53 | margin: 0; 54 | } 55 | 56 | #root { 57 | margin: 0 auto; 58 | text-align: center; 59 | display: flex; 60 | flex-flow: row nowrap; 61 | flex: 1; 62 | min-height: 100svh; 63 | } 64 | 65 | main { 66 | display: flex; 67 | flex-direction: column; 68 | flex: 1; 69 | } 70 | 71 | #app { 72 | display: flex; 73 | flex-direction: column; 74 | align-items: center; 75 | justify-content: center; 76 | flex: 1; 77 | padding: 0 var(--app-padding) var(--app-padding) var(--app-padding); 78 | } 79 | 80 | #tray { 81 | } 82 | 83 | /* Animation keyframes */ 84 | @keyframes appear { 85 | from { 86 | opacity: 0; 87 | transform: translateY(1rem); 88 | } 89 | to { 90 | opacity: 1; 91 | transform: translateY(0); 92 | } 93 | } 94 | 95 | @keyframes wiggle { 96 | 0% { 97 | transform: translateX(0); 98 | } 99 | 25% { 100 | transform: translateX(-5px); 101 | } 102 | 50% { 103 | transform: translateX(5px); 104 | } 105 | 75% { 106 | transform: translateX(-5px); 107 | } 108 | 100% { 109 | transform: translateX(0); 110 | } 111 | } 112 | 113 | /* Utilities */ 114 | 115 | .borderClip { 116 | background-clip: padding-box, border-box; 117 | } 118 | 119 | samp { 120 | font-family: theme(fontFamily.mono); 121 | display: inline; 122 | font-size: theme(fontSize.xs); 123 | padding: 0 0.4em; 124 | line-height: 1; 125 | border-radius: 0.25rem; 126 | background: color-mix(in srgb, currentColor, transparent 92%); 127 | letter-spacing: 0.05rem; 128 | } 129 | 130 | /* Dialog (modal) */ 131 | 132 | dialog { 133 | background: transparent; 134 | box-shadow: theme(boxShadow.long); 135 | border-radius: theme(borderRadius.3xl); 136 | animation: appear 0.3s ease-out; 137 | 138 | @screen md { 139 | } 140 | } 141 | 142 | dialog::backdrop { 143 | background-color: rgba(255, 255, 255, 0.5); 144 | backdrop-filter: blur(5px); 145 | } 146 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import { DailyTransport } from "@daily-co/realtime-ai-daily"; 4 | import { LLMHelper, RTVIClient, RTVIClientConfigOption } from "realtime-ai"; 5 | import { RTVIClientAudio, RTVIClientProvider } from "realtime-ai-react"; 6 | 7 | import { Header } from "./components/ui/header"; 8 | import { TooltipProvider } from "./components/ui/tooltip"; 9 | import App from "./App"; 10 | import { defaultConfig } from "./config"; 11 | import { Splash } from "./Splash"; 12 | 13 | import "./global.css"; // Note: Core app layout can be found here 14 | 15 | // Show warning on Firefox 16 | // @ts-expect-error - Firefox is not well support 17 | const isFirefox: boolean = typeof InstallTrigger !== "undefined"; 18 | 19 | const rtviClient = new RTVIClient({ 20 | transport: new DailyTransport(), 21 | params: { 22 | baseUrl: import.meta.env.VITE_BASE_URL, 23 | endpoints: { 24 | connect: "/start-bot", 25 | action: "/bot-action", 26 | }, 27 | config: defaultConfig as RTVIClientConfigOption[], 28 | }, 29 | enableMic: true, 30 | enableCam: false, 31 | }); 32 | 33 | const llmHelper = new LLMHelper({ 34 | callbacks: { 35 | // ... 36 | }, 37 | }); 38 | 39 | // Register the helper 40 | rtviClient.registerHelper("llm", llmHelper); 41 | 42 | export const Layout = () => { 43 | const [showSplash, setShowSplash] = useState(true); 44 | 45 | if (showSplash) { 46 | return setShowSplash(false)} />; 47 | } 48 | 49 | return ( 50 | 51 | 52 |
53 |
54 |
55 | 56 |
57 |
58 |