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:
@@ -0,0 +1,200 @@
|
||||
"""Administration: OpenAI-compatible connections and their models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import AdminUser, Db
|
||||
from lembas.db.models import Connection, Model
|
||||
from lembas.services.crypto import decrypt, encrypt, mask
|
||||
from lembas.services.llm.openai_client import Endpoint, LLMError, list_models
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
# Sent back in place of a stored key. If a submitted key still equals this, the
|
||||
# admin did not touch the field and the existing key must be kept -- otherwise
|
||||
# saving a name change would silently wipe the credential.
|
||||
UNCHANGED_SENTINEL = "•" * 12
|
||||
|
||||
|
||||
def _connection(db: DBSession, connection_id: str) -> Connection:
|
||||
connection = db.get(Connection, connection_id)
|
||||
if connection is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That connection no longer exists.")
|
||||
return connection
|
||||
|
||||
|
||||
def _connections(db: DBSession) -> list[Connection]:
|
||||
return list(db.scalars(select(Connection).order_by(Connection.position, Connection.name)))
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def admin_home(user: AdminUser):
|
||||
return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.get("/connections")
|
||||
async def connections_page(request: Request, db: Db, user: AdminUser, message: str = ""):
|
||||
connections = _connections(db)
|
||||
return render(
|
||||
request,
|
||||
"admin/connections.html",
|
||||
{
|
||||
"connections": connections,
|
||||
"masked": {c.id: mask(decrypt(c.api_key_encrypted)) for c in connections},
|
||||
"model_counts": {
|
||||
c.id: sum(1 for m in c.models if m.enabled) for c in connections
|
||||
},
|
||||
"message": message,
|
||||
"unchanged": UNCHANGED_SENTINEL,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/connections")
|
||||
async def create_connection(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
name: str = Form(...),
|
||||
base_url: str = Form(...),
|
||||
api_key: str = Form(""),
|
||||
) -> Response:
|
||||
base_url = base_url.strip().rstrip("/")
|
||||
if not base_url.startswith(("http://", "https://")):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"The base URL must start with http:// or https://",
|
||||
)
|
||||
|
||||
position = db.scalar(select(func.coalesce(func.max(Connection.position), -1))) + 1
|
||||
connection = Connection(
|
||||
name=name.strip()[:120] or "Connection",
|
||||
base_url=base_url,
|
||||
api_key_encrypted=encrypt(api_key.strip()),
|
||||
position=position,
|
||||
)
|
||||
db.add(connection)
|
||||
db.commit()
|
||||
|
||||
# Discover models immediately: a connection that lists nothing is
|
||||
# indistinguishable from a broken one, and finding out now is the point.
|
||||
await _refresh_models(db, connection)
|
||||
return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/connections/{connection_id}")
|
||||
async def update_connection(
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
connection_id: str,
|
||||
name: str = Form(...),
|
||||
base_url: str = Form(...),
|
||||
api_key: str = Form(""),
|
||||
enabled: bool = Form(False),
|
||||
) -> Response:
|
||||
connection = _connection(db, connection_id)
|
||||
connection.name = name.strip()[:120] or connection.name
|
||||
connection.base_url = base_url.strip().rstrip("/")
|
||||
connection.enabled = enabled
|
||||
|
||||
submitted = api_key.strip()
|
||||
if submitted and submitted != UNCHANGED_SENTINEL:
|
||||
connection.api_key_encrypted = encrypt(submitted)
|
||||
elif not submitted:
|
||||
# An explicitly emptied field means "this endpoint needs no key".
|
||||
connection.api_key_encrypted = ""
|
||||
|
||||
db.commit()
|
||||
return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.post("/connections/{connection_id}/test")
|
||||
async def test_connection(
|
||||
request: Request, db: Db, user: AdminUser, connection_id: str
|
||||
) -> Response:
|
||||
"""Contact the endpoint and refresh its model list."""
|
||||
connection = _connection(db, connection_id)
|
||||
count, error = await _refresh_models(db, connection)
|
||||
|
||||
message = (
|
||||
f"{connection.name}: {error}"
|
||||
if error
|
||||
else f"{connection.name}: found {count} model{'s' if count != 1 else ''}."
|
||||
)
|
||||
return render(
|
||||
request,
|
||||
"admin/_connection_row.html",
|
||||
{
|
||||
"connection": connection,
|
||||
"masked": mask(decrypt(connection.api_key_encrypted)),
|
||||
"message": message,
|
||||
"message_kind": "error" if error else "success",
|
||||
"unchanged": UNCHANGED_SENTINEL,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _refresh_models(db: DBSession, connection: Connection) -> tuple[int, str]:
|
||||
"""Sync the cached model list. Returns (count, error message)."""
|
||||
try:
|
||||
discovered = await list_models(Endpoint.from_connection(connection))
|
||||
except LLMError as exc:
|
||||
connection.last_error = exc.message
|
||||
connection.last_checked_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
return 0, exc.message
|
||||
|
||||
existing = {model.model_id: model for model in connection.models}
|
||||
seen: set[str] = set()
|
||||
|
||||
for entry in discovered:
|
||||
model_id = str(entry["id"])[:300]
|
||||
seen.add(model_id)
|
||||
if model_id in existing:
|
||||
continue
|
||||
db.add(Model(connection_id=connection.id, model_id=model_id))
|
||||
|
||||
# Models that vanished upstream are dropped, so the picker never offers
|
||||
# something the endpoint will reject.
|
||||
for model_id, model in existing.items():
|
||||
if model_id not in seen:
|
||||
db.delete(model)
|
||||
|
||||
connection.last_error = ""
|
||||
connection.last_checked_at = datetime.now(UTC)
|
||||
db.commit()
|
||||
log.info("connection %s: %d models", connection.name, len(seen))
|
||||
return len(seen), ""
|
||||
|
||||
|
||||
@router.post("/connections/{connection_id}/delete")
|
||||
async def delete_connection(db: Db, user: AdminUser, connection_id: str) -> Response:
|
||||
connection = _connection(db, connection_id)
|
||||
db.delete(connection)
|
||||
db.commit()
|
||||
return RedirectResponse("/admin/connections", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.get("/models")
|
||||
async def models_page(request: Request, db: Db, user: AdminUser):
|
||||
connections = _connections(db)
|
||||
return render(request, "admin/models.html", {"connections": connections})
|
||||
|
||||
|
||||
@router.post("/models/{model_id}/toggle")
|
||||
async def toggle_model(db: Db, user: AdminUser, model_id: str) -> Response:
|
||||
model = db.get(Model, model_id)
|
||||
if model is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That model no longer exists.")
|
||||
model.enabled = not model.enabled
|
||||
db.commit()
|
||||
return RedirectResponse("/admin/models", status_code=status.HTTP_303_SEE_OTHER)
|
||||
@@ -0,0 +1,186 @@
|
||||
"""Registration, sign-in and sign-out."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from lembas.api.deps import CurrentUser, Db
|
||||
from lembas.config import settings
|
||||
from lembas.db.models import ROLE_ADMIN, ROLE_USER, User
|
||||
from lembas.security.passwords import hash_password, validate_password, verify_password
|
||||
from lembas.security.sessions import COOKIE_NAME, create_session, revoke_session
|
||||
from lembas.web.templating import render
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _no_users_yet(db: Db) -> bool:
|
||||
return db.scalar(select(func.count()).select_from(User)) == 0
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
COOKIE_NAME,
|
||||
token,
|
||||
max_age=settings.session_ttl,
|
||||
httponly=True,
|
||||
# Lax is what makes this application CSRF-safe without tokens: the
|
||||
# cookie is not sent on cross-site POSTs, and every mutating route here
|
||||
# is a POST. Do not relax to "none".
|
||||
samesite="lax",
|
||||
# Only over HTTPS when the deployment is not plain local http. Marking
|
||||
# it secure on http would silently break sign-in for a LAN install.
|
||||
secure=False,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def _safe_next(raw: str | None) -> str:
|
||||
"""Reject open redirects: only same-origin absolute paths are allowed."""
|
||||
if not raw or not raw.startswith("/") or raw.startswith("//"):
|
||||
return "/"
|
||||
return raw
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login_form(request: Request, db: Db, user: CurrentUser, next: str = "/"):
|
||||
if user is not None:
|
||||
return RedirectResponse(_safe_next(next), status_code=status.HTTP_303_SEE_OTHER)
|
||||
# An empty database means this install has never been set up. Send the
|
||||
# first visitor straight to registration rather than to a login form they
|
||||
# cannot possibly satisfy.
|
||||
if _no_users_yet(db):
|
||||
return RedirectResponse("/auth/register", status_code=status.HTTP_303_SEE_OTHER)
|
||||
return render(request, "auth/login.html", {"next": _safe_next(next)})
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
async def login(
|
||||
request: Request,
|
||||
db: Db,
|
||||
email: str = Form(...),
|
||||
password: str = Form(...),
|
||||
next: str = Form("/"),
|
||||
):
|
||||
email = email.strip().lower()
|
||||
user = db.scalar(select(User).where(User.email == email))
|
||||
|
||||
# One message for "no such account" and "wrong password" alike, so the form
|
||||
# cannot be used to discover which addresses are registered.
|
||||
if user is None or not verify_password(password, user.password_hash):
|
||||
log.info("failed sign-in for %s", email)
|
||||
return render(
|
||||
request,
|
||||
"auth/login.html",
|
||||
{"error": "That email and password do not match.", "email": email,
|
||||
"next": _safe_next(next)},
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
if not user.active:
|
||||
return render(
|
||||
request,
|
||||
"auth/login.html",
|
||||
{"error": "This account has been deactivated. Ask an administrator.",
|
||||
"email": email, "next": _safe_next(next)},
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
token = create_session(
|
||||
db,
|
||||
user,
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
ip_address=request.client.host if request.client else "",
|
||||
)
|
||||
response = RedirectResponse(_safe_next(next), status_code=status.HTTP_303_SEE_OTHER)
|
||||
_set_session_cookie(response, token)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/register")
|
||||
async def register_form(request: Request, db: Db, user: CurrentUser):
|
||||
if user is not None:
|
||||
return RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
||||
first_run = _no_users_yet(db)
|
||||
if not first_run and not settings.allow_signup:
|
||||
return render(
|
||||
request,
|
||||
"auth/login.html",
|
||||
{"error": "Registration is closed. Ask an administrator for an account."},
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
return render(request, "auth/register.html", {"first_run": first_run})
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(
|
||||
request: Request,
|
||||
db: Db,
|
||||
name: str = Form(...),
|
||||
email: str = Form(...),
|
||||
password: str = Form(...),
|
||||
):
|
||||
first_run = _no_users_yet(db)
|
||||
if not first_run and not settings.allow_signup:
|
||||
return render(
|
||||
request,
|
||||
"auth/login.html",
|
||||
{"error": "Registration is closed. Ask an administrator for an account."},
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
name = name.strip()
|
||||
email = email.strip().lower()
|
||||
|
||||
def fail(message: str) -> Response:
|
||||
return render(
|
||||
request,
|
||||
"auth/register.html",
|
||||
{"error": message, "name": name, "email": email, "first_run": first_run},
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not name:
|
||||
return fail("Please enter a name.")
|
||||
if "@" not in email or "." not in email.split("@")[-1]:
|
||||
return fail("Please enter a valid email address.")
|
||||
if (problem := validate_password(password)) is not None:
|
||||
return fail(problem)
|
||||
if db.scalar(select(User).where(User.email == email)) is not None:
|
||||
return fail("An account with that email already exists.")
|
||||
|
||||
# Whoever sets the instance up owns it. Everyone after that is a plain user
|
||||
# until an admin says otherwise.
|
||||
user = User(
|
||||
name=name,
|
||||
email=email,
|
||||
password_hash=hash_password(password),
|
||||
role=ROLE_ADMIN if first_run else ROLE_USER,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
log.info("registered %s as %s", email, user.role)
|
||||
|
||||
token = create_session(
|
||||
db,
|
||||
user,
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
ip_address=request.client.host if request.client else "",
|
||||
)
|
||||
response = RedirectResponse("/", status_code=status.HTTP_303_SEE_OTHER)
|
||||
_set_session_cookie(response, token)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(request: Request, db: Db):
|
||||
revoke_session(db, request.cookies.get(COOKIE_NAME))
|
||||
response = RedirectResponse("/auth/login", status_code=status.HTTP_303_SEE_OTHER)
|
||||
response.delete_cookie(COOKIE_NAME, path="/")
|
||||
return response
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Chat creation, messaging and the streaming reply endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request, status
|
||||
from fastapi.responses import HTMLResponse, Response, StreamingResponse
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.db.models import ROLE_ASSISTANT, ROLE_USER, Chat, Message, User
|
||||
from lembas.db.session import session_scope
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services import sse
|
||||
from lembas.services.llm.openai_client import LLMError, delta_text, stream_chat
|
||||
from lembas.services.markdown import escape_text, render_markdown
|
||||
from lembas.web.templating import render, templates
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/chats", tags=["chats"])
|
||||
|
||||
|
||||
def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
|
||||
chat = db.get(Chat, chat_id)
|
||||
# 404 rather than 403 for someone else's chat: whether a given id exists is
|
||||
# not information this endpoint should hand out.
|
||||
if chat is None or chat.user_id != user_id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
|
||||
return chat
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_chat(db: Db, user: RequiredUser, folder_id: str = Form("")) -> Response:
|
||||
chosen = chat_service.default_model(db)
|
||||
chat = Chat(
|
||||
user_id=user.id,
|
||||
folder_id=folder_id or None,
|
||||
model_id=chosen[0] if chosen else "",
|
||||
connection_id=chosen[1] if chosen else None,
|
||||
)
|
||||
db.add(chat)
|
||||
db.commit()
|
||||
|
||||
# HX-Redirect rather than a swap: a new chat is a new URL, and the address
|
||||
# bar has to follow so the chat can be reloaded or bookmarked.
|
||||
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
response.headers["HX-Redirect"] = f"/chat/{chat.id}"
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/{chat_id}/messages")
|
||||
async def post_message(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
chat_id: str,
|
||||
content: str = Form(...),
|
||||
) -> Response:
|
||||
"""Persist the user's turn and hand back the pair of bubbles.
|
||||
|
||||
The assistant bubble comes back empty, carrying the sse-connect attribute
|
||||
that opens the stream below. Splitting it this way means the POST returns
|
||||
immediately and the slow part is a separate, resumable connection.
|
||||
"""
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
|
||||
content = content.strip()
|
||||
if not content:
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
|
||||
assistant_message = chat_service.create_message(
|
||||
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
|
||||
)
|
||||
|
||||
# `user` is required by the shared message template, which renders both
|
||||
# roles; without it the user bubble's initial blows up.
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"chat/_turn.html",
|
||||
{
|
||||
"request": request,
|
||||
"user_message": user_message,
|
||||
"assistant_message": assistant_message,
|
||||
"chat": chat,
|
||||
"user": user,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{chat_id}/messages/{message_id}/stream")
|
||||
async def stream_message(
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
) -> Response:
|
||||
"""Stream the assistant's reply as server-sent events.
|
||||
|
||||
Emits `token` events carrying escaped text, then a single `done` event
|
||||
carrying the finished bubble rendered from Markdown, then `close`.
|
||||
"""
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
message = db.get(Message, message_id)
|
||||
if message is None or message.chat_id != chat.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
|
||||
|
||||
return StreamingResponse(
|
||||
_generate(chat.id, message.id),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
"Connection": "keep-alive",
|
||||
# nginx buffers proxied responses by default, which turns a stream
|
||||
# into one delivery at the end. This is the documented opt-out.
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]:
|
||||
"""Drive one completion and frame it as SSE.
|
||||
|
||||
Opens its own database session rather than using the request's: streaming
|
||||
outlives the request handler, and the dependency-scoped session may already
|
||||
be closed by the time the first token arrives.
|
||||
"""
|
||||
accumulated: list[str] = []
|
||||
error: str | None = None
|
||||
|
||||
with session_scope() as db:
|
||||
chat = db.get(Chat, chat_id)
|
||||
message = db.get(Message, message_id)
|
||||
if chat is None or message is None:
|
||||
yield sse.event("close", "")
|
||||
return
|
||||
|
||||
first_user_text = ""
|
||||
try:
|
||||
endpoint, model_id = chat_service.resolve_endpoint(db, chat)
|
||||
payload = chat_service.build_request(db, chat, upto=message)
|
||||
first_user_text = next(
|
||||
(m["content"] for m in reversed(payload["messages"]) if m["role"] == ROLE_USER),
|
||||
"",
|
||||
)
|
||||
|
||||
async for chunk in stream_chat(endpoint, payload):
|
||||
text = delta_text(chunk)
|
||||
if not text:
|
||||
continue
|
||||
accumulated.append(text)
|
||||
yield sse.event("token", escape_text(text))
|
||||
# Hand control back so the event is flushed rather than
|
||||
# batched behind a fast generator.
|
||||
await asyncio.sleep(0)
|
||||
|
||||
except LLMError as exc:
|
||||
error = exc.message
|
||||
log.info("generation failed for chat %s: %s", chat_id, exc.message)
|
||||
except asyncio.CancelledError:
|
||||
# The reader navigated away or closed the tab. Keep whatever was
|
||||
# produced so the partial reply is still there on reload.
|
||||
message.content = "".join(accumulated)
|
||||
message.complete = True
|
||||
db.commit()
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 - must not kill the stream silently
|
||||
error = "Something went wrong while generating this reply."
|
||||
log.exception("unexpected generation failure for chat %s: %s", chat_id, exc)
|
||||
|
||||
message.content = "".join(accumulated)
|
||||
message.error = error or ""
|
||||
message.complete = True
|
||||
|
||||
if not chat.title_generated and (accumulated or error):
|
||||
chat.title = (
|
||||
await chat_service.generate_title(
|
||||
endpoint, model_id, first_user_text, message.content
|
||||
)
|
||||
if not error and first_user_text
|
||||
else chat_service.fallback_title(first_user_text)
|
||||
)
|
||||
chat.title_generated = True
|
||||
|
||||
db.commit()
|
||||
|
||||
final_html = templates.get_template("chat/_message.html").render(
|
||||
{
|
||||
"message": message,
|
||||
"body_html": render_markdown(message.content),
|
||||
"chat": chat,
|
||||
# Passed even though an assistant bubble never reads it: the
|
||||
# template shares both roles, and a missing `user` would only
|
||||
# blow up on whichever branch is not being exercised here.
|
||||
"user": db.get(User, chat.user_id),
|
||||
}
|
||||
)
|
||||
title_html = templates.get_template("chat/_title_oob.html").render(
|
||||
{"chat": chat}
|
||||
)
|
||||
|
||||
yield sse.event("done", final_html + title_html)
|
||||
yield sse.event("close", "")
|
||||
|
||||
|
||||
@router.patch("/{chat_id}")
|
||||
async def update_chat(
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
chat_id: str,
|
||||
title: str | None = Form(None),
|
||||
folder_id: str | None = Form(None),
|
||||
model_id: str | None = Form(None),
|
||||
) -> Response:
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
|
||||
if title is not None:
|
||||
cleaned = title.strip()[:300]
|
||||
if cleaned:
|
||||
chat.title = cleaned
|
||||
# An explicit rename must not be overwritten by auto-titling later.
|
||||
chat.title_generated = True
|
||||
|
||||
if folder_id is not None:
|
||||
chat.folder_id = folder_id or None
|
||||
|
||||
if model_id is not None and model_id:
|
||||
chat.model_id = model_id
|
||||
match = next(
|
||||
(m for m in chat_service.available_models(db) if m.model_id == model_id), None
|
||||
)
|
||||
chat.connection_id = match.connection_id if match else None
|
||||
|
||||
db.commit()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.delete("/{chat_id}")
|
||||
async def delete_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
db.delete(chat)
|
||||
db.commit()
|
||||
|
||||
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
response.headers["HX-Redirect"] = "/chat"
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/{chat_id}/messages/{message_id}/raw")
|
||||
async def raw_message(db: Db, user: RequiredUser, chat_id: str, message_id: str) -> HTMLResponse:
|
||||
"""The unrendered Markdown of a message, for the copy button."""
|
||||
_owned_chat(db, chat_id, user.id)
|
||||
message = db.get(Message, message_id)
|
||||
if message is None or message.chat_id != chat_id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
|
||||
return HTMLResponse(escape_text(message.content))
|
||||
|
||||
|
||||
@router.post("/{chat_id}/messages/{message_id}/regenerate")
|
||||
async def regenerate(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
chat_id: str,
|
||||
message_id: str,
|
||||
) -> Response:
|
||||
"""Discard an assistant reply and produce a fresh one in its place."""
|
||||
chat = _owned_chat(db, chat_id, user.id)
|
||||
message = db.get(Message, message_id)
|
||||
if message is None or message.chat_id != chat.id or message.role != ROLE_ASSISTANT:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That reply no longer exists.")
|
||||
|
||||
message.content = ""
|
||||
message.error = ""
|
||||
message.complete = False
|
||||
message.model_id = chat.model_id
|
||||
db.commit()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"chat/_message.html",
|
||||
{"request": request, "message": message, "chat": chat, "body_html": "", "user": user},
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["render", "router"]
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Folder management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Response, status
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.db.models import Folder
|
||||
|
||||
router = APIRouter(prefix="/api/folders", tags=["folders"])
|
||||
|
||||
MAX_DEPTH = 8
|
||||
|
||||
|
||||
def _owned_folder(db: DBSession, folder_id: str, user_id: str) -> Folder:
|
||||
folder = db.get(Folder, folder_id)
|
||||
if folder is None or folder.user_id != user_id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That folder no longer exists.")
|
||||
return folder
|
||||
|
||||
|
||||
def _depth_of(db: DBSession, folder: Folder | None) -> int:
|
||||
depth = 0
|
||||
seen: set[str] = set()
|
||||
while folder is not None and folder.id not in seen:
|
||||
seen.add(folder.id)
|
||||
depth += 1
|
||||
folder = db.get(Folder, folder.parent_id) if folder.parent_id else None
|
||||
return depth
|
||||
|
||||
|
||||
def _refresh_sidebar() -> Response:
|
||||
"""Tell the browser to reload so the tree re-renders.
|
||||
|
||||
The folder tree is recursive and a change can move any part of it, so
|
||||
re-rendering the whole sidebar server-side is both simpler and less
|
||||
error-prone than trying to patch individual nodes over the wire.
|
||||
"""
|
||||
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
response.headers["HX-Refresh"] = "true"
|
||||
return response
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_folder(
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
name: str = Form("New folder"),
|
||||
parent_id: str = Form(""),
|
||||
) -> Response:
|
||||
parent = _owned_folder(db, parent_id, user.id) if parent_id else None
|
||||
|
||||
# A cap on nesting, so a runaway client cannot build a tree deep enough to
|
||||
# blow the recursion limit in the template.
|
||||
if parent is not None and _depth_of(db, parent) >= MAX_DEPTH:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
f"Folders cannot be nested more than {MAX_DEPTH} deep.",
|
||||
)
|
||||
|
||||
db.add(
|
||||
Folder(
|
||||
user_id=user.id,
|
||||
name=name.strip()[:200] or "New folder",
|
||||
parent_id=parent.id if parent else None,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return _refresh_sidebar()
|
||||
|
||||
|
||||
@router.patch("/{folder_id}")
|
||||
async def update_folder(
|
||||
db: Db,
|
||||
user: RequiredUser,
|
||||
folder_id: str,
|
||||
name: str | None = Form(None),
|
||||
parent_id: str | None = Form(None),
|
||||
collapsed: bool | None = Form(None),
|
||||
) -> Response:
|
||||
folder = _owned_folder(db, folder_id, user.id)
|
||||
|
||||
if name is not None and name.strip():
|
||||
folder.name = name.strip()[:200]
|
||||
|
||||
if parent_id is not None:
|
||||
new_parent = _owned_folder(db, parent_id, user.id) if parent_id else None
|
||||
# Reparenting a folder into its own subtree would detach that subtree
|
||||
# from the root and make it unreachable.
|
||||
cursor = new_parent
|
||||
while cursor is not None:
|
||||
if cursor.id == folder.id:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
"A folder cannot be moved inside itself.",
|
||||
)
|
||||
cursor = db.get(Folder, cursor.parent_id) if cursor.parent_id else None
|
||||
folder.parent_id = new_parent.id if new_parent else None
|
||||
|
||||
if collapsed is not None:
|
||||
folder.collapsed = collapsed
|
||||
|
||||
db.commit()
|
||||
return _refresh_sidebar()
|
||||
|
||||
|
||||
@router.delete("/{folder_id}")
|
||||
async def delete_folder(db: Db, user: RequiredUser, folder_id: str) -> Response:
|
||||
"""Delete a folder. Child folders go with it; chats do not.
|
||||
|
||||
Chats fall back to the unfiled list (the FK is ON DELETE SET NULL), because
|
||||
losing a conversation to a mis-clicked folder delete is unforgivable.
|
||||
"""
|
||||
folder = _owned_folder(db, folder_id, user.id)
|
||||
db.delete(folder)
|
||||
db.commit()
|
||||
return _refresh_sidebar()
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Full-page routes: the chat shell and the user's own settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
from lembas.db.models import Chat, Folder, Message, User
|
||||
from lembas.services import chat as chat_service
|
||||
from lembas.services.markdown import render_markdown
|
||||
from lembas.web.templating import render
|
||||
|
||||
router = APIRouter(tags=["pages"])
|
||||
|
||||
|
||||
def _sidebar_context(db: DBSession, user: User) -> dict:
|
||||
"""Folder tree plus the chats that belong to no folder.
|
||||
|
||||
Only root folders are queried; children come through the relationship and
|
||||
render recursively in the template.
|
||||
"""
|
||||
folders = list(
|
||||
db.scalars(
|
||||
select(Folder)
|
||||
.where(Folder.user_id == user.id, Folder.parent_id.is_(None))
|
||||
.order_by(Folder.position, Folder.name)
|
||||
)
|
||||
)
|
||||
unfiled = list(
|
||||
db.scalars(
|
||||
select(Chat)
|
||||
.where(
|
||||
Chat.user_id == user.id,
|
||||
Chat.folder_id.is_(None),
|
||||
Chat.archived.is_(False),
|
||||
)
|
||||
.order_by(Chat.pinned.desc(), Chat.updated_at.desc())
|
||||
)
|
||||
)
|
||||
return {"folders": folders, "unfiled_chats": unfiled}
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def home(user: RequiredUser):
|
||||
return RedirectResponse("/chat", status_code=status.HTTP_303_SEE_OTHER)
|
||||
|
||||
|
||||
@router.get("/chat")
|
||||
async def chat_index(request: Request, db: Db, user: RequiredUser):
|
||||
return render(
|
||||
request,
|
||||
"chat/index.html",
|
||||
{
|
||||
"chat": None,
|
||||
"messages": [],
|
||||
"models": chat_service.available_models(db),
|
||||
**_sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/chat/{chat_id}")
|
||||
async def chat_detail(request: Request, db: Db, user: RequiredUser, chat_id: str):
|
||||
chat = db.get(Chat, chat_id)
|
||||
if chat is None or chat.user_id != user.id:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
|
||||
|
||||
messages = list(
|
||||
db.scalars(
|
||||
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
|
||||
)
|
||||
)
|
||||
|
||||
# Markdown is rendered once here rather than in the template so the same
|
||||
# helper produces the page and the streamed final frame -- one code path,
|
||||
# no chance of the two disagreeing.
|
||||
bodies = {
|
||||
message.id: render_markdown(message.content)
|
||||
for message in messages
|
||||
if message.role == "assistant" and message.content
|
||||
}
|
||||
|
||||
return render(
|
||||
request,
|
||||
"chat/index.html",
|
||||
{
|
||||
"chat": chat,
|
||||
"messages": messages,
|
||||
"bodies": bodies,
|
||||
"models": chat_service.available_models(db),
|
||||
**_sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/settings")
|
||||
async def settings_page(request: Request, db: Db, user: RequiredUser):
|
||||
return render(
|
||||
request,
|
||||
"settings.html",
|
||||
{
|
||||
"chat": None,
|
||||
"models": chat_service.available_models(db),
|
||||
**_sidebar_context(db, user),
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Per-user preferences set from the browser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from lembas.api.deps import Db, RequiredUser
|
||||
|
||||
router = APIRouter(prefix="/api/preferences", tags=["preferences"])
|
||||
|
||||
THEMES = ("moria", "shire")
|
||||
|
||||
|
||||
@router.post("/theme")
|
||||
async def set_theme(db: Db, user: RequiredUser, theme: str = Body(..., embed=True)) -> dict:
|
||||
"""Mirror the browser's theme choice onto the account.
|
||||
|
||||
localStorage is the source of truth for the current tab; this is what makes
|
||||
the choice follow the user to another browser, and what lets the server
|
||||
render the right theme on first paint instead of flashing the default.
|
||||
"""
|
||||
if theme not in THEMES:
|
||||
return {"ok": False, "detail": "Unknown theme."}
|
||||
|
||||
# Replaced rather than mutated in place: SQLAlchemy only reliably detects
|
||||
# a change to a JSON column when the whole value is reassigned.
|
||||
user.settings_json = {**(user.settings_json or {}), "theme": theme}
|
||||
db.commit()
|
||||
return {"ok": True, "theme": theme}
|
||||
@@ -0,0 +1,112 @@
|
||||
"""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()
|
||||
+14
-8
@@ -7,7 +7,7 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic import Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ class Settings(BaseSettings):
|
||||
)
|
||||
|
||||
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"
|
||||
@@ -34,13 +38,15 @@ class Settings(BaseSettings):
|
||||
session_ttl: int = 60 * 60 * 24 * 30
|
||||
request_timeout: float = 300.0
|
||||
|
||||
@field_validator("secret_key")
|
||||
@classmethod
|
||||
def _generate_secret_if_absent(cls, v: str) -> str:
|
||||
# A generated key lets `lembas serve` work out of the box, but it changes
|
||||
# on every restart: sessions drop and stored API keys become unreadable.
|
||||
# main.py warns loudly about this. Never rely on it in production.
|
||||
return v or secrets.token_urlsafe(48)
|
||||
@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:
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Application factory, lifespan and error handling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from lembas import __version__
|
||||
from lembas.api import admin, auth, chats, folders, pages, preferences
|
||||
from lembas.api.deps import RedirectToLogin, is_htmx, login_redirect
|
||||
from lembas.config import settings
|
||||
from lembas.db.session import init_db
|
||||
from lembas.web.templating import STATIC_DIR, render
|
||||
|
||||
log = logging.getLogger("lembas")
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
logging.basicConfig(
|
||||
level=settings.log_level.upper(),
|
||||
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||
configure_logging()
|
||||
settings.ensure_dirs()
|
||||
init_db()
|
||||
|
||||
if settings.secret_key_is_ephemeral:
|
||||
log.warning(
|
||||
"No LEMBAS_SECRET_KEY set, so a temporary one was generated. Every "
|
||||
"restart will sign all users out and make stored API keys "
|
||||
"unreadable. Generate a permanent key with:\n"
|
||||
' python -c "import secrets; print(secrets.token_urlsafe(48))"'
|
||||
)
|
||||
|
||||
log.info("LLeMbas %s starting on http://%s:%s", __version__, settings.host, settings.port)
|
||||
log.info("data directory: %s", settings.data_dir.resolve())
|
||||
yield
|
||||
log.info("LLeMbas stopped")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(
|
||||
title="LLeMbas",
|
||||
version=__version__,
|
||||
lifespan=lifespan,
|
||||
# The API is an implementation detail of the UI, not a product surface.
|
||||
docs_url="/api/docs" if settings.log_level == "debug" else None,
|
||||
redoc_url=None,
|
||||
)
|
||||
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
app.include_router(pages.router)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(preferences.router)
|
||||
app.include_router(chats.router)
|
||||
app.include_router(folders.router)
|
||||
app.include_router(admin.router)
|
||||
|
||||
register_error_handlers(app)
|
||||
return app
|
||||
|
||||
|
||||
def register_error_handlers(app: FastAPI) -> None:
|
||||
@app.exception_handler(RedirectToLogin)
|
||||
async def _not_signed_in(request: Request, exc: RedirectToLogin) -> Response:
|
||||
# An htmx request must not swap a login page into a fragment of the
|
||||
# chat UI, so tell the browser to navigate instead.
|
||||
if is_htmx(request):
|
||||
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
response.headers["HX-Redirect"] = "/auth/login"
|
||||
return response
|
||||
return login_redirect(exc.next_url)
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def _http_error(request: Request, exc: StarletteHTTPException) -> Response:
|
||||
# JSON callers and htmx fragments want the bare status; humans loading a
|
||||
# page want a themed page they can navigate away from.
|
||||
wants_page = "text/html" in request.headers.get("accept", "") and not is_htmx(request)
|
||||
if not wants_page:
|
||||
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"error.html",
|
||||
{
|
||||
"status_code": exc.status_code,
|
||||
"detail": exc.detail,
|
||||
"flavour": ERROR_FLAVOUR.get(exc.status_code, ERROR_FLAVOUR[500]),
|
||||
},
|
||||
status_code=exc.status_code,
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def _unhandled(request: Request, exc: Exception) -> Response:
|
||||
log.exception("unhandled error at %s", request.url.path)
|
||||
if is_htmx(request) or "text/html" not in request.headers.get("accept", ""):
|
||||
return JSONResponse({"detail": "Internal server error"}, status_code=500)
|
||||
return render(
|
||||
request,
|
||||
"error.html",
|
||||
{"status_code": 500, "detail": "Something went wrong.",
|
||||
"flavour": ERROR_FLAVOUR[500]},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
# Flavour lives in error pages, empty states and theme names -- never in the
|
||||
# functional UI. See CLAUDE.md.
|
||||
ERROR_FLAVOUR = {
|
||||
403: "Speak, friend, and enter. This door is not yours to open.",
|
||||
404: "Not all those who wander are lost. This page, however, is.",
|
||||
500: "The Road goes ever on, but this stretch of it has washed out.",
|
||||
}
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -8,7 +8,7 @@ defaults tighten in a future release.
|
||||
from __future__ import annotations
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import InvalidHashError, VerifyMismatchError, VerificationError
|
||||
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Chat orchestration: building requests, streaming replies, naming chats."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session as DBSession
|
||||
|
||||
from lembas.db.models import (
|
||||
ROLE_ASSISTANT,
|
||||
ROLE_SYSTEM,
|
||||
ROLE_USER,
|
||||
Chat,
|
||||
Connection,
|
||||
Message,
|
||||
Model,
|
||||
)
|
||||
from lembas.services.llm.openai_client import Endpoint, LLMError, complete
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Sampling keys forwarded upstream. Anything else a user puts in params_json is
|
||||
# ignored rather than passed through, so a typo cannot produce a 400 from the
|
||||
# provider that looks like a LLeMbas bug.
|
||||
FORWARDED_PARAMS = frozenset(
|
||||
{"temperature", "top_p", "max_tokens", "presence_penalty", "frequency_penalty",
|
||||
"seed", "stop"}
|
||||
)
|
||||
|
||||
MAX_TITLE_LENGTH = 60
|
||||
|
||||
|
||||
def resolve_endpoint(db: DBSession, chat: Chat) -> tuple[Endpoint, str]:
|
||||
"""Find the connection and model a chat should use.
|
||||
|
||||
Chats store the model id as text rather than a foreign key so history
|
||||
survives an admin deleting a connection, which means the mapping back to a
|
||||
live connection has to be resolved at send time and can legitimately fail.
|
||||
"""
|
||||
if not chat.model_id:
|
||||
raise LLMError("This chat has no model selected.")
|
||||
|
||||
connection: Connection | None = None
|
||||
if chat.connection_id:
|
||||
connection = db.get(Connection, chat.connection_id)
|
||||
|
||||
if connection is None or not connection.enabled:
|
||||
# The original connection is gone or disabled. Any enabled connection
|
||||
# still offering this model id will do.
|
||||
model = db.scalar(
|
||||
select(Model)
|
||||
.join(Connection)
|
||||
.where(
|
||||
Model.model_id == chat.model_id,
|
||||
Model.enabled.is_(True),
|
||||
Connection.enabled.is_(True),
|
||||
)
|
||||
.order_by(Connection.position)
|
||||
)
|
||||
if model is None:
|
||||
raise LLMError(
|
||||
f"No enabled connection currently offers the model "
|
||||
f"'{chat.model_id}'. Pick another model for this chat."
|
||||
)
|
||||
connection = model.connection
|
||||
chat.connection_id = connection.id
|
||||
db.commit()
|
||||
|
||||
return Endpoint.from_connection(connection), chat.model_id
|
||||
|
||||
|
||||
def build_messages(db: DBSession, chat: Chat, *, upto: Message | None = None) -> list[dict]:
|
||||
"""Assemble the message list to send upstream.
|
||||
|
||||
`upto` excludes the placeholder assistant row being generated into, and
|
||||
everything after it.
|
||||
"""
|
||||
payload: list[dict[str, Any]] = []
|
||||
if chat.system_prompt.strip():
|
||||
payload.append({"role": ROLE_SYSTEM, "content": chat.system_prompt.strip()})
|
||||
|
||||
history = db.scalars(
|
||||
select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at)
|
||||
).all()
|
||||
|
||||
for message in history:
|
||||
if upto is not None and message.id == upto.id:
|
||||
break
|
||||
# Skip turns that failed or produced nothing: sending an empty
|
||||
# assistant message upsets several providers.
|
||||
if message.error or not message.content.strip():
|
||||
continue
|
||||
payload.append({"role": message.role, "content": message.content})
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def build_request(db: DBSession, chat: Chat, *, upto: Message | None = None) -> dict[str, Any]:
|
||||
params = {
|
||||
key: value
|
||||
for key, value in (chat.params_json or {}).items()
|
||||
if key in FORWARDED_PARAMS and value not in (None, "")
|
||||
}
|
||||
return {
|
||||
"model": chat.model_id,
|
||||
"messages": build_messages(db, chat, upto=upto),
|
||||
**params,
|
||||
}
|
||||
|
||||
|
||||
def default_model(db: DBSession) -> tuple[str, str] | None:
|
||||
"""First enabled model on the first enabled connection, or None."""
|
||||
model = db.scalar(
|
||||
select(Model)
|
||||
.join(Connection)
|
||||
.where(Model.enabled.is_(True), Connection.enabled.is_(True))
|
||||
.order_by(Connection.position, Model.model_id)
|
||||
)
|
||||
if model is None:
|
||||
return None
|
||||
return model.model_id, model.connection_id
|
||||
|
||||
|
||||
def available_models(db: DBSession) -> list[Model]:
|
||||
return list(
|
||||
db.scalars(
|
||||
select(Model)
|
||||
.join(Connection)
|
||||
.where(Model.enabled.is_(True), Connection.enabled.is_(True))
|
||||
.order_by(Connection.position, Model.model_id)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def fallback_title(text: str) -> str:
|
||||
"""Derive a chat title from the opening message, without calling a model."""
|
||||
cleaned = " ".join(text.split())
|
||||
if not cleaned:
|
||||
return "New chat"
|
||||
if len(cleaned) <= MAX_TITLE_LENGTH:
|
||||
return cleaned
|
||||
# Prefer a word boundary, but only if it does not cut the title in half.
|
||||
clipped = cleaned[:MAX_TITLE_LENGTH]
|
||||
space = clipped.rfind(" ")
|
||||
if space > MAX_TITLE_LENGTH * 0.6:
|
||||
clipped = clipped[:space]
|
||||
return clipped.rstrip(" ,.;:-") + "…"
|
||||
|
||||
|
||||
async def generate_title(endpoint: Endpoint, model_id: str, question: str, answer: str) -> str:
|
||||
"""Ask the model for a short chat title.
|
||||
|
||||
Best-effort by design: any failure falls back to trimming the first
|
||||
message. Naming a chat is never worth surfacing an error for.
|
||||
"""
|
||||
prompt = (
|
||||
"Summarise this exchange as a title of at most six words. "
|
||||
"Reply with the title alone: no quotes, no punctuation at the end, "
|
||||
"no preamble.\n\n"
|
||||
f"User: {question[:500]}\n\nAssistant: {answer[:500]}"
|
||||
)
|
||||
try:
|
||||
raw = await complete(
|
||||
endpoint,
|
||||
{
|
||||
"model": model_id,
|
||||
"messages": [{"role": ROLE_USER, "content": prompt}],
|
||||
"max_tokens": 24,
|
||||
"temperature": 0.2,
|
||||
},
|
||||
)
|
||||
except LLMError as exc:
|
||||
log.debug("auto-title failed, using fallback: %s", exc)
|
||||
return fallback_title(question)
|
||||
|
||||
title = " ".join(raw.split()).strip().strip('"“”\'')
|
||||
# Small models sometimes ignore the instruction and answer the question
|
||||
# instead; an over-long reply is a better signal of that than anything else.
|
||||
if not title or len(title) > MAX_TITLE_LENGTH * 1.5:
|
||||
return fallback_title(question)
|
||||
return title[:MAX_TITLE_LENGTH]
|
||||
|
||||
|
||||
def create_message(
|
||||
db: DBSession,
|
||||
chat: Chat,
|
||||
role: str,
|
||||
content: str = "",
|
||||
*,
|
||||
complete_: bool = True,
|
||||
model_id: str = "",
|
||||
) -> Message:
|
||||
message = Message(
|
||||
chat_id=chat.id,
|
||||
role=role,
|
||||
content=content,
|
||||
complete=complete_,
|
||||
model_id=model_id,
|
||||
)
|
||||
db.add(message)
|
||||
db.commit()
|
||||
return message
|
||||
|
||||
|
||||
def user_chats(db: DBSession, user_id: str, *, folder_id: str | None = None) -> list[Chat]:
|
||||
query = select(Chat).where(Chat.user_id == user_id, Chat.archived.is_(False))
|
||||
if folder_id is not None:
|
||||
query = query.where(Chat.folder_id == folder_id)
|
||||
return list(db.scalars(query.order_by(Chat.pinned.desc(), Chat.updated_at.desc())))
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ROLE_ASSISTANT",
|
||||
"ROLE_USER",
|
||||
"available_models",
|
||||
"build_request",
|
||||
"create_message",
|
||||
"default_model",
|
||||
"fallback_title",
|
||||
"generate_title",
|
||||
"resolve_endpoint",
|
||||
"user_chats",
|
||||
]
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Client for OpenAI-compatible chat endpoints.
|
||||
|
||||
Deliberately plain httpx rather than the official SDK. The target is not just
|
||||
api.openai.com but LM Studio, vLLM, llama.cpp, Ollama's compatibility layer,
|
||||
OpenRouter and anything else exposing /v1 -- and they differ in small ways. A
|
||||
thin client passes request parameters through untouched and is tolerant about
|
||||
what comes back, which is exactly what talking to all of them requires.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from lembas.config import settings
|
||||
from lembas.db.models import Connection
|
||||
from lembas.services.crypto import decrypt
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMError(Exception):
|
||||
"""An upstream failure with a message fit to show a user.
|
||||
|
||||
Every failure path in this module raises this rather than letting an httpx
|
||||
or JSON exception escape, so callers have exactly one thing to catch and
|
||||
the chat UI always has something intelligible to display.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, *, status_code: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Endpoint:
|
||||
"""Everything needed to call a connection, with the key already decrypted.
|
||||
|
||||
A frozen snapshot rather than the ORM object because streaming outlives the
|
||||
request that started it, and a detached SQLAlchemy instance is a trap.
|
||||
"""
|
||||
|
||||
base_url: str
|
||||
api_key: str
|
||||
extra_headers: dict[str, str]
|
||||
name: str = ""
|
||||
|
||||
@classmethod
|
||||
def from_connection(cls, connection: Connection) -> Endpoint:
|
||||
return cls(
|
||||
base_url=connection.base_url.rstrip("/"),
|
||||
api_key=decrypt(connection.api_key_encrypted),
|
||||
extra_headers=dict(connection.extra_headers_json or {}),
|
||||
name=connection.name,
|
||||
)
|
||||
|
||||
def url(self, path: str) -> str:
|
||||
# Accept both "http://host:1234" and "http://host:1234/v1" so users do
|
||||
# not have to guess which form this expects.
|
||||
base = self.base_url
|
||||
if not base.endswith("/v1") and "/v1/" not in base:
|
||||
base = f"{base}/v1"
|
||||
return f"{base}/{path.lstrip('/')}"
|
||||
|
||||
def headers(self) -> dict[str, str]:
|
||||
headers = {"Content-Type": "application/json", **self.extra_headers}
|
||||
# Local endpoints frequently need no key at all; sending an empty
|
||||
# bearer token makes some of them reject the request outright.
|
||||
if self.api_key:
|
||||
headers["Authorization"] = f"Bearer {self.api_key}"
|
||||
return headers
|
||||
|
||||
|
||||
def _describe_http_error(exc: httpx.HTTPStatusError) -> str:
|
||||
"""Turn an upstream error response into something worth reading.
|
||||
|
||||
Providers put the useful part in wildly different places, so try the common
|
||||
shapes before falling back to the raw body.
|
||||
"""
|
||||
status = exc.response.status_code
|
||||
detail = ""
|
||||
try:
|
||||
payload = exc.response.json()
|
||||
if isinstance(payload, dict):
|
||||
error = payload.get("error")
|
||||
if isinstance(error, dict):
|
||||
detail = error.get("message", "")
|
||||
elif isinstance(error, str):
|
||||
detail = error
|
||||
detail = detail or payload.get("message", "") or payload.get("detail", "")
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
detail = exc.response.text[:300]
|
||||
|
||||
friendly = {
|
||||
401: "The API key was rejected.",
|
||||
403: "The API key is not permitted to use this model.",
|
||||
404: "The endpoint or model was not found.",
|
||||
429: "Rate limited by the provider.",
|
||||
}.get(status)
|
||||
|
||||
if friendly and detail:
|
||||
return f"{friendly} {detail}"
|
||||
return friendly or detail or f"The endpoint returned HTTP {status}."
|
||||
|
||||
|
||||
def _wrap_transport_error(exc: httpx.RequestError, endpoint: Endpoint) -> LLMError:
|
||||
if isinstance(exc, httpx.ConnectError):
|
||||
return LLMError(
|
||||
f"Could not reach {endpoint.base_url}. Is the endpoint running and "
|
||||
f"the URL correct?"
|
||||
)
|
||||
if isinstance(exc, httpx.TimeoutException):
|
||||
return LLMError(
|
||||
f"{endpoint.base_url} did not respond within "
|
||||
f"{settings.request_timeout:.0f}s."
|
||||
)
|
||||
return LLMError(f"Could not reach {endpoint.base_url}: {exc}")
|
||||
|
||||
|
||||
async def list_models(endpoint: Endpoint) -> list[dict[str, Any]]:
|
||||
"""Fetch the models a connection advertises via GET /v1/models."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(endpoint.url("models"), headers=endpoint.headers())
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise LLMError(_describe_http_error(exc), status_code=exc.response.status_code) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise _wrap_transport_error(exc, endpoint) from exc
|
||||
except ValueError as exc:
|
||||
raise LLMError("The endpoint returned a response that was not JSON.") from exc
|
||||
|
||||
# The spec says {"data": [...]}, but some servers return a bare list.
|
||||
entries = payload.get("data", payload) if isinstance(payload, dict) else payload
|
||||
if not isinstance(entries, list):
|
||||
raise LLMError("The endpoint's model list was not in the expected format.")
|
||||
|
||||
models = []
|
||||
for entry in entries:
|
||||
if isinstance(entry, dict) and entry.get("id"):
|
||||
models.append(entry)
|
||||
elif isinstance(entry, str):
|
||||
models.append({"id": entry})
|
||||
return models
|
||||
|
||||
|
||||
async def stream_chat(
|
||||
endpoint: Endpoint,
|
||||
payload: dict[str, Any],
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
"""Stream a chat completion, yielding each parsed SSE data object.
|
||||
|
||||
Yields the raw upstream chunks; interpreting them is the caller's job. The
|
||||
terminating "[DONE]" sentinel is consumed here and not yielded.
|
||||
"""
|
||||
body = {**payload, "stream": True}
|
||||
|
||||
try:
|
||||
async with (
|
||||
httpx.AsyncClient(timeout=settings.request_timeout) as client,
|
||||
client.stream(
|
||||
"POST",
|
||||
endpoint.url("chat/completions"),
|
||||
headers=endpoint.headers(),
|
||||
json=body,
|
||||
) as response,
|
||||
):
|
||||
if response.status_code >= 400:
|
||||
# The body has not been read yet on a streaming response, and
|
||||
# the error detail is in it.
|
||||
await response.aread()
|
||||
response.raise_for_status()
|
||||
|
||||
async for line in response.aiter_lines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith(":"):
|
||||
continue # keep-alive comment
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data = line[5:].strip()
|
||||
if data == "[DONE]":
|
||||
return
|
||||
try:
|
||||
yield json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
# A malformed frame is not worth killing a reply over.
|
||||
log.warning("skipping unparseable SSE frame: %.120s", data)
|
||||
continue
|
||||
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise LLMError(_describe_http_error(exc), status_code=exc.response.status_code) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise _wrap_transport_error(exc, endpoint) from exc
|
||||
|
||||
|
||||
async def complete(endpoint: Endpoint, payload: dict[str, Any]) -> str:
|
||||
"""Non-streaming completion. Used for short internal calls like auto-titling."""
|
||||
body = {**payload, "stream": False}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
response = await client.post(
|
||||
endpoint.url("chat/completions"), headers=endpoint.headers(), json=body
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
raise LLMError(_describe_http_error(exc), status_code=exc.response.status_code) from exc
|
||||
except httpx.RequestError as exc:
|
||||
raise _wrap_transport_error(exc, endpoint) from exc
|
||||
except ValueError as exc:
|
||||
raise LLMError("The endpoint returned a response that was not JSON.") from exc
|
||||
|
||||
try:
|
||||
return data["choices"][0]["message"]["content"] or ""
|
||||
except (KeyError, IndexError, TypeError) as exc:
|
||||
raise LLMError("The endpoint returned no completion.") from exc
|
||||
|
||||
|
||||
def delta_text(chunk: dict[str, Any]) -> str:
|
||||
"""Pull the text out of one streamed chunk, tolerating provider variation."""
|
||||
try:
|
||||
choices = chunk.get("choices") or []
|
||||
if not choices:
|
||||
return ""
|
||||
delta = choices[0].get("delta") or {}
|
||||
content = delta.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
# Some providers send content as a list of typed parts even in deltas.
|
||||
if isinstance(content, list):
|
||||
return "".join(
|
||||
part.get("text", "")
|
||||
for part in content
|
||||
if isinstance(part, dict) and part.get("type") == "text"
|
||||
)
|
||||
return ""
|
||||
except (AttributeError, TypeError):
|
||||
return ""
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Render assistant messages from Markdown to sanitised HTML.
|
||||
|
||||
Rendering happens on the server, in Python, so there is no JavaScript Markdown
|
||||
library to vendor and the streamed and final views cannot disagree about how
|
||||
something should look.
|
||||
|
||||
The output is sanitised with nh3 (Rust ammonia). Model output is untrusted
|
||||
input: it routinely contains HTML, and a model can be talked into emitting a
|
||||
script tag, so this is a real boundary and not a formality.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import html
|
||||
|
||||
import nh3
|
||||
from markdown_it import MarkdownIt
|
||||
from pygments import highlight
|
||||
from pygments.formatters import HtmlFormatter
|
||||
from pygments.lexers import get_lexer_by_name, guess_lexer
|
||||
from pygments.util import ClassNotFound
|
||||
|
||||
# Class-based highlighting; the colours come from theme tokens in chat.css, so
|
||||
# code blocks follow the active theme instead of carrying their own palette.
|
||||
_FORMATTER = HtmlFormatter(nowrap=True, classprefix="pg-")
|
||||
|
||||
ALLOWED_TAGS = {
|
||||
"p", "br", "hr", "div", "span",
|
||||
"strong", "em", "del", "sub", "sup", "mark",
|
||||
"h1", "h2", "h3", "h4", "h5", "h6",
|
||||
"ul", "ol", "li",
|
||||
"blockquote", "pre", "code",
|
||||
"table", "thead", "tbody", "tr", "th", "td",
|
||||
"a", "img",
|
||||
}
|
||||
|
||||
ALLOWED_ATTRIBUTES = {
|
||||
# "rel" is intentionally absent: nh3 rejects it here when link_rel is set,
|
||||
# because link_rel below is what writes it.
|
||||
"a": {"href", "title", "target"},
|
||||
"img": {"src", "alt", "title"},
|
||||
"code": {"class"},
|
||||
"pre": {"class"},
|
||||
"span": {"class"},
|
||||
"div": {"class"},
|
||||
"td": {"align"},
|
||||
"th": {"align"},
|
||||
}
|
||||
|
||||
# javascript: and data: URLs are the obvious injection route through a link.
|
||||
ALLOWED_URL_SCHEMES = {"http", "https", "mailto"}
|
||||
|
||||
|
||||
def _render_fence(tokens, idx, _options, _env) -> str:
|
||||
"""Render a fenced code block.
|
||||
|
||||
This replaces the renderer's `fence` rule outright rather than using
|
||||
markdown-it's `highlight` option, because that option re-wraps whatever it
|
||||
is given in <pre><code> unless the string already starts with "<pre" --
|
||||
which would nest a second <pre> inside the wrapper this returns.
|
||||
"""
|
||||
token = tokens[idx]
|
||||
code = token.content
|
||||
language = (token.info or "").strip().split()[0] if token.info else ""
|
||||
|
||||
lexer = None
|
||||
if language:
|
||||
try:
|
||||
lexer = get_lexer_by_name(language, stripall=False)
|
||||
except (ClassNotFound, ValueError):
|
||||
lexer = None
|
||||
elif code.strip():
|
||||
# Guessing is only worth it for a decent sample; on two lines of text
|
||||
# Pygments guesses confidently and wrongly.
|
||||
try:
|
||||
lexer = guess_lexer(code) if len(code) > 80 else None
|
||||
except (ClassNotFound, ValueError):
|
||||
lexer = None
|
||||
|
||||
if lexer is None:
|
||||
body = nh3.clean_text(code)
|
||||
label = language
|
||||
else:
|
||||
body = highlight(code, lexer, _FORMATTER)
|
||||
label = language or (lexer.aliases[0] if lexer.aliases else "")
|
||||
|
||||
label_html = (
|
||||
f'<div class="code-block__label">{nh3.clean_text(label)}</div>' if label else ""
|
||||
)
|
||||
return (
|
||||
f'<div class="code-block">{label_html}'
|
||||
f'<pre class="code-block__pre"><code>{body}</code></pre></div>'
|
||||
)
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _parser() -> MarkdownIt:
|
||||
md = MarkdownIt("commonmark", {"linkify": True, "typographer": False})
|
||||
md.enable(["table", "strikethrough", "linkify"])
|
||||
md.renderer.rules["fence"] = _render_fence
|
||||
return md
|
||||
|
||||
|
||||
def render_markdown(text: str) -> str:
|
||||
"""Markdown to safe HTML, ready to drop into a message bubble."""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
html = _parser().render(text)
|
||||
return nh3.clean(
|
||||
html,
|
||||
tags=ALLOWED_TAGS,
|
||||
attributes=ALLOWED_ATTRIBUTES,
|
||||
url_schemes=ALLOWED_URL_SCHEMES,
|
||||
# Anything opened from a model's output is untrusted; noopener stops it
|
||||
# reaching back through window.opener.
|
||||
link_rel="nofollow noopener noreferrer",
|
||||
)
|
||||
|
||||
|
||||
def escape_text(text: str) -> str:
|
||||
"""Escape a plain-text run for insertion as HTML element content.
|
||||
|
||||
Used for user messages and for partial assistant text mid-stream, where the
|
||||
content is not yet complete enough to parse as Markdown.
|
||||
|
||||
html.escape rather than nh3.clean_text: escaping the three structural
|
||||
characters is all that is needed for a text node, and it escapes character
|
||||
by character, so escaping a stream chunk-by-chunk gives the same result as
|
||||
escaping the whole string at once. nh3.clean_text also escapes spaces and
|
||||
slashes, which triples the size of a streamed token for no benefit.
|
||||
"""
|
||||
return html.escape(text, quote=False)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Server-sent event framing.
|
||||
|
||||
Small, but worth isolating: getting the wire format subtly wrong is the usual
|
||||
cause of a stream that "works" until a model emits a newline.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Every 15s of silence, so proxies that kill idle connections (nginx defaults
|
||||
# to 60s) do not drop a stream while a model is still thinking.
|
||||
KEEPALIVE = ": keepalive\n\n"
|
||||
|
||||
|
||||
def event(name: str, data: str) -> str:
|
||||
"""Frame one SSE event.
|
||||
|
||||
A payload containing newlines must be split across several `data:` lines;
|
||||
the browser rejoins them with "\\n". Sending a raw newline inside a single
|
||||
data line silently truncates the event, which is exactly what happens the
|
||||
first time a model emits a code block.
|
||||
"""
|
||||
lines = data.split("\n")
|
||||
body = "".join(f"data: {line}\n" for line in lines)
|
||||
return f"event: {name}\n{body}\n"
|
||||
@@ -0,0 +1,113 @@
|
||||
/* Administration screens. */
|
||||
|
||||
.admin-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
|
||||
.admin-page {
|
||||
max-width: 46rem;
|
||||
margin: 0 auto;
|
||||
padding: var(--sp-6) var(--sp-5) var(--sp-12);
|
||||
}
|
||||
|
||||
.admin-lede {
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-relaxed);
|
||||
margin-bottom: var(--sp-6);
|
||||
max-width: 42rem;
|
||||
}
|
||||
|
||||
.admin-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--text-lg);
|
||||
margin: var(--sp-8) 0 var(--sp-4);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-5);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
.card__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--text-md);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
|
||||
.form-grid { display: block; }
|
||||
.form-grid .field:last-of-type { margin-bottom: 0; }
|
||||
.field--actions { margin-top: var(--sp-5); margin-bottom: 0; }
|
||||
|
||||
.connection__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.connection__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
margin-top: var(--sp-5);
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Reachable status at a glance: green working, red errored, grey disabled. */
|
||||
.status-dot {
|
||||
width: 0.55rem;
|
||||
height: 0.55rem;
|
||||
border-radius: var(--radius-full);
|
||||
flex: none;
|
||||
background: var(--ink-faint);
|
||||
}
|
||||
.status-dot.is-ok { background: var(--success); }
|
||||
.status-dot.is-bad { background: var(--danger); }
|
||||
.status-dot.is-off { background: var(--ink-faint); }
|
||||
|
||||
.model-list { list-style: none; margin: 0; padding: 0; }
|
||||
.model-list__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-2) 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.model-list__item:last-child { border-bottom: 0; }
|
||||
.model-list__id {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-item.is-disabled {
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
.nav-item.is-disabled:hover { background: none; color: var(--ink-muted); }
|
||||
|
||||
.field__hint code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.92em;
|
||||
padding: 0.05em 0.3em;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--code-border);
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
/*
|
||||
Application styles.
|
||||
|
||||
Rules here resolve colour, spacing and radius through the variables in
|
||||
tokens.css and never hard-code a value. Layout is flexbox and grid only --
|
||||
no framework, no preprocessor, no build step.
|
||||
*/
|
||||
|
||||
/* --- Reset ---------------------------------------------------------------- */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
color: var(--ink);
|
||||
background: var(--bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
line-height: var(--leading-tight);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
p { margin: 0 0 var(--sp-4); }
|
||||
p:last-child { margin-bottom: 0; }
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
a:hover { color: var(--accent-hover); }
|
||||
|
||||
button, input, textarea, select {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* A single, consistent focus ring. Never remove it without a replacement. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
:focus:not(:focus-visible) { outline: none; }
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- Icons ---------------------------------------------------------------- */
|
||||
.icon {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
flex: none;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.7;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.icon--sm { width: 1em; height: 1em; }
|
||||
.icon--lg { width: 1.5em; height: 1.5em; }
|
||||
/* The leaf is a filled silhouette, not a stroked pictogram. */
|
||||
.icon--leaf { fill: currentColor; stroke: none; }
|
||||
|
||||
/* --- Buttons -------------------------------------------------------------- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 0.5rem 0.9rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface-raised);
|
||||
color: var(--ink);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
.btn--primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
.btn--primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.btn--danger { color: var(--danger); border-color: var(--border); }
|
||||
.btn--danger:hover:not(:disabled) {
|
||||
background: var(--danger-soft);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.btn--ghost { background: transparent; border-color: transparent; }
|
||||
.btn--ghost:hover:not(:disabled) { background: var(--surface-hover); border-color: transparent; }
|
||||
|
||||
.btn--icon {
|
||||
padding: 0.4rem;
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.btn--icon:hover:not(:disabled) { background: var(--surface-hover); color: var(--ink); }
|
||||
|
||||
.btn--block { width: 100%; }
|
||||
.btn--sm { padding: 0.3rem 0.6rem; font-size: var(--text-xs); }
|
||||
|
||||
/* --- Forms ---------------------------------------------------------------- */
|
||||
.field { margin-bottom: var(--sp-4); }
|
||||
.field__label {
|
||||
display: block;
|
||||
margin-bottom: var(--sp-2);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.field__hint {
|
||||
margin-top: var(--sp-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
line-height: var(--leading-normal);
|
||||
}
|
||||
|
||||
.input,
|
||||
.textarea,
|
||||
.select {
|
||||
width: 100%;
|
||||
padding: 0.55rem 0.7rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-sunken);
|
||||
color: var(--ink);
|
||||
font-size: var(--text-base);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
.input:focus,
|
||||
.textarea:focus,
|
||||
.select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.input::placeholder, .textarea::placeholder { color: var(--ink-faint); }
|
||||
.textarea { resize: vertical; min-height: 5rem; line-height: var(--leading-normal); }
|
||||
.input--mono { font-family: var(--font-mono); font-size: var(--text-sm); }
|
||||
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
.checkbox input { accent-color: var(--accent); width: 1rem; height: 1rem; }
|
||||
|
||||
/* --- Alerts --------------------------------------------------------------- */
|
||||
.alert {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border: 1px solid var(--border);
|
||||
border-left-width: 3px;
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--sp-4);
|
||||
background: var(--surface);
|
||||
}
|
||||
.alert--error { border-left-color: var(--danger); background: var(--danger-soft); color: var(--ink); }
|
||||
.alert--success { border-left-color: var(--success); background: var(--success-soft); }
|
||||
.alert--warning { border-left-color: var(--warning); background: var(--warning-soft); }
|
||||
.alert__icon { color: var(--danger); flex: none; margin-top: 0.15rem; }
|
||||
|
||||
/* --- Badges --------------------------------------------------------------- */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
background: var(--surface-active);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
.badge--gold { background: var(--gold-soft); color: var(--gold); }
|
||||
.badge--success { background: var(--success-soft); color: var(--success); }
|
||||
.badge--danger { background: var(--danger-soft); color: var(--danger); }
|
||||
|
||||
/* --- Application shell ---------------------------------------------------- */
|
||||
.shell {
|
||||
display: flex;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: var(--sidebar-width);
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-sunken);
|
||||
border-right: 1px solid var(--border);
|
||||
transition: margin-left var(--transition);
|
||||
}
|
||||
.sidebar[hidden] { display: none; }
|
||||
|
||||
.sidebar__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
height: var(--header-height);
|
||||
padding: 0 var(--sp-3);
|
||||
flex: none;
|
||||
}
|
||||
.sidebar__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
min-width: 0;
|
||||
}
|
||||
.sidebar__brand:hover { color: var(--ink); }
|
||||
.sidebar__brand .brand-mark { width: 1.6rem; height: 1.6rem; flex: none; }
|
||||
.sidebar__brand .brand-llm { color: var(--gold); }
|
||||
|
||||
.sidebar__actions { padding: 0 var(--sp-3) var(--sp-3); display: flex; gap: var(--sp-2); }
|
||||
|
||||
.sidebar__scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 var(--sp-2) var(--sp-3);
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
|
||||
.sidebar__footer {
|
||||
flex: none;
|
||||
border-top: 1px solid var(--border);
|
||||
padding: var(--sp-2);
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
height: var(--header-height);
|
||||
flex: none;
|
||||
padding: 0 var(--sp-4);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
.topbar__title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-md);
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.topbar__spacer { flex: 1; }
|
||||
|
||||
/* --- Sidebar navigation --------------------------------------------------- */
|
||||
.nav-group { margin-bottom: var(--sp-4); }
|
||||
.nav-group__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-2) var(--sp-2) var(--sp-1);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--ink-faint);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: 0.4rem var(--sp-2);
|
||||
border-radius: var(--radius);
|
||||
color: var(--ink-muted);
|
||||
text-decoration: none;
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.nav-item:hover { background: var(--surface-hover); color: var(--ink); }
|
||||
.nav-item.is-active { background: var(--surface-active); color: var(--ink); font-weight: 500; }
|
||||
.nav-item__label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
/* Row actions stay hidden until the row is hovered or focused within, so the
|
||||
list reads calmly, but they remain keyboard reachable. */
|
||||
.nav-item__actions {
|
||||
display: flex;
|
||||
gap: 0.1rem;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
.nav-item:hover .nav-item__actions,
|
||||
.nav-item:focus-within .nav-item__actions { opacity: 1; }
|
||||
|
||||
.nav-empty {
|
||||
padding: var(--sp-3) var(--sp-2);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* --- Auth screens --------------------------------------------------------- */
|
||||
.auth {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--sp-6);
|
||||
background:
|
||||
radial-gradient(ellipse 60% 50% at 50% 0%, var(--gold-soft), transparent 70%),
|
||||
var(--bg);
|
||||
}
|
||||
.auth__card {
|
||||
width: 100%;
|
||||
max-width: 25rem;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--sp-8);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.auth__brand { display: grid; place-items: center; gap: var(--sp-3); margin-bottom: var(--sp-6); }
|
||||
.auth__brand .brand-mark { width: 3.5rem; height: 3.5rem; }
|
||||
.auth__title {
|
||||
font-size: var(--text-2xl);
|
||||
font-family: var(--font-display);
|
||||
text-align: center;
|
||||
}
|
||||
.auth__subtitle {
|
||||
text-align: center;
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-sm);
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
.auth__footer {
|
||||
margin-top: var(--sp-5);
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: center;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
/* --- Empty states --------------------------------------------------------- */
|
||||
.empty {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-10);
|
||||
text-align: center;
|
||||
}
|
||||
.empty__mark { width: 4.5rem; height: 4.5rem; opacity: 0.85; }
|
||||
.empty__title { font-size: var(--text-xl); font-family: var(--font-display); }
|
||||
.empty__text {
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-sm);
|
||||
max-width: 32rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* --- Utilities ------------------------------------------------------------ */
|
||||
.stack > * + * { margin-top: var(--sp-4); }
|
||||
.row { display: flex; align-items: center; gap: var(--sp-3); }
|
||||
.row--between { justify-content: space-between; }
|
||||
.muted { color: var(--ink-muted); }
|
||||
.faint { color: var(--ink-faint); }
|
||||
.text-sm { font-size: var(--text-sm); }
|
||||
.text-xs { font-size: var(--text-xs); }
|
||||
.mono { font-family: var(--font-mono); }
|
||||
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* --- Small screens -------------------------------------------------------- */
|
||||
@media (max-width: 48rem) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: 40;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
.sidebar[data-collapsed="true"] { display: none; }
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
/*
|
||||
Chat thread, composer, message bodies and code blocks.
|
||||
|
||||
Loaded only on chat pages. Like app.css, every value resolves through
|
||||
tokens.css.
|
||||
*/
|
||||
|
||||
/* --- Thread --------------------------------------------------------------- */
|
||||
.thread-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
|
||||
.thread {
|
||||
max-width: var(--thread-max-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--sp-6) var(--sp-5) var(--sp-8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-6);
|
||||
}
|
||||
|
||||
.thread__intro {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: var(--sp-3);
|
||||
text-align: center;
|
||||
padding: var(--sp-12) 0 var(--sp-6);
|
||||
}
|
||||
|
||||
/* --- Messages ------------------------------------------------------------- */
|
||||
.msg {
|
||||
display: grid;
|
||||
grid-template-columns: 2rem 1fr;
|
||||
gap: var(--sp-3);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.msg__gutter {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
}
|
||||
.msg__mark { width: 2rem; height: 2rem; }
|
||||
.msg__initial {
|
||||
width: 2rem; height: 2rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--surface-active);
|
||||
color: var(--ink-muted);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.msg__main { min-width: 0; }
|
||||
|
||||
.msg__meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--sp-2);
|
||||
margin-bottom: var(--sp-1);
|
||||
}
|
||||
.msg__author {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
}
|
||||
.msg__model {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
font-family: var(--font-mono);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 16rem;
|
||||
}
|
||||
|
||||
.msg__body {
|
||||
line-height: var(--leading-relaxed);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
/* User turns and mid-stream assistant text are plain text, so newlines and
|
||||
runs of spaces have to survive. */
|
||||
.msg__body--plain,
|
||||
.msg__body--streaming { white-space: pre-wrap; }
|
||||
|
||||
.msg--user .msg__body--plain {
|
||||
background: var(--bubble-user);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-radius: var(--radius-lg);
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.msg__error { margin: var(--sp-2) 0; align-items: flex-start; }
|
||||
|
||||
/* --- Streaming indicator -------------------------------------------------- */
|
||||
/* Shown until the first token arrives, then hidden by the sibling selector
|
||||
below -- no JavaScript involved in either direction. */
|
||||
.msg__waiting { padding: var(--sp-2) 0; }
|
||||
.msg__body--streaming:not(:empty) + .msg__waiting { display: none; }
|
||||
|
||||
.dots { display: inline-flex; gap: 0.25rem; align-items: center; }
|
||||
.dots i {
|
||||
width: 0.4rem;
|
||||
height: 0.4rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--ink-faint);
|
||||
animation: dot-pulse 1.3s ease-in-out infinite;
|
||||
}
|
||||
.dots i:nth-child(2) { animation-delay: 0.18s; }
|
||||
.dots i:nth-child(3) { animation-delay: 0.36s; }
|
||||
|
||||
@keyframes dot-pulse {
|
||||
0%, 60%, 100% { opacity: 0.28; transform: translateY(0); }
|
||||
30% { opacity: 1; transform: translateY(-2px); }
|
||||
}
|
||||
|
||||
/* A caret trailing the text while it streams. */
|
||||
.msg__body--streaming::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 0.45rem;
|
||||
height: 1.05em;
|
||||
margin-left: 1px;
|
||||
vertical-align: text-bottom;
|
||||
background: var(--gold);
|
||||
opacity: 0.75;
|
||||
animation: caret 1.05s steps(1) infinite;
|
||||
}
|
||||
@keyframes caret { 0%, 49% { opacity: 0.75; } 50%, 100% { opacity: 0; } }
|
||||
|
||||
/* --- Message actions ------------------------------------------------------ */
|
||||
.msg__actions {
|
||||
display: flex;
|
||||
gap: var(--sp-1);
|
||||
margin-top: var(--sp-2);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
.msg:hover .msg__actions,
|
||||
.msg:focus-within .msg__actions { opacity: 1; }
|
||||
.msg__actions .is-copied { color: var(--success); }
|
||||
|
||||
/* --- Rendered Markdown ---------------------------------------------------- */
|
||||
.msg__body > :first-child { margin-top: 0; }
|
||||
.msg__body > :last-child { margin-bottom: 0; }
|
||||
|
||||
.msg__body h1, .msg__body h2, .msg__body h3,
|
||||
.msg__body h4, .msg__body h5, .msg__body h6 {
|
||||
margin: var(--sp-5) 0 var(--sp-2);
|
||||
}
|
||||
.msg__body h1 { font-size: var(--text-xl); }
|
||||
.msg__body h2 { font-size: var(--text-lg); }
|
||||
.msg__body h3 { font-size: var(--text-md); }
|
||||
|
||||
.msg__body ul, .msg__body ol { margin: 0 0 var(--sp-4); padding-left: var(--sp-6); }
|
||||
.msg__body li { margin-bottom: var(--sp-1); }
|
||||
|
||||
.msg__body blockquote {
|
||||
margin: 0 0 var(--sp-4);
|
||||
padding: var(--sp-1) var(--sp-4);
|
||||
border-left: 3px solid var(--border-strong);
|
||||
color: var(--ink-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.msg__body hr { border: 0; border-top: 1px solid var(--border); margin: var(--sp-5) 0; }
|
||||
|
||||
.msg__body :not(pre) > code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.875em;
|
||||
padding: 0.13em 0.36em;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--code-bg);
|
||||
border: 1px solid var(--code-border);
|
||||
}
|
||||
|
||||
.msg__body table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0 0 var(--sp-4);
|
||||
font-size: var(--text-sm);
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.msg__body th, .msg__body td {
|
||||
border: 1px solid var(--border);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
text-align: left;
|
||||
}
|
||||
.msg__body th { background: var(--surface); font-weight: 600; }
|
||||
|
||||
.msg__body img { max-width: 100%; height: auto; border-radius: var(--radius); }
|
||||
|
||||
/* --- Code blocks ---------------------------------------------------------- */
|
||||
.code-block {
|
||||
margin: 0 0 var(--sp-4);
|
||||
border: 1px solid var(--code-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--code-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.code-block__label {
|
||||
padding: var(--sp-1) var(--sp-3);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
border-bottom: 1px solid var(--code-border);
|
||||
background: color-mix(in srgb, var(--code-bg) 60%, var(--surface));
|
||||
}
|
||||
.code-block__pre {
|
||||
margin: 0;
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
overflow-x: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.code-block__pre code { font-family: inherit; background: none; border: 0; padding: 0; }
|
||||
|
||||
/*
|
||||
Pygments token colours, mapped onto theme tokens rather than a fixed scheme,
|
||||
so code follows the active theme. classprefix "pg-" is set in markdown.py.
|
||||
*/
|
||||
.pg-c, .pg-c1, .pg-cm, .pg-cs, .pg-cp { color: var(--ink-faint); font-style: italic; }
|
||||
.pg-k, .pg-kn, .pg-kd, .pg-kc, .pg-kr, .pg-kt { color: var(--gold); }
|
||||
.pg-s, .pg-s1, .pg-s2, .pg-sb, .pg-sd, .pg-se, .pg-sh, .pg-si, .pg-sx { color: var(--success); }
|
||||
.pg-m, .pg-mi, .pg-mf, .pg-mh, .pg-mo { color: var(--danger); }
|
||||
.pg-nf, .pg-nd { color: var(--accent); }
|
||||
.pg-nc, .pg-nn { color: var(--accent-hover); font-weight: 600; }
|
||||
.pg-nb, .pg-bp { color: var(--accent); }
|
||||
.pg-nv, .pg-vi, .pg-vg, .pg-vc { color: var(--ink); }
|
||||
.pg-o, .pg-ow, .pg-p { color: var(--ink-muted); }
|
||||
.pg-err { color: var(--danger); }
|
||||
.pg-gd { color: var(--danger); }
|
||||
.pg-gi { color: var(--success); }
|
||||
|
||||
/* --- Composer ------------------------------------------------------------- */
|
||||
.composer {
|
||||
flex: none;
|
||||
padding: var(--sp-3) var(--sp-5) var(--sp-4);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg);
|
||||
}
|
||||
.composer__form {
|
||||
max-width: var(--thread-max-width);
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
align-items: flex-end;
|
||||
padding: var(--sp-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--surface);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
.composer__form:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.composer__input {
|
||||
flex: 1;
|
||||
border: 0;
|
||||
background: none;
|
||||
resize: none;
|
||||
padding: var(--sp-2);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
max-height: 20rem;
|
||||
}
|
||||
.composer__input:focus { outline: none; }
|
||||
.composer__send { border-radius: var(--radius-full); padding: 0.55rem 0.7rem; }
|
||||
.composer__hint {
|
||||
max-width: var(--thread-max-width);
|
||||
margin: var(--sp-2) auto 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--ink-faint);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.select--compact {
|
||||
width: auto;
|
||||
max-width: 16rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* --- Folders -------------------------------------------------------------- */
|
||||
.folder__row { padding-right: var(--sp-1); }
|
||||
.folder__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.folder__chevron {
|
||||
display: inline-flex;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
.folder__chevron.is-open { transform: rotate(90deg); }
|
||||
.folder__contents { padding-left: var(--sp-4); }
|
||||
|
||||
.nav-item__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* --- Theme toggle --------------------------------------------------------- */
|
||||
/* Only the icon for the theme you would switch TO is shown. */
|
||||
:root[data-theme="moria"] .theme-icon--dark { display: none; }
|
||||
:root[data-theme="shire"] .theme-icon--light { display: none; }
|
||||
|
||||
/* Alpine sets x-cloak until it has initialised; without this, collapsed
|
||||
folders flash open on every page load. */
|
||||
[x-cloak] { display: none !important; }
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
Design tokens.
|
||||
|
||||
Every colour, space and radius in the application resolves through a variable
|
||||
declared here. Component CSS must never hard-code a hex value -- that is what
|
||||
makes adding a theme a matter of writing one new block rather than auditing
|
||||
every stylesheet.
|
||||
|
||||
Themes are selected with data-theme on <html>. `moria` is the default and is
|
||||
declared on :root so the page is styled even before the theme script runs.
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* --- Type ------------------------------------------------------------- */
|
||||
--font-display: "Iowan Old Style", "Palatino Linotype", Palatino, Palladio,
|
||||
"URW Palladio L", "Book Antiqua", Baskerville, Georgia, serif;
|
||||
--font-body: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
|
||||
Arial, sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code",
|
||||
"Cascadia Code", Menlo, Consolas, monospace;
|
||||
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.8125rem;
|
||||
--text-base: 0.9375rem;
|
||||
--text-md: 1rem;
|
||||
--text-lg: 1.125rem;
|
||||
--text-xl: 1.375rem;
|
||||
--text-2xl: 1.75rem;
|
||||
--text-3xl: 2.25rem;
|
||||
|
||||
--leading-tight: 1.25;
|
||||
--leading-normal: 1.6;
|
||||
--leading-relaxed: 1.75;
|
||||
|
||||
/* --- Space (4px scale) ------------------------------------------------ */
|
||||
--sp-1: 0.25rem;
|
||||
--sp-2: 0.5rem;
|
||||
--sp-3: 0.75rem;
|
||||
--sp-4: 1rem;
|
||||
--sp-5: 1.25rem;
|
||||
--sp-6: 1.5rem;
|
||||
--sp-8: 2rem;
|
||||
--sp-10: 2.5rem;
|
||||
--sp-12: 3rem;
|
||||
--sp-16: 4rem;
|
||||
|
||||
/* --- Radius & shadow -------------------------------------------------- */
|
||||
--radius-sm: 4px;
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
--radius-xl: 18px;
|
||||
--radius-full: 999px;
|
||||
|
||||
/* --- Layout ----------------------------------------------------------- */
|
||||
--sidebar-width: 17rem;
|
||||
--thread-max-width: 48rem;
|
||||
--header-height: 3.25rem;
|
||||
|
||||
--transition-fast: 120ms ease;
|
||||
--transition: 200ms ease;
|
||||
}
|
||||
|
||||
/*
|
||||
---------------------------------------------------------------------------
|
||||
MORIA (default, dark)
|
||||
|
||||
Deep stone and lamplight: the halls under the mountain. Surfaces are cool and
|
||||
near-neutral so the gold and mithril accents carry all the colour.
|
||||
---------------------------------------------------------------------------
|
||||
*/
|
||||
:root,
|
||||
:root[data-theme="moria"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--bg: #101317;
|
||||
--bg-sunken: #0B0E11;
|
||||
--surface: #171B21;
|
||||
--surface-raised: #1E242B;
|
||||
--surface-hover: #232A32;
|
||||
--surface-active: #2A323B;
|
||||
|
||||
--border: #2A313A;
|
||||
--border-strong: #3A434E;
|
||||
|
||||
--ink: #E4E8EC;
|
||||
--ink-muted: #A2ADB8;
|
||||
--ink-faint: #6E7883;
|
||||
--ink-inverse: #0B0E11;
|
||||
|
||||
/* Mithril: the cool primary, used for focus and interactive accents. */
|
||||
--accent: #8FB3CC;
|
||||
--accent-hover: #A9C6DA;
|
||||
--accent-ink: #0B0E11;
|
||||
--accent-soft: rgba(143, 179, 204, 0.14);
|
||||
|
||||
/* Rune gold: the warm accent. Brand colour, and the assistant's mark. */
|
||||
--gold: #E0B252;
|
||||
--gold-hover: #EDC46F;
|
||||
--gold-soft: rgba(224, 178, 82, 0.13);
|
||||
|
||||
/* Ember: destructive actions and errors. */
|
||||
--danger: #E2795A;
|
||||
--danger-hover: #EC8E72;
|
||||
--danger-soft: rgba(226, 121, 90, 0.14);
|
||||
|
||||
--success: #7FB77E;
|
||||
--success-soft: rgba(127, 183, 126, 0.14);
|
||||
--warning: #DFAE58;
|
||||
--warning-soft: rgba(223, 174, 88, 0.14);
|
||||
|
||||
--bubble-user: #232B34;
|
||||
--bubble-assistant: transparent;
|
||||
--code-bg: #0C0F13;
|
||||
--code-border: #262D36;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
--shadow: 0 4px 14px rgba(0, 0, 0, 0.45);
|
||||
--shadow-lg: 0 12px 34px rgba(0, 0, 0, 0.55);
|
||||
|
||||
--scrim: rgba(6, 8, 10, 0.66);
|
||||
}
|
||||
|
||||
/*
|
||||
---------------------------------------------------------------------------
|
||||
SHIRE (light)
|
||||
|
||||
Parchment, ink and moss: warm, low-contrast, easy to read for a long time.
|
||||
Backgrounds are deliberately off-white -- pure #FFF next to the gold accent
|
||||
reads as clinical rather than as paper.
|
||||
---------------------------------------------------------------------------
|
||||
*/
|
||||
:root[data-theme="shire"] {
|
||||
color-scheme: light;
|
||||
|
||||
--bg: #F6F1E4;
|
||||
--bg-sunken: #EDE6D4;
|
||||
--surface: #FDFBF5;
|
||||
--surface-raised: #FFFFFF;
|
||||
--surface-hover: #F1EADA;
|
||||
--surface-active: #E7DEC9;
|
||||
|
||||
--border: #DED3BB;
|
||||
--border-strong: #C6B896;
|
||||
|
||||
--ink: #2C2419;
|
||||
--ink-muted: #6A5C48;
|
||||
--ink-faint: #94856D;
|
||||
--ink-inverse: #FDFBF5;
|
||||
|
||||
/* Hobbit-door blue-green: the cool primary. */
|
||||
--accent: #3E6B7A;
|
||||
--accent-hover: #325867;
|
||||
--accent-ink: #FDFBF5;
|
||||
--accent-soft: rgba(62, 107, 122, 0.12);
|
||||
|
||||
--gold: #A8801A;
|
||||
--gold-hover: #8E6B12;
|
||||
--gold-soft: rgba(168, 128, 26, 0.13);
|
||||
|
||||
--danger: #A6432B;
|
||||
--danger-hover: #8C3722;
|
||||
--danger-soft: rgba(166, 67, 43, 0.11);
|
||||
|
||||
--success: #4F7A3F;
|
||||
--success-soft: rgba(79, 122, 63, 0.12);
|
||||
--warning: #98701A;
|
||||
--warning-soft: rgba(152, 112, 26, 0.13);
|
||||
|
||||
--bubble-user: #EDE4CF;
|
||||
--bubble-assistant: transparent;
|
||||
--code-bg: #F2EBD9;
|
||||
--code-border: #DED3BB;
|
||||
|
||||
--shadow-sm: 0 1px 2px rgba(72, 58, 34, 0.09);
|
||||
--shadow: 0 4px 14px rgba(72, 58, 34, 0.11);
|
||||
--shadow-lg: 0 12px 34px rgba(72, 58, 34, 0.16);
|
||||
|
||||
--scrim: rgba(44, 36, 25, 0.4);
|
||||
}
|
||||
|
||||
/* Respect a stated preference for reduced motion everywhere, at once. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 420"
|
||||
width="1280" height="420" role="img"
|
||||
aria-label="LLeMbas - Waybread for the long road of thought">
|
||||
<title>LLeMbas</title>
|
||||
<desc>Waybread for the long road of thought. A mallorn leaf and wafer above the mountains at night.</desc>
|
||||
<defs>
|
||||
<linearGradient id="b-sky" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#080B0F"/>
|
||||
<stop offset="0.62" stop-color="#101822"/>
|
||||
<stop offset="1" stop-color="#1A2530"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="b-glow" cx="0.5" cy="0.54" r="0.5">
|
||||
<stop offset="0" stop-color="#C9A227" stop-opacity="0.22"/>
|
||||
<stop offset="1" stop-color="#C9A227" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<!-- Cool light sitting just above the ridge line, so the far mountains
|
||||
separate from the near ones instead of merging into one dark mass. -->
|
||||
<radialGradient id="b-horizon" cx="0.5" cy="1" r="0.72">
|
||||
<stop offset="0" stop-color="#4E6C86" stop-opacity="0.30"/>
|
||||
<stop offset="1" stop-color="#4E6C86" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
|
||||
<linearGradient id="b-wafer" x1="0" y1="0" x2="0.3" y2="1">
|
||||
<stop offset="0" stop-color="#EACB74"/>
|
||||
<stop offset="0.5" stop-color="#C9A227"/>
|
||||
<stop offset="1" stop-color="#916F13"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="b-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
|
||||
<stop offset="0" stop-color="#93A5B6"/>
|
||||
<stop offset="0.4" stop-color="#F1F6FA"/>
|
||||
<stop offset="1" stop-color="#B8C7D5"/>
|
||||
</linearGradient>
|
||||
<clipPath id="b-clip">
|
||||
<rect x="6" y="6" width="52" height="52" rx="13"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
|
||||
<rect width="1280" height="420" fill="url(#b-sky)"/>
|
||||
<g fill="#FFFFFF">
|
||||
<circle cx="579.0" cy="167.9" r="1.80" opacity="0.49"/>
|
||||
<circle cx="650.0" cy="176.2" r="0.84" opacity="0.52"/>
|
||||
<circle cx="806.2" cy="237.9" r="0.72" opacity="0.38"/>
|
||||
<circle cx="116.1" cy="242.9" r="1.50" opacity="0.21"/>
|
||||
<circle cx="1257.2" cy="289.4" r="1.45" opacity="0.59"/>
|
||||
<circle cx="201.6" cy="4.5" r="1.29" opacity="0.22"/>
|
||||
<circle cx="243.5" cy="72.6" r="0.64" opacity="0.49"/>
|
||||
<circle cx="563.9" cy="252.7" r="1.27" opacity="0.61"/>
|
||||
<circle cx="639.7" cy="198.7" r="1.19" opacity="0.37"/>
|
||||
<circle cx="1277.0" cy="298.7" r="1.69" opacity="0.65"/>
|
||||
<circle cx="403.6" cy="68.9" r="0.98" opacity="0.23"/>
|
||||
<circle cx="980.8" cy="120.1" r="1.70" opacity="0.44"/>
|
||||
<circle cx="1226.3" cy="254.2" r="0.60" opacity="0.32"/>
|
||||
<circle cx="1165.1" cy="141.0" r="1.87" opacity="0.45"/>
|
||||
<circle cx="93.5" cy="188.8" r="1.61" opacity="0.36"/>
|
||||
<circle cx="111.5" cy="99.8" r="1.85" opacity="0.69"/>
|
||||
<circle cx="151.0" cy="73.9" r="0.73" opacity="0.22"/>
|
||||
<circle cx="1020.2" cy="53.3" r="1.33" opacity="0.48"/>
|
||||
<circle cx="244.1" cy="219.6" r="0.77" opacity="0.61"/>
|
||||
<circle cx="149.1" cy="126.2" r="0.88" opacity="0.36"/>
|
||||
<circle cx="1242.8" cy="241.0" r="1.00" opacity="0.77"/>
|
||||
<circle cx="269.7" cy="118.3" r="1.71" opacity="0.61"/>
|
||||
<circle cx="128.4" cy="296.8" r="0.88" opacity="0.35"/>
|
||||
<circle cx="989.0" cy="98.7" r="0.99" opacity="0.23"/>
|
||||
<circle cx="115.3" cy="174.8" r="0.92" opacity="0.58"/>
|
||||
<circle cx="475.8" cy="136.0" r="1.85" opacity="0.50"/>
|
||||
<circle cx="735.5" cy="260.0" r="0.84" opacity="0.28"/>
|
||||
<circle cx="1162.8" cy="245.3" r="0.92" opacity="0.31"/>
|
||||
<circle cx="946.5" cy="282.1" r="0.86" opacity="0.82"/>
|
||||
<circle cx="1129.2" cy="181.1" r="1.15" opacity="0.25"/>
|
||||
<circle cx="49.5" cy="288.8" r="0.91" opacity="0.65"/>
|
||||
<circle cx="328.9" cy="247.1" r="1.38" opacity="0.38"/>
|
||||
<circle cx="224.6" cy="216.1" r="0.69" opacity="0.33"/>
|
||||
<circle cx="716.0" cy="255.7" r="1.40" opacity="0.37"/>
|
||||
<circle cx="1174.2" cy="61.2" r="0.62" opacity="0.36"/>
|
||||
<circle cx="570.5" cy="18.1" r="0.83" opacity="0.43"/>
|
||||
<circle cx="732.4" cy="39.5" r="1.07" opacity="0.78"/>
|
||||
<circle cx="1255.0" cy="197.1" r="1.50" opacity="0.57"/>
|
||||
<circle cx="179.6" cy="10.5" r="0.62" opacity="0.79"/>
|
||||
<circle cx="897.2" cy="288.8" r="0.63" opacity="0.61"/>
|
||||
<circle cx="617.3" cy="219.1" r="1.01" opacity="0.85"/>
|
||||
<circle cx="96.3" cy="163.8" r="1.56" opacity="0.78"/>
|
||||
<circle cx="943.5" cy="211.1" r="1.63" opacity="0.79"/>
|
||||
<circle cx="450.3" cy="205.5" r="1.77" opacity="0.76"/>
|
||||
<circle cx="534.0" cy="237.2" r="1.72" opacity="0.56"/>
|
||||
<circle cx="799.9" cy="114.7" r="1.36" opacity="0.59"/>
|
||||
<circle cx="102.7" cy="191.8" r="1.89" opacity="0.77"/>
|
||||
<circle cx="932.1" cy="116.5" r="1.56" opacity="0.57"/>
|
||||
<circle cx="563.9" cy="251.5" r="0.71" opacity="0.68"/>
|
||||
<circle cx="38.1" cy="180.4" r="1.23" opacity="0.33"/>
|
||||
<circle cx="893.9" cy="149.2" r="1.40" opacity="0.80"/>
|
||||
<circle cx="327.5" cy="3.4" r="0.99" opacity="0.63"/>
|
||||
<circle cx="259.3" cy="50.9" r="1.78" opacity="0.62"/>
|
||||
<circle cx="565.7" cy="267.5" r="1.03" opacity="0.63"/>
|
||||
<circle cx="254.1" cy="129.3" r="1.65" opacity="0.79"/>
|
||||
<circle cx="1126.7" cy="115.3" r="1.36" opacity="0.39"/>
|
||||
<circle cx="174.3" cy="148.9" r="1.69" opacity="0.75"/>
|
||||
<circle cx="910.4" cy="285.0" r="0.96" opacity="0.29"/>
|
||||
<circle cx="576.8" cy="82.5" r="0.88" opacity="0.46"/>
|
||||
<circle cx="800.9" cy="148.2" r="1.01" opacity="0.74"/>
|
||||
<circle cx="1257.0" cy="135.7" r="0.70" opacity="0.20"/>
|
||||
<circle cx="1117.2" cy="12.4" r="1.52" opacity="0.56"/>
|
||||
<circle cx="395.6" cy="237.5" r="0.62" opacity="0.27"/>
|
||||
<circle cx="582.2" cy="7.4" r="1.68" opacity="0.34"/>
|
||||
<circle cx="180.3" cy="14.1" r="1.42" opacity="0.48"/>
|
||||
<circle cx="806.4" cy="196.5" r="1.65" opacity="0.82"/>
|
||||
<circle cx="876.2" cy="59.8" r="1.22" opacity="0.30"/>
|
||||
<circle cx="13.8" cy="141.7" r="1.53" opacity="0.30"/>
|
||||
<circle cx="348.6" cy="103.7" r="1.51" opacity="0.53"/>
|
||||
<circle cx="786.5" cy="226.9" r="1.11" opacity="0.71"/>
|
||||
<circle cx="1160.0" cy="26.2" r="1.81" opacity="0.66"/>
|
||||
<circle cx="166.3" cy="136.1" r="1.41" opacity="0.79"/>
|
||||
<circle cx="482.3" cy="170.6" r="1.74" opacity="0.71"/>
|
||||
<circle cx="1208.7" cy="139.1" r="1.45" opacity="0.32"/>
|
||||
<circle cx="924.1" cy="245.5" r="1.43" opacity="0.66"/>
|
||||
<circle cx="273.0" cy="270.0" r="1.87" opacity="0.83"/>
|
||||
<circle cx="687.3" cy="237.2" r="1.02" opacity="0.79"/>
|
||||
<circle cx="1095.4" cy="104.6" r="0.71" opacity="0.48"/>
|
||||
<circle cx="704.4" cy="230.5" r="1.23" opacity="0.20"/>
|
||||
<circle cx="1035.7" cy="19.2" r="1.64" opacity="0.30"/>
|
||||
<circle cx="428.8" cy="236.4" r="0.78" opacity="0.28"/>
|
||||
<circle cx="661.2" cy="217.1" r="1.69" opacity="0.64"/>
|
||||
<circle cx="1210.6" cy="147.8" r="1.83" opacity="0.24"/>
|
||||
<circle cx="283.4" cy="158.0" r="0.98" opacity="0.67"/>
|
||||
<circle cx="817.8" cy="156.8" r="1.70" opacity="0.56"/>
|
||||
<circle cx="399.0" cy="114.4" r="1.70" opacity="0.78"/>
|
||||
<circle cx="266.5" cy="255.2" r="1.86" opacity="0.53"/>
|
||||
<circle cx="733.4" cy="60.3" r="1.30" opacity="0.52"/>
|
||||
<circle cx="774.7" cy="8.3" r="1.86" opacity="0.53"/>
|
||||
<circle cx="512.7" cy="240.3" r="1.33" opacity="0.51"/>
|
||||
<circle cx="884.5" cy="19.8" r="1.30" opacity="0.46"/>
|
||||
<circle cx="1224.8" cy="277.0" r="0.95" opacity="0.50"/>
|
||||
<circle cx="162.5" cy="130.1" r="1.66" opacity="0.78"/>
|
||||
<circle cx="610.0" cy="95.2" r="0.85" opacity="0.59"/>
|
||||
<circle cx="1184.3" cy="38.8" r="1.61" opacity="0.20"/>
|
||||
<circle cx="248.5" cy="68.2" r="1.49" opacity="0.40"/>
|
||||
<circle cx="454.8" cy="185.9" r="0.74" opacity="0.67"/>
|
||||
<circle cx="157.2" cy="153.1" r="0.93" opacity="0.31"/>
|
||||
<circle cx="678.9" cy="131.0" r="1.09" opacity="0.46"/>
|
||||
<circle cx="677.6" cy="47.9" r="0.87" opacity="0.60"/>
|
||||
<circle cx="817.2" cy="158.9" r="1.71" opacity="0.59"/>
|
||||
<circle cx="1096.7" cy="69.8" r="1.56" opacity="0.72"/>
|
||||
<circle cx="1155.4" cy="94.8" r="1.01" opacity="0.80"/>
|
||||
<circle cx="279.2" cy="299.5" r="1.75" opacity="0.27"/>
|
||||
<circle cx="306.4" cy="218.0" r="0.94" opacity="0.25"/>
|
||||
<circle cx="1065.2" cy="126.5" r="1.63" opacity="0.26"/>
|
||||
<circle cx="515.6" cy="205.6" r="0.62" opacity="0.31"/>
|
||||
<circle cx="873.5" cy="273.4" r="1.86" opacity="0.26"/>
|
||||
<circle cx="647.3" cy="227.4" r="1.25" opacity="0.64"/>
|
||||
<circle cx="241.9" cy="21.2" r="0.74" opacity="0.21"/>
|
||||
<circle cx="706.1" cy="154.4" r="1.34" opacity="0.28"/>
|
||||
<circle cx="236.2" cy="61.2" r="1.69" opacity="0.84"/>
|
||||
<circle cx="1186.4" cy="28.6" r="0.68" opacity="0.82"/>
|
||||
<circle cx="591.5" cy="229.4" r="1.02" opacity="0.49"/>
|
||||
<circle cx="659.6" cy="129.0" r="1.38" opacity="0.19"/>
|
||||
<circle cx="897.3" cy="253.3" r="0.84" opacity="0.48"/>
|
||||
<circle cx="946.3" cy="121.6" r="0.85" opacity="0.29"/>
|
||||
<circle cx="656.1" cy="4.6" r="1.76" opacity="0.72"/>
|
||||
<circle cx="902.0" cy="258.2" r="1.42" opacity="0.45"/>
|
||||
<circle cx="767.5" cy="151.3" r="1.88" opacity="0.72"/>
|
||||
<circle cx="330.6" cy="273.4" r="1.57" opacity="0.70"/>
|
||||
<circle cx="1042.7" cy="121.7" r="1.77" opacity="0.77"/>
|
||||
<circle cx="889.3" cy="230.2" r="1.59" opacity="0.45"/>
|
||||
<circle cx="925.0" cy="21.2" r="1.04" opacity="0.49"/>
|
||||
<circle cx="13.6" cy="106.7" r="1.43" opacity="0.60"/>
|
||||
<circle cx="297.1" cy="283.4" r="1.47" opacity="0.41"/>
|
||||
<circle cx="844.5" cy="170.9" r="1.29" opacity="0.44"/>
|
||||
<circle cx="1279.9" cy="192.7" r="1.51" opacity="0.69"/>
|
||||
<circle cx="1254.5" cy="6.8" r="1.40" opacity="0.67"/>
|
||||
<circle cx="328.5" cy="120.5" r="0.67" opacity="0.31"/>
|
||||
</g>
|
||||
<rect y="180" width="1280" height="240" fill="url(#b-horizon)"/>
|
||||
<rect width="1280" height="420" fill="url(#b-glow)"/>
|
||||
<g transform="translate(120 90) rotate(-18) scale(0.42) translate(-32 -32)" opacity="0.16"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
|
||||
<g transform="translate(250 250) rotate(24) scale(0.3) translate(-32 -32)" opacity="0.17"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
|
||||
<g transform="translate(1035 95) rotate(12) scale(0.36) translate(-32 -32)" opacity="0.17"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
|
||||
<g transform="translate(1160 215) rotate(-32) scale(0.46) translate(-32 -32)" opacity="0.18"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
|
||||
<g transform="translate(905 300) rotate(40) scale(0.26) translate(-32 -32)" opacity="0.17"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
|
||||
<g transform="translate(185 300) rotate(-8) scale(0.24) translate(-32 -32)" opacity="0.18"><path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="#E0B252"/></g>
|
||||
|
||||
<!-- Ridge lines, furthest first. Each is lighter than the one in front of it,
|
||||
which is what reads as distance. -->
|
||||
<polygon points="0.0,366.0 77.4,260.4 99.7,287.8 209.3,307.1 222.5,340.5 301.6,290.7 339.9,314.6 467.1,267.1 496.3,282.9 606.7,228.9 632.9,259.8 746.4,307.3 778.6,334.3 861.2,310.5 896.2,334.5 1013.6,227.8 1044.7,263.3 1135.1,235.4 1159.3,271.3 1280.0,304.0 1280.0,366.0 1280,999 0,999" fill="#1C2836"/>
|
||||
<polygon points="0.0,392.0 76.5,282.7 92.5,305.1 157.2,334.8 195.6,347.7 306.6,319.4 331.0,337.8 404.6,292.3 419.7,305.8 478.9,333.4 502.2,359.5 591.3,344.5 610.7,372.4 673.6,307.7 696.0,329.2 781.8,302.5 807.3,323.8 939.9,310.5 956.3,320.6 1092.6,317.2 1110.4,344.2 1216.2,299.7 1251.5,314.1 1280.0,288.9 1280.0,392.0 1280,999 0,999" fill="#111A25"/>
|
||||
<polygon points="0.0,416.0 71.3,356.9 100.4,368.9 176.0,352.0 209.4,364.3 309.1,378.7 321.9,389.3 428.2,386.8 461.4,395.6 538.3,388.1 576.7,403.3 707.1,360.5 720.7,370.5 819.7,384.5 856.9,395.1 927.4,350.8 943.4,368.4 1038.7,363.5 1060.3,375.6 1133.4,388.3 1168.4,397.2 1280.0,380.1 1280.0,416.0 1280,999 0,999" fill="#080D13"/>
|
||||
<rect y="415" width="1280" height="5" fill="#C9A227" opacity="0.55"/>
|
||||
|
||||
<!-- Lockup. Colours are fixed rather than themed: the banner carries its own
|
||||
night sky, so it must not follow the reader's colour scheme. -->
|
||||
<g transform="translate(304.75 118.00) scale(2.1250)">
|
||||
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#b-wafer)"/>
|
||||
<g clip-path="url(#b-clip)" fill="none" stroke-linecap="round">
|
||||
<g stroke="#7A5C10" stroke-opacity="0.38" stroke-width="2">
|
||||
<path d="M32 6 V58"/>
|
||||
<path d="M6 32 H58"/>
|
||||
</g>
|
||||
<g stroke="#F6E3A8" stroke-opacity="0.3" stroke-width="1">
|
||||
<path d="M33.2 6 V58"/>
|
||||
<path d="M6 33.2 H58"/>
|
||||
</g>
|
||||
</g>
|
||||
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9"
|
||||
fill="none" stroke="#7A5C10" stroke-opacity="0.3" stroke-width="1.2"/>
|
||||
<g>
|
||||
<path d="M21.2 44.8 L17 49.4" stroke="#8A9AA8" stroke-width="3"
|
||||
stroke-linecap="round" fill="none"/>
|
||||
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="url(#b-leaf)"/>
|
||||
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.5"
|
||||
stroke-width="1.5" stroke-linecap="round"/>
|
||||
<g fill="none" stroke="#61758A" stroke-opacity="0.32"
|
||||
stroke-width="1" stroke-linecap="round">
|
||||
<path d="M26.9 38.8 Q25.2 35.8 24.9 32.1"/>
|
||||
<path d="M32.3 33.1 Q30.9 30.3 30.4 26.9"/>
|
||||
<path d="M37.8 27.0 Q36.6 24.6 36.3 21.7"/>
|
||||
<path d="M26.9 38.8 Q30.5 40.1 33.7 40.3"/>
|
||||
<path d="M32.3 33.1 Q35.8 34.3 38.6 34.5"/>
|
||||
<path d="M37.8 27.0 Q40.8 27.9 43.2 28.1"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g transform="translate(470.08 232.00)">
|
||||
<style>.base { fill: #EDE6D6; } .accent { fill: #E0B252; }</style>
|
||||
<path class="accent" data-char="L" d="M4.67 -88.57 14.83 -87.33C15.24 -76.48 15.24 -61.52 15.24 -49.02V-42.98C15.24 -30.21 15.24 -14.83 14.83 -3.84L4.67 -2.61V0.00H65.91L67.56 -26.78H64.95L56.30 -3.43H29.93C29.52 -14.28 29.39 -29.93 29.39 -42.98V-49.02C29.39 -61.52 29.52 -76.48 29.93 -87.33L39.96 -88.57V-91.18H4.67Z"/>
|
||||
<path class="accent" data-char="L" d="M75.52 -88.57 85.68 -87.33C86.10 -76.48 86.10 -61.52 86.10 -49.02V-42.98C86.10 -30.21 86.10 -14.83 85.68 -3.84L75.52 -2.61V0.00H136.76L138.41 -26.78H135.80L127.15 -3.43H100.79C100.38 -14.28 100.24 -29.93 100.24 -42.98V-49.02C100.24 -61.52 100.38 -76.48 100.79 -87.33L110.81 -88.57V-91.18H75.52Z"/>
|
||||
<path class="base" data-char="e" d="M175.76 -60.97C182.76 -60.97 187.43 -55.47 187.43 -45.18C187.43 -39.13 185.37 -37.07 179.06 -37.07H161.07C162.03 -54.65 168.76 -60.97 175.76 -60.97ZM175.90 1.79C186.20 1.79 194.85 -2.88 199.79 -13.46L197.87 -14.83C193.75 -9.75 188.39 -6.45 180.98 -6.45C169.44 -6.45 160.93 -16.20 160.93 -32.82V-33.92H198.69C199.24 -35.84 199.52 -37.49 199.52 -40.51C199.52 -54.79 189.63 -64.26 175.62 -64.26C160.38 -64.26 146.93 -51.49 146.93 -30.07C146.93 -9.89 159.70 1.79 175.90 1.79Z"/>
|
||||
<path class="accent" data-char="M" d="M209.95 0.00H235.63V-2.61L224.64 -3.84V-80.33L253.21 0.00H258.29L286.85 -81.29V-42.98C286.85 -30.21 286.71 -14.69 286.30 -3.84L276.96 -2.61V0.00H311.56V-2.61L301.54 -3.84C301.13 -14.69 300.99 -30.21 300.99 -42.98V-49.02C300.99 -61.52 301.13 -76.48 301.54 -87.33L311.56 -88.57V-91.18H286.30L261.03 -20.32L236.04 -91.18H209.95V-88.57L220.66 -87.19V-3.84L209.95 -2.61Z"/>
|
||||
<path class="base" data-char="b" d="M320.76 0.00 343.01 1.51V-8.51C347.68 -1.10 353.86 1.79 360.59 1.79C375.28 1.79 386.26 -10.99 386.26 -32.82C386.26 -53.55 375.69 -64.26 361.96 -64.26C353.58 -64.26 347.13 -59.59 343.01 -52.59V-72.50L343.56 -99.96L342.05 -101.34L320.21 -95.57V-93.37L329.69 -91.18V-28.56C329.69 -21.42 329.55 -11.40 329.28 -3.84L320.76 -2.61ZM356.19 -57.26C365.25 -57.26 372.12 -49.84 372.12 -31.99C372.12 -13.73 365.12 -5.36 356.47 -5.36C350.97 -5.36 347.40 -7.00 343.01 -11.67V-49.57C346.72 -54.24 351.11 -57.26 356.19 -57.26Z"/>
|
||||
<path class="base" data-char="a" d="M442.97 1.51C449.15 1.51 453.00 -1.92 455.06 -6.87L453.41 -8.24C451.21 -5.77 449.98 -4.81 447.92 -4.81C445.58 -4.81 444.21 -6.32 444.21 -10.71V-41.33C444.21 -57.26 437.89 -64.26 423.89 -64.26C410.02 -64.26 400.13 -57.81 398.62 -48.47C399.31 -44.76 401.09 -42.70 404.80 -42.70C408.51 -42.70 411.67 -45.18 412.35 -51.08L413.86 -59.73C415.65 -60.28 417.30 -60.56 419.22 -60.56C427.59 -60.56 430.75 -56.30 430.75 -42.84V-38.04C425.53 -36.80 421.14 -35.56 418.12 -34.47C400.96 -28.97 396.29 -22.24 396.29 -13.73C396.29 -3.71 403.29 1.79 412.49 1.79C419.63 1.79 425.40 -0.82 430.89 -7.96C431.85 -1.92 435.83 1.51 442.97 1.51ZM409.47 -16.89C409.47 -22.93 412.21 -27.87 421.55 -31.99C423.34 -32.82 426.50 -33.92 430.75 -35.29V-10.71C426.22 -7.00 423.20 -6.18 419.08 -6.18C413.59 -6.18 409.47 -9.34 409.47 -16.89Z"/>
|
||||
<path class="base" data-char="s" d="M481.56 1.79C496.80 1.79 505.18 -5.90 505.18 -17.85C505.18 -28.97 496.94 -33.09 488.84 -36.53L484.99 -38.17C478.26 -41.06 472.50 -44.08 472.50 -50.94C472.50 -56.99 476.89 -61.24 484.58 -61.24C488.01 -61.24 490.76 -60.83 493.23 -59.59L499.13 -43.53H501.47L502.02 -59.46C496.66 -62.61 491.44 -64.26 484.58 -64.26C469.89 -64.26 462.47 -56.02 462.47 -45.31C462.47 -34.47 470.30 -30.21 478.13 -26.91L481.97 -25.27C488.70 -22.38 494.60 -19.50 494.60 -12.50C494.60 -5.90 489.93 -1.10 481.56 -1.10C477.30 -1.10 474.28 -1.65 471.26 -2.75L465.08 -20.46H462.88L462.06 -3.43C468.24 0.00 473.87 1.79 481.56 1.79Z"/>
|
||||
</g>
|
||||
<g transform="translate(351.54 300.00)">
|
||||
<style>.tag { fill: #9AA7B4; }</style>
|
||||
<path class="tag" data-char="W" d="M7.61 0.23H8.46L19.17 -22.00L21.34 0.23H22.20L33.80 -25.03L36.56 -25.38L36.71 -26.00H29.07L28.95 -25.38L32.56 -25.03L23.56 -5.01L21.58 -25.03L24.87 -25.38L24.99 -26.00H16.45L16.34 -25.38L19.56 -25.03L9.86 -4.97L8.58 -25.03L12.15 -25.38L12.26 -26.00H3.34L3.22 -25.38L5.78 -25.03Z"/>
|
||||
<path class="tag" data-char="a" d="M37.72 -5.12C37.72 -7.68 38.77 -11.64 40.82 -14.09C41.91 -15.41 43.31 -16.30 44.94 -16.30C46.06 -16.30 46.92 -15.83 47.58 -15.25L45.64 -5.63C43.04 -2.64 41.21 -1.44 39.85 -1.44C38.22 -1.44 37.72 -2.91 37.72 -5.12ZM46.96 0.47C49.28 0.47 50.84 -1.71 52.04 -3.69L51.57 -4.07C50.21 -2.52 48.93 -1.51 48.00 -1.51C47.61 -1.51 47.38 -1.75 47.38 -2.13C47.38 -2.68 47.50 -3.14 47.65 -3.96L50.53 -18.01L50.21 -18.32L48.47 -16.96C47.69 -17.62 46.72 -18.01 45.79 -18.01C41.02 -18.01 35.20 -9.93 35.20 -4.19C35.20 -0.93 36.75 0.47 38.61 0.47C41.02 0.47 43.27 -1.63 45.40 -4.54C44.90 -2.25 44.90 -1.79 44.90 -1.40C44.90 -0.19 45.87 0.47 46.96 0.47Z"/>
|
||||
<path class="tag" data-char="y" d="M50.87 10.05C51.69 10.05 52.54 9.86 53.47 9.31C56.97 7.30 59.80 3.14 61.78 0.00C63.72 -3.10 65.23 -5.90 66.55 -8.34C67.95 -10.83 68.73 -12.30 69.46 -13.89C69.73 -14.55 70.32 -15.76 70.32 -16.76C70.32 -17.50 70.04 -18.16 69.15 -18.16C68.03 -18.16 67.56 -17.39 66.98 -14.47C65.85 -8.89 64.26 -5.70 61.39 -0.85C61.31 -5.32 60.81 -11.76 60.34 -15.02C60.03 -17.11 59.33 -18.01 57.82 -18.01C56.04 -18.01 54.95 -16.45 53.71 -13.50L54.17 -13.16C55.41 -15.13 56.07 -16.03 56.85 -16.03C57.36 -16.03 57.74 -15.56 57.94 -13.93C58.40 -10.17 59.06 -3.61 59.33 2.17C57.78 4.46 56.07 6.52 53.82 8.11C53.47 8.38 53.05 8.69 52.66 8.89L52.16 8.34C51.34 7.41 50.49 6.91 49.48 6.91C48.62 6.91 47.73 7.37 47.58 8.19C47.93 9.51 49.32 10.05 50.87 10.05Z"/>
|
||||
<path class="tag" data-char="b" d="M75.40 -4.35C75.40 -6.09 76.02 -8.11 76.18 -8.93L76.80 -11.91C79.44 -14.90 81.34 -16.10 82.73 -16.10C84.36 -16.10 85.02 -14.79 85.02 -12.42C85.02 -9.82 83.94 -5.86 81.88 -3.41C80.79 -2.13 79.47 -1.28 77.84 -1.28C75.94 -1.28 75.40 -2.72 75.40 -4.35ZM76.76 0.47C81.80 0.47 87.55 -7.61 87.55 -13.35C87.55 -16.61 85.84 -18.01 83.94 -18.01C81.57 -18.01 79.20 -15.91 76.99 -12.96L80.21 -28.56L79.82 -28.87L74.08 -27.24L74.00 -26.70L77.30 -26.12L73.61 -8.34C73.30 -6.91 72.92 -5.36 72.92 -4.00C72.92 -1.40 74.04 0.47 76.76 0.47Z"/>
|
||||
<path class="tag" data-char="r" d="M90.92 0.00 91.23 0.31 93.56 0.00C93.95 -2.64 94.38 -5.20 94.88 -7.76L95.39 -10.28C96.70 -12.81 98.14 -14.94 99.54 -16.10C100.35 -15.25 101.09 -14.82 101.90 -14.82C103.11 -14.82 103.88 -15.64 103.92 -16.69C103.57 -17.73 102.68 -18.01 101.75 -18.01C99.69 -18.01 97.79 -16.10 95.58 -11.84L96.78 -17.70L96.43 -18.01L90.84 -16.38L90.77 -15.83L94.10 -15.29Z"/>
|
||||
<path class="tag" data-char="e" d="M114.09 -17.23C115.29 -17.23 115.80 -16.22 115.80 -15.06C115.80 -12.46 113.70 -9.74 107.18 -7.80C107.88 -13.19 111.64 -17.23 114.09 -17.23ZM109.94 0.47C112.89 0.47 115.10 -1.40 116.57 -4.00L116.11 -4.31C115.02 -3.07 113.04 -1.47 111.10 -1.47C108.89 -1.47 107.07 -2.95 107.07 -5.98C107.07 -6.33 107.07 -6.67 107.10 -7.02C116.07 -9.55 118.05 -12.42 118.05 -15.06C118.05 -17.04 116.69 -18.01 114.36 -18.01C109.94 -18.01 104.62 -12.19 104.62 -5.32C104.62 -1.55 106.79 0.47 109.94 0.47Z"/>
|
||||
<path class="tag" data-char="a" d="M122.12 -5.12C122.12 -7.68 123.17 -11.64 125.23 -14.09C126.31 -15.41 127.71 -16.30 129.34 -16.30C130.47 -16.30 131.32 -15.83 131.98 -15.25L130.04 -5.63C127.44 -2.64 125.61 -1.44 124.26 -1.44C122.63 -1.44 122.12 -2.91 122.12 -5.12ZM131.36 0.47C133.69 0.47 135.24 -1.71 136.44 -3.69L135.98 -4.07C134.62 -2.52 133.34 -1.51 132.41 -1.51C132.02 -1.51 131.79 -1.75 131.79 -2.13C131.79 -2.68 131.90 -3.14 132.06 -3.96L134.93 -18.01L134.62 -18.32L132.87 -16.96C132.10 -17.62 131.13 -18.01 130.19 -18.01C125.42 -18.01 119.60 -9.93 119.60 -4.19C119.60 -0.93 121.15 0.47 123.01 0.47C125.42 0.47 127.67 -1.63 129.81 -4.54C129.30 -2.25 129.30 -1.79 129.30 -1.40C129.30 -0.19 130.27 0.47 131.36 0.47Z"/>
|
||||
<path class="tag" data-char="d" d="M141.41 -5.12C141.41 -7.92 142.69 -12.30 145.02 -14.67C146.03 -15.64 147.27 -16.30 148.63 -16.30C149.75 -16.30 150.61 -15.83 151.27 -15.25L149.29 -5.51C146.69 -2.60 144.90 -1.44 143.54 -1.44C141.91 -1.44 141.41 -2.91 141.41 -5.12ZM150.64 0.47C152.97 0.47 154.53 -1.71 155.73 -3.69L155.26 -4.07C153.90 -2.52 152.62 -1.51 151.69 -1.51C151.30 -1.51 151.07 -1.75 151.07 -2.13C151.07 -2.68 151.19 -3.14 151.34 -3.96L156.43 -28.56L156.08 -28.87L150.37 -27.24L150.26 -26.70L153.59 -26.12L151.77 -17.27C151.07 -17.73 150.26 -18.01 149.48 -18.01C144.71 -18.01 138.89 -9.93 138.89 -4.19C138.89 -0.93 140.44 0.47 142.30 0.47C144.71 0.47 146.96 -1.63 149.05 -4.50L148.90 -3.65C148.63 -2.33 148.59 -1.82 148.59 -1.36C148.59 -0.19 149.56 0.47 150.64 0.47Z"/>
|
||||
<path class="tag" data-char="f" d="M161.32 10.05C163.22 10.05 164.81 9.08 166.09 7.57C167.95 5.32 169.16 2.02 169.62 -0.97C170.44 -6.17 171.25 -11.41 172.07 -16.61H176.99L177.19 -17.54H172.22C172.30 -17.97 172.34 -18.39 172.41 -18.82C173.35 -24.84 175.29 -27.63 177.30 -28.60L178.70 -27.01C179.48 -26.08 180.02 -25.61 180.84 -25.61C181.42 -25.61 182.04 -25.88 182.19 -26.74C181.96 -28.21 180.06 -29.26 178.12 -29.26C175.67 -29.26 171.33 -27.13 170.01 -18.74C169.97 -18.39 169.89 -18.01 169.85 -17.66L166.24 -17.23V-16.61H169.70C168.88 -11.37 168.07 -6.17 167.25 -0.97C166.90 1.16 166.32 4.42 165.04 6.75C164.54 7.64 163.92 8.42 163.14 8.93L162.56 8.34C161.67 7.45 161.04 6.91 159.88 6.91C159.03 6.91 158.17 7.37 158.02 8.19C158.37 9.51 159.73 10.05 161.32 10.05Z"/>
|
||||
<path class="tag" data-char="o" d="M182.35 0.47C187.51 0.47 191.27 -5.12 191.27 -11.33C191.27 -15.79 188.64 -18.01 185.45 -18.01C180.33 -18.01 176.57 -12.42 176.57 -6.21C176.57 -1.75 179.17 0.47 182.35 0.47ZM182.35 -0.31C180.10 -0.31 179.09 -2.29 179.09 -5.98C179.09 -12.19 182.08 -17.23 185.45 -17.23C187.70 -17.23 188.75 -15.25 188.75 -11.60C188.75 -5.36 185.73 -0.31 182.35 -0.31Z"/>
|
||||
<path class="tag" data-char="r" d="M194.77 0.00 195.08 0.31 197.41 0.00C197.79 -2.64 198.22 -5.20 198.73 -7.76L199.23 -10.28C200.55 -12.81 201.99 -14.94 203.38 -16.10C204.20 -15.25 204.93 -14.82 205.75 -14.82C206.95 -14.82 207.73 -15.64 207.77 -16.69C207.42 -17.73 206.53 -18.01 205.59 -18.01C203.54 -18.01 201.64 -16.10 199.42 -11.84L200.63 -17.70L200.28 -18.01L194.69 -16.38L194.61 -15.83L197.95 -15.29Z"/>
|
||||
<path class="tag" data-char="t" d="M219.18 0.47C221.54 0.47 223.29 -1.71 224.49 -3.69L224.03 -4.07C222.71 -2.52 221.39 -1.51 220.46 -1.51C220.07 -1.51 219.84 -1.75 219.84 -2.13C219.84 -2.68 219.99 -3.14 220.15 -3.96L222.79 -16.61H227.29L227.48 -17.54H222.98L224.22 -23.44H223.52L220.77 -17.70L216.85 -17.23V-16.61H220.42L217.74 -3.73C217.47 -2.37 217.35 -1.82 217.35 -1.36C217.35 -0.19 218.13 0.47 219.18 0.47Z"/>
|
||||
<path class="tag" data-char="h" d="M228.53 0.31 230.86 0.00C231.24 -2.64 231.63 -5.20 232.18 -7.76L233.22 -12.84C235.82 -14.94 237.73 -15.95 239.16 -15.95C239.94 -15.95 240.56 -15.44 240.56 -14.44C240.56 -13.54 240.17 -11.99 239.90 -10.79L238.23 -3.61C237.92 -2.25 237.92 -1.71 237.92 -1.24C237.92 -0.08 238.93 0.47 239.86 0.47C242.27 0.47 243.90 -1.71 245.10 -3.69L244.63 -4.07C243.27 -2.52 241.88 -1.51 241.14 -1.51C240.79 -1.51 240.44 -1.79 240.44 -2.25C240.44 -2.64 240.56 -3.34 240.75 -4.15L242.50 -11.84C242.77 -13.00 243.04 -14.20 243.04 -15.37C243.04 -17.07 242.19 -18.01 240.67 -18.01C238.46 -18.01 235.75 -16.03 233.42 -13.74L236.52 -28.56L236.21 -28.87L230.39 -27.24L230.31 -26.70L233.50 -26.16C233.22 -24.37 232.87 -22.51 232.49 -20.68L228.22 0.00Z"/>
|
||||
<path class="tag" data-char="e" d="M256.86 -17.23C258.06 -17.23 258.56 -16.22 258.56 -15.06C258.56 -12.46 256.47 -9.74 249.95 -7.80C250.65 -13.19 254.41 -17.23 256.86 -17.23ZM252.70 0.47C255.65 0.47 257.87 -1.40 259.34 -4.00L258.87 -4.31C257.79 -3.07 255.81 -1.47 253.87 -1.47C251.66 -1.47 249.83 -2.95 249.83 -5.98C249.83 -6.33 249.83 -6.67 249.87 -7.02C258.84 -9.55 260.81 -12.42 260.81 -15.06C260.81 -17.04 259.46 -18.01 257.13 -18.01C252.70 -18.01 247.39 -12.19 247.39 -5.32C247.39 -1.55 249.56 0.47 252.70 0.47Z"/>
|
||||
<path class="tag" data-char="l" d="M272.84 0.47C275.29 0.47 277.04 -1.71 278.24 -3.69L277.77 -4.07C276.41 -2.52 275.06 -1.51 274.28 -1.51C273.93 -1.51 273.58 -1.79 273.58 -2.25C273.58 -2.64 273.74 -3.34 273.89 -4.15L278.94 -28.56L278.63 -28.87L272.88 -27.24L272.81 -26.70L275.91 -26.16C275.64 -24.37 275.29 -22.51 274.90 -20.68L271.37 -3.61C271.10 -2.25 271.06 -1.71 271.06 -1.24C271.06 -0.08 271.91 0.47 272.84 0.47Z"/>
|
||||
<path class="tag" data-char="o" d="M286.50 0.47C291.67 0.47 295.43 -5.12 295.43 -11.33C295.43 -15.79 292.79 -18.01 289.61 -18.01C284.49 -18.01 280.72 -12.42 280.72 -6.21C280.72 -1.75 283.32 0.47 286.50 0.47ZM286.50 -0.31C284.25 -0.31 283.24 -2.29 283.24 -5.98C283.24 -12.19 286.23 -17.23 289.61 -17.23C291.86 -17.23 292.91 -15.25 292.91 -11.60C292.91 -5.36 289.88 -0.31 286.50 -0.31Z"/>
|
||||
<path class="tag" data-char="n" d="M299.23 0.31 301.56 0.00C301.95 -2.64 302.34 -5.20 302.88 -7.76L303.89 -12.84C306.53 -14.94 308.43 -15.95 309.87 -15.95C310.60 -15.95 311.22 -15.44 311.22 -14.44C311.22 -13.54 310.84 -11.99 310.56 -10.79L308.93 -3.61C308.62 -2.25 308.59 -1.71 308.59 -1.24C308.59 -0.08 309.59 0.47 310.53 0.47C312.97 0.47 314.56 -1.71 315.76 -3.69L315.30 -4.07C313.98 -2.52 312.58 -1.51 311.81 -1.51C311.46 -1.51 311.11 -1.79 311.11 -2.25C311.11 -2.64 311.22 -3.34 311.42 -4.15L313.16 -11.84C313.44 -13.00 313.71 -14.20 313.71 -15.37C313.71 -17.07 312.89 -18.01 311.38 -18.01C309.13 -18.01 306.41 -16.03 304.08 -13.70L304.94 -17.70L304.59 -18.01L298.84 -16.38L298.77 -15.83L302.10 -15.29L298.92 0.00Z"/>
|
||||
<path class="tag" data-char="g" d="M324.19 -5.28C327.91 -5.28 330.28 -8.38 330.94 -11.76C331.21 -13.08 331.64 -14.59 332.02 -15.60C334.08 -15.79 335.52 -16.14 335.52 -17.54C335.52 -17.81 335.40 -18.20 335.24 -18.39C335.01 -18.51 334.62 -18.55 334.24 -18.55C332.76 -18.55 331.48 -17.81 330.82 -13.97V-13.66C330.74 -16.73 328.92 -18.16 326.16 -18.16C322.32 -18.16 319.30 -14.51 319.30 -10.01C319.30 -8.07 320.03 -6.75 321.24 -6.01C319.53 -4.73 318.60 -3.49 318.60 -2.17C318.60 -0.97 319.22 -0.19 320.42 0.19C317.82 1.40 315.61 3.34 315.61 5.98C315.61 8.58 317.78 10.05 321.20 10.05C327.29 10.05 331.36 6.40 331.36 2.60C331.36 0.70 330.16 -0.85 326.55 -1.24L322.90 -1.63C321.16 -1.82 320.46 -2.48 320.46 -3.30C320.46 -3.88 320.69 -4.58 321.70 -5.78C322.44 -5.43 323.25 -5.28 324.19 -5.28ZM324.38 -6.01C322.71 -6.01 321.78 -7.45 321.78 -10.44C321.78 -14.55 323.56 -17.46 325.97 -17.46C327.64 -17.46 328.57 -16.18 328.57 -13.16C328.57 -9.08 326.75 -6.01 324.38 -6.01ZM317.82 5.24C317.82 3.34 319.02 1.75 321.12 0.39C321.31 0.43 321.47 0.43 321.62 0.47L325.50 0.89C328.57 1.24 329.19 2.48 329.19 4.19C329.19 6.60 326.71 8.65 322.63 8.65C319.68 8.65 317.82 7.33 317.82 5.24Z"/>
|
||||
<path class="tag" data-char="r" d="M344.48 0.00 344.79 0.31 347.12 0.00C347.51 -2.64 347.93 -5.20 348.44 -7.76L348.94 -10.28C350.26 -12.81 351.70 -14.94 353.10 -16.10C353.91 -15.25 354.65 -14.82 355.46 -14.82C356.67 -14.82 357.44 -15.64 357.48 -16.69C357.13 -17.73 356.24 -18.01 355.31 -18.01C353.25 -18.01 351.35 -16.10 349.14 -11.84L350.34 -17.70L349.99 -18.01L344.40 -16.38L344.33 -15.83L347.66 -15.29Z"/>
|
||||
<path class="tag" data-char="o" d="M364.12 0.47C369.28 0.47 373.04 -5.12 373.04 -11.33C373.04 -15.79 370.40 -18.01 367.22 -18.01C362.10 -18.01 358.33 -12.42 358.33 -6.21C358.33 -1.75 360.93 0.47 364.12 0.47ZM364.12 -0.31C361.87 -0.31 360.86 -2.29 360.86 -5.98C360.86 -12.19 363.84 -17.23 367.22 -17.23C369.47 -17.23 370.52 -15.25 370.52 -11.60C370.52 -5.36 367.49 -0.31 364.12 -0.31Z"/>
|
||||
<path class="tag" data-char="a" d="M378.01 -5.12C378.01 -7.68 379.06 -11.64 381.11 -14.09C382.20 -15.41 383.60 -16.30 385.23 -16.30C386.35 -16.30 387.21 -15.83 387.87 -15.25L385.93 -5.63C383.33 -2.64 381.50 -1.44 380.14 -1.44C378.51 -1.44 378.01 -2.91 378.01 -5.12ZM387.24 0.47C389.57 0.47 391.13 -1.71 392.33 -3.69L391.86 -4.07C390.50 -2.52 389.22 -1.51 388.29 -1.51C387.90 -1.51 387.67 -1.75 387.67 -2.13C387.67 -2.68 387.79 -3.14 387.94 -3.96L390.81 -18.01L390.50 -18.32L388.76 -16.96C387.98 -17.62 387.01 -18.01 386.08 -18.01C381.31 -18.01 375.49 -9.93 375.49 -4.19C375.49 -0.93 377.04 0.47 378.90 0.47C381.31 0.47 383.56 -1.63 385.69 -4.54C385.19 -2.25 385.19 -1.79 385.19 -1.40C385.19 -0.19 386.16 0.47 387.24 0.47Z"/>
|
||||
<path class="tag" data-char="d" d="M397.30 -5.12C397.30 -7.92 398.58 -12.30 400.90 -14.67C401.91 -15.64 403.16 -16.30 404.51 -16.30C405.64 -16.30 406.49 -15.83 407.15 -15.25L405.17 -5.51C402.57 -2.60 400.79 -1.44 399.43 -1.44C397.80 -1.44 397.30 -2.91 397.30 -5.12ZM406.53 0.47C408.86 0.47 410.41 -1.71 411.61 -3.69L411.15 -4.07C409.79 -2.52 408.51 -1.51 407.58 -1.51C407.19 -1.51 406.96 -1.75 406.96 -2.13C406.96 -2.68 407.07 -3.14 407.23 -3.96L412.31 -28.56L411.96 -28.87L406.26 -27.24L406.14 -26.70L409.48 -26.12L407.66 -17.27C406.96 -17.73 406.14 -18.01 405.37 -18.01C400.59 -18.01 394.77 -9.93 394.77 -4.19C394.77 -0.93 396.33 0.47 398.19 0.47C400.59 0.47 402.84 -1.63 404.94 -4.50L404.79 -3.65C404.51 -2.33 404.47 -1.82 404.47 -1.36C404.47 -0.19 405.44 0.47 406.53 0.47Z"/>
|
||||
<path class="tag" data-char="o" d="M427.76 0.47C432.92 0.47 436.68 -5.12 436.68 -11.33C436.68 -15.79 434.04 -18.01 430.86 -18.01C425.74 -18.01 421.98 -12.42 421.98 -6.21C421.98 -1.75 424.58 0.47 427.76 0.47ZM427.76 -0.31C425.51 -0.31 424.50 -2.29 424.50 -5.98C424.50 -12.19 427.49 -17.23 430.86 -17.23C433.11 -17.23 434.16 -15.25 434.16 -11.60C434.16 -5.36 431.13 -0.31 427.76 -0.31Z"/>
|
||||
<path class="tag" data-char="f" d="M434.20 10.05C436.10 10.05 437.69 9.08 438.97 7.57C440.84 5.32 442.04 2.02 442.50 -0.97C443.32 -6.17 444.13 -11.41 444.95 -16.61H449.88L450.07 -17.54H445.10C445.18 -17.97 445.22 -18.39 445.30 -18.82C446.23 -24.84 448.17 -27.63 450.19 -28.60L451.59 -27.01C452.36 -26.08 452.90 -25.61 453.72 -25.61C454.30 -25.61 454.92 -25.88 455.08 -26.74C454.84 -28.21 452.94 -29.26 451.00 -29.26C448.56 -29.26 444.21 -27.13 442.89 -18.74C442.85 -18.39 442.78 -18.01 442.74 -17.66L439.13 -17.23V-16.61H442.58C441.77 -11.37 440.95 -6.17 440.14 -0.97C439.79 1.16 439.21 4.42 437.93 6.75C437.42 7.64 436.80 8.42 436.02 8.93L435.44 8.34C434.55 7.45 433.93 6.91 432.76 6.91C431.91 6.91 431.06 7.37 430.90 8.19C431.25 9.51 432.61 10.05 434.20 10.05Z"/>
|
||||
<path class="tag" data-char="t" d="M460.01 0.47C462.37 0.47 464.12 -1.71 465.32 -3.69L464.86 -4.07C463.54 -2.52 462.22 -1.51 461.29 -1.51C460.90 -1.51 460.67 -1.75 460.67 -2.13C460.67 -2.68 460.82 -3.14 460.98 -3.96L463.61 -16.61H468.12L468.31 -17.54H463.81L465.05 -23.44H464.35L461.60 -17.70L457.68 -17.23V-16.61H461.25L458.57 -3.73C458.30 -2.37 458.18 -1.82 458.18 -1.36C458.18 -0.19 458.96 0.47 460.01 0.47Z"/>
|
||||
<path class="tag" data-char="h" d="M469.36 0.31 471.69 0.00C472.07 -2.64 472.46 -5.20 473.01 -7.76L474.05 -12.84C476.65 -14.94 478.56 -15.95 479.99 -15.95C480.77 -15.95 481.39 -15.44 481.39 -14.44C481.39 -13.54 481.00 -11.99 480.73 -10.79L479.06 -3.61C478.75 -2.25 478.75 -1.71 478.75 -1.24C478.75 -0.08 479.76 0.47 480.69 0.47C483.10 0.47 484.73 -1.71 485.93 -3.69L485.46 -4.07C484.10 -2.52 482.71 -1.51 481.97 -1.51C481.62 -1.51 481.27 -1.79 481.27 -2.25C481.27 -2.64 481.39 -3.34 481.58 -4.15L483.33 -11.84C483.60 -13.00 483.87 -14.20 483.87 -15.37C483.87 -17.07 483.02 -18.01 481.50 -18.01C479.29 -18.01 476.58 -16.03 474.25 -13.74L477.35 -28.56L477.04 -28.87L471.22 -27.24L471.14 -26.70L474.33 -26.16C474.05 -24.37 473.70 -22.51 473.32 -20.68L469.05 0.00Z"/>
|
||||
<path class="tag" data-char="o" d="M494.16 0.47C499.32 0.47 503.08 -5.12 503.08 -11.33C503.08 -15.79 500.44 -18.01 497.26 -18.01C492.14 -18.01 488.37 -12.42 488.37 -6.21C488.37 -1.75 490.97 0.47 494.16 0.47ZM494.16 -0.31C491.90 -0.31 490.90 -2.29 490.90 -5.98C490.90 -12.19 493.88 -17.23 497.26 -17.23C499.51 -17.23 500.56 -15.25 500.56 -11.60C500.56 -5.36 497.53 -0.31 494.16 -0.31Z"/>
|
||||
<path class="tag" data-char="u" d="M509.21 0.47C511.46 0.47 514.14 -1.51 516.47 -3.80C516.16 -2.33 516.12 -1.82 516.12 -1.36C516.12 -0.19 516.90 0.47 517.94 0.47C520.31 0.47 522.10 -1.71 523.30 -3.69L522.79 -4.07C521.47 -2.52 520.16 -1.51 519.26 -1.51C518.87 -1.51 518.64 -1.75 518.64 -2.13C518.64 -2.68 518.76 -3.14 518.91 -3.96L521.75 -17.70L521.40 -18.01L519.03 -17.39C518.64 -14.79 518.21 -12.34 517.71 -9.78L516.66 -4.70C514.06 -2.60 512.16 -1.59 510.76 -1.59C509.99 -1.59 509.41 -2.10 509.41 -3.14C509.41 -4.00 509.79 -5.55 510.07 -6.75L512.43 -17.70L512.12 -18.01L506.38 -16.38L506.26 -15.83L509.60 -15.29L507.43 -5.70C507.16 -4.54 506.88 -3.34 506.88 -2.17C506.88 -0.47 507.70 0.47 509.21 0.47Z"/>
|
||||
<path class="tag" data-char="g" d="M531.68 -5.28C535.41 -5.28 537.77 -8.38 538.43 -11.76C538.70 -13.08 539.13 -14.59 539.52 -15.60C541.58 -15.79 543.01 -16.14 543.01 -17.54C543.01 -17.81 542.90 -18.20 542.74 -18.39C542.51 -18.51 542.12 -18.55 541.73 -18.55C540.26 -18.55 538.98 -17.81 538.32 -13.97V-13.66C538.24 -16.73 536.41 -18.16 533.66 -18.16C529.82 -18.16 526.79 -14.51 526.79 -10.01C526.79 -8.07 527.53 -6.75 528.73 -6.01C527.02 -4.73 526.09 -3.49 526.09 -2.17C526.09 -0.97 526.71 -0.19 527.92 0.19C525.32 1.40 523.10 3.34 523.10 5.98C523.10 8.58 525.28 10.05 528.69 10.05C534.79 10.05 538.86 6.40 538.86 2.60C538.86 0.70 537.66 -0.85 534.05 -1.24L530.40 -1.63C528.65 -1.82 527.96 -2.48 527.96 -3.30C527.96 -3.88 528.19 -4.58 529.20 -5.78C529.93 -5.43 530.75 -5.28 531.68 -5.28ZM531.87 -6.01C530.21 -6.01 529.27 -7.45 529.27 -10.44C529.27 -14.55 531.06 -17.46 533.47 -17.46C535.13 -17.46 536.07 -16.18 536.07 -13.16C536.07 -9.08 534.24 -6.01 531.87 -6.01ZM525.32 5.24C525.32 3.34 526.52 1.75 528.61 0.39C528.81 0.43 528.96 0.43 529.12 0.47L533.00 0.89C536.07 1.24 536.69 2.48 536.69 4.19C536.69 6.60 534.20 8.65 530.13 8.65C527.18 8.65 525.32 7.33 525.32 5.24Z"/>
|
||||
<path class="tag" data-char="h" d="M543.75 0.31 546.08 0.00C546.47 -2.64 546.85 -5.20 547.40 -7.76L548.44 -12.84C551.04 -14.94 552.95 -15.95 554.38 -15.95C555.16 -15.95 555.78 -15.44 555.78 -14.44C555.78 -13.54 555.39 -11.99 555.12 -10.79L553.45 -3.61C553.14 -2.25 553.14 -1.71 553.14 -1.24C553.14 -0.08 554.15 0.47 555.08 0.47C557.49 0.47 559.12 -1.71 560.32 -3.69L559.85 -4.07C558.50 -2.52 557.10 -1.51 556.36 -1.51C556.01 -1.51 555.66 -1.79 555.66 -2.25C555.66 -2.64 555.78 -3.34 555.97 -4.15L557.72 -11.84C557.99 -13.00 558.26 -14.20 558.26 -15.37C558.26 -17.07 557.41 -18.01 555.90 -18.01C553.68 -18.01 550.97 -16.03 548.64 -13.74L551.74 -28.56L551.43 -28.87L545.61 -27.24L545.53 -26.70L548.72 -26.16C548.44 -24.37 548.10 -22.51 547.71 -20.68L543.44 0.00Z"/>
|
||||
<path class="tag" data-char="t" d="M565.40 0.47C567.77 0.47 569.52 -1.71 570.72 -3.69L570.25 -4.07C568.93 -2.52 567.61 -1.51 566.68 -1.51C566.30 -1.51 566.06 -1.75 566.06 -2.13C566.06 -2.68 566.22 -3.14 566.37 -3.96L569.01 -16.61H573.51L573.71 -17.54H569.21L570.45 -23.44H569.75L566.99 -17.70L563.07 -17.23V-16.61H566.64L563.97 -3.73C563.70 -2.37 563.58 -1.82 563.58 -1.36C563.58 -0.19 564.36 0.47 565.40 0.47Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 36 KiB |
@@ -0,0 +1,27 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"
|
||||
role="img" aria-label="LLeMbas">
|
||||
<title>LLeMbas</title>
|
||||
<defs>
|
||||
<linearGradient id="f-wafer" x1="0" y1="0" x2="0.3" y2="1">
|
||||
<stop offset="0" stop-color="#EACB74"/>
|
||||
<stop offset="0.5" stop-color="#C9A227"/>
|
||||
<stop offset="1" stop-color="#916F13"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="f-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
|
||||
<stop offset="0" stop-color="#93A5B6"/>
|
||||
<stop offset="0.4" stop-color="#F1F6FA"/>
|
||||
<stop offset="1" stop-color="#B8C7D5"/>
|
||||
</linearGradient>
|
||||
<clipPath id="f-clip">
|
||||
<rect x="6" y="6" width="52" height="52" rx="13"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#f-wafer)"/>
|
||||
<g transform="translate(32 32) scale(1.16) translate(-32 -32)">
|
||||
<path d="M20.6 44.6 L15.6 50.1" stroke="#8A9AA8" stroke-width="3.4"
|
||||
stroke-linecap="round" fill="none"/>
|
||||
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="url(#f-leaf)"/>
|
||||
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.45"
|
||||
stroke-width="1.8" stroke-linecap="round"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,49 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"
|
||||
role="img" aria-label="LLeMbas">
|
||||
<title>LLeMbas</title>
|
||||
<desc>A silver mallorn leaf laid across a scored golden lembas wafer.</desc>
|
||||
<defs>
|
||||
<linearGradient id="m-wafer" x1="0" y1="0" x2="0.3" y2="1">
|
||||
<stop offset="0" stop-color="#EACB74"/>
|
||||
<stop offset="0.5" stop-color="#C9A227"/>
|
||||
<stop offset="1" stop-color="#916F13"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="m-leaf" x1="0.1" y1="1" x2="0.9" y2="0">
|
||||
<stop offset="0" stop-color="#93A5B6"/>
|
||||
<stop offset="0.4" stop-color="#F1F6FA"/>
|
||||
<stop offset="1" stop-color="#B8C7D5"/>
|
||||
</linearGradient>
|
||||
<clipPath id="m-clip">
|
||||
<rect x="6" y="6" width="52" height="52" rx="13"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#m-wafer)"/>
|
||||
<g clip-path="url(#m-clip)" fill="none" stroke-linecap="round">
|
||||
<g stroke="#7A5C10" stroke-opacity="0.38" stroke-width="2">
|
||||
<path d="M32 6 V58"/>
|
||||
<path d="M6 32 H58"/>
|
||||
</g>
|
||||
<g stroke="#F6E3A8" stroke-opacity="0.3" stroke-width="1">
|
||||
<path d="M33.2 6 V58"/>
|
||||
<path d="M6 33.2 H58"/>
|
||||
</g>
|
||||
</g>
|
||||
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9"
|
||||
fill="none" stroke="#7A5C10" stroke-opacity="0.3" stroke-width="1.2"/>
|
||||
<g>
|
||||
<path d="M21.2 44.8 L17 49.4" stroke="#8A9AA8" stroke-width="3"
|
||||
stroke-linecap="round" fill="none"/>
|
||||
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z" fill="url(#m-leaf)"/>
|
||||
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A" stroke-opacity="0.5"
|
||||
stroke-width="1.5" stroke-linecap="round"/>
|
||||
<g fill="none" stroke="#61758A" stroke-opacity="0.32"
|
||||
stroke-width="1" stroke-linecap="round">
|
||||
<path d="M26.9 38.8 Q25.2 35.8 24.9 32.1"/>
|
||||
<path d="M32.3 33.1 Q30.9 30.3 30.4 26.9"/>
|
||||
<path d="M37.8 27.0 Q36.6 24.6 36.3 21.7"/>
|
||||
<path d="M26.9 38.8 Q30.5 40.1 33.7 40.3"/>
|
||||
<path d="M32.3 33.1 Q35.8 34.3 38.6 34.5"/>
|
||||
<path d="M37.8 27.0 Q40.8 27.9 43.2 28.1"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
Client-side behaviour.
|
||||
|
||||
Everything here is progressive: the application is server-rendered and works
|
||||
without this file, apart from the streaming reply, which is htmx's SSE
|
||||
extension rather than anything hand-written below.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var THEME_KEY = "lembas-theme";
|
||||
var THEMES = ["moria", "shire"];
|
||||
|
||||
/* --- Theme -------------------------------------------------------------
|
||||
Stored locally so the choice applies instantly and survives being signed
|
||||
out, and mirrored to the server so it follows the user to another device.
|
||||
The server call is best-effort: a failure must not undo the local switch. */
|
||||
function currentTheme() {
|
||||
return document.documentElement.dataset.theme || THEMES[0];
|
||||
}
|
||||
|
||||
function applyTheme(name) {
|
||||
if (THEMES.indexOf(name) === -1) return;
|
||||
document.documentElement.dataset.theme = name;
|
||||
try {
|
||||
localStorage.setItem(THEME_KEY, name);
|
||||
} catch (e) { /* private mode */ }
|
||||
|
||||
document.querySelectorAll("[data-theme-toggle]").forEach(function (el) {
|
||||
el.setAttribute("aria-label", name === "moria" ? "Switch to Shire (light)"
|
||||
: "Switch to Moria (dark)");
|
||||
});
|
||||
|
||||
if (document.body.dataset.authenticated === "true") {
|
||||
fetch("/api/preferences/theme", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ theme: name })
|
||||
}).catch(function () { /* preference is already applied locally */ });
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
applyTheme(currentTheme() === "moria" ? "shire" : "moria");
|
||||
}
|
||||
|
||||
/* --- Textarea autosize -------------------------------------------------
|
||||
Grows the composer with its content up to a cap, after which it scrolls. */
|
||||
function autosize(el) {
|
||||
if (!el) return;
|
||||
var max = parseInt(el.dataset.maxHeight || "320", 10);
|
||||
el.style.height = "auto";
|
||||
el.style.height = Math.min(el.scrollHeight, max) + "px";
|
||||
el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
|
||||
}
|
||||
|
||||
/* --- Copy --------------------------------------------------------------
|
||||
Falls back to a hidden textarea because navigator.clipboard is unavailable
|
||||
on pages served over plain http, which self-hosted installs often are. */
|
||||
function copyText(text, trigger) {
|
||||
function done() {
|
||||
if (!trigger) return;
|
||||
var original = trigger.getAttribute("aria-label");
|
||||
trigger.classList.add("is-copied");
|
||||
trigger.setAttribute("aria-label", "Copied");
|
||||
setTimeout(function () {
|
||||
trigger.classList.remove("is-copied");
|
||||
if (original) trigger.setAttribute("aria-label", original);
|
||||
}, 1400);
|
||||
}
|
||||
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(done).catch(function () {});
|
||||
return;
|
||||
}
|
||||
var scratch = document.createElement("textarea");
|
||||
scratch.value = text;
|
||||
scratch.setAttribute("readonly", "");
|
||||
scratch.style.position = "fixed";
|
||||
scratch.style.opacity = "0";
|
||||
document.body.appendChild(scratch);
|
||||
scratch.select();
|
||||
try { document.execCommand("copy"); done(); } catch (e) { /* nothing to do */ }
|
||||
document.body.removeChild(scratch);
|
||||
}
|
||||
|
||||
/* --- Thread scrolling --------------------------------------------------
|
||||
Only auto-scrolls when the reader is already near the bottom, so scrolling
|
||||
up to re-read something is not yanked away by an incoming token. */
|
||||
var STICK_THRESHOLD = 120;
|
||||
|
||||
function isNearBottom(el) {
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < STICK_THRESHOLD;
|
||||
}
|
||||
|
||||
function scrollThread(force) {
|
||||
var thread = document.getElementById("thread-scroll");
|
||||
if (!thread) return;
|
||||
if (force || isNearBottom(thread)) {
|
||||
thread.scrollTop = thread.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
window.lembas = {
|
||||
applyTheme: applyTheme,
|
||||
toggleTheme: toggleTheme,
|
||||
copyText: copyText,
|
||||
scrollThread: scrollThread,
|
||||
autosize: autosize
|
||||
};
|
||||
|
||||
/* --- Wiring ------------------------------------------------------------ */
|
||||
document.addEventListener("click", function (event) {
|
||||
var toggle = event.target.closest("[data-theme-toggle]");
|
||||
if (toggle) {
|
||||
event.preventDefault();
|
||||
toggleTheme();
|
||||
return;
|
||||
}
|
||||
|
||||
var copy = event.target.closest("[data-copy]");
|
||||
if (copy) {
|
||||
event.preventDefault();
|
||||
var source = document.getElementById(copy.dataset.copy);
|
||||
if (source) copyText(source.textContent.trim(), copy);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("input", function (event) {
|
||||
if (event.target.matches("[data-autosize]")) autosize(event.target);
|
||||
});
|
||||
|
||||
/* Enter sends, Shift+Enter inserts a newline -- the convention every chat
|
||||
application uses. Left alone on touch devices, where there is no easy
|
||||
Shift and Enter should mean "new line". */
|
||||
document.addEventListener("keydown", function (event) {
|
||||
if (event.key !== "Enter" || event.shiftKey) return;
|
||||
var composer = event.target.closest("[data-composer-input]");
|
||||
if (!composer) return;
|
||||
if (window.matchMedia("(pointer: coarse)").matches) return;
|
||||
event.preventDefault();
|
||||
var form = composer.closest("form");
|
||||
if (form && composer.value.trim()) form.requestSubmit();
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
document.querySelectorAll("[data-autosize]").forEach(autosize);
|
||||
scrollThread(true);
|
||||
applyTheme(currentTheme());
|
||||
});
|
||||
|
||||
/* After any htmx swap: re-measure the composer and follow new content. */
|
||||
document.body.addEventListener("htmx:afterSwap", function () {
|
||||
document.querySelectorAll("[data-autosize]").forEach(autosize);
|
||||
scrollThread(false);
|
||||
});
|
||||
|
||||
/* Tokens arriving over SSE are appended outside the normal swap cycle. */
|
||||
document.body.addEventListener("htmx:sseMessage", function () {
|
||||
scrollThread(false);
|
||||
});
|
||||
})();
|
||||
+5
File diff suppressed because one or more lines are too long
+290
@@ -0,0 +1,290 @@
|
||||
/*
|
||||
Server Sent Events Extension
|
||||
============================
|
||||
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
|
||||
|
||||
*/
|
||||
|
||||
(function() {
|
||||
/** @type {import("../htmx").HtmxInternalApi} */
|
||||
var api
|
||||
|
||||
htmx.defineExtension('sse', {
|
||||
|
||||
/**
|
||||
* Init saves the provided reference to the internal HTMX API.
|
||||
*
|
||||
* @param {import("../htmx").HtmxInternalApi} api
|
||||
* @returns void
|
||||
*/
|
||||
init: function(apiRef) {
|
||||
// store a reference to the internal API.
|
||||
api = apiRef
|
||||
|
||||
// set a function in the public API for creating new EventSource objects
|
||||
if (htmx.createEventSource == undefined) {
|
||||
htmx.createEventSource = createEventSource
|
||||
}
|
||||
},
|
||||
|
||||
getSelectors: function() {
|
||||
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
|
||||
},
|
||||
|
||||
/**
|
||||
* onEvent handles all events passed to this extension.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {Event} evt
|
||||
* @returns void
|
||||
*/
|
||||
onEvent: function(name, evt) {
|
||||
var parent = evt.target || evt.detail.elt
|
||||
switch (name) {
|
||||
case 'htmx:beforeCleanupElement':
|
||||
var internalData = api.getInternalData(parent)
|
||||
// Try to remove remove an EventSource when elements are removed
|
||||
var source = internalData.sseEventSource
|
||||
if (source) {
|
||||
api.triggerEvent(parent, 'htmx:sseClose', {
|
||||
source,
|
||||
type: 'nodeReplaced',
|
||||
})
|
||||
internalData.sseEventSource.close()
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
// Try to create EventSources when elements are processed
|
||||
case 'htmx:afterProcessNode':
|
||||
ensureEventSourceOnElement(parent)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/// ////////////////////////////////////////////
|
||||
// HELPER FUNCTIONS
|
||||
/// ////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* createEventSource is the default method for creating new EventSource objects.
|
||||
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
|
||||
*
|
||||
* @param {string} url
|
||||
* @returns EventSource
|
||||
*/
|
||||
function createEventSource(url) {
|
||||
return new EventSource(url, { withCredentials: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* registerSSE looks for attributes that can contain sse events, right
|
||||
* now hx-trigger and sse-swap and adds listeners based on these attributes too
|
||||
* the closest event source
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
*/
|
||||
function registerSSE(elt) {
|
||||
// Add message handlers for every `sse-swap` attribute
|
||||
if (api.getAttributeValue(elt, 'sse-swap')) {
|
||||
// Find closest existing event source
|
||||
var sourceElement = api.getClosestMatch(elt, hasEventSource)
|
||||
if (sourceElement == null) {
|
||||
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||
return null // no eventsource in parentage, orphaned element
|
||||
}
|
||||
|
||||
// Set internalData and source
|
||||
var internalData = api.getInternalData(sourceElement)
|
||||
var source = internalData.sseEventSource
|
||||
|
||||
var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
|
||||
var sseEventNames = sseSwapAttr.split(',')
|
||||
|
||||
for (var i = 0; i < sseEventNames.length; i++) {
|
||||
const sseEventName = sseEventNames[i].trim()
|
||||
const listener = function(event) {
|
||||
// If the source is missing then close SSE
|
||||
if (maybeCloseSSESource(sourceElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
// If the body no longer contains the element, remove the listener
|
||||
if (!api.bodyContains(elt)) {
|
||||
source.removeEventListener(sseEventName, listener)
|
||||
return
|
||||
}
|
||||
|
||||
// swap the response into the DOM and trigger a notification
|
||||
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
|
||||
return
|
||||
}
|
||||
swap(elt, event.data)
|
||||
api.triggerEvent(elt, 'htmx:sseMessage', event)
|
||||
}
|
||||
|
||||
// Register the new listener
|
||||
api.getInternalData(elt).sseEventListener = listener
|
||||
source.addEventListener(sseEventName, listener)
|
||||
}
|
||||
}
|
||||
|
||||
// Add message handlers for every `hx-trigger="sse:*"` attribute
|
||||
if (api.getAttributeValue(elt, 'hx-trigger')) {
|
||||
// Find closest existing event source
|
||||
var sourceElement = api.getClosestMatch(elt, hasEventSource)
|
||||
if (sourceElement == null) {
|
||||
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
|
||||
return null // no eventsource in parentage, orphaned element
|
||||
}
|
||||
|
||||
// Set internalData and source
|
||||
var internalData = api.getInternalData(sourceElement)
|
||||
var source = internalData.sseEventSource
|
||||
|
||||
var triggerSpecs = api.getTriggerSpecs(elt)
|
||||
triggerSpecs.forEach(function(ts) {
|
||||
if (ts.trigger.slice(0, 4) !== 'sse:') {
|
||||
return
|
||||
}
|
||||
|
||||
var listener = function (event) {
|
||||
if (maybeCloseSSESource(sourceElement)) {
|
||||
return
|
||||
}
|
||||
if (!api.bodyContains(elt)) {
|
||||
source.removeEventListener(ts.trigger.slice(4), listener)
|
||||
}
|
||||
// Trigger events to be handled by the rest of htmx
|
||||
htmx.trigger(elt, ts.trigger, event)
|
||||
htmx.trigger(elt, 'htmx:sseMessage', event)
|
||||
}
|
||||
|
||||
// Register the new listener
|
||||
api.getInternalData(elt).sseEventListener = listener
|
||||
source.addEventListener(ts.trigger.slice(4), listener)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
|
||||
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
|
||||
* is created and stored in the element's internalData.
|
||||
* @param {HTMLElement} elt
|
||||
* @param {number} retryCount
|
||||
* @returns {EventSource | null}
|
||||
*/
|
||||
function ensureEventSourceOnElement(elt, retryCount) {
|
||||
if (elt == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
// handle extension source creation attribute
|
||||
if (api.getAttributeValue(elt, 'sse-connect')) {
|
||||
var sseURL = api.getAttributeValue(elt, 'sse-connect')
|
||||
if (sseURL == null) {
|
||||
return
|
||||
}
|
||||
|
||||
ensureEventSource(elt, sseURL, retryCount)
|
||||
}
|
||||
|
||||
registerSSE(elt)
|
||||
}
|
||||
|
||||
function ensureEventSource(elt, url, retryCount) {
|
||||
var source = htmx.createEventSource(url)
|
||||
|
||||
source.onerror = function(err) {
|
||||
// Log an error event
|
||||
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })
|
||||
|
||||
// If parent no longer exists in the document, then clean up this EventSource
|
||||
if (maybeCloseSSESource(elt)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, try to reconnect the EventSource
|
||||
if (source.readyState === EventSource.CLOSED) {
|
||||
retryCount = retryCount || 0
|
||||
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
|
||||
var timeout = retryCount * 500
|
||||
window.setTimeout(function() {
|
||||
ensureEventSourceOnElement(elt, retryCount)
|
||||
}, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
source.onopen = function(evt) {
|
||||
api.triggerEvent(elt, 'htmx:sseOpen', { source })
|
||||
|
||||
if (retryCount && retryCount > 0) {
|
||||
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
|
||||
for (let i = 0; i < childrenToFix.length; i++) {
|
||||
registerSSE(childrenToFix[i])
|
||||
}
|
||||
// We want to increase the reconnection delay for consecutive failed attempts only
|
||||
retryCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
api.getInternalData(elt).sseEventSource = source
|
||||
|
||||
|
||||
var closeAttribute = api.getAttributeValue(elt, "sse-close");
|
||||
if (closeAttribute) {
|
||||
// close eventsource when this message is received
|
||||
source.addEventListener(closeAttribute, function() {
|
||||
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||
source,
|
||||
type: 'message',
|
||||
})
|
||||
source.close()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* maybeCloseSSESource confirms that the parent element still exists.
|
||||
* If not, then any associated SSE source is closed and the function returns true.
|
||||
*
|
||||
* @param {HTMLElement} elt
|
||||
* @returns boolean
|
||||
*/
|
||||
function maybeCloseSSESource(elt) {
|
||||
if (!api.bodyContains(elt)) {
|
||||
var source = api.getInternalData(elt).sseEventSource
|
||||
if (source != undefined) {
|
||||
api.triggerEvent(elt, 'htmx:sseClose', {
|
||||
source,
|
||||
type: 'nodeMissing',
|
||||
})
|
||||
source.close()
|
||||
// source = null
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} elt
|
||||
* @param {string} content
|
||||
*/
|
||||
function swap(elt, content) {
|
||||
api.withExtensions(elt, function(extension) {
|
||||
content = extension.transformResponse(content, null, elt)
|
||||
})
|
||||
|
||||
var swapSpec = api.getSwapSpecification(elt)
|
||||
var target = api.getTarget(elt)
|
||||
api.swap(target, content, swapSpec, { contextElement: elt })
|
||||
}
|
||||
|
||||
|
||||
function hasEventSource(node) {
|
||||
return api.getInternalData(node).sseEventSource != null
|
||||
}
|
||||
})()
|
||||
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,68 @@
|
||||
{#
|
||||
Shared template macros.
|
||||
|
||||
Import at the top of any template that needs them:
|
||||
{% from "_macros.html" import icon, brand, mark %}
|
||||
#}
|
||||
|
||||
{# An icon from the inlined sprite. `name` omits the "i-" prefix. #}
|
||||
{% macro icon(name, cls="") -%}
|
||||
<svg class="icon {{ cls }}" aria-hidden="true"><use href="#i-{{ name }}"/></svg>
|
||||
{%- endmacro %}
|
||||
|
||||
{#
|
||||
The leaf-and-wafer mark, inlined rather than referenced as <img> so it can
|
||||
scale with the surrounding font size. Gradient ids are suffixed with `uid`
|
||||
because ids are document-global: two marks on one page with the same ids
|
||||
means the second silently reuses the first one's gradients.
|
||||
#}
|
||||
{% macro mark(cls="brand-mark", uid="a") -%}
|
||||
<svg class="{{ cls }}" viewBox="0 0 64 64" role="img" aria-label="LLeMbas">
|
||||
<defs>
|
||||
<linearGradient id="mk-{{ uid }}-w" x1="0" y1="0" x2="0.3" y2="1">
|
||||
<stop offset="0" stop-color="#EACB74"/>
|
||||
<stop offset="0.5" stop-color="#C9A227"/>
|
||||
<stop offset="1" stop-color="#916F13"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="mk-{{ uid }}-l" x1="0.1" y1="1" x2="0.9" y2="0">
|
||||
<stop offset="0" stop-color="#93A5B6"/>
|
||||
<stop offset="0.4" stop-color="#F1F6FA"/>
|
||||
<stop offset="1" stop-color="#B8C7D5"/>
|
||||
</linearGradient>
|
||||
<clipPath id="mk-{{ uid }}-c"><rect x="6" y="6" width="52" height="52" rx="13"/></clipPath>
|
||||
</defs>
|
||||
<rect x="6" y="6" width="52" height="52" rx="13" fill="url(#mk-{{ uid }}-w)"/>
|
||||
<g clip-path="url(#mk-{{ uid }}-c)" fill="none" stroke-linecap="round">
|
||||
<g stroke="#7A5C10" stroke-opacity="0.38" stroke-width="2">
|
||||
<path d="M32 6 V58"/><path d="M6 32 H58"/>
|
||||
</g>
|
||||
<g stroke="#F6E3A8" stroke-opacity="0.3" stroke-width="1">
|
||||
<path d="M33.2 6 V58"/><path d="M6 33.2 H58"/>
|
||||
</g>
|
||||
</g>
|
||||
<rect x="7.1" y="7.1" width="49.8" height="49.8" rx="11.9" fill="none"
|
||||
stroke="#7A5C10" stroke-opacity="0.3" stroke-width="1.2"/>
|
||||
<path d="M21.2 44.8 L17 49.4" stroke="#8A9AA8" stroke-width="3"
|
||||
stroke-linecap="round" fill="none"/>
|
||||
<path d="M20.5 45.5 C13.8 31.7 23.8 20.9 45.5 18.5 C49.8 35 39.8 45.8 20.5 45.5 Z"
|
||||
fill="url(#mk-{{ uid }}-l)"/>
|
||||
<path d="M20.5 45.5 C28 38 36 29 45.5 18.5" fill="none" stroke="#61758A"
|
||||
stroke-opacity="0.5" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
{%- endmacro %}
|
||||
|
||||
{#
|
||||
The wordmark as live text rather than the outlined SVG: it stays selectable,
|
||||
searchable and readable to a screen reader, and scales with the user's font
|
||||
size. The capitals spelling LLM take the gold accent.
|
||||
#}
|
||||
{% macro wordmark() -%}
|
||||
<span class="brand-llm">LL</span>e<span class="brand-llm">M</span>bas
|
||||
{%- endmacro %}
|
||||
|
||||
{% macro brand(href="/", uid="a") -%}
|
||||
<a class="sidebar__brand" href="{{ href }}">
|
||||
{{ mark(uid=uid) }}
|
||||
<span>{{ wordmark() }}</span>
|
||||
</a>
|
||||
{%- endmacro %}
|
||||
@@ -0,0 +1,98 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
One connection: an editable form plus its current status.
|
||||
|
||||
Swapped in place by the "Test & refresh" button, so this fragment has to be
|
||||
able to render on its own as well as inside the list.
|
||||
#}
|
||||
<section class="card connection" id="connection-{{ connection.id }}">
|
||||
<form method="post" action="/admin/connections/{{ connection.id }}" class="form-grid">
|
||||
<div class="connection__head">
|
||||
<div class="row" style="gap: var(--sp-2); min-width: 0">
|
||||
<span class="status-dot {{ 'is-ok' if connection.enabled and not connection.last_error
|
||||
else 'is-bad' if connection.last_error else 'is-off' }}"
|
||||
aria-hidden="true"></span>
|
||||
<strong class="truncate">{{ connection.name }}</strong>
|
||||
{% if model_count is defined %}
|
||||
<span class="badge">{{ model_count }} model{{ '' if model_count == 1 else 's' }}</span>
|
||||
{% endif %}
|
||||
{% if not connection.enabled %}
|
||||
<span class="badge">disabled</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="row" style="gap: var(--sp-2)">
|
||||
<button class="btn btn--sm" type="submit"
|
||||
hx-post="/admin/connections/{{ connection.id }}/test"
|
||||
hx-target="#connection-{{ connection.id }}" hx-swap="outerHTML"
|
||||
formnovalidate>
|
||||
{{ icon("refresh", "icon--sm") }} Test & refresh
|
||||
</button>
|
||||
<button class="btn btn--sm btn--primary" type="submit">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if message %}
|
||||
<div class="alert alert--{{ message_kind|default('success') }}">
|
||||
{% if message_kind == "error" %}{{ icon("warning", "alert__icon") }}{% endif %}
|
||||
<span>{{ message }}</span>
|
||||
</div>
|
||||
{% elif connection.last_error %}
|
||||
<div class="alert alert--error">
|
||||
{{ icon("warning", "alert__icon") }}
|
||||
<span>{{ connection.last_error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="name-{{ connection.id }}">Name</label>
|
||||
<input class="input" id="name-{{ connection.id }}" name="name"
|
||||
value="{{ connection.name }}" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="url-{{ connection.id }}">Base URL</label>
|
||||
<input class="input input--mono" id="url-{{ connection.id }}" name="base_url"
|
||||
value="{{ connection.base_url }}" required>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="key-{{ connection.id }}">API key</label>
|
||||
<input class="input input--mono" id="key-{{ connection.id }}" name="api_key"
|
||||
type="password" autocomplete="off"
|
||||
value="{{ unchanged if connection.api_key_encrypted else '' }}"
|
||||
placeholder="No key set">
|
||||
<p class="field__hint">
|
||||
{% if connection.api_key_encrypted %}
|
||||
Currently <code>{{ masked }}</code>. Leave the dots alone to keep it,
|
||||
or clear the field to remove the key entirely.
|
||||
{% else %}
|
||||
No key is stored for this endpoint.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" name="enabled" value="true"
|
||||
{{ 'checked' if connection.enabled }}>
|
||||
<span>Enabled — its models are offered in chats</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="connection__footer">
|
||||
<span class="text-xs faint">
|
||||
{% if connection.last_checked_at %}
|
||||
Last checked {{ connection.last_checked_at.strftime("%Y-%m-%d %H:%M") }} UTC
|
||||
{% else %}
|
||||
Never checked
|
||||
{% endif %}
|
||||
</span>
|
||||
<button class="btn btn--sm btn--danger" type="submit"
|
||||
formaction="/admin/connections/{{ connection.id }}/delete" formnovalidate
|
||||
onclick="return confirm('Delete the connection “{{ connection.name }}”? Existing chats keep their history.')">
|
||||
{{ icon("trash", "icon--sm") }} Delete
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import icon, brand %}
|
||||
{#
|
||||
Shared chrome for the admin area: its own narrow nav rather than the chat
|
||||
sidebar, so administration is visibly a different place from chatting.
|
||||
#}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar__header">
|
||||
{{ brand(uid="admin") }}
|
||||
</div>
|
||||
|
||||
<nav class="sidebar__scroll" aria-label="Administration">
|
||||
<div class="nav-group">
|
||||
<div class="nav-group__label">Administration</div>
|
||||
<a class="nav-item {{ 'is-active' if section == 'connections' }}"
|
||||
href="/admin/connections">
|
||||
{{ icon("server", "icon--sm") }}
|
||||
<span class="nav-item__label">Connections</span>
|
||||
</a>
|
||||
<a class="nav-item {{ 'is-active' if section == 'models' }}" href="/admin/models">
|
||||
{{ icon("sliders", "icon--sm") }}
|
||||
<span class="nav-item__label">Models</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="nav-group">
|
||||
<div class="nav-group__label">Not yet built</div>
|
||||
<span class="nav-item is-disabled">
|
||||
{{ icon("users", "icon--sm") }}
|
||||
<span class="nav-item__label">Users & groups</span>
|
||||
</span>
|
||||
<span class="nav-item is-disabled">
|
||||
{{ icon("gear", "icon--sm") }}
|
||||
<span class="nav-item__label">Tools</span>
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar__footer">
|
||||
<a class="nav-item" href="/chat">
|
||||
{{ icon("chat", "icon--sm") }}
|
||||
<span class="nav-item__label">Back to chats</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<h1 class="topbar__title">{% block heading %}Administration{% endblock %}</h1>
|
||||
<button class="btn btn--icon" type="button" data-theme-toggle aria-label="Switch theme">
|
||||
<span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span>
|
||||
<span class="theme-icon theme-icon--light">{{ icon("sun") }}</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="admin-scroll">
|
||||
<div class="admin-page">
|
||||
{% block admin_content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,76 @@
|
||||
{% extends "admin/_layout.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% set section = "connections" %}
|
||||
|
||||
{% block title %}Connections - LLeMbas{% endblock %}
|
||||
{% block heading %}Connections{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
Any endpoint that speaks the OpenAI HTTP API: OpenAI itself, or a local
|
||||
runner such as LM Studio, vLLM, llama.cpp or Ollama. LLeMbas asks each one
|
||||
for its model list and offers those models in the chat picker.
|
||||
</p>
|
||||
|
||||
{% if message %}
|
||||
<div class="alert alert--success">{{ message }}</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Add a connection</h2>
|
||||
<form method="post" action="/admin/connections" class="form-grid">
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-name">Name</label>
|
||||
<input class="input" id="new-name" name="name" required placeholder="Local LM Studio">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-url">Base URL</label>
|
||||
<input class="input input--mono" id="new-url" name="base_url" required
|
||||
placeholder="http://localhost:1234/v1">
|
||||
<p class="field__hint">
|
||||
With or without the trailing <code>/v1</code> — both are accepted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="new-key">API key</label>
|
||||
<input class="input input--mono" id="new-key" name="api_key" type="password"
|
||||
autocomplete="off" placeholder="sk-…">
|
||||
<p class="field__hint">
|
||||
Encrypted before it is stored, and never sent back to the browser.
|
||||
Leave empty for endpoints that need no key.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field field--actions">
|
||||
<button class="btn btn--primary" type="submit">
|
||||
{{ icon("plus", "icon--sm") }} Add and load models
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<h2 class="admin-section-title">
|
||||
Configured connections
|
||||
<span class="badge">{{ connections|length }}</span>
|
||||
</h2>
|
||||
|
||||
{% if not connections %}
|
||||
<div class="empty" style="padding: var(--sp-10) 0">
|
||||
{{ icon("server", "empty__mark") }}
|
||||
<p class="empty__text">
|
||||
Nothing configured yet. Add a connection above and its models appear here.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="connection-list">
|
||||
{% for connection in connections %}
|
||||
{% with masked = masked[connection.id],
|
||||
model_count = model_counts[connection.id] %}
|
||||
{% include "admin/_connection_row.html" %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,51 @@
|
||||
{% extends "admin/_layout.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
{% set section = "models" %}
|
||||
|
||||
{% block title %}Models - LLeMbas{% endblock %}
|
||||
{% block heading %}Models{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<p class="admin-lede">
|
||||
Every model discovered on each connection. Disable the ones you do not want
|
||||
cluttering the chat picker — nothing is deleted, and re-running
|
||||
“Test & refresh” will not bring a disabled model back on.
|
||||
</p>
|
||||
|
||||
{% for connection in connections %}
|
||||
<section class="card">
|
||||
<h2 class="card__title">
|
||||
{{ connection.name }}
|
||||
<span class="badge">{{ connection.models|length }}</span>
|
||||
{% if not connection.enabled %}<span class="badge">connection disabled</span>{% endif %}
|
||||
</h2>
|
||||
|
||||
{% if not connection.models %}
|
||||
<p class="muted text-sm">
|
||||
No models loaded. Run “Test & refresh” on
|
||||
<a href="/admin/connections">the connections page</a>.
|
||||
</p>
|
||||
{% else %}
|
||||
<ul class="model-list">
|
||||
{% for model in connection.models %}
|
||||
<li class="model-list__item">
|
||||
<code class="model-list__id">{{ model.model_id }}</code>
|
||||
<form method="post" action="/admin/models/{{ model.id }}/toggle">
|
||||
<button class="btn btn--sm {{ 'btn--primary' if model.enabled }}" type="submit">
|
||||
{% if model.enabled %}{{ icon("check", "icon--sm") }} Enabled{% else %}Disabled{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% else %}
|
||||
<div class="empty" style="padding: var(--sp-10) 0">
|
||||
{{ icon("server", "empty__mark") }}
|
||||
<p class="empty__text">
|
||||
No connections yet. <a href="/admin/connections">Add one</a> to load models.
|
||||
</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import icon, mark, wordmark %}
|
||||
|
||||
{% block title %}Sign in - LLeMbas{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<main class="auth">
|
||||
<div class="auth__card">
|
||||
<div class="auth__brand">
|
||||
{{ mark(cls="brand-mark", uid="auth") }}
|
||||
<h1 class="auth__title">{{ wordmark() }}</h1>
|
||||
<p class="auth__subtitle">Waybread for the long road of thought.</p>
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert--error" role="alert">
|
||||
{{ icon("warning", "alert__icon") }}
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/auth/login" class="stack">
|
||||
<input type="hidden" name="next" value="{{ next|default('/') }}">
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="email">Email</label>
|
||||
<input class="input" type="email" id="email" name="email" required
|
||||
autocomplete="username" autofocus value="{{ email|default('') }}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="password">Password</label>
|
||||
<input class="input" type="password" id="password" name="password" required
|
||||
autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<button class="btn btn--primary btn--block" type="submit">Sign in</button>
|
||||
</form>
|
||||
|
||||
{% if allow_signup %}
|
||||
<p class="auth__footer">
|
||||
No account yet? <a href="/auth/register">Create one</a>.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,60 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import icon, mark, wordmark %}
|
||||
|
||||
{% block title %}{% if first_run %}Set up LLeMbas{% else %}Create an account{% endif %}{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<main class="auth">
|
||||
<div class="auth__card">
|
||||
<div class="auth__brand">
|
||||
{{ mark(cls="brand-mark", uid="auth") }}
|
||||
<h1 class="auth__title">{{ wordmark() }}</h1>
|
||||
{% if first_run %}
|
||||
<p class="auth__subtitle">
|
||||
Nobody has claimed this instance yet. The first account becomes its administrator.
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="auth__subtitle">Create your account.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="alert alert--error" role="alert">
|
||||
{{ icon("warning", "alert__icon") }}
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/auth/register" class="stack">
|
||||
<div class="field">
|
||||
<label class="field__label" for="name">Name</label>
|
||||
<input class="input" type="text" id="name" name="name" required autofocus
|
||||
autocomplete="name" value="{{ name|default('') }}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="email">Email</label>
|
||||
<input class="input" type="email" id="email" name="email" required
|
||||
autocomplete="username" value="{{ email|default('') }}">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field__label" for="password">Password</label>
|
||||
<input class="input" type="password" id="password" name="password" required
|
||||
autocomplete="new-password" minlength="8">
|
||||
<p class="field__hint">At least 8 characters.</p>
|
||||
</div>
|
||||
|
||||
<button class="btn btn--primary btn--block" type="submit">
|
||||
{% if first_run %}Create administrator account{% else %}Create account{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{% if not first_run %}
|
||||
<p class="auth__footer">
|
||||
Already have an account? <a href="/auth/login">Sign in</a>.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,43 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="{{ theme }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}LLeMbas{% endblock %}</title>
|
||||
<meta name="description" content="LLeMbas - a web UI for your language models.">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
|
||||
<link rel="icon" href="{{ url_for('static', path='img/favicon.svg') }}" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/tokens.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/app.css') }}">
|
||||
{% block head %}{% endblock %}
|
||||
|
||||
{#
|
||||
Applied before first paint so a reload never flashes the wrong theme. The
|
||||
server already rendered data-theme from the signed-in user's preference; this
|
||||
only corrects it for a visitor whose local choice differs (or who is signed
|
||||
out, where the server has nothing to go on).
|
||||
#}
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var stored = localStorage.getItem("lembas-theme");
|
||||
if (stored && stored !== document.documentElement.dataset.theme) {
|
||||
document.documentElement.dataset.theme = stored;
|
||||
}
|
||||
} catch (e) { /* private mode: the server-rendered theme stands */ }
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body{% block body_attrs %}{% endblock %}>
|
||||
{% include "partials/icons.html" %}
|
||||
|
||||
{% block body %}{% endblock %}
|
||||
|
||||
<script src="{{ url_for('static', path='vendor/htmx.min.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', path='vendor/htmx-ext-sse.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', path='vendor/alpine.min.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', path='js/app.js') }}" defer></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,93 @@
|
||||
{% from "_macros.html" import icon, mark %}
|
||||
{#
|
||||
One message bubble, in either of two states.
|
||||
|
||||
An incomplete assistant message renders the streaming shell: it carries the
|
||||
sse-connect that opens the reply stream. This is deliberately the ONLY thing
|
||||
that starts a generation, which means a page load showing an unfinished reply
|
||||
picks it up again -- reloading after a dropped connection retries rather than
|
||||
leaving a permanently half-written answer.
|
||||
|
||||
A complete message renders its finished body: Markdown for the assistant,
|
||||
escaped plain text for everyone else.
|
||||
#}
|
||||
{% set streaming = (message.role == "assistant" and not message.complete) %}
|
||||
|
||||
<article class="msg msg--{{ message.role }}" id="msg-{{ message.id }}"
|
||||
{% if streaming %}
|
||||
hx-ext="sse"
|
||||
sse-connect="/api/chats/{{ chat.id }}/messages/{{ message.id }}/stream"
|
||||
sse-close="close"
|
||||
{% endif %}>
|
||||
|
||||
<div class="msg__gutter" aria-hidden="true">
|
||||
{% if message.role == "assistant" %}
|
||||
{{ mark(cls="msg__mark", uid="m" ~ message.id) }}
|
||||
{% else %}
|
||||
<span class="msg__initial">{{ (user.name or "?")[0]|upper }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="msg__main">
|
||||
<header class="msg__meta">
|
||||
<span class="msg__author">
|
||||
{{ "LLeMbas" if message.role == "assistant" else (user.name or "You") }}
|
||||
</span>
|
||||
{% if message.model_id %}
|
||||
<span class="msg__model" title="{{ message.model_id }}">{{ message.model_id }}</span>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if streaming %}
|
||||
{# Tokens are appended here as they arrive. The cursor is a CSS
|
||||
pseudo-element on the empty parent, so it disappears by itself once
|
||||
the first token lands. #}
|
||||
<div class="msg__body msg__body--streaming" id="stream-{{ message.id }}"
|
||||
sse-swap="token" hx-swap="beforeend"></div>
|
||||
<div class="msg__waiting">
|
||||
<span class="dots"><i></i><i></i><i></i></span>
|
||||
</div>
|
||||
{% elif message.error %}
|
||||
<div class="alert alert--error msg__error" role="alert">
|
||||
{{ icon("warning", "alert__icon") }}
|
||||
<div>
|
||||
<strong>The reply could not be completed.</strong>
|
||||
<div class="text-sm" style="margin-top: var(--sp-1)">{{ message.error }}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if message.content %}
|
||||
<div class="msg__body">{{ body_html|safe }}</div>
|
||||
{% endif %}
|
||||
{% elif message.role == "assistant" %}
|
||||
<div class="msg__body">{{ body_html|safe }}</div>
|
||||
{% else %}
|
||||
<div class="msg__body msg__body--plain">{{ message.content }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not streaming %}
|
||||
<footer class="msg__actions">
|
||||
<button class="btn btn--icon btn--sm" type="button"
|
||||
data-copy="msg-body-{{ message.id }}" aria-label="Copy message">
|
||||
{{ icon("copy", "icon--sm") }}
|
||||
</button>
|
||||
{% if message.role == "assistant" %}
|
||||
<button class="btn btn--icon btn--sm" type="button"
|
||||
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/regenerate"
|
||||
hx-target="#msg-{{ message.id }}" hx-swap="outerHTML"
|
||||
aria-label="Regenerate reply">
|
||||
{{ icon("refresh", "icon--sm") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</footer>
|
||||
{# The raw source, so the copy button yields Markdown rather than rendered
|
||||
text. A hidden div and not a <script>: script content is raw text, so
|
||||
the escaping Jinja applies would be copied out literally as entities. #}
|
||||
<div hidden id="msg-body-{{ message.id }}">{{ message.content }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if streaming %}
|
||||
{# Receives the finished bubble and replaces this whole article with it. #}
|
||||
<div hidden sse-swap="done" hx-target="#msg-{{ message.id }}" hx-swap="outerHTML"></div>
|
||||
{% endif %}
|
||||
</article>
|
||||
@@ -0,0 +1,10 @@
|
||||
{#
|
||||
Out-of-band updates sent alongside the finished reply.
|
||||
|
||||
A chat is named from its first exchange, which happens on the server while
|
||||
the reply streams. These two fragments push the new title into the page
|
||||
without the client having to poll or reload.
|
||||
#}
|
||||
<span id="chat-title" hx-swap-oob="true">{{ chat.title }}</span>
|
||||
<span id="chat-link-label-{{ chat.id }}" hx-swap-oob="true"
|
||||
class="nav-item__label">{{ chat.title }}</span>
|
||||
@@ -0,0 +1,11 @@
|
||||
{#
|
||||
One exchange: the user's message plus the empty assistant bubble that will
|
||||
stream into it. Returned by POST /api/chats/{id}/messages and appended to the
|
||||
thread in a single swap, so the pair always arrives together.
|
||||
#}
|
||||
{% with message = user_message %}
|
||||
{% include "chat/_message.html" %}
|
||||
{% endwith %}
|
||||
{% with message = assistant_message %}
|
||||
{% include "chat/_message.html" %}
|
||||
{% endwith %}
|
||||
@@ -0,0 +1,120 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import icon, mark %}
|
||||
|
||||
{% block title %}{{ chat.title if chat else "Chats" }} - LLeMbas{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="shell">
|
||||
{% include "partials/sidebar.html" %}
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<button class="btn btn--icon" type="button" aria-label="Toggle sidebar"
|
||||
onclick="document.getElementById('sidebar').toggleAttribute('hidden')">
|
||||
{{ icon("sidebar") }}
|
||||
</button>
|
||||
|
||||
{% if chat %}
|
||||
<h1 class="topbar__title"><span id="chat-title">{{ chat.title }}</span></h1>
|
||||
|
||||
{% if models %}
|
||||
<form hx-patch="/api/chats/{{ chat.id }}" hx-swap="none" hx-trigger="change from:find select">
|
||||
<select class="select select--compact" name="model_id" aria-label="Model">
|
||||
{% for model in models %}
|
||||
<option value="{{ model.model_id }}" {{ 'selected' if model.model_id == chat.model_id }}>
|
||||
{{ model.label }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<h1 class="topbar__title">Chats</h1>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if not chat %}
|
||||
{# No chat selected. #}
|
||||
<div class="empty">
|
||||
{{ mark(cls="empty__mark", uid="empty") }}
|
||||
<h2 class="empty__title">The road goes ever on</h2>
|
||||
<p class="empty__text">
|
||||
Pick a chat from the side, or start a new one.
|
||||
</p>
|
||||
<button class="btn btn--primary" hx-post="/api/chats" hx-swap="none">
|
||||
{{ icon("plus", "icon--sm") }} New chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% elif not models %}
|
||||
{# Nothing to talk to yet. This is the state every fresh install lands in,
|
||||
so it points straight at the fix rather than just reporting a problem. #}
|
||||
<div class="empty">
|
||||
{{ icon("server", "empty__mark") }}
|
||||
<h2 class="empty__title">No models available</h2>
|
||||
<p class="empty__text">
|
||||
{% if user.is_admin %}
|
||||
Add an OpenAI-compatible connection and LLeMbas will load its models.
|
||||
{% else %}
|
||||
No model connections have been set up yet. Ask an administrator.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if user.is_admin %}
|
||||
<a class="btn btn--primary" href="/admin/connections">
|
||||
{{ icon("server", "icon--sm") }} Set up a connection
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<div class="thread-scroll" id="thread-scroll">
|
||||
<div class="thread" id="thread">
|
||||
{% if not messages %}
|
||||
<div class="thread__intro">
|
||||
{{ mark(cls="empty__mark", uid="intro") }}
|
||||
<h2 class="empty__title">What would you ask?</h2>
|
||||
<p class="empty__text">Speak, friend, and enter.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% for message in messages %}
|
||||
{# Markdown was rendered server-side in pages.py, keyed by message
|
||||
id, so this loop stays a lookup rather than a render. #}
|
||||
{% with body_html = bodies.get(message.id, "") %}
|
||||
{% include "chat/_message.html" %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="composer">
|
||||
<form class="composer__form"
|
||||
hx-post="/api/chats/{{ chat.id }}/messages"
|
||||
hx-target="#thread" hx-swap="beforeend"
|
||||
hx-on::after-request="if (event.detail.successful) {
|
||||
this.reset();
|
||||
const t = this.querySelector('textarea');
|
||||
window.lembas.autosize(t);
|
||||
window.lembas.scrollThread(true);
|
||||
}">
|
||||
<textarea class="composer__input" name="content" rows="1"
|
||||
data-autosize data-max-height="320" data-composer-input
|
||||
placeholder="Send a message…" aria-label="Message"></textarea>
|
||||
<button class="btn btn--primary composer__send" type="submit" aria-label="Send">
|
||||
{{ icon("send", "icon--sm") }}
|
||||
</button>
|
||||
</form>
|
||||
<p class="composer__hint">
|
||||
Enter to send, Shift+Enter for a new line.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import mark %}
|
||||
|
||||
{% block title %}{{ status_code }} - LLeMbas{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<main class="auth">
|
||||
<div class="auth__card" style="text-align: center">
|
||||
{{ mark(cls="empty__mark", uid="err") }}
|
||||
<h1 class="auth__title" style="margin-top: var(--sp-4)">{{ status_code }}</h1>
|
||||
<p class="empty__text" style="margin: var(--sp-3) auto var(--sp-5)">{{ flavour }}</p>
|
||||
{% if detail and detail != flavour %}
|
||||
<p class="text-sm muted" style="margin-bottom: var(--sp-5)">{{ detail }}</p>
|
||||
{% endif %}
|
||||
<a class="btn btn--primary" href="/">Back to your chats</a>
|
||||
</div>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
A single chat row. `chat_item` rather than `chat` so this can be included
|
||||
from a page that already has the open chat bound to `chat`.
|
||||
#}
|
||||
<div class="nav-item {% if chat and chat.id == chat_item.id %}is-active{% endif %}"
|
||||
data-chat-id="{{ chat_item.id }}">
|
||||
<a class="nav-item__link" href="/chat/{{ chat_item.id }}">
|
||||
{{ icon("chat", "icon--sm") }}
|
||||
<span class="nav-item__label" id="chat-link-label-{{ chat_item.id }}">{{ chat_item.title }}</span>
|
||||
</a>
|
||||
<span class="nav-item__actions">
|
||||
<button class="btn btn--icon btn--sm" type="button"
|
||||
hx-delete="/api/chats/{{ chat_item.id }}"
|
||||
hx-confirm="Delete “{{ chat_item.title }}”? This cannot be undone."
|
||||
hx-swap="none"
|
||||
aria-label="Delete chat">
|
||||
{{ icon("trash", "icon--sm") }}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
{% from "_macros.html" import icon %}
|
||||
{#
|
||||
A folder and everything under it. Recursive: a folder includes this same
|
||||
template for each of its children, so nesting has no fixed depth limit.
|
||||
|
||||
Open/closed state is local to the browser (Alpine), not persisted per
|
||||
request -- collapsing a folder should not cost a round trip.
|
||||
#}
|
||||
<div class="folder" x-data="{ open: {{ 'false' if folder.collapsed else 'true' }} }">
|
||||
<div class="nav-item folder__row">
|
||||
<button class="folder__toggle" type="button" @click="open = !open"
|
||||
:aria-expanded="open ? 'true' : 'false'">
|
||||
<span class="folder__chevron" :class="open && 'is-open'">
|
||||
{{ icon("chevron-right", "icon--sm") }}
|
||||
</span>
|
||||
<span x-show="open">{{ icon("folder-open", "icon--sm") }}</span>
|
||||
<span x-show="!open" x-cloak>{{ icon("folder", "icon--sm") }}</span>
|
||||
<span class="nav-item__label">{{ folder.name }}</span>
|
||||
</button>
|
||||
<span class="nav-item__actions">
|
||||
<button class="btn btn--icon btn--sm" type="button"
|
||||
hx-delete="/api/folders/{{ folder.id }}"
|
||||
hx-confirm="Delete the folder “{{ folder.name }}”? Chats inside it are kept."
|
||||
hx-swap="none"
|
||||
aria-label="Delete folder">
|
||||
{{ icon("trash", "icon--sm") }}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="folder__contents" x-show="open" x-cloak>
|
||||
{% for child in folder.children %}
|
||||
{% with folder = child %}
|
||||
{% include "partials/_folder.html" %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
|
||||
{% for chat_item in folder.chats %}
|
||||
{% include "partials/_chat_link.html" %}
|
||||
{% endfor %}
|
||||
|
||||
{% if not folder.children and not folder.chats %}
|
||||
<p class="nav-empty">Empty</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,72 @@
|
||||
{% from "_macros.html" import icon, brand %}
|
||||
{#
|
||||
Sidebar: brand, new chat, the folder tree, then unfiled chats.
|
||||
|
||||
Folders render recursively through _folder.html. Chats appear under their
|
||||
folder, and any chat without one falls to the flat list at the bottom.
|
||||
#}
|
||||
<aside class="sidebar" id="sidebar" x-data="{ }">
|
||||
<div class="sidebar__header">
|
||||
{{ brand(uid="side") }}
|
||||
</div>
|
||||
|
||||
<div class="sidebar__actions">
|
||||
<button class="btn btn--primary btn--block" hx-post="/api/chats" hx-swap="none">
|
||||
{{ icon("plus", "icon--sm") }} New chat
|
||||
</button>
|
||||
<button class="btn btn--icon" hx-post="/api/folders" hx-swap="none"
|
||||
hx-vals='{"name": "New folder"}' aria-label="New folder" title="New folder">
|
||||
{{ icon("folder") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar__scroll" id="sidebar-tree" aria-label="Chats">
|
||||
{% if folders %}
|
||||
<div class="nav-group">
|
||||
<div class="nav-group__label">Folders</div>
|
||||
{% for folder in folders %}
|
||||
{% include "partials/_folder.html" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="nav-group">
|
||||
<div class="nav-group__label">Chats</div>
|
||||
{% if unfiled_chats %}
|
||||
{% for chat_item in unfiled_chats %}
|
||||
{% include "partials/_chat_link.html" %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="nav-empty">No chats yet. The road begins here.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar__footer">
|
||||
<div class="row row--between">
|
||||
<a class="nav-item" href="/settings" style="flex: 1">
|
||||
{{ icon("user", "icon--sm") }}
|
||||
<span class="nav-item__label">{{ user.name }}</span>
|
||||
{% if user.is_admin %}<span class="badge badge--gold">admin</span>{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<div class="row" style="gap: var(--sp-1); margin-top: var(--sp-1)">
|
||||
{% if user.is_admin %}
|
||||
<a class="btn btn--icon" href="/admin" aria-label="Admin settings" title="Admin settings">
|
||||
{{ icon("shield") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<button class="btn btn--icon" type="button" data-theme-toggle
|
||||
aria-label="Switch theme" title="Switch theme">
|
||||
<span class="theme-icon theme-icon--dark">{{ icon("moon") }}</span>
|
||||
<span class="theme-icon theme-icon--light">{{ icon("sun") }}</span>
|
||||
</button>
|
||||
<span class="topbar__spacer"></span>
|
||||
<form method="post" action="/auth/logout">
|
||||
<button class="btn btn--icon" type="submit" aria-label="Sign out" title="Sign out">
|
||||
{{ icon("logout") }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import icon %}
|
||||
|
||||
{% block title %}Your settings - LLeMbas{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/chat.css') }}">
|
||||
<link rel="stylesheet" href="{{ url_for('static', path='css/admin.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block body_attrs %} data-authenticated="true"{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="shell">
|
||||
{% include "partials/sidebar.html" %}
|
||||
|
||||
<main class="main">
|
||||
<header class="topbar">
|
||||
<h1 class="topbar__title">Your settings</h1>
|
||||
</header>
|
||||
|
||||
<div class="admin-scroll">
|
||||
<div class="admin-page">
|
||||
<section class="card">
|
||||
<h2 class="card__title">Account</h2>
|
||||
<div class="field">
|
||||
<span class="field__label">Name</span>
|
||||
<p>{{ user.name }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field__label">Email</span>
|
||||
<p class="mono text-sm">{{ user.email }}</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<span class="field__label">Role</span>
|
||||
<p>
|
||||
<span class="badge {{ 'badge--gold' if user.is_admin }}">{{ user.role }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Appearance</h2>
|
||||
<div class="row" style="gap: var(--sp-3)">
|
||||
<button class="btn" type="button" onclick="window.lembas.applyTheme('moria')">
|
||||
{{ icon("moon", "icon--sm") }} Moria
|
||||
</button>
|
||||
<button class="btn" type="button" onclick="window.lembas.applyTheme('shire')">
|
||||
{{ icon("sun", "icon--sm") }} Shire
|
||||
</button>
|
||||
</div>
|
||||
<p class="field__hint" style="margin-top: var(--sp-3)">
|
||||
Moria is the dark theme, Shire the light one. Your choice is saved
|
||||
to this browser and to your account.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2 class="card__title">Session</h2>
|
||||
<form method="post" action="/auth/logout">
|
||||
<button class="btn btn--danger" type="submit">
|
||||
{{ icon("logout", "icon--sm") }} Sign out
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Jinja environment and the context every template receives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from lembas import __version__
|
||||
from lembas.config import settings
|
||||
from lembas.db.models import User
|
||||
|
||||
TEMPLATE_DIR = Path(__file__).parent / "templates"
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
templates = Jinja2Templates(directory=str(TEMPLATE_DIR))
|
||||
templates.env.trim_blocks = True
|
||||
templates.env.lstrip_blocks = True
|
||||
|
||||
|
||||
def resolve_theme(user: User | None) -> str:
|
||||
"""Theme to render with on the server.
|
||||
|
||||
Only ever a first guess: the inline script in base.html corrects it from
|
||||
localStorage before first paint. Getting it close server-side is what stops
|
||||
a signed-in user seeing a flash of the wrong theme on every navigation.
|
||||
"""
|
||||
if user is not None:
|
||||
chosen = (user.settings_json or {}).get("theme")
|
||||
if chosen in ("moria", "shire"):
|
||||
return chosen
|
||||
return settings.default_theme
|
||||
|
||||
|
||||
def render(
|
||||
request: Request,
|
||||
template: str,
|
||||
context: dict[str, Any] | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
"""Render a template with the globals every page expects.
|
||||
|
||||
Using this instead of templates.TemplateResponse directly is what
|
||||
guarantees `user` and `theme` are always defined, so templates never need
|
||||
to guard against a missing variable.
|
||||
"""
|
||||
user = getattr(request.state, "user", None)
|
||||
payload: dict[str, Any] = {
|
||||
"request": request,
|
||||
"user": user,
|
||||
"theme": resolve_theme(user),
|
||||
"version": __version__,
|
||||
"allow_signup": settings.allow_signup,
|
||||
}
|
||||
payload.update(context or {})
|
||||
return templates.TemplateResponse(request, template, payload, **kwargs)
|
||||
Reference in New Issue
Block a user