Compare commits

..
3 Commits
Author SHA1 Message Date
Shivering Ocelot 279861bdb8 wip: responsiveness 2026-10-14 01:36:02 -05:00
Shivering Ocelot 9a85c61781 feat: Telegram Channel 2026-10-11 12:58:51 -05:00
Shivering Ocelot 3238c059f6 feat(backend): refactor websocket handler 2026-10-11 06:35:17 -05:00
9 changed files with 883 additions and 486 deletions

No files matched your search

+1
View File
@@ -1,6 +1,7 @@
Quart==0.20.0 Quart==0.20.0
quart-auth quart-auth
quart-cors quart-cors
httpx
sqlalchemy sqlalchemy
nest_asyncio nest_asyncio
replicate replicate
+11 -2
View File
@@ -22,6 +22,7 @@ from drives import init_drives
from remote import RemoteConnection, RemoteToolSpec, parse_function_specs from remote import RemoteConnection, RemoteToolSpec, parse_function_specs
from app_mixins import ChatMixin, AgentMixin from app_mixins import ChatMixin, AgentMixin
from channels import Channels
class App(ChatMixin, AgentMixin): class App(ChatMixin, AgentMixin):
def __init__(self, data_directory, replicate_token=None, openrouter_token=None): def __init__(self, data_directory, replicate_token=None, openrouter_token=None):
@@ -34,6 +35,9 @@ class App(ChatMixin, AgentMixin):
# active websocket tasks, for manual cancelling upon server shutdown # active websocket tasks, for manual cancelling upon server shutdown
self.ws_tasks = [] self.ws_tasks = []
# Channel bindings, in-memory transcripts, pollers and HTTP.
self.channels = Channels(self)
self.tmp = tmp.State() self.tmp = tmp.State()
# Clients # Clients
@@ -236,13 +240,16 @@ async def main():
openrouter_token=os.getenv("OPENROUTER_API_KEY") openrouter_token=os.getenv("OPENROUTER_API_KEY")
) )
# 3. Start Server # 3. Set up channel listeners (webhook reconciliation or long polling)
await horde_app.channels.setup()
# 4. Start Server
# manually trigger server shutdown, avoids hypercorn installing signal handlers # manually trigger server shutdown, avoids hypercorn installing signal handlers
shutdown_event = asyncio.Event() shutdown_event = asyncio.Event()
# TODO: get port from env or don't pass it (actually server should handle none) # TODO: get port from env or don't pass it (actually server should handle none)
server_task = asyncio.create_task(start_server(horde_app, port, shutdown_event)) server_task = asyncio.create_task(start_server(horde_app, port, shutdown_event))
# 4. Graceful Termination # 5. Graceful Termination
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
def interrupt(): def interrupt():
print("Interrupt Signal") print("Interrupt Signal")
@@ -250,6 +257,8 @@ async def main():
# Unblock websocket handlers parked in websocket.receive() # Unblock websocket handlers parked in websocket.receive()
for task in list(horde_app.ws_tasks): for task in list(horde_app.ws_tasks):
task.cancel() task.cancel()
# Stop channel pollers and any in-flight channel runs
horde_app.channels.shutdown()
for sig in (signal.SIGINT, signal.SIGTERM): for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, interrupt) loop.add_signal_handler(sig, interrupt)
+445
View File
@@ -0,0 +1,445 @@
"""Channel integration, currently Telegram only.
A *channel* is an inbound/outbound pipe between an external messaging service
and an agent. The binding is stored as an ordinary ``AgentResource`` on the
responding agent::
kind = "channel"
key = "telegram"
value = {"token": "<bot token>", "secret": "<webhook secret>", "name": "..."}
Nothing else is persisted: a Telegram conversation never creates ``Chat`` /
``Thread`` / ``Message`` rows. The rolling transcript lives in memory here
(``Channels``) and only for the lifetime of the process.
Inbound delivery has two mutually exclusive modes, chosen at startup:
* **Webhook** when ``HUB_HOST`` is set: Telegram POSTs updates to
``/channel/telegram/<agent_id>`` (served by ``server/telegram.py``).
* **Long polling** otherwise: one background task per binding calls
``getUpdates``.
Both modes converge on :meth:`Channels.handle_update`.
Outbound is a single ``sendMessage`` call, exposed to the model as a
``ToolSpec`` carrying a ``func`` so the agent's run loop can execute it.
"""
import asyncio
import os
import time
from collections import deque
from datetime import datetime
from functools import partial
import httpx
import database as db
from tool import ToolSpec, ToolParameter
TELEGRAM_API = "https://api.telegram.org"
# Telegram long-poll timeout (server holds the request open this long).
POLL_TIMEOUT = 50
# Rolling transcript cap per conversation, in bytes (oldest trimmed first).
TRANSCRIPT_BYTE_CAP = 8 * 1024
# Telegram hard limit for a single text message.
MAX_MESSAGE_LENGTH = 4096
class Channels:
"""Owns channel bindings, in-memory transcripts, pollers and HTTP."""
def __init__(self, app):
self._app = app
self._client = None
self._tasks = []
self._transcripts = {} # (agent_id, chat_id) -> {transcript, lock}
self._last_update = {} # (agent_id, kind) -> update_id
# --- Startup / lifecycle ---
async def setup(self):
"""Query channel resources and register listeners where necessary.
Called once at startup, after ``db.init_database`` and App
construction. Webhook mode reconciles the webhook per binding; when
``HUB_HOST`` is absent we warn and start one long-polling task per
binding instead.
"""
resources = await db.AgentResource.filter(kind="channel", key="telegram").all()
if not resources:
print("[channel] no telegram channel bindings found")
return
hub_host = os.getenv("HUB_HOST")
if hub_host:
for resource in resources:
try:
await self._reconcile_webhook(resource, hub_host)
except Exception as e:
print(f"[channel] webhook setup failed for agent {resource.agent_id}: {e}")
else:
print("WARNING: HUB_HOST is not set; telegram channels fall back to long polling")
for resource in resources:
self._track(asyncio.create_task(self._poll(resource)))
def shutdown(self):
"""Cancel every tracked poller / in-flight run."""
for task in list(self._tasks):
task.cancel()
# --- Telegram HTTP ---
def _get_client(self):
if self._client is None:
self._client = httpx.AsyncClient()
return self._client
async def _api(self, token, method, payload=None, timeout=30):
"""Call a Telegram Bot API method and return its ``result``.
Raises on transport errors and on ``ok: false`` responses.
"""
url = f"{TELEGRAM_API}/bot{token}/{method}"
response = await self._get_client().post(url, json=payload or {}, timeout=timeout)
data = response.json()
if not data.get("ok"):
raise Exception(f"telegram {method} failed: {data.get('description')}")
return data.get("result")
async def get_binding(self, agent_id):
return await db.AgentResource.get_or_none(
agent_id=agent_id, kind="channel", key="telegram")
# --- Webhook mode ---
async def _reconcile_webhook(self, resource, hub_host):
value = resource.value or {}
token = value.get("token")
if not token:
print(f"[channel] telegram binding of agent {resource.agent_id} has no token, skipping")
return
url = f"https://{hub_host}/channel/telegram/{resource.agent_id}"
info = await self._api(token, "getWebhookInfo")
if info.get("url") == url:
print(f"[channel] webhook already registered for agent {resource.agent_id}")
return
payload = {"url": url, "allowed_updates": ["message"]}
secret = value.get("secret")
if secret:
payload["secret_token"] = secret
await self._api(token, "setWebhook", payload)
print(f"[channel] registered webhook {url} for agent {resource.agent_id}")
def accept_update(self, agent_id, kind, update_id):
"""Drop duplicate webhook updates by remembering the last update id."""
if update_id is None:
return True
key = (agent_id, kind)
last = self._last_update.get(key)
if last is not None and update_id <= last:
return False
self._last_update[key] = update_id
return True
def dispatch(self, agent_id, kind, payload):
"""Accept an inbound update and run it in the background.
Does the duplicate check, spawns the tracked run and returns
immediately so the caller (the webhook route) holds no channel
bookkeeping.
"""
if not self.accept_update(agent_id, kind, payload.get("update_id")):
return
self._track(asyncio.create_task(self.handle_update(agent_id, kind, payload)))
# --- Long-polling mode ---
async def _poll(self, resource):
"""Long-poll ``getUpdates`` for one telegram binding until cancelled.
``offset`` is recomputed from the last seen ``update_id`` so each
update is acknowledged and delivered exactly once. Transport errors
back off instead of busy-looping.
"""
value = resource.value or {}
token = value.get("token")
agent_id = resource.agent_id
if not token:
print(f"[channel] telegram binding of agent {agent_id} has no token, not polling")
return
# Webhooks and getUpdates are mutually exclusive: clear any webhook
# once before the first poll.
try:
await self._api(token, "deleteWebhook", {"drop_pending_updates": False})
except Exception as e:
print(f"[channel] deleteWebhook failed for agent {agent_id}: {e}")
offset = None
backoff = 1
print(f"[channel] polling telegram updates for agent {agent_id}")
while True:
try:
payload = {
"timeout": POLL_TIMEOUT,
"limit": 100,
"allowed_updates": ["message"],
}
if offset is not None:
payload["offset"] = offset
updates = await self._api(
token, "getUpdates", payload, timeout=POLL_TIMEOUT + 15)
backoff = 1
for update in updates:
offset = update["update_id"] + 1
await self.handle_update(agent_id, "telegram", update)
except asyncio.CancelledError:
print(f"[channel] polling stopped for agent {agent_id}")
raise
except Exception as e:
print(f"[channel] telegram polling error for agent {agent_id}: {e}")
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 60)
# --- Inbound ---
async def handle_update(self, agent_id, kind, payload):
"""Handle one inbound update from a channel.
Minimal path: only plain text messages from non-bot senders are
consumed. Everything else (edited messages, media, callbacks) is
ignored.
"""
if kind != "telegram":
print(f"[channel] unsupported channel kind '{kind}'")
return
message = payload.get("message")
if not message:
return
text = message.get("text")
if not text:
return
sender = message.get("from") or {}
if sender.get("is_bot"):
return
chat = message.get("chat") or {}
chat_id = chat.get("id")
if chat_id is None:
return
binding = await self.get_binding(agent_id)
if binding is None:
print(f"[channel] no telegram binding for agent {agent_id}, dropping update")
return
sender_name = self._sender_name(sender, chat)
state = self._state(agent_id, chat_id)
async with state["lock"]:
state["transcript"].append({
"sender": sender_name,
"text": text,
"time": time.time(),
})
self._trim(state["transcript"])
transcript = self._render(state["transcript"])
await self.mark_as_read(agent_id, chat_id)
await self._run_agent(agent_id, kind, chat_id, chat, sender_name, transcript)
async def _run_agent(self, agent_id, kind, chat_id, chat, sender_name, transcript):
"""Fire one prompt run for a conversation event and drain it.
The run input is flattened to an instruction plus exactly two user
messages: a status/info message and the rendered conversation.
"""
instruction = (
"You are responding to a Telegram conversation. The messages below "
"are the recent conversation; the first user message carries channel "
"context. Reply to the latest message by calling the `send_message` "
"tool with the text to send. Do not add commentary outside the tool call."
)
title = chat.get("title") or sender_name
status = "\n".join([
"Channel: telegram",
f"Chat id: {chat_id}",
f"Chat title: {title}",
f"Chat type: {chat.get('type')}",
f"Current time: {datetime.now().isoformat(timespec='seconds')}",
f"Latest message from: {sender_name}",
])
# Signal presence for the lifetime of this run.
await self.set_online_status(agent_id, chat_id, online=True)
tool = ToolSpec(
name="send_message",
description="Send a text message back to this Telegram conversation.",
parameters=[ToolParameter(
name="text",
type="string",
description="The message text to send to the conversation.",
required=True,
)],
func=partial(self.send_message, agent_id, kind, chat_id),
)
messages = [
{"role": "user", "content": status},
{"role": "user", "content": transcript},
]
async def drain():
async for msg in self._app.prompt_agent(
agent_id,
instruction,
input=None,
options={"tools": [tool], "messages": messages},
):
self._log_message(kind, chat_id, msg)
task = self._track(asyncio.create_task(drain()))
try:
await task
except asyncio.CancelledError:
raise
except Exception as e:
print(f"[channel] run failed for agent {agent_id}: {e}")
def _log_message(self, kind, chat_id, msg):
content = msg.content
if not isinstance(content, str):
content = repr(content)
print(f"[channel:{kind}:{chat_id}] {msg.role}: {content}")
# --- Outbound ---
async def send_message(self, agent_id, kind, chat_id, text):
"""Send a text message to a channel conversation.
Returns a short result string so the model sees the tool outcome.
"""
if kind != "telegram":
return f"error: unsupported channel '{kind}'"
binding = await self.get_binding(agent_id)
if binding is None:
return "error: no telegram channel binding for this agent"
token = (binding.value or {}).get("token")
if not token:
return "error: telegram binding has no token"
text = (text or "").strip()
if not text:
return "error: message text is empty"
if len(text) > MAX_MESSAGE_LENGTH:
text = text[:MAX_MESSAGE_LENGTH]
# Typing indicator immediately before the message so the user sees
# the three-dot animation right as the reply is ready.
try:
await self._api(token, "sendAction", {"chat_id": chat_id, "action": "typing"})
except Exception as e:
print(f"[channel] sendAction typing failed for agent {agent_id}: {e}")
try:
await self._api(token, "sendMessage", {"chat_id": chat_id, "text": text})
return "ok"
except Exception as e:
print(f"[channel] sendMessage failed for agent {agent_id}: {e}")
return f"error: {e}"
async def send_typing(self, agent_id, chat_id, typing=True):
"""Send a typing action indicator to the chat.
Telegram exposes this via ``sendAction`` with ``typing`` (start) or
``cancel`` (stop). Failures are swallowed: an indicator is cosmetic
and must never break a run.
"""
binding = await self.get_binding(agent_id)
if binding is None:
return
token = (binding.value or {}).get("token")
if not token:
return
action = "typing" if typing else "cancel"
try:
await self._api(token, "sendAction", {"chat_id": chat_id, "action": action})
except Exception as e:
print(f"[channel] sendAction failed: {e}")
async def mark_as_read(self, agent_id, chat_id):
"""Mark an inbound message as read.
Telegram bots have no native read receipt; acceptance is already
handled by :meth:`accept_update`. This stays an explicit hook on the
inbound path so a provider that does support receipts can be wired in
here later.
"""
pass
async def set_online_status(self, agent_id, chat_id, online=True):
"""Signal presence for the conversation.
A single ``typing`` action marks the run as online; ``cancel`` clears
it. Delegates to :meth:`send_typing`.
"""
await self.send_typing(agent_id, chat_id, typing=online)
# --- In-memory transcript ---
def _state(self, agent_id, chat_id):
key = (agent_id, chat_id)
state = self._transcripts.get(key)
if state is None:
state = {"transcript": deque(), "lock": asyncio.Lock()}
self._transcripts[key] = state
return state
def _trim(self, transcript):
"""Drop oldest messages until the deque fits the byte cap."""
while transcript and self._size(transcript) > TRANSCRIPT_BYTE_CAP:
transcript.popleft()
def _size(self, transcript):
return sum(
len(m["sender"].encode("utf-8")) + len(m["text"].encode("utf-8")) + 2
for m in transcript
)
def _render(self, transcript):
if not transcript:
return "(no messages)"
return "\n".join(f"{m['sender']}: {m['text']}" for m in transcript)
# --- Parse helpers ---
def _sender_name(self, user, chat):
name = " ".join(filter(None, [user.get("first_name"), user.get("last_name")]))
username = user.get("username")
if name and username:
return f"{name} (@{username})"
if name:
return name
if username:
return f"@{username}"
return chat.get("title") or "unknown"
# --- Tracking ---
def _track(self, task):
self._tasks.append(task)
def discard(t):
if t in self._tasks:
self._tasks.remove(t)
task.add_done_callback(discard)
return task
+36
View File
@@ -0,0 +1,36 @@
"""Telegram webhook endpoint.
Telegram delivers updates to ``POST /channel/telegram/<agent_id>`` once a
webhook is registered for that agent's binding (see ``channels.py``). The
route is always served; it is only *useful* when ``HUB_HOST`` is set and
the webhook has been reconciled, but that is a startup concern, not a
routing one.
Mirrors ``server/openai.py``'s ``register(server, app)`` pattern.
"""
from quart import request
def register(server, app):
@server.post('/channel/telegram/<agent_id>')
async def telegram_webhook(agent_id):
try:
agent_id = int(agent_id)
except (TypeError, ValueError):
return "invalid agent id", 404
binding = await app.channels.get_binding(agent_id)
if binding is None:
return "no telegram channel for this agent", 404
secret = request.headers.get('X-Telegram-Bot-Api-Secret-Token')
expected = (binding.value or {}).get('secret')
if expected and secret != expected:
return "forbidden", 403
payload = await request.get_json(silent=True) or {}
# Never block the response on the run: Telegram retries on timeouts.
app.channels.dispatch(agent_id, 'telegram', payload)
return {"ok": True}
+3 -1
View File
@@ -2,6 +2,7 @@ from quart import Quart, request, send_from_directory, send_file, current_app
from quart_auth import QuartAuth, AuthUser, login_required, login_user, current_user from quart_auth import QuartAuth, AuthUser, login_required, login_user, current_user
from server.websocket import register as register_websocket from server.websocket import register as register_websocket
from server.openai import register as register_openai from server.openai import register as register_openai
from server.telegram import register as register_telegram
import database as db import database as db
import drives import drives
@@ -175,5 +176,6 @@ async def start_server(app, port, shutdown_event):
register_websocket(server, app, auth_manager) register_websocket(server, app, auth_manager)
register_openai(server, app) register_openai(server, app)
register_telegram(server, app)
await server.run_task(debug=True, shutdown_trigger=shutdown_event.wait, port=port) await server.run_task(debug=True, shutdown_trigger=shutdown_event.wait, host="0.0.0.0", port=port)
+293 -268
View File
@@ -68,275 +68,10 @@ def register(server, app, auth_manager):
stop_listening = app.on_event(on_event) stop_listening = app.on_event(on_event)
while True: while True:
req = json.loads(await websocket.receive()) asyncio.create_task(handle_request(
if not 'action' in req: app, user_id, session_connections, send, await websocket.receive()
await send({'error': 'no action'}) ))
continue
action = req['action']
body = req.get('body', {})
nonce = req.get('nonce', None)
print(f"[ws] action @{user_id} {action}")
async def agent_id_from_body (allow_none=False):
if 'agentId' in body:
return body['agentId']
elif 'agentname' in body:
return await app.agent_id_from_name(body['agentname'])
elif allow_none:
return None
else:
raise Exception("field 'agentId' or 'agentname' required")
if action == "ping":
await send({'message': "pong"})
elif action == "create-agent":
name = body['name']
agent_id = await app.create_agent(
user_id=user_id,
name=name,
agentname=body.get('agentname'),
description=body.get('description'),
picture=body.get('picture'),
kind=body['kind'],
data=body.get('data'),
temporary=body.get('temporary'),
)
await send({'message': "agent created", 'agentId': agent_id, 'nonce': nonce})
elif action == "update-agent":
agent_id = await agent_id_from_body()
await app.update_agent(
agent_id=agent_id,
name=body.get('name'),
agentname=body.get('agentname'),
description=body.get('description'),
picture=body.get('picture'),
data=body.get('data', {})
)
await send({'message': "agent updated", 'agentId': agent_id, 'nonce': nonce})
elif action == "get-models":
await send({
'models': [{"id": model.id, "name": model.name, "value": model.value, "kind": model.kind} for model in await app.get_models()],
'nonce': nonce
})
elif action == "save-model":
model = await app.save_model(
model_id=body.get("modelId"),
name=body.get("name"),
kind=body.get("kind"),
value=body.get("value"),
)
if model:
await send({'message': "model saved", 'modelId': model.id, 'nonce': nonce})
elif "modelId" in body:
await send({'message': "model deleted", 'modelId': body["modelId"], 'nonce': nonce})
elif action == "get-agent-config-fields":
await send({'fields': await app.get_agent_config_fields(await agent_id_from_body()), 'nonce': nonce})
elif action == "get-agent-kinds":
await send({'message': "agents retrieved", 'kinds': app.get_agent_kinds(), 'nonce': nonce})
elif action == "get-agent-kind-info":
await send({'message': "agent kind retrieved", 'info': app.get_agent_kind_info(body['kind']), 'nonce': nonce})
elif action == "get-agents":
await send({'message': "agents retrieved", 'agents': await app.get_agents(user_id), 'nonce': nonce})
elif action == "get-agent-resources":
values = await app.get_agent_resources(await agent_id_from_body(), body.get('kind'), body.get('key'))
await send({'resources': [{'kind': r.kind, 'key': r.key, 'value': r.value} for r in values], 'nonce': nonce})
elif action == "save-agent-resource":
await app.save_agent_resource(await agent_id_from_body(), body['kind'], body['key'], body['value'])
await send({'message': "resource saved", 'nonce': nonce})
elif action == "delete-agent-resource":
await app.delete_agent_resource(await agent_id_from_body(), body['kind'], body['key'])
await send({'message': "resource deleted", 'nonce': nonce})
elif action == "get-agent-runs":
await send({
'runs': await app.get_agent_runs(agent_id=await agent_id_from_body(allow_none=True), user_id=user_id),
'nonce': nonce
})
elif action == "cancel-agent-run":
await app.cancel_agent_run(body['runId'])
elif action == "remote-list":
await send({'remotes': app.listRemotes(user_id), 'nonce': nonce})
elif action == "remote-connect":
# Connect a remote served by THIS session's channel.
# Expected failures (duplicate name) are replied, not
# raised, so the session stays alive.
try:
connection = app.connectRemote(user_id, body['remotename'], body.get('functions', []), send)
session_connections[body['remotename']] = connection
await send({'message': "remote connected", 'remotename': body['remotename'], 'nonce': nonce})
except Exception as e:
await send({'error': str(e), 'nonce': nonce})
elif action == "remote-disconnect":
# Manual disconnect, allowed from any session of the user
connection = app.disconnectRemote(user_id, body['remotename'])
session_connections.pop(body['remotename'], None)
if connection is None:
await send({'error': "no connected remote named '" + str(body['remotename']) + "'", 'nonce': nonce})
else:
await send({'message': "remote disconnected", 'remotename': body['remotename'], 'nonce': nonce})
elif action == "remote-call":
# Invoke a function of one of the user's connected remotes.
# Expected failures (unknown remote/function, timeout,
# remote-side error) are replied, not raised.
async def _handle_remote_call(user_id, remotename, func_name, args, send, nonce):
try:
result = await app.callRemote(user_id, remotename, func_name, args)
await send({'result': result, 'nonce': nonce})
except Exception as e:
await send({'error': str(e), 'nonce': nonce})
asyncio.create_task(_handle_remote_call(
user_id, body['remotename'], body['function'], body.get('args', {}), send, nonce
))
elif action == "remote-call-result":
# A session serving remotes replies to a downstream call.
# Fields live at the message top level, not in 'body'.
# requestIds are '<remotename>#<n>' and resolve/reject
# are no-ops for unknown ids, so fanning out over this
# session's connections routes the reply correctly.
app.resolve_remote_call(body['requestId'], body.get('result'), error=body.get('error'))
elif action == "create-chat":
chat_id = await app.create_chat(
user_id=user_id,
name=body['name'],
description=body.get('description'),
picture=body.get('picture'),
members=body.get('members', []),
temporary=body.get('temporary'),
)
await send({'message': "chat created", 'chatId': chat_id, 'nonce': nonce})
elif action == "get-chats":
chats_data = await app.get_chats(user_id)
await send({'message': "chats retrieved", 'chats': chats_data, 'nonce': nonce})
elif action == "delete-chat":
chat_id = await app.delete_chat(
chat_id=body['chatId']
)
await send({'message': "chat deleted", 'chatId': chat_id, 'nonce': nonce})
elif action == "get-chat-messages":
messages_data = await app.get_chat_messages(
body['chatId'],
earliest_index=body.get('earliestIndex', 0)
)
await send({'message': "chat messages retrieved", 'messages': messages_data, 'nonce': nonce})
elif action == "get-chat-threads":
await send({'message': "threads retrieved", 'nonce': nonce,
'threads': await app.get_chat_threads(body['chatId'])
})
elif action == "get-chat-thread-heads":
messages_data = await app.get_chat_thread_heads(
body['chatId'],
limit_message_id=body.get('limitMessageId', None)
)
await send({'message': "chat messages retrieved", 'messages': messages_data, 'nonce': nonce})
elif action == "get-thread-messages":
messages_data = await app.get_thread_messages(
body['chatId'],
body['threadId'],
limit_message_id=body.get('limitMessageId', None)
)
await send({'message': "chat messages retrieved", 'messages': messages_data, 'nonce': nonce})
elif action == "send-chat-message":
message = await app.send_chat_message(
chat_id=body['chatId'],
thread_id=body.get('threadId'),
content=body['content'],
kind=body.get('kind'),
subkind=body.get('subkind'),
format=body.get('format'),
)
await send({'message': "message sent", 'messageId': message['id'], 'nonce': nonce})
elif action == "update-chat-message":
message = await app.update_chat_message(
message_id=body['messageId'],
content=body.get('content'),
kind=body.get('kind'),
subkind=body.get('subkind'),
format=body.get('format'),
)
await send({'message': "message sent", 'messageId': body['messageId'], 'nonce': nonce})
elif action == "create-chat-thread":
thread_id = await app.create_chat_thread(
chat_id=body['chatId'],
name=body.get('name'),
)
await send({'message': "chat thread created", 'threadId': thread_id, 'nonce': nonce})
elif action == "fork-chat-thread":
thread_id = await app.fork_chat_thread(
thread_id=body['threadId'],
message_id=body.get('messageId'),
)
await send({'message': "chat thread created", 'threadId': thread_id, 'nonce': nonce})
elif action == "prompt-agent":
if 'instruction' not in body and 'input' not in body:
await send({'error': "instruction or input required", 'nonce': nonce})
continue
# Multiple-result action, same shape as read-stream: every
# generated message is its own packet carrying the request
# nonce, so the client callback runs once per message. The
# terminal eof packet resolves the action. Streams referenced
# by open assistant/reasoning messages are read by the
# client via the existing read-stream action.
generator = app.prompt_agent(
await agent_id_from_body(),
body.get('instruction'),
body.get('input'),
body.get('options', {})
)
try:
async for msg in generator:
await send({'message': 'agent-message', 'data': msg, 'nonce': nonce})
finally:
# Disconnect / cancellation point: closes the run task.
await generator.aclose()
await send({'eof': True, 'nonce': nonce})
elif action == "read-stream":
stream_id = body['streamId']
print("Read Stream" + str(stream_id))
stream = app.streams.get(stream_id)
if stream is None:
await send({'error': 'bad stream id: ' + str(stream_id), 'nonce': nonce})
else:
async def chunk_fn(chunk):
await send({'data': chunk, 'eof': False, 'nonce': nonce})
async def eof_fn():
await send({'eof': True, 'fullData': stream.content, 'nonce': nonce})
stream.listen(chunk_fn, eof_fn)
else:
await send({'error': 'unknown action: ' + action, 'nonce': nonce})
finally: finally:
print("User logged out:", user_id) print("User logged out:", user_id)
sender_task.cancel() sender_task.cancel()
@@ -352,3 +87,293 @@ def register(server, app, auth_manager):
for remotename in list(session_connections): for remotename in list(session_connections):
app.disconnectRemote(user_id, remotename) app.disconnectRemote(user_id, remotename)
app.remove_user_connection(user_id, send_queue) app.remove_user_connection(user_id, send_queue)
# Error handling
async def handle_request(app, user_id, session_connections, send, req):
try:
req = json.loads(req)
await process_request(app, user_id, session_connections, send, req)
except Exception as err:
action = req.get('action') if isinstance(req, dict) else None
nonce = req.get('nonce') if isinstance(req, dict) else None
# Maintainers
print(f"[ws] req error @{user_id} {action!r}: {err!r}")
print(traceback.format_exc())
# Client — nonce makes this routable to the caller's promise/callback
try:
await send({'error': str(err), 'nonce': nonce})
except Exception:
print(traceback.format_exc())
# Actual processing
async def process_request(app, user_id, session_connections, send, req):
if 'action' not in req:
await send({'error': 'no action'})
return
action = req['action']
body = req.get('body', {})
nonce = req.get('nonce', None)
print(f"[ws] req @{user_id} {action}")
async def agent_id_from_body (allow_none=False):
if 'agentId' in body:
return body['agentId']
elif 'agentname' in body:
return await app.agent_id_from_name(body['agentname'])
elif allow_none:
return None
else:
raise Exception("field 'agentId' or 'agentname' required")
if action == "ping":
await send({'message': "pong", 'nonce': nonce})
elif action == "create-agent":
name = body['name']
agent_id = await app.create_agent(
user_id=user_id,
name=name,
agentname=body.get('agentname'),
description=body.get('description'),
picture=body.get('picture'),
kind=body['kind'],
data=body.get('data'),
temporary=body.get('temporary'),
)
await send({'message': "agent created", 'agentId': agent_id, 'nonce': nonce})
elif action == "update-agent":
agent_id = await agent_id_from_body()
await app.update_agent(
agent_id=agent_id,
name=body.get('name'),
agentname=body.get('agentname'),
description=body.get('description'),
picture=body.get('picture'),
data=body.get('data', {})
)
await send({'message': "agent updated", 'agentId': agent_id, 'nonce': nonce})
elif action == "get-models":
await send({
'models': [{"id": model.id, "name": model.name, "value": model.value, "kind": model.kind} for model in await app.get_models()],
'nonce': nonce
})
elif action == "save-model":
model = await app.save_model(
model_id=body.get("modelId"),
name=body.get("name"),
kind=body.get("kind"),
value=body.get("value"),
)
if model:
await send({'message': "model saved", 'modelId': model.id, 'nonce': nonce})
elif "modelId" in body:
await send({'message': "model deleted", 'modelId': body["modelId"], 'nonce': nonce})
elif action == "get-agent-config-fields":
await send({'fields': await app.get_agent_config_fields(await agent_id_from_body()), 'nonce': nonce})
elif action == "get-agent-kinds":
await send({'message': "agents retrieved", 'kinds': app.get_agent_kinds(), 'nonce': nonce})
elif action == "get-agent-kind-info":
await send({'message': "agent kind retrieved", 'info': app.get_agent_kind_info(body['kind']), 'nonce': nonce})
elif action == "get-agents":
await send({'message': "agents retrieved", 'agents': await app.get_agents(user_id), 'nonce': nonce})
elif action == "get-agent-resources":
values = await app.get_agent_resources(await agent_id_from_body(), body.get('kind'), body.get('key'))
await send({'resources': [{'kind': r.kind, 'key': r.key, 'value': r.value} for r in values], 'nonce': nonce})
elif action == "save-agent-resource":
await app.save_agent_resource(await agent_id_from_body(), body['kind'], body['key'], body['value'])
await send({'message': "resource saved", 'nonce': nonce})
elif action == "delete-agent-resource":
await app.delete_agent_resource(await agent_id_from_body(), body['kind'], body['key'])
await send({'message': "resource deleted", 'nonce': nonce})
elif action == "get-agent-runs":
await send({
'runs': await app.get_agent_runs(agent_id=await agent_id_from_body(allow_none=True), user_id=user_id),
'nonce': nonce
})
elif action == "cancel-agent-run":
await app.cancel_agent_run(body['runId'])
elif action == "remote-list":
await send({'remotes': app.listRemotes(user_id), 'nonce': nonce})
elif action == "remote-connect":
# Connect a remote served by THIS session's channel.
# Expected failures (duplicate name) are replied, not
# raised, so the session stays alive.
try:
connection = app.connectRemote(user_id, body['remotename'], body.get('functions', []), send)
session_connections[body['remotename']] = connection
await send({'message': "remote connected", 'remotename': body['remotename'], 'nonce': nonce})
except Exception as e:
await send({'error': str(e), 'nonce': nonce})
elif action == "remote-disconnect":
# Manual disconnect, allowed from any session of the user
connection = app.disconnectRemote(user_id, body['remotename'])
session_connections.pop(body['remotename'], None)
if connection is None:
await send({'error': "no connected remote named '" + str(body['remotename']) + "'", 'nonce': nonce})
else:
await send({'message': "remote disconnected", 'remotename': body['remotename'], 'nonce': nonce})
elif action == "remote-call":
# Invoke a function of one of the user's connected remotes.
# Expected failures (unknown remote/function, timeout,
# remote-side error) are replied, not raised.
async def _handle_remote_call(user_id, remotename, func_name, args, send, nonce):
try:
result = await app.callRemote(user_id, remotename, func_name, args)
await send({'result': result, 'nonce': nonce})
except Exception as e:
await send({'error': str(e), 'nonce': nonce})
asyncio.create_task(_handle_remote_call(
user_id, body['remotename'], body['function'], body.get('args', {}), send, nonce
))
elif action == "remote-call-result":
# A session serving remotes replies to a downstream call.
# Fields live at the message top level, not in 'body'.
# requestIds are '<remotename>#<n>' and resolve/reject
# are no-ops for unknown ids, so fanning out over this
# session's connections routes the reply correctly.
app.resolve_remote_call(body['requestId'], body.get('result'), error=body.get('error'))
elif action == "create-chat":
chat_id = await app.create_chat(
user_id=user_id,
name=body['name'],
description=body.get('description'),
picture=body.get('picture'),
members=body.get('members', []),
temporary=body.get('temporary'),
)
await send({'message': "chat created", 'chatId': chat_id, 'nonce': nonce})
elif action == "get-chats":
chats_data = await app.get_chats(user_id)
await send({'message': "chats retrieved", 'chats': chats_data, 'nonce': nonce})
elif action == "delete-chat":
chat_id = await app.delete_chat(
chat_id=body['chatId']
)
await send({'message': "chat deleted", 'chatId': chat_id, 'nonce': nonce})
elif action == "get-chat-messages":
messages_data = await app.get_chat_messages(
body['chatId'],
earliest_index=body.get('earliestIndex', 0)
)
await send({'message': "chat messages retrieved", 'messages': messages_data, 'nonce': nonce})
elif action == "get-chat-threads":
await send({'message': "threads retrieved", 'nonce': nonce,
'threads': await app.get_chat_threads(body['chatId'])
})
elif action == "get-chat-thread-heads":
messages_data = await app.get_chat_thread_heads(
body['chatId'],
limit_message_id=body.get('limitMessageId', None)
)
await send({'message': "chat messages retrieved", 'messages': messages_data, 'nonce': nonce})
elif action == "get-thread-messages":
messages_data = await app.get_thread_messages(
body['chatId'],
body['threadId'],
limit_message_id=body.get('limitMessageId', None)
)
await send({'message': "chat messages retrieved", 'messages': messages_data, 'nonce': nonce})
elif action == "send-chat-message":
message = await app.send_chat_message(
chat_id=body['chatId'],
thread_id=body.get('threadId'),
content=body['content'],
kind=body.get('kind'),
subkind=body.get('subkind'),
format=body.get('format'),
)
await send({'message': "message sent", 'messageId': message['id'], 'nonce': nonce})
elif action == "update-chat-message":
message = await app.update_chat_message(
message_id=body['messageId'],
content=body.get('content'),
kind=body.get('kind'),
subkind=body.get('subkind'),
format=body.get('format'),
)
await send({'message': "message sent", 'messageId': body['messageId'], 'nonce': nonce})
elif action == "create-chat-thread":
thread_id = await app.create_chat_thread(
chat_id=body['chatId'],
name=body.get('name'),
)
await send({'message': "chat thread created", 'threadId': thread_id, 'nonce': nonce})
elif action == "fork-chat-thread":
thread_id = await app.fork_chat_thread(
thread_id=body['threadId'],
message_id=body.get('messageId'),
)
await send({'message': "chat thread created", 'threadId': thread_id, 'nonce': nonce})
elif action == "prompt-agent":
if 'instruction' not in body and 'input' not in body:
await send({'error': "instruction or input required", 'nonce': nonce})
return
# Multiple-result action, same shape as read-stream: every
# generated message is its own packet carrying the request
# nonce, so the client callback runs once per message. The
# terminal eof packet resolves the action. Streams referenced
# by open assistant/reasoning messages are read by the
# client via the existing read-stream action.
generator = app.prompt_agent(
await agent_id_from_body(),
body.get('instruction'),
body.get('input'),
body.get('options', {})
)
try:
async for msg in generator:
await send({'message': 'agent-message', 'data': msg, 'nonce': nonce})
finally:
# Disconnect / cancellation point: closes the run task.
await generator.aclose()
await send({'eof': True, 'nonce': nonce})
elif action == "read-stream":
stream_id = body['streamId']
print("Read Stream" + str(stream_id))
stream = app.streams.get(stream_id)
if stream is None:
await send({'error': 'bad stream id: ' + str(stream_id), 'nonce': nonce})
else:
async def chunk_fn(chunk):
await send({'data': chunk, 'eof': False, 'nonce': nonce})
async def eof_fn():
await send({'eof': True, 'fullData': stream.content, 'nonce': nonce})
stream.listen(chunk_fn, eof_fn)
else:
await send({'error': 'unknown action: ' + action, 'nonce': nonce})
+91
View File
@@ -0,0 +1,91 @@
# Telegram Channel
Connect a Telegram bot to an agent so the agent can read incoming messages and
reply. Telegram is the only channel implemented today; the machinery lives in
`backend/src/app_mixins/channel.py` (logic) and `backend/src/server/telegram.py`
(webhook route).
An agent that owns a `channel`/`telegram` resource *is* the agent that answers.
You can bind the same bot token to several agents, and one agent can be bound to
several bots.
## 1. Create a bot
Talk to [@BotFather](https://t.me/BotFather), create a bot, and copy its token
(looks like `123456:ABC-DEF...`). Treat the token as a secret.
## 2. Choose a delivery mode
Telegram can push updates to a webhook, or the hub can pull them with long
polling. The choice is driven entirely by the `HUB_HOST` environment variable:
- **Webhook** — set `HUB_HOST` to a publicly reachable host. Telegram requires
HTTPS on ports 443, 80, 88 or 8443, so the hub must be reachable there.
- **Long polling** — leave `HUB_HOST` unset. The hub prints a warning and polls
`getUpdates` instead. No public endpoint or TLS certificate is needed.
The `POST /channel/telegram/<agent_id>` route is always served regardless of
mode; in polling mode it simply never receives traffic.
Webhooks and polling are mutually exclusive. A webhook is never registered when
`HUB_HOST` is unset, and the poller clears any existing webhook (`deleteWebhook`)
before it starts.
## 3. Bind the bot to an agent
Add a resource to the responding agent (via the resources UI or the
`save-agent-resource` action):
```
kind = "channel"
key = "telegram"
value = {"token": "<bot token>", "secret": "<random webhook secret>", "name": "<display>"}
```
- `token` — the BotFather token. Required.
- `secret` — a random string of `A-Z a-z 0-9 _ -`, 1–256 characters. Optional,
and only meaningful in webhook mode. When present, Telegram sends it back on
every request in the `X-Telegram-Bot-Api-Secret-Token` header and the route
rejects mismatches with `403`.
- `name` — a display label. Optional.
This is the only state the channel persists. Conversations are **not** stored —
no `Chat`, `Thread` or `Message` rows are created. The rolling transcript is
held in memory on the hub and discarded when the process exits.
## 4. Restart the hub
On startup the hub queries all `channel`/`telegram` resources and reconciles the
provider:
- **Webhook mode**: it calls `getWebhookInfo` for each binding and, when the
registered URL does not match `https://<HUB_HOST>/channel/telegram/<agent_id>`,
calls `setWebhook` with that URL, the `secret_token`, and
`allowed_updates=["message"]`. Verify with `getWebhookInfo`.
- **Polling mode**: it starts one background task per binding. Confirm the bot is
reachable with `getMe`, then send a test message.
## 5. Talk to the bot
Message the bot. On each text message the hub:
1. Appends the message to the conversation's in-memory transcript.
2. Runs the agent with a flattened input — an instruction, a status message
(channel, chat id/title/type, current time, sender), and the rendered
conversation.
3. The agent replies by calling the `send_message` tool, which issues a
`sendMessage` call to Telegram.
Non-text updates (edited messages, media, callbacks) are ignored. Messages
authored by other bots are ignored.
## 6. Troubleshooting
| Symptom | Cause |
|---|---|
| Webhook not set | `HUB_HOST` unset, or the host is not reachable over HTTPS. Check the startup log. |
| Polling active when you expected a webhook | `HUB_HOST` is unset. |
| `403` from the webhook route | `X-Telegram-Bot-Api-Secret-Token` does not match the resource's `secret`. |
| `404` from the webhook route | No `channel`/`telegram` resource on that agent. |
| No reply | The agent has no model/tool configuration, or the run errored — check the hub log for `[channel]` lines. |
| `Conflict: can't use getUpdates method while webhook is active` | A stale webhook from a previous deployment. Remove it (`deleteWebhook`) or set `HUB_HOST` so webhook mode takes over. |
+1 -189
View File
@@ -2,7 +2,6 @@
import { ref, computed, watch } from 'vue'; import { ref, computed, watch } from 'vue';
import { marked } from "marked"; import { marked } from "marked";
import * as backend from '../backend'; import * as backend from '../backend';
import * as iconoir from '@iconoir/vue';
marked.use({ marked.use({
renderer: { renderer: {
code ({lang, text}) { code ({lang, text}) {
@@ -34,12 +33,6 @@ const props = defineProps({
logExpanded: { logExpanded: {
type: Boolean, type: Boolean,
required: false, required: false,
},
// Toggle set of expanded message ids, shared with the parent view.
// Used by log-groups to expand/collapse the previewed log.
expandedLogs: {
type: Object,
required: false,
} }
}); });
@@ -52,8 +45,6 @@ function chatEntryClass(msg) {
return ['resource', 'resource-' + (msg.subkind || 'generic')]; return ['resource', 'resource-' + (msg.subkind || 'generic')];
} else if (msg.type == 'log') { } else if (msg.type == 'log') {
return ['log']; return ['log'];
} else if (msg.type == 'log-group') {
return ['log-group'];
} else if (msg.type == 'thread-header') { } else if (msg.type == 'thread-header') {
let cls = ['thread']; let cls = ['thread'];
if (!msg.active) cls.push(msg.isFork ? 'thread-fork' : 'thread-closed') if (!msg.active) cls.push(msg.isFork ? 'thread-fork' : 'thread-closed')
@@ -117,106 +108,10 @@ watch(() => [props.message.format, props.message.content], ([newFormat, newConte
} }
}, {immediate: true}) }, {immediate: true})
// === Log groups === //
// A log-group collects consecutive log messages. Each one is shown as a square
// icon (which selects it as the preview); the selected log is previewed below.
const logs = computed(() =>
props.message.type == 'log-group' ? (props.message.logs || []) : []
);
// Icon used for a log's square button, chosen by log subkind
function logIcon(subkind) {
if (subkind == 'reasoning') return iconoir.Brain;
if (subkind == 'tool-call') return iconoir.Wrench;
return iconoir.Terminal;
}
// Null means "default to the last log in the group"
const selectedLogId = ref(null);
const selectedLog = computed(() => {
const arr = logs.value;
if (!arr.length) return null;
if (selectedLogId.value != null) {
const found = arr.find(l => l.id == selectedLogId.value);
if (found) return found;
}
return arr[arr.length - 1];
});
// Streamed logs resolve their content through a shared per-stream cache, so
// switching the preview between logs still shows up-to-date content.
const boundStreams = new Set();
const streamContents = ref({});
function bindLogStreams() {
for (const log of logs.value) {
if (log.format != 'stream') continue;
const streamId = parseInt(log.content);
if (boundStreams.has(streamId)) continue;
boundStreams.add(streamId);
const stream = backend.readStream(streamId);
const update = () => {
streamContents.value = { ...streamContents.value, [streamId]: stream.partialContent };
};
update();
stream.on('data', update);
}
}
watch(logs, bindLogStreams, { immediate: true });
const selectedLogText = computed(() => {
const log = selectedLog.value;
if (!log) return '';
if (log.format == 'stream') return streamContents.value[parseInt(log.content)] ?? '';
return log.content ?? '';
});
const groupLogLong = computed(() => {
if (!selectedLog.value) return false;
const lines = String(selectedLogText.value ?? '').split('\n');
return lines.length > LOG_MAX_LINES || lines.some(line => line.length > LOG_MAX_LENGTH);
});
const groupExpanded = computed(() =>
selectedLog.value != null && !!props.expandedLogs?.has(selectedLog.value.id)
);
// Collapsed preview keeps the *last* N characters (the tail), not the head;
// CSS then clips the top so only the bottom lines show.
const groupLogContent = computed(() => {
const text = String(selectedLogText.value ?? '');
if (groupLogLong.value && !groupExpanded.value) {
return text.slice(-(LOG_MAX_LENGTH * LOG_MAX_LINES));
}
return text;
});
const groupLogMeta = computed(() => groupLogLong.value ? {expanded: groupExpanded.value} : null);
function toggleGroupLog() {
if (selectedLog.value) props.expandedLogs?.toggle(selectedLog.value.id);
}
function onContextMenu() {
if (props.message.type == 'log-group') {
if (selectedLog.value) emit('open-menu', selectedLog.value, groupLogMeta.value);
} else {
emit('open-menu', props.message, logMeta.value);
}
}
</script> </script>
<template> <template>
<div :class="chatEntryClass(message)" @contextmenu.prevent="onContextMenu" @click="handleClick(message)"> <div :class="chatEntryClass(message)" @contextmenu.prevent="$emit('open-menu', message, logMeta)" @click="handleClick(message)">
<template v-if="message.type == 'message' || message.type == null"> <template v-if="message.type == 'message' || message.type == null">
<div v-if="message.sender != 'user'" class="sender">{{message.sender}}</div> <div v-if="message.sender != 'user'" class="sender">{{message.sender}}</div>
@@ -240,28 +135,6 @@ function onContextMenu() {
<a v-if="logLong" class="log-toggle" @click.prevent="$emit('toggle-log')">{{logExpanded ? '(collapse)' : '(expand)'}}</a> <a v-if="logLong" class="log-toggle" @click.prevent="$emit('toggle-log')">{{logExpanded ? '(collapse)' : '(expand)'}}</a>
</template> </template>
<template v-if="message.type == 'log-group'">
<div class="log-icons">
<button
v-for="log in logs"
:key="log.id"
type="button"
class="icon"
:class="{ highlight: selectedLog && log.id == selectedLog.id }"
:title="(log.sender || '') + (log.subkind ? ' [' + log.subkind + ']' : '')"
@click="selectedLogId = log.id"
>
<component :is="logIcon(log.subkind)" />
</button>
</div>
<div v-if="selectedLog" class="log-preview">
<div class="log-sender">{{selectedLog.sender}}<span v-if="selectedLog.subkind"> [{{selectedLog.subkind}}]</span></div>
<div class="content" :class="{ clipped: !groupExpanded }">{{groupLogContent}}</div>
<a v-if="groupLogLong" class="log-toggle" @click.prevent="toggleGroupLog()">{{groupExpanded ? '(collapse)' : '(expand)'}}</a>
</div>
</template>
<template v-if="message.type == 'thread-header'"> <template v-if="message.type == 'thread-header'">
<div class="thread-title"> <div class="thread-title">
{{ message.threadId == null ? 'New Thread' : `Thread ${message.threadId}` }} {{ message.threadId == null ? 'New Thread' : `Thread ${message.threadId}` }}
@@ -346,67 +219,6 @@ function onContextMenu() {
user-select: none; user-select: none;
} }
/* === Log groups === */
/* Completely transparent container; stacks icons above the preview and
stretches to the full width of the chat */
.log-group {
align-self: stretch;
width: 100%;
padding: 0.6em 1.4em;
background: transparent;
display: flex;
flex-direction: column;
gap: 0.4em;
}
/* Square buttons (sized by the global `button.icon`) that wrap to the width,
each line centered within the container */
.log-icons {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.3em;
}
.log-icons .highlight {
color: var(--color-highlight);
border-color: var(--color-highlight);
}
.log-preview {
font-family: monospace;
font-size: 80%;
max-width: 70em;
}
.log-preview .log-sender {
font-weight: bold;
}
.log-preview .content {
white-space: pre-wrap;
word-wrap: anywhere;
width: fit-content;
max-width: 70em;
line-height: 1.3;
}
/* Clipping shows only the bottom lines: the content is bottom-aligned, so any
vertical overflow is clipped off the top. ~6 lines at line-height 1.3. */
.log-preview .content.clipped {
display: flex;
flex-direction: column;
justify-content: flex-end;
max-height: 7.8em;
overflow: hidden;
}
.log-preview .log-toggle {
cursor: pointer;
user-select: none;
}
.resource { .resource {
border-radius: var(--border-radius); border-radius: var(--border-radius);
border: 1px dashed var(--color-border); border: 1px dashed var(--color-border);
+2 -26
View File
@@ -1,6 +1,6 @@
<script setup> <script setup>
import { import {
ref, computed, watch, ref, watch,
onMounted, onUnmounted, onMounted, onUnmounted,
} from "vue"; } from "vue";
@@ -30,29 +30,6 @@
creatingThread, creatingThread,
} = session; } = session;
// Consecutive log messages collapse into a single "log-group" entry, which
// Message.vue renders as a container of icons plus a preview.
const displayEntries = computed(() => {
const entries = chatEntries.value || [];
const out = [];
let group = null;
for (const entry of entries) {
if (entry.type == 'log') {
if (!group) {
group = { id: 'log-group-' + entry.id, type: 'log-group', logs: [] };
out.push(group);
}
group.logs.push(entry);
} else {
group = null;
out.push(entry);
}
}
return out;
});
// UI state coupled to the document itself // UI state coupled to the document itself
// Message ids of logs currently expanded // Message ids of logs currently expanded
@@ -239,7 +216,7 @@
<div class="row" style="overflow: auto;"> <div class="row" style="overflow: auto;">
<div class="column"> <div class="column">
<div class="messages-container" ref="chatContainerElem"> <div class="messages-container" ref="chatContainerElem">
<div class="message-container" v-for="msg in displayEntries"> <div class="message-container" v-for="msg in chatEntries">
<Message <Message
:key="msg.id" :key="msg.id"
:message="msg" :message="msg"
@@ -248,7 +225,6 @@
:log-expanded="expandedLogs.has(msg.id)" :log-expanded="expandedLogs.has(msg.id)"
@toggle-log="expandedLogs.toggle(msg.id)" @toggle-log="expandedLogs.toggle(msg.id)"
:expanded-logs="expandedLogs"
:cancellable="messageRunMap[msg.id]" :cancellable="messageRunMap[msg.id]"
@cancel="session.cancelMessageRun(msg.id)" @cancel="session.cancelMessageRun(msg.id)"