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('idle'); const [connectionActive, setConnectionActive] = useState(false); @@ -137,41 +110,15 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia const currentAllowInterruptRef = useRef(undefined); const interruptWarningShownRef = useRef(false); - const getWebSocketUrl = useCallback(async () => { - // 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; - - 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) { - throw new Error('Dograh API is not reachable at http://localhost:8000. Ensure the api container is running and port 8000 is published.'); - } - - 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 + const getWebSocketUrl = useCallback(() => { + // Single source of truth for the browser→API base URL: the centrally + // resolved API client config (NEXT_PUBLIC_BACKEND_URL → the backend + // endpoint reported by /health → window.location.origin), seeded by + // createClientConfig and upgraded by AppConfigProvider. The backend now + // reports the endpoint it runs on, so the old localhost autodetect that + // forced :8000 (back when an unset endpoint fell through to the UI origin) + // is no longer needed. + const baseUrl = client.getConfig().baseUrl || resolveBrowserBackendUrl(); const wsUrl = baseUrl.replace(/^http/, 'ws'); return `${wsUrl}/api/v1/ws/signaling/${workflowId}/${workflowRunId}?token=${accessToken}`; }, [workflowId, workflowRunId, accessToken]); @@ -352,7 +299,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia }; const connectWebSocket = useCallback(async () => { - const wsUrl = await getWebSocketUrl(); + const wsUrl = getWebSocketUrl(); return new Promise((resolve, reject) => { logger.info(`Connecting to WebSocket: ${wsUrl}`); diff --git a/ui/src/components/MCPSection.tsx b/ui/src/components/MCPSection.tsx index 6412641f..00c637a4 100644 --- a/ui/src/components/MCPSection.tsx +++ b/ui/src/components/MCPSection.tsx @@ -6,22 +6,41 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; +import { useAppConfig } from "@/context/AppConfigContext"; +import { resolveBrowserBackendUrl } from "@/lib/apiClient"; + +const MCP_PATH = "/api/v1/mcp/"; export function MCPSection() { - const backendUrl = - process.env.NEXT_PUBLIC_BACKEND_URL || - (typeof window !== "undefined" ? window.location.origin : ""); - const endpoint = `${backendUrl}/api/v1/mcp/`; + const { config } = useAppConfig(); + // Backend URL: the address the deployment runs on (a private IP when the backend + // sits on one). Tunnel URL, when present: the publicly reachable Cloudflare tunnel + // URL externally-hosted assistants should use to reach an otherwise-private host. + const backendUrl = resolveBrowserBackendUrl(config?.backendApiEndpoint); + const tunnelUrl = config?.tunnelUrl ?? null; - const [endpointCopied, setEndpointCopied] = useState(false); + const endpoints = [ + ...(tunnelUrl + ? [ + { + key: "tunnel", + label: "Public URL (Cloudflare tunnel)", + url: `${tunnelUrl}${MCP_PATH}`, + }, + ] + : []), + { key: "backend", label: "Backend URL", url: `${backendUrl}${MCP_PATH}` }, + ]; - const handleCopy = async ( - value: string, - setter: (v: boolean) => void, - ) => { + const [copiedKey, setCopiedKey] = useState(null); + + const handleCopy = async (value: string, key: string) => { await navigator.clipboard.writeText(value); - setter(true); - setTimeout(() => setter(false), 2000); + setCopiedKey(key); + setTimeout( + () => setCopiedKey((current) => (current === key ? null : current)), + 2000, + ); }; return ( @@ -39,23 +58,40 @@ export function MCPSection() { Get your API key

-
- - {endpoint} - - +
+ {endpoints.map(({ key, label, url }) => ( +
+ {endpoints.length > 1 && ( + + {label} + + )} +
+ + {url} + + +
+
+ ))}
+ {tunnelUrl && ( +

+ 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 {