@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.
- package/core/event_hub/entry.py +305 -26
- package/core/event_hub/hub.py +8 -0
- package/core/event_hub/server.py +80 -17
- package/core/kite_log.py +241 -0
- package/core/launcher/entry.py +978 -284
- package/core/launcher/process_manager.py +456 -46
- package/core/registry/entry.py +272 -3
- package/core/registry/server.py +339 -289
- package/core/registry/store.py +10 -4
- package/extensions/agents/__init__.py +1 -0
- package/extensions/agents/assistant/__init__.py +1 -0
- package/extensions/agents/assistant/entry.py +380 -0
- package/extensions/agents/assistant/module.md +22 -0
- package/extensions/agents/assistant/server.py +236 -0
- package/extensions/channels/__init__.py +1 -0
- package/extensions/channels/acp_channel/__init__.py +1 -0
- package/extensions/channels/acp_channel/entry.py +380 -0
- package/extensions/channels/acp_channel/module.md +22 -0
- package/extensions/channels/acp_channel/server.py +236 -0
- package/extensions/event_hub_bench/entry.py +664 -379
- package/extensions/event_hub_bench/module.md +2 -1
- package/extensions/services/backup/__init__.py +1 -0
- package/extensions/services/backup/entry.py +380 -0
- package/extensions/services/backup/module.md +22 -0
- package/extensions/services/backup/server.py +244 -0
- package/extensions/services/model_service/__init__.py +1 -0
- package/extensions/services/model_service/entry.py +380 -0
- package/extensions/services/model_service/module.md +22 -0
- package/extensions/services/model_service/server.py +236 -0
- package/extensions/services/watchdog/entry.py +460 -147
- package/extensions/services/watchdog/module.md +3 -0
- package/extensions/services/watchdog/monitor.py +128 -13
- package/extensions/services/watchdog/server.py +75 -13
- package/extensions/services/web/__init__.py +1 -0
- package/extensions/services/web/config.yaml +149 -0
- package/extensions/services/web/entry.py +487 -0
- package/extensions/services/web/module.md +24 -0
- package/extensions/services/web/routes/__init__.py +1 -0
- package/extensions/services/web/routes/routes_call.py +189 -0
- package/extensions/services/web/routes/routes_config.py +512 -0
- package/extensions/services/web/routes/routes_contacts.py +98 -0
- package/extensions/services/web/routes/routes_devlog.py +99 -0
- package/extensions/services/web/routes/routes_phone.py +81 -0
- package/extensions/services/web/routes/routes_sms.py +48 -0
- package/extensions/services/web/routes/routes_stats.py +17 -0
- package/extensions/services/web/routes/routes_voicechat.py +554 -0
- package/extensions/services/web/routes/schemas.py +216 -0
- package/extensions/services/web/server.py +332 -0
- package/extensions/services/web/static/css/style.css +1064 -0
- package/extensions/services/web/static/index.html +1445 -0
- package/extensions/services/web/static/js/app.js +4671 -0
- package/extensions/services/web/vendor/__init__.py +1 -0
- package/extensions/services/web/vendor/bluetooth/__init__.py +0 -0
- package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
- package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
- package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
- package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
- package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
- package/extensions/services/web/vendor/config.py +139 -0
- package/extensions/services/web/vendor/conversation/__init__.py +0 -0
- package/extensions/services/web/vendor/conversation/asr.py +936 -0
- package/extensions/services/web/vendor/conversation/engine.py +548 -0
- package/extensions/services/web/vendor/conversation/llm.py +534 -0
- package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
- package/extensions/services/web/vendor/conversation/tts.py +322 -0
- package/extensions/services/web/vendor/conversation/vad.py +138 -0
- package/extensions/services/web/vendor/storage/__init__.py +1 -0
- package/extensions/services/web/vendor/storage/identity.py +312 -0
- package/extensions/services/web/vendor/storage/store.py +507 -0
- package/extensions/services/web/vendor/task/__init__.py +0 -0
- package/extensions/services/web/vendor/task/manager.py +864 -0
- package/extensions/services/web/vendor/task/models.py +45 -0
- package/extensions/services/web/vendor/task/webhook.py +263 -0
- package/extensions/services/web/vendor/tools/__init__.py +0 -0
- package/extensions/services/web/vendor/tools/registry.py +321 -0
- package/main.py +230 -90
- 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")
|