Copilot
Bump Open WebUI subpath build to v0.12.0 + fix port-owner classification (#51)
8e35ed1 unverified Download deploy/openwebui-subpath/subpath.patch from SushantGautam/simpleaudit-studio: direct link, hf CLI and curl.
- Browser
- Download file 293 kB
-
https://huggingface.co/spaces/SushantGautam/simpleaudit-studio/resolve/main/deploy/openwebui-subpath/subpath.patch
- Command line
-
hf download hf://spaces/SushantGautam/simpleaudit-studio/deploy/openwebui-subpath/subpath.patch
-
curl -L -o subpath.patch https://huggingface.co/spaces/SushantGautam/simpleaudit-studio/resolve/main/deploy/openwebui-subpath/subpath.patch
293 kB
| diff --git a/.env.example b/.env.example | |
| index 8426e3a23ef45d631c1311a13173b467dba38946..5eeb72463ddf5bd492b334f462820ad9c0933d1c 100644 | |
| --- a/.env.example | |
| +++ b/.env.example | |
| 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 | |
| +# 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| +# 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 | |
| [](https://discord.gg/5rJgQTnV4s) | |
| [](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 | |
| 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 | |
| def serve( | |
| workers=UVICORN_WORKERS, | |
| ws_per_message_deflate=UVICORN_WS_PER_MESSAGE_DEFLATE, | |
| loop=loop, | |
| + root_path=WEBUI_SUBPATH, | |
| ) | |
| 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', | |
| 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 | |
| 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 | |
| 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, | |
| 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, | |
| 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) | |
| 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: | |
| 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 = [] | |
| 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': { | |
| 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': [ | |
| 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'}, | |
| }, | |
| 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 | |
| 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 | |
| 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: | |
| 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_-]. | |
| 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 | |
| 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: | |
| 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, | |
| ) | |
| 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, | |
| ) | |
| 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 | |
| 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 | |
| async def send_get_request( | |
| return None | |
| -async def send_request( | |
| +async def _send_request( | |
| url: str, | |
| method: str = 'POST', | |
| *, | |
| 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 | |
| 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() | |
| ########################################## | |
| 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 | |
| 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 | |
| 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 | |
| +[ | |
| + {"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 | |
| +[ | |
| + {"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 | |
| +[ | |
| + {"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 | |
| +[ | |
| + {"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 | |
| +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 | |
| +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 | |
| +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 | |
| +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 | |
| +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 | |
| +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 | |
| +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 | |
| +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 | |
| +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 | |
| 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 | |
| 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, | |
| 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: | |
| 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': { | |
| async def execute_tool_call(form_data, metadata, event_caller, tool_call): | |
| }, | |
| } | |
| ) | |
| - else: | |
| function = await get_updated_tool_function( | |
| function=tool['callable'], | |
| extra_params={ | |
| 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 | |
| +# 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 | |
| +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 | |
| +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 | |
| +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 | |
| +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 | |
| +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 | |
| +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 | |
| +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 | |
| +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 | |
| +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 | |
| +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 | |
| +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 | |
| +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 | |
| 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 | |
| +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 | |
| +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 | |
| 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 | |
| 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) { | |
| // 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'; | |
| // 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'); | |
| <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 | |
| 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 | |
| <script> | |
| import { getContext } from 'svelte'; | |
| + import { base } from '$app/paths'; | |
| const i18n = getContext('i18n'); | |
| export let show = true; | |
| <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 | |
| <script> | |
| + import { base } from '$app/paths'; | |
| import { onMount, getContext } from 'svelte'; | |
| import { goto } from '$app/navigation'; | |
| import { user } from '$lib/stores'; | |
| 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { onMount, getContext } from 'svelte'; | |
| import { models } from '$lib/stores'; | |
| import { | |
| // 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> | |
| 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 | |
| +<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 | |
| <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'; | |
| > | |
| <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' | |
| <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 | |
| <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'); | |
| <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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { getContext } from 'svelte'; | |
| import { adminLeaderboardCount, models } from '$lib/stores'; | |
| import { getLeaderboard } from '$lib/apis/evaluations'; | |
| // 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 | |
| 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; | |
| }; | |
| 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) => { | |
| id: `${_function.id}_clone`, | |
| name: `${_function.name} (${$i18n.t('Clone')})` | |
| }); | |
| - goto('/admin/functions/create'); | |
| + goto(`${base}/admin/functions/create`); | |
| } | |
| }; | |
| sessionStorage.function = JSON.stringify({ | |
| ...func | |
| }); | |
| - goto('/admin/functions/create'); | |
| + goto(`${base}/admin/functions/create`); | |
| }} | |
| /> | |
| { | |
| id: 'functions-new', | |
| label: $i18n.t('Create'), | |
| - href: '/admin/functions/create' | |
| + href: `${base}/admin/functions/create` | |
| }, | |
| { | |
| id: 'functions-import-link', | |
| {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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { getContext, onMount, tick } from 'svelte'; | |
| import { toast } from 'svelte-sonner'; | |
| import { goto } from '$app/navigation'; | |
| 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { marked } from 'marked'; | |
| import Sortable from 'sortablejs'; | |
| import fileSaver from 'file-saver'; | |
| name: `${model.name} (Clone)` | |
| }); | |
| showSettings.set(false); | |
| - await goto('/workspace/models/create'); | |
| + await goto(`${base}/workspace/models/create`); | |
| }; | |
| const exportModelHandler = async (model) => { | |
| // 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 | |
| <script> | |
| + import { base } from '$app/paths'; | |
| import { getContext, onMount } from 'svelte'; | |
| import { goto } from '$app/navigation'; | |
| onMount(async () => { | |
| if ($user?.role !== 'admin') { | |
| - await goto('/'); | |
| + await goto(`${base}/`); | |
| } | |
| loaded = true; | |
| > | |
| <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' | |
| <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 | |
| </script> | |
| <script> | |
| + import { base } from '$app/paths'; | |
| import { toast } from 'svelte-sonner'; | |
| import { onMount, getContext } from 'svelte'; | |
| import { goto } from '$app/navigation'; | |
| 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 | |
| <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'; | |
| // 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { toast } from 'svelte-sonner'; | |
| import dayjs from 'dayjs'; | |
| import { createEventDispatcher } from 'svelte'; | |
| 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { formatSchedule } from '$lib/utils/schedule'; | |
| import { onMount, getContext } from 'svelte'; | |
| import { toast } from 'svelte-sonner'; | |
| }); | |
| 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { getContext } from 'svelte'; | |
| import { models } from '$lib/stores'; | |
| // 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { toast } from 'svelte-sonner'; | |
| import { onDestroy, onMount, tick } from 'svelte'; | |
| } | |
| } | |
| } 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { getAbortSignal, getContext, onMount } from 'svelte'; | |
| const i18n = getContext('i18n'); | |
| // 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'} | |
| // 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 | |
| <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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { getContext, onMount } from 'svelte'; | |
| const i18n = getContext<typeof import('$lib/i18n').default>('i18n'); | |
| }); | |
| 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { goto } from '$app/navigation'; | |
| import { socket, user } from '$lib/stores'; | |
| 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { v4 as uuidv4 } from 'uuid'; | |
| import { toast } from 'svelte-sonner'; | |
| messageInput?.focus({ preventScroll: true }); | |
| } else if (!embedded) { | |
| - await goto('/'); | |
| + await goto(`${base}/`); | |
| } else { | |
| loading = false; | |
| console.warn('[note-chat] embedded load failed; clearing spinner', { | |
| } | |
| 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(); | |
| } | |
| 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; | |
| error | |
| }); | |
| if (!embedded) { | |
| - await goto('/'); | |
| + await goto(`${base}/`); | |
| } | |
| return null; | |
| }); | |
| }); | |
| 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 | |
| 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(); | |
| 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.')); | |
| 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)); | |
| 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 | |
| <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'; | |
| // 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 | |
| <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'); | |
| 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 | |
| <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'; | |
| 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import Fuse from 'fuse.js'; | |
| import { createEventDispatcher, onDestroy, onMount } from 'svelte'; | |
| // 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 | |
| <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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { getContext } from 'svelte'; | |
| import { embed, showControls, showEmbeds } from '$lib/stores'; | |
| import { isValidHttpUrl } from '$lib/utils'; | |
| // 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import type { Token } from 'marked'; | |
| import { LinkPreview } from 'bits-ui'; | |
| 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { marked } from 'marked'; | |
| import { getContext, tick } from 'svelte'; | |
| // 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 | |
| <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'; | |
| 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 | |
| <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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { getContext } from 'svelte'; | |
| import { toast } from 'svelte-sonner'; | |
| } | |
| 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { toast } from 'svelte-sonner'; | |
| import { marked } from 'marked'; | |
| import DOMPurify from 'dompurify'; | |
| // 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { getContext } from 'svelte'; | |
| import type { Writable } from 'svelte/store'; | |
| <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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { canManageChats } from '$lib/utils/settings-access'; | |
| import fileSaver from 'file-saver'; | |
| const { saveAs } = fileSaver; | |
| }; | |
| const archiveAllChatsHandler = async () => { | |
| - await goto('/'); | |
| + await goto(`${base}/`); | |
| const success = await archiveAllChats(localStorage.token).catch((error) => { | |
| toast.error(`${error}`); | |
| }); | |
| }; | |
| 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 | |
| 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 | |
| <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 | |
| <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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { getContext } from 'svelte'; | |
| import dayjs from 'dayjs'; | |
| import calendar from 'dayjs/plugin/calendar'; | |
| {/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 | |
| 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 || '')); | |
| 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { toast } from 'svelte-sonner'; | |
| import { getContext } from 'svelte'; | |
| {/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"> | |
| '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 | |
| <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'; | |
| <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')} | |
| 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 | |
| <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'); | |
| chatList = chatList?.filter((c) => c.id !== id) ?? null; | |
| if ($currentChatId === id) { | |
| - await goto('/'); | |
| + await goto(`\${base}/`); | |
| currentChatId.set(''); | |
| } | |
| 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 | |
| import Sortable from 'sortablejs'; | |
| import { goto } from '$app/navigation'; | |
| + import { base } from '$app/paths'; | |
| import { page } from '$app/stores'; | |
| import { | |
| user, | |
| 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) => { | |
| await initChannels(); | |
| showCreateChannel = false; | |
| showChannels = true; | |
| - goto(`/channels/${res.id}`); | |
| + goto(`${base}/channels/${res.id}`); | |
| } | |
| }} | |
| /> | |
| id="sidebar-new-chat-button" | |
| class="hidden" | |
| on:click={() => { | |
| - goto('/'); | |
| + goto(`${base}/`); | |
| newChatHandler(); | |
| }} | |
| /> | |
| <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')} | |
| <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" | |
| > | |
| <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} | |
| > | |
| /> | |
| </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. --> | |
| <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')} | |
| ? '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)} | |
| 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { toast } from 'svelte-sonner'; | |
| import { onMount, getContext, tick, onDestroy } from 'svelte'; | |
| const i18n = getContext('i18n'); | |
| > | |
| <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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { getContext, createEventDispatcher, onMount } from 'svelte'; | |
| const i18n = getContext('i18n'); | |
| 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 | |
| </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'; | |
| if ($mobile) { | |
| event?.preventDefault(); | |
| - void goto(`/c/${id}`); | |
| + void goto(`${base}/c/${id}`); | |
| showSidebar.set(false); | |
| } | |
| }; | |
| }); | |
| if (res) { | |
| - goto(`/c/${res.id}`); | |
| + goto(`${base}/c/${res.id}`); | |
| await refreshSidebar(localStorage.token); | |
| } | |
| if (res) { | |
| tags.set(await getAllTags(localStorage.token)); | |
| if ($chatId === id) { | |
| - await goto('/'); | |
| + await goto(`${base}/`); | |
| await chatId.set(''); | |
| await tick(); | |
| await archiveChatById(localStorage.token, id); | |
| if ($chatId === id) { | |
| - await goto('/'); | |
| + await goto(`${base}/`); | |
| chatId.set(''); | |
| } | |
| <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" | |
| <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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { getContext } from 'svelte'; | |
| const i18n = getContext('i18n'); | |
| > | |
| <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" | |
| > | |
| // 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 | |
| <script> | |
| + import { base } from '$app/paths'; | |
| import { getContext, createEventDispatcher, onMount, onDestroy, tick } from 'svelte'; | |
| const i18n = getContext('i18n'); | |
| 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 | |
| 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'; | |
| 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); | |
| 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); | |
| 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); | |
| 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); | |
| 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); | |
| {#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) => { | |
| } | |
| 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 | |
| <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'; | |
| $socket?.off('events:note', noteEventHandler); | |
| $socket?.on('events:note', noteEventHandler); | |
| } else { | |
| - goto('/'); | |
| + goto(`${base}/`); | |
| return; | |
| } | |
| ${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 | |
| 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, | |
| ); | |
| if (res) { | |
| - goto(`/notes/${res.id}`); | |
| + goto(`${base}/notes/${res.id}`); | |
| } | |
| } | |
| }, | |
| 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"> | |
| 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" | |
| {/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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import fileSaver from 'file-saver'; | |
| const { saveAs } = fileSaver; | |
| 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { toast } from 'svelte-sonner'; | |
| import { goto } from '$app/navigation'; | |
| 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { toast } from 'svelte-sonner'; | |
| import { onMount, getContext } from 'svelte'; | |
| import { goto } from '$app/navigation'; | |
| 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 | |
| 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'; | |
| 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 | |
| import { toast } from 'svelte-sonner'; | |
| import { goto } from '$app/navigation'; | |
| + import { base } from '$app/paths'; | |
| import { getContext } from 'svelte'; | |
| const i18n = getContext('i18n'); | |
| 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { toast } from 'svelte-sonner'; | |
| import { v4 as uuidv4 } from 'uuid'; | |
| knowledge.access_grants = []; | |
| } | |
| } else { | |
| - goto('/workspace/knowledge'); | |
| + goto(`\${base}/workspace/knowledge`); | |
| } | |
| const dropZone = document.querySelector('body'); | |
| <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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import WorkspaceAccessModal from './common/WorkspaceAccessModal.svelte'; | |
| let accessModal: WorkspaceAccessModal; | |
| import dayjs from 'dayjs'; | |
| 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 | |
| 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'; | |
| }; | |
| const openPrompt = (prompt) => { | |
| - goto(`/workspace/prompts/${prompt.id}`); | |
| + goto(`${base}/workspace/prompts/${prompt.id}`); | |
| }; | |
| const shouldIgnoreRowClick = (target: EventTarget | null) => { | |
| 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 | |
| <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'; | |
| <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 | |
| <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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { onMount, tick, getContext } from 'svelte'; | |
| import { toast } from 'svelte-sonner'; | |
| 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 | |
| } from '$lib/stores'; | |
| import { goto } from '$app/navigation'; | |
| + import { base } from '$app/paths'; | |
| import { | |
| createNewTool, | |
| loadToolByUrl, | |
| { | |
| id: 'tools-new', | |
| label: $i18n.t('Create'), | |
| - href: '/workspace/tools/create' | |
| + href: `${base}/workspace/tools/create` | |
| }, | |
| { | |
| id: 'tools-import-link', | |
| }; | |
| 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) => { | |
| id: `${_tool.id}_clone`, | |
| name: `${_tool.name} (Clone)` | |
| }); | |
| - goto('/workspace/tools/create'); | |
| + goto(`${base}/workspace/tools/create`); | |
| } | |
| }; | |
| sessionStorage.tool = JSON.stringify({ | |
| ...tool | |
| }); | |
| - goto('/workspace/tools/create'); | |
| + goto(`${base}/workspace/tools/create`); | |
| }} | |
| loadUrlHandler={async (url) => { | |
| return await loadToolByUrl(localStorage.token, url); | |
| 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { toast } from 'svelte-sonner'; | |
| import { getContext, onMount, tick } from 'svelte'; | |
| 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 | |
| +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, | |
| // 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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| "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 | |
| +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 | |
| +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 | |
| 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; | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { toast } from 'svelte-sonner'; | |
| import { onMount, tick, getContext } from 'svelte'; | |
| 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) => { | |
| const nextChat = currentIndex === -1 ? $chats[0] : $chats[currentIndex + direction]; | |
| if (nextChat) { | |
| - await goto(`/c/${nextChat.id}`); | |
| + await goto(`${base}/c/${nextChat.id}`); | |
| } | |
| }; | |
| } 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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { onMount, getContext } from 'svelte'; | |
| import { goto } from '$app/navigation'; | |
| 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; | |
| }); | |
| 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 | |
| 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} | |
| 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} | |
| 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 | |
| <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 | |
| <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 | |
| <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 | |
| <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 | |
| <script> | |
| + import { base } from '$app/paths'; | |
| import { toast } from 'svelte-sonner'; | |
| import { onMount, getContext } from 'svelte'; | |
| import { goto } from '$app/navigation'; | |
| ) | |
| ); | |
| - 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 | |
| <script> | |
| + import { base } from '$app/paths'; | |
| import { toast } from 'svelte-sonner'; | |
| import { onMount, getContext } from 'svelte'; | |
| 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 | |
| <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 | |
| <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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { formatSchedule } from '$lib/utils/schedule'; | |
| import { onMount, onDestroy, getContext } from 'svelte'; | |
| !($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 | |
| <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'; | |
| !($config?.features as any)?.enable_automations || | |
| ($user?.role !== 'admin' && !($user?.permissions?.features?.automations ?? false)) | |
| ) { | |
| - goto('/'); | |
| + goto(`${base}/`); | |
| return; | |
| } | |
| 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 | |
| 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, | |
| 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 | |
| <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'; | |
| 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 | |
| <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'; | |
| ) | |
| ) { | |
| // 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 | |
| <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 | |
| <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 | |
| <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'; | |
| ) | |
| ? '' | |
| : '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 | |
| 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 | |
| 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 | |
| <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'; | |
| 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); | |
| 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 }); | |
| } | |
| } | |
| '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"> | |
| '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"> | |
| '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"> | |
| '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"> | |
| '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 | |
| <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 | |
| <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 | |
| <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 | |
| <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'; | |
| ) | |
| ); | |
| toast.success($i18n.t('Model created successfully!')); | |
| - await goto('/workspace/models'); | |
| + await goto(`${base}/workspace/models`); | |
| } catch (error: any) { | |
| toast.error(`${error?.message ?? error}`); | |
| } | |
| {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 | |
| <script> | |
| + import { base } from '$app/paths'; | |
| import { toast } from 'svelte-sonner'; | |
| import { goto } from '$app/navigation'; | |
| }); | |
| 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`); | |
| } | |
| }); | |
| ) | |
| ); | |
| toast.success($i18n.t('Model updated successfully')); | |
| - await goto('/workspace/models'); | |
| + await goto(`${base}/workspace/models`); | |
| } catch (error) { | |
| toast.error(`${error}`); | |
| } | |
| {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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { toast } from 'svelte-sonner'; | |
| import { goto } from '$app/navigation'; | |
| import { onMount, getContext } from 'svelte'; | |
| 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 | |
| <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 | |
| <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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { toast } from 'svelte-sonner'; | |
| import { goto } from '$app/navigation'; | |
| import { skills } from '$lib/stores'; | |
| 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 | |
| <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'; | |
| 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 | |
| <script> | |
| + import { base } from '$app/paths'; | |
| import { goto } from '$app/navigation'; | |
| import { page } from '$app/stores'; | |
| import { getToolById, getTools, updateToolById } from '$lib/apis/tools'; | |
| toast.success($i18n.t('Tool updated successfully')); | |
| tools.set(await getTools(localStorage.token)); | |
| - // await goto('/workspace/tools'); | |
| + // await goto(`${base}/workspace/tools`); | |
| } | |
| return res; | |
| }; | |
| 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 | |
| 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, | |
| }; | |
| 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 } | |
| }); | |
| toast.custom(NotificationToast, { | |
| componentProps: { | |
| onClick: () => { | |
| - goto('/calendar'); | |
| + goto(`${base}/calendar`); | |
| }, | |
| title: data.title, | |
| content: timeStr | |
| ) { | |
| 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); | |
| toast.custom(NotificationToast, { | |
| componentProps: { | |
| onClick: () => { | |
| - goto(`/c/${event.chat_id}`); | |
| + goto(`${base}/c/${event.chat_id}`); | |
| }, | |
| content: contentPreview, | |
| title: displayTitle | |
| 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, | |
| } | |
| 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) { | |
| 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); | |
| } | |
| } else { | |
| // Redirect to /error when Backend Not Detected | |
| - await goto(`/error`); | |
| + await goto(`${base}/error`); | |
| } | |
| await tick(); | |
| 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); | |
| 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 | |
| <script> | |
| import { goto } from '$app/navigation'; | |
| + import { base } from '$app/paths'; | |
| import { WEBUI_NAME, config } from '$lib/stores'; | |
| import { onMount, getContext } from 'svelte'; | |
| onMount(async () => { | |
| if ($config) { | |
| - await goto('/'); | |
| + await goto(`${base}/`); | |
| } | |
| loaded = true; | |
| <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 | |
| <script lang="ts"> | |
| + import { base } from '$app/paths'; | |
| import { onMount, tick, getContext } from 'svelte'; | |
| import { goto } from '$app/navigation'; | |
| import { page } from '$app/stores'; | |
| 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 | |
| <script> | |
| + import { base } from '$app/paths'; | |
| import { onMount } from 'svelte'; | |
| import { goto } from '$app/navigation'; | |
| // 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 | |
| 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 | |
| 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 | |
| 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'] | |
| export default defineConfig({ | |
| APP_BUILD_HASH: JSON.stringify(buildInfo.hash), | |
| APP_BUILD_CHANNEL: JSON.stringify(buildInfo.channel) | |
| }, | |
| + base: WEBUI_BASE_PATH || '/', | |
| build: { | |
| sourcemap: true | |
| }, | |