Files
LLeMbas/scripts/fetch_vendor.py
T
Jaroslav Beneš dd9e0e9440 Working chat: auth, connections, streaming, folders
LLeMbas now runs end to end. Register, add an OpenAI-compatible
connection, and hold a real streaming conversation organised into
folders. Verified against the local llama-swap instance.

Streaming is the one genuinely tricky part. Sending a message returns
two HTML fragments -- the user bubble and an empty assistant bubble
carrying an sse-connect -- and that attribute is the ONLY thing that
starts a generation. Rendering an incomplete assistant message as a
streaming shell falls out of the same template, which means loading a
page whose last reply never finished simply picks it up again.

Details worth knowing about, each commented where it matters:

- SSE payloads are split across several data: lines. A raw newline in
  one data: line truncates the event, which shows up the first time a
  model emits a code block.
- Markdown is rendered server-side by the same helper for both the page
  and the final streamed frame, so the two cannot disagree. The fence
  renderer is replaced outright rather than using markdown-it's
  highlight option, which re-wraps output in a second <pre>.
- escape_text is html.escape, not nh3.clean_text: it escapes character
  by character, so escaping stream chunks separately equals escaping
  the whole string.
- The stream opens its own session via session_scope(); it outlives the
  request handler and the dependency-scoped session may be closed.
- Deleting a folder keeps the chats inside it (FK is SET NULL). Losing
  a conversation to a mis-clicked folder delete is unforgivable.
- Login failures use one message for "no such account" and "wrong
  password" so the form cannot enumerate registered addresses.

Also adds deploy/ for the gamebox install at https://chat.lan: system
unit, nginx vhost with buffering off (buffering on turns streaming into
one lump at the end), and install/update scripts following the same
service-user and /srv bind-mount conventions as llama-swap and comfyui.

70 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:04:13 +02:00

120 lines
3.9 KiB
Python
Executable File

#!/usr/bin/env python3
"""Download the pinned browser libraries into the static vendor directory.
LLeMbas has no Node toolchain and loads nothing from a CDN at runtime -- a
self-hosted tool should keep working without internet access, and should not
report every user's page view to a third party. The three libraries it does use
are fetched once, here, and committed.
Integrity is enforced with vendor.lock.json. A mismatched hash aborts rather
than overwriting: that is the whole point of pinning.
python scripts/fetch_vendor.py # fetch and verify against the lock
python scripts/fetch_vendor.py --update # re-pin after a version bump
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VENDOR_DIR = ROOT / "src" / "lembas" / "web" / "static" / "vendor"
LOCKFILE = Path(__file__).resolve().parent / "vendor.lock.json"
# Pinned deliberately. Bump the version, run with --update, review the diff.
PACKAGES = {
"htmx.min.js": {
"version": "2.0.10",
"url": "https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js",
"why": "Server-rendered interactivity: every swap in the app.",
},
"htmx-ext-sse.js": {
"version": "2.2.4",
"url": "https://unpkg.com/htmx-ext-sse@2.2.4/sse.js",
"why": "Server-sent events, which is how streamed replies reach the page.",
},
"alpine.min.js": {
"version": "3.15.12",
"url": "https://unpkg.com/alpinejs@3.15.12/dist/cdn.min.js",
"why": "Small client-only state: menus, theme toggle, composer autosize.",
},
}
def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def fetch(url: str) -> bytes:
request = urllib.request.Request(url, headers={"User-Agent": "lembas-vendor-fetch"})
with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310
return response.read()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--update",
action="store_true",
help="rewrite vendor.lock.json with the hashes just downloaded",
)
args = parser.parse_args()
lock = json.loads(LOCKFILE.read_text()) if LOCKFILE.exists() else {}
VENDOR_DIR.mkdir(parents=True, exist_ok=True)
new_lock: dict[str, dict[str, str]] = {}
failed = False
for filename, spec in PACKAGES.items():
try:
payload = fetch(spec["url"])
except (urllib.error.URLError, TimeoutError) as exc:
print(f" FAIL {filename}: {exc}", file=sys.stderr)
failed = True
continue
digest = sha256(payload)
expected = lock.get(filename, {}).get("sha256")
if expected and digest != expected and not args.update:
print(
f" FAIL {filename}: hash mismatch\n"
f" expected {expected}\n"
f" received {digest}\n"
f" Refusing to overwrite. If the version was bumped "
f"deliberately, re-run with --update.",
file=sys.stderr,
)
failed = True
continue
(VENDOR_DIR / filename).write_bytes(payload)
new_lock[filename] = {
"version": spec["version"],
"url": spec["url"],
"sha256": digest,
}
status = "ok" if expected == digest else ("pinned" if args.update else "new")
print(f" {status:>6} {filename} {len(payload):>8,} bytes v{spec['version']}")
if failed:
print("\nOne or more downloads failed. Vendored files were not fully written.")
return 1
if args.update or not LOCKFILE.exists():
LOCKFILE.write_text(json.dumps(new_lock, indent=2, sort_keys=True) + "\n")
print(f"\nwrote {LOCKFILE.relative_to(ROOT)}")
return 0
if __name__ == "__main__":
sys.exit(main())