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:
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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 ()),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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") }}
|
||||
|
||||
Reference in New Issue
Block a user