mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-25 12:01:04 +02:00
Merge remote-tracking branch 'origin/main' into feat/external-pbx
This commit is contained in:
commit
688773d3b3
28 changed files with 1558 additions and 116 deletions
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
65
deploy/hostinger/.env.example
Normal file
65
deploy/hostinger/.env.example
Normal 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
|
||||
59
deploy/hostinger/README.md
Normal file
59
deploy/hostinger/README.md
Normal 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.
|
||||
58
deploy/hostinger/docker-compose.traefik.yaml
Normal file
58
deploy/hostinger/docker-compose.traefik.yaml
Normal 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}
|
||||
281
deploy/hostinger/docker-compose.yaml
Normal file
281
deploy/hostinger/docker-compose.yaml
Normal 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}
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 265 KiB |
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
''
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}';
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
451
ui/src/components/VoiceSelectorModal.tsx
Normal file
451
ui/src/components/VoiceSelectorModal.tsx
Normal 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'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>
|
||||
);
|
||||
};
|
||||
56
ui/src/constants/accents.ts
Normal file
56
ui/src/constants/accents.ts
Normal 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",
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue