diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3aa1cb..ee18a35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -208,6 +208,9 @@ jobs: run: echo "TAG=$(echo '${{ github.ref_name }}' | tr '/' '-')" >> "$GITHUB_ENV" # Loaded locally first so Trivy scans the exact image that gets pushed. + # VITE_BASE_PATH is baked into the bundle, so an image built for a + # sub-path serves only from there. The default "/" is the origin root; + # set the repository variable to publish for a sub-path instead. - name: Build (local, unpushed) uses: docker/build-push-action@v6 with: @@ -215,6 +218,8 @@ jobs: file: Dockerfile platforms: linux/amd64 load: true + build-args: | + VITE_BASE_PATH=${{ vars.VITE_BASE_PATH || '/' }} tags: experience-ui:${{ env.TAG }} # Report-only. It does not fail the build: a base-image CVE with no fix diff --git a/Dockerfile b/Dockerfile index 0b3c15f..db4972d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,17 @@ COPY package.json package-lock.json ./ RUN npm ci COPY . . + +# Where the app will be mounted, baked into the bundle. "/" is the origin root. +# An image built with a sub-path serves only from that path, so build one per +# path if a deployment needs more than one: +# +# docker build --build-arg VITE_BASE_PATH=/experience/ . +# +# Leading and trailing slashes both matter. +ARG VITE_BASE_PATH=/ +ENV VITE_BASE_PATH=$VITE_BASE_PATH + RUN npm run build # ---- Serve ------------------------------------------------------------------ diff --git a/README.md b/README.md index d6ba33e..c3791b2 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,47 @@ docker run -p 8080:8080 experience-ui `GET /healthz` returns `ok` for whatever is in front. +### Serving from a sub-path + +The client is built for the origin root by default. To mount it under a path on +a shared domain, build with that path and have the proxy strip the prefix: + +```bash +docker build --build-arg VITE_BASE_PATH=/experience/ -t experience-ui . +``` + +```nginx +location /experience/ { + proxy_pass http://experience-ui:8080/; # the trailing slash strips the prefix +} + +location = /experience { + return 301 /experience/; # otherwise a bare /experience 404s +} +``` + +Leading and trailing slashes on `VITE_BASE_PATH` both matter. The path is baked +into the bundle, so **an image built for a sub-path serves only from that +path** — a deployment that needs two paths builds two images. In CI it comes +from the `VITE_BASE_PATH` repository variable, defaulting to `/`. + +Note that nginx resolves a `proxy_pass` hostname once at config load: restart +the app container and the proxy keeps the old address until it is reloaded too. + +### Brand assets in `config.json` + +`logo`, `favicon` and `assistantAvatar` are used exactly as written — nothing +rewrites them. Two forms work: + +```json +"logo": "brand/logo.png", // a file served by this app +"logo": "https://cdn.example.org/logo.svg" // anywhere else +``` + +For a file, put it under `public/` and reference it **without a leading +slash**, so it resolves relative to wherever the app is served from. A leading +slash pins it to the domain root and breaks a sub-path deployment. + ### Configuring a deployment Everything a deployment changes — name, logo, favicon, colours, which features diff --git a/src/bootstrap.tsx b/src/bootstrap.tsx index b2ee834..e660fb9 100644 --- a/src/bootstrap.tsx +++ b/src/bootstrap.tsx @@ -23,6 +23,10 @@ const routeProgress = createRouteProgress(); const router = createRouter({ routeTree, + // Without this the router matches paths against the origin root, so every + // route 404s when the app is served from a sub-path — while the page itself + // loads fine, which makes it look like something else. + basepath: import.meta.env.BASE_URL, context: { queryClient }, defaultPendingComponent: () => (
diff --git a/src/components/screens-component/chat-screen/components/recording-controls.tsx b/src/components/screens-component/chat-screen/components/recording-controls.tsx index 11af668..2ac80f9 100644 --- a/src/components/screens-component/chat-screen/components/recording-controls.tsx +++ b/src/components/screens-component/chat-screen/components/recording-controls.tsx @@ -5,10 +5,10 @@ import { cn } from "@/lib/utils"; import { useLanguage } from "@/components/LanguageProvider"; import { CHAT_ASSISTANT } from "../config"; -const deleteIcon = "/assets/delete.svg"; -const micIcon = "/assets/micIcon.svg"; -const sendRecord = "/assets/sendRecord.svg"; -const pauseRecordIcon = "/assets/pauseRecordIcon.svg"; +const deleteIcon = `${import.meta.env.BASE_URL}assets/delete.svg`; +const micIcon = `${import.meta.env.BASE_URL}assets/micIcon.svg`; +const sendRecord = `${import.meta.env.BASE_URL}assets/sendRecord.svg`; +const pauseRecordIcon = `${import.meta.env.BASE_URL}assets/pauseRecordIcon.svg`; type RecordingState = "recording" | "paused"; diff --git a/src/components/screens-component/layouts/chat-header.tsx b/src/components/screens-component/layouts/chat-header.tsx index 607a83e..73ef9b5 100644 --- a/src/components/screens-component/layouts/chat-header.tsx +++ b/src/components/screens-component/layouts/chat-header.tsx @@ -4,8 +4,8 @@ import { useLanguage } from "@/components/LanguageProvider"; import { BRAND_LOGO, LANGUAGES } from "@/components/screens-component/chat-screen/config"; import { FEATURES } from "@/lib/config/features"; import { LanguageSelectionDropdown } from "@/components/screens-component/chat-screen/components/language-selection-dialog"; -const settingsIcon = "/assets/settings.svg"; -const langIcon = "/assets/langIcon.svg"; +const settingsIcon = `${import.meta.env.BASE_URL}assets/settings.svg`; +const langIcon = `${import.meta.env.BASE_URL}assets/langIcon.svg`; export type ChatHeaderProps = { title: string; diff --git a/src/config/app-config.json b/src/config/app-config.json index 5564d58..2866f49 100644 --- a/src/config/app-config.json +++ b/src/config/app-config.json @@ -52,9 +52,9 @@ "appName": "Agri Assistant", "assistantName": "Assistant", "documentTitle": "Agri Assistant", - "logo": "/brand/logo.png", - "favicon": "/brand/favicon.svg", - "assistantAvatar": "/brand/assistant.svg", + "logo": "brand/logo.png", + "favicon": "brand/favicon.svg", + "assistantAvatar": "brand/assistant.svg", "userAvatar": "" }, "languages": [ diff --git a/src/lib/api-service.ts b/src/lib/api-service.ts index b43ef3d..f48e98a 100644 --- a/src/lib/api-service.ts +++ b/src/lib/api-service.ts @@ -54,6 +54,12 @@ const stripSseKeepAliveMarkers = (chunk: string): string => .join('\n'); +/** + * Where the API lives. `environment.apiUrl` is empty, so it is same-origin; + * BASE_URL carries the sub-path the app was built for, if any. + */ +const apiBase = `${import.meta.env.BASE_URL}api/`; + class ApiService { private apiUrl: string = environment.apiUrl; private locationData: LocationData | null = null; @@ -62,7 +68,7 @@ class ApiService { constructor() { this.axiosInstance = axios.create({ - baseURL: this.apiUrl, + baseURL: this.apiUrl || apiBase, headers: { 'Content-Type': 'application/json' } @@ -99,7 +105,7 @@ class ApiService { if (onStreamData) { // Handle streaming response - const response = await fetch(`${this.apiUrl}/api/chat/?${new URLSearchParams(params)}`, { + const response = await fetch(`${this.apiUrl || apiBase}chat/?${new URLSearchParams(params)}`, { method: 'GET' }); @@ -150,7 +156,7 @@ class ApiService { const config = { params, }; - const response = await this.axiosInstance.get('/api/chat/', config); + const response = await this.axiosInstance.get('chat/', config); const responseQid = response.headers[CHAT_QID_HEADER.toLowerCase()] as string | undefined; onResponseStarted?.(); return { @@ -193,7 +199,7 @@ class ApiService { const formData = new FormData(); formData.append('image', imageFile); - const response = await fetch(`${this.apiUrl}/api/image/upload`, { + const response = await fetch(`${this.apiUrl || apiBase}image/upload`, { method: 'POST', body: formData }); @@ -245,7 +251,7 @@ class ApiService { params, }; - const response = await this.axiosInstance.get('/api/suggest/', config); + const response = await this.axiosInstance.get('suggest/', config); return response.data.map((item: string) => ({ question: item })); @@ -277,7 +283,7 @@ class ApiService { const config = { }; - const response = await this.axiosInstance.post('/api/transcribe/', payload, config); + const response = await this.axiosInstance.post('transcribe/', payload, config); return response.data; } catch (error) { console.error('Error transcribing audio:', error); @@ -295,7 +301,7 @@ class ApiService { timeout: 120000, // 120s timeout for TTS (can be slow on cold start) }; - return this.axiosInstance.post(`/api/tts/`, { + return this.axiosInstance.post(`tts/`, { session_id: sessionId, text: text, target_lang: targetLang diff --git a/src/lib/config/runtime-config.ts b/src/lib/config/runtime-config.ts index 7f2a465..6a2885c 100644 --- a/src/lib/config/runtime-config.ts +++ b/src/lib/config/runtime-config.ts @@ -18,7 +18,7 @@ import bundledConfig from "@/config/app-config.json"; export type AppConfig = typeof bundledConfig; -const CONFIG_URL = "/config.json"; +const CONFIG_URL = `${import.meta.env.BASE_URL}config.json`; let active: AppConfig = bundledConfig; let loaded = false; diff --git a/vite.config.ts b/vite.config.ts index c9c614b..258a6d9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -47,6 +47,10 @@ const appConfigPlugin: PluginOption = { // https://vite.dev/config/ export default defineConfig({ + // Where the app will be mounted. Baked in at build time, so an image built + // with a sub-path only serves from that path. Supplied by the Docker build + // arg VITE_BASE_PATH; "/" is the default and means the origin root. + base: process.env.VITE_BASE_PATH || "/", resolve: { alias: { "@": path.resolve(__dirname, "src"),