@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.
- package/cli.js +127 -25
- package/core/event_hub/entry.py +384 -61
- package/core/event_hub/hub.py +8 -0
- package/core/event_hub/module.md +0 -1
- package/core/event_hub/server.py +169 -38
- package/core/kite_log.py +241 -0
- package/core/launcher/entry.py +1306 -425
- package/core/launcher/module_scanner.py +10 -9
- package/core/launcher/process_manager.py +555 -121
- package/core/registry/entry.py +335 -30
- package/core/registry/server.py +339 -256
- package/core/registry/store.py +13 -2
- package/extensions/agents/__init__.py +1 -0
- package/extensions/agents/assistant/__init__.py +1 -0
- package/extensions/agents/assistant/entry.py +380 -0
- package/extensions/agents/assistant/module.md +22 -0
- package/extensions/agents/assistant/server.py +236 -0
- package/extensions/channels/__init__.py +1 -0
- package/extensions/channels/acp_channel/__init__.py +1 -0
- package/extensions/channels/acp_channel/entry.py +380 -0
- package/extensions/channels/acp_channel/module.md +22 -0
- package/extensions/channels/acp_channel/server.py +236 -0
- package/{core → extensions}/event_hub_bench/entry.py +664 -371
- package/{core → extensions}/event_hub_bench/module.md +4 -2
- package/extensions/services/backup/__init__.py +1 -0
- package/extensions/services/backup/entry.py +380 -0
- package/extensions/services/backup/module.md +22 -0
- package/extensions/services/backup/server.py +244 -0
- package/extensions/services/model_service/__init__.py +1 -0
- package/extensions/services/model_service/entry.py +380 -0
- package/extensions/services/model_service/module.md +22 -0
- package/extensions/services/model_service/server.py +236 -0
- package/extensions/services/watchdog/entry.py +460 -143
- package/extensions/services/watchdog/module.md +3 -0
- package/extensions/services/watchdog/monitor.py +128 -13
- package/extensions/services/watchdog/server.py +75 -13
- package/extensions/services/web/__init__.py +1 -0
- package/extensions/services/web/config.yaml +149 -0
- package/extensions/services/web/entry.py +487 -0
- package/extensions/services/web/module.md +24 -0
- package/extensions/services/web/routes/__init__.py +1 -0
- package/extensions/services/web/routes/routes_call.py +189 -0
- package/extensions/services/web/routes/routes_config.py +512 -0
- package/extensions/services/web/routes/routes_contacts.py +98 -0
- package/extensions/services/web/routes/routes_devlog.py +99 -0
- package/extensions/services/web/routes/routes_phone.py +81 -0
- package/extensions/services/web/routes/routes_sms.py +48 -0
- package/extensions/services/web/routes/routes_stats.py +17 -0
- package/extensions/services/web/routes/routes_voicechat.py +554 -0
- package/extensions/services/web/routes/schemas.py +216 -0
- package/extensions/services/web/server.py +332 -0
- package/extensions/services/web/static/css/style.css +1064 -0
- package/extensions/services/web/static/index.html +1445 -0
- package/extensions/services/web/static/js/app.js +4671 -0
- package/extensions/services/web/vendor/__init__.py +1 -0
- package/extensions/services/web/vendor/bluetooth/audio.py +348 -0
- package/extensions/services/web/vendor/bluetooth/contacts.py +251 -0
- package/extensions/services/web/vendor/bluetooth/manager.py +395 -0
- package/extensions/services/web/vendor/bluetooth/sms.py +290 -0
- package/extensions/services/web/vendor/bluetooth/telephony.py +274 -0
- package/extensions/services/web/vendor/config.py +139 -0
- package/extensions/services/web/vendor/conversation/__init__.py +0 -0
- package/extensions/services/web/vendor/conversation/asr.py +936 -0
- package/extensions/services/web/vendor/conversation/engine.py +548 -0
- package/extensions/services/web/vendor/conversation/llm.py +534 -0
- package/extensions/services/web/vendor/conversation/mcp_tools.py +190 -0
- package/extensions/services/web/vendor/conversation/tts.py +322 -0
- package/extensions/services/web/vendor/conversation/vad.py +138 -0
- package/extensions/services/web/vendor/storage/__init__.py +1 -0
- package/extensions/services/web/vendor/storage/identity.py +312 -0
- package/extensions/services/web/vendor/storage/store.py +507 -0
- package/extensions/services/web/vendor/task/__init__.py +0 -0
- package/extensions/services/web/vendor/task/manager.py +864 -0
- package/extensions/services/web/vendor/task/models.py +45 -0
- package/extensions/services/web/vendor/task/webhook.py +263 -0
- package/extensions/services/web/vendor/tools/__init__.py +0 -0
- package/extensions/services/web/vendor/tools/registry.py +321 -0
- package/main.py +344 -4
- package/package.json +11 -2
- package/core/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/__pycache__/data_dir.cpython-313.pyc +0 -0
- package/core/data_dir.py +0 -62
- package/core/event_hub/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/bench.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/bench_perf.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/dedup.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/entry.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/hub.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/router.cpython-313.pyc +0 -0
- package/core/event_hub/__pycache__/server.cpython-313.pyc +0 -0
- package/core/event_hub/bench_results/2026-02-28_13-26-48.json +0 -51
- package/core/event_hub/bench_results/2026-02-28_13-44-45.json +0 -51
- package/core/event_hub/bench_results/2026-02-28_13-45-39.json +0 -51
- package/core/launcher/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/launcher/__pycache__/entry.cpython-313.pyc +0 -0
- package/core/launcher/__pycache__/module_scanner.cpython-313.pyc +0 -0
- package/core/launcher/__pycache__/process_manager.cpython-313.pyc +0 -0
- package/core/launcher/data/log/lifecycle.jsonl +0 -1158
- package/core/launcher/data/token.txt +0 -1
- package/core/registry/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/registry/__pycache__/entry.cpython-313.pyc +0 -0
- package/core/registry/__pycache__/server.cpython-313.pyc +0 -0
- package/core/registry/__pycache__/store.cpython-313.pyc +0 -0
- package/core/registry/data/port.txt +0 -1
- package/core/registry/data/port_484.txt +0 -1
- package/extensions/__pycache__/__init__.cpython-313.pyc +0 -0
- package/extensions/services/__pycache__/__init__.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/__pycache__/__init__.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/__pycache__/entry.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/__pycache__/monitor.cpython-313.pyc +0 -0
- package/extensions/services/watchdog/__pycache__/server.cpython-313.pyc +0 -0
- /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 @@
|
|
|
1
|
+
|
|
@@ -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}
|