├── .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 |
42 |
43 |
61 |
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 |
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 |
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 |
23 |
24 | >
25 | );
26 | };
27 |
28 | export default Avatar;
29 |
--------------------------------------------------------------------------------
/src/components/Session/Agent/face.svg:
--------------------------------------------------------------------------------
1 |
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 |
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 |
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 |
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 |
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 | }
47 | onChange={(e) => handleSpeakerChange(e.target.value)}
48 | defaultValue={currentSpeaker?.device.deviceId}
49 | >
50 | {speakers.length === 0 ? (
51 |
52 | ) : (
53 | speakers.map((m) => (
54 |
57 | ))
58 | )}
59 |
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 ;
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 |
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 |
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 |
21 |
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 |
57 |
58 |
59 |
60 |
61 |
62 | );
63 | };
64 |
65 | ReactDOM.createRoot(document.getElementById("root")!).render(
66 |
67 | {isFirefox && (
68 |
69 | Latency readings can be inaccurate in Firefox. For best results, please
70 | use Chrome.
71 |
72 | )}
73 |
74 |
75 | );
76 |
--------------------------------------------------------------------------------
/src/types/stats_aggregator.d.ts:
--------------------------------------------------------------------------------
1 | type Stat = [string, string, number, number];
2 |
3 | interface MetricValue {
4 | latest: number;
5 | timeseries: number[];
6 | median: number | null;
7 | high: number | null;
8 | low: number | null;
9 | }
10 |
11 | interface Metric {
12 | [metric: string]: MetricValue;
13 | }
14 |
15 | interface StatsMap {
16 | [service: string]: Metric;
17 | }
18 |
19 | interface IStatsAggregator {
20 | statsMap: StatsMap;
21 | hasNewStats: boolean;
22 | turns: number;
23 |
24 | addStat(stat: Stat): void;
25 | getStats(): StatsMap | null;
26 | }
27 |
28 | declare class StatsAggregator implements IStatsAggregator {
29 | statsMap: StatsMap;
30 | hasNewStats: boolean;
31 | turns: number;
32 |
33 | constructor();
34 | addStat(stat: Stat): void;
35 | getStats(): StatsMap | null;
36 | }
37 |
--------------------------------------------------------------------------------
/src/utils/stats_aggregator.ts:
--------------------------------------------------------------------------------
1 | class StatsAggregator implements IStatsAggregator {
2 | statsMap: StatsMap = {};
3 | hasNewStats = false;
4 | turns = 0;
5 |
6 | constructor() {}
7 |
8 | addStat(stat: Stat) {
9 | const [service, metric, value] = stat;
10 | if (!service || !metric || value <= 0) {
11 | return;
12 | }
13 |
14 | // Ensure the service exists in statsMap
15 | if (!this.statsMap[service]) {
16 | this.statsMap[service] = {};
17 | }
18 |
19 | const timeseries = [
20 | ...(this.statsMap[service][metric]?.timeseries || []),
21 | value,
22 | ];
23 |
24 | const median =
25 | timeseries.reduce((acc, curr) => acc + curr, 0) / timeseries.length;
26 | const high = timeseries.reduce((acc, curr) => Math.max(acc, curr), 0);
27 | const low = timeseries.reduce((acc, curr) => Math.min(acc, curr), Infinity);
28 |
29 | this.statsMap[service][metric] = {
30 | latest: value,
31 | timeseries,
32 | median,
33 | high,
34 | low,
35 | };
36 |
37 | this.hasNewStats = true;
38 | }
39 |
40 | getStats(): StatsMap | null {
41 | if (this.hasNewStats) {
42 | this.hasNewStats = false;
43 | return this.statsMap;
44 | } else {
45 | return null;
46 | }
47 | }
48 | }
49 |
50 | export default StatsAggregator;
51 |
--------------------------------------------------------------------------------
/src/utils/tailwind.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | import colors from "tailwindcss/colors";
3 | import defaultTheme from "tailwindcss/defaultTheme";
4 |
5 | export default {
6 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
7 | theme: {
8 | extend: {
9 | colors: {
10 | primary: {
11 | ...colors.gray,
12 | DEFAULT: colors.gray[900],
13 | foreground: colors.white,
14 | hairline: colors.gray[100],
15 | },
16 | ring: colors.gray[500],
17 | },
18 | fontFamily: {
19 | sans: ["Inter", ...defaultTheme.fontFamily.sans],
20 | mono: ["Space Mono", ...defaultTheme.fontFamily.mono],
21 | },
22 | container: {
23 | center: true,
24 | padding: {
25 | DEFAULT: "12px",
26 | md: "1rem",
27 | },
28 | },
29 | backgroundImage: {
30 | colorWash: "url('/color-wash-bg.png')",
31 | cardBorder: `linear-gradient(90deg, white, white), linear-gradient(0deg, ${colors.gray[300]}, ${colors.gray[200]})`,
32 | selectArrow:
33 | "url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQgNkw4IDEwTDEyIDYiIHN0cm9rZT0iIzY0NzQ4QiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+Cjwvc3ZnPgo=')",
34 | },
35 | backgroundPosition: {
36 | selectArrow: "right 1rem center",
37 | },
38 | boxShadow: {
39 | short:
40 | "0px 7px 2px 0px rgba(0, 0, 0, 0), 0px 5px 2px 0px rgba(0, 0, 0, 0.01), 0px 3px 2px 0px rgba(0, 0, 0, 0.03), 0px 1px 1px 0px rgba(0, 0, 0, 0.04), 0px 0px 1px 0px rgba(0, 0, 0, 0.05)",
41 | mid: "0px 100px 28px 0px rgba(0, 0, 0, 0), 0px 64px 26px 0px rgba(0, 0, 0, 0.01), 0px 36px 22px 0px rgba(0, 0, 0, 0.03), 0px 16px 16px 0px rgba(0, 0, 0, 0.04), 0px 4px 9px 0px rgba(0, 0, 0, 0.05)",
42 | long: "0px 360px 101px 0px rgba(0, 0, 0, 0), 0px 231px 92px 0px rgba(0, 0, 0, 0), 0px 130px 78px 0px rgba(0, 0, 0, 0.02),0px 58px 58px 0px rgba(0, 0, 0, 0.03), 0px 14px 32px 0px rgba(0, 0, 0, 0.03)",
43 | stats: "0px -2px 15px 0px rgba(0, 0, 0, 0.07)",
44 | },
45 | animation: {
46 | wiggle: "wiggle 0.2s 1",
47 | appear: "appear 0.5s ease-out forwards",
48 | },
49 | },
50 | },
51 | plugins: [],
52 | };
53 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ESNext",
4 | "module": "ESNext",
5 | "useDefineForClassFields": true,
6 | "lib": ["ESNext", "DOM", "DOM.Iterable"],
7 | "skipLibCheck": true,
8 |
9 | "baseUrl": ".",
10 | "paths": {
11 | "@/*": ["src/*"]
12 | },
13 |
14 | /* Bundler mode */
15 | "moduleResolution": "bundler",
16 | "allowImportingTsExtensions": true,
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "noEmit": true,
20 | "jsx": "react-jsx",
21 |
22 | /* Linting */
23 | "strict": true,
24 | "noUnusedLocals": true,
25 | "noUnusedParameters": true,
26 | "noFallthroughCasesInSwitch": true
27 | },
28 | "include": ["src"],
29 | "references": [{ "path": "./tsconfig.node.json" }]
30 | }
31 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true,
8 | "strict": true
9 | },
10 | "include": ["vite.config.ts"]
11 | }
12 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "headers": [
3 | {
4 | "source": "/",
5 | "headers": [
6 | {
7 | "key": "Cross-Origin-Embedder-Policy",
8 | "value": "require-corp"
9 | },
10 | {
11 | "key": "Cross-Origin-Opener-Policy",
12 | "value": "same-origin"
13 | }
14 | ]
15 | }
16 | ]
17 | }
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import react from "@vitejs/plugin-react";
2 | import path from "path";
3 | import { defineConfig } from "vite";
4 | import { viteStaticCopy } from "vite-plugin-static-copy";
5 | import webfontDownload from "vite-plugin-webfont-dl";
6 |
7 | export default defineConfig({
8 | assetsInclude: ["**/*.onnx"],
9 | server: {
10 | headers: {
11 | "Cross-Origin-Embedder-Policy": "require-corp",
12 | "Cross-Origin-Opener-Policy": "same-origin",
13 | },
14 | },
15 | resolve: {
16 | alias: {
17 | "@": path.resolve(__dirname, "./src"),
18 | },
19 | },
20 | plugins: [
21 | react(),
22 | webfontDownload(),
23 | viteStaticCopy({
24 | targets: [
25 | {
26 | src: "node_modules/onnxruntime-web/dist/*.wasm",
27 | dest: "./",
28 | },
29 | ],
30 | }),
31 | ],
32 | });
33 |
--------------------------------------------------------------------------------