docs/UserDefinedUI.md
UI_TEMPLATES_DIR)LightRAG can serve your own welcome page, login-page text, user agreement, query empty state, copyright line and brand logo — in several languages — without rebuilding the WebUI. You write a small directory of Markdown files plus a manifest.json, point the UI_TEMPLATES_DIR environment variable at it, and restart the server.
This document is the complete guide: what can be customized, the exact bundle format, how to deploy it from source / Docker / Kubernetes, how to verify it, and what every startup error means.
A ready-to-copy bundle lives in docs/ui_templates_example/.
| Surface | Where the visitor sees it | Bundle field |
|---|---|---|
| Welcome page | /workspace entry, before sign-in (/workspace/#/welcome) | welcome (required) |
| Query empty state | /workspace query page, while the conversation is empty (and again after Clear) | query_empty (required) |
| Login page text | Above the username/password form, on both entries (/webui/#/login and /workspace/#/login) | login (optional) |
| User agreement + consent checkbox | A checkbox on the login page, whose link opens the document in a dialog; the WebUI's Login button stays disabled until it is ticked — a WebUI prompt, not server-side enforcement (§6) | agreements (optional, pairs with login) |
| Consent checkbox link text | The words the checkbox links — "I agree to …" | consent_documents (optional; falls back to the WebUI's generic "Privacy Policy Agreement") |
| Brand logo (PNG / JPEG / WebP / SVG — see §4.4) | Welcome page, query empty state and login page | brand.logo, per-locale logo, logo_alt |
| Copyright line | Foot of the welcome and login pages — outside the card, at the bottom edge of the page | brand.copyright, per-locale copyright (optional) |
What you cannot set from the bundle:
WEBUI_TITLE supplies both the browser tab title and login heading (falling back to LightRAG), while the subtitle remains the localized login.description in LoginPage.tsx.SiteHeader on /webui, the workspace header on /workspace). That one is set by the WEBUI_TITLE / WEBUI_DESCRIPTION environment variables, and the manifest deliberately cannot override them.All-or-nothing per locale. A visitor either sees your bundle's representation of a locale entirely, or LightRAG's built-in branding entirely. Fields are never mixed one by one, so you cannot supply only a logo and inherit LightRAG's welcome text.
# The conventional location for a source checkout; it is git-ignored.
cp -r docs/ui_templates_example lightrag_webui/ui_templates
# Edit the texts and drop in your own logo (PNG / JPEG / WebP / SVG all work, see §4.4)
$EDITOR lightrag_webui/ui_templates/locales/en/welcome.md
cp /path/to/your-logo.svg lightrag_webui/ui_templates/assets/logo.svg
Then add to .env:
UI_TEMPLATES_DIR=./lightrag_webui/ui_templates
and start the server:
lightrag-server
The startup log tells you which state you are in:
INFO: UI customization: bundle 1f0c… ['en', 'zh', 'zh-TW']
The bundled compose files already mount the bundle directory read-only and point UI_TEMPLATES_DIR at it:
volumes:
- ./data/ui_templates:/app/data/ui_templates:ro
environment:
UI_TEMPLATES_DIR: "/app/data/ui_templates"
So writing the bundle is the whole procedure — there is no compose file to edit:
mkdir -p ./data/ui_templates
cp -r docs/ui_templates_example/* ./data/ui_templates/
docker compose up -d --force-recreate lightrag
Until you do, the directory holds no manifest.json, the server serves its built-in branding and says so once at startup. Nothing about the default deployment changes.
See §7 Deployment for the full picture, including the wizard-generated docker-compose.final.yml and Kubernetes.
ui_templates/
├── manifest.json # the only index — nothing else is discovered
├── assets/
│ └── logo.svg # PNG / JPEG / WebP / SVG
└── locales/
├── en/
│ ├── welcome.md # required
│ ├── query_empty.md # required
│ ├── login.md # optional
│ └── agreements.md # optional
├── zh/
│ └── …
└── zh-TW/
└── …
The directory names above are a convention, not a rule: every file is located through manifest.json, and a file the manifest does not reference is never read and never served. Paths in the manifest are relative to the bundle root; absolute paths, .. segments and symlinks pointing outside the bundle are all rejected at startup.
manifest.json reference{
"schema_version": 1,
"default_locale": "en",
"fallbacks": {
"ko": ["en"],
"ja": ["en"]
},
"brand": {
"logo": "assets/logo.svg",
"copyright": "© 2025 Example Corp. All rights reserved."
},
"locales": {
"en": {
"welcome": "locales/en/welcome.md",
"query_empty": "locales/en/query_empty.md",
"login": "locales/en/login.md",
"agreements": "locales/en/agreements.md",
"consent_documents": "Privacy Policy and Model Service Agreement",
"logo_alt": "Example Corp"
},
"zh": {
"welcome": "locales/zh/welcome.md",
"query_empty": "locales/zh/query_empty.md",
"login": "locales/zh/login.md",
"agreements": "locales/zh/agreements.md",
"consent_documents": "《用户隐私协议》和《模型服务协议》",
"logo_alt": "示例公司"
}
}
}
JSON has no comments — the
jsoncabove is for reading only. Keep your real manifest as plain JSON.
| Field | Required | Type | Notes |
|---|---|---|---|
schema_version | yes | number | Must be 1. |
default_locale | yes | string | Must be one of the declared locales keys. Used when a visitor's locale matches nothing. |
brand | yes | object | Two keys: logo (required) and copyright (optional). |
brand.logo | yes | string | null | Path to the default logo, or an explicit null for "this deployment shows no logo". Omitting the key fails startup — a missing logo must never silently fall back to the LightRAG logo underneath your texts. PNG, JPEG, WebP and SVG are all accepted — detected from the file's bytes, not its extension, see §4.4. |
brand.copyright | no | string | null | The copyright line for every locale, as plain text written in the manifest (not a Markdown file). Omitted, null, empty or whitespace-only all mean the same thing: no line is shown. There is no LightRAG default to inherit — see §4.5. |
locales | yes | object | Non-empty map of locale → entry. |
fallbacks | no | object | null | Maps uncovered locales onto declared ones. See §5.1. |
Unknown top-level fields are an error, so a typo (defaultLocale) is reported at startup rather than silently ignored.
| Field | Required | Type | Notes |
|---|---|---|---|
welcome | yes | string | Path to the welcome-page Markdown. |
query_empty | yes | string | Path to the query-empty-state Markdown. |
logo_alt | yes | string | Non-empty alt text for the logo, in this locale's language. |
logo | no | string | null | Overrides brand.logo for this locale (null = no logo here). When the key is absent, the locale inherits brand.logo. |
login | no | string | null | Path to the login-page blurb. |
agreements | no | string | null | Path to the single user-agreement document. Absent or null means this locale has no agreement document: the gate is off and the login page shows no checkbox (see §6). There is no discovery by conventional filename — an agreements.md that happens to sit in the bundle is never read unless the manifest references it (see §3). |
consent_documents | no | string | null | Inline text, not a path: how the consent checkbox names the document it links to, in this locale's language. Declared-but-blank fails startup. When absent or null, the WebUI's own translation is used. |
copyright | no | string | null | Overrides brand.copyright for this locale (null = no line here). When the key is absent, the locale inherits brand.copyright. |
login and agreements together switch on the login consent gate — see §6. consent_documents only labels that gate; it never turns it on.
Keys are BCP 47 tags in hyphen form, written exactly in their normalized shape:
zh, en, ar);Hant, Arab);TW, CN);zh_TW (underscore) is rejected — write zh-TW;zh-tw is rejected too: the key must already be normalized (zh-TW).What the server applies is a shape and case check, not full BCP 47 validation. It is worth being precise about, because the two differ in both directions:
| Rule | Accepted | Rejected |
|---|---|---|
| Primary subtag is 2–8 letters | en, zh, art-lojban | x-acme, i-klingon — a single-letter first subtag |
| Every later subtag is 1–8 alphanumerics | zh-Hant-TW, de-CH-1901, sl-rozaj-biske | abcdefghi |
| The whole tag is ≤ 35 characters | anything longer |
Script, region, variant and extension subtags all work — zh-Hant-TW, ar-aao-Latn, en-US-u-VA-posix.
The exclusion is precisely a first subtag of one letter, which is what rules out private-use tags (x-acme) and the irregular grandfathered ones (i-klingon). It is not "grandfathered tags" as a class: the regular grandfathered tags begin with a normal language subtag and are accepted — art-lojban, en-GB-oed and zh-min-nan all pass.
In the other direction, the check is looser than BCP 47: en-u is accepted even though a well-formed tag must follow an extension singleton with at least one subtag. Nothing depends on that being caught — an unknown locale simply falls back — so treat the table as the contract and BCP 47 as the convention the table approximates.
Note that normalization is positional and applies to every subtag, including an extension's own values: en-US-u-va-posix normalizes to en-US-u-VA-posix, and since the key must already be normalized, that is the form to write.
Read §5.3 before declaring a locale outside the WebUI's own languages.
| Rule | Value |
|---|---|
| Markdown template size | ≤ 64 KiB per file |
manifest.json size | ≤ 64 KiB |
| Logo size | ≤ 2 MiB per file |
| Template encoding | UTF-8 (invalid UTF-8 fails startup) |
| Logo formats | PNG, JPEG, WebP, SVG — detected from the file's bytes, not its extension |
For SVG, the file must actually open an <svg> root element (an XML declaration, comments, a DOCTYPE or processing instructions may precede it). A namespace-prefixed root (<s:svg>) or a UTF-16/32-encoded file is not recognized — no SVG tool emits those.
The copyright line is your deployment's own legal statement, so LightRAG never writes one for you: without a bundle — or with a bundle that says nothing about copyright — the pages show no copyright line at all, and LightRAG's own notice is never printed on your pages.
Where it appears: at the bottom edge of the welcome page and the login page, outside the card, in small muted type. It is pre-login content, like everything else in the bundle; the in-app pages after sign-in do not show it.
How to declare it:
{
"brand": {
"logo": "assets/logo.svg",
"copyright": "© 2025 Example Corp. All rights reserved." // every locale
},
"locales": {
"zh": {
"copyright": "© 2025 示例公司 版权所有" // this locale instead
},
"en": {
"copyright": null // no line for en
}
}
}
The rules, in short:
manifest.json; there is no template file to point at, and no Markdown is rendered — a footer is not a place for headings, images or links.logo: key absent → inherit brand.copyright; a string → use it; null → no line for this locale."" or whitespace is treated exactly like omitting the field: no line. Unlike a blank login / agreements file, it does not fail startup — blankness here only turns something off, so there is nothing to be silently mis-shown.ko to zh sees the zh line (see §5.1).The WebUI asks for one locale: the interface language it resolved for this visitor (explicit setting in the UI > browser language > en). The server then resolves it in a single hop:
fallbacks.<requested>.default_locale.Every fallbacks target must be a declared locale, which is what makes resolution single-hop and cycles impossible. The source side may be any locale — pointing uncovered languages somewhere sensible is the entire purpose of the map:
"fallbacks": {
"ko": ["en"],
"ja": ["en"],
"de": ["en"]
}
Without an entry, an uncovered locale simply lands on default_locale, so fallbacks only matters when different uncovered languages should land in different places (e.g. zh-HK → zh-TW, everything else → en).
Direction is derived from the resolved locale against a CLDR-derived registry and sent to the browser; the bundle cannot set it. An explicit script subtag wins over the language, which is the escape hatch when the default is wrong:
ar, he, fa, ur, ps, ckb, dv… → right-to-left;ku → left-to-right, but ku-Arab → right-to-left;az-Arab, pa-Arab, ha-Arab likewise.The bundle's set of languages and the WebUI's are independent. The WebUI ships interface translations (buttons, settings, login labels, the consent checkbox wording) for:
en, zh, zh-TW, fr, ar, ru, ja, de, uk, ko, vi
A bundle may declare a locale outside that list — say nl. Its content will render correctly, direction included, but the controls around it stay in the visitor's resolved interface language, because no Dutch interface translation exists to switch to. Startup logs a warning naming such locales:
WARNING: UI customization: the WebUI ships no interface translation for ['nl'] …
Declare a locale from the supported list whenever you want the whole page in one language.
When a locale declares both login and agreements, the login page for that locale shows:
login Markdown above the form, andconsent_documents, or by the WebUI's own translation when you declare none — opens your agreements document in a dialog.The Login button stays disabled until the box is ticked, and pressing Enter in the form is refused the same way.
What this gate is, precisely — read before relying on it. It is a WebUI control, not server-side enforcement. The server computes
consent_requiredand the WebUI obeys it, butPOST /logintakes only the standard credential fields: it neither requires nor records acceptance, and stores nothing about which revision of the document a user agreed to. A client posting credentials straight to/login— curl, a script, the Ollama-compatible API, another frontend — receives a token without ever seeing the checkbox.So treat it as an informed-consent prompt for people using the WebUI, not as an access control, and do not treat it as evidence that a particular user accepted a particular revision. If your deployment needs enforceable, auditable acceptance, it has to be built server-side; this feature does not provide it.
The checkbox carries exactly one link, so everything the visitor has to agree to goes into that single agreements.md, separated by headings:
# Privacy Policy and Model Service Agreement
## Privacy Policy
…
## Model Service Agreement
…
A merged document must name itself. The WebUI's fallback link text is the generic "Privacy Policy Agreement". The moment your file covers anything beyond a privacy policy — a model service agreement, terms of service, an acceptable-use policy — that fallback understates what the visitor is ticking, so set
consent_documentsto the real name.This includes bundles written before the field existed: they keep loading unchanged, but their checkbox now reads "Privacy Policy Agreement". If your
agreements.mdmerges several documents, addconsent_documentswhen you upgrade.
consent_documents is the text the checkbox turns into the link — set it to whatever your deployment actually calls the document:
"consent_documents": "Example Corp Terms of Service"
The sentence around it ("I agree to …") still comes from the WebUI's own translations, so it reads naturally in each interface language; only the document's name is yours. Leave the field out and that translation names the link too — the generic "Privacy Policy Agreement" (《隐私政策协议》 in Chinese, and so on), which fits a file that really is just a privacy policy and understates every file that is not (see the note above).
The dialog renders agreements.md as written — no title is printed above it. So give the file its own heading; that heading is the document's title on screen:
# Privacy Policy and Model Service Agreement
## Privacy Policy
…
Nothing reads the document — the dialog renders it. Its name for screen readers is the checkbox's own link text (consent_documents, or the WebUI's fallback), which is also what the visitor just ticked. Keeping that name consistent with the file's heading, and with what the file actually contains, is yours to maintain.
Headings, paragraphs, lists, tables, block quotes, code blocks, horizontal rules and links all render with the standard document typography. Raw HTML inside the Markdown is dropped, as in every bundle template — see §10.
login gives a branded login page with no gate; declaring only agreements gives a document nothing links to. Neither turns the gate on — a half-configuration is treated as a half-configuration, not as consent.fallbacks.consent_documents.consent_documents on its own never switches the gate on, and a locale that declares the pair without it still gets a working checkbox — labelled by the WebUI translation.POST /login has no consent field, so the gate constrains the WebUI's login form and nothing else.AUTH_ACCOUNTS unset) admits visitors as guests without the gate. That is deliberate rather than a gap: with no authentication there is no identified user to bind an agreement to, and auth-disabled is a development/demo posture. If the agreement must be accepted, configure AUTH_ACCOUNTS (with TOKEN_SECRET).| Deployment | Bundle directory | UI_TEMPLATES_DIR |
|---|---|---|
| From source | lightrag_webui/ui_templates/ (git-ignored) | ./lightrag_webui/ui_templates |
| Docker / Compose | host ./data/ui_templates/ → container /app/data/ui_templates | /app/data/ui_templates |
| Kubernetes | ConfigMap or PVC mounted at /app/data/ui_templates | /app/data/ui_templates |
Relative paths are resolved against the server's working directory, so an absolute path is the safer choice whenever you are not sure where the process starts.
docker-compose.yml and docker-compose-full.yml ship both halves already:
services:
lightrag:
volumes:
- ./data/ui_templates:/app/data/ui_templates:ro
environment:
UI_TEMPLATES_DIR: "/app/data/ui_templates"
Both are inert until you write a bundle. A configured directory that holds no manifest.json is an unpopulated mount point, not a broken bundle: the server logs a warning naming the directory and serves its built-in branding. So the default deployment starts normally, and the whole activation procedure is dropping a bundle into ./data/ui_templates and restarting — no compose edit, ever.
The leniency stops at the manifest's edge. Once manifest.json exists, the bundle is validated in full and any problem in it refuses startup (§9) — a half-copied bundle never quietly serves LightRAG content.
Three practical notes:
up. If Docker creates it for you it will be owned by root, and you will need sudo to copy files into it."customized": false on /ui/customization are how you tell that state from "I have not written the bundle yet"; both name what the server actually saw.:ro is intentional — the server only ever reads the bundle.docker-compose.podman.yml keeps the mount and UI_TEMPLATES_DIR commented out, because Podman is stricter than Docker about a bind mount whose host source is missing — an unconditional mount would turn the feature into a startup prerequisite. There, mkdir -p ./data/ui_templates first, then uncomment both.The compose entry outranks .env on purpose. A compose environment: entry wins over the same key in the mounted .env, and UI_TEMPLATES_DIR uses that deliberately — exactly like WORKING_DIR, INPUT_DIR and PROMPT_DIR. The value is a container path, so keeping it out of .env is what lets one .env, holding host paths such as ./lightrag_webui/ui_templates, serve a source run and this deployment at the same time. Setting UI_TEMPLATES_DIR in .env therefore affects the source run only; the container uses the compose value.
To point the container at a different bundle, edit the compose entry (the wizard preserves it — see §7.3) or change the mount's host side.
make env-base / make env-storage / make env-server generate docker-compose.final.yml. The generator now adds the same read-only mount if it is not already present, so regenerating an existing file picks it up:
make env-server # or any other make env-* target
grep ui_templates docker-compose.final.yml
The wizard also seeds UI_TEMPLATES_DIR: "/app/data/ui_templates" into the lightrag service's environment: block, so a wizard-generated deployment behaves exactly like the shipped compose files: inert until ./data/ui_templates holds a bundle.
Seeded, not managed — the wizard never changes a value you set. WORKING_DIR / INPUT_DIR / PROMPT_DIR are rewritten on every run, so a hand-edited value there does not survive. UI_TEMPLATES_DIR is written only when the compose file does not already declare the key:
docker-compose.final.yml survives every regeneration — including UI_TEMPLATES_DIR: "", which is how a deployment turns the feature off, and - UI_TEMPLATES_DIR in a list-style block../my-branding:/app/data/ui_templates:ro)..env, which is what keeps a host-path UI_TEMPLATES_DIR in .env usable for source runs (§7.2).User-added bind mounts and other user-added environment keys are preserved across regenerations as before.
The bundled Helm chart has no dedicated value for this yet, so mount the bundle by patching the deployment.
A ConfigMap has no directories. Its keys are flat, and every key becomes a file directly under the mount — --from-file=<dir> packages that directory's files under their bare basenames and skips subdirectories entirely. So a bundle delivered as a ConfigMap must be flat, with a manifest whose paths are exactly those keys. Copying the nested example layout as-is fails startup with 'locales/en/welcome.md' does not exist or is not a file.
Write a flat manifest for this deployment:
{
"schema_version": 1,
"default_locale": "en",
"brand": { "logo": "logo.svg" },
"locales": {
"en": {
"welcome": "welcome.en.md",
"query_empty": "query_empty.en.md",
"login": "login.en.md",
"agreements": "agreements.en.md",
"logo_alt": "Example Corp"
}
}
}
and name every referenced file explicitly, the logo included — the key=path form does the flattening, so your source tree can stay nested. A file the manifest references but the ConfigMap omits fails startup:
kubectl create configmap lightrag-ui-templates \
--from-file=manifest.json=./k8s/ui-manifest.json \
--from-file=welcome.en.md=./ui_templates/locales/en/welcome.md \
--from-file=query_empty.en.md=./ui_templates/locales/en/query_empty.md \
--from-file=login.en.md=./ui_templates/locales/en/login.md \
--from-file=agreements.en.md=./ui_templates/locales/en/agreements.md \
--from-file=logo.svg=./ui_templates/assets/logo.svg \
--dry-run=client -o yaml | kubectl apply -f -
kubectl places a non-UTF-8 logo (PNG, JPEG, WebP) into the ConfigMap's binaryData automatically. Note that a ConfigMap is capped at ~1 MiB in total, well below this feature's 2 MiB per-logo limit: a bundle with a large logo needs a PVC instead, which also lets you keep the nested layout.
Then add to the container:
volumeMounts:
- name: ui-templates
mountPath: /app/data/ui_templates
readOnly: true
env:
- name: UI_TEMPLATES_DIR
value: /app/data/ui_templates
volumes:
- name: ui-templates
configMap:
name: lightrag-ui-templates
Keep mountPath equal to UI_TEMPLATES_DIR. The projected volume's internal ..data symlinks stay inside the mount, so the bundle's containment checks are satisfied — a flat ConfigMap mount loads exactly like a directory on disk. Updating the ConfigMap does not reload the bundle; restart the pods (§7.5).
There is no hot reload. The whole bundle is validated once at startup and activated as an immutable in-memory snapshot; request handling never touches the disk again. After editing the bundle, restart the server — and with lightrag-gunicorn or any multi-worker setup, restart all workers, or some of them will keep serving the previous revision.
No cache purge is needed: the content response is sent Cache-Control: no-store, and logo URLs embed the file's content hash, so changed bytes produce a new URL.
Startup log. One of these lines always appears:
INFO: UI customization: no bundle configured (UI_TEMPLATES_DIR unset)
WARNING: UI customization: UI_TEMPLATES_DIR=/app/data/ui_templates holds no manifest.json — serving the built-in LightRAG branding. …
INFO: UI customization: bundle <sha256> ['en', 'zh']
The middle line is the Docker default state: the variable is set by the shipped compose files, and the mounted directory is still empty. It is a warning rather than an info line because the same state is what a mount pointing at the wrong host directory produces — it names the directory the server actually read so you can tell the two apart.
bundle_revision is a hash over every referenced file. If it does not change after you edited a file, the server is not reading the directory you think it is — or it was not actually restarted.
The endpoint. It is public (the welcome page is shown before login), so a plain curl works:
curl -s 'http://localhost:9621/ui/customization?locale=zh' | jq
{
"customized": true,
"requested_locale": "zh",
"locale": "zh",
"fallback_used": false,
"direction": "ltr",
"brand": {
"title": "My Graph KB",
"description": "Simple and Fast Graph Based RAG System",
"logo_url": "/ui/customization/assets/9f2a…/brand-logo",
"logo_alt": "示例公司",
"copyright": "© 2025 示例公司 版权所有"
},
"welcome": { "format": "markdown", "content": "## 欢迎…" },
"query_empty": { "format": "markdown", "content": "…" },
"login": { "format": "markdown", "content": "…" },
"agreements": { "format": "markdown", "content": "…" },
"consent_documents": "《用户隐私协议》和《模型服务协议》",
"consent_required": true
}
Useful checks:
"customized": false → no bundle is active (UI_TEMPLATES_DIR unset, or pointed at a directory with no manifest.json). The frontend is showing LightRAG's built-in branding."fallback_used": true → the requested locale is not declared; locale tells you where it landed."consent_required" → whether the checkbox will appear for this locale."consent_documents": null → this locale declares no link text; the WebUI names the link from its own translation.logo_url: null → this locale resolves to no logo (an explicit null somewhere), not to the LightRAG logo.brand.copyright: null → this locale shows no copyright line. On a "customized": false response the key is absent entirely, which means the same thing: nothing to render.In the browser. Visit /workspace for the welcome page, /workspace/#/login or /webui/#/login for the login page, and switch the interface language from the settings menu to check each locale.
Once the configured directory contains a manifest.json, anything invalid in the bundle makes the server refuse to start with a message beginning UI_TEMPLATES_DIR bundle invalid:. That is deliberate: silently falling back to LightRAG content would leave you believing customer branding is live when it is not.
A configured directory without manifest.json is the one exception — the unpopulated mount every default Docker deployment starts with. It is not a startup failure; see the second table below.
| Message (abridged) | Cause / fix |
|---|---|
directory '…' does not exist or is not a directory | Wrong path, or the container mount is missing. Check it from inside the container: docker compose exec lightrag ls /app/data/ui_templates. |
manifest.json is not valid JSON | A trailing comma or a comment. JSON allows neither. |
unknown field(s) [...] | A typo in a field name; the schema is closed on purpose. |
missing required field(s) [...] | Add the field. Note brand.logo is required — use an explicit null for "no logo". |
unsupported schema_version | Must be exactly 1. |
locales: key 'zh_TW' uses the underscore form | Write zh-TW. |
locales: key 'x-acme' has an invalid language subtag | The primary subtag must be 2–8 letters, so a one-letter first subtag is rejected — private-use (x-…) and irregular grandfathered (i-…) tags. Regular grandfathered tags such as art-lojban are fine — see §4.3. |
locales: key 'zh-tw' must be written in its normalized form 'zh-TW' | Fix the casing. |
default_locale '…' is not a declared locale | default_locale must appear in locales. |
fallbacks.xx: target 'yy' is not a declared locale | Fallback targets must be declared; only sources may be uncovered. |
…: '…' does not exist or is not a file | A manifest path points at nothing. Paths are relative to the bundle root and case-sensitive. |
…: absolute paths are not allowed / path traversal is not allowed / escapes the bundle directory | Keep every referenced file inside the bundle, including symlink targets. |
…: file exceeds the … byte limit | 64 KiB per template, 2 MiB per logo. |
…: file is not valid UTF-8 | Re-save the Markdown as UTF-8. |
logo '…': content is not PNG, JPEG, WebP or SVG | The bytes do not match any accepted format — e.g. a .svg that is really HTML, or an SVG whose root element is missing or prefixed. |
locales.xx.login: template file is empty | login / agreements are declared-or-absent switches; a blank declared file is rejected. welcome / query_empty may be blank. |
locales.xx.logo_alt must be a non-empty string | Give each locale real alt text. |
locales.xx.consent_documents must be a non-empty string or null | It is inline text, not a path — a blank label would leave the checkbox naming nothing. Remove the key to fall back to the WebUI translation. |
brand.copyright must be a string or null / locales.xx.copyright must be a string or null | The copyright line is plain text written in the manifest — a string, or null for "no line". Not a path, not a number, not a list. |
Symptoms that are not startup failures:
| Symptom | Cause |
|---|---|
| Server starts, but the page still shows LightRAG branding | UI_TEMPLATES_DIR is unset — check the log line and /ui/customization. In Docker, remember a compose environment: entry overrides .env. |
Startup logs holds no manifest.json and the branding is unchanged | The configured directory exists but has no bundle in it. Either you have not written one yet, or the server is reading a different directory than you populated — the warning names the path it read. manifest.json must sit directly in the bundle root, not one level down (a common cause is copying the parent directory). Check it from inside the container: docker compose exec lightrag ls /app/data/ui_templates. |
| Edits do not appear | No hot reload. Restart the server (all workers). |
| Checkbox says "Privacy Policy Agreement", but the document covers more | That is the WebUI fallback for a locale declaring no consent_documents. Name the document yourself (§6.1) — bundles written before the field existed hit this on upgrade. |
| Consent dialog has no title | agreements.md starts with no heading. The dialog prints the file as written — add a # Title line to it (§6.1). |
An agreements.md sits in the directory, but no checkbox ever appears | That locale's manifest entry does not reference it. manifest.json is the only index — there is no discovery by filename, so declare agreements (and login) explicitly (§4.2). |
| Consent checkbox missing | The resolved locale declares only one of login / agreements, or auth is disabled (AUTH_ACCOUNTS unset), or the visitor resolved to a different locale than you expected — check locale and consent_required in the endpoint response. |
| Content is in your language, buttons are not | The locale is outside the WebUI's interface languages — see §5.3 and the startup warning. |
| Logo does not show | The locale (or brand) resolves to null, or the browser failed to load the asset URL — fetch logo_url directly and check the status. |
| Copyright line does not show | Neither brand.copyright nor the resolved locale's copyright declares text (or one of them is null / empty). Check brand.copyright in the endpoint response for the locale you are looking at. There is no LightRAG default: no declaration means no line (§4.5). |
dir attribute, no CSS — see §5.2..env and your compose files. Validation exists to catch configuration mistakes, not to defend against the bundle's own author.X-Content-Type-Options: nosniff and a restrictive Content-Security-Policy, so an SVG served here stays inert even if opened as a top-level document.docs/ui_templates_example/ — a complete, copyable bundle (en, zh, zh-TW).AUTH_ACCOUNTS and TOKEN_SECRET.make env-* wizard and docker-compose.final.yml.