feat(telegram): stop generation from the client, questions survive a restart, images reach the model inline, albums become one turn

This commit is contained in:
hh
2026-09-02 02:11:44 +02:00
parent 7a8ee0f200
commit 85b14e2c2f
12 changed files with 452 additions and 76 deletions
+13 -3
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Literal, cast
from typing import TYPE_CHECKING, Any, Literal, cast
from sqlalchemy import func
from sqlmodel import col, select
@@ -70,13 +70,23 @@ class InjectQueue:
self._db = db
async def push(
self, *, conversation_id: int, priority: Priority, origin: str, text: str
self,
*,
conversation_id: int,
priority: Priority,
origin: str,
text: str,
attachments: list[dict[str, Any]] | None = None,
) -> InjectQueueItem:
if priority not in PRIORITY_RANK:
msg = f"unknown priority {priority!r}"
raise ValueError(msg)
row = InjectQueueItem(
conversation_id=conversation_id, priority=priority, origin=origin, text=text
conversation_id=conversation_id,
priority=priority,
origin=origin,
text=text,
attachments=attachments or None,
)
async with self._db.session() as session:
session.add(row)
+15 -1
View File
@@ -20,13 +20,19 @@ _log = logging.getLogger(__name__)
class Messaging(Turns):
async def post(
self, conv: Conversation, text: str, *, origin: str = "user"
self,
conv: Conversation,
text: str,
*,
origin: str = "user",
attachments: list[dict[str, Any]] | None = None,
) -> InjectQueueItem:
item = await self._queue.push(
conversation_id=cast("int", conv.id),
priority="user",
origin=origin,
text=text,
attachments=attachments,
)
await self.touch_user(conv)
self._bus.publish(
@@ -86,6 +92,14 @@ class Messaging(Turns):
self._ensure_worker(cast("int", conv.id))
return item
async def interrupt(self, conv: Conversation) -> bool:
"""Cut the running turn; the reply so far still lands."""
try:
backend = self._backend(conv.agent_name)
except LookupError:
return False
return await backend.interrupt(conv.external_id)
async def say(self, conv: Conversation, text: str) -> dict[str, Any]:
runner = self._runners.get(cast("int", conv.id))
_log.info("say[%s]: %s", conv.external_id, text[:200])
@@ -44,6 +44,7 @@ class Spawning(Messaging):
origin: str = "api",
binding: tuple[str, str] | None = None,
flags: dict[str, Any] | None = None,
attachments: list[dict[str, Any]] | None = None,
) -> Conversation:
"""Create a conversation in a window and queue its seed.
@@ -95,6 +96,7 @@ class Spawning(Messaging):
priority="user",
origin=f"seed:{seed}" if seed == "brief" else origin,
text=await self.seed_text(ctx, window=window),
attachments=attachments,
)
self._ensure_worker(cast("int", conv.id))
return conv
+38 -3
View File
@@ -3,10 +3,12 @@
from __future__ import annotations
import asyncio
import base64
import contextlib
import inspect
import logging
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from uuid import uuid4
@@ -34,7 +36,7 @@ if TYPE_CHECKING:
from beaver_gateway.events.stream import MessageStreamEvent
__all__ = ["Turns"]
__all__ = ["Turns", "content_of", "image_block"]
_log = logging.getLogger(__name__)
@@ -125,13 +127,14 @@ class Turns(Seeds):
tools: bool = True,
turn_id: str | None = None,
item_origin: str | None = None,
attachments: Sequence[dict[str, Any]] | None = None,
) -> tuple[str, TurnCapture]:
capture = TurnCapture()
acc = StreamAccumulator()
agent = self._claude_agent(conv.agent_name)
async for event in self.turn(
conv,
messages=[{"role": "user", "content": text}],
messages=[{"role": "user", "content": content_of(text, attachments)}],
origin=origin,
capture=capture,
tools=tools,
@@ -224,7 +227,12 @@ class Turns(Seeds):
prompt = f"{seed}\n\n{prompt}"
try:
text, capture = await self.run_text_turn(
conv, prompt, origin=origin, turn_id=turn_id, item_origin=head.origin
conv,
prompt,
origin=origin,
turn_id=turn_id,
item_origin=head.origin,
attachments=head.attachments if origin == "user" else None,
)
except Exception: # noqa: BLE001
_log.exception("turn %s on %s failed", turn_id, conv.external_id)
@@ -402,6 +410,33 @@ class Turns(Seeds):
await self._update(conv, apply)
IMAGE_MAX_BYTES = 5 * 1024 * 1024
def content_of(
text: str, attachments: Sequence[dict[str, Any]] | None
) -> str | list[dict[str, Any]]:
"""Plain text, or text plus the images that ride with it."""
blocks = [b for a in attachments or () if (b := image_block(a)) is not None]
if not blocks:
return text
return [{"type": "text", "text": text}, *blocks]
def image_block(attachment: dict[str, Any]) -> dict[str, Any] | None:
media_type = str(attachment.get("media_type") or "")
path = Path(str(attachment.get("path") or ""))
if not media_type.startswith("image/") or not path.is_file():
return None
if path.stat().st_size > IMAGE_MAX_BYTES:
return None
data = base64.b64encode(path.read_bytes()).decode("ascii")
return {
"type": "image",
"source": {"type": "base64", "media_type": media_type, "data": data},
}
def _prompt_preview(messages: Sequence[Any], limit: int = 400) -> str | None:
if not messages:
return None