Merge remote-tracking branch 'origin/main' into feat/external-pbx

This commit is contained in:
Abhishek 2026-06-23 13:18:42 +00:00
commit 688773d3b3
28 changed files with 1558 additions and 116 deletions

View file

@ -388,9 +388,18 @@ class VoiceInfo(BaseModel):
preview_url: Optional[str] = None
class VoiceFacets(BaseModel):
"""Distinct selector values across a provider's full voice catalog."""
genders: List[str] = []
accents: List[str] = []
languages: List[str] = []
class VoicesResponse(BaseModel):
provider: str
voices: List[VoiceInfo]
facets: Optional[VoiceFacets] = None
@router.get("/configurations/voices/{provider}")
@ -398,6 +407,9 @@ async def get_voices(
provider: TTSProvider,
model: Optional[str] = None,
language: Optional[str] = None,
q: Optional[str] = None,
gender: Optional[str] = None,
accent: Optional[str] = None,
user: UserModel = Depends(get_user),
) -> VoicesResponse:
"""Get available voices for a TTS provider."""
@ -406,12 +418,16 @@ async def get_voices(
provider=provider,
model=model,
language=language,
q=q,
gender=gender,
accent=accent,
organization_id=user.selected_organization_id,
created_by=user.provider_id,
)
return VoicesResponse(
provider=result.get("provider", provider),
voices=[VoiceInfo(**voice) for voice in result.get("voices", [])],
facets=result.get("facets"),
)
except Exception as e:
logger.error(f"Failed to fetch voices for {provider}: {e}")

View file

@ -720,6 +720,9 @@ class MPSServiceKeyClient:
provider: str,
model: Optional[str] = None,
language: Optional[str] = None,
q: Optional[str] = None,
gender: Optional[str] = None,
accent: Optional[str] = None,
organization_id: Optional[int] = None,
created_by: Optional[str] = None,
) -> dict:
@ -745,6 +748,12 @@ class MPSServiceKeyClient:
params["model"] = model
if language:
params["language"] = language
if q:
params["q"] = q
if gender:
params["gender"] = gender
if accent:
params["accent"] = accent
response = await client.get(
f"{self.base_url}/api/v1/voice-proxy/{provider}/voices",
headers=self._get_headers(organization_id, created_by),

View file

@ -0,0 +1,65 @@
# Dograh — Hostinger VPS (managed Traefik) environment
# Copy to .env (in this directory) and fill in. See README.md for bring-up.
# ---------------------------------------------------------------------------
# Public identity
# ---------------------------------------------------------------------------
# The domain users hit in the browser. Must already point (DNS A record) at
# this VPS, and be a router rule Traefik will issue a Let's Encrypt cert for.
PUBLIC_HOST=app.example.com
# ---------------------------------------------------------------------------
# Managed Traefik wiring (Hostinger Docker Manager defaults — leave as-is there)
# ---------------------------------------------------------------------------
# Name of the existing Docker network Traefik watches/attaches to.
TRAEFIK_NETWORK=traefik-proxy
# Name of Traefik's HTTPS entrypoint (often "websecure" or "https").
TRAEFIK_ENTRYPOINT=websecure
# Name of Traefik's Let's Encrypt certificate resolver.
TRAEFIK_CERTRESOLVER=letsencrypt
# ---------------------------------------------------------------------------
# WebRTC media (coturn) — REQUIRED for voice. NOT proxied by Traefik.
# ---------------------------------------------------------------------------
# Public IP of this VPS (or a domain that resolves to it). coturn advertises
# this as its external relay address.
TURN_HOST=203.0.113.10
# Shared secret for time-limited TURN credentials. Generate a strong random
# value, e.g.: openssl rand -hex 32
TURN_SECRET=change-me-to-a-long-random-secret
# Set true only to *force* relay-only ICE for debugging TURN reachability.
FORCE_TURN_RELAY=false
# ---------------------------------------------------------------------------
# Secrets
# ---------------------------------------------------------------------------
# JWT signing secret. Generate, e.g.: openssl rand -hex 32
OSS_JWT_SECRET=change-me-to-a-long-random-secret
# Postgres password (baked into the volume on first init; changing later does
# NOT re-key an existing volume).
POSTGRES_PASSWORD=postgres
# Internal datastore credentials. Redis and MinIO are NOT published to the host
# (reachable only on the internal Docker network), but set strong values anyway
# on a public box — the compose falls back to weak well-known defaults
# (redissecret / minioadmin) if these are unset. Generate with: openssl rand -hex 32
REDIS_PASSWORD=change-me-to-a-long-random-secret
MINIO_ROOT_USER=dograh
MINIO_ROOT_PASSWORD=change-me-to-a-long-random-secret
# ---------------------------------------------------------------------------
# Images — pin to a GitHub release tag for predictable upgrades/rollback.
# Leave at "latest" only for evaluation.
# ---------------------------------------------------------------------------
REGISTRY=dograhai
DOGRAH_VERSION=latest
# ---------------------------------------------------------------------------
# Optional
# ---------------------------------------------------------------------------
ENABLE_TELEMETRY=true
# Only needed if you run the bundled docker-compose.traefik.yaml to self-host a
# stand-in Traefik for testing (NOT on Hostinger — their Traefik provides this).
# Email Let's Encrypt uses for expiry notices.
ACME_EMAIL=admin@example.com

View file

@ -0,0 +1,59 @@
# Hostinger (managed-Traefik) deployment
Deploy Dograh where a shared, managed Traefik with Let's Encrypt already
terminates TLS and routes ingress — e.g. **Hostinger's VPS Docker Manager**.
The same files work on any host that fronts containers with Traefik.
## Files
| File | Role | Deploy on Hostinger? |
|---|---|---|
| `docker-compose.yaml` | The Dograh app stack. **Single self-contained file** — named volumes only, no host bind-mounts, no init/sidecar that reads files outside the compose. | ✅ Yes |
| `.env.example` | Required + optional environment variables, with guidance. Copy to `.env` and fill in. | ✅ Yes (as the env template) |
| `docker-compose.traefik.yaml` | A standalone Traefik + Let's Encrypt that **stands in for** the managed Traefik, so you can reproduce the environment on a plain VPS for testing. Also documents what the platform's Traefik must provide. | ❌ **No — reference only** |
## What the app stack needs from Traefik
Routing is declared with Traefik labels on `ui`, `api`, and `minio`:
`/api/v1` → api (includes the signaling **WebSocket**), `/voice-audio` → minio,
everything else → ui. For that to work the platform's Traefik must offer:
- an HTTPS entrypoint — set `TRAEFIK_ENTRYPOINT` (e.g. `websecure`)
- a Let's Encrypt certresolver — set `TRAEFIK_CERTRESOLVER`
- the Docker provider watching a shared network — set `TRAEFIK_NETWORK`
- a long `idleTimeout` so long-lived signaling WebSockets aren't cut
- (recommended) a global HTTP→HTTPS redirect
Traefik upgrades WebSockets automatically — no special label is required.
## WebRTC media (coturn) is NOT proxied by Traefik
Voice audio is UDP (ICE/DTLS-SRTP), relayed by the bundled `coturn`. A reverse
proxy cannot carry it. coturn publishes host ports that **must be open in the
VPS firewall**: UDP+TCP `3478` and `5349`, and UDP `49152-49200`. `TURN_HOST`
must be the public IP (or a domain resolving to it). Without this, calls
connect (signaling succeeds) but have **no audio**.
## Deploy on Hostinger
The platform provides Traefik, so you only deploy the app stack:
1. Copy `.env.example``.env` and fill in `PUBLIC_HOST`, `TURN_HOST`, the
secrets, and the three `TRAEFIK_*` values (matched to Hostinger's Traefik).
2. Import / deploy `docker-compose.yaml`.
3. Ensure the coturn UDP/TCP ports above are open in the firewall.
## Test on a generic VPS (self-managed stand-in Traefik)
On a box that does **not** already run Traefik:
```bash
cp .env.example .env # fill in PUBLIC_HOST, TURN_HOST, secrets, ACME_EMAIL
docker network create traefik-proxy
docker compose -f docker-compose.traefik.yaml --env-file .env up -d # stand-in Traefik
docker compose --env-file .env up -d # app stack
```
A no-cost trick for a real cert without owning a domain: set
`PUBLIC_HOST=<public-ip>.sslip.io` (sslip.io resolves any embedded IP), which
Let's Encrypt will happily issue for.

View file

@ -0,0 +1,58 @@
# Standalone Traefik + Let's Encrypt — STANDS IN FOR Hostinger's managed Traefik.
# =================================================================
# On Hostinger's VPS Docker Manager you do NOT deploy this — their platform
# already runs Traefik. Use this file to reproduce that environment on a
# generic VPS (e.g. a plain EC2 box) so you can test docker-compose.yaml
# end to end: TLS issuance, HTTP->HTTPS redirect, WebSocket upgrade, routing.
#
# It also documents exactly what we need Hostinger's Traefik to provide:
# - an HTTPS entrypoint (here: websecure / :443)
# - a Let's Encrypt certresolver (here: letsencrypt)
# - the Docker provider watching a shared network (here: traefik)
# - a long idleTimeout so long-lived signaling WebSockets aren't cut
#
# Bring up BEFORE the app stack, on the same external network:
# docker network create traefik-proxy
# docker compose -f docker-compose.traefik.yaml --env-file .env up -d
# docker compose --env-file .env up -d
# =================================================================
services:
traefik:
image: traefik:v3.1
container_name: traefik
restart: unless-stopped
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
# Global HTTP->HTTPS redirect (the ACME HTTP-01 challenge is still served
# on :80 — Traefik handles the challenge ahead of this redirect).
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
# Keep long-lived WebSockets (signaling) from being cut while idle.
- --entrypoints.websecure.transport.respondingTimeouts.idleTimeout=3600s
# Let's Encrypt via HTTP-01. Must match TRAEFIK_CERTRESOLVER in the app .env.
- --certificatesresolvers.letsencrypt.acme.httpchallenge=true
- --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
- --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:?set ACME_EMAIL in .env}
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
# For repeated test runs, point at LE staging to avoid prod rate limits:
# - --certificatesresolvers.letsencrypt.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik-acme:/letsencrypt
networks:
- traefik
volumes:
traefik-acme:
networks:
traefik:
external: true
name: ${TRAEFIK_NETWORK:-traefik-proxy}

View file

@ -0,0 +1,281 @@
# Dograh — Hostinger VPS Docker Manager deployment
# =================================================================
# This variant is for environments where a SHARED, MANAGED Traefik
# (with Let's Encrypt) already terminates TLS and routes ingress —
# e.g. Hostinger's VPS Docker Manager catalog.
#
# Differences from the canonical docker-compose.yaml:
# - No bundled `nginx` service. Traefik is the ingress; we attach
# routing intent via labels instead of shipping our own proxy.
# - No `cloudflared` tunnel. The app is reachable at a real domain.
# - Web tier (ui/api/minio) publishes NO host ports — Traefik reaches
# them over its own Docker network. coturn is the ONLY service that
# binds host ports, because WebRTC media is UDP and CANNOT traverse
# an HTTP reverse proxy.
# - `api` runs ENVIRONMENT=production (correct public ICE-candidate
# filtering + UDP-first TURN).
# - coturn is configured entirely from CLI flags (no `dograh-init`
# renderer, no host bind-mounts). The whole stack is therefore a single
# self-contained file — nothing outside it needs to exist on the host,
# which is what a catalog compose-import requires.
# - FASTAPI_WORKERS is pinned to 1 (see the note on the `api` service).
#
# Required .env keys — see .env.example. At minimum:
# PUBLIC_HOST, TURN_HOST, TURN_SECRET, OSS_JWT_SECRET,
# TRAEFIK_NETWORK, TRAEFIK_CERTRESOLVER, TRAEFIK_ENTRYPOINT
# =================================================================
services:
postgres:
image: pgvector/pgvector:pg17
restart: unless-stopped
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-postgres}"
POSTGRES_DB: postgres
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# No host port: Postgres is reachable only inside app-network.
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 3s
timeout: 3s
retries: 10
networks:
- app-network
redis:
image: redis:7
restart: unless-stopped
command: >
--requirepass ${REDIS_PASSWORD:-redissecret}
# No host port: Redis is reachable only inside app-network.
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redissecret}", "ping"]
interval: 3s
timeout: 10s
retries: 10
networks:
- app-network
minio:
image: minio/minio
container_name: minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-minioadmin}"
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-minioadmin}"
MINIO_API_CORS_ALLOW_ORIGIN: "*"
# No host ports. Browsers fetch audio via Traefik at
# https://${PUBLIC_HOST}/voice-audio/... (objects are public-read,
# unsigned URLs — no presign/host-signature to break). For console
# admin, port-forward 9001 over SSH instead of publishing it.
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 5
labels:
- "traefik.enable=true"
- "traefik.docker.network=${TRAEFIK_NETWORK:-traefik-proxy}"
# Audio file downloads. Highest priority so it wins over the UI catch-all.
- "traefik.http.routers.dograh-audio.rule=Host(`${PUBLIC_HOST}`) && PathPrefix(`/voice-audio`)"
- "traefik.http.routers.dograh-audio.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
- "traefik.http.routers.dograh-audio.tls=true"
- "traefik.http.routers.dograh-audio.tls.certresolver=${TRAEFIK_CERTRESOLVER:-letsencrypt}"
- "traefik.http.routers.dograh-audio.priority=20"
- "traefik.http.services.dograh-audio.loadbalancer.server.port=9000"
networks:
- app-network
- traefik
# TURN/STUN relay for WebRTC NAT traversal. This is the voice media path —
# it is UDP and is NOT, and cannot be, proxied by Traefik. These host ports
# MUST be published and opened in the VPS firewall, and TURN_HOST must be the
# VPS public IP (or a domain resolving to it). Without this, calls connect
# (signaling succeeds over Traefik) but carry NO audio.
#
# Configured entirely from CLI flags (`-n` disables config-file lookup), so
# the stack ships no host-side config and needs no init/renderer container —
# it is a single self-contained file, which is what a catalog import requires.
coturn:
image: coturn/coturn:4.8.0
container_name: coturn
restart: unless-stopped
command:
- -n
- "--listening-port=3478"
- "--tls-listening-port=5349"
- "--min-port=49152"
- "--max-port=49200"
- "--external-ip=${TURN_HOST}"
- "--realm=dograh.com"
- --use-auth-secret
- "--static-auth-secret=${TURN_SECRET}"
- --fingerprint
- --no-cli
- --no-multicast-peers
- "--log-file=stdout"
ports:
- "3478:3478/udp"
- "3478:3478/tcp"
- "5349:5349/udp"
- "5349:5349/tcp"
- "49152-49200:49152-49200/udp"
networks:
- app-network
api:
image: ${REGISTRY:-dograhai}/dograh-api:${DOGRAH_VERSION:-latest}
restart: unless-stopped
volumes:
- shared-tmp:/tmp
environment:
# production => drop private-IP host ICE candidates on a public VPS and
# order TURN URIs UDP-first. Required for correct remote WebRTC.
ENVIRONMENT: "production"
LOG_LEVEL: "INFO"
# Public HTTPS origin (Traefik-terminated). Used for absolute URLs and
# for verifying inbound telephony webhook signatures.
BACKEND_API_ENDPOINT: "https://${PUBLIC_HOST}"
DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres"
REDIS_URL: "redis://:${REDIS_PASSWORD:-redissecret}@redis:6379"
ENABLE_AWS_S3: "false"
MINIO_ENDPOINT: "minio:9000"
# Full public URL browsers use to fetch audio. Traefik routes
# /voice-audio/ to minio:9000. This replaces the nginx sub_filter hack.
MINIO_PUBLIC_ENDPOINT: "https://${PUBLIC_HOST}"
# Must match the MinIO root creds above (same env vars).
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-minioadmin}"
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-minioadmin}"
MINIO_BUCKET: "voice-audio"
MINIO_SECURE: "false"
# IMPORTANT: pinned to 1. The image launches FASTAPI_WORKERS as
# independent uvicorn processes on consecutive ports (8000, 8001, ...)
# expecting the bundled nginx to least_conn-balance long-lived
# WebSockets across them. We dropped that nginx for Traefik, and a
# managed Traefik (label provider) can only target one port — so values
# >1 would leave the extra workers idle. The container also runs
# singletons (telephony ARI manager, campaign orchestrator) + migrations
# that must run exactly once. Scale vertically (bigger VPS) here; do not
# raise this and do not naively replicate this service.
FASTAPI_WORKERS: "1"
# Trust Traefik's X-Forwarded-Proto: https so request.url is https and
# inbound webhook signature checks pass. Narrow to the Docker subnet if
# you prefer.
FORWARDED_ALLOW_IPS: "*"
# TURN — must match coturn. TURN_HOST is the VPS public IP / TURN domain.
TURN_HOST: "${TURN_HOST:?TURN_HOST is required for WebRTC media}"
TURN_SECRET: "${TURN_SECRET:?TURN_SECRET is required for WebRTC media}"
FORCE_TURN_RELAY: "${FORCE_TURN_RELAY:-false}"
OSS_JWT_SECRET: "${OSS_JWT_SECRET:?OSS_JWT_SECRET must be set to a strong secret}"
ENABLE_TELEMETRY: "${ENABLE_TELEMETRY:-true}"
POSTHOG_API_KEY: "phc_ItizB1dP6yv7ZYobbcqrpxTdbomDA8hJFSEmAMdYvIr"
POSTHOG_HOST: "https://us.i.posthog.com"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
minio:
condition: service_healthy
healthcheck:
test:
[
"CMD-SHELL",
'python -c "import urllib.request; urllib.request.urlopen(''http://localhost:8000/api/v1/health'').read()"',
]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
labels:
- "traefik.enable=true"
- "traefik.docker.network=${TRAEFIK_NETWORK:-traefik-proxy}"
# REST API + the signaling WebSocket (/api/v1/ws/signaling/...).
# Traefik upgrades WebSockets automatically — no extra labels needed.
# Higher priority than the UI so /api/v1 is matched first.
- "traefik.http.routers.dograh-api.rule=Host(`${PUBLIC_HOST}`) && PathPrefix(`/api/v1`)"
- "traefik.http.routers.dograh-api.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
- "traefik.http.routers.dograh-api.tls=true"
- "traefik.http.routers.dograh-api.tls.certresolver=${TRAEFIK_CERTRESOLVER:-letsencrypt}"
- "traefik.http.routers.dograh-api.priority=10"
- "traefik.http.services.dograh-api.loadbalancer.server.port=8000"
networks:
- app-network
- traefik
ui:
image: ${REGISTRY:-dograhai}/dograh-ui:${DOGRAH_VERSION:-latest}
restart: unless-stopped
environment:
HOSTNAME: "0.0.0.0"
# Server-side (SSR) calls stay on the internal Docker network.
BACKEND_URL: "${BACKEND_URL:-http://api:8000}"
NODE_ENV: "oss"
ENABLE_TELEMETRY: "${ENABLE_TELEMETRY:-true}"
POSTHOG_KEY: "phc_ItizB1dP6yv7ZYobbcqrpxTdbomDA8hJFSEmAMdYvIr"
POSTHOG_HOST: "https://us.posthog.com"
depends_on:
api:
condition: service_healthy
healthcheck:
test:
[
"CMD-SHELL",
"wget --no-verbose --tries=1 --spider http://127.0.0.1:3010 || exit 1",
]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
labels:
- "traefik.enable=true"
- "traefik.docker.network=${TRAEFIK_NETWORK:-traefik-proxy}"
# Catch-all for everything that is not /api/v1 or /voice-audio.
- "traefik.http.routers.dograh-ui.rule=Host(`${PUBLIC_HOST}`)"
- "traefik.http.routers.dograh-ui.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}"
- "traefik.http.routers.dograh-ui.tls=true"
- "traefik.http.routers.dograh-ui.tls.certresolver=${TRAEFIK_CERTRESOLVER:-letsencrypt}"
- "traefik.http.routers.dograh-ui.priority=1"
- "traefik.http.services.dograh-ui.loadbalancer.server.port=3010"
networks:
- app-network
- traefik
volumes:
postgres_data:
redis_data:
minio-data:
driver: local
shared-tmp:
driver: local
networks:
# Internal network for service-to-service traffic (db, redis, minio, coturn).
app-network:
driver: bridge
# The EXTERNAL network that the managed Traefik is attached to. Its name is
# provider-specific — set TRAEFIK_NETWORK in .env to match Hostinger's actual
# Traefik network. This file does not create it; it must already exist.
traefik:
external: true
name: ${TRAEFIK_NETWORK:-traefik-proxy}

View file

@ -31,11 +31,11 @@ services:
ports:
- "6379:6379"
command: >
--requirepass redissecret
--requirepass ${REDIS_PASSWORD:-redissecret}
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "redissecret", "ping"]
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redissecret}", "ping"]
interval: 3s
timeout: 10s
retries: 10
@ -47,8 +47,8 @@ services:
container_name: minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
MINIO_ROOT_USER: "${MINIO_ROOT_USER:-minioadmin}"
MINIO_ROOT_PASSWORD: "${MINIO_ROOT_PASSWORD:-minioadmin}"
MINIO_API_CORS_ALLOW_ORIGIN: "*"
ports:
- "127.0.0.1:9000:9000" # Bind to localhost explicitly
@ -143,7 +143,7 @@ services:
DATABASE_URL: "postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/postgres"
# Redis configuration (using containerized redis)
REDIS_URL: "redis://:redissecret@redis:6379"
REDIS_URL: "redis://:${REDIS_PASSWORD:-redissecret}@redis:6379"
# Storage configuration - using local MinIO
ENABLE_AWS_S3: "false"
@ -154,8 +154,8 @@ services:
# deployments behind HTTPS, set MINIO_PUBLIC_ENDPOINT in .env to
# e.g. https://your-server.example.com (nginx proxies /voice-audio/).
MINIO_PUBLIC_ENDPOINT: "${MINIO_PUBLIC_ENDPOINT:-http://localhost:9000}"
MINIO_ACCESS_KEY: "minioadmin"
MINIO_SECRET_KEY: "minioadmin"
MINIO_ACCESS_KEY: "${MINIO_ROOT_USER:-minioadmin}"
MINIO_SECRET_KEY: "${MINIO_ROOT_PASSWORD:-minioadmin}"
MINIO_BUCKET: "voice-audio"
MINIO_SECURE: "false"

File diff suppressed because one or more lines are too long

View file

@ -57,3 +57,27 @@ button[data-testid*="search"],
animation: search-pulse 2s ease-in-out infinite !important;
}
/* Custom scrollbar */
/* Firefox */
* {
scrollbar-width: thin;
scrollbar-color: #16A34A transparent;
}
/* WebKit: Chrome, Edge, Safari, mobile */
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: #16A34A;
border-radius: 4px;
}
*::-webkit-scrollbar-thumb:hover {
background: #15803D;
}

View file

@ -56,7 +56,8 @@ Invoke-WebRequest -OutFile start_docker.ps1 https://raw.githubusercontent.com/do
This setup:
- Downloads the latest docker-compose.yaml
- Creates `OSS_JWT_SECRET` in `.env` if one does not already exist
- Creates `OSS_JWT_SECRET`, `REDIS_PASSWORD`, and MinIO credentials in `.env` if they do not already exist
- Creates `POSTGRES_PASSWORD` for brand-new `.env` files and syncs retained local Postgres volumes to that value before startup
- Prompts before running Docker Compose
- Starts all required services including PostgreSQL, Redis, MinIO, API, and UI
- Pulls the latest images automatically

View file

@ -18,13 +18,12 @@ Dograh publishes two images — `dograh-api` and `dograh-ui` — to both contain
- **GitHub Container Registry** — [github.com/orgs/dograh-hq/packages](https://github.com/orgs/dograh-hq/packages)
- **Docker Hub** — [hub.docker.com/u/dograhai](https://hub.docker.com/u/dograhai)
Each release is published under two kinds of tags. Note the formats differ between GitHub releases and the Docker image tags — `update_remote.sh` understands both and normalizes for you.
Each release is published under release tags. Note the formats differ between GitHub releases and Docker image tags — `update_remote.sh` understands both and normalizes for you.
| Where | Tag format | Example | When to use |
|-------|-----------|---------|-------------|
| GitHub release tag | `dograh-vX.Y.Z` | `dograh-v1.28.0` | What you see at [github.com/dograh-hq/dograh/releases](https://github.com/dograh-hq/dograh/releases) |
| GitHub release tag | `dograh-vX.Y.Z` | `dograh-v1.28.0` | Use when copying the version from a GitHub release |
| Docker image tag (semver) | `X.Y.Z` | `1.28.0` | Stable, recommended for production |
| Docker image tag (SHA) | short SHA | `a1b2c3d` | Bleeding edge — any commit merged to `main` |
| Docker image tag (`latest`) | `latest` | `latest` | Tracks the most recent release tag |
<Warning>
@ -49,7 +48,7 @@ curl -o update_remote.sh https://raw.githubusercontent.com/dograh-hq/dograh/main
bash update_remote.sh
```
You'll be prompted for the target version, defaulting to the most recent release. Accepted forms: bare semver (`1.28.0`), v-prefixed (`v1.28.0`), the full GitHub tag (`dograh-v1.28.0`), or `main` for bleeding edge — the script normalizes them. Non-interactive callers can set it via environment variable and skip the confirmation prompt:
You'll be prompted for the target version, defaulting to the most recent release. Accepted forms: bare semver (`1.28.0`), v-prefixed (`v1.28.0`), the full GitHub tag (`dograh-v1.28.0`), or `main` to refresh deployment files from the default branch — the script normalizes them. Non-interactive callers can set it via environment variable and skip the confirmation prompt:
```bash
TARGET_VERSION=1.28.0 DOGRAH_UPDATE_YES=1 bash update_remote.sh
@ -67,15 +66,17 @@ The script overwrites `docker-compose.yaml` and the remote helper bundle (`remot
## Local deployment
For local Docker installs (the [Quick Start](/deployment/docker#quick-start) flow or `setup_local.sh` / `setup_local.ps1`), there are no host-side config files to refresh — stop the stack, then use the startup script to preserve `OSS_JWT_SECRET` and pull new images:
For local Docker installs (the [Quick Start](/deployment/docker#quick-start) flow or `setup_local.sh` / `setup_local.ps1`), refresh `docker-compose.yaml` and the startup script, stop the stack, then run the startup script. The script preserves existing `.env` secrets, creates `REDIS_PASSWORD` and MinIO credentials if they are missing, and only creates `POSTGRES_PASSWORD` for brand-new `.env` files. If a retained local Postgres volume already exists, it syncs the database user's password to `.env` before starting the full stack:
<CodeGroup>
```bash macOS/Linux
curl -o docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml
curl -o start_docker.sh https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/start_docker.sh && chmod +x start_docker.sh
docker compose down
./start_docker.sh
```
```powershell Windows
Invoke-WebRequest -OutFile docker-compose.yaml https://raw.githubusercontent.com/dograh-hq/dograh/main/docker-compose.yaml
Invoke-WebRequest -OutFile start_docker.ps1 https://raw.githubusercontent.com/dograh-hq/dograh/main/scripts/start_docker.ps1
docker compose down
.\start_docker.ps1
@ -144,6 +145,6 @@ sudo docker compose --profile remote up -d
If you update the `pipecat` submodule, you **must** run `git submodule update --init --recursive` before rebuilding, or the Docker build will not pick up `pipecat` changes.
</Warning>
If you maintain a fork with local customizations on top of upstream, merging conflicts in `docker-compose.yaml`, `remote_up.sh`, `scripts/run_dograh_init.sh`, `deploy/templates/*`, or `setup_remote.sh` is up to you — resolve them as you would any other git merge. Leave `OSS_JWT_SECRET` and `TURN_SECRET` in `.env` unchanged across updates to preserve sessions and WebRTC auth.
If you maintain a fork with local customizations on top of upstream, merging conflicts in `docker-compose.yaml`, `remote_up.sh`, `scripts/run_dograh_init.sh`, `deploy/templates/*`, or `setup_remote.sh` is up to you — resolve them as you would any other git merge. Leave `OSS_JWT_SECRET`, `TURN_SECRET`, `POSTGRES_PASSWORD`, `REDIS_PASSWORD`, `MINIO_ROOT_USER`, and `MINIO_ROOT_PASSWORD` in `.env` unchanged across updates to preserve sessions, WebRTC auth, and service credentials.
The same migration warning above applies: rolling back across a schema change can leave the DB in a state the older API can't read.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 366 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 265 KiB

Before After
Before After

View file

@ -6,7 +6,7 @@ description: "Connect Dograh to Tuner — the observability, simulation, and tes
<iframe
width="100%"
height="400"
src="https://www.youtube.com/embed/Zxse1yLorbk"
src="https://www.youtube.com/embed/mFfMIsK-qPM"
title="Tuner Integration Walkthrough"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
@ -26,11 +26,11 @@ The Tuner integration node automatically sends your completed call data (transcr
### 1. Create an agent in Tuner
Log in to [Tuner](https://app.usetuner.ai) and create a new agent. When configuring the agent, set the **Provider** to **Custom API** — this is required for the Dograh integration.
Log in to [Tuner](https://app.usetuner.ai) and create a new agent. When configuring the agent, set the **Provider** to **Dograh**. Enter your **Agent Name** and choose the **Call Direction** (Inbound or Outbound) for your setup. The **Agent Remote ID** is auto-generated after you create the agent.
<img
src="/images/tuner-create-agent.png"
alt="Create a New Agent modal in Tuner — set Provider to Custom API"
alt="Create a New Agent modal in Tuner — set Provider to Dograh"
/>
@ -40,7 +40,7 @@ You'll need three values from your Tuner account:
| Credential | Where to find it |
|---|---|
| **Agent ID** | Agent Settings → Agent Remote ID |
| **Agent ID** | Agent Settings → Agent Connection → Agent Remote ID |
| **Workspace ID** | Workspace Settings → General Settings → Workspace ID |
| **API Key** | Workspace Settings → Tuner API Key |
@ -117,7 +117,7 @@ To temporarily stop exporting calls to Tuner, open the Tuner node configuration
| Calls not appearing in Tuner | Verify all three credentials are correct with no extra whitespace |
| Node shows "Not configured" | Open the node and fill in Agent ID, Workspace ID, and API Key |
| Workflow not sending data | Make sure the workflow is published, not just saved as a draft |
| Wrong agent in Tuner | Confirm the Tuner agent's Provider is set to **Custom API** |
| Wrong agent in Tuner | Confirm the Tuner agent's Provider is set to **Dograh** |
## Learn more

View file

@ -244,6 +244,9 @@ if ($UseCoturn) {
Write-Info "[2/$TotalSteps] Creating environment file..."
$ossJwtSecret = New-HexSecret 32
$postgresPassword = New-HexSecret 32
$redisPassword = New-HexSecret 32
$minioRootUser = "dograh$((New-HexSecret 6).Substring(0, 12))"
$minioRootPassword = New-HexSecret 32
$envLines = @(
'# Container registry for Dograh images'
@ -257,6 +260,16 @@ $envLines = @(
'# is baked into the postgres data volume when it is first created.'
"POSTGRES_PASSWORD=$postgresPassword"
''
"# Redis password. Used by the redis container's --requirepass and the API's"
'# REDIS_URL. This can be rotated by updating .env and recreating the redis'
'# container.'
"REDIS_PASSWORD=$redisPassword"
''
'# MinIO root credentials. Used by the MinIO container and the API''s'
'# MINIO_ACCESS_KEY / MINIO_SECRET_KEY.'
"MINIO_ROOT_USER=$minioRootUser"
"MINIO_ROOT_PASSWORD=$minioRootPassword"
''
'# Telemetry (set to false to disable)'
"ENABLE_TELEMETRY=$EnableTelemetry"
''

View file

@ -151,6 +151,9 @@ ENV_STEP=$TOTAL_STEPS
echo -e "${BLUE}[$ENV_STEP/$TOTAL_STEPS] Creating environment file...${NC}"
OSS_JWT_SECRET=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
MINIO_ROOT_USER="dograh$(openssl rand -hex 6)"
MINIO_ROOT_PASSWORD=$(openssl rand -hex 32)
cat > .env << ENV_EOF
# Container registry for Dograh images
@ -164,6 +167,16 @@ OSS_JWT_SECRET=$OSS_JWT_SECRET
# baked into the postgres data volume when it is first created.
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
# Redis password. Used by the redis container's --requirepass and the API's
# REDIS_URL. This can be rotated by updating .env and recreating the redis
# container.
REDIS_PASSWORD=$REDIS_PASSWORD
# MinIO root credentials. Used by the MinIO container and the API's
# MINIO_ACCESS_KEY / MINIO_SECRET_KEY.
MINIO_ROOT_USER=$MINIO_ROOT_USER
MINIO_ROOT_PASSWORD=$MINIO_ROOT_PASSWORD
# Telemetry (set to false to disable)
ENABLE_TELEMETRY=$ENABLE_TELEMETRY

View file

@ -252,6 +252,9 @@ echo -e "${GREEN}✓ SSL certificates generated${NC}"
echo -e "${BLUE}[4/$TOTAL] Creating environment file...${NC}"
OSS_JWT_SECRET=$(openssl rand -hex 32)
POSTGRES_PASSWORD=$(openssl rand -hex 32)
REDIS_PASSWORD=$(openssl rand -hex 32)
MINIO_ROOT_USER="dograh$(openssl rand -hex 6)"
MINIO_ROOT_PASSWORD=$(openssl rand -hex 32)
cat > .env << ENV_EOF
# Remote deployments run with production signaling and HTTPS defaults
@ -282,6 +285,16 @@ OSS_JWT_SECRET=$OSS_JWT_SECRET
# baked into the postgres data volume when it is first created.
POSTGRES_PASSWORD=$POSTGRES_PASSWORD
# Redis password. Used by the redis container's --requirepass and the API's
# REDIS_URL. Unlike postgres, this is not baked into a volume and can be
# rotated by updating .env and recreating the redis container.
REDIS_PASSWORD=$REDIS_PASSWORD
# MinIO root credentials. Used by the MinIO container and the API's
# MINIO_ACCESS_KEY / MINIO_SECRET_KEY.
MINIO_ROOT_USER=$MINIO_ROOT_USER
MINIO_ROOT_PASSWORD=$MINIO_ROOT_PASSWORD
# Telemetry (set to false to disable)
ENABLE_TELEMETRY=$ENABLE_TELEMETRY

View file

@ -11,6 +11,10 @@ function New-HexSecret {
return -join ($bytes | ForEach-Object { $_.ToString('x2') })
}
function New-MinioRootUser {
return "dograh$((New-HexSecret).Substring(0, 12))"
}
function Get-DotEnvValue {
param(
[string]$Path,
@ -60,11 +64,91 @@ function Set-DotEnvValue {
[System.IO.File]::WriteAllLines((Join-Path (Get-Location) $Path), $lines, $Utf8NoBom)
}
function Get-PostgresVolumeName {
try {
$configJson = docker compose config --format json 2>$null
if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrEmpty($configJson)) {
$config = $configJson | ConvertFrom-Json
$volumeName = $config.volumes.postgres_data.name
if (-not [string]::IsNullOrEmpty($volumeName)) {
return $volumeName
}
}
} catch {
# Fall back to Compose's default project-name convention below.
}
$projectName = if ([string]::IsNullOrEmpty($env:COMPOSE_PROJECT_NAME)) {
(Split-Path -Leaf (Get-Location).Path).ToLowerInvariant() -replace '[^a-z0-9_-]', ''
} else {
$env:COMPOSE_PROJECT_NAME.ToLowerInvariant() -replace '[^a-z0-9_-]', ''
}
return "${projectName}_postgres_data"
}
function Test-DockerVolumeExists {
param([string]$Name)
docker volume inspect $Name *> $null
return $LASTEXITCODE -eq 0
}
function Wait-PostgresReady {
for ($attempt = 0; $attempt -lt 20; $attempt++) {
docker compose exec -T postgres pg_isready -U postgres *> $null
if ($LASTEXITCODE -eq 0) {
return
}
Start-Sleep -Seconds 1
}
Write-Error 'Postgres did not become ready while syncing POSTGRES_PASSWORD.'
exit 1
}
function Sync-PostgresPassword {
param([string]$Password)
if ([string]::IsNullOrEmpty($Password)) {
return
}
$volumeName = Get-PostgresVolumeName
if ([string]::IsNullOrEmpty($volumeName) -or -not (Test-DockerVolumeExists $volumeName)) {
return
}
Write-Host "Existing Postgres volume detected; syncing postgres password from $EnvFile."
$env:REGISTRY = $Registry
$env:ENABLE_TELEMETRY = $EnableTelemetry
docker compose up -d postgres
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE
}
Wait-PostgresReady
"ALTER USER postgres WITH PASSWORD :'dograh_password';" | docker compose exec -T postgres psql `
-U postgres `
-d postgres `
-v 'ON_ERROR_STOP=1' `
-v "dograh_password=$Password" > $null
if ($LASTEXITCODE -ne 0) {
Write-Error 'Failed to sync POSTGRES_PASSWORD with the existing Postgres volume.'
exit $LASTEXITCODE
}
Write-Host 'Postgres password synced.'
}
if (-not (Test-Path 'docker-compose.yaml')) {
Write-Error 'docker-compose.yaml not found. Download it first, then re-run this script.'
exit 1
}
$envFileExisted = Test-Path $EnvFile
$existingSecret = Get-DotEnvValue -Path $EnvFile -Key 'OSS_JWT_SECRET'
if ([string]::IsNullOrEmpty($existingSecret)) {
Set-DotEnvValue -Path $EnvFile -Key 'OSS_JWT_SECRET' -Value (New-HexSecret)
@ -73,6 +157,54 @@ if ([string]::IsNullOrEmpty($existingSecret)) {
Write-Host "OSS_JWT_SECRET is already set in $EnvFile."
}
$existingPostgresPassword = Get-DotEnvValue -Path $EnvFile -Key 'POSTGRES_PASSWORD'
if ([string]::IsNullOrEmpty($existingPostgresPassword)) {
if (-not $envFileExisted) {
Set-DotEnvValue -Path $EnvFile -Key 'POSTGRES_PASSWORD' -Value (New-HexSecret)
Write-Host "Created POSTGRES_PASSWORD in $EnvFile."
} else {
Write-Host "POSTGRES_PASSWORD is not set in $EnvFile; keeping the docker-compose fallback for existing local data volumes."
}
} else {
Write-Host "POSTGRES_PASSWORD is already set in $EnvFile."
}
$existingRedisPassword = Get-DotEnvValue -Path $EnvFile -Key 'REDIS_PASSWORD'
if ([string]::IsNullOrEmpty($existingRedisPassword)) {
Set-DotEnvValue -Path $EnvFile -Key 'REDIS_PASSWORD' -Value (New-HexSecret)
Write-Host "Created REDIS_PASSWORD in $EnvFile."
} else {
Write-Host "REDIS_PASSWORD is already set in $EnvFile."
}
$existingMinioRootUser = Get-DotEnvValue -Path $EnvFile -Key 'MINIO_ROOT_USER'
if ([string]::IsNullOrEmpty($existingMinioRootUser)) {
$existingMinioAccessKey = Get-DotEnvValue -Path $EnvFile -Key 'MINIO_ACCESS_KEY'
if ([string]::IsNullOrEmpty($existingMinioAccessKey)) {
Set-DotEnvValue -Path $EnvFile -Key 'MINIO_ROOT_USER' -Value (New-MinioRootUser)
Write-Host "Created MINIO_ROOT_USER in $EnvFile."
} else {
Set-DotEnvValue -Path $EnvFile -Key 'MINIO_ROOT_USER' -Value $existingMinioAccessKey
Write-Host "Created MINIO_ROOT_USER in $EnvFile from existing MINIO_ACCESS_KEY."
}
} else {
Write-Host "MINIO_ROOT_USER is already set in $EnvFile."
}
$existingMinioRootPassword = Get-DotEnvValue -Path $EnvFile -Key 'MINIO_ROOT_PASSWORD'
if ([string]::IsNullOrEmpty($existingMinioRootPassword)) {
$existingMinioSecretKey = Get-DotEnvValue -Path $EnvFile -Key 'MINIO_SECRET_KEY'
if ([string]::IsNullOrEmpty($existingMinioSecretKey)) {
Set-DotEnvValue -Path $EnvFile -Key 'MINIO_ROOT_PASSWORD' -Value (New-HexSecret)
Write-Host "Created MINIO_ROOT_PASSWORD in $EnvFile."
} else {
Set-DotEnvValue -Path $EnvFile -Key 'MINIO_ROOT_PASSWORD' -Value $existingMinioSecretKey
Write-Host "Created MINIO_ROOT_PASSWORD in $EnvFile from existing MINIO_SECRET_KEY."
}
} else {
Write-Host "MINIO_ROOT_PASSWORD is already set in $EnvFile."
}
Write-Host ''
Write-Host "Docker registry: $Registry"
Write-Host "Telemetry enabled: $EnableTelemetry"
@ -89,6 +221,7 @@ if ($answer -match '^[Nn]') {
$env:REGISTRY = $Registry
$env:ENABLE_TELEMETRY = $EnableTelemetry
Sync-PostgresPassword -Password (Get-DotEnvValue -Path $EnvFile -Key 'POSTGRES_PASSWORD')
docker compose up --pull always
if ($LASTEXITCODE -ne 0) {
exit $LASTEXITCODE

View file

@ -23,7 +23,11 @@ generate_secret() {
return
fi
fail "Could not generate OSS_JWT_SECRET. Install python3 or openssl, or set OSS_JWT_SECRET manually in .env."
fail "Could not generate a secret. Install python3 or openssl, or set secrets manually in .env."
}
generate_minio_root_user() {
printf 'dograh%s\n' "$(generate_secret | cut -c1-12)"
}
dotenv_value() {
@ -74,8 +78,70 @@ set_dotenv_value() {
fi
}
postgres_volume_name() {
local volume_name=""
local project_name=""
if command -v python3 >/dev/null 2>&1; then
volume_name="$(
docker compose config --format json 2>/dev/null \
| python3 -c 'import json, sys; print(json.load(sys.stdin).get("volumes", {}).get("postgres_data", {}).get("name", ""))' 2>/dev/null \
|| true
)"
if [[ -n "$volume_name" ]]; then
printf '%s\n' "$volume_name"
return
fi
fi
project_name="${COMPOSE_PROJECT_NAME:-$(basename "$PWD")}"
project_name="$(printf '%s' "$project_name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9_-]//g')"
printf '%s_postgres_data\n' "$project_name"
}
sync_postgres_password() {
local postgres_password=$1
local volume_name=""
local postgres_ready=false
[[ -n "$postgres_password" ]] || return
volume_name="$(postgres_volume_name)"
if ! docker volume inspect "$volume_name" >/dev/null 2>&1; then
return
fi
echo "Existing Postgres volume detected; syncing postgres password from $ENV_FILE."
REGISTRY="$REGISTRY" ENABLE_TELEMETRY="$ENABLE_TELEMETRY" docker compose up -d postgres
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20; do
if docker compose exec -T postgres pg_isready -U postgres >/dev/null 2>&1; then
postgres_ready=true
break
fi
sleep 1
done
if [[ "$postgres_ready" != "true" ]]; then
fail "Postgres did not become ready while syncing POSTGRES_PASSWORD."
fi
printf '%s\n' "ALTER USER postgres WITH PASSWORD :'dograh_password';" \
| docker compose exec -T postgres psql \
-U postgres \
-d postgres \
-v ON_ERROR_STOP=1 \
-v "dograh_password=$postgres_password" >/dev/null
echo "Postgres password synced."
}
[[ -f docker-compose.yaml ]] || fail "docker-compose.yaml not found. Download it first, then re-run this script."
env_file_existed=false
if [[ -f "$ENV_FILE" ]]; then
env_file_existed=true
fi
existing_secret="$(dotenv_value OSS_JWT_SECRET || true)"
if [[ -z "$existing_secret" ]]; then
set_dotenv_value OSS_JWT_SECRET "$(generate_secret)"
@ -84,6 +150,54 @@ else
echo "OSS_JWT_SECRET is already set in $ENV_FILE."
fi
existing_postgres_password="$(dotenv_value POSTGRES_PASSWORD || true)"
if [[ -z "$existing_postgres_password" ]]; then
if [[ "$env_file_existed" == "false" ]]; then
set_dotenv_value POSTGRES_PASSWORD "$(generate_secret)"
echo "Created POSTGRES_PASSWORD in $ENV_FILE."
else
echo "POSTGRES_PASSWORD is not set in $ENV_FILE; keeping the docker-compose fallback for existing local data volumes."
fi
else
echo "POSTGRES_PASSWORD is already set in $ENV_FILE."
fi
existing_redis_password="$(dotenv_value REDIS_PASSWORD || true)"
if [[ -z "$existing_redis_password" ]]; then
set_dotenv_value REDIS_PASSWORD "$(generate_secret)"
echo "Created REDIS_PASSWORD in $ENV_FILE."
else
echo "REDIS_PASSWORD is already set in $ENV_FILE."
fi
existing_minio_root_user="$(dotenv_value MINIO_ROOT_USER || true)"
if [[ -z "$existing_minio_root_user" ]]; then
existing_minio_access_key="$(dotenv_value MINIO_ACCESS_KEY || true)"
if [[ -n "$existing_minio_access_key" ]]; then
set_dotenv_value MINIO_ROOT_USER "$existing_minio_access_key"
echo "Created MINIO_ROOT_USER in $ENV_FILE from existing MINIO_ACCESS_KEY."
else
set_dotenv_value MINIO_ROOT_USER "$(generate_minio_root_user)"
echo "Created MINIO_ROOT_USER in $ENV_FILE."
fi
else
echo "MINIO_ROOT_USER is already set in $ENV_FILE."
fi
existing_minio_root_password="$(dotenv_value MINIO_ROOT_PASSWORD || true)"
if [[ -z "$existing_minio_root_password" ]]; then
existing_minio_secret_key="$(dotenv_value MINIO_SECRET_KEY || true)"
if [[ -n "$existing_minio_secret_key" ]]; then
set_dotenv_value MINIO_ROOT_PASSWORD "$existing_minio_secret_key"
echo "Created MINIO_ROOT_PASSWORD in $ENV_FILE from existing MINIO_SECRET_KEY."
else
set_dotenv_value MINIO_ROOT_PASSWORD "$(generate_secret)"
echo "Created MINIO_ROOT_PASSWORD in $ENV_FILE."
fi
else
echo "MINIO_ROOT_PASSWORD is already set in $ENV_FILE."
fi
echo ""
echo "Docker registry: $REGISTRY"
echo "Telemetry enabled: $ENABLE_TELEMETRY"
@ -105,4 +219,7 @@ case "$answer" in
;;
esac
postgres_password="$(dotenv_value POSTGRES_PASSWORD || true)"
sync_postgres_password "$postgres_password"
REGISTRY="$REGISTRY" ENABLE_TELEMETRY="$ENABLE_TELEMETRY" docker compose up --pull always

View file

@ -31,6 +31,26 @@ trap cleanup EXIT
REPO="dograh-hq/dograh"
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
generate_secret() {
if command -v python3 >/dev/null 2>&1 && python3 -c 'import secrets; print(secrets.token_hex(32))'; then
return
fi
if command -v openssl >/dev/null 2>&1 && openssl rand -hex 32; then
return
fi
if [[ -r /dev/urandom ]] && command -v od >/dev/null 2>&1 && command -v tr >/dev/null 2>&1 && od -An -N32 -tx1 /dev/urandom | tr -d ' \n'; then
return
fi
dograh_fail "Could not generate a secret. Install python3 or openssl, or set missing secrets manually in .env."
}
generate_minio_root_user() {
printf 'dograh%s\n' "$(generate_secret | cut -c1-12)"
}
echo -e "${BLUE}"
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ Dograh Remote Update ║"
@ -96,7 +116,7 @@ if [[ -z "$TARGET_VERSION" ]]; then
if [[ -t 0 ]]; then
echo ""
echo -e "${YELLOW}Target version. Accepted forms: bare semver (1.28.0), v-prefixed (v1.28.0),${NC}"
echo -e "${YELLOW}full git tag (dograh-v1.28.0), or 'main' for bleeding edge.${NC}"
echo -e "${YELLOW}full git tag (dograh-v1.28.0), or 'main' for the latest deployment files.${NC}"
read -p "[$LATEST_TAG]: " TARGET_VERSION
TARGET_VERSION="${TARGET_VERSION:-$LATEST_TAG}"
else
@ -219,6 +239,28 @@ fi
echo -e "${BLUE}[3/3] Synchronizing environment and validating init-based remote config...${NC}"
dograh_set_env_key .env FASTAPI_WORKERS "$FASTAPI_WORKERS"
if [[ -z "${REDIS_PASSWORD:-}" ]]; then
dograh_set_env_key .env REDIS_PASSWORD "$(generate_secret)"
dograh_success "✓ REDIS_PASSWORD created in .env"
fi
if [[ -z "${MINIO_ROOT_USER:-}" ]]; then
if [[ -n "${MINIO_ACCESS_KEY:-}" ]]; then
dograh_set_env_key .env MINIO_ROOT_USER "$MINIO_ACCESS_KEY"
dograh_success "✓ MINIO_ROOT_USER created in .env from existing MINIO_ACCESS_KEY"
else
dograh_set_env_key .env MINIO_ROOT_USER "$(generate_minio_root_user)"
dograh_success "✓ MINIO_ROOT_USER created in .env"
fi
fi
if [[ -z "${MINIO_ROOT_PASSWORD:-}" ]]; then
if [[ -n "${MINIO_SECRET_KEY:-}" ]]; then
dograh_set_env_key .env MINIO_ROOT_PASSWORD "$MINIO_SECRET_KEY"
dograh_success "✓ MINIO_ROOT_PASSWORD created in .env from existing MINIO_SECRET_KEY"
else
dograh_set_env_key .env MINIO_ROOT_PASSWORD "$(generate_secret)"
dograh_success "✓ MINIO_ROOT_PASSWORD created in .env"
fi
fi
dograh_prepare_remote_install "$(pwd)"
docker compose config -q
dograh_success "✓ Remote init configuration validated"

View file

@ -1,6 +1,6 @@
# generated by datamodel-codegen:
# filename: dograh-openapi-XXXXXX.json.6F33jkClt9
# timestamp: 2026-06-19T12:41:10+00:00
# filename: dograh-openapi-XXXXXX.json.rRr9IUrKFk
# timestamp: 2026-06-23T13:02:10+00:00
from __future__ import annotations

View file

@ -138,16 +138,27 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
const interruptWarningShownRef = useRef(false);
const getWebSocketUrl = useCallback(async () => {
let baseUrl = client.getConfig().baseUrl || 'http://127.0.0.1:8000';
// An explicitly configured backend URL always wins. When set, honor it
// verbatim and skip the localhost autodetect below — the operator has
// told us exactly where the API lives. Read the env var directly (not
// client.getConfig().baseUrl) so we can distinguish "explicitly set"
// from the client's window.location.origin fallback.
const configuredBackendUrl = process.env.NEXT_PUBLIC_BACKEND_URL;
if (isLocalhostUi()) {
// Local Docker exposes the API on localhost:8000 while the UI runs
// on localhost:3010. WebSocket upgrades cannot pass through the
// Next.js route-handler HTTP proxy, so local browser calls should
// connect to the API directly when that port is available. A
// Next.js rewrite/proxy for the upgrade was considered, but we
// keep the WebRTC signaling path direct so signaling and the API's
// ICE/WebRTC handling terminate at the same local endpoint.
let baseUrl: string;
if (configuredBackendUrl) {
baseUrl = configuredBackendUrl;
} else if (isLocalhostUi()) {
// No backend URL configured and the UI is on localhost: the client
// would otherwise fall back to window.location.origin (the UI port,
// e.g. 3010), which is wrong for the API. Local Docker exposes the
// API on localhost:8000. WebSocket upgrades cannot pass through the
// Next.js route-handler HTTP proxy, so connect to the API directly
// when that port is reachable. A Next.js rewrite/proxy for the
// upgrade was considered, but we keep the WebRTC signaling path
// direct so signaling and the API's ICE/WebRTC handling terminate
// at the same local endpoint.
const localhostApiReachable = await probeLocalhostApi();
if (!localhostApiReachable) {
@ -155,6 +166,9 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
}
baseUrl = LOCALHOST_API_BASE_URL;
} else {
// Same-origin deployment: UI and API share an origin.
baseUrl = client.getConfig().baseUrl || 'http://127.0.0.1:8000';
}
// Convert HTTP to WS protocol

File diff suppressed because one or more lines are too long

View file

@ -6294,6 +6294,26 @@ export type VobizConfigurationResponse = {
from_numbers: Array<string>;
};
/**
* VoiceFacets
*
* Distinct selector values across a provider's full voice catalog.
*/
export type VoiceFacets = {
/**
* Genders
*/
genders?: Array<string>;
/**
* Accents
*/
accents?: Array<string>;
/**
* Languages
*/
languages?: Array<string>;
};
/**
* VoiceInfo
*/
@ -6340,6 +6360,7 @@ export type VoicesResponse = {
* Voices
*/
voices: Array<VoiceInfo>;
facets?: VoiceFacets | null;
};
/**
@ -9208,6 +9229,18 @@ export type GetVoicesApiV1UserConfigurationsVoicesProviderGetData = {
* Language
*/
language?: string | null;
/**
* Q
*/
q?: string | null;
/**
* Gender
*/
gender?: string | null;
/**
* Accent
*/
accent?: string | null;
};
url: '/api/v1/user/configurations/voices/{provider}';
};

View file

@ -12,11 +12,11 @@ import {
} from "@/components/ServiceConfigurationForm";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { VoiceSelectorModal } from "@/components/VoiceSelectorModal";
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
type ModelMode = "realtime" | "dograh" | "byok";
@ -278,7 +278,6 @@ export function AIModelConfigurationV2Editor({
const [realtimeInitialConfig, setRealtimeInitialConfig] = useState<Record<string, unknown> | null>(null);
const [pipelineInitialConfig, setPipelineInitialConfig] = useState<Record<string, unknown> | null>(null);
const [isSavingDograh, setIsSavingDograh] = useState(false);
const [isCustomVoice, setIsCustomVoice] = useState(false);
const [error, setError] = useState<string | null>(null);
const allowCustomVoice = defaults.dograh.allow_custom_input ?? false;
@ -290,7 +289,6 @@ export function AIModelConfigurationV2Editor({
setMode(preferredMode(rawConfiguration, rawEffectiveConfiguration));
const nextDograh = buildDograhState(defaults, rawConfiguration, rawEffectiveConfiguration);
setDograh(nextDograh);
setIsCustomVoice(allowCustomVoice && !defaults.dograh.voices.includes(nextDograh.voice));
setRealtimeInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration, true));
setPipelineInitialConfig(getByokInitialConfig(rawConfiguration, rawEffectiveConfiguration, false));
}, [configuration, defaults, effectiveConfiguration, allowCustomVoice]);
@ -390,46 +388,30 @@ export function AIModelConfigurationV2Editor({
<Card>
<CardContent className="pt-6">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="space-y-2 sm:col-span-2">
<Label>Voice</Label>
{isCustomVoice ? (
<Input
placeholder="Enter voice"
value={dograh.voice}
onChange={(event) => setDograh({ ...dograh, voice: event.target.value })}
/>
) : (
<Select value={dograh.voice} onValueChange={(voice) => setDograh({ ...dograh, voice })}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select voice" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.voices.map((voice) => (
<SelectItem key={voice} value={voice}>
{voice}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{allowCustomVoice && (
<div className="flex items-center space-x-2">
<Checkbox
id="dograh-custom-voice"
checked={isCustomVoice}
onCheckedChange={(checked) => {
const custom = checked as boolean;
setIsCustomVoice(custom);
if (!custom) {
setDograh({ ...dograh, voice: defaults.dograh.defaults.voice });
}
}}
/>
<Label htmlFor="dograh-custom-voice" className="text-sm font-normal cursor-pointer">
Enter Custom Value
</Label>
</div>
)}
<VoiceSelectorModal
provider="dograh"
value={dograh.voice}
onChange={(voice) => setDograh({ ...dograh, voice })}
allowManualInput={allowCustomVoice}
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>Language</Label>
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.languages.map((language) => (
<SelectItem key={language} value={language}>
{LANGUAGE_DISPLAY_NAMES[language] || language}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
@ -451,23 +433,7 @@ export function AIModelConfigurationV2Editor({
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>Language</Label>
<Select value={dograh.language} onValueChange={(language) => setDograh({ ...dograh, language })}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{defaults.dograh.languages.map((language) => (
<SelectItem key={language} value={language}>
{LANGUAGE_DISPLAY_NAMES[language] || language}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 sm:col-span-2">
<div className="space-y-2">
<Label htmlFor="dograh-api-key">API Key</Label>
<div className="relative">
<KeyRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />

View file

@ -10,11 +10,13 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
// Providers that have MPS voice endpoints
type TTSProviderWithVoices = "elevenlabs" | "deepgram" | "sarvam" | "cartesia" | "dograh" | "rime";
const MPS_VOICE_PROVIDERS: TTSProviderWithVoices[] = ["elevenlabs", "deepgram", "sarvam", "cartesia", "dograh", "rime"];
const ALL_FILTER_VALUE = "__all__";
interface VoiceSelectorProps {
provider: string;
@ -22,6 +24,8 @@ interface VoiceSelectorProps {
onChange: (voiceId: string) => void;
model?: string;
language?: string;
showFilters?: boolean;
allowManualInput?: boolean;
className?: string;
}
@ -31,10 +35,15 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
onChange,
model,
language,
showFilters = false,
allowManualInput = true,
className,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [genderFilter, setGenderFilter] = useState(ALL_FILTER_VALUE);
const [languageFilter, setLanguageFilter] = useState(ALL_FILTER_VALUE);
const [accentFilter, setAccentFilter] = useState(ALL_FILTER_VALUE);
const [isManualInput, setIsManualInput] = useState(false);
const [manualVoiceId, setManualVoiceId] = useState(value || "");
const [voices, setVoices] = useState<VoiceInfo[]>([]);
@ -102,13 +111,15 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
useEffect(() => {
if (value && voices.length > 0) {
const voiceExists = voices.some((v) => v.voice_id === value);
if (!voiceExists) {
if (!voiceExists && allowManualInput) {
// If the value doesn't exist in the list, switch to manual input mode
setIsManualInput(true);
setManualVoiceId(value);
} else if (voiceExists) {
setIsManualInput(false);
}
}
}, [value, voices]);
}, [value, voices, allowManualInput]);
// Cleanup audio on unmount or when popover closes
useEffect(() => {
@ -131,7 +142,7 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
const filteredVoices = voices.filter((voice) => {
const searchLower = searchTerm.toLowerCase();
return (
const matchesSearch = (
voice.name.toLowerCase().includes(searchLower) ||
voice.voice_id.toLowerCase().includes(searchLower) ||
(voice.description?.toLowerCase() || "").includes(searchLower) ||
@ -139,8 +150,23 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
(voice.gender?.toLowerCase() || "").includes(searchLower) ||
(voice.language?.toLowerCase() || "").includes(searchLower)
);
if (!matchesSearch) return false;
if (genderFilter !== ALL_FILTER_VALUE && (voice.gender || "").toLowerCase() !== genderFilter) return false;
if (languageFilter !== ALL_FILTER_VALUE && (voice.language || "").toLowerCase() !== languageFilter) return false;
if (accentFilter !== ALL_FILTER_VALUE && (voice.accent || "").toLowerCase() !== accentFilter) return false;
return true;
});
const genderOptions = Array.from(
new Set(voices.map((voice) => voice.gender?.toLowerCase()).filter(Boolean) as string[]),
).sort();
const languageOptions = Array.from(
new Set(voices.map((voice) => voice.language?.toLowerCase()).filter(Boolean) as string[]),
).sort();
const accentOptions = Array.from(
new Set(voices.map((voice) => voice.accent?.toLowerCase()).filter(Boolean) as string[]),
).sort();
const handleSelectVoice = (voiceId: string) => {
onChange(voiceId);
setIsOpen(false);
@ -148,6 +174,7 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
};
const handleManualInputToggle = (checked: boolean) => {
if (!allowManualInput) return;
setIsManualInput(checked);
if (checked) {
setManualVoiceId(value || "");
@ -219,7 +246,7 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
);
}
if (isManualInput) {
if (isManualInput && allowManualInput) {
return (
<div className={cn("space-y-2", className)}>
<Input
@ -281,6 +308,52 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
/>
</div>
{showFilters && (
<div className="grid gap-2 sm:grid-cols-3">
<Select value={genderFilter} onValueChange={setGenderFilter}>
<SelectTrigger className="h-8">
<SelectValue placeholder="Gender" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_FILTER_VALUE}>All genders</SelectItem>
{genderOptions.map((gender) => (
<SelectItem key={gender} value={gender} className="capitalize">
{gender}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={languageFilter} onValueChange={setLanguageFilter}>
<SelectTrigger className="h-8">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_FILTER_VALUE}>All languages</SelectItem>
{languageOptions.map((voiceLanguage) => (
<SelectItem key={voiceLanguage} value={voiceLanguage} className="uppercase">
{voiceLanguage}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={accentFilter} onValueChange={setAccentFilter}>
<SelectTrigger className="h-8">
<SelectValue placeholder="Accent" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_FILTER_VALUE}>All accents</SelectItem>
{accentOptions.map((accent) => (
<SelectItem key={accent} value={accent} className="uppercase">
{accent}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="max-h-[300px] overflow-auto space-y-1">
{error ? (
<p className="text-sm text-red-500 text-center py-4">
@ -358,26 +431,30 @@ export const VoiceSelector: React.FC<VoiceSelectorProps> = ({
</div>
<div className="pt-2 border-t flex items-center justify-between">
<div className="flex items-center space-x-2">
<Checkbox
id="manual-voice-input-popup"
checked={isManualInput}
onCheckedChange={(checked) => {
handleManualInputToggle(checked as boolean);
if (checked) {
setIsOpen(false);
}
}}
/>
<Label
htmlFor="manual-voice-input-popup"
className="text-sm font-normal cursor-pointer"
>
Add Voice ID Manually
</Label>
</div>
{allowManualInput ? (
<div className="flex items-center space-x-2">
<Checkbox
id="manual-voice-input-popup"
checked={isManualInput}
onCheckedChange={(checked) => {
handleManualInputToggle(checked as boolean);
if (checked) {
setIsOpen(false);
}
}}
/>
<Label
htmlFor="manual-voice-input-popup"
className="text-sm font-normal cursor-pointer"
>
Add Voice ID Manually
</Label>
</div>
) : (
<span />
)}
<p className="text-xs text-muted-foreground">
{voices.length} voices available
{filteredVoices.length} of {voices.length} voices
</p>
</div>
</div>

View file

@ -0,0 +1,451 @@
"use client";
import { Check, ChevronDown, Loader2, Pencil, Play, Square } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getVoicesApiV1UserConfigurationsVoicesProviderGet } from "@/client/sdk.gen";
import { VoiceInfo } from "@/client/types.gen";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ACCENT_DISPLAY_NAMES } from "@/constants/accents";
import { LANGUAGE_DISPLAY_NAMES } from "@/constants/languages";
import { cn } from "@/lib/utils";
const ALL_FILTER_VALUE = "__all__";
// Defaults so the modal opens on a focused set instead of the full catalog.
const DEFAULT_GENDER = "female";
const DEFAULT_ACCENT = "us"; // American
const DEFAULT_LANGUAGE = "en";
const SEARCH_DEBOUNCE_MS = 300;
interface Facets {
genders: string[];
accents: string[];
languages: string[];
}
const EMPTY_FACETS: Facets = { genders: [], accents: [], languages: [] };
interface VoiceSelectorModalProps {
provider: string;
value: string;
onChange: (voiceId: string) => void;
/** Optional model passed through to the voice catalog query. */
model?: string;
/** Allow typing a raw voice ID for voices outside the catalog. */
allowManualInput?: boolean;
className?: string;
}
const capitalize = (value: string) => value.charAt(0).toUpperCase() + value.slice(1);
const accentLabel = (code?: string | null) =>
code ? ACCENT_DISPLAY_NAMES[code.toLowerCase()] || capitalize(code) : "";
const languageLabel = (code?: string | null) =>
code ? LANGUAGE_DISPLAY_NAMES[code] || code.toUpperCase() : "";
const genderLabel = (gender?: string | null) => (gender ? capitalize(gender) : "");
/** Build the "Accent · Gender · Language" trait line shown under a voice name. */
function voiceTraits(voice: VoiceInfo): string {
return [accentLabel(voice.accent), genderLabel(voice.gender), languageLabel(voice.language)]
.filter(Boolean)
.join(" · ");
}
/** Ensure the active filter value is always an option so the Select can render it. */
function withSelected(options: string[], selected: string): string[] {
if (selected === ALL_FILTER_VALUE || options.includes(selected)) return options;
return [selected, ...options];
}
export const VoiceSelectorModal: React.FC<VoiceSelectorModalProps> = ({
provider,
value,
onChange,
model,
allowManualInput = false,
className,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [voices, setVoices] = useState<VoiceInfo[]>([]);
const [facets, setFacets] = useState<Facets>(EMPTY_FACETS);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Filters drive a server-side query (we never fetch the whole catalog).
const [gender, setGender] = useState(DEFAULT_GENDER);
const [accent, setAccent] = useState(DEFAULT_ACCENT);
const [language, setLanguage] = useState(DEFAULT_LANGUAGE);
const [searchInput, setSearchInput] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
// Pending (in-modal) selection; only committed via "Use this voice".
const [pendingVoiceId, setPendingVoiceId] = useState(value);
const [selectedVoiceInfo, setSelectedVoiceInfo] = useState<VoiceInfo | null>(null);
const [manualMode, setManualMode] = useState(false);
const [manualVoiceId, setManualVoiceId] = useState("");
// Preview playback.
const [playingVoiceId, setPlayingVoiceId] = useState<string | null>(null);
const audioRef = useRef<HTMLAudioElement | null>(null);
const requestId = useRef(0);
const stopPreview = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
setPlayingVoiceId(null);
}, []);
// Debounce the search box so typing doesn't fire a request per keystroke.
useEffect(() => {
const timer = setTimeout(() => setDebouncedSearch(searchInput), SEARCH_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [searchInput]);
// Resolve the currently-selected voice (for the trigger label) without
// pulling the catalog: a targeted lookup by voice ID.
useEffect(() => {
if (!value) {
setSelectedVoiceInfo(null);
return;
}
let active = true;
(async () => {
const response = await getVoicesApiV1UserConfigurationsVoicesProviderGet({
path: { provider: provider as never },
query: { q: value },
});
if (!active) return;
const found = response.data?.voices?.find((voice) => voice.voice_id === value) ?? null;
setSelectedVoiceInfo(found);
})();
return () => {
active = false;
};
}, [value, provider]);
// Fetch the filtered voice list (server-side) whenever the modal is open
// and a filter changes. A request counter discards out-of-order responses.
useEffect(() => {
if (!isOpen || manualMode) return;
const id = ++requestId.current;
setIsLoading(true);
setError(null);
(async () => {
const query: Record<string, string> = {};
if (model) query.model = model;
if (gender !== ALL_FILTER_VALUE) query.gender = gender;
if (accent !== ALL_FILTER_VALUE) query.accent = accent;
if (language !== ALL_FILTER_VALUE) query.language = language;
const search = debouncedSearch.trim();
if (search) query.q = search;
const response = await getVoicesApiV1UserConfigurationsVoicesProviderGet({
path: { provider: provider as never },
query,
});
if (id !== requestId.current) return; // a newer request superseded this one
if (response.error) {
setError("Failed to load voices");
setVoices([]);
} else {
setVoices(response.data?.voices ?? []);
if (response.data?.facets) {
setFacets({
genders: response.data.facets.genders ?? [],
accents: response.data.facets.accents ?? [],
languages: response.data.facets.languages ?? [],
});
}
}
setIsLoading(false);
})();
}, [isOpen, manualMode, provider, model, gender, accent, language, debouncedSearch]);
// Stop any preview when the modal closes / unmounts.
useEffect(() => {
if (!isOpen) stopPreview();
return () => stopPreview();
}, [isOpen, stopPreview]);
// Facets arrive sorted by raw code; present them sorted by display label so
// the dropdowns read alphabetically (e.g. "American" near the top, not "us").
const toSortedOptions = (codes: string[], selected: string, label: (code: string) => string) =>
withSelected(codes, selected)
.map((code) => ({ value: code, label: label(code) }))
.sort((a, b) => a.label.localeCompare(b.label));
const genderOptions = useMemo(
() => toSortedOptions(facets.genders, gender, genderLabel),
[facets.genders, gender],
);
const accentOptions = useMemo(
() => toSortedOptions(facets.accents, accent, accentLabel),
[facets.accents, accent],
);
const languageOptions = useMemo(
() => toSortedOptions(facets.languages, language, languageLabel),
[facets.languages, language],
);
const openModal = () => {
setGender(DEFAULT_GENDER);
setAccent(DEFAULT_ACCENT);
setLanguage(DEFAULT_LANGUAGE);
setSearchInput("");
setDebouncedSearch("");
setManualMode(false);
setManualVoiceId(value);
setPendingVoiceId(value);
setIsOpen(true);
};
const playPreview = (voice: VoiceInfo) => {
if (playingVoiceId === voice.voice_id) {
stopPreview();
return;
}
stopPreview();
if (!voice.preview_url) return;
const audio = new Audio(voice.preview_url);
audioRef.current = audio;
setPlayingVoiceId(voice.voice_id);
const clear = () => {
if (audioRef.current === audio) audioRef.current = null;
setPlayingVoiceId((current) => (current === voice.voice_id ? null : current));
};
audio.onended = clear;
audio.onerror = clear;
audio.play().catch(clear);
};
const commitSelection = () => {
if (manualMode) {
const next = manualVoiceId.trim();
if (next) onChange(next);
} else if (pendingVoiceId) {
onChange(pendingVoiceId);
const chosen = voices.find((voice) => voice.voice_id === pendingVoiceId);
if (chosen) setSelectedVoiceInfo(chosen);
}
setIsOpen(false);
};
const triggerLabel = selectedVoiceInfo?.name || value || "Select a voice";
const triggerTraits = selectedVoiceInfo ? voiceTraits(selectedVoiceInfo) : "";
return (
<div className={cn("space-y-2", className)}>
<Button
type="button"
variant="outline"
className={cn("w-full justify-between", !value && "text-muted-foreground")}
onClick={openModal}
>
<span className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">{triggerLabel}</span>
{triggerTraits && (
<span className="truncate text-xs text-muted-foreground">{triggerTraits}</span>
)}
</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl">
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>Select Voice</DialogTitle>
</DialogHeader>
{/* Filter row: Gender · Accent · Language · Search */}
<div className="flex flex-wrap items-center gap-2 border-b px-6 py-3">
<Select value={gender} onValueChange={setGender} disabled={manualMode}>
<SelectTrigger className="h-9 w-[130px]">
<SelectValue placeholder="Gender" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_FILTER_VALUE}>All genders</SelectItem>
{genderOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={accent} onValueChange={setAccent} disabled={manualMode}>
<SelectTrigger className="h-9 w-[140px]">
<SelectValue placeholder="Accent" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_FILTER_VALUE}>All accents</SelectItem>
{accentOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={language} onValueChange={setLanguage} disabled={manualMode}>
<SelectTrigger className="h-9 w-[150px]">
<SelectValue placeholder="Language" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_FILTER_VALUE}>All languages</SelectItem>
{languageOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder="Search voices..."
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
className="h-9 min-w-[160px] flex-1"
disabled={manualMode}
/>
</div>
{/* Body */}
<div className="min-h-[260px] flex-1 overflow-auto px-6 py-4">
{manualMode ? (
<div className="space-y-2">
<Label htmlFor="manual-voice-id">Custom voice ID</Label>
<Input
id="manual-voice-id"
placeholder="Enter voice ID"
value={manualVoiceId}
onChange={(event) => setManualVoiceId(event.target.value)}
autoFocus
/>
<p className="text-xs text-muted-foreground">
Use a voice ID that isn&apos;t in the catalog above.
</p>
</div>
) : error ? (
<p className="py-10 text-center text-sm text-destructive">{error}</p>
) : isLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : voices.length === 0 ? (
<p className="py-10 text-center text-sm text-muted-foreground">
No voices match these filters
</p>
) : (
<div className="grid gap-2 sm:grid-cols-2">
{voices.map((voice) => {
const isSelected = pendingVoiceId === voice.voice_id;
const isPlaying = playingVoiceId === voice.voice_id;
return (
<button
type="button"
key={voice.voice_id}
onClick={() => setPendingVoiceId(voice.voice_id)}
className={cn(
"flex items-center gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-accent",
isSelected ? "border-primary ring-1 ring-primary" : "border-border",
)}
>
<span
role="button"
tabIndex={voice.preview_url ? 0 : -1}
aria-label={isPlaying ? "Stop preview" : "Play preview"}
onClick={(event) => {
event.stopPropagation();
playPreview(voice);
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
playPreview(voice);
}
}}
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
voice.preview_url
? "bg-primary/10 text-primary hover:bg-primary/20"
: "bg-muted text-muted-foreground",
)}
>
{isPlaying ? (
<Square className="h-4 w-4 fill-current" />
) : (
<Play className="h-4 w-4 fill-current" />
)}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{voice.name}</span>
{isSelected && <Check className="h-4 w-4 shrink-0 text-primary" />}
</span>
{voiceTraits(voice) && (
<span className="truncate text-xs text-muted-foreground">
{voiceTraits(voice)}
</span>
)}
<span className="truncate text-[11px] text-muted-foreground/70">
ID: {voice.voice_id}
</span>
</span>
</button>
);
})}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between gap-3 border-t px-6 py-3">
{allowManualInput ? (
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground"
onClick={() => setManualMode((prev) => !prev)}
>
<Pencil className="mr-2 h-4 w-4" />
{manualMode ? "Browse catalog" : "Custom voice ID"}
</Button>
) : (
<span className="text-xs text-muted-foreground">
{!manualMode && !isLoading && !error ? `${voices.length} voices` : ""}
</span>
)}
<div className="flex items-center gap-2">
<Button type="button" variant="outline" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button
type="button"
onClick={commitSelection}
disabled={manualMode ? !manualVoiceId.trim() : !pendingVoiceId}
>
Use this voice
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
};

View file

@ -0,0 +1,56 @@
// Display names for accent codes returned by the voice catalog.
//
// The catalog derives accent from a voice's locale country (e.g. "en-US" -> "us"),
// so the stored/filter value is an ISO 3166-1 alpha-2 country code. These are the
// human-readable accent labels shown in the UI; the underlying code stays the
// filter value. Unknown codes fall back to a capitalized form at the call site.
export const ACCENT_DISPLAY_NAMES: Record<string, string> = {
us: "American",
gb: "British",
au: "Australian",
ca: "Canadian",
ie: "Irish",
nz: "New Zealand",
za: "South African",
in: "Indian",
bd: "Bangladeshi",
sg: "Singaporean",
my: "Malaysian",
ph: "Filipino",
id: "Indonesian",
vn: "Vietnamese",
th: "Thai",
cn: "Chinese",
jp: "Japanese",
kr: "Korean",
fr: "French",
de: "German",
ch: "Swiss",
nl: "Dutch",
it: "Italian",
es: "Spanish",
mx: "Mexican",
co: "Colombian",
bo: "Bolivian",
br: "Brazilian",
pt: "Portuguese",
ru: "Russian",
ua: "Ukrainian",
pl: "Polish",
cz: "Czech",
sk: "Slovak",
hu: "Hungarian",
ro: "Romanian",
bg: "Bulgarian",
hr: "Croatian",
gr: "Greek",
ge: "Georgian",
md: "Moldovan",
se: "Swedish",
no: "Norwegian",
dk: "Danish",
fi: "Finnish",
tr: "Turkish",
il: "Israeli",
sa: "Saudi",
};