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>
This commit is contained in:
Regular → Executable
+15
-2
@@ -35,6 +35,11 @@ except ImportError: # pragma: no cover - design-time tool
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
ASSETS = ROOT / "assets"
|
||||
STATIC_IMG = ROOT / "src" / "lembas" / "web" / "static" / "img"
|
||||
|
||||
# assets/ holds the design masters; the application serves its own copies from
|
||||
# static/. These are the few the running app actually needs.
|
||||
SERVED_BY_APP = ("favicon.svg", "logo-mark.svg", "banner.svg")
|
||||
|
||||
FONT_SEMIBOLD = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-Semibold.otf")
|
||||
FONT_ITALIC = Path("/usr/share/fonts/adobe-source-serif/SourceSerif4Display-It.otf")
|
||||
@@ -463,10 +468,18 @@ def main() -> None:
|
||||
args = parser.parse_args()
|
||||
|
||||
args.out.mkdir(parents=True, exist_ok=True)
|
||||
STATIC_IMG.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for filename in args.only or BUILDERS:
|
||||
content = BUILDERS[filename]()
|
||||
path = args.out / filename
|
||||
path.write_text(BUILDERS[filename](), encoding="utf-8")
|
||||
print(f"wrote {path.relative_to(ROOT)} ({path.stat().st_size:,} bytes)")
|
||||
path.write_text(content, encoding="utf-8")
|
||||
print(f"wrote {path.relative_to(ROOT)} ({len(content.encode()):,} bytes)")
|
||||
|
||||
if filename in SERVED_BY_APP:
|
||||
served = STATIC_IMG / filename
|
||||
served.write_text(content, encoding="utf-8")
|
||||
print(f" -> {served.relative_to(ROOT)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Executable
+119
@@ -0,0 +1,119 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"alpine.min.js": {
|
||||
"sha256": "57b37d7cae9a27d965fdae4adcc844245dfdc407e655aee85dcfff3a08036a3f",
|
||||
"url": "https://unpkg.com/alpinejs@3.15.12/dist/cdn.min.js",
|
||||
"version": "3.15.12"
|
||||
},
|
||||
"htmx-ext-sse.js": {
|
||||
"sha256": "3b5992a541619babefc4c169505af474df5c3039da51e59b96ccf9241ecd61d2",
|
||||
"url": "https://unpkg.com/htmx-ext-sse@2.2.4/sse.js",
|
||||
"version": "2.2.4"
|
||||
},
|
||||
"htmx.min.js": {
|
||||
"sha256": "71ea67185bfa8c98c39d31717c6fce5d852370fcdfd129db4543774d3145c0de",
|
||||
"url": "https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js",
|
||||
"version": "2.0.10"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user