diff --git a/Dockerfile b/Dockerfile
index 0b3c15f..490a20f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -18,7 +18,13 @@ RUN npm run build
FROM nginx:alpine AS serve
COPY --from=build /app/dist /usr/share/nginx/html
-COPY nginx.conf /etc/nginx/conf.d/default.conf
+# A template, not a config: the base image's entrypoint runs envsubst over
+# /etc/nginx/templates and writes the result into conf.d before nginx starts.
+# That is what substitutes BASE_PATH without needing a rebuild per deployment.
+COPY nginx.conf.template /etc/nginx/templates/default.conf.template
+
+# Serve from the origin root unless a deployment says otherwise.
+ENV BASE_PATH=""
# The configuration a deployment overrides lives in its own directory, mounted
# over by infrastructure. Moved out of the static root so there is exactly one
diff --git a/README.md b/README.md
index d6ba33e..3c5592d 100644
--- a/README.md
+++ b/README.md
@@ -112,6 +112,30 @@ docker run -p 8080:8080 experience-ui
`GET /healthz` returns `ok` for whatever is in front.
+### Serving from a sub-path
+
+The client works from the origin root by default. To mount it under a path on a
+shared domain, set `BASE_PATH` and have the proxy strip the prefix:
+
+```yaml
+environment:
+ BASE_PATH: /experience
+```
+
+```nginx
+location /experience/ {
+ proxy_pass http://experience-ui:8080/; # the trailing slash strips the prefix
+}
+```
+
+Nothing is rebuilt. The container rewrites the `` in `index.html` at
+start-up, and every URL the client builds — assets, fonts, `config.json` and the
+API — is relative, so all of them follow it.
+
+Set the prefix **without** a trailing slash, and remember that nginx resolves a
+`proxy_pass` hostname once at config load: restart the app container and the
+proxy will keep the old address until it is reloaded too.
+
### Configuring a deployment
Everything a deployment changes — name, logo, favicon, colours, which features
diff --git a/docker-compose.yml b/docker-compose.yml
index 9b510c9..edb522e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -4,6 +4,15 @@ services:
image: experience-ui:latest
ports:
- "8080:8080"
+ environment:
+ # Where the app is mounted. Empty (the default) serves it from the origin
+ # root. Set it to the prefix without a trailing slash when it sits behind
+ # a path on a shared domain, and have the proxy strip that prefix:
+ #
+ # BASE_PATH: /experience
+ # location /experience/ { proxy_pass http://experience-ui:8080/; }
+ BASE_PATH: ${BASE_PATH:-}
+
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -q -O - http://localhost:8080/healthz || exit 1"]
diff --git a/index.html b/index.html
index 591cbfa..4c0aad9 100644
--- a/index.html
+++ b/index.html
@@ -2,7 +2,11 @@
-
+
+
+
diff --git a/nginx.conf b/nginx.conf.template
similarity index 78%
rename from nginx.conf
rename to nginx.conf.template
index 96a2bdc..c78b16b 100644
--- a/nginx.conf
+++ b/nginx.conf.template
@@ -28,6 +28,22 @@ server {
# bundles, so caching it pins the old ones.
add_header Cache-Control $cache_control always;
+ # Where this app is mounted. Every URL the client builds — assets,
+ # config.json, the API — is relative and resolves against this, so the same
+ # image serves from / or from /experience/ with no rebuild.
+ #
+ # BASE_PATH is substituted by the base image's envsubst entrypoint. Empty
+ # (the default) leaves the app at the origin root. Set it to the prefix
+ # *without* a trailing slash: BASE_PATH=/experience
+ #
+ # The edge proxy is expected to strip the prefix before forwarding, so
+ # nothing below needs to know about it:
+ #
+ # location /experience/ { proxy_pass http://experience-ui:8080/; }
+ sub_filter '' '';
+ sub_filter_once on;
+ sub_filter_types text/html;
+
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob: https:; media-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
diff --git a/src/bootstrap.tsx b/src/bootstrap.tsx
index b2ee834..7f3f385 100644
--- a/src/bootstrap.tsx
+++ b/src/bootstrap.tsx
@@ -23,6 +23,10 @@ const routeProgress = createRouteProgress();
const router = createRouter({
routeTree,
+ // Where the app is mounted, from the the container injects.
+ // Without this the router matches paths against the origin root and every
+ // route 404s when the app is served from a sub-path.
+ basepath: new URL(document.baseURI).pathname,
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..6b8a642 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 = "assets/delete.svg";
+const micIcon = "assets/micIcon.svg";
+const sendRecord = "assets/sendRecord.svg";
+const pauseRecordIcon = "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..c24de3a 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 = "assets/settings.svg";
+const langIcon = "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..653f462 100644
--- a/src/lib/api-service.ts
+++ b/src/lib/api-service.ts
@@ -54,6 +54,15 @@ const stripSseKeepAliveMarkers = (chunk: string): string =>
.join('\n');
+/**
+ * Where the API lives, resolved against the document base.
+ *
+ * `environment.apiUrl` is empty, so the API is same-origin. Resolving against
+ * `document.baseURI` rather than `/` is what lets the app be served from a
+ * sub-path: mounted at /experience/, this becomes /experience/api/.
+ */
+const apiBase = new URL('api/', document.baseURI).pathname;
+
class ApiService {
private apiUrl: string = environment.apiUrl;
private locationData: LocationData | null = null;
@@ -62,7 +71,7 @@ class ApiService {
constructor() {
this.axiosInstance = axios.create({
- baseURL: this.apiUrl,
+ baseURL: this.apiUrl || apiBase,
headers: {
'Content-Type': 'application/json'
}
@@ -99,7 +108,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 +159,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 +202,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 +254,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 +286,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 +304,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..cc6482a 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 = "config.json";
let active: AppConfig = bundledConfig;
let loaded = false;
diff --git a/vite.config.ts b/vite.config.ts
index c9c614b..1425ecc 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -47,6 +47,10 @@ const appConfigPlugin: PluginOption = {
// https://vite.dev/config/
export default defineConfig({
+ // Relative, so every asset URL resolves against the the
+ // container injects. Without this they are absolute from / and the app
+ // only works when it owns the origin root.
+ base: "./",
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),