Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<base href>` 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
Expand Down
9 changes: 9 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
6 changes: 5 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/brand/favicon.svg" />
<!-- Rewritten at container start from BASE_PATH; see nginx.conf.template.
Every relative URL in the app resolves against this, which is what
lets the same image be served from / or from /experience/. -->
<base href="/" />
<link rel="icon" type="image/svg+xml" href="brand/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" />
<!-- Fallback only. The configured brand.documentTitle replaces this once
/config.json resolves; see src/lib/config/branding.ts -->
Expand Down
16 changes: 16 additions & 0 deletions nginx.conf → nginx.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<base href="/" />' '<base href="${BASE_PATH}/" />';
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;
Expand Down
4 changes: 4 additions & 0 deletions src/bootstrap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ const routeProgress = createRouteProgress();

const router = createRouter({
routeTree,
// Where the app is mounted, from the <base href> 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: () => (
<div className="bg-background flex h-screen w-screen items-center justify-center">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
4 changes: 2 additions & 2 deletions src/components/screens-component/layouts/chat-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions src/config/app-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
23 changes: 16 additions & 7 deletions src/lib/api-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -62,7 +71,7 @@ class ApiService {

constructor() {
this.axiosInstance = axios.create({
baseURL: this.apiUrl,
baseURL: this.apiUrl || apiBase,
headers: {
'Content-Type': 'application/json'
}
Expand Down Expand Up @@ -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'
});

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
});
Expand Down Expand Up @@ -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
}));
Expand Down Expand Up @@ -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);
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/lib/config/runtime-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ const appConfigPlugin: PluginOption = {

// https://vite.dev/config/
export default defineConfig({
// Relative, so every asset URL resolves against the <base href> 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"),
Expand Down
Loading