@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.
Files changed (78) hide show
  1. package/CHANGELOG.md +200 -0
  2. package/cli.js +76 -0
  3. package/extensions/agents/assistant/entry.py +111 -1
  4. package/extensions/agents/assistant/server.py +263 -215
  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 -215
  8. package/extensions/event_hub_bench/entry.py +107 -1
  9. package/extensions/services/backup/entry.py +299 -21
  10. package/extensions/services/backup/module.md +24 -22
  11. package/extensions/services/model_service/entry.py +145 -19
  12. package/extensions/services/model_service/module.md +21 -22
  13. package/extensions/services/watchdog/entry.py +188 -25
  14. package/extensions/services/watchdog/monitor.py +144 -34
  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/routes/schemas.py +0 -22
  28. package/extensions/services/web/server.py +421 -98
  29. package/extensions/services/web/static/css/style.css +67 -28
  30. package/extensions/services/web/static/index.html +234 -44
  31. package/extensions/services/web/static/js/app.js +1335 -48
  32. package/extensions/services/web/static/js/kernel-client-example.js +161 -0
  33. package/extensions/services/web/static/js/kernel-client.js +383 -0
  34. package/extensions/services/web/static/js/registry-tests.js +558 -0
  35. package/extensions/services/web/static/js/token-manager.js +175 -0
  36. package/extensions/services/web/static/pairing.html +248 -0
  37. package/extensions/services/web/static/test_registry.html +262 -0
  38. package/extensions/services/web/web_config.json5 +29 -0
  39. package/kernel/entry.py +120 -32
  40. package/kernel/event_hub.py +141 -16
  41. package/kernel/module.md +36 -33
  42. package/kernel/registry_store.py +48 -15
  43. package/kernel/rpc_router.py +120 -53
  44. package/kernel/server.py +219 -12
  45. package/kite_cli/__init__.py +3 -0
  46. package/kite_cli/__main__.py +5 -0
  47. package/kite_cli/commands/__init__.py +1 -0
  48. package/kite_cli/commands/clean.py +101 -0
  49. package/kite_cli/commands/doctor.py +35 -0
  50. package/kite_cli/commands/history.py +111 -0
  51. package/kite_cli/commands/info.py +96 -0
  52. package/kite_cli/commands/install.py +313 -0
  53. package/kite_cli/commands/list.py +143 -0
  54. package/kite_cli/commands/log.py +81 -0
  55. package/kite_cli/commands/rollback.py +88 -0
  56. package/kite_cli/commands/search.py +73 -0
  57. package/kite_cli/commands/uninstall.py +85 -0
  58. package/kite_cli/commands/update.py +118 -0
  59. package/kite_cli/core/__init__.py +1 -0
  60. package/kite_cli/core/checker.py +142 -0
  61. package/kite_cli/core/dependency.py +229 -0
  62. package/kite_cli/core/downloader.py +209 -0
  63. package/kite_cli/core/install_info.py +40 -0
  64. package/kite_cli/core/tool_installer.py +397 -0
  65. package/kite_cli/core/validator.py +78 -0
  66. package/kite_cli/main.py +289 -0
  67. package/kite_cli/utils/__init__.py +1 -0
  68. package/kite_cli/utils/i18n.py +252 -0
  69. package/kite_cli/utils/interactive.py +63 -0
  70. package/kite_cli/utils/operation_log.py +77 -0
  71. package/kite_cli/utils/paths.py +34 -0
  72. package/kite_cli/utils/version.py +308 -0
  73. package/launcher/entry.py +819 -158
  74. package/launcher/logging_setup.py +104 -0
  75. package/launcher/module.md +37 -37
  76. package/package.json +2 -1
  77. package/scripts/plan_manager.py +315 -0
  78. 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 /health endpoint."""
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
- url = f"{status.api_endpoint}{status.health_endpoint}"
170
+
161
171
  status.last_check = time.time()
162
172
 
163
173
  try:
164
- async with httpx.AsyncClient() as client:
165
- resp = await client.get(url, timeout=self.HEALTH_TIMEOUT)
166
- if resp.status_code == 200:
167
- body = resp.json()
168
- if body.get("status") == "healthy":
169
- await self._mark_healthy(status)
170
- return
171
- status.last_error = f"unhealthy response: {body.get('status')}"
172
- else:
173
- 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"
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 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())
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
- 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}")
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. Has exit_intentfollow the declared action (none/restart/restart_delay)
432
- 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 → 崩溃,重启
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 by running python main.py in the project directory."""
493
- project_dir = os.environ.get("KITE_PROJECT", "")
494
- if not project_dir:
495
- print("[watchdog] ERROR: KITE_PROJECT not set, cannot start new instance")
496
- return
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
- main_py = os.path.join(project_dir, "main.py")
499
- if not os.path.exists(main_py):
500
- print(f"[watchdog] ERROR: {main_py} not found, cannot start new instance")
501
- return
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", sys.executable, main_py],
510
- cwd=project_dir,
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", main_py],
517
- cwd=project_dir,
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 + [sys.executable, main_py],
532
- cwd=project_dir,
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
- [sys.executable, main_py],
544
- cwd=project_dir,
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)