mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
- Add module docstring to tui_async.py describing async TUI implementation - Add new message_handler.py module with shared SSE message handling
311 lines
9.7 KiB
Python
311 lines
9.7 KiB
Python
"""Shared SSE message handler for AstrBot clients (WebChat, TUI, etc).
|
|
|
|
This module provides a unified way to parse and handle SSE messages from the
|
|
AstrBot chat API, supporting all message types including streaming responses.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from typing import Any
|
|
|
|
|
|
class MessageType(Enum):
|
|
"""SSE message types from AstrBot API."""
|
|
|
|
SESSION_ID = "session_id"
|
|
PLAIN = "plain"
|
|
IMAGE = "image"
|
|
RECORD = "record"
|
|
FILE = "file"
|
|
TOOL_CALL = "tool_call"
|
|
TOOL_CALL_RESULT = "tool_call_result"
|
|
REASONING = "reasoning"
|
|
AGENT_STATS = "agent_stats"
|
|
AUDIO_CHUNK = "audio_chunk"
|
|
COMPLETE = "complete"
|
|
END = "end"
|
|
MESSAGE_SAVED = "message_saved"
|
|
ERROR = "error"
|
|
|
|
|
|
@dataclass
|
|
class ToolCall:
|
|
"""Represents a tool call in progress."""
|
|
|
|
id: str
|
|
name: str
|
|
arguments: str | None = None
|
|
result: str | None = None
|
|
finished_ts: float | None = None
|
|
|
|
|
|
@dataclass
|
|
class ParsedMessage:
|
|
"""A parsed SSE message with type and data."""
|
|
|
|
type: MessageType
|
|
data: str
|
|
raw: dict[str, Any] = field(default_factory=dict)
|
|
chain_type: str | None = None
|
|
streaming: bool = False
|
|
message_id: str | None = None
|
|
|
|
|
|
@dataclass
|
|
class ChatResponse:
|
|
"""Complete chat response accumulated from SSE stream."""
|
|
|
|
text: str = ""
|
|
reasoning: str = ""
|
|
tool_calls: dict[str, ToolCall] = field(default_factory=dict)
|
|
agent_stats: dict[str, Any] = field(default_factory=dict)
|
|
refs: dict[str, Any] = field(default_factory=dict)
|
|
media_parts: list[dict[str, Any]] = field(default_factory=list)
|
|
complete: bool = False
|
|
session_id: str | None = None
|
|
saved_message_id: str | None = None
|
|
error: str | None = None
|
|
|
|
def get_display_text(self) -> str:
|
|
"""Get the main text content for display."""
|
|
return self.text
|
|
|
|
def get_reasoning_display(self) -> str:
|
|
"""Get reasoning content formatted for display."""
|
|
if not self.reasoning:
|
|
return ""
|
|
return f"[Reasoning]\n{self.reasoning}"
|
|
|
|
def get_tool_calls_display(self) -> list[str]:
|
|
"""Get tool calls formatted for display."""
|
|
results = []
|
|
for tc in self.tool_calls.values():
|
|
if tc.result:
|
|
results.append(f"[Tool: {tc.name}]\n{tc.result}")
|
|
else:
|
|
results.append(f"[Tool: {tc.name}] (running...)")
|
|
return results
|
|
|
|
def get_stats_display(self) -> str:
|
|
"""Get agent stats formatted for display."""
|
|
if not self.agent_stats:
|
|
return ""
|
|
parts = []
|
|
for key, value in self.agent_stats.items():
|
|
parts.append(f"{key}: {value}")
|
|
return " | ".join(parts)
|
|
|
|
|
|
class SSEMessageParser:
|
|
"""Parse SSE messages from AstrBot chat API.
|
|
|
|
Usage:
|
|
parser = SSEMessageParser()
|
|
async for msg in parser.parse_stream(response):
|
|
handle_message(msg)
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._tool_calls: dict[str, ToolCall] = {}
|
|
self._accumulated_text: str = ""
|
|
self._accumulated_reasoning: str = ""
|
|
self._accumulated_parts: list[dict[str, Any]] = []
|
|
|
|
def reset(self) -> None:
|
|
"""Reset parser state for a new stream."""
|
|
self._tool_calls = {}
|
|
self._accumulated_text = ""
|
|
self._accumulated_reasoning = ""
|
|
self._accumulated_parts = []
|
|
|
|
def parse_line(self, line: str) -> ParsedMessage | None:
|
|
"""Parse a single SSE data line.
|
|
|
|
Args:
|
|
line: A line starting with "data: "
|
|
|
|
Returns:
|
|
ParsedMessage if valid, None if skip-worthy
|
|
"""
|
|
if not line.startswith("data: "):
|
|
return None
|
|
|
|
data_str = line[6:] # Remove "data: " prefix
|
|
if not data_str:
|
|
return None
|
|
|
|
try:
|
|
data = json.loads(data_str)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
msg_type_str = data.get("type", "")
|
|
msg_type = self._get_message_type(msg_type_str)
|
|
msg_data = data.get("data", "")
|
|
chain_type = data.get("chain_type")
|
|
streaming = data.get("streaming", False)
|
|
message_id = data.get("message_id")
|
|
|
|
return ParsedMessage(
|
|
type=msg_type,
|
|
data=msg_data,
|
|
raw=data,
|
|
chain_type=chain_type,
|
|
streaming=streaming,
|
|
message_id=message_id,
|
|
)
|
|
|
|
def _get_message_type(self, type_str: str) -> MessageType:
|
|
"""Map string type to MessageType enum."""
|
|
try:
|
|
return MessageType(type_str)
|
|
except ValueError:
|
|
return MessageType.PLAIN
|
|
|
|
def process_message(
|
|
self, msg: ParsedMessage
|
|
) -> tuple[ChatResponse, bool]:
|
|
"""Process a parsed message and update accumulated response.
|
|
|
|
Args:
|
|
msg: The parsed message
|
|
|
|
Returns:
|
|
tuple of (accumulated_response, is_complete)
|
|
"""
|
|
response = ChatResponse()
|
|
|
|
if msg.type == MessageType.SESSION_ID:
|
|
response.session_id = msg.raw.get("session_id")
|
|
return response, False
|
|
|
|
if msg.type == MessageType.AGENT_STATS:
|
|
try:
|
|
response.agent_stats = json.loads(msg.data)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
return response, False
|
|
|
|
if msg.type == MessageType.REASONING:
|
|
self._accumulated_reasoning += msg.data
|
|
response.reasoning = self._accumulated_reasoning
|
|
return response, False
|
|
|
|
if msg.type == MessageType.TOOL_CALL:
|
|
try:
|
|
tool_call = json.loads(msg.data)
|
|
tc = ToolCall(
|
|
id=tool_call.get("id", ""),
|
|
name=tool_call.get("name", ""),
|
|
arguments=tool_call.get("arguments"),
|
|
)
|
|
self._tool_calls[tc.id] = tc
|
|
self._accumulated_parts.append(
|
|
{"type": "plain", "text": self._accumulated_text}
|
|
)
|
|
self._accumulated_text = ""
|
|
except json.JSONDecodeError:
|
|
pass
|
|
response.tool_calls = self._tool_calls
|
|
return response, False
|
|
|
|
if msg.type == MessageType.TOOL_CALL_RESULT:
|
|
try:
|
|
tcr = json.loads(msg.data)
|
|
tc_id = tcr.get("id")
|
|
if tc_id in self._tool_calls:
|
|
self._tool_calls[tc_id].result = tcr.get("result")
|
|
self._tool_calls[tc_id].finished_ts = tcr.get("ts")
|
|
self._accumulated_parts.append(
|
|
{"type": "tool_call", "tool_calls": [self._tool_calls[tc_id].__dict__]}
|
|
)
|
|
self._tool_calls.pop(tc_id, None)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
response.tool_calls = self._tool_calls
|
|
return response, False
|
|
|
|
if msg.type == MessageType.PLAIN:
|
|
if msg.chain_type == "tool_call":
|
|
pass # Already handled above
|
|
elif msg.chain_type == "reasoning":
|
|
self._accumulated_reasoning += msg.data
|
|
response.reasoning = self._accumulated_reasoning
|
|
elif msg.streaming:
|
|
self._accumulated_text += msg.data
|
|
else:
|
|
self._accumulated_text = msg.data
|
|
response.text = self._accumulated_text
|
|
return response, False
|
|
|
|
if msg.type == MessageType.IMAGE:
|
|
filename = msg.data.replace("[IMAGE]", "")
|
|
self._accumulated_parts.append({"type": "image", "filename": filename})
|
|
response.media_parts = self._accumulated_parts
|
|
return response, False
|
|
|
|
if msg.type == MessageType.RECORD:
|
|
filename = msg.data.replace("[RECORD]", "")
|
|
self._accumulated_parts.append({"type": "record", "filename": filename})
|
|
response.media_parts = self._accumulated_parts
|
|
return response, False
|
|
|
|
if msg.type == MessageType.FILE:
|
|
filename = msg.data.replace("[FILE]", "")
|
|
self._accumulated_parts.append({"type": "file", "filename": filename})
|
|
response.media_parts = self._accumulated_parts
|
|
return response, False
|
|
|
|
if msg.type == MessageType.COMPLETE:
|
|
response.text = self._accumulated_text
|
|
response.reasoning = self._accumulated_reasoning
|
|
response.tool_calls = self._tool_calls
|
|
response.complete = True
|
|
self.reset()
|
|
return response, True
|
|
|
|
if msg.type == MessageType.END:
|
|
response.text = self._accumulated_text
|
|
response.complete = True
|
|
self.reset()
|
|
return response, True
|
|
|
|
if msg.type == MessageType.MESSAGE_SAVED:
|
|
response.saved_message_id = msg.raw.get("data", {}).get("id")
|
|
return response, False
|
|
|
|
return response, False
|
|
|
|
|
|
async def parse_sse_stream(async_iterable, callback) -> ChatResponse:
|
|
"""Parse SSE stream and call callback for each message update.
|
|
|
|
This is a convenience function for processing SSE streams.
|
|
|
|
Args:
|
|
async_iterable: Async iterable of SSE lines (e.g., response.aiter_lines())
|
|
callback: Async function called with (ChatResponse, is_complete)
|
|
|
|
Returns:
|
|
Final ChatResponse when stream completes
|
|
"""
|
|
parser = SSEMessageParser()
|
|
final_response = ChatResponse()
|
|
|
|
async for line in async_iterable:
|
|
msg = parser.parse_line(line)
|
|
if msg is None:
|
|
continue
|
|
|
|
response, is_complete = parser.process_message(msg)
|
|
await callback(response, is_complete)
|
|
final_response = response
|
|
|
|
if is_complete:
|
|
break
|
|
|
|
return final_response
|