Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -208,13 +208,18 @@ 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:
context: .
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
Expand Down
11 changes: 11 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ------------------------------------------------------------------
Expand Down
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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,
// 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: () => (
<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 = `${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";

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 = `${import.meta.env.BASE_URL}assets/settings.svg`;
const langIcon = `${import.meta.env.BASE_URL}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
20 changes: 13 additions & 7 deletions src/lib/api-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -62,7 +68,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 +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'
});

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
});
Expand Down Expand Up @@ -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
}));
Expand Down Expand Up @@ -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);
Expand All @@ -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
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 = `${import.meta.env.BASE_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({
// 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"),
Expand Down
Loading