@agentunion/kite 1.0.7 → 1.2.0

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.
Files changed (77) hide show
  1. package/core/event_hub/entry.py +305 -26
  2. package/core/event_hub/hub.py +8 -0
  3. package/core/event_hub/server.py +80 -17
  4. package/core/kite_log.py +241 -0
  5. package/core/launcher/entry.py +978 -284
  6. package/core/launcher/process_manager.py +456 -46
  7. package/core/registry/entry.py +272 -3
  8. package/core/registry/server.py +339 -289
  9. package/core/registry/store.py +10 -4
  10. package/extensions/agents/__init__.py +1 -0
  11. package/extensions/agents/assistant/__init__.py +1 -0
  12. package/extensions/agents/assistant/entry.py +380 -0
  13. package/extensions/agents/assistant/module.md +22 -0
  14. package/extensions/agents/assistant/server.py +236 -0
  15. package/extensions/channels/__init__.py +1 -0
  16. package/extensions/channels/acp_channel/__init__.py +1 -0
  17. package/extensions/channels/acp_channel/entry.py +380 -0
  18. package/extensions/channels/acp_channel/module.md +22 -0
  19. package/extensions/channels/acp_channel/server.py +236 -0
  20. package/extensions/event_hub_bench/entry.py +664 -379
  21. package/extensions/event_hub_bench/module.md +2 -1
  22. package/extensions/services/backup/__init__.py +1 -0
  23. package/extensions/services/backup/entry.py +380 -0
  24. package/extensions/services/backup/module.md +22 -0
  25. package/extensions/services/backup/server.py +244 -0
  26. package/extensions/services/model_service/__init__.py +1 -0
  27. package/extensions/services/model_service/entry.py +380 -0
  28. package/extensions/services/model_service/module.md +22 -0
  29. package/extensions/services/model_service/server.py +236 -0
  30. package/extensions/services/watchdog/entry.py +460 -147
  31. package/extensions/services/watchdog/module.md +3 -0
  32. package/extensions/services/watchdog/monitor.py +128 -13
  33. package/extensions/services/watchdog/server.py +75 -13
  34. package/extensions/services/web/__init__.py +1 -0
  35. package/extensions/services/web/config.yaml +149 -0
  36. package/extensions/services/web/entry.py +487 -0
  37. package/extensions/services/web/module.md +24 -0
  38. package/extensions/services/web/routes/__init__.py +1 -0
  39. package/extensions/services/web/routes/routes_call.py +189 -0
  40. package/extensions/services/web/routes/routes_config.py +512 -0
  41. package/extensions/services/web/routes/routes_contacts.py +98 -0
  42. package/extensions/services/web/routes/routes_devlog.py +99 -0
  43. package/extensions/services/web/routes/routes_phone.py +81 -0
  44. package/extensions/services/web/routes/routes_sms.py +48 -0
  45. package/extensions/services/web/routes/routes_stats.py +17 -0
  46. package/extensions/services/web/routes/routes_voicechat.py +554 -0
  47. package/extensions/services/web/routes/schemas.py +216 -0
  48. package/extensions/services/web/server.py +332 -0
  49. package/extensions/services/web/static/css/style.css +1064 -0
  50. package/extensions/services/web/static/index.html +1445 -0
  51. package/extensions/services/web/static/js/app.js +4671 -0
  52. package/extensions/services/web/vendor/__init__.py +1 -0
  53. package/extensions/services/web/vendor/bluetooth/__init__.py +0 -0
  54. package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
  55. package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
  56. package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
  57. package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
  58. package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
  59. package/extensions/services/web/vendor/config.py +139 -0
  60. package/extensions/services/web/vendor/conversation/__init__.py +0 -0
  61. package/extensions/services/web/vendor/conversation/asr.py +936 -0
  62. package/extensions/services/web/vendor/conversation/engine.py +548 -0
  63. package/extensions/services/web/vendor/conversation/llm.py +534 -0
  64. package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
  65. package/extensions/services/web/vendor/conversation/tts.py +322 -0
  66. package/extensions/services/web/vendor/conversation/vad.py +138 -0
  67. package/extensions/services/web/vendor/storage/__init__.py +1 -0
  68. package/extensions/services/web/vendor/storage/identity.py +312 -0
  69. package/extensions/services/web/vendor/storage/store.py +507 -0
  70. package/extensions/services/web/vendor/task/__init__.py +0 -0
  71. package/extensions/services/web/vendor/task/manager.py +864 -0
  72. package/extensions/services/web/vendor/task/models.py +45 -0
  73. package/extensions/services/web/vendor/task/webhook.py +263 -0
  74. package/extensions/services/web/vendor/tools/__init__.py +0 -0
  75. package/extensions/services/web/vendor/tools/registry.py +321 -0
  76. package/main.py +230 -90
  77. package/package.json +1 -1
@@ -0,0 +1,216 @@
1
+ """Pydantic models for all API request / response types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # Call
12
+ # ---------------------------------------------------------------------------
13
+
14
+ class CallRequest(BaseModel):
15
+ phone_number: Optional[str] = None
16
+ purpose: str
17
+ system_prompt: Optional[str] = None
18
+ webhook_url: Optional[str] = None
19
+ max_duration_seconds: int = 300
20
+ language: str = "zh"
21
+ play_text: Optional[str] = None
22
+ require_confirmation: Optional[bool] = None
23
+
24
+
25
+ class CallResponse(BaseModel):
26
+ task_id: str
27
+ status: str
28
+
29
+
30
+ class CallStatus(BaseModel):
31
+ task_id: str
32
+ status: str
33
+ phone_number: Optional[str] = None
34
+ contact_name: Optional[str] = None
35
+ direction: Optional[str] = None
36
+ duration_seconds: Optional[float] = None
37
+ result: Optional[str] = None
38
+ summary: Optional[str] = None
39
+ started_at: Optional[str] = None
40
+ ended_at: Optional[str] = None
41
+ has_recording: Optional[bool] = None
42
+
43
+
44
+ class CallConfirmRequest(BaseModel):
45
+ action: str
46
+ phone_number: Optional[str] = None
47
+ system_prompt: Optional[str] = None
48
+ purpose: Optional[str] = None
49
+
50
+
51
+ class CallMessageRequest(BaseModel):
52
+ message: str
53
+
54
+
55
+ class HangupResponse(BaseModel):
56
+ task_id: str
57
+ status: str
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Phone / Bluetooth
62
+ # ---------------------------------------------------------------------------
63
+
64
+ class PhoneStatus(BaseModel):
65
+ bluetooth_connected: bool = False
66
+ device_name: Optional[str] = None
67
+ device_address: Optional[str] = None
68
+ battery_level: Optional[int] = None
69
+ signal_strength: Optional[int] = None
70
+ operator: Optional[str] = None
71
+ in_call: bool = False
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # SMS
76
+ # ---------------------------------------------------------------------------
77
+
78
+ class SMSRequest(BaseModel):
79
+ phone_number: str
80
+ content: str
81
+
82
+
83
+ class SMSRecord(BaseModel):
84
+ id: str
85
+ phone_number: str
86
+ contact_name: Optional[str] = None
87
+ direction: str
88
+ content: str
89
+ status: Optional[str] = None
90
+ timestamp: Optional[str] = None
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Contacts
95
+ # ---------------------------------------------------------------------------
96
+
97
+ class ContactCreate(BaseModel):
98
+ name: str
99
+ phone: str
100
+ company: Optional[str] = None
101
+ title: Optional[str] = None
102
+ notes: Optional[str] = None
103
+ tags: Optional[list[str]] = None
104
+
105
+
106
+ class ContactUpdate(BaseModel):
107
+ name: Optional[str] = None
108
+ phone: Optional[str] = None
109
+ company: Optional[str] = None
110
+ title: Optional[str] = None
111
+ notes: Optional[str] = None
112
+ tags: Optional[list[str]] = None
113
+
114
+
115
+ class ContactRecord(BaseModel):
116
+ id: str
117
+ name: str
118
+ phone: str
119
+ company: Optional[str] = None
120
+ title: Optional[str] = None
121
+ notes: Optional[str] = None
122
+ tags: Optional[list[str]] = None
123
+ source: Optional[str] = None
124
+ created_at: Optional[str] = None
125
+ updated_at: Optional[str] = None
126
+
127
+
128
+ # ---------------------------------------------------------------------------
129
+ # Paginated wrapper
130
+ # ---------------------------------------------------------------------------
131
+
132
+ class PaginatedResponse(BaseModel):
133
+ items: list[Any] = Field(default_factory=list)
134
+ total: int = 0
135
+ page: int = 1
136
+ page_size: int = 20
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Stats
141
+ # ---------------------------------------------------------------------------
142
+
143
+ class StatsResponse(BaseModel):
144
+ total_calls: int = 0
145
+ total_duration_seconds: float = 0
146
+ avg_duration_seconds: float = 0
147
+ calls_today: int = 0
148
+ calls_this_week: int = 0
149
+ calls_by_result: dict[str, int] = Field(default_factory=dict)
150
+ calls_by_direction: dict[str, int] = Field(default_factory=dict)
151
+ total_sms_sent: int = 0
152
+ total_sms_received: int = 0
153
+ total_contacts: int = 0
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # Webhook
158
+ # ---------------------------------------------------------------------------
159
+
160
+ class WebhookPayload(BaseModel):
161
+ task_id: str
162
+ type: str
163
+ status: str
164
+ phone_number: Optional[str] = None
165
+ contact_name: Optional[str] = None
166
+ direction: Optional[str] = None
167
+ duration_seconds: Optional[float] = None
168
+ transcript: Optional[list[dict[str, Any]]] = None
169
+ summary: Optional[str] = None
170
+ result: Optional[str] = None
171
+ ended_reason: Optional[str] = None
172
+ recording_url: Optional[str] = None
173
+ timestamp: Optional[str] = None
174
+ matched_contact: Optional[dict[str, Any]] = None
175
+ contact_info: Optional[dict[str, Any]] = None
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Dev Log
180
+ # ---------------------------------------------------------------------------
181
+
182
+ class DevLogCreate(BaseModel):
183
+ content: str
184
+ important: bool = False
185
+ urgent: bool = False
186
+ type: str = "需求" # 需求 | BUG | 优化 | 重构 | 澄清 | 文档
187
+
188
+
189
+ class DevLogUpdate(BaseModel):
190
+ content: Optional[str] = None
191
+ important: Optional[bool] = None
192
+ urgent: Optional[bool] = None
193
+ status: Optional[str] = None
194
+ type: Optional[str] = None
195
+
196
+
197
+ class DevLogRecord(BaseModel):
198
+ id: str
199
+ content: str
200
+ important: bool = False
201
+ urgent: bool = False
202
+ type: str = "需求"
203
+ status: str = "pending"
204
+ created_at: Optional[str] = None
205
+ updated_at: Optional[str] = None
206
+ completed_at: Optional[str] = None
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # Config
211
+ # ---------------------------------------------------------------------------
212
+
213
+ class ConfigUpdate(BaseModel):
214
+ """Arbitrary config update payload — accepts any key/value pairs."""
215
+
216
+ model_config = {"extra": "allow"}
@@ -0,0 +1,332 @@
1
+ """
2
+ Web Management HTTP server.
3
+ Full web UI with all AI Phone Agent API endpoints.
4
+ Exposes /health, /status, static frontend, and all /api/* routes.
5
+ Connects to Event Hub via WebSocket for event publishing and subscription.
6
+ Sends periodic heartbeat to Registry and test events to Event Hub.
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import time
13
+ import uuid
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+
17
+ import httpx
18
+ import websockets
19
+ from fastapi import FastAPI
20
+ from fastapi.staticfiles import StaticFiles
21
+
22
+ from vendor import config as cfg
23
+ from vendor.bluetooth.manager import BluetoothManager
24
+ from vendor.task.manager import TaskManager
25
+ from vendor.tools.registry import init_registry
26
+
27
+ from routes.routes_call import router as call_router
28
+ from routes.routes_phone import router as phone_router
29
+ from routes.routes_config import router as config_router
30
+ from routes.routes_sms import router as sms_router
31
+ from routes.routes_contacts import router as contacts_router
32
+ from routes.routes_stats import router as stats_router
33
+ from routes.routes_voicechat import router as voicechat_router
34
+ from routes.routes_devlog import router as devlog_router
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class WebServer:
40
+
41
+ def __init__(self, token: str = "", registry_url: str = "",
42
+ event_hub_ws: str = "",
43
+ host: str = "0.0.0.0", port: int = 0, boot_t0: float = 0):
44
+ self.token = token
45
+ self.registry_url = registry_url
46
+ self.event_hub_ws = event_hub_ws
47
+ self.host = host
48
+ self.port = port
49
+ self.boot_t0 = boot_t0
50
+ self._ws_task: asyncio.Task | None = None
51
+ self._heartbeat_task: asyncio.Task | None = None
52
+ self._test_task: asyncio.Task | None = None
53
+ self._ws: object | None = None
54
+ self._ready_sent = False
55
+ self._shutting_down = False
56
+ self._uvicorn_server = None # set by entry.py for graceful shutdown
57
+ self._start_time = time.time()
58
+ self.bt_manager: BluetoothManager | None = None
59
+ self.task_manager: TaskManager | None = None
60
+ self.app = self._create_app()
61
+
62
+ def _create_app(self) -> FastAPI:
63
+ app = FastAPI(title="Kite Web Management", docs_url="/docs", redoc_url=None)
64
+ server = self
65
+
66
+ @app.on_event("startup")
67
+ async def _startup():
68
+ # Load configuration
69
+ cfg.load_config()
70
+ load_err = cfg.get_load_error()
71
+ if load_err:
72
+ print(f"[web] 配置加载失败,无法启动:\n {load_err}")
73
+ # Schedule graceful exit instead of crashing
74
+ if server._uvicorn_server:
75
+ server._uvicorn_server.should_exit = True
76
+ return
77
+
78
+ # Ensure data directories exist
79
+ base = cfg.data_dir()
80
+ for sub in ("sms", "contacts", "tasks", "config", "devlog", "users", "tools"):
81
+ (base / sub).mkdir(parents=True, exist_ok=True)
82
+
83
+ logging.basicConfig(
84
+ level=logging.INFO,
85
+ format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
86
+ )
87
+
88
+ # Initialize tool registry
89
+ registry = init_registry()
90
+ app.state.tool_registry = registry
91
+
92
+ # Initialize managers
93
+ server.bt_manager = BluetoothManager()
94
+ server.task_manager = TaskManager(server.bt_manager)
95
+
96
+ app.state.bt_manager = server.bt_manager
97
+ app.state.task_manager = server.task_manager
98
+
99
+ # Start bluetooth auto-connect in background
100
+ asyncio.create_task(server.bt_manager.start())
101
+
102
+ logger.info("Web Management: managers initialized")
103
+
104
+ # Start background tasks directly
105
+ server._heartbeat_task = asyncio.create_task(server._heartbeat_loop())
106
+ if server.event_hub_ws:
107
+ server._ws_task = asyncio.create_task(server._ws_loop())
108
+ server._test_task = asyncio.create_task(server._test_event_loop())
109
+
110
+ @app.on_event("shutdown")
111
+ async def _shutdown():
112
+ if server._heartbeat_task:
113
+ server._heartbeat_task.cancel()
114
+ if server._ws_task:
115
+ server._ws_task.cancel()
116
+ if server._test_task:
117
+ server._test_task.cancel()
118
+ if server._ws:
119
+ await server._ws.close()
120
+ if server.bt_manager:
121
+ await server.bt_manager.stop()
122
+ print("[web] Shutdown complete")
123
+
124
+ # Health and status endpoints
125
+ @app.get("/health")
126
+ async def health():
127
+ return {
128
+ "status": "healthy",
129
+ "details": {
130
+ "event_hub_connected": server._ws is not None,
131
+ "uptime_seconds": round(time.time() - server._start_time),
132
+ },
133
+ }
134
+
135
+ @app.get("/status")
136
+ async def status():
137
+ return {
138
+ "module": "web",
139
+ "status": "running",
140
+ "event_hub_connected": server._ws is not None,
141
+ "uptime_seconds": round(time.time() - server._start_time),
142
+ }
143
+
144
+ # Mount all API routes
145
+ app.include_router(call_router, prefix="/api")
146
+ app.include_router(phone_router, prefix="/api")
147
+ app.include_router(config_router, prefix="/api")
148
+ app.include_router(sms_router, prefix="/api")
149
+ app.include_router(contacts_router, prefix="/api")
150
+ app.include_router(stats_router, prefix="/api")
151
+ app.include_router(voicechat_router) # no prefix (has own /ws/ and /api/ paths)
152
+ app.include_router(devlog_router, prefix="/api")
153
+
154
+ # Serve frontend static files
155
+ static_dir = Path(__file__).parent / "static"
156
+ if static_dir.exists():
157
+ app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="web")
158
+
159
+ return app
160
+
161
+ # ── Event Hub WebSocket client ──
162
+
163
+ async def _ws_loop(self):
164
+ """Connect to Event Hub, subscribe, and listen. Reconnect on failure."""
165
+ retry_delay = 0.5 # start with 0.5s
166
+ max_delay = 30 # cap at 30s
167
+ while not self._shutting_down:
168
+ try:
169
+ await self._ws_connect()
170
+ retry_delay = 0.5 # reset on successful connection
171
+ except asyncio.CancelledError:
172
+ return
173
+ except Exception as e:
174
+ print(f"[web] Event Hub connection error: {e}, retrying in {retry_delay:.1f}s")
175
+ self._ws = None
176
+ if self._shutting_down:
177
+ return
178
+ await asyncio.sleep(retry_delay)
179
+ retry_delay = min(retry_delay * 2, max_delay) # exponential backoff
180
+
181
+ async def _ws_connect(self):
182
+ """Single WebSocket session: connect, subscribe, receive loop."""
183
+ url = f"{self.event_hub_ws}?token={self.token}&id=web"
184
+ print(f"[web] WS connecting to {self.event_hub_ws}")
185
+ async with websockets.connect(url, open_timeout=3, ping_interval=None, ping_timeout=None, close_timeout=10) as ws:
186
+ self._ws = ws
187
+ elapsed = time.monotonic() - self.boot_t0 if self.boot_t0 else 0
188
+ elapsed_str = f" ({elapsed:.1f}s)" if elapsed else ""
189
+ print(f"[web] Connected to Event Hub{elapsed_str}")
190
+
191
+ # Subscribe to module lifecycle events + shutdown
192
+ await ws.send(json.dumps({
193
+ "type": "subscribe",
194
+ "events": ["module.started", "module.stopped", "module.shutdown"],
195
+ }))
196
+
197
+ # Send module.ready (once) so Launcher knows we're up
198
+ if not self._ready_sent:
199
+ ready_msg = {
200
+ "type": "event",
201
+ "event_id": str(uuid.uuid4()),
202
+ "event": "module.ready",
203
+ "source": "web",
204
+ "timestamp": datetime.now(timezone.utc).isoformat(),
205
+ "data": {
206
+ "module_id": "web",
207
+ "graceful_shutdown": True,
208
+ },
209
+ }
210
+ await ws.send(json.dumps(ready_msg))
211
+ self._ready_sent = True
212
+ elapsed = time.monotonic() - self.boot_t0 if self.boot_t0 else 0
213
+ elapsed_str = f" ({elapsed:.1f}s)" if elapsed else ""
214
+ print(f"[web] module.ready sent{elapsed_str}")
215
+
216
+ # Publish web.started event with access URL
217
+ display_host = "localhost" if self.host == "0.0.0.0" else self.host
218
+ access_url = f"http://{display_host}:{self.port}"
219
+ await self._publish_event({
220
+ "event": "web.started",
221
+ "data": {
222
+ "module_id": "web",
223
+ "url": access_url,
224
+ "host": self.host,
225
+ "port": self.port,
226
+ },
227
+ })
228
+ print(f"[web] \033[32m✓ Web UI ready: {access_url}\033[0m")
229
+
230
+ # Receive loop
231
+ async for raw in ws:
232
+ try:
233
+ msg = json.loads(raw)
234
+ except (json.JSONDecodeError, TypeError):
235
+ continue
236
+
237
+ try:
238
+ msg_type = msg.get("type", "")
239
+ if msg_type == "event":
240
+ event_name = msg.get("event", "")
241
+ if event_name == "module.shutdown":
242
+ target = (msg.get("data") if isinstance(msg.get("data"), dict) else {}).get("module_id", "")
243
+ if target == "web":
244
+ await self._handle_shutdown(ws)
245
+ return
246
+ elif msg_type == "ack":
247
+ pass # publish confirmed
248
+ elif msg_type == "error":
249
+ print(f"[web] Event Hub error: {msg.get('message')}")
250
+ except Exception as e:
251
+ print(f"[web] 事件处理异常(已忽略): {e}")
252
+
253
+ async def _handle_shutdown(self, ws):
254
+ """Handle module.shutdown: ack → cleanup → ready → exit."""
255
+ print("[web] Received module.shutdown")
256
+ self._shutting_down = True
257
+
258
+ # Step 1: Send ack
259
+ await self._publish_event({
260
+ "event": "module.shutdown.ack",
261
+ "data": {"module_id": "web", "estimated_cleanup": 2},
262
+ })
263
+ print("[web] shutdown ack sent")
264
+
265
+ # Step 2: Cleanup (cancel background tasks)
266
+ if self._heartbeat_task:
267
+ self._heartbeat_task.cancel()
268
+ if self._test_task:
269
+ self._test_task.cancel()
270
+ if self.bt_manager:
271
+ await self.bt_manager.stop()
272
+
273
+ # Step 3: Send ready (before closing WS!)
274
+ await self._publish_event({
275
+ "event": "module.shutdown.ready",
276
+ "data": {"module_id": "web"},
277
+ })
278
+ print("[web] Shutdown complete")
279
+
280
+ # Step 4: Trigger uvicorn exit (WS will close when uvicorn shuts down)
281
+ if self._uvicorn_server:
282
+ self._uvicorn_server.should_exit = True
283
+
284
+ async def _publish_event(self, event: dict):
285
+ """Publish an event to Event Hub via WebSocket."""
286
+ if not self._ws:
287
+ return
288
+ msg = {
289
+ "type": "event",
290
+ "event_id": str(uuid.uuid4()),
291
+ "event": event.get("event", ""),
292
+ "source": "web",
293
+ "timestamp": datetime.now(timezone.utc).isoformat(),
294
+ "data": event.get("data", {}),
295
+ }
296
+ try:
297
+ await self._ws.send(json.dumps(msg))
298
+ except Exception as e:
299
+ print(f"[web] Failed to publish event: {e}")
300
+
301
+ # ── Heartbeat to Registry ──
302
+
303
+ async def _heartbeat_loop(self):
304
+ """Send heartbeat to Registry every 30 seconds."""
305
+ while True:
306
+ await asyncio.sleep(30)
307
+ try:
308
+ async with httpx.AsyncClient() as client:
309
+ await client.post(
310
+ f"{self.registry_url}/modules",
311
+ json={"action": "heartbeat", "module_id": "web"},
312
+ headers={"Authorization": f"Bearer {self.token}"},
313
+ timeout=5,
314
+ )
315
+ print("[web] heartbeat sent")
316
+ except Exception:
317
+ pass
318
+
319
+ # ── Test event loop ──
320
+
321
+ async def _test_event_loop(self):
322
+ """Publish a test event every 10 seconds."""
323
+ while True:
324
+ await asyncio.sleep(10)
325
+ await self._publish_event({
326
+ "event": "web.test",
327
+ "data": {
328
+ "message": "test event from web",
329
+ "timestamp": datetime.now(timezone.utc).isoformat(),
330
+ },
331
+ })
332
+ print("[web] test event published")