Compare commits

...

7 Commits

Author SHA1 Message Date
Jon Taylor
fb741e5c3e moved expiry time to create_room 2024-05-16 19:41:17 +01:00
Jon Taylor
d4bef6e1d4 enabled open mic for simple chatbot demo 2024-05-16 19:38:40 +01:00
Jon Taylor
88992c1d9b added open mic config var 2024-05-16 19:36:00 +01:00
Jon Taylor
b798d9cd42 added open mic config 2024-05-16 19:35:10 +01:00
Jon Taylor
dab01e0d58 fixed runner 2024-05-16 18:57:34 +01:00
Jon Taylor
72da9320da added temporary api 2024-05-16 17:32:52 +01:00
Jon Taylor
27c019c25a introducing standalone client UI for examples that use Daily prebuilt 2024-05-16 17:24:07 +01:00
37 changed files with 5099 additions and 194 deletions

View File

@@ -2,6 +2,7 @@ import asyncio
import aiohttp
import os
import sys
import argparse
from PIL import Image
@@ -23,8 +24,6 @@ from pipecat.services.openai import OpenAILLMService
from pipecat.transports.services.daily import DailyParams, DailyTranscriptionSettings, DailyTransport
from pipecat.vad.silero import SileroVAD
from runner import configure
from loguru import logger
from dotenv import load_dotenv
@@ -43,7 +42,8 @@ for i in range(1, 26):
# Get the filename without the extension to use as the dictionary key
# Open the image and convert it to bytes
with Image.open(full_path) as img:
sprites.append(ImageRawFrame(image=img.tobytes(), size=img.size, format=img.format))
sprites.append(ImageRawFrame(image=img.tobytes(),
size=img.size, format=img.format))
flipped = sprites[::-1]
sprites.extend(flipped)
@@ -156,5 +156,9 @@ async def main(room_url: str, token):
if __name__ == "__main__":
(url, token) = configure()
asyncio.run(main(url, token))
parser = argparse.ArgumentParser(description="Daily Storyteller Bot")
parser.add_argument("-u", type=str, help="Room URL")
parser.add_argument("-t", type=str, help="Token")
config = parser.parse_args()
asyncio.run(main(config.u, config.t))

View File

@@ -0,0 +1,169 @@
from daily_helpers import create_room, get_token, check_room_url
import os
import argparse
import subprocess
import atexit
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, JSONResponse
from dotenv import load_dotenv
load_dotenv(override=True)
# Bot sub-process dict for status reporting and concurrency control
bot_procs = {}
def cleanup():
# Clean up function, just to be extra safe
for proc in bot_procs.values():
proc[0].terminate()
proc[0].wait()
atexit.register(cleanup)
# ------------ Configuration ------------ #
MAX_SESSION_TIME = + 5 * 60 # 5 minutes
BOT_CAN_IDLE = True
SERVE_STATIC = True
STATIC_DIR = "../web-ui/dist"
STATIC_ROUTE = "/static"
STATIC_INDEX = "index.html"
USE_OPEN_MIC = True # Can the user freely talk, or do they need to wait their turn?
# ----------------- API ----------------- #
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"]
)
# Optionally serve client static files
if SERVE_STATIC:
app.mount(STATIC_ROUTE, StaticFiles(
directory=STATIC_DIR, html=True), name="static")
@app.get("/{path_name:path}", response_class=FileResponse)
async def catch_all(path_name: Optional[str] = ""):
if path_name == "":
return FileResponse(f"{STATIC_DIR}/{STATIC_INDEX}")
file_path = Path(STATIC_DIR) / (path_name or "")
if file_path.is_file():
return file_path
html_file_path = file_path.with_suffix(".html")
if html_file_path.is_file():
return FileResponse(html_file_path)
raise HTTPException(
status_code=404, detail="Page not found")
@app.post("/start_bot")
async def start_bot(request: Request) -> JSONResponse:
try:
data = await request.json()
# Is this a webhook creation request?
if "test" in data:
return JSONResponse({"test": True})
except Exception:
pass
# Use specified room URL, or create a new one if not specified
room_url = os.getenv("DAILY_SAMPLE_ROOM_URL", None)
if not room_url:
try:
room_url = create_room(MAX_SESSION_TIME)
except Exception:
raise HTTPException(
status_code=500,
detail="Unable to provision room")
else:
# Check passed room URL exists
try:
check_room_url(room_url)
except Exception:
raise HTTPException(
status_code=500, detail=f"Room not found: {room_url}")
# Give the agent a token to join the session
token = get_token(room_url)
if not room_url or not token:
raise HTTPException(
status_code=500, detail=f"Failed to get token for room: {room_url}")
# Spawn a new agent, and join the user session
# Note: this is mostly for demonstration purposes (refer to 'deployment' in README)
# @TODO: Spawn a new fly machine here...
try:
proc = subprocess.Popen(
[
f"python3 -m bot -u {room_url} -t {token}"
],
shell=True,
bufsize=1,
cwd=os.path.dirname(os.path.abspath(__file__))
)
bot_procs[proc.pid] = (proc, room_url)
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to start subprocess: {e}")
# Grab a token for the user to join with
user_token = get_token(room_url)
return JSONResponse({
"bot_id": proc.pid,
"room_url": room_url,
"token": user_token,
"config": {"open_mic": USE_OPEN_MIC}})
# ----------------- Main ----------------- #
if __name__ == "__main__":
# Check environment variables
required_env_vars = ['OPENAI_API_KEY', 'DAILY_API_KEY',
'ELEVENLABS_VOICE_ID', 'ELEVENLABS_API_KEY']
for env_var in required_env_vars:
if env_var not in os.environ:
raise Exception(f"Missing environment variable: {env_var}.")
parser = argparse.ArgumentParser(description="Pipecat Bot Runner")
parser.add_argument("--host", type=str,
default=os.getenv("HOST", "localhost"), help="Host address")
parser.add_argument("--port", type=int,
default=os.getenv("PORT", 7860), help="Port number")
parser.add_argument("--reload", action="store_true",
default=True, help="Reload code on change")
config = parser.parse_args()
try:
import uvicorn
uvicorn.run(
"bot_runner:app",
host=config.host,
port=config.port,
reload=config.reload
)
except KeyboardInterrupt:
print("Pipecat runner shutting down...")

View File

@@ -1,4 +1,5 @@
from re import X
import urllib.parse
import os
import time
@@ -9,11 +10,11 @@ from dotenv import load_dotenv
load_dotenv()
daily_api_path = os.getenv("DAILY_API_URL") or "api.daily.co/v1"
daily_api_path = os.getenv("DAILY_API_URL", "api.daily.co/v1")
daily_api_key = os.getenv("DAILY_API_KEY")
def create_room() -> tuple[str, str]:
def create_room(expiry_time=5 * 60) -> tuple[str, str]:
"""
Helper function to create a Daily room.
# See: https://docs.daily.co/reference/rest-api/rooms
@@ -25,7 +26,7 @@ def create_room() -> tuple[str, str]:
Exception: If the request to create the room fails or if the response does not contain the room URL or room name.
"""
room_props = {
"exp": time.time() + 60 * 60, # 1 hour
"exp": time.time() * expiry_time,
"enable_chat": True,
"enable_emoji_reactions": True,
"eject_at_room_exp": True,
@@ -50,6 +51,31 @@ def create_room() -> tuple[str, str]:
return room_url, room_name
def check_room_url(room_url: str) -> bool:
"""
Checks if a room exists in Daily.
# See: https://docs.daily.co/reference/rest-api/rooms/get-room-config
Args:
room_name (str): The url of the room to check for
Returns:
bool: True if 200 OK, Exception otherwise.
"""
room_name = get_name_from_url(room_url)
res: requests.Response = requests.get(
f"https://{daily_api_path}/rooms/{room_name}",
headers={"Authorization": f"Bearer {daily_api_key}"}
)
if res.status_code != 200:
raise Exception(f"Room not found: {room_name}")
return True
def get_name_from_url(room_url: str) -> str:
"""
Extracts the name from a given room URL.

View File

@@ -1,5 +1,7 @@
python-dotenv
requests
fastapi[all]
pipecat-ai[daily,openai,fal]
fastapi
uvicorn
pipecat-ai[daily,openai,silero]
requests
python-dotenv
loguru
requests

View File

@@ -1,58 +0,0 @@
import argparse
import os
import time
import urllib
import requests
def configure():
parser = argparse.ArgumentParser(description="Daily AI SDK Bot Sample")
parser.add_argument(
"-u",
"--url",
type=str,
required=False,
help="URL of the Daily room to join")
parser.add_argument(
"-k",
"--apikey",
type=str,
required=False,
help="Daily API Key (needed to create an owner token for the room)",
)
args, unknown = parser.parse_known_args()
url = args.url or os.getenv("DAILY_SAMPLE_ROOM_URL")
key = args.apikey or os.getenv("DAILY_API_KEY")
if not url:
raise Exception(
"No Daily room specified. use the -u/--url option from the command line, or set DAILY_SAMPLE_ROOM_URL in your environment to specify a Daily room URL.")
if not key:
raise Exception("No Daily API key specified. use the -k/--apikey option from the command line, or set DAILY_API_KEY in your environment to specify a Daily API key, available from https://dashboard.daily.co/developers.")
# Create a meeting token for the given room with an expiration 1 hour in
# the future.
room_name: str = urllib.parse.urlparse(url).path[1:]
expiration: float = time.time() + 60 * 60
res: requests.Response = requests.post(
f"https://api.daily.co/v1/meeting-tokens",
headers={
"Authorization": f"Bearer {key}"},
json={
"properties": {
"room_name": room_name,
"is_owner": True,
"exp": expiration}},
)
if res.status_code != 200:
raise Exception(
f"Failed to create meeting token: {res.status_code} {res.text}")
token: str = res.json()["token"]
return (url, token)

View File

@@ -1,124 +0,0 @@
import os
import argparse
import subprocess
import atexit
from fastapi import FastAPI, Request, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse
from utils.daily_helpers import create_room as _create_room, get_token
MAX_BOTS_PER_ROOM = 1
# Bot sub-process dict for status reporting and concurrency control
bot_procs = {}
def cleanup():
# Clean up function, just to be extra safe
for proc in bot_procs.values():
proc.terminate()
proc.wait()
atexit.register(cleanup)
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/start")
async def start_agent(request: Request):
print(f"!!! Creating room")
room_url, room_name = _create_room()
print(f"!!! Room URL: {room_url}")
# Ensure the room property is present
if not room_url:
raise HTTPException(
status_code=500,
detail="Missing 'room' property in request data. Cannot start agent without a target room!")
# Check if there is already an existing process running in this room
num_bots_in_room = sum(
1 for proc in bot_procs.values() if proc[1] == room_url and proc[0].poll() is None)
if num_bots_in_room >= MAX_BOTS_PER_ROOM:
raise HTTPException(
status_code=500, detail=f"Max bot limited reach for room: {room_url}")
# Get the token for the room
token = get_token(room_url)
if not token:
raise HTTPException(
status_code=500, detail=f"Failed to get token for room: {room_url}")
# Spawn a new agent, and join the user session
# Note: this is mostly for demonstration purposes (refer to 'deployment' in README)
try:
proc = subprocess.Popen(
[
f"python3 -m bot -u {room_url} -t {token}"
],
shell=True,
bufsize=1,
cwd=os.path.dirname(os.path.abspath(__file__))
)
bot_procs[proc.pid] = (proc, room_url)
except Exception as e:
raise HTTPException(
status_code=500, detail=f"Failed to start subprocess: {e}")
return RedirectResponse(room_url)
@app.get("/status/{pid}")
def get_status(pid: int):
# Look up the subprocess
proc = bot_procs.get(pid)
# If the subprocess doesn't exist, return an error
if not proc:
raise HTTPException(
status_code=404, detail=f"Bot with process id: {pid} not found")
# Check the status of the subprocess
if proc[0].poll() is None:
status = "running"
else:
status = "finished"
return JSONResponse({"bot_id": pid, "status": status})
if __name__ == "__main__":
import uvicorn
default_host = os.getenv("HOST", "0.0.0.0")
default_port = int(os.getenv("FAST_API_PORT", "7860"))
parser = argparse.ArgumentParser(
description="Daily Storyteller FastAPI server")
parser.add_argument("--host", type=str,
default=default_host, help="Host address")
parser.add_argument("--port", type=int,
default=default_port, help="Port number")
parser.add_argument("--reload", action="store_true",
help="Reload code on change")
config = parser.parse_args()
uvicorn.run(
"server:app",
host=config.host,
port=config.port,
reload=config.reload,
)

View File

@@ -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 },
],
},
}

24
examples/web-ui/.gitignore vendored Normal file
View File

@@ -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?

30
examples/web-ui/README.md Normal file
View File

@@ -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

View File

@@ -0,0 +1 @@
VITE_SERVER_URL=... #optional: if serving frontend independetely from backend (otherwise relative.)

View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100..900&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<title>Pipecat Demo</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

141
examples/web-ui/src/App.tsx Normal file
View File

@@ -0,0 +1,141 @@
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<State>("idle");
const [error, setError] = useState<string | null>(null);
const [config, setConfig] = useState<{ open_mic?: boolean }>({});
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",
},
});
data = await res.json();
setConfig(data.config || {});
if (!res.ok) {
setError(data.detail);
setState("error");
return;
}
} 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.token,
videoSource: false,
startAudioOff: true,
});
setState("connected");
}
async function leave() {
await daily?.leave();
setState("idle");
}
if (state === "error") {
return (
<Alert intent="danger" title="An error occurred">
{error}
</Alert>
);
}
if (state === "connected") {
return <Session onLeave={() => leave()} openMic={config?.open_mic} />;
}
const status_text = {
configuring: "Start",
requesting_agent: "Requesting agent...",
connecting: "Connecting to agent...",
};
if (state !== "idle") {
return (
<div className="card card-appear">
<div className="card-inner">
<h1 className="card-header">Configure your devices</h1>
<p className="card-text">
Please configure your microphone and speakers below
</p>
<DeviceSelect />
<Button
key="start"
onClick={() => start()}
disabled={state !== "configuring"}
>
{state !== "configuring" && <Loader2 className="animate-spin" />}
{status_text[state as keyof typeof status_text]}
</Button>
</div>
</div>
);
}
return (
<div className="card card-appear">
<div className="card-inner">
<h1 className="card-header">Pipecat Simple Chatbot</h1>
<p className="card-text">
Please ensure you microphone and speakers are connected and ready to
go
</p>
{import.meta.env.DEV && !import.meta.env.VITE_SERVER_URL && (
<div>
Warning: you have not set a server URL for local development. Please
set <code>VITE_SERVER_URL</code> in{" "}
<code>.env.development.local</code>
</div>
)}
<Button key="next" onClick={() => setState("configuring")}>
Next <ArrowRight />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,7 @@
<svg width="332" height="192" viewBox="0 0 332 192" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M45.7718 0.769635C50.4477 -0.990844 55.7252 0.330188 59.0204 4.08595L101.936 53H230.064L272.98 4.08595C276.275 0.330188 281.552 -0.990844 286.228 0.769635C290.904 2.53011 294 7.00367 294 12V120H332V144H270V43.8728L244.52 72.9141C242.242 75.5111 238.955 77 235.5 77H96.5C93.0452 77 89.7581 75.5111 87.4796 72.9141L62 43.8728V144H0V120H38V12C38 7.00367 41.0958 2.53011 45.7718 0.769635Z" fill="black"/>
<path d="M270 168H332V192H270V168Z" fill="black"/>
<path d="M0 168H62V192H0V168Z" fill="black"/>
<path d="M128 128C128 136.837 120.837 144 112 144C103.163 144 96 136.837 96 128C96 119.164 103.163 112 112 112C120.837 112 128 119.164 128 128Z" fill="black"/>
<path d="M236 128C236 136.837 228.837 144 220 144C211.163 144 204 136.837 204 128C204 119.164 211.163 112 220 112C228.837 112 236 119.164 236 128Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 937 B

View File

@@ -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<HTMLDivElement>(null);
useAudioLevel(
audioTrack?.persistentTrack,
useCallback((volume) => {
if (volRef.current)
volRef.current.style.width = Math.max(2, volume * 100) + "%";
}, [])
);
return (
<div className={styles.bar}>
<div ref={volRef} />
</div>
);
};
export default AudioIndicatorBar;

View File

@@ -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;
}
}

View File

@@ -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 (
<div className={styles.deviceSelect}>
{hasMicError && (
<Alert intent="danger" title="Device error">
{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.
</>
)}
</Alert>
)}
<section className={styles.field}>
<label className={styles.label}>Microphone:</label>
<div className={styles.selectContainer}>
<Mic size={24} />
<select
onChange={(e) => handleMicrophoneChange(e.target.value)}
defaultValue={currentMic?.device.deviceId}
className={styles.deviceSelectField}
>
{microphones.length === 0 ? (
<option value="">Loading devices...</option>
) : (
microphones.map((m) => (
<option key={m.device.deviceId} value={m.device.deviceId}>
{m.device.label}
</option>
))
)}
</select>
</div>
<AudioIndicatorBar />
</section>
<section className={styles.field}>
<label className={styles.label}>Speakers:</label>
<div className={styles.selectContainer}>
<Speaker size={24} />
<select
onChange={(e) => handleSpeakerChange(e.target.value)}
defaultValue={currentSpeaker?.device.deviceId}
className={styles.deviceSelectField}
>
{speakers.length === 0 ? (
<option value="">Loading devices...</option>
) : (
speakers.map((m) => (
<option key={m.device.deviceId} value={m.device.deviceId}>
{m.device.label}
</option>
))
)}
</select>
</div>
</section>
</div>
);
}
export default DeviceSelect;

View File

@@ -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;
}

View File

@@ -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 (
<div className={styles.agent}>
<div className={styles.agentWindow}></div>
<footer className={styles.agentFooter}>
<Status>User status</Status>
<Status variant={status}>Agent status</Status>
</footer>
</div>
);
};
export default Agent;

View File

@@ -0,0 +1,88 @@
import React, { useEffect, useRef, useState } from "react";
import { LogOut, Settings } from "lucide-react";
import { DailyAudio, useAppMessage, useDaily } from "@daily-co/daily-react";
import DeviceSelect from "../DeviceSelect";
import Agent from "./agent";
import { Button } from "../button";
import UserMicBubble from "../UserMicBubble";
import styles from "./styles.module.css";
interface SessionProps {
onLeave: () => void;
openMic?: boolean;
}
export const Session: React.FC<SessionProps> = ({
onLeave,
openMic = false,
}) => {
const daily = useDaily();
const [showDevices, setShowDevices] = useState(false);
const modalRef = useRef<HTMLDialogElement>(null);
const [talkState, setTalkState] = useState<"user" | "assistant" | "open">(
openMic ? "open" : "assistant"
);
useAppMessage({
onAppMessage: (e) => {
if (!daily || !e.data?.cue) return;
// Determine the UI state from the cue sent by the bot
if (e.data?.cue === "user_turn") {
// Delay enabling local mic input to avoid feedback from LLM
setTimeout(() => daily.setLocalAudio(true), 500);
setTalkState("user");
} else {
daily.setLocalAudio(false);
setTalkState("assistant");
}
},
});
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 (
<>
<dialog ref={modalRef}>
<h2>Configure devices</h2>
<DeviceSelect />
<Button onClick={() => setShowDevices(false)}>Close</Button>
</dialog>
<div className={styles.agentContainer}>
<Agent />
<UserMicBubble openMic={openMic} active={talkState !== "assistant"} />
<DailyAudio />
</div>
<footer className={styles.footer}>
<div className={styles.controls}>
<Button
variant="ghost"
size="icon"
onClick={() => setShowDevices(true)}
>
<Settings />
</Button>
<Button onClick={() => onLeave()}>
<LogOut size={16} />
End
</Button>
</div>
</footer>
</>
);
};
export default Session;

View File

@@ -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<HTMLElement>,
VariantProps<typeof statusVariants> {}
const status_text = {
default: "Idle",
connecting: "Connecting",
connected: "Connected",
};
export const Status: React.FC<StatusProps> = ({
children,
variant = "default",
}) => {
return (
<div className={styles.status}>
<span>{children}</span>
<div className={statusVariants({ variant })}>
<span />
{status_text[variant as keyof typeof status_text]}
</div>
</div>
);
};
export default Status;

View File

@@ -0,0 +1,119 @@
.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);
}
}
.statusGreen {
color: var(--color-green-800);
background: var(--color-green-100);
> span {
background: var(--color-green-400);
}
}

View File

@@ -0,0 +1,71 @@
import React, { useCallback, useRef } 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<HTMLDivElement>(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 <div ref={volRef} className={styles.volume} />;
};
interface Props {
active: boolean;
openMic: boolean;
}
export default function UserMicBubble({ active, openMic = false }: Props) {
/*
const [transcription, setTranscription] = useState<string[]>([]);
useAppMessage({
onAppMessage: (e: DailyEventObjectAppMessage<any>) => {
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]);*/
const cx = openMic ? styles.micIconOpen : active && styles.micIconActive;
return (
<div className={`${styles.bubbleContainer}`}>
<div className={`${styles.micIcon} ${cx}`}>
{!openMic && !active ? <MicOff size={42} /> : <Mic size={42} />}
{(openMic || active) && <AudioIndicatorBubble />}
</div>
<footer className={styles.transcript}></footer>
</div>
);
}

View File

@@ -0,0 +1,103 @@
.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;
}
}
.micIconOpen {
background-color: var(--color-gray-500);
background-image: radial-gradient(
var(--color-gray-500),
var(--color-gray-600)
);
border: 6px solid color-mix(in srgb, var(--color-gray-200), transparent 60%);
outline: 6px solid color-mix(in srgb, var(--color-gray-400), transparent 70%);
}
.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;
}
.micIconOpen svg,
.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);
}

View File

@@ -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<HTMLElement>,
VariantProps<typeof alertVariants> {}
export const Alert: React.FC<AlertProps> = ({ children, intent, title }) => {
return (
<div className={alertVariants({ intent })}>
<span>{title}</span>
{children}
</div>
);
};

View File

@@ -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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export const Button: React.FC<ButtonProps> = ({ variant, size, ...props }) => {
return <button className={buttonVariants({ variant, size })} {...props} />;
};

View File

@@ -0,0 +1,22 @@
import Logo from "./logo";
function Header() {
return (
<header className="header">
<span className="logo-button">
<Logo />
</span>
<nav>
<a href="https://git.new/ai" target="_blank">
GitHub
</a>
<a href="https://discord.gg/pipecat" target="_blank">
Discord
</a>
</nav>
</header>
);
}
export default Header;

View File

@@ -0,0 +1,46 @@
import React from "react";
const Logo: React.FC = () => {
return (
<svg
width="332"
height="192"
viewBox="0 0 332 192"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="logo"
>
<path
d="M45.7718 0.769635C50.4477 -0.990844 55.7252 0.330188 59.0204 4.08595L101.936 53H230.064L272.98 4.08595C276.275 0.330188 281.552 -0.990844 286.228 0.769635C290.904 2.53011 294 7.00367 294 12V120H332V144H270V43.8728L244.52 72.9141C242.242 75.5111 238.955 77 235.5 77H96.5C93.0452 77 89.7581 75.5111 87.4796 72.9141L62 43.8728V144H0V120H38V12C38 7.00367 41.0958 2.53011 45.7718 0.769635Z"
fill="black"
/>
<path d="M270 168H332V192H270V168Z" fill="black" />
<path d="M0 168H62V192H0V168Z" fill="black" />
<g id="eyes_1">
<path
d="M128 128C128 136.837 120.837 144 112 144C103.163 144 96 136.837 96 128C96 119.164 103.163 112 112 112C120.837 112 128 119.164 128 128Z"
fill="black"
/>
<path
d="M236 128C236 136.837 228.837 144 220 144C211.163 144 204 136.837 204 128C204 119.164 211.163 112 220 112C228.837 112 236 119.164 236 128Z"
fill="black"
/>
</g>
<g id="eyes_2" visibility="hidden">
<path
d="M128 128C128 136.837 120.837 144 112 144C103.163 144 96 136.837 96 128C96 119.163 103.163 112 112 112C120.837 112 128 119.163 128 128Z"
stroke="black"
strokeWidth="12"
/>
<path
d="M236 128C236 136.837 228.837 144 220 144C211.163 144 204 136.837 204 128C204 119.163 211.163 112 220 112C228.837 112 236 119.163 236 128Z"
stroke="black"
strokeWidth="12"
/>
</g>
</svg>
);
};
export default Logo;

View File

@@ -0,0 +1,387 @@
:root {
--color-gray-50: #f8fafc;
--color-gray-100: #f1f5f9;
--color-gray-200: #e2e8f0;
--color-gray-300: #cbd5e1;
--color-gray-400: #94a3b8;
--color-gray-500: #64748b;
--color-gray-600: #475569;
--color-gray-700: #334155;
--color-gray-800: #1e293b;
--color-gray-900: #0f172a;
--color-gray-950: #020617;
--color-orange-50: #fff7ed;
--color-orange-100: #ffedd5;
--color-orange-200: #fed7aa;
--color-orange-300: #fdba74;
--color-orange-400: #fb923c;
--color-orange-500: #f97316;
--color-orange-600: #ea580c;
--color-orange-700: #c2410c;
--color-orange-800: #9a3412;
--color-orange-900: #7c2d12;
--color-orange-950: #431407;
--color-green-50: #f0fdf4;
--color-green-100: #dcfce7;
--color-green-200: #bbf7d0;
--color-green-300: #86efac;
--color-green-400: #4ade80;
--color-green-500: #22c55e;
--color-green-600: #16a34a;
--color-green-700: #15803d;
--color-green-800: #166534;
--color-green-900: #14532d;
--color-green-950: #052e16;
--borderRadius-xs: 6px;
--borderRadius-sm: 9px;
--borderRadius-md: 12px;
--borderRadius-lg: 24px;
--input-height: 48px;
--font-sans: "Inter", system-ui, Avenir, Helvetica, Arial, sans-serif;
--font-mono: "Space Mono", monospace;
--font-size-base: 1rem;
--font-size-sm: 0.875rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
}
* {
font-family: var(--font-sans);
}
#root {
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
margin: 0 auto;
padding: 1rem;
text-align: center;
display: flex;
flex-direction: column;
flex: 1;
}
* {
outline-color: black;
outline-offset: 4px;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
display: flex;
color: var(--color-gray-950);
background-color: var(--color-gray-50);
}
a {
color: var(--color-gray-950);
text-decoration: inherit;
}
a:hover {
text-decoration-line: underline;
}
main {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
.header {
display: flex;
align-self: flex-start;
align-items: center;
gap: 2rem;
}
.header .logo-button {
box-sizing: border-box;
border: 1px solid var(--color-gray-200);
padding: 0.5rem;
display: flex;
place-items: center;
border-radius: var(--borderRadius-md);
transition: all 0.3s ease;
background: white;
box-shadow: 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);
}
.header .logo {
width: 42px;
height: auto;
aspect-ratio: 1;
}
.header nav {
pointer-events: none;
flex-flow: row nowrap;
align-items: center;
gap: 2rem;
display: none;
a {
font-size: 1.125rem;
line-height: 1.75rem;
text-underline-offset: 4px;
text-decoration: underline solid transparent;
transition: text-decoration 0.5s ease;
&:hover {
text-decoration: underline solid var(--color-gray-400);
}
}
}
.header:hover {
.logo {
animation: wiggle 0.2s 1;
}
#eyes_2 {
visibility: visible;
}
#eyes_1 {
visibility: hidden;
}
nav {
display: flex;
pointer-events: all;
display: flex;
animation: fadeIn 0.5s ease;
}
.logo-button {
border-color: var(--color-gray-200);
box-shadow: 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);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes wiggle {
0% {
transform: translateX(0);
}
25% {
transform: translateX(-5px);
}
50% {
transform: translateX(5px);
}
75% {
transform: translateX(-5px);
}
100% {
transform: translateX(0);
}
}
.button {
border-radius: var(--borderRadius-md);
border: 1px solid transparent;
padding: 0 1.5rem;
height: var(--input-height);
font-size: 1rem;
font-weight: 600;
font-family: inherit;
background-color: var(--color-gray-950);
color: white;
cursor: pointer;
display: flex;
flex-direction: row;
gap: 0.5rem;
align-items: center;
}
.button > svg {
width: 20px;
height: 20px;
}
.button:hover:not(:disabled) {
background-color: var(--color-gray-600);
}
.button:disabled {
cursor: not-allowed;
background-color: var(--color-gray-300);
color: var(--color-gray-100);
}
.button-ghost {
border: 1px solid var(--color-gray-300);
background-color: white;
color: var(--color-gray-950);
}
.button-ghost:hover:not(:disabled) {
background-color: white;
border-color: var(--color-gray-600);
}
.button-icon {
padding: 1rem;
}
.alert {
border: 1px solid black;
border-radius: var(--borderRadius-md);
padding: 1rem;
display: flex;
flex-flow: column wrap;
gap: 2;
font-size: 0.875rem;
span {
font-size: 1rem;
font-weight: bold;
}
}
.alert-danger {
border-color: red;
color: red;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
.controls {
}
.card {
padding: 12px;
border-radius: var(--borderRadius-lg);
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;
}
.card-appear {
animation: appear 0.5s ease-out forwards;
}
.card-inner {
padding: 20px;
max-width: 420px;
display: flex;
gap: 24px;
flex-direction: column;
justify-content: center;
place-items: center;
}
.card-header {
font-size: 1.5rem;
font-weight: 600;
margin: 0px;
}
.card-text {
font-size: 1.125rem;
color: var(--color-gray-500);
text-wrap: pretty;
margin: 0px;
}
@keyframes appear {
from {
opacity: 0;
transform: translateY(1rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}
select {
appearance: none;
background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTQgNkw4IDEwTDEyIDYiIHN0cm9rZT0iIzY0NzQ4QiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+Cjwvc3ZnPgo=");
background-repeat: no-repeat;
background-position: right 1rem top 50%;
background-size: 16px auto;
border-radius: var(--borderRadius-md);
border: 1px solid var(--color-gray-300);
padding: 0 1rem;
height: var(--input-height);
font-size: var(--font-size-sm);
white-space: nowrap;
text-overflow: ellipsis;
padding-right: 3rem;
}
dialog {
max-width: 420px;
padding: 2rem;
border-radius: var(--borderRadius-lg);
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;
animation: appear 0.5s ease-out forwards;
> h2 {
font-size: var(--font-size-xl);
margin: 0;
margin-bottom: 2rem;
}
> button {
margin: 2rem auto 0 auto;
}
}
dialog::backdrop {
background-color: rgba(255, 255, 255, 0.5);
backdrop-filter: blur(5px);
}

View File

@@ -0,0 +1,18 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import Header from "./components/header.tsx";
import { DailyProvider } from "@daily-co/daily-react";
import "./global.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<Header />
<main>
<DailyProvider>
<App />
</DailyProvider>
</main>
</React.StrictMode>
);

View File

1
examples/web-ui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import webfontDownload from "vite-plugin-webfont-dl";
export default defineConfig({
plugins: [react(), webfontDownload()],
});

3384
examples/web-ui/yarn.lock Normal file

File diff suppressed because it is too large Load Diff