Live Markdown, stop, rewind, custom picker, dialogs
Seven things. **Reasoning starts closed.** The answer is what the reader is waiting for; the thinking is one click away. **Image borders.** .attachments__image was a block-level <a>, so its border stretched the full column around a narrow picture. inline-block, and the frame is the picture. Same fix for the composer thumbnail. **Markdown now renders during the stream.** The generator re-renders the answer so far and sends it as a `render` event at most every 100ms, swapped with innerHTML, instead of appending escaped tokens and formatting everything at the end. Re-rendering whole rather than appending is the point: a list or a code fence is only correct once its context exists, and partial syntax resolves itself as more arrives. Measured against a live model: 29 render events, formatting visible from the first content token. **Stop button.** A stop request goes into an in-process set the generator checks between chunks; whatever arrived is kept, because a half-written answer the reader chose to cut short is still worth having. Measured: stream ended 0.2s after the request, 1155 characters preserved, message marked stopped rather than errored. Navigating away does the same thing via CancelledError. **Rewind and edit.** Edit one of your own turns and everything after it is deleted, then the conversation runs on from there. Deliberately not branching: that needs a UI for choosing between versions, and "go back and try again from here" is what was asked for. The form states how many messages will be discarded before you confirm. **Custom model picker.** A <select> renders only text in an <option>, so it can never show an avatar. Built from buttons and a hidden input, with descriptions, capability tags, a filter box past eight models, and arrow-key navigation written out by hand since there is no native widget doing it. **Notification system.** lembas.notify/confirm/prompt in ui.js, built on <dialog> so focus trapping, Escape and page inertness come from the browser. htmx:confirm is intercepted, so every existing hx-confirm gets the themed dialog with no change at the call site; the browser's grey confirm() is gone from every template. 230 tests, ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,9 +35,12 @@ runtime. Clone it, `pip install -e .`, run it.
|
|||||||
the first message, so an abandoned one never clutters the sidebar
|
the first message, so an abandoned one never clutters the sidebar
|
||||||
- **System prompts** — instance-wide, per-model and per-chat, with the most
|
- **System prompts** — instance-wide, per-model and per-chat, with the most
|
||||||
specific winning outright
|
specific winning outright
|
||||||
- **Reasoning display** — thinking from reasoning models streams into its own
|
- **Reasoning display** — thinking streams into its own collapsible block
|
||||||
collapsible block, labelled with how long it took, and is never replayed as
|
(closed by default), labelled with how long it took, and is never replayed as
|
||||||
context
|
context
|
||||||
|
- **Live Markdown** — formatting appears as the model writes, not at the end
|
||||||
|
- **Stop and rewind** — cut a reply short and keep what arrived, or edit an
|
||||||
|
earlier message and run the conversation on from there
|
||||||
- **Attachments** — drag, paste or pick images, PDFs and text files. Images are
|
- **Attachments** — drag, paste or pick images, PDFs and text files. Images are
|
||||||
downscaled and sent to vision models; PDF and text content is extracted and
|
downscaled and sent to vision models; PDF and text content is extracted and
|
||||||
put in the prompt
|
put in the prompt
|
||||||
|
|||||||
+173
-2
@@ -9,6 +9,7 @@ from collections.abc import AsyncIterator
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||||
from fastapi.responses import HTMLResponse, Response, StreamingResponse
|
from fastapi.responses import HTMLResponse, Response, StreamingResponse
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session as DBSession
|
from sqlalchemy.orm import Session as DBSession
|
||||||
|
|
||||||
from lembas.api.deps import Db, RequiredUser, require_permission
|
from lembas.api.deps import Db, RequiredUser, require_permission
|
||||||
@@ -32,6 +33,21 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/chats", tags=["chats"])
|
router = APIRouter(prefix="/api/chats", tags=["chats"])
|
||||||
|
|
||||||
|
# Message ids whose generation has been asked to stop. The generator checks
|
||||||
|
# this between chunks and finalises with whatever it has.
|
||||||
|
#
|
||||||
|
# In-process, which is correct for the single-worker deployment this ships
|
||||||
|
# with: the request that stops a stream and the task producing it are in the
|
||||||
|
# same process. Running multiple workers would need this in the database or a
|
||||||
|
# broker instead -- see deploy/README.md.
|
||||||
|
_CANCELLED: set[str] = set()
|
||||||
|
|
||||||
|
# How often the partially rendered reply is pushed to the browser. Markdown is
|
||||||
|
# re-rendered from scratch each time, so this trades a little server work for
|
||||||
|
# formatting that appears as the model writes rather than all at once at the
|
||||||
|
# end. 100ms is below the threshold where the eye reads it as stepping.
|
||||||
|
RENDER_INTERVAL = 0.1
|
||||||
|
|
||||||
|
|
||||||
def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
|
def _owned_chat(db: DBSession, chat_id: str, user_id: str) -> Chat:
|
||||||
chat = db.get(Chat, chat_id)
|
chat = db.get(Chat, chat_id)
|
||||||
@@ -221,6 +237,9 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]:
|
|||||||
splitter = ReasoningSplitter()
|
splitter = ReasoningSplitter()
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
reasoning_started: float | None = None
|
reasoning_started: float | None = None
|
||||||
|
last_render = 0.0
|
||||||
|
dirty = False
|
||||||
|
stopped = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
endpoint, model_id = chat_service.resolve_endpoint(db, chat)
|
endpoint, model_id = chat_service.resolve_endpoint(db, chat)
|
||||||
@@ -261,18 +280,31 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]:
|
|||||||
if reasoning_started is not None and not reasoning_ms:
|
if reasoning_started is not None and not reasoning_ms:
|
||||||
reasoning_ms = int((time.monotonic() - reasoning_started) * 1000)
|
reasoning_ms = int((time.monotonic() - reasoning_started) * 1000)
|
||||||
accumulated.append(piece)
|
accumulated.append(piece)
|
||||||
yield sse.event("token", escape_text(piece))
|
dirty = True
|
||||||
# Hand control back so the event is flushed rather than
|
# Hand control back so the event is flushed rather than
|
||||||
# batched behind a fast generator.
|
# batched behind a fast generator.
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
# Re-render the answer so far, at most every RENDER_INTERVAL.
|
||||||
|
# Markdown is rendered whole rather than appended, because a
|
||||||
|
# list or a code fence is only correct once its context is
|
||||||
|
# known -- and partial syntax resolves itself as more arrives.
|
||||||
|
now = time.monotonic()
|
||||||
|
if dirty and now - last_render >= RENDER_INTERVAL:
|
||||||
|
yield sse.event("render", render_markdown("".join(accumulated)))
|
||||||
|
last_render, dirty = now, False
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
if message_id in _CANCELLED:
|
||||||
|
stopped = True
|
||||||
|
break
|
||||||
|
|
||||||
for kind, piece in splitter.flush():
|
for kind, piece in splitter.flush():
|
||||||
if kind == REASONING:
|
if kind == REASONING:
|
||||||
thinking.append(piece)
|
thinking.append(piece)
|
||||||
yield sse.event("reasoning", escape_text(piece))
|
yield sse.event("reasoning", escape_text(piece))
|
||||||
else:
|
else:
|
||||||
accumulated.append(piece)
|
accumulated.append(piece)
|
||||||
yield sse.event("token", escape_text(piece))
|
|
||||||
|
|
||||||
except LLMError as exc:
|
except LLMError as exc:
|
||||||
error = exc.message
|
error = exc.message
|
||||||
@@ -280,9 +312,11 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
# The reader navigated away or closed the tab. Keep whatever was
|
# The reader navigated away or closed the tab. Keep whatever was
|
||||||
# produced so the partial reply is still there on reload.
|
# produced so the partial reply is still there on reload.
|
||||||
|
_CANCELLED.discard(message_id)
|
||||||
message.content = "".join(accumulated)
|
message.content = "".join(accumulated)
|
||||||
message.reasoning = "".join(thinking)
|
message.reasoning = "".join(thinking)
|
||||||
message.complete = True
|
message.complete = True
|
||||||
|
message.stopped = True
|
||||||
db.commit()
|
db.commit()
|
||||||
raise
|
raise
|
||||||
except Exception as exc: # noqa: BLE001 - must not kill the stream silently
|
except Exception as exc: # noqa: BLE001 - must not kill the stream silently
|
||||||
@@ -293,11 +327,14 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]:
|
|||||||
# Reasoning ran to the end without an answer following it.
|
# Reasoning ran to the end without an answer following it.
|
||||||
reasoning_ms = int((time.monotonic() - reasoning_started) * 1000)
|
reasoning_ms = int((time.monotonic() - reasoning_started) * 1000)
|
||||||
|
|
||||||
|
_CANCELLED.discard(message_id)
|
||||||
|
|
||||||
message.content = "".join(accumulated)
|
message.content = "".join(accumulated)
|
||||||
message.reasoning = "".join(thinking)
|
message.reasoning = "".join(thinking)
|
||||||
message.reasoning_ms = reasoning_ms
|
message.reasoning_ms = reasoning_ms
|
||||||
message.error = error or ""
|
message.error = error or ""
|
||||||
message.complete = True
|
message.complete = True
|
||||||
|
message.stopped = stopped
|
||||||
log.debug(
|
log.debug(
|
||||||
"chat %s: %d chars answer, %d chars reasoning, %.1fs total",
|
"chat %s: %d chars answer, %d chars reasoning, %.1fs total",
|
||||||
chat_id,
|
chat_id,
|
||||||
@@ -340,6 +377,140 @@ async def _generate(chat_id: str, message_id: str) -> AsyncIterator[str]:
|
|||||||
yield sse.event("close", "")
|
yield sse.event("close", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _thread_context(db: DBSession, chat: Chat, user: User) -> dict:
|
||||||
|
"""Everything chat/_thread.html needs to render the conversation."""
|
||||||
|
messages = list(
|
||||||
|
db.scalars(select(Message).where(Message.chat_id == chat.id).order_by(Message.created_at))
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"chat": chat,
|
||||||
|
"user": user,
|
||||||
|
"messages": messages,
|
||||||
|
"bodies": {
|
||||||
|
m.id: render_markdown(m.content)
|
||||||
|
for m in messages
|
||||||
|
if m.role == ROLE_ASSISTANT and m.content
|
||||||
|
},
|
||||||
|
"models_by_id": {m.model_id: m for m in chat_service.available_models(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)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
chat_service.create_message(
|
||||||
|
db, chat, ROLE_ASSISTANT, "", complete_=False, model_id=chat.model_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}/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.")
|
||||||
|
|
||||||
|
_CANCELLED.add(message.id)
|
||||||
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{chat_id}")
|
@router.patch("/{chat_id}")
|
||||||
async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str) -> Response:
|
async def update_chat(request: Request, db: Db, user: RequiredUser, chat_id: str) -> Response:
|
||||||
"""Partially update a chat.
|
"""Partially update a chat.
|
||||||
|
|||||||
@@ -118,6 +118,9 @@ class Message(UUIDPrimaryKey, Timestamps, Base):
|
|||||||
error: Mapped[str] = mapped_column(Text, default="")
|
error: Mapped[str] = mapped_column(Text, default="")
|
||||||
# False while a reply is still streaming; flipped when the stream ends.
|
# False while a reply is still streaming; flipped when the stream ends.
|
||||||
complete: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
complete: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
# 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)
|
||||||
|
|
||||||
chat: Mapped[Chat] = relationship(back_populates="messages")
|
chat: Mapped[Chat] = relationship(back_populates="messages")
|
||||||
attachments: Mapped[list[Attachment]] = relationship( # noqa: F821
|
attachments: Mapped[list[Attachment]] = relationship( # noqa: F821
|
||||||
|
|||||||
@@ -578,3 +578,200 @@ button, input, textarea, select {
|
|||||||
}
|
}
|
||||||
.sidebar[data-collapsed="true"] { display: none; }
|
.sidebar[data-collapsed="true"] { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Toasts ----------------------------------------------------------------
|
||||||
|
Bottom-right, stacked, above dialogs' backdrop but out of the way of the
|
||||||
|
composer.
|
||||||
|
*/
|
||||||
|
.toasts {
|
||||||
|
position: fixed;
|
||||||
|
right: var(--sp-4);
|
||||||
|
bottom: var(--sp-4);
|
||||||
|
z-index: 60;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
max-width: min(24rem, calc(100vw - var(--sp-8)));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
pointer-events: auto;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(0.5rem);
|
||||||
|
transition: opacity var(--transition), transform var(--transition);
|
||||||
|
}
|
||||||
|
.toast.is-in { opacity: 1; transform: none; }
|
||||||
|
.toast--success { border-left-color: var(--success); }
|
||||||
|
.toast--error { border-left-color: var(--danger); }
|
||||||
|
.toast--warning { border-left-color: var(--warning); }
|
||||||
|
.toast__text { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.toast__close {
|
||||||
|
flex: none;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 0.15rem;
|
||||||
|
}
|
||||||
|
.toast__close:hover { color: var(--ink); }
|
||||||
|
|
||||||
|
/* --- Dialogs ----------------------------------------------------------------
|
||||||
|
<dialog> gives focus trapping, Escape and page inertness for free.
|
||||||
|
*/
|
||||||
|
.dialog {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-xl);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 0;
|
||||||
|
max-width: min(28rem, calc(100vw - var(--sp-8)));
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
.dialog::backdrop { background: var(--scrim); backdrop-filter: blur(2px); }
|
||||||
|
|
||||||
|
.dialog__form { padding: var(--sp-5); display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
|
.dialog__title { font-size: var(--text-lg); }
|
||||||
|
.dialog__message {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--leading-relaxed);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.dialog__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
margin-top: var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A destructive confirmation needs the weight of a filled button, not the
|
||||||
|
outline treatment .btn--danger gives a row action. */
|
||||||
|
.btn--danger-solid {
|
||||||
|
background: var(--danger);
|
||||||
|
border-color: var(--danger);
|
||||||
|
color: var(--ink-inverse);
|
||||||
|
}
|
||||||
|
.btn--danger-solid:hover:not(:disabled) {
|
||||||
|
background: var(--danger-hover);
|
||||||
|
border-color: var(--danger-hover);
|
||||||
|
color: var(--ink-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Model picker ----------------------------------------------------------
|
||||||
|
Built by hand because a <select> renders only text in an <option> -- no
|
||||||
|
avatar, no description, no badges.
|
||||||
|
*/
|
||||||
|
.picker { position: relative; }
|
||||||
|
|
||||||
|
.picker__button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
height: var(--control-h);
|
||||||
|
max-width: 16rem;
|
||||||
|
padding: 0 var(--sp-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--transition-fast);
|
||||||
|
}
|
||||||
|
.picker__button:hover { border-color: var(--border-strong); }
|
||||||
|
.picker__button[aria-expanded="true"] {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
.picker__avatar { width: 1.4rem; height: 1.4rem; border-radius: var(--radius-sm); flex: none; }
|
||||||
|
.picker__label {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.picker__chevron { flex: none; color: var(--ink-faint); }
|
||||||
|
|
||||||
|
.picker__menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--sp-1));
|
||||||
|
right: 0;
|
||||||
|
z-index: 30;
|
||||||
|
width: min(24rem, calc(100vw - var(--sp-8)));
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.picker__search { padding: var(--sp-2); border-bottom: 1px solid var(--border); }
|
||||||
|
.input--sm { height: var(--control-h-sm); font-size: var(--text-xs); }
|
||||||
|
.picker__list { max-height: 22rem; overflow-y: auto; scrollbar-width: thin; padding: var(--sp-1); }
|
||||||
|
|
||||||
|
.picker__option {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--sp-2);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: none;
|
||||||
|
color: var(--ink);
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.picker__option:hover, .picker__option:focus-visible { background: var(--surface-hover); }
|
||||||
|
.picker__option.is-selected { background: var(--accent-soft); }
|
||||||
|
.picker__option .picker__avatar { width: 1.75rem; height: 1.75rem; margin-top: 0.1rem; }
|
||||||
|
.picker__option-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 0.15rem; }
|
||||||
|
.picker__option-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.picker__pin { color: var(--gold); }
|
||||||
|
.picker__option-desc {
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
line-height: var(--leading-normal);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.picker__option-tags { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||||
|
.picker__option-tags:empty { display: none; }
|
||||||
|
.tag {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--gold-soft);
|
||||||
|
color: var(--gold);
|
||||||
|
}
|
||||||
|
.picker__tick { color: var(--accent); flex: none; margin-top: 0.35rem; }
|
||||||
|
.picker__empty {
|
||||||
|
padding: var(--sp-4);
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|||||||
@@ -89,8 +89,7 @@
|
|||||||
}
|
}
|
||||||
/* User turns and mid-stream assistant text are plain text, so newlines and
|
/* User turns and mid-stream assistant text are plain text, so newlines and
|
||||||
runs of spaces have to survive. */
|
runs of spaces have to survive. */
|
||||||
.msg__body--plain,
|
.msg__body--plain { white-space: pre-wrap; }
|
||||||
.msg__body--streaming { white-space: pre-wrap; }
|
|
||||||
|
|
||||||
.msg--user .msg__body--plain {
|
.msg--user .msg__body--plain {
|
||||||
background: var(--bubble-user);
|
background: var(--bubble-user);
|
||||||
@@ -106,7 +105,6 @@
|
|||||||
/* Shown until the first token arrives, then hidden by the sibling selector
|
/* Shown until the first token arrives, then hidden by the sibling selector
|
||||||
below -- no JavaScript involved in either direction. */
|
below -- no JavaScript involved in either direction. */
|
||||||
.msg__waiting { padding: var(--sp-2) 0; }
|
.msg__waiting { padding: var(--sp-2) 0; }
|
||||||
.msg__body--streaming:not(:empty) + .msg__waiting { display: none; }
|
|
||||||
|
|
||||||
.dots { display: inline-flex; gap: 0.25rem; align-items: center; }
|
.dots { display: inline-flex; gap: 0.25rem; align-items: center; }
|
||||||
.dots i {
|
.dots i {
|
||||||
@@ -124,8 +122,9 @@
|
|||||||
30% { opacity: 1; transform: translateY(-2px); }
|
30% { opacity: 1; transform: translateY(-2px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* A caret trailing the text while it streams. */
|
/* A caret trailing the text while it streams. Attached to the last block so it
|
||||||
.msg__body--streaming::after {
|
sits at the end of the prose rather than on a line of its own. */
|
||||||
|
.msg__body--live:not(:empty) > :last-child::after {
|
||||||
content: "";
|
content: "";
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 0.45rem;
|
width: 0.45rem;
|
||||||
@@ -198,6 +197,34 @@
|
|||||||
50% { opacity: 1; }
|
50% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Stop, notes and editing ---------------------------------------------- */
|
||||||
|
.msg__waiting {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-3);
|
||||||
|
padding: var(--sp-2) 0;
|
||||||
|
}
|
||||||
|
/* Once the answer has content the caret carries the "still going" signal, so
|
||||||
|
the dots go, but Stop must stay reachable until the stream ends. */
|
||||||
|
.msg__body--live:not(:empty) + .msg__waiting .dots { display: none; }
|
||||||
|
|
||||||
|
.msg__stop { color: var(--ink-muted); }
|
||||||
|
.msg__stop:hover { color: var(--danger); border-color: var(--danger); }
|
||||||
|
|
||||||
|
.msg__note {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-1);
|
||||||
|
margin: var(--sp-2) 0 0;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg--editing .msg__main { width: 100%; }
|
||||||
|
.edit-form { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
|
.edit-form .textarea { min-height: 4rem; }
|
||||||
|
|
||||||
/* --- Message actions ------------------------------------------------------ */
|
/* --- Message actions ------------------------------------------------------ */
|
||||||
.msg__actions {
|
.msg__actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -416,6 +443,7 @@
|
|||||||
.chip--error .chip__icon { color: var(--danger); }
|
.chip--error .chip__icon { color: var(--danger); }
|
||||||
|
|
||||||
.chip__thumb {
|
.chip__thumb {
|
||||||
|
display: block;
|
||||||
width: 2.25rem;
|
width: 2.25rem;
|
||||||
height: 2.25rem;
|
height: 2.25rem;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -455,19 +483,22 @@
|
|||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
margin-bottom: var(--sp-2);
|
margin-bottom: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
/* inline-block, not block: as a block the anchor filled the column and drew its
|
||||||
|
border at full width around a narrow image. Now the frame is the picture. */
|
||||||
.attachments__image {
|
.attachments__image {
|
||||||
display: block;
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
}
|
}
|
||||||
.attachments__image img {
|
.attachments__image img {
|
||||||
|
display: block;
|
||||||
max-width: min(22rem, 100%);
|
max-width: min(22rem, 100%);
|
||||||
max-height: 20rem;
|
max-height: 20rem;
|
||||||
width: auto;
|
width: auto;
|
||||||
height: auto;
|
height: auto;
|
||||||
object-fit: contain;
|
|
||||||
}
|
}
|
||||||
.attachments__doc {
|
.attachments__doc {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -0,0 +1,380 @@
|
|||||||
|
/*
|
||||||
|
Toasts and dialogs.
|
||||||
|
|
||||||
|
Replaces window.confirm/prompt, which cannot be styled, ignore the theme, and
|
||||||
|
block the whole tab. Dialogs are built on <dialog>, so focus trapping, Escape
|
||||||
|
and inertness of the page behind come from the browser rather than from
|
||||||
|
hand-written key handling.
|
||||||
|
|
||||||
|
Everything returns a Promise, so callers read as if they were still using the
|
||||||
|
built-ins:
|
||||||
|
|
||||||
|
if (await lembas.confirm({ message: "Delete this?" })) { ... }
|
||||||
|
const name = await lembas.prompt({ message: "New name", value: old });
|
||||||
|
lembas.notify("Saved.", { kind: "success" });
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var TOAST_MS = 4000;
|
||||||
|
|
||||||
|
function el(tag, className, text) {
|
||||||
|
var node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
// textContent, never innerHTML: these messages carry filenames, chat
|
||||||
|
// titles and upstream error text, none of which is ours to trust.
|
||||||
|
if (text != null) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Toasts ------------------------------------------------------------ */
|
||||||
|
function toastHost() {
|
||||||
|
var host = document.getElementById("toasts");
|
||||||
|
if (!host) {
|
||||||
|
host = el("div", "toasts");
|
||||||
|
host.id = "toasts";
|
||||||
|
// Announced politely so a screen reader hears it without being yanked
|
||||||
|
// away from whatever it was reading.
|
||||||
|
host.setAttribute("role", "status");
|
||||||
|
host.setAttribute("aria-live", "polite");
|
||||||
|
document.body.appendChild(host);
|
||||||
|
}
|
||||||
|
return host;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(message, options) {
|
||||||
|
options = options || {};
|
||||||
|
var toast = el("div", "toast toast--" + (options.kind || "info"));
|
||||||
|
toast.appendChild(el("span", "toast__text", message));
|
||||||
|
|
||||||
|
var close = el("button", "toast__close");
|
||||||
|
close.type = "button";
|
||||||
|
close.setAttribute("aria-label", "Dismiss");
|
||||||
|
close.textContent = "×";
|
||||||
|
close.addEventListener("click", function () { dismiss(toast); });
|
||||||
|
toast.appendChild(close);
|
||||||
|
|
||||||
|
toastHost().appendChild(toast);
|
||||||
|
// Next frame, so the entry transition has a state to move from.
|
||||||
|
requestAnimationFrame(function () { toast.classList.add("is-in"); });
|
||||||
|
|
||||||
|
var timeout = options.timeout == null ? TOAST_MS : options.timeout;
|
||||||
|
if (timeout > 0) setTimeout(function () { dismiss(toast); }, timeout);
|
||||||
|
return toast;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(toast) {
|
||||||
|
if (!toast || toast.dataset.going) return;
|
||||||
|
toast.dataset.going = "1";
|
||||||
|
toast.classList.remove("is-in");
|
||||||
|
setTimeout(function () { toast.remove(); }, 180);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Dialogs ----------------------------------------------------------- */
|
||||||
|
function buildDialog(options) {
|
||||||
|
var dialog = el("dialog", "dialog");
|
||||||
|
var form = el("form", "dialog__form");
|
||||||
|
form.method = "dialog";
|
||||||
|
|
||||||
|
if (options.title) form.appendChild(el("h2", "dialog__title", options.title));
|
||||||
|
if (options.message) form.appendChild(el("p", "dialog__message", options.message));
|
||||||
|
|
||||||
|
var input = null;
|
||||||
|
if (options.kind === "prompt") {
|
||||||
|
input = el("input", "input");
|
||||||
|
input.type = "text";
|
||||||
|
input.value = options.value || "";
|
||||||
|
if (options.placeholder) input.placeholder = options.placeholder;
|
||||||
|
input.setAttribute("aria-label", options.title || options.message || "Value");
|
||||||
|
form.appendChild(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
var actions = el("div", "dialog__actions");
|
||||||
|
|
||||||
|
var cancel = el("button", "btn", options.cancelLabel || "Cancel");
|
||||||
|
cancel.type = "button";
|
||||||
|
cancel.value = "cancel";
|
||||||
|
actions.appendChild(cancel);
|
||||||
|
|
||||||
|
var accept = el(
|
||||||
|
"button",
|
||||||
|
"btn " + (options.danger ? "btn--danger-solid" : "btn--primary"),
|
||||||
|
options.confirmLabel || "OK"
|
||||||
|
);
|
||||||
|
accept.type = "submit";
|
||||||
|
accept.value = "accept";
|
||||||
|
actions.appendChild(accept);
|
||||||
|
|
||||||
|
form.appendChild(actions);
|
||||||
|
dialog.appendChild(form);
|
||||||
|
document.body.appendChild(dialog);
|
||||||
|
|
||||||
|
return { dialog: dialog, form: form, input: input, cancel: cancel, accept: accept };
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(options) {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
var parts = buildDialog(options);
|
||||||
|
var settled = false;
|
||||||
|
|
||||||
|
function finish(value) {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
resolve(value);
|
||||||
|
parts.dialog.close();
|
||||||
|
// Let the closing transition finish before the node disappears.
|
||||||
|
setTimeout(function () { parts.dialog.remove(); }, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
parts.cancel.addEventListener("click", function () { finish(options.kind === "prompt" ? null : false); });
|
||||||
|
parts.form.addEventListener("submit", function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
finish(options.kind === "prompt" ? (parts.input.value || "") : true);
|
||||||
|
});
|
||||||
|
// Escape and the backdrop both mean "no".
|
||||||
|
parts.dialog.addEventListener("cancel", function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
finish(options.kind === "prompt" ? null : false);
|
||||||
|
});
|
||||||
|
parts.dialog.addEventListener("click", function (event) {
|
||||||
|
if (event.target === parts.dialog) finish(options.kind === "prompt" ? null : false);
|
||||||
|
});
|
||||||
|
|
||||||
|
parts.dialog.showModal();
|
||||||
|
if (parts.input) {
|
||||||
|
parts.input.focus();
|
||||||
|
parts.input.select();
|
||||||
|
} else {
|
||||||
|
(options.danger ? parts.cancel : parts.accept).focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm(options) {
|
||||||
|
if (typeof options === "string") options = { message: options };
|
||||||
|
return open(Object.assign({ kind: "confirm", confirmLabel: "OK" }, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
function prompt(options) {
|
||||||
|
if (typeof options === "string") options = { message: options };
|
||||||
|
return open(Object.assign({ kind: "prompt", confirmLabel: "Save" }, options));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.lembas = window.lembas || {};
|
||||||
|
window.lembas.notify = notify;
|
||||||
|
window.lembas.confirm = confirm;
|
||||||
|
window.lembas.prompt = prompt;
|
||||||
|
|
||||||
|
/* --- htmx integration --------------------------------------------------
|
||||||
|
hx-confirm normally calls window.confirm. Intercepting the event lets every
|
||||||
|
existing hx-confirm attribute keep working while getting the themed dialog,
|
||||||
|
with no change at the call sites. */
|
||||||
|
document.addEventListener("htmx:confirm", function (event) {
|
||||||
|
if (!event.detail.question) return; // no confirmation asked for
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
var trigger = event.detail.elt;
|
||||||
|
confirm({
|
||||||
|
title: trigger && trigger.dataset.confirmTitle,
|
||||||
|
message: event.detail.question,
|
||||||
|
confirmLabel: (trigger && trigger.dataset.confirmLabel) || "Delete",
|
||||||
|
danger: !trigger || trigger.dataset.confirmDanger !== "false",
|
||||||
|
}).then(function (ok) {
|
||||||
|
if (ok) event.detail.issueRequest(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/* A submit button that acts on its own (formaction) rather than the form it
|
||||||
|
sits in. Confirming the whole form would be wrong: the same form also has
|
||||||
|
a plain Save. */
|
||||||
|
document.addEventListener("click", function (event) {
|
||||||
|
var button = event.target.closest("[data-confirm-button]");
|
||||||
|
if (!button || button.dataset.confirmed) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
confirm({
|
||||||
|
title: button.dataset.confirmTitle,
|
||||||
|
message: button.dataset.confirmButton,
|
||||||
|
confirmLabel: button.dataset.confirmLabel || "Delete",
|
||||||
|
danger: button.dataset.confirmDanger !== "false",
|
||||||
|
}).then(function (ok) {
|
||||||
|
if (!ok) return;
|
||||||
|
button.dataset.confirmed = "1";
|
||||||
|
button.click();
|
||||||
|
delete button.dataset.confirmed;
|
||||||
|
});
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
/* Plain forms opt in with data-confirm, so they need no inline onsubmit. */
|
||||||
|
document.addEventListener("submit", function (event) {
|
||||||
|
var form = event.target;
|
||||||
|
if (!form.dataset || !form.dataset.confirm || form.dataset.confirmed) return;
|
||||||
|
event.preventDefault();
|
||||||
|
confirm({
|
||||||
|
title: form.dataset.confirmTitle,
|
||||||
|
message: form.dataset.confirm,
|
||||||
|
confirmLabel: form.dataset.confirmLabel || "Delete",
|
||||||
|
danger: form.dataset.confirmDanger !== "false",
|
||||||
|
}).then(function (ok) {
|
||||||
|
if (!ok) return;
|
||||||
|
form.dataset.confirmed = "1";
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
}, true);
|
||||||
|
})();
|
||||||
|
|
||||||
|
/*
|
||||||
|
The model picker.
|
||||||
|
|
||||||
|
A <select> cannot render an avatar, a description or capability badges, so
|
||||||
|
the control is built out of buttons and a hidden input. Keyboard behaviour is
|
||||||
|
written out by hand for the same reason -- there is no native widget doing it
|
||||||
|
for us.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
function close(picker) {
|
||||||
|
var menu = picker.querySelector("[data-picker-menu]");
|
||||||
|
var toggle = picker.querySelector("[data-picker-toggle]");
|
||||||
|
if (!menu || menu.hidden) return;
|
||||||
|
menu.hidden = true;
|
||||||
|
toggle.setAttribute("aria-expanded", "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAll(except) {
|
||||||
|
document.querySelectorAll("[data-picker]").forEach(function (picker) {
|
||||||
|
if (picker !== except) close(picker);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function open(picker) {
|
||||||
|
var menu = picker.querySelector("[data-picker-menu]");
|
||||||
|
var toggle = picker.querySelector("[data-picker-toggle]");
|
||||||
|
closeAll(picker);
|
||||||
|
menu.hidden = false;
|
||||||
|
toggle.setAttribute("aria-expanded", "true");
|
||||||
|
|
||||||
|
var filter = menu.querySelector("[data-picker-filter]");
|
||||||
|
if (filter) {
|
||||||
|
filter.value = "";
|
||||||
|
applyFilter(menu, "");
|
||||||
|
filter.focus();
|
||||||
|
} else {
|
||||||
|
var selected = menu.querySelector(".picker__option.is-selected") ||
|
||||||
|
menu.querySelector(".picker__option");
|
||||||
|
if (selected) selected.focus();
|
||||||
|
}
|
||||||
|
// Keep the chosen model in view when the list is long.
|
||||||
|
var current = menu.querySelector(".picker__option.is-selected");
|
||||||
|
if (current) current.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilter(menu, needle) {
|
||||||
|
var shown = 0;
|
||||||
|
menu.querySelectorAll(".picker__option").forEach(function (option) {
|
||||||
|
var match = !needle || option.dataset.pickerSearch.indexOf(needle) !== -1;
|
||||||
|
option.hidden = !match;
|
||||||
|
if (match) shown += 1;
|
||||||
|
});
|
||||||
|
var empty = menu.querySelector("[data-picker-empty]");
|
||||||
|
if (empty) empty.hidden = shown > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(picker, value) {
|
||||||
|
var navigate = picker.querySelector("[data-picker-navigate]");
|
||||||
|
if (navigate) {
|
||||||
|
window.location = navigate.dataset.pickerNavigate + encodeURIComponent(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var input = picker.querySelector("[data-picker-input]");
|
||||||
|
if (input) {
|
||||||
|
input.value = value;
|
||||||
|
// htmx listens for change on the input; assigning .value does not fire it.
|
||||||
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reflect the choice immediately rather than waiting for a reload.
|
||||||
|
picker.querySelectorAll(".picker__option").forEach(function (option) {
|
||||||
|
var selected = option.dataset.pickerValue === value;
|
||||||
|
option.classList.toggle("is-selected", selected);
|
||||||
|
option.setAttribute("aria-selected", selected ? "true" : "false");
|
||||||
|
});
|
||||||
|
var chosen = picker.querySelector('[data-picker-value="' + CSS.escape(value) + '"]');
|
||||||
|
var label = picker.querySelector(".picker__label");
|
||||||
|
var avatar = picker.querySelector(".picker__button .picker__avatar");
|
||||||
|
if (chosen && label) {
|
||||||
|
label.textContent = chosen.querySelector(".picker__option-name").textContent.trim();
|
||||||
|
}
|
||||||
|
if (chosen && avatar) {
|
||||||
|
var source = chosen.querySelector(".picker__avatar");
|
||||||
|
if (source) avatar.replaceWith(source.cloneNode(true));
|
||||||
|
}
|
||||||
|
close(picker);
|
||||||
|
if (window.lembas && window.lembas.notify) {
|
||||||
|
window.lembas.notify("Model switched to " + (label ? label.textContent : value), {
|
||||||
|
kind: "info", timeout: 2000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", function (event) {
|
||||||
|
var toggle = event.target.closest("[data-picker-toggle]");
|
||||||
|
if (toggle) {
|
||||||
|
var picker = toggle.closest("[data-picker]");
|
||||||
|
var menu = picker.querySelector("[data-picker-menu]");
|
||||||
|
if (menu.hidden) open(picker); else close(picker);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var option = event.target.closest("[data-picker-value]");
|
||||||
|
if (option) {
|
||||||
|
choose(option.closest("[data-picker]"), option.dataset.pickerValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event.target.closest("[data-picker-menu]")) closeAll(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("input", function (event) {
|
||||||
|
if (!event.target.matches("[data-picker-filter]")) return;
|
||||||
|
applyFilter(
|
||||||
|
event.target.closest("[data-picker-menu]"),
|
||||||
|
event.target.value.trim().toLowerCase()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function (event) {
|
||||||
|
var picker = event.target.closest("[data-picker]");
|
||||||
|
if (!picker) return;
|
||||||
|
var menu = picker.querySelector("[data-picker-menu]");
|
||||||
|
|
||||||
|
if (event.key === "Escape" && !menu.hidden) {
|
||||||
|
event.preventDefault();
|
||||||
|
close(picker);
|
||||||
|
picker.querySelector("[data-picker-toggle]").focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (menu.hidden) {
|
||||||
|
if (event.key === "ArrowDown" || event.key === "Enter") {
|
||||||
|
if (event.target.matches("[data-picker-toggle]")) {
|
||||||
|
event.preventDefault();
|
||||||
|
open(picker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
||||||
|
event.preventDefault();
|
||||||
|
var options = Array.prototype.filter.call(
|
||||||
|
menu.querySelectorAll(".picker__option"), function (o) { return !o.hidden; }
|
||||||
|
);
|
||||||
|
if (!options.length) return;
|
||||||
|
var at = options.indexOf(document.activeElement);
|
||||||
|
var step = event.key === "ArrowDown" ? 1 : -1;
|
||||||
|
var next = at === -1 ? 0 : (at + step + options.length) % options.length;
|
||||||
|
options[next].focus();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<button class="btn btn--sm btn--danger" type="submit"
|
<button class="btn btn--sm btn--danger" type="submit"
|
||||||
formaction="/admin/connections/{{ connection.id }}/delete" formnovalidate
|
formaction="/admin/connections/{{ connection.id }}/delete" formnovalidate
|
||||||
onclick="return confirm('Delete the connection “{{ connection.name }}”? Existing chats keep their history.')">
|
data-confirm-button="Delete the connection “{{ connection.name }}”? Existing chats keep their history.">
|
||||||
{{ icon("trash", "icon--sm") }} Delete
|
{{ icon("trash", "icon--sm") }} Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -158,7 +158,8 @@
|
|||||||
<div class="card__footer">
|
<div class="card__footer">
|
||||||
<span class="text-xs faint">Deleting a group leaves its members alone.</span>
|
<span class="text-xs faint">Deleting a group leaves its members alone.</span>
|
||||||
<form method="post" action="/admin/groups/{{ group.id }}/delete"
|
<form method="post" action="/admin/groups/{{ group.id }}/delete"
|
||||||
onsubmit="return confirm('Delete the group “{{ group.name }}”?')">
|
data-confirm="Delete the group “{{ group.name }}”? Its members keep their accounts."
|
||||||
|
data-confirm-title="Delete group">
|
||||||
<button class="btn btn--sm btn--danger" type="submit">
|
<button class="btn btn--sm btn--danger" type="submit">
|
||||||
{{ icon("trash", "icon--sm") }} Delete
|
{{ icon("trash", "icon--sm") }} Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -150,7 +150,8 @@
|
|||||||
|
|
||||||
{% if account.id != user.id %}
|
{% if account.id != user.id %}
|
||||||
<form method="post" action="/admin/users/{{ account.id }}/delete"
|
<form method="post" action="/admin/users/{{ account.id }}/delete"
|
||||||
onsubmit="return confirm('Delete {{ account.email }} and all their chats? This cannot be undone.')">
|
data-confirm="Delete {{ account.email }} and all their chats? This cannot be undone."
|
||||||
|
data-confirm-title="Delete account">
|
||||||
<button class="btn btn--sm btn--danger" type="submit">
|
<button class="btn btn--sm btn--danger" type="submit">
|
||||||
{{ icon("trash", "icon--sm") }} Delete
|
{{ icon("trash", "icon--sm") }} Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
<script src="{{ url_for('static', path='vendor/htmx-ext-sse.js') }}" defer></script>
|
<script src="{{ url_for('static', path='vendor/htmx-ext-sse.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', path='vendor/alpine.min.js') }}" defer></script>
|
<script src="{{ url_for('static', path='vendor/alpine.min.js') }}" defer></script>
|
||||||
<script src="{{ url_for('static', path='js/app.js') }}" defer></script>
|
<script src="{{ url_for('static', path='js/app.js') }}" defer></script>
|
||||||
|
<script src="{{ url_for('static', path='js/ui.js') }}" defer></script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{% from "_macros.html" import icon %}
|
||||||
|
{#
|
||||||
|
A user turn switched into edit mode, replacing its bubble in place.
|
||||||
|
|
||||||
|
Saving rewinds: the message is rewritten and everything after it is deleted,
|
||||||
|
then the conversation runs again from that point. That is destructive, so the
|
||||||
|
button says so and asks first.
|
||||||
|
#}
|
||||||
|
<article class="msg msg--user msg--editing" id="msg-{{ message.id }}">
|
||||||
|
<div class="msg__gutter" aria-hidden="true">
|
||||||
|
<span class="msg__initial">{{ (user.name or "?")[0]|upper }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="msg__main">
|
||||||
|
<header class="msg__meta">
|
||||||
|
<span class="msg__author">{{ user.name or "You" }}</span>
|
||||||
|
<span class="msg__model">editing</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="edit-form"
|
||||||
|
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/edit"
|
||||||
|
hx-target="#thread" hx-swap="innerHTML"
|
||||||
|
hx-confirm="Rewind here? The {{ following }} message{{ '' if following == 1 else 's' }} after this one will be deleted."
|
||||||
|
data-confirm-title="Rewind the conversation"
|
||||||
|
data-confirm-label="Rewind and send">
|
||||||
|
<textarea class="textarea" name="content" rows="3" data-autosize
|
||||||
|
data-max-height="320" aria-label="Edit message"
|
||||||
|
autofocus>{{ message.content }}</textarea>
|
||||||
|
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn--primary" type="submit">
|
||||||
|
{{ icon("refresh", "icon--sm") }}
|
||||||
|
{% if following %}Rewind and send{% else %}Send again{% endif %}
|
||||||
|
</button>
|
||||||
|
<button class="btn" type="button"
|
||||||
|
hx-get="/api/chats/{{ chat.id }}/messages/{{ message.id }}/cancel-edit"
|
||||||
|
hx-target="#msg-{{ message.id }}" hx-swap="outerHTML">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{% if following %}
|
||||||
|
<span class="text-xs faint">
|
||||||
|
{{ following }} later message{{ '' if following == 1 else 's' }} will be discarded.
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
@@ -87,26 +87,32 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if streaming %}
|
{% if streaming %}
|
||||||
{# Reasoning arrives before the answer, so this block sits above it. It
|
{# Reasoning arrives before the answer, so this block sits above it.
|
||||||
starts open (watching a model think is the point) and the :has() rule
|
Closed by default -- the answer is what the reader is waiting for, and
|
||||||
in chat.css hides the whole thing while it is still empty, so models
|
the thinking is one click away. The :has() rule in chat.css hides the
|
||||||
that emit no reasoning never show an empty box. #}
|
whole thing while it is still empty, so models that emit no reasoning
|
||||||
<details class="reasoning reasoning--live" id="reasoning-{{ message.id }}" open>
|
never show an empty box. #}
|
||||||
|
<details class="reasoning reasoning--live" id="reasoning-{{ message.id }}">
|
||||||
<summary class="reasoning__summary">
|
<summary class="reasoning__summary">
|
||||||
{{ icon("sparkle", "icon--sm reasoning__icon") }}
|
{{ icon("sparkle", "icon--sm reasoning__icon") }}
|
||||||
<span class="reasoning__label">Thinking</span>
|
<span class="reasoning__label">Thinking…</span>
|
||||||
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
|
{{ icon("chevron-down", "icon--sm reasoning__chevron") }}
|
||||||
</summary>
|
</summary>
|
||||||
<div class="reasoning__body" sse-swap="reasoning" hx-swap="beforeend"></div>
|
<div class="reasoning__body" sse-swap="reasoning" hx-swap="beforeend"></div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
{# Tokens are appended here as they arrive. The cursor is a CSS
|
{# The server re-renders the answer as Markdown a few times a second and
|
||||||
pseudo-element on the empty parent, so it disappears by itself once
|
replaces this whole block, so formatting appears as the model writes
|
||||||
the first token lands. #}
|
rather than snapping into place at the end. #}
|
||||||
<div class="msg__body msg__body--streaming" id="stream-{{ message.id }}"
|
<div class="msg__body msg__body--live" id="stream-{{ message.id }}"
|
||||||
sse-swap="token" hx-swap="beforeend"></div>
|
sse-swap="render" hx-swap="innerHTML"></div>
|
||||||
<div class="msg__waiting">
|
<div class="msg__waiting">
|
||||||
<span class="dots"><i></i><i></i><i></i></span>
|
<span class="dots"><i></i><i></i><i></i></span>
|
||||||
|
<button class="btn btn--sm msg__stop" type="button"
|
||||||
|
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/stop"
|
||||||
|
hx-swap="none">
|
||||||
|
{{ icon("x", "icon--sm") }} Stop
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% elif message.reasoning and not message.error %}
|
{% elif message.reasoning and not message.error %}
|
||||||
{# Collapsed once finished: the answer is what the reader came for, and
|
{# Collapsed once finished: the answer is what the reader came for, and
|
||||||
@@ -140,6 +146,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% elif message.role == "assistant" %}
|
{% elif message.role == "assistant" %}
|
||||||
<div class="msg__body">{{ body_html|safe }}</div>
|
<div class="msg__body">{{ body_html|safe }}</div>
|
||||||
|
{% if message.stopped %}
|
||||||
|
<p class="msg__note">{{ icon("x", "icon--sm") }} Stopped. This reply is cut short.</p>
|
||||||
|
{% endif %}
|
||||||
{% elif message.content %}
|
{% elif message.content %}
|
||||||
<div class="msg__body msg__body--plain">{{ message.content }}</div>
|
<div class="msg__body msg__body--plain">{{ message.content }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -152,6 +161,14 @@
|
|||||||
data-copy="msg-body-{{ message.id }}" aria-label="Copy message">
|
data-copy="msg-body-{{ message.id }}" aria-label="Copy message">
|
||||||
{{ icon("copy", "icon--sm") }}
|
{{ icon("copy", "icon--sm") }}
|
||||||
</button>
|
</button>
|
||||||
|
{% if message.role == "user" %}
|
||||||
|
<button class="btn btn--icon btn--sm" type="button"
|
||||||
|
hx-get="/api/chats/{{ chat.id }}/messages/{{ message.id }}/edit"
|
||||||
|
hx-target="#msg-{{ message.id }}" hx-swap="outerHTML"
|
||||||
|
aria-label="Edit and retry from here">
|
||||||
|
{{ icon("pencil", "icon--sm") }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
{% if message.role == "assistant" %}
|
{% if message.role == "assistant" %}
|
||||||
<button class="btn btn--icon btn--sm" type="button"
|
<button class="btn btn--icon btn--sm" type="button"
|
||||||
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/regenerate"
|
hx-post="/api/chats/{{ chat.id }}/messages/{{ message.id }}/regenerate"
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{% from "_macros.html" import icon, model_avatar %}
|
||||||
|
{#
|
||||||
|
Model picker.
|
||||||
|
|
||||||
|
A real dropdown rather than a <select>, because a <select> cannot show an
|
||||||
|
image, a description or capability badges -- browsers render only text in an
|
||||||
|
<option>. The hidden input is what actually carries the value, so the control
|
||||||
|
still behaves like a form field.
|
||||||
|
|
||||||
|
Inside a chat it PATCHes the chat; on /chat it navigates, because there is no
|
||||||
|
chat row to patch yet.
|
||||||
|
#}
|
||||||
|
<div class="picker" data-picker>
|
||||||
|
<button class="picker__button" type="button" data-picker-toggle
|
||||||
|
aria-haspopup="listbox" aria-expanded="false">
|
||||||
|
{% if current_model %}
|
||||||
|
{{ model_avatar(current_model, cls="picker__avatar") }}
|
||||||
|
<span class="picker__label">{{ current_model.label }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="picker__label">Choose a model</span>
|
||||||
|
{% endif %}
|
||||||
|
{{ icon("chevron-down", "icon--sm picker__chevron") }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="picker__menu" data-picker-menu role="listbox" hidden
|
||||||
|
aria-label="Models">
|
||||||
|
{% if models|length > 8 %}
|
||||||
|
<div class="picker__search">
|
||||||
|
<input class="input input--sm" type="search" data-picker-filter
|
||||||
|
placeholder="Filter models…" aria-label="Filter models">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="picker__list">
|
||||||
|
{% for model in models %}
|
||||||
|
<button class="picker__option {{ 'is-selected' if current_model and model.model_id == current_model.model_id }}"
|
||||||
|
type="button" role="option"
|
||||||
|
aria-selected="{{ 'true' if current_model and model.model_id == current_model.model_id else 'false' }}"
|
||||||
|
data-picker-value="{{ model.model_id }}"
|
||||||
|
data-picker-search="{{ model.label|lower }} {{ model.model_id|lower }}">
|
||||||
|
{{ model_avatar(model, cls="picker__avatar") }}
|
||||||
|
<span class="picker__option-body">
|
||||||
|
<span class="picker__option-name">
|
||||||
|
{{ model.label }}
|
||||||
|
{% if model.pinned %}{{ icon("pin", "icon--sm picker__pin") }}{% endif %}
|
||||||
|
</span>
|
||||||
|
{% if model.description %}
|
||||||
|
<span class="picker__option-desc">{{ model.description }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="picker__option-tags">
|
||||||
|
{% for name, on in (model.capabilities_json or {}).items() %}
|
||||||
|
{% if on %}<span class="tag">{{ name }}</span>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{% if current_model and model.model_id == current_model.model_id %}
|
||||||
|
{{ icon("check", "icon--sm picker__tick") }}
|
||||||
|
{% endif %}
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="picker__empty" data-picker-empty hidden>No model matches that.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if chat %}
|
||||||
|
{# Changing the value fires the PATCH; htmx serialises the hidden input. #}
|
||||||
|
<form hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
||||||
|
hx-trigger="change from:find input" data-picker-form>
|
||||||
|
<input type="hidden" name="model_id" data-picker-input
|
||||||
|
value="{{ current_model.model_id if current_model else '' }}">
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
{# No chat yet: selecting navigates so the whole composer re-renders with the
|
||||||
|
right vision warning and the right hidden model_id. #}
|
||||||
|
<span hidden data-picker-navigate="/chat?model="></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{#
|
||||||
|
The whole thread. Returned after a rewind, which changes an arbitrary number
|
||||||
|
of messages at once -- replacing the lot is simpler and less error-prone than
|
||||||
|
working out which individual bubbles to remove.
|
||||||
|
#}
|
||||||
|
{% for message in messages %}
|
||||||
|
{% with body_html = bodies.get(message.id, "") %}
|
||||||
|
{% include "chat/_message.html" %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
@@ -27,26 +27,7 @@
|
|||||||
<div class="topbar__actions">
|
<div class="topbar__actions">
|
||||||
{% if models %}
|
{% if models %}
|
||||||
{% if can.get("chat.model_select") or not chat %}
|
{% if can.get("chat.model_select") or not chat %}
|
||||||
{# Models are listed in the administrator's order. Pinning is a
|
{% include "chat/_model_picker.html" %}
|
||||||
sidebar shortcut and deliberately does not reorder this. #}
|
|
||||||
<label class="model-select">
|
|
||||||
{% if current_model %}{{ model_avatar(current_model, cls="model-select__avatar") }}{% endif %}
|
|
||||||
<select class="select select--bare" name="model_id" id="model-select"
|
|
||||||
aria-label="Model"
|
|
||||||
{% if chat %}hx-patch="/api/chats/{{ chat.id }}" hx-swap="none"
|
|
||||||
hx-trigger="change"{% else %}onchange="
|
|
||||||
const u = new URL(window.location);
|
|
||||||
u.searchParams.set('model', this.value);
|
|
||||||
window.location = u;
|
|
||||||
"{% endif %}>
|
|
||||||
{% for model in models %}
|
|
||||||
<option value="{{ model.model_id }}"
|
|
||||||
{{ 'selected' if current_model and model.model_id == current_model.model_id }}>
|
|
||||||
{{ model.label }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
{% elif current_model %}
|
{% elif current_model %}
|
||||||
<span class="badge">{{ current_model.label }}</span>
|
<span class="badge">{{ current_model.label }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<button class="btn btn--icon btn--sm" type="button"
|
<button class="btn btn--icon btn--sm" type="button"
|
||||||
hx-delete="/api/chats/{{ chat_item.id }}"
|
hx-delete="/api/chats/{{ chat_item.id }}"
|
||||||
hx-confirm="Delete “{{ chat_item.title }}”? This cannot be undone."
|
hx-confirm="Delete “{{ chat_item.title }}”? This cannot be undone."
|
||||||
|
data-confirm-title="Delete chat"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
aria-label="Delete chat">
|
aria-label="Delete chat">
|
||||||
{{ icon("trash", "icon--sm") }}
|
{{ icon("trash", "icon--sm") }}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<button class="btn btn--icon btn--sm" type="button"
|
<button class="btn btn--icon btn--sm" type="button"
|
||||||
hx-delete="/api/folders/{{ folder.id }}"
|
hx-delete="/api/folders/{{ folder.id }}"
|
||||||
hx-confirm="Delete the folder “{{ folder.name }}”? Chats inside it are kept."
|
hx-confirm="Delete the folder “{{ folder.name }}”? Chats inside it are kept."
|
||||||
|
data-confirm-title="Delete folder"
|
||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
aria-label="Delete folder">
|
aria-label="Delete folder">
|
||||||
{{ icon("trash", "icon--sm") }}
|
{{ icon("trash", "icon--sm") }}
|
||||||
|
|||||||
+150
-1
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from lembas.db.models import Chat, Connection, Folder, Message, Model
|
from lembas.db.models import Chat, Connection, Folder, Message, Model
|
||||||
from lembas.services import chat as chat_service
|
from lembas.services import chat as chat_service
|
||||||
@@ -352,3 +352,152 @@ def test_streaming_reports_an_unreachable_endpoint_in_the_thread(
|
|||||||
db.refresh(message)
|
db.refresh(message)
|
||||||
assert message.complete is True
|
assert message.complete is True
|
||||||
assert message.error
|
assert message.error
|
||||||
|
|
||||||
|
|
||||||
|
# --- Stopping a stream -------------------------------------------------------
|
||||||
|
def test_stopping_marks_the_message_and_keeps_what_arrived(
|
||||||
|
client: TestClient, db, registered, make_chat
|
||||||
|
):
|
||||||
|
"""A half-written answer the reader chose to cut short is still worth
|
||||||
|
having; discarding it would be a surprise."""
|
||||||
|
from lembas.api.chats import _CANCELLED
|
||||||
|
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = make_chat()
|
||||||
|
client.post(f"/api/chats/{chat_id}/messages", data={"content": "hi"})
|
||||||
|
message = db.scalar(select(Message).where(Message.role == "assistant"))
|
||||||
|
|
||||||
|
assert client.post(
|
||||||
|
f"/api/chats/{chat_id}/messages/{message.id}/stop"
|
||||||
|
).status_code == 204
|
||||||
|
assert message.id in _CANCELLED
|
||||||
|
_CANCELLED.discard(message.id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_stopping_someone_elses_message_is_refused(client: TestClient, db, registered, make_chat):
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = make_chat()
|
||||||
|
client.post(f"/api/chats/{chat_id}/messages", data={"content": "hi"})
|
||||||
|
message = db.scalar(select(Message).where(Message.role == "assistant"))
|
||||||
|
|
||||||
|
client.post("/auth/logout", follow_redirects=False)
|
||||||
|
client.post(
|
||||||
|
"/auth/register",
|
||||||
|
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert client.post(
|
||||||
|
f"/api/chats/{chat_id}/messages/{message.id}/stop"
|
||||||
|
).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_streaming_bubble_offers_a_stop_button(client: TestClient, db, registered, make_chat):
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = make_chat()
|
||||||
|
response = client.post(f"/api/chats/{chat_id}/messages", data={"content": "hi"})
|
||||||
|
assert "/stop" in response.text
|
||||||
|
assert "msg__stop" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_streaming_bubble_renders_markdown_not_raw_tokens(
|
||||||
|
client: TestClient, db, registered, make_chat
|
||||||
|
):
|
||||||
|
"""The body receives re-rendered Markdown, so formatting appears as the
|
||||||
|
model writes rather than snapping in at the end."""
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = make_chat()
|
||||||
|
response = client.post(f"/api/chats/{chat_id}/messages", data={"content": "hi"})
|
||||||
|
assert 'sse-swap="render"' in response.text
|
||||||
|
assert 'hx-swap="innerHTML"' in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_reasoning_starts_closed(client: TestClient, db, registered, make_chat):
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = make_chat()
|
||||||
|
response = client.post(f"/api/chats/{chat_id}/messages", data={"content": "hi"})
|
||||||
|
block = response.text[response.text.index("reasoning--live"):]
|
||||||
|
assert not block[: block.index(">")].strip().endswith("open")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Rewinding ---------------------------------------------------------------
|
||||||
|
def _exchange(client: TestClient, db, chat_id: str, text: str) -> Message:
|
||||||
|
client.post(f"/api/chats/{chat_id}/messages", data={"content": text})
|
||||||
|
assistant = db.scalars(
|
||||||
|
select(Message).where(Message.role == "assistant").order_by(Message.created_at)
|
||||||
|
).all()[-1]
|
||||||
|
assistant.content = f"reply to {text}"
|
||||||
|
assistant.complete = True
|
||||||
|
db.commit()
|
||||||
|
return assistant
|
||||||
|
|
||||||
|
|
||||||
|
def test_editing_rewinds_and_discards_later_messages(
|
||||||
|
client: TestClient, db, registered, make_chat
|
||||||
|
):
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = make_chat()
|
||||||
|
_exchange(client, db, chat_id, "first")
|
||||||
|
_exchange(client, db, chat_id, "second")
|
||||||
|
assert db.scalar(select(func.count()).select_from(Message)) == 4
|
||||||
|
|
||||||
|
first_user = db.scalars(
|
||||||
|
select(Message).where(Message.role == "user").order_by(Message.created_at)
|
||||||
|
).first()
|
||||||
|
client.post(
|
||||||
|
f"/api/chats/{chat_id}/messages/{first_user.id}/edit",
|
||||||
|
data={"content": "first, revised"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# The edit happened in the request's session; this one still holds the old
|
||||||
|
# instance in its identity map.
|
||||||
|
db.expire_all()
|
||||||
|
remaining = db.scalars(select(Message).order_by(Message.created_at)).all()
|
||||||
|
assert [m.role for m in remaining] == ["user", "assistant"]
|
||||||
|
assert remaining[0].content == "first, revised"
|
||||||
|
# The fresh assistant row is incomplete, which is what restarts the stream.
|
||||||
|
assert remaining[1].complete is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_edit_form_says_how_much_will_be_lost(client: TestClient, db, registered, make_chat):
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = make_chat()
|
||||||
|
_exchange(client, db, chat_id, "first")
|
||||||
|
_exchange(client, db, chat_id, "second")
|
||||||
|
|
||||||
|
first_user = db.scalars(
|
||||||
|
select(Message).where(Message.role == "user").order_by(Message.created_at)
|
||||||
|
).first()
|
||||||
|
page = client.get(f"/api/chats/{chat_id}/messages/{first_user.id}/edit").text
|
||||||
|
assert "3 messages after this one will be deleted" in page
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_your_own_turns_can_be_edited(client: TestClient, db, registered, make_chat):
|
||||||
|
"""Rewriting what the model said would be inventing history."""
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = make_chat()
|
||||||
|
assistant = _exchange(client, db, chat_id, "hello")
|
||||||
|
assert client.get(
|
||||||
|
f"/api/chats/{chat_id}/messages/{assistant.id}/edit"
|
||||||
|
).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_edit_cannot_empty_a_message(client: TestClient, db, registered, make_chat):
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = make_chat()
|
||||||
|
_exchange(client, db, chat_id, "hello")
|
||||||
|
user_message = db.scalar(select(Message).where(Message.role == "user"))
|
||||||
|
|
||||||
|
assert client.post(
|
||||||
|
f"/api/chats/{chat_id}/messages/{user_message.id}/edit", data={"content": " "}
|
||||||
|
).status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancelling_an_edit_restores_the_bubble(client: TestClient, db, registered, make_chat):
|
||||||
|
_add_connection(db)
|
||||||
|
chat_id = make_chat()
|
||||||
|
_exchange(client, db, chat_id, "unchanged")
|
||||||
|
user_message = db.scalar(select(Message).where(Message.role == "user"))
|
||||||
|
|
||||||
|
page = client.get(f"/api/chats/{chat_id}/messages/{user_message.id}/cancel-edit").text
|
||||||
|
assert "unchanged" in page
|
||||||
|
assert "edit-form" not in page
|
||||||
|
|||||||
@@ -355,7 +355,7 @@ def test_deactivating_a_user_revokes_their_sessions(client: TestClient, db, regi
|
|||||||
|
|
||||||
|
|
||||||
def test_a_pinned_model_appears_once_in_the_picker(client: TestClient, db, registered, make_chat):
|
def test_a_pinned_model_appears_once_in_the_picker(client: TestClient, db, registered, make_chat):
|
||||||
"""Two options with the same value, both selected, is not a picker."""
|
"""Listing a model twice would let two rows claim to be selected."""
|
||||||
connection = _connection(db)
|
connection = _connection(db)
|
||||||
db.add_all(
|
db.add_all(
|
||||||
[
|
[
|
||||||
@@ -367,8 +367,8 @@ def test_a_pinned_model_appears_once_in_the_picker(client: TestClient, db, regis
|
|||||||
|
|
||||||
chat_id = make_chat()
|
chat_id = make_chat()
|
||||||
page = client.get(f"/chat/{chat_id}").text
|
page = client.get(f"/chat/{chat_id}").text
|
||||||
assert page.count('<option value="favourite"') == 1
|
assert page.count('data-picker-value="favourite"') == 1
|
||||||
assert page.count('<option value="ordinary"') == 1
|
assert page.count('data-picker-value="ordinary"') == 1
|
||||||
|
|
||||||
|
|
||||||
# --- System prompt layering --------------------------------------------------
|
# --- System prompt layering --------------------------------------------------
|
||||||
@@ -678,10 +678,7 @@ def test_the_new_chat_composer_preselects_the_default_model(
|
|||||||
settings_store.update(db, {"default_model": "the-default"})
|
settings_store.update(db, {"default_model": "the-default"})
|
||||||
|
|
||||||
page = client.get("/chat").text
|
page = client.get("/chat").text
|
||||||
assert 'value="the-default"' in page
|
assert 'data-picker-value="the-default"' in page
|
||||||
assert '<option value="the-default"\n selected' in page or (
|
|
||||||
'value="the-default"' in page and "selected" in page
|
|
||||||
)
|
|
||||||
# And the hidden field the composer submits carries it too.
|
# And the hidden field the composer submits carries it too.
|
||||||
assert 'name="model_id" value="the-default"' in page
|
assert 'name="model_id" value="the-default"' in page
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user