@agentunion/kite 1.3.2 → 1.4.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 +200 -0
- package/cli.js +76 -0
- package/extensions/agents/assistant/entry.py +111 -1
- package/extensions/agents/assistant/server.py +263 -215
- package/extensions/channels/acp_channel/entry.py +111 -1
- package/extensions/channels/acp_channel/module.md +23 -22
- package/extensions/channels/acp_channel/server.py +263 -215
- package/extensions/event_hub_bench/entry.py +107 -1
- package/extensions/services/backup/entry.py +299 -21
- package/extensions/services/backup/module.md +24 -22
- package/extensions/services/model_service/entry.py +145 -19
- package/extensions/services/model_service/module.md +21 -22
- package/extensions/services/watchdog/entry.py +188 -25
- package/extensions/services/watchdog/monitor.py +144 -34
- package/extensions/services/web/WEBSOCKET_STATUS.md +143 -0
- package/extensions/services/web/config_example.py +35 -0
- package/extensions/services/web/config_loader.py +110 -0
- package/extensions/services/web/entry.py +114 -26
- package/extensions/services/web/module.md +35 -24
- package/extensions/services/web/pairing.py +250 -0
- package/extensions/services/web/pairing_codes.jsonl +16 -0
- package/extensions/services/web/relay.py +643 -0
- package/extensions/services/web/relay_config.json5 +67 -0
- package/extensions/services/web/routes/routes_management_ws.py +127 -0
- package/extensions/services/web/routes/routes_rpc.py +89 -0
- package/extensions/services/web/routes/routes_test.py +61 -0
- package/extensions/services/web/routes/schemas.py +0 -22
- package/extensions/services/web/server.py +421 -98
- package/extensions/services/web/static/css/style.css +67 -28
- package/extensions/services/web/static/index.html +234 -44
- package/extensions/services/web/static/js/app.js +1335 -48
- package/extensions/services/web/static/js/kernel-client-example.js +161 -0
- package/extensions/services/web/static/js/kernel-client.js +383 -0
- package/extensions/services/web/static/js/registry-tests.js +558 -0
- package/extensions/services/web/static/js/token-manager.js +175 -0
- package/extensions/services/web/static/pairing.html +248 -0
- package/extensions/services/web/static/test_registry.html +262 -0
- package/extensions/services/web/web_config.json5 +29 -0
- package/kernel/entry.py +120 -32
- package/kernel/event_hub.py +141 -16
- package/kernel/module.md +36 -33
- package/kernel/registry_store.py +48 -15
- package/kernel/rpc_router.py +120 -53
- package/kernel/server.py +219 -12
- package/kite_cli/__init__.py +3 -0
- package/kite_cli/__main__.py +5 -0
- package/kite_cli/commands/__init__.py +1 -0
- package/kite_cli/commands/clean.py +101 -0
- package/kite_cli/commands/doctor.py +35 -0
- package/kite_cli/commands/history.py +111 -0
- package/kite_cli/commands/info.py +96 -0
- package/kite_cli/commands/install.py +313 -0
- package/kite_cli/commands/list.py +143 -0
- package/kite_cli/commands/log.py +81 -0
- package/kite_cli/commands/rollback.py +88 -0
- package/kite_cli/commands/search.py +73 -0
- package/kite_cli/commands/uninstall.py +85 -0
- package/kite_cli/commands/update.py +118 -0
- package/kite_cli/core/__init__.py +1 -0
- package/kite_cli/core/checker.py +142 -0
- package/kite_cli/core/dependency.py +229 -0
- package/kite_cli/core/downloader.py +209 -0
- package/kite_cli/core/install_info.py +40 -0
- package/kite_cli/core/tool_installer.py +397 -0
- package/kite_cli/core/validator.py +78 -0
- package/kite_cli/main.py +289 -0
- package/kite_cli/utils/__init__.py +1 -0
- package/kite_cli/utils/i18n.py +252 -0
- package/kite_cli/utils/interactive.py +63 -0
- package/kite_cli/utils/operation_log.py +77 -0
- package/kite_cli/utils/paths.py +34 -0
- package/kite_cli/utils/version.py +308 -0
- package/launcher/entry.py +819 -158
- package/launcher/logging_setup.py +104 -0
- package/launcher/module.md +37 -37
- package/package.json +2 -1
- package/scripts/plan_manager.py +315 -0
- package/extensions/services/web/routes/routes_modules.py +0 -249
|
@@ -13,6 +13,14 @@ import time
|
|
|
13
13
|
from datetime import datetime, timezone
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
# System broadcast events (received by all modules, may not need handling)
|
|
17
|
+
SYSTEM_BROADCAST_EVENTS = {
|
|
18
|
+
"module.ready", "module.registered", "module.started", "module.stopped",
|
|
19
|
+
"module.crashed", "module.exiting", "module.offline",
|
|
20
|
+
"module.shutdown.ack", "module.shutdown.ready",
|
|
21
|
+
"system.ready", "registry.updated",
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
# Module health states
|
|
17
25
|
HEALTHY = "healthy"
|
|
18
26
|
UNHEALTHY = "unhealthy"
|
|
@@ -91,6 +99,8 @@ class HealthMonitor:
|
|
|
91
99
|
# Launcher loss tracking
|
|
92
100
|
self._launcher_offline = False
|
|
93
101
|
self._launcher_had_exiting = False # True if module.exiting was received for launcher
|
|
102
|
+
self._launcher_restart_requested = False # True if launcher requested restart
|
|
103
|
+
self._launcher_startup_info = None # Startup info from launcher (python, argv, cwd, env)
|
|
94
104
|
|
|
95
105
|
# ── Module discovery ──
|
|
96
106
|
|
|
@@ -154,23 +164,28 @@ class HealthMonitor:
|
|
|
154
164
|
# ── Health check ──
|
|
155
165
|
|
|
156
166
|
async def _check_one(self, status: ModuleStatus):
|
|
157
|
-
"""Check a single module's
|
|
167
|
+
"""Check a single module's health via RPC."""
|
|
158
168
|
if not status.api_endpoint:
|
|
159
169
|
return # Not yet registered in Registry, will be picked up on next discover
|
|
160
|
-
|
|
170
|
+
|
|
161
171
|
status.last_check = time.time()
|
|
162
172
|
|
|
163
173
|
try:
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
+
# Call module.health via RPC
|
|
175
|
+
resp = await self.rpc_call(f"{status.module_id}.health", {})
|
|
176
|
+
|
|
177
|
+
# Check if RPC call succeeded
|
|
178
|
+
if "error" in resp:
|
|
179
|
+
error = resp["error"]
|
|
180
|
+
status.last_error = f"RPC error: {error.get('message', 'unknown')}"
|
|
181
|
+
elif "result" in resp:
|
|
182
|
+
result = resp["result"]
|
|
183
|
+
if result.get("status") == "healthy":
|
|
184
|
+
await self._mark_healthy(status)
|
|
185
|
+
return
|
|
186
|
+
status.last_error = f"unhealthy response: {result.get('status')}"
|
|
187
|
+
else:
|
|
188
|
+
status.last_error = "Invalid RPC response"
|
|
174
189
|
except Exception as e:
|
|
175
190
|
status.last_error = str(e)
|
|
176
191
|
|
|
@@ -399,7 +414,9 @@ class HealthMonitor:
|
|
|
399
414
|
if event_type == "module.started":
|
|
400
415
|
print(f"[watchdog] Received module.started: {module_id}")
|
|
401
416
|
self._crash_counts.pop(module_id, None)
|
|
402
|
-
await
|
|
417
|
+
# Don't await discover_modules() here - it blocks event processing!
|
|
418
|
+
# Schedule it as a background task instead
|
|
419
|
+
asyncio.create_task(self.discover_modules())
|
|
403
420
|
|
|
404
421
|
elif event_type == "module.stopped":
|
|
405
422
|
print(f"[watchdog] Received module.stopped: {module_id}")
|
|
@@ -408,11 +425,19 @@ class HealthMonitor:
|
|
|
408
425
|
|
|
409
426
|
elif event_type == "module.exiting":
|
|
410
427
|
action = data.get("action", "none")
|
|
411
|
-
|
|
428
|
+
reason = data.get("reason", "")
|
|
429
|
+
print(f"[watchdog] Received module.exiting: {module_id}, action={action}, reason={reason}")
|
|
412
430
|
self._exit_intents[module_id] = action
|
|
413
431
|
# Track launcher exiting intent
|
|
414
432
|
if module_id == "launcher":
|
|
415
433
|
self._launcher_had_exiting = True
|
|
434
|
+
if action == "restart_launcher":
|
|
435
|
+
print(f"[watchdog] Launcher 请求计划内重启 (reason={reason})")
|
|
436
|
+
self._launcher_restart_requested = True
|
|
437
|
+
# Save startup info for restart
|
|
438
|
+
self._launcher_startup_info = data.get("startup_info")
|
|
439
|
+
# 启动快速检测任务
|
|
440
|
+
asyncio.create_task(self._quick_check_launcher_exit())
|
|
416
441
|
|
|
417
442
|
elif event_type == "module.ready":
|
|
418
443
|
graceful = bool(data.get("graceful_shutdown"))
|
|
@@ -423,13 +448,22 @@ class HealthMonitor:
|
|
|
423
448
|
self._launcher_offline = False
|
|
424
449
|
self._launcher_had_exiting = False
|
|
425
450
|
|
|
451
|
+
# Layer 2: 忽略系统广播事件
|
|
452
|
+
elif event_type in SYSTEM_BROADCAST_EVENTS:
|
|
453
|
+
pass
|
|
454
|
+
|
|
455
|
+
# Layer 3: 警告未知事件
|
|
456
|
+
else:
|
|
457
|
+
print(f"[watchdog] Warning: Received unhandled event: {event_type}")
|
|
458
|
+
|
|
426
459
|
async def _handle_module_stopped(self, module_id: str, data: dict):
|
|
427
460
|
"""Restart decision engine — called when module.stopped is received.
|
|
428
461
|
|
|
429
462
|
Priority:
|
|
430
463
|
1. System shutting down → no restart
|
|
431
|
-
2.
|
|
432
|
-
3.
|
|
464
|
+
2. stop_type == "graceful_stop" → Launcher主动停止,不重启
|
|
465
|
+
3. stop_type == "process_exit" + has exit_intent → 按 intent 处理
|
|
466
|
+
4. stop_type == "process_exit" + no intent → 崩溃,重启
|
|
433
467
|
"""
|
|
434
468
|
# Sync graceful_shutdown from Launcher (covers missed module.ready)
|
|
435
469
|
if "graceful_shutdown" in data:
|
|
@@ -439,6 +473,18 @@ class HealthMonitor:
|
|
|
439
473
|
print(f"[watchdog] {module_id} stopped during shutdown, skipping restart")
|
|
440
474
|
return
|
|
441
475
|
|
|
476
|
+
# Check stop_type first (most reliable indicator)
|
|
477
|
+
stop_type = data.get("stop_type", "unknown")
|
|
478
|
+
|
|
479
|
+
if stop_type == "graceful_stop":
|
|
480
|
+
# Launcher主动停止(通过RPC或shutdown流程)
|
|
481
|
+
reason = data.get("reason", "unknown")
|
|
482
|
+
print(f"[watchdog] {module_id} stopped by Launcher (stop_type=graceful_stop, reason={reason}), no restart")
|
|
483
|
+
self._crash_counts.pop(module_id, None)
|
|
484
|
+
self._exit_intents.pop(module_id, None) # 清理可能残留的intent
|
|
485
|
+
return
|
|
486
|
+
|
|
487
|
+
# stop_type == "process_exit": 进程自行退出,需要判断是否崩溃
|
|
442
488
|
intent = self._exit_intents.pop(module_id, None)
|
|
443
489
|
if intent is not None:
|
|
444
490
|
if intent == "none":
|
|
@@ -473,6 +519,27 @@ class HealthMonitor:
|
|
|
473
519
|
"message": f"{module_id} exceeded {self.MAX_RESTARTS} crash restarts",
|
|
474
520
|
})
|
|
475
521
|
|
|
522
|
+
|
|
523
|
+
async def _quick_check_launcher_exit(self):
|
|
524
|
+
"""快速检测 Launcher 退出(0.2s 间隔,最多 5s,5s 后强制重启)"""
|
|
525
|
+
print("[watchdog] 开始快速检测 Launcher 退出(0.2s 间隔,最多 5s)")
|
|
526
|
+
for i in range(25): # 25 * 0.2s = 5s
|
|
527
|
+
try:
|
|
528
|
+
await self.rpc_call("launcher.list_modules", {})
|
|
529
|
+
await asyncio.sleep(0.2)
|
|
530
|
+
except Exception:
|
|
531
|
+
# Launcher 已退出
|
|
532
|
+
print(f"[watchdog] Launcher 已退出(检测 {(i+1)*0.2:.1f}s),启动新实例")
|
|
533
|
+
self._start_new_instance()
|
|
534
|
+
print("[watchdog] 新实例已启动,watchdog 退出")
|
|
535
|
+
sys.exit(0)
|
|
536
|
+
|
|
537
|
+
# 5s 后仍未退出,强制重启
|
|
538
|
+
print("[watchdog] Launcher 5s 内未退出,强制重启")
|
|
539
|
+
self._start_new_instance()
|
|
540
|
+
print("[watchdog] 新实例已启动,watchdog 退出")
|
|
541
|
+
sys.exit(0)
|
|
542
|
+
|
|
476
543
|
async def _handle_launcher_lost(self):
|
|
477
544
|
"""Handle launcher_lost: decide whether to start a new Kite instance.
|
|
478
545
|
|
|
@@ -489,32 +556,73 @@ class HealthMonitor:
|
|
|
489
556
|
sys.exit(0)
|
|
490
557
|
|
|
491
558
|
def _start_new_instance(self):
|
|
492
|
-
"""Start a new Kite instance
|
|
493
|
-
|
|
494
|
-
if
|
|
495
|
-
|
|
496
|
-
|
|
559
|
+
"""Start a new Kite instance using saved startup info from launcher."""
|
|
560
|
+
# Use startup info if available (from module.exiting event)
|
|
561
|
+
if self._launcher_startup_info:
|
|
562
|
+
python_exe = self._launcher_startup_info.get("python", sys.executable)
|
|
563
|
+
argv = self._launcher_startup_info.get("argv", [])
|
|
564
|
+
cwd = self._launcher_startup_info.get("cwd", os.environ.get("KITE_PROJECT", ""))
|
|
565
|
+
env = self._launcher_startup_info.get("env", {})
|
|
566
|
+
|
|
567
|
+
# Build command based on argv[0]
|
|
568
|
+
if argv:
|
|
569
|
+
# Check if argv[0] is executable (e.g., kite.exe or python script)
|
|
570
|
+
argv0 = argv[0]
|
|
571
|
+
if os.path.isabs(argv0) and os.path.exists(argv0):
|
|
572
|
+
# argv[0] is absolute path to executable/script, use it directly
|
|
573
|
+
cmd = argv
|
|
574
|
+
elif argv0.endswith(('.py', '.pyw')):
|
|
575
|
+
# argv[0] is a Python script, prepend Python interpreter
|
|
576
|
+
cmd = [python_exe] + argv
|
|
577
|
+
else:
|
|
578
|
+
# argv[0] might be a command in PATH (e.g., 'kite'), use it directly
|
|
579
|
+
cmd = argv
|
|
580
|
+
else:
|
|
581
|
+
# Fallback to main.py
|
|
582
|
+
main_py = os.path.join(cwd, "main.py")
|
|
583
|
+
cmd = [python_exe, main_py]
|
|
497
584
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
print(f"[watchdog]
|
|
501
|
-
|
|
585
|
+
print(f"[watchdog] Starting new instance with saved startup info:")
|
|
586
|
+
print(f"[watchdog] Command: {' '.join(cmd)}")
|
|
587
|
+
print(f"[watchdog] CWD: {cwd}")
|
|
588
|
+
|
|
589
|
+
# Use saved environment variables
|
|
590
|
+
new_env = env
|
|
591
|
+
|
|
592
|
+
else:
|
|
593
|
+
# Fallback: use current environment (old behavior)
|
|
594
|
+
project_dir = os.environ.get("KITE_PROJECT", "")
|
|
595
|
+
if not project_dir:
|
|
596
|
+
print("[watchdog] ERROR: KITE_PROJECT not set, cannot start new instance")
|
|
597
|
+
return
|
|
598
|
+
|
|
599
|
+
main_py = os.path.join(project_dir, "main.py")
|
|
600
|
+
if not os.path.exists(main_py):
|
|
601
|
+
print(f"[watchdog] ERROR: {main_py} not found, cannot start new instance")
|
|
602
|
+
return
|
|
603
|
+
|
|
604
|
+
python_exe = sys.executable
|
|
605
|
+
cmd = [python_exe, main_py]
|
|
606
|
+
cwd = project_dir
|
|
607
|
+
new_env = os.environ.copy()
|
|
608
|
+
print(f"[watchdog] Starting new instance (fallback mode): python {main_py}")
|
|
502
609
|
|
|
503
|
-
print(f"[watchdog] Starting new Kite instance: python {main_py}")
|
|
504
610
|
try:
|
|
505
611
|
# Start detached process with new console window
|
|
506
612
|
if sys.platform == "win32":
|
|
507
613
|
# Use 'start' command to force visible console window
|
|
508
614
|
subprocess.Popen(
|
|
509
|
-
["cmd", "/c", "start", "Kite"
|
|
510
|
-
cwd=
|
|
615
|
+
["cmd", "/c", "start", "Kite"] + cmd,
|
|
616
|
+
cwd=cwd,
|
|
617
|
+
env=new_env,
|
|
511
618
|
shell=False,
|
|
512
619
|
)
|
|
513
620
|
elif sys.platform == "darwin":
|
|
514
621
|
# macOS: use 'open -a Terminal' to launch in new Terminal window
|
|
515
622
|
subprocess.Popen(
|
|
516
|
-
["open", "-a", "Terminal"
|
|
517
|
-
cwd=
|
|
623
|
+
["open", "-a", "Terminal"] + cmd,
|
|
624
|
+
cwd=cwd,
|
|
625
|
+
env=new_env,
|
|
518
626
|
)
|
|
519
627
|
else:
|
|
520
628
|
# Linux: try common terminal emulators
|
|
@@ -528,8 +636,9 @@ class HealthMonitor:
|
|
|
528
636
|
for term_cmd in terminals:
|
|
529
637
|
try:
|
|
530
638
|
subprocess.Popen(
|
|
531
|
-
term_cmd +
|
|
532
|
-
cwd=
|
|
639
|
+
term_cmd + cmd,
|
|
640
|
+
cwd=cwd,
|
|
641
|
+
env=new_env,
|
|
533
642
|
start_new_session=True,
|
|
534
643
|
)
|
|
535
644
|
launched = True
|
|
@@ -540,8 +649,9 @@ class HealthMonitor:
|
|
|
540
649
|
# Fallback: headless start
|
|
541
650
|
print("[watchdog] WARNING: No terminal emulator found, starting headless")
|
|
542
651
|
subprocess.Popen(
|
|
543
|
-
|
|
544
|
-
cwd=
|
|
652
|
+
cmd,
|
|
653
|
+
cwd=cwd,
|
|
654
|
+
env=new_env,
|
|
545
655
|
start_new_session=True,
|
|
546
656
|
stdin=subprocess.DEVNULL,
|
|
547
657
|
)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# Web 模块实时状态推送 — WebSocket 实现
|
|
2
|
+
|
|
3
|
+
## 概述
|
|
4
|
+
|
|
5
|
+
为 Web 管理后台新增 `/ws/management` WebSocket 端点,实时推送模块状态变更到前端,实现无需刷新的实时状态监控。
|
|
6
|
+
|
|
7
|
+
## 架构
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
Kernel (事件源)
|
|
11
|
+
↓ WebSocket JSON-RPC 2.0
|
|
12
|
+
Web 模块 (server.py)
|
|
13
|
+
↓ 订阅事件 → 转发
|
|
14
|
+
routes_management_ws.py (广播)
|
|
15
|
+
↓ WebSocket
|
|
16
|
+
前端 (app.js)
|
|
17
|
+
↓ 更新 UI
|
|
18
|
+
模块列表页面
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## 模块状态体系
|
|
22
|
+
|
|
23
|
+
### 1. 静态状态(module.md 配置)
|
|
24
|
+
- `enabled` — 自动启动
|
|
25
|
+
- `manual` — 手动启动
|
|
26
|
+
- `disabled` — 禁用
|
|
27
|
+
|
|
28
|
+
### 2. 运行时状态(Launcher 管理)
|
|
29
|
+
- `stopped` — 未运行
|
|
30
|
+
- `starting` — 启动中
|
|
31
|
+
- `running` — 运行中
|
|
32
|
+
- `stopping` — 停止中
|
|
33
|
+
- `crashed` — 崩溃
|
|
34
|
+
- `restarting` — 重启中
|
|
35
|
+
|
|
36
|
+
### 3. 连接状态(Kernel 连接)
|
|
37
|
+
- `disconnected` — 未连接到 Kernel
|
|
38
|
+
- `connected` — 已连接到 Kernel
|
|
39
|
+
- `registered` — 已注册到 Kernel Registry
|
|
40
|
+
|
|
41
|
+
### 4. 前端综合显示
|
|
42
|
+
|
|
43
|
+
| 状态 | 颜色 | 说明 |
|
|
44
|
+
|------|------|------|
|
|
45
|
+
| 离线 | 灰色 | stopped |
|
|
46
|
+
| 启动中 | 黄色 | starting |
|
|
47
|
+
| 在线 | 绿色 | running + registered |
|
|
48
|
+
| 停止中 | 黄色 | stopping |
|
|
49
|
+
| 崩溃 | 红色 | crashed |
|
|
50
|
+
| 未连接 | 橙色 | running 但未连接 Kernel |
|
|
51
|
+
|
|
52
|
+
## 实现细节
|
|
53
|
+
|
|
54
|
+
### 后端
|
|
55
|
+
|
|
56
|
+
#### 1. `routes/routes_management_ws.py`(新增)
|
|
57
|
+
|
|
58
|
+
- **端点**: `/ws/management`
|
|
59
|
+
- **功能**:
|
|
60
|
+
- 接受前端 WebSocket 连接
|
|
61
|
+
- 维护全局连接池 `_management_clients`
|
|
62
|
+
- 提供 `broadcast_event()` 函数广播事件到所有客户端
|
|
63
|
+
- 心跳机制(30 秒)
|
|
64
|
+
- **消息格式**:
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"type": "module.started",
|
|
68
|
+
"data": { "module_id": "watchdog" },
|
|
69
|
+
"timestamp": "2026-03-05T16:00:00Z"
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
#### 2. `server.py`(修改)
|
|
74
|
+
|
|
75
|
+
- **导入**: 添加 `from routes.routes_management_ws import router as management_ws_router, broadcast_event`
|
|
76
|
+
- **挂载路由**: `app.include_router(management_ws_router)`
|
|
77
|
+
- **事件订阅**: 扩展订阅列表,包含:
|
|
78
|
+
- `module.started`
|
|
79
|
+
- `module.stopped`
|
|
80
|
+
- `module.crashed`
|
|
81
|
+
- `module.ready`
|
|
82
|
+
- `module.exiting`
|
|
83
|
+
- `module.shutdown`
|
|
84
|
+
- `module.shutdown.ack`
|
|
85
|
+
- `module.shutdown.ready`
|
|
86
|
+
- **事件转发**: 在 `_handle_event_notification()` 中调用 `broadcast_event()` 转发模块状态事件
|
|
87
|
+
|
|
88
|
+
### 前端
|
|
89
|
+
|
|
90
|
+
#### 1. `static/js/app.js`(修改)
|
|
91
|
+
|
|
92
|
+
**新增全局变量**:
|
|
93
|
+
```javascript
|
|
94
|
+
let _managementWs = null;
|
|
95
|
+
let _managementWsConnected = false;
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
**新增函数**:
|
|
99
|
+
- `connectManagementWebSocket()` — 连接 WebSocket,自动重连(3 秒)
|
|
100
|
+
- `_handleManagementEvent(msg)` — 处理收到的事件
|
|
101
|
+
- `_onModuleStatusChange(moduleName, status)` — 更新模块状态缓存和 UI
|
|
102
|
+
- `_updateWsIndicator()` — 更新连线状态指示器
|
|
103
|
+
|
|
104
|
+
**事件处理逻辑**:
|
|
105
|
+
- `module.started` / `module.ready` → 标记为 running,清除 pending,刷新 UI
|
|
106
|
+
- `module.stopped` / `module.crashed` → 标记为 stopped,清除 pending,刷新 UI
|
|
107
|
+
- `module.exiting` / `module.shutdown.ack` → 标记为 stopping(保持 pending)
|
|
108
|
+
|
|
109
|
+
**初始化**:
|
|
110
|
+
在 `DOMContentLoaded` 中调用 `connectManagementWebSocket()`
|
|
111
|
+
|
|
112
|
+
#### 2. `static/index.html`(修改)
|
|
113
|
+
|
|
114
|
+
在模块页面标题栏添加连线状态指示器:
|
|
115
|
+
```html
|
|
116
|
+
<span id="ws-indicator" style="font-size:13px;color:var(--gray-400);">○ 未连线</span>
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- 已连线: `● 已连线` (绿色)
|
|
120
|
+
- 未连线: `○ 未连线` (灰色)
|
|
121
|
+
|
|
122
|
+
## 使用效果
|
|
123
|
+
|
|
124
|
+
1. **实时状态更新** — 模块启动/停止/崩溃时,前端立即更新,无需手动刷新
|
|
125
|
+
2. **连线状态可见** — 页面顶部显示 WebSocket 连接状态
|
|
126
|
+
3. **自动重连** — 连接断开后 3 秒自动重连
|
|
127
|
+
4. **心跳保活** — 每 30 秒发送心跳,保持连接活跃
|
|
128
|
+
|
|
129
|
+
## 端口复用
|
|
130
|
+
|
|
131
|
+
HTTP 和 WebSocket 共用同一个端口(默认 18766),通过 FastAPI 统一管理:
|
|
132
|
+
- HTTP: `http://localhost:18766/api/*`
|
|
133
|
+
- WebSocket: `ws://localhost:18766/ws/management`
|
|
134
|
+
|
|
135
|
+
## 扩展性
|
|
136
|
+
|
|
137
|
+
未来可以通过 `broadcast_event()` 推送更多实时事件:
|
|
138
|
+
- 通话状态变更
|
|
139
|
+
- 蓝牙连接状态
|
|
140
|
+
- 配置更新通知
|
|
141
|
+
- 系统告警
|
|
142
|
+
|
|
143
|
+
只需在 `server.py` 的事件处理中添加对应的 `broadcast_event()` 调用即可。
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
配置加载使用示例
|
|
3
|
+
|
|
4
|
+
演示如何在 web 模块中使用 config_loader 加载业务配置。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from config_loader import load_business_configs, get_business_config
|
|
9
|
+
|
|
10
|
+
# 获取模块目录
|
|
11
|
+
module_dir = os.path.dirname(os.path.abspath(__file__))
|
|
12
|
+
|
|
13
|
+
# 方式 1:加载所有业务配置
|
|
14
|
+
all_configs = load_business_configs(module_dir)
|
|
15
|
+
|
|
16
|
+
# 访问 web_server 配置
|
|
17
|
+
if 'web_server' in all_configs:
|
|
18
|
+
web_config = all_configs['web_server']['config']
|
|
19
|
+
server_host = web_config['server']['host']
|
|
20
|
+
server_port = web_config['server']['port']
|
|
21
|
+
print(f"Web server: {server_host}:{server_port}")
|
|
22
|
+
|
|
23
|
+
# 访问 relay_service 配置
|
|
24
|
+
if 'relay_service' in all_configs:
|
|
25
|
+
relay_config = all_configs['relay_service']['config']
|
|
26
|
+
base_module_id = relay_config['relay']['base_module_id']
|
|
27
|
+
reconnect_timeout = relay_config['relay']['reconnect_timeout']
|
|
28
|
+
print(f"Relay: base_module_id={base_module_id}, timeout={reconnect_timeout}s")
|
|
29
|
+
|
|
30
|
+
# 方式 2:只加载特定业务配置
|
|
31
|
+
relay_business = get_business_config(module_dir, 'relay_service')
|
|
32
|
+
if relay_business:
|
|
33
|
+
relay_config = relay_business['config']
|
|
34
|
+
permissions = relay_config['permissions']
|
|
35
|
+
print(f"Relay permissions: {list(permissions.keys())}")
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""
|
|
2
|
+
配置加载工具
|
|
3
|
+
|
|
4
|
+
用于加载模块的业务配置。支持从 module.md 读取业务配置块,
|
|
5
|
+
并动态加载对应的 JSON5 配置文件。
|
|
6
|
+
|
|
7
|
+
零共享代码依赖 - 此文件可以独立拷贝到其他模块使用。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import json5
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_module_metadata(module_dir: str) -> dict:
|
|
17
|
+
"""
|
|
18
|
+
读取 module.md 的 YAML frontmatter。
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
module_dir: 模块目录路径
|
|
22
|
+
|
|
23
|
+
Returns:
|
|
24
|
+
模块元数据字典
|
|
25
|
+
"""
|
|
26
|
+
md_path = os.path.join(module_dir, "module.md")
|
|
27
|
+
|
|
28
|
+
if not os.path.exists(md_path):
|
|
29
|
+
return {}
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
with open(md_path, "r", encoding="utf-8") as f:
|
|
33
|
+
text = f.read()
|
|
34
|
+
|
|
35
|
+
# 提取 YAML frontmatter (--- ... ---)
|
|
36
|
+
m = re.match(r'^---\s*\n(.*?)\n---', text, re.DOTALL)
|
|
37
|
+
if m:
|
|
38
|
+
return yaml.safe_load(m.group(1)) or {}
|
|
39
|
+
except Exception as e:
|
|
40
|
+
print(f"[config_loader] Error loading module.md: {e}")
|
|
41
|
+
|
|
42
|
+
return {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_business_configs(module_dir: str) -> dict:
|
|
46
|
+
"""
|
|
47
|
+
加载所有业务配置。
|
|
48
|
+
|
|
49
|
+
从 module.md 的 businesses 块读取业务列表,
|
|
50
|
+
然后加载每个业务的配置文件(JSON5 格式)。
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
module_dir: 模块目录路径
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
业务配置字典,格式:
|
|
57
|
+
{
|
|
58
|
+
"business_name": {
|
|
59
|
+
"metadata": {
|
|
60
|
+
"name": "business_name",
|
|
61
|
+
"type": "business_type",
|
|
62
|
+
"description": "...",
|
|
63
|
+
"config_file": "config.json5"
|
|
64
|
+
},
|
|
65
|
+
"config": { ... } # JSON5 配置内容
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
"""
|
|
69
|
+
metadata = load_module_metadata(module_dir)
|
|
70
|
+
businesses = metadata.get('businesses', [])
|
|
71
|
+
|
|
72
|
+
configs = {}
|
|
73
|
+
for business in businesses:
|
|
74
|
+
name = business.get('name')
|
|
75
|
+
config_file = business.get('config_file')
|
|
76
|
+
|
|
77
|
+
if not name or not config_file:
|
|
78
|
+
print(f"[config_loader] Warning: Invalid business entry: {business}")
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
config_path = os.path.join(module_dir, config_file)
|
|
82
|
+
|
|
83
|
+
if os.path.exists(config_path):
|
|
84
|
+
try:
|
|
85
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
86
|
+
configs[name] = {
|
|
87
|
+
'metadata': business,
|
|
88
|
+
'config': json5.load(f)
|
|
89
|
+
}
|
|
90
|
+
except Exception as e:
|
|
91
|
+
print(f"[config_loader] Error loading {config_file}: {e}")
|
|
92
|
+
else:
|
|
93
|
+
print(f"[config_loader] Warning: Config file not found: {config_file}")
|
|
94
|
+
|
|
95
|
+
return configs
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_business_config(module_dir: str, business_name: str) -> dict | None:
|
|
99
|
+
"""
|
|
100
|
+
获取指定业务的配置。
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
module_dir: 模块目录路径
|
|
104
|
+
business_name: 业务名称
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
业务配置字典,如果不存在则返回 None
|
|
108
|
+
"""
|
|
109
|
+
configs = load_business_configs(module_dir)
|
|
110
|
+
return configs.get(business_name)
|