diff --git a/ui/src/app/api/config/version/route.ts b/ui/src/app/api/config/version/route.ts
index a031cf4a..bc65c8b2 100644
--- a/ui/src/app/api/config/version/route.ts
+++ b/ui/src/app/api/config/version/route.ts
@@ -36,6 +36,7 @@ export async function GET() {
let turnEnabled = false;
let forceTurnRelay = false;
let tunnelUrl: string | null = null;
+ let backendApiEndpoint: string | null = null;
let backendStatus: "reachable" | "unreachable" = "unreachable";
let backendMessage: string | null = `Backend is not reachable at ${backendUrl}.`;
@@ -55,6 +56,11 @@ export async function GET() {
turnEnabled = Boolean(data.turn_enabled);
forceTurnRelay = Boolean(data.force_turn_relay);
tunnelUrl = data.tunnel_url ?? null;
+ backendApiEndpoint =
+ typeof data.backend_api_endpoint === "string" &&
+ data.backend_api_endpoint.length > 0
+ ? trimTrailingSlash(data.backend_api_endpoint)
+ : null;
backendStatus = "reachable";
backendMessage = null;
}
@@ -71,6 +77,7 @@ export async function GET() {
turnEnabled,
forceTurnRelay,
tunnelUrl,
+ backendApiEndpoint,
backend: {
status: backendStatus,
url: backendUrl,
diff --git a/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx b/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx
index bbbd1550..db7a6ce8 100644
--- a/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx
+++ b/ui/src/app/workflow/[workflowId]/run/[runId]/hooks/useWebSocketRTC.tsx
@@ -6,6 +6,7 @@ import { TurnCredentialsResponse } from "@/client/types.gen";
import { WorkflowValidationError } from "@/components/flow/types";
import type { ConversationNodeTransitionItem, RealtimeFeedbackMessage as FeedbackMessage } from "@/components/workflow/conversation";
import { useAppConfig } from "@/context/AppConfigContext";
+import { resolveBrowserBackendUrl } from '@/lib/apiClient';
import logger from '@/lib/logger';
import { sdpFilterCodec } from "../utils";
@@ -37,34 +38,6 @@ const HANDLED_SERVICE_ERROR_TYPES = new Set([
'quota_check_failed',
]);
-const LOCALHOST_API_BASE_URL = 'http://localhost:8000';
-const LOCALHOST_API_HEALTH_URL = `${LOCALHOST_API_BASE_URL}/api/v1/health`;
-const LOCALHOST_API_PROBE_TIMEOUT_MS = 1500;
-
-function isLocalhostUi() {
- if (typeof window === 'undefined') return false;
-
- return ['localhost', '127.0.0.1', '::1'].includes(window.location.hostname);
-}
-
-async function probeLocalhostApi() {
- const controller = new AbortController();
- const timeout = window.setTimeout(() => controller.abort(), LOCALHOST_API_PROBE_TIMEOUT_MS);
-
- try {
- const response = await fetch(LOCALHOST_API_HEALTH_URL, {
- cache: 'no-store',
- signal: controller.signal,
- });
-
- return response.ok;
- } catch {
- return false;
- } finally {
- window.clearTimeout(timeout);
- }
-}
-
export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initialContextVariables, onNodeTransition }: UseWebSocketRTCProps) => {
const [connectionStatus, setConnectionStatus] = useState
- {endpoint}
-
-
+
+ {url}
+
+
+ + Use the public URL from externally-hosted assistants; the backend URL + works from the deployment's own network. +
+ )}diff --git a/ui/src/context/AppConfigContext.tsx b/ui/src/context/AppConfigContext.tsx index 7cf98205..af8256c2 100644 --- a/ui/src/context/AppConfigContext.tsx +++ b/ui/src/context/AppConfigContext.tsx @@ -2,6 +2,9 @@ import { createContext, ReactNode, useCallback, useContext, useEffect, useState } from 'react'; +import { client } from '@/client/client.gen'; +import { resolveBrowserBackendUrl } from '@/lib/apiClient'; + type BackendStatus = 'reachable' | 'unreachable'; interface AppConfig { @@ -14,6 +17,11 @@ interface AppConfig { // Public URL when the deployment is reached through a Cloudflare tunnel // (host has no public IP); null for a directly-reachable deployment. tunnelUrl: string | null; + // The URL the backend reports it is running on (via /health). This is the + // address the browser reaches the backend at — a private IP when the backend + // runs on one. null until /health is reached. Used to resolve the API client + // base URL; distinct from tunnelUrl, which is only for external consumers. + backendApiEndpoint: string | null; backendStatus: BackendStatus; backendUrl: string; backendMessage: string | null; @@ -33,6 +41,7 @@ const defaultConfig: AppConfig = { turnEnabled: false, forceTurnRelay: false, tunnelUrl: null, + backendApiEndpoint: null, backendStatus: 'unreachable', backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL || 'unknown', backendMessage: process.env.NEXT_PUBLIC_BACKEND_URL @@ -60,6 +69,21 @@ export function AppConfigProvider({ children }: { children: ReactNode }) { const backendUrl = typeof backend.url === 'string' && backend.url.length > 0 ? backend.url : defaultConfig.backendUrl; + const backendApiEndpoint = typeof data.backendApiEndpoint === 'string' && data.backendApiEndpoint.length > 0 + ? data.backendApiEndpoint + : null; + + // createClientConfig seeds the API client base URL before /health is + // known. Now that the backend has reported the endpoint it runs on, + // re-apply the single browser→API preference order so all SDK calls + // (and anything reading client.getConfig().baseUrl) hit it directly — + // window.location.origin would be wrong when the API is served from a + // different host/port. resolveBrowserBackendUrl keeps NEXT_PUBLIC_BACKEND_URL + // ahead of the reported endpoint. Guard on a present endpoint so a + // transient /health failure never downgrades a good base URL to origin. + if (backendApiEndpoint) { + client.setConfig({ baseUrl: resolveBrowserBackendUrl(backendApiEndpoint) }); + } setConfig({ uiVersion: data.ui || 'dev', @@ -69,6 +93,7 @@ export function AppConfigProvider({ children }: { children: ReactNode }) { turnEnabled: Boolean(data.turnEnabled), forceTurnRelay: Boolean(data.forceTurnRelay), tunnelUrl: typeof data.tunnelUrl === 'string' ? data.tunnelUrl : null, + backendApiEndpoint, backendStatus, backendUrl, backendMessage: typeof backend.message === 'string' && backend.message.length > 0 diff --git a/ui/src/lib/apiClient.ts b/ui/src/lib/apiClient.ts index d0268206..4136cd55 100644 --- a/ui/src/lib/apiClient.ts +++ b/ui/src/lib/apiClient.ts @@ -5,6 +5,31 @@ export function getServerBackendUrl() { return process.env.BACKEND_URL || 'http://api:8000'; } +/** + * Resolve the base URL the browser should use to reach the backend API. + * + * Precedence: + * 1. NEXT_PUBLIC_BACKEND_URL — explicit build-time operator config, always wins. + * 2. backendApiEndpoint — the URL the backend reports it is running on via /health + * (surfaced through AppConfigContext). This is the address the browser actually + * reaches the backend at; for a backend on a private IP it is that private IP. + * Unknown at module init, so createClientConfig seeds without it and + * AppConfigProvider upgrades the client once /health resolves. + * 3. window.location.origin — same-origin public deployment. + * + * This is the browser→API order. It is intentionally NOT tunnel-aware: the + * Cloudflare tunnel URL is only for externally-hosted consumers (telephony + * webhooks, MCP, external API triggers) that cannot reach a private IP — see + * resolveWebhookBaseUrl. + */ +export function resolveBrowserBackendUrl(backendApiEndpoint?: string | null): string { + return ( + process.env.NEXT_PUBLIC_BACKEND_URL || + backendApiEndpoint || + (typeof window !== 'undefined' ? window.location.origin : '') + ); +} + export const createClientConfig: CreateClientConfig = (config) => { // Use different URLs for server-side vs client-side const isServer = typeof window === 'undefined'; @@ -13,7 +38,10 @@ export const createClientConfig: CreateClientConfig = (config) => { if (isServer) { baseUrl = getServerBackendUrl(); } else { - baseUrl = process.env.NEXT_PUBLIC_BACKEND_URL || window.location.origin; + // The backend-reported endpoint is not known yet at module init; + // AppConfigProvider upgrades the client base URL once /health reports it + // (when no explicit NEXT_PUBLIC_BACKEND_URL is configured). + baseUrl = resolveBrowserBackendUrl(); } return {