Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92070d7879
|
||
|
|
d73a791c86
|
||
|
|
25aa208d04
|
@@ -16,6 +16,81 @@ 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
|
||||||
|
|
||||||
|
- **The documentation moved to the [wiki](https://git.houmeres.sk/Houmeres/LLeMbas/wiki).**
|
||||||
|
`CLAUDE.md`, `PLAN.md` and `docs/` are gone from the repository: they are
|
||||||
|
documentation *about* this project rather than part of it, and a clone should
|
||||||
|
carry software. Nothing was lost — the working notes, the roadmap and the eight
|
||||||
|
topic notes are all there, with every internal link rewritten, and the README
|
||||||
|
now opens onto them. Where a source comment said "see `CLAUDE.md`" it now says
|
||||||
|
"see the working notes".
|
||||||
|
- Entries below this one still name `PLAN.md` and `docs/notes/…`, and are left as
|
||||||
|
they were written. A changelog records what happened at the time; rewriting old
|
||||||
|
entries to match a later decision makes it a worse record, not a better one.
|
||||||
|
|
||||||
## 1.0.1
|
## 1.0.1
|
||||||
|
|
||||||
- Fixed: the Updates page showed **"v1.0.0 (reports 1.0.0)"** — two spellings of
|
- Fixed: the Updates page showed **"v1.0.0 (reports 1.0.0)"** — two spellings of
|
||||||
|
|||||||
@@ -144,7 +144,24 @@ runtime. Clone it, `pip install -e .`, run it.
|
|||||||
|
|
||||||
OCR for scanned PDFs · conversation branching · chat export · archived chats.
|
OCR for scanned PDFs · conversation branching · chat export · archived chats.
|
||||||
|
|
||||||
See [PLAN.md](PLAN.md) for what is built, what is not, and why.
|
See the [Roadmap](https://git.houmeres.sk/Houmeres/LLeMbas/wiki/Roadmap) for what
|
||||||
|
is built, what is not, and why.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
The **[wiki](https://git.houmeres.sk/Houmeres/LLeMbas/wiki)** carries everything
|
||||||
|
about how this works and why — it is documentation *about* the project rather
|
||||||
|
than part of it, so a clone stays software.
|
||||||
|
|
||||||
|
- **[Working notes](https://git.houmeres.sk/Houmeres/LLeMbas/wiki/Working-notes)**
|
||||||
|
— read this before changing anything. The hard rules the project is built
|
||||||
|
around, the layout, and a long catalogue of *things that will bite you*: bugs
|
||||||
|
that shipped looking correct, why each happened, and what stops it recurring.
|
||||||
|
- **[Roadmap](https://git.houmeres.sk/Houmeres/LLeMbas/wiki/Roadmap)** — what is
|
||||||
|
built, what is deliberately not, and the reasoning behind each.
|
||||||
|
- A page each for agent chats, schedules and reports, permissions and sharing,
|
||||||
|
search and extraction, image generation, subagents, branding, and the manual
|
||||||
|
release checklist.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
|
|
||||||
@@ -479,7 +496,7 @@ python scripts/fetch_vendor.py # verify vendored JS against the lockfile
|
|||||||
There is no Alembic. The schema is SQLite-only and synchronised at startup:
|
There is no Alembic. The schema is SQLite-only and synchronised at startup:
|
||||||
missing tables and missing columns are added automatically, so adding a field to
|
missing tables and missing columns are added automatically, so adding a field to
|
||||||
a model needs nothing but a restart. Renames, drops and retypes are still manual
|
a model needs nothing but a restart. Renames, drops and retypes are still manual
|
||||||
— see `CLAUDE.md`.
|
— see the [working notes](https://git.houmeres.sk/Houmeres/LLeMbas/wiki/Working-notes).
|
||||||
|
|
||||||
## Artwork
|
## Artwork
|
||||||
|
|
||||||
|
|||||||
+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.1"
|
__version__ = "1.0.4"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
Two shapes on one nav entry, because they are two different kinds of thing. The
|
Two shapes on one nav entry, because they are two different kinds of thing. The
|
||||||
connection, the checkpoints and the switches are instance settings and get a
|
connection, the checkpoints and the switches are instance settings and get a
|
||||||
settings page. A workflow is an authored document with a name, a description and
|
settings page. A workflow is an authored document with a name, a description and
|
||||||
a body, so the workflows are list-plus-detail -- the shape `CLAUDE.md` requires
|
a body, so the workflows are list-plus-detail -- the shape the working notes require
|
||||||
of any admin list, and for the reason it gives: a page that renders a ten-line
|
of any admin list, and for the reason it gives: a page that renders a ten-line
|
||||||
JSON textarea per row is unusable at three rows.
|
JSON textarea per row is unusable at three rows.
|
||||||
|
|
||||||
|
|||||||
@@ -238,7 +238,7 @@ async def browse_profile(
|
|||||||
it holds for the same reason -- somebody who owns the credential could list
|
it holds for the same reason -- somebody who owns the credential could list
|
||||||
the directory with an ssh client -- but it does mean Manual mode's promise
|
the directory with an ssh client -- but it does mean Manual mode's promise
|
||||||
that everything is shown to you first now has a second exception. Both are
|
that everything is shown to you first now has a second exception. Both are
|
||||||
written down in CLAUDE.md.
|
written down in the working notes.
|
||||||
"""
|
"""
|
||||||
profile = _profile(db, user, profile_id)
|
profile = _profile(db, user, profile_id)
|
||||||
entries: list = []
|
entries: list = []
|
||||||
|
|||||||
+44
-22
@@ -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,11 +987,11 @@ 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
|
||||||
application runs one worker -- see the first known limit in PLAN.md. With
|
application runs one worker -- see the first known limit in the roadmap. With
|
||||||
several, this becomes a guess, and a quota that is a guess should be a
|
several, this becomes a guess, and a quota that is a guess should be a
|
||||||
number in the database instead. Stated here rather than discovered.
|
number in the database instead. Stated here rather than discovered.
|
||||||
"""
|
"""
|
||||||
@@ -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
|
||||||
|
|||||||
+1
-1
@@ -263,7 +263,7 @@ def register_error_handlers(app: FastAPI) -> None:
|
|||||||
|
|
||||||
|
|
||||||
# Flavour lives in error pages, empty states and theme names -- never in the
|
# Flavour lives in error pages, empty states and theme names -- never in the
|
||||||
# functional UI. See CLAUDE.md.
|
# functional UI. See the working notes.
|
||||||
#
|
#
|
||||||
# The three lines themselves moved into `services/branding.py` with the rest of
|
# The three lines themselves moved into `services/branding.py` with the rest of
|
||||||
# what an administrator can replace. What is left here is the mapping from a
|
# what an administrator can replace. What is left here is the mapping from a
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ a model choosing to run something. Both are read-only, both are built here
|
|||||||
rather than assembled from anything a model said, and the project directory is
|
rather than assembled from anything a model said, and the project directory is
|
||||||
configuration rather than input. It is still an exception to Manual mode's
|
configuration rather than input. It is still an exception to Manual mode's
|
||||||
"everything is shown to you before it happens", and it is written down in
|
"everything is shown to you before it happens", and it is written down in
|
||||||
CLAUDE.md next to the others.
|
the working notes, next to the others.
|
||||||
|
|
||||||
**Nothing here is trusted.** Filenames come off somebody else's machine and end
|
**Nothing here is trusted.** Filenames come off somebody else's machine and end
|
||||||
up inside a system prompt, so they are stripped of control characters, capped
|
up inside a system prompt, so they are stripped of control characters, capped
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ def launch_and_wait_command(chat_id: str, job_id: str, command: str, max_bytes:
|
|||||||
# operand and formats to "<Logger … (WARNING)>", whose angle brackets and
|
# operand and formats to "<Logger … (WARNING)>", whose angle brackets and
|
||||||
# parentheses are shell syntax -- so this line died with a syntax error,
|
# parentheses are shell syntax -- so this line died with a syntax error,
|
||||||
# after the sentinel where nothing reads it, and every job's four files
|
# after the sentinel where nothing reads it, and every job's four files
|
||||||
# were left on the far side forever. See the note in CLAUDE.md.
|
# were left on the far side forever. See the note in the working notes.
|
||||||
f"rm -f {_file(chat_id, job_id, 'sh')} {pid} {logf} {exit_}\n"
|
f"rm -f {_file(chat_id, job_id, 'sh')} {pid} {logf} {exit_}\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ are separate because they fail differently:
|
|||||||
shows;
|
shows;
|
||||||
- **flavour text** — the Middle-earth lines, which live in the artwork, the
|
- **flavour text** — the Middle-earth lines, which live in the artwork, the
|
||||||
empty states, the loading lines and the error pages and nowhere else (see the
|
empty states, the loading lines and the error pages and nowhere else (see the
|
||||||
flavour rule in CLAUDE.md), and which somebody rebranding needs to be able to
|
flavour rule in the working notes), and which somebody rebranding needs to be able to
|
||||||
replace without editing templates;
|
replace without editing templates;
|
||||||
- **themes**, which are token sets rather than stylesheets, because the
|
- **themes**, which are token sets rather than stylesheets, because the
|
||||||
invariant that no component hard-codes a colour is what makes a third one
|
invariant that no component hard-codes a colour is what makes a third one
|
||||||
@@ -36,7 +36,7 @@ page and by nothing else.
|
|||||||
The cost of being a cache is stated rather than discovered: with several
|
The cost of being a cache is stated rather than discovered: with several
|
||||||
workers, a save in one is not seen by the others until each next reads. That is
|
workers, a save in one is not seen by the others until each next reads. That is
|
||||||
already true of this application for other reasons -- see the "one worker" note
|
already true of this application for other reasons -- see the "one worker" note
|
||||||
in PLAN.md -- and this does not make it worse.
|
in the roadmap -- and this does not make it worse.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -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};")
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ async def _run_fetch(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
|
|||||||
|
|
||||||
Straight through `services/fetch.py`, which owns the SSRF guard, the
|
Straight through `services/fetch.py`, which owns the SSRF guard, the
|
||||||
hand-rolled redirect loop that re-checks every hop, and the content-type
|
hand-rolled redirect loop that re-checks every hop, and the content-type
|
||||||
sniff. Deliberately not a second HTTP client: CLAUDE.md already names three
|
sniff. Deliberately not a second HTTP client: the working notes already name three
|
||||||
places that follow redirects by hand as the ceiling, and a fourth is how one
|
places that follow redirects by hand as the ceiling, and a fourth is how one
|
||||||
of them loses its check.
|
of them loses its check.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -118,7 +116,7 @@
|
|||||||
toggling their panel twice, which is to say doing nothing at all.
|
toggling their panel twice, which is to say doing nothing at all.
|
||||||
|
|
||||||
None of it looks like a script loaded twice. Found by driving the file under a
|
None of it looks like a script loaded twice. Found by driving the file under a
|
||||||
DOM stub, which is the rule `CLAUDE.md` sets out and the reason it does.
|
DOM stub, which is the rule the working notes set out and the reason it does.
|
||||||
#}
|
#}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ url_for('static', path='js/steps.js') }}" defer></script>
|
<script src="{{ url_for('static', path='js/steps.js') }}" defer></script>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
Cached at install time, so it has to stand entirely on its own: no user, no
|
Cached at install time, so it has to stand entirely on its own: no user, no
|
||||||
chats, nothing that was rendered from the database. One of the few places
|
chats, nothing that was rendered from the database. One of the few places
|
||||||
flavour belongs -- see the flavour rule in CLAUDE.md.
|
flavour belongs -- see the flavour rule in the working notes.
|
||||||
#}
|
#}
|
||||||
|
|
||||||
{% block title %}Offline - {{ brand.name }}{% endblock %}
|
{% block title %}Offline - {{ brand.name }}{% endblock %}
|
||||||
|
|||||||
@@ -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
|
||||||
|
# marks -- the second binding replaces the first, silently. It did, for the
|
||||||
|
# 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 = [
|
||||||
|
pytest.mark.skipif(
|
||||||
shutil.which("setsid") is None or shutil.which("base64") is None,
|
shutil.which("setsid") is None or shutil.which("base64") is None,
|
||||||
reason="needs setsid and base64 (Linux)",
|
reason="needs setsid and base64 (Linux)",
|
||||||
)
|
),
|
||||||
|
|
||||||
|
|
||||||
# Stands up something real -- see the `slow` marker in pyproject.toml.
|
# Stands up something real -- see the `slow` marker in pyproject.toml.
|
||||||
pytestmark = pytest.mark.slow
|
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