Files
EvoScientist-Multi/EvoScientist/commands/channel_ui.py
T

247 lines
8.4 KiB
Python

from __future__ import annotations
import asyncio
import logging
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
from .base import CommandUI
if TYPE_CHECKING:
from ..gateway import GraphGateway
_logger = logging.getLogger(__name__)
_COMMAND_OUTPUT_FAILURE_NOTICE = "Command output could not be delivered."
class ChannelCommandUI(CommandUI):
"""CommandUI implementation for messaging channels with output buffering."""
_TEXT_CHUNK_LIMIT = 3500
@property
def supports_interactive(self) -> bool:
return False
def __init__(
self,
channel_msg: Any,
*,
graph_gateway: GraphGateway,
append_system_callback: Any = None,
start_new_session_callback: Callable[[], Awaitable[None]] | None = None,
handle_session_resume_callback: Any = None,
):
self.msg = channel_msg
self.append_system_callback = append_system_callback
self.start_new_session_callback = start_new_session_callback
self.handle_session_resume_callback = handle_session_resume_callback
self.graph_gateway = graph_gateway
self._system_buffer: list[str] = []
# Whether any output was delivered (or scheduled for delivery) to the
# channel. The slash dispatcher consults this to decide between a
# bare completion ack and staying silent.
self.sent_to_channel: bool = False
def _queue_system(
self,
text: str,
style: str = "dim",
*,
mirror_local: bool = True,
) -> None:
if mirror_local and self.append_system_callback:
self.append_system_callback(text, style)
# Buffer the text for grouped delivery to the channel
# We ignore style for grouping but keep it for individual lines if needed
self._system_buffer.append(text)
def append_system(self, text: str, style: str = "dim") -> None:
self._queue_system(text, style)
@staticmethod
def _extract_message_text(message: Any) -> str:
content = getattr(message, "content", "") or ""
if isinstance(content, list):
parts = [
block.get("text", "")
for block in content
if isinstance(block, dict) and block.get("type") == "text"
]
content = " ".join(parts) if parts else ""
return str(content).strip()
async def _send_text_chunks(self, text: str, *, mirror_local: bool = True) -> None:
"""Flush long plain-text payloads in channel-safe chunks."""
text = (text or "").strip()
if not text:
return
pending = text
while pending:
chunk = pending[: self._TEXT_CHUNK_LIMIT]
if len(pending) > self._TEXT_CHUNK_LIMIT:
split_at = chunk.rfind("\n")
if split_at > 0:
chunk = chunk[:split_at]
chunk = chunk.rstrip()
if not chunk:
chunk = pending[: self._TEXT_CHUNK_LIMIT]
self._queue_system(chunk, mirror_local=mirror_local)
await self.flush()
pending = pending[len(chunk) :].lstrip("\n")
async def flush(self) -> None:
"""Send all buffered system messages as a single grouped message."""
if not self._system_buffer:
return
grouped_text = "\n".join(self._system_buffer)
self._system_buffer = []
from ..channels.base import OutboundMessage
from ..cli.channel import _bus_loop
loop = _bus_loop
if not loop:
return
outbound = OutboundMessage(
channel=self.msg.channel_type,
chat_id=self.msg.chat_id,
content=grouped_text,
reply_to=self.msg.message_id,
metadata=self.msg.metadata,
failure_notice=_COMMAND_OUTPUT_FAILURE_NOTICE,
)
if self.msg.bus_ref:
coro = self.msg.bus_ref.publish_outbound(outbound)
else:
coro = self.msg.channel_ref.send(outbound)
self.sent_to_channel = True
asyncio.run_coroutine_threadsafe(coro, loop)
def mount_renderable(self, renderable: Any) -> None:
# Convert Rich renderable to text/markdown for channel
from io import StringIO
from rich.console import Console
# Increase width to 120 to avoid wrapping tables like /threads
with StringIO() as f:
console = Console(
file=f, force_terminal=False, width=120, color_system=None
)
console.print(renderable)
text = f.getvalue()
from ..channels.base import OutboundMessage
from ..cli.channel import _bus_loop
loop = _bus_loop
if not loop:
return
# Flush any pending system messages first to preserve order
if self._system_buffer:
asyncio.run_coroutine_threadsafe(self.flush(), loop)
outbound = OutboundMessage(
channel=self.msg.channel_type,
chat_id=self.msg.chat_id,
content=f"```\n{text}\n```",
reply_to=self.msg.message_id,
metadata=self.msg.metadata,
failure_notice=_COMMAND_OUTPUT_FAILURE_NOTICE,
)
if self.msg.bus_ref:
coro = self.msg.bus_ref.publish_outbound(outbound)
else:
coro = self.msg.channel_ref.send(outbound)
self.sent_to_channel = True
asyncio.run_coroutine_threadsafe(coro, loop)
async def wait_for_thread_pick(
self, threads: list[dict], current_thread: str, title: str
) -> str | None:
self.append_system(f"{title}\nUse /resume <id> to continue.")
await self.flush()
return None
async def wait_for_skill_browse(
self, index: list[dict], installed_names: set[str], pre_filter_tag: str
) -> list[str] | None:
self.append_system(
"Interactive skill browsing not supported in channels. Use /install-skill <name> instead."
)
await self.flush()
return None
def clear_chat(self) -> None:
self.append_system("Clear chat not supported in channels.")
def request_quit(self) -> None:
self.append_system("Quit command ignored in channel.")
def force_quit(self) -> None:
self.request_quit()
async def start_new_session(self) -> None:
if self.start_new_session_callback:
await self.start_new_session_callback()
else:
self.append_system(
"New session requested. Please restart the channel link or use /new if supported."
)
async def handle_session_resume(
self, thread_id: str, workspace_dir: str | None = None
) -> None:
mirror_local = self.handle_session_resume_callback is None
if self.handle_session_resume_callback:
await self.handle_session_resume_callback(thread_id, workspace_dir)
lines = [f"Resumed session: {thread_id}"]
try:
messages = await self.graph_gateway.get_thread_messages(thread_id)
except Exception as exc:
_logger.exception(
"Failed to load saved history for resumed thread %s",
thread_id,
)
lines.append(f"(history unavailable: {exc})")
await self._send_text_chunks("\n".join(lines), mirror_local=mirror_local)
return
display = [m for m in messages if getattr(m, "type", None) in ("human", "ai")]
if not display:
if messages:
lines.append("No displayable messages in this session.")
else:
lines.append("No saved messages in this session.")
await self._send_text_chunks("\n".join(lines), mirror_local=mirror_local)
return
HISTORY_WINDOW = 10
if len(display) > HISTORY_WINDOW:
display = display[-HISTORY_WINDOW:]
lines.append(f"Conversation history (last {HISTORY_WINDOW} messages):")
else:
lines.append("Conversation history:")
for message in display:
text = self._extract_message_text(message)
if not text:
continue
if getattr(message, "type", None) == "human":
lines.append(f"User: {text}")
else:
lines.append(f"EvoScientist: {text}")
await self._send_text_chunks("\n".join(lines), mirror_local=mirror_local)