SushantGautam's picture
Copilot
Bump Open WebUI subpath build to v0.12.0 + fix port-owner classification (#51)
8e35ed1 unverified
Raw History Blame Contribute Delete
293 kB
diff --git a/.env.example b/.env.example
index 8426e3a23ef45d631c1311a13173b467dba38946..5eeb72463ddf5bd492b334f462820ad9c0933d1c 100644
--- a/.env.example
+++ b/.env.example
@@ -51,6 +51,11 @@ ENABLE_TOOL_SERVERS=true
# Otherwise, set this to the IP addresses or networks of your trusted reverse proxies.
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/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000000000000000000000000000000000000..92647ef00f6755f8fa18b7c798e0aa66c7801de6
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,111 @@
+# Plan-driven implementation
+
+For work governed by `IMPLEMENTATION_PLAN.md`:
+
+1. `IMPLEMENTATION_PLAN.md` is authoritative. Do not edit it.
+2. Do not redesign the architecture unless the plan is impossible to implement as written.
+3. Do not substitute libraries, abstractions, APIs, naming conventions, or data models merely because you prefer another approach.
+4. Work on exactly one milestone at a time, in order. Do not skip ahead.
+5. Before editing for a milestone:
+ - read the entire milestone in `IMPLEMENTATION_PLAN.md`;
+ - inspect every file/class/function/library explicitly referenced by it;
+ - identify its acceptance criteria (the binary checklist).
+6. Implement the smallest change that satisfies the milestone.
+7. Run every verification command the milestone specifies.
+8. Fix failures before progressing. Never weaken a test to make it pass.
+9. Re-read the milestone after implementation and compare every checklist item against the actual diff.
+10. Update `EXECUTION_STATE.md` (see format there) before moving on.
+11. Do not mark a milestone COMPLETE unless every acceptance-criteria box has evidence (a command you ran and its output).
+12. Never silently omit a requirement.
+13. If a requirement cannot be implemented as specified: mark the milestone BLOCKED in `EXECUTION_STATE.md` with exact evidence (command, error, file/line). Do not invent a workaround that contradicts the plan.
+14. Never proceed past a BLOCKED milestone — stop and wait for a human or a stronger-model review.
+
+## Repo-wide rules for this fork (apply to every milestone)
+
+- This is a downstream fork of Open WebUI (see `FORK.md`). Upstream merges happen periodically; anything you add must survive that process, so prefer additive changes at clearly marked call sites over scattering edits through business logic.
+- Telemetry is OpenTelemetry GenAI semantic conventions first, OpenInference second (compatibility only), nothing invented. Never emit `simpleaudit.*` attributes. `openwebui.*` attributes only for information with no standard equivalent.
+- Do not add SimpleAudit-specific protocol or knowledge anywhere in this repo. The fork must not know SimpleAudit exists — it just emits standard OTLP.
+- Do not install or auto-enable Langfuse/OpenLIT/Phoenix/OpenLLMetry SDKs as runtime dependencies. They are reference material only (read their source, don't vendor it).
+- Instrumentation must be fail-open: a tracing bug can never break a chat, tool call, retrieval, or model request. Wrap span lifecycle code so exceptions inside telemetry never propagate to application code.
+- Structural telemetry (operation names, IDs, counts, scores, token usage, timings) must work correctly with all content capture flags OFF. Content (prompts/outputs/tool args/documents) capture is opt-in and independently toggleable per content type.
+- Never record secrets: Authorization headers, cookies, API keys, OAuth tokens, connection strings. Never use raw email as a trace identity — use existing internal IDs.
+- Don't compute model pricing/cost in this repo — only provider, model, and token usage. Cost is derived externally.
+- Before any expensive attribute extraction/serialization, check `span.is_recording()` first.
+- Derive OTel package versions for `pyproject.toml` from what's already pinned in `backend/requirements.txt` — don't pick new versions independently.
+
+## Environment failures are NOT implementation blockers
+
+This repo is `uv`-managed (`uv.lock` at root). If a required verification command fails before
+actually exercising the changed code — import errors, version-mismatch errors, missing env vars —
+that is a setup problem, not evidence the milestone is impossible. Do not mark BLOCKED on this
+basis alone.
+
+1. Diagnose whether the failure is caused by:
+ - missing dependency installation;
+ - a stale/global virtualenv instead of the repo's managed environment;
+ - a missing test-only environment variable;
+ - a missing development service;
+ - a repository bootstrap/setup step not yet run.
+2. Inspect the repository's canonical setup files before concluding anything is broken:
+ `pyproject.toml`, `uv.lock`, `backend/requirements.txt`, any `.env.example`, `FORK.md`,
+ `.github/workflows/*.yaml`.
+3. Reproduce the repository's intended test environment:
+ - Never invoke bare `python`/`pytest`. Always use `uv run pytest ...` (or `uv sync` first if
+ dependencies look stale/mismatched) so the test runs against the locked dependency set in
+ `uv.lock`, not whatever global interpreter happens to resolve.
+ - If import of `open_webui.env` fails with `WEBUI_SECRET_KEY is not set`, that var is a hard
+ requirement only when `WEBUI_AUTH` is enabled (see `backend/open_webui/env.py`); set it for
+ the test invocation only, e.g. `WEBUI_SECRET_KEY=test-secret-key uv run pytest ...`. Do not
+ hardcode a secret into application code or add a fallback default in `env.py` — this is a
+ test-invocation environment variable, not a code change.
+4. You MAY install/sync declared dependencies (`uv sync`) and set documented test-only environment
+ variables. You MAY NOT change production code, dependency pins, or test assertions merely to
+ accommodate a stale or incorrectly configured local environment.
+5. Only mark BLOCKED after attempting the repository's canonical setup (`uv sync` +
+ `uv run pytest ...` + required test-only env vars) and the failure persists — record the exact
+ command, exact error, and what you already tried to reproduce the canonical environment.
+
+## Continuous execution — do not stop between milestones
+
+Completing a milestone is not a stopping point. After you commit a milestone and update
+`EXECUTION_STATE.md`, immediately start the next incomplete milestone in the same session —
+do not end your turn, do not summarize and wait, do not ask "should I continue?". Keep going,
+milestone after milestone, until either:
+
+- every milestone in `IMPLEMENTATION_PLAN.md` is COMPLETE, or
+- a milestone is genuinely BLOCKED after exhausting the self-recovery steps below, or
+- you are explicitly told to stop.
+
+A short completion report after one milestone is not a deliverable — the deliverable is a
+completed plan. Treat "M1 complete, next is M2" as your cue to start M2 in the same breath, not
+as a place to hand control back.
+
+## Self-recovery from blockers
+
+This is a git repository — nearly every failure mode here is recoverable, so exhaust these before
+writing BLOCKED:
+
+1. Test/lint/typecheck failure caused by your own change: read the failure, fix the code or the
+ test, re-run. This is normal iteration, not a blocker.
+2. Environment failure: follow "Environment failures are NOT implementation blockers" above first.
+3. You made an edit that turned out wrong mid-milestone: use `git diff`/`git stash`/`git checkout
+ -- <file>` to back out just that edit and retry differently. You do not need to ask permission
+ to revert your own uncommitted work.
+4. A dependency install, generated file, or cache is corrupted: re-run the canonical setup
+ (`uv sync`, reinstall) rather than declaring the milestone impossible.
+5. Only after trying the above does a genuine BLOCKED apply: the plan asks for something that
+ contradicts the actual code (e.g. a referenced function no longer exists, a requirement is
+ logically impossible given the current architecture). That is the one case where you stop and
+ wait for a human/stronger-model review — don't keep retrying the same impossible thing, and
+ don't silently reinterpret the requirement to make it easier.
+
+## Execution algorithm (for a fresh executor session)
+
+1. Read, in order: `AGENTS.md` (this file) → `IMPLEMENTATION_PLAN.md` → `EXECUTION_STATE.md` → the repo code relevant to the next incomplete milestone.
+2. Determine the first milestone in `IMPLEMENTATION_PLAN.md` not marked COMPLETE in `EXECUTION_STATE.md`.
+3. Work ONLY on that milestone. Do not perform future milestones, opportunistically refactor unrelated code, change architecture, introduce alternative libraries, or replace a specified pattern with one you consider better.
+4. After implementation: run milestone tests, run relevant existing tests, run lint/typecheck, inspect `git diff`, re-read the milestone, compare every checklist line against the diff, fix discrepancies, re-run verification.
+5. Update `EXECUTION_STATE.md` with: milestone, status, requirements completed, files changed, tests run + results, deviations (NONE or explicit reason), next milestone.
+6. Commit with a message naming the milestone (e.g. `feat(otel): M3 — tool execution spans`).
+7. Continue milestone-by-milestone until the plan is complete or a milestone is genuinely BLOCKED.
+8. At full completion, do one final audit: read `IMPLEMENTATION_PLAN.md` start to finish and compare against repo state; fix any omission before declaring done.
diff --git a/Dockerfile b/Dockerfile
index 4bf9cfc57c24e359de5a30a5b9f741fdb542fa37..35f2f36588f9788b356c4a8c43c875640496c902 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -23,6 +23,7 @@ ARG BUILD_CHANNEL=unknown
# Override at your own risk - non-root configurations are untested
ARG UID=0
ARG GID=0
+ARG WEBUI_SUBPATH=""
######## WebUI frontend ########
FROM --platform=$BUILDPLATFORM node:22-alpine3.20 AS build
@@ -35,6 +36,9 @@ ARG GID
# Set Node.js options (heap limit Allocation failed - JavaScript heap out of memory)
# ENV NODE_OPTIONS="--max-old-space-size=4096"
+# The SvelteKit/Vite production build OOMs at Node's ~2GB default heap.
+ENV NODE_OPTIONS="--max-old-space-size=6144"
+
WORKDIR /app
# to store git revision in build
@@ -46,6 +50,7 @@ RUN npm ci --force
COPY . .
ENV APP_BUILD_HASH=${BUILD_HASH}
ENV APP_BUILD_CHANNEL=${BUILD_CHANNEL}
+ENV WEBUI_SUBPATH=${WEBUI_SUBPATH}
RUN npm run build && \
if [ "$USE_SLIM" = "true" ]; then find build -type f -name '*.map' -delete; fi
@@ -231,4 +236,9 @@ ARG BUILD_HASH
ENV WEBUI_BUILD_HASH=${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/EXECUTION_STATE.md b/EXECUTION_STATE.md
new file mode 100644
index 0000000000000000000000000000000000000000..1fd192a8e659fc4957e17321a85af0e838bc6e37
--- /dev/null
+++ b/EXECUTION_STATE.md
@@ -0,0 +1,163 @@
+# Execution State
+
+Agent-updated. Do not hand-edit except to fix a factual error. One entry per milestone attempt —
+append, don't overwrite history (if a milestone is retried, add a new entry below the old one).
+
+Format per entry:
+
+```
+## M<N> — <milestone title>
+Status: NOT_STARTED | IN_PROGRESS | COMPLETE | BLOCKED
+Requirements completed: <list checklist lines from IMPLEMENTATION_PLAN.md with [x]/[ ]>
+Files changed: <paths>
+Tests run: <exact command(s)>
+Test results: <pass/fail summary, paste key output lines>
+Deviations: NONE | <explicit deviation + reason>
+Next milestone: M<N+1> | NONE (plan complete)
+```
+
+If BLOCKED, also include:
+
+```
+Blocked reason: <precise statement>
+Evidence: <exact command run + exact error output>
+```
+
+---
+
+## M1 — OTLP transport hygiene
+Status: NOT_STARTED
+Requirements completed: (none yet)
+Files changed: (none yet)
+Tests run: (none yet)
+Test results: (none yet)
+Deviations: NONE
+Next milestone: M1
+
+## M1 — OTLP transport hygiene
+Status: BLOCKED
+Requirements completed: [ ] All M1 checklist lines remain unverified because the required test command cannot execute in the current environment.
+Files changed: backend/open_webui/utils/telemetry/setup.py; backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py
+Tests run: `pytest backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py -v`
+Test results: FAIL — 3 failed. Importing OTel fails with `ImportError: cannot import name '_ExtendedAttributes' from 'opentelemetry.util.types'`; importing `open_webui.env` also fails with `SystemExit: WEBUI_SECRET_KEY is not set`.
+Deviations: NONE
+Next milestone: M1
+Blocked reason: The required M1 verification command cannot pass because the execution environment has incompatible installed OTel versions (`opentelemetry-api==1.45.0`, `opentelemetry-sdk==1.39.1`, `opentelemetry-exporter-otlp==1.39.1`, while `backend/requirements.txt` pins the OTel core packages to `1.42.1`) and lacks `WEBUI_SECRET_KEY` for importing the application environment. Satisfying the checklist requires changing the environment or receiving human direction; no code workaround is permitted by the plan.
+Evidence: `python -m pip show opentelemetry-api opentelemetry-sdk opentelemetry-exporter-otlp opentelemetry-semantic-conventions` reported versions `1.45.0`, `1.39.1`, `1.39.1`, and `0.60b1`, respectively. `pytest backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py -v` reported `ImportError: cannot import name '_ExtendedAttributes' from 'opentelemetry.util.types'` and `SystemExit: WEBUI_SECRET_KEY is not set`.
+
+## M1 — OTLP transport hygiene
+Status: COMPLETE
+Requirements completed: [x] Basic-auth Authorization header preserved; [x] unset basic-auth passes `headers=None`; [x] `OTEL_EXPORTER_OTLP_HEADERS=foo=bar` is honored; [x] HTTP and default gRPC exporter paths covered; [x] gRPC `OTEL_EXPORTER_OTLP_INSECURE` preserved; [x] required test file created; [x] required pytest command passes.
+Files changed: backend/open_webui/utils/telemetry/setup.py; backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py
+Tests run: `uv sync`; `uv pip install --python .venv/bin/python -r backend/requirements.txt`; `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py -v`; `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/tests/test_env.py -v`; `uv run ruff check backend/open_webui/utils/telemetry/setup.py backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py`; `git diff --check`
+Test results: PASS — required telemetry test: `3 passed, 1 warning in 2.19s`; existing environment test: `1 passed`; Ruff: `All checks passed!`; diff check: passed.
+Deviations: NONE
+Next milestone: M2
+
+## M2 — Wheel packaging for observability deps
+Status: COMPLETE
+Requirements completed: [x] Added `observability` optional dependency extra listing every `opentelemetry-*` package from `backend/requirements.txt` at its pinned version; [x] clean-venv editable install succeeded; [x] gRPC OTLP exporter import succeeded without a separate package install.
+Files changed: pyproject.toml; uv.lock
+Tests run: `uv sync`; `uv lock`; `clean_env_dir=$(mktemp -d) && uv venv "$clean_env_dir" && uv pip install --python "$clean_env_dir/bin/python" -e '.[observability]' && "$clean_env_dir/bin/python" -c "import opentelemetry.exporter.otlp.proto.grpc.trace_exporter; print('grpc exporter import ok')"`; `uv sync --extra observability && uv run python -c "import opentelemetry.exporter.otlp.proto.grpc.trace_exporter; print('project grpc exporter import ok')"`; `uv run ruff check backend/open_webui/utils/telemetry/setup.py backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py`; `git diff --check`
+Test results: PASS — clean venv installed `open-webui==0.11.4` with OTel dependencies and printed `grpc exporter import ok`; project extra sync printed `project grpc exporter import ok`; Ruff: `All checks passed!`; diff check: passed.
+Deviations: NONE
+Next milestone: M3
+
+## M3 — Semantic core: config, semconv, context, tracer
+Status: COMPLETE
+Requirements completed: [x] Official/fallback semantic constants centralized in `semconv.py`; [x] no semantic-key literals outside `semconv.py`; [x] workflow/inference/embedding/retrieval/rerank/tool context managers with child spans, exception recording, ERROR status, re-raising user exceptions, and fail-open telemetry handling; [x] recording checks precede extraction; [x] capture flags and truncation implemented; [x] streaming wrapper closes on exhaustion, error, and cancellation; [x] InMemorySpanExporter tests cover kinds, errors, privacy, truncation, and cancellation; [x] required pytest command passes.
+Files changed: backend/open_webui/utils/telemetry/genai/__init__.py; backend/open_webui/utils/telemetry/genai/config.py; backend/open_webui/utils/telemetry/genai/semconv.py; backend/open_webui/utils/telemetry/genai/context.py; backend/open_webui/utils/telemetry/genai/tracer.py; backend/open_webui/utils/telemetry/genai/extractors.py; backend/open_webui/utils/telemetry/genai/streaming.py; backend/open_webui/utils/telemetry/genai/openinference.py; backend/open_webui/test/apps/webui/utils/test_genai_tracer.py
+Tests run: `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_tracer.py -v`; `uv run ruff check backend/open_webui/utils/telemetry/genai backend/open_webui/test/apps/webui/utils/test_genai_tracer.py`; semantic-key grep; `git diff --check`
+Test results: PASS — `5 passed in 0.06s`; Ruff: `All checks passed!`; literal-key grep: `literal-key grep passed`; diff check: passed.
+Deviations: NONE
+Next milestone: M4
+
+## M4 — Workflow root span + context propagation
+Status: COMPLETE
+Requirements completed: [x] Chat orchestration call path now wraps `_process_chat`/`process_chat_payload()` in `ai_tracer.workflow(...)`; [x] workflow name is exactly `invoke_workflow open-webui.chat` and contains no IDs; [x] conversation/message/purpose attributes are set and propagated through `context.py`; [x] deferred workflow span remains open until streaming iterator exhaustion/cancellation; [x] background workflow purpose can be non-primary; [x] InMemorySpanExporter tests added; [x] required pytest command passes.
+Files changed: backend/open_webui/main.py; backend/open_webui/utils/telemetry/genai/tracer.py; backend/open_webui/test/apps/webui/utils/test_genai_workflow_context.py
+Tests run: `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_workflow_context.py -v`; `WEBUI_SECRET_KEY=test-secret-key uv run python -c "import open_webui.main; print('main import ok')"`; `uv run ruff check --select I backend/open_webui/main.py backend/open_webui/test/apps/webui/utils/test_genai_workflow_context.py`; `git diff --check`
+Test results: PASS — workflow tests: `3 passed`; main import: `main import ok`; import lint: `All checks passed!`; diff check: passed. Full-file Ruff reports pre-existing unrelated violations in `main.py`.
+Deviations: NONE
+Next milestone: M5
+
+## M5 — Inference spans (model gateway)
+Status: COMPLETE
+Requirements completed: [x] OpenAI-compatible and Ollama chat-completions outbound boundaries wrapped in `ai_tracer.inference`; [x] provider/request model/response model/response ID/usage/finish reason and request sampling attributes supported; [x] streaming spans close only after body iteration; [x] input capture remains flag-controlled through extractors; [x] two-round tool-loop and independent usage test; [x] privacy test for both provider paths; [x] required pytest command passes.
+Files changed: backend/open_webui/routers/openai.py; backend/open_webui/routers/ollama.py; backend/open_webui/utils/telemetry/genai/tracer.py; backend/open_webui/utils/telemetry/genai/inference.py; backend/open_webui/test/apps/webui/utils/test_genai_inference.py
+Tests run: `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_inference.py -v`; `WEBUI_SECRET_KEY=test-secret-key uv run python -c "import open_webui.routers.openai; import open_webui.routers.ollama; print('gateway imports ok')"`; `uv run ruff check --select I --fix backend/open_webui/routers/openai.py backend/open_webui/routers/ollama.py backend/open_webui/utils/telemetry/genai/inference.py backend/open_webui/test/apps/webui/utils/test_genai_inference.py`; `git diff --check`
+Test results: PASS — inference tests: `3 passed`; gateway imports: `gateway imports ok`; import lint: `All checks passed!`; diff check: passed. Full-file Ruff reports pre-existing unrelated violations in the gateway modules.
+Deviations: NONE
+Next milestone: M6
+
+## M6 — Tool execution spans
+Status: COMPLETE
+Requirements completed: [x] Legacy, approved/resumed, and Responses API tool execution paths call the shared tool tracer; [x] tool spans carry `execute_tool`, name, call ID, and official tool type classification; [x] arguments/results honor independent capture flags; [x] active OTel context preserves HTTP auto-instrumentation as a child span; [x] two-tool parent/child topology test; [x] privacy/content tests; [x] required pytest command passes.
+Files changed: backend/open_webui/utils/middleware.py; backend/open_webui/utils/telemetry/genai/semconv.py; backend/open_webui/utils/telemetry/genai/tools.py; backend/open_webui/test/apps/webui/utils/test_genai_tools.py
+Tests run: `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_tools.py -v`; `WEBUI_SECRET_KEY=test-secret-key uv run python -c "import open_webui.utils.middleware; print('middleware import ok')"`; `uv run ruff check --select I --fix backend/open_webui/utils/middleware.py backend/open_webui/test/apps/webui/utils/test_genai_tools.py`; `git diff --check`
+Test results: PASS — tool tests: `3 passed`; middleware import: `middleware import ok`; import lint: `All checks passed!`; diff check: passed.
+Deviations: NONE
+Next milestone: M7
+
+## M7 — Retrieval, embedding, rerank spans
+Status: COMPLETE
+Requirements completed: [x] `get_sources_from_items()` call path is decorated with a retrieval span; [x] retrieval mode, top-k, candidate/selected counts, and data-source metadata are emitted, with query capture gated; [x] result document IDs/file/chunk/rank/score metadata is emitted independently of content capture; [x] embedding callbacks get child spans with independently gated text/vectors; [x] configured rerank callbacks get child RERANKER spans; [x] span-tree and privacy tests added; [x] required pytest command passes.
+Files changed: backend/open_webui/retrieval/utils.py; backend/open_webui/utils/telemetry/genai/semconv.py; backend/open_webui/utils/telemetry/genai/retrieval.py; backend/open_webui/test/apps/webui/utils/test_genai_retrieval.py
+Tests run: `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_retrieval.py -v`; `WEBUI_SECRET_KEY=test-secret-key uv run python -c "import open_webui.retrieval.utils; print('retrieval import ok')"`; `uv run ruff check --select I --fix backend/open_webui/retrieval/utils.py backend/open_webui/utils/telemetry/genai/retrieval.py backend/open_webui/test/apps/webui/utils/test_genai_retrieval.py`; `git diff --check`
+Test results: PASS — retrieval tests: `2 passed`; retrieval import: `retrieval import ok`; import lint: `All checks passed!`; diff check: passed.
+Deviations: NONE
+Next milestone: M8
+
+## M8 — Background vs. primary classification
+Status: COMPLETE
+Requirements completed: [x] Existing task metadata values are mapped to `title_generation`, `followup_generation`, `tags_generation`, and `retrieval_query`; [x] primary inference/workflow defaults remain `primary`; [x] title/background versus primary test coverage added; [x] required pytest command passes.
+Files changed: backend/open_webui/utils/telemetry/genai/purpose.py; backend/open_webui/utils/telemetry/genai/inference.py; backend/open_webui/test/apps/webui/utils/test_genai_purpose.py
+Tests run: `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_purpose.py -v`; `WEBUI_SECRET_KEY=test-secret-key uv run python -c "import open_webui.routers.tasks; print('tasks import ok')"`; `uv run ruff check --select I --fix backend/open_webui/utils/telemetry/genai/inference.py backend/open_webui/test/apps/webui/utils/test_genai_purpose.py`; `git diff --check`
+Test results: PASS — purpose tests: `2 passed`; tasks import: `tasks import ok`; import lint: `All checks passed!`; diff check: passed.
+Deviations: NONE
+Next milestone: M9
+
+## M9 — Conformance test suite + golden topology snapshots
+Status: COMPLETE
+Requirements completed: [x] Conformance coverage exists for all 20 scenario rows with graph/attribute assertions; [x] four golden topology JSON snapshots exist and are diffed by tests; [x] required conformance pytest command passes.
+Files changed: backend/open_webui/test/apps/webui/utils/test_genai_conformance.py; backend/open_webui/test/apps/webui/utils/telemetry/golden/simple_chat.json; backend/open_webui/test/apps/webui/utils/telemetry/golden/rag_chat.json; backend/open_webui/test/apps/webui/utils/telemetry/golden/tool_loop.json; backend/open_webui/test/apps/webui/utils/telemetry/golden/rag_rerank_tool.json
+Tests run: `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_conformance.py -v`; `uv run ruff check --select I --fix backend/open_webui/test/apps/webui/utils/test_genai_conformance.py`; `git diff --check`
+Test results: PASS — `24 passed in 0.08s`; import lint: `All checks passed!`; diff check: passed.
+Deviations: NONE
+Next milestone: M10
+
+## M10 — Cross-backend interoperability check
+Status: COMPLETE
+Requirements completed: [x] Documented export of the M9 `rag_rerank_tool` representative trace to local Phoenix, local/hosted Langfuse, and generic Jaeger/Tempo using standard `OTEL_EXPORTER_OTLP_ENDPOINT` changes; [x] documented backend-specific rendering differences as known differences with no backend-specific code.
+Files changed: backend/open_webui/utils/telemetry/genai/README.md; EXECUTION_STATE.md
+Tests run: `test -s backend/open_webui/utils/telemetry/genai/README.md && rg -n "OTEL_GENAI_|Phoenix|Langfuse|Jaeger|Tempo|rag_rerank_tool|known differences" backend/open_webui/utils/telemetry/genai/README.md`; `git diff --check`
+Test results: PASS — README contains all required destinations, fixture, environment procedure, and known-differences section; diff check passed.
+Deviations: NONE
+Next milestone: M11
+
+## M11 — Performance gates
+Status: COMPLETE
+Requirements completed: [x] Disabled tracing benchmark stays within the agreed 20 ms total overhead for 20,000 spans; [x] disabled capture and non-recording paths prove extractor JSON serialization is not called; [x] explicit caps tested for tool arguments, tool results, message content, retrieval document count, and exception payload size; [x] required performance test file added; [x] required pytest command passes.
+Files changed: backend/open_webui/utils/telemetry/genai/config.py; backend/open_webui/utils/telemetry/genai/tracer.py; backend/open_webui/utils/telemetry/genai/retrieval.py; backend/open_webui/utils/telemetry/genai/streaming.py; backend/open_webui/utils/telemetry/genai/README.md; backend/open_webui/test/apps/webui/utils/test_genai_performance.py; EXECUTION_STATE.md
+Tests run: `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_performance.py -v`; `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_tracer.py backend/open_webui/test/apps/webui/utils/test_genai_retrieval.py backend/open_webui/test/apps/webui/utils/test_genai_conformance.py -q`; `uv run ruff check backend/open_webui/utils/telemetry/genai backend/open_webui/test/apps/webui/utils/test_genai_performance.py`; `git diff --check`
+Test results: PASS — performance tests `3 passed in 0.12s`; regression/conformance tests `31 passed in 0.08s`; Ruff: `All checks passed!`; diff check passed.
+Deviations: NONE
+Next milestone: M12
+
+## M12 — Fork maintenance documentation
+Status: COMPLETE
+Requirements completed: [x] `FORK.md` documents the downstream semantic telemetry patch, all M4–M8 hook-point files, and the M9 conformance acceptance command; [x] `backend/open_webui/utils/telemetry/genai/README.md` documents every `OTEL_GENAI_*` variable and the M10 interoperability procedure.
+Files changed: FORK.md; backend/open_webui/utils/telemetry/genai/README.md; EXECUTION_STATE.md
+Tests run: `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_conformance.py -v`; `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_performance.py -q`; `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py -v`; `uv run ruff check --select I backend/open_webui/test/apps/webui/utils/test_genai_conformance.py backend/open_webui/test/apps/webui/utils/test_genai_performance.py backend/open_webui/utils/telemetry/genai`; `git diff --check`; full-plan audit: `sed -n '1,430p' IMPLEMENTATION_PLAN.md`
+Test results: PASS — conformance `24 passed in 0.10s`; performance `3 passed in 0.10s`; locked telemetry setup `3 passed, 1 warning in 3.32s`; import lint `All checks passed!`; diff check passed; full-plan audit completed with M1–M12 checklist coverage.
+Deviations: NONE
+Next milestone: NONE (plan complete)
+
+## M13 — Opt-in content capture enablement
+Status: COMPLETE
+Requirements completed: [x] Buffered inference outputs are emitted when `OTEL_GENAI_CAPTURE_OUTPUTS=true`; [x] streamed inference output is collected until stream completion/cancellation and capped; [x] embedding vectors and retrieved document content are size-capped; [x] content capture remains independently flag-controlled and default-off; [x] protected runtime configuration documented; [x] SimpleAudit OTLP parsing regression confirms `gen_ai.*` content attributes are preserved.
+Files changed: backend/open_webui/utils/telemetry/genai/tracer.py; backend/open_webui/utils/telemetry/genai/inference.py; backend/open_webui/utils/telemetry/genai/streaming.py; backend/open_webui/utils/telemetry/genai/retrieval.py; backend/open_webui/utils/telemetry/genai/README.md; backend/open_webui/test/apps/webui/utils/test_genai_inference.py; backend/open_webui/test/apps/webui/utils/test_genai_retrieval.py; /Users/sushantgautam/Documents/SimpleAudit/tests/test_tracing.py
+Tests run: `WEBUI_SECRET_KEY=test-secret-key uv run pytest backend/open_webui/test/apps/webui/utils/test_genai_inference.py backend/open_webui/test/apps/webui/utils/test_genai_retrieval.py backend/open_webui/test/apps/webui/utils/test_genai_tracer.py backend/open_webui/test/apps/webui/utils/test_genai_tools.py backend/open_webui/test/apps/webui/utils/test_genai_conformance.py backend/open_webui/test/apps/webui/utils/test_genai_performance.py -q`; `uv run ruff check backend/open_webui/utils/telemetry/genai backend/open_webui/test/apps/webui/utils/test_genai_inference.py backend/open_webui/test/apps/webui/utils/test_genai_retrieval.py`; `uv sync --extra dev --extra tracing && uv run python -m pytest tests/test_tracing.py -q` in SimpleAudit; `git diff --check`
+Test results: PASS — Open WebUI `43 passed in 0.29s`; Ruff `All checks passed!`; SimpleAudit `39 passed in 9.46s`; diff checks passed.
+Deviations: NONE
+Next milestone: NONE (plan complete)
diff --git a/README.md b/README.md
index ab0fdd1345cc7b25f4146db15ac8b8047c625f58..5e3962358f2124a5976cfd26de37e0351250281c 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,53 @@
[![Discord](https://img.shields.io/badge/Discord-Open_WebUI-blue?logo=discord&logoColor=white)](https://discord.gg/5rJgQTnV4s)
[![](https://img.shields.io/static/v1?label=Sponsor&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/open-webui)
+> [!NOTE]
+>
+> ## 🍴 About this fork — subpath support
+>
+> Upstream Open WebUI can only be served at the **origin root**. This fork adds
+> **`WEBUI_SUBPATH`** so you can serve it under a **URL prefix** (e.g. `/chat`)
+> behind a reverse proxy — e.g. `https://studio.example.com/chat/`. It is
+> upstream **v0.11.4** plus a small downstream patch (upstream declined the
+> feature in open-webui/open-webui#10440 and open-webui/open-webui#23242; otherwise everything is
+> stock upstream).
+>
+> You can use it exactly like the official release — **Docker, uvx, pip, or uv**:
+>
+> - **Docker (recommended)** — multi-arch image with the subpath baked in at
+> build time:
+>
+> ```bash
+> docker pull ghcr.io/sushantgautam/open-webui:v0.11.4-subpath
+> ```
+>
+> - **uvx** — fastest one-liner; [`uvx`](https://docs.astral.sh/uv/) fetches
+> the pre-built wheel from the
+> [`v0.11.4-subpath` GitHub Release](https://github.com/SushantGautam/open-webui/releases/tag/v0.11.4-subpath)
+> and runs it, no environment to manage:
+>
+> ```bash
+> WEBUI_SUBPATH=/chat uvx --from https://github.com/SushantGautam/open-webui/releases/download/v0.11.4-subpath/open_webui-0.11.4-py3-none-any.whl open-webui serve
+> ```
+>
+> Great for a quick run. For a permanent deployment (persistent data,
+> secrets, models), install into an environment instead:
+>
+> - **pip / uv** — install the same pre-built wheel:
+>
+> ```bash
+> pip install https://github.com/SushantGautam/open-webui/releases/download/v0.11.4-subpath/open_webui-0.11.4-py3-none-any.whl
+> # or: uv pip install <same wheel URL>
+> WEBUI_SUBPATH=/chat open-webui serve
+> ```
+>
+> The subpath is **baked in at build time** — one image/wheel serves one
+> subpath. Serve behind a proxy that forwards the prefix **without stripping**
+> (e.g. `/chat/* → open-webui:8080`).
+>
+> 📖 **Full details: [FORK.md](FORK.md)** — what the patch changes, tag naming,
+> publishing, proxy setup, and the upgrade recipe.
+
Open WebUI is **a home for AI**, a self-hosted AI platform that's **[extensible](https://docs.openwebui.com/features/extensibility/plugin/)**, **[feature-rich](https://docs.openwebui.com/features/)**, user-friendly, and built to run **[entirely offline](https://openwebui.com/sovereign-ai)**. With support for **Ollama** and **OpenAI-compatible APIs**, it gives you a powerful, provider-agnostic interface for both local and cloud-based models.
Passionate about open-source AI? [Join our team →](https://careers.openwebui.com/)
diff --git a/backend/open_webui/__init__.py b/backend/open_webui/__init__.py
index 0611bda1c6250e152608795c60b4d6c9a657c2e7..03ef264b74d060c06ab540ce4aa6e3594cb33e08 100644
--- a/backend/open_webui/__init__.py
+++ b/backend/open_webui/__init__.py
@@ -76,7 +76,11 @@ def serve(
os.environ['LD_LIBRARY_PATH'] = ':'.join(LD_LIBRARY_PATH)
import open_webui.main # noqa: F401
- from open_webui.env import UVICORN_WORKERS, UVICORN_WS_PER_MESSAGE_DEFLATE
+ from open_webui.env import (
+ UVICORN_WORKERS,
+ UVICORN_WS_PER_MESSAGE_DEFLATE,
+ 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
@@ -91,6 +95,7 @@ def serve(
workers=UVICORN_WORKERS,
ws_per_message_deflate=UVICORN_WS_PER_MESSAGE_DEFLATE,
loop=loop,
+ root_path=WEBUI_SUBPATH,
)
@@ -100,7 +105,7 @@ def dev(
port: int = 8080,
reload: bool = True,
):
- from open_webui.env import UVICORN_WS_PER_MESSAGE_DEFLATE
+ from open_webui.env import UVICORN_WS_PER_MESSAGE_DEFLATE, WEBUI_SUBPATH
uvicorn.run(
'open_webui.main:app',
@@ -109,6 +114,7 @@ def dev(
reload=reload,
forwarded_allow_ips=os.getenv('FORWARDED_ALLOW_IPS', '*'),
ws_per_message_deflate=UVICORN_WS_PER_MESSAGE_DEFLATE,
+ root_path=WEBUI_SUBPATH,
)
diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py
index 1f3a719bcf6d2ccf074cae939490cb0ca26eb65c..f4387a13033caa911c31cdf9c5f7bc8a1c343cc7 100644
--- a/backend/open_webui/env.py
+++ b/backend/open_webui/env.py
@@ -964,6 +964,30 @@ if WEBUI_NAME != 'Open WebUI':
# https://docs.openwebui.com/license.
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 f5181fb1b65c84e997e0b672fd2b8add82f0d220..f95a398483c0abb6fd2828d49935daad0e60688d 100644
--- a/backend/open_webui/main.py
+++ b/backend/open_webui/main.py
@@ -127,6 +127,7 @@ from open_webui.env import (
WEBUI_SECRET_KEY,
WEBUI_SESSION_COOKIE_SAME_SITE,
WEBUI_SESSION_COOKIE_SECURE,
+ WEBUI_SUBPATH,
)
from open_webui.events import (
EVENTS,
@@ -280,6 +281,8 @@ from open_webui.utils.plugin import install_tool_and_function_dependencies
from open_webui.utils.recurrence import RecurrenceEvaluationTimeout
from open_webui.utils.redis import get_redis_client
from open_webui.utils.session_pool import cleanup_response, get_client_timeout, get_session, stream_wrapper
+from open_webui.utils.telemetry.genai.streaming import wrap_stream as wrap_genai_stream
+from open_webui.utils.telemetry.genai.tracer import GenAITracer
from open_webui.utils.tool_approval import (
ResolveToolCallForm,
build_tool_approval_resume_payload,
@@ -538,7 +541,10 @@ 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)
+ai_tracer = GenAITracer()
@app.exception_handler(RecurrenceEvaluationTimeout)
@@ -1635,7 +1641,7 @@ async def chat_completion(
detail=str(e),
)
- async def process_chat(request, form_data, user, metadata, model, tasks=None):
+ async def _process_chat(request, form_data, user, metadata, model, tasks=None):
error_detail = None
try:
try:
@@ -1805,6 +1811,25 @@ async def chat_completion(
except Exception:
log.exception('Failed to process pending internal messages for chat %s', metadata.get('chat_id'))
+ async def process_chat(request, form_data, user, metadata, model, tasks=None):
+ with ai_tracer.workflow(
+ 'invoke_workflow open-webui.chat',
+ operation_name='invoke_workflow',
+ conversation_id=metadata.get('chat_id'),
+ message_id=metadata.get('message_id'),
+ purpose='primary',
+ _defer_end=True,
+ ) as workflow_span:
+ result = await _process_chat(request, form_data, user, metadata, model, tasks)
+ if isinstance(result, StreamingResponse):
+ result.body_iterator = wrap_genai_stream(workflow_span, result.body_iterator)
+ else:
+ try:
+ workflow_span.end()
+ except Exception:
+ log.exception('Unable to end chat workflow span')
+ return result
+
# Fan out: one task per model
if metadata.get('session_id') and metadata.get('chat_id'):
task_ids = []
@@ -2375,6 +2400,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),
'i18n': config.get('ui.i18n') or {},
'oauth': {
@@ -2970,7 +2996,7 @@ 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': [
@@ -2978,20 +3004,20 @@ async def get_manifest_json():
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
# https://docs.openwebui.com/license.
{
- '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'},
},
@@ -3136,9 +3162,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/retrieval/utils.py b/backend/open_webui/retrieval/utils.py
index a0b2d3a11e403d9f0dc0f6ca8ae1c89a6c92d2ec..931b8f893eed1fc36b6a4b0fee21a8144da03202 100644
--- a/backend/open_webui/retrieval/utils.py
+++ b/backend/open_webui/retrieval/utils.py
@@ -56,8 +56,11 @@ from open_webui.utils.access_control.files import get_owner_accessible_folder_fi
from open_webui.utils.access_control.folders import has_folder_access
from open_webui.utils.headers import get_json_bearer_headers, include_user_info_headers
from open_webui.utils.misc import get_content_from_message, get_message_list
+from open_webui.utils.telemetry.genai.retrieval import instrument_reranker, traced_retrieval
+from open_webui.utils.telemetry.genai.tracer import GenAITracer
log = logging.getLogger(__name__)
+ai_tracer = GenAITracer()
from typing import Any
@@ -1258,8 +1261,11 @@ def get_reranking_function(reranking_engine, reranking_model, reranking_function
if reranking_function is None:
return None
if reranking_engine == 'external':
- return lambda query, documents, user=None: reranking_function.predict(
- [(query, doc.page_content) for doc in documents], user=user
+ return instrument_reranker(
+ tracer=ai_tracer,
+ reranking_function=lambda query, documents, user=None: reranking_function.predict(
+ [(query, doc.page_content) for doc in documents], user=user
+ ),
)
else:
@@ -1269,7 +1275,7 @@ def get_reranking_function(reranking_engine, reranking_model, reranking_function
[(query, doc.page_content) for doc in documents], batch_size=int(reranking_batch_size)
)
- return predict
+ return instrument_reranker(tracer=ai_tracer, reranking_function=predict)
# UUIDs, SHA-256 digests, and prefixed variants thereof all fit [A-Za-z0-9_-].
@@ -1353,6 +1359,7 @@ def filter_source_metadata(metadata: dict) -> dict:
return {key: metadata[key] for key in RAG_SOURCE_METADATA_KEYS if metadata.get(key) is not None}
+@traced_retrieval(ai_tracer)
async def get_sources_from_items(
request,
items,
diff --git a/backend/open_webui/routers/models.py b/backend/open_webui/routers/models.py
index 6925275d98020d6a225d32567a02511473c9cae1..ff829a530974c4df46479a2592a8d4b54006160e 100644
--- a/backend/open_webui/routers/models.py
+++ b/backend/open_webui/routers/models.py
@@ -999,6 +999,10 @@ async def get_model_profile_image(
bypass_access_control = BYPASS_MODEL_ACCESS_CONTROL or (user.role == 'admin' and BYPASS_ADMIN_ACCESS_CONTROL)
+ # 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:
@@ -1056,7 +1060,7 @@ async def get_model_profile_image(
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
# https://docs.openwebui.com/license.
return RedirectResponse(
- url='/static/favicon.png',
+ url=f'{root_path}/static/favicon.png',
status_code=status.HTTP_302_FOUND,
)
@@ -1078,7 +1082,7 @@ 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,
)
@@ -1086,7 +1090,7 @@ async def get_model_profile_image(
# Do not alter, remove, obscure, or replace it except as LICENSE permits:
# https://docs.openwebui.com/license.
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/routers/ollama.py b/backend/open_webui/routers/ollama.py
index fd5bf331aff445c95650e556da3a58455ddc6287..5a8a1f05372328ef1ffd7e82b76616d3fa484bf4 100644
--- a/backend/open_webui/routers/ollama.py
+++ b/backend/open_webui/routers/ollama.py
@@ -44,10 +44,13 @@ from open_webui.utils.payload import (
apply_system_prompt_to_body,
)
from open_webui.utils.session_pool import cleanup_response, get_client_timeout, get_session, stream_wrapper
+from open_webui.utils.telemetry.genai.inference import traced_inference
+from open_webui.utils.telemetry.genai.tracer import GenAITracer
from pydantic import BaseModel, ConfigDict, validator
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
+ai_tracer = GenAITracer()
# Headers that become stale after aiohttp auto-decompresses the upstream
# response body. Forwarding them verbatim causes desktop / programmatic
@@ -89,7 +92,7 @@ async def send_get_request(
return None
-async def send_request(
+async def _send_request(
url: str,
method: str = 'POST',
*,
@@ -183,6 +186,63 @@ async def send_request(
await cleanup_response(r)
+async def send_request(
+ url: str,
+ method: str = 'POST',
+ *,
+ payload: Union[str, bytes | None] = None,
+ key: str | None = None,
+ user: UserModel = None,
+ stream: bool = False,
+ passthrough: bool = False,
+ content_type: str | None = None,
+ metadata: dict | None = None,
+ api_config: dict | None = None,
+ request: Request | None = None,
+):
+ if '/chat/completions' not in url:
+ return await _send_request(
+ url,
+ method,
+ payload=payload,
+ key=key,
+ user=user,
+ stream=stream,
+ passthrough=passthrough,
+ content_type=content_type,
+ metadata=metadata,
+ api_config=api_config,
+ request=request,
+ )
+
+ try:
+ request_payload = JSONCodec.loads(payload) if isinstance(payload, (str, bytes)) else (payload or {})
+ except Exception:
+ request_payload = {}
+
+ async def call():
+ return await _send_request(
+ url,
+ method,
+ payload=payload,
+ key=key,
+ user=user,
+ stream=stream,
+ passthrough=passthrough,
+ content_type=content_type,
+ metadata=metadata,
+ api_config=api_config,
+ request=request,
+ )
+
+ return await traced_inference(
+ ai_tracer,
+ provider='ollama',
+ request_payload=request_payload,
+ call=call,
+ )
+
+
def get_api_key(idx, url, configs):
parsed_url = urlparse(url)
base_url = f'{parsed_url.scheme}://{parsed_url.netloc}'
diff --git a/backend/open_webui/routers/openai.py b/backend/open_webui/routers/openai.py
index 8548a8ab0dd6c99c6585fb881f36db4b21f2799b..d867b3a3bccac559ab9993f39bac1a3783f1c290 100644
--- a/backend/open_webui/routers/openai.py
+++ b/backend/open_webui/routers/openai.py
@@ -54,10 +54,13 @@ from open_webui.utils.session_pool import (
get_session,
stream_wrapper,
)
+from open_webui.utils.telemetry.genai.inference import traced_inference
+from open_webui.utils.telemetry.genai.tracer import GenAITracer
from pydantic import BaseModel, ConfigDict
from sqlalchemy.ext.asyncio import AsyncSession
log = logging.getLogger(__name__)
+ai_tracer = GenAITracer()
##########################################
@@ -1408,6 +1411,24 @@ async def generate_chat_completion(
request: Request,
form_data: dict,
user=Depends(get_verified_user),
+):
+ request_payload = dict(form_data)
+
+ async def call():
+ return await _generate_chat_completion(request, form_data, user)
+
+ return await traced_inference(
+ ai_tracer,
+ provider='openai-compatible',
+ request_payload=request_payload,
+ call=call,
+ )
+
+
+async def _generate_chat_completion(
+ request: Request,
+ form_data: dict,
+ user=Depends(get_verified_user),
):
if not await Config.get('openai.enable'):
raise HTTPException(status_code=503, detail='OpenAI API is disabled')
diff --git a/backend/open_webui/socket/main.py b/backend/open_webui/socket/main.py
index c2e7d810d57bcc4440b2c0945f9f4a34e6966d1d..6900836850636ca39f01da23339ca128207a4c78 100644
--- a/backend/open_webui/socket/main.py
+++ b/backend/open_webui/socket/main.py
@@ -34,6 +34,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
@@ -321,9 +322,14 @@ async def periodic_usage_pool_cleanup():
await asyncio.sleep(retry_delay)
+# 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/test/apps/webui/utils/telemetry/golden/rag_chat.json b/backend/open_webui/test/apps/webui/utils/telemetry/golden/rag_chat.json
new file mode 100644
index 0000000000000000000000000000000000000000..520090f55ead96053ccc3a1b91ec637118e1df13
--- /dev/null
+++ b/backend/open_webui/test/apps/webui/utils/telemetry/golden/rag_chat.json
@@ -0,0 +1,5 @@
+[
+ {"name": "embedding", "parent": "retrieval", "attributes": ["openinference.span.kind"]},
+ {"name": "retrieval", "parent": "invoke_workflow open-webui.chat", "attributes": ["gen_ai.operation.name", "gen_ai.retrieval.documents", "openinference.span.kind", "openwebui.retrieval.top_k"]},
+ {"name": "invoke_workflow open-webui.chat", "parent": null, "attributes": ["gen_ai.operation.name", "openinference.span.kind", "openwebui.operation.purpose"]}
+]
diff --git a/backend/open_webui/test/apps/webui/utils/telemetry/golden/rag_rerank_tool.json b/backend/open_webui/test/apps/webui/utils/telemetry/golden/rag_rerank_tool.json
new file mode 100644
index 0000000000000000000000000000000000000000..9d5a6884b624a62bafaf2fffbfb6ecf61070d2d6
--- /dev/null
+++ b/backend/open_webui/test/apps/webui/utils/telemetry/golden/rag_rerank_tool.json
@@ -0,0 +1,8 @@
+[
+ {"name": "embedding", "parent": "retrieval", "attributes": ["openinference.span.kind"]},
+ {"name": "vector_search", "parent": "retrieval", "attributes": []},
+ {"name": "rerank", "parent": "retrieval", "attributes": ["openinference.span.kind", "openwebui.retrieval.candidate_count"]},
+ {"name": "execute_tool", "parent": "retrieval", "attributes": ["gen_ai.operation.name", "gen_ai.tool.call.id", "gen_ai.tool.name", "gen_ai.tool.type", "openinference.span.kind"]},
+ {"name": "retrieval", "parent": "invoke_workflow open-webui.chat", "attributes": ["gen_ai.operation.name", "gen_ai.retrieval.documents", "openinference.span.kind", "openwebui.retrieval.top_k"]},
+ {"name": "invoke_workflow open-webui.chat", "parent": null, "attributes": ["gen_ai.operation.name", "openinference.span.kind", "openwebui.operation.purpose"]}
+]
diff --git a/backend/open_webui/test/apps/webui/utils/telemetry/golden/simple_chat.json b/backend/open_webui/test/apps/webui/utils/telemetry/golden/simple_chat.json
new file mode 100644
index 0000000000000000000000000000000000000000..9d0688479293683abd76750dc41f3993c76fab58
--- /dev/null
+++ b/backend/open_webui/test/apps/webui/utils/telemetry/golden/simple_chat.json
@@ -0,0 +1,4 @@
+[
+ {"name": "chat_completion", "parent": "invoke_workflow open-webui.chat", "attributes": ["gen_ai.provider.name", "gen_ai.request.model", "gen_ai.response.finish_reasons", "gen_ai.response.id", "gen_ai.response.model", "gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens", "openinference.span.kind", "openwebui.operation.purpose"]},
+ {"name": "invoke_workflow open-webui.chat", "parent": null, "attributes": ["gen_ai.operation.name", "openinference.span.kind", "openwebui.operation.purpose"]}
+]
diff --git a/backend/open_webui/test/apps/webui/utils/telemetry/golden/tool_loop.json b/backend/open_webui/test/apps/webui/utils/telemetry/golden/tool_loop.json
new file mode 100644
index 0000000000000000000000000000000000000000..d20d1e45a7e032f58ec4ab655e20e3478e64712f
--- /dev/null
+++ b/backend/open_webui/test/apps/webui/utils/telemetry/golden/tool_loop.json
@@ -0,0 +1,5 @@
+[
+ {"name": "chat_completion", "parent": "invoke_workflow open-webui.chat", "attributes": ["gen_ai.provider.name", "gen_ai.request.model", "gen_ai.response.finish_reasons", "gen_ai.response.id", "gen_ai.response.model", "gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens", "openinference.span.kind", "openwebui.operation.purpose"]},
+ {"name": "chat_completion", "parent": "invoke_workflow open-webui.chat", "attributes": ["gen_ai.provider.name", "gen_ai.request.model", "gen_ai.response.finish_reasons", "gen_ai.response.id", "gen_ai.response.model", "gen_ai.usage.input_tokens", "gen_ai.usage.output_tokens", "openinference.span.kind", "openwebui.operation.purpose"]},
+ {"name": "invoke_workflow open-webui.chat", "parent": null, "attributes": ["gen_ai.operation.name", "openinference.span.kind", "openwebui.operation.purpose"]}
+]
diff --git a/backend/open_webui/test/apps/webui/utils/test_genai_conformance.py b/backend/open_webui/test/apps/webui/utils/test_genai_conformance.py
new file mode 100644
index 0000000000000000000000000000000000000000..8f62c20fa0b59e050d6907b196cc83227e977799
--- /dev/null
+++ b/backend/open_webui/test/apps/webui/utils/test_genai_conformance.py
@@ -0,0 +1,228 @@
+import asyncio
+import json
+from pathlib import Path
+
+import pytest
+from open_webui.utils.telemetry.genai.config import GenAIConfig
+from open_webui.utils.telemetry.genai.inference import traced_inference
+from open_webui.utils.telemetry.genai.retrieval import instrument_embedding, instrument_reranker
+from open_webui.utils.telemetry.genai.tools import traced_tool
+from open_webui.utils.telemetry.genai.tracer import GenAITracer
+from opentelemetry import trace
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import SimpleSpanProcessor
+from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
+from opentelemetry.trace import StatusCode
+
+GOLDEN_DIR = Path(__file__).parent / 'telemetry' / 'golden'
+
+
+def setup(config=None):
+ exporter = InMemorySpanExporter()
+ provider = TracerProvider()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+ return GenAITracer(provider.get_tracer('conformance'), config or GenAIConfig()), exporter
+
+
+def topology(spans):
+ by_id = {span.context.span_id: span.name for span in spans}
+ return [
+ {
+ 'name': span.name,
+ 'parent': by_id.get(span.parent.span_id) if span.parent else None,
+ 'attributes': sorted(span.attributes),
+ }
+ for span in spans
+ ]
+
+
+async def successful_response():
+ return {
+ 'id': 'response-id',
+ 'model': 'actual-model',
+ 'usage': {'prompt_tokens': 2, 'completion_tokens': 3},
+ 'choices': [{'finish_reason': 'stop'}],
+ }
+
+
+async def build_scenario(name, tracer):
+ with tracer.workflow('invoke_workflow open-webui.chat', operation_name='invoke_workflow', purpose='primary'):
+ if name in {'simple_chat', 'openai', 'ollama', 'no_chat_id', 'background_title', 'followup'}:
+ task = None
+ if name == 'background_title':
+ task = 'title_generation'
+ elif name == 'followup':
+ task = 'follow_up_generation'
+ await traced_inference(
+ tracer,
+ provider='ollama' if name == 'ollama' else 'openai-compatible',
+ request_payload={'model': 'requested-model', 'metadata': {'task': task} if task else {}},
+ call=successful_response,
+ )
+ elif name == 'streaming':
+
+ async def stream():
+ yield 'first'
+ yield 'final'
+
+ async def response():
+ return type('Response', (), {'body_iterator': stream()})()
+
+ result = await traced_inference(
+ tracer,
+ provider='openai-compatible',
+ request_payload={'model': 'model', 'stream': True},
+ call=response,
+ )
+ [item async for item in result.body_iterator]
+ elif name == 'cancelled':
+ span = tracer.tracer.start_span('chat_completion')
+
+ async def stream():
+ yield 'first'
+ await asyncio.sleep(10)
+
+ from open_webui.utils.telemetry.genai.streaming import wrap_stream
+
+ wrapped = wrap_stream(span, stream())
+ await wrapped.__anext__()
+ await wrapped.aclose()
+ elif name in {'one_tool', 'multiple_tools', 'mcp_tool'}:
+ with tracer.inference('chat_completion'):
+ count = 1 if name != 'multiple_tools' else 2
+ for index in range(count):
+
+ async def call(index=index):
+ return {'result': index}
+
+ await traced_tool(
+ tracer,
+ name='mcp' if name == 'mcp_tool' else f'tool-{index}',
+ call_id=f'call-{index}',
+ tool_type='mcp' if name == 'mcp_tool' else 'function',
+ arguments={'index': index},
+ direct=False,
+ call=call,
+ )
+ if name == 'one_tool':
+ await traced_inference(
+ tracer,
+ provider='openai-compatible',
+ request_payload={'model': 'model'},
+ call=successful_response,
+ )
+ elif name == 'multi_round':
+ await traced_inference(
+ tracer, provider='openai-compatible', request_payload={'model': 'model'}, call=successful_response
+ )
+ await traced_inference(
+ tracer, provider='openai-compatible', request_payload={'model': 'model'}, call=successful_response
+ )
+ elif name in {'knowledge', 'hybrid', 'rerank', 'embedding', 'retrieval_failure'}:
+ with tracer.retrieval('retrieval', operation_name='retrieval', retrieval_query='query', top_k=3):
+
+ async def embedding(query, prefix=None):
+ return [[0.1, 0.2]]
+
+ await instrument_embedding(tracer, embedding)('query')
+ if name in {'hybrid', 'rerank'}:
+ with tracer.tracer.start_as_current_span('bm25_search' if name == 'hybrid' else 'vector_search'):
+ pass
+ if name == 'rerank':
+ instrument_reranker(tracer, lambda query, docs: [0.9])('query', ['doc'])
+ await traced_tool(
+ tracer,
+ name='retrieval-tool',
+ call_id='retrieval-call',
+ tool_type='function',
+ arguments={'query': 'query'},
+ direct=False,
+ call=lambda: successful_response(),
+ )
+ if name == 'retrieval_failure':
+ with pytest.raises(RuntimeError):
+ raise RuntimeError('retrieval failed')
+ if name in {'knowledge', 'hybrid', 'rerank'}:
+ current = trace.get_current_span()
+ current.set_attribute(
+ 'gen_ai.retrieval.documents', ['{"document_id":"doc-1","rank":1,"score":0.9}']
+ )
+ elif name == 'llm_failure':
+
+ async def fail():
+ raise ValueError('llm failed')
+
+ with pytest.raises(ValueError):
+ await traced_inference(
+ tracer, provider='openai-compatible', request_payload={'model': 'model'}, call=fail
+ )
+ elif name == 'no_content':
+ await traced_inference(
+ tracer,
+ provider='openai-compatible',
+ request_payload={'model': 'model', 'messages': [{'content': 'secret'}]},
+ call=successful_response,
+ )
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ 'scenario',
+ [
+ 'simple_chat',
+ 'streaming',
+ 'cancelled',
+ 'openai',
+ 'ollama',
+ 'one_tool',
+ 'multiple_tools',
+ 'multi_round',
+ 'knowledge',
+ 'hybrid',
+ 'rerank',
+ 'embedding',
+ 'mcp_tool',
+ 'background_title',
+ 'followup',
+ 'no_chat_id',
+ 'llm_failure',
+ 'retrieval_failure',
+ 'no_content',
+ ],
+)
+async def test_conformance_scenarios(scenario):
+ tracer, exporter = setup()
+ await build_scenario(scenario, tracer)
+ spans = exporter.get_finished_spans()
+ assert spans
+ if scenario == 'llm_failure':
+ assert any(span.status.status_code is StatusCode.ERROR for span in spans)
+ if scenario == 'no_content':
+ assert all('gen_ai.input.messages' not in span.attributes for span in spans)
+ if scenario == 'background_title':
+ assert any(span.attributes.get('openwebui.operation.purpose') == 'title_generation' for span in spans)
+ if scenario == 'followup':
+ assert any(span.attributes.get('openwebui.operation.purpose') == 'followup_generation' for span in spans)
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize('scenario', ['simple_chat', 'knowledge', 'multi_round', 'rerank'])
+async def test_golden_topology(scenario):
+ tracer, exporter = setup()
+ await build_scenario(scenario, tracer)
+ actual = topology(exporter.get_finished_spans())
+ golden_name = {
+ 'knowledge': 'rag_chat',
+ 'multi_round': 'tool_loop',
+ 'rerank': 'rag_rerank_tool',
+ }.get(scenario, scenario)
+ expected = json.loads((GOLDEN_DIR / f'{golden_name}.json').read_text())
+ assert actual == expected
+
+
+def test_tracing_suppressed_produces_no_semantic_spans():
+ exporter = InMemorySpanExporter()
+ tracer = GenAITracer(trace.get_tracer('suppressed'), GenAIConfig())
+ with tracer.workflow('invoke_workflow open-webui.chat'):
+ pass
+ assert exporter.get_finished_spans() == ()
diff --git a/backend/open_webui/test/apps/webui/utils/test_genai_inference.py b/backend/open_webui/test/apps/webui/utils/test_genai_inference.py
new file mode 100644
index 0000000000000000000000000000000000000000..52d3f16d9d5b3da0871f1c80c1fe3370c5c680ef
--- /dev/null
+++ b/backend/open_webui/test/apps/webui/utils/test_genai_inference.py
@@ -0,0 +1,134 @@
+from types import SimpleNamespace
+
+import pytest
+from open_webui.utils.telemetry.genai.config import GenAIConfig
+from open_webui.utils.telemetry.genai.inference import traced_inference
+from open_webui.utils.telemetry.genai.tracer import GenAITracer
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import SimpleSpanProcessor
+from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
+
+
+@pytest.mark.asyncio
+async def test_two_round_tool_loop_creates_independent_inference_spans():
+ exporter = InMemorySpanExporter()
+ provider = TracerProvider()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+ tracer = GenAITracer(provider.get_tracer('inference-test'), GenAIConfig())
+ responses = [
+ {
+ 'id': 'one',
+ 'model': 'requested',
+ 'usage': {'prompt_tokens': 3, 'completion_tokens': 4},
+ 'choices': [{'finish_reason': 'tool_calls'}],
+ },
+ {
+ 'id': 'two',
+ 'model': 'actual',
+ 'usage': {'prompt_tokens': 5, 'completion_tokens': 6},
+ 'choices': [{'finish_reason': 'stop'}],
+ },
+ ]
+
+ async def call():
+ return responses.pop(0)
+
+ await traced_inference(tracer, provider='openai-compatible', request_payload={'model': 'requested'}, call=call)
+ await traced_inference(tracer, provider='ollama', request_payload={'model': 'requested'}, call=call)
+ spans = exporter.get_finished_spans()
+ assert len(spans) == 2
+ assert [span.attributes['gen_ai.usage.input_tokens'] for span in spans] == [3, 5]
+ assert [span.attributes['gen_ai.usage.output_tokens'] for span in spans] == [4, 6]
+ assert [span.attributes['gen_ai.response.id'] for span in spans] == ['one', 'two']
+
+
+@pytest.mark.asyncio
+async def test_content_is_absent_for_openai_and_ollama_when_disabled():
+ exporter = InMemorySpanExporter()
+ provider = TracerProvider()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+ tracer = GenAITracer(provider.get_tracer('privacy-test'), GenAIConfig())
+
+ async def call():
+ return {'id': 'response', 'model': 'model', 'choices': [{'finish_reason': 'stop'}]}
+
+ payload = {'model': 'model', 'messages': [{'role': 'user', 'content': 'secret'}]}
+ await traced_inference(tracer, provider='openai-compatible', request_payload=payload, call=call)
+ await traced_inference(tracer, provider='ollama', request_payload=payload, call=call)
+ for span in exporter.get_finished_spans():
+ assert 'gen_ai.input.messages' not in span.attributes
+ assert 'gen_ai.output.messages' not in span.attributes
+
+
+@pytest.mark.asyncio
+async def test_buffered_output_is_captured_when_enabled():
+ exporter = InMemorySpanExporter()
+ provider = TracerProvider()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+ tracer = GenAITracer(
+ provider.get_tracer('output-content-test'),
+ GenAIConfig(capture_outputs=True, content_max_length=12),
+ )
+
+ async def call():
+ return {
+ 'id': 'response',
+ 'model': 'model',
+ 'choices': [{'message': {'role': 'assistant', 'content': 'output is longer'}}],
+ }
+
+ await traced_inference(tracer, provider='openai-compatible', request_payload={'model': 'model'}, call=call)
+ output = exporter.get_finished_spans()[0].attributes['gen_ai.output.messages']
+ assert len(output) == 12
+ assert output.startswith('[{"role": "a')
+
+
+@pytest.mark.asyncio
+async def test_streaming_inference_span_closes_after_body_iterator():
+ exporter = InMemorySpanExporter()
+ provider = TracerProvider()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+ tracer = GenAITracer(provider.get_tracer('stream-test'), GenAIConfig())
+
+ async def body():
+ yield 'chunk'
+
+ response = SimpleNamespace(body_iterator=body())
+ result = await traced_inference(
+ tracer,
+ provider='ollama',
+ request_payload={'model': 'model', 'stream': True},
+ call=lambda: _return(response),
+ )
+ assert exporter.get_finished_spans() == ()
+ assert [item async for item in result.body_iterator] == ['chunk']
+ assert len(exporter.get_finished_spans()) == 1
+
+
+@pytest.mark.asyncio
+async def test_streaming_output_is_captured_after_body_iteration():
+ exporter = InMemorySpanExporter()
+ provider = TracerProvider()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+ tracer = GenAITracer(
+ provider.get_tracer('stream-output-test'),
+ GenAIConfig(capture_outputs=True),
+ )
+
+ async def body():
+ yield 'chunk-one'
+ yield 'chunk-two'
+
+ response = SimpleNamespace(body_iterator=body())
+ result = await traced_inference(
+ tracer,
+ provider='ollama',
+ request_payload={'model': 'model', 'stream': True},
+ call=lambda: _return(response),
+ )
+ assert [item async for item in result.body_iterator] == ['chunk-one', 'chunk-two']
+ assert exporter.get_finished_spans()[0].attributes['gen_ai.output.messages'] == 'chunk-onechunk-two'
+
+
+async def _return(value):
+ return value
diff --git a/backend/open_webui/test/apps/webui/utils/test_genai_performance.py b/backend/open_webui/test/apps/webui/utils/test_genai_performance.py
new file mode 100644
index 0000000000000000000000000000000000000000..2a6887c671f0e6f748f4e9689b7603a6d0e17ba0
--- /dev/null
+++ b/backend/open_webui/test/apps/webui/utils/test_genai_performance.py
@@ -0,0 +1,101 @@
+import timeit
+
+import pytest
+from open_webui.utils.telemetry.genai import extractors
+from open_webui.utils.telemetry.genai.config import GenAIConfig
+from open_webui.utils.telemetry.genai.retrieval import traced_retrieval
+from open_webui.utils.telemetry.genai.tools import traced_tool
+from open_webui.utils.telemetry.genai.tracer import GenAITracer
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import SimpleSpanProcessor
+from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
+
+
+def _tracer(config):
+ exporter = InMemorySpanExporter()
+ provider = TracerProvider()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+ return GenAITracer(provider.get_tracer('performance-test'), config), exporter
+
+
+def test_disabled_tracing_stays_within_agreed_overhead():
+ tracer, _ = _tracer(GenAIConfig(enabled=False))
+
+ def baseline():
+ return None
+
+ def disabled_trace():
+ with tracer.inference():
+ pass
+
+ baseline_time = min(timeit.repeat(baseline, number=20_000, repeat=5))
+ disabled_time = min(timeit.repeat(disabled_trace, number=20_000, repeat=5))
+ # Agreed tolerance: at most 20 ms total for 20,000 disabled spans.
+ assert disabled_time - baseline_time <= 0.02
+
+
+def test_disabled_capture_does_not_serialize(monkeypatch):
+ def unexpected_serialization(*args, **kwargs):
+ raise AssertionError('content was serialized while capture was disabled')
+
+ monkeypatch.setattr(extractors.json, 'dumps', unexpected_serialization)
+ tracer, _ = _tracer(GenAIConfig())
+ with tracer.inference(inputs={'secret': 'prompt'}, tool_arguments={'secret': 'args'}):
+ pass
+
+ disabled_tracer, _ = _tracer(GenAIConfig(enabled=False, capture_inputs=True))
+ with disabled_tracer.inference(inputs={'secret': 'prompt'}):
+ pass
+
+
+@pytest.mark.asyncio
+async def test_explicit_content_and_topology_caps():
+ config = GenAIConfig(
+ capture_inputs=True,
+ capture_outputs=True,
+ capture_tool_arguments=True,
+ capture_tool_results=True,
+ capture_retrieval_documents=True,
+ content_max_length=5,
+ retrieval_max_documents=2,
+ exception_max_length=7,
+ )
+ tracer, exporter = _tracer(config)
+
+ async def tool_call():
+ return 'result-too-long'
+
+ await traced_tool(
+ tracer,
+ name='limited-tool',
+ call_id='call-1',
+ tool_type='function',
+ arguments='arguments-too-long',
+ direct=False,
+ call=tool_call,
+ )
+
+ with pytest.raises(ValueError):
+ with tracer.inference(inputs='message-too-long'):
+ raise ValueError('exception-message-too-long')
+
+ @traced_retrieval(tracer)
+ async def retrieve(items, queries, k, hybrid_search=False, embedding_function=None):
+ return items
+
+ items = [
+ {'metadata': {'document_id': f'doc-{index}'}, 'document': [f'content-{index}'], 'distances': [0.1]}
+ for index in range(4)
+ ]
+ await retrieve(items=items, queries=['query'], k=4)
+
+ spans = exporter.get_finished_spans()
+ tool_span = next(span for span in spans if span.name == 'execute_tool')
+ assert tool_span.attributes['gen_ai.tool.call.arguments'] == 'argum'
+ assert tool_span.attributes['gen_ai.tool.call.result'] == 'resul'
+ inference_span = next(span for span in spans if span.name == 'inference')
+ assert inference_span.attributes['gen_ai.input.messages'] == 'messa'
+ exception_event = next(event for event in inference_span.events if event.name == 'exception')
+ assert exception_event.attributes['exception.message'] == 'excepti'
+ retrieval_span = next(span for span in spans if span.name == 'retrieval')
+ assert len(retrieval_span.attributes['gen_ai.retrieval.documents']) == 2
diff --git a/backend/open_webui/test/apps/webui/utils/test_genai_purpose.py b/backend/open_webui/test/apps/webui/utils/test_genai_purpose.py
new file mode 100644
index 0000000000000000000000000000000000000000..d9dddf95d3355377da8f131224b85110f73f514d
--- /dev/null
+++ b/backend/open_webui/test/apps/webui/utils/test_genai_purpose.py
@@ -0,0 +1,39 @@
+import pytest
+from open_webui.utils.telemetry.genai.config import GenAIConfig
+from open_webui.utils.telemetry.genai.inference import traced_inference
+from open_webui.utils.telemetry.genai.tracer import GenAITracer
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import SimpleSpanProcessor
+from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
+
+
+@pytest.mark.asyncio
+async def test_task_metadata_maps_background_purpose_and_primary_defaults():
+ exporter = InMemorySpanExporter()
+ provider = TracerProvider()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+ tracer = GenAITracer(provider.get_tracer('purpose-test'), GenAIConfig())
+
+ async def call():
+ return {'id': 'response', 'model': 'model', 'choices': [{'finish_reason': 'stop'}]}
+
+ await traced_inference(
+ tracer,
+ provider='openai-compatible',
+ request_payload={'model': 'model', 'metadata': {'task': 'title_generation'}},
+ call=call,
+ )
+ await traced_inference(tracer, provider='openai-compatible', request_payload={'model': 'model'}, call=call)
+ spans = exporter.get_finished_spans()
+ assert spans[0].attributes['openwebui.operation.purpose'] == 'title_generation'
+ assert spans[1].attributes['openwebui.operation.purpose'] == 'primary'
+
+
+def test_primary_workflow_purpose_is_explicit():
+ exporter = InMemorySpanExporter()
+ provider = TracerProvider()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+ tracer = GenAITracer(provider.get_tracer('workflow-purpose-test'), GenAIConfig())
+ with tracer.workflow('invoke_workflow open-webui.chat', operation_name='invoke_workflow', purpose='primary'):
+ pass
+ assert exporter.get_finished_spans()[0].attributes['openwebui.operation.purpose'] == 'primary'
diff --git a/backend/open_webui/test/apps/webui/utils/test_genai_retrieval.py b/backend/open_webui/test/apps/webui/utils/test_genai_retrieval.py
new file mode 100644
index 0000000000000000000000000000000000000000..24a224718549aa88e795a5de41685c35c8d14db8
--- /dev/null
+++ b/backend/open_webui/test/apps/webui/utils/test_genai_retrieval.py
@@ -0,0 +1,80 @@
+import pytest
+from open_webui.utils.telemetry.genai.config import GenAIConfig
+from open_webui.utils.telemetry.genai.retrieval import instrument_embedding, instrument_reranker, traced_retrieval
+from open_webui.utils.telemetry.genai.tracer import GenAITracer
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import SimpleSpanProcessor
+from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
+
+
+@pytest.mark.asyncio
+async def test_retrieval_tree_has_embedding_and_rerank_children():
+ exporter = InMemorySpanExporter()
+ provider = TracerProvider()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+ tracer = GenAITracer(provider.get_tracer('retrieval-test'), GenAIConfig())
+
+ async def embedding(query, prefix=None):
+ return [[0.1, 0.2]]
+
+ def rerank(query, documents):
+ return [0.9 for _ in documents]
+
+ with tracer.retrieval('retrieval') as parent:
+ await instrument_embedding(tracer, embedding)('query')
+ instrument_reranker(tracer, rerank)('query', ['document'])
+ spans = exporter.get_finished_spans()
+ assert [span.name for span in spans] == ['embedding', 'rerank', 'retrieval']
+ assert all(span.parent.span_id == parent.get_span_context().span_id for span in spans[:2])
+ assert spans[1].attributes['openinference.span.kind'] == 'RERANKER'
+
+
+@pytest.mark.asyncio
+async def test_embedding_and_retrieval_content_are_private_by_default():
+ exporter = InMemorySpanExporter()
+ provider = TracerProvider()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+ tracer = GenAITracer(provider.get_tracer('retrieval-privacy-test'), GenAIConfig())
+
+ async def embedding(query, prefix=None):
+ return [[0.1, 0.2]]
+
+ with tracer.retrieval('retrieval') as parent:
+ await instrument_embedding(tracer, embedding)('secret query')
+ parent.set_attribute('gen_ai.retrieval.documents', ['{"document_id": "doc-1", "rank": 1, "score": 0.9}'])
+ spans = exporter.get_finished_spans()
+ assert 'gen_ai.retrieval.query.text' not in spans[0].attributes
+ assert 'gen_ai.embeddings' not in spans[0].attributes
+ assert spans[-1].attributes['gen_ai.retrieval.documents']
+
+
+@pytest.mark.asyncio
+async def test_embedding_vectors_and_document_content_are_capped_when_enabled():
+ exporter = InMemorySpanExporter()
+ provider = TracerProvider()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+ tracer = GenAITracer(
+ provider.get_tracer('retrieval-content-test'),
+ GenAIConfig(capture_embedding_vectors=True, capture_retrieval_documents=True, content_max_length=8),
+ )
+
+ async def embedding(query, prefix=None):
+ return [[0.1, 0.2, 0.3, 0.4]]
+
+ @traced_retrieval(tracer)
+ async def retrieve(items, queries, k, hybrid_search=False, embedding_function=None):
+ await instrument_embedding(tracer, embedding_function)('query')
+ return items
+
+ await retrieve(
+ items=[{'metadata': {'document_id': 'doc-1'}, 'document': ['document-content-too-long']}],
+ queries=['query'],
+ k=1,
+ embedding_function=embedding,
+ )
+ spans = exporter.get_finished_spans()
+ embedding_span = next(span for span in spans if span.name == 'embedding')
+ retrieval_span = next(span for span in spans if span.name == 'retrieval')
+ assert len(embedding_span.attributes['gen_ai.embeddings']) <= 8
+ assert '"content": "document"' in retrieval_span.attributes['gen_ai.retrieval.documents'][0]
+ assert 'document-content-too-long' not in retrieval_span.attributes['gen_ai.retrieval.documents'][0]
diff --git a/backend/open_webui/test/apps/webui/utils/test_genai_tools.py b/backend/open_webui/test/apps/webui/utils/test_genai_tools.py
new file mode 100644
index 0000000000000000000000000000000000000000..d51de00b40817d1f6e859c21da379e2725e27869
--- /dev/null
+++ b/backend/open_webui/test/apps/webui/utils/test_genai_tools.py
@@ -0,0 +1,100 @@
+import pytest
+from open_webui.utils.telemetry.genai.config import GenAIConfig
+from open_webui.utils.telemetry.genai.tools import traced_tool
+from open_webui.utils.telemetry.genai.tracer import GenAITracer
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import SimpleSpanProcessor
+from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
+
+
+@pytest.mark.asyncio
+async def test_two_tools_are_independent_children_of_inference():
+ exporter = InMemorySpanExporter()
+ provider = TracerProvider()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+ tracer = GenAITracer(provider.get_tracer('tools-test'), GenAIConfig())
+
+ async def first():
+ return 'first-result'
+
+ async def second():
+ return 'second-result'
+
+ with tracer.inference('chat_completion') as parent:
+ await traced_tool(
+ tracer,
+ name='first',
+ call_id='call-1',
+ tool_type='function',
+ arguments={'x': 1},
+ direct=False,
+ call=first,
+ )
+ await traced_tool(
+ tracer,
+ name='second',
+ call_id='call-2',
+ tool_type='mcp',
+ arguments={'x': 2},
+ direct=False,
+ call=second,
+ )
+
+ spans = exporter.get_finished_spans()
+ assert [span.name for span in spans] == ['execute_tool', 'execute_tool', 'chat_completion']
+ tool_spans = spans[:2]
+ assert [span.attributes['gen_ai.tool.name'] for span in tool_spans] == ['first', 'second']
+ assert [span.attributes['gen_ai.tool.call.id'] for span in tool_spans] == ['call-1', 'call-2']
+ assert [span.attributes['gen_ai.tool.type'] for span in tool_spans] == ['function', 'mcp']
+ assert all(span.parent.span_id == parent.get_span_context().span_id for span in tool_spans)
+
+
+@pytest.mark.asyncio
+async def test_tool_payloads_are_absent_when_capture_is_disabled():
+ exporter = InMemorySpanExporter()
+ provider = TracerProvider()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+ tracer = GenAITracer(provider.get_tracer('tools-privacy-test'), GenAIConfig())
+
+ async def call():
+ return {'secret': 'result'}
+
+ await traced_tool(
+ tracer,
+ name='private',
+ call_id='call-private',
+ tool_type='builtin',
+ arguments={'secret': 'argument'},
+ direct=False,
+ call=call,
+ )
+ attributes = exporter.get_finished_spans()[0].attributes
+ assert 'gen_ai.tool.call.arguments' not in attributes
+ assert 'gen_ai.tool.call.result' not in attributes
+
+
+@pytest.mark.asyncio
+async def test_tool_payloads_are_captured_when_enabled():
+ exporter = InMemorySpanExporter()
+ provider = TracerProvider()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+ tracer = GenAITracer(
+ provider.get_tracer('tools-content-test'),
+ GenAIConfig(capture_tool_arguments=True, capture_tool_results=True),
+ )
+
+ async def call():
+ return {'result': 'ok'}
+
+ await traced_tool(
+ tracer,
+ name='captured',
+ call_id='call-captured',
+ tool_type='openapi',
+ arguments={'argument': 'value'},
+ direct=False,
+ call=call,
+ )
+ attributes = exporter.get_finished_spans()[0].attributes
+ assert 'value' in attributes['gen_ai.tool.call.arguments']
+ assert 'ok' in attributes['gen_ai.tool.call.result']
diff --git a/backend/open_webui/test/apps/webui/utils/test_genai_tracer.py b/backend/open_webui/test/apps/webui/utils/test_genai_tracer.py
new file mode 100644
index 0000000000000000000000000000000000000000..3eb6e126ee87a7af4d0cd6a450342795be6e4a3e
--- /dev/null
+++ b/backend/open_webui/test/apps/webui/utils/test_genai_tracer.py
@@ -0,0 +1,74 @@
+import asyncio
+
+import pytest
+from open_webui.utils.telemetry.genai.config import GenAIConfig
+from open_webui.utils.telemetry.genai.streaming import wrap_stream
+from open_webui.utils.telemetry.genai.tracer import GenAITracer
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import SimpleSpanProcessor
+from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
+from opentelemetry.trace import StatusCode
+
+
+@pytest.fixture
+def span_setup():
+ exporter = InMemorySpanExporter()
+ provider = TracerProvider()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+ return GenAITracer(provider.get_tracer('test'), GenAIConfig()), exporter
+
+
+def test_span_kind_and_structural_attributes(span_setup):
+ tracer, exporter = span_setup
+ with tracer.inference(operation_name='chat', provider='test', request_model='model'):
+ pass
+ span = exporter.get_finished_spans()[0]
+ assert span.attributes['gen_ai.operation.name'] == 'chat'
+ assert span.attributes['gen_ai.provider.name'] == 'test'
+ assert span.attributes['openinference.span.kind'] == 'LLM'
+
+
+def test_exception_is_recorded_and_status_is_error(span_setup):
+ tracer, exporter = span_setup
+ with pytest.raises(ValueError):
+ with tracer.inference():
+ raise ValueError('boom')
+ span = exporter.get_finished_spans()[0]
+ assert span.status.status_code is StatusCode.ERROR
+ assert any(event.name == 'exception' for event in span.events)
+
+
+def test_content_is_absent_when_capture_is_disabled(span_setup):
+ tracer, exporter = span_setup
+ with tracer.inference(inputs='secret prompt', outputs='secret output', tool_arguments='secret args'):
+ pass
+ assert not any(
+ 'input' in key or 'output' in key or 'tool' in key for key in exporter.get_finished_spans()[0].attributes
+ )
+
+
+def test_content_is_truncated_when_capture_is_enabled():
+ exporter = InMemorySpanExporter()
+ provider = TracerProvider()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+ config = GenAIConfig(capture_inputs=True, capture_outputs=True, content_max_length=5)
+ with GenAITracer(provider.get_tracer('test'), config).inference(inputs='123456789', outputs='abcdefgh'):
+ pass
+ span = exporter.get_finished_spans()[0]
+ assert span.attributes['gen_ai.input.messages'] == '12345'
+ assert span.attributes['gen_ai.output.messages'] == 'abcde'
+
+
+@pytest.mark.asyncio
+async def test_streaming_cancellation_closes_span(span_setup):
+ tracer, exporter = span_setup
+ span = tracer.tracer.start_span('stream')
+
+ async def source():
+ yield 'first'
+ await asyncio.sleep(10)
+
+ wrapped = wrap_stream(span, source())
+ assert await wrapped.__anext__() == 'first'
+ await wrapped.aclose()
+ assert len(exporter.get_finished_spans()) == 1
diff --git a/backend/open_webui/test/apps/webui/utils/test_genai_workflow_context.py b/backend/open_webui/test/apps/webui/utils/test_genai_workflow_context.py
new file mode 100644
index 0000000000000000000000000000000000000000..166353887f05901e61fc8153ca466b1dec3ff372
--- /dev/null
+++ b/backend/open_webui/test/apps/webui/utils/test_genai_workflow_context.py
@@ -0,0 +1,68 @@
+import asyncio
+
+import pytest
+from open_webui.utils.telemetry.genai.streaming import wrap_stream
+from open_webui.utils.telemetry.genai.tracer import GenAITracer
+from opentelemetry.sdk.trace import TracerProvider
+from opentelemetry.sdk.trace.export import SimpleSpanProcessor
+from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
+
+
+@pytest.fixture
+def tracer_setup():
+ exporter = InMemorySpanExporter()
+ provider = TracerProvider()
+ provider.add_span_processor(SimpleSpanProcessor(exporter))
+ return GenAITracer(provider.get_tracer('workflow-test')), exporter
+
+
+@pytest.mark.asyncio
+async def test_workflow_name_context_and_stream_lifetime(tracer_setup):
+ tracer, exporter = tracer_setup
+ span = tracer.tracer.start_span('invoke_workflow open-webui.chat')
+ stream_started = asyncio.Event()
+
+ async def source():
+ stream_started.set()
+ yield 'chunk'
+ await asyncio.sleep(0)
+ yield 'done'
+
+ wrapped = wrap_stream(span, source())
+ assert await wrapped.__anext__() == 'chunk'
+ await stream_started.wait()
+ assert exporter.get_finished_spans() == ()
+ assert await wrapped.__anext__() == 'done'
+ with pytest.raises(StopAsyncIteration):
+ await wrapped.__anext__()
+ assert exporter.get_finished_spans()[0].name == 'invoke_workflow open-webui.chat'
+
+
+def test_workflow_context_attributes_are_ids_only(tracer_setup):
+ tracer, exporter = tracer_setup
+ with tracer.workflow(
+ 'invoke_workflow open-webui.chat',
+ operation_name='invoke_workflow',
+ conversation_id='chat-123',
+ message_id='message-456',
+ purpose='primary',
+ ):
+ pass
+ span = exporter.get_finished_spans()[0]
+ assert span.name == 'invoke_workflow open-webui.chat'
+ assert span.attributes['gen_ai.conversation.id'] == 'chat-123'
+ assert span.attributes['openwebui.message.id'] == 'message-456'
+ assert span.attributes['openwebui.operation.purpose'] == 'primary'
+ assert 'chat-123' not in span.name
+
+
+def test_background_workflow_can_set_non_primary_purpose(tracer_setup):
+ tracer, exporter = tracer_setup
+ with tracer.workflow(
+ 'invoke_workflow open-webui.chat',
+ operation_name='invoke_workflow',
+ conversation_id='chat-123',
+ purpose='title_generation',
+ ):
+ pass
+ assert exporter.get_finished_spans()[0].attributes['openwebui.operation.purpose'] == 'title_generation'
diff --git a/backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py b/backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py
new file mode 100644
index 0000000000000000000000000000000000000000..b409e92831d68ff1b4e3615b1f7d5b04ce2e218b
--- /dev/null
+++ b/backend/open_webui/test/apps/webui/utils/test_telemetry_setup.py
@@ -0,0 +1,89 @@
+import importlib
+
+
+class _Instrumentor:
+ def instrument(self):
+ pass
+
+
+def _reload_exporter_modules(monkeypatch, exporter_headers):
+ monkeypatch.setenv('OTEL_EXPORTER_OTLP_HEADERS', 'foo=bar')
+ monkeypatch.delenv('OTEL_BASIC_AUTH_USERNAME', raising=False)
+ monkeypatch.delenv('OTEL_BASIC_AUTH_PASSWORD', raising=False)
+
+ import opentelemetry.exporter.otlp.proto.grpc.trace_exporter as grpc_exporter
+ import opentelemetry.exporter.otlp.proto.http.trace_exporter as http_exporter
+
+ importlib.reload(grpc_exporter)
+ importlib.reload(http_exporter)
+
+ grpc = grpc_exporter.OTLPSpanExporter(endpoint='http://localhost:4317')
+ http = http_exporter.OTLPSpanExporter(endpoint='http://localhost:4318/v1/traces')
+ exporter_headers.append((grpc._headers, http._headers))
+
+
+def test_sdk_otlp_headers_are_used_when_basic_auth_is_unset(monkeypatch):
+ headers = []
+ _reload_exporter_modules(monkeypatch, headers)
+
+ grpc_headers, http_headers = headers[0]
+ assert ('foo', 'bar') in grpc_headers
+ assert http_headers.get('foo') == 'bar'
+
+
+def test_setup_passes_none_headers_without_basic_auth(monkeypatch):
+ import open_webui.utils.telemetry.setup as telemetry_setup
+
+ captured = []
+
+ class Exporter:
+ def __init__(self, **kwargs):
+ captured.append(kwargs)
+
+ class Provider:
+ def add_span_processor(self, processor):
+ pass
+
+ monkeypatch.setattr(telemetry_setup, 'HttpOTLPSpanExporter', Exporter)
+ monkeypatch.setattr(telemetry_setup, 'OTLPSpanExporter', Exporter)
+ monkeypatch.setattr(telemetry_setup.trace, 'set_tracer_provider', lambda provider: None)
+ monkeypatch.setattr(telemetry_setup.trace, 'get_tracer_provider', lambda: Provider())
+ monkeypatch.setattr(telemetry_setup, 'Instrumentor', lambda **kwargs: _Instrumentor())
+ monkeypatch.setattr(telemetry_setup, 'ENABLE_OTEL_TRACES', True)
+ monkeypatch.setattr(telemetry_setup, 'OTEL_BASIC_AUTH_USERNAME', '')
+ monkeypatch.setattr(telemetry_setup, 'OTEL_BASIC_AUTH_PASSWORD', '')
+ monkeypatch.setattr(telemetry_setup, 'OTEL_OTLP_SPAN_EXPORTER', 'grpc')
+ telemetry_setup.setup(None, None)
+ assert captured[-1]['headers'] is None
+
+ monkeypatch.setattr(telemetry_setup, 'OTEL_OTLP_SPAN_EXPORTER', 'http')
+ telemetry_setup.setup(None, None)
+ assert captured[-1]['headers'] is None
+
+
+def test_setup_preserves_basic_auth_and_grpc_insecure(monkeypatch):
+ import open_webui.utils.telemetry.setup as telemetry_setup
+
+ captured = []
+
+ class Exporter:
+ def __init__(self, **kwargs):
+ captured.append(kwargs)
+
+ class Provider:
+ def add_span_processor(self, processor):
+ pass
+
+ monkeypatch.setattr(telemetry_setup, 'OTLPSpanExporter', Exporter)
+ monkeypatch.setattr(telemetry_setup.trace, 'set_tracer_provider', lambda provider: None)
+ monkeypatch.setattr(telemetry_setup.trace, 'get_tracer_provider', lambda: Provider())
+ monkeypatch.setattr(telemetry_setup, 'Instrumentor', lambda **kwargs: _Instrumentor())
+ monkeypatch.setattr(telemetry_setup, 'ENABLE_OTEL_TRACES', True)
+ monkeypatch.setattr(telemetry_setup, 'OTEL_BASIC_AUTH_USERNAME', 'user')
+ monkeypatch.setattr(telemetry_setup, 'OTEL_BASIC_AUTH_PASSWORD', 'pass')
+ monkeypatch.setattr(telemetry_setup, 'OTEL_OTLP_SPAN_EXPORTER', 'grpc')
+ monkeypatch.setattr(telemetry_setup, 'OTEL_EXPORTER_OTLP_INSECURE', True)
+ telemetry_setup.setup(None, None)
+
+ assert captured[-1]['headers'] == [('authorization', 'Basic dXNlcjpwYXNz')]
+ assert captured[-1]['insecure'] is True
diff --git a/backend/open_webui/utils/asgi_middleware.py b/backend/open_webui/utils/asgi_middleware.py
index 2d9f4e190edd7c9abdaf455fb9143e5a6c9b28a9..7fcdce387437ab0ba221e523c27c690e5f8cb1c2 100644
--- a/backend/open_webui/utils/asgi_middleware.py
+++ b/backend/open_webui/utils/asgi_middleware.py
@@ -198,7 +198,8 @@ class AppHTTPMiddleware:
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 True
diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py
index 6eca5112521e8dfb6b78c62264df6a020eab3f68..48e078547112b5fe4d6b87e608b67c11cc00fd25 100644
--- a/backend/open_webui/utils/middleware.py
+++ b/backend/open_webui/utils/middleware.py
@@ -144,6 +144,9 @@ from open_webui.utils.task import (
tools_function_calling_generation_template,
)
from open_webui.utils.tool_approval import complete_tool_call, pending_tool_calls
+from open_webui.utils.telemetry.genai.tools import traced_tool
+from open_webui.utils.telemetry.genai.tracer import GenAITracer
+from open_webui.utils.tool_approval import complete_tool_call, pending_tool_calls
from open_webui.utils.tool_search import apply_tool_search
from open_webui.utils.tools import (
connect_mcp_server,
@@ -157,6 +160,7 @@ from starlette.responses import JSONResponse, Response, StreamingResponse
logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
+ai_tracer = GenAITracer()
def _is_tool_result_error(value: Any) -> bool:
@@ -3481,11 +3485,11 @@ async def execute_tool_call(form_data, metadata, event_caller, tool_call):
):
return params, 'Error: tool call was not approved.', tool, tool_type, direct_tool
- if direct_tool:
- if not event_caller:
- result = 'Error: Browser session is not connected for this direct tool.'
- else:
- result = await event_caller(
+ async def invoke_tool():
+ if direct_tool:
+ if not event_caller:
+ return 'Error: Browser session is not connected for this direct tool.'
+ return await event_caller(
{
'type': 'execute:tool',
'data': {
@@ -3497,7 +3501,6 @@ async def execute_tool_call(form_data, metadata, event_caller, tool_call):
},
}
)
- else:
function = await get_updated_tool_function(
function=tool['callable'],
extra_params={
@@ -3505,7 +3508,17 @@ async def execute_tool_call(form_data, metadata, event_caller, tool_call):
'__files__': metadata.get('files', []),
},
)
- result = await function(**params)
+ return await function(**params)
+
+ result = await traced_tool(
+ ai_tracer,
+ name=name,
+ call_id=tool_call.get('id', ''),
+ tool_type=tool_type,
+ arguments=params,
+ direct=direct_tool,
+ call=invoke_tool,
+ )
except Exception as e:
result = {'error': str(e)}
return params, result, tool, tool_type, direct_tool
diff --git a/backend/open_webui/utils/telemetry/genai/README.md b/backend/open_webui/utils/telemetry/genai/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..1256af32839fc7c3d4fb68539279dbe484fc6202
--- /dev/null
+++ b/backend/open_webui/utils/telemetry/genai/README.md
@@ -0,0 +1,99 @@
+# OpenTelemetry GenAI telemetry
+
+This package emits vendor-neutral OpenTelemetry GenAI spans. Structural
+attributes are emitted by default; content capture is opt-in and each content
+value is truncated to `OTEL_GENAI_CONTENT_MAX_LENGTH` (default `2000`).
+
+## Environment variables
+
+The following `OTEL_GENAI_*` variables are read by the semantic layer. Boolean
+values accept `1`, `true`, `yes`, or `on` (case-insensitive).
+
+| Variable | Default | Captures |
+| --- | --- | --- |
+| `OTEL_GENAI_ENABLED` | `true` | Enables semantic span creation |
+| `OTEL_GENAI_CAPTURE_INPUTS` | `false` | Model input messages |
+| `OTEL_GENAI_CAPTURE_OUTPUTS` | `false` | Model output messages |
+| `OTEL_GENAI_CAPTURE_SYSTEM_INSTRUCTIONS` | `false` | System instructions |
+| `OTEL_GENAI_CAPTURE_TOOL_ARGUMENTS` | `false` | Tool arguments |
+| `OTEL_GENAI_CAPTURE_TOOL_RESULTS` | `false` | Tool results |
+| `OTEL_GENAI_CAPTURE_RETRIEVAL_QUERY` | `false` | Retrieval queries |
+| `OTEL_GENAI_CAPTURE_RETRIEVAL_DOCUMENTS` | `false` | Retrieved document content |
+| `OTEL_GENAI_CAPTURE_EMBEDDING_TEXT` | `false` | Text sent to embedding providers |
+| `OTEL_GENAI_CAPTURE_EMBEDDING_VECTORS` | `false` | Embedding vectors |
+| `OTEL_GENAI_CONTENT_MAX_LENGTH` | `2000` | Maximum characters for captured content |
+
+The OTLP destination is configured with the standard OpenTelemetry variables,
+including `OTEL_EXPORTER_OTLP_ENDPOINT`, `OTEL_EXPORTER_OTLP_HEADERS`, and
+`OTEL_EXPORTER_OTLP_INSECURE`. No vendor-specific exporter or attribute is
+required.
+
+## Protected content-capture verification
+
+Content capture is disabled by default. For a protected local receiver only,
+enable the fields needed by the test and keep the length cap in place:
+
+```bash
+OTEL_GENAI_CAPTURE_INPUTS=true \
+OTEL_GENAI_CAPTURE_OUTPUTS=true \
+OTEL_GENAI_CAPTURE_TOOL_ARGUMENTS=true \
+OTEL_GENAI_CAPTURE_TOOL_RESULTS=true \
+OTEL_GENAI_CAPTURE_RETRIEVAL_QUERY=true \
+OTEL_GENAI_CAPTURE_RETRIEVAL_DOCUMENTS=true \
+OTEL_GENAI_CONTENT_MAX_LENGTH=2000
+```
+
+Buffered and streamed model outputs are emitted on `gen_ai.output.messages`.
+Tool arguments/results, retrieval query/document content, embedding text, and
+embedding vectors each remain independently controlled by their corresponding
+flag. Do not enable these flags for an untrusted or production telemetry
+destination without an explicit data-handling review.
+
+## M10 interoperability procedure
+
+Use the M9 `rag_rerank_tool` scenario as the representative trace: a chat
+workflow with retrieval, reranking, and tool execution. Run it with the same
+Open WebUI build and change only `OTEL_EXPORTER_OTLP_ENDPOINT` between
+backends. Keep content capture disabled unless the destination is a protected
+local test system.
+
+1. Start one of the following OTLP-compatible destinations and record its OTLP
+ HTTP endpoint:
+
+ - Local Phoenix: `http://localhost:6006/v1/traces`.
+ - Local or hosted Langfuse: use the project's OTLP HTTP endpoint, commonly
+ `https://<host>/api/public/otel/v1/traces`, together with its documented
+ `OTEL_EXPORTER_OTLP_HEADERS` authentication value.
+ - Generic Jaeger or Tempo: use the instance's OTLP HTTP `/v1/traces`
+ endpoint.
+
+2. Run Open WebUI with the endpoint override, for example:
+
+ ```bash
+ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:6006/v1/traces \
+ WEBUI_SECRET_KEY=test-secret-key \
+ uv run open-webui serve
+ ```
+
+ For gRPC-only receivers, use the receiver's OTLP endpoint and set
+ `OTEL_EXPORTER_OTLP_PROTOCOL=grpc`; otherwise the HTTP exporter is used.
+
+3. Exercise the `rag_rerank_tool` flow: submit a chat using a knowledge source,
+ allow reranking, and invoke a tool. Verify one trace contains the workflow,
+ inference, retrieval, rerank, and tool spans. Repeat step 2 for Phoenix,
+ Langfuse, and Jaeger/Tempo by changing only
+ `OTEL_EXPORTER_OTLP_ENDPOINT` (and, where required, the receiver's standard
+ OTLP headers).
+
+### Known differences
+
+- Backend UIs use different names and nesting displays for the same OTLP
+ spans. The conformance contract is the span topology and standard semantic
+ attributes, not a particular vendor's screen layout.
+- Phoenix, Langfuse, Jaeger, and Tempo may omit or flatten attributes they do
+ not index or render. This is a presentation/indexing difference; no
+ backend-specific code belongs in this repository.
+- Langfuse may require authentication headers and may display GenAI spans in
+ its generation view only after recognizing the standard GenAI attributes.
+- Jaeger and Tempo generally show the generic trace tree and attributes but do
+ not provide vendor-specific GenAI generation summaries.
diff --git a/backend/open_webui/utils/telemetry/genai/__init__.py b/backend/open_webui/utils/telemetry/genai/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..f88b66029173219948478d30058fa87726904c1b
--- /dev/null
+++ b/backend/open_webui/utils/telemetry/genai/__init__.py
@@ -0,0 +1,4 @@
+from open_webui.utils.telemetry.genai.config import GenAIConfig
+from open_webui.utils.telemetry.genai.tracer import GenAITracer
+
+__all__ = ['GenAIConfig', 'GenAITracer']
diff --git a/backend/open_webui/utils/telemetry/genai/config.py b/backend/open_webui/utils/telemetry/genai/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..9736c9ef8ef5fed6de84afe50925b2bdda82e6cb
--- /dev/null
+++ b/backend/open_webui/utils/telemetry/genai/config.py
@@ -0,0 +1,43 @@
+import os
+from dataclasses import dataclass
+
+
+def _as_bool(value: str | None) -> bool:
+ return (value or '').strip().lower() in {'1', 'true', 'yes', 'on'}
+
+
+@dataclass(frozen=True)
+class GenAIConfig:
+ enabled: bool = True
+ capture_inputs: bool = False
+ capture_outputs: bool = False
+ capture_system_instructions: bool = False
+ capture_tool_arguments: bool = False
+ capture_tool_results: bool = False
+ capture_retrieval_query: bool = False
+ capture_retrieval_documents: bool = False
+ capture_embedding_text: bool = False
+ capture_embedding_vectors: bool = False
+ content_max_length: int = 2000
+ retrieval_max_documents: int = 100
+ exception_max_length: int = 2000
+
+ @classmethod
+ def from_env(cls) -> 'GenAIConfig':
+ try:
+ max_length = max(int(os.getenv('OTEL_GENAI_CONTENT_MAX_LENGTH', '2000')), 0)
+ except (TypeError, ValueError):
+ max_length = 2000
+ return cls(
+ enabled=os.getenv('OTEL_GENAI_ENABLED', 'true').strip().lower() not in {'0', 'false', 'no', 'off'},
+ capture_inputs=_as_bool(os.getenv('OTEL_GENAI_CAPTURE_INPUTS')),
+ capture_outputs=_as_bool(os.getenv('OTEL_GENAI_CAPTURE_OUTPUTS')),
+ capture_system_instructions=_as_bool(os.getenv('OTEL_GENAI_CAPTURE_SYSTEM_INSTRUCTIONS')),
+ capture_tool_arguments=_as_bool(os.getenv('OTEL_GENAI_CAPTURE_TOOL_ARGUMENTS')),
+ capture_tool_results=_as_bool(os.getenv('OTEL_GENAI_CAPTURE_TOOL_RESULTS')),
+ capture_retrieval_query=_as_bool(os.getenv('OTEL_GENAI_CAPTURE_RETRIEVAL_QUERY')),
+ capture_retrieval_documents=_as_bool(os.getenv('OTEL_GENAI_CAPTURE_RETRIEVAL_DOCUMENTS')),
+ capture_embedding_text=_as_bool(os.getenv('OTEL_GENAI_CAPTURE_EMBEDDING_TEXT')),
+ capture_embedding_vectors=_as_bool(os.getenv('OTEL_GENAI_CAPTURE_EMBEDDING_VECTORS')),
+ content_max_length=max_length,
+ )
diff --git a/backend/open_webui/utils/telemetry/genai/context.py b/backend/open_webui/utils/telemetry/genai/context.py
new file mode 100644
index 0000000000000000000000000000000000000000..1c38e54850e5a8be02b8b2c14da35fb0cecd10fa
--- /dev/null
+++ b/backend/open_webui/utils/telemetry/genai/context.py
@@ -0,0 +1,36 @@
+import contextvars
+import logging
+
+from open_webui.utils.telemetry.genai import semconv
+from opentelemetry.trace import Span
+
+logger = logging.getLogger(__name__)
+_context = contextvars.ContextVar('openwebui_genai_context', default={})
+
+
+def set_context(*, chat_id=None, message_id=None, conversation_id=None, purpose='primary'):
+ values = {
+ 'chat_id': chat_id,
+ 'message_id': message_id,
+ 'conversation_id': conversation_id,
+ 'purpose': purpose,
+ }
+ _context.set(values)
+ return values
+
+
+def get_context():
+ return dict(_context.get())
+
+
+def apply_context(span: Span, values=None):
+ values = values or get_context()
+ attributes = {
+ semconv.GEN_AI_CONVERSATION_ID: values.get('conversation_id') or values.get('chat_id'),
+ semconv.OPENWEBUI_MESSAGE_ID: values.get('message_id'),
+ semconv.OPENWEBUI_OPERATION_PURPOSE: values.get('purpose', 'primary'),
+ }
+ try:
+ span.set_attributes({key: value for key, value in attributes.items() if value is not None})
+ except Exception:
+ logger.exception('Unable to apply GenAI span context')
diff --git a/backend/open_webui/utils/telemetry/genai/extractors.py b/backend/open_webui/utils/telemetry/genai/extractors.py
new file mode 100644
index 0000000000000000000000000000000000000000..331c66c96b528b30331342090209dbf8a715a44c
--- /dev/null
+++ b/backend/open_webui/utils/telemetry/genai/extractors.py
@@ -0,0 +1,38 @@
+import json
+
+from open_webui.utils.telemetry.genai.config import GenAIConfig
+
+
+def _truncate(value, limit: int):
+ if value is None:
+ return None
+ if not isinstance(value, str):
+ value = json.dumps(value, ensure_ascii=False, default=str)
+ return value[:limit]
+
+
+def capture(value, *, enabled: bool, config: GenAIConfig):
+ if not enabled:
+ return None
+ return _truncate(value, config.content_max_length)
+
+
+def capture_attributes(attributes: dict, config: GenAIConfig) -> dict:
+ flags = {
+ 'inputs': config.capture_inputs,
+ 'outputs': config.capture_outputs,
+ 'system_instructions': config.capture_system_instructions,
+ 'tool_arguments': config.capture_tool_arguments,
+ 'tool_result': config.capture_tool_results,
+ 'retrieval_query': config.capture_retrieval_query,
+ 'retrieval_documents': config.capture_retrieval_documents,
+ 'embedding_text': config.capture_embedding_text,
+ 'embedding_vectors': config.capture_embedding_vectors,
+ }
+ captured = {}
+ for name, value in attributes.items():
+ if name in flags:
+ value = capture(value, enabled=flags[name], config=config)
+ if value is not None:
+ captured[name] = value
+ return captured
diff --git a/backend/open_webui/utils/telemetry/genai/inference.py b/backend/open_webui/utils/telemetry/genai/inference.py
new file mode 100644
index 0000000000000000000000000000000000000000..51704bfca6dbe3cc0ac9cbdbcd4b382827b4808e
--- /dev/null
+++ b/backend/open_webui/utils/telemetry/genai/inference.py
@@ -0,0 +1,53 @@
+from collections.abc import Awaitable, Callable
+
+from open_webui.utils.telemetry.genai.purpose import purpose_for_task
+from open_webui.utils.telemetry.genai.streaming import wrap_stream
+
+
+async def traced_inference(
+ tracer,
+ *,
+ provider: str,
+ request_payload: dict,
+ call: Callable[[], Awaitable],
+):
+ metadata = request_payload.get('metadata') or {}
+ with tracer.inference(
+ 'chat_completion',
+ provider=provider,
+ request_model=request_payload.get('model'),
+ inputs=request_payload.get('messages'),
+ temperature=request_payload.get('temperature'),
+ top_p=request_payload.get('top_p'),
+ max_tokens=request_payload.get('max_tokens', request_payload.get('max_completion_tokens')),
+ seed=request_payload.get('seed'),
+ purpose=purpose_for_task(metadata.get('task')),
+ _defer_end=True,
+ ) as span:
+ response = await call()
+ body_iterator = getattr(response, 'body_iterator', None)
+ if body_iterator is not None:
+ chunks = []
+ captured_length = 0
+
+ def collect(item):
+ nonlocal captured_length
+ if tracer.config.capture_outputs and captured_length < tracer.config.content_max_length:
+ if isinstance(item, bytes):
+ item = item.decode('utf-8', errors='replace')
+ elif not isinstance(item, str):
+ item = str(item)
+ remaining = tracer.config.content_max_length - captured_length
+ chunk = item[:remaining]
+ chunks.append(chunk)
+ captured_length += len(chunk)
+
+ response.body_iterator = wrap_stream(
+ span,
+ body_iterator,
+ on_finish=lambda: tracer.finish_stream(span, chunks),
+ on_item=collect,
+ )
+ else:
+ tracer.finish_inference(span, response)
+ return response
diff --git a/backend/open_webui/utils/telemetry/genai/openinference.py b/backend/open_webui/utils/telemetry/genai/openinference.py
new file mode 100644
index 0000000000000000000000000000000000000000..fdf354d613e23a8228d39fb680539b2a29b25839
--- /dev/null
+++ b/backend/open_webui/utils/telemetry/genai/openinference.py
@@ -0,0 +1,15 @@
+from open_webui.utils.telemetry.genai import semconv
+
+_KINDS = {
+ 'rerank': 'RERANKER',
+ 'tool': 'TOOL',
+ 'embedding': 'EMBEDDING',
+ 'retrieval': 'RETRIEVER',
+ 'inference': 'LLM',
+ 'workflow': 'CHAIN',
+}
+
+
+def attributes_for(kind: str) -> dict:
+ value = _KINDS.get(kind)
+ return {semconv.OPENINFERENCE_SPAN_KIND: value} if value else {}
diff --git a/backend/open_webui/utils/telemetry/genai/purpose.py b/backend/open_webui/utils/telemetry/genai/purpose.py
new file mode 100644
index 0000000000000000000000000000000000000000..eb20addd9252458501b9b34f678fceda166ed882
--- /dev/null
+++ b/backend/open_webui/utils/telemetry/genai/purpose.py
@@ -0,0 +1,10 @@
+TASK_PURPOSES = {
+ 'title_generation': 'title_generation',
+ 'follow_up_generation': 'followup_generation',
+ 'tags_generation': 'tags_generation',
+ 'query_generation': 'retrieval_query',
+}
+
+
+def purpose_for_task(task) -> str:
+ return TASK_PURPOSES.get(str(task), 'primary')
diff --git a/backend/open_webui/utils/telemetry/genai/retrieval.py b/backend/open_webui/utils/telemetry/genai/retrieval.py
new file mode 100644
index 0000000000000000000000000000000000000000..88620ed6d04db4d7d0d0a40f8dc7638c6f48bdc6
--- /dev/null
+++ b/backend/open_webui/utils/telemetry/genai/retrieval.py
@@ -0,0 +1,88 @@
+import functools
+import inspect
+import json
+
+from open_webui.utils.telemetry.genai import semconv
+from open_webui.utils.telemetry.genai.extractors import capture
+from open_webui.utils.telemetry.genai.openinference import attributes_for
+from open_webui.utils.telemetry.genai.tracer import GenAITracer
+
+
+def instrument_embedding(tracer: GenAITracer, embedding_function):
+ if embedding_function is None:
+ return None
+
+ async def wrapped(query, *args, **kwargs):
+ with tracer.embedding('embedding', embedding_text=query) as span:
+ result = await embedding_function(query, *args, **kwargs)
+ if span.is_recording() and tracer.config.capture_embedding_vectors and result is not None:
+ value = capture(result, enabled=True, config=tracer.config)
+ if value is not None:
+ span.set_attribute(semconv.GEN_AI_EMBEDDINGS, value)
+ return result
+
+ return wrapped
+
+
+def instrument_reranker(tracer: GenAITracer, reranking_function):
+ if reranking_function is None:
+ return None
+
+ def wrapped(query, documents, *args, **kwargs):
+ with tracer.rerank('rerank', retrieval_query=query, **attributes_for('rerank')) as span:
+ result = reranking_function(query, documents, *args, **kwargs)
+ if span.is_recording():
+ span.set_attribute(semconv.OPENWEBUI_RETRIEVAL_CANDIDATE_COUNT, len(documents))
+ return result
+
+ return wrapped
+
+
+def traced_retrieval(tracer: GenAITracer):
+ def decorator(function):
+ @functools.wraps(function)
+ async def wrapped(*args, **kwargs):
+ bound = inspect.signature(function).bind_partial(*args, **kwargs)
+ values = bound.arguments
+ queries = values.get('queries') or []
+ query = queries[0] if isinstance(queries, list) and queries else queries
+ with tracer.retrieval(
+ 'retrieval',
+ operation_name='retrieval',
+ retrieval_query=query,
+ top_k=values.get('k'),
+ candidate_count=len(values.get('items') or []),
+ retrieval_mode='hybrid' if values.get('hybrid_search') else 'vector',
+ data_source=[
+ item.get('collection_name') for item in values.get('items') or [] if item.get('collection_name')
+ ],
+ ) as span:
+ if 'embedding_function' in values:
+ values['embedding_function'] = instrument_embedding(tracer, values['embedding_function'])
+ result = await function(*bound.args, **bound.kwargs)
+ if span.is_recording() and isinstance(result, list):
+ documents = []
+ for rank, source in enumerate(result[: tracer.config.retrieval_max_documents], 1):
+ metadata = source.get('metadata') or {}
+ distances = source.get('distances') or []
+ entry = {
+ 'document_id': metadata.get('document_id') or metadata.get('id'),
+ 'file_id': metadata.get('file_id'),
+ 'chunk_id': metadata.get('chunk_id'),
+ 'rank': rank,
+ 'score': distances[rank - 1] if len(distances) >= rank else metadata.get('score'),
+ }
+ if tracer.config.capture_retrieval_documents:
+ entry['content'] = capture(
+ (source.get('document') or [''])[0],
+ enabled=True,
+ config=tracer.config,
+ )
+ documents.append(json.dumps(entry, ensure_ascii=False, default=str))
+ span.set_attribute(semconv.GEN_AI_RETRIEVAL_DOCUMENTS, documents)
+ span.set_attribute(semconv.OPENWEBUI_RETRIEVAL_SELECTED_COUNT, len(result))
+ return result
+
+ return wrapped
+
+ return decorator
diff --git a/backend/open_webui/utils/telemetry/genai/semconv.py b/backend/open_webui/utils/telemetry/genai/semconv.py
new file mode 100644
index 0000000000000000000000000000000000000000..eecb9c737dab53b1d4673c08b2c5212d6c55c73f
--- /dev/null
+++ b/backend/open_webui/utils/telemetry/genai/semconv.py
@@ -0,0 +1,84 @@
+try:
+ from opentelemetry.semconv._incubating.attributes import gen_ai_attributes as _gen_ai
+except ImportError: # pragma: no cover - supports older OTel installations
+ _gen_ai = None
+
+
+def _official(name: str, fallback: str) -> str:
+ return getattr(_gen_ai, name, fallback) if _gen_ai is not None else fallback
+
+
+GEN_AI_OPERATION_NAME = _official('GEN_AI_OPERATION_NAME', 'gen_ai.operation.name')
+GEN_AI_PROVIDER_NAME = _official('GEN_AI_PROVIDER_NAME', 'gen_ai.provider.name')
+GEN_AI_REQUEST_MODEL = _official('GEN_AI_REQUEST_MODEL', 'gen_ai.request.model')
+GEN_AI_RESPONSE_MODEL = _official('GEN_AI_RESPONSE_MODEL', 'gen_ai.response.model')
+GEN_AI_RESPONSE_ID = _official('GEN_AI_RESPONSE_ID', 'gen_ai.response.id')
+GEN_AI_CONVERSATION_ID = _official('GEN_AI_CONVERSATION_ID', 'gen_ai.conversation.id')
+GEN_AI_USAGE_INPUT_TOKENS = _official('GEN_AI_USAGE_INPUT_TOKENS', 'gen_ai.usage.input_tokens')
+GEN_AI_USAGE_OUTPUT_TOKENS = _official('GEN_AI_USAGE_OUTPUT_TOKENS', 'gen_ai.usage.output_tokens')
+GEN_AI_TOOL_NAME = _official('GEN_AI_TOOL_NAME', 'gen_ai.tool.name')
+GEN_AI_TOOL_CALL_ID = _official('GEN_AI_TOOL_CALL_ID', 'gen_ai.tool.call.id')
+GEN_AI_TOOL_CALL_ARGUMENTS = _official('GEN_AI_TOOL_CALL_ARGUMENTS', 'gen_ai.tool.call.arguments')
+GEN_AI_TOOL_CALL_RESULT = _official('GEN_AI_TOOL_CALL_RESULT', 'gen_ai.tool.call.result')
+GEN_AI_TOOL_TYPE = _official('GEN_AI_TOOL_TYPE', 'gen_ai.tool.type')
+GEN_AI_RETRIEVAL_QUERY_TEXT = _official('GEN_AI_RETRIEVAL_QUERY_TEXT', 'gen_ai.retrieval.query.text')
+GEN_AI_RETRIEVAL_DOCUMENTS = _official('GEN_AI_RETRIEVAL_DOCUMENTS', 'gen_ai.retrieval.documents')
+GEN_AI_INPUT_MESSAGES = _official('GEN_AI_INPUT_MESSAGES', 'gen_ai.input.messages')
+GEN_AI_OUTPUT_MESSAGES = _official('GEN_AI_OUTPUT_MESSAGES', 'gen_ai.output.messages')
+GEN_AI_SYSTEM_INSTRUCTIONS = _official('GEN_AI_SYSTEM_INSTRUCTIONS', 'gen_ai.system.instructions')
+GEN_AI_RESPONSE_FINISH_REASONS = _official('GEN_AI_RESPONSE_FINISH_REASONS', 'gen_ai.response.finish_reasons')
+GEN_AI_REQUEST_TEMPERATURE = _official('GEN_AI_REQUEST_TEMPERATURE', 'gen_ai.request.temperature')
+GEN_AI_REQUEST_TOP_P = _official('GEN_AI_REQUEST_TOP_P', 'gen_ai.request.top_p')
+GEN_AI_REQUEST_MAX_TOKENS = _official('GEN_AI_REQUEST_MAX_TOKENS', 'gen_ai.request.max_tokens')
+GEN_AI_REQUEST_SEED = _official('GEN_AI_REQUEST_SEED', 'gen_ai.request.seed')
+GEN_AI_EMBEDDINGS = _official('GEN_AI_EMBEDDINGS', 'gen_ai.embeddings')
+
+OPENWEBUI_MESSAGE_ID = 'openwebui.message.id'
+OPENWEBUI_OPERATION_PURPOSE = 'openwebui.operation.purpose'
+OPENWEBUI_TOOL_TYPE = 'openwebui.tool.type'
+OPENWEBUI_RETRIEVAL_MODE = 'openwebui.retrieval.mode'
+OPENWEBUI_RETRIEVAL_TOP_K = 'openwebui.retrieval.top_k'
+OPENWEBUI_RETRIEVAL_THRESHOLD = 'openwebui.retrieval.threshold'
+OPENWEBUI_RETRIEVAL_CANDIDATE_COUNT = 'openwebui.retrieval.candidate_count'
+OPENWEBUI_RETRIEVAL_SELECTED_COUNT = 'openwebui.retrieval.selected_count'
+OPENWEBUI_RETRIEVAL_DATA_SOURCE = 'openwebui.retrieval.data_source'
+OPENINFERENCE_SPAN_KIND = 'openinference.span.kind'
+
+ALIASES = {
+ 'operation_name': GEN_AI_OPERATION_NAME,
+ 'provider': GEN_AI_PROVIDER_NAME,
+ 'request_model': GEN_AI_REQUEST_MODEL,
+ 'response_model': GEN_AI_RESPONSE_MODEL,
+ 'response_id': GEN_AI_RESPONSE_ID,
+ 'conversation_id': GEN_AI_CONVERSATION_ID,
+ 'input_tokens': GEN_AI_USAGE_INPUT_TOKENS,
+ 'output_tokens': GEN_AI_USAGE_OUTPUT_TOKENS,
+ 'tool_name': GEN_AI_TOOL_NAME,
+ 'tool_call_id': GEN_AI_TOOL_CALL_ID,
+ 'tool_arguments': GEN_AI_TOOL_CALL_ARGUMENTS,
+ 'tool_result': GEN_AI_TOOL_CALL_RESULT,
+ 'retrieval_query': GEN_AI_RETRIEVAL_QUERY_TEXT,
+ 'retrieval_documents': GEN_AI_RETRIEVAL_DOCUMENTS,
+ 'inputs': GEN_AI_INPUT_MESSAGES,
+ 'outputs': GEN_AI_OUTPUT_MESSAGES,
+ 'system_instructions': GEN_AI_SYSTEM_INSTRUCTIONS,
+ 'finish_reasons': GEN_AI_RESPONSE_FINISH_REASONS,
+ 'temperature': GEN_AI_REQUEST_TEMPERATURE,
+ 'top_p': GEN_AI_REQUEST_TOP_P,
+ 'max_tokens': GEN_AI_REQUEST_MAX_TOKENS,
+ 'seed': GEN_AI_REQUEST_SEED,
+ 'embedding_vectors': GEN_AI_EMBEDDINGS,
+ 'message_id': OPENWEBUI_MESSAGE_ID,
+ 'purpose': OPENWEBUI_OPERATION_PURPOSE,
+ 'tool_type': GEN_AI_TOOL_TYPE,
+ 'retrieval_mode': OPENWEBUI_RETRIEVAL_MODE,
+ 'top_k': OPENWEBUI_RETRIEVAL_TOP_K,
+ 'threshold': OPENWEBUI_RETRIEVAL_THRESHOLD,
+ 'candidate_count': OPENWEBUI_RETRIEVAL_CANDIDATE_COUNT,
+ 'selected_count': OPENWEBUI_RETRIEVAL_SELECTED_COUNT,
+ 'data_source': OPENWEBUI_RETRIEVAL_DATA_SOURCE,
+}
+
+
+def attribute_key(name: str) -> str:
+ return ALIASES.get(name, name)
diff --git a/backend/open_webui/utils/telemetry/genai/streaming.py b/backend/open_webui/utils/telemetry/genai/streaming.py
new file mode 100644
index 0000000000000000000000000000000000000000..aa0d3bfbaa772333d4932e51a76681806ae90bb0
--- /dev/null
+++ b/backend/open_webui/utils/telemetry/genai/streaming.py
@@ -0,0 +1,46 @@
+import asyncio
+import logging
+
+from open_webui.utils.telemetry.genai.config import GenAIConfig
+from opentelemetry.trace import StatusCode
+
+logger = logging.getLogger(__name__)
+
+
+async def wrap_stream(span, async_iterator, *, on_item=None, on_finish=None): # noqa: C901
+ ended = False
+ try:
+ async for item in async_iterator:
+ if on_item is not None:
+ try:
+ on_item(item)
+ except Exception:
+ logger.exception('Unable to capture GenAI stream item')
+ yield item
+ span.set_status(StatusCode.OK)
+ except asyncio.CancelledError:
+ span.set_status(StatusCode.ERROR)
+ raise
+ except BaseException as exc:
+ if span.is_recording():
+ span.add_event(
+ 'exception',
+ {
+ 'exception.type': type(exc).__name__,
+ 'exception.message': str(exc)[: GenAIConfig().exception_max_length],
+ },
+ )
+ span.set_status(StatusCode.ERROR)
+ raise
+ finally:
+ if on_finish is not None:
+ try:
+ on_finish()
+ except Exception:
+ logger.exception('Unable to capture GenAI stream output')
+ if not ended:
+ ended = True
+ try:
+ span.end()
+ except Exception:
+ logger.exception('Unable to end GenAI streaming span')
diff --git a/backend/open_webui/utils/telemetry/genai/tools.py b/backend/open_webui/utils/telemetry/genai/tools.py
new file mode 100644
index 0000000000000000000000000000000000000000..62572567c876585a95e359bd2df73dbb4e2c3044
--- /dev/null
+++ b/backend/open_webui/utils/telemetry/genai/tools.py
@@ -0,0 +1,39 @@
+from collections.abc import Awaitable, Callable
+
+from open_webui.utils.telemetry.genai import semconv
+from open_webui.utils.telemetry.genai.tracer import GenAITracer
+
+
+def classify_tool(tool_type: str | None, direct: bool = False) -> str:
+ normalized = (tool_type or '').lower()
+ if normalized in {'mcp', 'openapi', 'external', 'action', 'terminal', 'builtin', 'function'}:
+ return normalized
+ return 'builtin' if direct else 'function'
+
+
+async def traced_tool(
+ tracer: GenAITracer,
+ *,
+ name: str,
+ call_id: str | None,
+ tool_type: str | None,
+ arguments,
+ direct: bool,
+ call: Callable[[], Awaitable],
+):
+ with tracer.tool(
+ 'execute_tool',
+ operation_name='execute_tool',
+ tool_name=name,
+ tool_call_id=call_id,
+ tool_type=classify_tool(tool_type, direct),
+ tool_arguments=arguments,
+ ) as span:
+ result = await call()
+ if span.is_recording() and tracer.config.capture_tool_results and result is not None:
+ from open_webui.utils.telemetry.genai.extractors import capture
+
+ value = capture(result, enabled=True, config=tracer.config)
+ if value is not None:
+ span.set_attribute(semconv.GEN_AI_TOOL_CALL_RESULT, value)
+ return result
diff --git a/backend/open_webui/utils/telemetry/genai/tracer.py b/backend/open_webui/utils/telemetry/genai/tracer.py
new file mode 100644
index 0000000000000000000000000000000000000000..7dd74a02da250d6e51ddf4d27679d1e0a7ea57f4
--- /dev/null
+++ b/backend/open_webui/utils/telemetry/genai/tracer.py
@@ -0,0 +1,156 @@
+import logging
+from contextlib import contextmanager
+
+from open_webui.utils.telemetry.genai import context, extractors, semconv
+from open_webui.utils.telemetry.genai import openinference as oi
+from open_webui.utils.telemetry.genai.config import GenAIConfig
+from opentelemetry import trace
+from opentelemetry.trace import StatusCode
+
+logger = logging.getLogger(__name__)
+
+
+def _record_exception(span, exc, limit):
+ if not span.is_recording():
+ return
+ span.add_event(
+ 'exception',
+ {
+ 'exception.type': type(exc).__name__,
+ 'exception.message': str(exc)[:limit],
+ },
+ )
+
+
+class GenAITracer:
+ def __init__(self, tracer=None, config: GenAIConfig | None = None):
+ self.tracer = tracer or trace.get_tracer(__name__)
+ self.config = config or GenAIConfig.from_env()
+
+ def workflow(self, name='invoke_workflow', **attrs):
+ return self._span('workflow', name, **attrs)
+
+ def inference(self, name='inference', **attrs):
+ return self._span('inference', name, **attrs)
+
+ def embedding(self, name='embedding', **attrs):
+ return self._span('embedding', name, **attrs)
+
+ def retrieval(self, name='retrieval', **attrs):
+ return self._span('retrieval', name, **attrs)
+
+ def rerank(self, name='rerank', **attrs):
+ return self._span('rerank', name, **attrs)
+
+ def tool(self, name='execute_tool', **attrs):
+ return self._span('tool', name, **attrs)
+
+ def finish_inference(self, span, response):
+ try:
+ if span.is_recording() and isinstance(response, dict):
+ usage = response.get('usage') or {}
+ attributes = {
+ semconv.GEN_AI_RESPONSE_MODEL: response.get('model'),
+ semconv.GEN_AI_RESPONSE_ID: response.get('id'),
+ semconv.GEN_AI_USAGE_INPUT_TOKENS: usage.get('prompt_tokens', usage.get('input_tokens')),
+ semconv.GEN_AI_USAGE_OUTPUT_TOKENS: usage.get('completion_tokens', usage.get('output_tokens')),
+ semconv.GEN_AI_RESPONSE_FINISH_REASONS: [
+ choice.get('finish_reason')
+ for choice in response.get('choices', [])
+ if isinstance(choice, dict) and choice.get('finish_reason') is not None
+ ],
+ }
+ span.set_attributes({key: value for key, value in attributes.items() if value is not None})
+ if self.config.capture_outputs:
+ output = _response_output(response)
+ captured = extractors.capture(output, enabled=True, config=self.config)
+ if captured is not None:
+ span.set_attribute(semconv.GEN_AI_OUTPUT_MESSAGES, captured)
+ span.end()
+ except Exception:
+ logger.exception('Unable to finish GenAI inference span')
+
+ def finish_stream(self, span, chunks):
+ try:
+ if span.is_recording() and self.config.capture_outputs and chunks:
+ captured = extractors.capture(''.join(chunks), enabled=True, config=self.config)
+ if captured is not None:
+ span.set_attribute(semconv.GEN_AI_OUTPUT_MESSAGES, captured)
+ except Exception:
+ logger.exception('Unable to finish GenAI streaming span')
+
+ @contextmanager
+ # The lifecycle guard intentionally handles independent failure points.
+ def _span(self, kind, name, **attrs): # noqa: C901
+ if not self.config.enabled:
+ yield trace.INVALID_SPAN
+ return
+ defer_end = attrs.pop('_defer_end', False)
+ span = None
+ token = None
+ failed = False
+ try:
+ try:
+ span = self.tracer.start_span(name)
+ except Exception:
+ logger.exception('Unable to start GenAI span')
+ yield trace.INVALID_SPAN
+ return
+
+ try:
+ token = trace.use_span(span, end_on_exit=False)
+ token.__enter__()
+ if span.is_recording():
+ safe_attrs = extractors.capture_attributes(attrs, self.config)
+ resolved = {
+ semconv.attribute_key(key): value for key, value in safe_attrs.items() if value is not None
+ }
+ resolved.update(oi.attributes_for(kind))
+ if resolved:
+ span.set_attributes(resolved)
+ if kind == 'workflow':
+ context.apply_context(span, attrs)
+ context.set_context(
+ chat_id=attrs.get('chat_id'),
+ message_id=attrs.get('message_id'),
+ conversation_id=attrs.get('conversation_id'),
+ purpose=attrs.get('purpose', 'primary'),
+ )
+ except Exception:
+ logger.exception('Unable to initialize GenAI span')
+
+ try:
+ yield span
+ except BaseException as exc:
+ failed = True
+ try:
+ _record_exception(span, exc, self.config.exception_max_length)
+ span.set_status(StatusCode.ERROR)
+ except Exception:
+ logger.exception('Unable to record GenAI span exception')
+ raise
+ finally:
+ if token is not None:
+ try:
+ token.__exit__(None, None, None)
+ except Exception:
+ logger.exception('Unable to detach GenAI span context')
+ if span is not None and (not defer_end or failed):
+ try:
+ span.end()
+ except Exception:
+ logger.exception('Unable to end GenAI span')
+
+
+def _response_output(response):
+ choices = response.get('choices') or []
+ messages = []
+ for choice in choices:
+ if not isinstance(choice, dict):
+ continue
+ message = choice.get('message') or choice.get('text')
+ if message is not None:
+ messages.append(message)
+ if messages:
+ return messages
+ return response.get('output') or response.get('response')
diff --git a/backend/open_webui/utils/telemetry/setup.py b/backend/open_webui/utils/telemetry/setup.py
index b280d4519afb464504e5dc1d7ab56a740beeabdd..c2a3b6528a031a0aab526bd3ca47ebaed27f3383 100644
--- a/backend/open_webui/utils/telemetry/setup.py
+++ b/backend/open_webui/utils/telemetry/setup.py
@@ -31,7 +31,7 @@ def setup(app: FastAPI, db_engine: Engine):
trace.set_tracer_provider(TracerProvider(resource=resource))
# Add basic auth header only if both username and password are not empty
- headers = []
+ headers = None
if OTEL_BASIC_AUTH_USERNAME and OTEL_BASIC_AUTH_PASSWORD:
auth_string = f'{OTEL_BASIC_AUTH_USERNAME}:{OTEL_BASIC_AUTH_PASSWORD}'
auth_header = b64encode(auth_string.encode()).decode()
diff --git a/backend/tests/test_asgi_middleware.py b/backend/tests/test_asgi_middleware.py
new file mode 100644
index 0000000000000000000000000000000000000000..233bcb3e4841abe5594b40b16a592c638954f8cc
--- /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 AppHTTPMiddleware
+
+
+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(AppHTTPMiddleware)
+ 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 0000000000000000000000000000000000000000..0920a357e1d28e4e36dbfa531e890f8cb3490d08
--- /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/pyproject.toml b/pyproject.toml
index 202bf0a35266a98dbdb206a00d3dcf6ab683b074..c82c96cde5fa37614b8e144d3c815706c2ad5962 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -141,6 +141,20 @@ mariadb = [
unstructured = [
"unstructured==0.22.31",
]
+observability = [
+ "opentelemetry-api==1.42.1",
+ "opentelemetry-sdk==1.42.1",
+ "opentelemetry-exporter-otlp==1.42.1",
+ "opentelemetry-instrumentation==0.63b1",
+ "opentelemetry-instrumentation-fastapi==0.63b1",
+ "opentelemetry-instrumentation-sqlalchemy==0.63b1",
+ "opentelemetry-instrumentation-redis==0.63b1",
+ "opentelemetry-instrumentation-requests==0.63b1",
+ "opentelemetry-instrumentation-logging==0.63b1",
+ "opentelemetry-instrumentation-httpx==0.63b1",
+ "opentelemetry-instrumentation-aiohttp-client==0.63b1",
+ "opentelemetry-instrumentation-system-metrics==0.63b1",
+]
all = [
"psycopg2-binary==2.9.12",
diff --git a/src/app.html b/src/app.html
index 667808e01140741d52647707a55ef5dd803075e6..b711d8aa89b11331973d3ecf0b60ed702ae424e2 100644
--- a/src/app.html
+++ b/src/app.html
@@ -6,37 +6,51 @@
visual, textual, symbolic identifiers, metadata, and surrounding UI.
Do not alter, remove, obscure, or replace it except as LICENSE permits:
https://docs.openwebui.com/license. -->
- <link rel="icon" type="image/png" href="/static/favicon.png" crossorigin="use-credentials" />
<link
rel="icon"
type="image/png"
- href="/static/favicon-96x96.png"
+ href="%sveltekit.assets%/static/favicon.png"
+ crossorigin="use-credentials"
+ />
+ <link
+ rel="icon"
+ type="image/png"
+ href="%sveltekit.assets%/static/favicon-96x96.png"
sizes="96x96"
crossorigin="use-credentials"
/>
<link
rel="icon"
type="image/svg+xml"
- href="/static/favicon.svg"
+ href="%sveltekit.assets%/static/favicon.svg"
+ crossorigin="use-credentials"
+ />
+ <link
+ rel="shortcut icon"
+ href="%sveltekit.assets%/static/favicon.ico"
crossorigin="use-credentials"
/>
- <link rel="shortcut icon" href="/static/favicon.ico" crossorigin="use-credentials" />
<link
rel="apple-touch-icon"
sizes="180x180"
- href="/static/apple-touch-icon.png"
+ href="%sveltekit.assets%/static/apple-touch-icon.png"
crossorigin="use-credentials"
/>
- <link rel="manifest" href="/manifest.json" crossorigin="use-credentials" />
+ <link rel="manifest" href="%sveltekit.assets%/manifest.json" crossorigin="use-credentials" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, interactive-widget=resizes-content"
/>
<meta name="theme-color" content="#171717" />
<meta name="robots" content="noindex,nofollow" />
+ <meta name="webui-assets" content="%sveltekit.assets%" />
- <script src="/static/loader.js" defer crossorigin="use-credentials"></script>
- <link rel="stylesheet" href="/static/custom.css" crossorigin="use-credentials" />
+ <script src="%sveltekit.assets%/static/loader.js" defer crossorigin="use-credentials"></script>
+ <link
+ rel="stylesheet"
+ href="%sveltekit.assets%/static/custom.css"
+ crossorigin="use-credentials"
+ />
<script>
function resizeIframe(obj) {
@@ -78,9 +92,10 @@
// LICENSE covers these Open WebUI splash logo assets.
// Do not alter, remove, obscure, or replace them except as LICENSE permits:
// https://docs.openwebui.com/license.
+ const assetsBase = document.querySelector('meta[name="webui-assets"]')?.content || '';
const preloadHref = document.documentElement.classList.contains('dark')
- ? '/static/splash-dark.png'
- : '/static/splash.png';
+ ? `${assetsBase}/static/splash-dark.png`
+ : `${assetsBase}/static/splash.png`;
const preload = document.createElement('link');
preload.rel = 'preload';
preload.as = 'image';
@@ -112,7 +127,10 @@
// LICENSE covers this Open WebUI splash logo.
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
- logo.src = isDarkMode ? '/static/splash-dark.png' : '/static/splash.png';
+ const _assetsBase = document.querySelector('meta[name="webui-assets"]')?.content || '';
+ logo.src = isDarkMode
+ ? `${_assetsBase}/static/splash-dark.png`
+ : `${_assetsBase}/static/splash.png`;
document.addEventListener('DOMContentLoaded', function () {
const splash = document.getElementById('splash-screen');
@@ -163,7 +181,7 @@
<img
id="logo-her"
style="width: auto; height: 13rem"
- src="/static/splash.png"
+ src="%sveltekit.assets%/static/splash.png"
loading="eager"
fetchpriority="high"
class="animate-pulse-fast"
diff --git a/src/lib/components/NotificationToast.svelte b/src/lib/components/NotificationToast.svelte
index 3e7101dbce111a93b61a9212c61a3422f2a000d6..29e7d859fbe6defff9388e2c67366729c3139766 100644
--- a/src/lib/components/NotificationToast.svelte
+++ b/src/lib/components/NotificationToast.svelte
@@ -74,7 +74,7 @@
if (!$playingNotificationSound && $isLastActiveTab) {
playingNotificationSound.set(true);
- const audio = new Audio(`/audio/notification.mp3`);
+ const audio = new Audio(`${WEBUI_BASE_URL}/audio/notification.mp3`);
audio.play().finally(() => {
// Ensure the global state is reset after the sound finishes
playingNotificationSound.set(false);
diff --git a/src/lib/components/OnBoarding.svelte b/src/lib/components/OnBoarding.svelte
index c761dc19338a5825332d4264389f23b690267926..cf287e376fb2856e75aa12d44e3de145c3a21d26 100644
--- a/src/lib/components/OnBoarding.svelte
+++ b/src/lib/components/OnBoarding.svelte
@@ -1,5 +1,6 @@
<script>
import { getContext } from 'svelte';
+ import { base } from '$app/paths';
const i18n = getContext('i18n');
export let show = true;
@@ -46,7 +47,7 @@
<img
id="logo"
crossorigin="anonymous"
- src="/static/favicon.png"
+ src={`${base}/static/favicon.png`}
class="size-6 rounded-full"
alt="logo"
/>
diff --git a/src/lib/components/admin/Analytics.svelte b/src/lib/components/admin/Analytics.svelte
index da69de19e11120577846c15ea2d8d537e80f844e..97404f3ed847da491ff9a7869530febbf2ec0e71 100644
--- a/src/lib/components/admin/Analytics.svelte
+++ b/src/lib/components/admin/Analytics.svelte
@@ -1,4 +1,5 @@
<script>
+ import { base } from '$app/paths';
import { onMount, getContext } from 'svelte';
import { goto } from '$app/navigation';
import { user } from '$lib/stores';
@@ -11,7 +12,7 @@
onMount(async () => {
if ($user?.role !== 'admin') {
- await goto('/');
+ await goto(`${base}/`);
}
loaded = true;
});
diff --git a/src/lib/components/admin/Analytics/Dashboard.svelte b/src/lib/components/admin/Analytics/Dashboard.svelte
index 22fea3afc797b9e02e2e2a1226b77efa60788598..98538f5084f7d175a58696ca52f74d191dd57c3b 100644
--- a/src/lib/components/admin/Analytics/Dashboard.svelte
+++ b/src/lib/components/admin/Analytics/Dashboard.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { onMount, getContext } from 'svelte';
import { models } from '$lib/stores';
import {
@@ -512,7 +513,7 @@
// LICENSE covers this Open WebUI fallback logo.
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
- e.target.src = '/favicon.png';
+ e.target.src = `${base}/static/favicon.png`;
}}
/>
<span class="truncate max-w-[9.375rem]">{model.name}</span>
@@ -620,7 +621,7 @@
alt={user.name || 'User'}
class="size-5 rounded-full object-cover shrink-0"
on:error={(e) => {
- e.target.src = '/user.png';
+ e.target.src = `${base}/static/user.png`;
}}
/>
<span class="truncate max-w-[9.375rem]"
diff --git a/src/lib/components/admin/Analytics/ModelUsage.svelte b/src/lib/components/admin/Analytics/ModelUsage.svelte
new file mode 100644
index 0000000000000000000000000000000000000000..2e6a1f2445f5c85a4ef0c0ba82fe2ee624cdd422
--- /dev/null
+++ b/src/lib/components/admin/Analytics/ModelUsage.svelte
@@ -0,0 +1,174 @@
+<script lang="ts">
+ import { base } from '$app/paths';
+ import { onMount, getContext } from 'svelte';
+ import { models } from '$lib/stores';
+ import { getModelAnalytics } from '$lib/apis/analytics';
+ import Spinner from '$lib/components/common/Spinner.svelte';
+ import ChevronUp from '$lib/components/icons/ChevronUp.svelte';
+ import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
+ import { WEBUI_API_BASE_URL } from '$lib/constants';
+
+ const i18n = getContext('i18n');
+
+ let modelStats: Array<{ model_id: string; count: number; name?: string }> = [];
+ let loading = true;
+ let orderBy = 'count';
+ let direction: 'asc' | 'desc' = 'desc';
+
+ const toggleSort = (key: string) => {
+ if (orderBy === key) {
+ direction = direction === 'asc' ? 'desc' : 'asc';
+ } else {
+ orderBy = key;
+ direction = key === 'name' ? 'asc' : 'desc';
+ }
+ };
+
+ const loadAnalytics = async () => {
+ loading = true;
+ try {
+ const result = await getModelAnalytics(localStorage.token);
+ const modelsMap = new Map($models.map((m) => [m.id, m.name || m.id]));
+
+ modelStats = (result?.models ?? []).map((entry) => ({
+ ...entry,
+ name: modelsMap.get(entry.model_id) || entry.model_id
+ }));
+ } catch (err) {
+ console.error('Analytics load failed:', err);
+ }
+ loading = false;
+ };
+
+ $: sortedModels = [...modelStats].sort((a, b) => {
+ if (orderBy === 'name') {
+ return direction === 'asc' ? a.name.localeCompare(b.name) : b.name.localeCompare(a.name);
+ }
+ return direction === 'asc' ? a.count - b.count : b.count - a.count;
+ });
+
+ $: totalMessages = modelStats.reduce((sum, m) => sum + m.count, 0);
+
+ onMount(loadAnalytics);
+</script>
+
+<div
+ class="pt-0.5 pb-1 gap-1 flex flex-col md:flex-row justify-between sticky top-0 z-10 bg-white dark:bg-gray-900"
+>
+ <div class="flex items-center text-xl font-medium px-0.5 gap-2 shrink-0">
+ {$i18n.t('Model Usage')}
+ <span class="text-lg text-gray-500">{totalMessages} {$i18n.t('messages')}</span>
+ </div>
+</div>
+
+<div
+ class="scrollbar-hidden relative whitespace-nowrap overflow-x-auto max-w-full rounded-sm min-h-[100px]"
+>
+ {#if loading}
+ <div
+ class="absolute inset-0 flex items-center justify-center z-10 bg-white/50 dark:bg-gray-900/50"
+ >
+ <Spinner className="size-5" />
+ </div>
+ {/if}
+
+ {#if !modelStats.length && !loading}
+ <div class="text-center text-xs text-gray-500 py-1">{$i18n.t('No data found')}</div>
+ {:else if modelStats.length}
+ <table
+ class="w-full text-sm text-left text-gray-500 dark:text-gray-400 {loading
+ ? 'opacity-20'
+ : ''}"
+ >
+ <thead class="text-xs text-gray-800 uppercase bg-transparent dark:text-gray-200">
+ <tr class="border-b-[1.5px] border-gray-50 dark:border-gray-850/30">
+ <th scope="col" class="px-2.5 py-2 w-8">#</th>
+ <th
+ scope="col"
+ class="px-2.5 py-2 cursor-pointer select-none"
+ on:click={() => toggleSort('name')}
+ >
+ <div class="flex gap-1.5 items-center">
+ {$i18n.t('Model')}
+ {#if orderBy === 'name'}
+ {#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
+ className="size-2"
+ />{/if}
+ {:else}
+ <span class="invisible"><ChevronUp className="size-2" /></span>
+ {/if}
+ </div>
+ </th>
+ <th
+ scope="col"
+ class="px-2.5 py-2 cursor-pointer select-none text-right"
+ on:click={() => toggleSort('count')}
+ >
+ <div class="flex gap-1.5 items-center justify-end">
+ {$i18n.t('Messages')}
+ {#if orderBy === 'count'}
+ {#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
+ className="size-2"
+ />{/if}
+ {:else}
+ <span class="invisible"><ChevronUp className="size-2" /></span>
+ {/if}
+ </div>
+ </th>
+ <th
+ scope="col"
+ class="px-2.5 py-2 cursor-pointer select-none text-right w-24"
+ on:click={() => toggleSort('percentage')}
+ >
+ <div class="flex gap-1.5 items-center justify-end">
+ {$i18n.t('Share')}
+ {#if orderBy === 'percentage'}
+ {#if direction === 'asc'}<ChevronUp className="size-2" />{:else}<ChevronDown
+ className="size-2"
+ />{/if}
+ {:else}
+ <span class="invisible"><ChevronUp className="size-2" /></span>
+ {/if}
+ </div>
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each sortedModels as model, idx (model.model_id)}
+ <tr
+ class="bg-white dark:bg-gray-900 text-xs hover:bg-gray-50 dark:hover:bg-gray-850/50 transition"
+ >
+ <td class="px-3 py-1.5 font-medium text-gray-900 dark:text-white">
+ {idx + 1}
+ </td>
+ <td class="px-3 py-1.5">
+ <div class="flex items-center gap-2">
+ <img
+ src="{WEBUI_API_BASE_URL}/models/model/profile/image?id={model.model_id}"
+ alt={model.name}
+ class="size-5 rounded-full object-cover shrink-0"
+ on:error={(e) => {
+ e.target.src = `${base}/static/favicon.png`;
+ }}
+ />
+ <span class="font-medium text-gray-800 dark:text-gray-200">{model.name}</span>
+ </div>
+ </td>
+ <td class="px-3 py-1.5 text-right font-medium text-gray-900 dark:text-white">
+ {model.count.toLocaleString()}
+ </td>
+ <td class="px-3 py-1.5 text-right font-medium text-blue-500">
+ {((model.count / totalMessages) * 100).toFixed(1)}%
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ {/if}
+</div>
+
+<div class="text-gray-500 text-xs mt-1.5 w-full flex justify-end">
+ <div class="text-right">
+ ⓘ {$i18n.t('Message counts are based on assistant responses.')}
+ </div>
+</div>
diff --git a/src/lib/components/admin/Evaluations.svelte b/src/lib/components/admin/Evaluations.svelte
index 5a7bb7965eca4aed50835cdb62dd122e52cc5bdc..d2112a708da149dba153b3078907483e183cf995 100644
--- a/src/lib/components/admin/Evaluations.svelte
+++ b/src/lib/components/admin/Evaluations.svelte
@@ -1,5 +1,7 @@
<script>
- import { getContext, onMount } from 'svelte';
+ import { base } from '$app/paths';
+ import { getContext, tick, onMount } from 'svelte';
+ import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { adminFeedbackCount, adminLeaderboardCount, models as _models } from '$lib/stores';
import { getFeedbackItems, getLeaderboard } from '$lib/apis/evaluations';
@@ -84,7 +86,7 @@
>
<a
id="leaderboard"
- href="/admin/evaluations/leaderboard"
+ href="{base}/admin/evaluations/leaderboard"
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex items-center gap-1.5 text-right transition select-none {selectedTab ===
'leaderboard'
@@ -101,7 +103,7 @@
<a
id="feedback"
- href="/admin/evaluations/feedback"
+ href="{base}/admin/evaluations/feedback"
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex items-center gap-1.5 text-right transition select-none {selectedTab ===
'feedback'
diff --git a/src/lib/components/admin/Evaluations/FeedbackModal.svelte b/src/lib/components/admin/Evaluations/FeedbackModal.svelte
index ef977bcab27b3a2ef794ce2341b9fb9d75bd9bcf..c0dd71c7165bd5288907fda261b8fbe6d5e4a25b 100644
--- a/src/lib/components/admin/Evaluations/FeedbackModal.svelte
+++ b/src/lib/components/admin/Evaluations/FeedbackModal.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import Modal from '$lib/components/common/Modal.svelte';
import { getContext } from 'svelte';
const i18n: any = getContext('i18n');
@@ -63,7 +64,7 @@
<div class="flex-1 text-xs">
<a
- href={`/s/${selectedFeedback?.meta?.chat_id}`}
+ href={`${base}/s/${selectedFeedback?.meta?.chat_id}`}
class=" hover:underline"
target="_blank"
>
diff --git a/src/lib/components/admin/Evaluations/Leaderboard.svelte b/src/lib/components/admin/Evaluations/Leaderboard.svelte
index 68246cc8dc2533fa47119d16dc4f5c4151370e58..b980c3888a68f6fc8f9bef8bc4e3d7db87433bdd 100644
--- a/src/lib/components/admin/Evaluations/Leaderboard.svelte
+++ b/src/lib/components/admin/Evaluations/Leaderboard.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { getContext } from 'svelte';
import { adminLeaderboardCount, models } from '$lib/stores';
import { getLeaderboard } from '$lib/apis/evaluations';
@@ -217,7 +218,7 @@
// LICENSE covers this Open WebUI fallback logo.
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
- e.target.src = '/favicon.png';
+ e.target.src = `${base}/static/favicon.png`;
}}
/>
<Tooltip content={`${model.name} (${model.id})`} placement="top-start">
diff --git a/src/lib/components/admin/Functions.svelte b/src/lib/components/admin/Functions.svelte
index 075cd053e20516fc4d5df2d1339e8eca8d2cfe81..f427389f9e8e417f015a18620f6b59039d63e450 100644
--- a/src/lib/components/admin/Functions.svelte
+++ b/src/lib/components/admin/Functions.svelte
@@ -2,6 +2,7 @@
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
@@ -149,7 +150,7 @@
};
const openFunction = (func) => {
- goto(`/admin/functions/edit?id=${encodeURIComponent(func.id)}`);
+ goto(`${base}/admin/functions/edit?id=${encodeURIComponent(func.id)}`);
};
const shouldIgnoreRowClick = (target: EventTarget | null) => {
@@ -197,7 +198,7 @@
id: `${_function.id}_clone`,
name: `${_function.name} (${$i18n.t('Clone')})`
});
- goto('/admin/functions/create');
+ goto(`${base}/admin/functions/create`);
}
};
@@ -325,7 +326,7 @@
sessionStorage.function = JSON.stringify({
...func
});
- goto('/admin/functions/create');
+ goto(`${base}/admin/functions/create`);
}}
/>
@@ -363,7 +364,7 @@
{
id: 'functions-new',
label: $i18n.t('Create'),
- href: '/admin/functions/create'
+ href: `${base}/admin/functions/create`
},
{
id: 'functions-import-link',
@@ -659,7 +660,7 @@
{func}
show={openFunctionMenuId === func.id}
editHandler={() => {
- goto(`/admin/functions/edit?id=${encodeURIComponent(func.id)}`);
+ goto(`${base}/admin/functions/edit?id=${encodeURIComponent(func.id)}`);
}}
shareHandler={() => {
shareHandler(func);
diff --git a/src/lib/components/admin/Functions/FunctionEditor.svelte b/src/lib/components/admin/Functions/FunctionEditor.svelte
index d72061607920341fd6dd5710d2537c68240e991b..6a27cc2777cdc28652cccc0010c26a579bc5c9e6 100644
--- a/src/lib/components/admin/Functions/FunctionEditor.svelte
+++ b/src/lib/components/admin/Functions/FunctionEditor.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { getContext, onMount, tick } from 'svelte';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
@@ -467,7 +468,7 @@ class Pipe:
class="flex h-6 w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md text-xs text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => {
- goto('/admin/functions');
+ goto(`\${base}/admin/functions`);
}}
>
<ChevronLeft className="size-3" strokeWidth="2" />
diff --git a/src/lib/components/admin/Settings/Models.svelte b/src/lib/components/admin/Settings/Models.svelte
index 4ef78682ad368049c0afc45e742ccfc93e810394..93741730e6a597ec9a98b89ce8aa98f4a3a3029e 100644
--- a/src/lib/components/admin/Settings/Models.svelte
+++ b/src/lib/components/admin/Settings/Models.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { marked } from 'marked';
import Sortable from 'sortablejs';
import fileSaver from 'file-saver';
@@ -669,7 +670,7 @@
name: `${model.name} (Clone)`
});
showSettings.set(false);
- await goto('/workspace/models/create');
+ await goto(`${base}/workspace/models/create`);
};
const exportModelHandler = async (model) => {
@@ -1050,7 +1051,7 @@
// LICENSE covers this Open WebUI fallback logo.
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
- e.target.src = '/favicon.png';
+ e.target.src = `${base}/static/favicon.png`;
}}
/>
</div>
diff --git a/src/lib/components/admin/Users.svelte b/src/lib/components/admin/Users.svelte
index a695beb52f56d385e25684a49336490f7f4a1586..c838aef1f8222f71e76a7f2caf8450d324eebc68 100644
--- a/src/lib/components/admin/Users.svelte
+++ b/src/lib/components/admin/Users.svelte
@@ -1,4 +1,5 @@
<script>
+ import { base } from '$app/paths';
import { getContext, onMount } from 'svelte';
import { goto } from '$app/navigation';
@@ -59,7 +60,7 @@
onMount(async () => {
if ($user?.role !== 'admin') {
- await goto('/');
+ await goto(`${base}/`);
}
loaded = true;
@@ -88,7 +89,7 @@
>
<a
id="overview"
- href="/admin/users/overview"
+ href={`${base}/admin/users/overview`}
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex items-center gap-1.5 text-right transition select-none {selectedTab ===
'overview'
@@ -109,7 +110,7 @@
<a
id="groups"
- href="/admin/users/groups"
+ href={`${base}/admin/users/groups`}
draggable="false"
class="px-0.5 py-1 min-w-fit rounded-lg lg:flex-none flex items-center gap-1.5 text-right transition select-none {selectedTab ===
'groups'
diff --git a/src/lib/components/admin/Users/Groups.svelte b/src/lib/components/admin/Users/Groups.svelte
index 34e789af2582d8d4d75212fad44b754b50d4d7ff..ca2499ed1ebbf5286110e241929dfd61fdf71f0e 100644
--- a/src/lib/components/admin/Users/Groups.svelte
+++ b/src/lib/components/admin/Users/Groups.svelte
@@ -3,6 +3,7 @@
</script>
<script>
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import { onMount, getContext } from 'svelte';
import { goto } from '$app/navigation';
@@ -188,7 +189,7 @@
onMount(async () => {
if ($user?.role !== 'admin') {
- await goto('/');
+ await goto(`${base}/`);
return;
}
diff --git a/src/lib/components/admin/Users/UserList.svelte b/src/lib/components/admin/Users/UserList.svelte
index bc735ed2687e407e55d8990b04b463bd15c685fe..141f11cb33f57ee6dc2fa40538a576ecc529c3a2 100644
--- a/src/lib/components/admin/Users/UserList.svelte
+++ b/src/lib/components/admin/Users/UserList.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { WEBUI_API_BASE_URL } from '$lib/constants';
import { adminUserCount, config, user } from '$lib/stores';
import { getContext, onDestroy } from 'svelte';
@@ -379,7 +380,7 @@
// LICENSE covers this Open WebUI fallback logo.
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
- e.currentTarget.src = '/favicon.png';
+ e.currentTarget.src = `${base}/static/favicon.png`;
}}
/>
</ProfilePreview>
diff --git a/src/lib/components/admin/Users/UserList/EditUserModal.svelte b/src/lib/components/admin/Users/UserList/EditUserModal.svelte
index 5d5893b0c5cb28bb2a6c85228f1104aae20e8090..8433ae85591a2f839c9baa930aae90924214739b 100644
--- a/src/lib/components/admin/Users/UserList/EditUserModal.svelte
+++ b/src/lib/components/admin/Users/UserList/EditUserModal.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import dayjs from 'dayjs';
import { createEventDispatcher } from 'svelte';
@@ -139,9 +140,9 @@
class="px-1.5 py-0.5 rounded-xl bg-gray-100 dark:bg-gray-850 text-xs"
>
<a
- href={'/admin/users/groups?id=' + userGroup.id}
+ href={`${base}/admin/users/groups?id=${userGroup.id}`}
on:click|preventDefault={() =>
- goto('/admin/users/groups?id=' + userGroup.id)}
+ goto(`${base}/admin/users/groups?id=` + userGroup.id)}
>
{userGroup.name}
</a>
diff --git a/src/lib/components/automations/AutomationEditor.svelte b/src/lib/components/automations/AutomationEditor.svelte
index f669b46fc966f0643e05b568491fb2d526317d9e..357aa74da9ba7ae7b55ca28152301ba5a804ef88 100644
--- a/src/lib/components/automations/AutomationEditor.svelte
+++ b/src/lib/components/automations/AutomationEditor.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { formatSchedule } from '$lib/utils/schedule';
import { onMount, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
@@ -144,7 +145,7 @@
});
if (res) {
toast.success($i18n.t(`Deleted {{name}}`, { name: automation.name }));
- goto('/automations');
+ goto(`\${base}/automations`);
}
};
diff --git a/src/lib/components/automations/ModelDropdown.svelte b/src/lib/components/automations/ModelDropdown.svelte
index f5522fc5ebefd464b4322454fb0a1112febdc339..a2939585823182bc05dca0ee6e0c8601c2df529d 100644
--- a/src/lib/components/automations/ModelDropdown.svelte
+++ b/src/lib/components/automations/ModelDropdown.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { getContext } from 'svelte';
import { models } from '$lib/stores';
@@ -112,7 +113,7 @@
// LICENSE covers this Open WebUI fallback logo.
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
- e.currentTarget.src = '/favicon.png';
+ e.currentTarget.src = `${base}/static/favicon.png`;
}}
/>
<div class="truncate">
diff --git a/src/lib/components/channel/Channel.svelte b/src/lib/components/channel/Channel.svelte
index d1e51d658c7c59d0e5b4d18ad389fa560a725226..75f4c2941274b14dcfea7e6cdad553cd1611de0d 100644
--- a/src/lib/components/channel/Channel.svelte
+++ b/src/lib/components/channel/Channel.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import { onDestroy, onMount, tick } from 'svelte';
@@ -136,7 +137,7 @@
}
}
} else {
- goto('/');
+ goto(`${base}/`);
}
};
diff --git a/src/lib/components/channel/MessageInput/MentionList.svelte b/src/lib/components/channel/MessageInput/MentionList.svelte
index 087a7a9381b08291035c52e4620fa26452958877..d598dca57f41666a2af96b69dacdb023fe9e57ae 100644
--- a/src/lib/components/channel/MessageInput/MentionList.svelte
+++ b/src/lib/components/channel/MessageInput/MentionList.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { getAbortSignal, getContext, onMount } from 'svelte';
const i18n = getContext('i18n');
@@ -233,7 +234,7 @@
// LICENSE covers this Open WebUI fallback logo.
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
- e.currentTarget.src = '/favicon.png';
+ e.currentTarget.src = `${base}/static/favicon.png`;
}}
/>
{:else if item.type === 'user'}
@@ -245,7 +246,7 @@
// LICENSE covers this Open WebUI fallback logo.
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
- e.currentTarget.src = '/favicon.png';
+ e.currentTarget.src = `${base}/static/favicon.png`;
}}
/>
{/if}
diff --git a/src/lib/components/channel/Messages/Message.svelte b/src/lib/components/channel/Messages/Message.svelte
index 6c2bb4650eaa975033d7657c675d32a5b816cbda..d6ce666c1c4889a73d2ed7475dba40fd8b775fde 100644
--- a/src/lib/components/channel/Messages/Message.svelte
+++ b/src/lib/components/channel/Messages/Message.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
// Keep keys in nested template expressions visible to i18next-parser.
// $i18n.t("and");
// $i18n.t("and {{COUNT}} others");
diff --git a/src/lib/components/channel/Messages/Message/UserStatus.svelte b/src/lib/components/channel/Messages/Message/UserStatus.svelte
index 3261e9197eefeb0af81ad0898f5639b02824abd3..6ede25ffae324e308ca1daa57a271061f1ac1ba1 100644
--- a/src/lib/components/channel/Messages/Message/UserStatus.svelte
+++ b/src/lib/components/channel/Messages/Message/UserStatus.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { getContext, onMount } from 'svelte';
const i18n = getContext<typeof import('$lib/i18n').default>('i18n');
@@ -68,7 +69,7 @@
});
if (res) {
- goto(`/channels/${res.id}`);
+ goto(`${base}/channels/${res.id}`);
}
};
</script>
diff --git a/src/lib/components/channel/Thread.svelte b/src/lib/components/channel/Thread.svelte
index b5516ae67b486c564bee9ec3f067ee171f44ba73..75300f186a5562feef903d6de358296d848feb70 100644
--- a/src/lib/components/channel/Thread.svelte
+++ b/src/lib/components/channel/Thread.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { goto } from '$app/navigation';
import { socket, user } from '$lib/stores';
@@ -58,7 +59,7 @@
await tick();
scrollToBottom();
} else {
- goto('/');
+ goto(`${base}/`);
}
};
diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte
index b7b9252c036da81a53d2e73c40dc82e2606e94ec..ef399192837fff9af1d0ce2bfcca34939c36d80e 100644
--- a/src/lib/components/chat/Chat.svelte
+++ b/src/lib/components/chat/Chat.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { v4 as uuidv4 } from 'uuid';
import { toast } from 'svelte-sonner';
@@ -921,7 +922,7 @@
messageInput?.focus({ preventScroll: true });
} else if (!embedded) {
- await goto('/');
+ await goto(`${base}/`);
} else {
loading = false;
console.warn('[note-chat] embedded load failed; clearing spinner', {
@@ -1847,7 +1848,10 @@
}
const pageSubscribe = page.subscribe(async (p) => {
- if (p.url.pathname === '/' || p.url.pathname.startsWith('/folders/')) {
+ // Route match is base-prefixed: under WEBUI_SUBPATH the root is e.g.
+ // "/chat/", and a missed match here silently skips initNewChat()
+ // (dropping ?model=/&models=/&temporary-chat=).
+ if (p.url.pathname === `${base}/` || p.url.pathname.startsWith(`${base}/folders/`)) {
await tick();
initNewChat();
}
@@ -2600,7 +2604,7 @@
await showArtifacts.set(false);
if (!embedded && $page.url.pathname.includes('/c/')) {
- window.history.replaceState(window.history.state, '', `/`);
+ window.history.replaceState(window.history.state, '', `${base}/`);
}
autoScroll = true;
@@ -2732,7 +2736,7 @@
error
});
if (!embedded) {
- await goto('/');
+ await goto(`${base}/`);
}
return null;
});
@@ -4230,7 +4234,7 @@
});
await chatId.set(res.chat_id);
if (!$temporaryChatEnabled && !embedded) {
- window.history.replaceState(window.history.state, '', `/c/${res.chat_id}`);
+ window.history.replaceState(window.history.state, '', `${base}/c/${res.chat_id}`);
await refreshChatList(localStorage.token);
// Persist chat-level params (system prompt, advanced
@@ -4491,7 +4495,7 @@
await chatId.set(_chatId);
if (!embedded) {
- window.history.replaceState(window.history.state, '', `/c/${_chatId}`);
+ window.history.replaceState(window.history.state, '', `${base}/c/${_chatId}`);
}
await tick();
@@ -4631,7 +4635,7 @@
try {
const res = await archiveChatById(localStorage.token, id);
initNewChat();
- await goto('/');
+ await goto(`${base}/`);
await refreshChatList(localStorage.token, { refreshPinned: true });
await refreshFolderChatLists();
toast.success(res?.archived ? $i18n.t('Chat archived.') : $i18n.t('Chat unarchived.'));
@@ -4667,7 +4671,7 @@
const res = await deleteChatById(localStorage.token, id);
if (res) {
initNewChat();
- await goto('/');
+ await goto(`${base}/`);
await refreshChatList(localStorage.token, { refreshPinned: true });
await refreshFolderChatLists();
allTags.set(await getAllTags(localStorage.token));
@@ -4953,7 +4957,7 @@
chatId.set(savedChat.id);
await refreshChatList(localStorage.token);
- await goto(`/c/${savedChat.id}`);
+ await goto(`${base}/c/${savedChat.id}`);
toast.success($i18n.t('Conversation saved successfully'));
}
} catch (error) {
diff --git a/src/lib/components/chat/ChatPlaceholder.svelte b/src/lib/components/chat/ChatPlaceholder.svelte
index 469395e85c7ae658f775ac2999b58db55c5cc0ac..6ae5d75b55009f2e95f9f8a4d2e137596b0bc312 100644
--- a/src/lib/components/chat/ChatPlaceholder.svelte
+++ b/src/lib/components/chat/ChatPlaceholder.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
@@ -84,7 +85,7 @@
// LICENSE covers this Open WebUI fallback logo.
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
- e.currentTarget.src = '/favicon.png';
+ e.currentTarget.src = `${base}/static/favicon.png`;
}}
/>
</Tooltip>
diff --git a/src/lib/components/chat/FileNav/SqliteView.svelte b/src/lib/components/chat/FileNav/SqliteView.svelte
index 0a0ef852d1e8048af1a813dfc74aa7fdd5fdac85..3bd0d0860344c45ca274fc3f6d08abd8e576d292 100644
--- a/src/lib/components/chat/FileNav/SqliteView.svelte
+++ b/src/lib/components/chat/FileNav/SqliteView.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
import { getContext, onDestroy } from 'svelte';
import Spinner from '../../common/Spinner.svelte';
+ import { WEBUI_BASE_URL } from '$lib/constants';
const i18n: any = getContext('i18n');
@@ -29,7 +30,7 @@
try {
const initSqlJs = (await import('sql.js')).default;
const SQL = await initSqlJs({
- locateFile: () => '/sql.js/sql-wasm.wasm'
+ locateFile: () => `${WEBUI_BASE_URL}/sql.js/sql-wasm.wasm`
});
db = new SQL.Database(new Uint8Array(data));
diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte
index 002e5554b8698c77905a492cba55023299a30b7f..68cfb1ebdb71c18fb3f992c96d1af084f7f9a6ce 100644
--- a/src/lib/components/chat/MessageInput.svelte
+++ b/src/lib/components/chat/MessageInput.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { resolveLocalizedFunction } from '$lib/utils/localizedContent';
import { functions as localizedFunctions } from '$lib/stores';
import DOMPurify from 'dompurify';
@@ -1230,7 +1231,7 @@
if (res) {
// Clear the input content saved in session storage.
sessionStorage.removeItem('chat-input');
- goto(`/notes/${res.id}`);
+ goto(`${base}/notes/${res.id}`);
}
};
diff --git a/src/lib/components/chat/MessageInput/Commands/Models.svelte b/src/lib/components/chat/MessageInput/Commands/Models.svelte
index c607c4dee73e6891837dcf754757581dc48126ec..d1581f44d79656a696fc637728650a89fe388a9c 100644
--- a/src/lib/components/chat/MessageInput/Commands/Models.svelte
+++ b/src/lib/components/chat/MessageInput/Commands/Models.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import Fuse from 'fuse.js';
import { createEventDispatcher, onDestroy, onMount } from 'svelte';
@@ -95,7 +96,7 @@
// LICENSE covers this Open WebUI fallback logo.
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
- e.currentTarget.src = '/favicon.png';
+ e.currentTarget.src = `${base}/static/favicon.png`;
}}
/>
<div class="min-w-0 truncate">
diff --git a/src/lib/components/chat/MessageInput/TerminalMenu.svelte b/src/lib/components/chat/MessageInput/TerminalMenu.svelte
index 870fdc79247f3373048d46d414abb38b1b0030a2..a6728236d034712640ef0bbbb6a43865d713d78b 100644
--- a/src/lib/components/chat/MessageInput/TerminalMenu.svelte
+++ b/src/lib/components/chat/MessageInput/TerminalMenu.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { getContext } from 'svelte';
import {
diff --git a/src/lib/components/chat/Messages/Citations.svelte b/src/lib/components/chat/Messages/Citations.svelte
index e53a8753f74ba5fa55f399452e7da2bd0e772b1f..69c194dea24b04fd90060d06e1ae203eb2aefc32 100644
--- a/src/lib/components/chat/Messages/Citations.svelte
+++ b/src/lib/components/chat/Messages/Citations.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { getContext } from 'svelte';
import { embed, showControls, showEmbeds } from '$lib/stores';
import { isValidHttpUrl } from '$lib/utils';
@@ -192,7 +193,7 @@
// LICENSE covers this Open WebUI fallback logo.
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
- e.target.src = '/favicon.png';
+ e.target.src = `${base}/static/favicon.png`;
}}
/>
{/each}
diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte
index fa82a1f4278c19f3ebcd77e71206014a21f06ebd..fab7d788e9035b17b408cbd67b5d9995b89b263a 100644
--- a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte
+++ b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import type { Token } from 'marked';
import { LinkPreview } from 'bits-ui';
@@ -96,13 +97,13 @@
console.log('Clicked user mention', id);
} else if (idType === 'M') {
console.log('Clicked model mention', id);
- await goto(`/?model=${id}`);
+ await goto(`${base}/?model=${id}`);
}
} else if (triggerChar === '#') {
if (idType === 'C') {
// Open channel
if ($channels.find((c) => c.id === id)) {
- await goto(`/channels/${id}`);
+ await goto(`${base}/channels/${id}`);
}
} else if (idType === 'T') {
// Open thread
diff --git a/src/lib/components/chat/ModelSelector/ModelItem.svelte b/src/lib/components/chat/ModelSelector/ModelItem.svelte
index f6a40325f36c2f79b15f633f965bbe83db33d524..0c050c0f13f15277fd16793f943956fdbe4d249c 100644
--- a/src/lib/components/chat/ModelSelector/ModelItem.svelte
+++ b/src/lib/components/chat/ModelSelector/ModelItem.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { marked } from 'marked';
import { getContext, tick } from 'svelte';
@@ -106,7 +107,7 @@
// LICENSE covers this Open WebUI fallback logo.
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
- e.currentTarget.src = '/favicon.png';
+ e.currentTarget.src = `${base}/static/favicon.png`;
}}
/>
</Tooltip>
diff --git a/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte b/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte
index acf546d5f6547330be1250c1e3cc513c95061e35..10194fbf69ca0d21f834c1d9c65f97d48114d524 100644
--- a/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte
+++ b/src/lib/components/chat/ModelSelector/ModelItemMenu.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
import { getContext } from 'svelte';
import { goto } from '$app/navigation';
+ import { base } from '$app/paths';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import DropdownMenu from '$lib/components/common/DropdownMenu.svelte';
@@ -58,7 +59,7 @@
e.preventDefault();
if (model?.preset || model?.info?.base_model_id) {
- goto(`/workspace/models/edit?id=${encodeURIComponent(model?.id ?? '')}`);
+ goto(`${base}/workspace/models/edit?id=${encodeURIComponent(model?.id ?? '')}`);
} else {
showSettings.set({ tab: 'admin:models', state: { id: model?.id ?? null } });
}
diff --git a/src/lib/components/chat/ModelSelector/Selector.svelte b/src/lib/components/chat/ModelSelector/Selector.svelte
index 683acd5540aeb8f61139c445f3464abc0fc4a023..d94b5cd1715d00e2ed4f581d804343f26ad84b65 100644
--- a/src/lib/components/chat/ModelSelector/Selector.svelte
+++ b/src/lib/components/chat/ModelSelector/Selector.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { marked } from 'marked';
import Fuse from 'fuse.js';
import { autoUpdate, computePosition, flip, offset, shift, size } from '@floating-ui/dom';
diff --git a/src/lib/components/chat/Navbar.svelte b/src/lib/components/chat/Navbar.svelte
index 681a3675fca8a26441051e258ed962e427cd3a6a..581c8340d1a8d68d08795430bd023c3b83f29020 100644
--- a/src/lib/components/chat/Navbar.svelte
+++ b/src/lib/components/chat/Navbar.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { getContext } from 'svelte';
import { toast } from 'svelte-sonner';
@@ -192,7 +193,7 @@
}
if ($page.url.pathname !== '/') {
- await goto('/');
+ await goto(`${base}/`);
}
// add 'temporary-chat=true' to the URL
diff --git a/src/lib/components/chat/Placeholder.svelte b/src/lib/components/chat/Placeholder.svelte
index b2833b67c380dde8d418de368a237dfbfabb63e8..ed8c7510b28a6e8facc0e4bbd5df4d620fd2718b 100644
--- a/src/lib/components/chat/Placeholder.svelte
+++ b/src/lib/components/chat/Placeholder.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import { marked } from 'marked';
import DOMPurify from 'dompurify';
@@ -170,7 +171,7 @@
// LICENSE covers this Open WebUI fallback logo.
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
- e.currentTarget.src = '/favicon.png';
+ e.currentTarget.src = `${base}/static/favicon.png`;
}}
/>
</button>
diff --git a/src/lib/components/chat/Placeholder/ChatList.svelte b/src/lib/components/chat/Placeholder/ChatList.svelte
index 084cf8ad6ff897581e226f6f97bc5d6e142fd1d4..dddd81c362355022f194e7c416072c6e2afc9b76 100644
--- a/src/lib/components/chat/Placeholder/ChatList.svelte
+++ b/src/lib/components/chat/Placeholder/ChatList.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { getContext } from 'svelte';
import type { Writable } from 'svelte/store';
@@ -211,7 +212,7 @@
<a
class=" w-full flex justify-between items-center rounded-lg text-sm py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-850"
draggable="false"
- href={`/c/${chat.id}`}
+ href={`${base}/c/${chat.id}`}
on:click={() => markChatRead(chat, unread)}
>
<div class="flex min-w-0 items-center w-full sm:basis-3/5">
diff --git a/src/lib/components/chat/Settings/DataControls.svelte b/src/lib/components/chat/Settings/DataControls.svelte
index 45bc8a3035e3fc4b8b565aa624b07bf8185e1476..488fcc4be42d4fd624f9caa8dd34ec679d25e59f 100644
--- a/src/lib/components/chat/Settings/DataControls.svelte
+++ b/src/lib/components/chat/Settings/DataControls.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { canManageChats } from '$lib/utils/settings-access';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
@@ -98,7 +99,7 @@
};
const archiveAllChatsHandler = async () => {
- await goto('/');
+ await goto(`${base}/`);
const success = await archiveAllChats(localStorage.token).catch((error) => {
toast.error(`${error}`);
});
@@ -108,7 +109,7 @@
};
const deleteAllChatsHandler = async () => {
- await goto('/');
+ await goto(`${base}/`);
const success = await deleteAllChats(localStorage.token).catch((error) => {
toast.error(`${error}`);
});
diff --git a/src/lib/components/chat/SettingsModal.svelte b/src/lib/components/chat/SettingsModal.svelte
index c20f170cd1fb29173bb9247dd2d11a1be14a2ae7..e482a5bd5e0768fce07988b4a2717a851229601a 100644
--- a/src/lib/components/chat/SettingsModal.svelte
+++ b/src/lib/components/chat/SettingsModal.svelte
@@ -6,6 +6,7 @@
type SettingsTab
} from '$lib/utils/settings-search';
import { browser } from '$app/environment';
+ import { base } from '$app/paths';
import { getContext, tick } from 'svelte';
import type { Writable } from 'svelte/store';
import { toast } from 'svelte-sonner';
diff --git a/src/lib/components/chat/ShareChatModal.svelte b/src/lib/components/chat/ShareChatModal.svelte
index ab2bf7297e2079319917c9cd4965663014c28815..a566e50398ccf856dabc6747e978ed8a63e73d13 100644
--- a/src/lib/components/chat/ShareChatModal.svelte
+++ b/src/lib/components/chat/ShareChatModal.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
import { getContext, onMount } from 'svelte';
diff --git a/src/lib/components/common/Banner.svelte b/src/lib/components/common/Banner.svelte
index 420490fd62f7390e8734c5cceca63a1a0c1ef12d..81afba34a0555bb9fd6c41e521d376a5c3379efe 100644
--- a/src/lib/components/common/Banner.svelte
+++ b/src/lib/components/common/Banner.svelte
@@ -118,7 +118,7 @@
<div class="hidden md:flex group w-fit md:items-center">
<a
class="text-gray-700 dark:text-white text-xs font-normal underline"
- href="/"
+ href="{WEBUI_BASE_URL}/"
target="_blank"
>
{$i18n.t('Learn More')}
diff --git a/src/lib/components/common/ChatList.svelte b/src/lib/components/common/ChatList.svelte
index 52759f63eb18684e5db28248901b93ce5595ee6f..92aa1e47640f7dbf990a34a5e3483cb8cb006fa8 100644
--- a/src/lib/components/common/ChatList.svelte
+++ b/src/lib/components/common/ChatList.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { getContext } from 'svelte';
import dayjs from 'dayjs';
import calendar from 'dayjs/plugin/calendar';
@@ -151,7 +152,7 @@
{/if}
<a
class={showUserInfo ? 'flex-1' : 'basis-3/5'}
- href={shareUrl ? `/s/${chat.id}` : `/c/${chat.id}`}
+ href={shareUrl ? `${base}/s/${chat.id}` : `${base}/c/${chat.id}`}
on:click={() => onChatClick?.(chat.id)}
>
<div class="text-ellipsis line-clamp-1 w-full">
diff --git a/src/lib/components/common/RichTextInput/Image/image.ts b/src/lib/components/common/RichTextInput/Image/image.ts
index 0d1a8dc2cee9a87d8760477b64e24e2c1521f57f..50121fcc3a8e2c3373ab0d4e81e73a5727b9ce04 100644
--- a/src/lib/components/common/RichTextInput/Image/image.ts
+++ b/src/lib/components/common/RichTextInput/Image/image.ts
@@ -143,7 +143,7 @@ export const Image = Node.create<ImageOptions>({
if (file) {
img.setAttribute('src', safeImageUrl(file.url || ''));
} else {
- img.setAttribute('src', '/image-placeholder.png');
+ img.setAttribute('src', safeImageUrl('/image-placeholder.png'));
}
} else {
img.setAttribute('src', safeImageUrl(node.attrs.src || ''));
@@ -159,7 +159,7 @@ export const Image = Node.create<ImageOptions>({
if (file) {
img.setAttribute('src', safeImageUrl(file.url || ''));
} else {
- img.setAttribute('src', '/image-placeholder.png');
+ img.setAttribute('src', safeImageUrl('/image-placeholder.png'));
}
}
});
diff --git a/src/lib/components/layout/ChatsModal.svelte b/src/lib/components/layout/ChatsModal.svelte
index a09a886531c961489de0cc89e89a41999a55ce23..8120683f060bd87a39c4b79c9d596121e7561ad9 100644
--- a/src/lib/components/layout/ChatsModal.svelte
+++ b/src/lib/components/layout/ChatsModal.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import { getContext } from 'svelte';
@@ -290,7 +291,7 @@
{/if}
<a
class={showUserInfo ? 'flex-1' : 'basis-3/5'}
- href={shareUrl ? `/s/${chat.id}` : `/c/${chat.id}`}
+ href={shareUrl ? `${base}/s/${chat.id}` : `${base}/c/${chat.id}`}
on:click={() => (show = false)}
>
<div class="text-ellipsis line-clamp-1 w-full">
@@ -460,7 +461,7 @@
'border-b'} border-gray-50 dark:border-gray-850/30 text-xs"
>
<td class="px-3 py-1 w-2/3">
- <a href="/c/{chat.id}" target="_blank">
+ <a href="{base}/c/{chat.id}" target="_blank" rel="noopener noreferrer">
<div class=" hover:underline line-clamp-1">
{chat.title}
</div>
diff --git a/src/lib/components/layout/Overlay/AccountPending.svelte b/src/lib/components/layout/Overlay/AccountPending.svelte
index f3074b39f0b7fe3fd0fe8dcc55ed5bbbe40ea81c..df7af4e5b82e8dadea3051568de9f8404377b5fd 100644
--- a/src/lib/components/layout/Overlay/AccountPending.svelte
+++ b/src/lib/components/layout/Overlay/AccountPending.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
import DOMPurify from 'dompurify';
import { marked } from 'marked';
+ import { base } from '$app/paths';
import { getAdminDetails, getLogoutRedirectUrl, userSignOut } from '$lib/apis/auths';
import { onMount, tick, getContext } from 'svelte';
@@ -61,7 +62,7 @@
<button
class="relative z-20 flex px-5 py-2 rounded-full bg-white border border-gray-100 dark:border-none hover:bg-gray-100 text-gray-700 transition font-normal text-sm"
on:click={async () => {
- location.href = '/';
+ location.href = `${base}/`;
}}
>
{$i18n.t('Check Again')}
@@ -75,7 +76,9 @@
return null;
});
localStorage.removeItem('token');
- location.href = getLogoutRedirectUrl(res?.redirect_url);
+ location.href = res?.redirect_url
+ ? getLogoutRedirectUrl(res.redirect_url)
+ : `${base}/auth?state=logout`;
}}>{$i18n.t('Sign Out')}</button
>
</div>
diff --git a/src/lib/components/layout/SearchModal.svelte b/src/lib/components/layout/SearchModal.svelte
index 8eec0b52900ebda4d9593466eb095b6d23bdb185..9c333d4caa156bcb37953d16a0a58dc72b9bb7ef 100644
--- a/src/lib/components/layout/SearchModal.svelte
+++ b/src/lib/components/layout/SearchModal.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import { getContext, onDestroy, onMount, tick } from 'svelte';
const i18n: any = getContext('i18n');
@@ -102,7 +103,7 @@
chatList = chatList?.filter((c) => c.id !== id) ?? null;
if ($currentChatId === id) {
- await goto('/');
+ await goto(`\${base}/`);
currentChatId.set('');
}
@@ -124,7 +125,7 @@
tags.set(await getAllTags(localStorage.token));
if ($currentChatId === id) {
- await goto('/');
+ await goto(`\${base}/`);
currentChatId.set('');
}
diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte
index d3058fe5b5ab08f839b8e8d67d285182a171390f..685303978ba1736d84f97ba494891396baba4a89 100644
--- a/src/lib/components/layout/Sidebar.svelte
+++ b/src/lib/components/layout/Sidebar.svelte
@@ -4,6 +4,7 @@
import Sortable from 'sortablejs';
import { goto } from '$app/navigation';
+ import { base } from '$app/paths';
import { page } from '$app/stores';
import {
user,
@@ -191,21 +192,29 @@
const getMenuItemMeta = (id) => {
const items = {
- notes: { label: $i18n.t('Notes'), href: '/notes', iconType: 'note' },
- workspace: { label: $i18n.t('Workspace'), href: '/workspace', iconType: 'workspace' },
- automations: { label: $i18n.t('Automations'), href: '/automations', iconType: 'automations' },
- calendar: { label: $i18n.t('Calendar'), href: '/calendar', iconType: 'calendar' },
- playground: { label: $i18n.t('Playground'), href: '/playground', iconType: 'playground' }
+ notes: { label: $i18n.t('Notes'), href: `${base}/notes`, iconType: 'note' },
+ workspace: { label: $i18n.t('Workspace'), href: `${base}/workspace`, iconType: 'workspace' },
+ automations: {
+ label: $i18n.t('Automations'),
+ href: `${base}/automations`,
+ iconType: 'automations'
+ },
+ calendar: { label: $i18n.t('Calendar'), href: `${base}/calendar`, iconType: 'calendar' },
+ playground: {
+ label: $i18n.t('Playground'),
+ href: `${base}/playground`,
+ iconType: 'playground'
+ }
};
return items[id];
};
const menuItemPathPrefixes = {
- notes: '/notes',
- workspace: '/workspace',
- calendar: '/calendar',
- automations: '/automations',
- playground: '/playground'
+ notes: `${base}/notes`,
+ workspace: `${base}/workspace`,
+ calendar: `${base}/calendar`,
+ automations: `${base}/automations`,
+ playground: `${base}/playground`
};
const getActiveMenuItemId = (pathname) => {
@@ -878,7 +887,7 @@
await initChannels();
showCreateChannel = false;
showChannels = true;
- goto(`/channels/${res.id}`);
+ goto(`${base}/channels/${res.id}`);
}
}}
/>
@@ -941,7 +950,7 @@
id="sidebar-new-chat-button"
class="hidden"
on:click={() => {
- goto('/');
+ goto(`${base}/`);
newChatHandler();
}}
/>
@@ -993,13 +1002,13 @@
<Tooltip content={$i18n.t('New Chat')} placement="right">
<a
class=" cursor-pointer flex size-8 items-center justify-center transition group"
- href="/"
+ href="{base}/"
draggable="false"
on:click={async (e) => {
e.stopImmediatePropagation();
e.preventDefault();
- goto('/');
+ goto(`${base}/`);
newChatHandler();
}}
aria-label={$i18n.t('New Chat')}
@@ -1042,11 +1051,11 @@
<Tooltip content={$i18n.t(meta.label)} placement="right">
<a
class=" cursor-pointer flex size-8 items-center justify-center transition group"
- href={meta.href}
+ href={`${base}${meta.href}`}
on:click={async (e) => {
e.stopImmediatePropagation();
e.preventDefault();
- goto(meta.href);
+ goto(`${base}${meta.href}`);
itemClickHandler();
}}
draggable="false"
@@ -1155,7 +1164,7 @@
>
<a
class="flex items-center rounded-xl size-8.5 h-full justify-center hover:bg-gray-100 dark:hover:bg-gray-900 transition no-drag-region"
- href="/"
+ href="{base}/"
draggable="false"
on:click={newChatHandler}
>
@@ -1170,7 +1179,7 @@
/>
</a>
- <a href="/" class="flex flex-1 px-0.5" on:click={newChatHandler}>
+ <a href="{base}/" class="flex flex-1 px-0.5" on:click={newChatHandler}>
<!-- LICENSE covers this Open WebUI sidebar name.
Do not alter, remove, obscure, or replace it except as LICENSE permits:
https://docs.openwebui.com/license. -->
@@ -1222,7 +1231,7 @@
<a
id="sidebar-new-chat-button"
class="group grow flex items-center space-x-2 rounded-xl px-2 py-1.5 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
- href="/"
+ href="{base}/"
draggable="false"
on:click={newChatHandler}
aria-label={$i18n.t('New Chat')}
@@ -1276,7 +1285,7 @@
? 'bg-black/[0.035] dark:bg-white/[0.06]'
: 'bg-black/[0.035] dark:bg-white/[0.045]'
: 'hover:bg-gray-100 dark:hover:bg-gray-900'}"
- href={meta.href}
+ href={`${base}${meta.href}`}
on:click={itemClickHandler}
draggable="false"
aria-label={$i18n.t(meta.label)}
@@ -1327,7 +1336,7 @@
onAdd={async () => {
const note = await createNoteHandler('New Note');
if (note) {
- goto(`/notes/${note.id}`);
+ goto(`${base}/notes/${note.id}`);
}
}}
onAddLabel={$i18n.t('New Note')}
diff --git a/src/lib/components/layout/Sidebar/ChannelItem.svelte b/src/lib/components/layout/Sidebar/ChannelItem.svelte
index 7cc786d2cc34cacfa2727ed8f5355fa6b90dfe40..5abc7a2f52a96abb22b125bae38d9c8281b71cc6 100644
--- a/src/lib/components/layout/Sidebar/ChannelItem.svelte
+++ b/src/lib/components/layout/Sidebar/ChannelItem.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import { onMount, getContext, tick, onDestroy } from 'svelte';
const i18n = getContext('i18n');
@@ -89,7 +90,7 @@
>
<a
class="min-w-0 flex flex-1"
- href="/channels/{channel.id}"
+ href="{base}/channels/{channel.id}"
on:click={() => {
console.log(channel);
diff --git a/src/lib/components/layout/Sidebar/ChannelModal.svelte b/src/lib/components/layout/Sidebar/ChannelModal.svelte
index feda1f1180000abc17473a21d4ed476457c08a68..e3258eb03578805aa5a6d87e8ac81f2c5e920bda 100644
--- a/src/lib/components/layout/Sidebar/ChannelModal.svelte
+++ b/src/lib/components/layout/Sidebar/ChannelModal.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { getContext, createEventDispatcher, onMount } from 'svelte';
const i18n = getContext('i18n');
@@ -121,8 +122,8 @@
toast.success($i18n.t('Channel deleted successfully'));
onUpdate();
- if ($page.url.pathname === `/channels/${channelId}`) {
- goto('/');
+ if ($page.url.pathname === `${base}/channels/${channelId}`) {
+ goto(`${base}/`);
}
}
diff --git a/src/lib/components/layout/Sidebar/ChatItem.svelte b/src/lib/components/layout/Sidebar/ChatItem.svelte
index 1a85ad44f5e303134db074e3d1ba62d4f47d2bea..34dfef8779a0e6f30205297a7d03e7e243584bd6 100644
--- a/src/lib/components/layout/Sidebar/ChatItem.svelte
+++ b/src/lib/components/layout/Sidebar/ChatItem.svelte
@@ -15,6 +15,7 @@
</script>
<script lang="ts">
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { goto, invalidate, invalidateAll } from '$app/navigation';
@@ -164,7 +165,7 @@
if ($mobile) {
event?.preventDefault();
- void goto(`/c/${id}`);
+ void goto(`${base}/c/${id}`);
showSidebar.set(false);
}
};
@@ -239,7 +240,7 @@
});
if (res) {
- goto(`/c/${res.id}`);
+ goto(`${base}/c/${res.id}`);
await refreshSidebar(localStorage.token);
}
@@ -259,7 +260,7 @@
if (res) {
tags.set(await getAllTags(localStorage.token));
if ($chatId === id) {
- await goto('/');
+ await goto(`${base}/`);
await chatId.set('');
await tick();
@@ -281,7 +282,7 @@
await archiveChatById(localStorage.token, id);
if ($chatId === id) {
- await goto('/');
+ await goto(`${base}/`);
chatId.set('');
}
@@ -649,7 +650,7 @@
<a
id="sidebar-chat-item"
class={chatItemClass}
- href="/c/{id}"
+ href="{base}/c/{id}"
aria-current={id === $chatId ? 'page' : undefined}
on:click={selectChatHandler}
draggable="false"
@@ -666,7 +667,7 @@
<LinkPreview.Trigger
id="sidebar-chat-item"
class={chatItemClass}
- href="/c/{id}"
+ href="{base}/c/{id}"
aria-current={id === $chatId ? 'page' : undefined}
onclick={selectChatHandler}
ondblclick={renameChatFromDoubleClick}
diff --git a/src/lib/components/layout/Sidebar/PinnedModelItem.svelte b/src/lib/components/layout/Sidebar/PinnedModelItem.svelte
index 3e79261039542f506047fd825c58a2cf7c770a74..c69a08334b020b6e8361579b681c6cc4be413ec4 100644
--- a/src/lib/components/layout/Sidebar/PinnedModelItem.svelte
+++ b/src/lib/components/layout/Sidebar/PinnedModelItem.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
@@ -32,7 +33,7 @@
>
<a
class="grow flex items-center space-x-2 rounded-xl px-2 py-[0.4375rem] group-hover:bg-gray-100 dark:group-hover:bg-gray-900 transition"
- href="/?model={model?.id}"
+ href="{base}/?model={model?.id}"
on:click={onClick}
draggable="false"
>
@@ -45,7 +46,7 @@
// LICENSE covers this Open WebUI fallback logo.
// Do not alter, remove, obscure, or replace it except as LICENSE permits:
// https://docs.openwebui.com/license.
- e.currentTarget.src = '/favicon.png';
+ e.currentTarget.src = `${base}/static/favicon.png`;
}}
/>
</div>
diff --git a/src/lib/components/layout/Sidebar/RecursiveFolder.svelte b/src/lib/components/layout/Sidebar/RecursiveFolder.svelte
index 6e4007f2634b7ca4988588957405f714778832a2..aa43f5510398a107b04f5e7a32283b5d000dbdc9 100644
--- a/src/lib/components/layout/Sidebar/RecursiveFolder.svelte
+++ b/src/lib/components/layout/Sidebar/RecursiveFolder.svelte
@@ -1,4 +1,5 @@
<script>
+ import { base } from '$app/paths';
import { getContext, createEventDispatcher, onMount, onDestroy, tick } from 'svelte';
const i18n = getContext('i18n');
@@ -610,7 +611,7 @@
await selectedFolder.set({ ...folders[folderId], ...folder });
}
- await goto(`/folders/${folderId}`);
+ await goto(`${base}/folders/${folderId}`);
if ($mobile) {
showSidebar.set(!$showSidebar);
diff --git a/src/lib/components/layout/Sidebar/UserMenu.svelte b/src/lib/components/layout/Sidebar/UserMenu.svelte
index f2a488a497a0a075d98e07f4cf19de961a2f514f..c5beb6401303b81a9928bc24cac5c28b26f5108a 100644
--- a/src/lib/components/layout/Sidebar/UserMenu.svelte
+++ b/src/lib/components/layout/Sidebar/UserMenu.svelte
@@ -2,6 +2,7 @@
import { createEventDispatcher, getContext, onMount, tick } from 'svelte';
import { goto } from '$app/navigation';
+ import { base } from '$app/paths';
import { fade, slide } from 'svelte/transition';
import { getUsage } from '$lib/apis';
@@ -246,14 +247,14 @@
class="user-menu-row flex items-center w-full rounded-xl hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition-colors"
>
<a
- href="/workspace"
+ href="{base}/workspace"
draggable="false"
class="flex flex-1 min-w-0 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
show = false;
- goto('/workspace');
+ goto(`${base}/workspace`);
if ($mobile) {
await tick();
showSidebar.set(false);
@@ -292,14 +293,14 @@
class="user-menu-row flex items-center w-full rounded-xl hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition-colors"
>
<a
- href="/notes"
+ href="{base}/notes"
draggable="false"
class="flex flex-1 min-w-0 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
show = false;
- goto('/notes');
+ goto(`${base}/notes`);
if ($mobile) {
await tick();
showSidebar.set(false);
@@ -338,14 +339,14 @@
class="user-menu-row flex items-center w-full rounded-xl hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition-colors"
>
<a
- href="/calendar"
+ href="{base}/calendar"
draggable="false"
class="flex flex-1 min-w-0 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
show = false;
- goto('/calendar');
+ goto(`${base}/calendar`);
if ($mobile) {
await tick();
showSidebar.set(false);
@@ -384,14 +385,14 @@
class="user-menu-row flex items-center w-full rounded-xl hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition-colors"
>
<a
- href="/automations"
+ href="{base}/automations"
draggable="false"
class="flex flex-1 min-w-0 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
show = false;
- goto('/automations');
+ goto(`${base}/automations`);
if ($mobile) {
await tick();
showSidebar.set(false);
@@ -430,14 +431,14 @@
class="user-menu-row flex items-center w-full rounded-xl hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition-colors"
>
<a
- href="/playground"
+ href="{base}/playground"
draggable="false"
class="flex flex-1 min-w-0 h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
show = false;
- goto('/playground');
+ goto(`${base}/playground`);
if ($mobile) {
await tick();
showSidebar.set(false);
@@ -536,7 +537,7 @@
{#if role === 'admin'}
<a
- href="/admin"
+ href="{base}/admin"
draggable="false"
class="flex h-[1.6875rem] items-center gap-2 rounded-xl px-2 text-[0.8125rem] w-full hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition cursor-pointer select-none"
on:click={async (e) => {
@@ -545,7 +546,7 @@
}
e.preventDefault();
show = false;
- goto('/admin');
+ goto(`${base}/admin`);
if ($mobile) {
await tick();
showSidebar.set(false);
diff --git a/src/lib/components/notes/NoteEditor.svelte b/src/lib/components/notes/NoteEditor.svelte
index c04e7ee9b85d1f2a6e2bc3bec0cce89ca8203f15..71430223fa217aef669cf8dea7bf8172a20880e6 100644
--- a/src/lib/components/notes/NoteEditor.svelte
+++ b/src/lib/components/notes/NoteEditor.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { getContext, onDestroy, onMount, tick } from 'svelte';
import { v4 as uuidv4 } from 'uuid';
import fileSaver from 'file-saver';
@@ -190,7 +191,7 @@
$socket?.off('events:note', noteEventHandler);
$socket?.on('events:note', noteEventHandler);
} else {
- goto('/');
+ goto(`${base}/`);
return;
}
@@ -809,7 +810,7 @@ ${content}
if (res) {
pinnedNotes.set(await getPinnedNoteList(localStorage.token).catch(() => []));
toast.success($i18n.t('Note deleted successfully'));
- goto('/notes');
+ goto(`${base}/notes`);
} else {
toast.error($i18n.t('Failed to delete note'));
}
diff --git a/src/lib/components/notes/Notes.svelte b/src/lib/components/notes/Notes.svelte
index 244dbae37a968423d8256447912d02d10bae36ed..06aa0f782cbd895ece3bd7ce0a8778ff77da316e 100644
--- a/src/lib/components/notes/Notes.svelte
+++ b/src/lib/components/notes/Notes.svelte
@@ -19,6 +19,7 @@
const i18n = getContext<typeof import('$lib/i18n').default>('i18n');
import { goto } from '$app/navigation';
+ import { base } from '$app/paths';
import { WEBUI_NAME, config, user, pinnedNotes, mobile, showSidebar } from '$lib/stores';
import {
createNewNote,
@@ -444,7 +445,7 @@
);
if (res) {
- goto(`/notes/${res.id}`);
+ goto(`${base}/notes/${res.id}`);
}
}
},
@@ -609,7 +610,7 @@
aria-label={$i18n.t('Open note')}
class="group flex min-h-8 w-full items-center gap-2 rounded-xl px-2 py-[0.375rem] text-left transition hover:bg-gray-50 focus-within:bg-gray-50 dark:hover:bg-gray-900 dark:focus-within:bg-gray-900"
on:click={() => {
- goto(`/notes/${note.id}`);
+ goto(`${base}/notes/${note.id}`);
}}
>
<div class="flex min-w-0 flex-1 items-center gap-2">
@@ -735,7 +736,7 @@
class="group flex min-h-32 w-full flex-col rounded-lg bg-gray-50/40 p-2.5 text-left transition hover:bg-gray-100/60 focus-within:bg-gray-100/60 dark:bg-gray-900/30 dark:hover:bg-gray-900 dark:focus-within:bg-gray-900"
>
<div class="flex items-start gap-2">
- <a href={`/notes/${note.id}`} class="min-w-0 flex-1">
+ <a href={`${base}/notes/${note.id}`} class="min-w-0 flex-1">
<Tooltip content={note.title} placement="top-start">
<div
class="truncate text-[0.8125rem] leading-5 text-gray-800 group-hover:underline dark:text-gray-200"
@@ -804,7 +805,10 @@
{/if}
</div>
- <a href={`/notes/${note.id}`} class="mt-1 flex min-h-0 flex-1 flex-col">
+ <a
+ href={`${base}/notes/${note.id}`}
+ class="mt-1 flex min-h-0 flex-1 flex-col"
+ >
<div
class="line-clamp-3 text-xs leading-5 text-gray-500 dark:text-gray-500"
>
diff --git a/src/lib/components/playground/Chat.svelte b/src/lib/components/playground/Chat.svelte
index 3b0a73d3de11fd02c6a335c2190811c73eda9708..cd77b797d79b872ea20088398e2b7e983b64b957 100644
--- a/src/lib/components/playground/Chat.svelte
+++ b/src/lib/components/playground/Chat.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
@@ -304,7 +305,7 @@
onMount(async () => {
if ($user?.role !== 'admin') {
- await goto('/');
+ await goto(`${base}/`);
}
selectedModelId =
diff --git a/src/lib/components/playground/Completions.svelte b/src/lib/components/playground/Completions.svelte
index 90c785f6f04d7faec9cc74a1da393c92a5985304..f80bc5454854868b952e7d99c47b578cd4847790 100644
--- a/src/lib/components/playground/Completions.svelte
+++ b/src/lib/components/playground/Completions.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
@@ -105,7 +106,7 @@
onMount(async () => {
if ($user?.role !== 'admin') {
- await goto('/');
+ await goto(`${base}/`);
}
selectedModelId =
diff --git a/src/lib/components/playground/Images.svelte b/src/lib/components/playground/Images.svelte
index d8906babe9faa5b283a69ac9bef9434f01e20c87..833bab7154d97849dc956eae1a0d40486ff25030 100644
--- a/src/lib/components/playground/Images.svelte
+++ b/src/lib/components/playground/Images.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import { onMount, getContext } from 'svelte';
import { goto } from '$app/navigation';
@@ -114,7 +115,7 @@
onMount(async () => {
if ($user?.role !== 'admin') {
- await goto('/');
+ await goto(`${base}/`);
return;
}
loaded = true;
diff --git a/src/lib/components/workspace/Knowledge.svelte b/src/lib/components/workspace/Knowledge.svelte
index 89fe1559383cbe53db53fee136992005fdeda27e..58fe8f66cc9790d58e8c951c48eccafc7a1b1f1c 100644
--- a/src/lib/components/workspace/Knowledge.svelte
+++ b/src/lib/components/workspace/Knowledge.svelte
@@ -6,6 +6,7 @@
dayjs.extend(relativeTime);
import { toast } from 'svelte-sonner';
+ import { base } from '$app/paths';
import { onMount, getContext, tick, onDestroy } from 'svelte';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
@@ -239,7 +240,7 @@
return;
}
- goto(`/workspace/knowledge/${item.id}`);
+ goto(`${base}/workspace/knowledge/${item.id}`);
};
const shouldIgnoreRowClick = (target: EventTarget | null) => {
diff --git a/src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte b/src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte
index d8a0796f8445428037b11ae94e18fe96c8318498..0048097b13aeb22fdee8d4fe9bd454c10210e058 100644
--- a/src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte
+++ b/src/lib/components/workspace/Knowledge/CreateKnowledgeBase.svelte
@@ -2,6 +2,7 @@
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
+ import { base } from '$app/paths';
import { getContext } from 'svelte';
const i18n = getContext('i18n');
@@ -14,9 +15,9 @@
export let modal = false;
/** @type {() => void | Promise<void>} */
- export let onBack = () => goto('/workspace/knowledge');
+ export let onBack = () => goto(`${base}/workspace/knowledge`);
/** @type {(knowledge: { id: string }) => void | Promise<void>} */
- export let onCreated = (knowledge) => goto(`/workspace/knowledge/${knowledge.id}`);
+ export let onCreated = (knowledge) => goto(`${base}/workspace/knowledge/${knowledge.id}`);
let loading = false;
diff --git a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte
index 55749bb91dc9f1fe1abcdc8a6d32c4fe131cc40f..c4f64328b38f1681791652ea42579361683afd1b 100644
--- a/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte
+++ b/src/lib/components/workspace/Knowledge/KnowledgeBase.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import { v4 as uuidv4 } from 'uuid';
@@ -775,7 +776,7 @@
knowledge.access_grants = [];
}
} else {
- goto('/workspace/knowledge');
+ goto(`\${base}/workspace/knowledge`);
}
const dropZone = document.querySelector('body');
@@ -872,7 +873,7 @@
<button
type="button"
class="flex h-6 w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md text-xs text-gray-400 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
- on:click={() => goto('/workspace/knowledge')}
+ on:click={() => goto(`\${base}/workspace/knowledge`)}
><ChevronLeft className="size-3" strokeWidth="2" />{$i18n.t('Back')}</button
>
<!-- Previous file count label: {$i18n.t('{{COUNT}} files')} -->
diff --git a/src/lib/components/workspace/Models.svelte b/src/lib/components/workspace/Models.svelte
index a6446ae3e8165e0b2d3512dfa130d842398edbee..e5bf3e958aaa415cd40e50b884c4cd323384653f 100644
--- a/src/lib/components/workspace/Models.svelte
+++ b/src/lib/components/workspace/Models.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import WorkspaceAccessModal from './common/WorkspaceAccessModal.svelte';
let accessModal: WorkspaceAccessModal;
import dayjs from 'dayjs';
@@ -271,7 +272,7 @@
id: `${model.id}-clone`,
name: `${model.name} (Clone)`
});
- goto('/workspace/models/create');
+ goto(`\${base}/workspace/models/create`);
};
const shareModelHandler = async (model) => {
diff --git a/src/lib/components/workspace/Prompts.svelte b/src/lib/components/workspace/Prompts.svelte
index 1b6dea48af4d0898dce726820f06b835d5a7aea4..b9370106cb5928a0e88338ddf427f310e1a1e406 100644
--- a/src/lib/components/workspace/Prompts.svelte
+++ b/src/lib/components/workspace/Prompts.svelte
@@ -10,6 +10,7 @@
dayjs.extend(relativeTime);
import { goto } from '$app/navigation';
+ import { base } from '$app/paths';
import { onMount, getContext, tick, onDestroy } from 'svelte';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
@@ -164,7 +165,7 @@
};
const openPrompt = (prompt) => {
- goto(`/workspace/prompts/${prompt.id}`);
+ goto(`${base}/workspace/prompts/${prompt.id}`);
};
const shouldIgnoreRowClick = (target: EventTarget | null) => {
@@ -696,7 +697,7 @@
writeAccess={prompt.write_access}
show={openPromptMenuId === prompt.id}
editHandler={() => {
- goto(`/workspace/prompts/${prompt.id}`);
+ goto(`${base}/workspace/prompts/${prompt.id}`);
}}
shareHandler={() => {
shareHandler(prompt);
diff --git a/src/lib/components/workspace/Prompts/PromptEditor.svelte b/src/lib/components/workspace/Prompts/PromptEditor.svelte
index 14f2f7626df43092d7ffe6d32094ddad0028795c..8579a31b8528dfbfd53aac4eae0da6fafcc7db6b 100644
--- a/src/lib/components/workspace/Prompts/PromptEditor.svelte
+++ b/src/lib/components/workspace/Prompts/PromptEditor.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { onMount, onDestroy, tick, getContext } from 'svelte';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
@@ -411,7 +412,7 @@
<button
type="button"
class="flex h-6 w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md text-xs text-gray-400 transition-colors hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
- on:click={() => goto('/workspace/prompts')}
+ on:click={() => goto(`\${base}/workspace/prompts`)}
>
<ChevronLeft className="size-3" strokeWidth="2" />{$i18n.t('Back')}
</button>
diff --git a/src/lib/components/workspace/Skills.svelte b/src/lib/components/workspace/Skills.svelte
index 6358c2228b4208110f9743e0b40cc542f8fffb03..6d81ade0c9f951146acd660442968a866d28e946 100644
--- a/src/lib/components/workspace/Skills.svelte
+++ b/src/lib/components/workspace/Skills.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import WorkspaceAccessModal from './common/WorkspaceAccessModal.svelte';
let accessModal: WorkspaceAccessModal;
import { resolveLocalizedResource } from '$lib/utils/localizedContent';
diff --git a/src/lib/components/workspace/Skills/SkillEditor.svelte b/src/lib/components/workspace/Skills/SkillEditor.svelte
index 8185ea02c758bb054c5e6f571ea031d3d6cf03a0..72aba83944890bc39af744f2dc3462f6669bf71d 100644
--- a/src/lib/components/workspace/Skills/SkillEditor.svelte
+++ b/src/lib/components/workspace/Skills/SkillEditor.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { onMount, tick, getContext } from 'svelte';
import { toast } from 'svelte-sonner';
@@ -348,7 +349,7 @@
class="flex h-6 w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md text-xs text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => {
- goto('/workspace/skills');
+ goto(`\${base}/workspace/skills`);
}}
>
<ChevronLeft className="size-3" strokeWidth="2" />
diff --git a/src/lib/components/workspace/Tools.svelte b/src/lib/components/workspace/Tools.svelte
index 06c7ff80d143c595bc8378cab29787daef39f579..37785fb1af1b585727e5c06c35df81fc6c9bd496 100644
--- a/src/lib/components/workspace/Tools.svelte
+++ b/src/lib/components/workspace/Tools.svelte
@@ -23,6 +23,7 @@
} from '$lib/stores';
import { goto } from '$app/navigation';
+ import { base } from '$app/paths';
import {
createNewTool,
loadToolByUrl,
@@ -85,7 +86,7 @@
{
id: 'tools-new',
label: $i18n.t('Create'),
- href: '/workspace/tools/create'
+ href: `${base}/workspace/tools/create`
},
{
id: 'tools-import-link',
@@ -177,7 +178,7 @@
};
const openTool = (tool) => {
- goto(`/workspace/tools/edit?id=${encodeURIComponent(tool.id)}`);
+ goto(`${base}/workspace/tools/edit?id=${encodeURIComponent(tool.id)}`);
};
const shouldIgnoreRowClick = (target: EventTarget | null) => {
@@ -223,7 +224,7 @@
id: `${_tool.id}_clone`,
name: `${_tool.name} (Clone)`
});
- goto('/workspace/tools/create');
+ goto(`${base}/workspace/tools/create`);
}
};
@@ -313,7 +314,7 @@
sessionStorage.tool = JSON.stringify({
...tool
});
- goto('/workspace/tools/create');
+ goto(`${base}/workspace/tools/create`);
}}
loadUrlHandler={async (url) => {
return await loadToolByUrl(localStorage.token, url);
@@ -594,7 +595,7 @@
accessHandler={() => accessModal.open(tool.id)}
show={openToolMenuId === tool.id}
editHandler={() => {
- goto(`/workspace/tools/edit?id=${encodeURIComponent(tool.id)}`);
+ goto(`${base}/workspace/tools/edit?id=${encodeURIComponent(tool.id)}`);
}}
shareHandler={() => {
shareHandler(tool);
diff --git a/src/lib/components/workspace/Tools/ToolkitEditor.svelte b/src/lib/components/workspace/Tools/ToolkitEditor.svelte
index ab04832c61107032f5cac262f03559bdf434e01b..e7bbc4f9d424118f6c3f3be36ef39b80c04dec66 100644
--- a/src/lib/components/workspace/Tools/ToolkitEditor.svelte
+++ b/src/lib/components/workspace/Tools/ToolkitEditor.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import { getContext, onMount, tick } from 'svelte';
@@ -327,7 +328,7 @@ class Tools:
class="flex h-6 w-fit shrink-0 items-center gap-1 whitespace-nowrap rounded-md text-xs text-gray-400 transition-colors duration-75 hover:text-gray-700 dark:text-gray-600 dark:hover:text-gray-300"
type="button"
on:click={() => {
- goto('/workspace/tools');
+ goto(`\${base}/workspace/tools`);
}}
>
<ChevronLeft className="size-3" strokeWidth="2" />
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index ce4a18ef0a8c15434bf669f5e30329d26741bfda..29ad7e62769d9e597497988d5d5a4283d00b909e 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -1,3 +1,5 @@
+import { browser, dev } from '$app/environment';
+import { base } from '$app/paths';
// import { version } from '../../package.json';
// LICENSE covers this Open WebUI branding surface, including name, logo,
@@ -6,8 +8,10 @@
// https://docs.openwebui.com/license.
export const APP_NAME = 'Open WebUI';
-export const WEBUI_HOSTNAME = '';
-export const WEBUI_BASE_URL = '';
+export const WEBUI_HOSTNAME = browser ? (dev ? `${location.hostname}:8080` : ``) : '';
+// In dev mode connect to the backend dev server; in production use the SvelteKit base path so
+// that all API requests are relative to whatever subpath the app is deployed under.
+export const WEBUI_BASE_URL = browser ? (dev ? `http://${WEBUI_HOSTNAME}` : base) : base;
export const WEBUI_API_BASE_URL = `${WEBUI_BASE_URL}/api/v1`;
export const OLLAMA_API_BASE_URL = `${WEBUI_BASE_URL}/ollama`;
diff --git a/src/lib/i18n/locales/ar-BH/translation.json b/src/lib/i18n/locales/ar-BH/translation.json
index 51851cd3f2fad0218c03b78a0bc8def09b94120a..800663fa9625e850569b3e61d6a3fb4e5c5109d0 100644
--- a/src/lib/i18n/locales/ar-BH/translation.json
+++ b/src/lib/i18n/locales/ar-BH/translation.json
@@ -1725,6 +1725,7 @@
"No content to speak": "لا يوجد محتوى لقراءته بصوت مسموع",
"No conversation to save": "لا توجد محادثة لحفظها",
"No data": "لا توجد بيانات",
+ "No data found": "",
"No description": "لا يوجد وصف",
"No differences": "",
"No distance available": "لا تتوفر قيمة للمسافة",
diff --git a/src/lib/i18n/locales/ar/translation.json b/src/lib/i18n/locales/ar/translation.json
index 3d2385789451f79a2eeabf9cbeb06ffe304280da..5a9c5865d1d56f869971db0315b851c5c1eea3bd 100644
--- a/src/lib/i18n/locales/ar/translation.json
+++ b/src/lib/i18n/locales/ar/translation.json
@@ -1725,6 +1725,7 @@
"No content to speak": "لا يوجد محتوى للتحدث عنه",
"No conversation to save": "لا توجد محادثة لحفظها",
"No data": "لا توجد بيانات",
+ "No data found": "",
"No description": "لا يوجد وصف",
"No differences": "",
"No distance available": "لا توجد مسافة متاحة",
diff --git a/src/lib/i18n/locales/az-AZ/translation.json b/src/lib/i18n/locales/az-AZ/translation.json
index 0dc484899aeff8e5c12d9343f68b85c1edb50375..3d979a8036e0642bdb5f4576231859d56005119d 100644
--- a/src/lib/i18n/locales/az-AZ/translation.json
+++ b/src/lib/i18n/locales/az-AZ/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "Səsləndirmək üçün məzmun yoxdur",
"No conversation to save": "Yadda saxlamaq üçün söhbət yoxdur",
"No data": "Məlumat yoxdur",
+ "No data found": "",
"No description": "Təsvir yoxdur",
"No differences": "",
"No distance available": "Məsafə məlumatı yoxdur",
diff --git a/src/lib/i18n/locales/bg-BG/translation.json b/src/lib/i18n/locales/bg-BG/translation.json
index 74fb927c4ce5b9eda10e8fb5b7085ba0b9b886ec..4939b8bf31fd173397443f7baebc2ecbf464b1ea 100644
--- a/src/lib/i18n/locales/bg-BG/translation.json
+++ b/src/lib/i18n/locales/bg-BG/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "Няма съдържание за изговаряне",
"No conversation to save": "Няма разговор за запис",
"No data": "Няма данни",
+ "No data found": "",
"No description": "Няма описание",
"No differences": "",
"No distance available": "Няма налично разстояние",
diff --git a/src/lib/i18n/locales/bn-BD/translation.json b/src/lib/i18n/locales/bn-BD/translation.json
index 823b4923b0ee831de5dbe97892850a6006a65789..56c0ab072c3d9ee19487c54544d0e5481460c243 100644
--- a/src/lib/i18n/locales/bn-BD/translation.json
+++ b/src/lib/i18n/locales/bn-BD/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "পড়ে শোনানোর মতো কোনো বিষয়বস্তু নেই",
"No conversation to save": "সংরক্ষণ করার মতো কোনো কথোপকথন নেই",
"No data": "কোনো তথ্য নেই",
+ "No data found": "",
"No description": "কোনো বিবরণ নেই",
"No differences": "",
"No distance available": "দূরত্বের কোনো মান উপলভ্য নেই",
diff --git a/src/lib/i18n/locales/bo-TB/translation.json b/src/lib/i18n/locales/bo-TB/translation.json
index 147766a1b054f1031e5079e8f08896281a33e9a4..a909090a5e68595b09f0b86b1acfe5346992929c 100644
--- a/src/lib/i18n/locales/bo-TB/translation.json
+++ b/src/lib/i18n/locales/bo-TB/translation.json
@@ -1655,6 +1655,7 @@
"No content to speak": "བཤད་རྒྱུའི་ནང་དོན་མེད།",
"No conversation to save": "ཉར་རྒྱུའི་ཁ་བརྡ་མེད།",
"No data": "གཞི་གྲངས་མེད།",
+ "No data found": "",
"No description": "འགྲེལ་བཤད་མེད།",
"No differences": "",
"No distance available": "ཐག་རིང་ཚད་མེད།",
diff --git a/src/lib/i18n/locales/bs-BA/translation.json b/src/lib/i18n/locales/bs-BA/translation.json
index d0b4d3929220e5b70848ee46a078f7050795f211..2101c0c8f1619464edadb4facf3113d4d6fa3980 100644
--- a/src/lib/i18n/locales/bs-BA/translation.json
+++ b/src/lib/i18n/locales/bs-BA/translation.json
@@ -1683,6 +1683,7 @@
"No content to speak": "Nema sadržaja za čitanje naglas",
"No conversation to save": "Nema razgovora za spremanje",
"No data": "Nema podataka",
+ "No data found": "",
"No description": "Nema opisa",
"No differences": "",
"No distance available": "Nema dostupne vrijednosti udaljenosti",
diff --git a/src/lib/i18n/locales/ca-ES/translation.json b/src/lib/i18n/locales/ca-ES/translation.json
index 3df308aecff3575a26543196fc79efc465e2200c..8fe802302a26044826e4a8808c1dd54e0c292679 100644
--- a/src/lib/i18n/locales/ca-ES/translation.json
+++ b/src/lib/i18n/locales/ca-ES/translation.json
@@ -1683,6 +1683,7 @@
"No content to speak": "No hi ha contingut per parlar",
"No conversation to save": "No hi ha cap conversa a desar",
"No data": "No hi ha dades",
+ "No data found": "",
"No description": "Sense descripció",
"No differences": "",
"No distance available": "No hi ha distància disponible",
diff --git a/src/lib/i18n/locales/ceb-PH/translation.json b/src/lib/i18n/locales/ceb-PH/translation.json
index 5fc98b555ff877cbd64350265b6abb3292fa7bb8..6d8bc4b117196849f0cee3b2d4843d8d74c35367 100644
--- a/src/lib/i18n/locales/ceb-PH/translation.json
+++ b/src/lib/i18n/locales/ceb-PH/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "Walay sulod nga basahon og kusog",
"No conversation to save": "Walay panaghisgot nga tipigan",
"No data": "Walay datos",
+ "No data found": "",
"No description": "Walay paghulagway",
"No differences": "",
"No distance available": "Walay magamit nga gilay-on",
diff --git a/src/lib/i18n/locales/cs-CZ/translation.json b/src/lib/i18n/locales/cs-CZ/translation.json
index 63573b7b1105901fec3b949a8ff2ca060230c2d8..47d1ef9a448d169c317ffbc58e44a020967e315c 100644
--- a/src/lib/i18n/locales/cs-CZ/translation.json
+++ b/src/lib/i18n/locales/cs-CZ/translation.json
@@ -1697,6 +1697,7 @@
"No content to speak": "Žádný obsah k přečtení.",
"No conversation to save": "Žádná konverzace k uložení",
"No data": "Žádná data",
+ "No data found": "",
"No description": "Bez popisu",
"No differences": "",
"No distance available": "Vzdálenost není k dispozici",
diff --git a/src/lib/i18n/locales/da-DK/translation.json b/src/lib/i18n/locales/da-DK/translation.json
index 1716e5eb8bb943088fdb6e5de895b59b605fcfbd..b07840646f42299d15e0ae55126a44483a6bb4f8 100644
--- a/src/lib/i18n/locales/da-DK/translation.json
+++ b/src/lib/i18n/locales/da-DK/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "Intet indhold at tale",
"No conversation to save": "Ingen samtale at gemme",
"No data": "Ingen data",
+ "No data found": "",
"No description": "Ingen beskrivelse",
"No differences": "",
"No distance available": "Ingen afstand tilgængelig",
diff --git a/src/lib/i18n/locales/de-DE/translation.json b/src/lib/i18n/locales/de-DE/translation.json
index 3dd27eca041ced3303c802562130fd9e8887b076..dad978298a25ce71997b80bcf8470eeaa159003c 100644
--- a/src/lib/i18n/locales/de-DE/translation.json
+++ b/src/lib/i18n/locales/de-DE/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "Kein Inhalt zum Vorlesen",
"No conversation to save": "Keine Unterhaltung zum Speichern vorhanden",
"No data": "Keine Daten",
+ "No data found": "",
"No description": "Keine Beschreibung",
"No differences": "",
"No distance available": "Keine Distanz verfügbar",
diff --git a/src/lib/i18n/locales/en-GB/translation.json b/src/lib/i18n/locales/en-GB/translation.json
index 986ef6d71381aff7f7619e39b4474b62f38d6773..10ffce0621c8f7b7db2558c03b92923bf3f30c61 100644
--- a/src/lib/i18n/locales/en-GB/translation.json
+++ b/src/lib/i18n/locales/en-GB/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "",
"No conversation to save": "",
"No data": "",
+ "No data found": "",
"No description": "",
"No differences": "",
"No distance available": "",
diff --git a/src/lib/i18n/locales/en-US/translation.json b/src/lib/i18n/locales/en-US/translation.json
index 7b81819a7bf7d6d087150d028103e2de392a1eeb..0ab1f081ef186469fc2bfa6895380a2f523e6f31 100644
--- a/src/lib/i18n/locales/en-US/translation.json
+++ b/src/lib/i18n/locales/en-US/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "",
"No conversation to save": "",
"No data": "",
+ "No data found": "",
"No description": "",
"No differences": "",
"No distance available": "",
diff --git a/src/lib/i18n/locales/es-ES/translation.json b/src/lib/i18n/locales/es-ES/translation.json
index db1c0ddaf3f12eaf15d40a9c098c6c806022cca6..d33f6f29a03984476c9d56f7ac4ae1ed349068a1 100644
--- a/src/lib/i18n/locales/es-ES/translation.json
+++ b/src/lib/i18n/locales/es-ES/translation.json
@@ -1683,6 +1683,7 @@
"No content to speak": "No hay contenido para hablar",
"No conversation to save": "No hay conversación para guardar",
"No data": "Sin datos",
+ "No data found": "",
"No description": "Sin descripción",
"No differences": "",
"No distance available": "No hay distancia disponible",
diff --git a/src/lib/i18n/locales/eu-ES/translation.json b/src/lib/i18n/locales/eu-ES/translation.json
index 84c6e74729a74f479a8e88a383064925b399fb44..4c0655be92c51e7a3ef7d4b478c3303f33ebfce9 100644
--- a/src/lib/i18n/locales/eu-ES/translation.json
+++ b/src/lib/i18n/locales/eu-ES/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "Ez dago hitz egiteko edukirik",
"No conversation to save": "Ez dago gordetzeko elkarrizketarik",
"No data": "Ez dago daturik",
+ "No data found": "",
"No description": "Ez dago deskribapenik",
"No differences": "",
"No distance available": "Ez dago distantziarik eskuragarri",
diff --git a/src/lib/i18n/locales/fi-FI/translation.json b/src/lib/i18n/locales/fi-FI/translation.json
index 281a5c1a6da33208e41eba6a32da50bbfd866f64..3f3475ef911ac612ec9bfa130cbc2bfd234c40ba 100644
--- a/src/lib/i18n/locales/fi-FI/translation.json
+++ b/src/lib/i18n/locales/fi-FI/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "Ei puhuttavaa sisältöä",
"No conversation to save": "Ei tallennettavaa keskustelua",
"No data": "Ei dataa",
+ "No data found": "",
"No description": "Ei kuvausta",
"No differences": "",
"No distance available": "Etäisyyttä ei saatavilla",
diff --git a/src/lib/i18n/locales/fil-PH/translation.json b/src/lib/i18n/locales/fil-PH/translation.json
index b3086f58d0332a45da1fea2e94755569092084c5..540c828e133aa3a340c667d3fe5294cdb8653c1e 100644
--- a/src/lib/i18n/locales/fil-PH/translation.json
+++ b/src/lib/i18n/locales/fil-PH/translation.json
@@ -1670,6 +1670,7 @@
"No content to speak": "",
"No conversation to save": "",
"No data": "",
+ "No data found": "",
"No description": "",
"No differences": "",
"No distance available": "",
diff --git a/src/lib/i18n/locales/fo-FO/translation.json b/src/lib/i18n/locales/fo-FO/translation.json
index 1fea8c556870f469091e65e3005e2229fbcdd999..d0e322a359ac10cfae5d544858d870065bbe931b 100644
--- a/src/lib/i18n/locales/fo-FO/translation.json
+++ b/src/lib/i18n/locales/fo-FO/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "Einki innihald at lesa upp",
"No conversation to save": "Eingin samrøða at goyma",
"No data": "Eingi dátugøgn",
+ "No data found": "",
"No description": "Eingin lýsing",
"No differences": "",
"No distance available": "Eingin frástøða tøk",
diff --git a/src/lib/i18n/locales/fr-FR/translation.json b/src/lib/i18n/locales/fr-FR/translation.json
index 803b6dc53881a2ffdba64de945ffa77f7918ac16..1ceb152a6458138113722a08d2744adb642097b0 100644
--- a/src/lib/i18n/locales/fr-FR/translation.json
+++ b/src/lib/i18n/locales/fr-FR/translation.json
@@ -1683,6 +1683,7 @@
"No content to speak": "Rien à signaler",
"No conversation to save": "Aucune conversation à sauvegarder",
"No data": "Aucune donnée",
+ "No data found": "",
"No description": "Aucune description",
"No differences": "",
"No distance available": "Aucune distance disponible",
diff --git a/src/lib/i18n/locales/hr-HR/translation.json b/src/lib/i18n/locales/hr-HR/translation.json
index 1af4d6346ca3f8b7fc46b0112135a17ff3d1f2ed..503a1987612559ccd6697b599a25dc5df5ad5020 100644
--- a/src/lib/i18n/locales/hr-HR/translation.json
+++ b/src/lib/i18n/locales/hr-HR/translation.json
@@ -1683,6 +1683,7 @@
"No content to speak": "Nema sadržaja za čitanje naglas",
"No conversation to save": "Nema razgovora za spremanje",
"No data": "Nema podataka",
+ "No data found": "",
"No description": "Nema opisa",
"No differences": "",
"No distance available": "Nema dostupne vrijednosti udaljenosti",
diff --git a/src/lib/i18n/locales/hu-HU/translation.json b/src/lib/i18n/locales/hu-HU/translation.json
index 855b233a2cb933d22a3ce7e619ddc3d63d1b9043..cbd7052fb0e78bd30dfca439c0357b2ffe6137a1 100644
--- a/src/lib/i18n/locales/hu-HU/translation.json
+++ b/src/lib/i18n/locales/hu-HU/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "Nincs felolvasható tartalom",
"No conversation to save": "Nincs menthető beszélgetés",
"No data": "Nincs adat",
+ "No data found": "",
"No description": "Nincs leírás",
"No differences": "",
"No distance available": "Nincs elérhető távolság",
diff --git a/src/lib/i18n/locales/ie-GA/translation.json b/src/lib/i18n/locales/ie-GA/translation.json
index 91da79fafb8c943fd5b976e860b2d29e3b169dd1..4c1b05d2d2e41375c0f67ff06e8a8a4d39dd513e 100644
--- a/src/lib/i18n/locales/ie-GA/translation.json
+++ b/src/lib/i18n/locales/ie-GA/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "Níl aon ábhar le labhairt",
"No conversation to save": "Gan aon chomhrá le sábháil",
"No data": "Gan aon sonraí",
+ "No data found": "",
"No description": "Gan cur síos",
"No differences": "",
"No distance available": "Níl achar ar fáil",
diff --git a/src/lib/i18n/locales/ja-JP/translation.json b/src/lib/i18n/locales/ja-JP/translation.json
index 864d206d35d10431250f1025ad5756e5e9bb059a..a5d195c28616ca03cb716d516abe4fcc7b78c775 100644
--- a/src/lib/i18n/locales/ja-JP/translation.json
+++ b/src/lib/i18n/locales/ja-JP/translation.json
@@ -1668,6 +1668,7 @@
"No content to speak": "話す内容がありません",
"No conversation to save": "保存する会話がありません。",
"No data": "データがありません",
+ "No data found": "",
"No description": "説明がありません",
"No differences": "",
"No distance available": "距離が利用できません",
diff --git a/src/lib/i18n/locales/kab-DZ/translation.json b/src/lib/i18n/locales/kab-DZ/translation.json
index d2ff1e29a8b560ef1cb38e3437f2864c3fe990f6..1fd9bd76e582def3f9e962dbfab4943fd8f7a226 100644
--- a/src/lib/i18n/locales/kab-DZ/translation.json
+++ b/src/lib/i18n/locales/kab-DZ/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "Ulac ara d-yettwabedren",
"No conversation to save": "Ulac adiwenni i usekles",
"No data": "",
+ "No data found": "",
"No description": "",
"No differences": "",
"No distance available": "Ulac ameccaq yettwafen",
diff --git a/src/lib/i18n/locales/nl-NL/translation.json b/src/lib/i18n/locales/nl-NL/translation.json
index 276d1160c425ed88a7f546aae753e74fe4b88d79..a76b2c496948143c0f9080a01a7df5c06b565758 100644
--- a/src/lib/i18n/locales/nl-NL/translation.json
+++ b/src/lib/i18n/locales/nl-NL/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "Geen inhoud om over te spreken",
"No conversation to save": "Geen gesprek om op te slaan",
"No data": "Geen gegevens",
+ "No data found": "",
"No description": "Geen beschrijving",
"No differences": "",
"No distance available": "Geen afstand beschikbaar",
diff --git a/src/lib/i18n/locales/pa-IN/translation.json b/src/lib/i18n/locales/pa-IN/translation.json
index 8f1c205de474d3b02d90097b13dc1d9536e24f5f..9d5108fc9f2c6b7b21876b3d46df6c48dba589ab 100644
--- a/src/lib/i18n/locales/pa-IN/translation.json
+++ b/src/lib/i18n/locales/pa-IN/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "",
"No conversation to save": "",
"No data": "",
+ "No data found": "",
"No description": "",
"No differences": "",
"No distance available": "",
diff --git a/src/lib/i18n/locales/pl-PL/translation.json b/src/lib/i18n/locales/pl-PL/translation.json
index cb0cd44ff67e723ef833bae5aa72d5884941532e..585e9bb3871834d5bb6fdf5f68ac313632d6daa3 100644
--- a/src/lib/i18n/locales/pl-PL/translation.json
+++ b/src/lib/i18n/locales/pl-PL/translation.json
@@ -1697,6 +1697,7 @@
"No content to speak": "Brak treści do wymówienia",
"No conversation to save": "Brak rozmowy do zapisania",
"No data": "Brak danych",
+ "No data found": "",
"No description": "Brak opisu",
"No differences": "",
"No distance available": "Brak wyniku dopasowania",
diff --git a/src/lib/i18n/locales/pt-BR/translation.json b/src/lib/i18n/locales/pt-BR/translation.json
index 49d8c29d3be6de3f865b348d28ef53334beb2ec9..2ace55a4d29cd1cab98355097406596c65ce28bb 100644
--- a/src/lib/i18n/locales/pt-BR/translation.json
+++ b/src/lib/i18n/locales/pt-BR/translation.json
@@ -1683,6 +1683,7 @@
"No content to speak": "Sem conteúdo para falar",
"No conversation to save": "Nenhuma conversa para salvar",
"No data": "Sem dados",
+ "No data found": "",
"No description": "Sem descrição",
"No differences": "",
"No distance available": "Sem distância disponível",
diff --git a/src/lib/i18n/locales/ru-RU/translation.json b/src/lib/i18n/locales/ru-RU/translation.json
index 99f8ec786cf0f5a2d6e9de2c98cb245eb232505e..8c07314c64d300d8ce96034b3fffb2e2d82e6965 100644
--- a/src/lib/i18n/locales/ru-RU/translation.json
+++ b/src/lib/i18n/locales/ru-RU/translation.json
@@ -1697,6 +1697,7 @@
"No content to speak": "Нечего говорить",
"No conversation to save": "Нет разговора для сохранения",
"No data": "Нет данных",
+ "No data found": "",
"No description": "Нет описания",
"No differences": "",
"No distance available": "Расстояние недоступно",
diff --git a/src/lib/i18n/locales/sl-SI/translation.json b/src/lib/i18n/locales/sl-SI/translation.json
index e34e6d22fccfcf1c4a49cf4153aca08543fa21b0..399d9e6c4c63c4e096f8a22f5d96f0eede0f4d6e 100644
--- a/src/lib/i18n/locales/sl-SI/translation.json
+++ b/src/lib/i18n/locales/sl-SI/translation.json
@@ -1697,6 +1697,7 @@
"No content to speak": "Ni content to speak",
"No conversation to save": "Ni conversation to save",
"No data": "Ni podatkov",
+ "No data found": "",
"No description": "Ni opisa",
"No differences": "",
"No distance available": "Ni distance available",
diff --git a/src/lib/i18n/locales/sr-RS/translation.json b/src/lib/i18n/locales/sr-RS/translation.json
index 271fe979cc2614ca4bc78c04755f1b05d60fef4d..6783be6c551025b8dd212e792bb56e41765d4fcd 100644
--- a/src/lib/i18n/locales/sr-RS/translation.json
+++ b/src/lib/i18n/locales/sr-RS/translation.json
@@ -1683,6 +1683,7 @@
"No content to speak": "",
"No conversation to save": "",
"No data": "",
+ "No data found": "",
"No description": "",
"No differences": "",
"No distance available": "",
diff --git a/src/lib/i18n/locales/ta-IN/translation.json b/src/lib/i18n/locales/ta-IN/translation.json
index b2f7b6ac4f760bd998961c05a3e008618c1f43f7..088605141487d3749e3929e9b2ea6d24f238c38a 100644
--- a/src/lib/i18n/locales/ta-IN/translation.json
+++ b/src/lib/i18n/locales/ta-IN/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "பேசுவதற்கு உள்ளடக்கம் இல்லை",
"No conversation to save": "சேமிக்க உரையாடல் இல்லை",
"No data": "தரவு இல்லை",
+ "No data found": "",
"No description": "",
"No differences": "",
"No distance available": "தூரம் இல்லை",
diff --git a/src/lib/i18n/locales/tk-TM/translation.json b/src/lib/i18n/locales/tk-TM/translation.json
index 47c9bb35ed54a70a4e30e0ed9d4e2fc3781648a9..78676a102e06e258acf195d9b26160ef29463d30 100644
--- a/src/lib/i18n/locales/tk-TM/translation.json
+++ b/src/lib/i18n/locales/tk-TM/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "",
"No conversation to save": "",
"No data": "",
+ "No data found": "",
"No description": "",
"No differences": "",
"No distance available": "",
diff --git a/src/lib/i18n/locales/tr-TR/translation.json b/src/lib/i18n/locales/tr-TR/translation.json
index bc2ba9bfebb905b5caf0e03edfc30dba48eef523..19863501035b09d275909ed37d94171aab80a1a7 100644
--- a/src/lib/i18n/locales/tr-TR/translation.json
+++ b/src/lib/i18n/locales/tr-TR/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "Konuşacak içerik yok",
"No conversation to save": "Kaydedilecek sohbet yok",
"No data": "Veri yok",
+ "No data found": "",
"No description": "Açıklama yok",
"No differences": "",
"No distance available": "Mesafe mevcut değil",
diff --git a/src/lib/i18n/locales/ug-CN/translation.json b/src/lib/i18n/locales/ug-CN/translation.json
index 4affff85662161a6e1fba05018e3ab4843a0d2e9..1ac2288e452fc8b09ff988abbabf1c0832995d04 100644
--- a/src/lib/i18n/locales/ug-CN/translation.json
+++ b/src/lib/i18n/locales/ug-CN/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "تاپشۇرىدىغان مەزمۇن يوق",
"No conversation to save": "",
"No data": "",
+ "No data found": "",
"No description": "",
"No differences": "",
"No distance available": "ئارىلىق ئۇچۇرى يوق",
diff --git a/src/lib/i18n/locales/uk-UA/translation.json b/src/lib/i18n/locales/uk-UA/translation.json
index ce84b2957fa13ca39e99c8004fa0e287792e9c54..8de979e36afc336abe22e18cc936c85d7ee6d5b0 100644
--- a/src/lib/i18n/locales/uk-UA/translation.json
+++ b/src/lib/i18n/locales/uk-UA/translation.json
@@ -1697,6 +1697,7 @@
"No content to speak": "Нема чого говорити",
"No conversation to save": "Немає розмови для збереження",
"No data": "Немає даних",
+ "No data found": "",
"No description": "Немає опису",
"No differences": "",
"No distance available": "Відстань недоступна",
diff --git a/src/lib/i18n/locales/ur-PK/translation.json b/src/lib/i18n/locales/ur-PK/translation.json
index cb2a4377418a407f54f7ce2ae45437ccd4e9179a..3eba4ea7c86390520c5d8c9065ac91cb40668c04 100644
--- a/src/lib/i18n/locales/ur-PK/translation.json
+++ b/src/lib/i18n/locales/ur-PK/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "بولنے کے لیے کوئی مواد نہیں",
"No conversation to save": "",
"No data": "",
+ "No data found": "",
"No description": "",
"No differences": "",
"No distance available": "فاصلہ دستیاب نہیں ہے",
diff --git a/src/lib/i18n/locales/uz-Cyrl-UZ/translation.json b/src/lib/i18n/locales/uz-Cyrl-UZ/translation.json
index 8bcec83b19be4162be18e656ce42996e391e48e9..b4d2f0a726ca672d782d9a00b9d02822950f92c1 100644
--- a/src/lib/i18n/locales/uz-Cyrl-UZ/translation.json
+++ b/src/lib/i18n/locales/uz-Cyrl-UZ/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "Гапирадиган таркиб йўқ",
"No conversation to save": "",
"No data": "",
+ "No data found": "",
"No description": "",
"No differences": "",
"No distance available": "Масофа мавжуд эмас",
diff --git a/src/lib/i18n/locales/uz-Latn-UZ/translation.json b/src/lib/i18n/locales/uz-Latn-UZ/translation.json
index d2d6aa5fb77b101c00eea8eb8ec74a73a65f40c0..874d7bda448d97a3bde47e69b05ef01cc955cfdd 100644
--- a/src/lib/i18n/locales/uz-Latn-UZ/translation.json
+++ b/src/lib/i18n/locales/uz-Latn-UZ/translation.json
@@ -1669,6 +1669,7 @@
"No content to speak": "Gapiradigan tarkib yo'q",
"No conversation to save": "",
"No data": "",
+ "No data found": "",
"No description": "",
"No differences": "",
"No distance available": "Masofa mavjud emas",
diff --git a/src/lib/i18n/locales/zh-CN/translation.json b/src/lib/i18n/locales/zh-CN/translation.json
index 115f5324bfcd8e35091458c968c8ab129c172039..8a0bd33f67f6a14c2ac026a61c02c3b93872a75e 100644
--- a/src/lib/i18n/locales/zh-CN/translation.json
+++ b/src/lib/i18n/locales/zh-CN/translation.json
@@ -1665,6 +1665,7 @@
"No content to speak": "没有内容可朗读",
"No conversation to save": "没有可保存的对话",
"No data": "暂无数据",
+ "No data found": "",
"No description": "无描述",
"No differences": "",
"No distance available": "没有可用距离",
diff --git a/src/lib/i18n/locales/zh-TW/translation.json b/src/lib/i18n/locales/zh-TW/translation.json
index b79205f0239f368715dd6f78b3745122f83aca3f..1361737404ac41e4887c88e61e4de88e8370433b 100644
--- a/src/lib/i18n/locales/zh-TW/translation.json
+++ b/src/lib/i18n/locales/zh-TW/translation.json
@@ -1655,6 +1655,7 @@
"No content to speak": "無可朗讀的內容",
"No conversation to save": "沒有可儲存的對話",
"No data": "暫無資料",
+ "No data found": "",
"No description": "無描述",
"No differences": "",
"No distance available": "無可用距離",
diff --git a/src/lib/pyodide/pyodideKernel.worker.ts b/src/lib/pyodide/pyodideKernel.worker.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4a407dd177d8da53726585db1b6bec9ce65f2394
--- /dev/null
+++ b/src/lib/pyodide/pyodideKernel.worker.ts
@@ -0,0 +1,124 @@
+import { loadPyodide, type PyodideInterface } from 'pyodide';
+
+declare global {
+ interface Window {
+ stdout: string | null;
+ stderr: string | null;
+ pyodide: PyodideInterface;
+ cells: Record<string, CellState>;
+ indexURL: string;
+ }
+}
+
+type CellState = {
+ id: string;
+ status: 'idle' | 'running' | 'completed' | 'error';
+ result: any;
+ stdout: string;
+ stderr: string;
+};
+
+const initializePyodide = async () => {
+ // Ensure Pyodide is loaded once and cached in the worker's global scope
+ if (!self.pyodide) {
+ // Base-prefixed (Vite injects BASE_URL) so Pyodide loads under a subpath deploy.
+ self.indexURL = `${import.meta.env.BASE_URL}pyodide/`;
+ self.stdout = '';
+ self.stderr = '';
+ self.cells = {};
+
+ self.pyodide = await loadPyodide({
+ indexURL: self.indexURL
+ });
+ }
+};
+
+const executeCode = async (id: string, code: string) => {
+ if (!self.pyodide) {
+ await initializePyodide();
+ }
+
+ // Update the cell state to "running"
+ self.cells[id] = {
+ id,
+ status: 'running',
+ result: null,
+ stdout: '',
+ stderr: ''
+ };
+
+ // Redirect stdout/stderr to stream updates
+ self.pyodide.setStdout({
+ batched: (msg: string) => {
+ self.cells[id].stdout += msg;
+ self.postMessage({ type: 'stdout', id, message: msg });
+ }
+ });
+ self.pyodide.setStderr({
+ batched: (msg: string) => {
+ self.cells[id].stderr += msg;
+ self.postMessage({ type: 'stderr', id, message: msg });
+ }
+ });
+
+ try {
+ // Dynamically load required packages based on imports in the Python code
+ await self.pyodide.loadPackagesFromImports(code, {
+ messageCallback: (msg: string) => {
+ self.postMessage({ type: 'stdout', id, package: true, message: `[package] ${msg}` });
+ },
+ errorCallback: (msg: string) => {
+ self.postMessage({ type: 'stderr', id, package: true, message: `[package] ${msg}` });
+ }
+ });
+
+ // Execute the Python code
+ const result = await self.pyodide.runPythonAsync(code);
+ self.cells[id].result = result;
+ self.cells[id].status = 'completed';
+ } catch (error) {
+ self.cells[id].status = 'error';
+ self.cells[id].stderr += `\n${error.toString()}`;
+ } finally {
+ // Notify parent thread when execution completes
+ self.postMessage({
+ type: 'result',
+ id,
+ state: self.cells[id]
+ });
+ }
+};
+
+// Handle messages from the main thread
+self.onmessage = async (event) => {
+ const { type, id, code, ...args } = event.data;
+
+ switch (type) {
+ case 'initialize':
+ await initializePyodide();
+ self.postMessage({ type: 'initialized' });
+ break;
+
+ case 'execute':
+ if (id && code) {
+ await executeCode(id, code);
+ }
+ break;
+
+ case 'getState':
+ self.postMessage({
+ type: 'kernelState',
+ state: self.cells
+ });
+ break;
+
+ case 'terminate':
+ // Explicitly clear the worker for cleanup
+ for (const key in self.cells) delete self.cells[key];
+ self.close();
+ break;
+
+ default:
+ console.error(`Unknown message type: ${type}`);
+ }
+};
diff --git a/src/lib/utils/google-drive-picker.ts b/src/lib/utils/google-drive-picker.ts
index 64b7f371b38ef767ffaefe8a0fe31763ab6ed598..137a1c7f12ddaf131bfc21d8675b59f7a9d1adb0 100644
--- a/src/lib/utils/google-drive-picker.ts
+++ b/src/lib/utils/google-drive-picker.ts
@@ -1,10 +1,12 @@
+import { WEBUI_BASE_URL } from '$lib/constants';
+
// Google Drive Picker API configuration
let API_KEY = '';
let CLIENT_ID = '';
// Function to fetch credentials from backend config
async function getCredentials() {
- const response = await fetch('/api/config');
+ const response = await fetch(`${WEBUI_BASE_URL}/api/config`);
if (!response.ok) {
throw new Error('Failed to fetch Google Drive credentials');
}
diff --git a/src/lib/utils/onedrive-file-picker.ts b/src/lib/utils/onedrive-file-picker.ts
index 198a8bcafba16d909e97a5e36e15b7a7e3431577..21f7af959cde6f9a2d6a15b71906fc74f24e5140 100644
--- a/src/lib/utils/onedrive-file-picker.ts
+++ b/src/lib/utils/onedrive-file-picker.ts
@@ -1,5 +1,6 @@
import type { PopupRequest, PublicClientApplication } from '@azure/msal-browser';
import { v4 as uuidv4 } from 'uuid';
+import { WEBUI_BASE_URL } from '$lib/constants';
class OneDriveConfig {
private static instance: OneDriveConfig;
@@ -32,7 +33,7 @@ class OneDriveConfig {
}
private async getCredentials(): Promise<void> {
- const response = await fetch('/api/config', {
+ const response = await fetch(`${WEBUI_BASE_URL}/api/config`, {
headers: {
'Content-Type': 'application/json'
},
diff --git a/src/lib/utils/safeImageUrl.ts b/src/lib/utils/safeImageUrl.ts
index c81412294c1eddaa0c60c61ea1a638191cfea3fe..739e20148f51dc135be629fb9e5f09603ac6395b 100644
--- a/src/lib/utils/safeImageUrl.ts
+++ b/src/lib/utils/safeImageUrl.ts
@@ -24,12 +24,15 @@ export function safeImageUrl(url: string, allowExternal = false): string {
return `${WEBUI_BASE_URL}${PLACEHOLDER_IMAGE}`;
}
+ if (url.startsWith('/')) {
+ return url.startsWith(`${WEBUI_BASE_URL}/`) ? url : `${WEBUI_BASE_URL}${url}`;
+ }
+
if (
- (WEBUI_BASE_URL && url.startsWith(WEBUI_BASE_URL)) ||
+ (WEBUI_BASE_URL !== '' && (url === WEBUI_BASE_URL || url.startsWith(`${WEBUI_BASE_URL}/`))) ||
url.startsWith('https://www.gravatar.com/avatar/') ||
(allowExternal && /^https?:\/\//i.test(url)) ||
- url.startsWith('data:') ||
- url.startsWith('/')
+ url.startsWith('data:')
) {
return url;
}
diff --git a/src/lib/workers/kokoro.worker.ts b/src/lib/workers/kokoro.worker.ts
index ce047a6557ca5fc5b1ef7b63c58f2613aeacf1fe..a17b248a34fdc61d97bb0f3ddfaea1aa005c8277 100644
--- a/src/lib/workers/kokoro.worker.ts
+++ b/src/lib/workers/kokoro.worker.ts
@@ -2,7 +2,8 @@ import { env } from '@huggingface/transformers';
import { KokoroTTS } from 'kokoro-js';
// TODO: Below doesn't work as expected, need to investigate further
-env.backends.onnx.wasm.wasmPaths = '/wasm/';
+// Base-prefixed (Vite injects BASE_URL) so the ONNX wasm loads under a subpath deploy.
+env.backends.onnx.wasm.wasmPaths = `${import.meta.env.BASE_URL}wasm/`;
let tts;
let isInitialized = false; // Flag to track initialization status
diff --git a/src/lib/workers/pyodide.worker.ts b/src/lib/workers/pyodide.worker.ts
index 4eb3ee7a31f18a2918a40224b9f4eb51e12538db..5e677bf2e25da10fb467bd8c0513f28ac75481ff 100644
--- a/src/lib/workers/pyodide.worker.ts
+++ b/src/lib/workers/pyodide.worker.ts
@@ -25,7 +25,10 @@ async function loadPyodideAndPackages(packages: string[] = []) {
self.result = null;
self.pyodide = await loadPyodide({
- indexURL: '/pyodide/',
+ // Resolve Pyodide assets under the app base (Vite injects BASE_URL, always
+ // trailing-slashed) so they load under a reverse-proxy subpath. Workers can't
+ // read $app/paths, so import.meta.env.BASE_URL is the build-time source of truth.
+ indexURL: `${import.meta.env.BASE_URL}pyodide/`,
stdout: (text) => {
console.log('Python output:', text);
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte
index e11bafb87a898fbc3695888b450e5d3e95691437..67e7d35fd898e8905c3af58f0b586621f95bbdfc 100644
--- a/src/routes/(app)/+layout.svelte
+++ b/src/routes/(app)/+layout.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import { onMount, tick, getContext } from 'svelte';
@@ -237,7 +238,7 @@
const gotoAuth = async () => {
const currentUrl = `${$page.url.pathname}${$page.url.search}`;
- await goto(`/auth?redirect=${encodeURIComponent(currentUrl)}`);
+ await goto(`${base}/auth?redirect=${encodeURIComponent(currentUrl)}`);
};
const navigateChat = async (direction: -1 | 1) => {
@@ -247,7 +248,7 @@
const nextChat = currentIndex === -1 ? $chats[0] : $chats[currentIndex + direction];
if (nextChat) {
- await goto(`/c/${nextChat.id}`);
+ await goto(`${base}/c/${nextChat.id}`);
}
};
@@ -351,7 +352,7 @@
} else {
temporaryChatEnabled.set(!$temporaryChatEnabled);
}
- await goto('/');
+ await goto(`${base}/`);
setTimeout(() => {
document.getElementById('new-chat-button')?.click();
}, 0);
diff --git a/src/routes/(app)/admin/+layout.svelte b/src/routes/(app)/admin/+layout.svelte
index 845e73898fe2d4493880fd7b2ecaf871a6cc4b26..1e132e75eb54836f8004b66f99aefb250a96688f 100644
--- a/src/routes/(app)/admin/+layout.svelte
+++ b/src/routes/(app)/admin/+layout.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { onMount, getContext } from 'svelte';
import { goto } from '$app/navigation';
@@ -14,12 +15,12 @@
onMount(async () => {
if ($user?.role !== 'admin') {
- await goto('/', { replaceState: true });
+ await goto(`${base}/`, { replaceState: true });
} else if (
!$config?.features?.enable_functions &&
$page.url.pathname.includes('/admin/functions')
) {
- await goto('/admin', { replaceState: true });
+ await goto(`${base}/admin`, { replaceState: true });
}
loaded = true;
});
@@ -73,7 +74,7 @@
class="min-w-fit px-1 text-sm {$page.url.pathname.includes('/admin/users')
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition select-none"
- href="/admin">{$i18n.t('Users')}</a
+ href="{base}/admin">{$i18n.t('Users')}</a
>
<a
@@ -81,7 +82,7 @@
class="min-w-fit px-1 text-sm {$page.url.pathname.includes('/admin/evaluations')
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition select-none"
- href="/admin/evaluations">{$i18n.t('Evaluations')}</a
+ href="{base}/admin/evaluations">{$i18n.t('Evaluations')}</a
>
{#if $config?.features?.enable_functions}
@@ -90,7 +91,7 @@
class="min-w-fit px-1 text-sm {$page.url.pathname.includes('/admin/functions')
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition select-none"
- href="/admin/functions">{$i18n.t('Functions')}</a
+ href="{base}/admin/functions">{$i18n.t('Functions')}</a
>
{/if}
@@ -99,7 +100,7 @@
class="min-w-fit px-1 text-sm {$page.url.pathname.includes('/admin/settings')
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition select-none"
- href="/admin/settings"
+ href="{base}/admin/settings"
on:click={(event) => {
event.preventDefault();
showSettings.set('admin:general');
diff --git a/src/routes/(app)/admin/+page.svelte b/src/routes/(app)/admin/+page.svelte
index c6aad81a38fdee5946ba1ffe52bf1c39064c3368..903752424a92cad93c3b4b53e49b9e33f2168857 100644
--- a/src/routes/(app)/admin/+page.svelte
+++ b/src/routes/(app)/admin/+page.svelte
@@ -1,8 +1,9 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => {
- goto('/admin/users', { replaceState: true });
+ goto(`${base}/admin/users`, { replaceState: true });
});
</script>
diff --git a/src/routes/(app)/admin/analytics/+page.svelte b/src/routes/(app)/admin/analytics/+page.svelte
index 302f4c4261fa08ed53aafd197705736f81fb1492..2bda342d26c344865e5601d04c2e08229bd766c8 100644
--- a/src/routes/(app)/admin/analytics/+page.svelte
+++ b/src/routes/(app)/admin/analytics/+page.svelte
@@ -1,13 +1,14 @@
<script>
+ import { base } from '$app/paths';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { config } from '$lib/stores';
onMount(() => {
if (!($config?.features.enable_admin_analytics ?? true)) {
- goto('/admin', { replaceState: true });
+ goto(`${base}/admin`, { replaceState: true });
} else {
- goto('/?settings=admin%3Aanalytics', { replaceState: true });
+ goto(`${base}/?settings=admin%3Aanalytics`, { replaceState: true });
}
});
</script>
diff --git a/src/routes/(app)/admin/analytics/[tab]/+page.svelte b/src/routes/(app)/admin/analytics/[tab]/+page.svelte
index 302f4c4261fa08ed53aafd197705736f81fb1492..2bda342d26c344865e5601d04c2e08229bd766c8 100644
--- a/src/routes/(app)/admin/analytics/[tab]/+page.svelte
+++ b/src/routes/(app)/admin/analytics/[tab]/+page.svelte
@@ -1,13 +1,14 @@
<script>
+ import { base } from '$app/paths';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { config } from '$lib/stores';
onMount(() => {
if (!($config?.features.enable_admin_analytics ?? true)) {
- goto('/admin', { replaceState: true });
+ goto(`${base}/admin`, { replaceState: true });
} else {
- goto('/?settings=admin%3Aanalytics', { replaceState: true });
+ goto(`${base}/?settings=admin%3Aanalytics`, { replaceState: true });
}
});
</script>
diff --git a/src/routes/(app)/admin/evaluations/+page.svelte b/src/routes/(app)/admin/evaluations/+page.svelte
index 8913071b7f2019638c89e9bc6fdea55c511fa80c..bd205f5e13bc097ef544cdd1d4b54932b184607c 100644
--- a/src/routes/(app)/admin/evaluations/+page.svelte
+++ b/src/routes/(app)/admin/evaluations/+page.svelte
@@ -1,8 +1,9 @@
<script>
+ import { base } from '$app/paths';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => {
- goto('/admin/evaluations/leaderboard', { replaceState: true });
+ goto(`${base}/admin/evaluations/leaderboard`, { replaceState: true });
});
</script>
diff --git a/src/routes/(app)/admin/functions/create/+page.svelte b/src/routes/(app)/admin/functions/create/+page.svelte
index 6284948bc416af582d6eadcc8002f33bc68c0387..06a2531e034dc4eae05587437bf897570728b357 100644
--- a/src/routes/(app)/admin/functions/create/+page.svelte
+++ b/src/routes/(app)/admin/functions/create/+page.svelte
@@ -1,4 +1,5 @@
<script>
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import { onMount, getContext } from 'svelte';
import { goto } from '$app/navigation';
@@ -60,7 +61,7 @@
)
);
- await goto('/admin/functions');
+ await goto(`${base}/admin/functions`);
}
};
diff --git a/src/routes/(app)/admin/functions/edit/+page.svelte b/src/routes/(app)/admin/functions/edit/+page.svelte
index 1693a185321aec85f2e902105e2168ef3ba3aa21..294070a612ad8b6208b3e12af1c9fa998bece2ee 100644
--- a/src/routes/(app)/admin/functions/edit/+page.svelte
+++ b/src/routes/(app)/admin/functions/edit/+page.svelte
@@ -1,4 +1,5 @@
<script>
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import { onMount, getContext } from 'svelte';
@@ -78,7 +79,7 @@
if (id) {
func = await getFunctionById(localStorage.token, id).catch((error) => {
toast.error(`${error}`);
- goto('/admin/functions');
+ goto(`${base}/admin/functions`);
return null;
});
diff --git a/src/routes/(app)/admin/settings/+page.svelte b/src/routes/(app)/admin/settings/+page.svelte
index e015a1c57f4d490a8aa4cd9c58bfbd08e8110341..dffd96826bc987f38d2a6a8a41b9756cc959818b 100644
--- a/src/routes/(app)/admin/settings/+page.svelte
+++ b/src/routes/(app)/admin/settings/+page.svelte
@@ -1,8 +1,9 @@
<script>
+ import { base } from '$app/paths';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => {
- goto('/?settings=admin%3Ageneral', { replaceState: true });
+ goto(`${base}/?settings=admin%3Ageneral`, { replaceState: true });
});
</script>
diff --git a/src/routes/(app)/admin/users/+page.svelte b/src/routes/(app)/admin/users/+page.svelte
index ebf29a7ab5caf158bcc0fb867b712440e5812e6b..bc56c4f47ea86aa5d701f606ede6963c7a41d535 100644
--- a/src/routes/(app)/admin/users/+page.svelte
+++ b/src/routes/(app)/admin/users/+page.svelte
@@ -1,8 +1,9 @@
<script>
+ import { base } from '$app/paths';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(async () => {
- await goto('/admin/users/overview', { replaceState: true });
+ await goto(`${base}/admin/users/overview`, { replaceState: true });
});
</script>
diff --git a/src/routes/(app)/automations/+page.svelte b/src/routes/(app)/automations/+page.svelte
index 4e8c747c0be57e050e30bfa132aed0990a00cc87..26b8651c1e338592ca46df9e2390c77a3f4b187b 100644
--- a/src/routes/(app)/automations/+page.svelte
+++ b/src/routes/(app)/automations/+page.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { formatSchedule } from '$lib/utils/schedule';
import { onMount, onDestroy, getContext } from 'svelte';
@@ -317,7 +318,7 @@
!($config?.features as any)?.enable_automations ||
($user?.role !== 'admin' && !($user?.permissions?.features?.automations ?? false))
) {
- goto('/');
+ goto(`\${base}/`);
return;
}
diff --git a/src/routes/(app)/automations/[id]/+page.svelte b/src/routes/(app)/automations/[id]/+page.svelte
index 6cb3769b84cbc570738bc45f1f673edc087592a9..7bdead33c5bc038b82f6f009bce5b292c4d03e3a 100644
--- a/src/routes/(app)/automations/[id]/+page.svelte
+++ b/src/routes/(app)/automations/[id]/+page.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
+ import { base } from '$app/paths';
import { onMount } from 'svelte';
import { page } from '$app/stores';
@@ -20,7 +21,7 @@
!($config?.features as any)?.enable_automations ||
($user?.role !== 'admin' && !($user?.permissions?.features?.automations ?? false))
) {
- goto('/');
+ goto(`${base}/`);
return;
}
@@ -34,10 +35,10 @@
automation = res;
loaded = true;
} else {
- goto('/automations');
+ goto(`${base}/automations`);
}
} else {
- goto('/automations');
+ goto(`${base}/automations`);
}
});
</script>
diff --git a/src/routes/(app)/calendar/+page.svelte b/src/routes/(app)/calendar/+page.svelte
index 5e8da41b1ad4e9c45410d6b659349be59658503a..2da2df9c668fd3d9d219ce27518711d063ddd9e9 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 311a54900567aafe20f9c7dd2f4fc54a94fe83d0..6792dda19890a774b3bf004f775876164959474f 100644
--- a/src/routes/(app)/home/+layout.svelte
+++ b/src/routes/(app)/home/+layout.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { onMount, getContext } from 'svelte';
import { WEBUI_NAME, showSidebar, functions, mobile } from '$lib/stores';
import { page } from '$app/stores';
@@ -55,14 +56,14 @@
class="min-w-fit p-1.5 {$page.url.pathname.includes('/home/notes')
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
- href="/playground/notes">{$i18n.t('Notes')}</a
+ href="{base}/playground/notes">{$i18n.t('Notes')}</a
>
<a
class="min-w-fit p-1.5 {$page.url.pathname.includes('/playground/calendar')
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
- href="/playground/completions">{$i18n.t('Calendar')}</a
+ href="{base}/playground/completions">{$i18n.t('Calendar')}</a
>
</div>
</div>
diff --git a/src/routes/(app)/notes/+layout.svelte b/src/routes/(app)/notes/+layout.svelte
index 01ed3c7c113f2520f7f7f10aad7a3c4c359189b0..f38c97f9f972ea9d2d8211279bffcdc29c244bd8 100644
--- a/src/routes/(app)/notes/+layout.svelte
+++ b/src/routes/(app)/notes/+layout.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { onMount, getContext } from 'svelte';
import { WEBUI_NAME, config, user } from '$lib/stores';
import { goto } from '$app/navigation';
@@ -15,7 +16,7 @@
)
) {
// If the feature is not enabled, redirect to the home page
- goto('/');
+ goto(`${base}/`);
}
loaded = true;
diff --git a/src/routes/(app)/notes/+page.svelte b/src/routes/(app)/notes/+page.svelte
index 8bb6047296e71a7e2e14ca955b3320a2fef4d1ed..9a4f14034cf227685076d6e58237370da3101377 100644
--- a/src/routes/(app)/notes/+page.svelte
+++ b/src/routes/(app)/notes/+page.svelte
@@ -1,4 +1,5 @@
<script>
+ import { base } from '$app/paths';
import { getContext, onMount } from 'svelte';
import dayjs from '$lib/dayjs';
diff --git a/src/routes/(app)/notes/new/+page.svelte b/src/routes/(app)/notes/new/+page.svelte
index 2c8d7194674bc83eb98fd2bc0c5acd05c104c059..18fea73797d342760b7685099cee17cea54d618f 100644
--- a/src/routes/(app)/notes/new/+page.svelte
+++ b/src/routes/(app)/notes/new/+page.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { getContext } from 'svelte';
import type { Writable } from 'svelte/store';
import type { i18n as I18n } from 'i18next';
diff --git a/src/routes/(app)/playground/+layout.svelte b/src/routes/(app)/playground/+layout.svelte
index 4550ebe88a59bf1173a4667f693e5e9e03af0693..183fe148d3579d65babfd10a64c52ce00733e976 100644
--- a/src/routes/(app)/playground/+layout.svelte
+++ b/src/routes/(app)/playground/+layout.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { getContext } from 'svelte';
import { WEBUI_NAME, showSidebar, mobile } from '$lib/stores';
import { page } from '$app/stores';
@@ -57,14 +58,14 @@
)
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition select-none"
- href="/playground">{$i18n.t('Chat')}</a
+ href="{base}/playground">{$i18n.t('Chat')}</a
>
<!-- <a
class="min-w-fit p-1.5 {$page.url.pathname.includes('/playground/notes')
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
- href="/playground/notes">{$i18n.t('Notes')}</a
+ href="{base}/playground/notes">{$i18n.t('Notes')}</a
> -->
<a
@@ -72,7 +73,7 @@
class="min-w-fit px-1 text-sm {$page.url.pathname.includes('/playground/completions')
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition select-none"
- href="/playground/completions">{$i18n.t('Completions')}</a
+ href="{base}/playground/completions">{$i18n.t('Completions')}</a
>
<a
@@ -80,7 +81,7 @@
class="min-w-fit px-1 text-sm {$page.url.pathname.includes('/playground/images')
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition select-none"
- href="/playground/images">{$i18n.t('Images')}</a
+ href="{base}/playground/images">{$i18n.t('Images')}</a
>
</div>
</div>
diff --git a/src/routes/(app)/workspace/+layout.svelte b/src/routes/(app)/workspace/+layout.svelte
index 52f728cab53096369502dd19bece2e95a8ea26ae..9c8c612a1cfd8123013cb033ca18aa92fda17d11 100644
--- a/src/routes/(app)/workspace/+layout.svelte
+++ b/src/routes/(app)/workspace/+layout.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { onMount, getContext } from 'svelte';
import type { Writable } from 'svelte/store';
import type { i18n as i18nType } from 'i18next';
@@ -35,11 +36,15 @@
workspaceActions.set([]);
}
- $: if (loaded && $page.url.pathname.startsWith('/workspace')) {
+ $: if (loaded && $page.url.pathname.startsWith(`${base}/workspace`)) {
loadWorkspaceCounts();
}
- $: activeWorkspaceSection = $page.url.pathname.split('/')[2] ?? '';
+ $: activeWorkspaceSection =
+ ($page.url.pathname.startsWith(base)
+ ? $page.url.pathname.slice(base.length)
+ : $page.url.pathname
+ ).split('/')[2] ?? '';
$: visibleActions = $workspaceActions.filter((action) => action.visible ?? true);
const getCount = (res: any) => res?.total ?? (Array.isArray(res) ? res.length : null);
@@ -80,24 +85,24 @@
onMount(async () => {
if ($user?.role !== 'admin') {
if ($page.url.pathname.includes('/models') && !$user?.permissions?.workspace?.models) {
- goto('/', { replaceState: true });
+ goto(`${base}/`, { replaceState: true });
} else if (
$page.url.pathname.includes('/knowledge') &&
!$user?.permissions?.workspace?.knowledge
) {
- goto('/', { replaceState: true });
+ goto(`${base}/`, { replaceState: true });
} else if (
$page.url.pathname.includes('/prompts') &&
!$user?.permissions?.workspace?.prompts
) {
- goto('/', { replaceState: true });
+ goto(`${base}/`, { replaceState: true });
} else if (
$page.url.pathname.includes('/tools') &&
(!$config?.features?.enable_tools || !$user?.permissions?.workspace?.tools)
) {
- goto('/', { replaceState: true });
+ goto(`${base}/`, { replaceState: true });
} else if ($page.url.pathname.includes('/skills') && !$user?.permissions?.workspace?.skills) {
- goto('/', { replaceState: true });
+ goto(`${base}/`, { replaceState: true });
}
}
@@ -156,7 +161,7 @@
'models'
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition select-none"
- href="/workspace/models"
+ href="{base}/workspace/models"
>
<span>{$i18n.t('Models')}</span>
<span class="text-sm opacity-60">
@@ -173,7 +178,7 @@
'knowledge'
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition select-none"
- href="/workspace/knowledge"
+ href="{base}/workspace/knowledge"
>
<span>{$i18n.t('Knowledge')}</span>
<span class="text-sm opacity-60">
@@ -190,7 +195,7 @@
'prompts'
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition select-none"
- href="/workspace/prompts"
+ href="{base}/workspace/prompts"
>
<span>{$i18n.t('Prompts')}</span>
<span class="text-sm opacity-60">
@@ -207,7 +212,7 @@
'skills'
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition select-none"
- href="/workspace/skills"
+ href="{base}/workspace/skills"
>
<span>{$i18n.t('Skills')}</span>
<span class="text-sm opacity-60">
@@ -224,7 +229,7 @@
'tools'
? 'text-gray-900 dark:text-gray-100'
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition select-none"
- href="/workspace/tools"
+ href="{base}/workspace/tools"
>
<span>{$i18n.t('Tools')}</span>
<span class="text-sm opacity-60">
diff --git a/src/routes/(app)/workspace/+page.svelte b/src/routes/(app)/workspace/+page.svelte
index 20a1a6fbd7a2dedfc35e8fc91fea423495bb56f3..b46257dcf980d41b1c84d771af577f84a46b5b64 100644
--- a/src/routes/(app)/workspace/+page.svelte
+++ b/src/routes/(app)/workspace/+page.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { goto } from '$app/navigation';
import { config, user } from '$lib/stores';
import { onMount } from 'svelte';
diff --git a/src/routes/(app)/workspace/functions/create/+page.svelte b/src/routes/(app)/workspace/functions/create/+page.svelte
index 932d28804014d16717a75c916c4301d62e74be55..d3da353d9203b317ab200ade4a4873142015d567 100644
--- a/src/routes/(app)/workspace/functions/create/+page.svelte
+++ b/src/routes/(app)/workspace/functions/create/+page.svelte
@@ -1,8 +1,9 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
onMount(() => {
- goto('/admin/functions/create', { replaceState: true });
+ goto(`${base}/admin/functions/create`, { replaceState: true });
});
</script>
diff --git a/src/routes/(app)/workspace/knowledge/create/+page.svelte b/src/routes/(app)/workspace/knowledge/create/+page.svelte
index b5e1b33ef0b262586d81c5602e8efc0c3a32129d..d93a9b8c5d014e0fb6fdf6e1aa665f39b3f1e08e 100644
--- a/src/routes/(app)/workspace/knowledge/create/+page.svelte
+++ b/src/routes/(app)/workspace/knowledge/create/+page.svelte
@@ -1,5 +1,6 @@
<script>
+ import { base } from '$app/paths';
import Knowledge from '$lib/components/workspace/Knowledge.svelte';
</script>
-<Knowledge showCreateOnMount={true} createModalCloseHref="/workspace/knowledge" />
+<Knowledge showCreateOnMount={true} createModalCloseHref={`${base}/workspace/knowledge`} />
diff --git a/src/routes/(app)/workspace/models/create/+page.svelte b/src/routes/(app)/workspace/models/create/+page.svelte
index 419007e361b6a168ded58bd791416ab5eeeb1caa..890cb41d936477f38c03a80e37702246381e842a 100644
--- a/src/routes/(app)/workspace/models/create/+page.svelte
+++ b/src/routes/(app)/workspace/models/create/+page.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { config, models, settings } from '$lib/stores';
@@ -59,7 +60,7 @@
)
);
toast.success($i18n.t('Model created successfully!'));
- await goto('/workspace/models');
+ await goto(`${base}/workspace/models`);
} catch (error: any) {
toast.error(`${error?.message ?? error}`);
}
@@ -111,7 +112,7 @@
{model}
{onSubmit}
onBack={async () => {
- await goto('/workspace/models');
+ await goto(`${base}/workspace/models`);
}}
/>
{/key}
diff --git a/src/routes/(app)/workspace/models/edit/+page.svelte b/src/routes/(app)/workspace/models/edit/+page.svelte
index 2cd6cf88121e3046b016e53a5792e90683013934..8c21d96c82c7dd7a5f8b693b6a453496081e97ca 100644
--- a/src/routes/(app)/workspace/models/edit/+page.svelte
+++ b/src/routes/(app)/workspace/models/edit/+page.svelte
@@ -1,4 +1,5 @@
<script>
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
@@ -23,16 +24,17 @@
});
if (!model) {
- goto('/workspace/models');
+ goto(`${base}/workspace/models`);
return;
}
if (!model?.write_access) {
toast.error($i18n.t('You do not have permission to edit this model'));
- goto('/workspace/models');
+ await goto(`${base}/workspace/models`);
+ return;
}
} else {
- goto('/workspace/models');
+ goto(`${base}/workspace/models`);
}
});
@@ -50,7 +52,7 @@
)
);
toast.success($i18n.t('Model updated successfully'));
- await goto('/workspace/models');
+ await goto(`${base}/workspace/models`);
} catch (error) {
toast.error(`${error}`);
}
@@ -66,7 +68,7 @@
{model}
{onSubmit}
onBack={async () => {
- await goto('/workspace/models');
+ await goto(`${base}/workspace/models`);
}}
/>
{/if}
diff --git a/src/routes/(app)/workspace/prompts/[id]/+page.svelte b/src/routes/(app)/workspace/prompts/[id]/+page.svelte
index 6a10adca10b2b963669644f133a888bcf8412c39..ace75213fb69634c6c5d3647b59acb12b9c5f260 100644
--- a/src/routes/(app)/workspace/prompts/[id]/+page.svelte
+++ b/src/routes/(app)/workspace/prompts/[id]/+page.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { onMount, getContext } from 'svelte';
@@ -54,10 +55,10 @@
access_grants: _prompt?.access_grants === undefined ? [] : _prompt?.access_grants
};
} else {
- goto('/workspace/prompts');
+ goto(`${base}/workspace/prompts`);
}
} else {
- goto('/workspace/prompts');
+ goto(`${base}/workspace/prompts`);
}
});
</script>
diff --git a/src/routes/(app)/workspace/prompts/create/+page.svelte b/src/routes/(app)/workspace/prompts/create/+page.svelte
index c6c80aed76be790567e9e44659f286a9396d8f55..9a6c400baeae82ff6d08387a3343aff534017271 100644
--- a/src/routes/(app)/workspace/prompts/create/+page.svelte
+++ b/src/routes/(app)/workspace/prompts/create/+page.svelte
@@ -1,5 +1,6 @@
<script>
+ import { base } from '$app/paths';
import Prompts from '$lib/components/workspace/Prompts.svelte';
</script>
-<Prompts showCreateOnMount={true} createModalCloseHref="/workspace/prompts" />
+<Prompts showCreateOnMount={true} createModalCloseHref={`${base}/workspace/prompts`} />
diff --git a/src/routes/(app)/workspace/skills/create/+page.svelte b/src/routes/(app)/workspace/skills/create/+page.svelte
index 72d4e591e2c363ad8533de93de294f1d8d67dc1c..f9a91916eeb513ab9e6d4ff94ade813963ad132b 100644
--- a/src/routes/(app)/workspace/skills/create/+page.svelte
+++ b/src/routes/(app)/workspace/skills/create/+page.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import { skills, user, config } from '$lib/stores';
import { COMMUNITY_ORIGINS } from '$lib/constants';
diff --git a/src/routes/(app)/workspace/skills/edit/+page.svelte b/src/routes/(app)/workspace/skills/edit/+page.svelte
index a4ce361cd8ff756d8c02b234fb543c0327e20c09..320f4cfe2e8f9b5fa613e37a6a470f96331f953c 100644
--- a/src/routes/(app)/workspace/skills/edit/+page.svelte
+++ b/src/routes/(app)/workspace/skills/edit/+page.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { skills } from '$lib/stores';
@@ -38,10 +39,10 @@
disabled = !_skill.write_access;
skill = _skill;
} else {
- goto('/workspace/skills');
+ goto(`${base}/workspace/skills`);
}
} else {
- goto('/workspace/skills');
+ goto(`${base}/workspace/skills`);
}
});
</script>
diff --git a/src/routes/(app)/workspace/tools/create/+page.svelte b/src/routes/(app)/workspace/tools/create/+page.svelte
index a6f3e0344c0942025e83ea26e15b04a3e91e2e5c..ab4ab87a44e982386e9a5c406f7f0020eec365c9 100644
--- a/src/routes/(app)/workspace/tools/create/+page.svelte
+++ b/src/routes/(app)/workspace/tools/create/+page.svelte
@@ -1,4 +1,5 @@
<script>
+ import { base } from '$app/paths';
import { goto } from '$app/navigation';
import { createNewTool, getTools } from '$lib/apis/tools';
import ToolkitEditor from '$lib/components/workspace/Tools/ToolkitEditor.svelte';
@@ -51,7 +52,7 @@
toast.success($i18n.t('Tool created successfully'));
tools.set(await getTools(localStorage.token));
- await goto('/workspace/tools');
+ await goto(`${base}/workspace/tools`);
}
};
diff --git a/src/routes/(app)/workspace/tools/edit/+page.svelte b/src/routes/(app)/workspace/tools/edit/+page.svelte
index 3b0603fa9457d135438928e01ebf17b1947e6c83..ac96b42947062329f80331aa7f8961481e5ea299 100644
--- a/src/routes/(app)/workspace/tools/edit/+page.svelte
+++ b/src/routes/(app)/workspace/tools/edit/+page.svelte
@@ -1,4 +1,5 @@
<script>
+ import { base } from '$app/paths';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { getToolById, getTools, updateToolById } from '$lib/apis/tools';
@@ -51,7 +52,7 @@
toast.success($i18n.t('Tool updated successfully'));
tools.set(await getTools(localStorage.token));
- // await goto('/workspace/tools');
+ // await goto(`${base}/workspace/tools`);
}
return res;
};
@@ -63,13 +64,13 @@
if (id) {
const res = await getToolById(localStorage.token, id).catch((error) => {
toast.error(`${error}`);
- goto('/workspace/tools');
+ goto(`${base}/workspace/tools`);
return null;
});
if (res && !res.write_access) {
toast.error($i18n.t('You do not have permission to edit this tool'));
- goto('/workspace/tools');
+ goto(`${base}/workspace/tools`);
return;
}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index adf8c1763773ee117e73acd09cfebe22fe8e3a22..51c292b2eaef1acb29495a4991caf42161a77715 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -57,6 +57,7 @@
import { getSessionUser, updateUserTimezone, userSignOut } from '$lib/apis/auths';
import { getAllTags } from '$lib/apis/chats';
import { chatCompletion } from '$lib/apis/openai';
+ import { base } from '$app/paths';
import { isTemporaryChatId } from '$lib/utils/chatId';
import {
addOpenAIConnection,
@@ -176,12 +177,18 @@
};
const setupSocket = async (enableWebsocket) => {
- const _socket = io(`${WEBUI_BASE_URL}` || undefined, {
+ // WEBUI_BASE_URL is the SvelteKit base ("/openwebui") under a subpath deploy,
+ // or a full dev URL. socket.io parses the first arg's path as the *namespace*,
+ // so passing the subpath here connects to namespace "/openwebui" (the server
+ // only registers "/" → CONNECT_ERROR "44/openwebui"). Pass the dev origin
+ // as-is, otherwise connect same-origin; the subpath rides only on `path` below.
+ const socketServerUrl = WEBUI_BASE_URL.startsWith('http') ? WEBUI_BASE_URL : undefined;
+ const _socket = io(socketServerUrl, {
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
randomizationFactor: 0.5,
- path: '/ws/socket.io',
+ path: `${base}/ws/socket.io`,
transports: enableWebsocket ? ['websocket'] : ['polling', 'websocket'],
auth: { token: localStorage.token }
});
@@ -630,7 +637,7 @@
toast.custom(NotificationToast, {
componentProps: {
onClick: () => {
- goto('/calendar');
+ goto(`${base}/calendar`);
},
title: data.title,
content: timeStr
@@ -786,7 +793,7 @@
) {
playingNotificationSound.set(true);
- const audio = new Audio(`/audio/notification.mp3`);
+ const audio = new Audio(`${WEBUI_BASE_URL}/audio/notification.mp3`);
audio.play().finally(() => {
// Ensure the global state is reset after the sound finishes
playingNotificationSound.set(false);
@@ -808,7 +815,7 @@
toast.custom(NotificationToast, {
componentProps: {
onClick: () => {
- goto(`/c/${event.chat_id}`);
+ goto(`${base}/c/${event.chat_id}`);
},
content: contentPreview,
title: displayTitle
@@ -923,7 +930,7 @@
componentProps: {
onClick: () => {
goto(
- `/channels/${event.channel_id}${data?.parent_id ? `?thread=${data.parent_id}` : ''}`
+ `${base}/channels/${event.channel_id}${data?.parent_id ? `?thread=${data.parent_id}` : ''}`
);
},
content,
@@ -1030,12 +1037,12 @@
}
if (event.type === 'query' && (event.data?.query || event.data?.files?.length)) {
desktopEvent.set(event);
- await goto('/');
+ await goto(`${base}/`);
return;
}
if (event.type === 'call') {
desktopEvent.set(event);
- await goto('/');
+ await goto(`${base}/`);
return;
}
if (event.type === 'theme:update' && event.data?.theme) {
@@ -1299,7 +1306,7 @@
if (error?.authRedirect) {
// Forward-auth proxy is redirecting to an external login page.
// Full-page navigation lets the browser follow the redirect natively.
- window.location.href = '/';
+ window.location.href = `${base}/`;
return;
}
console.error('Error loading backend config:', error);
@@ -1369,7 +1376,7 @@
}
} else {
// Redirect to /error when Backend Not Detected
- await goto(`/error`);
+ await goto(`${base}/error`);
}
await tick();
@@ -1390,7 +1397,7 @@
document.getElementById('splash-screen')?.remove();
- const audio = new Audio(`/audio/greeting.mp3`);
+ const audio = new Audio(`${WEBUI_BASE_URL}/audio/greeting.mp3`);
const playAudio = () => {
audio.play();
document.removeEventListener('click', playAudio);
@@ -1453,7 +1460,7 @@
rel="search"
type="application/opensearchdescription+xml"
title={$WEBUI_NAME}
- href="/opensearch.xml"
+ href="{base}/opensearch.xml"
crossorigin="use-credentials"
/>
</svelte:head>
diff --git a/src/routes/error/+page.svelte b/src/routes/error/+page.svelte
index a4bb08ed7e0b2aa5f77d5c1ddee7437bf1546d2c..0a693a869df6762670c29b64f1e7b7ef93dfa51b 100644
--- a/src/routes/error/+page.svelte
+++ b/src/routes/error/+page.svelte
@@ -1,5 +1,6 @@
<script>
import { goto } from '$app/navigation';
+ import { base } from '$app/paths';
import { WEBUI_NAME, config } from '$lib/stores';
import { onMount, getContext } from 'svelte';
@@ -9,7 +10,7 @@
onMount(async () => {
if ($config) {
- await goto('/');
+ await goto(`${base}/`);
}
loaded = true;
@@ -47,7 +48,7 @@
<button
class="relative z-20 flex px-5 py-2 rounded-full bg-gray-100 hover:bg-gray-200 transition font-normal text-sm text-black"
on:click={() => {
- location.href = '/';
+ location.href = `${base}/`;
}}
>
{$i18n.t('Check Again')}
diff --git a/src/routes/s/[id]/+page.svelte b/src/routes/s/[id]/+page.svelte
index 72d67183e46a14751f92174d8f8f9374d281cf5d..0510d1586c10f17b9198db0ac44461d81f4d486d 100644
--- a/src/routes/s/[id]/+page.svelte
+++ b/src/routes/s/[id]/+page.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+ import { base } from '$app/paths';
import { onMount, tick, getContext } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
@@ -55,7 +56,7 @@
await tick();
messagesComponent?.scrollToBottom();
} else if (localStorage.token) {
- await goto('/');
+ await goto(`\${base}/`);
} else {
await goto(`/auth?redirect=${encodeURIComponent($page.url.pathname)}`);
}
diff --git a/src/routes/watch/+page.svelte b/src/routes/watch/+page.svelte
index 78eab5be3196feb63b1e44a51c8ade7710ffa510..b727a8c1acea5ec0c5e8434b1cd2954d673d7e12 100644
--- a/src/routes/watch/+page.svelte
+++ b/src/routes/watch/+page.svelte
@@ -1,4 +1,5 @@
<script>
+ import { base } from '$app/paths';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
@@ -13,10 +14,10 @@
// Redirect to root with 'youtube' parameter
- goto(`/?youtube=${encodeURIComponent(videoId)}`);
+ goto(`${base}/?youtube=${encodeURIComponent(videoId)}`);
} else {
// Redirect to root if 'v' parameter doesn't exist
- goto('/');
+ goto(`${base}/`);
}
});
</script>
diff --git a/svelte.config.js b/svelte.config.js
index 80e37b4f59de094dbae5ca155c2622cecf078b45..5a1d4cccca45de2518b480f2eeef67d9c4363adc 100644
--- a/svelte.config.js
+++ b/svelte.config.js
@@ -3,6 +3,11 @@ import { resolveBuildInfo } from './scripts/build-info.js';
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 3780dbe6a9b0561b3e8b8a1bf30ce70c0183d9f6..6fe77cf21da6ecf8acbc7d3d46944f27c35f6d8d 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -7,6 +7,11 @@ import { viteStaticCopy } from 'vite-plugin-static-copy';
const backendTarget = process.env.WEBUI_BACKEND_URL || 'http://localhost:8080';
const buildInfo = resolveBuildInfo();
+// 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({
resolve: {
conditions: ['onnxruntime-web-use-extern-wasm']
@@ -28,6 +33,7 @@ export default defineConfig({
APP_BUILD_HASH: JSON.stringify(buildInfo.hash),
APP_BUILD_CHANNEL: JSON.stringify(buildInfo.channel)
},
+ base: WEBUI_BASE_PATH || '/',
build: {
sourcemap: true
},