Update Modal App: (#1755)
* Update Modal App: Updated Modal App to include: 1. Latest Modal API usage 2. Ability to launch different Pipecat pipelines, much like the simple chatbot example 3. Ability to choose which pipeline is launched via the /connect endpoint 4. Added a pipeline option for connecting to a self-hosted LLM on Modal 5. Improved READMEs 6. Added a web client for interacting with the Modal deployment tmp * Update README
3
examples/deployment/modal-example/.gitignore
vendored
@@ -1,3 +1,6 @@
|
|||||||
|
# Modal clone
|
||||||
|
modal-examples
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
|||||||
@@ -1,24 +1,44 @@
|
|||||||
# Deploying Pipecat to Modal.com
|
# Deploying Pipecat to Modal.com
|
||||||
|
|
||||||
Barebones deployment example for [modal.com](https://www.modal.com)
|
Deployment example for [modal.com](https://www.modal.com). This example demonstrates how to deploy a FastAPI webapp to Modal with an RTVI compatible `/connect` endpoint that launches a Pipecat pipeline in a separate Modal container and returns a room/token for the client to join. This example also supports providing a parameter to the `/connect` endpoint for specifying which Pipecat pipeline to launch; openai, gemini, or vllm. The vllm pipeline points to a self-hosted OpenAI compatible LLM, using a llama model (neuralmagic/Meta-Llama-3.1-8B-Instruct-quantized.w4a16), deployed to Modal.
|
||||||
|
|
||||||
1. Install dependencies
|
# Running this Example
|
||||||
|
|
||||||
```bash
|
## Prerequisites
|
||||||
python -m venv venv
|
|
||||||
source venv/bin/active # or OS equivalent
|
Setup a Modal account and install it on your machine if you have not already, following their easy 3-steps in their [Getting Started Guide](https://modal.com/docs/guide#getting-started)
|
||||||
pip install -r requirements.txt
|
|
||||||
```
|
## Deploy a self-serve LLM
|
||||||
|
|
||||||
2. Setup .env
|
1. Follow the Modal Guide and example for [Deploying an OpenAI-compatible LLM service with vLLM](https://modal.com/docs/examples/vllm_inference).
|
||||||
|
|
||||||
|
The TLDR, though, is to simply do the following from within this directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/modal-labs/modal-examples
|
||||||
|
cd modal-examples
|
||||||
|
modal deploy 06_gpu_and_ml/llm-serving/vllm_inference.py
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Jot down the endpoint from the previous step to use in the bot_vllm file mentioned below. It will look something like: `https://<Modal workspace>--example-vllm-openai-compatible-serve.modal.run`
|
||||||
|
|
||||||
|
**Note:** This Modal example is their [initial getting started example](https://modal.com/docs/examples/vllm_inference) with a Llama-3.1 model. By default, it will tear down the container after 15 minutes of inactivity and can take 5-10 minutes to re-start, during which time it is unusable. So for the purposes of just getting started and this example, we recommend visiting the `/docs` endpoint (`https://<Modal workspace>--example-vllm-openai-compatible-serve.modal.run/docs`) for your deployed llm in a browser to trigger the cold start. Then wait for the page to load, indicating its ready before trying to connect your client.
|
||||||
|
|
||||||
|
## Deploy FastAPI App and Pipecat pipeline to Modal
|
||||||
|
|
||||||
|
1. Setup environment variables
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cd server
|
||||||
cp env.example .env
|
cp env.example .env
|
||||||
|
# Modify .env to provide your service API Keys
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternatively, you can configure your Modal app to use [secrets](https://modal.com/docs/guide/secrets)
|
Alternatively, you can configure your Modal app to use [secrets](https://modal.com/docs/guide/secrets)
|
||||||
|
|
||||||
3. Test the app locally
|
1. Update the `modal_url` in `server/src/bot_vllm.py` to point to the url produced from the self-serve llm deploy, mentioned above.
|
||||||
|
|
||||||
|
2. From within the `server` directory, test the app locally:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
modal serve app.py
|
modal serve app.py
|
||||||
@@ -30,8 +50,34 @@ modal serve app.py
|
|||||||
modal deploy app.py
|
modal deploy app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration options
|
5. Jot down the endpoint from the previous step to use in the client's app.js file mentioned its README. It will look something like: `https://<Modal workspace>--pipecat-modal-fastapi-app.modal.run`
|
||||||
|
|
||||||
This app sets some sensible defaults for reducing cold starts, such as `minkeep_warm=1`, which will keep at least 1 warm instance ready for your bot function.
|
## Launch and Talk to your Bots running on Modal
|
||||||
|
|
||||||
It has been configured to only allow a concurrency of 1 (`max_inputs=1`) as each user will require their own running function.
|
## Option 1: Direct Link
|
||||||
|
|
||||||
|
Simply click on the url displayed after running the server or deploy step to launch an agent and be redirected to a Daily room to talk with the launched bot. This will use the OpenAI pipeline.
|
||||||
|
|
||||||
|
## Option 2: Connect via an RTVI Client
|
||||||
|
|
||||||
|
Follow the instructions provided in the [client folder's README](client/javascript/README.md) for building and running a custom client that connects to your Modal endpoint. The provided client provides a dropdown for choosing which bot pipeline to run.
|
||||||
|
|
||||||
|
# Navigating your llm, server, and Pipecat logs
|
||||||
|
|
||||||
|
In your [Modal dashboard](https://modal.com/apps), you should have two Apps listed under Live Apps:
|
||||||
|
|
||||||
|
1. `example-vllm-openai-compatible`: This App contains the containers and logs used to run your self-hosted LLM. There will be just one App Function listed: `serve`. Click on this function to view logs for your LLM.
|
||||||
|
2. `pipecat-modal`: This App contains the containers and logs used to run your `connect` endpoints and Pipecat pipelines. It will list two App Functions:
|
||||||
|
1. `fastapi_app`: This function is running the endpoints that your client will interact with and initiate starting a new pipeline (`/`, `/connect`, `/status`). Click on this function to see logs for each endpoint hit.
|
||||||
|
2. `bot_runner`: This function handles launching and running a bot pipeline. Click on this function to get a list of all pipeline runs and access each run's logs.
|
||||||
|
|
||||||
|
## Diagram of Deployment
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
# Modal + Pipecat Tips
|
||||||
|
|
||||||
|
- In most other Pipecat examples, we use Popen to launch the pipeline process from the /connect endpoint. In this example, we instead use a Modal function with its own Modal image defined. This change ensures that each run of the Pipeline happens in a isolated, customizable container.
|
||||||
|
- For the FastAPI and most common Pipecat Pipeline containers, a default debian_slim CPU-only should be all that's required to run. GPU containers are needed for self-hosted services.
|
||||||
|
- To minimize cold starts of the pipeline and reduce latency for users, set `min_containers=1` on the Modal Function that launches the pipeline to ensure at least one warm instance of your function is always available.
|
||||||
|
- For next steps on running a self-hosted llm and reducing latency, check out all of [Modal's LLM examples](https://modal.com/docs/examples/vllm_inference).
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
#
|
|
||||||
# Copyright (c) 2024–2025, Daily
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: BSD 2-Clause License
|
|
||||||
#
|
|
||||||
|
|
||||||
import os
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
import modal
|
|
||||||
from bot import _voice_bot_process
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
MAX_SESSION_TIME = 15 * 60 # 15 minutes
|
|
||||||
|
|
||||||
app = modal.App("pipecat-modal")
|
|
||||||
|
|
||||||
|
|
||||||
image = modal.Image.debian_slim(python_version="3.12").pip_install_from_requirements(
|
|
||||||
"requirements.txt"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.function(
|
|
||||||
image=image,
|
|
||||||
cpu=1.0,
|
|
||||||
secrets=[modal.Secret.from_dotenv()],
|
|
||||||
keep_warm=1,
|
|
||||||
enable_memory_snapshot=True,
|
|
||||||
max_inputs=1, # Do not reuse instances across requests
|
|
||||||
retries=0,
|
|
||||||
)
|
|
||||||
def launch_bot_process(room_url: str, token: str):
|
|
||||||
_voice_bot_process(room_url, token)
|
|
||||||
|
|
||||||
|
|
||||||
@app.function(
|
|
||||||
image=image,
|
|
||||||
secrets=[modal.Secret.from_dotenv()],
|
|
||||||
)
|
|
||||||
@modal.web_endpoint(method="POST")
|
|
||||||
async def start():
|
|
||||||
from pipecat.transports.services.helpers.daily_rest import (
|
|
||||||
DailyRESTHelper,
|
|
||||||
DailyRoomParams,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info("Request received")
|
|
||||||
|
|
||||||
async with aiohttp.ClientSession() as session:
|
|
||||||
daily_rest_helper = DailyRESTHelper(
|
|
||||||
daily_api_key=os.getenv("DAILY_API_KEY", ""),
|
|
||||||
daily_api_url=os.getenv("DAILY_API_URL", "https://api.daily.co/v1"),
|
|
||||||
aiohttp_session=session,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create new Daily room
|
|
||||||
room = await daily_rest_helper.create_room(DailyRoomParams())
|
|
||||||
if not room.url:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail="Unable to create room",
|
|
||||||
)
|
|
||||||
logger.info(f"Created room: {room.url}")
|
|
||||||
|
|
||||||
# Create bot token for room
|
|
||||||
token = await daily_rest_helper.get_token(room.url, MAX_SESSION_TIME)
|
|
||||||
if not token:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to get token for room: {room.url}")
|
|
||||||
|
|
||||||
logger.info(f"Bot token created: {token}")
|
|
||||||
|
|
||||||
# Spawn a new bot process
|
|
||||||
launch_bot_process.spawn(room_url=room.url, token=token)
|
|
||||||
|
|
||||||
# Return room URL to the user to join
|
|
||||||
# Note: in production, you would want to return a token to the user
|
|
||||||
return JSONResponse(content={"room_url": room.url, token: token})
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
#
|
|
||||||
# Copyright (c) 2024–2025, Daily
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: BSD 2-Clause License
|
|
||||||
#
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from loguru import logger
|
|
||||||
|
|
||||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
|
||||||
from pipecat.pipeline.pipeline import Pipeline
|
|
||||||
from pipecat.pipeline.runner import PipelineRunner
|
|
||||||
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
|
||||||
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
|
|
||||||
from pipecat.services.cartesia.tts import CartesiaTTSService
|
|
||||||
from pipecat.services.openai.llm import OpenAILLMService
|
|
||||||
from pipecat.transports.services.daily import DailyParams, DailyTransport
|
|
||||||
|
|
||||||
load_dotenv(override=True)
|
|
||||||
|
|
||||||
logger.remove(0)
|
|
||||||
logger.add(sys.stderr, level="DEBUG")
|
|
||||||
|
|
||||||
|
|
||||||
async def main(room_url: str, token: str):
|
|
||||||
transport = DailyTransport(
|
|
||||||
room_url,
|
|
||||||
token,
|
|
||||||
"bot",
|
|
||||||
DailyParams(
|
|
||||||
audio_in_enabled=True,
|
|
||||||
audio_out_enabled=True,
|
|
||||||
transcription_enabled=True,
|
|
||||||
vad_analyzer=SileroVADAnalyzer(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
tts = CartesiaTTSService(
|
|
||||||
api_key=os.getenv("CARTESIA_API_KEY", ""), voice_id="71a7ad14-091c-4e8e-a314-022ece01c121"
|
|
||||||
)
|
|
||||||
|
|
||||||
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
|
|
||||||
|
|
||||||
messages = [
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": "You are a helpful LLM in a WebRTC call. Your goal is to demonstrate your capabilities in a succinct way. Your output will be converted to audio so don't include special characters in your answers. Respond to what the user said in a creative and helpful way.",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
context = OpenAILLMContext(messages)
|
|
||||||
context_aggregator = llm.create_context_aggregator(context)
|
|
||||||
|
|
||||||
pipeline = Pipeline(
|
|
||||||
[
|
|
||||||
transport.input(),
|
|
||||||
context_aggregator.user(),
|
|
||||||
llm,
|
|
||||||
tts,
|
|
||||||
transport.output(),
|
|
||||||
context_aggregator.assistant(),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
task = PipelineTask(
|
|
||||||
pipeline,
|
|
||||||
params=PipelineParams(
|
|
||||||
allow_interruptions=True,
|
|
||||||
enable_metrics=True,
|
|
||||||
enable_usage_metrics=True,
|
|
||||||
report_only_initial_ttfb=True,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
@transport.event_handler("on_first_participant_joined")
|
|
||||||
async def on_first_participant_joined(transport, participant):
|
|
||||||
await transport.capture_participant_transcription(participant["id"])
|
|
||||||
messages.append({"role": "system", "content": "Please introduce yourself to the user."})
|
|
||||||
await task.queue_frames([context_aggregator.user().get_context_frame()])
|
|
||||||
|
|
||||||
@transport.event_handler("on_participant_left")
|
|
||||||
async def on_participant_left(transport, participant, reason):
|
|
||||||
await task.cancel()
|
|
||||||
|
|
||||||
runner = PipelineRunner()
|
|
||||||
|
|
||||||
await runner.run(task)
|
|
||||||
|
|
||||||
|
|
||||||
def _voice_bot_process(room_url: str, token: str):
|
|
||||||
asyncio.run(main(room_url, token))
|
|
||||||
1
examples/deployment/modal-example/client/javascript/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# JavaScript Implementation
|
||||||
|
|
||||||
|
Basic implementation using the [Pipecat JavaScript SDK](https://docs.pipecat.ai/client/js/introduction).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Deploy the Modal server. See the main [README](../../README).
|
||||||
|
|
||||||
|
2. Navigate to the `client/javascript` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client/javascript
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Modify the baseUrl in src/app.js to point to your deployed Modal endpoint
|
||||||
|
|
||||||
|
4. Install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Run the client app:
|
||||||
|
|
||||||
|
```
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Visit http://localhost:5173 in your browser.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AI Chatbot</title>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="status-bar">
|
||||||
|
<div class="status">
|
||||||
|
Status: <span id="connection-status">Disconnected</span>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<select id="bot-selector">
|
||||||
|
<option value="openai">OpenAI</option>
|
||||||
|
<option value="gemini">Gemini</option>
|
||||||
|
<option value="vllm">Llama</option>
|
||||||
|
</select>
|
||||||
|
<button id="connect-btn">Connect</button>
|
||||||
|
<button id="disconnect-btn" disabled>Disconnect</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main-content">
|
||||||
|
<div class="bot-container">
|
||||||
|
<div id="bot-video-container"></div>
|
||||||
|
<audio id="bot-audio" autoplay></audio>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="device-bar">
|
||||||
|
<div class="device-controls">
|
||||||
|
<select id="device-selector"></select>
|
||||||
|
<button id="mic-toggle-btn">Mute Mic</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="debug-panel">
|
||||||
|
<h3>Debug Info</h3>
|
||||||
|
<div id="debug-log"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/src/app.js"></script>
|
||||||
|
<link rel="stylesheet" href="/src/style.css" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
502
examples/deployment/modal-example/client/javascript/package-lock.json
generated
Normal file
@@ -0,0 +1,502 @@
|
|||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@pipecat-ai/client-js": "^0.3.5",
|
||||||
|
"@pipecat-ai/daily-transport": "^0.3.10"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^6.0.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.27.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
|
||||||
|
"integrity": "sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"regenerator-runtime": "^0.14.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@daily-co/daily-js": {
|
||||||
|
"version": "0.77.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@daily-co/daily-js/-/daily-js-0.77.0.tgz",
|
||||||
|
"integrity": "sha512-icNXKieKAkRR/C5dcPjrCkL1jQGFp5C5WtLHy5uHAdTztm+mo9wlPJuehbWaGOM3TV24mgWHZ/+8jOys1G0I4w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.5",
|
||||||
|
"@sentry/browser": "^8.33.1",
|
||||||
|
"bowser": "^2.8.1",
|
||||||
|
"dequal": "^2.0.3",
|
||||||
|
"events": "^3.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pipecat-ai/client-js": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/events": "^3.0.3",
|
||||||
|
"clone-deep": "^4.0.1",
|
||||||
|
"events": "^3.3.0",
|
||||||
|
"typed-emitter": "^2.1.0",
|
||||||
|
"uuid": "^10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@pipecat-ai/daily-transport": {
|
||||||
|
"version": "0.3.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pipecat-ai/daily-transport/-/daily-transport-0.3.10.tgz",
|
||||||
|
"integrity": "sha512-x25V+qV6+TmPHojxtY54NSsyErNWy7AHEiiAYUCBlh5degiB7dLAKmREvNMXegLmEc2s3+npAHHd5VYxEUz/Mg==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"@daily-co/daily-js": "^0.77.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@pipecat-ai/client-js": "~0.3.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
|
"version": "4.34.6",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@sentry-internal/browser-utils": {
|
||||||
|
"version": "8.55.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.55.0.tgz",
|
||||||
|
"integrity": "sha512-ROgqtQfpH/82AQIpESPqPQe0UyWywKJsmVIqi3c5Fh+zkds5LUxnssTj3yNd1x+kxaPDVB023jAP+3ibNgeNDw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry/core": "8.55.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry-internal/feedback": {
|
||||||
|
"version": "8.55.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.55.0.tgz",
|
||||||
|
"integrity": "sha512-cP3BD/Q6pquVQ+YL+rwCnorKuTXiS9KXW8HNKu4nmmBAyf7urjs+F6Hr1k9MXP5yQ8W3yK7jRWd09Yu6DHWOiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry/core": "8.55.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry-internal/replay": {
|
||||||
|
"version": "8.55.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.55.0.tgz",
|
||||||
|
"integrity": "sha512-roCDEGkORwolxBn8xAKedybY+Jlefq3xYmgN2fr3BTnsXjSYOPC7D1/mYqINBat99nDtvgFvNfRcZPiwwZ1hSw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry-internal/browser-utils": "8.55.0",
|
||||||
|
"@sentry/core": "8.55.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry-internal/replay-canvas": {
|
||||||
|
"version": "8.55.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.55.0.tgz",
|
||||||
|
"integrity": "sha512-nIkfgRWk1091zHdu4NbocQsxZF1rv1f7bbp3tTIlZYbrH62XVZosx5iHAuZG0Zc48AETLE7K4AX9VGjvQj8i9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry-internal/replay": "8.55.0",
|
||||||
|
"@sentry/core": "8.55.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/browser": {
|
||||||
|
"version": "8.55.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.55.0.tgz",
|
||||||
|
"integrity": "sha512-1A31mCEWCjaMxJt6qGUK+aDnLDcK6AwLAZnqpSchNysGni1pSn1RWSmk9TBF8qyTds5FH8B31H480uxMPUJ7Cw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@sentry-internal/browser-utils": "8.55.0",
|
||||||
|
"@sentry-internal/feedback": "8.55.0",
|
||||||
|
"@sentry-internal/replay": "8.55.0",
|
||||||
|
"@sentry-internal/replay-canvas": "8.55.0",
|
||||||
|
"@sentry/core": "8.55.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@sentry/core": {
|
||||||
|
"version": "8.55.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.55.0.tgz",
|
||||||
|
"integrity": "sha512-6g7jpbefjHYs821Z+EBJ8r4Z7LT5h80YSWRJaylGS4nW5W5Z2KXzpdnyFarv37O7QjauzVC2E+PABmpkw5/JGA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/estree": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/events": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/bowser": {
|
||||||
|
"version": "2.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz",
|
||||||
|
"integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/clone-deep": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-plain-object": "^2.0.4",
|
||||||
|
"kind-of": "^6.0.2",
|
||||||
|
"shallow-clone": "^3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dequal": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.24.2",
|
||||||
|
"@esbuild/android-arm": "0.24.2",
|
||||||
|
"@esbuild/android-arm64": "0.24.2",
|
||||||
|
"@esbuild/android-x64": "0.24.2",
|
||||||
|
"@esbuild/darwin-arm64": "0.24.2",
|
||||||
|
"@esbuild/darwin-x64": "0.24.2",
|
||||||
|
"@esbuild/freebsd-arm64": "0.24.2",
|
||||||
|
"@esbuild/freebsd-x64": "0.24.2",
|
||||||
|
"@esbuild/linux-arm": "0.24.2",
|
||||||
|
"@esbuild/linux-arm64": "0.24.2",
|
||||||
|
"@esbuild/linux-ia32": "0.24.2",
|
||||||
|
"@esbuild/linux-loong64": "0.24.2",
|
||||||
|
"@esbuild/linux-mips64el": "0.24.2",
|
||||||
|
"@esbuild/linux-ppc64": "0.24.2",
|
||||||
|
"@esbuild/linux-riscv64": "0.24.2",
|
||||||
|
"@esbuild/linux-s390x": "0.24.2",
|
||||||
|
"@esbuild/linux-x64": "0.24.2",
|
||||||
|
"@esbuild/netbsd-arm64": "0.24.2",
|
||||||
|
"@esbuild/netbsd-x64": "0.24.2",
|
||||||
|
"@esbuild/openbsd-arm64": "0.24.2",
|
||||||
|
"@esbuild/openbsd-x64": "0.24.2",
|
||||||
|
"@esbuild/sunos-x64": "0.24.2",
|
||||||
|
"@esbuild/win32-arm64": "0.24.2",
|
||||||
|
"@esbuild/win32-ia32": "0.24.2",
|
||||||
|
"@esbuild/win32-x64": "0.24.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/events": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.3",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-plain-object": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"isobject": "^3.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/isobject": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/kind-of": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/nanoid": {
|
||||||
|
"version": "3.3.8",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/picocolors": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/postcss": {
|
||||||
|
"version": "8.5.2",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/postcss/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"nanoid": "^3.3.8",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || >=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/regenerator-runtime": {
|
||||||
|
"version": "0.14.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||||
|
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/rollup": {
|
||||||
|
"version": "4.34.6",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/estree": "1.0.6"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"rollup": "dist/bin/rollup"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0",
|
||||||
|
"npm": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@rollup/rollup-android-arm-eabi": "4.34.6",
|
||||||
|
"@rollup/rollup-android-arm64": "4.34.6",
|
||||||
|
"@rollup/rollup-darwin-arm64": "4.34.6",
|
||||||
|
"@rollup/rollup-darwin-x64": "4.34.6",
|
||||||
|
"@rollup/rollup-freebsd-arm64": "4.34.6",
|
||||||
|
"@rollup/rollup-freebsd-x64": "4.34.6",
|
||||||
|
"@rollup/rollup-linux-arm-gnueabihf": "4.34.6",
|
||||||
|
"@rollup/rollup-linux-arm-musleabihf": "4.34.6",
|
||||||
|
"@rollup/rollup-linux-arm64-gnu": "4.34.6",
|
||||||
|
"@rollup/rollup-linux-arm64-musl": "4.34.6",
|
||||||
|
"@rollup/rollup-linux-loongarch64-gnu": "4.34.6",
|
||||||
|
"@rollup/rollup-linux-powerpc64le-gnu": "4.34.6",
|
||||||
|
"@rollup/rollup-linux-riscv64-gnu": "4.34.6",
|
||||||
|
"@rollup/rollup-linux-s390x-gnu": "4.34.6",
|
||||||
|
"@rollup/rollup-linux-x64-gnu": "4.34.6",
|
||||||
|
"@rollup/rollup-linux-x64-musl": "4.34.6",
|
||||||
|
"@rollup/rollup-win32-arm64-msvc": "4.34.6",
|
||||||
|
"@rollup/rollup-win32-ia32-msvc": "4.34.6",
|
||||||
|
"@rollup/rollup-win32-x64-msvc": "4.34.6",
|
||||||
|
"fsevents": "~2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/rxjs": {
|
||||||
|
"version": "7.8.2",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/shallow-clone": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"kind-of": "^6.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/source-map-js": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"license": "0BSD",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/typed-emitter": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"rxjs": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite": {
|
||||||
|
"version": "6.1.0",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"esbuild": "^0.24.2",
|
||||||
|
"postcss": "^8.5.1",
|
||||||
|
"rollup": "^4.30.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"vite": "bin/vite.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||||
|
"jiti": ">=1.21.0",
|
||||||
|
"less": "*",
|
||||||
|
"lightningcss": "^1.21.0",
|
||||||
|
"sass": "*",
|
||||||
|
"sass-embedded": "*",
|
||||||
|
"stylus": "*",
|
||||||
|
"sugarss": "*",
|
||||||
|
"terser": "^5.16.0",
|
||||||
|
"tsx": "^4.8.1",
|
||||||
|
"yaml": "^2.4.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"jiti": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"less": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"lightningcss": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sass": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sass-embedded": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"stylus": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sugarss": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"terser": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"tsx": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"yaml": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^6.0.9"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@pipecat-ai/client-js": "^0.3.5",
|
||||||
|
"@pipecat-ai/daily-transport": "^0.3.10"
|
||||||
|
}
|
||||||
|
}
|
||||||
381
examples/deployment/modal-example/client/javascript/src/app.js
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
/**
|
||||||
|
* Copyright (c) 2024–2025, Daily
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RTVI Client Implementation
|
||||||
|
*
|
||||||
|
* This client connects to an RTVI-compatible bot server using WebRTC (via Daily).
|
||||||
|
* It handles audio/video streaming and manages the connection lifecycle.
|
||||||
|
*
|
||||||
|
* Requirements:
|
||||||
|
* - A running RTVI bot server (defaults to http://localhost:7860)
|
||||||
|
* - The server must implement the /connect endpoint that returns Daily.co room credentials
|
||||||
|
* - Browser with WebRTC support
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { RTVIClient, RTVIEvent } from '@pipecat-ai/client-js';
|
||||||
|
import { DailyTransport } from '@pipecat-ai/daily-transport';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ChatbotClient handles the connection and media management for a real-time
|
||||||
|
* voice and video interaction with an AI bot.
|
||||||
|
*/
|
||||||
|
class ChatbotClient {
|
||||||
|
constructor() {
|
||||||
|
// Initialize client state
|
||||||
|
this.rtviClient = null;
|
||||||
|
this.setupDOMElements();
|
||||||
|
this.initializeClientAndTransport();
|
||||||
|
this.setupEventListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up references to DOM elements and create necessary media elements
|
||||||
|
*/
|
||||||
|
setupDOMElements() {
|
||||||
|
// Get references to UI control elements
|
||||||
|
this.connectBtn = document.getElementById('connect-btn');
|
||||||
|
this.disconnectBtn = document.getElementById('disconnect-btn');
|
||||||
|
this.statusSpan = document.getElementById('connection-status');
|
||||||
|
this.debugLog = document.getElementById('debug-log');
|
||||||
|
this.botVideoContainer = document.getElementById('bot-video-container');
|
||||||
|
this.deviceSelector = document.getElementById('device-selector');
|
||||||
|
|
||||||
|
// Create an audio element for bot's voice output
|
||||||
|
this.botAudio = document.createElement('audio');
|
||||||
|
this.botAudio.autoplay = true;
|
||||||
|
this.botAudio.playsInline = true;
|
||||||
|
document.body.appendChild(this.botAudio);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up event listeners for connect/disconnect buttons
|
||||||
|
*/
|
||||||
|
setupEventListeners() {
|
||||||
|
this.connectBtn.addEventListener('click', () => this.connect());
|
||||||
|
this.disconnectBtn.addEventListener('click', () => this.disconnect());
|
||||||
|
|
||||||
|
// Populate device selector
|
||||||
|
this.rtviClient.getAllMics().then((mics) => {
|
||||||
|
console.log('Available mics:', mics);
|
||||||
|
mics.forEach((device) => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = device.deviceId;
|
||||||
|
option.textContent = device.label || `Microphone ${device.deviceId}`;
|
||||||
|
this.deviceSelector.appendChild(option);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
this.deviceSelector.addEventListener('change', (event) => {
|
||||||
|
const selectedDeviceId = event.target.value;
|
||||||
|
console.log('Selected device ID:', selectedDeviceId);
|
||||||
|
this.rtviClient.updateMic(selectedDeviceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle mic mute/unmute toggle
|
||||||
|
const micToggleBtn = document.getElementById('mic-toggle-btn');
|
||||||
|
|
||||||
|
micToggleBtn.addEventListener('click', () => {
|
||||||
|
let micEnabled = this.rtviClient.isMicEnabled;
|
||||||
|
micToggleBtn.textContent = micEnabled ? 'Unmute Mic' : 'Mute Mic';
|
||||||
|
this.rtviClient.enableMic(!micEnabled);
|
||||||
|
// Add logic to mute/unmute the mic
|
||||||
|
if (micEnabled) {
|
||||||
|
console.log('Mic muted');
|
||||||
|
// Add code to mute the mic
|
||||||
|
} else {
|
||||||
|
console.log('Mic unmuted');
|
||||||
|
// Add code to unmute the mic
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up the RTVI client and Daily transport
|
||||||
|
*/
|
||||||
|
async initializeClientAndTransport() {
|
||||||
|
// Initialize the RTVI client with a DailyTransport and our configuration
|
||||||
|
this.rtviClient = new RTVIClient({
|
||||||
|
transport: new DailyTransport(),
|
||||||
|
params: {
|
||||||
|
// REPLACE WITH YOUR MODAL URL ENDPOINT
|
||||||
|
baseUrl:
|
||||||
|
'https://<Modal workspace>--pipecat-modal-bot-launcher.modal.run',
|
||||||
|
endpoints: {
|
||||||
|
connect: '/connect',
|
||||||
|
},
|
||||||
|
requestData: {
|
||||||
|
bot_name: 'openai',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
enableMic: true, // Enable microphone for user input
|
||||||
|
enableCam: false,
|
||||||
|
callbacks: {
|
||||||
|
// Handle connection state changes
|
||||||
|
onConnected: () => {
|
||||||
|
this.updateStatus('Connected');
|
||||||
|
this.connectBtn.disabled = true;
|
||||||
|
this.disconnectBtn.disabled = false;
|
||||||
|
this.log('Client connected');
|
||||||
|
},
|
||||||
|
onDisconnected: () => {
|
||||||
|
this.updateStatus('Disconnected');
|
||||||
|
this.connectBtn.disabled = false;
|
||||||
|
this.disconnectBtn.disabled = true;
|
||||||
|
this.log('Client disconnected');
|
||||||
|
},
|
||||||
|
// Handle transport state changes
|
||||||
|
onTransportStateChanged: (state) => {
|
||||||
|
this.updateStatus(`Transport: ${state}`);
|
||||||
|
this.log(`Transport state changed: ${state}`);
|
||||||
|
if (state === 'connecting') {
|
||||||
|
window.startTime = Date.now();
|
||||||
|
}
|
||||||
|
if (state === 'ready') {
|
||||||
|
this.setupMediaTracks();
|
||||||
|
console.warn('TIME TO BOT READY:', Date.now() - window.startTime);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Handle bot connection events
|
||||||
|
onBotConnected: (participant) => {
|
||||||
|
this.log(`Bot connected: ${JSON.stringify(participant)}`);
|
||||||
|
},
|
||||||
|
onBotDisconnected: (participant) => {
|
||||||
|
this.log(`Bot disconnected: ${JSON.stringify(participant)}`);
|
||||||
|
},
|
||||||
|
onBotReady: (data) => {
|
||||||
|
this.log(`Bot ready: ${JSON.stringify(data)}`);
|
||||||
|
this.setupMediaTracks();
|
||||||
|
},
|
||||||
|
// Transcript events
|
||||||
|
onUserTranscript: (data) => {
|
||||||
|
// Only log final transcripts
|
||||||
|
if (data.final) {
|
||||||
|
this.log(`User: ${data.text}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onBotTranscript: (data) => {
|
||||||
|
this.log(`Bot: ${data.text}`);
|
||||||
|
},
|
||||||
|
// Error handling
|
||||||
|
onMessageError: (error) => {
|
||||||
|
console.log('Message error:', error);
|
||||||
|
},
|
||||||
|
onMicUpdated: (data) => {
|
||||||
|
console.log('Mic updated:', data);
|
||||||
|
this.deviceSelector.value = data.deviceId;
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.log('Error:', JSON.stringify(error));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up listeners for media track events
|
||||||
|
this.setupTrackListeners();
|
||||||
|
|
||||||
|
await this.rtviClient.initDevices();
|
||||||
|
window.client = this.rtviClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a timestamped message to the debug log
|
||||||
|
*/
|
||||||
|
log(message) {
|
||||||
|
const entry = document.createElement('div');
|
||||||
|
entry.textContent = `${new Date().toISOString()} - ${message}`;
|
||||||
|
|
||||||
|
// Add styling based on message type
|
||||||
|
if (message.startsWith('User: ')) {
|
||||||
|
entry.style.color = '#2196F3'; // blue for user
|
||||||
|
} else if (message.startsWith('Bot: ')) {
|
||||||
|
entry.style.color = '#4CAF50'; // green for bot
|
||||||
|
}
|
||||||
|
|
||||||
|
this.debugLog.appendChild(entry);
|
||||||
|
this.debugLog.scrollTop = this.debugLog.scrollHeight;
|
||||||
|
console.log(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the connection status display
|
||||||
|
*/
|
||||||
|
updateStatus(status) {
|
||||||
|
this.statusSpan.textContent = status;
|
||||||
|
this.log(`Status: ${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for available media tracks and set them up if present
|
||||||
|
* This is called when the bot is ready or when the transport state changes to ready
|
||||||
|
*/
|
||||||
|
setupMediaTracks() {
|
||||||
|
if (!this.rtviClient) return;
|
||||||
|
|
||||||
|
// Get current tracks from the client
|
||||||
|
const tracks = this.rtviClient.tracks();
|
||||||
|
|
||||||
|
// Set up any available bot tracks
|
||||||
|
if (tracks.bot?.audio) {
|
||||||
|
this.setupAudioTrack(tracks.bot.audio);
|
||||||
|
}
|
||||||
|
if (tracks.bot?.video) {
|
||||||
|
this.setupVideoTrack(tracks.bot.video);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up listeners for track events (start/stop)
|
||||||
|
* This handles new tracks being added during the session
|
||||||
|
*/
|
||||||
|
setupTrackListeners() {
|
||||||
|
if (!this.rtviClient) return;
|
||||||
|
|
||||||
|
// Listen for new tracks starting
|
||||||
|
this.rtviClient.on(RTVIEvent.TrackStarted, (track, participant) => {
|
||||||
|
// Only handle non-local (bot) tracks
|
||||||
|
if (!participant?.local) {
|
||||||
|
if (track.kind === 'audio') {
|
||||||
|
this.setupAudioTrack(track);
|
||||||
|
} else if (track.kind === 'video') {
|
||||||
|
this.setupVideoTrack(track);
|
||||||
|
}
|
||||||
|
this.log(
|
||||||
|
`Track started event: ${track.kind} from ${
|
||||||
|
participant?.name || 'unknown'
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.log('Local mic unmuted');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for tracks stopping
|
||||||
|
this.rtviClient.on(RTVIEvent.TrackStopped, (track, participant) => {
|
||||||
|
if (participant.local) {
|
||||||
|
this.log('Local mic muted');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.log(
|
||||||
|
`Track stopped event: ${track.kind} from ${
|
||||||
|
participant?.name || 'unknown'
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up an audio track for playback
|
||||||
|
* Handles both initial setup and track updates
|
||||||
|
*/
|
||||||
|
setupAudioTrack(track) {
|
||||||
|
this.log('Setting up audio track');
|
||||||
|
// Check if we're already playing this track
|
||||||
|
if (this.botAudio.srcObject) {
|
||||||
|
const oldTrack = this.botAudio.srcObject.getAudioTracks()[0];
|
||||||
|
if (oldTrack?.id === track.id) return;
|
||||||
|
}
|
||||||
|
// Create a new MediaStream with the track and set it as the audio source
|
||||||
|
this.botAudio.srcObject = new MediaStream([track]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set up a video track for display
|
||||||
|
* Handles both initial setup and track updates
|
||||||
|
*/
|
||||||
|
setupVideoTrack(track) {
|
||||||
|
this.log('Setting up video track');
|
||||||
|
const videoEl = document.createElement('video');
|
||||||
|
videoEl.autoplay = true;
|
||||||
|
videoEl.playsInline = true;
|
||||||
|
videoEl.muted = true;
|
||||||
|
videoEl.style.width = '100%';
|
||||||
|
videoEl.style.height = '100%';
|
||||||
|
videoEl.style.objectFit = 'cover';
|
||||||
|
|
||||||
|
// Check if we're already displaying this track
|
||||||
|
if (this.botVideoContainer.querySelector('video')?.srcObject) {
|
||||||
|
const oldTrack = this.botVideoContainer
|
||||||
|
.querySelector('video')
|
||||||
|
.srcObject.getVideoTracks()[0];
|
||||||
|
if (oldTrack?.id === track.id) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new MediaStream with the track and set it as the video source
|
||||||
|
videoEl.srcObject = new MediaStream([track]);
|
||||||
|
this.botVideoContainer.innerHTML = '';
|
||||||
|
this.botVideoContainer.appendChild(videoEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize and connect to the bot
|
||||||
|
* This sets up the RTVI client, initializes devices, and establishes the connection
|
||||||
|
*/
|
||||||
|
async connect() {
|
||||||
|
try {
|
||||||
|
const botSelector = document.getElementById('bot-selector');
|
||||||
|
const selectedBot = botSelector.value;
|
||||||
|
this.rtviClient.params.requestData.bot_name = selectedBot;
|
||||||
|
|
||||||
|
// Initialize audio/video devices
|
||||||
|
this.log('Initializing devices...');
|
||||||
|
await this.rtviClient.initDevices();
|
||||||
|
|
||||||
|
// Connect to the bot
|
||||||
|
this.log(`Connecting to bot: ${selectedBot}`);
|
||||||
|
await this.rtviClient.connect();
|
||||||
|
|
||||||
|
this.log('Connection complete');
|
||||||
|
} catch (error) {
|
||||||
|
// Handle any errors during connection
|
||||||
|
console.error('Connection error:', error);
|
||||||
|
this.log(`Error connecting: ${JSON.stringify(error.message)}`);
|
||||||
|
this.log(`Error stack: ${error.stack}`);
|
||||||
|
this.updateStatus('Error');
|
||||||
|
|
||||||
|
// Clean up if there's an error
|
||||||
|
if (this.rtviClient) {
|
||||||
|
try {
|
||||||
|
await this.rtviClient.disconnect();
|
||||||
|
} catch (disconnectError) {
|
||||||
|
this.log(`Error during disconnect: ${disconnectError.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from the bot and clean up media resources
|
||||||
|
*/
|
||||||
|
async disconnect() {
|
||||||
|
if (this.rtviClient) {
|
||||||
|
try {
|
||||||
|
// Disconnect the RTVI client
|
||||||
|
await this.rtviClient.disconnect();
|
||||||
|
|
||||||
|
// Clean up audio
|
||||||
|
if (this.botAudio.srcObject) {
|
||||||
|
this.botAudio.srcObject.getTracks().forEach((track) => track.stop());
|
||||||
|
this.botAudio.srcObject = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up video
|
||||||
|
if (this.botVideoContainer.querySelector('video')?.srcObject) {
|
||||||
|
const video = this.botVideoContainer.querySelector('video');
|
||||||
|
video.srcObject.getTracks().forEach((track) => track.stop());
|
||||||
|
video.srcObject = null;
|
||||||
|
}
|
||||||
|
this.botVideoContainer.innerHTML = '';
|
||||||
|
} catch (error) {
|
||||||
|
this.log(`Error disconnecting: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the client when the page loads
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
new ChatbotClient();
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar,
|
||||||
|
.device-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls,
|
||||||
|
.device-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px; /* Adds spacing between elements */
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-controls {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls button,
|
||||||
|
.device-controls button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
margin-left: 10px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bot-selector,
|
||||||
|
#device-selector {
|
||||||
|
padding: 8px 16px;
|
||||||
|
padding-right: 40px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #6c757d; /* Gray background */
|
||||||
|
color: white; /* White text */
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none; /* Removes default browser styling for dropdowns */
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E"); /* Custom arrow */
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 8px center; /* Position the arrow */
|
||||||
|
}
|
||||||
|
|
||||||
|
#bot-selector:focus,
|
||||||
|
#device-selector:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 4px rgba(0, 0, 0, 0.3); /* Add a subtle focus effect */
|
||||||
|
}
|
||||||
|
|
||||||
|
#connect-btn {
|
||||||
|
background-color: #4caf50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#disconnect-btn {
|
||||||
|
background-color: #f44336;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#mic-toggle-btn {
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bot-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bot-video-container {
|
||||||
|
width: 640px;
|
||||||
|
height: 360px;
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 20px auto;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bot-video-container video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-panel {
|
||||||
|
background-color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.debug-panel h3 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#debug-log {
|
||||||
|
height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
BIN
examples/deployment/modal-example/diagram.jpg
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
@@ -1,3 +0,0 @@
|
|||||||
DAILY_API_KEY=
|
|
||||||
OPENAI_API_KEY=
|
|
||||||
CARTESIA_API_KEY=
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
python-dotenv==1.0.1
|
|
||||||
modal==0.71.3
|
|
||||||
pipecat-ai[daily,silero,cartesia,openai]
|
|
||||||
fastapi==0.115.6
|
|
||||||
307
examples/deployment/modal-example/server/app.py
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
"""modal_example.
|
||||||
|
|
||||||
|
This module shows a simple example of how to deploy a bot using Modal and FastAPI.
|
||||||
|
|
||||||
|
It includes:
|
||||||
|
- FastAPI endpoints for starting agents and checking bot statuses.
|
||||||
|
- Dynamic loading of bot implementations.
|
||||||
|
- Use of a Daily transport for bot communication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright (c) 2024–2025, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any, Dict, Literal
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import modal
|
||||||
|
from fastapi import APIRouter, FastAPI, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
# container specifications for the FastAPI web server
|
||||||
|
web_image = (
|
||||||
|
modal.Image.debian_slim(python_version="3.13")
|
||||||
|
.pip_install_from_requirements("requirements.txt")
|
||||||
|
.pip_install("pipecat-ai[daily]")
|
||||||
|
.add_local_dir("src", remote_path="/root/src")
|
||||||
|
)
|
||||||
|
|
||||||
|
# container specifications for the Pipecat pipeline
|
||||||
|
bot_image = (
|
||||||
|
modal.Image.debian_slim(python_version="3.13")
|
||||||
|
.apt_install("ffmpeg")
|
||||||
|
.pip_install_from_requirements("requirements.txt")
|
||||||
|
.pip_install("pipecat-ai[daily,elevenlabs,openai,silero,google]")
|
||||||
|
.add_local_dir("src", remote_path="/root/src")
|
||||||
|
)
|
||||||
|
|
||||||
|
app = modal.App("pipecat-modal", secrets=[modal.Secret.from_dotenv()])
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
bot_jobs = {}
|
||||||
|
daily_helpers = {}
|
||||||
|
|
||||||
|
# Names of all supported bot implementations
|
||||||
|
# These correspond to the bot files in the src directory
|
||||||
|
BotName = Literal["openai", "gemini", "vllm"]
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup():
|
||||||
|
"""Cleanup function to terminate all bot processes.
|
||||||
|
|
||||||
|
Called during server shutdown.
|
||||||
|
"""
|
||||||
|
for entry in bot_jobs.values():
|
||||||
|
func = modal.FunctionCall.from_id(entry[0])
|
||||||
|
if func:
|
||||||
|
func.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
def get_bot_file(bot_name: BotName) -> str:
|
||||||
|
"""Retrieve the bot file name corresponding to the provided bot_name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot_name (BotName): The name of the bot (e.g., 'openai', 'gemini', 'vllm').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: The file name corresponding to the bot implementation.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the bot name is invalid or not supported.
|
||||||
|
"""
|
||||||
|
# bot_implementation = os.getenv("BOT_IMPLEMENTATION", "openai").lower().strip()
|
||||||
|
bot_implementation = bot_name.lower().strip()
|
||||||
|
if not bot_implementation:
|
||||||
|
bot_implementation = "openai"
|
||||||
|
if bot_implementation not in ["openai", "gemini", "vllm"]:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid BOT_IMPLEMENTATION: {bot_implementation}. Must be 'openai' or 'gemini' or 'vllm'"
|
||||||
|
)
|
||||||
|
|
||||||
|
return f"bot_{bot_implementation}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_runner(path: str, bot_file: str) -> callable:
|
||||||
|
"""Dynamically import the run_bot function based on the bot name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path (str): The path to the bot files (e.g., 'src').
|
||||||
|
bot_file (str): The file name of the bot implementation (e.g., 'openai', 'gemini', 'vllm').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
function: The run_bot function from the specified bot module.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ImportError: If the specified bot module or run_bot function is not found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Dynamically construct the module name
|
||||||
|
module_name = f"{path}.{bot_file}"
|
||||||
|
# Import the module
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
# Get the run_bot function from the module
|
||||||
|
return getattr(module, "run_bot")
|
||||||
|
except (ImportError, AttributeError) as e:
|
||||||
|
raise ImportError(f"Failed to import run_bot from {module_name}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def create_room_and_token() -> tuple[str, str]:
|
||||||
|
"""Create a Daily room and generate an authentication token.
|
||||||
|
|
||||||
|
This function checks for existing room URL and token in the environment variables.
|
||||||
|
If not found, it creates a new room using the Daily API and generates a token for it.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[str, str]: A tuple containing the room URL and the authentication token.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If room creation or token generation fails.
|
||||||
|
"""
|
||||||
|
from pipecat.transports.services.helpers.daily_rest import DailyRoomParams
|
||||||
|
|
||||||
|
room_url = os.getenv("DAILY_SAMPLE_ROOM_URL", None)
|
||||||
|
token = os.getenv("DAILY_SAMPLE_ROOM_TOKEN", None)
|
||||||
|
if not room_url:
|
||||||
|
room = await daily_helpers["rest"].create_room(DailyRoomParams())
|
||||||
|
if not room.url:
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to create room")
|
||||||
|
room_url = room.url
|
||||||
|
|
||||||
|
token = await daily_helpers["rest"].get_token(room_url)
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to get token for room: {room_url}")
|
||||||
|
|
||||||
|
return room_url, token
|
||||||
|
|
||||||
|
|
||||||
|
@app.function(image=bot_image, min_containers=1)
|
||||||
|
async def bot_runner(room_url, token, bot_name: BotName = "openai"):
|
||||||
|
"""Launch the provided bot process, providing the given room URL and token for the bot to join.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room_url (str): The URL of the Daily room where the bot and client will communicate.
|
||||||
|
token (str): The authentication token for the room.
|
||||||
|
bot_name (BotName): The name of the bot implementation to use. Defaults to "openai".
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If the bot pipeline fails to start.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
path = "src"
|
||||||
|
bot_file = get_bot_file(bot_name)
|
||||||
|
run_bot = get_runner(path, bot_file)
|
||||||
|
|
||||||
|
print(f"Starting bot process: {bot_file} -u {room_url} -t {token}")
|
||||||
|
await run_bot(room_url, token)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to start bot pipeline: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""FastAPI lifespan manager that handles startup and shutdown tasks.
|
||||||
|
|
||||||
|
- Creates aiohttp session
|
||||||
|
- Initializes Daily API helper
|
||||||
|
- Cleans up resources on shutdown
|
||||||
|
"""
|
||||||
|
from pipecat.transports.services.helpers.daily_rest import DailyRESTHelper
|
||||||
|
|
||||||
|
aiohttp_session = aiohttp.ClientSession()
|
||||||
|
daily_helpers["rest"] = DailyRESTHelper(
|
||||||
|
daily_api_key=os.getenv("DAILY_API_KEY", ""),
|
||||||
|
daily_api_url=os.getenv("DAILY_API_URL", "https://api.daily.co/v1"),
|
||||||
|
aiohttp_session=aiohttp_session,
|
||||||
|
)
|
||||||
|
yield
|
||||||
|
await aiohttp_session.close()
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectData(BaseModel):
|
||||||
|
"""Data provided by client to specify the bot pipeline.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
bot_name (BotName): The name of the bot to connect to. Defaults to "openai".
|
||||||
|
"""
|
||||||
|
|
||||||
|
bot_name: BotName = "openai"
|
||||||
|
|
||||||
|
|
||||||
|
async def start(data: ConnectData):
|
||||||
|
"""Internal method to start a bot agent and return the room URL and token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data (ConnectData): The data containing the bot name to use.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[str, str]: A tuple containing the room URL and token.
|
||||||
|
"""
|
||||||
|
room_url, token = await create_room_and_token()
|
||||||
|
launch_bot_func = modal.Function.from_name("pipecat-modal", "bot_runner")
|
||||||
|
function_id = launch_bot_func.spawn(room_url, token, data.bot_name)
|
||||||
|
bot_jobs[function_id] = (function_id, room_url)
|
||||||
|
|
||||||
|
return room_url, token
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def start_agent():
|
||||||
|
"""A user endpoint for launching a bot agent and redirecting to the created room URL.
|
||||||
|
|
||||||
|
This function retrieves the bot implementation from the environment,
|
||||||
|
starts the bot agent, and redirects the user to the room URL to
|
||||||
|
interact with the bot through a Daily Prebuilt Interface.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RedirectResponse: A response that redirects to the room URL.
|
||||||
|
"""
|
||||||
|
bot_name = os.getenv("BOT_IMPLEMENTATION", "openai").lower().strip()
|
||||||
|
print(f"Starting bot: {bot_name}")
|
||||||
|
room_url, token = await start(ConnectData(bot_name=bot_name))
|
||||||
|
|
||||||
|
return RedirectResponse(room_url)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/connect")
|
||||||
|
async def rtvi_connect(data: ConnectData) -> Dict[Any, Any]:
|
||||||
|
"""A user endpoint for launching a bot agent and retrieving the room/token credentials.
|
||||||
|
|
||||||
|
This function retrieves the bot implementation from the request, if provided,
|
||||||
|
starts the bot agent, and returns the room URL and token for the bot. This allows the
|
||||||
|
client to then connect to the bot using their own RTVI interface.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data (ConnectData): Optional. The data containing the bot name to use.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict[Any, Any]: A dictionary containing the room URL and token.
|
||||||
|
"""
|
||||||
|
print(f"Starting bot: {data.bot_name}")
|
||||||
|
if data is None or not data.bot_name:
|
||||||
|
data.bot_name = os.getenv("BOT_IMPLEMENTATION", "openai").lower().strip()
|
||||||
|
room_url, token = await start(data)
|
||||||
|
|
||||||
|
return {"room_url": room_url, "token": token}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status/{fid}")
|
||||||
|
def get_status(fid: str):
|
||||||
|
"""Retrieve the status of a bot process by its function ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
fid (str): The function ID of the bot process.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSONResponse: A JSON response containing the bot's status and result code.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If the bot process with the given ID is not found.
|
||||||
|
"""
|
||||||
|
func = modal.FunctionCall.from_id(fid)
|
||||||
|
if not func:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Bot with process id: {fid} not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = func.get(timeout=0)
|
||||||
|
return JSONResponse({"bot_id": fid, "status": "finished", "code": result})
|
||||||
|
except modal.exception.OutputExpiredError:
|
||||||
|
return JSONResponse({"bot_id": fid, "status": "finished", "code": 404})
|
||||||
|
except TimeoutError:
|
||||||
|
return JSONResponse({"bot_id": fid, "status": "running", "code": 202})
|
||||||
|
|
||||||
|
|
||||||
|
@app.function(image=web_image, min_containers=1)
|
||||||
|
@modal.concurrent(max_inputs=1)
|
||||||
|
@modal.asgi_app()
|
||||||
|
def fastapi_app():
|
||||||
|
"""Create and configure the FastAPI application.
|
||||||
|
|
||||||
|
This function initializes the FastAPI app with middleware, routes, and lifespan management.
|
||||||
|
It is decorated to be used as a Modal ASGI app.
|
||||||
|
"""
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
# Initialize FastAPI app
|
||||||
|
web_app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
web_app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Include the endpoints from endpoints.py
|
||||||
|
web_app.include_router(router)
|
||||||
|
|
||||||
|
return web_app
|
||||||
14
examples/deployment/modal-example/server/env.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
DAILY_API_KEY=
|
||||||
|
|
||||||
|
# determines which bot file to default to: 'openai', 'gemini', or 'vllm'
|
||||||
|
BOT_IMPLEMENTATION=openai
|
||||||
|
|
||||||
|
# needed for the openai bot pipeline
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
ELEVENLABS_API_KEY=
|
||||||
|
|
||||||
|
# needed for the gemini live bot pipeline
|
||||||
|
GOOGLE_API_KEY=
|
||||||
|
|
||||||
|
# needed if you modified the API Key for your self-hosted LLM
|
||||||
|
VLLM_API_KEY=
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
python-dotenv==1.0.1
|
||||||
|
modal==0.71.3
|
||||||
BIN
examples/deployment/modal-example/server/src/assets/robot01.png
Normal file
|
After Width: | Height: | Size: 759 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot010.png
Normal file
|
After Width: | Height: | Size: 884 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot011.png
Normal file
|
After Width: | Height: | Size: 876 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot012.png
Normal file
|
After Width: | Height: | Size: 881 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot013.png
Normal file
|
After Width: | Height: | Size: 866 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot014.png
Normal file
|
After Width: | Height: | Size: 874 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot015.png
Normal file
|
After Width: | Height: | Size: 882 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot016.png
Normal file
|
After Width: | Height: | Size: 885 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot017.png
Normal file
|
After Width: | Height: | Size: 888 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot018.png
Normal file
|
After Width: | Height: | Size: 890 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot019.png
Normal file
|
After Width: | Height: | Size: 898 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot02.png
Normal file
|
After Width: | Height: | Size: 836 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot020.png
Normal file
|
After Width: | Height: | Size: 903 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot021.png
Normal file
|
After Width: | Height: | Size: 908 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot022.png
Normal file
|
After Width: | Height: | Size: 908 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot023.png
Normal file
|
After Width: | Height: | Size: 905 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot024.png
Normal file
|
After Width: | Height: | Size: 903 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot025.png
Normal file
|
After Width: | Height: | Size: 866 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot03.png
Normal file
|
After Width: | Height: | Size: 849 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot04.png
Normal file
|
After Width: | Height: | Size: 866 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot05.png
Normal file
|
After Width: | Height: | Size: 866 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot06.png
Normal file
|
After Width: | Height: | Size: 864 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot07.png
Normal file
|
After Width: | Height: | Size: 858 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot08.png
Normal file
|
After Width: | Height: | Size: 875 KiB |
BIN
examples/deployment/modal-example/server/src/assets/robot09.png
Normal file
|
After Width: | Height: | Size: 881 KiB |
198
examples/deployment/modal-example/server/src/bot_gemini.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024–2025, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
"""Gemini Bot Implementation.
|
||||||
|
|
||||||
|
This module implements a chatbot using Google's Gemini Multimodal Live model.
|
||||||
|
It includes:
|
||||||
|
- Real-time audio/video interaction through Daily
|
||||||
|
- Animated robot avatar
|
||||||
|
- Speech-to-speech model
|
||||||
|
|
||||||
|
The bot runs as part of a pipeline that processes audio/video frames and manages
|
||||||
|
the conversation flow using Gemini's streaming capabilities.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from loguru import logger
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||||
|
from pipecat.audio.vad.vad_analyzer import VADParams
|
||||||
|
from pipecat.frames.frames import (
|
||||||
|
BotStartedSpeakingFrame,
|
||||||
|
BotStoppedSpeakingFrame,
|
||||||
|
Frame,
|
||||||
|
OutputImageRawFrame,
|
||||||
|
SpriteFrame,
|
||||||
|
)
|
||||||
|
from pipecat.pipeline.pipeline import Pipeline
|
||||||
|
from pipecat.pipeline.runner import PipelineRunner
|
||||||
|
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||||
|
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
|
||||||
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||||
|
from pipecat.processors.frameworks.rtvi import RTVIConfig, RTVIObserver, RTVIProcessor
|
||||||
|
from pipecat.services.gemini_multimodal_live.gemini import GeminiMultimodalLiveLLMService
|
||||||
|
from pipecat.transports.services.daily import DailyParams, DailyTransport
|
||||||
|
|
||||||
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.remove(0)
|
||||||
|
logger.add(sys.stderr, level="DEBUG")
|
||||||
|
except ValueError:
|
||||||
|
# Handle the case where logger is already initialized
|
||||||
|
pass
|
||||||
|
|
||||||
|
sprites = []
|
||||||
|
script_dir = os.path.dirname(__file__)
|
||||||
|
|
||||||
|
for i in range(1, 26):
|
||||||
|
# Build the full path to the image file
|
||||||
|
full_path = os.path.join(script_dir, f"assets/robot0{i}.png")
|
||||||
|
# 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(OutputImageRawFrame(image=img.tobytes(), size=img.size, format=img.format))
|
||||||
|
|
||||||
|
# Create a smooth animation by adding reversed frames
|
||||||
|
flipped = sprites[::-1]
|
||||||
|
sprites.extend(flipped)
|
||||||
|
|
||||||
|
# Define static and animated states
|
||||||
|
quiet_frame = sprites[0] # Static frame for when bot is listening
|
||||||
|
talking_frame = SpriteFrame(images=sprites) # Animation sequence for when bot is talking
|
||||||
|
|
||||||
|
|
||||||
|
class TalkingAnimation(FrameProcessor):
|
||||||
|
"""Manages the bot's visual animation states.
|
||||||
|
|
||||||
|
Switches between static (listening) and animated (talking) states based on
|
||||||
|
the bot's current speaking status.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._is_talking = False
|
||||||
|
|
||||||
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
|
"""Process incoming frames and update animation state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The incoming frame to process
|
||||||
|
direction: The direction of frame flow in the pipeline
|
||||||
|
"""
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
|
||||||
|
# Switch to talking animation when bot starts speaking
|
||||||
|
if isinstance(frame, BotStartedSpeakingFrame):
|
||||||
|
if not self._is_talking:
|
||||||
|
await self.push_frame(talking_frame)
|
||||||
|
self._is_talking = True
|
||||||
|
# Return to static frame when bot stops speaking
|
||||||
|
elif isinstance(frame, BotStoppedSpeakingFrame):
|
||||||
|
await self.push_frame(quiet_frame)
|
||||||
|
self._is_talking = False
|
||||||
|
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_bot(room_url: str, token: str):
|
||||||
|
"""Main bot execution function.
|
||||||
|
|
||||||
|
Sets up and runs the bot pipeline including:
|
||||||
|
- Daily video transport with specific audio parameters
|
||||||
|
- Gemini Live multimodal model integration
|
||||||
|
- Voice activity detection
|
||||||
|
- Animation processing
|
||||||
|
- RTVI event handling
|
||||||
|
"""
|
||||||
|
# Set up Daily transport with specific audio/video parameters for Gemini
|
||||||
|
transport = DailyTransport(
|
||||||
|
room_url,
|
||||||
|
token,
|
||||||
|
"Chatbot",
|
||||||
|
DailyParams(
|
||||||
|
audio_out_enabled=True,
|
||||||
|
camera_out_enabled=True,
|
||||||
|
camera_out_width=1024,
|
||||||
|
camera_out_height=576,
|
||||||
|
vad_enabled=True,
|
||||||
|
vad_audio_passthrough=True,
|
||||||
|
vad_analyzer=SileroVADAnalyzer(params=VADParams(stop_secs=0.5)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize the Gemini Multimodal Live model
|
||||||
|
llm = GeminiMultimodalLiveLLMService(
|
||||||
|
api_key=os.getenv("GOOGLE_API_KEY"),
|
||||||
|
voice_id="Puck", # Aoede, Charon, Fenrir, Kore, Puck
|
||||||
|
transcribe_user_audio=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "You are Chatbot, a friendly, helpful robot. Your goal is to demonstrate your capabilities in a succinct way. Your output will be converted to audio so don't include special characters in your answers. Respond to what the user said in a creative and helpful way, but keep your responses brief. Start by introducing yourself.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Set up conversation context and management
|
||||||
|
# The context_aggregator will automatically collect conversation context
|
||||||
|
context = OpenAILLMContext(messages)
|
||||||
|
context_aggregator = llm.create_context_aggregator(context)
|
||||||
|
|
||||||
|
ta = TalkingAnimation()
|
||||||
|
|
||||||
|
#
|
||||||
|
# RTVI events for Pipecat client UI
|
||||||
|
#
|
||||||
|
rtvi = RTVIProcessor(config=RTVIConfig(config=[]))
|
||||||
|
|
||||||
|
pipeline = Pipeline(
|
||||||
|
[
|
||||||
|
transport.input(),
|
||||||
|
rtvi,
|
||||||
|
context_aggregator.user(),
|
||||||
|
llm,
|
||||||
|
ta,
|
||||||
|
transport.output(),
|
||||||
|
context_aggregator.assistant(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
task = PipelineTask(
|
||||||
|
pipeline,
|
||||||
|
params=PipelineParams(
|
||||||
|
allow_interruptions=True,
|
||||||
|
enable_metrics=True,
|
||||||
|
enable_usage_metrics=True,
|
||||||
|
),
|
||||||
|
observers=[RTVIObserver(rtvi)],
|
||||||
|
)
|
||||||
|
await task.queue_frame(quiet_frame)
|
||||||
|
|
||||||
|
@rtvi.event_handler("on_client_ready")
|
||||||
|
async def on_client_ready(rtvi):
|
||||||
|
await rtvi.set_bot_ready()
|
||||||
|
# Kick off the conversation
|
||||||
|
await task.queue_frames([context_aggregator.user().get_context_frame()])
|
||||||
|
|
||||||
|
@transport.event_handler("on_first_participant_joined")
|
||||||
|
async def on_first_participant_joined(transport, participant):
|
||||||
|
await transport.capture_participant_transcription(participant["id"])
|
||||||
|
|
||||||
|
@transport.event_handler("on_participant_left")
|
||||||
|
async def on_participant_left(transport, participant, reason):
|
||||||
|
print(f"Participant left: {participant}")
|
||||||
|
await task.cancel()
|
||||||
|
|
||||||
|
runner = PipelineRunner()
|
||||||
|
|
||||||
|
await runner.run(task)
|
||||||
226
examples/deployment/modal-example/server/src/bot_openai.py
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024–2025, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
"""OpenAI Bot Implementation.
|
||||||
|
|
||||||
|
This module implements a chatbot using OpenAI's GPT-4 model for natural language
|
||||||
|
processing. It includes:
|
||||||
|
- Real-time audio/video interaction through Daily
|
||||||
|
- Animated robot avatar
|
||||||
|
- Text-to-speech using ElevenLabs
|
||||||
|
- Support for both English and Spanish
|
||||||
|
|
||||||
|
The bot runs as part of a pipeline that processes audio/video frames and manages
|
||||||
|
the conversation flow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from loguru import logger
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||||
|
from pipecat.frames.frames import (
|
||||||
|
BotStartedSpeakingFrame,
|
||||||
|
BotStoppedSpeakingFrame,
|
||||||
|
Frame,
|
||||||
|
OutputImageRawFrame,
|
||||||
|
SpriteFrame,
|
||||||
|
)
|
||||||
|
from pipecat.pipeline.pipeline import Pipeline
|
||||||
|
from pipecat.pipeline.runner import PipelineRunner
|
||||||
|
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||||
|
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
|
||||||
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||||
|
from pipecat.processors.frameworks.rtvi import RTVIConfig, RTVIObserver, RTVIProcessor
|
||||||
|
from pipecat.services.elevenlabs.tts import ElevenLabsTTSService
|
||||||
|
from pipecat.services.openai.llm import OpenAILLMService
|
||||||
|
from pipecat.transports.services.daily import DailyParams, DailyTransport
|
||||||
|
|
||||||
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.remove(0)
|
||||||
|
logger.add(sys.stderr, level="DEBUG")
|
||||||
|
except ValueError:
|
||||||
|
# Handle the case where logger is already initialized
|
||||||
|
pass
|
||||||
|
|
||||||
|
sprites = []
|
||||||
|
script_dir = os.path.dirname(__file__)
|
||||||
|
|
||||||
|
# Load sequential animation frames
|
||||||
|
for i in range(1, 26):
|
||||||
|
# Build the full path to the image file
|
||||||
|
full_path = os.path.join(script_dir, f"assets/robot0{i}.png")
|
||||||
|
# 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(OutputImageRawFrame(image=img.tobytes(), size=img.size, format=img.format))
|
||||||
|
|
||||||
|
# Create a smooth animation by adding reversed frames
|
||||||
|
flipped = sprites[::-1]
|
||||||
|
sprites.extend(flipped)
|
||||||
|
|
||||||
|
# Define static and animated states
|
||||||
|
quiet_frame = sprites[0] # Static frame for when bot is listening
|
||||||
|
talking_frame = SpriteFrame(images=sprites) # Animation sequence for when bot is talking
|
||||||
|
|
||||||
|
|
||||||
|
class TalkingAnimation(FrameProcessor):
|
||||||
|
"""Manages the bot's visual animation states.
|
||||||
|
|
||||||
|
Switches between static (listening) and animated (talking) states based on
|
||||||
|
the bot's current speaking status.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._is_talking = False
|
||||||
|
|
||||||
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
|
"""Process incoming frames and update animation state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The incoming frame to process
|
||||||
|
direction: The direction of frame flow in the pipeline
|
||||||
|
"""
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
|
||||||
|
# Switch to talking animation when bot starts speaking
|
||||||
|
if isinstance(frame, BotStartedSpeakingFrame):
|
||||||
|
if not self._is_talking:
|
||||||
|
await self.push_frame(talking_frame)
|
||||||
|
self._is_talking = True
|
||||||
|
# Return to static frame when bot stops speaking
|
||||||
|
elif isinstance(frame, BotStoppedSpeakingFrame):
|
||||||
|
await self.push_frame(quiet_frame)
|
||||||
|
self._is_talking = False
|
||||||
|
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_bot(room_url: str, token: str):
|
||||||
|
"""Main bot execution function.
|
||||||
|
|
||||||
|
Sets up and runs the bot pipeline including:
|
||||||
|
- Daily video transport
|
||||||
|
- Speech-to-text and text-to-speech services
|
||||||
|
- Language model integration
|
||||||
|
- Animation processing
|
||||||
|
- RTVI event handling
|
||||||
|
"""
|
||||||
|
# Set up Daily transport with video/audio parameters
|
||||||
|
transport = DailyTransport(
|
||||||
|
room_url,
|
||||||
|
token,
|
||||||
|
"Chatbot",
|
||||||
|
DailyParams(
|
||||||
|
audio_out_enabled=True,
|
||||||
|
camera_out_enabled=True,
|
||||||
|
camera_out_width=1024,
|
||||||
|
camera_out_height=576,
|
||||||
|
vad_enabled=True,
|
||||||
|
vad_analyzer=SileroVADAnalyzer(),
|
||||||
|
transcription_enabled=True,
|
||||||
|
#
|
||||||
|
# Spanish
|
||||||
|
#
|
||||||
|
# transcription_settings=DailyTranscriptionSettings(
|
||||||
|
# language="es",
|
||||||
|
# tier="nova",
|
||||||
|
# model="2-general"
|
||||||
|
# )
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize text-to-speech service
|
||||||
|
tts = ElevenLabsTTSService(
|
||||||
|
api_key=os.getenv("ELEVENLABS_API_KEY"),
|
||||||
|
#
|
||||||
|
# English
|
||||||
|
#
|
||||||
|
voice_id="SAz9YHcvj6GT2YYXdXww",
|
||||||
|
#
|
||||||
|
# Spanish
|
||||||
|
#
|
||||||
|
# model="eleven_multilingual_v2",
|
||||||
|
# voice_id="gD1IexrzCvsXPHUuT0s3",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize LLM service
|
||||||
|
llm = OpenAILLMService(api_key=os.getenv("OPENAI_API_KEY"))
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
#
|
||||||
|
# English
|
||||||
|
#
|
||||||
|
"content": "You are an incessant one-upper. Start by asking the user how their day is going.",
|
||||||
|
#
|
||||||
|
# Spanish
|
||||||
|
#
|
||||||
|
# "content": "Eres Chatbot, un amigable y útil robot. Tu objetivo es demostrar tus capacidades de una manera breve. Tus respuestas se convertiran a audio así que nunca no debes incluir caracteres especiales. Contesta a lo que el usuario pregunte de una manera creativa, útil y breve. Empieza por presentarte a ti mismo.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Set up conversation context and management
|
||||||
|
# The context_aggregator will automatically collect conversation context
|
||||||
|
context = OpenAILLMContext(messages)
|
||||||
|
context_aggregator = llm.create_context_aggregator(context)
|
||||||
|
|
||||||
|
ta = TalkingAnimation()
|
||||||
|
|
||||||
|
#
|
||||||
|
# RTVI events for Pipecat client UI
|
||||||
|
#
|
||||||
|
rtvi = RTVIProcessor(config=RTVIConfig(config=[]))
|
||||||
|
|
||||||
|
pipeline = Pipeline(
|
||||||
|
[
|
||||||
|
transport.input(),
|
||||||
|
rtvi,
|
||||||
|
context_aggregator.user(),
|
||||||
|
llm,
|
||||||
|
tts,
|
||||||
|
ta,
|
||||||
|
transport.output(),
|
||||||
|
context_aggregator.assistant(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
task = PipelineTask(
|
||||||
|
pipeline,
|
||||||
|
params=PipelineParams(
|
||||||
|
allow_interruptions=True,
|
||||||
|
enable_metrics=True,
|
||||||
|
enable_usage_metrics=True,
|
||||||
|
),
|
||||||
|
observers=[RTVIObserver(rtvi)],
|
||||||
|
)
|
||||||
|
await task.queue_frame(quiet_frame)
|
||||||
|
|
||||||
|
@rtvi.event_handler("on_client_ready")
|
||||||
|
async def on_client_ready(rtvi):
|
||||||
|
await rtvi.set_bot_ready()
|
||||||
|
# Kick off the conversation
|
||||||
|
await task.queue_frames([context_aggregator.user().get_context_frame()])
|
||||||
|
|
||||||
|
@transport.event_handler("on_first_participant_joined")
|
||||||
|
async def on_first_participant_joined(transport, participant):
|
||||||
|
await transport.capture_participant_transcription(participant["id"])
|
||||||
|
|
||||||
|
@transport.event_handler("on_participant_left")
|
||||||
|
async def on_participant_left(transport, participant, reason):
|
||||||
|
print(f"Participant left: {participant}")
|
||||||
|
await task.cancel()
|
||||||
|
|
||||||
|
runner = PipelineRunner()
|
||||||
|
|
||||||
|
await runner.run(task)
|
||||||
239
examples/deployment/modal-example/server/src/bot_vllm.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024–2025, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
"""OpenAI Bot Implementation.
|
||||||
|
|
||||||
|
This module implements a chatbot using OpenAI's GPT-4 model for natural language
|
||||||
|
processing. It includes:
|
||||||
|
- Real-time audio/video interaction through Daily
|
||||||
|
- Animated robot avatar
|
||||||
|
- Text-to-speech using ElevenLabs
|
||||||
|
- Support for both English and Spanish
|
||||||
|
|
||||||
|
The bot runs as part of a pipeline that processes audio/video frames and manages
|
||||||
|
the conversation flow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from loguru import logger
|
||||||
|
from openai.types.chat import ChatCompletionMessageParam
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||||
|
from pipecat.frames.frames import (
|
||||||
|
BotStartedSpeakingFrame,
|
||||||
|
BotStoppedSpeakingFrame,
|
||||||
|
Frame,
|
||||||
|
OutputImageRawFrame,
|
||||||
|
SpriteFrame,
|
||||||
|
)
|
||||||
|
from pipecat.pipeline.pipeline import Pipeline
|
||||||
|
from pipecat.pipeline.runner import PipelineRunner
|
||||||
|
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
||||||
|
from pipecat.processors.aggregators.openai_llm_context import OpenAILLMContext
|
||||||
|
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
|
||||||
|
from pipecat.processors.frameworks.rtvi import RTVIConfig, RTVIObserver, RTVIProcessor
|
||||||
|
from pipecat.services.elevenlabs.tts import ElevenLabsTTSService
|
||||||
|
from pipecat.services.openai.llm import OpenAILLMService
|
||||||
|
from pipecat.transports.services.daily import DailyParams, DailyTransport
|
||||||
|
|
||||||
|
load_dotenv(override=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.remove(0)
|
||||||
|
logger.add(sys.stderr, level="DEBUG")
|
||||||
|
except ValueError:
|
||||||
|
# Handle the case where logger is already initialized
|
||||||
|
pass
|
||||||
|
|
||||||
|
# REPLACE WITH YOUR MODAL URL ENDPOINT
|
||||||
|
modal_url = "https://<Modal workspace>--example-vllm-openai-compatible-serve.modal.run"
|
||||||
|
api_key = os.getenv("VLLM_API_KEY", "super-secret-key")
|
||||||
|
|
||||||
|
|
||||||
|
sprites = []
|
||||||
|
script_dir = os.path.dirname(__file__)
|
||||||
|
|
||||||
|
# Load sequential animation frames
|
||||||
|
for i in range(1, 26):
|
||||||
|
# Build the full path to the image file
|
||||||
|
full_path = os.path.join(script_dir, f"assets/robot0{i}.png")
|
||||||
|
# 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(OutputImageRawFrame(image=img.tobytes(), size=img.size, format=img.format))
|
||||||
|
|
||||||
|
# Create a smooth animation by adding reversed frames
|
||||||
|
flipped = sprites[::-1]
|
||||||
|
sprites.extend(flipped)
|
||||||
|
|
||||||
|
# Define static and animated states
|
||||||
|
quiet_frame = sprites[0] # Static frame for when bot is listening
|
||||||
|
talking_frame = SpriteFrame(images=sprites) # Animation sequence for when bot is talking
|
||||||
|
|
||||||
|
|
||||||
|
class TalkingAnimation(FrameProcessor):
|
||||||
|
"""Manages the bot's visual animation states.
|
||||||
|
|
||||||
|
Switches between static (listening) and animated (talking) states based on
|
||||||
|
the bot's current speaking status.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self._is_talking = False
|
||||||
|
|
||||||
|
async def process_frame(self, frame: Frame, direction: FrameDirection):
|
||||||
|
"""Process incoming frames and update animation state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
frame: The incoming frame to process
|
||||||
|
direction: The direction of frame flow in the pipeline
|
||||||
|
"""
|
||||||
|
await super().process_frame(frame, direction)
|
||||||
|
|
||||||
|
# Switch to talking animation when bot starts speaking
|
||||||
|
if isinstance(frame, BotStartedSpeakingFrame):
|
||||||
|
if not self._is_talking:
|
||||||
|
await self.push_frame(talking_frame)
|
||||||
|
self._is_talking = True
|
||||||
|
# Return to static frame when bot stops speaking
|
||||||
|
elif isinstance(frame, BotStoppedSpeakingFrame):
|
||||||
|
await self.push_frame(quiet_frame)
|
||||||
|
self._is_talking = False
|
||||||
|
|
||||||
|
await self.push_frame(frame, direction)
|
||||||
|
|
||||||
|
|
||||||
|
async def run_bot(room_url: str, token: str):
|
||||||
|
"""Main bot execution function.
|
||||||
|
|
||||||
|
Sets up and runs the bot pipeline including:
|
||||||
|
- Daily video transport
|
||||||
|
- Speech-to-text and text-to-speech services
|
||||||
|
- Language model integration
|
||||||
|
- Animation processing
|
||||||
|
- RTVI event handling
|
||||||
|
"""
|
||||||
|
# Set up Daily transport with video/audio parameters
|
||||||
|
transport = DailyTransport(
|
||||||
|
room_url,
|
||||||
|
token,
|
||||||
|
"Chatbot",
|
||||||
|
DailyParams(
|
||||||
|
audio_out_enabled=True,
|
||||||
|
camera_out_enabled=True,
|
||||||
|
camera_out_width=1024,
|
||||||
|
camera_out_height=576,
|
||||||
|
vad_enabled=True,
|
||||||
|
vad_analyzer=SileroVADAnalyzer(),
|
||||||
|
transcription_enabled=True,
|
||||||
|
#
|
||||||
|
# Spanish
|
||||||
|
#
|
||||||
|
# transcription_settings=DailyTranscriptionSettings(
|
||||||
|
# language="es",
|
||||||
|
# tier="nova",
|
||||||
|
# model="2-general"
|
||||||
|
# )
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize text-to-speech service
|
||||||
|
tts = ElevenLabsTTSService(
|
||||||
|
api_key=os.getenv("ELEVENLABS_API_KEY"),
|
||||||
|
#
|
||||||
|
# English
|
||||||
|
#
|
||||||
|
voice_id="D38z5RcWu1voky8WS1ja",
|
||||||
|
#
|
||||||
|
# Spanish
|
||||||
|
#
|
||||||
|
# model="eleven_multilingual_v2",
|
||||||
|
# voice_id="gD1IexrzCvsXPHUuT0s3",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize LLM service
|
||||||
|
llm = OpenAILLMService(
|
||||||
|
# To use OpenAI
|
||||||
|
api_key=api_key,
|
||||||
|
# Or, to use a local vLLM (or similar) api server
|
||||||
|
model="neuralmagic/Meta-Llama-3.1-8B-Instruct-quantized.w4a16",
|
||||||
|
base_url=f"{modal_url}/v1",
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
#
|
||||||
|
# English
|
||||||
|
#
|
||||||
|
"content": "You are a salesman for Modal, the cloud-native serverless Python computing platform.",
|
||||||
|
#
|
||||||
|
# Spanish
|
||||||
|
#
|
||||||
|
# "content": "Eres Chatbot, un amigable y útil robot. Tu objetivo es demostrar tus capacidades de una manera breve. Tus respuestas se convertiran a audio así que nunca no debes incluir caracteres especiales. Contesta a lo que el usuario pregunte de una manera creativa, útil y breve. Empieza por presentarte a ti mismo.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Set up conversation context and management
|
||||||
|
# The context_aggregator will automatically collect conversation context
|
||||||
|
context = OpenAILLMContext(messages)
|
||||||
|
context_aggregator = llm.create_context_aggregator(context)
|
||||||
|
|
||||||
|
ta = TalkingAnimation()
|
||||||
|
|
||||||
|
#
|
||||||
|
# RTVI events for Pipecat client UI
|
||||||
|
#
|
||||||
|
rtvi = RTVIProcessor(config=RTVIConfig(config=[]))
|
||||||
|
|
||||||
|
pipeline = Pipeline(
|
||||||
|
[
|
||||||
|
transport.input(),
|
||||||
|
rtvi,
|
||||||
|
context_aggregator.user(),
|
||||||
|
llm,
|
||||||
|
tts,
|
||||||
|
ta,
|
||||||
|
transport.output(),
|
||||||
|
context_aggregator.assistant(),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
task = PipelineTask(
|
||||||
|
pipeline,
|
||||||
|
params=PipelineParams(
|
||||||
|
allow_interruptions=True,
|
||||||
|
enable_metrics=True,
|
||||||
|
enable_usage_metrics=True,
|
||||||
|
),
|
||||||
|
observers=[RTVIObserver(rtvi)],
|
||||||
|
)
|
||||||
|
await task.queue_frame(quiet_frame)
|
||||||
|
|
||||||
|
@rtvi.event_handler("on_client_ready")
|
||||||
|
async def on_client_ready(rtvi):
|
||||||
|
await rtvi.set_bot_ready()
|
||||||
|
# Kick off the conversation
|
||||||
|
await task.queue_frames([context_aggregator.user().get_context_frame()])
|
||||||
|
|
||||||
|
@transport.event_handler("on_first_participant_joined")
|
||||||
|
async def on_first_participant_joined(transport, participant):
|
||||||
|
await transport.capture_participant_transcription(participant["id"])
|
||||||
|
|
||||||
|
@transport.event_handler("on_participant_left")
|
||||||
|
async def on_participant_left(transport, participant, reason):
|
||||||
|
print(f"Participant left: {participant}")
|
||||||
|
await task.cancel()
|
||||||
|
|
||||||
|
runner = PipelineRunner()
|
||||||
|
|
||||||
|
await runner.run(task)
|
||||||
84
examples/deployment/modal-example/server/src/runner.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
#
|
||||||
|
# Copyright (c) 2024–2025, Daily
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: BSD 2-Clause License
|
||||||
|
#
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def get_bot_file(arg_bot: str | None) -> str:
|
||||||
|
bot_implementation = arg_bot or os.getenv("BOT_IMPLEMENTATION", "openai").lower().strip()
|
||||||
|
if not bot_implementation:
|
||||||
|
bot_implementation = "openai"
|
||||||
|
if bot_implementation not in ["openai", "gemini", "vllm"]:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid BOT_IMPLEMENTATION: {bot_implementation}. Must be 'openai' or 'gemini'"
|
||||||
|
)
|
||||||
|
return f"bot_{bot_implementation}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_runner(bot_file: str):
|
||||||
|
"""Dynamically import the run_bot function based on the bot name.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
bot_name (str): The name of the bot implementation (e.g., 'openai', 'gemini').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
function: The run_bot function from the specified bot module.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ImportError: If the specified bot module or run_bot function is not found.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Dynamically construct the module name
|
||||||
|
module_name = f"{bot_file}"
|
||||||
|
# Import the module
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
# Get the run_bot function from the module
|
||||||
|
return getattr(module, "run_bot")
|
||||||
|
except (ImportError, AttributeError) as e:
|
||||||
|
raise ImportError(f"Failed to import run_bot from {module_name}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Parse the args to launch the appropriate bot using the given room/token."""
|
||||||
|
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(
|
||||||
|
"-t",
|
||||||
|
"--token",
|
||||||
|
type=str,
|
||||||
|
required=False,
|
||||||
|
help="Daily room token",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-b",
|
||||||
|
"--bot",
|
||||||
|
type=str,
|
||||||
|
required=False,
|
||||||
|
help="Bot runner to use (e.g., openai, gemini)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args, unknown = parser.parse_known_args()
|
||||||
|
|
||||||
|
url = args.url or os.getenv("DAILY_SAMPLE_ROOM_URL")
|
||||||
|
token = args.token or os.getenv("DAILY_SAMPLE_ROOM_TOKEN")
|
||||||
|
bot_file = get_bot_file(args.bot)
|
||||||
|
|
||||||
|
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."
|
||||||
|
)
|
||||||
|
|
||||||
|
run_bot = get_runner(bot_file)
|
||||||
|
asyncio.run(run_bot(url, token))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||