Two selects that never wrote anything, and a queue

The approval card in Auto mode and the missing /effort were one bug. Both
selects hung their hx-patch on an empty sibling form reached by form="…",
and htmx binds a trigger to the annotated element: change fires on the
select and bubbles to its ancestors, which a sibling is not. The live rows
read agent_mode=manual and params_json={} while the browser showed Auto and
Effort: high. policy.py was never involved.

The verb moves onto the control; the empty form stays as value scoping,
which is the half of the CLAUDE.md note that was right. conftest gains
control_named so a test asserts the element carrying the name carries the
verb, rather than asserting the markup that was there throughout.

The composer's highlight was a third instance of the same carelessness in
CSS: .tok-mention is written for the transcript and scoped to nothing, so
the mirror painted its token in accent-coloured monospace over the
textarea's own text. Scoped under .msg; the mirror restates transparency
and font rather than inheriting them, and bleeds by box-shadow.

/effort is now offered before the first prompt and _new_chat reads it.
/index re-walks the project directory on demand, file_write drops the
listing it just invalidated, and the index ladder falls through to SFTP on
a host that refuses exec instead of returning nothing.

A second message during a reply is queued rather than starting a second
concurrent generation: a real Message row with queued set, so it survives a
restart and can be withdrawn. _drain hands one on at the end of a reply,
_inject takes one in at a tool-round boundary so an agent can be steered
mid-task. Stop leaves the queue undelivered. The terminal's Auto toggle
becomes off/copy/send, and send posts straight to the chat without touching
the composer.

@ now offers notes, skills, this chat's attachments and a URL to fetch; a
knowledge base attaches as a reference rather than a copy. copy_document
carries provenance, which was the one attach path that dropped it.

Also fixes an unrelated live bug: the round loop compared against the
global MAX_ROUNDS of 3 while sizing itself from the agent budget of 40, so
agent replies stopped after three rounds and reported forty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-02 19:49:34 +02:00
parent 0bee366488
commit 8a3a225fea
31 changed files with 2131 additions and 81 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints."""
__version__ = "0.6.1"
__version__ = "0.6.2"
+332 -5
View File
@@ -10,8 +10,8 @@ from collections.abc import AsyncIterator
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, Response, StreamingResponse
from sqlalchemy import select
from fastapi.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from sqlalchemy import func, select
from sqlalchemy.orm import Session as DBSession
from lembas.api.deps import Db, RequiredUser, require_permission
@@ -49,6 +49,12 @@ router = APIRouter(prefix="/api/chats", tags=["chats"])
# Well under nginx's 60s default; see services/sse.py:KEEPALIVE.
KEEPALIVE_AFTER = 15.0
# How many prompts may wait behind a reply at once. The terminal panel's Auto
# send is what this exists for: a `for` loop in a shell can produce commands
# faster than any model answers them, and a bound with a sentence attached is
# better than four hundred rows nobody meant to write.
MAX_QUEUED = 10
def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
chat = db.get(Chat, chat_id)
@@ -70,6 +76,7 @@ def _new_chat(
ssh_profile_id: str = "",
project_dir: str = "",
agent_mode: str = "",
reasoning_effort: str = "",
) -> Chat:
"""Create a chat row, resolving which model it should use.
@@ -81,7 +88,8 @@ def _new_chat(
before the first word. Without it, reaching Plan mode meant starting a chat
in Manual, sending something to make the chat exist, and only then being
offered the control -- by which point the model had already answered under
the wrong rules.
the wrong rules. The reasoning effort is accepted for the same reason, and
wins over the model's default: an explicit choice beats an inherited one.
"""
chosen = None
if model_id:
@@ -126,6 +134,12 @@ def _new_chat(
# Left alone entirely on a plain chat, where it means nothing.
if profile is not None and agent_mode.strip() in agent_policy.MODES:
chat.agent_mode = agent_mode.strip()
# After the model's defaults, so choosing one on the new-chat screen wins
# over the administrator's. Empty means "whatever the model said", not
# "none" -- clearing it is what the blank option on an existing chat does.
wanted_effort = reasoning_effort.strip().lower()
if wanted_effort in chat_service.EFFORTS:
chat.params_json = {**chat.params_json, "reasoning_effort": wanted_effort}
db.add(chat)
db.commit()
return chat
@@ -144,6 +158,7 @@ async def start_chat(
ssh_profile_id: str = Form(""),
project_dir: str = Form(""),
agent_mode: str = Form(""),
reasoning_effort: str = Form(""),
) -> Response:
"""Create a chat from its first message.
@@ -166,6 +181,7 @@ async def start_chat(
ssh_profile_id=ssh_profile_id,
project_dir=project_dir,
agent_mode=agent_mode,
reasoning_effort=reasoning_effort,
)
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
@@ -400,6 +416,108 @@ async def compact_chat(request: Request, db: Db, user: RequiredUser, chat_id: st
)
@router.post("/{chat_id}/index")
async def reindex_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
"""Walk the project directory again, now.
The listing is cached for five minutes and only ever built when a reply
starts, so a tree that has just changed under somebody's hands -- a checkout,
a build, anything done in the terminal panel rather than through
`file_write` -- stays wrong until the next reply after the TTL lapses. This
is the "look again" that was missing.
Read-only, and therefore outside `agent/policy.py` for the reason the
directory browser is: it is LLeMbas acting on a person's instruction, not a
model choosing to look, and a listing that asked permission would be
useless. The gate is ownership of the connection, checked here rather than
trusted from the chat.
"""
from lembas.db.models import SshProfile
from lembas.services.agent import index as index_service
from lembas.services.agent import ssh as ssh_service
chat = _owned_chat(db, chat_id, user.id)
if chat.kind != KIND_AGENT or not chat.ssh_profile_id:
raise HTTPException(status.HTTP_409_CONFLICT, "This chat has no project directory.")
if not permissions.has(db, user, "tools.agent"):
raise HTTPException(status.HTTP_403_FORBIDDEN, "You may not use agent connections.")
profile = db.get(SshProfile, chat.ssh_profile_id)
if profile is None or profile.owner_id != user.id or not profile.enabled:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That connection is not available.")
if not profile.host_key:
raise HTTPException(
status.HTTP_409_CONFLICT,
"This connection's host key has not been accepted yet.",
)
project_dir = chat.project_dir or profile.default_dir or ""
try:
found = await index_service.ensure(
ssh_service.SshExecutor(ssh_service.spec_from(profile), project_dir),
profile.id,
project_dir,
refresh=True,
)
except Exception as exc: # noqa: BLE001 - surfaced to the reader, not swallowed
log.warning("could not index %s for chat %s: %s", project_dir, chat.id, exc)
raise HTTPException(
status.HTTP_502_BAD_GATEWAY, "Could not read the project directory."
) from exc
listed = len(found.paths)
return JSONResponse(
{
"ok": True,
"files": found.total,
"listed": listed,
"truncated": found.truncated,
"message": (
f"{found.total} files under {project_dir or '~'}"
+ (f", {listed} listed." if listed != found.total else ".")
),
}
)
@router.post("/{chat_id}/bases")
async def attach_base(
request: Request, db: Db, user: RequiredUser, chat_id: str, base_id: str = Form("")
) -> Response:
"""Scope this chat to a knowledge base, from the `@` menu.
A base is a *reference*, not an attachment: `Chat.knowledge_bases` already
narrows `knowledge_search`, and the harness already names the attached bases
so the model can tell "there is nothing about this" from "I can only see
this folder". Copying a folder of documents into the window instead would
cost the context on every request forever to answer one question.
Additive, and idempotent -- choosing the same base twice is not an error.
Removing one is a checkbox in the chat's settings, where the whole set is
visible at once.
"""
from lembas.db.models import KnowledgeBase
from lembas.services.library import documents as documents_service
chat = _owned_chat(db, chat_id, user.id)
if not permissions.has(db, user, "library.use"):
raise HTTPException(status.HTTP_403_FORBIDDEN, "You may not use the library.")
base = db.scalar(
documents_service.visible_bases(db, user).where(KnowledgeBase.id == base_id)
)
if base is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "That knowledge base is not available.")
if base.id not in {b.id for b in chat.knowledge_bases}:
chat.knowledge_bases = [*chat.knowledge_bases, base]
db.commit()
return templates.TemplateResponse(
request, "chat/_base_chip.html", {"request": request, "base": base}
)
@router.post("/{chat_id}/keep")
async def keep_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
"""Stop a temporary chat being temporary.
@@ -489,6 +607,22 @@ async def post_message(
return _send(request, db, chat, user, content, file_ids=file_ids)
def _reply_in_flight(db: DBSession, chat: Chat) -> bool:
"""Whether this chat already has a reply being written.
The row is the authority, not the registry: a restart leaves an incomplete
assistant message behind with no `Generation` anywhere, and that row is what
starts the reply again on the next page load. The registry is consulted too,
for the sliver in which a generation is still running and its row has
already been written -- `_persist` sets `complete` before `_run` sets
`done`.
"""
unfinished = db.scalar(
select(Message).where(Message.chat_id == chat.id, Message.complete.is_(False))
)
return unfinished is not None or generation_service.running_for(chat.id) is not None
def _note_rewind(chat: Chat) -> None:
"""Record that an agent chat's transcript went back and the machine did not.
@@ -516,11 +650,53 @@ def _send(
Shared by the composer and by anything else that puts words into a
conversation on somebody's behalf -- carrying out a plan, for one. One path
rather than two, so a second way of sending cannot drift from the first.
If a reply is already being written, the turn is *queued* instead: written,
shown, and not sent. Starting a second reply here is what used to happen,
and it produced two generations answering the same chat from two different
prefixes of it, with Stop pointing at whichever bubble came first in the
document.
"""
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
if queued := _reply_in_flight(db, chat):
waiting = db.scalar(
select(func.count())
.select_from(Message)
.where(Message.chat_id == chat.id, Message.queued.is_(True))
)
if waiting >= MAX_QUEUED:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"There are already {MAX_QUEUED} messages waiting to be sent.",
)
user_message = chat_service.create_message(db, chat, ROLE_USER, content, queued=queued)
if file_ids:
files_service.claim(db, ids=file_ids, user_id=user.id, message_id=user_message.id)
db.refresh(user_message)
if queued:
# One bubble and no assistant placeholder. The streaming shell is the
# only thing that starts a generation, so a placeholder here would be a
# second concurrent reply -- exactly what the queue exists to prevent.
if (live := generation_service.running_for(chat.id)) is not None:
# So a reply between two rounds of tool calls notices it, and so
# anybody following sees the status change.
live.touch()
return templates.TemplateResponse(
request,
"chat/_message.html",
{
"request": request,
"message": user_message,
"chat": chat,
"user": user,
"models_by_id": {
m.model_id: m for m in chat_service.available_models(db, user)
},
**audio_service.template_flags(db, user),
},
)
assistant_message = chat_service.create_message(
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
)
@@ -680,10 +856,90 @@ async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
)
title_html = templates.get_template("chat/_title_oob.html").render({"chat": chat})
yield sse.event("done", final_html + title_html)
# What the queue did while this reply was running. There is no push
# channel that outlives one message's stream, and this is the last frame
# that reaches the browser -- so it carries the rest out of band, the
# way the chat title already does.
moved_html, queue_html = _queue_frames(db, chat, owner, generation)
yield sse.event("done", moved_html + final_html + queue_html + title_html)
yield sse.event("close", "")
def _render_bubble(db: DBSession, chat: Chat, owner: User | None, message: Message) -> str:
"""One finished bubble, rendered the way the `done` frame renders its own."""
return templates.get_template("chat/_message.html").render(
{
"message": message,
"body_html": (
render_markdown(message.content) if message.role == ROLE_ASSISTANT else ""
),
"chat": chat,
"user": owner,
"models_by_id": {m.model_id: m for m in chat_service.available_models(db, None)},
**audio_service.template_flags(db, owner),
}
)
def _queue_frames(
db: DBSession, chat: Chat, owner: User | None, generation
) -> tuple[str, str]:
"""The bubbles the queue produced during this reply, as out-of-band HTML.
Two pieces, because they swap differently. Anything taken *into* this reply
mid-round now sorts before it, so it is rendered ahead of the finished
bubble in the same `outerHTML` swap and its stale node is deleted out of
band -- one frame, and the DOM ends up in the order the database is in.
Anything drained *after* the reply is a new pair appended to the thread.
"""
moved: list[str] = []
out_of_band: list[str] = []
for injected_id in generation.injected_ids:
row = db.get(Message, injected_id)
if row is None:
continue
moved.append(_render_bubble(db, chat, owner, row))
# Removed where it was; it is about to reappear above the reply.
out_of_band.append(f'<article id="msg-{row.id}" hx-swap-oob="delete"></article>')
if generation.drained:
fresh = list(
db.scalars(
select(Message)
.where(Message.chat_id == chat.id, Message.complete.is_(False))
.order_by(Message.created_at)
)
)
for assistant in fresh:
# The user turn that was waiting has just lost its Send now and
# Discard, so it is re-rendered in place.
delivered = db.scalars(
select(Message)
.where(
Message.chat_id == chat.id,
Message.role == ROLE_USER,
Message.created_at <= assistant.created_at,
)
.order_by(Message.created_at.desc())
.limit(1)
).first()
if delivered is not None:
out_of_band.append(
f'<div hx-swap-oob="outerHTML:#msg-{delivered.id}">'
+ _render_bubble(db, chat, owner, delivered)
+ "</div>"
)
out_of_band.append(
'<div hx-swap-oob="beforeend:#thread">'
+ _render_bubble(db, chat, owner, assistant)
+ "</div>"
)
return "".join(moved), "".join(out_of_band)
def _metrics_html(generation) -> str:
"""The metric chips for a reply still being written.
@@ -799,6 +1055,16 @@ async def edit_message(
if not content and not message.attachments:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "A message cannot be empty.")
# Editing rewinds and then starts a reply, unconditionally. Doing that while
# one is already being written is a second concurrent generation -- the
# thing the queue exists to prevent -- reachable here by a button that is on
# screen throughout. It was reachable before the queue too; nothing made it
# obvious.
if _reply_in_flight(db, chat):
raise HTTPException(
status.HTTP_409_CONFLICT, "Wait for the current reply to finish, or stop it."
)
message.content = content
# Attachments cascade with their message, so the files go too.
@@ -884,6 +1150,67 @@ async def stop_message(db: Db, user: RequiredUser, chat_id: str, message_id: str
return Response(status_code=status.HTTP_204_NO_CONTENT)
def _waiting_message(db: DBSession, chat: Chat, message_id: str) -> Message:
message = db.get(Message, message_id)
if message is None or message.chat_id != chat.id or not message.queued:
raise HTTPException(
status.HTTP_404_NOT_FOUND, "That message is not waiting to be sent."
)
return message
@router.post("/{chat_id}/messages/{message_id}/discard")
async def discard_queued(db: Db, user: RequiredUser, chat_id: str, message_id: str) -> Response:
"""Withdraw a prompt that has not been sent.
Deleted outright rather than marked: it never reached a model, nothing in
the transcript refers to it, and a conversation full of tombstones for
things nobody said is worse than the row being gone. Attachments cascade.
An empty body rather than a 204, because htmx does not swap on a 204 and the
bubble has to disappear.
"""
chat = _owned_chat(db, chat_id, user.id)
message = _waiting_message(db, chat, message_id)
db.delete(message)
db.commit()
return HTMLResponse("")
@router.post("/{chat_id}/messages/{message_id}/send-now")
async def send_queued_now(
request: Request, db: Db, user: RequiredUser, chat_id: str, message_id: str
) -> Response:
"""Deliver a waiting prompt at once.
Refused while a reply is being written rather than allowed to jump ahead of
it: that is what the queue *is*, and starting a second generation here is
the thing this whole mechanism exists to stop. Stop the reply first.
The whole thread comes back, which is the rewind and compaction idiom, and
is safe only because of the refusal above -- there is no live bubble to
destroy.
"""
chat = _owned_chat(db, chat_id, user.id)
message = _waiting_message(db, chat, message_id)
if _reply_in_flight(db, chat):
raise HTTPException(
status.HTTP_409_CONFLICT, "Wait for the current reply to finish, or stop it."
)
message.queued = False
db.commit()
assistant = chat_service.create_message(
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
)
generation_service.ensure(chat.id, assistant.id)
return templates.TemplateResponse(
request, "chat/_thread.html", {"request": request, **_thread_context(db, chat, user)}
)
@router.post("/{chat_id}/interaction/{interaction_id}")
async def answer_interaction(
request: Request,
+152 -1
View File
@@ -16,14 +16,17 @@ from fastapi import (
status,
)
from fastapi.responses import FileResponse
from sqlalchemy import select
from lembas.api.deps import Db, RequiredUser, require_permission
from lembas.db.models import Attachment, Document
from lembas.db.models import Attachment, Document, KnowledgeBase, Note
from lembas.security import permissions
from lembas.services import files as files_service
from lembas.services import settings_store
from lembas.services.fetch import FetchError, fetch
from lembas.services.library import documents as documents_service
from lembas.services.library import notes as notes_service
from lembas.services.library import skills as skills_service
from lembas.web.templating import templates
log = logging.getLogger(__name__)
@@ -144,6 +147,102 @@ async def attach_from_knowledge(
)
def _chip(request: Request, attachment: Attachment) -> Response:
return templates.TemplateResponse(
request, "chat/_attachment_chip.html", {"request": request, "attachment": attachment}
)
def _not_available(request: Request, what: str) -> Response:
return templates.TemplateResponse(
request,
"chat/_attachment_error.html",
{"request": request, "filename": what, "error": f"That {what} is not available."},
)
@router.post("/from-note", dependencies=[Depends(require_permission("files.upload"))])
async def attach_from_note(
request: Request, db: Db, user: RequiredUser, note_id: str = Form(""), chat_id: str = Form("")
) -> Response:
"""Attach a note the model wrote earlier.
A copy, like every other attach path: a note is edited far more often than a
document, and a transcript that changes underneath itself because somebody
tidied a note later is the thing all of this is arranged to prevent.
"""
note = notes_service.get(db, note_id, user)
if note is None:
return _not_available(request, "note")
return _chip(
request,
files_service.store_text(
db,
user_id=user.id,
chat_id=chat_id or None,
filename=f"{note.title or 'note'}.txt",
text=note.body,
source_path=note.title or "",
source_label="Note",
),
)
@router.post("/from-skill", dependencies=[Depends(require_permission("files.upload"))])
async def attach_from_skill(
request: Request, db: Db, user: RequiredUser, skill_id: str = Form(""), chat_id: str = Form("")
) -> Response:
"""Hand a skill over directly, rather than hoping the model fetches it.
The index of enabled skills is already in the harness and `skill_get` pulls
a body on demand -- but only if the model decides to. `@` is the reader
saying "use this one", which is a different act and deserves a way to say it.
"""
skill = skills_service.get(db, skill_id, user)
if skill is None:
return _not_available(request, "skill")
return _chip(
request,
files_service.store_text(
db,
user_id=user.id,
chat_id=chat_id or None,
filename=f"{skill.name}.md",
text=skill.body,
source_path=skill.name,
source_label="Skill",
),
)
@router.post("/from-attachment", dependencies=[Depends(require_permission("files.upload"))])
async def attach_from_attachment(
request: Request,
db: Db,
user: RequiredUser,
attachment_id: str = Form(""),
chat_id: str = Form(""),
) -> Response:
"""Point at something already in this conversation, without uploading again.
Copied rather than referenced, like everything else here -- an attachment
belongs to the message it was sent with, and two messages sharing one row
would make deleting either of them a question rather than an answer.
"""
original = db.get(Attachment, attachment_id)
if original is None or original.user_id != user.id:
return _not_available(request, "attachment")
return _chip(
request,
files_service.copy_attachment(
db, user_id=user.id, chat_id=chat_id or None, attachment=original
),
)
@router.get("/knowledge-picker", dependencies=[Depends(require_permission("files.upload"))])
async def knowledge_picker(
request: Request, db: Db, user: RequiredUser, q: str = "", chat_id: str = ""
@@ -210,9 +309,14 @@ async def mention_picker(
][:20]
documents: list = []
notes: list = []
skills: list = []
bases: list = []
if permissions.has(db, user, "library.use"):
if needle:
documents = documents_service.search(db, user, q, limit=10)
notes = notes_service.search(db, user, q, limit=5)
skills = skills_service.search(db, user, q, limit=5)
else:
documents = list(
db.scalars(
@@ -221,6 +325,48 @@ async def mention_picker(
.limit(10)
)
)
notes = list(
db.scalars(
notes_service.visible(db, user).order_by(Note.updated_at.desc()).limit(5)
)
)
skills = list(db.scalars(skills_service.visible(db, user).limit(5)))
# A whole base is a *reference*, not a copy: attaching one scopes the
# chat to it and the model searches inside it. Dumping the contents of
# a folder of contracts into the window would be the wrong shape
# entirely, and `Chat.knowledge_bases` already means exactly this.
# Only in an existing chat, because there is nothing to attach it to
# before one exists -- the same reason project files are absent there.
if chat_id:
bases = [
base
for base in db.scalars(
documents_service.visible_bases(db, user).order_by(KnowledgeBase.name)
)
if not needle or needle in base.name.lower()
][:5]
# A URL typed after `@` is a page to read, not a name to look up. The
# fetcher, its SSRF guard and its HTML-to-text already live behind
# `/api/files/link`; this only offers it.
website = q.strip() if q.strip().lower().startswith(("http://", "https://")) else ""
attachments: list = []
if chat_id and needle:
attachments = list(
db.scalars(
select(Attachment)
.where(
Attachment.user_id == user.id,
Attachment.chat_id == chat_id,
Attachment.message_id.is_not(None),
)
.order_by(Attachment.created_at.desc())
.limit(20)
)
)
attachments = [a for a in attachments if needle in a.filename.lower()][:5]
return templates.TemplateResponse(
request,
@@ -230,6 +376,11 @@ async def mention_picker(
"user": user,
"files": files,
"documents": documents,
"notes": notes,
"skills": skills,
"bases": bases,
"attachments": attachments,
"website": website,
"q": q,
"chat_id": chat_id,
"profile_id": profile_id,
+7
View File
@@ -223,6 +223,13 @@ class Message(UUIDPrimaryKey, Timestamps, Base):
# True when the reader pressed Stop. Distinct from `error`: the text that
# did arrive is kept and is perfectly usable, it is just cut short.
stopped: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# Typed while a reply was still being written, and not yet handed to a
# model. A row rather than something held in the browser: it survives a
# restart, it is in the transcript the moment it is typed, and it can be
# withdrawn before it is ever sent. `build_messages` skips it; delivery --
# `generation._drain` at the end of a reply, or `_inject` between two rounds
# of tool calls -- is the only thing that clears it.
queued: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
chat: Mapped[Chat] = relationship(back_populates="messages")
attachments: Mapped[list[Attachment]] = relationship( # noqa: F821
+24 -3
View File
@@ -223,12 +223,21 @@ async def build(executor: Executor, project_dir: str) -> ProjectIndex:
"""Walk the directory, by whichever means works first."""
started = time.monotonic()
try:
found = None
for attempt in (_from_git, _from_find):
found = await attempt(executor, project_dir)
try:
found = await attempt(executor, project_dir)
except ExecError as exc:
# A rung that cannot run at all is a rung that did not answer,
# not the end of the ladder. A host that refuses exec entirely
# -- an SFTP-only account, a forced command -- is the exact case
# the SFTP rung below exists for, and letting this out skipped
# straight past it to an empty listing.
log.debug("indexing %s: %s did not run: %s", project_dir, attempt.__name__,
exc.message)
found = None
if found is not None:
break
else:
found = None
if found is None:
found = await _from_sftp(executor, project_dir)
except ExecError as exc:
@@ -491,6 +500,17 @@ def forget(profile_id: str) -> int:
return len(doomed)
def forget_dir(profile_id: str, project_dir: str) -> None:
"""Drop one tree's listing, because something just changed it.
The TTL exists for drift nobody can see coming. A write through `file_write`
is not that: it is this process changing the tree it has just described, and
leaving five minutes of a listing that is known to be wrong is worse than
having none -- a model reading it concludes the file it created is missing.
"""
_CACHE.pop((profile_id, project_dir), None)
def clear() -> None:
_CACHE.clear()
@@ -503,5 +523,6 @@ __all__ = [
"clear",
"ensure",
"forget",
"forget_dir",
"render",
]
+5
View File
@@ -36,6 +36,10 @@ class AgentContext:
chat_id: str
label: str
project_dir: str
# The connection's id, carried so a runner can drop the project listing it
# has just invalidated. `index` is keyed on the connection and the
# directory, not on the chat -- two chats on one tree share a listing.
profile_id: str = ""
mode: str = policy.MODE_MANUAL
allow: tuple[str, ...] = ()
deny: tuple[str, ...] = ()
@@ -112,6 +116,7 @@ def resolve(db: DBSession, chat: Chat, user: User | None) -> AgentContext | None
chat_id=chat.id,
label=profile.label,
project_dir=chat.project_dir or profile.default_dir or "",
profile_id=profile.id,
mode=chat.agent_mode if chat.agent_mode in policy.MODES else policy.MODE_MANUAL,
allow=tuple(values.get("allow_default") or ()),
deny=tuple(values.get("deny_default") or ()),
+8 -1
View File
@@ -21,7 +21,7 @@ import json
import logging
from typing import Any
from lembas.services.agent import policy
from lembas.services.agent import index, policy
from lembas.services.agent.base import ExecError, ExecRequest
from lembas.services.agent.session import AgentContext
from lembas.services.tools import (
@@ -198,6 +198,13 @@ async def _run_write(context: ToolContext, args: dict[str, Any]) -> ToolOutcome:
exc.message, _event("file_write", agent, path, status="error", error=exc.message)
)
# The tree just changed, and this process is what changed it. The listing's
# TTL is for drift nobody can see coming; leaving five more minutes of a
# listing known to be wrong makes a model conclude the file it has just
# written does not exist.
if agent.profile_id:
index.forget_dir(agent.profile_id, agent.project_dir)
return ToolOutcome(
f"Wrote {written} bytes to {path}.",
_event("file_write", agent, path, status="ok", text=f"{written} bytes"),
+7
View File
@@ -226,6 +226,11 @@ def build_messages(
message
) <= compaction_service.moment(cutoff):
continue
# Typed while the previous reply was still being written, and not yet
# handed to a model. It is in the transcript and it is not in the
# request; delivery is what moves it from one to the other.
if message.queued:
continue
# Skip turns that failed or produced nothing -- but a message carrying
# only an attachment has no text and must still be sent.
if message.error:
@@ -447,6 +452,7 @@ def create_message(
*,
complete_: bool = True,
model_id: str = "",
queued: bool = False,
) -> Message:
message = Message(
chat_id=chat.id,
@@ -454,6 +460,7 @@ def create_message(
content=content,
complete=complete_,
model_id=model_id,
queued=queued,
)
db.add(message)
db.commit()
+9 -2
View File
@@ -98,8 +98,12 @@ def split(
return [], list(messages)
boundary = moment(cutoff)
return (
[m for m in messages if moment(m) <= boundary],
[m for m in messages if moment(m) > boundary],
# A prompt still waiting to be sent stays on the live side whatever its
# timestamp says. Folding one into the "earlier messages" details would
# hide the only place its Send now and Discard exist, and it has not
# been part of any request to summarise.
[m for m in messages if not m.queued and moment(m) <= boundary],
[m for m in messages if m.queued or moment(m) > boundary],
)
@@ -133,6 +137,9 @@ def transcript(db: DBSession, chat: Chat, *, upto: Message) -> str:
Message.chat_id == chat.id,
Message.created_at <= upto.created_at,
Message.error == "",
# Not yet sent to anything. Summarising it would fold words the model
# has never seen into the record, and then deliver them again later.
Message.queued.is_(False),
)
if previous is not None:
query = query.where(Message.created_at > previous.created_at)
+43
View File
@@ -375,6 +375,43 @@ def store_text(
return attachment
def copy_attachment(
db: DBSession, *, user_id: str, chat_id: str | None, attachment: Attachment
) -> Attachment:
"""Duplicate something already sent, so it can ride along with a new message.
A copy and not a second reference to one row: an attachment belongs to the
message it was sent with, and sharing one between two would make deleting
either of them a question rather than an answer.
"""
stored_name = ""
source = attachments_dir() / attachment.stored_name if attachment.stored_name else None
if source is not None and source.exists():
stored_name = f"{secrets.token_hex(16)}{Path(source.name).suffix}"
(attachments_dir() / stored_name).write_bytes(source.read_bytes())
copy = Attachment(
user_id=user_id,
chat_id=chat_id,
filename=attachment.filename,
stored_name=stored_name,
media_type=attachment.media_type,
size_bytes=attachment.size_bytes,
kind=attachment.kind,
width=attachment.width,
height=attachment.height,
extracted_text=attachment.extracted_text,
pages=attachment.pages,
truncated=attachment.truncated,
extraction_error=attachment.extraction_error,
source_path=attachment.source_path,
source_label=attachment.source_label,
)
db.add(copy)
db.commit()
return copy
def copy_document(
db: DBSession, *, user_id: str, chat_id: str | None, document
) -> Attachment:
@@ -407,6 +444,12 @@ def copy_document(
pages=document.pages,
truncated=document.truncated,
extraction_error=document.extraction_error,
# Where it came from, for the same reason a project file carries it: a
# model handed four documents cannot tell which is which, and cannot
# name one back when asked to work on it. This was the one attach path
# that dropped provenance.
source_path=(document.title or "")[:1000],
source_label=(document.base.name if document.base else "Knowledge")[:200],
)
db.add(attachment)
db.commit()
+169 -1
View File
@@ -129,6 +129,13 @@ class Generation:
# the reply and is written onto the message, so the Execute button sends
# exactly what was proposed rather than something parsed back out of prose.
plan: dict | None = None
# The queue, seen from the reply's side. `drained` says this reply's ending
# handed the next waiting prompt to a fresh one; `injected_ids` names the
# prompts taken into *this* reply between two rounds of tool calls. Both are
# read only by `_follow`, which turns them into bubbles on the `done` frame
# -- the one frame that reaches a browser after a reply is over.
drained: bool = False
injected_ids: list[str] = field(default_factory=list)
def touch(self) -> None:
self.version += 1
@@ -187,6 +194,22 @@ def answer(
return False
def running_for(chat_id: str) -> Generation | None:
"""The reply being written in this chat, if there is one.
A linear scan for the reason `answer` gives above: one entry per reply in
flight, consulted at human speed. `_prune` first, because a finished
generation lingers `KEEP_FINISHED` so that late followers still get the
final frames -- and without the sweep those five minutes would look like a
chat that is permanently busy, and queue everything typed into it.
"""
_prune()
for generation in _RUNNING.values():
if generation.chat_id == chat_id and not generation.done:
return generation
return None
_VERDICTS = (interaction.ALLOW, interaction.ALLOW_ALWAYS, interaction.DENY)
@@ -328,6 +351,12 @@ async def _run(generation: Generation) -> None:
model = chat_service.model_for(db, chat)
generation.context_limit = model.context_length if model is not None else 0
# Kept for `_inject`, which builds a user turn after this session
# has closed. A turn taken in mid-reply has to be shaped exactly as
# the same words typed a moment later would have been -- images to a
# vision model, a plain string to anything else, or the endpoint
# rejects the whole request.
vision = chat_service.model_supports(db, chat, "vision")
generation.prompt_estimate = tokens.estimate_request(payload)
@@ -406,10 +435,17 @@ async def _run(generation: Generation) -> None:
if generation.stopped or not calls:
break
if round_number == tools_service.MAX_ROUNDS:
if round_number == budget:
# Out of rounds with the model still asking for tools. Recorded
# rather than silently dropped: an answer that stops here needs
# to be explicable.
#
# `budget`, not `MAX_ROUNDS`. The loop is sized by the budget on
# the line above and the message below has always reported it,
# but the comparison was against the global 3 -- so an agent
# chat allowed forty steps stopped after three and said it had
# taken forty. Two numbers, one of them wrong, in code whose
# whole job is to say what happened.
generation.tool_events.append(
{
"name": calls[0]["name"],
@@ -454,6 +490,22 @@ async def _run(generation: Generation) -> None:
generation.plan = outcome.event["plan"]
generation.touch()
# Something typed while this reply was working. Taken in here, at a
# round boundary, rather than made to wait for the whole reply: an
# agent that has just finished one loop and is about to start
# another is exactly when "actually, do it the other way" is worth
# having.
#
# Only while there is a round left to answer in. Injecting into the
# last one would deliver the prompt into a reply that then runs out
# of budget without addressing it -- and it is marked delivered, so
# nothing would ever send it again. Below that line it waits for
# `_drain`, which always gives it a reply of its own.
if round_number + 1 < budget and (
added := _inject(generation, generation.chat_id, vision)
):
messages.append(added)
payload = {**payload, "messages": messages}
# A plan ends the turn. One more request so the model can say what
@@ -525,6 +577,11 @@ async def _run(generation: Generation) -> None:
# the row. The other order left a window in which the finished frame
# showed the previous turn's stored values.
_persist(generation, title, time.monotonic() - started)
# After the row is authoritative and before `done`, for the same reason
# `_persist` is: `_follow` breaks the instant it sees that flag, and the
# frame it then sends is the one that has to carry the next turn's
# bubbles. There is no push channel that outlives a single reply.
_drain(generation)
generation.done = True
generation.finished_at = datetime.now(UTC)
generation.touch()
@@ -1006,6 +1063,117 @@ def _question_from(payload: dict) -> str:
return ""
def _next_waiting(db, chat_id: str) -> Message | None:
"""The oldest prompt in this chat that has not been sent."""
return db.scalars(
select(Message)
.where(
Message.chat_id == chat_id,
Message.role == ROLE_USER,
Message.queued.is_(True),
)
.order_by(Message.created_at)
.limit(1)
).first()
def _drain(generation: Generation) -> None:
"""Hand the next waiting prompt to a reply of its own, if there is one.
Exactly one, not all of them. Draining the lot would put two consecutive
user turns into the next request, which several local chat templates refuse
outright -- `build_messages` already goes to some trouble over that around
the compaction lead. "One after another" is also what was asked for: the
second waiting prompt is drained by the reply the first one starts, and so
on down the chain.
Three refusals, and none of them is a special case:
- **Superseded.** The same test `_persist` makes, for the same reason: a
regeneration cancels its predecessor and the predecessor's `finally:`
still runs. Without this, regenerating would drain the queue *and* leave
a third generation running.
- **Stopped.** Stop means stop, and the queue stays visible and
undelivered with Send now beside it. This is also what makes shutdown
safe -- cancellation sets `stopped`, so a restart never fires off a reply
with nobody watching.
- **Errored.** The endpoint has just failed. Feeding the next prompt into it
produces a second failure and spends somebody's words to do it.
"""
owner = _RUNNING.get(generation.message_id)
if owner is not None and owner is not generation:
return
if generation.stopped or generation.error:
return
try:
with session_scope() as db:
chat = db.get(Chat, generation.chat_id)
if chat is None:
return
waiting = _next_waiting(db, chat.id)
if waiting is None:
return
waiting.queued = False
assistant = chat_service.create_message(
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
)
chat_id, assistant_id = chat.id, assistant.id
except Exception: # noqa: BLE001 - the reply is over either way
log.exception("could not drain the queue for chat %s", generation.chat_id)
return
# Outside the session: this starts a task, and a task is not something to
# hold a database session open across.
ensure(chat_id, assistant_id)
generation.drained = True
def _inject(generation: Generation, chat_id: str, vision: bool) -> dict | None:
"""Take the oldest waiting prompt into this reply, between two rounds.
Marked delivered and committed *before* the request goes out, so this is
at-most-once. A crash in between loses the turn, which is recoverable --
the words are still in the transcript with Send now beside them. The other
way round would ask the same question twice and let an agent act on it
twice, which is not.
Sent verbatim, in the user role, with no framing. Everything else this
codebase injects is quoted and attributed because it came out of a file, a
page or a machine; this one genuinely *is* the person at the keyboard,
authenticated by the session cookie and stored as a `Message` whose role
says so. Wrapping it would teach a model that a user turn can be a
quotation, which is the exact distinction the other two rely on. What the
model needs -- that this can happen at all -- is one sentence in the
harness, where authored wording lives.
"""
try:
with session_scope() as db:
waiting = _next_waiting(db, chat_id)
if waiting is None:
return None
waiting.queued = False
entry = chat_service.message_payload(waiting, vision=vision)
# The reply that answers it must sort *before* it, or the next
# turn's transcript reads "answer, then the question it answered"
# and a small model dutifully answers again. Moving the placeholder
# rather than the prompt keeps several interjections in the order
# they were typed.
placeholder = db.get(Message, generation.message_id)
if placeholder is not None:
placeholder.created_at = datetime.now(UTC)
generation.injected_ids.append(waiting.id)
except Exception: # noqa: BLE001 - a lost interjection is not a failed reply
log.exception("could not take a queued prompt into chat %s", chat_id)
return None
generation.status = "Taking in what you just added…"
generation.touch()
return entry
def _persist(generation: Generation, title: str, elapsed: float) -> None:
"""Write the finished reply, name the chat, and set the unread flag.
+16
View File
@@ -587,6 +587,22 @@ BUILTIN: tuple[Fragment, ...] = (
"within that budget: two careful searches beat six that run out halfway."
),
),
Fragment(
key="core.interjection",
label="Being interrupted",
group=GROUP_CORE,
order=115,
when_tools=True,
hint="A message typed while you are working is handed to you between two "
"rounds of tool calls. Without this a model reads it as a fresh "
"conversation and starts the whole task again.",
default=(
"A new message from the person you are working for can arrive between "
"rounds of tool calls, while you are still working. Take it into account "
"from that point on. You do not need to start again or to re-explain what "
"you have already done — carry on, adjusted."
),
),
Fragment(
key="core.no_replay",
label="Results are not kept",
+42 -8
View File
@@ -576,6 +576,24 @@
.msg:focus-within .msg__actions { opacity: 1; }
.msg__actions .is-copied { color: var(--success); }
/* --- A turn that is waiting to be sent ------------------------------------
Its actions do not fade in on hover like the others: they are the only way
to withdraw something that has not happened yet, and a control you have to
find by hovering is one somebody will not find. */
.msg--queued { opacity: 0.75; }
.msg--queued .msg__body {
border-inline-start: 2px dashed var(--border-strong);
padding-inline-start: var(--sp-2);
}
.msg__actions--queued { opacity: 1; align-items: center; }
.msg__note {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
color: var(--ink-muted);
font-size: var(--text-xs);
}
/* --- Rendered Markdown ---------------------------------------------------- */
.msg__body > :first-child { margin-top: 0; }
.msg__body > :last-child { margin-bottom: 0; }
@@ -761,16 +779,32 @@
.composer__mirror .tok-mention,
.composer__mirror .tok-command {
border-radius: var(--radius-sm);
/* Bled sideways so the rectangle does not sit hard against the next word,
and the negative margin keeps the text metrics identical. */
padding: 0 2px;
margin: 0 -2px;
/* Restated rather than inherited. `color: transparent` on the mirror is an
inherited value, and a colour the span declares itself beats it -- which is
exactly what the transcript's rule below used to do from across the file,
painting the token in accent-coloured mono at 0.95em on top of the
textarea's own text. Doubled, and shifted from there on, because the
metrics differ. The font must be restated for the same reason. */
color: transparent;
font: inherit;
/* Bled sideways by a shadow, not by padding: a rectangle that spreads cannot
move a glyph, and the negative margin that used to do this was the only
thing in the mirror that could. */
padding: 0;
margin: 0;
}
.composer__mirror .tok-mention {
background: var(--accent-soft);
box-shadow: 0 0 0 2px var(--accent-soft);
}
.composer__mirror .tok-command {
background: var(--leaf-soft);
box-shadow: 0 0 0 2px var(--leaf-soft);
}
.composer__mirror .tok-mention { background: var(--accent-soft); }
.composer__mirror .tok-command { background: var(--leaf-soft); }
/* The same two in a sent message, where they are text rather than a backdrop. */
.tok-mention {
/* The same two in a sent message, where they are text rather than a backdrop --
and scoped to it, because unscoped they also matched the mirror's spans. */
.msg .tok-mention {
border-radius: var(--radius-sm);
padding: 0 2px;
background: var(--accent-soft);
+42 -2
View File
@@ -75,7 +75,11 @@
name: "effort",
summary: "How hard a reasoning model should think",
argument: "low | medium | high",
when: function () { return !!chat() && !!el("[data-effort]"); },
/* Offered wherever there is a model, not only where the control is.
`available()` filters `find()` and `run()` as well as the menu, so a
command hidden here is not merely unlisted -- typing it in full stops
being a command and gets sent as a message. Better to answer. */
when: function () { return !!el('[name="model_id"]'); },
run: function (rest) { setEffort(rest); }
},
{
@@ -115,6 +119,12 @@
});
}
},
{
name: "index",
summary: "Read the project directory again",
when: function () { return !!chat() && isAgent(); },
run: function () { reindex(); }
},
{
name: "terminal",
summary: "Show or hide the terminal",
@@ -163,6 +173,32 @@
return function () { window.location = url; };
}
/* --- Reading the project directory again --------------------------------
The listing is cached for five minutes and only ever built when a reply
starts, so anything done in the terminal panel -- a checkout, a build --
is invisible to it until then. This is the "look again now". */
var indexing = false;
function reindex() {
if (indexing) return note("Already reading the project directory.");
indexing = true;
note("Reading the project directory…");
post("/api/chats/" + chat() + "/index")
.then(function (response) {
return response.json().then(function (body) {
return { ok: response.ok, body: body };
});
})
.then(function (result) {
if (!result.ok) {
return note(result.body.detail || "Could not read the project directory.", "error");
}
note(result.body.message);
})
.catch(function () { note("Could not read the project directory.", "error"); })
.finally(function () { indexing = false; });
}
/* --- Reasoning effort ---------------------------------------------------
The command drives the same select the composer shows, so there is one
piece of state and the control updates itself when the command is used. */
@@ -171,7 +207,11 @@
function setEffort(rest) {
var select = el("[data-effort]");
if (!select) {
return note("This model is not marked as a reasoning model.", "error");
return note(
"This model is not marked as a reasoning model, so effort would do " +
"nothing. An administrator can mark it on the model's page.",
"error"
);
}
var wanted = (rest || "").trim().toLowerCase();
if (!wanted) {
+18
View File
@@ -273,6 +273,24 @@
} else if (option.dataset.mentionKnowledge) {
body.append("document_id", option.dataset.mentionKnowledge);
attach("/api/files/from-knowledge", body);
} else if (option.dataset.mentionNote) {
body.append("note_id", option.dataset.mentionNote);
attach("/api/files/from-note", body);
} else if (option.dataset.mentionSkill) {
body.append("skill_id", option.dataset.mentionSkill);
attach("/api/files/from-skill", body);
} else if (option.dataset.mentionAttachment) {
body.append("attachment_id", option.dataset.mentionAttachment);
attach("/api/files/from-attachment", body);
} else if (option.dataset.mentionUrl) {
body.append("url", option.dataset.mentionUrl);
attach("/api/files/link", body);
} else if (option.dataset.mentionBase) {
/* Not an attachment: nothing is copied, and what changes is what this
chat is allowed to search. It goes on the chat, so the route is the
chat's own. */
body.append("base_id", option.dataset.mentionBase);
attach("/api/chats/" + where.chatId + "/bases", body);
}
}
+44 -19
View File
@@ -35,7 +35,11 @@
/* Whether this shell tells us where commands begin and end -- "live",
"loading" or "none". Everything the three buttons do keys off it. */
var integration = "loading";
var autoSend = false;
/* "off" | "copy" | "send". Three states rather than a boolean, because the
old one did the wrong one of them: it appended into the composer, on top of
whatever was being typed there. A select rather than a cycling button --
a button cannot say which of three states it is in. */
var autoMode = "off";
var lastCommand = null;
function say(text, isError) {
@@ -169,8 +173,12 @@
and the buttons fetch what they need when they are pressed. */
if (integration !== "live") { integration = "live"; applyIntegration(); }
showLast(payload.command);
if (autoSend) {
capture(true).then(function (text) { intoComposer(text, true); });
if (autoMode !== "off") {
capture(true).then(function (text) {
if (!text) return;
if (autoMode === "copy") return intoComposer(text, true);
sendStraightToChat(text);
});
}
return;
}
@@ -287,10 +295,10 @@
var usable = integration === "live";
auto.disabled = !usable;
auto.title = usable
? "Attach every command you run to your next message"
? "What to do with each command you run"
: "This shell did not load LLeMbas's command markers, so there is no way " +
"to tell where one command's output ends.";
if (!usable && autoSend) setAuto(false);
if (!usable && autoMode !== "off") setAuto("off");
}
function showLast(command) {
@@ -301,16 +309,32 @@
slot.textContent = lastCommand ? lastCommand.summary : "";
}
function setAuto(on) {
autoSend = !!on;
var button = panel.querySelector("[data-terminal-auto]");
if (button) {
button.setAttribute("aria-pressed", autoSend ? "true" : "false");
button.classList.toggle("is-active", autoSend);
}
say(autoSend
? "Every command you run will be attached to your next message."
: "Commands are no longer attached automatically.");
var AUTO_SAID = {
off: "Commands are no longer attached automatically.",
copy: "Every command you run will be put into the message box.",
send: "Every command you run will be sent as a message on its own."
};
function setAuto(mode) {
autoMode = AUTO_SAID[mode] ? mode : "off";
var select = panel && panel.querySelector("[data-terminal-auto]");
if (select && select.value !== autoMode) select.value = autoMode;
say(AUTO_SAID[autoMode]);
}
/* Sent, not typed. The composer is left entirely alone -- somebody may be
half-way through a sentence in it, and overwriting that is the complaint
this replaces. The thread receives whatever the server decides the message
is: a streaming pair, or a single queued bubble if a reply is already being
written. Nothing here needs to know which. */
function sendStraightToChat(text) {
var url = panel.dataset.url.replace(/\/terminal\/ws$/, "/messages");
if (!window.htmx) return;
window.htmx.ajax("POST", url, {
target: "#thread",
swap: "beforeend",
values: { content: text }
});
}
/* A selection always wins, in every state. People rely on it, and it is the
@@ -427,10 +451,11 @@
event.preventDefault();
return copyToClipboard();
}
if (event.target.closest("[data-terminal-auto]")) {
event.preventDefault();
return setAuto(!autoSend);
}
});
panel.addEventListener("change", function (event) {
var select = event.target.closest("[data-terminal-auto]");
if (select) setAuto(select.value);
});
/* xterm holds colours as values, not as variables, so a theme change has
@@ -0,0 +1,19 @@
{% from "_macros.html" import icon %}
{#
A knowledge base attached from the `@` menu.
Deliberately not an attachment chip: nothing was copied and there is no
`file_ids` input to submit. The base is already on the chat, and what it does
is narrow what `knowledge_search` may see -- so this says so, and says it in
the present tense, because it is already in force before the message is sent.
No remove button. Removing one is a checkbox in the chat's settings, where
the whole set is visible at once rather than only whichever was added last.
#}
<div class="attach-chip attach-chip--base" id="base-chip-{{ base.id }}">
<span class="attach-chip__icon">{{ icon("archive", "icon--sm") }}</span>
<span class="attach-chip__body">
<span class="attach-chip__name" title="{{ base.name }}">{{ base.name }}</span>
<span class="attach-chip__meta">This chat now searches only the bases it is attached to</span>
</span>
</div>
+37 -16
View File
@@ -215,10 +215,16 @@
they were taking a slot in a row that has work to do.
Its own form: nesting one inside the composer's form is invalid HTML
and the browser drops the inner one. #}
and the browser drops the inner one.
The verb is on the select, not on that form. htmx binds a trigger to
the annotated element itself, and `change` fires here and bubbles
through this element's *ancestors* -- which a sibling form is not.
`form=` scopes the values, and only the values. #}
<div class="composer__context">
<select class="select select--sm" name="agent_mode" aria-label="Approval mode"
form="agent-mode-form">
form="agent-mode-form"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none">
{% for value, label, hint in agent_modes %}
<option value="{{ value }}" title="{{ hint }}"
{{ 'selected' if value == chat.agent_mode }}>{{ label }}</option>
@@ -234,17 +240,27 @@
Only on a model an administrator has marked as **reasoning**: that
flag has existed since the beginning with no reader at all, and
offering the control everywhere would be offering a setting that does
nothing almost everywhere. Its own form, for the reason the mode has
one -- a form cannot nest inside another.
nothing almost everywhere.
On an existing chat it writes on change, and needs the same treatment
the mode select gets: the verb on the control, `form=` for the values.
Before there is a chat there is nothing to PATCH, so it is an ordinary
field of the composer's own form and `_new_chat` reads it -- which is
what makes the setting choosable before the first prompt rather than
after it.
#}
{% if chat and current_model and current_model.capabilities_json.get("reasoning") %}
{% if current_model and current_model.capabilities_json.get("reasoning") %}
<select class="select select--sm" name="reasoning_effort" data-effort
aria-label="Reasoning effort" title="How hard this model should think"
form="chat-params-form">
{% if chat %}
form="chat-params-form"
hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
{% endif %}>
{% set chosen = chat.params_json.get('reasoning_effort') if chat
else (current_model.params_json or {}).get('reasoning_effort') %}
<option value="">Effort: default</option>
{% for value in efforts %}
<option value="{{ value }}"
{{ 'selected' if chat.params_json.get('reasoning_effort') == value }}>
<option value="{{ value }}" {{ 'selected' if chosen == value }}>
Effort: {{ value }}
</option>
{% endfor %}
@@ -286,17 +302,22 @@
</div>
</form>
{# Outside the composer's form, and referenced by the mode select's `form`
attribute above. hx-patch and not hx-post: there is no POST for a chat,
only PATCH, and htmx shows nothing when a request 405s -- which is how
this control spent its whole life doing nothing. #}
{# Outside the composer's form, and referenced by the two selects' `form`
attributes above. These carry no htmx of their own: they exist so that
`Nt(e)` -- htmx's "which form do the values come from", which reads
`e.form` before falling back to `closest("form")` -- resolves to a form
holding exactly one control. Without them the PATCH would carry the
composer's `content`, `model_id` and `project_dir`, and `update_chat`
answers `project_dir` with a 409.
hx-patch and not hx-post: there is no POST for a chat, only PATCH, and
htmx shows nothing when a request 405s -- which is how these controls
spent the first half of their lives doing nothing. #}
{% if chat and chat.kind == "agent" %}
<form id="agent-mode-form" hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
hx-trigger="change"></form>
<form id="agent-mode-form"></form>
{% endif %}
{% if chat %}
<form id="chat-params-form" hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
hx-trigger="change"></form>
<form id="chat-params-form"></form>
{% endif %}
<p class="composer__hint">
@@ -11,7 +11,8 @@
all of it is escaped by autoescaping and none of it is marked safe.
#}
<div id="mention-results">
{% if not files and not documents %}
{% if not files and not documents and not notes and not skills
and not bases and not attachments and not website %}
<p class="muted text-sm" style="padding: var(--sp-3)">
{% if q %}
Nothing matches “{{ q }}”.
@@ -21,6 +22,22 @@
</p>
{% else %}
{% if website %}
<p class="picker__group">A page to read</p>
<ul class="picker__list">
<li>
<button class="picker__option" type="button"
data-mention-url="{{ website }}" data-mention-token="{{ website }}">
{{ icon("link", "icon--sm") }}
<span class="picker__option-body">
<span class="picker__option-name">Fetch this page</span>
<span class="picker__option-note mono">{{ website }}</span>
</span>
</button>
</li>
</ul>
{% endif %}
{% if files %}
<p class="picker__group">In the project</p>
<ul class="picker__list">
@@ -61,5 +78,82 @@
</ul>
{% endif %}
{% if notes %}
<p class="picker__group">Notes</p>
<ul class="picker__list">
{% for note in notes %}
<li>
<button class="picker__option" type="button"
data-mention-note="{{ note.id }}" data-mention-token="{{ note.title }}">
{{ icon("file-text", "icon--sm") }}
<span class="picker__option-body">
<span class="picker__option-name">{{ note.title }}</span>
<span class="picker__option-note">
{{ note.body | truncate(70) }}{% if note.owner_id != user.id %} · shared{% endif %}
</span>
</span>
</button>
</li>
{% endfor %}
</ul>
{% endif %}
{% if skills %}
<p class="picker__group">Skills</p>
<ul class="picker__list">
{% for skill in skills %}
<li>
<button class="picker__option" type="button"
data-mention-skill="{{ skill.id }}" data-mention-token="{{ skill.name }}">
{{ icon("sparkle", "icon--sm") }}
<span class="picker__option-body">
<span class="picker__option-name mono">{{ skill.name }}</span>
<span class="picker__option-note">{{ skill.description }}</span>
</span>
</button>
</li>
{% endfor %}
</ul>
{% endif %}
{% if bases %}
{# A base is a reference and not a copy: choosing one narrows what this chat
may search rather than putting anything into the message. #}
<p class="picker__group">Search only these</p>
<ul class="picker__list">
{% for base in bases %}
<li>
<button class="picker__option" type="button"
data-mention-base="{{ base.id }}" data-mention-token="{{ base.name }}">
{{ icon("archive", "icon--sm") }}
<span class="picker__option-body">
<span class="picker__option-name">{{ base.name }}</span>
<span class="picker__option-note">Scope this chat to this knowledge base</span>
</span>
</button>
</li>
{% endfor %}
</ul>
{% endif %}
{% if attachments %}
<p class="picker__group">Already in this chat</p>
<ul class="picker__list">
{% for attachment in attachments %}
<li>
<button class="picker__option" type="button"
data-mention-attachment="{{ attachment.id }}"
data-mention-token="{{ attachment.filename }}">
{{ icon("attach", "icon--sm") }}
<span class="picker__option-body">
<span class="picker__option-name">{{ attachment.filename }}</span>
<span class="picker__option-note">{{ attachment.human_size }}</span>
</span>
</button>
</li>
{% endfor %}
</ul>
{% endif %}
{% endif %}
</div>
+27 -2
View File
@@ -13,8 +13,16 @@
escaped plain text for everyone else.
#}
{% set streaming = (message.role == "assistant" and not message.complete) %}
{#
Typed while the previous reply was still being written, and not yet handed to
a model. It is in the transcript and it is not in the request. It must never
carry `sse-connect` -- a queued turn with a streaming shell on it would be the
second concurrent reply the queue exists to prevent.
#}
{% set queued = (message.role == "user" and message.queued) %}
<article class="msg msg--{{ message.role }}" id="msg-{{ message.id }}"
<article class="msg msg--{{ message.role }}{{ ' msg--queued' if queued }}"
id="msg-{{ message.id }}"
{% if streaming %}
hx-ext="sse"
sse-connect="/api/chats/{{ chat.id }}/messages/{{ message.id }}/stream"
@@ -210,7 +218,24 @@
</div>
{% endif %}
{% if not streaming %}
{% if queued %}
{# Nothing has been sent to any model. Both buttons sit on the bubble they
act on rather than in a toast somewhere, and Edit is deliberately absent:
editing rewinds and then starts a reply, which on a row you can press
while another reply is streaming is a second concurrent generation behind
a pencil. Discard and retype is the honest affordance. #}
<footer class="msg__actions msg__actions--queued">
<span class="msg__note">{{ icon("clock", "icon--sm") }} Waiting to be sent</span>
<button class="btn btn--sm" type="button"
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/send-now"
hx-target="#thread" hx-swap="innerHTML">Send now</button>
<button class="btn btn--sm" type="button"
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/discard"
hx-target="#msg-{{ message.id }}" hx-swap="outerHTML"
data-confirm-button="Discard this message?">Discard</button>
</footer>
<div hidden id="msg-body-{{ message.id }}">{{ message.content }}</div>
{% elif 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">
+15 -6
View File
@@ -50,12 +50,21 @@
aria-label="Send to chat">
{{ icon("arrow-up", "icon--sm") }}
</button>
<button class="btn btn--icon btn--sm" type="button" data-terminal-auto
aria-pressed="false"
title="Attach every command you run to your next message"
aria-label="Send every command automatically">
{{ icon("sparkle", "icon--sm") }}
</button>
{#
Three states, not two. A cycling icon button cannot say which of three it
is in, and this one decides whether things are sent to a model without
being asked again -- so it says so in words.
Not remembered between page loads on purpose: a switch that forwards every
command you run to a model is not something to inherit from last week.
#}
<select class="select select--sm" data-terminal-auto
aria-label="What to do when a command finishes"
title="What to do with each command you run">
<option value="off" selected>Auto: off</option>
<option value="copy">Auto: copy</option>
<option value="send">Auto: send</option>
</select>
<button class="btn btn--icon btn--sm" type="button" data-toggle="#terminal"
aria-label="Close terminal">
{{ icon("x", "icon--sm") }}