kiwi-code 0.0.4__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.
- kiwi_code-0.0.4.dist-info/METADATA +234 -0
- kiwi_code-0.0.4.dist-info/RECORD +24 -0
- kiwi_code-0.0.4.dist-info/WHEEL +4 -0
- kiwi_code-0.0.4.dist-info/entry_points.txt +4 -0
- kiwi_runtime/__init__.py +3 -0
- kiwi_runtime/__main__.py +5 -0
- kiwi_runtime/main.py +989 -0
- kiwi_tui/__init__.py +3 -0
- kiwi_tui/auth.py +125 -0
- kiwi_tui/cli.py +243 -0
- kiwi_tui/client.py +539 -0
- kiwi_tui/commands.py +434 -0
- kiwi_tui/config.py +79 -0
- kiwi_tui/logger.py +32 -0
- kiwi_tui/main.py +337 -0
- kiwi_tui/models.py +85 -0
- kiwi_tui/runtime_manager.py +130 -0
- kiwi_tui/screens/__init__.py +9 -0
- kiwi_tui/screens/actions.py +271 -0
- kiwi_tui/screens/autobots.py +216 -0
- kiwi_tui/screens/dashboard.py +608 -0
- kiwi_tui/screens/login.py +320 -0
- kiwi_tui/screens/runtime_logs.py +96 -0
- kiwi_tui/widgets.py +197 -0
kiwi_tui/client.py
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""Autobots client wrapper for API interactions."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
from typing import Optional, Callable
|
|
5
|
+
import time
|
|
6
|
+
import json
|
|
7
|
+
import codecs
|
|
8
|
+
import httpx
|
|
9
|
+
from loguru import logger
|
|
10
|
+
from autobots_client import Client, AuthenticatedClient
|
|
11
|
+
from autobots_client.api.hello import hello_v1_hello_get
|
|
12
|
+
from autobots_client.api.auth import (
|
|
13
|
+
return_user_and_session_v1_auth_post,
|
|
14
|
+
refresh_password_email_v1_auth_session_refresh_post,
|
|
15
|
+
)
|
|
16
|
+
from autobots_client.api.actions import async_run_action_v1_actions_id_async_run_post
|
|
17
|
+
from autobots_client.api.action_results import get_action_result_v1_action_results_id_get
|
|
18
|
+
from autobots_client.models.body_return_user_and_session_v1_auth_post import (
|
|
19
|
+
BodyReturnUserAndSessionV1AuthPost,
|
|
20
|
+
)
|
|
21
|
+
from autobots_client.models.body_async_run_action_v1_actions_id_async_run_post import (
|
|
22
|
+
BodyAsyncRunActionV1ActionsIdAsyncRunPost,
|
|
23
|
+
)
|
|
24
|
+
from autobots_client.models.body_async_run_action_v1_actions_id_async_run_post_input import (
|
|
25
|
+
BodyAsyncRunActionV1ActionsIdAsyncRunPostInput,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
from .models import AuthTokens, LoginCredentials
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AutobotsClientWrapper:
|
|
32
|
+
"""Wrapper around autobots_client for API calls."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
base_url: str,
|
|
37
|
+
access_token: Optional[str] = None,
|
|
38
|
+
api_key: Optional[str] = None,
|
|
39
|
+
):
|
|
40
|
+
"""Initialize autobots client wrapper.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
base_url: Base URL of the autobots backend
|
|
44
|
+
access_token: Optional access token for authentication
|
|
45
|
+
api_key: Optional API key for authentication (legacy)
|
|
46
|
+
"""
|
|
47
|
+
self.base_url = base_url
|
|
48
|
+
self.access_token = access_token
|
|
49
|
+
self.api_key = api_key
|
|
50
|
+
|
|
51
|
+
# Use access_token first, then api_key, then unauthenticated
|
|
52
|
+
token = access_token or api_key
|
|
53
|
+
|
|
54
|
+
if token:
|
|
55
|
+
self.client = AuthenticatedClient(
|
|
56
|
+
base_url=base_url,
|
|
57
|
+
token=token,
|
|
58
|
+
raise_on_unexpected_status=False,
|
|
59
|
+
)
|
|
60
|
+
logger.info(f"Initialized authenticated client for {base_url}")
|
|
61
|
+
else:
|
|
62
|
+
self.client = Client(
|
|
63
|
+
base_url=base_url,
|
|
64
|
+
raise_on_unexpected_status=False,
|
|
65
|
+
)
|
|
66
|
+
logger.info(f"Initialized unauthenticated client for {base_url}")
|
|
67
|
+
|
|
68
|
+
def update_token(self, access_token: str) -> None:
|
|
69
|
+
"""Update the client with a new access token.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
access_token: New access token
|
|
73
|
+
"""
|
|
74
|
+
self.access_token = access_token
|
|
75
|
+
self.client = AuthenticatedClient(
|
|
76
|
+
base_url=self.base_url,
|
|
77
|
+
token=access_token,
|
|
78
|
+
raise_on_unexpected_status=False,
|
|
79
|
+
)
|
|
80
|
+
logger.info("Client updated with new access token")
|
|
81
|
+
|
|
82
|
+
def hello(self) -> tuple[bool, str]:
|
|
83
|
+
"""Call the hello API endpoint.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Tuple of (success, message)
|
|
87
|
+
"""
|
|
88
|
+
try:
|
|
89
|
+
logger.debug("Calling hello API")
|
|
90
|
+
response = hello_v1_hello_get.sync_detailed(client=self.client)
|
|
91
|
+
|
|
92
|
+
if response.status_code == 200:
|
|
93
|
+
# Get response content as text
|
|
94
|
+
message = response.content.decode('utf-8') if response.content else "Hello from Autobots!"
|
|
95
|
+
logger.info(f"Hello API success: {message}")
|
|
96
|
+
return True, message
|
|
97
|
+
else:
|
|
98
|
+
error_msg = f"Hello API returned status {response.status_code}"
|
|
99
|
+
logger.warning(error_msg)
|
|
100
|
+
return False, error_msg
|
|
101
|
+
|
|
102
|
+
except Exception as e:
|
|
103
|
+
error_msg = f"Failed to call hello API: {str(e)}"
|
|
104
|
+
logger.error(error_msg)
|
|
105
|
+
return False, error_msg
|
|
106
|
+
|
|
107
|
+
def login(self, credentials: LoginCredentials) -> tuple[bool, Optional[AuthTokens], str]:
|
|
108
|
+
"""Login with username and password.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
credentials: Login credentials
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Tuple of (success, auth_tokens, message)
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
logger.info(f"Attempting login for user: {credentials.username}")
|
|
118
|
+
|
|
119
|
+
body = BodyReturnUserAndSessionV1AuthPost(
|
|
120
|
+
username=credentials.username,
|
|
121
|
+
password=credentials.password,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
response = return_user_and_session_v1_auth_post.sync_detailed(
|
|
125
|
+
client=self.client,
|
|
126
|
+
body=body,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if response.status_code == 200 and response.parsed:
|
|
130
|
+
auth_response = response.parsed
|
|
131
|
+
|
|
132
|
+
if auth_response.session:
|
|
133
|
+
session = auth_response.session
|
|
134
|
+
|
|
135
|
+
# Calculate expiry time
|
|
136
|
+
expires_at = None
|
|
137
|
+
if hasattr(session, 'expires_in') and session.expires_in:
|
|
138
|
+
expires_at = datetime.now() + timedelta(seconds=session.expires_in)
|
|
139
|
+
|
|
140
|
+
tokens = AuthTokens(
|
|
141
|
+
access_token=session.access_token,
|
|
142
|
+
refresh_token=session.refresh_token,
|
|
143
|
+
token_type=session.token_type,
|
|
144
|
+
expires_at=expires_at,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Update client with new token
|
|
148
|
+
self.update_token(tokens.access_token)
|
|
149
|
+
|
|
150
|
+
logger.info(f"Login successful for user: {credentials.username}")
|
|
151
|
+
return True, tokens, "Login successful"
|
|
152
|
+
else:
|
|
153
|
+
error_msg = "No session in response"
|
|
154
|
+
logger.error(error_msg)
|
|
155
|
+
return False, None, error_msg
|
|
156
|
+
else:
|
|
157
|
+
error_msg = f"Login failed with status {response.status_code}"
|
|
158
|
+
if response.content:
|
|
159
|
+
error_msg += f": {response.content.decode('utf-8')}"
|
|
160
|
+
logger.error(error_msg)
|
|
161
|
+
return False, None, error_msg
|
|
162
|
+
|
|
163
|
+
except Exception as e:
|
|
164
|
+
error_msg = f"Login error: {str(e)}"
|
|
165
|
+
logger.error(error_msg)
|
|
166
|
+
return False, None, error_msg
|
|
167
|
+
|
|
168
|
+
def refresh_token(self, refresh_token: str) -> tuple[bool, Optional[AuthTokens], str]:
|
|
169
|
+
"""Refresh access token using refresh token.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
refresh_token: Refresh token
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Tuple of (success, new_auth_tokens, message)
|
|
176
|
+
"""
|
|
177
|
+
try:
|
|
178
|
+
logger.info("Attempting to refresh access token")
|
|
179
|
+
|
|
180
|
+
# Need authenticated client for refresh
|
|
181
|
+
if not isinstance(self.client, AuthenticatedClient):
|
|
182
|
+
temp_client = AuthenticatedClient(
|
|
183
|
+
base_url=self.base_url,
|
|
184
|
+
token=refresh_token,
|
|
185
|
+
raise_on_unexpected_status=False,
|
|
186
|
+
)
|
|
187
|
+
else:
|
|
188
|
+
temp_client = self.client
|
|
189
|
+
|
|
190
|
+
response = refresh_password_email_v1_auth_session_refresh_post.sync_detailed(
|
|
191
|
+
client=temp_client,
|
|
192
|
+
refresh_token=refresh_token,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if response.status_code == 200 and response.parsed:
|
|
196
|
+
auth_response = response.parsed
|
|
197
|
+
|
|
198
|
+
if auth_response.session:
|
|
199
|
+
session = auth_response.session
|
|
200
|
+
|
|
201
|
+
# Calculate expiry time
|
|
202
|
+
expires_at = None
|
|
203
|
+
if hasattr(session, 'expires_in') and session.expires_in:
|
|
204
|
+
expires_at = datetime.now() + timedelta(seconds=session.expires_in)
|
|
205
|
+
|
|
206
|
+
tokens = AuthTokens(
|
|
207
|
+
access_token=session.access_token,
|
|
208
|
+
refresh_token=session.refresh_token,
|
|
209
|
+
token_type=session.token_type,
|
|
210
|
+
expires_at=expires_at,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
# Update client with new token
|
|
214
|
+
self.update_token(tokens.access_token)
|
|
215
|
+
|
|
216
|
+
logger.info("Token refresh successful")
|
|
217
|
+
return True, tokens, "Token refreshed successfully"
|
|
218
|
+
else:
|
|
219
|
+
error_msg = "No session in refresh response"
|
|
220
|
+
logger.error(error_msg)
|
|
221
|
+
return False, None, error_msg
|
|
222
|
+
else:
|
|
223
|
+
error_msg = f"Token refresh failed with status {response.status_code}"
|
|
224
|
+
logger.error(error_msg)
|
|
225
|
+
return False, None, error_msg
|
|
226
|
+
|
|
227
|
+
except Exception as e:
|
|
228
|
+
error_msg = f"Token refresh error: {str(e)}"
|
|
229
|
+
logger.error(error_msg)
|
|
230
|
+
return False, None, error_msg
|
|
231
|
+
|
|
232
|
+
def run_action_async(self, action_id: str, user_input: str, action_result_id: Optional[str] = None) -> tuple[bool, Optional[str], str]:
|
|
233
|
+
"""Run an action asynchronously and return the run ID.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
action_id: ID of the action to run
|
|
237
|
+
user_input: User input text for the action
|
|
238
|
+
action_result_id: Optional ID to continue an existing conversation
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Tuple of (success, run_id, message)
|
|
242
|
+
"""
|
|
243
|
+
try:
|
|
244
|
+
if action_result_id:
|
|
245
|
+
logger.info(f"Running action {action_id} with input: {user_input}, continuing run_id: {action_result_id}")
|
|
246
|
+
else:
|
|
247
|
+
logger.info(f"Running action {action_id} with input: {user_input}")
|
|
248
|
+
|
|
249
|
+
if not isinstance(self.client, AuthenticatedClient):
|
|
250
|
+
return False, None, "Authentication required"
|
|
251
|
+
|
|
252
|
+
# Create input body - DataBlock structure
|
|
253
|
+
input_data = BodyAsyncRunActionV1ActionsIdAsyncRunPostInput()
|
|
254
|
+
input_data.additional_properties = {
|
|
255
|
+
"text": user_input,
|
|
256
|
+
"urls": [],
|
|
257
|
+
"metadata": {
|
|
258
|
+
"process_url_in_text": False,
|
|
259
|
+
"process_attachment_url": True,
|
|
260
|
+
"attachment_requested": 0
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
body = BodyAsyncRunActionV1ActionsIdAsyncRunPost(input_=input_data)
|
|
265
|
+
|
|
266
|
+
# Import UNSET for optional parameter
|
|
267
|
+
from autobots_client.types import UNSET
|
|
268
|
+
|
|
269
|
+
response = async_run_action_v1_actions_id_async_run_post.sync_detailed(
|
|
270
|
+
id=action_id,
|
|
271
|
+
client=self.client,
|
|
272
|
+
body=body,
|
|
273
|
+
action_result_id=action_result_id if action_result_id else UNSET,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if response.status_code == 200 and response.parsed:
|
|
277
|
+
action_result = response.parsed
|
|
278
|
+
|
|
279
|
+
# ActionResultDoc has field_id as the run ID
|
|
280
|
+
run_id = getattr(action_result, 'field_id', None)
|
|
281
|
+
|
|
282
|
+
# Fallback to additional_properties if needed
|
|
283
|
+
if not run_id and hasattr(action_result, 'additional_properties'):
|
|
284
|
+
run_id = action_result.additional_properties.get('_id')
|
|
285
|
+
|
|
286
|
+
if run_id:
|
|
287
|
+
logger.info(f"Action started with run ID: {run_id}")
|
|
288
|
+
return True, run_id, f"Action started with ID: {run_id}"
|
|
289
|
+
else:
|
|
290
|
+
error_msg = "No run ID found in response"
|
|
291
|
+
logger.error(error_msg)
|
|
292
|
+
return False, None, error_msg
|
|
293
|
+
else:
|
|
294
|
+
error_msg = f"Failed to start action: status {response.status_code}"
|
|
295
|
+
logger.error(error_msg)
|
|
296
|
+
return False, None, error_msg
|
|
297
|
+
|
|
298
|
+
except Exception as e:
|
|
299
|
+
error_msg = f"Error running action: {str(e)}"
|
|
300
|
+
logger.error(error_msg)
|
|
301
|
+
return False, None, error_msg
|
|
302
|
+
|
|
303
|
+
def get_action_result(self, run_id: str) -> tuple[bool, Optional[dict], str]:
|
|
304
|
+
"""Get the result of an action run.
|
|
305
|
+
|
|
306
|
+
Args:
|
|
307
|
+
run_id: ID of the action run (action_results id)
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
Tuple of (success, result_data, message)
|
|
311
|
+
"""
|
|
312
|
+
try:
|
|
313
|
+
if not isinstance(self.client, AuthenticatedClient):
|
|
314
|
+
return False, None, "Authentication required"
|
|
315
|
+
|
|
316
|
+
response = get_action_result_v1_action_results_id_get.sync_detailed(
|
|
317
|
+
id=run_id,
|
|
318
|
+
client=self.client,
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
if response.status_code == 200 and response.parsed:
|
|
322
|
+
result = response.parsed
|
|
323
|
+
|
|
324
|
+
# Convert the parsed result to dict preserving full structure
|
|
325
|
+
# The result is ActionResultDoc which has:
|
|
326
|
+
# - status: EventResultStatus
|
|
327
|
+
# - result: ActionDoc (which contains results array)
|
|
328
|
+
# - error_message: DataBlock
|
|
329
|
+
|
|
330
|
+
result_dict = {}
|
|
331
|
+
|
|
332
|
+
# Extract status
|
|
333
|
+
if hasattr(result, 'status'):
|
|
334
|
+
status_obj = result.status
|
|
335
|
+
if hasattr(status_obj, 'value'):
|
|
336
|
+
result_dict["status"] = status_obj.value
|
|
337
|
+
else:
|
|
338
|
+
result_dict["status"] = str(status_obj)
|
|
339
|
+
|
|
340
|
+
# Extract the full ActionDoc with results array
|
|
341
|
+
if hasattr(result, 'result'):
|
|
342
|
+
action_doc = result.result
|
|
343
|
+
# Convert ActionDoc to dict
|
|
344
|
+
if hasattr(action_doc, 'to_dict'):
|
|
345
|
+
result_dict["result"] = action_doc.to_dict()
|
|
346
|
+
elif hasattr(action_doc, 'additional_properties'):
|
|
347
|
+
result_dict["result"] = action_doc.additional_properties
|
|
348
|
+
else:
|
|
349
|
+
# Try to extract key fields
|
|
350
|
+
action_dict = {}
|
|
351
|
+
if hasattr(action_doc, 'results'):
|
|
352
|
+
# This is the results array we need
|
|
353
|
+
results = action_doc.results
|
|
354
|
+
if isinstance(results, list):
|
|
355
|
+
action_dict["results"] = [
|
|
356
|
+
r.to_dict() if hasattr(r, 'to_dict') else r
|
|
357
|
+
for r in results
|
|
358
|
+
]
|
|
359
|
+
else:
|
|
360
|
+
action_dict["results"] = results
|
|
361
|
+
result_dict["result"] = action_dict
|
|
362
|
+
|
|
363
|
+
# Extract error message
|
|
364
|
+
if hasattr(result, 'error_message'):
|
|
365
|
+
error_obj = result.error_message
|
|
366
|
+
if error_obj:
|
|
367
|
+
if hasattr(error_obj, 'to_dict'):
|
|
368
|
+
result_dict["error"] = error_obj.to_dict()
|
|
369
|
+
elif hasattr(error_obj, 'text'):
|
|
370
|
+
result_dict["error"] = error_obj.text
|
|
371
|
+
else:
|
|
372
|
+
result_dict["error"] = str(error_obj)
|
|
373
|
+
|
|
374
|
+
result_dict["id"] = run_id
|
|
375
|
+
|
|
376
|
+
logger.info(f"Parsed result: status={result_dict.get('status')}, has_result={('result' in result_dict)}")
|
|
377
|
+
logger.info(f"Result structure keys: {result_dict.keys()}")
|
|
378
|
+
if "result" in result_dict and isinstance(result_dict["result"], dict):
|
|
379
|
+
logger.info(f"Result.result keys: {result_dict['result'].keys()}")
|
|
380
|
+
|
|
381
|
+
return True, result_dict, "Result retrieved"
|
|
382
|
+
else:
|
|
383
|
+
error_msg = f"Failed to get result: status {response.status_code}"
|
|
384
|
+
logger.error(error_msg)
|
|
385
|
+
return False, None, error_msg
|
|
386
|
+
|
|
387
|
+
except Exception as e:
|
|
388
|
+
error_msg = f"Error getting result: {str(e)}"
|
|
389
|
+
logger.error(error_msg)
|
|
390
|
+
return False, None, error_msg
|
|
391
|
+
|
|
392
|
+
def poll_action_result(self, run_id: str, max_attempts: int = 60, interval: float = 2.0) -> tuple[bool, Optional[dict], str]:
|
|
393
|
+
"""Poll for action result until completion or timeout.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
run_id: ID of the action run
|
|
397
|
+
max_attempts: Maximum number of polling attempts (default 60 = 2 minutes)
|
|
398
|
+
interval: Seconds between polls
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
Tuple of (success, result_data, message)
|
|
402
|
+
"""
|
|
403
|
+
logger.info(f"Polling for result of run {run_id}")
|
|
404
|
+
|
|
405
|
+
for attempt in range(max_attempts):
|
|
406
|
+
success, result, message = self.get_action_result(run_id)
|
|
407
|
+
|
|
408
|
+
if not success:
|
|
409
|
+
return False, None, message
|
|
410
|
+
|
|
411
|
+
status = result.get("status", "").lower() if result else ""
|
|
412
|
+
|
|
413
|
+
# Check for completion - status can be "completed", "success", or "finished"
|
|
414
|
+
if status in ["completed", "success", "finished"]:
|
|
415
|
+
logger.info(f"Action completed after {attempt + 1} attempts")
|
|
416
|
+
return True, result, "Action completed successfully"
|
|
417
|
+
|
|
418
|
+
# Check for failure
|
|
419
|
+
elif status in ["failed", "error"]:
|
|
420
|
+
error = result.get("error", "Unknown error") if result else "Unknown error"
|
|
421
|
+
logger.error(f"Action failed: {error}")
|
|
422
|
+
return False, result, f"Action failed: {error}"
|
|
423
|
+
|
|
424
|
+
# Still processing
|
|
425
|
+
elif status in ["processing", "running", "pending"]:
|
|
426
|
+
logger.debug(f"Action still {status}, attempt {attempt + 1}/{max_attempts}")
|
|
427
|
+
if attempt < max_attempts - 1:
|
|
428
|
+
time.sleep(interval)
|
|
429
|
+
|
|
430
|
+
# Unknown status
|
|
431
|
+
else:
|
|
432
|
+
logger.warning(f"Unknown status '{status}', continuing to poll...")
|
|
433
|
+
if attempt < max_attempts - 1:
|
|
434
|
+
time.sleep(interval)
|
|
435
|
+
|
|
436
|
+
return False, result, f"Polling timeout after {max_attempts * interval}s - action may still be running"
|
|
437
|
+
|
|
438
|
+
async def stream_action_result(self, run_id: str, on_message: Callable[[dict], None]) -> None:
|
|
439
|
+
"""Stream action result updates via Server-Sent Events.
|
|
440
|
+
|
|
441
|
+
Args:
|
|
442
|
+
run_id: ID of the action run
|
|
443
|
+
on_message: Callback function to handle incoming messages
|
|
444
|
+
"""
|
|
445
|
+
sse_endpoint = f"{self.base_url}/v1/server_sent_events/stream/{run_id}"
|
|
446
|
+
logger.info(f"Connecting to SSE: {sse_endpoint}")
|
|
447
|
+
|
|
448
|
+
try:
|
|
449
|
+
# Create headers
|
|
450
|
+
headers = {
|
|
451
|
+
"Accept": "text/event-stream",
|
|
452
|
+
"Cache-Control": "no-cache",
|
|
453
|
+
"Connection": "keep-alive",
|
|
454
|
+
}
|
|
455
|
+
if self.access_token:
|
|
456
|
+
headers["Authorization"] = f"Bearer {self.access_token}"
|
|
457
|
+
|
|
458
|
+
# Use httpx to stream SSE with longer timeout
|
|
459
|
+
async with httpx.AsyncClient(timeout=None) as client:
|
|
460
|
+
async with client.stream("GET", sse_endpoint, headers=headers) as response:
|
|
461
|
+
logger.info(f"SSE connected, status: {response.status_code}")
|
|
462
|
+
|
|
463
|
+
if response.status_code != 200:
|
|
464
|
+
logger.error(f"SSE connection failed with status {response.status_code}")
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
# Use incremental UTF-8 decoder to handle multi-byte characters across chunk boundaries
|
|
468
|
+
decoder = codecs.getincrementaldecoder("utf-8")()
|
|
469
|
+
buffer = ""
|
|
470
|
+
|
|
471
|
+
# Process SSE stream byte by byte with incremental decoder
|
|
472
|
+
async for chunk in response.aiter_bytes():
|
|
473
|
+
# Decode chunk with incremental decoder (handles partial multi-byte chars)
|
|
474
|
+
text_chunk = decoder.decode(chunk, final=False)
|
|
475
|
+
buffer += text_chunk
|
|
476
|
+
|
|
477
|
+
# Process complete lines from buffer
|
|
478
|
+
while "\n" in buffer:
|
|
479
|
+
line, buffer = buffer.split("\n", 1)
|
|
480
|
+
line = line.strip()
|
|
481
|
+
|
|
482
|
+
# Skip empty lines
|
|
483
|
+
if not line:
|
|
484
|
+
continue
|
|
485
|
+
|
|
486
|
+
# Skip keep-alive comments (`:keep-alive`)
|
|
487
|
+
if line.startswith(":"):
|
|
488
|
+
logger.debug("Received SSE keep-alive")
|
|
489
|
+
continue
|
|
490
|
+
|
|
491
|
+
# Parse SSE data format: "data: {...}" or "data: plain text"
|
|
492
|
+
if line.startswith("data: "):
|
|
493
|
+
data_str = line[6:].strip()
|
|
494
|
+
|
|
495
|
+
# Try to parse as JSON first
|
|
496
|
+
try:
|
|
497
|
+
data = json.loads(data_str)
|
|
498
|
+
logger.info(f"SSE JSON received, keys: {data.keys() if isinstance(data, dict) else type(data)}")
|
|
499
|
+
|
|
500
|
+
# Call the callback with parsed data
|
|
501
|
+
on_message(data)
|
|
502
|
+
|
|
503
|
+
# Check if action is complete
|
|
504
|
+
if isinstance(data, dict):
|
|
505
|
+
status = data.get("status", "").lower()
|
|
506
|
+
if status in ["completed", "success", "finished", "failed", "error"]:
|
|
507
|
+
logger.info(f"Action completed with status: {status}")
|
|
508
|
+
return
|
|
509
|
+
|
|
510
|
+
except json.JSONDecodeError:
|
|
511
|
+
# Not JSON, treat as plain text status message
|
|
512
|
+
logger.info(f"SSE text message: {data_str}")
|
|
513
|
+
|
|
514
|
+
# Mark as status so dashboard can distinguish from real output
|
|
515
|
+
text_data = {
|
|
516
|
+
"type": "status",
|
|
517
|
+
"text": data_str,
|
|
518
|
+
}
|
|
519
|
+
on_message(text_data)
|
|
520
|
+
|
|
521
|
+
except Exception as e:
|
|
522
|
+
logger.error(f"Error processing SSE data: {e}")
|
|
523
|
+
|
|
524
|
+
# Flush any remaining bytes in decoder
|
|
525
|
+
remaining = decoder.decode(b"", final=True)
|
|
526
|
+
if remaining:
|
|
527
|
+
buffer += remaining
|
|
528
|
+
|
|
529
|
+
except Exception as e:
|
|
530
|
+
logger.error(f"SSE error: {e}")
|
|
531
|
+
raise
|
|
532
|
+
|
|
533
|
+
def close(self) -> None:
|
|
534
|
+
"""Close the client connection."""
|
|
535
|
+
try:
|
|
536
|
+
self.client.__exit__(None, None, None)
|
|
537
|
+
logger.info("Client connection closed")
|
|
538
|
+
except Exception as e:
|
|
539
|
+
logger.warning(f"Error closing client: {e}")
|