Add SPEECH_TO_SPEECH_URL: deploy-pinned direct URL that overrides the LB
Browse filesWhen set, the browser connects straight to it (shown read-only in
Settings) and all load-balancer logic β session proxy, queue, metering,
sign-in β is disabled. Also gitignore the local-only deploy_replica.py.
- .gitignore +5 -0
- README.md +22 -9
- main.js +24 -7
- server.py +16 -1
- style.css +9 -0
.gitignore
CHANGED
|
@@ -14,3 +14,8 @@ __pycache__/
|
|
| 14 |
*.pyc
|
| 15 |
.venv/
|
| 16 |
venv/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
*.pyc
|
| 15 |
.venv/
|
| 16 |
venv/
|
| 17 |
+
|
| 18 |
+
# Local-only replica deploy script (never tracked)
|
| 19 |
+
deploy_replica.py
|
| 20 |
+
|
| 21 |
+
.env
|
README.md
CHANGED
|
@@ -88,14 +88,26 @@ button, top-right):
|
|
| 88 |
|
| 89 |
## Connecting to a backend
|
| 90 |
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
`connect_url` (`wss://host/v1/realtime?...`) or a bare host like `localhost:8080`
|
| 98 |
-
(the app adds `/v1/realtime`).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
**Settings β Restart** reconnects with the current voice, instructions and URL.
|
| 101 |
|
|
@@ -126,8 +138,9 @@ proxy from one container).
|
|
| 126 |
|
| 127 |
```bash
|
| 128 |
pip install -r requirements.txt
|
| 129 |
-
export SERPER_API_KEY=...
|
| 130 |
-
export
|
|
|
|
| 131 |
uvicorn server:app --reload --port 7860
|
| 132 |
# or, matching production: docker build -t s2s . && docker run -p 7860:7860 -e SERPER_API_KEY=... -e LOAD_BALANCER_URL=... s2s
|
| 133 |
```
|
|
|
|
| 88 |
|
| 89 |
## Connecting to a backend
|
| 90 |
|
| 91 |
+
Three modes, picked by env (`/api/config` tells the client which one is active):
|
| 92 |
+
|
| 93 |
+
- **`SPEECH_TO_SPEECH_URL` env** β highest priority. The browser connects
|
| 94 |
+
**directly** to this realtime WebSocket URL; it's shown read-only in Settings.
|
| 95 |
+
Setting it disables the load-balancer logic entirely (no `/api/session` proxy,
|
| 96 |
+
no queue, no metering, no sign-in). Unlike the LB address it is not a secret.
|
| 97 |
+
- **`LOAD_BALANCER_URL` env** β the original flow: the browser POSTs the
|
| 98 |
+
same-origin `/api/session` proxy, the server forwards to the LB, and the
|
| 99 |
+
browser dials the per-session compute URL the LB hands back. The LB address
|
| 100 |
+
never reaches the browser; the Settings URL field is hidden.
|
| 101 |
+
- **Neither** β **Settings β Speech-to-speech server URL**: paste a full
|
| 102 |
`connect_url` (`wss://host/v1/realtime?...`) or a bare host like `localhost:8080`
|
| 103 |
+
(the app adds `/v1/realtime`), and the browser connects to it directly.
|
| 104 |
+
|
| 105 |
+
| `SPEECH_TO_SPEECH_URL` | `LOAD_BALANCER_URL` | `SPACE_ID` | Connection | URL field | Metering |
|
| 106 |
+
|:---:|:---:|:---:|---|---|---|
|
| 107 |
+
| β
| any | any | direct β pinned URL | visible, locked | off |
|
| 108 |
+
| β | β
| β
| LB proxy | hidden | **on** |
|
| 109 |
+
| β | β
| β | LB proxy | hidden | off |
|
| 110 |
+
| β | β | any | direct β user URL | editable | off |
|
| 111 |
|
| 112 |
**Settings β Restart** reconnects with the current voice, instructions and URL.
|
| 113 |
|
|
|
|
| 138 |
|
| 139 |
```bash
|
| 140 |
pip install -r requirements.txt
|
| 141 |
+
export SERPER_API_KEY=... # optional; web search is disabled without it
|
| 142 |
+
export SPEECH_TO_SPEECH_URL=... # optional; pin a direct s2s server URL (overrides the LB)
|
| 143 |
+
export LOAD_BALANCER_URL=... # optional; session-proxy flow (set a URL in Settings otherwise)
|
| 144 |
uvicorn server:app --reload --port 7860
|
| 145 |
# or, matching production: docker build -t s2s . && docker run -p 7860:7860 -e SERPER_API_KEY=... -e LOAD_BALANCER_URL=... s2s
|
| 146 |
```
|
main.js
CHANGED
|
@@ -264,16 +264,22 @@ let currentState = "idle";
|
|
| 264 |
let settings = loadSettings();
|
| 265 |
|
| 266 |
// ββ Connection target ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 267 |
-
//
|
|
|
|
|
|
|
|
|
|
| 268 |
// β’ LOAD_BALANCER_URL set -> original flow: POST the same-origin /api/session
|
| 269 |
// proxy (the server forwards to the LB; the LB address is never sent here).
|
| 270 |
-
// β’
|
| 271 |
// the browser connects to it directly (no load balancer, no /session).
|
| 272 |
let lbMode = false;
|
| 273 |
// Fail open: direct entry is allowed unless /api/config reports an LB URL. This
|
| 274 |
// way a missing/unreachable config (e.g. static hosting) leaves the field
|
| 275 |
// usable rather than locked.
|
| 276 |
let allowDirect = true;
|
|
|
|
|
|
|
|
|
|
| 277 |
|
| 278 |
// ββ Tool state ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 279 |
let toolsEnabled = loadTools();
|
|
@@ -829,6 +835,8 @@ async function fetchConfig() {
|
|
| 829 |
lbMode = !!json.lb;
|
| 830 |
// Lock to LB mode only when the deploy reports a load balancer.
|
| 831 |
allowDirect = json.allowDirect ?? !lbMode;
|
|
|
|
|
|
|
| 832 |
// The conversation-time limiter rides on the LB being present.
|
| 833 |
limiterOn = lbMode;
|
| 834 |
}
|
|
@@ -855,7 +863,7 @@ function connectionTarget() {
|
|
| 855 |
if (!allowDirect) {
|
| 856 |
return { sessionUrl: "api/session" };
|
| 857 |
}
|
| 858 |
-
const directUrl = buildDirectWsUrl(settings.directUrl);
|
| 859 |
if (!directUrl) {
|
| 860 |
throw new Error("Enter a speech-to-speech server URL in Settings.");
|
| 861 |
}
|
|
@@ -904,10 +912,11 @@ function createResumedAudioContext() {
|
|
| 904 |
}
|
| 905 |
|
| 906 |
/** Read the editable settings out of the form. The URL field is only honoured
|
| 907 |
-
* in direct mode
|
|
|
|
| 908 |
function readSettingsFromForm() {
|
| 909 |
return {
|
| 910 |
-
directUrl: allowDirect ? inputLbUrl.value.trim() : settings.directUrl,
|
| 911 |
voice: inputVoice.value || DEFAULT_VOICE,
|
| 912 |
instructions: inputInstructions.value.trim() || DEFAULT_INSTRUCTIONS,
|
| 913 |
noiseGate: readGateThreshold(),
|
|
@@ -923,10 +932,18 @@ function readGateThreshold() {
|
|
| 923 |
|
| 924 |
/** Adapt the connection field to the mode learned from /api/config. */
|
| 925 |
function syncConnectionUi() {
|
| 926 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 927 |
// Direct mode: the user sets their own s2s server URL.
|
| 928 |
connField.hidden = false;
|
| 929 |
inputLbUrl.value = settings.directUrl;
|
|
|
|
| 930 |
inputLbUrl.placeholder = "http://localhost:port";
|
| 931 |
connHint.classList.remove("error");
|
| 932 |
connHint.textContent =
|
|
@@ -941,7 +958,7 @@ function syncConnectionUi() {
|
|
| 941 |
/** True when the user must supply a server URL before connecting (direct mode
|
| 942 |
* with nothing set). */
|
| 943 |
function missingServerUrl() {
|
| 944 |
-
return allowDirect && !buildDirectWsUrl(settings.directUrl);
|
| 945 |
}
|
| 946 |
|
| 947 |
/** Open Settings and point the user at the empty server-URL field. */
|
|
|
|
| 264 |
let settings = loadSettings();
|
| 265 |
|
| 266 |
// ββ Connection target ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 267 |
+
// Three modes, decided by the deploy via /api/config:
|
| 268 |
+
// β’ SPEECH_TO_SPEECH_URL set -> direct mode pinned by the deploy: the browser
|
| 269 |
+
// connects straight to that URL, shown read-only in Settings. Overrides the
|
| 270 |
+
// load balancer entirely.
|
| 271 |
// β’ LOAD_BALANCER_URL set -> original flow: POST the same-origin /api/session
|
| 272 |
// proxy (the server forwards to the LB; the LB address is never sent here).
|
| 273 |
+
// β’ neither (allowDirect) -> the user sets a speech-to-speech server URL and
|
| 274 |
// the browser connects to it directly (no load balancer, no /session).
|
| 275 |
let lbMode = false;
|
| 276 |
// Fail open: direct entry is allowed unless /api/config reports an LB URL. This
|
| 277 |
// way a missing/unreachable config (e.g. static hosting) leaves the field
|
| 278 |
// usable rather than locked.
|
| 279 |
let allowDirect = true;
|
| 280 |
+
// Deploy-pinned s2s URL (SPEECH_TO_SPEECH_URL). Non-empty -> locked direct
|
| 281 |
+
// mode: the field displays it read-only and the saved user URL is untouched.
|
| 282 |
+
let pinnedUrl = "";
|
| 283 |
|
| 284 |
// ββ Tool state ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 285 |
let toolsEnabled = loadTools();
|
|
|
|
| 835 |
lbMode = !!json.lb;
|
| 836 |
// Lock to LB mode only when the deploy reports a load balancer.
|
| 837 |
allowDirect = json.allowDirect ?? !lbMode;
|
| 838 |
+
// Deploy-pinned direct URL (overrides the LB server-side already).
|
| 839 |
+
pinnedUrl = (json.s2sUrl || "").trim();
|
| 840 |
// The conversation-time limiter rides on the LB being present.
|
| 841 |
limiterOn = lbMode;
|
| 842 |
}
|
|
|
|
| 863 |
if (!allowDirect) {
|
| 864 |
return { sessionUrl: "api/session" };
|
| 865 |
}
|
| 866 |
+
const directUrl = buildDirectWsUrl(pinnedUrl || settings.directUrl);
|
| 867 |
if (!directUrl) {
|
| 868 |
throw new Error("Enter a speech-to-speech server URL in Settings.");
|
| 869 |
}
|
|
|
|
| 912 |
}
|
| 913 |
|
| 914 |
/** Read the editable settings out of the form. The URL field is only honoured
|
| 915 |
+
* in free direct mode β in LB mode it's hidden, and when the deploy pins a
|
| 916 |
+
* URL it's read-only, so the user's saved URL survives either way. */
|
| 917 |
function readSettingsFromForm() {
|
| 918 |
return {
|
| 919 |
+
directUrl: allowDirect && !pinnedUrl ? inputLbUrl.value.trim() : settings.directUrl,
|
| 920 |
voice: inputVoice.value || DEFAULT_VOICE,
|
| 921 |
instructions: inputInstructions.value.trim() || DEFAULT_INSTRUCTIONS,
|
| 922 |
noiseGate: readGateThreshold(),
|
|
|
|
| 932 |
|
| 933 |
/** Adapt the connection field to the mode learned from /api/config. */
|
| 934 |
function syncConnectionUi() {
|
| 935 |
+
if (pinnedUrl) {
|
| 936 |
+
// Deploy-pinned URL: show it, but locked β the deployment owns it.
|
| 937 |
+
connField.hidden = false;
|
| 938 |
+
inputLbUrl.value = pinnedUrl;
|
| 939 |
+
inputLbUrl.readOnly = true;
|
| 940 |
+
connHint.classList.remove("error");
|
| 941 |
+
connHint.textContent = "Speech-to-speech server URL pinned by this deployment.";
|
| 942 |
+
} else if (allowDirect) {
|
| 943 |
// Direct mode: the user sets their own s2s server URL.
|
| 944 |
connField.hidden = false;
|
| 945 |
inputLbUrl.value = settings.directUrl;
|
| 946 |
+
inputLbUrl.readOnly = false;
|
| 947 |
inputLbUrl.placeholder = "http://localhost:port";
|
| 948 |
connHint.classList.remove("error");
|
| 949 |
connHint.textContent =
|
|
|
|
| 958 |
/** True when the user must supply a server URL before connecting (direct mode
|
| 959 |
* with nothing set). */
|
| 960 |
function missingServerUrl() {
|
| 961 |
+
return allowDirect && !pinnedUrl && !buildDirectWsUrl(settings.directUrl);
|
| 962 |
}
|
| 963 |
|
| 964 |
/** Open Settings and point the user at the empty server-URL field. */
|
server.py
CHANGED
|
@@ -18,8 +18,12 @@ On the deployed Space the server also meters conversation time by HF login tier
|
|
| 18 |
is off unless BOTH `LOAD_BALANCER_URL` and `SPACE_ID` are set, so it runs only on
|
| 19 |
the live Space, never locally (even with the LB exported for testing).
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
Endpoints:
|
| 22 |
-
GET /api/config -> { search, lb, allowDirect, auth }
|
| 23 |
GET /api/me -> login + tier + remaining budget (LB mode only)
|
| 24 |
POST /api/search -> { results, answer } Google via Serper.dev
|
| 25 |
POST /api/session -> proxies <LB>/session: a grant, or a queue ticket
|
|
@@ -58,6 +62,14 @@ SERPER_KEY = os.environ.get("SERPER_API_KEY", "").strip()
|
|
| 58 |
# When empty, the user may instead set a direct s2s server URL in Settings and the
|
| 59 |
# browser connects to it straight (no load balancer).
|
| 60 |
LOAD_BALANCER_URL = os.environ.get("LOAD_BALANCER_URL", "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
# HF injects SPACE_ID ("owner/space") into every Space runtime; it's absent
|
| 62 |
# locally and on a plain `docker run`. We meter conversation time ONLY on the
|
| 63 |
# deployed Space β i.e. when BOTH the LB is configured AND we're on a Space.
|
|
@@ -112,6 +124,9 @@ def config():
|
|
| 112 |
"search": bool(SERPER_KEY),
|
| 113 |
"lb": bool(LOAD_BALANCER_URL),
|
| 114 |
"allowDirect": not LOAD_BALANCER_URL,
|
|
|
|
|
|
|
|
|
|
| 115 |
"auth": AUTH_ENABLED,
|
| 116 |
}
|
| 117 |
|
|
|
|
| 18 |
is off unless BOTH `LOAD_BALANCER_URL` and `SPACE_ID` are set, so it runs only on
|
| 19 |
the live Space, never locally (even with the LB exported for testing).
|
| 20 |
|
| 21 |
+
`SPEECH_TO_SPEECH_URL` overrides everything: when set, the LB logic above is
|
| 22 |
+
disabled entirely (no session proxy, no queue, no metering, no sign-in) and the
|
| 23 |
+
browser connects directly to that URL, shown read-only in Settings.
|
| 24 |
+
|
| 25 |
Endpoints:
|
| 26 |
+
GET /api/config -> { search, lb, allowDirect, s2sUrl, auth }
|
| 27 |
GET /api/me -> login + tier + remaining budget (LB mode only)
|
| 28 |
POST /api/search -> { results, answer } Google via Serper.dev
|
| 29 |
POST /api/session -> proxies <LB>/session: a grant, or a queue ticket
|
|
|
|
| 62 |
# When empty, the user may instead set a direct s2s server URL in Settings and the
|
| 63 |
# browser connects to it straight (no load balancer).
|
| 64 |
LOAD_BALANCER_URL = os.environ.get("LOAD_BALANCER_URL", "").strip()
|
| 65 |
+
# Direct s2s server URL pinned by the deploy. Takes priority over the load
|
| 66 |
+
# balancer: when set, ALL LB logic is disabled (no /api/session proxy, no queue,
|
| 67 |
+
# no limiter, no sign-in) and the browser connects to this URL directly. Unlike
|
| 68 |
+
# the LB address it is NOT a secret β /api/config sends it to the client, which
|
| 69 |
+
# shows it read-only in Settings.
|
| 70 |
+
SPEECH_TO_SPEECH_URL = os.environ.get("SPEECH_TO_SPEECH_URL", "").strip()
|
| 71 |
+
if SPEECH_TO_SPEECH_URL:
|
| 72 |
+
LOAD_BALANCER_URL = ""
|
| 73 |
# HF injects SPACE_ID ("owner/space") into every Space runtime; it's absent
|
| 74 |
# locally and on a plain `docker run`. We meter conversation time ONLY on the
|
| 75 |
# deployed Space β i.e. when BOTH the LB is configured AND we're on a Space.
|
|
|
|
| 124 |
"search": bool(SERPER_KEY),
|
| 125 |
"lb": bool(LOAD_BALANCER_URL),
|
| 126 |
"allowDirect": not LOAD_BALANCER_URL,
|
| 127 |
+
# Deploy-pinned direct s2s URL (empty when unset). Not a secret: the
|
| 128 |
+
# browser dials it itself, and Settings shows it locked.
|
| 129 |
+
"s2sUrl": SPEECH_TO_SPEECH_URL,
|
| 130 |
"auth": AUTH_ENABLED,
|
| 131 |
}
|
| 132 |
|
style.css
CHANGED
|
@@ -1419,6 +1419,15 @@ body.cam-on .footer {
|
|
| 1419 |
.field textarea:focus {
|
| 1420 |
border-color: var(--text-dim);
|
| 1421 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1422 |
.field small {
|
| 1423 |
color: var(--text-faint);
|
| 1424 |
font-size: 12px;
|
|
|
|
| 1419 |
.field textarea:focus {
|
| 1420 |
border-color: var(--text-dim);
|
| 1421 |
}
|
| 1422 |
+
/* Deploy-pinned server URL: shown for transparency, not editable. */
|
| 1423 |
+
.field input[readonly] {
|
| 1424 |
+
color: var(--text-dim);
|
| 1425 |
+
border-style: dashed;
|
| 1426 |
+
cursor: default;
|
| 1427 |
+
}
|
| 1428 |
+
.field input[readonly]:focus {
|
| 1429 |
+
border-color: var(--border);
|
| 1430 |
+
}
|
| 1431 |
.field small {
|
| 1432 |
color: var(--text-faint);
|
| 1433 |
font-size: 12px;
|