Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92070d7879
|
||
|
|
d73a791c86
|
@@ -16,6 +16,68 @@ for 1.0.0 have something to be assembled from.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
## 1.0.4
|
||||||
|
|
||||||
|
Six things that looked like they worked. Five of them were found by reading the
|
||||||
|
code rather than by anybody reporting them, which is what they have in common:
|
||||||
|
none of these fails loudly, and two of them correct themselves if you reload.
|
||||||
|
|
||||||
|
- Fixed: **a reply lost the model's name and picture the moment it finished.**
|
||||||
|
While a reply streams it is attributed correctly; at the instant it lands, the
|
||||||
|
frame that replaces the bubble was looking the models up as nobody, and "no
|
||||||
|
user" answers "no models" rather than "all models". So a finished reply swapped
|
||||||
|
the model's avatar for the plain leaf mark, put the instance's name where the
|
||||||
|
model's should be, and grew a raw model id beside it. Reloading the page put it
|
||||||
|
all back, which is why this survived a release: it is only ever wrong until you
|
||||||
|
look away.
|
||||||
|
- Fixed: **a limit on how many replies an account may write at once could be
|
||||||
|
stepped over by pressing New chat.** It was enforced when sending into a chat
|
||||||
|
that already existed and nowhere else — not on a new chat, not on editing an
|
||||||
|
earlier message, not on sending a queued one, and not on regenerating. Four of
|
||||||
|
the six ways to start a reply ignored it, including the commonest.
|
||||||
|
- Fixed: **a custom theme's confirmations and warnings kept the built-in
|
||||||
|
theme's colour behind them.** Setting `success` or `warning` moved the text and
|
||||||
|
left the background it sits on, because the faded companion colour was derived
|
||||||
|
for three of the five settable colours. Visible on every alert and badge of
|
||||||
|
those two kinds, on the "on" state in the permissions list, and on the added
|
||||||
|
lines of every diff in an agent chat.
|
||||||
|
- Fixed: **on a phone, every page with a sidebar could be scrolled past its own
|
||||||
|
bottom into empty background.** The shell was sized to the part of the screen
|
||||||
|
you can actually see and the document around it to the part you can see with
|
||||||
|
the browser's toolbar retracted; the difference between those is real on a
|
||||||
|
phone and nil on a desktop, which is why it was never noticed on one. Reported
|
||||||
|
on Settings and true everywhere. A flick that ran off the end of a list now
|
||||||
|
stops there as well, instead of dragging the page behind it.
|
||||||
|
- Fixed: **the conversation was rendering every assistant message twice on every
|
||||||
|
page load** — once into Markdown that nothing read, and once the way it is
|
||||||
|
actually shown. The same was true of Messages, for your own turns. Nothing
|
||||||
|
looked wrong; a long conversation was simply slower to open than it needed to
|
||||||
|
be, every time, along with every rewind and every compaction.
|
||||||
|
- Fixed: a test file meant to skip itself on a machine without `setsid` never
|
||||||
|
did, because it set its marker twice and the second one replaced the first.
|
||||||
|
- Removed: an endpoint serving a message's unrendered Markdown, which nothing
|
||||||
|
had ever called — the copy button reads the page it is already on.
|
||||||
|
|
||||||
|
## 1.0.3
|
||||||
|
|
||||||
|
Two Arch-isms in the installer, both of which only a Debian machine could find.
|
||||||
|
`deploy/lxc-install.sh` had never been executed — it was reviewed and
|
||||||
|
syntax-checked, which is not the same claim — and running it is what found them.
|
||||||
|
|
||||||
|
- Fixed: **`deploy/install.sh` could not create its virtualenv on Debian**, and
|
||||||
|
so `deploy/lxc-install.sh` could not finish. It called bare `python`, which is
|
||||||
|
Python 3 on Arch — the machine this was written and only ever run on — and
|
||||||
|
does not exist on Debian at all unless `python-is-python3` is installed. The
|
||||||
|
LXC bootstrap installs `python3`, so the install aborted at the virtualenv
|
||||||
|
step with the service user, the bind mount and the clone already made. It now
|
||||||
|
calls `python3`, which is right on both.
|
||||||
|
- Fixed: the service account was created with `--shell /usr/bin/nologin`, which
|
||||||
|
is where Arch keeps it and where Debian does not. Nothing invoked it — `sudo -u`
|
||||||
|
execs directly and systemd's `User=` never reads a shell — so the account
|
||||||
|
worked either way, but it was created pointing at a file that was not there.
|
||||||
|
Now `/usr/sbin/nologin`, which is correct on Debian and resolves on Arch too,
|
||||||
|
since Arch's `/usr/sbin` is a symlink to `bin`.
|
||||||
|
|
||||||
## 1.0.2
|
## 1.0.2
|
||||||
|
|
||||||
- **The documentation moved to the [wiki](https://git.houmeres.sk/Houmeres/LLeMbas/wiki).**
|
- **The documentation moved to the [wiki](https://git.houmeres.sk/Houmeres/LLeMbas/wiki).**
|
||||||
|
|||||||
+11
-2
@@ -95,9 +95,13 @@ fi
|
|||||||
echo "== service user =="
|
echo "== service user =="
|
||||||
# --system: no ageing, no mail spool. Home under /home, not /var/lib, so the
|
# --system: no ageing, no mail spool. Home under /home, not /var/lib, so the
|
||||||
# venv and database sit on the larger volume.
|
# venv and database sit on the larger volume.
|
||||||
|
#
|
||||||
|
# `/usr/sbin/nologin` is Debian's path and works on both: Arch keeps `nologin`
|
||||||
|
# in /usr/bin, but its /usr/sbin is a symlink to bin, so the Debian spelling
|
||||||
|
# resolves there while the Arch one does not resolve on Debian at all.
|
||||||
if ! getent passwd "$SERVICE_USER" >/dev/null; then
|
if ! getent passwd "$SERVICE_USER" >/dev/null; then
|
||||||
sudo useradd --system --create-home --home-dir "$HOME_DIR" \
|
sudo useradd --system --create-home --home-dir "$HOME_DIR" \
|
||||||
--shell /usr/bin/nologin --comment "LLeMbas" "$SERVICE_USER"
|
--shell /usr/sbin/nologin --comment "LLeMbas" "$SERVICE_USER"
|
||||||
else
|
else
|
||||||
echo " user $SERVICE_USER already exists"
|
echo " user $SERVICE_USER already exists"
|
||||||
fi
|
fi
|
||||||
@@ -120,8 +124,13 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "== virtualenv =="
|
echo "== virtualenv =="
|
||||||
|
# `python3`, not `python`. On Arch -- the machine this was written on and the
|
||||||
|
# only one it had ever run on -- `python` is Python 3 and the bare name worked.
|
||||||
|
# On Debian it does not exist unless somebody installed `python-is-python3`, so
|
||||||
|
# the LXC bootstrap aborted here, after the service user, the bind mount and the
|
||||||
|
# clone were already in place. `python3` is correct on both.
|
||||||
if [[ ! -x "$VENV/bin/python" ]]; then
|
if [[ ! -x "$VENV/bin/python" ]]; then
|
||||||
sudo -u "$SERVICE_USER" python -m venv "$VENV"
|
sudo -u "$SERVICE_USER" python3 -m venv "$VENV"
|
||||||
fi
|
fi
|
||||||
sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet --upgrade pip
|
sudo -u "$SERVICE_USER" "$VENV/bin/pip" install --quiet --upgrade pip
|
||||||
# The extras a deployment gets. `search` because DuckDuckGo is the default web
|
# The extras a deployment gets. `search` because DuckDuckGo is the default web
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
|
||||||
|
|
||||||
__version__ = "1.0.2"
|
__version__ = "1.0.4"
|
||||||
|
|||||||
+43
-21
@@ -308,6 +308,12 @@ async def start_chat(
|
|||||||
if not content and not file_ids:
|
if not content and not file_ids:
|
||||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
# Before `_new_chat`, not after: a refusal that has already written the row
|
||||||
|
# leaves an empty chat in the sidebar as the visible result of being told
|
||||||
|
# no. There is no chat yet to exclude from the count, and none is needed --
|
||||||
|
# nothing can be running for a chat that does not exist.
|
||||||
|
_refuse_extra_reply(db, None, user)
|
||||||
|
|
||||||
chat = _new_chat(
|
chat = _new_chat(
|
||||||
db,
|
db,
|
||||||
user,
|
user,
|
||||||
@@ -981,7 +987,7 @@ def _note_rewind(chat: Chat) -> None:
|
|||||||
chat.rewound_at = datetime.now(UTC)
|
chat.rewound_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
def _too_many_replies(db: DBSession, chat: Chat, user: User) -> str:
|
def _too_many_replies(db: DBSession, chat: Chat | None, user: User) -> str:
|
||||||
"""Why this account may not start another reply right now, or "".
|
"""Why this account may not start another reply right now, or "".
|
||||||
|
|
||||||
In-process, and that is exact rather than approximate only because this
|
In-process, and that is exact rather than approximate only because this
|
||||||
@@ -998,10 +1004,13 @@ def _too_many_replies(db: DBSession, chat: Chat, user: User) -> str:
|
|||||||
row[0]
|
row[0]
|
||||||
for row in db.execute(select(Chat.id).where(Chat.user_id == user.id)).all()
|
for row in db.execute(select(Chat.id).where(Chat.user_id == user.id)).all()
|
||||||
}
|
}
|
||||||
|
# `chat` is None on the new-chat path, where there is no row yet and so
|
||||||
|
# nothing to exclude -- every running reply of theirs counts.
|
||||||
|
here = chat.id if chat is not None else None
|
||||||
running = sum(
|
running = sum(
|
||||||
1
|
1
|
||||||
for chat_id in mine
|
for chat_id in mine
|
||||||
if chat_id != chat.id and generation_service.running_for(chat_id) is not None
|
if chat_id != here and generation_service.running_for(chat_id) is not None
|
||||||
)
|
)
|
||||||
if running < ceiling:
|
if running < ceiling:
|
||||||
return ""
|
return ""
|
||||||
@@ -1011,6 +1020,22 @@ def _too_many_replies(db: DBSession, chat: Chat, user: User) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _refuse_extra_reply(db: DBSession, chat: Chat | None, user: User) -> None:
|
||||||
|
"""Raise if this account is already writing as many replies as it may.
|
||||||
|
|
||||||
|
A function rather than two lines repeated, because it is repeated five
|
||||||
|
times now. It used to be called once -- from `_send`, which serves
|
||||||
|
`post_message` and `execute_plan` -- while four other routes start a
|
||||||
|
generation: `start_chat`, `edit_message`, `send_queued_now` and
|
||||||
|
`regenerate`. So a group's `concurrent_replies` was reached by sending into
|
||||||
|
a chat that already existed and walked straight past by pressing New chat,
|
||||||
|
which is the commonest way to start a reply there is. A quota you can step
|
||||||
|
over by using the obvious button is not a quota.
|
||||||
|
"""
|
||||||
|
if busy := _too_many_replies(db, chat, user):
|
||||||
|
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, busy)
|
||||||
|
|
||||||
|
|
||||||
def _send(
|
def _send(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Db,
|
db: Db,
|
||||||
@@ -1042,8 +1067,7 @@ def _send(
|
|||||||
# This chat's own reply does not count against it -- a second message here
|
# This chat's own reply does not count against it -- a second message here
|
||||||
# is queued rather than sent, a few lines down, and that path is what the
|
# is queued rather than sent, a few lines down, and that path is what the
|
||||||
# queue is for.
|
# queue is for.
|
||||||
if busy := _too_many_replies(db, chat, user):
|
_refuse_extra_reply(db, chat, user)
|
||||||
raise HTTPException(status.HTTP_429_TOO_MANY_REQUESTS, busy)
|
|
||||||
|
|
||||||
if queued := _reply_in_flight(db, chat):
|
if queued := _reply_in_flight(db, chat):
|
||||||
waiting = db.scalar(
|
waiting = db.scalar(
|
||||||
@@ -1328,8 +1352,16 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
|
|||||||
# template shares both roles, and a missing `user` would only
|
# template shares both roles, and a missing `user` would only
|
||||||
# blow up on whichever branch is not being exercised here.
|
# blow up on whichever branch is not being exercised here.
|
||||||
"user": owner,
|
"user": owner,
|
||||||
|
# `owner`, never None. `models_visible_to` answers an absent
|
||||||
|
# user with [], so a None here is not "every model" but *no*
|
||||||
|
# model -- and this frame replaces the whole bubble at the
|
||||||
|
# moment a reply finishes. The template then finds no
|
||||||
|
# `speaking_model` and the finished reply swaps its avatar for
|
||||||
|
# the LLeMbas mark, its author for the instance name, and grows
|
||||||
|
# a raw model_id chip, all of which a reload silently corrects.
|
||||||
|
# That is why it went unreported for so long.
|
||||||
"models_by_id": {
|
"models_by_id": {
|
||||||
m.model_id: m for m in chat_service.available_models(db, None)
|
m.model_id: m for m in chat_service.available_models(db, owner)
|
||||||
},
|
},
|
||||||
# This frame replaces the whole bubble, so it has to carry the
|
# This frame replaces the whole bubble, so it has to carry the
|
||||||
# speaker button's conditions too -- and the owner's, not the
|
# speaker button's conditions too -- and the owner's, not the
|
||||||
@@ -1360,7 +1392,7 @@ def _render_bubble(db: DBSession, chat: Chat, owner: User | None, message: Messa
|
|||||||
"message": message,
|
"message": message,
|
||||||
"chat": chat,
|
"chat": chat,
|
||||||
"user": owner,
|
"user": owner,
|
||||||
"models_by_id": {m.model_id: m for m in chat_service.available_models(db, None)},
|
"models_by_id": {m.model_id: m for m in chat_service.available_models(db, owner)},
|
||||||
**audio_service.template_flags(db, owner),
|
**audio_service.template_flags(db, owner),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1447,11 +1479,6 @@ def _thread_context(db: DBSession, chat: Chat, user: User) -> dict:
|
|||||||
"user": user,
|
"user": user,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"compacted": compacted,
|
"compacted": compacted,
|
||||||
"bodies": {
|
|
||||||
m.id: render_markdown(m.content)
|
|
||||||
for m in everything
|
|
||||||
if m.role == ROLE_ASSISTANT and m.content
|
|
||||||
},
|
|
||||||
"models_by_id": {m.model_id: m for m in chat_service.available_models(db, user)},
|
"models_by_id": {m.model_id: m for m in chat_service.available_models(db, user)},
|
||||||
**audio_service.template_flags(db, user),
|
**audio_service.template_flags(db, user),
|
||||||
}
|
}
|
||||||
@@ -1560,6 +1587,8 @@ async def edit_message(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_409_CONFLICT, "Wait for the current reply to finish, or stop it."
|
status.HTTP_409_CONFLICT, "Wait for the current reply to finish, or stop it."
|
||||||
)
|
)
|
||||||
|
# `_reply_in_flight` is about *this* chat; the quota is about the account.
|
||||||
|
_refuse_extra_reply(db, chat, user)
|
||||||
|
|
||||||
message.content = content
|
message.content = content
|
||||||
|
|
||||||
@@ -1703,6 +1732,7 @@ async def send_queued_now(
|
|||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_409_CONFLICT, "Wait for the current reply to finish, or stop it."
|
status.HTTP_409_CONFLICT, "Wait for the current reply to finish, or stop it."
|
||||||
)
|
)
|
||||||
|
_refuse_extra_reply(db, chat, user)
|
||||||
|
|
||||||
message.queued = False
|
message.queued = False
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -2123,16 +2153,6 @@ async def delete_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
|
|||||||
return response
|
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")
|
@router.post("/{chat_id}/messages/{message_id}/regenerate")
|
||||||
async def regenerate(
|
async def regenerate(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -2147,6 +2167,8 @@ async def regenerate(
|
|||||||
if message is None or message.chat_id != chat.id or message.role != ROLE_ASSISTANT:
|
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.")
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That reply no longer exists.")
|
||||||
|
|
||||||
|
_refuse_extra_reply(db, chat, user)
|
||||||
|
|
||||||
message.content = ""
|
message.content = ""
|
||||||
message.error = ""
|
message.error = ""
|
||||||
message.complete = False
|
message.complete = False
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ from lembas.api.pages import _chat_context, sidebar_context
|
|||||||
from lembas.db.models import Message, Schedule
|
from lembas.db.models import Message, Schedule
|
||||||
from lembas.services import messages as messages_service
|
from lembas.services import messages as messages_service
|
||||||
from lembas.services import schedules as schedules_service
|
from lembas.services import schedules as schedules_service
|
||||||
from lembas.services.markdown import render_markdown
|
|
||||||
from lembas.services.schedule import clock
|
from lembas.services.schedule import clock
|
||||||
from lembas.services.schedule import rule as rule_service
|
from lembas.services.schedule import rule as rule_service
|
||||||
from lembas.web.templating import render
|
from lembas.web.templating import render
|
||||||
@@ -31,11 +30,6 @@ log = logging.getLogger(__name__)
|
|||||||
router = APIRouter(tags=["messages"])
|
router = APIRouter(tags=["messages"])
|
||||||
|
|
||||||
|
|
||||||
def _bodies(messages: list[Message]) -> dict[str, str]:
|
|
||||||
"""Markdown rendered server-side, keyed by id, as `chat_detail` does."""
|
|
||||||
return {m.id: render_markdown(m.content) for m in messages if m.role == "user"}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/messages")
|
@router.get("/messages")
|
||||||
async def messages_page(request: Request, db: Db, user: RequiredUser):
|
async def messages_page(request: Request, db: Db, user: RequiredUser):
|
||||||
conversation = messages_service.for_user(db, user)
|
conversation = messages_service.for_user(db, user)
|
||||||
@@ -61,7 +55,6 @@ async def messages_page(request: Request, db: Db, user: RequiredUser):
|
|||||||
"chat": conversation,
|
"chat": conversation,
|
||||||
"messages": live,
|
"messages": live,
|
||||||
"compacted": [],
|
"compacted": [],
|
||||||
"bodies": _bodies(live),
|
|
||||||
"inherited_prompt": "",
|
"inherited_prompt": "",
|
||||||
"inherited_from": "",
|
"inherited_from": "",
|
||||||
"more_before": bool(live) and messages_service.has_more_before(
|
"more_before": bool(live) and messages_service.has_more_before(
|
||||||
@@ -109,7 +102,6 @@ async def messages_history(
|
|||||||
"messages/_history.html",
|
"messages/_history.html",
|
||||||
{
|
{
|
||||||
"messages": page,
|
"messages": page,
|
||||||
"bodies": _bodies(page),
|
|
||||||
"more_before": messages_service.has_more_before(db, conversation, page[0]),
|
"more_before": messages_service.has_more_before(db, conversation, page[0]),
|
||||||
"oldest_id": page[0].id,
|
"oldest_id": page[0].id,
|
||||||
# `render()` injects `user` and friends; `TemplateResponse` does
|
# `render()` injects `user` and friends; `TemplateResponse` does
|
||||||
|
|||||||
@@ -462,7 +462,20 @@ def theme_css(theme: Theme) -> str:
|
|||||||
if not theme.tokens:
|
if not theme.tokens:
|
||||||
return ""
|
return ""
|
||||||
lines = [f" --{name}: {value};" for name, value in theme.tokens.items()]
|
lines = [f" --{name}: {value};" for name, value in theme.tokens.items()]
|
||||||
for name, alpha in (("accent", "0.14"), ("leaf", "0.14"), ("danger", "0.14")):
|
# Every settable colour that has a `-soft` companion in tokens.css, not the
|
||||||
|
# three somebody stopped at. `success` and `warning` were settable and their
|
||||||
|
# softs were not derived, so a custom theme moved the text and left the
|
||||||
|
# background behind it in the base theme's hue -- an alert, a badge, a
|
||||||
|
# permission's "on" state and the `+` lines of every agent diff, each in two
|
||||||
|
# colours that were never meant to meet. Precisely the half-working failure
|
||||||
|
# this function's own docstring says it exists to prevent.
|
||||||
|
for name, alpha in (
|
||||||
|
("accent", "0.14"),
|
||||||
|
("leaf", "0.14"),
|
||||||
|
("danger", "0.14"),
|
||||||
|
("success", "0.14"),
|
||||||
|
("warning", "0.14"),
|
||||||
|
):
|
||||||
soft = _soft(theme.tokens.get(name, ""), alpha)
|
soft = _soft(theme.tokens.get(name, ""), alpha)
|
||||||
if soft:
|
if soft:
|
||||||
lines.append(f" --{name}-soft: {soft};")
|
lines.append(f" --{name}-soft: {soft};")
|
||||||
|
|||||||
@@ -15,14 +15,10 @@
|
|||||||
that one, silently, while the reader was lost in the other. Under
|
that one, silently, while the reader was lost in the other. Under
|
||||||
`.admin-scroll` the body is now an ordinary block and the page scrolls as one.
|
`.admin-scroll` the body is now an ordinary block and the page scrolls as one.
|
||||||
*/
|
*/
|
||||||
.admin-scroll,
|
/* What makes one of these scroll is `.scroll-region` in app.css, which both of
|
||||||
.main > .tabs > .tabs__body {
|
these selectors are listed in. Named there so the four declarations exist
|
||||||
flex: 1;
|
once; named *here* is the reasoning above, which is about which element is
|
||||||
min-height: 0;
|
the scroller on which screen rather than about how a scroller behaves. */
|
||||||
overflow-y: auto;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border-strong) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page,
|
.page,
|
||||||
.admin-page {
|
.admin-page {
|
||||||
|
|||||||
@@ -18,6 +18,33 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
A page built around the shell never scrolls its own document.
|
||||||
|
|
||||||
|
`.shell` is `100dvh` -- the *dynamic* viewport, which is what you can actually
|
||||||
|
see -- while `html` and `body` above are `100%`, which resolves against the
|
||||||
|
initial containing block and is the *large* viewport, the one you get with the
|
||||||
|
browser's toolbar retracted. On a desktop those are the same number and this
|
||||||
|
rule does nothing. On a phone they differ by the height of the toolbar, and
|
||||||
|
the difference is a document taller than its own window: you scroll past the
|
||||||
|
bottom of the sidebar and the main column into bare background, and because
|
||||||
|
every gesture retracts or extends the toolbar the shell resizes underneath you
|
||||||
|
and it never settles.
|
||||||
|
|
||||||
|
Reported on /settings, true of every page with a shell. `:has()` rather than a
|
||||||
|
class because the shell is what decides this, not the route -- the auth, error
|
||||||
|
and offline pages have no shell and genuinely do scroll their document, and
|
||||||
|
they must keep doing so.
|
||||||
|
*/
|
||||||
|
html:has(body > .shell),
|
||||||
|
body:has(> .shell) {
|
||||||
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
|
/* A flick that reaches the end of an inner scroller stops there rather than
|
||||||
|
pulling the page around behind it. */
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
The `hidden` attribute has to win.
|
The `hidden` attribute has to win.
|
||||||
|
|
||||||
@@ -367,6 +394,39 @@ button, input, textarea, select {
|
|||||||
.badge--danger { background: var(--danger-soft); color: var(--danger); }
|
.badge--danger { background: var(--danger-soft); color: var(--danger); }
|
||||||
.badge--warning { background: var(--warning-soft); color: var(--warning); }
|
.badge--warning { background: var(--warning-soft); color: var(--warning); }
|
||||||
|
|
||||||
|
/* --- Scroll regions --------------------------------------------------------
|
||||||
|
The four declarations that make an element *the* scroller, written once.
|
||||||
|
|
||||||
|
They were spelled out five times -- the sidebar's list, the thread, the
|
||||||
|
inspector, the canvas and (in admin.css) the tabs and admin pages -- and
|
||||||
|
agreed on three of them. The fourth, `overscroll-behavior`, was on the
|
||||||
|
sidebar alone, with a good comment explaining why it was needed there. It is
|
||||||
|
needed everywhere for the same reason: a flick that reaches the end of a
|
||||||
|
scroller chains to whatever is behind it, and behind these is the shell,
|
||||||
|
which does not scroll -- so what the gesture produces is not a scrolled page
|
||||||
|
but a rubber-band into blank background, which reads as the layout having
|
||||||
|
come loose.
|
||||||
|
|
||||||
|
`min-height: 0` is the half that is load-bearing rather than cosmetic: a flex
|
||||||
|
child will not shrink below its content without it, so a scroller missing it
|
||||||
|
grows its parent instead of scrolling inside it. `.thread-scroll` relied on a
|
||||||
|
scroll container's automatic minimum size to get away with omitting it, which
|
||||||
|
is true and is not something the next person should have to know. */
|
||||||
|
.scroll-region,
|
||||||
|
.sidebar__scroll,
|
||||||
|
.inspector__body,
|
||||||
|
.canvas__body,
|
||||||
|
.thread-scroll,
|
||||||
|
.admin-scroll,
|
||||||
|
.main > .tabs > .tabs__body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-strong) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Application shell ----------------------------------------------------- */
|
/* --- Application shell ----------------------------------------------------- */
|
||||||
.shell { display: flex; height: 100dvh; overflow: hidden; }
|
.shell { display: flex; height: 100dvh; overflow: hidden; }
|
||||||
|
|
||||||
@@ -412,18 +472,9 @@ button, input, textarea, select {
|
|||||||
flex: none;
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A `.scroll-region`; only the padding is its own. */
|
||||||
.sidebar__scroll {
|
.sidebar__scroll {
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
/* A flick past the end of the list stops there rather than chaining to
|
|
||||||
whatever is behind it. The shell is `overflow: hidden`, so what chaining
|
|
||||||
produced was not a scrolled page but a rubber-band into blank background --
|
|
||||||
which reads as the sidebar having come loose from the layout. */
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
padding: 0 var(--sp-2) var(--sp-3);
|
padding: 0 var(--sp-2) var(--sp-3);
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border-strong) transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__footer {
|
.sidebar__footer {
|
||||||
@@ -491,12 +542,7 @@ button, input, textarea, select {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.inspector__body {
|
.inspector__body {
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: var(--sp-4);
|
padding: var(--sp-4);
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border-strong) transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.inspector__heading {
|
.inspector__heading {
|
||||||
@@ -789,8 +835,8 @@ body.is-resizing .canvas__body { pointer-events: none; }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.canvas__body {
|
.canvas__body {
|
||||||
flex: 1;
|
/* Both axes, unlike every other scroll region: nothing re-wraps a source
|
||||||
min-height: 0;
|
line, so it has to be reachable sideways. */
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: var(--sp-3);
|
padding: var(--sp-3);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
/* --- Thread --------------------------------------------------------------- */
|
/* --- Thread --------------------------------------------------------------- */
|
||||||
|
/* A `.scroll-region` (app.css); the smooth behaviour is this one's own, because
|
||||||
|
this is the scroller something is repeatedly scrolled *to* -- the newest
|
||||||
|
message, a jump back to the bottom -- and the others are not. */
|
||||||
.thread-scroll {
|
.thread-scroll {
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: var(--border-strong) transparent;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.thread {
|
.thread {
|
||||||
|
|||||||
@@ -25,16 +25,12 @@
|
|||||||
</p>
|
</p>
|
||||||
<div class="compacted__body">
|
<div class="compacted__body">
|
||||||
{% for message in compacted %}
|
{% for message in compacted %}
|
||||||
{% with body_html = bodies.get(message.id, "") %}
|
{% include "chat/_message.html" %}
|
||||||
{% include "chat/_message.html" %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
{% with body_html = bodies.get(message.id, "") %}
|
{% include "chat/_message.html" %}
|
||||||
{% include "chat/_message.html" %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -29,7 +29,5 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
{% with body_html = bodies.get(message.id, "") %}
|
{% include "chat/_message.html" %}
|
||||||
{% include "chat/_message.html" %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -83,9 +83,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for message in messages %}
|
{% for message in messages %}
|
||||||
{% with body_html = bodies.get(message.id, "") %}
|
{% include "chat/_message.html" %}
|
||||||
{% include "chat/_message.html" %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,14 +22,19 @@ from lembas.services.agent.base import ExecRequest, ExecResult, clean_output
|
|||||||
from lembas.services.agent.session import AgentContext
|
from lembas.services.agent.session import AgentContext
|
||||||
from lembas.services.agent.tools import _run_shell
|
from lembas.services.agent.tools import _run_shell
|
||||||
|
|
||||||
pytestmark = pytest.mark.skipif(
|
# A list, because there are two of them and `pytestmark = ...` twice is not two
|
||||||
shutil.which("setsid") is None or shutil.which("base64") is None,
|
# marks -- the second binding replaces the first, silently. It did, for the
|
||||||
reason="needs setsid and base64 (Linux)",
|
# whole life of this file: the guard below was written, read as present, and
|
||||||
)
|
# never once applied, so a host without `setsid` got a module that errored
|
||||||
|
# instead of the skip somebody had taken the trouble to write.
|
||||||
|
pytestmark = [
|
||||||
# Stands up something real -- see the `slow` marker in pyproject.toml.
|
pytest.mark.skipif(
|
||||||
pytestmark = pytest.mark.slow
|
shutil.which("setsid") is None or shutil.which("base64") is None,
|
||||||
|
reason="needs setsid and base64 (Linux)",
|
||||||
|
),
|
||||||
|
# Stands up something real -- see the `slow` marker in pyproject.toml.
|
||||||
|
pytest.mark.slow,
|
||||||
|
]
|
||||||
|
|
||||||
class LocalExecutor:
|
class LocalExecutor:
|
||||||
"""`SshExecutor.run`'s contract, run against the local shell.
|
"""`SshExecutor.run`'s contract, run against the local shell.
|
||||||
|
|||||||
@@ -1167,3 +1167,68 @@ def test_the_think_frame_lands_beside_the_reasoning_body_not_around_it():
|
|||||||
# Neither element may open a tag that the other closes: siblings, not nested.
|
# Neither element may open a tag that the other closes: siblings, not nested.
|
||||||
assert "</span>" in between or "</div>" in between
|
assert "</span>" in between or "</div>" in between
|
||||||
assert between.count("<div") <= 1
|
assert between.count("<div") <= 1
|
||||||
|
|
||||||
|
|
||||||
|
# --- Who a finished reply says it came from ----------------------------------
|
||||||
|
def test_a_finished_bubble_names_the_model_that_wrote_it(db, client, registered, make_chat):
|
||||||
|
"""The `done` frame and the tail route render the bubble from scratch, and
|
||||||
|
both looked the models up as *nobody* -- which `models_visible_to` answers
|
||||||
|
with an empty list, not with everything. So a reply was attributed correctly
|
||||||
|
for as long as it was streaming and lost its avatar and its author line at
|
||||||
|
the instant it finished, then corrected itself on the next page load.
|
||||||
|
|
||||||
|
Asserted on the rendered HTML rather than on the argument: passing `owner`
|
||||||
|
is what the old code looked like it was doing, and an assertion on the call
|
||||||
|
would have been green throughout.
|
||||||
|
"""
|
||||||
|
from lembas.api import chats as chats_api
|
||||||
|
from lembas.db.models import Chat, Connection, Message, Model, User
|
||||||
|
|
||||||
|
connection = Connection(name="c", base_url="http://127.0.0.1:1", api_key_encrypted="")
|
||||||
|
db.add(connection)
|
||||||
|
db.commit()
|
||||||
|
db.add(Model(connection_id=connection.id, model_id="mithril-7b", display_name="Mithril 7B"))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
chat_id = make_chat(model_id="mithril-7b")
|
||||||
|
chat = db.get(Chat, chat_id)
|
||||||
|
owner = db.get(User, chat.user_id)
|
||||||
|
message = Message(
|
||||||
|
chat_id=chat.id, role="assistant", content="Spoken.",
|
||||||
|
complete=True, model_id="mithril-7b",
|
||||||
|
)
|
||||||
|
db.add(message)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
html = chats_api._render_bubble(db, chat, owner, message)
|
||||||
|
assert "Mithril 7B" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_reply_limit_covers_every_way_of_starting_one(db):
|
||||||
|
"""It was enforced in `_send` alone, so an account at its ceiling reached it
|
||||||
|
by sending into an existing chat and walked past it by pressing New chat --
|
||||||
|
and by editing, by sending a queued message, and by regenerating.
|
||||||
|
|
||||||
|
Reading the source is the honest test here: driving four routes to the point
|
||||||
|
of refusal needs four live generations, which is a fixture that would tell
|
||||||
|
you more about the fixture than about the guard.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from lembas.api import chats as chats_api
|
||||||
|
|
||||||
|
source = inspect.getsource(chats_api)
|
||||||
|
for route in ("start_chat", "edit_message", "send_queued_now", "regenerate", "_send"):
|
||||||
|
body = source.split(f"def {route}(", 1)[1].split("\n@router", 1)[0]
|
||||||
|
assert "_refuse_extra_reply" in body, route
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_new_chat_is_not_written_before_the_limit_is_checked(db):
|
||||||
|
"""A refusal that has already created the row leaves an empty chat in the
|
||||||
|
sidebar as the visible result of being told no."""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from lembas.api import chats as chats_api
|
||||||
|
|
||||||
|
body = inspect.getsource(chats_api.start_chat)
|
||||||
|
assert body.index("_refuse_extra_reply") < body.index("_new_chat(")
|
||||||
|
|||||||
+7
-2
@@ -163,10 +163,15 @@ def test_the_tab_reset_finds_the_container_that_actually_scrolls():
|
|||||||
either alone leaves the bug.
|
either alone leaves the bug.
|
||||||
"""
|
"""
|
||||||
admin = (ROOT / "web/static/css/admin.css").read_text(encoding="utf-8")
|
admin = (ROOT / "web/static/css/admin.css").read_text(encoding="utf-8")
|
||||||
|
app = (ROOT / "web/static/css/app.css").read_text(encoding="utf-8")
|
||||||
|
|
||||||
# The scroller rule names where the tabs must be, not just the class.
|
# The scroller rule names where the tabs must be, not just the class. Which
|
||||||
assert ".main > .tabs > .tabs__body" in admin
|
# stylesheet it is written in is not the point and is not asserted -- the
|
||||||
|
# four declarations that make something a scroller now live once, in
|
||||||
|
# app.css, and this selector is listed there with the rest.
|
||||||
|
assert ".main > .tabs > .tabs__body" in admin + app
|
||||||
assert "\n.tabs__body {" not in admin
|
assert "\n.tabs__body {" not in admin
|
||||||
|
assert "\n.tabs__body {" not in app
|
||||||
|
|
||||||
assert "overflowY" in SOURCE
|
assert "overflowY" in SOURCE
|
||||||
assert "scrollHeight > " in SOURCE
|
assert "scrollHeight > " in SOURCE
|
||||||
|
|||||||
Reference in New Issue
Block a user