Spaces:
Running
Running
THOX Open WebUI: branded image + Dataset-backed state + connection seeding
Browse files- Dockerfile +42 -0
- README.md +68 -5
- brand/model-avatar.png +0 -0
- brand/static/apple-touch-icon.png +0 -0
- brand/static/custom.css +59 -0
- brand/static/favicon-96x96.png +0 -0
- brand/static/favicon.ico +0 -0
- brand/static/favicon.png +0 -0
- brand/static/favicon.svg +1 -0
- brand/static/logo.png +0 -0
- brand/static/splash-dark.png +0 -0
- brand/static/splash.png +0 -0
- brand/static/thox-model-avatar.png +0 -0
- brand/static/web-app-manifest-192x192.png +0 -0
- brand/static/web-app-manifest-512x512.png +0 -0
- scripts/seed_connections.py +108 -0
- scripts/set-model-avatars.py +64 -0
- scripts/thox-space-boot.sh +41 -0
- scripts/thox_state.py +162 -0
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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]()
|