mindroom 0.0.0__py3-none-any.whl → 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mindroom/__init__.py +3 -0
- mindroom/agent_prompts.py +963 -0
- mindroom/agents.py +248 -0
- mindroom/ai.py +421 -0
- mindroom/api/__init__.py +1 -0
- mindroom/api/credentials.py +137 -0
- mindroom/api/google_integration.py +355 -0
- mindroom/api/google_tools_helper.py +40 -0
- mindroom/api/homeassistant_integration.py +421 -0
- mindroom/api/integrations.py +189 -0
- mindroom/api/main.py +506 -0
- mindroom/api/matrix_operations.py +219 -0
- mindroom/api/tools.py +94 -0
- mindroom/background_tasks.py +87 -0
- mindroom/bot.py +2470 -0
- mindroom/cli.py +86 -0
- mindroom/commands.py +377 -0
- mindroom/config.py +343 -0
- mindroom/config_commands.py +324 -0
- mindroom/config_confirmation.py +411 -0
- mindroom/constants.py +52 -0
- mindroom/credentials.py +146 -0
- mindroom/credentials_sync.py +134 -0
- mindroom/custom_tools/__init__.py +8 -0
- mindroom/custom_tools/config_manager.py +765 -0
- mindroom/custom_tools/gmail.py +92 -0
- mindroom/custom_tools/google_calendar.py +92 -0
- mindroom/custom_tools/google_sheets.py +92 -0
- mindroom/custom_tools/homeassistant.py +341 -0
- mindroom/error_handling.py +35 -0
- mindroom/file_watcher.py +49 -0
- mindroom/interactive.py +313 -0
- mindroom/logging_config.py +207 -0
- mindroom/matrix/__init__.py +1 -0
- mindroom/matrix/client.py +782 -0
- mindroom/matrix/event_info.py +173 -0
- mindroom/matrix/identity.py +149 -0
- mindroom/matrix/large_messages.py +267 -0
- mindroom/matrix/mentions.py +141 -0
- mindroom/matrix/message_builder.py +94 -0
- mindroom/matrix/message_content.py +209 -0
- mindroom/matrix/presence.py +178 -0
- mindroom/matrix/rooms.py +311 -0
- mindroom/matrix/state.py +77 -0
- mindroom/matrix/typing.py +91 -0
- mindroom/matrix/users.py +217 -0
- mindroom/memory/__init__.py +21 -0
- mindroom/memory/config.py +137 -0
- mindroom/memory/functions.py +396 -0
- mindroom/py.typed +0 -0
- mindroom/response_tracker.py +128 -0
- mindroom/room_cleanup.py +139 -0
- mindroom/routing.py +107 -0
- mindroom/scheduling.py +758 -0
- mindroom/stop.py +207 -0
- mindroom/streaming.py +203 -0
- mindroom/teams.py +749 -0
- mindroom/thread_utils.py +318 -0
- mindroom/tools/__init__.py +520 -0
- mindroom/tools/agentql.py +64 -0
- mindroom/tools/airflow.py +57 -0
- mindroom/tools/apify.py +49 -0
- mindroom/tools/arxiv.py +64 -0
- mindroom/tools/aws_lambda.py +41 -0
- mindroom/tools/aws_ses.py +57 -0
- mindroom/tools/baidusearch.py +87 -0
- mindroom/tools/brightdata.py +116 -0
- mindroom/tools/browserbase.py +62 -0
- mindroom/tools/cal_com.py +98 -0
- mindroom/tools/calculator.py +112 -0
- mindroom/tools/cartesia.py +84 -0
- mindroom/tools/composio.py +166 -0
- mindroom/tools/config_manager.py +44 -0
- mindroom/tools/confluence.py +73 -0
- mindroom/tools/crawl4ai.py +101 -0
- mindroom/tools/csv.py +104 -0
- mindroom/tools/custom_api.py +106 -0
- mindroom/tools/dalle.py +85 -0
- mindroom/tools/daytona.py +180 -0
- mindroom/tools/discord.py +81 -0
- mindroom/tools/docker.py +73 -0
- mindroom/tools/duckdb.py +124 -0
- mindroom/tools/duckduckgo.py +99 -0
- mindroom/tools/e2b.py +121 -0
- mindroom/tools/eleven_labs.py +77 -0
- mindroom/tools/email.py +74 -0
- mindroom/tools/exa.py +246 -0
- mindroom/tools/fal.py +50 -0
- mindroom/tools/file.py +80 -0
- mindroom/tools/financial_datasets_api.py +112 -0
- mindroom/tools/firecrawl.py +124 -0
- mindroom/tools/gemini.py +85 -0
- mindroom/tools/giphy.py +49 -0
- mindroom/tools/github.py +376 -0
- mindroom/tools/gmail.py +102 -0
- mindroom/tools/google_calendar.py +55 -0
- mindroom/tools/google_maps.py +112 -0
- mindroom/tools/google_sheets.py +86 -0
- mindroom/tools/googlesearch.py +83 -0
- mindroom/tools/groq.py +77 -0
- mindroom/tools/hackernews.py +54 -0
- mindroom/tools/jina.py +108 -0
- mindroom/tools/jira.py +70 -0
- mindroom/tools/linear.py +103 -0
- mindroom/tools/linkup.py +65 -0
- mindroom/tools/lumalabs.py +71 -0
- mindroom/tools/mem0.py +82 -0
- mindroom/tools/modelslabs.py +85 -0
- mindroom/tools/moviepy_video_tools.py +62 -0
- mindroom/tools/newspaper4k.py +63 -0
- mindroom/tools/openai.py +143 -0
- mindroom/tools/openweather.py +89 -0
- mindroom/tools/oxylabs.py +54 -0
- mindroom/tools/pandas.py +35 -0
- mindroom/tools/pubmed.py +64 -0
- mindroom/tools/python.py +120 -0
- mindroom/tools/reddit.py +155 -0
- mindroom/tools/replicate.py +56 -0
- mindroom/tools/resend.py +55 -0
- mindroom/tools/scrapegraph.py +87 -0
- mindroom/tools/searxng.py +120 -0
- mindroom/tools/serpapi.py +55 -0
- mindroom/tools/serper.py +81 -0
- mindroom/tools/shell.py +46 -0
- mindroom/tools/slack.py +80 -0
- mindroom/tools/sleep.py +38 -0
- mindroom/tools/spider.py +62 -0
- mindroom/tools/sql.py +138 -0
- mindroom/tools/tavily.py +104 -0
- mindroom/tools/telegram.py +54 -0
- mindroom/tools/todoist.py +103 -0
- mindroom/tools/trello.py +121 -0
- mindroom/tools/twilio.py +97 -0
- mindroom/tools/web_browser_tools.py +37 -0
- mindroom/tools/webex.py +63 -0
- mindroom/tools/website.py +45 -0
- mindroom/tools/whatsapp.py +81 -0
- mindroom/tools/wikipedia.py +45 -0
- mindroom/tools/x.py +97 -0
- mindroom/tools/yfinance.py +121 -0
- mindroom/tools/youtube.py +81 -0
- mindroom/tools/zendesk.py +62 -0
- mindroom/tools/zep.py +107 -0
- mindroom/tools/zoom.py +62 -0
- mindroom/tools_metadata.json +7643 -0
- mindroom/tools_metadata.py +220 -0
- mindroom/topic_generator.py +153 -0
- mindroom/voice_handler.py +266 -0
- mindroom-0.1.0.dist-info/METADATA +425 -0
- mindroom-0.1.0.dist-info/RECORD +152 -0
- {mindroom-0.0.0.dist-info → mindroom-0.1.0.dist-info}/WHEEL +1 -2
- mindroom-0.1.0.dist-info/entry_points.txt +2 -0
- mindroom-0.0.0.dist-info/METADATA +0 -24
- mindroom-0.0.0.dist-info/RECORD +0 -4
- mindroom-0.0.0.dist-info/top_level.txt +0 -1
mindroom/stop.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Minimal stop button functionality for the bot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import nio
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from nio import AsyncClient
|
|
13
|
+
|
|
14
|
+
import structlog
|
|
15
|
+
|
|
16
|
+
logger = structlog.get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class TrackedMessage:
|
|
21
|
+
"""Track a message with stop button."""
|
|
22
|
+
|
|
23
|
+
message_id: str
|
|
24
|
+
room_id: str
|
|
25
|
+
task: asyncio.Task[None]
|
|
26
|
+
reaction_event_id: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class StopManager:
|
|
30
|
+
"""Manager for handling stop reactions."""
|
|
31
|
+
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
"""Initialize the stop manager."""
|
|
34
|
+
# Track multiple concurrent messages by message_id
|
|
35
|
+
self.tracked_messages: dict[str, TrackedMessage] = {}
|
|
36
|
+
# Keep references to cleanup tasks
|
|
37
|
+
self.cleanup_tasks: list[asyncio.Task] = []
|
|
38
|
+
logger.info("StopManager initialized")
|
|
39
|
+
|
|
40
|
+
def set_current(
|
|
41
|
+
self,
|
|
42
|
+
message_id: str,
|
|
43
|
+
room_id: str,
|
|
44
|
+
task: asyncio.Task[None],
|
|
45
|
+
reaction_event_id: str | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Track a message generation."""
|
|
48
|
+
self.tracked_messages[message_id] = TrackedMessage(
|
|
49
|
+
message_id=message_id,
|
|
50
|
+
room_id=room_id,
|
|
51
|
+
task=task,
|
|
52
|
+
reaction_event_id=reaction_event_id,
|
|
53
|
+
)
|
|
54
|
+
logger.info(
|
|
55
|
+
"Tracking message generation",
|
|
56
|
+
message_id=message_id,
|
|
57
|
+
room_id=room_id,
|
|
58
|
+
reaction_event_id=reaction_event_id,
|
|
59
|
+
total_tracked=len(self.tracked_messages),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def clear_message(
|
|
63
|
+
self,
|
|
64
|
+
message_id: str,
|
|
65
|
+
client: AsyncClient,
|
|
66
|
+
remove_button: bool = True,
|
|
67
|
+
delay: float = 5.0,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Clear tracking for a specific message and optionally remove stop button.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
message_id: The message ID to clear
|
|
73
|
+
client: Matrix client for removing stop button
|
|
74
|
+
remove_button: Whether to remove the stop button (default True)
|
|
75
|
+
delay: Seconds to wait before clearing (default 5.0)
|
|
76
|
+
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
async def delayed_clear() -> None:
|
|
80
|
+
"""Clear the message and remove stop button after a delay."""
|
|
81
|
+
if remove_button and message_id in self.tracked_messages:
|
|
82
|
+
tracked = self.tracked_messages[message_id]
|
|
83
|
+
if tracked.reaction_event_id:
|
|
84
|
+
logger.info("Removing stop button in cleanup", message_id=message_id)
|
|
85
|
+
try:
|
|
86
|
+
await client.room_redact(
|
|
87
|
+
room_id=tracked.room_id,
|
|
88
|
+
event_id=tracked.reaction_event_id,
|
|
89
|
+
reason="Response completed",
|
|
90
|
+
)
|
|
91
|
+
tracked.reaction_event_id = None
|
|
92
|
+
except Exception as e:
|
|
93
|
+
logger.warning(f"Failed to remove stop button in cleanup: {e}")
|
|
94
|
+
|
|
95
|
+
await asyncio.sleep(delay)
|
|
96
|
+
if message_id in self.tracked_messages:
|
|
97
|
+
logger.info("Clearing tracked message after delay", message_id=message_id, delay=delay)
|
|
98
|
+
del self.tracked_messages[message_id]
|
|
99
|
+
|
|
100
|
+
if message_id in self.tracked_messages:
|
|
101
|
+
logger.info(
|
|
102
|
+
"Scheduling message cleanup",
|
|
103
|
+
message_id=message_id,
|
|
104
|
+
delay=delay,
|
|
105
|
+
remove_button=remove_button,
|
|
106
|
+
)
|
|
107
|
+
task = asyncio.create_task(delayed_clear())
|
|
108
|
+
self.cleanup_tasks.append(task)
|
|
109
|
+
# Clean up old completed tasks
|
|
110
|
+
self.cleanup_tasks = [t for t in self.cleanup_tasks if not t.done()]
|
|
111
|
+
else:
|
|
112
|
+
logger.debug("Message not tracked, skipping cleanup", message_id=message_id)
|
|
113
|
+
|
|
114
|
+
async def handle_stop_reaction(self, message_id: str) -> bool:
|
|
115
|
+
"""Handle a stop reaction for a message.
|
|
116
|
+
|
|
117
|
+
Returns True if the task was cancelled, False otherwise.
|
|
118
|
+
"""
|
|
119
|
+
logger.info(
|
|
120
|
+
"Handling stop reaction",
|
|
121
|
+
message_id=message_id,
|
|
122
|
+
tracked_messages=list(self.tracked_messages.keys()),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if message_id in self.tracked_messages:
|
|
126
|
+
tracked = self.tracked_messages[message_id]
|
|
127
|
+
if tracked.task and not tracked.task.done():
|
|
128
|
+
logger.info("Cancelling task for message", message_id=message_id)
|
|
129
|
+
tracked.task.cancel()
|
|
130
|
+
# Don't clear here - let the finally block handle it
|
|
131
|
+
return True
|
|
132
|
+
logger.info(
|
|
133
|
+
"Task already completed or missing",
|
|
134
|
+
message_id=message_id,
|
|
135
|
+
task_exists=tracked.task is not None,
|
|
136
|
+
task_done=tracked.task.done() if tracked.task else None,
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
logger.warning("Stop reaction for untracked message", message_id=message_id)
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
async def add_stop_button(self, client: AsyncClient, room_id: str, message_id: str) -> str | None:
|
|
143
|
+
"""Add a stop button reaction to a message.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
The event ID of the reaction if successful, None otherwise.
|
|
147
|
+
|
|
148
|
+
"""
|
|
149
|
+
logger.info("Adding stop button", room_id=room_id, message_id=message_id)
|
|
150
|
+
try:
|
|
151
|
+
response = await client.room_send(
|
|
152
|
+
room_id=room_id,
|
|
153
|
+
message_type="m.reaction",
|
|
154
|
+
content={
|
|
155
|
+
"m.relates_to": {
|
|
156
|
+
"rel_type": "m.annotation",
|
|
157
|
+
"event_id": message_id,
|
|
158
|
+
"key": "🛑",
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
if isinstance(response, nio.RoomSendResponse):
|
|
163
|
+
event_id = str(response.event_id)
|
|
164
|
+
logger.info("Stop button added successfully", reaction_event_id=event_id, message_id=message_id)
|
|
165
|
+
# Update the tracked message with the reaction event ID
|
|
166
|
+
if message_id in self.tracked_messages:
|
|
167
|
+
self.tracked_messages[message_id].reaction_event_id = event_id
|
|
168
|
+
return event_id
|
|
169
|
+
logger.warning("Failed to add stop button - no event_id in response", response=response)
|
|
170
|
+
except Exception as e:
|
|
171
|
+
logger.exception("Exception adding stop button", error=str(e))
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
async def remove_stop_button(self, client: AsyncClient, message_id: str | None = None) -> None:
|
|
175
|
+
"""Remove the stop button reaction immediately when user clicks it.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
client: The Matrix client
|
|
179
|
+
message_id: The message ID to remove the button from
|
|
180
|
+
|
|
181
|
+
"""
|
|
182
|
+
if message_id and message_id in self.tracked_messages:
|
|
183
|
+
tracked = self.tracked_messages[message_id]
|
|
184
|
+
if tracked.reaction_event_id and tracked.room_id:
|
|
185
|
+
logger.info(
|
|
186
|
+
"Removing stop button immediately (user clicked)",
|
|
187
|
+
message_id=message_id,
|
|
188
|
+
reaction_event_id=tracked.reaction_event_id,
|
|
189
|
+
)
|
|
190
|
+
try:
|
|
191
|
+
await client.room_redact(
|
|
192
|
+
room_id=tracked.room_id,
|
|
193
|
+
event_id=tracked.reaction_event_id,
|
|
194
|
+
reason="User clicked stop",
|
|
195
|
+
)
|
|
196
|
+
tracked.reaction_event_id = None
|
|
197
|
+
logger.info("Stop button removed successfully")
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.exception("Failed to remove stop button", error=str(e))
|
|
200
|
+
else:
|
|
201
|
+
logger.debug(
|
|
202
|
+
"Stop button already removed or missing",
|
|
203
|
+
message_id=message_id,
|
|
204
|
+
has_reaction_id=tracked.reaction_event_id is not None,
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
logger.debug("Message not tracked, cannot remove stop button", message_id=message_id)
|
mindroom/streaming.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Streaming response implementation for real-time message updates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from agno.run.response import RunResponseContentEvent, ToolCallCompletedEvent, ToolCallStartedEvent
|
|
10
|
+
|
|
11
|
+
from . import interactive
|
|
12
|
+
from .ai import _format_tool_completed_message, _format_tool_started_message
|
|
13
|
+
from .logging_config import get_logger
|
|
14
|
+
from .matrix.client import edit_message, send_message
|
|
15
|
+
from .matrix.mentions import format_message_with_mentions
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import AsyncIterator
|
|
19
|
+
|
|
20
|
+
import nio
|
|
21
|
+
|
|
22
|
+
from .config import Config
|
|
23
|
+
|
|
24
|
+
from .matrix.client import get_latest_thread_event_id_if_needed
|
|
25
|
+
|
|
26
|
+
logger = get_logger(__name__)
|
|
27
|
+
|
|
28
|
+
# Global constant for the in-progress marker
|
|
29
|
+
IN_PROGRESS_MARKER = " ⋯"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class StreamingResponse:
|
|
34
|
+
"""Manages a streaming response with incremental message updates."""
|
|
35
|
+
|
|
36
|
+
room_id: str
|
|
37
|
+
reply_to_event_id: str | None
|
|
38
|
+
thread_id: str | None
|
|
39
|
+
sender_domain: str
|
|
40
|
+
config: Config
|
|
41
|
+
accumulated_text: str = ""
|
|
42
|
+
event_id: str | None = None # None until first message sent
|
|
43
|
+
last_update: float = 0.0
|
|
44
|
+
update_interval: float = 1.0
|
|
45
|
+
latest_thread_event_id: str | None = None # For MSC3440 compliance
|
|
46
|
+
|
|
47
|
+
def _update(self, new_chunk: str) -> None:
|
|
48
|
+
"""Append new chunk to accumulated text."""
|
|
49
|
+
self.accumulated_text += new_chunk
|
|
50
|
+
|
|
51
|
+
async def update_content(self, new_chunk: str, client: nio.AsyncClient) -> None:
|
|
52
|
+
"""Add new content and potentially update the message."""
|
|
53
|
+
self._update(new_chunk)
|
|
54
|
+
|
|
55
|
+
current_time = time.time()
|
|
56
|
+
if current_time - self.last_update >= self.update_interval:
|
|
57
|
+
await self._send_or_edit_message(client)
|
|
58
|
+
self.last_update = current_time
|
|
59
|
+
|
|
60
|
+
async def finalize(self, client: nio.AsyncClient) -> None:
|
|
61
|
+
"""Send final message update."""
|
|
62
|
+
await self._send_or_edit_message(client, is_final=True)
|
|
63
|
+
|
|
64
|
+
async def _send_or_edit_message(self, client: nio.AsyncClient, is_final: bool = False) -> None:
|
|
65
|
+
"""Send new message or edit existing one."""
|
|
66
|
+
if not self.accumulated_text.strip():
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
# Always ensure we have a thread_id - use the original message as thread root if needed
|
|
70
|
+
effective_thread_id = self.thread_id if self.thread_id else self.reply_to_event_id
|
|
71
|
+
|
|
72
|
+
# Add in-progress marker during streaming (not on final update)
|
|
73
|
+
text_to_send = self.accumulated_text
|
|
74
|
+
if not is_final:
|
|
75
|
+
text_to_send += IN_PROGRESS_MARKER
|
|
76
|
+
|
|
77
|
+
# Format the text (handles interactive questions if present)
|
|
78
|
+
response = interactive.parse_and_format_interactive(text_to_send, extract_mapping=False)
|
|
79
|
+
display_text = response.formatted_text
|
|
80
|
+
|
|
81
|
+
# Only use latest_thread_event_id for the initial message (not edits)
|
|
82
|
+
latest_for_message = self.latest_thread_event_id if self.event_id is None else None
|
|
83
|
+
|
|
84
|
+
content = format_message_with_mentions(
|
|
85
|
+
config=self.config,
|
|
86
|
+
text=display_text,
|
|
87
|
+
sender_domain=self.sender_domain,
|
|
88
|
+
thread_event_id=effective_thread_id,
|
|
89
|
+
reply_to_event_id=self.reply_to_event_id,
|
|
90
|
+
latest_thread_event_id=latest_for_message,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if self.event_id is None:
|
|
94
|
+
# First message - send new
|
|
95
|
+
logger.debug("Sending initial streaming message")
|
|
96
|
+
response_event_id = await send_message(client, self.room_id, content)
|
|
97
|
+
if response_event_id:
|
|
98
|
+
self.event_id = response_event_id
|
|
99
|
+
logger.debug("Initial streaming message sent", event_id=self.event_id)
|
|
100
|
+
else:
|
|
101
|
+
logger.error("Failed to send initial streaming message")
|
|
102
|
+
else:
|
|
103
|
+
# Subsequent updates - edit existing message
|
|
104
|
+
logger.debug("Editing streaming message", event_id=self.event_id)
|
|
105
|
+
response_event_id = await edit_message(client, self.room_id, self.event_id, content, display_text)
|
|
106
|
+
if not response_event_id:
|
|
107
|
+
logger.error("Failed to edit streaming message")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class ReplacementStreamingResponse(StreamingResponse):
|
|
111
|
+
"""StreamingResponse variant that replaces content instead of appending.
|
|
112
|
+
|
|
113
|
+
Useful for structured live rendering where the full document is rebuilt
|
|
114
|
+
on each tick and we want the message to reflect the latest full view,
|
|
115
|
+
not incremental concatenation.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
def _update(self, new_chunk: str) -> None:
|
|
119
|
+
"""Replace accumulated text with new chunk."""
|
|
120
|
+
self.accumulated_text = new_chunk
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def send_streaming_response(
|
|
124
|
+
client: nio.AsyncClient,
|
|
125
|
+
room_id: str,
|
|
126
|
+
reply_to_event_id: str | None,
|
|
127
|
+
thread_id: str | None,
|
|
128
|
+
sender_domain: str,
|
|
129
|
+
config: Config,
|
|
130
|
+
response_stream: AsyncIterator[object],
|
|
131
|
+
streaming_cls: type[StreamingResponse] = StreamingResponse,
|
|
132
|
+
header: str | None = None,
|
|
133
|
+
existing_event_id: str | None = None,
|
|
134
|
+
) -> tuple[str | None, str]:
|
|
135
|
+
"""Stream chunks to a Matrix room, returning (event_id, accumulated_text).
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
client: Matrix client
|
|
139
|
+
room_id: Destination room
|
|
140
|
+
reply_to_event_id: Event to reply to (can be None when in a thread)
|
|
141
|
+
thread_id: Thread root if already in a thread
|
|
142
|
+
sender_domain: Sender's homeserver domain for mention formatting
|
|
143
|
+
config: App config for mention formatting
|
|
144
|
+
response_stream: Async iterator yielding text chunks or response events
|
|
145
|
+
streaming_cls: StreamingResponse class to use (default: StreamingResponse, alternative: ReplacementStreamingResponse)
|
|
146
|
+
header: Optional text prefix to send before chunks
|
|
147
|
+
existing_event_id: If editing an existing message, pass its ID
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
Tuple of (final event_id or None, full accumulated text)
|
|
151
|
+
|
|
152
|
+
"""
|
|
153
|
+
latest_thread_event_id = await get_latest_thread_event_id_if_needed(
|
|
154
|
+
client,
|
|
155
|
+
room_id,
|
|
156
|
+
thread_id,
|
|
157
|
+
reply_to_event_id,
|
|
158
|
+
existing_event_id,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
streaming = streaming_cls(
|
|
162
|
+
room_id=room_id,
|
|
163
|
+
reply_to_event_id=reply_to_event_id,
|
|
164
|
+
thread_id=thread_id,
|
|
165
|
+
sender_domain=sender_domain,
|
|
166
|
+
config=config,
|
|
167
|
+
latest_thread_event_id=latest_thread_event_id,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Ensure the first chunk triggers an initial send immediately
|
|
171
|
+
streaming.last_update = float("-inf")
|
|
172
|
+
|
|
173
|
+
if existing_event_id:
|
|
174
|
+
streaming.event_id = existing_event_id
|
|
175
|
+
streaming.accumulated_text = ""
|
|
176
|
+
|
|
177
|
+
if header:
|
|
178
|
+
await streaming.update_content(header, client)
|
|
179
|
+
|
|
180
|
+
async for chunk in response_stream:
|
|
181
|
+
# Handle different types of chunks from the stream
|
|
182
|
+
if isinstance(chunk, str):
|
|
183
|
+
text_chunk = chunk
|
|
184
|
+
elif isinstance(chunk, RunResponseContentEvent) and chunk.content:
|
|
185
|
+
text_chunk = str(chunk.content)
|
|
186
|
+
elif isinstance(chunk, ToolCallStartedEvent):
|
|
187
|
+
text_chunk = _format_tool_started_message(chunk)
|
|
188
|
+
elif isinstance(chunk, ToolCallCompletedEvent):
|
|
189
|
+
text_chunk = _format_tool_completed_message(chunk)
|
|
190
|
+
else:
|
|
191
|
+
# Fallback for other event types - try to extract content
|
|
192
|
+
content = getattr(chunk, "content", None)
|
|
193
|
+
text_chunk = str(content) if content is not None else ""
|
|
194
|
+
if not text_chunk:
|
|
195
|
+
logger.debug(f"Unhandled streaming event type: {type(chunk).__name__}")
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
if text_chunk:
|
|
199
|
+
await streaming.update_content(text_chunk, client)
|
|
200
|
+
|
|
201
|
+
await streaming.finalize(client)
|
|
202
|
+
|
|
203
|
+
return streaming.event_id, streaming.accumulated_text
|