feat: localchat, a Go + Templ + HTMX chat app for local Gemma models
Streams replies over SSE from any OpenAI-compatible server (oMLX, llama.cpp, Ollama, ...). Single binary that can install itself as an OS service; docker compose bundles llama.cpp + Gemma 4 E2B.
This commit is contained in:
30 files changed
+2566
No files matched your search
@@ -0,0 +1,70 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["playwright"]
|
||||
# ///
|
||||
"""Browser end-to-end test: loads the UI, sends a message with Enter, waits
|
||||
for the streamed reply to finish rendering, and saves screenshots.
|
||||
|
||||
uv run scripts/e2e.py [--url http://127.0.0.1:3000] [--channel msedge]
|
||||
"""
|
||||
import argparse, sys, time
|
||||
from playwright.sync_api import sync_playwright, expect
|
||||
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--url", default="http://127.0.0.1:3000")
|
||||
p.add_argument("--channel", default="msedge", help="installed browser: chrome, msedge, ...")
|
||||
p.add_argument("--out", default="docs")
|
||||
p.add_argument("--dark", action="store_true")
|
||||
p.add_argument("--prompt", default="Give me 3 short tips for learning Go, as a numbered list. Include one tiny code snippet.")
|
||||
args = p.parse_args()
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch(channel=args.channel, headless=True)
|
||||
page = browser.new_page(viewport={"width": 900, "height": 760},
|
||||
color_scheme="dark" if args.dark else "light")
|
||||
errors = []
|
||||
page.on("pageerror", lambda e: errors.append(str(e)))
|
||||
page.goto(args.url)
|
||||
|
||||
expect(page.locator("#health .dot.ok")).to_be_visible(timeout=10_000)
|
||||
expect(page.locator(".empty")).to_be_visible()
|
||||
|
||||
box = page.locator("#composer textarea")
|
||||
box.fill(args.prompt)
|
||||
t0 = time.time()
|
||||
box.press("Enter")
|
||||
|
||||
# user bubble shows up and the input clears
|
||||
expect(page.locator(".msg.user")).to_have_count(1)
|
||||
expect(box).to_have_value("")
|
||||
expect(page.locator(".empty")).to_be_hidden()
|
||||
|
||||
# tokens stream in (plain spans) ...
|
||||
page.wait_for_selector(".streaming span", timeout=60_000)
|
||||
first = time.time() - t0
|
||||
# ... then get replaced by rendered Markdown
|
||||
page.wait_for_selector(".msg.assistant .markdown", timeout=180_000)
|
||||
total = time.time() - t0
|
||||
page.wait_for_timeout(300)
|
||||
assert page.locator(".msg.assistant [sse-connect]").count() == 0, "SSE element not cleaned up"
|
||||
if "numbered list" in args.prompt:
|
||||
assert page.locator(".msg.assistant ol li").count() >= 3, "expected a numbered list"
|
||||
if "code" in args.prompt:
|
||||
assert page.locator(".msg.assistant pre code").count() >= 1, "expected a code block"
|
||||
page.screenshot(path=f"{args.out}/screenshot{'-dark' if args.dark else ''}.png")
|
||||
|
||||
# reload: history persists for the session
|
||||
page.reload()
|
||||
expect(page.locator(".msg")).to_have_count(2)
|
||||
|
||||
# new chat clears it
|
||||
page.get_by_role("button", name="New chat").click()
|
||||
expect(page.locator(".msg")).to_have_count(0)
|
||||
expect(page.locator(".empty")).to_be_visible()
|
||||
|
||||
browser.close()
|
||||
|
||||
if errors:
|
||||
print("JS errors:", errors)
|
||||
sys.exit(1)
|
||||
print(f"OK: first token {first:.1f}s, full reply {total:.1f}s")
|
||||
Reference in new issue
Block a user