@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.
Files changed (78) hide show
  1. package/CHANGELOG.md +287 -1
  2. package/cli.js +76 -0
  3. package/extensions/agents/assistant/entry.py +111 -1
  4. package/extensions/agents/assistant/server.py +263 -197
  5. package/extensions/channels/acp_channel/entry.py +111 -1
  6. package/extensions/channels/acp_channel/module.md +23 -22
  7. package/extensions/channels/acp_channel/server.py +263 -197
  8. package/extensions/event_hub_bench/entry.py +107 -1
  9. package/extensions/services/backup/entry.py +408 -72
  10. package/extensions/services/backup/module.md +24 -22
  11. package/extensions/services/model_service/entry.py +255 -71
  12. package/extensions/services/model_service/module.md +21 -22
  13. package/extensions/services/watchdog/entry.py +344 -90
  14. package/extensions/services/watchdog/monitor.py +237 -21
  15. package/extensions/services/web/WEBSOCKET_STATUS.md +143 -0
  16. package/extensions/services/web/config_example.py +35 -0
  17. package/extensions/services/web/config_loader.py +110 -0
  18. package/extensions/services/web/entry.py +114 -26
  19. package/extensions/services/web/module.md +35 -24
  20. package/extensions/services/web/pairing.py +250 -0
  21. package/extensions/services/web/pairing_codes.jsonl +16 -0
  22. package/extensions/services/web/relay.py +643 -0
  23. package/extensions/services/web/relay_config.json5 +67 -0
  24. package/extensions/services/web/routes/routes_management_ws.py +127 -0
  25. package/extensions/services/web/routes/routes_rpc.py +89 -0
  26. package/extensions/services/web/routes/routes_test.py +61 -0
  27. package/extensions/services/web/server.py +445 -99
  28. package/extensions/services/web/static/css/style.css +138 -2
  29. package/extensions/services/web/static/index.html +295 -2
  30. package/extensions/services/web/static/js/app.js +1579 -5
  31. package/extensions/services/web/static/js/kernel-client-example.js +161 -0
  32. package/extensions/services/web/static/js/kernel-client.js +383 -0
  33. package/extensions/services/web/static/js/registry-tests.js +558 -0
  34. package/extensions/services/web/static/js/token-manager.js +175 -0
  35. package/extensions/services/web/static/pairing.html +248 -0
  36. package/extensions/services/web/static/test_registry.html +262 -0
  37. package/extensions/services/web/web_config.json5 +29 -0
  38. package/kernel/entry.py +120 -32
  39. package/kernel/event_hub.py +159 -16
  40. package/kernel/module.md +36 -33
  41. package/kernel/registry_store.py +70 -20
  42. package/kernel/rpc_router.py +134 -57
  43. package/kernel/server.py +292 -15
  44. package/kite_cli/__init__.py +3 -0
  45. package/kite_cli/__main__.py +5 -0
  46. package/kite_cli/commands/__init__.py +1 -0
  47. package/kite_cli/commands/clean.py +101 -0
  48. package/kite_cli/commands/doctor.py +35 -0
  49. package/kite_cli/commands/history.py +111 -0
  50. package/kite_cli/commands/info.py +96 -0
  51. package/kite_cli/commands/install.py +313 -0
  52. package/kite_cli/commands/list.py +143 -0
  53. package/kite_cli/commands/log.py +81 -0
  54. package/kite_cli/commands/rollback.py +88 -0
  55. package/kite_cli/commands/search.py +73 -0
  56. package/kite_cli/commands/uninstall.py +85 -0
  57. package/kite_cli/commands/update.py +118 -0
  58. package/kite_cli/core/__init__.py +1 -0
  59. package/kite_cli/core/checker.py +142 -0
  60. package/kite_cli/core/dependency.py +229 -0
  61. package/kite_cli/core/downloader.py +209 -0
  62. package/kite_cli/core/install_info.py +40 -0
  63. package/kite_cli/core/tool_installer.py +397 -0
  64. package/kite_cli/core/validator.py +78 -0
  65. package/kite_cli/main.py +289 -0
  66. package/kite_cli/utils/__init__.py +1 -0
  67. package/kite_cli/utils/i18n.py +252 -0
  68. package/kite_cli/utils/interactive.py +63 -0
  69. package/kite_cli/utils/operation_log.py +77 -0
  70. package/kite_cli/utils/paths.py +34 -0
  71. package/kite_cli/utils/version.py +308 -0
  72. package/launcher/count_lines.py +34 -0
  73. package/launcher/entry.py +905 -166
  74. package/launcher/logging_setup.py +104 -0
  75. package/launcher/module.md +37 -37
  76. package/launcher/process_manager.py +12 -1
  77. package/package.json +2 -1
  78. 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 /health endpoint."""
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
- url = f"{status.api_endpoint}{status.health_endpoint}"
170
+
154
171
  status.last_check = time.time()
155
172
 
156
173
  try:
157
- async with httpx.AsyncClient() as client:
158
- resp = await client.get(url, timeout=self.HEALTH_TIMEOUT)
159
- if resp.status_code == 200:
160
- body = resp.json()
161
- if body.get("status") == "healthy":
162
- await self._mark_healthy(status)
163
- return
164
- status.last_error = f"unhealthy response: {body.get('status')}"
165
- else:
166
- status.last_error = f"HTTP {resp.status_code}"
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 self.discover_modules()
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
- print(f"[watchdog] Received module.exiting: {module_id}, action={action}")
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
- elif event_type == "module.shutdown":
391
- reason = data.get("reason", "")
392
- if reason == "system_shutdown":
393
- print(f"[watchdog] Received system_shutdown signal")
394
- self._system_shutting_down = True
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. Has exit_intentfollow the declared action (none/restart/restart_delay)
402
- 3. No intent (crash) increment crash count, restart up to MAX_RESTARTS
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)