tommytracx commited on
Commit
39365b3
·
verified ·
1 Parent(s): 4257bf5

THOX Open WebUI: branded image + Dataset-backed state + connection seeding

Browse files
Dockerfile ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # THOX Open WebUI — Hugging Face Space (CPU, private)
2
+ #
3
+ # Branding is BAKED, not mounted. open-webui's config.py copies /app/build/static/*
4
+ # into the served static dir on EVERY boot: a read-only mount crashes that copy with
5
+ # Errno 30, and a read-write mount gets overwritten by the stock source. Writing THOX
6
+ # assets into BOTH the copy source and the destination makes the boot-copy THOX->THOX
7
+ # and therefore idempotent.
8
+ FROM ghcr.io/open-webui/open-webui:main
9
+
10
+ USER root
11
+
12
+ # zstd/tar drive the assets archive; huggingface_hub moves state to/from the Dataset.
13
+ RUN apt-get update && apt-get install -y --no-install-recommends zstd tar \
14
+ && rm -rf /var/lib/apt/lists/* \
15
+ && pip install --no-cache-dir "huggingface_hub>=0.34" hf_transfer
16
+
17
+ # --- branding: copy SOURCE (read at boot) and SERVED target ---
18
+ COPY brand/static/favicon.png /app/build/favicon.png
19
+ COPY brand/static/ /app/build/static/
20
+ COPY brand/static/ /app/backend/open_webui/static/
21
+
22
+ # --- THOX runtime ---
23
+ COPY brand/model-avatar.png /app/thox/thox-model-avatar.png
24
+ COPY scripts/ /app/thox/
25
+ RUN chmod +x /app/thox/thox-space-boot.sh
26
+
27
+ ENV WEBUI_NAME="THOX" \
28
+ ENV=prod \
29
+ PORT=8080 \
30
+ HF_HUB_ENABLE_HF_TRANSFER=1 \
31
+ DATA_DIR=/app/backend/data \
32
+ STATE_REPO=tommytracx/thox-openwebui-state \
33
+ BACKUP_INTERVAL=300 \
34
+ SPACE_OWNER=tommytracx \
35
+ SPACE_PREFIX=thox-zero- \
36
+ EXTRA_OPENAI_URLS=https://route.thox.ai/v1 \
37
+ SCARF_NO_ANALYTICS=true \
38
+ DO_NOT_TRACK=true \
39
+ ANONYMIZED_TELEMETRY=false
40
+
41
+ EXPOSE 8080
42
+ ENTRYPOINT ["bash", "/app/thox/thox-space-boot.sh"]
README.md CHANGED
@@ -1,10 +1,73 @@
1
  ---
2
- title: Thox Openwebui
3
- emoji: 📈
4
- colorFrom: red
5
- colorTo: blue
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: THOX Open WebUI
3
+ emoji: 💚
4
+ colorFrom: green
5
+ colorTo: gray
6
  sdk: docker
7
+ app_port: 8080
8
  pinned: false
9
+ short_description: THOX-branded Open WebUI with Dataset-backed state
10
  ---
11
 
12
+ # THOX Open WebUI
13
+
14
+ Your AI. Your Data. Your Rules.™
15
+
16
+ THOX-branded Open WebUI, restored from the live THOX instance and wired to the
17
+ THOX ZeroGPU model Spaces.
18
+
19
+ ## Persistence — read this before trusting it
20
+
21
+ Spaces have **ephemeral** storage. The paid persistent-storage tier is unavailable
22
+ on this account (`whoami-v2` reports `canPay: false`), so state lives in the private
23
+ Dataset **`tommytracx/thox-openwebui-state`** and is restored on every cold start.
24
+
25
+ | Artifact | Contents | Cadence |
26
+ |---|---|---|
27
+ | `webui.db` | users, chats, models, prompts, folders, settings | every 5 min, only when changed |
28
+ | `assets.tar.zst` | `uploads/` + `vector_db/` | every 12th cycle (~1h), only when changed |
29
+
30
+ **What this does not guarantee.** Writes made since the last successful backup are
31
+ **lost** on an unexpected restart. A Space stop does not reliably deliver `SIGTERM`,
32
+ so the periodic loop — not the shutdown hook — is the real durability mechanism. If
33
+ two instances ever run at once, the database is last-writer-wins. This is genuinely
34
+ weaker than a Docker volume; it is the ceiling of the free tier.
35
+
36
+ Two implementation details that matter:
37
+
38
+ - `webui.db` is live SQLite in **WAL** mode, so backups use `VACUUM INTO` rather
39
+ than a file copy, which would otherwise capture a torn database.
40
+ - `vector_db/` is ~4250 Chroma directories. It travels as a single tar because
41
+ syncing it as loose files costs ~4250 requests per pass.
42
+
43
+ ## Model connections
44
+
45
+ Seeded on boot by `scripts/seed_connections.py`, which **writes the `config` table
46
+ directly**. `OPENAI_API_BASE_URLS` cannot be used: it is a `PersistentConfig` value
47
+ that the environment seeds only on first boot, and this Space restores an existing
48
+ database every time, so the env var would be ignored.
49
+
50
+ Connections are discovered by listing Spaces under `SPACE_OWNER` matching
51
+ `SPACE_PREFIX` (`thox-zero-`), so model Spaces are picked up as they come online,
52
+ plus `EXTRA_OPENAI_URLS` (ThoxRoute at `https://route.thox.ai/v1`).
53
+
54
+ The model Spaces are private; Hugging Face gates them at the edge and the HF token
55
+ is what satisfies that check, so it doubles as the connection API key.
56
+
57
+ ## Required Space secrets
58
+
59
+ | Name | Purpose |
60
+ |---|---|
61
+ | `HF_TOKEN` | restore/backup against the state Dataset, and auth to the model Spaces |
62
+ | `WEBUI_SECRET_KEY` | session signing |
63
+
64
+ ## Branding
65
+
66
+ Assets are **baked into the image**, not mounted. `config.py` re-copies
67
+ `/app/build/static/*` into the served directory on every boot; a `:ro` mount makes
68
+ that copy fail with `Errno 30` and a `:rw` mount is overwritten by the stock source.
69
+ Writing THOX assets to both locations makes the copy idempotent.
70
+
71
+ Palette is brand v2 (`ttracx/thox-design-system`): `#05A451` primary, `#45e887`
72
+ accent, dark-first. Primary controls use **black** ink — white on `#05A451` is
73
+ 3.26:1 and fails WCAG AA, while black is 6.43:1.
brand/model-avatar.png ADDED
brand/static/apple-touch-icon.png ADDED
brand/static/custom.css ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =====================================================================
2
+ THOX Open WebUI — brand v2 overlay
3
+ Source of truth: ttracx/thox-design-system (standards/brand-v2.md, ADR-0001)
4
+
5
+ CONTRAST NOTE — this is the whole reason the ink is black, not white:
6
+ #FFFFFF on #05A451 = 3.26:1 -> FAILS WCAG AA
7
+ #000000 on #05A451 = 6.43:1 -> PASSES (primary-button pairing)
8
+ #05A451 on #09090B = 6.09:1 -> PASSES (green as text on dark canvas)
9
+ #37C77A on #09090B = 9.1:1 -> focus rings
10
+ The reflex "white text on the brand green" is wrong here. Do not "fix" it.
11
+
12
+ #10B981 / #34D399 are DEPRECATED v1 emeralds. Purple #A855F7 is MagStack-only
13
+ and neon #00FF88 is device-illumination-only — neither belongs in UI chrome.
14
+ ===================================================================== */
15
+ :root {
16
+ --thox-green:#05A451; /* canonical primary */
17
+ --thox-green-ink:#000000; /* the only AA-safe ink on --thox-green */
18
+ --thox-accent:#45e887; /* accent / highlight on dark */
19
+ --thox-focus:#37C77A; /* 9.1:1 on canvas */
20
+ --thox-green-deep:#03763B; /* for light surfaces: + white ink = 5.74:1 */
21
+ --thox-bg:#09090B;
22
+ --thox-surface:#18181B;
23
+ --thox-border:#27272A;
24
+ --thox-text:#FAFAFA;
25
+ --thox-font-sans:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
26
+ --thox-font-mono:'JetBrains Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
27
+ }
28
+
29
+ body, .font-primary { font-family: var(--thox-font-sans); -webkit-font-smoothing: antialiased; }
30
+ code, pre, kbd, .font-mono, textarea.font-mono { font-family: var(--thox-font-mono); }
31
+
32
+ :is(button, a, input, textarea, select):focus-visible {
33
+ outline: 2px solid var(--thox-focus);
34
+ outline-offset: 2px;
35
+ }
36
+
37
+ /* Emphasis controls -> THOX green with black ink. open-webui uses bg-black in
38
+ light mode and bg-white in dark for its primary affordance. */
39
+ button.bg-black:not(.text-black),
40
+ .dark button.bg-white:not(.text-white) {
41
+ background-color: var(--thox-green) !important;
42
+ color: var(--thox-green-ink) !important;
43
+ }
44
+ button.bg-black:hover,
45
+ .dark button.bg-white:hover { background-color: var(--thox-accent) !important; }
46
+
47
+ /* Green as *text* on the dark canvas is AA-clean, so links use it directly. */
48
+ a:not(.no-thox) { color: var(--thox-accent); }
49
+ a:not(.no-thox):hover { color: var(--thox-green); }
50
+
51
+ /* On light surfaces the plain green is not approved for body text. */
52
+ @media (prefers-color-scheme: light) {
53
+ a:not(.no-thox) { color: var(--thox-green-deep); }
54
+ }
55
+
56
+ ::selection { background: var(--thox-green); color: var(--thox-green-ink); }
57
+ * { scrollbar-color: var(--thox-border) transparent; }
58
+ ::-webkit-scrollbar-thumb { background: var(--thox-border); border-radius: 6px; }
59
+ ::-webkit-scrollbar-thumb:hover { background: var(--thox-green); }
brand/static/favicon-96x96.png ADDED
brand/static/favicon.ico ADDED
brand/static/favicon.png ADDED
brand/static/favicon.svg ADDED
brand/static/logo.png ADDED
brand/static/splash-dark.png ADDED
brand/static/splash.png ADDED
brand/static/thox-model-avatar.png ADDED
brand/static/web-app-manifest-192x192.png ADDED
brand/static/web-app-manifest-512x512.png ADDED
scripts/seed_connections.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Point Open WebUI's OpenAI connections at the THOX ZeroGPU model Spaces.
3
+
4
+ WHY THIS WRITES TO THE DATABASE INSTEAD OF USING ENV VARS
5
+ OPENAI_API_BASE_URLS / OPENAI_API_KEYS are Open WebUI `PersistentConfig`
6
+ values: the environment seeds them on FIRST boot only, and from then on the
7
+ value stored in the database wins. This Space restores an existing database
8
+ every boot, so env vars would be silently ignored. Seeding therefore has to
9
+ write the `config` table directly.
10
+
11
+ DISCOVERY
12
+ The ZeroGPU model Spaces are built by a separate lane from a shared template
13
+ and are still being rolled out, so rather than pin a list that goes stale we
14
+ enumerate Spaces matching SPACE_PREFIX under the account and add whichever
15
+ exist. Re-running is safe: entries are matched by URL and never duplicated.
16
+
17
+ AUTH
18
+ The model Spaces are private, so Hugging Face gates them at the edge. The HF
19
+ token is what satisfies that check and is used as the connection's API key.
20
+ """
21
+ import json, os, sqlite3, sys
22
+
23
+ DB = os.environ.get("DATA_DIR", "/app/backend/data") + "/webui.db"
24
+ TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
25
+ OWNER = os.environ.get("SPACE_OWNER", "tommytracx")
26
+ PREFIX = os.environ.get("SPACE_PREFIX", "thox-zero-")
27
+ EXTRA = [u for u in os.environ.get("EXTRA_OPENAI_URLS", "https://route.thox.ai/v1").split(",") if u.strip()]
28
+
29
+
30
+ def log(m): print(f"[thox-connections] {m}", flush=True)
31
+
32
+
33
+ def discover():
34
+ """(url, key) pairs for each ZeroGPU model Space that currently exists."""
35
+ urls = []
36
+ if TOKEN:
37
+ try:
38
+ from huggingface_hub import HfApi
39
+ for s in HfApi(token=TOKEN).list_spaces(author=OWNER):
40
+ name = s.id.split("/")[-1]
41
+ if name.startswith(PREFIX):
42
+ host = f"{OWNER}-{name}".lower().replace("_", "-")
43
+ urls.append((f"https://{host}.hf.space/v1", TOKEN))
44
+ except Exception as exc:
45
+ log(f"space discovery failed ({type(exc).__name__}) - using extras only")
46
+ for u in EXTRA:
47
+ urls.append((u.strip(), ""))
48
+ return urls
49
+
50
+
51
+ def get(cur, key, default):
52
+ row = cur.execute("select value from config where key=?", (key,)).fetchone()
53
+ if not row:
54
+ return default
55
+ try:
56
+ return json.loads(row[0])
57
+ except (TypeError, json.JSONDecodeError):
58
+ return default
59
+
60
+
61
+ def put(cur, key, value):
62
+ payload = json.dumps(value)
63
+ if cur.execute("select 1 from config where key=?", (key,)).fetchone():
64
+ cur.execute("update config set value=? where key=?", (payload, key))
65
+ else:
66
+ cur.execute("insert into config (key,value) values (?,?)", (key, payload))
67
+
68
+
69
+ def main():
70
+ if not os.path.exists(DB):
71
+ log("no database yet - skipping"); return
72
+ targets = discover()
73
+ if not targets:
74
+ log("nothing to seed"); return
75
+
76
+ con = sqlite3.connect(DB); cur = con.cursor()
77
+ base = get(cur, "openai.api_base_urls", [])
78
+ keys = get(cur, "openai.api_keys", [])
79
+ cfgs = get(cur, "openai.api_configs", {})
80
+
81
+ # api_base_urls and api_keys are positional partners; api_configs is keyed by
82
+ # the same index. Appending keeps every existing index stable.
83
+ while len(keys) < len(base):
84
+ keys.append("")
85
+
86
+ added = 0
87
+ for url, key in targets:
88
+ if url in base:
89
+ idx = base.index(url)
90
+ if key:
91
+ keys[idx] = key # refresh a rotated token
92
+ continue
93
+ base.append(url); keys.append(key)
94
+ cfgs[str(len(base) - 1)] = {"enable": True, "tags": [], "prefix_id": "",
95
+ "model_ids": [], "connection_type": "external"}
96
+ added += 1
97
+ log(f"added {url}")
98
+
99
+ put(cur, "openai.api_base_urls", base)
100
+ put(cur, "openai.api_keys", keys)
101
+ put(cur, "openai.api_configs", cfgs)
102
+ put(cur, "openai.enable", True)
103
+ con.commit(); con.close()
104
+ log(f"seeded {added} new connection(s); {len(base)} total")
105
+
106
+
107
+ if __name__ == "__main__":
108
+ sys.exit(main())
scripts/set-model-avatars.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Set every OpenWebUI model's avatar (meta.profile_image_url) to the THOX logo.
3
+
4
+ The per-model avatar lives in the SQLite DB (/app/backend/data/webui.db, in the
5
+ persistent volume), stored as a base64 data URI — the same shape OpenWebUI uses for
6
+ uploaded avatars. Storing a self-contained data URI (not a /static path) means the
7
+ avatar survives image updates AND does not depend on any static asset path.
8
+
9
+ Idempotent. Re-run after re-provisioning / re-importing models so a WebUI update
10
+ cannot revert avatars to the default chip.
11
+
12
+ Usage (inside the container):
13
+ python set-model-avatars.py [--only-empty] [AVATAR_PNG] [DB_PATH]
14
+
15
+ --only-empty Only set the avatar on models that have no image or a stock
16
+ default (empty / None / a /static/favicon* path). Used by the
17
+ on-boot hook so it re-brands re-provisioned/new models WITHOUT
18
+ clobbering any avatar a user later sets by hand. Omit it to force
19
+ the THOX logo onto EVERY model.
20
+ Defaults: AVATAR_PNG=/tmp/thox-model-avatar.png DB_PATH=/app/backend/data/webui.db
21
+ """
22
+ import sqlite3, json, base64, sys, os
23
+
24
+ args = [a for a in sys.argv[1:] if a != "--only-empty"]
25
+ ONLY_EMPTY = "--only-empty" in sys.argv[1:]
26
+ AVATAR = args[0] if len(args) > 0 else "/tmp/thox-model-avatar.png"
27
+ DB = args[1] if len(args) > 1 else "/app/backend/data/webui.db"
28
+
29
+ if not os.path.exists(AVATAR):
30
+ sys.exit(f"avatar not found: {AVATAR}")
31
+ if not os.path.exists(DB):
32
+ sys.exit(f"db not found: {DB}")
33
+
34
+ def is_default(url):
35
+ """True if this avatar is empty or a stock/default (i.e. safe to (re)brand)."""
36
+ if not url:
37
+ return True
38
+ return url.startswith("/static/favicon") or url in ("/favicon.png", "/static/favicon.png")
39
+
40
+ with open(AVATAR, "rb") as f:
41
+ data_uri = "data:image/png;base64," + base64.b64encode(f.read()).decode()
42
+
43
+ con = sqlite3.connect(DB)
44
+ cur = con.cursor()
45
+ # Tolerate a not-yet-migrated DB (fresh volume, model table not created yet).
46
+ tabs = [r[0] for r in cur.execute("SELECT name FROM sqlite_master WHERE type='table'")]
47
+ if "model" not in tabs:
48
+ print("model table not present yet; nothing to do")
49
+ sys.exit(0)
50
+ rows = cur.execute("SELECT id, meta FROM model").fetchall()
51
+ changed = 0
52
+ for mid, meta in rows:
53
+ m = json.loads(meta) if meta else {}
54
+ cur_url = m.get("profile_image_url")
55
+ if cur_url == data_uri:
56
+ continue
57
+ if ONLY_EMPTY and not is_default(cur_url):
58
+ continue
59
+ m["profile_image_url"] = data_uri
60
+ cur.execute("UPDATE model SET meta = ? WHERE id = ?", (json.dumps(m), mid))
61
+ changed += 1
62
+ con.commit()
63
+ con.close()
64
+ print(f"models_total={len(rows)} updated={changed} only_empty={ONLY_EMPTY} avatar_bytes={len(data_uri)}")
scripts/thox-space-boot.sh ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # THOX Open WebUI (Hugging Face Space) entrypoint.
3
+ #
4
+ # restore state -> seed connections -> background backup loop -> exec app
5
+ #
6
+ # Storage on a Space is ephemeral and the paid persistent tier is unavailable on
7
+ # this account, so /app/backend/data is rebuilt from a private HF Dataset on every
8
+ # cold start. See scripts/thox_state.py for the hot/cold split and its limits.
9
+ set -euo pipefail
10
+
11
+ echo "[thox-boot] THOX Open WebUI starting"
12
+ [ -n "${HF_TOKEN:-}" ] && echo "[thox-boot] HF_TOKEN present" \
13
+ || echo "[thox-boot] WARNING: HF_TOKEN unset - no restore, no backup"
14
+
15
+ python /app/thox/thox_state.py restore || echo "[thox-boot] restore failed - continuing with empty data"
16
+ python /app/thox/seed_connections.py || echo "[thox-boot] connection seeding failed - configure manually in Settings"
17
+
18
+ # Re-apply the THOX avatar to models that have none. Runs after the app has had a
19
+ # chance to migrate the schema, and never overwrites a user-set image.
20
+ if [ -f /app/thox/set-model-avatars.py ]; then
21
+ (
22
+ for _ in $(seq 1 40); do
23
+ if [ -f /app/backend/data/webui.db ]; then
24
+ python /app/thox/set-model-avatars.py --only-empty \
25
+ /app/thox/thox-model-avatar.png /app/backend/data/webui.db 2>&1 && break
26
+ fi
27
+ sleep 5
28
+ done
29
+ ) >/tmp/thox-avatars.log 2>&1 &
30
+ fi
31
+
32
+ python /app/thox/thox_state.py loop &
33
+ LOOP_PID=$!
34
+
35
+ # Best-effort final flush. SIGTERM is not guaranteed on a Space stop, which is
36
+ # precisely why the periodic loop above is the real durability mechanism.
37
+ trap 'echo "[thox-boot] SIGTERM - final backup"; kill $LOOP_PID 2>/dev/null || true; \
38
+ python /app/thox/thox_state.py backup || true' TERM INT
39
+
40
+ cd /app/backend
41
+ exec bash start.sh
scripts/thox_state.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """THOX Open WebUI state sync: HF Dataset <-> /app/backend/data.
3
+
4
+ WHY THIS EXISTS
5
+ HF Spaces have ephemeral storage. The paid persistent-storage tier is not
6
+ available on this account (whoami-v2 reports canPay:false), so a private HF
7
+ Dataset is the backing store instead.
8
+
9
+ HOT / COLD SPLIT
10
+ webui.db ~30MB, changes constantly -> snapshot+upload every cycle
11
+ assets.tar.zst ~1.2GB, changes rarely -> upload only when the hash moves
12
+
13
+ vector_db/ is ~4250 Chroma collection directories. Synced as loose files that
14
+ is 4250 requests per pass, so it travels as a single tar.
15
+
16
+ CONSISTENCY
17
+ webui.db is live SQLite in WAL mode. A plain copy while Open WebUI is writing
18
+ yields a torn file, so backups go through `VACUUM INTO`, which takes an atomic
19
+ snapshot against concurrent writers.
20
+
21
+ WHAT THIS DOES NOT GUARANTEE
22
+ Writes made since the last successful backup are lost on an unexpected
23
+ restart. This is weaker than a real volume; it is the ceiling on the free tier.
24
+ """
25
+ import hashlib, os, pathlib, shutil, sqlite3, subprocess, sys, time
26
+
27
+ DATA = pathlib.Path(os.environ.get("DATA_DIR", "/app/backend/data"))
28
+ REPO = os.environ.get("STATE_REPO", "tommytracx/thox-openwebui-state")
29
+ TOKEN = os.environ.get("HF_TOKEN") or os.environ.get("HUGGING_FACE_HUB_TOKEN")
30
+ INTERVAL = int(os.environ.get("BACKUP_INTERVAL", "300"))
31
+ WORK = pathlib.Path("/tmp/thox-state"); WORK.mkdir(parents=True, exist_ok=True)
32
+ ASSETS = ["uploads", "vector_db"]
33
+
34
+
35
+ def log(msg):
36
+ print(f"[thox-state] {msg}", flush=True)
37
+
38
+
39
+ def _api():
40
+ from huggingface_hub import HfApi
41
+ return HfApi(token=TOKEN)
42
+
43
+
44
+ def _sha(path, chunk=1 << 20):
45
+ h = hashlib.sha256()
46
+ with open(path, "rb") as fh:
47
+ for b in iter(lambda: fh.read(chunk), b""):
48
+ h.update(b)
49
+ return h.hexdigest()
50
+
51
+
52
+ def _marker(name):
53
+ return WORK / f"{name}.sha256"
54
+
55
+
56
+ # ---------------------------------------------------------------- restore
57
+ def restore():
58
+ if not TOKEN:
59
+ log("no HF_TOKEN -> starting with an empty database (nothing restored)")
60
+ return False
61
+ from huggingface_hub import hf_hub_download
62
+ DATA.mkdir(parents=True, exist_ok=True)
63
+ ok = False
64
+
65
+ try:
66
+ p = hf_hub_download(REPO, "webui.db", repo_type="dataset", token=TOKEN,
67
+ local_dir=str(WORK / "dl"))
68
+ shutil.copy2(p, DATA / "webui.db")
69
+ # A restored snapshot must not inherit a stale WAL from a previous boot.
70
+ for sidecar in ("webui.db-wal", "webui.db-shm"):
71
+ (DATA / sidecar).unlink(missing_ok=True)
72
+ n = sqlite3.connect(DATA / "webui.db").execute(
73
+ "select count(*) from chat").fetchone()[0]
74
+ _marker("webui.db").write_text(_sha(DATA / "webui.db"))
75
+ log(f"restored webui.db ({(DATA / 'webui.db').stat().st_size/1e6:.1f} MB, {n} chats)")
76
+ ok = True
77
+ except Exception as exc: # first boot, or repo still empty
78
+ log(f"no webui.db restored ({type(exc).__name__}) -> fresh database")
79
+
80
+ try:
81
+ p = hf_hub_download(REPO, "assets.tar.zst", repo_type="dataset", token=TOKEN,
82
+ local_dir=str(WORK / "dl"))
83
+ subprocess.run(["tar", "--use-compress-program=unzstd", "-xf", p, "-C", str(DATA)],
84
+ check=True)
85
+ _marker("assets").write_text(_sha(p))
86
+ log(f"restored assets.tar.zst -> {', '.join(ASSETS)}")
87
+ ok = True
88
+ except Exception as exc:
89
+ log(f"no assets restored ({type(exc).__name__}) -> knowledge bases will be empty")
90
+
91
+ return ok
92
+
93
+
94
+ # ---------------------------------------------------------------- backup
95
+ def _snapshot_db():
96
+ """Atomic, self-consistent copy of a live WAL database."""
97
+ out = WORK / "webui-snap.db"
98
+ out.unlink(missing_ok=True)
99
+ con = sqlite3.connect(f"file:{DATA/'webui.db'}?mode=ro", uri=True)
100
+ try:
101
+ con.execute("VACUUM INTO ?", (str(out),))
102
+ finally:
103
+ con.close()
104
+ return out
105
+
106
+
107
+ def backup_db():
108
+ if not TOKEN or not (DATA / "webui.db").exists():
109
+ return False
110
+ snap = _snapshot_db()
111
+ digest = _sha(snap)
112
+ if _marker("webui.db").exists() and _marker("webui.db").read_text().strip() == digest:
113
+ return False # unchanged since last push
114
+ _api().upload_file(path_or_fileobj=str(snap), path_in_repo="webui.db",
115
+ repo_id=REPO, repo_type="dataset",
116
+ commit_message=f"state: webui.db {digest[:12]}")
117
+ _marker("webui.db").write_text(digest)
118
+ log(f"backed up webui.db ({snap.stat().st_size/1e6:.1f} MB, {digest[:12]})")
119
+ return True
120
+
121
+
122
+ def backup_assets():
123
+ if not TOKEN:
124
+ return False
125
+ present = [a for a in ASSETS if (DATA / a).exists()]
126
+ if not present:
127
+ return False
128
+ tar = WORK / "assets.tar.zst"
129
+ tar.unlink(missing_ok=True)
130
+ subprocess.run(f"tar -C {DATA} -cf - {' '.join(present)} | zstd -3 -T0 -q -o {tar}",
131
+ shell=True, check=True)
132
+ digest = _sha(tar)
133
+ if _marker("assets").exists() and _marker("assets").read_text().strip() == digest:
134
+ tar.unlink(missing_ok=True)
135
+ return False
136
+ _api().upload_file(path_or_fileobj=str(tar), path_in_repo="assets.tar.zst",
137
+ repo_id=REPO, repo_type="dataset",
138
+ commit_message=f"state: assets {digest[:12]}")
139
+ _marker("assets").write_text(digest)
140
+ log(f"backed up assets.tar.zst ({tar.stat().st_size/1e6:.0f} MB, {digest[:12]})")
141
+ tar.unlink(missing_ok=True)
142
+ return True
143
+
144
+
145
+ def loop():
146
+ log(f"backup loop every {INTERVAL}s (db each cycle, assets every 12th)")
147
+ i = 0
148
+ while True:
149
+ time.sleep(INTERVAL)
150
+ i += 1
151
+ try:
152
+ backup_db()
153
+ if i % 12 == 0:
154
+ backup_assets()
155
+ except Exception as exc: # never let a sync fault kill the app
156
+ log(f"backup failed, will retry next cycle: {type(exc).__name__}: {exc}")
157
+
158
+
159
+ if __name__ == "__main__":
160
+ cmd = sys.argv[1] if len(sys.argv) > 1 else "restore"
161
+ {"restore": restore, "backup": lambda: (backup_db(), backup_assets()),
162
+ "backup-db": backup_db, "backup-assets": backup_assets, "loop": loop}[cmd]()