diff --git a/.env.example b/.env.example index df63e08e..5b0d7767 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,11 @@ CORS_ALLOW_ORIGIN='*' # For production you should set this to match the proxy configuration (127.0.0.1) FORWARDED_ALLOW_IPS='*' +# Optional URL subpath prefix for reverse-proxy deployments (e.g. "/openwebui"). +# Must start with a leading slash and must not end with a trailing slash. +# Leave empty to serve at the root path. +WEBUI_SUBPATH="" + # DO NOT TRACK SCARF_NO_ANALYTICS=true DO_NOT_TRACK=true diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index abd03628..ca4f9cb7 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -2,6 +2,12 @@ name: Create and publish Docker images with specific build args on: workflow_dispatch: + inputs: + webui_subpath: + description: 'URL subpath to bake into the image (e.g. /openwebui). Leave empty to serve at root.' + required: false + default: '' + type: string push: branches: - main @@ -15,6 +21,7 @@ concurrency: env: REGISTRY: ghcr.io + IMAGE_REPOSITORY: spencercjh/open-webui jobs: build: @@ -57,8 +64,8 @@ jobs: steps: - name: Prepare environment run: | - echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV} - echo "FULL_IMAGE_NAME=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV} + echo "IMAGE_NAME=${IMAGE_REPOSITORY,,}" >> ${GITHUB_ENV} + echo "FULL_IMAGE_NAME=${REGISTRY}/${IMAGE_REPOSITORY,,}" >> ${GITHUB_ENV} platform=${{ matrix.platform.arch }} echo "PLATFORM_PAIR=${platform//\//-}" >> ${GITHUB_ENV} @@ -88,15 +95,15 @@ jobs: with: images: ${{ env.FULL_IMAGE_NAME }} tags: | - type=ref,event=branch - type=ref,event=tag - type=sha,prefix=git- - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - ${{ matrix.variant.suffix != '' && format('type=raw,enable={0},prefix=,suffix=,value={1}', github.ref == 'refs/heads/main', matrix.variant.name) || '' }} + type=ref,event=branch,suffix=${{ steps.tag-context.outputs.combined_suffix }} + type=ref,event=tag,suffix=${{ steps.tag-context.outputs.combined_suffix }} + type=sha,prefix=git-,suffix=${{ steps.tag-context.outputs.combined_suffix }} + type=semver,pattern={{version}},suffix=${{ steps.tag-context.outputs.combined_suffix }} + type=semver,pattern={{major}}.{{minor}},suffix=${{ steps.tag-context.outputs.combined_suffix }} + ${{ format('type=raw,enable={0},value=latest{1}', github.ref == 'refs/heads/main', steps.tag-context.outputs.combined_suffix) }} + ${{ matrix.variant.suffix != '' && format('type=raw,enable={0},value={1}', github.ref == 'refs/heads/main', steps.tag-context.outputs.alias_tag) || '' }} flavor: | - latest=${{ github.ref == 'refs/heads/main' }} - ${{ matrix.variant.suffix != '' && format('suffix={0},onlatest=true', matrix.variant.suffix) || '' }} + latest=false - name: Extract metadata for Docker cache id: cache-meta @@ -110,6 +117,43 @@ jobs: prefix=cache-${{ matrix.variant.name }}-${{ matrix.platform.arch }}- latest=false + - name: Validate WEBUI_SUBPATH + env: + WEBUI_SUBPATH_INPUT: ${{ inputs.webui_subpath }} + run: | + sub="$WEBUI_SUBPATH_INPUT" + if [ -n "$sub" ]; then + case "$sub" in + /*) ;; + *) echo "::error::WEBUI_SUBPATH must start with '/' (got '$sub'); leave empty to serve at root."; exit 1 ;; + esac + case "$sub" in + */) echo "::error::WEBUI_SUBPATH must not end with a trailing '/' (got '$sub'); use e.g. /openwebui."; exit 1 ;; + esac + fi + echo "WEBUI_SUBPATH validated as '${sub:-}'" + + - name: Derive tag context + id: tag-context + env: + WEBUI_SUBPATH_INPUT: ${{ inputs.webui_subpath }} + VARIANT_NAME: ${{ matrix.variant.name }} + VARIANT_SUFFIX: ${{ matrix.variant.suffix }} + run: | + subpath_suffix="" + alias_tag="$VARIANT_NAME" + if [ -n "$WEBUI_SUBPATH_INPUT" ]; then + subpath_suffix="-subpath" + alias_tag="${VARIANT_NAME}${subpath_suffix}" + fi + + combined_suffix="${VARIANT_SUFFIX}${subpath_suffix}" + + { + echo "combined_suffix=${combined_suffix}" + echo "alias_tag=${alias_tag}" + } >> "$GITHUB_OUTPUT" + - name: Build Docker image uses: docker/build-push-action@v5 id: build @@ -124,6 +168,7 @@ jobs: sbom: true build-args: | BUILD_HASH=${{ github.sha }} + WEBUI_SUBPATH=${{ inputs.webui_subpath }} ${{ matrix.variant.build_args }} - name: Export digest @@ -165,8 +210,8 @@ jobs: steps: - name: Prepare environment run: | - echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV} - echo "FULL_IMAGE_NAME=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV} + echo "IMAGE_NAME=${IMAGE_REPOSITORY,,}" >> ${GITHUB_ENV} + echo "FULL_IMAGE_NAME=${REGISTRY}/${IMAGE_REPOSITORY,,}" >> ${GITHUB_ENV} - name: Download digests id: download @@ -201,6 +246,28 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Derive tag context + if: steps.check.outputs.skip != 'true' + id: tag-context + env: + WEBUI_SUBPATH_INPUT: ${{ inputs.webui_subpath }} + VARIANT_NAME: ${{ matrix.variant.name }} + VARIANT_SUFFIX: ${{ matrix.variant.suffix }} + run: | + subpath_suffix="" + alias_tag="$VARIANT_NAME" + if [ -n "$WEBUI_SUBPATH_INPUT" ]; then + subpath_suffix="-subpath" + alias_tag="${VARIANT_NAME}${subpath_suffix}" + fi + + combined_suffix="${VARIANT_SUFFIX}${subpath_suffix}" + + { + echo "combined_suffix=${combined_suffix}" + echo "alias_tag=${alias_tag}" + } >> "$GITHUB_OUTPUT" + - name: Extract metadata for Docker images if: steps.check.outputs.skip != 'true' id: meta @@ -208,15 +275,15 @@ jobs: with: images: ${{ env.FULL_IMAGE_NAME }} tags: | - type=ref,event=branch - type=ref,event=tag - type=sha,prefix=git- - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - ${{ matrix.variant.suffix != '' && format('type=raw,enable={0},prefix=,suffix=,value={1}', github.ref == 'refs/heads/main', matrix.variant.name) || '' }} + type=ref,event=branch,suffix=${{ steps.tag-context.outputs.combined_suffix }} + type=ref,event=tag,suffix=${{ steps.tag-context.outputs.combined_suffix }} + type=sha,prefix=git-,suffix=${{ steps.tag-context.outputs.combined_suffix }} + type=semver,pattern={{version}},suffix=${{ steps.tag-context.outputs.combined_suffix }} + type=semver,pattern={{major}}.{{minor}},suffix=${{ steps.tag-context.outputs.combined_suffix }} + ${{ format('type=raw,enable={0},value=latest{1}', github.ref == 'refs/heads/main', steps.tag-context.outputs.combined_suffix) }} + ${{ matrix.variant.suffix != '' && format('type=raw,enable={0},value={1}', github.ref == 'refs/heads/main', steps.tag-context.outputs.alias_tag) || '' }} flavor: | - latest=${{ github.ref == 'refs/heads/main' }} - ${{ matrix.variant.suffix != '' && format('suffix={0},onlatest=true', matrix.variant.suffix) || '' }} + latest=false - name: Create manifest list and push if: steps.check.outputs.skip != 'true' @@ -233,7 +300,7 @@ jobs: copy-to-dockerhub: runs-on: ubuntu-latest - if: ${{ !cancelled() && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }} + if: ${{ !cancelled() && inputs.webui_subpath == '' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')) }} needs: [merge] continue-on-error: true strategy: @@ -254,8 +321,8 @@ jobs: steps: - name: Prepare environment run: | - echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV} - echo "FULL_IMAGE_NAME=${REGISTRY}/${GITHUB_REPOSITORY,,}" >> ${GITHUB_ENV} + echo "IMAGE_NAME=${IMAGE_REPOSITORY,,}" >> ${GITHUB_ENV} + echo "FULL_IMAGE_NAME=${REGISTRY}/${IMAGE_REPOSITORY,,}" >> ${GITHUB_ENV} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 diff --git a/Dockerfile b/Dockerfile index 36e29e70..14d7ceaa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,7 @@ ARG GID=0 ######## WebUI frontend ######## FROM --platform=$BUILDPLATFORM node:22-alpine3.20 AS build ARG BUILD_HASH +ARG WEBUI_SUBPATH="" # Set Node.js options (heap limit Allocation failed - JavaScript heap out of memory) # ENV NODE_OPTIONS="--max-old-space-size=4096" @@ -40,6 +41,7 @@ RUN npm ci --force COPY . . ENV APP_BUILD_HASH=${BUILD_HASH} +ENV WEBUI_SUBPATH=${WEBUI_SUBPATH} RUN npm run build ######## WebUI backend ######## @@ -205,4 +207,9 @@ ARG BUILD_HASH ENV WEBUI_BUILD_VERSION=${BUILD_HASH} ENV DOCKER=true +# Default the backend runtime subpath to match the value the frontend was built +# with. Can still be overridden at runtime with `-e WEBUI_SUBPATH=...`. +ARG WEBUI_SUBPATH="" +ENV WEBUI_SUBPATH=${WEBUI_SUBPATH} + CMD [ "bash", "start.sh"] diff --git a/backend/open_webui/__init__.py b/backend/open_webui/__init__.py index 92cadefe..6a69f75a 100644 --- a/backend/open_webui/__init__.py +++ b/backend/open_webui/__init__.py @@ -67,7 +67,7 @@ def serve( os.environ['LD_LIBRARY_PATH'] = ':'.join(LD_LIBRARY_PATH) import open_webui.main # noqa: F401 - from open_webui.env import UVICORN_WORKERS # Import the workers setting + from open_webui.env import UVICORN_WORKERS, WEBUI_SUBPATH # Import the workers setting and web subpath # On Windows, uvicorn's default loop factory hardcodes ProactorEventLoop, # which is incompatible with psycopg v3 async. Setting loop='none' lets @@ -81,6 +81,7 @@ def serve( forwarded_allow_ips='*', workers=UVICORN_WORKERS, loop=loop, + root_path=WEBUI_SUBPATH, ) @@ -90,12 +91,15 @@ def dev( port: int = 8080, reload: bool = True, ): + from open_webui.env import WEBUI_SUBPATH + uvicorn.run( 'open_webui.main:app', host=host, port=port, reload=reload, forwarded_allow_ips='*', + root_path=WEBUI_SUBPATH, ) diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 2f6b4ed6..c9ed1a8b 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -774,6 +774,30 @@ if WEBUI_NAME != 'Open WebUI': WEBUI_FAVICON_URL = 'https://openwebui.com/favicon.png' WEBUI_BUILD_HASH = os.getenv('WEBUI_BUILD_HASH', 'dev-build') +# Optional URL subpath prefix for reverse-proxy deployments (e.g. "/openwebui"). +# Leave empty to serve the app at the root path. +WEBUI_SUBPATH = os.getenv('WEBUI_SUBPATH', '') +# Surface the resolved subpath at startup so misconfigured deployments are easy +# to diagnose from the pod logs. +if WEBUI_SUBPATH == '': + log.info('WEBUI_SUBPATH is empty; serving Open WebUI at the root path "/".') +else: + log.info( + 'WEBUI_SUBPATH=%r; Open WebUI will be served under this prefix (FastAPI root_path).', + WEBUI_SUBPATH, + ) + if not WEBUI_SUBPATH.startswith('/'): + log.warning( + 'WEBUI_SUBPATH=%r does not start with "/"; a reverse-proxy subpath must look ' + 'like "/openwebui". This is almost certainly misconfigured.', + WEBUI_SUBPATH, + ) + if WEBUI_SUBPATH != '/' and WEBUI_SUBPATH.endswith('/'): + log.warning( + 'WEBUI_SUBPATH=%r ends with a trailing "/"; it should not (use "/openwebui", ' + 'not "/openwebui/"). This is almost certainly misconfigured.', + WEBUI_SUBPATH, + ) TRUSTED_SIGNATURE_KEY = os.getenv('TRUSTED_SIGNATURE_KEY', '') #################################### diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index e05497c6..300528a4 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -468,6 +468,7 @@ from open_webui.env import ( WEBUI_SECRET_KEY, WEBUI_SESSION_COOKIE_SAME_SITE, WEBUI_SESSION_COOKIE_SECURE, + WEBUI_SUBPATH, ) from open_webui.internal.db import ScopedSession, engine, get_async_session from open_webui.models.access_grants import AccessGrants @@ -752,7 +753,9 @@ app = FastAPI( openapi_url='/openapi.json' if ENV == 'dev' else None, redoc_url=None, lifespan=lifespan, + root_path=WEBUI_SUBPATH, ) +log.info('FastAPI app initialized with root_path=%r (from WEBUI_SUBPATH).', app.root_path) # Used by readiness checks to gate traffic until startup work is done. app.state.startup_complete = False @@ -2401,6 +2404,7 @@ async def get_app_config(request: Request): 'status': True, 'name': app.state.WEBUI_NAME, 'version': VERSION, + 'subpath': WEBUI_SUBPATH, 'default_locale': str(DEFAULT_LOCALE), 'oauth': { 'providers': {name: config.get('name', name) for name, config in OAUTH_PROVIDERS.items()}, @@ -2856,25 +2860,25 @@ async def get_manifest_json(): 'name': app.state.WEBUI_NAME, 'short_name': app.state.WEBUI_NAME, 'description': f'{app.state.WEBUI_NAME} is an open, extensible, user-friendly interface for AI that adapts to your workflow.', - 'start_url': '/', + 'start_url': f'{WEBUI_SUBPATH}/', 'display': 'standalone', 'background_color': '#343541', 'icons': [ { - 'src': '/static/logo.png', + 'src': f'{WEBUI_SUBPATH}/static/logo.png', 'type': 'image/png', 'sizes': '500x500', 'purpose': 'any', }, { - 'src': '/static/logo.png', + 'src': f'{WEBUI_SUBPATH}/static/logo.png', 'type': 'image/png', 'sizes': '500x500', 'purpose': 'maskable', }, ], 'share_target': { - 'action': '/', + 'action': f'{WEBUI_SUBPATH}/', 'method': 'GET', 'params': {'text': 'shared'}, }, @@ -3010,9 +3014,9 @@ def swagger_ui_html(*args, **kwargs): return get_swagger_ui_html( *args, **kwargs, - swagger_js_url='/static/swagger-ui/swagger-ui-bundle.js', - swagger_css_url='/static/swagger-ui/swagger-ui.css', - swagger_favicon_url='/static/swagger-ui/favicon.png', + swagger_js_url=f'{WEBUI_SUBPATH}/static/swagger-ui/swagger-ui-bundle.js', + swagger_css_url=f'{WEBUI_SUBPATH}/static/swagger-ui/swagger-ui.css', + swagger_favicon_url=f'{WEBUI_SUBPATH}/static/swagger-ui/favicon.png', ) diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py index 75ee4e72..b3937b57 100644 --- a/backend/open_webui/routers/models.py +++ b/backend/open_webui/routers/models.py @@ -520,6 +520,10 @@ async def get_model_profile_image( profile_image_url = None updated_at = None + # Prefix static-asset redirects with the ASGI root_path so they keep the + # reverse-proxy subpath (a bare "/static/..." Location drops it → 404). + root_path = request.scope.get('root_path', '').rstrip('/') + # First, check the database for regular models model_meta = await Models.get_model_meta_by_id(id, db=db) if model_meta: @@ -558,7 +562,7 @@ async def get_model_profile_image( # only serve known-safe raster types inline; reject SVG/unknown (can run script on our origin) if media_type not in PROFILE_IMAGE_ALLOWED_MIME_TYPES: return RedirectResponse( - url='/static/favicon.png', + url=f'{root_path}/static/favicon.png', status_code=status.HTTP_302_FOUND, ) @@ -580,12 +584,12 @@ async def get_model_profile_image( safe_static = _safe_static_redirect_path(profile_image_url) if safe_static: return RedirectResponse( - url=safe_static, + url=f'{root_path}{safe_static}', status_code=status.HTTP_302_FOUND, ) return RedirectResponse( - url='/static/favicon.png', + url=f'{root_path}/static/favicon.png', status_code=status.HTTP_302_FOUND, ) diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py index 2884847a..1113d86b 100644 --- a/backend/open_webui/socket/main.py +++ b/backend/open_webui/socket/main.py @@ -29,6 +29,7 @@ from open_webui.env import ( WEBSOCKET_SERVER_LOGGING, WEBSOCKET_SERVER_PING_INTERVAL, WEBSOCKET_SERVER_PING_TIMEOUT, + WEBUI_SUBPATH, ) from open_webui.models.access_grants import AccessGrants from open_webui.models.channels import Channels @@ -231,9 +232,14 @@ async def periodic_usage_pool_cleanup(): release_func() +# engineio's ASGIApp matches the *full* request path (it does not subtract the +# ASGI root_path), so under a reverse-proxy subpath the browser hits +# `${WEBUI_SUBPATH}/ws/socket.io` and the handshake would otherwise 404 into a +# websocket-scope `not_found`. Prefix socketio_path with WEBUI_SUBPATH so it +# stays aligned with the frontend client (`path: ${base}/ws/socket.io`). app = socketio.ASGIApp( sio, - socketio_path='/ws/socket.io', + socketio_path=f'{WEBUI_SUBPATH}/ws/socket.io', ) diff --git a/backend/open_webui/utils/asgi_middleware.py b/backend/open_webui/utils/asgi_middleware.py index 3594b62a..a4a7734f 100644 --- a/backend/open_webui/utils/asgi_middleware.py +++ b/backend/open_webui/utils/asgi_middleware.py @@ -256,7 +256,8 @@ class RedirectMiddleware: redirect_params['q'] = text if redirect_params: - redirect_url = f'/?{urlencode(redirect_params)}' + root_path = (scope.get('root_path') or '').rstrip('/') + redirect_url = f'{root_path}/?{urlencode(redirect_params)}' response = RedirectResponse(url=redirect_url) await response(scope, receive, send) return diff --git a/backend/tests/test_asgi_middleware.py b/backend/tests/test_asgi_middleware.py new file mode 100644 index 00000000..bd0db41e --- /dev/null +++ b/backend/tests/test_asgi_middleware.py @@ -0,0 +1,34 @@ +from starlette.applications import Starlette +from starlette.responses import PlainTextResponse +from starlette.routing import Route +from starlette.testclient import TestClient + +from open_webui.utils.asgi_middleware import RedirectMiddleware + + +async def ok(_request): + return PlainTextResponse('ok') + + +def build_client(*, root_path: str = '') -> TestClient: + app = Starlette(routes=[Route('/', ok), Route('/watch', ok)]) + app.add_middleware(RedirectMiddleware) + return TestClient(app, root_path=root_path) + + +def test_redirect_middleware_keeps_root_path_for_watch_redirect(): + client = build_client(root_path='/openwebui') + + response = client.get('/watch?v=abc123', follow_redirects=False) + + assert response.status_code in (301, 302, 307, 308) + assert response.headers['location'] == '/openwebui/?youtube=abc123' + + +def test_redirect_middleware_uses_root_when_no_root_path_is_set(): + client = build_client() + + response = client.get('/watch?v=abc123', follow_redirects=False) + + assert response.status_code in (301, 302, 307, 308) + assert response.headers['location'] == '/?youtube=abc123' diff --git a/backend/tests/test_env.py b/backend/tests/test_env.py new file mode 100644 index 00000000..0920a357 --- /dev/null +++ b/backend/tests/test_env.py @@ -0,0 +1,11 @@ +import importlib + + +def test_env_exports_webui_subpath(monkeypatch): + monkeypatch.setenv('WEBUI_SECRET_KEY', 'test-secret') + monkeypatch.setenv('WEBUI_SUBPATH', '/openwebui') + + env_module = importlib.import_module('open_webui.env') + env_module = importlib.reload(env_module) + + assert env_module.WEBUI_SUBPATH == '/openwebui' diff --git a/src/app.html b/src/app.html index 285164d0..320af72a 100644 --- a/src/app.html +++ b/src/app.html @@ -2,37 +2,51 @@ - + + - - + + - - + + diff --git a/src/lib/components/channel/Thread.svelte b/src/lib/components/channel/Thread.svelte index ba03f3a6..97e915bc 100644 --- a/src/lib/components/channel/Thread.svelte +++ b/src/lib/components/channel/Thread.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/(app)/admin/analytics/+page.svelte b/src/routes/(app)/admin/analytics/+page.svelte index b0f2b001..2b6b32e8 100644 --- a/src/routes/(app)/admin/analytics/+page.svelte +++ b/src/routes/(app)/admin/analytics/+page.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/(app)/admin/analytics/[tab]/+page.svelte b/src/routes/(app)/admin/analytics/[tab]/+page.svelte index b0f2b001..2b6b32e8 100644 --- a/src/routes/(app)/admin/analytics/[tab]/+page.svelte +++ b/src/routes/(app)/admin/analytics/[tab]/+page.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/(app)/admin/evaluations/+page.svelte b/src/routes/(app)/admin/evaluations/+page.svelte index 176e3162..fd25b01a 100644 --- a/src/routes/(app)/admin/evaluations/+page.svelte +++ b/src/routes/(app)/admin/evaluations/+page.svelte @@ -1,11 +1,12 @@ diff --git a/src/routes/(app)/admin/functions/create/+page.svelte b/src/routes/(app)/admin/functions/create/+page.svelte index bb13a759..9b87731e 100644 --- a/src/routes/(app)/admin/functions/create/+page.svelte +++ b/src/routes/(app)/admin/functions/create/+page.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/(app)/admin/users/+page.svelte b/src/routes/(app)/admin/users/+page.svelte index 3f200961..8cd12c8a 100644 --- a/src/routes/(app)/admin/users/+page.svelte +++ b/src/routes/(app)/admin/users/+page.svelte @@ -1,8 +1,9 @@ diff --git a/src/routes/(app)/automations/+page.svelte b/src/routes/(app)/automations/+page.svelte index 0fef27bd..36d0191d 100644 --- a/src/routes/(app)/automations/+page.svelte +++ b/src/routes/(app)/automations/+page.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/(app)/calendar/+page.svelte b/src/routes/(app)/calendar/+page.svelte index 5aa94c73..ab78862c 100644 --- a/src/routes/(app)/calendar/+page.svelte +++ b/src/routes/(app)/calendar/+page.svelte @@ -2,6 +2,7 @@ import { onMount, getContext, tick } from 'svelte'; import { toast } from 'svelte-sonner'; import { goto } from '$app/navigation'; + import { base } from '$app/paths'; import { WEBUI_NAME, mobile, showSidebar, user } from '$lib/stores'; import { getCalendars, @@ -139,9 +140,9 @@ const evt = e.detail; if (evt.meta?.automation_id) { if (evt.meta?.chat_id) { - goto(`/c/${evt.meta.chat_id}`); + goto(`${base}/c/${evt.meta.chat_id}`); } else { - goto(`/automations/${evt.meta.automation_id}`); + goto(`${base}/automations/${evt.meta.automation_id}`); } return; } diff --git a/src/routes/(app)/home/+layout.svelte b/src/routes/(app)/home/+layout.svelte index 02009c2b..5d40f759 100644 --- a/src/routes/(app)/home/+layout.svelte +++ b/src/routes/(app)/home/+layout.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/(app)/playground/+layout.svelte b/src/routes/(app)/playground/+layout.svelte index 6d71039d..526956dd 100644 --- a/src/routes/(app)/playground/+layout.svelte +++ b/src/routes/(app)/playground/+layout.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/(app)/workspace/functions/create/+page.svelte b/src/routes/(app)/workspace/functions/create/+page.svelte index 14eec3b6..011668de 100644 --- a/src/routes/(app)/workspace/functions/create/+page.svelte +++ b/src/routes/(app)/workspace/functions/create/+page.svelte @@ -1,8 +1,9 @@ diff --git a/src/routes/(app)/workspace/models/create/+page.svelte b/src/routes/(app)/workspace/models/create/+page.svelte index 5ae4ac16..eef1481d 100644 --- a/src/routes/(app)/workspace/models/create/+page.svelte +++ b/src/routes/(app)/workspace/models/create/+page.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/(app)/workspace/prompts/[id]/+page.svelte b/src/routes/(app)/workspace/prompts/[id]/+page.svelte index d16d235d..8cbfb353 100644 --- a/src/routes/(app)/workspace/prompts/[id]/+page.svelte +++ b/src/routes/(app)/workspace/prompts/[id]/+page.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/(app)/workspace/prompts/create/+page.svelte b/src/routes/(app)/workspace/prompts/create/+page.svelte index 988d0dd3..0233118a 100644 --- a/src/routes/(app)/workspace/prompts/create/+page.svelte +++ b/src/routes/(app)/workspace/prompts/create/+page.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/(app)/workspace/tools/create/+page.svelte b/src/routes/(app)/workspace/tools/create/+page.svelte index e0baedcd..3844d5d8 100644 --- a/src/routes/(app)/workspace/tools/create/+page.svelte +++ b/src/routes/(app)/workspace/tools/create/+page.svelte @@ -1,4 +1,5 @@ diff --git a/src/routes/watch/+page.svelte b/src/routes/watch/+page.svelte index 78eab5be..b727a8c1 100644 --- a/src/routes/watch/+page.svelte +++ b/src/routes/watch/+page.svelte @@ -1,4 +1,5 @@ diff --git a/svelte.config.js b/svelte.config.js index 82e05951..9bb7f754 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -3,6 +3,11 @@ import * as child_process from 'node:child_process'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import fs from 'node:fs'; +// Optional URL subpath prefix for reverse-proxy deployments (e.g. "/openwebui"). +// Must start with a leading slash and must not end with a trailing slash. +// Leave empty to serve at the root path. +const WEBUI_BASE_PATH = process.env.WEBUI_SUBPATH || ''; + /** @type {import('@sveltejs/kit').Config} */ const config = { // Consult https://kit.svelte.dev/docs/integrations#preprocessors @@ -17,6 +22,16 @@ const config = { assets: 'build', fallback: 'index.html' }), + paths: { + base: WEBUI_BASE_PATH, + // Bake absolute, base-prefixed asset URLs instead of runtime-relative ones. + // With the default (relative: true) SvelteKit derives `base` from the + // document URL and emits relative `%sveltekit.assets%` paths, so static + // refs in app.html (e.g. /static/favicon.png) and nested-route asset loads + // drop the subpath. A fixed reverse-proxy subpath is always served from + // `base`, so absolute paths are correct and route-independent. + relative: false + }, // poll for new version name every 60 seconds (to trigger reload mechanic in +layout.svelte) version: { name: (() => { diff --git a/vite.config.ts b/vite.config.ts index df802c08..58ae7d7a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,11 @@ import { defineConfig } from 'vite'; import { viteStaticCopy } from 'vite-plugin-static-copy'; +// Optional URL subpath prefix for reverse-proxy deployments (e.g. "/openwebui"). +// Must start with a leading slash and must not end with a trailing slash. +// Leave empty to serve at the root path. +const WEBUI_BASE_PATH = process.env.WEBUI_SUBPATH || ''; + export default defineConfig({ plugins: [ sveltekit(), @@ -20,6 +25,7 @@ export default defineConfig({ APP_VERSION: JSON.stringify(process.env.npm_package_version), APP_BUILD_HASH: JSON.stringify(process.env.APP_BUILD_HASH || 'dev-build') }, + base: WEBUI_BASE_PATH || '/', build: { sourcemap: true },