a7e59a00f8
The mode select in the topbar posted with hx-post against a route that only answers PATCH, so every change returned 405 and the mode never moved. htmx shows nothing when a request fails, so the control looked like it worked: the select stayed where you put it and the server ignored you. It has never worked. Two more of the same kind. A mode could not be chosen at all until the chat existed, so reaching Plan meant sending something in Manual first and letting the model answer under the wrong rules. And the project directory box was real and submitted, but unlabelled and squeezed to a few characters by the select beside it, so it read as broken -- which is how it was reported. So the kind, the connection, the directory and the mode move out of the strip above the text and into one toolbar row beneath it, where attach and send already are. The directory becomes a button that opens a browser over SFTP, because a path is something you would rather find than spell. `scan_dir` is new beside `list_dir`: a picker has to tell a directory from a file before it can draw the row, and `list_dir` backs a tool whose contract is a list of names and must not change under a model mid-conversation. Browsing is a person clicking, not a model calling, so it does not pass through policy.py -- the same argument the terminal panel rests on. It does mean Manual mode has a second exception now. Also: .chip was two components with one name, and the attachment card won, so the Chat/Agent pills silently wore its padding. --radius-md was used twice and declared nowhere, so both fell back to 0. .btn.is-active has been set by syncToggles since the terminal landed and styled by nothing. Enter-to-send ignored isComposing, so committing an IME candidate sent the message. The terminal had five colours of a sixteen-colour palette, with fallbacks from a palette that no longer exists. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1080 lines
40 KiB
Python
1080 lines
40 KiB
Python
"""Chat creation, messaging and the streaming reply endpoint."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import time
|
|
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 sqlalchemy.orm import Session as DBSession
|
|
|
|
from lembas.api.deps import Db, RequiredUser, require_permission
|
|
from lembas.db.models import (
|
|
KIND_AGENT,
|
|
KIND_CHAT,
|
|
ROLE_ASSISTANT,
|
|
ROLE_USER,
|
|
Chat,
|
|
Message,
|
|
User,
|
|
)
|
|
from lembas.db.session import session_scope
|
|
from lembas.security import permissions
|
|
from lembas.services import audio as audio_service
|
|
from lembas.services import chat as chat_service
|
|
from lembas.services import compaction as compaction_service
|
|
from lembas.services import files as files_service
|
|
from lembas.services import generation as generation_service
|
|
from lembas.services import metrics as metrics_service
|
|
from lembas.services import prompts as prompts_service
|
|
from lembas.services import settings_store, sse
|
|
from lembas.services import tools as tools_service
|
|
from lembas.services.agent import policy as agent_policy
|
|
from lembas.services.agent import terminal as terminal_service
|
|
from lembas.services.markdown import escape_text, render_markdown
|
|
from lembas.web.templating import render, templates
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api/chats", tags=["chats"])
|
|
|
|
# Seconds of silence before a comment frame is sent to hold the connection open.
|
|
# Well under nginx's 60s default; see services/sse.py:KEEPALIVE.
|
|
KEEPALIVE_AFTER = 15.0
|
|
|
|
|
|
def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
|
|
chat = db.get(Chat, chat_id)
|
|
# 404 rather than 403 for someone else's chat: whether a given id exists is
|
|
# not information this endpoint should hand out.
|
|
if chat is None or chat.user_id != user_id:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That chat no longer exists.")
|
|
return chat
|
|
|
|
|
|
def _new_chat(
|
|
db: DBSession,
|
|
user: User,
|
|
*,
|
|
folder_id: str = "",
|
|
model_id: str = "",
|
|
temporary: bool = False,
|
|
kind: str = KIND_CHAT,
|
|
ssh_profile_id: str = "",
|
|
project_dir: str = "",
|
|
agent_mode: str = "",
|
|
) -> Chat:
|
|
"""Create a chat row, resolving which model it should use.
|
|
|
|
An agent chat's connection is settled here and never again. That is the
|
|
lock: the harness, the tools offered and the approval loop all differ, so a
|
|
conversation whose earlier turns ran somewhere else is not one conversation.
|
|
|
|
The mode is *not* part of that lock and is accepted here so it can be chosen
|
|
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.
|
|
"""
|
|
chosen = None
|
|
if model_id:
|
|
match = next(
|
|
(m for m in chat_service.available_models(db, user) if m.model_id == model_id), None
|
|
)
|
|
if match is not None:
|
|
chosen = (match.model_id, match.connection_id)
|
|
if chosen is None:
|
|
chosen = chat_service.default_model(db, user)
|
|
|
|
profile = _agent_target(db, user, kind, ssh_profile_id)
|
|
chat = Chat(
|
|
user_id=user.id,
|
|
folder_id=folder_id or None,
|
|
model_id=chosen[0] if chosen else "",
|
|
connection_id=chosen[1] if chosen else None,
|
|
temporary=temporary,
|
|
kind=KIND_AGENT if profile is not None else KIND_CHAT,
|
|
ssh_profile_id=profile.id if profile is not None else None,
|
|
project_dir=(project_dir.strip() or profile.default_dir) if profile is not None else "",
|
|
)
|
|
# Ignored rather than refused when it is not a mode, matching how every
|
|
# other bad value here collapses: somebody who mistypes should get a chat
|
|
# under the safest rules, not an error page holding their message hostage.
|
|
# 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()
|
|
db.add(chat)
|
|
db.commit()
|
|
return chat
|
|
|
|
|
|
@router.post("/start", dependencies=[Depends(require_permission("chat.create"))])
|
|
async def start_chat(
|
|
db: Db,
|
|
user: RequiredUser,
|
|
content: str = Form(""),
|
|
file_ids: list[str] = Form(default=[]),
|
|
folder_id: str = Form(""),
|
|
model_id: str = Form(""),
|
|
temporary: bool = Form(False),
|
|
kind: str = Form(KIND_CHAT),
|
|
ssh_profile_id: str = Form(""),
|
|
project_dir: str = Form(""),
|
|
agent_mode: str = Form(""),
|
|
) -> Response:
|
|
"""Create a chat from its first message.
|
|
|
|
Chats are made here rather than by a "New chat" button so that an opened-
|
|
and-abandoned chat never exists: the row appears only once there is
|
|
something in it. The reply then streams the same way as any other, because
|
|
/chat/{id} renders the unfinished assistant message with its sse-connect.
|
|
"""
|
|
content = content.strip()
|
|
if not content and not file_ids:
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
chat = _new_chat(
|
|
db,
|
|
user,
|
|
folder_id=folder_id,
|
|
model_id=model_id,
|
|
temporary=temporary,
|
|
kind=kind,
|
|
ssh_profile_id=ssh_profile_id,
|
|
project_dir=project_dir,
|
|
agent_mode=agent_mode,
|
|
)
|
|
|
|
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
|
|
if file_ids:
|
|
files_service.claim(db, ids=file_ids, user_id=user.id, message_id=user_message.id)
|
|
assistant = chat_service.create_message(
|
|
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
|
|
)
|
|
generation_service.ensure(chat.id, assistant.id)
|
|
|
|
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
response.headers["HX-Redirect"] = f"/chat/{chat.id}"
|
|
return response
|
|
|
|
|
|
def _agent_target(db: DBSession, user: User, kind: str, profile_id: str):
|
|
"""The connection an agent chat is being pointed at, or None.
|
|
|
|
Every "no" collapses to None and the chat is an ordinary one: not asked
|
|
for, no permission, the feature off, or a profile that is not this person's.
|
|
Refusing outright would be worse -- somebody whose permission was withdrawn
|
|
between opening the composer and sending would lose the message.
|
|
"""
|
|
from lembas.db.models import SshProfile
|
|
from lembas.security import permissions
|
|
|
|
if kind != KIND_AGENT or not profile_id:
|
|
return None
|
|
if not permissions.has(db, user, "tools.agent"):
|
|
return None
|
|
if not settings_store.agents(db).get("enabled"):
|
|
return None
|
|
|
|
profile = db.get(SshProfile, profile_id)
|
|
# Ownership re-checked rather than trusted from the form: an id in a POST is
|
|
# not an authorisation, and these are credentials to somebody's machine.
|
|
if profile is None or profile.owner_id != user.id or not profile.enabled:
|
|
return None
|
|
return profile
|
|
|
|
|
|
# There is deliberately no route that creates an empty chat. Starting one is
|
|
# navigation to /chat (optionally ?model=...), and the row is written by
|
|
# /start when the first message is actually sent.
|
|
|
|
|
|
@router.get("/{chat_id}/inspect")
|
|
async def inspect_chat(request: Request, db: Db, user: RequiredUser, chat_id: str) -> Response:
|
|
"""What this chat would send upstream right now.
|
|
|
|
Owner-checked *and* admin-checked, not admin alone. `permissions.resolve`
|
|
giving an admin everything is about configuration, which they can grant
|
|
themselves anyway; reading someone's conversation is a different act, which
|
|
is why `sharing.visible_to` has no admin branch either. An inspector that
|
|
could dump any user's transcript would be that branch under another name.
|
|
|
|
Rebuilt, not recorded. Recording every request would store a copy of the
|
|
whole conversation against every message, which grows quadratically with
|
|
chat length -- and the thing an administrator actually wants to see is what
|
|
the current configuration produces. The panel says so in as many words.
|
|
"""
|
|
chat = _owned_chat(db, chat_id, user.id)
|
|
if not user.is_admin:
|
|
raise HTTPException(
|
|
status.HTTP_403_FORBIDDEN, "The inspector is restricted to administrators."
|
|
)
|
|
|
|
offered = tools_service.enabled_tools(db, chat, user)
|
|
payload = chat_service.build_request(db, chat, tools=offered, user=user)
|
|
last = db.scalar(
|
|
select(Message)
|
|
.where(Message.chat_id == chat.id, Message.role == ROLE_ASSISTANT)
|
|
.order_by(Message.created_at.desc())
|
|
)
|
|
|
|
messages = payload.get("messages") or []
|
|
system = messages[0]["content"] if messages and messages[0].get("role") == "system" else ""
|
|
|
|
return render(
|
|
request,
|
|
"chat/_inspector_body.html",
|
|
{
|
|
"chat": chat,
|
|
"system": system,
|
|
"request_json": _pretty(_redact(payload)),
|
|
"row": last,
|
|
"metrics": metrics_service.from_message(last.usage_json if last else None),
|
|
"tool_names": [
|
|
(t.get("function") or {}).get("name", "") for t in offered
|
|
],
|
|
"model": chat_service.model_for(db, chat),
|
|
},
|
|
)
|
|
|
|
|
|
# Roughly what a downscaled phone photo comes to as base64. The exact figure
|
|
# does not matter; putting megabytes of it into the DOM does.
|
|
_REDACTED_URI = "data:…base64 image omitted…"
|
|
MAX_INSPECT_CHARS = 40_000
|
|
|
|
|
|
def _redact(payload: dict) -> dict:
|
|
"""Replace image data URIs before dumping.
|
|
|
|
Nothing else is hidden -- fidelity is the whole point of the panel, and API
|
|
keys never appear because `build_request` returns a body, not headers.
|
|
"""
|
|
messages = []
|
|
for message in payload.get("messages") or []:
|
|
content = message.get("content")
|
|
if isinstance(content, list):
|
|
parts = []
|
|
for part in content:
|
|
if isinstance(part, dict) and part.get("type") == "image_url":
|
|
parts.append({"type": "image_url", "image_url": {"url": _REDACTED_URI}})
|
|
else:
|
|
parts.append(part)
|
|
message = {**message, "content": parts}
|
|
messages.append(message)
|
|
return {**payload, "messages": messages}
|
|
|
|
|
|
def _pretty(payload: dict) -> str:
|
|
text = json.dumps(payload, indent=2, ensure_ascii=False, default=str)
|
|
if len(text) > MAX_INSPECT_CHARS:
|
|
return text[:MAX_INSPECT_CHARS] + "\n… truncated"
|
|
return text
|
|
|
|
|
|
@router.post("/{chat_id}/compact")
|
|
async def compact_chat(request: Request, db: Db, user: RequiredUser, chat_id: str) -> Response:
|
|
"""Summarise the earlier turns and stop sending them.
|
|
|
|
No permission of its own: compaction changes only what one chat sends
|
|
upstream, and gating it would mean answering "why can this user not tidy
|
|
their own conversation".
|
|
"""
|
|
chat = _owned_chat(db, chat_id, user.id)
|
|
|
|
unfinished = db.scalar(
|
|
select(Message).where(Message.chat_id == chat.id, Message.complete.is_(False))
|
|
)
|
|
if unfinished is not None:
|
|
# Summarising a transcript that is still being written races
|
|
# build_request. Queuing it is a state machine nobody asked for.
|
|
raise HTTPException(
|
|
status.HTTP_409_CONFLICT, "Wait for the current reply to finish, then compact."
|
|
)
|
|
|
|
template = prompts_service.resolve(db, "task.compact")
|
|
if not template.strip():
|
|
raise HTTPException(
|
|
status.HTTP_409_CONFLICT,
|
|
"Compaction is turned off: its prompt is empty under Admin → Prompts.",
|
|
)
|
|
|
|
upto = compaction_service.last_complete(db, chat)
|
|
if upto is None:
|
|
raise HTTPException(
|
|
status.HTTP_409_CONFLICT, "There is nothing here to summarise yet."
|
|
)
|
|
|
|
endpoint, model_id = chat_service.resolve_endpoint(db, chat)
|
|
transcript = compaction_service.transcript(db, chat, upto=upto)
|
|
previous = compaction_service.previous_summary_block(chat)
|
|
|
|
summary = await chat_service.summarise_for_compaction(
|
|
endpoint,
|
|
model_id,
|
|
transcript=transcript,
|
|
previous_summary=previous,
|
|
template=template,
|
|
)
|
|
if not summary:
|
|
raise HTTPException(
|
|
status.HTTP_409_CONFLICT, "The model returned no summary, so nothing changed."
|
|
)
|
|
|
|
compaction_service.apply(chat, summary=summary, upto=upto)
|
|
db.commit()
|
|
log.info("chat %s compacted through %s", chat.id, upto.id)
|
|
|
|
return templates.TemplateResponse(
|
|
request, "chat/_thread.html", {"request": request, **_thread_context(db, chat, user)}
|
|
)
|
|
|
|
|
|
@router.post("/{chat_id}/keep")
|
|
async def keep_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
|
|
"""Stop a temporary chat being temporary.
|
|
|
|
A conversation that turns out to matter has to have a way out; without one,
|
|
the sweep destroys it a day later with no recourse, and people discover that
|
|
exactly once.
|
|
"""
|
|
chat = _owned_chat(db, chat_id, user.id)
|
|
chat.temporary = False
|
|
db.commit()
|
|
|
|
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
# The sidebar has to gain a row and the topbar has to lose a badge; a full
|
|
# refresh is one line against a handful of out-of-band fragments.
|
|
response.headers["HX-Refresh"] = "true"
|
|
return response
|
|
|
|
|
|
@router.get("/unread")
|
|
async def unread_poll(db: Db, user: RequiredUser) -> Response:
|
|
"""Dots for the sidebar, and a toast for anything newly arrived.
|
|
|
|
Polled rather than pushed: a browser sitting on a different chat has no
|
|
open connection to the one that finished, and a second always-on channel
|
|
per tab is a lot of machinery for a green dot.
|
|
|
|
Returns out-of-band spans so only the dots change -- re-rendering the whole
|
|
sidebar would reset the folder open/closed state on every tick.
|
|
"""
|
|
chats = list(
|
|
db.scalars(
|
|
select(Chat).where(
|
|
Chat.user_id == user.id,
|
|
Chat.archived.is_(False),
|
|
# A temporary chat has no sidebar row, so a dot has nowhere to
|
|
# land and the toast would name a chat nobody can navigate to.
|
|
Chat.temporary.is_(False),
|
|
)
|
|
)
|
|
)
|
|
|
|
fresh = [c for c in chats if c.unread and not c.unread_notified]
|
|
for chat in fresh:
|
|
chat.unread_notified = True
|
|
if fresh:
|
|
db.commit()
|
|
|
|
markup = "".join(
|
|
f'<span id="unread-{c.id}" class="unread-dot" hx-swap-oob="true"'
|
|
f'{"" if c.unread else " hidden"} title="New reply"></span>'
|
|
for c in chats
|
|
)
|
|
|
|
response = HTMLResponse(markup)
|
|
if fresh:
|
|
# HX-Trigger carries the toast; ui.js listens for it.
|
|
response.headers["HX-Trigger"] = json.dumps(
|
|
{"lembas:unread": {"titles": [c.title for c in fresh]}}
|
|
)
|
|
return response
|
|
|
|
|
|
@router.post("/{chat_id}/messages")
|
|
async def post_message(
|
|
request: Request,
|
|
db: Db,
|
|
user: RequiredUser,
|
|
chat_id: str,
|
|
content: str = Form(""),
|
|
file_ids: list[str] = Form(default=[]),
|
|
) -> Response:
|
|
"""Persist the user's turn and hand back the pair of bubbles.
|
|
|
|
The assistant bubble comes back empty, carrying the sse-connect attribute
|
|
that opens the stream below. Splitting it this way means the POST returns
|
|
immediately and the slow part is a separate, resumable connection.
|
|
"""
|
|
chat = _owned_chat(db, chat_id, user.id)
|
|
|
|
content = content.strip()
|
|
# "Here, look at this" with no words is a legitimate turn, so an empty
|
|
# message is only empty when it carries nothing at all.
|
|
if not content and not file_ids:
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
return _send(request, db, chat, user, content, file_ids=file_ids)
|
|
|
|
|
|
def _note_rewind(chat: Chat) -> None:
|
|
"""Record that an agent chat's transcript went back and the machine did not.
|
|
|
|
Deliberately no attempt to undo anything out there. The project directory is
|
|
somebody's real working tree, and deleting their work to match a rewound
|
|
transcript would be far worse than the inconsistency. So the model is told
|
|
instead -- see the `tool.agent_rewound` fragment -- and can look rather than
|
|
assume.
|
|
"""
|
|
if chat.kind == KIND_AGENT:
|
|
chat.rewound_at = datetime.now(UTC)
|
|
|
|
|
|
def _send(
|
|
request: Request,
|
|
db: Db,
|
|
chat: Chat,
|
|
user: User,
|
|
content: str,
|
|
*,
|
|
file_ids: list[str] | None = None,
|
|
) -> Response:
|
|
"""Write a turn, start the reply, and hand back the pair of bubbles.
|
|
|
|
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.
|
|
"""
|
|
user_message = chat_service.create_message(db, chat, ROLE_USER, content)
|
|
if file_ids:
|
|
files_service.claim(db, ids=file_ids, user_id=user.id, message_id=user_message.id)
|
|
db.refresh(user_message)
|
|
assistant_message = chat_service.create_message(
|
|
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_id
|
|
)
|
|
generation_service.ensure(chat.id, assistant_message.id)
|
|
|
|
# `user` is required by the shared message template, which renders both
|
|
# roles; without it the user bubble's initial blows up.
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"chat/_turn.html",
|
|
{
|
|
"request": request,
|
|
"user_message": user_message,
|
|
"assistant_message": assistant_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),
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/{chat_id}/messages/{message_id}/stream")
|
|
async def stream_message(
|
|
db: Db,
|
|
user: RequiredUser,
|
|
chat_id: str,
|
|
message_id: str,
|
|
) -> Response:
|
|
"""Stream the assistant's reply as server-sent events.
|
|
|
|
Emits `token` events carrying escaped text, then a single `done` event
|
|
carrying the finished bubble rendered from Markdown, then `close`.
|
|
"""
|
|
chat = _owned_chat(db, chat_id, user.id)
|
|
message = db.get(Message, message_id)
|
|
if message is None or message.chat_id != chat.id:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
|
|
|
|
return StreamingResponse(
|
|
_follow(chat.id, message.id),
|
|
media_type="text/event-stream",
|
|
headers={
|
|
"Cache-Control": "no-cache, no-transform",
|
|
"Connection": "keep-alive",
|
|
# nginx buffers proxied responses by default, which turns a stream
|
|
# into one delivery at the end. This is the documented opt-out.
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
)
|
|
|
|
|
|
def _tool_activity(events: list[dict], *, live: bool = True) -> str:
|
|
"""Render the tool block. Whole, never a delta, like every other frame."""
|
|
return templates.get_template("chat/_tool_activity.html").render(
|
|
{"tool_events": events, "live": live}
|
|
)
|
|
|
|
|
|
def _ask_html(chat_id: str, pending) -> str:
|
|
"""The card asking the reader something, or nothing at all.
|
|
|
|
Returns "" when there is nothing pending, and the frame is sent
|
|
unconditionally, because this is one of the few blocks that has to be able
|
|
to *clear* itself: the card must vanish the moment it is answered.
|
|
`reasoning`, `tools` and `render` are the opposite -- guarded by truthiness
|
|
so a frame can never blank them.
|
|
"""
|
|
if pending is None:
|
|
return ""
|
|
return templates.get_template("chat/_interaction.html").render(
|
|
{"ask": pending, "chat_id": chat_id}
|
|
)
|
|
|
|
|
|
async def _follow(chat_id: str, message_id: str) -> AsyncIterator[str]:
|
|
"""Stream a generation that is running independently of this request.
|
|
|
|
This connection only *watches*. Closing it -- navigating away, opening
|
|
another chat -- leaves the reply being written, and reconnecting replays
|
|
the whole state immediately rather than starting over.
|
|
|
|
Both `render` and `reasoning` carry the complete block each time rather
|
|
than a delta, which is what makes reattaching mid-reply work at all: a
|
|
follower arriving late has no earlier fragments to append to.
|
|
"""
|
|
generation = generation_service.ensure(chat_id, message_id)
|
|
generation.followers += 1
|
|
seen = -1
|
|
last_frame = time.monotonic()
|
|
|
|
try:
|
|
while True:
|
|
if generation.version != seen:
|
|
seen = generation.version
|
|
if generation.thinking:
|
|
yield sse.event("reasoning", escape_text(generation.thinking))
|
|
if generation.tool_events:
|
|
yield sse.event("tools", _tool_activity(generation.tool_events))
|
|
if generation.content:
|
|
yield sse.event("render", render_markdown(generation.text))
|
|
yield sse.event("metrics", _metrics_html(generation))
|
|
yield sse.event("status", escape_text(generation.status))
|
|
yield sse.event("ask", _ask_html(chat_id, generation.pending))
|
|
last_frame = time.monotonic()
|
|
|
|
if generation.done:
|
|
break
|
|
|
|
# A reasoning model can think for a minute or more without emitting
|
|
# anything, and an idle connection is what a proxy closes. The
|
|
# comment frame keeps it open and is ignored by the browser.
|
|
if time.monotonic() - last_frame > KEEPALIVE_AFTER:
|
|
yield sse.KEEPALIVE
|
|
last_frame = time.monotonic()
|
|
|
|
# Polling rather than per-follower wakeups: the producer already
|
|
# works in RENDER_INTERVAL steps, so a short sleep is simpler and
|
|
# cannot drop a notification.
|
|
await asyncio.sleep(generation_service.RENDER_INTERVAL * 0.8)
|
|
finally:
|
|
generation.followers = max(0, generation.followers - 1)
|
|
|
|
# The producer commits the message before marking itself done, so by here
|
|
# the row is authoritative and the final bubble can be rendered from it.
|
|
with session_scope() as db:
|
|
message = db.get(Message, message_id)
|
|
chat = db.get(Chat, chat_id)
|
|
if message is None or chat is None:
|
|
yield sse.event("close", "")
|
|
return
|
|
|
|
owner = db.get(User, chat.user_id)
|
|
final_html = templates.get_template("chat/_message.html").render(
|
|
{
|
|
"message": message,
|
|
"body_html": render_markdown(message.content),
|
|
"chat": chat,
|
|
# Passed even though an assistant bubble never reads it: the
|
|
# template shares both roles, and a missing `user` would only
|
|
# blow up on whichever branch is not being exercised here.
|
|
"user": owner,
|
|
"models_by_id": {
|
|
m.model_id: m for m in chat_service.available_models(db, None)
|
|
},
|
|
# This frame replaces the whole bubble, so it has to carry the
|
|
# speaker button's conditions too -- and the owner's, not the
|
|
# follower's: there is no request here to ask who is watching.
|
|
**audio_service.template_flags(db, owner),
|
|
# The one render that means "this reply just landed", which is
|
|
# what read-aloud-automatically keys off. A page load must not
|
|
# set it or reopening a chat would start talking.
|
|
"just_finished": True,
|
|
}
|
|
)
|
|
title_html = templates.get_template("chat/_title_oob.html").render({"chat": chat})
|
|
|
|
yield sse.event("done", final_html + title_html)
|
|
yield sse.event("close", "")
|
|
|
|
|
|
def _metrics_html(generation) -> str:
|
|
"""The metric chips for a reply still being written.
|
|
|
|
Built from the same Metrics object the finished bubble uses, so the numbers
|
|
do not jump when the stream ends -- the only thing that changes is that an
|
|
estimate may have become exact.
|
|
"""
|
|
return templates.get_template("chat/_metrics.html").render(
|
|
{"metrics": metrics_service.from_generation(generation)}
|
|
)
|
|
|
|
|
|
def _thread_context(db: DBSession, chat: Chat, user: User) -> dict:
|
|
"""Everything chat/_thread.html needs to render the conversation."""
|
|
everything = list(
|
|
db.scalars(select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at))
|
|
)
|
|
compacted, messages = compaction_service.split(db, chat, everything)
|
|
return {
|
|
"chat": chat,
|
|
"user": user,
|
|
"messages": messages,
|
|
"compacted": compacted,
|
|
"bodies": {
|
|
m.id: render_markdown(m.content)
|
|
for m in everything
|
|
if m.role == ROLE_ASSISTANT and m.content
|
|
},
|
|
"models_by_id": {m.model_id: m for m in chat_service.available_models(db, user)},
|
|
**audio_service.template_flags(db, user),
|
|
}
|
|
|
|
|
|
def _messages_after(db: DBSession, message: Message) -> list[Message]:
|
|
return list(
|
|
db.scalars(
|
|
select(Message)
|
|
.where(Message.chat_id == message.chat_id, Message.created_at > message.created_at)
|
|
.order_by(Message.created_at)
|
|
)
|
|
)
|
|
|
|
|
|
@router.get("/{chat_id}/messages/{message_id}/edit")
|
|
async def edit_form(
|
|
request: Request, db: Db, user: RequiredUser, chat_id: str, message_id: str
|
|
) -> Response:
|
|
"""Swap one of the reader's own turns into an editable form."""
|
|
chat = _owned_chat(db, chat_id, user.id)
|
|
message = db.get(Message, message_id)
|
|
if message is None or message.chat_id != chat.id or message.role != ROLE_USER:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
|
|
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"chat/_edit_form.html",
|
|
{
|
|
"request": request,
|
|
"chat": chat,
|
|
"user": user,
|
|
"message": message,
|
|
"following": len(_messages_after(db, message)),
|
|
},
|
|
)
|
|
|
|
|
|
@router.get("/{chat_id}/messages/{message_id}/cancel-edit")
|
|
async def cancel_edit(
|
|
request: Request, db: Db, user: RequiredUser, chat_id: str, message_id: str
|
|
) -> Response:
|
|
"""Put the bubble back, unchanged."""
|
|
chat = _owned_chat(db, chat_id, user.id)
|
|
message = db.get(Message, message_id)
|
|
if message is None or message.chat_id != chat.id:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
|
|
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"chat/_message.html",
|
|
{
|
|
"request": request,
|
|
"chat": chat,
|
|
"user": user,
|
|
"message": message,
|
|
"body_html": "",
|
|
"models_by_id": {},
|
|
},
|
|
)
|
|
|
|
|
|
@router.post("/{chat_id}/messages/{message_id}/edit")
|
|
async def edit_message(
|
|
request: Request,
|
|
db: Db,
|
|
user: RequiredUser,
|
|
chat_id: str,
|
|
message_id: str,
|
|
content: str = Form(...),
|
|
) -> Response:
|
|
"""Rewrite one of the reader's turns and run the conversation on from there.
|
|
|
|
Everything after the edited message is deleted rather than branched. A
|
|
branch would need a UI for choosing between versions, and "go back and try
|
|
again from here" is what was actually asked for -- the simpler behaviour is
|
|
also the one people expect from every other chat client.
|
|
"""
|
|
chat = _owned_chat(db, chat_id, user.id)
|
|
message = db.get(Message, message_id)
|
|
if message is None or message.chat_id != chat.id or message.role != ROLE_USER:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
|
|
|
|
content = content.strip()
|
|
if not content and not message.attachments:
|
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "A message cannot be empty.")
|
|
|
|
message.content = content
|
|
|
|
# Attachments cascade with their message, so the files go too.
|
|
discarded = _messages_after(db, message)
|
|
for later in discarded:
|
|
db.delete(later)
|
|
|
|
# A rewind to at or before the compaction boundary leaves that boundary
|
|
# describing turns that no longer exist. There is no foreign key to null it
|
|
# out on an upgraded database, so it is cleared here.
|
|
cutoff = compaction_service.cutoff_message(db, chat)
|
|
if cutoff is None or compaction_service.moment(message) <= compaction_service.moment(cutoff):
|
|
compaction_service.reset(chat)
|
|
|
|
_note_rewind(chat)
|
|
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)
|
|
log.info("chat %s rewound to a message, %d discarded", chat.id, len(discarded))
|
|
|
|
return templates.TemplateResponse(
|
|
request, "chat/_thread.html", {"request": request, **_thread_context(db, chat, user)}
|
|
)
|
|
|
|
|
|
@router.post("/{chat_id}/messages/{message_id}/execute-plan")
|
|
async def execute_plan(
|
|
request: Request, db: Db, user: RequiredUser, chat_id: str, message_id: str
|
|
) -> Response:
|
|
"""Carry out a plan the model proposed.
|
|
|
|
Switches to **Edit**, never Auto. The plan was written under a mode where
|
|
every command stopped for approval, and a button that also removed the
|
|
asking is not the button anybody pressed.
|
|
|
|
The plan is sent back **marked as a quotation of the model's own words**
|
|
rather than as a bare instruction. A plan whose text came out of a file the
|
|
model read would otherwise arrive in the most trusted role in the
|
|
transcript, wearing the reader's authority -- which is precisely how an
|
|
injected instruction would like to arrive.
|
|
"""
|
|
chat = _owned_chat(db, chat_id, user.id)
|
|
message = db.get(Message, message_id)
|
|
if message is None or message.chat_id != chat.id or not message.plan_json:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "There is no plan on that message.")
|
|
if chat.kind != KIND_AGENT:
|
|
raise HTTPException(status.HTTP_409_CONFLICT, "This chat cannot act on anything.")
|
|
|
|
plan = message.plan_json
|
|
steps = [str(s) for s in (plan.get("steps") or [])]
|
|
body = "\n".join(f"{n}. {step}" for n, step in enumerate(steps, start=1))
|
|
|
|
chat.agent_mode = agent_policy.MODE_EDIT
|
|
db.commit()
|
|
|
|
content = (
|
|
"Carry out the plan you proposed above:\n\n"
|
|
f"> **{plan.get('title') or 'The plan'}**\n"
|
|
+ "\n".join(f"> {line}" for line in body.splitlines())
|
|
+ "\n\nWork through it in order. If a step turns out to be wrong, stop "
|
|
"and say so rather than improvising around it."
|
|
)
|
|
log.info("%s executing a plan in chat %s", user.email, chat.id)
|
|
return _send(request, db, chat, user, content)
|
|
|
|
|
|
@router.post("/{chat_id}/messages/{message_id}/stop")
|
|
async def stop_message(db: Db, user: RequiredUser, chat_id: str, message_id: str) -> Response:
|
|
"""Ask a running generation to stop.
|
|
|
|
Whatever has arrived is kept: a half-written answer the reader chose to cut
|
|
short is still worth having, and discarding it would be a surprise.
|
|
"""
|
|
chat = _owned_chat(db, chat_id, user.id)
|
|
message = db.get(Message, message_id)
|
|
if message is None or message.chat_id != chat.id:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
|
|
|
|
generation_service.request_stop(message.id)
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
@router.post("/{chat_id}/interaction/{interaction_id}")
|
|
async def answer_interaction(
|
|
request: Request,
|
|
db: Db,
|
|
user: RequiredUser,
|
|
chat_id: str,
|
|
interaction_id: str,
|
|
) -> Response:
|
|
"""Answer the questions, or allow the action, a reply is waiting on.
|
|
|
|
The whole card comes back at once, which is why the raw form is read rather
|
|
than declared parameters: one `ask_user` call may have put four questions,
|
|
and each carries a chosen option and a box to write something else. Per
|
|
question, what was written wins over what was picked -- somebody who typed
|
|
in the box after clicking an option meant the typing.
|
|
|
|
`_owned_chat` is the authorisation and it is not decoration: without it any
|
|
signed-in account that guessed an id would be answering -- and later,
|
|
approving a command in -- somebody else's conversation.
|
|
|
|
An id matching nothing (already answered, timed out, or the server was
|
|
restarted) is a 204 with a toast rather than a 404. The card is gone either
|
|
way, and an error page swapped into the middle of a chat is worse than
|
|
being told plainly.
|
|
"""
|
|
chat = _owned_chat(db, chat_id, user.id)
|
|
form = await request.form()
|
|
|
|
answers: dict[str, str] = {}
|
|
for field, value in form.multi_items():
|
|
kind, _, key = str(field).partition(".")
|
|
if not key or kind not in ("choice", "text"):
|
|
continue
|
|
written = str(value).strip()
|
|
if kind == "text" and written:
|
|
answers[key] = written
|
|
elif kind == "choice" and written:
|
|
answers.setdefault(key, written)
|
|
|
|
answered = generation_service.answer(
|
|
chat.id,
|
|
interaction_id,
|
|
verdict=str(form.get("verdict") or "").strip(),
|
|
answers=answers,
|
|
)
|
|
|
|
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
if not answered:
|
|
response.headers["HX-Trigger"] = json.dumps(
|
|
{"lembas:notify": {"message": "That question is no longer waiting for an answer."}}
|
|
)
|
|
return response
|
|
|
|
|
|
@router.patch("/{chat_id}")
|
|
async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str) -> Response:
|
|
"""Partially update a chat.
|
|
|
|
The raw form is read rather than declaring Form() parameters because
|
|
FastAPI substitutes the default for an empty form value, which makes
|
|
"field absent" and "field submitted empty" indistinguishable. That
|
|
difference is exactly what this endpoint needs: an empty system prompt or
|
|
temperature means *clear it*, not *leave it alone*.
|
|
"""
|
|
chat = _owned_chat(db, chat_id, user.id)
|
|
allowed = permissions.resolve(db, user)
|
|
form = await request.form()
|
|
|
|
if "title" in form:
|
|
cleaned = str(form["title"]).strip()[:300]
|
|
if cleaned:
|
|
chat.title = cleaned
|
|
# An explicit rename must not be overwritten by auto-titling later.
|
|
chat.title_generated = True
|
|
|
|
if "folder_id" in form:
|
|
chat.folder_id = str(form["folder_id"]) or None
|
|
|
|
# The mode is the one agent field that changes mid-chat: it decides what
|
|
# gets asked about, not what the conversation is.
|
|
if "agent_mode" in form:
|
|
wanted = str(form["agent_mode"]).strip()
|
|
if wanted in agent_policy.MODES:
|
|
chat.agent_mode = wanted
|
|
|
|
# And these are the ones that never do. Refused rather than ignored: a form
|
|
# that quietly did nothing would look like a bug from the outside, and
|
|
# without the refusal a crafted POST would repoint a conversation at another
|
|
# machine halfway through.
|
|
for locked in ("kind", "ssh_profile_id", "project_dir"):
|
|
if locked in form:
|
|
raise HTTPException(
|
|
status.HTTP_409_CONFLICT,
|
|
"A chat's connection is fixed when it is created. Start a new "
|
|
"chat to work somewhere else.",
|
|
)
|
|
|
|
model_id = str(form.get("model_id", "")).strip()
|
|
|
|
if model_id:
|
|
if not allowed.get("chat.model_select"):
|
|
raise HTTPException(
|
|
status.HTTP_403_FORBIDDEN, "You may not change the model for a chat."
|
|
)
|
|
# Checked against what this user can reach, not merely what exists --
|
|
# otherwise the picker is advisory and a crafted request bypasses it.
|
|
match = next(
|
|
(m for m in chat_service.available_models(db, user) if m.model_id == model_id),
|
|
None,
|
|
)
|
|
if match is None:
|
|
raise HTTPException(status.HTTP_403_FORBIDDEN, "That model is not available to you.")
|
|
chat.model_id = model_id
|
|
chat.connection_id = match.connection_id
|
|
|
|
if "system_prompt" in form:
|
|
if not allowed.get("chat.system_prompt"):
|
|
raise HTTPException(
|
|
status.HTTP_403_FORBIDDEN, "You may not set a system prompt."
|
|
)
|
|
chat.system_prompt = str(form["system_prompt"]).strip()[:8000]
|
|
|
|
if "knowledge_base_ids" in form:
|
|
# Sent as a single field even when empty, so that clearing every box
|
|
# actually clears the attachment -- absent checkboxes carry no signal of
|
|
# their own, which is the same trap update_chat exists to avoid.
|
|
from lembas.db.models import KnowledgeBase
|
|
from lembas.services.library import documents as documents_service
|
|
|
|
wanted = [value for value in form.getlist("knowledge_base_ids") if value]
|
|
chat.knowledge_bases = (
|
|
list(
|
|
db.scalars(
|
|
documents_service.visible_bases(db, user).where(
|
|
KnowledgeBase.id.in_(wanted)
|
|
)
|
|
)
|
|
)
|
|
if wanted
|
|
else []
|
|
)
|
|
|
|
submitted_params = {name: form[name] for name in _PARAM_RANGES if name in form}
|
|
if submitted_params:
|
|
if not allowed.get("chat.params"):
|
|
raise HTTPException(
|
|
status.HTTP_403_FORBIDDEN, "You may not change sampling parameters."
|
|
)
|
|
chat.params_json = {
|
|
**(chat.params_json or {}),
|
|
**_clean_params(**{k: str(v) for k, v in submitted_params.items()}),
|
|
}
|
|
|
|
db.commit()
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
# Bounds are the ones every provider agrees on. Out-of-range values are
|
|
# dropped rather than clamped: silently changing what someone typed is worse
|
|
# than ignoring it, and the form shows what actually stuck on reload.
|
|
_PARAM_RANGES: dict[str, tuple[type, float, float]] = {
|
|
"temperature": (float, 0.0, 2.0),
|
|
"top_p": (float, 0.0, 1.0),
|
|
"max_tokens": (int, 1, 1_000_000),
|
|
}
|
|
|
|
|
|
def _clean_params(**submitted: str | None) -> dict[str, float | int | None]:
|
|
"""Parse sampling parameters, dropping anything unusable.
|
|
|
|
An empty string means "unset this and let the provider default apply", so
|
|
it maps to None rather than being ignored.
|
|
"""
|
|
cleaned: dict[str, float | int | None] = {}
|
|
for name, raw in submitted.items():
|
|
if raw is None:
|
|
continue
|
|
if not raw.strip():
|
|
cleaned[name] = None
|
|
continue
|
|
caster, low, high = _PARAM_RANGES[name]
|
|
try:
|
|
value = caster(raw)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if low <= value <= high:
|
|
cleaned[name] = value
|
|
return cleaned
|
|
|
|
|
|
@router.delete("/{chat_id}", dependencies=[Depends(require_permission("chat.delete"))])
|
|
async def delete_chat(db: Db, user: RequiredUser, chat_id: str) -> Response:
|
|
chat = _owned_chat(db, chat_id, user.id)
|
|
# Before the row goes: a terminal is keyed on the chat id, so afterwards
|
|
# there would be nothing left to find it by and a shell would sit open on
|
|
# somebody's machine until the idle timeout noticed.
|
|
await terminal_service.close_chat(chat_id)
|
|
db.delete(chat)
|
|
db.commit()
|
|
|
|
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
response.headers["HX-Redirect"] = "/chat"
|
|
return response
|
|
|
|
|
|
@router.get("/{chat_id}/messages/{message_id}/raw")
|
|
async def raw_message(db: Db, user: RequiredUser, chat_id: str, message_id: str) -> HTMLResponse:
|
|
"""The unrendered Markdown of a message, for the copy button."""
|
|
_owned_chat(db, chat_id, user.id)
|
|
message = db.get(Message, message_id)
|
|
if message is None or message.chat_id != chat_id:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That message no longer exists.")
|
|
return HTMLResponse(escape_text(message.content))
|
|
|
|
|
|
@router.post("/{chat_id}/messages/{message_id}/regenerate")
|
|
async def regenerate(
|
|
request: Request,
|
|
db: Db,
|
|
user: RequiredUser,
|
|
chat_id: str,
|
|
message_id: str,
|
|
) -> Response:
|
|
"""Discard an assistant reply and produce a fresh one in its place."""
|
|
chat = _owned_chat(db, chat_id, user.id)
|
|
message = db.get(Message, message_id)
|
|
if message is None or message.chat_id != chat.id or message.role != ROLE_ASSISTANT:
|
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "That reply no longer exists.")
|
|
|
|
message.content = ""
|
|
message.error = ""
|
|
message.complete = False
|
|
message.model_id = chat.model_id
|
|
_note_rewind(chat)
|
|
db.commit()
|
|
# restart, not ensure: this is the one caller that reuses a Message row, and
|
|
# the finished generation for it is still registered.
|
|
generation_service.restart(chat.id, message.id)
|
|
|
|
return templates.TemplateResponse(
|
|
request,
|
|
"chat/_message.html",
|
|
{
|
|
"request": request,
|
|
"message": message,
|
|
"chat": chat,
|
|
"body_html": "",
|
|
"user": user,
|
|
"models_by_id": {
|
|
m.model_id: m for m in chat_service.available_models(db, user)
|
|
},
|
|
**audio_service.template_flags(db, user),
|
|
},
|
|
)
|
|
|
|
|
|
__all__ = ["render", "router"]
|