diff --git a/examples/client/.eslintrc.cjs b/examples/client/.eslintrc.cjs new file mode 100644 index 000000000..d6c953795 --- /dev/null +++ b/examples/client/.eslintrc.cjs @@ -0,0 +1,18 @@ +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, +} diff --git a/examples/client/.gitignore b/examples/client/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/examples/client/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/client/README.md b/examples/client/README.md new file mode 100644 index 000000000..0d6babedd --- /dev/null +++ b/examples/client/README.md @@ -0,0 +1,30 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default { + // other rules... + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: ['./tsconfig.json', './tsconfig.node.json'], + tsconfigRootDir: __dirname, + }, +} +``` + +- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked` +- Optionally add `plugin:@typescript-eslint/stylistic-type-checked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list diff --git a/examples/client/env.example b/examples/client/env.example new file mode 100644 index 000000000..a96939628 --- /dev/null +++ b/examples/client/env.example @@ -0,0 +1,2 @@ +VITE_SERVER_URL... #optional: if serving frontend independetely from backend (otherwise relative) +VITE_TRANSPORT_ROOM_URL=... #optional: use the same room each time (vs. creating a new one) diff --git a/examples/client/index.html b/examples/client/index.html new file mode 100644 index 000000000..d966a0a03 --- /dev/null +++ b/examples/client/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + + Pipecat Demo + + +
+ + + diff --git a/examples/client/package.json b/examples/client/package.json new file mode 100644 index 000000000..e0b20cf35 --- /dev/null +++ b/examples/client/package.json @@ -0,0 +1,36 @@ +{ + "name": "pipecatdemo", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "preview": "vite preview" + }, + "dependencies": { + "@daily-co/daily-js": "^0.64.0", + "@daily-co/daily-react": "^0.19.0", + "class-variance-authority": "^0.7.0", + "lucide-react": "^0.378.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "recoil": "^0.7.7" + }, + "devDependencies": { + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "@typescript-eslint/parser": "^7.2.0", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.6", + "i": "^0.3.7", + "npm": "^10.8.0", + "typescript": "^5.2.2", + "vite": "^5.2.0", + "vite-plugin-webfont-dl": "^3.9.4" + } +} \ No newline at end of file diff --git a/examples/client/public/favicon.ico b/examples/client/public/favicon.ico new file mode 100644 index 000000000..e74bf0837 Binary files /dev/null and b/examples/client/public/favicon.ico differ diff --git a/examples/client/src/App.tsx b/examples/client/src/App.tsx new file mode 100644 index 000000000..253bc9e1c --- /dev/null +++ b/examples/client/src/App.tsx @@ -0,0 +1,142 @@ +import { useState } from "react"; +import { useDaily } from "@daily-co/daily-react"; + +import { Alert } from "./components/alert"; +import { Button } from "./components/button"; +import { ArrowRight, Loader2 } from "lucide-react"; +import { DeviceSelect } from "./components/DeviceSelect"; +import Session from "./components/Session"; + +type State = + | "idle" + | "configuring" + | "requesting_agent" + | "connecting" + | "connected" + | "started" + | "finished" + | "error"; + +export default function App() { + // Use Daily as our agent transport + const daily = useDaily(); + + const [state, setState] = useState("idle"); + const [error, setError] = useState(null); + //const [room, setRoom] = useState(null); + + async function start() { + if (!daily) return; + + setState("requesting_agent"); + + const serverUrl = + import.meta.env.VITE_SERVER_URL || import.meta.env.BASE_URL; + + // Request a bot to join your session + let data; + + try { + const res = await fetch(`${serverUrl}/start_bot`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + room_url: import.meta.env.VITE_TRANSPORT_ROOM_URL || null, + }), + }); + + data = await res.json(); + + if (!res.ok) { + setError(data.detail); + setState("error"); + } + } catch (e) { + setError( + `Unable to connect to the server at '${serverUrl}' - is it running?` + ); + setState("error"); + return; + } + + setState("connecting"); + + await daily.join({ + url: data.room_url, + token: data.user_token, + videoSource: false, + startAudioOff: true, + }); + + setState("connected"); + } + + /*async function leave() { + await daily?.leave(); + setState("finished"); + }*/ + + if (state === "error") { + return ( + + {error} + + ); + } + + if (state === "connected") { + return ; + } + + const status_text = { + configuring: "Start", + requesting_agent: "Requesting agent...", + connecting: "Connecting to agent...", + }; + + if (state !== "idle") { + return ( +
+
+

Configure your devices

+

+ Please configure your microphone and speakers below +

+ + +
+
+ ); + } + + return ( +
+
+

Pipecat Simple Chatbot

+

+ Please ensure you microphone and speakers are connected and ready to + go +

+ {import.meta.env.DEV && !import.meta.env.VITE_SERVER_URL && ( +
+ Warning: you have not set a server URL for local development. Please + set VITE_SERVER_URL in{" "} + .env.development.local +
+ )} + +
+
+ ); +} diff --git a/examples/client/src/assets/logo.svg b/examples/client/src/assets/logo.svg new file mode 100644 index 000000000..1b0e20494 --- /dev/null +++ b/examples/client/src/assets/logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/examples/client/src/components/AudioIndicator/index.tsx b/examples/client/src/components/AudioIndicator/index.tsx new file mode 100644 index 000000000..f2f1ca3c5 --- /dev/null +++ b/examples/client/src/components/AudioIndicator/index.tsx @@ -0,0 +1,31 @@ +import { + useAudioLevel, + useAudioTrack, + useLocalSessionId, +} from "@daily-co/daily-react"; +import { useCallback, useRef } from "react"; + +import styles from "./styles.module.css"; + +export const AudioIndicatorBar: React.FC = () => { + const localSessionId = useLocalSessionId(); + const audioTrack = useAudioTrack(localSessionId); + + const volRef = useRef(null); + + useAudioLevel( + audioTrack?.persistentTrack, + useCallback((volume) => { + if (volRef.current) + volRef.current.style.width = Math.max(2, volume * 100) + "%"; + }, []) + ); + + return ( +
+
+
+ ); +}; + +export default AudioIndicatorBar; diff --git a/examples/client/src/components/AudioIndicator/styles.module.css b/examples/client/src/components/AudioIndicator/styles.module.css new file mode 100644 index 000000000..79139129a --- /dev/null +++ b/examples/client/src/components/AudioIndicator/styles.module.css @@ -0,0 +1,15 @@ +.bar { + background: var(--color-gray-200); + height: 8px; + width: 100%; + border-radius: 999px; + overflow: hidden; + + > div { + background: var(--color-green-500); + height: 8px; + width: 0px; + border-radius: 999px; + transition: width 0.1s ease; + } +} diff --git a/examples/client/src/components/DeviceSelect/index.tsx b/examples/client/src/components/DeviceSelect/index.tsx new file mode 100644 index 000000000..9d05bdd5c --- /dev/null +++ b/examples/client/src/components/DeviceSelect/index.tsx @@ -0,0 +1,127 @@ +"use client"; + +import { useEffect } from "react"; +import { DailyMeetingState } from "@daily-co/daily-js"; +import { useDaily, useDevices } from "@daily-co/daily-react"; +import { Mic, Speaker } from "lucide-react"; +import { AudioIndicatorBar } from "../AudioIndicator"; + +import styles from "./styles.module.css"; +import { Alert } from "../alert"; + +export function DeviceSelect() { + const daily = useDaily(); + const { + currentMic, + hasMicError, + micState, + microphones, + setMicrophone, + currentSpeaker, + speakers, + setSpeaker, + } = useDevices(); + + const handleMicrophoneChange = (value: string) => { + setMicrophone(value); + }; + + const handleSpeakerChange = (value: string) => { + setSpeaker(value); + }; + + useEffect(() => { + if (microphones.length > 0 || !daily || daily.isDestroyed()) return; + const meetingState = daily.meetingState(); + const meetingStatesBeforeJoin: DailyMeetingState[] = [ + "new", + "loading", + "loaded", + ]; + if (meetingStatesBeforeJoin.includes(meetingState)) { + daily.startCamera({ startVideoOff: true, startAudioOff: false }); + } + }, [daily, microphones]); + + return ( +
+ {hasMicError && ( + + {micState === "blocked" ? ( + <> + Please check your browser and system permissions. Make sure that + this app is allowed to access your microphone and refresh the + page. + + ) : micState === "in-use" ? ( + <> + Your microphone is being used by another app. Please close any + other apps using your microphone and restart this app. + + ) : micState === "not-found" ? ( + <> + No microphone seems to be connected. Please connect a microphone. + + ) : micState === "not-supported" ? ( + <> + This app is not supported on your device. Please update your + software or use a different device. + + ) : ( + <> + There seems to be an issue accessing your microphone. Try + restarting the app or consult a system administrator. + + )} + + )} + +
+ +
+ + +
+ +
+ +
+ +
+ + +
+
+
+ ); +} + +export default DeviceSelect; diff --git a/examples/client/src/components/DeviceSelect/styles.module.css b/examples/client/src/components/DeviceSelect/styles.module.css new file mode 100644 index 000000000..231a51c70 --- /dev/null +++ b/examples/client/src/components/DeviceSelect/styles.module.css @@ -0,0 +1,39 @@ +.deviceSelect { + display: flex; + flex-flow: column wrap; + width: 100%; + gap: 1rem; +} + +.field { + display: flex; + flex-flow: column wrap; + align-items: flex-start; + gap: 0.5rem; +} + +.label { + font-weight: 600; + font-size: 0.875rem; +} + +.selectContainer { + position: relative; + width: 100%; + + > svg { + position: absolute; + size: 24px; + top: 0; + bottom: 0; + margin: auto 0; + left: 0.75rem; + color: var(--color-gray-400); + } +} + +.deviceSelectField { + width: 100%; + padding-left: 2.875rem; + margin-top: auto; +} diff --git a/examples/client/src/components/Session/agent.tsx b/examples/client/src/components/Session/agent.tsx new file mode 100644 index 000000000..cc7c6a715 --- /dev/null +++ b/examples/client/src/components/Session/agent.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import Status from "./status"; +import styles from "./styles.module.css"; + +import { useParticipantIds } from "@daily-co/daily-react"; + +export const Agent: React.FC = () => { + const participantIds = useParticipantIds({ filter: "remote" }); + + const status = participantIds.length > 0 ? "connected" : "connecting"; + return ( +
+
+
+ User status + Agent status +
+
+ ); +}; + +export default Agent; diff --git a/examples/client/src/components/Session/index.tsx b/examples/client/src/components/Session/index.tsx new file mode 100644 index 000000000..66b6077a9 --- /dev/null +++ b/examples/client/src/components/Session/index.tsx @@ -0,0 +1,60 @@ +import React, { useEffect, useRef, useState } from "react"; +import { LogOut, Settings } from "lucide-react"; +import { DailyAudio } from "@daily-co/daily-react"; + +import DeviceSelect from "../DeviceSelect"; +import Agent from "./agent"; +import { Button } from "../button"; +import styles from "./styles.module.css"; +import UserMicBubble from "../UserMicBubble"; + +export const Session: React.FC = () => { + const [showDevices, setShowDevices] = useState(false); + const [canTalk, setCanTalk] = useState(false); + const modalRef = useRef(null); + + useEffect(() => { + const current = modalRef.current; + // Backdrop doesn't currently work with dialog open, so we use setModal instead + if (current && showDevices) { + current.inert = true; + current.showModal(); + current.inert = false; + } + return () => current?.close(); + }, [showDevices]); + + return ( + <> + +

Configure devices

+ + +
+ +
+ + + +
+ +
+
+ + +
+
+ + ); +}; + +export default Session; diff --git a/examples/client/src/components/Session/status.tsx b/examples/client/src/components/Session/status.tsx new file mode 100644 index 000000000..cd6b5f831 --- /dev/null +++ b/examples/client/src/components/Session/status.tsx @@ -0,0 +1,42 @@ +import { VariantProps, cva } from "class-variance-authority"; +import styles from "./styles.module.css"; + +const statusVariants = cva(styles.statusIndicator, { + variants: { + variant: { + default: styles.statusDefault, + connecting: styles.statusOrange, + connected: styles.statusGreen, + }, + }, + defaultVariants: { + variant: "default", + }, +}); + +export interface StatusProps + extends React.HTMLAttributes, + VariantProps {} + +const status_text = { + default: "Idle", + connecting: "Connecting", + connected: "Connected", +}; + +export const Status: React.FC = ({ + children, + variant = "default", +}) => { + return ( +
+ {children} +
+ + {status_text[variant as keyof typeof status_text]} +
+
+ ); +}; + +export default Status; diff --git a/examples/client/src/components/Session/styles.module.css b/examples/client/src/components/Session/styles.module.css new file mode 100644 index 000000000..1e09fb5ef --- /dev/null +++ b/examples/client/src/components/Session/styles.module.css @@ -0,0 +1,110 @@ +.footer { + display: flex; + flex-flow: row nowrap; + margin-top: auto; + align-self: flex-end; +} + +.controls { + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + gap: 0.75rem; +} + +.agentContainer { + flex: 1; + display: flex; + flex-flow: column wrap; + align-items: center; + justify-content: center; +} + +.agent { + margin-top: auto; + min-width: 420px; + padding: 0.75rem; + border-radius: var(--borderRadius-lg); + border-radius: 24px; + border: 1px solid #e2e8f0; + background: #fff; + box-shadow: 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); + background-image: linear-gradient(90deg, white, white), + linear-gradient(0deg, var(--color-gray-300), var(--color-gray-200)); + background-clip: padding-box, border-box; + background-origin: border-box; + border: 1px solid transparent; +} + +.agentWindow { + min-width: 400px; + aspect-ratio: 1; + background: var(--color-gray-200); + border-radius: var(--borderRadius-md); +} + +.agentFooter { + margin: 1.5rem 0 0.75rem 0; + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + width: 100%; + + > :first-child { + border-right: 1px solid var(--color-gray-200); + } +} + +.status { + flex: 1; + line-height: 1; + display: flex; + flex-flow: column wrap; + align-items: center; + gap: 0.5rem; + + > span { + font-family: var(--font-mono); + font-size: 11px; + text-transform: uppercase; + line-height: 1; + letter-spacing: 0.7px; + } +} + +.statusIndicator { + display: inline-flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + font-weight: 700; + padding: 0.5rem 0.75rem; + border-radius: var(--borderRadius-xs); + + > span { + display: block; + width: 9px; + height: 9px; + border-radius: 9px; + background: red; + } +} + +.statusDefault { + color: var(--color-gray-500); + background: var(--color-gray-100); + + > span { + background: var(--color-gray-400); + } +} +.statusOrange { + color: var(--color-orange-800); + background: var(--color-orange-100); + + > span { + background: var(--color-orange-400); + } +} diff --git a/examples/client/src/components/UserMicBubble/index.tsx b/examples/client/src/components/UserMicBubble/index.tsx new file mode 100644 index 000000000..bede863b7 --- /dev/null +++ b/examples/client/src/components/UserMicBubble/index.tsx @@ -0,0 +1,70 @@ +import React, { useCallback, useRef, useState, useEffect } from "react"; + +import { + useAudioLevel, + useAudioTrack, + useLocalSessionId, + useAppMessage, +} from "@daily-co/daily-react"; +import { DailyEventObjectAppMessage } from "@daily-co/daily-js"; +import { Mic, MicOff } from "lucide-react"; +//import { TypewriterEffect } from "../ui/typewriter"; +import styles from "./styles.module.css"; + +const AudioIndicatorBubble: React.FC = () => { + const localSessionId = useLocalSessionId(); + const audioTrack = useAudioTrack(localSessionId); + const volRef = useRef(null); + + useAudioLevel( + audioTrack?.persistentTrack, + useCallback((volume) => { + // this volume number will be between 0 and 1 + // give it a minimum scale of 0.15 to not completely disappear 👻 + if (volRef.current) { + const v = volume * 1.75; + volRef.current.style.transform = `scale(${Math.max(0.1, v)})`; + } + }, []) + ); + + // Your audio track's audio volume visualized in a small circle, + // whose size changes depending on the volume level + return
; +}; + +interface Props { + active: boolean; +} + +export default function UserMicBubble({ active }: Props) { + const [transcription, setTranscription] = useState([]); + + useAppMessage({ + onAppMessage: (e: DailyEventObjectAppMessage) => { + if (e.fromId && e.fromId === "transcription") { + if (e.data.user_id === "" && e.data.is_final) { + setTranscription((t) => [...t, ...e.data.text.split(" ")]); + } + } + }, + }); + + useEffect(() => { + if (active) return; + const t = setTimeout(() => setTranscription([]), 4000); + return () => clearTimeout(t); + }, [active]); + + return ( +
+
+ {active ? : } + {active && } +
+
+
+ ); +} diff --git a/examples/client/src/components/UserMicBubble/styles.module.css b/examples/client/src/components/UserMicBubble/styles.module.css new file mode 100644 index 000000000..d4049a653 --- /dev/null +++ b/examples/client/src/components/UserMicBubble/styles.module.css @@ -0,0 +1,96 @@ +.active::after { + opacity: 1; +} + +.bubbleContainer { + color: #ffffff; + position: relative; + z-index: 20; + display: flex; + flex-direction: column; + margin: auto; +} + +.micIcon { + position: relative; + box-sizing: border-box; + -moz-box-sizing: border-box; + -webkit-box-sizing: border-box; + width: 120px; + height: 120px; + border-radius: 120px; + display: flex; + justify-content: center; + align-items: center; + margin: 0 auto; + z-index: 20; + transition: all 0.5s ease; + border: 6px solid color-mix(in srgb, var(--color-gray-300), transparent 70%); + outline: 6px solid color-mix(in srgb, var(--color-gray-300), transparent 70%); + background-color: var(--color-gray-500); + background-image: radial-gradient( + var(--color-gray-300), + var(--color-gray-400) + ); +} + +.micIcon svg { + position: relative; + z-index: 20; + opacity: 0.3; + transition: opacity 0.5s ease; +} + +@keyframes pulse { + 0% { + outline-width: 6px; + @apply outline-teal-500/10; + } + 50% { + outline-width: 24px; + @apply outline-teal-500/50; + } + 100% { + outline-width: 6px; + @apply outline-teal-500/10; + } +} + +.micIconActive { + background-color: var(--color-green-500); + background-image: radial-gradient( + var(--color-green-500), + var(--color-green-600) + ); + border: 6px solid color-mix(in srgb, var(--color-green-200), transparent 60%); + outline: 6px solid color-mix(in srgb, var(--color-green-400), transparent 70%); + animation: pulse 2s infinite ease-in-out; +} + +.micIconActive svg { + opacity: 1; +} + +.transcript { + flex: 0; + align-self: center; + opacity: 0.25; + transition: opacity 1s ease; + transition-delay: 2.5s; +} + +.active .transcript { + opacity: 1; +} + +.volume { + position: absolute; + overflow: hidden; + inset: 0px; + z-index: 0; + border-radius: 999px; + transition: all 0.1s ease; + transform: scale(0); + opacity: 0.4; + background-color: var(--color-green-200); +} diff --git a/examples/client/src/components/alert.tsx b/examples/client/src/components/alert.tsx new file mode 100644 index 000000000..b8d15bc0c --- /dev/null +++ b/examples/client/src/components/alert.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { cva, VariantProps } from "class-variance-authority"; + +const alertVariants = cva("alert", { + variants: { + intent: { + info: "alert-info", + danger: "alert-danger", + }, + }, + defaultVariants: { + intent: "info", + }, +}); + +export interface AlertProps + extends React.HTMLAttributes, + VariantProps {} + +export const Alert: React.FC = ({ children, intent, title }) => { + return ( +
+ {title} + {children} +
+ ); +}; diff --git a/examples/client/src/components/button.tsx b/examples/client/src/components/button.tsx new file mode 100644 index 000000000..bda9f4e61 --- /dev/null +++ b/examples/client/src/components/button.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { cva, VariantProps } from "class-variance-authority"; + +const buttonVariants = cva("button", { + variants: { + variant: { + primary: "button-primary", + ghost: "button-ghost", + }, + size: { + base: "", + icon: "button-icon", + }, + }, + defaultVariants: { + variant: "primary", + size: "base", + }, +}); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +export const Button: React.FC = ({ variant, size, ...props }) => { + return