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"),