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:
+152
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user