@agentunion/kite 1.3.1 → 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 +287 -1
- package/cli.js +76 -0
- package/extensions/agents/assistant/entry.py +111 -1
- package/extensions/agents/assistant/server.py +263 -197
- 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 -197
- package/extensions/event_hub_bench/entry.py +107 -1
- package/extensions/services/backup/entry.py +408 -72
- package/extensions/services/backup/module.md +24 -22
- package/extensions/services/model_service/entry.py +255 -71
- package/extensions/services/model_service/module.md +21 -22
- package/extensions/services/watchdog/entry.py +344 -90
- package/extensions/services/watchdog/monitor.py +237 -21
- 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/server.py +445 -99
- package/extensions/services/web/static/css/style.css +138 -2
- package/extensions/services/web/static/index.html +295 -2
- package/extensions/services/web/static/js/app.js +1579 -5
- 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 +159 -16
- package/kernel/module.md +36 -33
- package/kernel/registry_store.py +70 -20
- package/kernel/rpc_router.py +134 -57
- package/kernel/server.py +292 -15
- 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/count_lines.py +34 -0
- package/launcher/entry.py +905 -166
- package/launcher/logging_setup.py +104 -0
- package/launcher/module.md +37 -37
- package/launcher/process_manager.py +12 -1
- package/package.json +2 -1
- package/scripts/plan_manager.py +315 -0
|
@@ -6,10 +6,21 @@ Launcher handles process-level crashes; Watchdog handles app-level failures
|
|
|
6
6
|
|
|
7
7
|
import asyncio
|
|
8
8
|
import json
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
9
12
|
import time
|
|
10
13
|
from datetime import datetime, timezone
|
|
11
14
|
|
|
12
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
|
+
|
|
13
24
|
# Module health states
|
|
14
25
|
HEALTHY = "healthy"
|
|
15
26
|
UNHEALTHY = "unhealthy"
|
|
@@ -85,6 +96,12 @@ class HealthMonitor:
|
|
|
85
96
|
self._system_ready_event = asyncio.Event()
|
|
86
97
|
self._crash_counts: dict[str, int] = {} # module_id -> consecutive crash count
|
|
87
98
|
|
|
99
|
+
# Launcher loss tracking
|
|
100
|
+
self._launcher_offline = False
|
|
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)
|
|
104
|
+
|
|
88
105
|
# ── Module discovery ──
|
|
89
106
|
|
|
90
107
|
async def discover_modules(self):
|
|
@@ -147,23 +164,28 @@ class HealthMonitor:
|
|
|
147
164
|
# ── Health check ──
|
|
148
165
|
|
|
149
166
|
async def _check_one(self, status: ModuleStatus):
|
|
150
|
-
"""Check a single module's
|
|
167
|
+
"""Check a single module's health via RPC."""
|
|
151
168
|
if not status.api_endpoint:
|
|
152
169
|
return # Not yet registered in Registry, will be picked up on next discover
|
|
153
|
-
|
|
170
|
+
|
|
154
171
|
status.last_check = time.time()
|
|
155
172
|
|
|
156
173
|
try:
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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"
|
|
167
189
|
except Exception as e:
|
|
168
190
|
status.last_error = str(e)
|
|
169
191
|
|
|
@@ -364,13 +386,37 @@ class HealthMonitor:
|
|
|
364
386
|
self._system_ready_event.set()
|
|
365
387
|
return
|
|
366
388
|
|
|
389
|
+
# module.offline — track launcher state
|
|
390
|
+
if event_type == "module.offline":
|
|
391
|
+
if module_id == "launcher":
|
|
392
|
+
print("[watchdog] Received module.offline(launcher)")
|
|
393
|
+
self._launcher_offline = True
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
# module.shutdown with reason launcher_lost — decide whether to start new instance
|
|
397
|
+
if event_type == "module.shutdown":
|
|
398
|
+
reason = data.get("reason", "")
|
|
399
|
+
if reason == "launcher_lost":
|
|
400
|
+
print("[watchdog] Received module.shutdown(reason=launcher_lost)")
|
|
401
|
+
await self._handle_launcher_lost()
|
|
402
|
+
return
|
|
403
|
+
target = data.get("module_id", "")
|
|
404
|
+
if not target:
|
|
405
|
+
# Broadcast shutdown — check reason
|
|
406
|
+
if reason == "system_shutdown":
|
|
407
|
+
print(f"[watchdog] Received system_shutdown signal")
|
|
408
|
+
self._system_shutting_down = True
|
|
409
|
+
return
|
|
410
|
+
|
|
367
411
|
if not module_id or module_id == "watchdog":
|
|
368
412
|
return
|
|
369
413
|
|
|
370
414
|
if event_type == "module.started":
|
|
371
415
|
print(f"[watchdog] Received module.started: {module_id}")
|
|
372
416
|
self._crash_counts.pop(module_id, None)
|
|
373
|
-
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())
|
|
374
420
|
|
|
375
421
|
elif event_type == "module.stopped":
|
|
376
422
|
print(f"[watchdog] Received module.stopped: {module_id}")
|
|
@@ -379,27 +425,45 @@ class HealthMonitor:
|
|
|
379
425
|
|
|
380
426
|
elif event_type == "module.exiting":
|
|
381
427
|
action = data.get("action", "none")
|
|
382
|
-
|
|
428
|
+
reason = data.get("reason", "")
|
|
429
|
+
print(f"[watchdog] Received module.exiting: {module_id}, action={action}, reason={reason}")
|
|
383
430
|
self._exit_intents[module_id] = action
|
|
431
|
+
# Track launcher exiting intent
|
|
432
|
+
if module_id == "launcher":
|
|
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())
|
|
384
441
|
|
|
385
442
|
elif event_type == "module.ready":
|
|
386
443
|
graceful = bool(data.get("graceful_shutdown"))
|
|
387
444
|
print(f"[watchdog] Received module.ready: {module_id}, graceful_shutdown={graceful}")
|
|
388
445
|
self._graceful_modules[module_id] = graceful
|
|
446
|
+
# Reset launcher loss tracking when launcher reconnects
|
|
447
|
+
if module_id == "launcher":
|
|
448
|
+
self._launcher_offline = False
|
|
449
|
+
self._launcher_had_exiting = False
|
|
389
450
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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}")
|
|
395
458
|
|
|
396
459
|
async def _handle_module_stopped(self, module_id: str, data: dict):
|
|
397
460
|
"""Restart decision engine — called when module.stopped is received.
|
|
398
461
|
|
|
399
462
|
Priority:
|
|
400
463
|
1. System shutting down → no restart
|
|
401
|
-
2.
|
|
402
|
-
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 → 崩溃,重启
|
|
403
467
|
"""
|
|
404
468
|
# Sync graceful_shutdown from Launcher (covers missed module.ready)
|
|
405
469
|
if "graceful_shutdown" in data:
|
|
@@ -409,6 +473,18 @@ class HealthMonitor:
|
|
|
409
473
|
print(f"[watchdog] {module_id} stopped during shutdown, skipping restart")
|
|
410
474
|
return
|
|
411
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": 进程自行退出,需要判断是否崩溃
|
|
412
488
|
intent = self._exit_intents.pop(module_id, None)
|
|
413
489
|
if intent is not None:
|
|
414
490
|
if intent == "none":
|
|
@@ -443,6 +519,146 @@ class HealthMonitor:
|
|
|
443
519
|
"message": f"{module_id} exceeded {self.MAX_RESTARTS} crash restarts",
|
|
444
520
|
})
|
|
445
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
|
+
|
|
543
|
+
async def _handle_launcher_lost(self):
|
|
544
|
+
"""Handle launcher_lost: decide whether to start a new Kite instance.
|
|
545
|
+
|
|
546
|
+
- If launcher sent module.exiting before going offline → normal exit, follow suit
|
|
547
|
+
- If launcher did NOT send module.exiting → crash/unexpected, start new instance
|
|
548
|
+
"""
|
|
549
|
+
if self._launcher_had_exiting:
|
|
550
|
+
print("[watchdog] Launcher had sent module.exiting before loss → normal exit, following suit")
|
|
551
|
+
sys.exit(0)
|
|
552
|
+
else:
|
|
553
|
+
print("[watchdog] Launcher lost without module.exiting → crash detected, starting new instance")
|
|
554
|
+
self._start_new_instance()
|
|
555
|
+
print("[watchdog] New instance started, exiting")
|
|
556
|
+
sys.exit(0)
|
|
557
|
+
|
|
558
|
+
def _start_new_instance(self):
|
|
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]
|
|
584
|
+
|
|
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}")
|
|
609
|
+
|
|
610
|
+
try:
|
|
611
|
+
# Start detached process with new console window
|
|
612
|
+
if sys.platform == "win32":
|
|
613
|
+
# Use 'start' command to force visible console window
|
|
614
|
+
subprocess.Popen(
|
|
615
|
+
["cmd", "/c", "start", "Kite"] + cmd,
|
|
616
|
+
cwd=cwd,
|
|
617
|
+
env=new_env,
|
|
618
|
+
shell=False,
|
|
619
|
+
)
|
|
620
|
+
elif sys.platform == "darwin":
|
|
621
|
+
# macOS: use 'open -a Terminal' to launch in new Terminal window
|
|
622
|
+
subprocess.Popen(
|
|
623
|
+
["open", "-a", "Terminal"] + cmd,
|
|
624
|
+
cwd=cwd,
|
|
625
|
+
env=new_env,
|
|
626
|
+
)
|
|
627
|
+
else:
|
|
628
|
+
# Linux: try common terminal emulators
|
|
629
|
+
terminals = [
|
|
630
|
+
["x-terminal-emulator", "-e"], # Debian/Ubuntu default
|
|
631
|
+
["gnome-terminal", "--"], # GNOME
|
|
632
|
+
["konsole", "-e"], # KDE
|
|
633
|
+
["xterm", "-e"], # Fallback
|
|
634
|
+
]
|
|
635
|
+
launched = False
|
|
636
|
+
for term_cmd in terminals:
|
|
637
|
+
try:
|
|
638
|
+
subprocess.Popen(
|
|
639
|
+
term_cmd + cmd,
|
|
640
|
+
cwd=cwd,
|
|
641
|
+
env=new_env,
|
|
642
|
+
start_new_session=True,
|
|
643
|
+
)
|
|
644
|
+
launched = True
|
|
645
|
+
break
|
|
646
|
+
except FileNotFoundError:
|
|
647
|
+
continue
|
|
648
|
+
if not launched:
|
|
649
|
+
# Fallback: headless start
|
|
650
|
+
print("[watchdog] WARNING: No terminal emulator found, starting headless")
|
|
651
|
+
subprocess.Popen(
|
|
652
|
+
cmd,
|
|
653
|
+
cwd=cwd,
|
|
654
|
+
env=new_env,
|
|
655
|
+
start_new_session=True,
|
|
656
|
+
stdin=subprocess.DEVNULL,
|
|
657
|
+
)
|
|
658
|
+
print("[watchdog] New Kite instance started successfully")
|
|
659
|
+
except Exception as e:
|
|
660
|
+
print(f"[watchdog] Failed to start new instance: {e}")
|
|
661
|
+
|
|
446
662
|
async def _restart_module_by_id(self, module_id: str, reason: str = "restart"):
|
|
447
663
|
"""Restart a module via Launcher RPC by module_id."""
|
|
448
664
|
print(f"[watchdog] Requesting restart for {module_id} (reason={reason})")
|
|
@@ -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)
|