@agentunion/kite 1.0.6 → 1.2.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 (112) hide show
  1. package/cli.js +127 -25
  2. package/core/event_hub/entry.py +384 -61
  3. package/core/event_hub/hub.py +8 -0
  4. package/core/event_hub/module.md +0 -1
  5. package/core/event_hub/server.py +169 -38
  6. package/core/kite_log.py +241 -0
  7. package/core/launcher/entry.py +1306 -425
  8. package/core/launcher/module_scanner.py +10 -9
  9. package/core/launcher/process_manager.py +555 -121
  10. package/core/registry/entry.py +335 -30
  11. package/core/registry/server.py +339 -256
  12. package/core/registry/store.py +13 -2
  13. package/extensions/agents/__init__.py +1 -0
  14. package/extensions/agents/assistant/__init__.py +1 -0
  15. package/extensions/agents/assistant/entry.py +380 -0
  16. package/extensions/agents/assistant/module.md +22 -0
  17. package/extensions/agents/assistant/server.py +236 -0
  18. package/extensions/channels/__init__.py +1 -0
  19. package/extensions/channels/acp_channel/__init__.py +1 -0
  20. package/extensions/channels/acp_channel/entry.py +380 -0
  21. package/extensions/channels/acp_channel/module.md +22 -0
  22. package/extensions/channels/acp_channel/server.py +236 -0
  23. package/{core → extensions}/event_hub_bench/entry.py +664 -371
  24. package/{core → extensions}/event_hub_bench/module.md +4 -2
  25. package/extensions/services/backup/__init__.py +1 -0
  26. package/extensions/services/backup/entry.py +380 -0
  27. package/extensions/services/backup/module.md +22 -0
  28. package/extensions/services/backup/server.py +244 -0
  29. package/extensions/services/model_service/__init__.py +1 -0
  30. package/extensions/services/model_service/entry.py +380 -0
  31. package/extensions/services/model_service/module.md +22 -0
  32. package/extensions/services/model_service/server.py +236 -0
  33. package/extensions/services/watchdog/entry.py +460 -143
  34. package/extensions/services/watchdog/module.md +3 -0
  35. package/extensions/services/watchdog/monitor.py +128 -13
  36. package/extensions/services/watchdog/server.py +75 -13
  37. package/extensions/services/web/__init__.py +1 -0
  38. package/extensions/services/web/config.yaml +149 -0
  39. package/extensions/services/web/entry.py +487 -0
  40. package/extensions/services/web/module.md +24 -0
  41. package/extensions/services/web/routes/__init__.py +1 -0
  42. package/extensions/services/web/routes/routes_call.py +189 -0
  43. package/extensions/services/web/routes/routes_config.py +512 -0
  44. package/extensions/services/web/routes/routes_contacts.py +98 -0
  45. package/extensions/services/web/routes/routes_devlog.py +99 -0
  46. package/extensions/services/web/routes/routes_phone.py +81 -0
  47. package/extensions/services/web/routes/routes_sms.py +48 -0
  48. package/extensions/services/web/routes/routes_stats.py +17 -0
  49. package/extensions/services/web/routes/routes_voicechat.py +554 -0
  50. package/extensions/services/web/routes/schemas.py +216 -0
  51. package/extensions/services/web/server.py +332 -0
  52. package/extensions/services/web/static/css/style.css +1064 -0
  53. package/extensions/services/web/static/index.html +1445 -0
  54. package/extensions/services/web/static/js/app.js +4671 -0
  55. package/extensions/services/web/vendor/__init__.py +1 -0
  56. package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
  57. package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
  58. package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
  59. package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
  60. package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
  61. package/extensions/services/web/vendor/config.py +139 -0
  62. package/extensions/services/web/vendor/conversation/__init__.py +0 -0
  63. package/extensions/services/web/vendor/conversation/asr.py +936 -0
  64. package/extensions/services/web/vendor/conversation/engine.py +548 -0
  65. package/extensions/services/web/vendor/conversation/llm.py +534 -0
  66. package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
  67. package/extensions/services/web/vendor/conversation/tts.py +322 -0
  68. package/extensions/services/web/vendor/conversation/vad.py +138 -0
  69. package/extensions/services/web/vendor/storage/__init__.py +1 -0
  70. package/extensions/services/web/vendor/storage/identity.py +312 -0
  71. package/extensions/services/web/vendor/storage/store.py +507 -0
  72. package/extensions/services/web/vendor/task/__init__.py +0 -0
  73. package/extensions/services/web/vendor/task/manager.py +864 -0
  74. package/extensions/services/web/vendor/task/models.py +45 -0
  75. package/extensions/services/web/vendor/task/webhook.py +263 -0
  76. package/extensions/services/web/vendor/tools/__init__.py +0 -0
  77. package/extensions/services/web/vendor/tools/registry.py +321 -0
  78. package/main.py +344 -4
  79. package/package.json +11 -2
  80. package/core/__pycache__/__init__.cpython-313.pyc +0 -0
  81. package/core/__pycache__/data_dir.cpython-313.pyc +0 -0
  82. package/core/data_dir.py +0 -62
  83. package/core/event_hub/__pycache__/__init__.cpython-313.pyc +0 -0
  84. package/core/event_hub/__pycache__/bench.cpython-313.pyc +0 -0
  85. package/core/event_hub/__pycache__/bench_perf.cpython-313.pyc +0 -0
  86. package/core/event_hub/__pycache__/dedup.cpython-313.pyc +0 -0
  87. package/core/event_hub/__pycache__/entry.cpython-313.pyc +0 -0
  88. package/core/event_hub/__pycache__/hub.cpython-313.pyc +0 -0
  89. package/core/event_hub/__pycache__/router.cpython-313.pyc +0 -0
  90. package/core/event_hub/__pycache__/server.cpython-313.pyc +0 -0
  91. package/core/event_hub/bench_results/2026-02-28_13-26-48.json +0 -51
  92. package/core/event_hub/bench_results/2026-02-28_13-44-45.json +0 -51
  93. package/core/event_hub/bench_results/2026-02-28_13-45-39.json +0 -51
  94. package/core/launcher/__pycache__/__init__.cpython-313.pyc +0 -0
  95. package/core/launcher/__pycache__/entry.cpython-313.pyc +0 -0
  96. package/core/launcher/__pycache__/module_scanner.cpython-313.pyc +0 -0
  97. package/core/launcher/__pycache__/process_manager.cpython-313.pyc +0 -0
  98. package/core/launcher/data/log/lifecycle.jsonl +0 -1158
  99. package/core/launcher/data/token.txt +0 -1
  100. package/core/registry/__pycache__/__init__.cpython-313.pyc +0 -0
  101. package/core/registry/__pycache__/entry.cpython-313.pyc +0 -0
  102. package/core/registry/__pycache__/server.cpython-313.pyc +0 -0
  103. package/core/registry/__pycache__/store.cpython-313.pyc +0 -0
  104. package/core/registry/data/port.txt +0 -1
  105. package/core/registry/data/port_484.txt +0 -1
  106. package/extensions/__pycache__/__init__.cpython-313.pyc +0 -0
  107. package/extensions/services/__pycache__/__init__.cpython-313.pyc +0 -0
  108. package/extensions/services/watchdog/__pycache__/__init__.cpython-313.pyc +0 -0
  109. package/extensions/services/watchdog/__pycache__/entry.cpython-313.pyc +0 -0
  110. package/extensions/services/watchdog/__pycache__/monitor.cpython-313.pyc +0 -0
  111. package/extensions/services/watchdog/__pycache__/server.cpython-313.pyc +0 -0
  112. /package/{core/event_hub/bench_results/.gitkeep → extensions/services/web/vendor/bluetooth/__init__.py} +0 -0
@@ -0,0 +1,487 @@
1
+ """
2
+ Web Management entry point.
3
+ Reads boot_info from stdin, registers to Registry, starts web service.
4
+ Serves the full AI Phone Agent web UI and all API endpoints.
5
+ """
6
+
7
+ import builtins
8
+ import json
9
+ import os
10
+ import re
11
+ import signal
12
+ import socket
13
+ import sys
14
+ import threading
15
+ import time
16
+ import traceback
17
+ import uuid
18
+ from datetime import datetime, timezone
19
+
20
+ import httpx
21
+ import uvicorn
22
+
23
+
24
+ # ── Safe stdout/stderr: ignore BrokenPipeError after Launcher closes stdio ──
25
+
26
+
27
+ # ── Module configuration ──
28
+ MODULE_NAME = "web"
29
+
30
+
31
+ class _SafeWriter:
32
+ """Wraps a stream to silently swallow BrokenPipeError on write/flush."""
33
+ def __init__(self, stream):
34
+ self._stream = stream
35
+
36
+ def write(self, s):
37
+ try:
38
+ self._stream.write(s)
39
+ except (BrokenPipeError, OSError):
40
+ pass
41
+
42
+ def flush(self):
43
+ try:
44
+ self._stream.flush()
45
+ except (BrokenPipeError, OSError):
46
+ pass
47
+
48
+ def __getattr__(self, name):
49
+ return getattr(self._stream, name)
50
+
51
+ sys.stdout = _SafeWriter(sys.stdout)
52
+ sys.stderr = _SafeWriter(sys.stderr)
53
+
54
+
55
+ # ── Timestamped print + log file writer ──
56
+ # Independent implementation per module (no shared code dependency)
57
+
58
+ _builtin_print = builtins.print
59
+ _start_ts = time.monotonic()
60
+ _last_ts = time.monotonic()
61
+ _ANSI_RE = re.compile(r"\033\[[0-9;]*m")
62
+ _log_lock = threading.Lock()
63
+ _log_latest_path = None
64
+ _log_daily_path = None
65
+ _log_daily_date = ""
66
+ _log_dir = None
67
+ _crash_log_path = None
68
+
69
+ def _strip_ansi(s: str) -> str:
70
+ return _ANSI_RE.sub("", s)
71
+
72
+ def _resolve_daily_log_path():
73
+ """Resolve daily log path based on current date."""
74
+ global _log_daily_path, _log_daily_date
75
+ if not _log_dir:
76
+ return
77
+ today = datetime.now().strftime("%Y-%m-%d")
78
+ if today == _log_daily_date and _log_daily_path:
79
+ return
80
+ month_dir = os.path.join(_log_dir, today[:7])
81
+ os.makedirs(month_dir, exist_ok=True)
82
+ _log_daily_path = os.path.join(month_dir, f"{today}.log")
83
+ _log_daily_date = today
84
+
85
+ def _write_log(plain_line: str):
86
+ """Write a plain-text line to both latest.log and daily log."""
87
+ with _log_lock:
88
+ if _log_latest_path:
89
+ try:
90
+ with open(_log_latest_path, "a", encoding="utf-8") as f:
91
+ f.write(plain_line)
92
+ except Exception:
93
+ pass
94
+ _resolve_daily_log_path()
95
+ if _log_daily_path:
96
+ try:
97
+ with open(_log_daily_path, "a", encoding="utf-8") as f:
98
+ f.write(plain_line)
99
+ except Exception:
100
+ pass
101
+
102
+ def _write_crash(exc_type, exc_value, exc_tb, thread_name=None, severity="critical", handled=False):
103
+ """Write crash record to crashes.jsonl + daily crash archive."""
104
+ record = {
105
+ "timestamp": datetime.now(timezone.utc).isoformat(),
106
+ "module": MODULE_NAME,
107
+ "thread": thread_name or threading.current_thread().name,
108
+ "exception_type": exc_type.__name__ if exc_type else "Unknown",
109
+ "exception_message": str(exc_value),
110
+ "traceback": "".join(traceback.format_exception(exc_type, exc_value, exc_tb)),
111
+ "severity": severity,
112
+ "handled": handled,
113
+ "process_id": os.getpid(),
114
+ "platform": sys.platform,
115
+ "runtime_version": f"Python {sys.version.split()[0]}",
116
+ }
117
+
118
+ if exc_tb:
119
+ tb_entries = traceback.extract_tb(exc_tb)
120
+ if tb_entries:
121
+ last = tb_entries[-1]
122
+ record["context"] = {
123
+ "function": last.name,
124
+ "file": os.path.basename(last.filename),
125
+ "line": last.lineno,
126
+ }
127
+
128
+ line = json.dumps(record, ensure_ascii=False) + "\n"
129
+
130
+ if _crash_log_path:
131
+ try:
132
+ with open(_crash_log_path, "a", encoding="utf-8") as f:
133
+ f.write(line)
134
+ except Exception:
135
+ pass
136
+
137
+ if _log_dir:
138
+ try:
139
+ today = datetime.now().strftime("%Y-%m-%d")
140
+ archive_dir = os.path.join(_log_dir, "crashes", today[:7])
141
+ os.makedirs(archive_dir, exist_ok=True)
142
+ archive_path = os.path.join(archive_dir, f"{today}.jsonl")
143
+ with open(archive_path, "a", encoding="utf-8") as f:
144
+ f.write(line)
145
+ except Exception:
146
+ pass
147
+
148
+ def _print_crash_summary(exc_type, exc_tb, thread_name=None):
149
+ """Print crash summary to console (red highlight)."""
150
+ RED = "\033[91m"
151
+ RESET = "\033[0m"
152
+
153
+ if exc_tb:
154
+ tb_entries = traceback.extract_tb(exc_tb)
155
+ if tb_entries:
156
+ last = tb_entries[-1]
157
+ location = f"{os.path.basename(last.filename)}:{last.lineno}"
158
+ else:
159
+ location = "unknown"
160
+ else:
161
+ location = "unknown"
162
+
163
+ prefix = f"[{MODULE_NAME}]"
164
+ if thread_name:
165
+ _builtin_print(f"{prefix} {RED}线程 {thread_name} 崩溃: "
166
+ f"{exc_type.__name__} in {location}{RESET}")
167
+ else:
168
+ _builtin_print(f"{prefix} {RED}崩溃: {exc_type.__name__} in {location}{RESET}")
169
+ if _crash_log_path:
170
+ _builtin_print(f"{prefix} 崩溃日志: {_crash_log_path}")
171
+
172
+ def _setup_exception_hooks():
173
+ """Set up global exception hooks."""
174
+ _orig_excepthook = sys.excepthook
175
+
176
+ def _excepthook(exc_type, exc_value, exc_tb):
177
+ _write_crash(exc_type, exc_value, exc_tb, severity="critical", handled=False)
178
+ _print_crash_summary(exc_type, exc_tb)
179
+ _orig_excepthook(exc_type, exc_value, exc_tb)
180
+
181
+ sys.excepthook = _excepthook
182
+
183
+ if hasattr(threading, "excepthook"):
184
+ def _thread_excepthook(args):
185
+ _write_crash(args.exc_type, args.exc_value, args.exc_traceback,
186
+ thread_name=args.thread.name if args.thread else "unknown",
187
+ severity="error", handled=False)
188
+ _print_crash_summary(args.exc_type, args.exc_traceback,
189
+ thread_name=args.thread.name if args.thread else None)
190
+
191
+ threading.excepthook = _thread_excepthook
192
+
193
+ def _tprint(*args, **kwargs):
194
+ """Timestamped print that adds [timestamp] HH:MM:SS.mmm +delta prefix."""
195
+ global _last_ts
196
+ now = time.monotonic()
197
+ elapsed = now - _start_ts
198
+ delta = now - _last_ts
199
+ _last_ts = now
200
+
201
+ if elapsed < 1:
202
+ elapsed_str = f"{elapsed * 1000:.0f}ms"
203
+ elif elapsed < 100:
204
+ elapsed_str = f"{elapsed:.1f}s"
205
+ else:
206
+ elapsed_str = f"{elapsed:.0f}s"
207
+
208
+ if delta < 0.001:
209
+ delta_str = ""
210
+ elif delta < 1:
211
+ delta_str = f"+{delta * 1000:.0f}ms"
212
+ elif delta < 100:
213
+ delta_str = f"+{delta:.1f}s"
214
+ else:
215
+ delta_str = f"+{delta:.0f}s"
216
+
217
+ ts = datetime.now().strftime("%H:%M:%S.%f")[:-3]
218
+
219
+ _builtin_print(*args, **kwargs)
220
+
221
+ if _log_latest_path or _log_daily_path:
222
+ sep = kwargs.get("sep", " ")
223
+ end = kwargs.get("end", "\n")
224
+ text = sep.join(str(a) for a in args)
225
+ prefix = f"[{elapsed_str:>6}] {ts} {delta_str:>8} "
226
+ _write_log(prefix + _strip_ansi(text) + end)
227
+
228
+ builtins.print = _tprint
229
+
230
+ # Ensure project root (Kite/) is on sys.path
231
+ _this_dir = os.path.dirname(os.path.abspath(__file__))
232
+ _project_root = os.environ.get("KITE_PROJECT") or os.path.dirname(os.path.dirname(os.path.dirname(_this_dir)))
233
+ if _project_root not in sys.path:
234
+ sys.path.insert(0, _project_root)
235
+
236
+ # Also add ai-phone-agent root so we can import config, storage, conversation, etc.
237
+ _agent_root = os.path.dirname(_project_root)
238
+ if _agent_root not in sys.path:
239
+ sys.path.insert(0, _agent_root)
240
+
241
+ from extensions.services.web.server import WebServer
242
+
243
+
244
+ def _fmt_elapsed(t0: float) -> str:
245
+ d = time.monotonic() - t0
246
+ if d < 1:
247
+ return f"{d * 1000:.0f}ms"
248
+ if d < 10:
249
+ return f"{d:.1f}s"
250
+ return f"{d:.0f}s"
251
+
252
+
253
+ def _read_module_md() -> dict:
254
+ """Read preferred_port and advertise_ip from own module.md."""
255
+ md_path = os.path.join(_this_dir, "module.md")
256
+ result = {"preferred_port": 0, "advertise_ip": "0.0.0.0"}
257
+ try:
258
+ with open(md_path, "r", encoding="utf-8") as f:
259
+ text = f.read()
260
+ m = re.match(r'^---\s*\n(.*?)\n---', text, re.DOTALL)
261
+ if m:
262
+ try:
263
+ import yaml
264
+ fm = yaml.safe_load(m.group(1)) or {}
265
+ except ImportError:
266
+ fm = {}
267
+ result["preferred_port"] = int(fm.get("preferred_port", 0))
268
+ result["advertise_ip"] = fm.get("advertise_ip", "0.0.0.0")
269
+ except Exception:
270
+ pass
271
+ return result
272
+
273
+
274
+ def _bind_port(preferred: int, host: str, max_attempts: int = 10) -> int | None:
275
+ """
276
+ Try to bind to preferred port, then port+1, port+2, ... up to max_attempts.
277
+ Returns bound port on success, None on failure.
278
+ """
279
+ if not preferred:
280
+ # No preferred port, use OS-assigned
281
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
282
+ s.bind((host, 0))
283
+ return s.getsockname()[1]
284
+
285
+ for attempt in range(max_attempts):
286
+ port = preferred + attempt
287
+ try:
288
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
289
+ s.bind((host, port))
290
+ if attempt > 0:
291
+ print(f"[web] Bound to port {port} (preferred {preferred} was occupied)")
292
+ return port
293
+ except OSError:
294
+ if attempt < max_attempts - 1:
295
+ continue
296
+ else:
297
+ print(f"[web] ERROR: Failed to bind port after {max_attempts} attempts ({preferred}-{port})")
298
+ return None
299
+
300
+ return None
301
+
302
+
303
+ def _register_to_registry(client: httpx.Client, token: str, registry_url: str, host: str, port: int):
304
+ payload = {
305
+ "action": "register",
306
+ "module_id": "web",
307
+ "module_type": "service",
308
+ "name": "Web Management",
309
+ "api_endpoint": f"http://127.0.0.1:{port}",
310
+ "health_endpoint": "/health",
311
+ "events_publish": {
312
+ "web.test": {"description": "Test event from web module"},
313
+ },
314
+ "events_subscribe": [
315
+ "module.started",
316
+ "module.stopped",
317
+ "module.shutdown",
318
+ ],
319
+ }
320
+ headers = {"Authorization": f"Bearer {token}"}
321
+ try:
322
+ resp = client.post(f"{registry_url}/modules", json=payload, headers=headers)
323
+ if resp.status_code == 200:
324
+ pass # timing printed in main()
325
+ else:
326
+ print(f"[web] WARNING: Registry returned {resp.status_code}")
327
+ except Exception as e:
328
+ print(f"[web] WARNING: Registry registration failed: {e}")
329
+
330
+
331
+ def _get_event_hub_ws(client: httpx.Client, token: str, registry_url: str) -> str:
332
+ """Discover Event Hub WebSocket endpoint from Registry, with retry."""
333
+ headers = {"Authorization": f"Bearer {token}"}
334
+ deadline = time.time() + 10
335
+ while time.time() < deadline:
336
+ try:
337
+ resp = client.get(
338
+ f"{registry_url}/get/event_hub.metadata.ws_endpoint",
339
+ headers=headers,
340
+ )
341
+ if resp.status_code == 200:
342
+ val = resp.json()
343
+ if val:
344
+ return val
345
+ except Exception:
346
+ pass
347
+ time.sleep(0.2)
348
+ return ""
349
+
350
+
351
+ def _send_exiting_event(ws_url: str, token: str, reason: str):
352
+ """Send module.exiting event to Event Hub before exit. Best-effort, non-blocking."""
353
+ try:
354
+ import websockets.sync.client as ws_sync
355
+ url = f"{ws_url}?token={token}&id=web"
356
+ with ws_sync.connect(url, close_timeout=3) as ws:
357
+ msg = {
358
+ "type": "event",
359
+ "event_id": str(uuid.uuid4()),
360
+ "event": "module.exiting",
361
+ "source": "web",
362
+ "timestamp": datetime.now(timezone.utc).isoformat(),
363
+ "data": {
364
+ "module_id": "web",
365
+ "reason": reason,
366
+ "action": "none",
367
+ },
368
+ }
369
+ ws.send(json.dumps(msg))
370
+ # Brief wait for delivery
371
+ time.sleep(0.3)
372
+ except Exception as e:
373
+ print(f"[web] WARNING: Could not send module.exiting: {e}")
374
+
375
+
376
+ def main():
377
+ # Initialize log file paths
378
+ global _log_dir, _log_latest_path, _crash_log_path
379
+ module_data = os.environ.get("KITE_MODULE_DATA")
380
+ if module_data:
381
+ _log_dir = os.path.join(module_data, "log")
382
+ os.makedirs(_log_dir, exist_ok=True)
383
+ suffix = os.environ.get("KITE_INSTANCE_SUFFIX", "")
384
+
385
+ _log_latest_path = os.path.join(_log_dir, f"latest{suffix}.log")
386
+ try:
387
+ with open(_log_latest_path, "w", encoding="utf-8") as f:
388
+ pass
389
+ except Exception:
390
+ _log_latest_path = None
391
+
392
+ _crash_log_path = os.path.join(_log_dir, f"crashes{suffix}.jsonl")
393
+ try:
394
+ with open(_crash_log_path, "w", encoding="utf-8") as f:
395
+ pass
396
+ except Exception:
397
+ _crash_log_path = None
398
+
399
+ _resolve_daily_log_path()
400
+
401
+ _setup_exception_hooks()
402
+
403
+ _t0 = time.monotonic()
404
+
405
+ # Read boot_info from stdin (only token)
406
+ token = ""
407
+ try:
408
+ line = sys.stdin.readline().strip()
409
+ if line:
410
+ boot_info = json.loads(line)
411
+ token = boot_info.get("token", "")
412
+ except Exception:
413
+ pass
414
+
415
+ # Read registry_port from environment variable
416
+ registry_port = int(os.environ.get("KITE_REGISTRY_PORT", "0"))
417
+
418
+ if not token or not registry_port:
419
+ print("[web] ERROR: Missing token or KITE_REGISTRY_PORT")
420
+ sys.exit(1)
421
+
422
+ print(f"[web] Token received ({len(token)} chars), registry port: {registry_port} ({_fmt_elapsed(_t0)})")
423
+
424
+ # Read preferred_port from module.md
425
+ md_cfg = _read_module_md()
426
+ host = md_cfg["advertise_ip"]
427
+ port = _bind_port(md_cfg["preferred_port"], host)
428
+
429
+ registry_url = f"http://127.0.0.1:{registry_port}"
430
+
431
+ # If port binding failed after 10 attempts, exit gracefully (no watchdog restart)
432
+ if port is None:
433
+ print("[web] ERROR: Cannot bind to any port, attempting graceful exit")
434
+
435
+ # Try to discover Event Hub and send module.exiting event
436
+ client = httpx.Client(timeout=5)
437
+ event_hub_ws = _get_event_hub_ws(client, token, registry_url)
438
+ client.close()
439
+
440
+ if event_hub_ws:
441
+ reason = f"Port binding failed after 10 attempts ({md_cfg['preferred_port']}-{md_cfg['preferred_port']+9})"
442
+ _send_exiting_event(event_hub_ws, token, reason)
443
+ print("[web] module.exiting event sent")
444
+ else:
445
+ print("[web] WARNING: Could not discover Event Hub, exiting without event")
446
+
447
+ sys.exit(1) # Exit code 1 = startup failure
448
+
449
+ # Register and discover Event Hub synchronously before starting uvicorn
450
+ client = httpx.Client(timeout=5)
451
+ _register_to_registry(client, token, registry_url, host, port)
452
+ print(f"[web] Registered to Registry ({_fmt_elapsed(_t0)})")
453
+ event_hub_ws = _get_event_hub_ws(client, token, registry_url)
454
+ if not event_hub_ws:
455
+ print("[web] WARNING: Could not discover Event Hub WS, events disabled")
456
+ else:
457
+ print(f"[web] Discovered Event Hub: {event_hub_ws}")
458
+ client.close()
459
+
460
+ server = WebServer(
461
+ token=token,
462
+ registry_url=registry_url,
463
+ event_hub_ws=event_hub_ws,
464
+ host=host,
465
+ port=port,
466
+ boot_t0=_t0,
467
+ )
468
+
469
+ # Display access URL in green
470
+ display_host = "localhost" if host == "0.0.0.0" else host
471
+ url = f"http://{display_host}:{port}"
472
+ print(f"[web] Starting on {host}:{port} ({_fmt_elapsed(_t0)})")
473
+ print(f"[web] \033[32m✓ Web UI ready: {url}\033[0m")
474
+
475
+ try:
476
+ config = uvicorn.Config(server.app, host=host, port=port, log_level="warning")
477
+ uvi_server = uvicorn.Server(config)
478
+ server._uvicorn_server = uvi_server
479
+ uvi_server.run()
480
+ except Exception as e:
481
+ _write_crash(type(e), e, e.__traceback__, severity="critical", handled=True)
482
+ _print_crash_summary(type(e), e.__traceback__)
483
+ sys.exit(1)
484
+
485
+
486
+ if __name__ == "__main__":
487
+ main()
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: web
3
+ display_name: Web Management
4
+ version: "1.0"
5
+ type: service
6
+ state: enabled
7
+ runtime: python
8
+ entry: entry.py
9
+ preferred_port: 18766
10
+ advertise_ip: 0.0.0.0
11
+ events:
12
+ - web.test
13
+ subscriptions:
14
+ - module.started
15
+ - module.stopped
16
+ - module.shutdown
17
+ ---
18
+
19
+ # Web Management(Web 管理界面)
20
+
21
+ Web 管理界面模块,提供系统管理和监控的 Web UI。
22
+
23
+ - 管理界面 — 提供系统配置和状态监控的 Web UI
24
+ - 事件通知 — 通过 Event Hub 发布管理操作事件
@@ -0,0 +1,189 @@
1
+ """Routes for outgoing / incoming call management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from pathlib import Path
7
+
8
+ from fastapi import APIRouter, HTTPException, Query, Request
9
+ from fastapi.responses import FileResponse
10
+
11
+ from routes.schemas import (
12
+ CallConfirmRequest,
13
+ CallMessageRequest,
14
+ CallRequest,
15
+ CallResponse,
16
+ CallStatus,
17
+ HangupResponse,
18
+ PaginatedResponse,
19
+ )
20
+ from vendor.storage import store
21
+ from vendor.storage import identity
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ router = APIRouter(tags=["calls"])
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Single-call endpoints (prefix: /call)
30
+ # ---------------------------------------------------------------------------
31
+
32
+ @router.post("/call", response_model=CallResponse)
33
+ async def create_call(request_body: CallRequest, request: Request):
34
+ """Initiate a new outgoing call or register an incoming-call task."""
35
+ task_manager = request.app.state.task_manager
36
+ try:
37
+ task = await task_manager.create_call_task(request_body)
38
+ return CallResponse(task_id=task["task_id"], status=task.get("status", "created"))
39
+ except Exception as exc:
40
+ logger.exception("Failed to create call task")
41
+ raise HTTPException(status_code=500, detail=str(exc))
42
+
43
+
44
+ @router.get("/call/{task_id}", response_model=CallStatus)
45
+ async def get_call_status(task_id: str):
46
+ """Return the current status of a call task."""
47
+ record = await store.get_task(task_id)
48
+ if record is None:
49
+ raise HTTPException(status_code=404, detail="Task not found")
50
+
51
+ has_recording = False
52
+ call_dir = record.get("call_dir")
53
+ if call_dir:
54
+ rec_path = identity.get_recording_path(Path(call_dir))
55
+ has_recording = rec_path is not None
56
+
57
+ return CallStatus(
58
+ task_id=record.get("task_id", task_id),
59
+ status=record.get("status", "unknown"),
60
+ phone_number=record.get("phone_number"),
61
+ contact_name=record.get("contact_name"),
62
+ direction=record.get("direction"),
63
+ duration_seconds=record.get("duration_seconds"),
64
+ result=record.get("result"),
65
+ summary=record.get("summary"),
66
+ started_at=record.get("started_at"),
67
+ ended_at=record.get("ended_at"),
68
+ has_recording=has_recording,
69
+ )
70
+
71
+
72
+ @router.post("/call/{task_id}/hangup", response_model=HangupResponse)
73
+ async def hangup_call(task_id: str, request: Request):
74
+ """Hang up an active call."""
75
+ task_manager = request.app.state.task_manager
76
+ try:
77
+ result = await task_manager.hangup_task(task_id)
78
+ return HangupResponse(
79
+ task_id=task_id,
80
+ status=result.get("status", "hangup_requested") if isinstance(result, dict) else "hangup_requested",
81
+ )
82
+ except Exception as exc:
83
+ logger.exception("Failed to hangup task %s", task_id)
84
+ raise HTTPException(status_code=500, detail=str(exc))
85
+
86
+
87
+ @router.post("/call/{task_id}/confirm", response_model=CallResponse)
88
+ async def confirm_call(task_id: str, request_body: CallConfirmRequest, request: Request):
89
+ """Confirm or reject an incoming call that requires confirmation."""
90
+ task_manager = request.app.state.task_manager
91
+ try:
92
+ result = await task_manager.confirm_task(task_id, request_body)
93
+ return CallResponse(
94
+ task_id=task_id,
95
+ status=result.get("status", "confirmed") if isinstance(result, dict) else "confirmed",
96
+ )
97
+ except Exception as exc:
98
+ logger.exception("Failed to confirm task %s", task_id)
99
+ raise HTTPException(status_code=500, detail=str(exc))
100
+
101
+
102
+ @router.post("/call/{task_id}/message")
103
+ async def send_call_message(task_id: str, request_body: CallMessageRequest, request: Request):
104
+ """Inject a text message into an active call (will be spoken via TTS)."""
105
+ task_manager = request.app.state.task_manager
106
+ try:
107
+ await task_manager.send_message(task_id, request_body.message)
108
+ return {"task_id": task_id, "status": "message_sent"}
109
+ except Exception as exc:
110
+ logger.exception("Failed to send message to task %s", task_id)
111
+ raise HTTPException(status_code=500, detail=str(exc))
112
+
113
+
114
+ # ---------------------------------------------------------------------------
115
+ # Multi-call / history endpoints (prefix: /calls)
116
+ # ---------------------------------------------------------------------------
117
+
118
+ @router.get("/calls", response_model=PaginatedResponse)
119
+ async def list_calls(
120
+ page: int = Query(1, ge=1),
121
+ page_size: int = Query(20, ge=1, le=100),
122
+ direction: str | None = Query(None),
123
+ result: str | None = Query(None),
124
+ ):
125
+ """List call history with optional filters and pagination."""
126
+ filters = {}
127
+ if direction is not None:
128
+ filters["direction"] = direction
129
+ if result is not None:
130
+ filters["result"] = result
131
+
132
+ items, total = await store.list_tasks(page=page, page_size=page_size, **filters)
133
+ return PaginatedResponse(items=items, total=total, page=page, page_size=page_size)
134
+
135
+
136
+ @router.get("/calls/{task_id}/recording")
137
+ async def get_recording(task_id: str):
138
+ """Download the WAV recording for a finished call."""
139
+ record = await store.get_task(task_id)
140
+ if record is None:
141
+ raise HTTPException(status_code=404, detail="Task not found")
142
+
143
+ call_dir = record.get("call_dir")
144
+ if not call_dir:
145
+ raise HTTPException(status_code=404, detail="Recording not found")
146
+
147
+ path = identity.get_recording_path(Path(call_dir))
148
+ if path is None:
149
+ raise HTTPException(status_code=404, detail="Recording not found")
150
+
151
+ return FileResponse(
152
+ path=str(path),
153
+ media_type="audio/wav",
154
+ filename=f"{task_id}.wav",
155
+ )
156
+
157
+
158
+ @router.get("/calls/{task_id}/transcript")
159
+ async def get_transcript(task_id: str):
160
+ """Return the full transcript (list of utterance events) for a call."""
161
+ record = await store.get_task(task_id)
162
+ if record is None:
163
+ raise HTTPException(status_code=404, detail="Task not found")
164
+
165
+ call_dir = record.get("call_dir")
166
+ if not call_dir:
167
+ raise HTTPException(status_code=404, detail="Transcript not found")
168
+
169
+ transcript = await identity.load_call_messages(Path(call_dir))
170
+ if not transcript:
171
+ raise HTTPException(status_code=404, detail="Transcript not found")
172
+ return {"task_id": task_id, "transcript": transcript}
173
+
174
+
175
+ @router.get("/calls/{task_id}/summary")
176
+ async def get_summary(task_id: str):
177
+ """Return the AI-generated summary for a call."""
178
+ record = await store.get_task(task_id)
179
+ if record is None:
180
+ raise HTTPException(status_code=404, detail="Task not found")
181
+
182
+ call_dir = record.get("call_dir")
183
+ if not call_dir:
184
+ raise HTTPException(status_code=404, detail="Summary not found")
185
+
186
+ summary = await identity.load_call_summary(Path(call_dir))
187
+ if summary is None:
188
+ raise HTTPException(status_code=404, detail="Summary not found")
189
+ return {"task_id": task_id, "summary": summary}