mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-09 03:39:37 -10:00
429 lines
16 KiB
PowerShell
429 lines
16 KiB
PowerShell
# Start voice-agent + Cloudflare tunnel for provider webhook URL.
|
|
# Usage: .\start_dev.ps1
|
|
# .\start_dev.ps1 -Restart # kill old app and reload code
|
|
|
|
param(
|
|
[int]$Port = 8001,
|
|
[switch]$Restart
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
|
|
Set-Location $Root
|
|
|
|
function Load-DotEnv {
|
|
$envFile = Join-Path $Root ".env"
|
|
if (-not (Test-Path $envFile)) { return }
|
|
Get-Content $envFile | ForEach-Object {
|
|
$line = $_.Trim()
|
|
if (-not $line -or $line.StartsWith("#") -or $line -notmatch "=") { return }
|
|
$key, $value = $line -split "=", 2
|
|
$key = $key.Trim()
|
|
if ($key -and -not [string]::IsNullOrWhiteSpace($key)) {
|
|
Set-Item -Path "Env:$key" -Value $value.Trim()
|
|
}
|
|
}
|
|
}
|
|
|
|
function Test-AppHttpReady([int]$p) {
|
|
foreach ($path in @("/health/live", "/health", "/api/voice/config")) {
|
|
try {
|
|
$r = Invoke-WebRequest -Uri "http://127.0.0.1:$p$path" -UseBasicParsing -TimeoutSec 15
|
|
if ($r.StatusCode -eq 200) { return $true }
|
|
} catch { continue }
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Test-TcpPortListening([int]$p) {
|
|
try {
|
|
$conn = Get-NetTCPConnection -LocalPort $p -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
|
|
if ($conn) { return $true }
|
|
} catch { }
|
|
try {
|
|
$client = New-Object System.Net.Sockets.TcpClient
|
|
$async = $client.BeginConnect("127.0.0.1", $p, $null, $null)
|
|
$ok = $async.AsyncWaitHandle.WaitOne(500)
|
|
if ($ok -and $client.Connected) {
|
|
$client.Close()
|
|
return $true
|
|
}
|
|
$client.Close()
|
|
} catch { }
|
|
return $false
|
|
}
|
|
|
|
function Wait-PortReleased([int]$p, [int]$maxSec = 20) {
|
|
$deadline = (Get-Date).AddSeconds($maxSec)
|
|
while ((Get-Date) -lt $deadline) {
|
|
if (-not (Test-TcpPortListening $p)) { return $true }
|
|
Start-Sleep -Seconds 1
|
|
}
|
|
return -not (Test-TcpPortListening $p)
|
|
}
|
|
|
|
function Stop-PortProcess([int]$p) {
|
|
try {
|
|
$conns = Get-NetTCPConnection -LocalPort $p -State Listen -ErrorAction SilentlyContinue
|
|
foreach ($conn in $conns) {
|
|
if ($conn.OwningProcess) {
|
|
Stop-Process -Id $conn.OwningProcess -Force -ErrorAction SilentlyContinue
|
|
}
|
|
}
|
|
if ($conns) {
|
|
Start-Sleep -Seconds 2
|
|
Write-Host "Stopped previous process on port $p"
|
|
}
|
|
} catch { }
|
|
}
|
|
|
|
function Clear-LogFile([string]$path) {
|
|
if (-not (Test-Path $path)) { return }
|
|
try {
|
|
Remove-Item $path -Force -ErrorAction Stop
|
|
} catch {
|
|
try { Clear-Content $path -ErrorAction SilentlyContinue } catch { }
|
|
}
|
|
}
|
|
|
|
function Start-VoiceApp([int]$p) {
|
|
Write-Host "Starting voice-agent on port $p..."
|
|
Stop-PortProcess $p
|
|
$appLog = Join-Path $Root ".app.log"
|
|
$appErr = Join-Path $Root ".app.err"
|
|
Clear-LogFile $appLog
|
|
Clear-LogFile $appErr
|
|
Start-Process -FilePath "py" -ArgumentList "-3.13", "app.py" -WorkingDirectory $Root -WindowStyle Minimized `
|
|
-RedirectStandardOutput $appLog -RedirectStandardError $appErr
|
|
$deadline = (Get-Date).AddSeconds(120)
|
|
while ((Get-Date) -lt $deadline) {
|
|
if (Test-AppHttpReady $p) { break }
|
|
Start-Sleep -Seconds 3
|
|
}
|
|
if (-not (Test-AppHttpReady $p)) {
|
|
$errText = ""
|
|
if (Test-Path $appErr) { $errText = Get-Content $appErr -Raw -ErrorAction SilentlyContinue }
|
|
if ($errText -match "Uvicorn running") {
|
|
Write-Host "Server process up — waiting for HTTP endpoints..."
|
|
Start-Sleep -Seconds 10
|
|
}
|
|
}
|
|
if (-not (Test-AppHttpReady $p)) {
|
|
if (Test-Path $appErr) {
|
|
Write-Host "App startup log:" -ForegroundColor Red
|
|
Get-Content $appErr -Tail 20 | ForEach-Object { Write-Host $_ }
|
|
}
|
|
throw "App did not become ready on http://127.0.0.1:$p"
|
|
}
|
|
Write-Host "App ready: http://127.0.0.1:$p"
|
|
}
|
|
|
|
function Get-CloudflaredPidFile([string]$suffix) {
|
|
return Join-Path $Root ".cloudflared$suffix.pid"
|
|
}
|
|
|
|
function Stop-CloudflaredForSuffix([string]$suffix) {
|
|
$pidFile = Get-CloudflaredPidFile $suffix
|
|
try {
|
|
if (Test-Path $pidFile) {
|
|
$procId = [int](Get-Content $pidFile -Raw).Trim()
|
|
if ($procId) {
|
|
Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue
|
|
}
|
|
Remove-Item $pidFile -Force -ErrorAction SilentlyContinue
|
|
}
|
|
Start-Sleep -Seconds 1
|
|
} catch { }
|
|
}
|
|
|
|
function Stop-CloudflaredTunnel {
|
|
Stop-CloudflaredForSuffix ""
|
|
}
|
|
|
|
function Test-TunnelUrl([string]$baseUrl, [int]$retries = 2) {
|
|
if (-not $baseUrl) { return $false }
|
|
for ($i = 0; $i -lt $retries; $i++) {
|
|
try {
|
|
$null = Invoke-WebRequest -Uri "$baseUrl/api/voice/config" -UseBasicParsing -TimeoutSec 8
|
|
return $true
|
|
} catch {
|
|
if ($i -lt ($retries - 1)) { Start-Sleep -Seconds 2 }
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Test-SpeechTunnelUrl([string]$baseUrl, [int]$retries = 12) {
|
|
if (-not $baseUrl) { return $false }
|
|
$paths = @("", "/ws")
|
|
for ($i = 0; $i -lt $retries; $i++) {
|
|
foreach ($path in $paths) {
|
|
$uri = if ($path) { "$baseUrl$path" } else { $baseUrl }
|
|
try {
|
|
$null = Invoke-WebRequest -Uri $uri -UseBasicParsing -TimeoutSec 10
|
|
return $true
|
|
} catch {
|
|
$code = $null
|
|
try {
|
|
if ($_.Exception.Response) {
|
|
$code = [int]$_.Exception.Response.StatusCode
|
|
}
|
|
} catch { }
|
|
# Sidecar returns 404 on / — that still means the tunnel is up.
|
|
if ($code -and $code -ge 200 -and $code -lt 500) {
|
|
return $true
|
|
}
|
|
}
|
|
}
|
|
if ($i -lt ($retries - 1)) {
|
|
Write-Host " Speech tunnel not ready yet (attempt $($i + 1)/$retries)..."
|
|
Start-Sleep -Seconds 3
|
|
}
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Update-EnvValue([string]$key, [string]$value) {
|
|
$envFile = Join-Path $Root ".env"
|
|
if (-not (Test-Path $envFile)) { return }
|
|
$lines = Get-Content $envFile
|
|
$updated = $false
|
|
$newLines = foreach ($line in $lines) {
|
|
if ($line -match "^\s*$key=") {
|
|
$updated = $true
|
|
"$key=$value"
|
|
} else { $line }
|
|
}
|
|
if (-not $updated) { $newLines += "$key=$value" }
|
|
Set-Content -Path $envFile -Value ($newLines -join "`n")
|
|
Set-Item -Path "Env:$key" -Value $value
|
|
}
|
|
|
|
function Start-SpeechSidecar([int]$p = 8002, [switch]$Force) {
|
|
if ((Test-TcpPortListening $p) -and -not $Force) {
|
|
Write-Host "Speech Engine sidecar already listening on port $p"
|
|
return
|
|
}
|
|
Write-Host "Starting Speech Engine sidecar on port $p..."
|
|
Stop-PortProcess $p
|
|
if (-not (Wait-PortReleased $p)) {
|
|
Write-Host "Port $p still in use — forcing stop again..." -ForegroundColor Yellow
|
|
Stop-PortProcess $p
|
|
Start-Sleep -Seconds 3
|
|
}
|
|
$log = Join-Path $Root ".speech-sidecar.log"
|
|
$logErr = "${log}.err"
|
|
Clear-LogFile $log
|
|
Clear-LogFile $logErr
|
|
$env:PYTHONUNBUFFERED = "1"
|
|
Start-Process -FilePath "py" -ArgumentList "-3.13", "-u", "speech_engine_sidecar.py" `
|
|
-WorkingDirectory $Root -WindowStyle Minimized `
|
|
-RedirectStandardOutput $log -RedirectStandardError $logErr
|
|
$deadline = (Get-Date).AddSeconds(90)
|
|
while ((Get-Date) -lt $deadline) {
|
|
if (Test-TcpPortListening $p) {
|
|
Write-Host "Speech Engine sidecar ready on port $p"
|
|
return
|
|
}
|
|
if (Test-Path $log) {
|
|
$tail = Get-Content $log -Tail 1 -ErrorAction SilentlyContinue
|
|
if ($tail) { Write-Host " $tail" }
|
|
}
|
|
Start-Sleep -Seconds 2
|
|
}
|
|
Write-Host "Sidecar logs:" -ForegroundColor Red
|
|
if (Test-Path $log) {
|
|
Get-Content $log -Tail 15 -ErrorAction SilentlyContinue | ForEach-Object { Write-Host $_ }
|
|
}
|
|
if (Test-Path $logErr) {
|
|
Get-Content $logErr -Tail 15 -ErrorAction SilentlyContinue | ForEach-Object { Write-Host $_ }
|
|
}
|
|
throw "Speech Engine sidecar did not start on port $p (ElevenLabs API fetch can take up to 90s — check .speech-sidecar.log)"
|
|
}
|
|
|
|
function Start-CloudflaredTunnel([int]$p, [string]$logSuffix = "") {
|
|
Write-Host "Starting Cloudflare tunnel for port $p..."
|
|
$log = Join-Path $Root ".tunnel$logSuffix.log"
|
|
$logErr = "${log}.err"
|
|
Stop-CloudflaredForSuffix $logSuffix
|
|
Clear-LogFile $log
|
|
Clear-LogFile $logErr
|
|
$proc = Start-Process -FilePath "cloudflared" -ArgumentList "tunnel", "--url", "http://127.0.0.1:$p" `
|
|
-RedirectStandardOutput $log -RedirectStandardError $logErr -WindowStyle Minimized -PassThru
|
|
if ($proc -and $proc.Id) {
|
|
Set-Content -Path (Get-CloudflaredPidFile $logSuffix) -Value $proc.Id
|
|
}
|
|
$deadline = (Get-Date).AddSeconds(90)
|
|
$newUrl = $null
|
|
while ((Get-Date) -lt $deadline -and -not $newUrl) {
|
|
Start-Sleep -Seconds 2
|
|
foreach ($path in @($logErr, $log)) {
|
|
if (-not (Test-Path $path)) { continue }
|
|
$match = Select-String -Path $path -Pattern "https://[a-z0-9]+(-[a-z0-9]+)+\.trycloudflare\.com" | Select-Object -First 1
|
|
if ($match) {
|
|
$newUrl = $match.Matches[0].Value
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if (-not $newUrl) { throw "Could not read tunnel URL from $logErr" }
|
|
if ($logSuffix) {
|
|
Write-Host "Tunnel ready ($logSuffix): $newUrl"
|
|
} else {
|
|
Update-PublicUrl $newUrl
|
|
Write-Host "Tunnel ready: $newUrl"
|
|
}
|
|
return $newUrl
|
|
}
|
|
|
|
function Update-PublicUrl([string]$baseUrl) {
|
|
$envFile = Join-Path $Root ".env"
|
|
$content = Get-Content $envFile -Raw
|
|
if ($content -match "PUBLIC_API_BASE_URL=.*") {
|
|
$newContent = $content -replace "PUBLIC_API_BASE_URL=.*", "PUBLIC_API_BASE_URL=$baseUrl"
|
|
} else {
|
|
$newContent = $content.TrimEnd() + "`nPUBLIC_API_BASE_URL=$baseUrl`n"
|
|
}
|
|
Set-Content -Path $envFile -Value $newContent -NoNewline
|
|
$env:PUBLIC_API_BASE_URL = $baseUrl
|
|
Write-Host "Updated .env PUBLIC_API_BASE_URL=$baseUrl"
|
|
}
|
|
|
|
function Ensure-ServerSecret {
|
|
$envFile = Join-Path $Root ".env"
|
|
if (-not (Test-Path $envFile)) { return }
|
|
$lines = Get-Content $envFile
|
|
$secret = $env:VOICE_SERVER_URL_SECRET
|
|
foreach ($line in $lines) {
|
|
if ($line -match '^\s*VOICE_SERVER_URL_SECRET=(.+)$') {
|
|
$secret = $Matches[1].Trim()
|
|
break
|
|
}
|
|
}
|
|
if (-not $secret) {
|
|
$secret = [guid]::NewGuid().ToString()
|
|
$updated = $false
|
|
$newLines = foreach ($line in $lines) {
|
|
if ($line -match '^\s*VOICE_SERVER_URL_SECRET=') {
|
|
$updated = $true
|
|
"VOICE_SERVER_URL_SECRET=$secret"
|
|
} else { $line }
|
|
}
|
|
if (-not $updated) { $newLines += "VOICE_SERVER_URL_SECRET=$secret" }
|
|
Set-Content -Path $envFile -Value ($newLines -join "`n")
|
|
Write-Host "Generated VOICE_SERVER_URL_SECRET in .env"
|
|
}
|
|
$env:VOICE_SERVER_URL_SECRET = $secret
|
|
}
|
|
|
|
function Show-ProviderSetupSteps([string]$webhookUrl, [string]$secret) {
|
|
Write-Host ""
|
|
Write-Host "=== Provider webhook setup (Assistant -> Advanced -> Webhook Server) ===" -ForegroundColor Cyan
|
|
Write-Host "Webhook URL:"
|
|
Write-Host " $webhookUrl"
|
|
Write-Host ""
|
|
Write-Host "Secret header:"
|
|
Write-Host " Name: X-Voice-Webhook-Secret"
|
|
Write-Host " Value: $secret"
|
|
Write-Host "================================================================" -ForegroundColor Cyan
|
|
}
|
|
|
|
Load-DotEnv
|
|
Ensure-ServerSecret
|
|
$env:VOICE_AGENT_PORT = "$Port"
|
|
|
|
$needsRestart = $Restart.IsPresent
|
|
if (Test-TcpPortListening $Port) {
|
|
if ($needsRestart) {
|
|
Stop-PortProcess $Port
|
|
} else {
|
|
try {
|
|
$cfg = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/voice/config" -TimeoutSec 3
|
|
if (-not $cfg.webhook_url -and $env:PUBLIC_API_BASE_URL) {
|
|
Write-Host "App running but missing webhook URL — restarting to reload .env"
|
|
$needsRestart = $true
|
|
Stop-PortProcess $Port
|
|
} else {
|
|
Write-Host "App already running on port $Port"
|
|
}
|
|
} catch {
|
|
Write-Host "App already running on port $Port"
|
|
}
|
|
}
|
|
}
|
|
|
|
$public = $env:PUBLIC_API_BASE_URL
|
|
$mainTunnelRecreated = $false
|
|
if (Test-TunnelUrl $public) {
|
|
Write-Host "Tunnel OK: $public"
|
|
} else {
|
|
$null = Start-CloudflaredTunnel $Port
|
|
$mainTunnelRecreated = $true
|
|
}
|
|
if ($needsRestart -or -not (Test-TcpPortListening $Port)) {
|
|
Start-VoiceApp $Port
|
|
} elseif ($mainTunnelRecreated) {
|
|
Write-Host "Main tunnel URL changed — restarting app to reload .env"
|
|
Stop-PortProcess $Port
|
|
Start-VoiceApp $Port
|
|
}
|
|
|
|
$config = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/api/voice/config"
|
|
Write-Host "Voice configured: $($config.configured)"
|
|
Write-Host "Webhook URL: $($config.webhook_url)"
|
|
$webhook = if ($config.webhook_url) { $config.webhook_url } else { "$($env:PUBLIC_API_BASE_URL)/webhooks/voice" }
|
|
|
|
Write-Host ""
|
|
Write-Host "Configuring voice provider via API..." -ForegroundColor Cyan
|
|
py -3.13 setup_voice_provider.py
|
|
if ($LASTEXITCODE -eq 0) {
|
|
Write-Host "Voice provider auto-configured (tools + webhook URL + auth)." -ForegroundColor Green
|
|
} else {
|
|
Show-ProviderSetupSteps -webhookUrl $webhook -secret $env:VOICE_SERVER_URL_SECRET
|
|
}
|
|
|
|
if ($env:ELEVENLABS_API_KEY) {
|
|
Write-Host ""
|
|
$speechSidecarOk = Test-TcpPortListening 8002
|
|
$speechTunnelOk = Test-SpeechTunnelUrl $env:SPEECH_ENGINE_PUBLIC_URL
|
|
if (-not $speechTunnelOk) {
|
|
Write-Host "Speech tunnel down — starting sidecar + new Cloudflare tunnel..." -ForegroundColor Cyan
|
|
Start-SpeechSidecar 8002 -Force
|
|
$speechTunnel = Start-CloudflaredTunnel 8002 "-speech"
|
|
Update-EnvValue "SPEECH_ENGINE_PUBLIC_URL" $speechTunnel
|
|
Write-Host "Speech Engine tunnel: $speechTunnel"
|
|
Write-Host "Waiting for speech tunnel to accept connections..."
|
|
Start-Sleep -Seconds 5
|
|
Write-Host "Configuring ElevenLabs Speech Engine..." -ForegroundColor Cyan
|
|
py -3.13 setup_speech_engine.py
|
|
if ($LASTEXITCODE -ne 0) {
|
|
throw "Speech Engine setup failed — run: py -3.13 setup_speech_engine.py"
|
|
}
|
|
Write-Host "Speech Engine configured (WebSocket /ws + browser token)." -ForegroundColor Green
|
|
Load-DotEnv
|
|
Stop-PortProcess $Port
|
|
Start-VoiceApp $Port
|
|
} elseif ($Restart.IsPresent -or -not $speechSidecarOk) {
|
|
Write-Host "Restarting speech sidecar to reload code (keeping existing tunnel)..." -ForegroundColor Yellow
|
|
Start-SpeechSidecar 8002 -Force
|
|
} else {
|
|
Write-Host "Speech stack OK (sidecar :8002 + tunnel reachable)" -ForegroundColor Green
|
|
}
|
|
if (-not (Test-TcpPortListening 8002)) {
|
|
throw "Speech Engine sidecar not listening on 8002"
|
|
}
|
|
Load-DotEnv
|
|
if (-not (Test-SpeechTunnelUrl $env:SPEECH_ENGINE_PUBLIC_URL)) {
|
|
Write-Host ""
|
|
Write-Host "WARNING: Speech Engine tunnel not reachable yet: $($env:SPEECH_ENGINE_PUBLIC_URL)" -ForegroundColor Yellow
|
|
Write-Host "Local dashboard is up at http://127.0.0.1:$Port — wait ~30s, then retry ElevenLabs talk." -ForegroundColor Yellow
|
|
Write-Host "If it still fails, run: .\start_dev.ps1 -Restart" -ForegroundColor Yellow
|
|
} else {
|
|
Write-Host "Speech sidecar: port 8002 | ws_url wss://$($env:SPEECH_ENGINE_PUBLIC_URL -replace 'https://','')/ws"
|
|
}
|
|
}
|
|
|
|
Write-Host "Done. Dashboard: http://127.0.0.1:$Port"
|
|
Write-Host "ElevenLabs talk: http://127.0.0.1:$Port/eleven-talk"
|
|
Write-Host "Keep this window open — sidecar + tunnels run in the background."
|