0f44e8d24c
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>
113 lines
3.8 KiB
Python
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()
|