Skip to content

Commit f2fd0ee

Browse files
Merge pull request #44 from keyboardstaff/ws-rework
Ws rework
2 parents 69e1774 + 0749ddc commit f2fd0ee

41 files changed

Lines changed: 1766 additions & 3514 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

AGENTS.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,10 @@ When running in Docker, Agent Zero uses two distinct Python runtimes to isolate
7171
├── initialize.py # Framework initialization logic
7272
├── models.py # LLM provider configurations
7373
├── run_ui.py # WebUI server entry point
74-
├── python/
75-
│ ├── api/ # API Handlers (ApiHandler subclasses)
76-
│ ├── extensions/ # Backend lifecycle extensions
77-
│ ├── helpers/ # Shared Python utilities (plugins, files, etc.)
78-
│ ├── tools/ # Agent tools (Tool subclasses)
79-
│ └── websocket_handlers/# WebSocket event handlers
74+
├── api/ # API Handlers (ApiHandler subclasses) + WsHandler subclasses (ws_*.py)
75+
├── extensions/ # Backend lifecycle extensions
76+
├── helpers/ # Shared Python utilities (plugins, files, etc.)
77+
├── tools/ # Agent tools (Tool subclasses)
8078
├── webui/
8179
│ ├── components/ # Alpine.js components
8280
│ ├── js/ # Core frontend logic (modals, stores, etc.)

api/image_get.py

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import base64
22
import os
3+
from urllib.parse import quote
34
from helpers.api import ApiHandler, Request, Response, send_file
45
from helpers import files, runtime
56
import io
@@ -47,24 +48,31 @@ async def process(self, input: dict, request: Request) -> dict | Response:
4748

4849
# in development environment, try to serve the image from local file system if exists, otherwise from docker
4950
if runtime.is_development():
50-
if files.exists(path):
51-
response = send_file(path)
52-
elif await runtime.call_development_function(files.exists, path):
53-
b64_content = await runtime.call_development_function(
54-
files.read_file_base64, path
55-
)
56-
file_content = base64.b64decode(b64_content)
57-
mime_type, _ = guess_type(filename)
58-
if not mime_type:
59-
mime_type = "application/octet-stream"
60-
response = send_file(
61-
io.BytesIO(file_content),
62-
mimetype=mime_type,
63-
as_attachment=False,
64-
download_name=filename,
65-
)
51+
# Convert /a0/... Docker paths to local absolute paths
52+
local_path = files.fix_dev_path(path)
53+
if files.exists(local_path):
54+
response = send_file(local_path)
6655
else:
67-
response = _send_fallback_icon("image")
56+
# Try fetching from Docker via RFC as fallback
57+
try:
58+
if await runtime.call_development_function(files.exists, path):
59+
b64_content = await runtime.call_development_function(
60+
files.read_file_base64, path
61+
)
62+
file_content = base64.b64decode(b64_content)
63+
mime_type, _ = guess_type(filename)
64+
if not mime_type:
65+
mime_type = "application/octet-stream"
66+
response = send_file(
67+
io.BytesIO(file_content),
68+
mimetype=mime_type,
69+
as_attachment=False,
70+
download_name=filename,
71+
)
72+
else:
73+
response = _send_fallback_icon("image")
74+
except Exception:
75+
response = _send_fallback_icon("image")
6876
else:
6977
if files.exists(path):
7078
response = send_file(path)
@@ -74,7 +82,7 @@ async def process(self, input: dict, request: Request) -> dict | Response:
7482
# Add cache headers for better device sync performance
7583
response.headers["Cache-Control"] = "public, max-age=3600"
7684
response.headers["X-File-Type"] = "image"
77-
response.headers["X-File-Name"] = filename
85+
response.headers["X-File-Name"] = quote(filename)
7886
return response
7987
else:
8088
# Handle non-image files with fallback icons
@@ -135,7 +143,7 @@ def _send_file_type_icon(file_ext, filename=None):
135143
response.headers["X-File-Type"] = "icon"
136144
response.headers["X-Icon-Type"] = icon_name
137145
if filename:
138-
response.headers["X-File-Name"] = filename
146+
response.headers["X-File-Name"] = quote(filename)
139147

140148
return response
141149

api/ws_dev_test.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import asyncio
2+
from typing import Any
3+
4+
from helpers.ws import WsHandler
5+
from helpers.ws_manager import WsResult
6+
from helpers.print_style import PrintStyle
7+
from helpers import runtime
8+
9+
10+
class WsDevTest(WsHandler):
11+
"""Developer-only WebSocket test harness handler."""
12+
13+
async def process(self, event: str, data: dict, sid: str) -> dict[str, Any] | WsResult | None:
14+
if event == "ws_event_console_subscribe":
15+
if not runtime.is_development():
16+
return WsResult.error(
17+
code="NOT_AVAILABLE",
18+
message="Event console is available only in development mode",
19+
)
20+
registered = self.manager.register_diagnostic_watcher(self.namespace, sid)
21+
if not registered:
22+
return WsResult.error(
23+
code="SUBSCRIBE_FAILED",
24+
message="Unable to subscribe to diagnostics",
25+
)
26+
return {"status": "subscribed", "timestamp": data.get("requestedAt")}
27+
28+
if event == "ws_event_console_unsubscribe":
29+
self.manager.unregister_diagnostic_watcher(self.namespace, sid)
30+
return {"status": "unsubscribed"}
31+
32+
if event == "ws_tester_emit":
33+
message = data.get("message", "emit")
34+
payload = {"message": message, "echo": True, "timestamp": data.get("timestamp")}
35+
await self.broadcast("ws_tester_broadcast", payload)
36+
PrintStyle.info(f"Harness emit broadcasted message='{message}'")
37+
return None
38+
39+
if event == "ws_tester_request":
40+
value = data.get("value")
41+
PrintStyle.debug("Harness request responded with echo %s", value)
42+
return {"echo": value, "handler": self.identifier, "status": "ok"}
43+
44+
if event == "ws_tester_request_delayed":
45+
delay_ms = int(data.get("delay_ms", 0))
46+
await asyncio.sleep(delay_ms / 1000)
47+
PrintStyle.warning("Harness delayed request finished after %s ms", delay_ms)
48+
return {"status": "delayed", "delay_ms": delay_ms, "handler": self.identifier}
49+
50+
if event == "ws_tester_trigger_persistence":
51+
phase = data.get("phase", "unknown")
52+
payload = {"phase": phase, "handler": self.identifier}
53+
await self.emit_to(sid, "ws_tester_persistence", payload)
54+
PrintStyle.info(f"Harness persistence event phase='{phase}' -> {sid}")
55+
return None
56+
57+
if event == "ws_tester_broadcast_demo_trigger":
58+
payload = {"demo": True, "requested_at": data.get("requested_at")}
59+
await self.broadcast("ws_tester_broadcast_demo", payload)
60+
PrintStyle.info("Harness broadcast demo event dispatched")
61+
return None
62+
63+
if event == "ws_tester_request_all":
64+
correlation_id = data.get("correlationId")
65+
aggregated = await self.dispatch_to_all_sids(
66+
"ws_tester_request",
67+
{"value": data.get("marker", "aggregate")},
68+
correlation_id=correlation_id,
69+
)
70+
return {"results": aggregated}
71+
72+
# Ignore events not targeted at this handler (other activated handlers
73+
# may process them). Only warn for events that look like dev-harness
74+
# traffic so we don't spam logs with unrelated events.
75+
if event.startswith("ws_tester_"):
76+
PrintStyle.warning(f"Harness received unknown event '{event}'")
77+
return None

api/ws_hello.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from helpers.ws import WsHandler
2+
from helpers.print_style import PrintStyle
3+
4+
5+
class WsHello(WsHandler):
6+
"""Simple echo handler used for foundational testing."""
7+
8+
async def process(self, event: str, data: dict, sid: str) -> dict | None:
9+
if event != "hello_request":
10+
return None
11+
name = data.get("name") or "stranger"
12+
PrintStyle.info(f"hello_request from {sid} ({name})")
13+
return {"message": f"Hello, {name}!", "handler": self.identifier}

python/websocket_handlers/webui_handler.py renamed to api/ws_webui.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
from helpers.websocket import WebSocketHandler, WebSocketResult
1+
from helpers.ws import WsHandler
22
from helpers import extension
33

44

5-
class WebuiHandler(WebSocketHandler):
5+
class WsWebui(WsHandler):
6+
"""State synchronisation handler — the primary WebSocket endpoint for the UI."""
7+
68
async def on_connect(self, sid: str) -> None:
79
await extension.call_extensions_async(
810
"webui_ws_connect", agent=None, instance=self, sid=sid
@@ -13,22 +15,18 @@ async def on_disconnect(self, sid: str) -> None:
1315
"webui_ws_disconnect", agent=None, instance=self, sid=sid
1416
)
1517

16-
async def process_event(
17-
self, event_type: str, data: dict, sid: str
18-
) -> dict | WebSocketResult | None:
18+
async def process(self, event: str, data: dict, sid: str) -> dict | None:
1919
response_data: dict = {}
2020

2121
await extension.call_extensions_async(
2222
"webui_ws_event",
2323
agent=None,
2424
instance=self,
2525
sid=sid,
26-
event_type=event_type,
26+
event_type=event,
2727
data=data,
2828
response_data=response_data,
2929
)
3030

31-
return self.result_ok(
32-
response_data,
33-
correlation_id=data.get("correlationId"),
34-
)
31+
# Return None (fire-and-forget) when no extension populated the response.
32+
return response_data if response_data else None

0 commit comments

Comments
 (0)