@agentunion/kite 1.0.7 → 1.3.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/CHANGELOG.md +208 -0
- package/README.md +48 -0
- package/cli.js +1 -1
- package/extensions/agents/__init__.py +1 -0
- package/extensions/agents/assistant/__init__.py +1 -0
- package/extensions/agents/assistant/entry.py +329 -0
- package/extensions/agents/assistant/module.md +22 -0
- package/extensions/agents/assistant/server.py +197 -0
- package/extensions/channels/__init__.py +1 -0
- package/extensions/channels/acp_channel/__init__.py +1 -0
- package/extensions/channels/acp_channel/entry.py +329 -0
- package/extensions/channels/acp_channel/module.md +22 -0
- package/extensions/channels/acp_channel/server.py +197 -0
- package/extensions/event_hub_bench/entry.py +624 -379
- package/extensions/event_hub_bench/module.md +2 -1
- package/extensions/services/backup/__init__.py +1 -0
- package/extensions/services/backup/entry.py +508 -0
- package/extensions/services/backup/module.md +22 -0
- package/extensions/services/model_service/__init__.py +1 -0
- package/extensions/services/model_service/entry.py +508 -0
- package/extensions/services/model_service/module.md +22 -0
- package/extensions/services/watchdog/entry.py +468 -102
- package/extensions/services/watchdog/module.md +3 -0
- package/extensions/services/watchdog/monitor.py +170 -69
- package/extensions/services/web/__init__.py +1 -0
- package/extensions/services/web/config.yaml +149 -0
- package/extensions/services/web/entry.py +390 -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 +375 -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/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/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/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/registry.py +321 -0
- package/kernel/__init__.py +0 -0
- package/kernel/entry.py +407 -0
- package/{core/event_hub/hub.py → kernel/event_hub.py} +62 -74
- package/kernel/module.md +33 -0
- package/{core/registry/store.py → kernel/registry_store.py} +23 -8
- package/kernel/rpc_router.py +388 -0
- package/kernel/server.py +267 -0
- package/launcher/__init__.py +10 -0
- package/launcher/__main__.py +6 -0
- package/launcher/count_lines.py +258 -0
- package/launcher/entry.py +1778 -0
- package/launcher/logging_setup.py +289 -0
- package/{core/launcher → launcher}/module_scanner.py +11 -6
- package/launcher/process_manager.py +880 -0
- package/main.py +11 -210
- package/package.json +6 -9
- package/__init__.py +0 -1
- package/__main__.py +0 -15
- package/core/event_hub/BENCHMARK.md +0 -94
- package/core/event_hub/bench.py +0 -459
- package/core/event_hub/bench_extreme.py +0 -308
- package/core/event_hub/bench_perf.py +0 -350
- package/core/event_hub/entry.py +0 -157
- package/core/event_hub/module.md +0 -20
- package/core/event_hub/server.py +0 -206
- package/core/launcher/entry.py +0 -1158
- package/core/launcher/process_manager.py +0 -470
- package/core/registry/entry.py +0 -110
- package/core/registry/module.md +0 -30
- package/core/registry/server.py +0 -289
- package/extensions/services/watchdog/server.py +0 -167
- /package/{core → extensions/services/web/vendor/bluetooth}/__init__.py +0 -0
- /package/{core/event_hub → extensions/services/web/vendor/conversation}/__init__.py +0 -0
- /package/{core/launcher → extensions/services/web/vendor/task}/__init__.py +0 -0
- /package/{core/registry → extensions/services/web/vendor/tools}/__init__.py +0 -0
- /package/{core/event_hub → kernel}/dedup.py +0 -0
- /package/{core/event_hub → kernel}/router.py +0 -0
- /package/{core/launcher → launcher}/module.md +0 -0
|
@@ -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,375 @@
|
|
|
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 Kernel via WebSocket JSON-RPC 2.0 for event publishing and subscription.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
import uuid
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import websockets
|
|
17
|
+
from fastapi import FastAPI
|
|
18
|
+
from fastapi.staticfiles import StaticFiles
|
|
19
|
+
|
|
20
|
+
from vendor import config as cfg
|
|
21
|
+
from vendor.bluetooth.manager import BluetoothManager
|
|
22
|
+
from vendor.task.manager import TaskManager
|
|
23
|
+
from vendor.tools.registry import init_registry
|
|
24
|
+
|
|
25
|
+
from routes.routes_call import router as call_router
|
|
26
|
+
from routes.routes_phone import router as phone_router
|
|
27
|
+
from routes.routes_config import router as config_router
|
|
28
|
+
from routes.routes_sms import router as sms_router
|
|
29
|
+
from routes.routes_contacts import router as contacts_router
|
|
30
|
+
from routes.routes_stats import router as stats_router
|
|
31
|
+
from routes.routes_voicechat import router as voicechat_router
|
|
32
|
+
from routes.routes_devlog import router as devlog_router
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class WebServer:
|
|
38
|
+
|
|
39
|
+
def __init__(self, token: str = "", kernel_port: int = 0,
|
|
40
|
+
host: str = "0.0.0.0", port: int = 0, boot_t0: float = 0):
|
|
41
|
+
self.token = token
|
|
42
|
+
self.kernel_port = kernel_port
|
|
43
|
+
self.host = host
|
|
44
|
+
self.port = port
|
|
45
|
+
self.boot_t0 = boot_t0
|
|
46
|
+
self._ws_task: asyncio.Task | None = None
|
|
47
|
+
self._test_task: asyncio.Task | None = None
|
|
48
|
+
self._ws: object | None = None
|
|
49
|
+
self._ready_sent = False
|
|
50
|
+
self._shutting_down = False
|
|
51
|
+
self._uvicorn_server = None # set by entry.py for graceful shutdown
|
|
52
|
+
self._start_time = time.time()
|
|
53
|
+
self.bt_manager: BluetoothManager | None = None
|
|
54
|
+
self.task_manager: TaskManager | None = None
|
|
55
|
+
self.app = self._create_app()
|
|
56
|
+
|
|
57
|
+
def _create_app(self) -> FastAPI:
|
|
58
|
+
app = FastAPI(title="Kite Web Management", docs_url="/docs", redoc_url=None)
|
|
59
|
+
server = self
|
|
60
|
+
|
|
61
|
+
@app.on_event("startup")
|
|
62
|
+
async def _startup():
|
|
63
|
+
# Load configuration
|
|
64
|
+
cfg.load_config()
|
|
65
|
+
load_err = cfg.get_load_error()
|
|
66
|
+
if load_err:
|
|
67
|
+
print(f"[web] 配置加载失败,无法启动:\n {load_err}")
|
|
68
|
+
# Schedule graceful exit instead of crashing
|
|
69
|
+
if server._uvicorn_server:
|
|
70
|
+
server._uvicorn_server.should_exit = True
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
# Ensure data directories exist
|
|
74
|
+
base = cfg.data_dir()
|
|
75
|
+
for sub in ("sms", "contacts", "tasks", "config", "devlog", "users", "tools"):
|
|
76
|
+
(base / sub).mkdir(parents=True, exist_ok=True)
|
|
77
|
+
|
|
78
|
+
logging.basicConfig(
|
|
79
|
+
level=logging.INFO,
|
|
80
|
+
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
# Initialize tool registry
|
|
84
|
+
registry = init_registry()
|
|
85
|
+
app.state.tool_registry = registry
|
|
86
|
+
|
|
87
|
+
# Initialize managers
|
|
88
|
+
server.bt_manager = BluetoothManager()
|
|
89
|
+
server.task_manager = TaskManager(server.bt_manager)
|
|
90
|
+
|
|
91
|
+
app.state.bt_manager = server.bt_manager
|
|
92
|
+
app.state.task_manager = server.task_manager
|
|
93
|
+
|
|
94
|
+
# Start bluetooth auto-connect in background
|
|
95
|
+
asyncio.create_task(server.bt_manager.start())
|
|
96
|
+
|
|
97
|
+
logger.info("Web Management: managers initialized")
|
|
98
|
+
|
|
99
|
+
# Start background tasks directly
|
|
100
|
+
if server.kernel_port:
|
|
101
|
+
server._ws_task = asyncio.create_task(server._ws_loop())
|
|
102
|
+
server._test_task = asyncio.create_task(server._test_event_loop())
|
|
103
|
+
|
|
104
|
+
@app.on_event("shutdown")
|
|
105
|
+
async def _shutdown():
|
|
106
|
+
if server._ws_task:
|
|
107
|
+
server._ws_task.cancel()
|
|
108
|
+
if server._test_task:
|
|
109
|
+
server._test_task.cancel()
|
|
110
|
+
if server._ws:
|
|
111
|
+
await server._ws.close()
|
|
112
|
+
if server.bt_manager:
|
|
113
|
+
await server.bt_manager.stop()
|
|
114
|
+
print("[web] Shutdown complete")
|
|
115
|
+
|
|
116
|
+
# Health and status endpoints
|
|
117
|
+
@app.get("/health")
|
|
118
|
+
async def health():
|
|
119
|
+
return {
|
|
120
|
+
"status": "healthy",
|
|
121
|
+
"details": {
|
|
122
|
+
"kernel_connected": server._ws is not None,
|
|
123
|
+
"uptime_seconds": round(time.time() - server._start_time),
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
@app.get("/status")
|
|
128
|
+
async def status():
|
|
129
|
+
return {
|
|
130
|
+
"module": "web",
|
|
131
|
+
"status": "running",
|
|
132
|
+
"event_hub_connected": server._ws is not None,
|
|
133
|
+
"uptime_seconds": round(time.time() - server._start_time),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
# Mount all API routes
|
|
137
|
+
app.include_router(call_router, prefix="/api")
|
|
138
|
+
app.include_router(phone_router, prefix="/api")
|
|
139
|
+
app.include_router(config_router, prefix="/api")
|
|
140
|
+
app.include_router(sms_router, prefix="/api")
|
|
141
|
+
app.include_router(contacts_router, prefix="/api")
|
|
142
|
+
app.include_router(stats_router, prefix="/api")
|
|
143
|
+
app.include_router(voicechat_router) # no prefix (has own /ws/ and /api/ paths)
|
|
144
|
+
app.include_router(devlog_router, prefix="/api")
|
|
145
|
+
|
|
146
|
+
# Serve frontend static files
|
|
147
|
+
static_dir = Path(__file__).parent / "static"
|
|
148
|
+
if static_dir.exists():
|
|
149
|
+
app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="web")
|
|
150
|
+
|
|
151
|
+
return app
|
|
152
|
+
|
|
153
|
+
# ── Kernel WebSocket client ──
|
|
154
|
+
|
|
155
|
+
async def _ws_loop(self):
|
|
156
|
+
"""Connect to Kernel, subscribe, register, and listen. Reconnect on failure."""
|
|
157
|
+
retry_delay = 0.5 # start with 0.5s
|
|
158
|
+
max_delay = 30 # cap at 30s
|
|
159
|
+
while not self._shutting_down:
|
|
160
|
+
try:
|
|
161
|
+
await self._ws_connect()
|
|
162
|
+
retry_delay = 0.5 # reset on successful connection
|
|
163
|
+
except asyncio.CancelledError:
|
|
164
|
+
return
|
|
165
|
+
except Exception as e:
|
|
166
|
+
print(f"[web] Kernel connection error: {e}, retrying in {retry_delay:.1f}s")
|
|
167
|
+
self._ws = None
|
|
168
|
+
if self._shutting_down:
|
|
169
|
+
return
|
|
170
|
+
await asyncio.sleep(retry_delay)
|
|
171
|
+
retry_delay = min(retry_delay * 2, max_delay) # exponential backoff
|
|
172
|
+
|
|
173
|
+
async def _ws_connect(self):
|
|
174
|
+
"""Single WebSocket session: connect, register, subscribe, receive loop."""
|
|
175
|
+
url = f"ws://127.0.0.1:{self.kernel_port}/ws?token={self.token}&id=web"
|
|
176
|
+
print(f"[web] WS connecting to Kernel")
|
|
177
|
+
async with websockets.connect(url, open_timeout=5, ping_interval=None, ping_timeout=None, close_timeout=10) as ws:
|
|
178
|
+
self._ws = ws
|
|
179
|
+
elapsed = time.monotonic() - self.boot_t0 if self.boot_t0 else 0
|
|
180
|
+
elapsed_str = f" ({elapsed:.1f}s)" if elapsed else ""
|
|
181
|
+
print(f"[web] Connected to Kernel{elapsed_str}")
|
|
182
|
+
|
|
183
|
+
# Subscribe to events
|
|
184
|
+
await self._rpc_call(ws, "event.subscribe", {
|
|
185
|
+
"events": [
|
|
186
|
+
"module.started",
|
|
187
|
+
"module.stopped",
|
|
188
|
+
"module.shutdown",
|
|
189
|
+
],
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
# Register to Kernel Registry via RPC
|
|
193
|
+
await self._rpc_call(ws, "registry.register", {
|
|
194
|
+
"module_id": "web",
|
|
195
|
+
"module_type": "service",
|
|
196
|
+
"api_endpoint": f"http://127.0.0.1:{self.port}",
|
|
197
|
+
"health_endpoint": "/health",
|
|
198
|
+
"events_publish": {
|
|
199
|
+
"web.test": {"description": "Test event from web module"},
|
|
200
|
+
"web.started": {"description": "Web UI started with access URL"},
|
|
201
|
+
},
|
|
202
|
+
"events_subscribe": [
|
|
203
|
+
"module.started",
|
|
204
|
+
"module.stopped",
|
|
205
|
+
"module.shutdown",
|
|
206
|
+
],
|
|
207
|
+
})
|
|
208
|
+
print(f"[web] Registered to Kernel{elapsed_str}")
|
|
209
|
+
|
|
210
|
+
# Send module.ready (once) so Launcher knows we're up
|
|
211
|
+
if not self._ready_sent:
|
|
212
|
+
await self._rpc_call(ws, "event.publish", {
|
|
213
|
+
"event_id": str(uuid.uuid4()),
|
|
214
|
+
"event": "module.ready",
|
|
215
|
+
"data": {
|
|
216
|
+
"module_id": "web",
|
|
217
|
+
"graceful_shutdown": True,
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
self._ready_sent = True
|
|
221
|
+
elapsed = time.monotonic() - self.boot_t0 if self.boot_t0 else 0
|
|
222
|
+
elapsed_str = f" ({elapsed:.1f}s)" if elapsed else ""
|
|
223
|
+
print(f"[web] module.ready sent{elapsed_str}")
|
|
224
|
+
|
|
225
|
+
# Publish web.started event with access URL
|
|
226
|
+
display_host = "localhost" if self.host == "0.0.0.0" else self.host
|
|
227
|
+
access_url = f"http://{display_host}:{self.port}"
|
|
228
|
+
await self._publish_event({
|
|
229
|
+
"event": "web.started",
|
|
230
|
+
"data": {
|
|
231
|
+
"module_id": "web",
|
|
232
|
+
"url": access_url,
|
|
233
|
+
"host": self.host,
|
|
234
|
+
"port": self.port,
|
|
235
|
+
},
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
# Receive loop
|
|
239
|
+
async for raw in ws:
|
|
240
|
+
try:
|
|
241
|
+
msg = json.loads(raw)
|
|
242
|
+
except (json.JSONDecodeError, TypeError):
|
|
243
|
+
continue
|
|
244
|
+
|
|
245
|
+
try:
|
|
246
|
+
has_method = "method" in msg
|
|
247
|
+
has_id = "id" in msg
|
|
248
|
+
|
|
249
|
+
if has_method and not has_id:
|
|
250
|
+
# Event Notification
|
|
251
|
+
await self._handle_event_notification(msg)
|
|
252
|
+
elif has_method and has_id:
|
|
253
|
+
# Incoming RPC request
|
|
254
|
+
await self._handle_rpc_request(ws, msg)
|
|
255
|
+
# Ignore RPC responses (we don't await them in this simple impl)
|
|
256
|
+
except Exception as e:
|
|
257
|
+
print(f"[web] 消息处理异常(已忽略): {e}")
|
|
258
|
+
|
|
259
|
+
async def _rpc_call(self, ws, method: str, params: dict = None):
|
|
260
|
+
"""Send a JSON-RPC 2.0 request (fire-and-forget, no response awaited)."""
|
|
261
|
+
msg = {"jsonrpc": "2.0", "id": str(uuid.uuid4()), "method": method}
|
|
262
|
+
if params:
|
|
263
|
+
msg["params"] = params
|
|
264
|
+
await ws.send(json.dumps(msg))
|
|
265
|
+
|
|
266
|
+
async def _handle_event_notification(self, msg: dict):
|
|
267
|
+
"""Handle an event notification (JSON-RPC 2.0 Notification with method='event')."""
|
|
268
|
+
params = msg.get("params", {})
|
|
269
|
+
event_type = params.get("event", "")
|
|
270
|
+
data = params.get("data", {})
|
|
271
|
+
|
|
272
|
+
# Special handling for module.shutdown targeting web
|
|
273
|
+
if event_type == "module.shutdown" and data.get("module_id") == "web":
|
|
274
|
+
await self._handle_shutdown()
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
# Log other events
|
|
278
|
+
print(f"[web] Event received: {event_type}")
|
|
279
|
+
|
|
280
|
+
async def _handle_rpc_request(self, ws, msg: dict):
|
|
281
|
+
"""Handle an incoming RPC request (web.* methods)."""
|
|
282
|
+
rpc_id = msg.get("id", "")
|
|
283
|
+
method = msg.get("method", "")
|
|
284
|
+
params = msg.get("params", {})
|
|
285
|
+
|
|
286
|
+
handlers = {
|
|
287
|
+
"health": lambda p: self._rpc_health(),
|
|
288
|
+
"status": lambda p: self._rpc_status(),
|
|
289
|
+
}
|
|
290
|
+
handler = handlers.get(method)
|
|
291
|
+
if handler:
|
|
292
|
+
try:
|
|
293
|
+
result = await handler(params)
|
|
294
|
+
await ws.send(json.dumps({"jsonrpc": "2.0", "id": rpc_id, "result": result}))
|
|
295
|
+
except Exception as e:
|
|
296
|
+
await ws.send(json.dumps({
|
|
297
|
+
"jsonrpc": "2.0", "id": rpc_id,
|
|
298
|
+
"error": {"code": -32603, "message": str(e)},
|
|
299
|
+
}))
|
|
300
|
+
else:
|
|
301
|
+
await ws.send(json.dumps({
|
|
302
|
+
"jsonrpc": "2.0", "id": rpc_id,
|
|
303
|
+
"error": {"code": -32601, "message": f"Method not found: {method}"},
|
|
304
|
+
}))
|
|
305
|
+
|
|
306
|
+
async def _rpc_health(self) -> dict:
|
|
307
|
+
"""RPC handler for web.health."""
|
|
308
|
+
return {
|
|
309
|
+
"status": "healthy",
|
|
310
|
+
"details": {
|
|
311
|
+
"uptime_seconds": round(time.time() - self._start_time),
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async def _rpc_status(self) -> dict:
|
|
316
|
+
"""RPC handler for web.status."""
|
|
317
|
+
return {
|
|
318
|
+
"module": "web",
|
|
319
|
+
"status": "running",
|
|
320
|
+
"uptime_seconds": round(time.time() - self._start_time),
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async def _handle_shutdown(self):
|
|
324
|
+
"""Handle module.shutdown: ack → cleanup → ready → exit."""
|
|
325
|
+
print("[web] Received module.shutdown")
|
|
326
|
+
self._shutting_down = True
|
|
327
|
+
|
|
328
|
+
# Step 1: Send ack
|
|
329
|
+
await self._publish_event({
|
|
330
|
+
"event": "module.shutdown.ack",
|
|
331
|
+
"data": {"module_id": "web", "estimated_cleanup": 2},
|
|
332
|
+
})
|
|
333
|
+
print("[web] shutdown ack sent")
|
|
334
|
+
|
|
335
|
+
# Step 2: Cleanup (cancel background tasks)
|
|
336
|
+
if self._test_task:
|
|
337
|
+
self._test_task.cancel()
|
|
338
|
+
if self.bt_manager:
|
|
339
|
+
await self.bt_manager.stop()
|
|
340
|
+
|
|
341
|
+
# Step 3: Send ready (before closing WS!)
|
|
342
|
+
await self._publish_event({
|
|
343
|
+
"event": "module.shutdown.ready",
|
|
344
|
+
"data": {"module_id": "web"},
|
|
345
|
+
})
|
|
346
|
+
print("[web] Shutdown complete")
|
|
347
|
+
|
|
348
|
+
# Step 4: Trigger uvicorn exit (WS will close when uvicorn shuts down)
|
|
349
|
+
if self._uvicorn_server:
|
|
350
|
+
self._uvicorn_server.should_exit = True
|
|
351
|
+
|
|
352
|
+
async def _publish_event(self, event: dict):
|
|
353
|
+
"""Publish an event via RPC event.publish."""
|
|
354
|
+
if not self._ws:
|
|
355
|
+
return
|
|
356
|
+
await self._rpc_call(self._ws, "event.publish", {
|
|
357
|
+
"event_id": str(uuid.uuid4()),
|
|
358
|
+
"event": event.get("event", ""),
|
|
359
|
+
"data": event.get("data", {}),
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
# ── Test event loop ──
|
|
363
|
+
|
|
364
|
+
async def _test_event_loop(self):
|
|
365
|
+
"""Publish a test event every 10 seconds."""
|
|
366
|
+
while True:
|
|
367
|
+
await asyncio.sleep(10)
|
|
368
|
+
await self._publish_event({
|
|
369
|
+
"event": "web.test",
|
|
370
|
+
"data": {
|
|
371
|
+
"message": "test event from web",
|
|
372
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
373
|
+
},
|
|
374
|
+
})
|
|
375
|
+
print("[web] test event published")
|