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>
71 lines
2.1 KiB
Python
71 lines
2.1 KiB
Python
"""Application configuration, loaded from the environment and/or a .env file."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from typing import Literal
|
|
|
|
from pydantic import Field, model_validator
|
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
|
|
|
|
class Settings(BaseSettings):
|
|
"""Runtime configuration. Every variable is prefixed ``LEMBAS_``."""
|
|
|
|
model_config = SettingsConfigDict(
|
|
env_prefix="LEMBAS_",
|
|
env_file=".env",
|
|
env_file_encoding="utf-8",
|
|
extra="ignore",
|
|
)
|
|
|
|
secret_key: str = Field(default="")
|
|
# Set when no LEMBAS_SECRET_KEY was supplied and one had to be invented.
|
|
# main.py warns about it at startup; see the validator below.
|
|
secret_key_is_ephemeral: bool = Field(default=False, exclude=True)
|
|
|
|
data_dir: Path = Path("./data")
|
|
|
|
host: str = "127.0.0.1"
|
|
port: int = 8080
|
|
reload: bool = False
|
|
log_level: Literal["debug", "info", "warning", "error"] = "info"
|
|
|
|
allow_signup: bool = True
|
|
default_theme: Literal["moria", "shire"] = "moria"
|
|
session_ttl: int = 60 * 60 * 24 * 30
|
|
request_timeout: float = 300.0
|
|
|
|
@model_validator(mode="after")
|
|
def _generate_secret_if_absent(self) -> Settings:
|
|
# A generated key lets `lembas serve` work with no configuration at all,
|
|
# but it changes on every restart: sessions drop and stored API keys
|
|
# become unreadable. Flagged so startup can warn. Never use in anger.
|
|
if not self.secret_key:
|
|
self.secret_key = secrets.token_urlsafe(48)
|
|
self.secret_key_is_ephemeral = True
|
|
return self
|
|
|
|
@property
|
|
def db_path(self) -> Path:
|
|
return self.data_dir / "lembas.db"
|
|
|
|
@property
|
|
def uploads_dir(self) -> Path:
|
|
return self.data_dir / "uploads"
|
|
|
|
def ensure_dirs(self) -> None:
|
|
self.data_dir.mkdir(parents=True, exist_ok=True)
|
|
self.uploads_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
@lru_cache
|
|
def get_settings() -> Settings:
|
|
"""Cached singleton so config is parsed once per process."""
|
|
return Settings()
|
|
|
|
|
|
settings = get_settings()
|