diff --git a/CLAUDE.md b/CLAUDE.md index 76bbb24..4f527cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,7 +20,7 @@ lembas info # paths + counts, useful when confused lembas secret-key # generate LEMBAS_SECRET_KEY lembas create-admin # create or promote an admin -pytest # 1009 tests, ~58s +pytest # 1051 tests, ~60s # PLAN.md tracks what is and is not built ruff check . # lint (line length 100) python scripts/build_artwork.py # regenerate artwork (SVG + PWA icons; @@ -392,6 +392,22 @@ than appear as an empty heading. Anything else wanting the listing gets the same deal: the `@` picker offers no files until one exists, because a keystroke must never wait on a machine. +**And it only ever goes stale in one direction.** `_warm_index` returns early +whenever anything is cached, so within the 300s TTL a reply never re-walks; +after it lapses, the next reply rebuilds. What that misses is the tree changing +underneath — so `file_write` calls `index.forget_dir` for the directory it just +wrote into (the one place the cache is *known* wrong, and a model reading a +stale listing concludes the file it created does not exist), and `/index` → +`POST /api/chats/{id}/index` is the "look again now" for everything else, +notably anything done by hand in the terminal panel. Read-only, so it is outside +`agent/policy.py` for the reason the directory browser is. + +**The ladder falls through on failure, not just on absence.** `_from_git` and +`_from_find` raising `ExecError` — an SFTP-only account, a forced command, a +shell of `/bin/false` — used to escape the loop and be caught outside it, +returning an empty listing without ever trying the SFTP rung that exists for +exactly that host. Each rung catches its own now. + **A listing is budgeted, not dumped.** A tree of a thousand files costs the window on every request forever and buries the four names that mattered. `index.render` collapses what will not fit to `src/vendor/ (412 files)` and says @@ -448,6 +464,34 @@ selects in the composer — the agent mode and the effort — belong to empty `form="…"`. A form cannot nest inside another; the browser silently drops the inner one, and the control then posts nothing at all. +**`form="…"` scopes the values; it does not route the event.** That is half of +the paragraph above, and taking it for the whole cost both those selects an +entire release in which they wrote nothing. htmx binds a trigger listener to the +annotated element itself unless `from:` says otherwise — +`if(c.from){t=m(l,c.from)}else{t=[l]}` — and `change` fires on the select and +bubbles through its **DOM ancestors**, which a sibling form is not. So the verb +goes **on the control**; the empty form stays, earning its keep as the answer to +htmx's "whose values are these", which is `function Nt(e){return e.form||g(e,"form")}` +— `e.form` first, so a form-associated control resolves to the empty form and +the PATCH carries that one field. Without it, `closest("form")` finds the +composer and the request carries `project_dir`, which `update_chat` answers with +a 409. `tests/conftest.py:control_named` exists to pin this: the element +carrying the `name` must be the element carrying the verb. The three failures in +this feature — `hx-post` at a PATCH-only route, a menu built after it was +written to, a trigger bound where the event does not go — were all *silent*, and +all three had passing tests that asserted the markup rather than the property. + +**A rule written for one context matches every context.** `.tok-mention` styles +a mention in the transcript: accent colour, monospace, 0.95em. Nothing scoped it +there, so it also hit the composer mirror's spans — and `.composer__mirror`'s +`color: transparent` is *inherited*, which loses to a colour the span declares +itself. The mirror painted its token visibly, in a different font, over the +textarea's own text: doubled, and shifted from that point on because the metrics +differ. Transcript token styles are `.msg .tok-*`; the mirror's restate +`color: transparent` and `font: inherit` rather than relying on inheritance, and +bleed with `box-shadow` rather than padding and a negative margin, because a +shadow cannot move a glyph. + **Unused columns are worse than missing ones.** `Model.params_json` documented itself as "default sampling params applied to new chats using this model" and was applied nowhere for its entire existence, which is how a per-model default @@ -463,6 +507,17 @@ where it came from into `chat.document_context`'s tag, because a model handed asked to change something. Those two are attribute values in a tag we write, so `_attr` strips quotes and angle brackets rather than escaping them. +**`@` offers everything a chat can reach, and a knowledge base is the exception +that proves the rule.** Project files, documents, notes, skills, this chat's +earlier attachments and a URL all resolve to *an attachment*, copied — a +transcript must not change because somebody edited a note afterwards, the same +rule as PDF extraction. A **base** is a reference instead: `POST +/api/chats/{id}/bases` puts it on `Chat.knowledge_bases`, which already narrows +`knowledge_search`, and the harness already names the attached bases. Copying a +folder of contracts into the window would cost the context on every request +forever to answer one question. It therefore needs an existing chat, so it is +absent on the new-chat screen — the same reason project files are. + **Unread is polled, not pushed.** A browser on another chat has no connection to the one that finished. `/api/chats/unread` returns out-of-band dot spans and an `HX-Trigger` for the toast; `unread_notified` stops the same arrival being @@ -526,6 +581,43 @@ the attribute to win. Anything toggled with `hidden` depends on that line. `ui.js` flips `data-composer-action` plus `type` (`submit` ↔ `button`) when a message in the thread is still streaming. Do not add a second button back. +**A second message while a reply streams is queued, not sent.** It used to be +accepted outright: a second assistant placeholder, a second concurrent +`Generation` answering the same chat from a different prefix of it, and +`ui.js`'s first-match `querySelector(".msg[sse-connect]")` pointing Stop at +whichever bubble came first in the document. A queued turn is a real `Message` +with `queued` set — so it survives a restart, is in the transcript the moment it +is typed, and can be withdrawn before it is ever sent. `build_messages` skips +it. It must never carry `sse-connect`; the streaming shell is still the only +thing that starts a generation, so one on a queued bubble is that second +generation again. + +**Delivery is two places, and neither is a special case.** `_drain` runs in +`_run`'s `finally:` between `_persist` and `done` — after the row is +authoritative, before the flag `_follow` breaks on, because the `done` frame is +the last thing that reaches a browser and has to carry the next turn's bubbles +out of band. It takes **one** waiting prompt, not all of them: draining the lot +puts two consecutive user turns in the next request. `_inject` takes one *into* +a reply at a tool-round boundary, which is the point of queueing in an agent +chat — steering work already under way — and restamps the assistant +placeholder's `created_at` so the reply still sorts before the prompt it +answered. It refuses on the last round: a prompt delivered into a reply that +then runs out of budget is marked delivered and never sent again. + +**Stop leaves the queue alone**, deliberately, and `_drain` refuses on stopped, +errored and superseded. Stopped is the reader's decision; errored would feed the +next prompt into an endpoint that has just failed; superseded is the same test +`_persist` makes, without which regenerating drains the queue as a side effect. +Cancellation sets `stopped`, so a restart never fires off a reply with nobody +watching. + +**An interjection is sent verbatim, in the user role.** 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. Wrapping it +would teach a model that a user turn can be a quotation, which is the exact +distinction `execute_plan` and `Capture.as_text` rely on. What the model needs — +that this can happen at all — is the `core.interjection` harness fragment. + **The tool loop is inside one generation.** `services/generation.py:_run()` runs up to `tools_service.MAX_ROUNDS` request rounds for a single reply: stream, accumulate tool calls, run them, append the results, ask again. `Generation` @@ -838,6 +930,16 @@ therefore no markers — at which point Copy and Send fall back to scraping the screen and say so, and the automatic toggle is **disabled rather than degraded**. Forty arbitrary lines attached to every message is worse than nothing attached. +**The automatic toggle has three states, and a select to say which.** Off, copy, +send. It was a boolean doing the wrong one of them: it appended into the +composer, on top of whatever was being typed there. `send` posts straight to +`/api/chats/{id}/messages` and never touches the composer — which is what makes +the queue load-bearing, since commands finish while a reply is running. Not +persisted between page loads, deliberately: a switch that forwards everything +you type in a shell to a model is not something to inherit from last week's +session. A cycling icon button was the obvious shape and cannot say which of +three states it is in. + **The nginx vhost must pass upgrades through.** `deploy/nginx-vhost.conf` used to set `Connection ""`, which is right for SSE and fails every WebSocket handshake — and a failed handshake tells the browser nothing: no status, no diff --git a/pyproject.toml b/pyproject.toml index 25f7d14..76dd452 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "lembas" -version = "0.6.1" +version = "0.6.2" description = "LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints" readme = "README.md" requires-python = ">=3.11" diff --git a/src/lembas/__init__.py b/src/lembas/__init__.py index d5e23b9..c87f999 100644 --- a/src/lembas/__init__.py +++ b/src/lembas/__init__.py @@ -1,3 +1,3 @@ """LLeMbas - a Middle-earth themed web UI for OpenAI-compatible LLM endpoints.""" -__version__ = "0.6.1" +__version__ = "0.6.2" diff --git a/src/lembas/api/chats.py b/src/lembas/api/chats.py index 29f38ba..c5d89b2 100644 --- a/src/lembas/api/chats.py +++ b/src/lembas/api/chats.py @@ -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'
') + + 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'
' + + _render_bubble(db, chat, owner, delivered) + + "
" + ) + out_of_band.append( + '
' + + _render_bubble(db, chat, owner, assistant) + + "
" + ) + + 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, diff --git a/src/lembas/api/files.py b/src/lembas/api/files.py index e06bc19..ee7ec4f 100644 --- a/src/lembas/api/files.py +++ b/src/lembas/api/files.py @@ -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, diff --git a/src/lembas/db/models/chat.py b/src/lembas/db/models/chat.py index a44c765..9a090b6 100644 --- a/src/lembas/db/models/chat.py +++ b/src/lembas/db/models/chat.py @@ -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 diff --git a/src/lembas/services/agent/index.py b/src/lembas/services/agent/index.py index 8287322..d5a8b44 100644 --- a/src/lembas/services/agent/index.py +++ b/src/lembas/services/agent/index.py @@ -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", ] diff --git a/src/lembas/services/agent/session.py b/src/lembas/services/agent/session.py index 20ebb04..be7fee4 100644 --- a/src/lembas/services/agent/session.py +++ b/src/lembas/services/agent/session.py @@ -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 ()), diff --git a/src/lembas/services/agent/tools.py b/src/lembas/services/agent/tools.py index 28b15b0..482f835 100644 --- a/src/lembas/services/agent/tools.py +++ b/src/lembas/services/agent/tools.py @@ -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"), diff --git a/src/lembas/services/chat.py b/src/lembas/services/chat.py index e2f2bd6..69c81d9 100644 --- a/src/lembas/services/chat.py +++ b/src/lembas/services/chat.py @@ -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() diff --git a/src/lembas/services/compaction.py b/src/lembas/services/compaction.py index a967ba9..f7ea707 100644 --- a/src/lembas/services/compaction.py +++ b/src/lembas/services/compaction.py @@ -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) diff --git a/src/lembas/services/files.py b/src/lembas/services/files.py index 8850117..6ada44b 100644 --- a/src/lembas/services/files.py +++ b/src/lembas/services/files.py @@ -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() diff --git a/src/lembas/services/generation.py b/src/lembas/services/generation.py index fdf18bc..2d18928 100644 --- a/src/lembas/services/generation.py +++ b/src/lembas/services/generation.py @@ -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. diff --git a/src/lembas/services/prompts.py b/src/lembas/services/prompts.py index 13cf361..cd28c3a 100644 --- a/src/lembas/services/prompts.py +++ b/src/lembas/services/prompts.py @@ -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", diff --git a/src/lembas/web/static/css/chat.css b/src/lembas/web/static/css/chat.css index 812dc36..717a061 100644 --- a/src/lembas/web/static/css/chat.css +++ b/src/lembas/web/static/css/chat.css @@ -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); diff --git a/src/lembas/web/static/js/commands.js b/src/lembas/web/static/js/commands.js index f8e44e1..c50b6b3 100644 --- a/src/lembas/web/static/js/commands.js +++ b/src/lembas/web/static/js/commands.js @@ -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) { diff --git a/src/lembas/web/static/js/composer.js b/src/lembas/web/static/js/composer.js index 6e84669..f68c771 100644 --- a/src/lembas/web/static/js/composer.js +++ b/src/lembas/web/static/js/composer.js @@ -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); } } diff --git a/src/lembas/web/static/js/terminal.js b/src/lembas/web/static/js/terminal.js index d48ed7b..1105dc2 100644 --- a/src/lembas/web/static/js/terminal.js +++ b/src/lembas/web/static/js/terminal.js @@ -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 diff --git a/src/lembas/web/templates/chat/_base_chip.html b/src/lembas/web/templates/chat/_base_chip.html new file mode 100644 index 0000000..b0f8747 --- /dev/null +++ b/src/lembas/web/templates/chat/_base_chip.html @@ -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. +#} +
+ {{ icon("archive", "icon--sm") }} + + {{ base.name }} + This chat now searches only the bases it is attached to + +
diff --git a/src/lembas/web/templates/chat/_composer.html b/src/lembas/web/templates/chat/_composer.html index 5e5f52e..60d7ea2 100644 --- a/src/lembas/web/templates/chat/_composer.html +++ b/src/lembas/web/templates/chat/_composer.html @@ -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. #}
+ {% 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') %} {% for value in efforts %} - {% endfor %} @@ -286,17 +302,22 @@
- {# 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" %} -
+
{% endif %} {% if chat %} -
+
{% endif %}

diff --git a/src/lembas/web/templates/chat/_mention_picker.html b/src/lembas/web/templates/chat/_mention_picker.html index 7a9d7d4..5d0424d 100644 --- a/src/lembas/web/templates/chat/_mention_picker.html +++ b/src/lembas/web/templates/chat/_mention_picker.html @@ -11,7 +11,8 @@ all of it is escaped by autoescaping and none of it is marked safe. #}

- {% 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 %}

{% if q %} Nothing matches “{{ q }}”. @@ -21,6 +22,22 @@

{% else %} + {% if website %} +

A page to read

+ + {% endif %} + {% if files %}

In the project

{% endif %} + {% if notes %} +

Notes

+ + {% endif %} + + {% if skills %} +

Skills

+ + {% 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. #} +

Search only these

+ + {% endif %} + + {% if attachments %} +

Already in this chat

+ + {% endif %} + {% endif %}
diff --git a/src/lembas/web/templates/chat/_message.html b/src/lembas/web/templates/chat/_message.html index 6f68c3c..2bf2227 100644 --- a/src/lembas/web/templates/chat/_message.html +++ b/src/lembas/web/templates/chat/_message.html @@ -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) %} -
{% 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. #} + + + {% elif not streaming %}