dd9e0e9440
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>
120 lines
3.9 KiB
Python
Executable File
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())
|