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