Files
LLeMbas/src/lembas/cli.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

113 lines
3.8 KiB
Python

"""Command line entry points."""
from __future__ import annotations
import secrets as secrets_module
import typer
import uvicorn
from sqlalchemy import func, select
from lembas import __version__
from lembas.config import settings
app = typer.Typer(
help="LLeMbas - a Middle-earth themed web UI for your language models.",
no_args_is_help=True,
add_completion=False,
)
@app.command()
def serve(
host: str = typer.Option(None, help="Bind address. Defaults to LEMBAS_HOST."),
port: int = typer.Option(None, help="Port. Defaults to LEMBAS_PORT."),
reload: bool = typer.Option(None, "--reload/--no-reload", help="Autoreload on change."),
) -> None:
"""Run the web server."""
uvicorn.run(
"lembas.main:app",
host=host or settings.host,
port=port or settings.port,
reload=settings.reload if reload is None else reload,
log_level=settings.log_level,
# Access logs duplicate what the application already logs and drown out
# anything useful during development.
access_log=settings.log_level == "debug",
)
@app.command("create-admin")
def create_admin(
email: str = typer.Option(..., prompt=True),
name: str = typer.Option(..., prompt=True),
password: str = typer.Option(..., prompt=True, hide_input=True, confirmation_prompt=True),
) -> None:
"""Create an administrator, or promote an existing account to one.
The web sign-up already makes the first account an admin. This is the way
back in when that account is lost, or when scripting a deployment.
"""
from lembas.db.models import ROLE_ADMIN, User
from lembas.db.session import init_db, session_scope
from lembas.security.passwords import hash_password, validate_password
if (problem := validate_password(password)) is not None:
typer.secho(problem, fg=typer.colors.RED)
raise typer.Exit(1)
init_db()
with session_scope() as db:
existing = db.scalar(select(User).where(User.email == email.strip().lower()))
if existing is not None:
existing.role = ROLE_ADMIN
existing.password_hash = hash_password(password)
existing.active = True
typer.secho(f"Promoted {existing.email} to administrator.", fg=typer.colors.GREEN)
return
db.add(
User(
email=email.strip().lower(),
name=name.strip(),
password_hash=hash_password(password),
role=ROLE_ADMIN,
)
)
typer.secho(f"Created administrator {email}.", fg=typer.colors.GREEN)
@app.command("secret-key")
def secret_key() -> None:
"""Print a fresh value for LEMBAS_SECRET_KEY."""
typer.echo(secrets_module.token_urlsafe(48))
@app.command()
def info() -> None:
"""Show where this instance keeps its data and what is configured."""
from lembas.db.models import Chat, Connection, User
from lembas.db.session import init_db, session_scope
init_db()
typer.echo(f"LLeMbas {__version__}")
typer.echo(f" data directory : {settings.data_dir.resolve()}")
typer.echo(f" database : {settings.db_path.resolve()}")
typer.echo(f" bind : {settings.host}:{settings.port}")
typer.echo(f" default theme : {settings.default_theme}")
typer.echo(f" signup open : {settings.allow_signup}")
if settings.secret_key_is_ephemeral:
typer.secho(
" secret key : GENERATED (set LEMBAS_SECRET_KEY for a real install)",
fg=typer.colors.YELLOW,
)
with session_scope() as db:
for label, model in (("users", User), ("connections", Connection), ("chats", Chat)):
count = db.scalar(select(func.count()).select_from(model))
typer.echo(f" {label:<15}: {count}")
if __name__ == "__main__":
app()