@agentunion/kite 1.3.0 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -1
- package/extensions/agents/assistant/server.py +30 -12
- package/extensions/channels/acp_channel/server.py +30 -12
- package/extensions/services/backup/entry.py +123 -65
- package/extensions/services/model_service/entry.py +123 -65
- package/extensions/services/watchdog/entry.py +171 -80
- package/extensions/services/watchdog/monitor.py +112 -6
- package/extensions/services/web/routes/routes_modules.py +249 -0
- package/extensions/services/web/routes/schemas.py +22 -0
- package/extensions/services/web/server.py +37 -14
- package/extensions/services/web/static/css/style.css +97 -0
- package/extensions/services/web/static/index.html +105 -2
- package/extensions/services/web/static/js/app.js +288 -1
- package/kernel/event_hub.py +21 -3
- package/kernel/registry_store.py +22 -5
- package/kernel/rpc_router.py +15 -5
- package/kernel/server.py +75 -5
- package/launcher/count_lines.py +34 -0
- package/launcher/entry.py +92 -14
- package/launcher/process_manager.py +12 -1
- package/package.json +1 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title>AI Phone Agent</title>
|
|
7
|
-
<link rel="stylesheet" href="css/style.css?v=
|
|
7
|
+
<link rel="stylesheet" href="css/style.css?v=36">
|
|
8
8
|
</head>
|
|
9
9
|
<body>
|
|
10
10
|
|
|
@@ -52,6 +52,10 @@
|
|
|
52
52
|
<span class="nav-label">蓝牙</span>
|
|
53
53
|
<span class="nav-sublabel">Bluetooth</span>
|
|
54
54
|
</li>
|
|
55
|
+
<li class="nav-item" data-page="modules">
|
|
56
|
+
<span class="nav-label">模块</span>
|
|
57
|
+
<span class="nav-sublabel">Modules</span>
|
|
58
|
+
</li>
|
|
55
59
|
<li class="nav-item" data-page="voicechat">
|
|
56
60
|
<span class="nav-label">语音测试</span>
|
|
57
61
|
<span class="nav-sublabel">Voice Chat</span>
|
|
@@ -960,6 +964,105 @@
|
|
|
960
964
|
</div>
|
|
961
965
|
</section>
|
|
962
966
|
|
|
967
|
+
<!-- ==================== Voice Chat ==================== -->
|
|
968
|
+
|
|
969
|
+
<!-- ==================== Modules ==================== -->
|
|
970
|
+
<section id="page-modules" class="page">
|
|
971
|
+
<div style="display:flex;align-items:center;gap:12px;margin-bottom:20px;">
|
|
972
|
+
<h2 class="page-title" style="margin-bottom:0;">模块</h2>
|
|
973
|
+
<span id="module-save-status" class="config-save-status"></span>
|
|
974
|
+
</div>
|
|
975
|
+
|
|
976
|
+
<!-- Module Cards Grid -->
|
|
977
|
+
<div id="modules-grid" class="modules-grid">
|
|
978
|
+
<p class="text-muted" style="padding:40px;text-align:center;">加载中...</p>
|
|
979
|
+
</div>
|
|
980
|
+
|
|
981
|
+
<!-- Module Detail Panel (hidden by default) -->
|
|
982
|
+
<div id="module-detail" class="hidden">
|
|
983
|
+
<div style="display:flex;align-items:center;gap:12px;margin-bottom:16px;">
|
|
984
|
+
<button class="btn btn-secondary btn-sm" id="btn-module-back">← 返回</button>
|
|
985
|
+
<h3 id="module-detail-name" style="font-size:18px;font-weight:600;color:var(--gray-900);margin:0;"></h3>
|
|
986
|
+
<span id="module-detail-type-badge" class="module-type-badge"></span>
|
|
987
|
+
</div>
|
|
988
|
+
|
|
989
|
+
<!-- Metadata Form -->
|
|
990
|
+
<div class="form-section" id="module-meta-form">
|
|
991
|
+
<h4 class="form-title">元数据</h4>
|
|
992
|
+
|
|
993
|
+
<!-- Read-only fields -->
|
|
994
|
+
<div class="form-row" style="margin-bottom:14px;">
|
|
995
|
+
<div class="form-group">
|
|
996
|
+
<label>Name</label>
|
|
997
|
+
<input type="text" id="mod-meta-name" disabled style="background:var(--gray-100);cursor:not-allowed;">
|
|
998
|
+
</div>
|
|
999
|
+
<div class="form-group">
|
|
1000
|
+
<label>Type</label>
|
|
1001
|
+
<input type="text" id="mod-meta-type" disabled style="background:var(--gray-100);cursor:not-allowed;">
|
|
1002
|
+
</div>
|
|
1003
|
+
</div>
|
|
1004
|
+
<div class="form-row" style="margin-bottom:14px;">
|
|
1005
|
+
<div class="form-group">
|
|
1006
|
+
<label>Runtime</label>
|
|
1007
|
+
<input type="text" id="mod-meta-runtime" disabled style="background:var(--gray-100);cursor:not-allowed;">
|
|
1008
|
+
</div>
|
|
1009
|
+
<div class="form-group">
|
|
1010
|
+
<label>Entry</label>
|
|
1011
|
+
<input type="text" id="mod-meta-entry" disabled style="background:var(--gray-100);cursor:not-allowed;">
|
|
1012
|
+
</div>
|
|
1013
|
+
</div>
|
|
1014
|
+
|
|
1015
|
+
<hr style="border:none;border-top:1px solid var(--gray-200);margin:16px 0;">
|
|
1016
|
+
|
|
1017
|
+
<!-- Editable fields -->
|
|
1018
|
+
<div class="form-row">
|
|
1019
|
+
<div class="form-group">
|
|
1020
|
+
<label>Display Name</label>
|
|
1021
|
+
<input type="text" id="mod-meta-display-name" data-field="display_name">
|
|
1022
|
+
</div>
|
|
1023
|
+
<div class="form-group">
|
|
1024
|
+
<label>Version</label>
|
|
1025
|
+
<input type="text" id="mod-meta-version" data-field="version">
|
|
1026
|
+
</div>
|
|
1027
|
+
</div>
|
|
1028
|
+
<div class="form-row">
|
|
1029
|
+
<div class="form-group">
|
|
1030
|
+
<label>State</label>
|
|
1031
|
+
<select id="mod-meta-state" data-field="state">
|
|
1032
|
+
<option value="enabled">enabled</option>
|
|
1033
|
+
<option value="manual">manual</option>
|
|
1034
|
+
<option value="disabled">disabled</option>
|
|
1035
|
+
</select>
|
|
1036
|
+
</div>
|
|
1037
|
+
<div class="form-group">
|
|
1038
|
+
<label>Preferred Port</label>
|
|
1039
|
+
<input type="number" id="mod-meta-port" data-field="preferred_port" placeholder="(无)">
|
|
1040
|
+
</div>
|
|
1041
|
+
</div>
|
|
1042
|
+
<div class="form-row">
|
|
1043
|
+
<div class="form-group">
|
|
1044
|
+
<label>Advertise IP</label>
|
|
1045
|
+
<input type="text" id="mod-meta-ip" data-field="advertise_ip" placeholder="0.0.0.0">
|
|
1046
|
+
</div>
|
|
1047
|
+
<div class="form-group">
|
|
1048
|
+
<label>Monitor</label>
|
|
1049
|
+
<select id="mod-meta-monitor" data-field="monitor">
|
|
1050
|
+
<option value="">(未设置)</option>
|
|
1051
|
+
<option value="true">true</option>
|
|
1052
|
+
<option value="false">false</option>
|
|
1053
|
+
</select>
|
|
1054
|
+
</div>
|
|
1055
|
+
</div>
|
|
1056
|
+
</div>
|
|
1057
|
+
|
|
1058
|
+
<!-- Config Tree (only shown if module has config.yaml) -->
|
|
1059
|
+
<div class="form-section hidden" id="module-config-section">
|
|
1060
|
+
<h4 class="form-title">config.yaml</h4>
|
|
1061
|
+
<div id="module-config-tree"></div>
|
|
1062
|
+
</div>
|
|
1063
|
+
</div>
|
|
1064
|
+
</section>
|
|
1065
|
+
|
|
963
1066
|
<!-- ==================== Voice Chat ==================== -->
|
|
964
1067
|
<section id="page-voicechat" class="page">
|
|
965
1068
|
<div class="vc-layout">
|
|
@@ -1440,6 +1543,6 @@
|
|
|
1440
1543
|
<!-- ===== Toast / Notification Area ===== -->
|
|
1441
1544
|
<div id="toast-container"></div>
|
|
1442
1545
|
|
|
1443
|
-
<script src="js/app.js?v=
|
|
1546
|
+
<script src="js/app.js?v=52"></script>
|
|
1444
1547
|
</body>
|
|
1445
1548
|
</html>
|
|
@@ -97,7 +97,7 @@ function showToast(message, type = 'info') {
|
|
|
97
97
|
// ============================================================
|
|
98
98
|
// Navigation / Router
|
|
99
99
|
// ============================================================
|
|
100
|
-
const pages = ['dashboard', 'calls', 'sms', 'contacts', 'config', 'bluetooth', 'voicechat', 'devlog'];
|
|
100
|
+
const pages = ['dashboard', 'calls', 'sms', 'contacts', 'config', 'bluetooth', 'modules', 'voicechat', 'devlog'];
|
|
101
101
|
let currentPage = '';
|
|
102
102
|
|
|
103
103
|
function navigate(page) {
|
|
@@ -128,6 +128,7 @@ function navigate(page) {
|
|
|
128
128
|
case 'contacts': loadContacts(); break;
|
|
129
129
|
case 'config': loadConfig(); break;
|
|
130
130
|
case 'bluetooth': loadBluetooth(); break;
|
|
131
|
+
case 'modules': loadModules(); break;
|
|
131
132
|
case 'voicechat': loadVoiceChatConfig(); break;
|
|
132
133
|
case 'devlog': loadDevLog(); break;
|
|
133
134
|
}
|
|
@@ -4202,6 +4203,282 @@ function closeDevLogArchiveModal() {
|
|
|
4202
4203
|
document.getElementById('devlog-archive-modal')?.classList.add('hidden');
|
|
4203
4204
|
}
|
|
4204
4205
|
|
|
4206
|
+
// ============================================================
|
|
4207
|
+
// Modules Page
|
|
4208
|
+
// ============================================================
|
|
4209
|
+
let _moduleSaveTimer = null;
|
|
4210
|
+
let _currentModuleName = '';
|
|
4211
|
+
|
|
4212
|
+
async function loadModules() {
|
|
4213
|
+
// Reset to grid view
|
|
4214
|
+
document.getElementById('modules-grid')?.classList.remove('hidden');
|
|
4215
|
+
document.getElementById('module-detail')?.classList.add('hidden');
|
|
4216
|
+
|
|
4217
|
+
try {
|
|
4218
|
+
const modules = await API.get('/api/modules');
|
|
4219
|
+
renderModulesGrid(modules);
|
|
4220
|
+
} catch (err) {
|
|
4221
|
+
const grid = document.getElementById('modules-grid');
|
|
4222
|
+
if (grid) grid.innerHTML = `<p class="text-muted" style="padding:40px;text-align:center;">加载失败: ${escapeHtml(err.message)}</p>`;
|
|
4223
|
+
}
|
|
4224
|
+
}
|
|
4225
|
+
|
|
4226
|
+
function renderModulesGrid(modules) {
|
|
4227
|
+
const grid = document.getElementById('modules-grid');
|
|
4228
|
+
if (!grid) return;
|
|
4229
|
+
|
|
4230
|
+
if (!modules.length) {
|
|
4231
|
+
grid.innerHTML = '<p class="text-muted" style="padding:40px;text-align:center;">未发现模块</p>';
|
|
4232
|
+
return;
|
|
4233
|
+
}
|
|
4234
|
+
|
|
4235
|
+
grid.innerHTML = modules.map(m => {
|
|
4236
|
+
const stateClass = m.state === 'enabled' ? 'enabled' : m.state === 'manual' ? 'manual' : 'disabled';
|
|
4237
|
+
const typeClass = m.type || 'unknown';
|
|
4238
|
+
const displayName = m.display_name || m.name;
|
|
4239
|
+
const version = m.version ? `<span class="module-version">v${escapeHtml(String(m.version))}</span>` : '';
|
|
4240
|
+
const configBadge = m.has_config ? '<span class="badge badge-info" style="font-size:10px;">config</span>' : '';
|
|
4241
|
+
return `<div class="module-card" data-name="${escapeHtml(m.name)}" onclick="openModuleDetail('${escapeHtml(m.name)}')">
|
|
4242
|
+
<div class="module-card-header">
|
|
4243
|
+
<span class="module-state-dot ${stateClass}" title="${escapeHtml(m.state || 'enabled')}"></span>
|
|
4244
|
+
<span class="module-type-badge type-${escapeHtml(typeClass)}">${escapeHtml(m.type || '?')}</span>
|
|
4245
|
+
${version}
|
|
4246
|
+
</div>
|
|
4247
|
+
<div class="module-card-name">${escapeHtml(m.name)}</div>
|
|
4248
|
+
<div class="module-card-display-name">${escapeHtml(displayName)}</div>
|
|
4249
|
+
<div class="module-card-footer">
|
|
4250
|
+
${m.preferred_port ? '<span style="font-size:11px;color:var(--gray-400);">:' + m.preferred_port + '</span>' : ''}
|
|
4251
|
+
${configBadge}
|
|
4252
|
+
</div>
|
|
4253
|
+
</div>`;
|
|
4254
|
+
}).join('');
|
|
4255
|
+
}
|
|
4256
|
+
|
|
4257
|
+
async function openModuleDetail(name) {
|
|
4258
|
+
_currentModuleName = name;
|
|
4259
|
+
|
|
4260
|
+
// Switch view
|
|
4261
|
+
document.getElementById('modules-grid')?.classList.add('hidden');
|
|
4262
|
+
document.getElementById('module-detail')?.classList.remove('hidden');
|
|
4263
|
+
|
|
4264
|
+
try {
|
|
4265
|
+
const mod = await API.get(`/api/modules/${encodeURIComponent(name)}`);
|
|
4266
|
+
|
|
4267
|
+
// Header
|
|
4268
|
+
document.getElementById('module-detail-name').textContent = mod.display_name || mod.name;
|
|
4269
|
+
const badge = document.getElementById('module-detail-type-badge');
|
|
4270
|
+
if (badge) {
|
|
4271
|
+
badge.textContent = mod.type || '?';
|
|
4272
|
+
badge.className = `module-type-badge type-${mod.type || 'unknown'}`;
|
|
4273
|
+
}
|
|
4274
|
+
|
|
4275
|
+
// Read-only fields
|
|
4276
|
+
_setVal('mod-meta-name', mod.name || '');
|
|
4277
|
+
_setVal('mod-meta-type', mod.type || '');
|
|
4278
|
+
_setVal('mod-meta-runtime', mod.runtime || '');
|
|
4279
|
+
_setVal('mod-meta-entry', mod.entry || '');
|
|
4280
|
+
|
|
4281
|
+
// Editable fields
|
|
4282
|
+
_setVal('mod-meta-display-name', mod.display_name || '');
|
|
4283
|
+
_setVal('mod-meta-version', mod.version || '');
|
|
4284
|
+
_setVal('mod-meta-state', mod.state || 'enabled');
|
|
4285
|
+
_setVal('mod-meta-port', mod.preferred_port != null ? mod.preferred_port : '');
|
|
4286
|
+
_setVal('mod-meta-ip', mod.advertise_ip || '');
|
|
4287
|
+
const monitorEl = document.getElementById('mod-meta-monitor');
|
|
4288
|
+
if (monitorEl) monitorEl.value = mod.monitor != null ? String(mod.monitor) : '';
|
|
4289
|
+
|
|
4290
|
+
// Config section
|
|
4291
|
+
const configSection = document.getElementById('module-config-section');
|
|
4292
|
+
const configTree = document.getElementById('module-config-tree');
|
|
4293
|
+
if (mod.has_config && mod.config) {
|
|
4294
|
+
configSection?.classList.remove('hidden');
|
|
4295
|
+
if (configTree) {
|
|
4296
|
+
configTree.innerHTML = '';
|
|
4297
|
+
renderConfigTree(mod.config, configTree, '');
|
|
4298
|
+
}
|
|
4299
|
+
} else {
|
|
4300
|
+
configSection?.classList.add('hidden');
|
|
4301
|
+
}
|
|
4302
|
+
} catch (err) {
|
|
4303
|
+
showToast('加载模块详情失败: ' + err.message, 'error');
|
|
4304
|
+
}
|
|
4305
|
+
}
|
|
4306
|
+
|
|
4307
|
+
function renderConfigTree(obj, container, pathPrefix) {
|
|
4308
|
+
if (!obj || typeof obj !== 'object') return;
|
|
4309
|
+
|
|
4310
|
+
// Secret field patterns
|
|
4311
|
+
const secretKeys = /(?:password|secret|token|api_key|access_key|private_key)/i;
|
|
4312
|
+
|
|
4313
|
+
Object.entries(obj).forEach(([key, val]) => {
|
|
4314
|
+
const fullPath = pathPrefix ? `${pathPrefix}.${key}` : key;
|
|
4315
|
+
|
|
4316
|
+
if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
|
|
4317
|
+
// Nested object → collapsible group
|
|
4318
|
+
const group = document.createElement('div');
|
|
4319
|
+
group.className = 'config-tree-group';
|
|
4320
|
+
|
|
4321
|
+
const header = document.createElement('div');
|
|
4322
|
+
header.className = 'config-tree-key';
|
|
4323
|
+
header.textContent = key;
|
|
4324
|
+
header.addEventListener('click', () => {
|
|
4325
|
+
group.classList.toggle('collapsed');
|
|
4326
|
+
});
|
|
4327
|
+
|
|
4328
|
+
const nested = document.createElement('div');
|
|
4329
|
+
nested.className = 'config-tree-nested';
|
|
4330
|
+
|
|
4331
|
+
group.appendChild(header);
|
|
4332
|
+
group.appendChild(nested);
|
|
4333
|
+
container.appendChild(group);
|
|
4334
|
+
|
|
4335
|
+
renderConfigTree(val, nested, fullPath);
|
|
4336
|
+
} else {
|
|
4337
|
+
// Leaf value → form input
|
|
4338
|
+
const row = document.createElement('div');
|
|
4339
|
+
row.className = 'config-tree-leaf';
|
|
4340
|
+
|
|
4341
|
+
const label = document.createElement('label');
|
|
4342
|
+
label.className = 'config-tree-label';
|
|
4343
|
+
label.textContent = key;
|
|
4344
|
+
|
|
4345
|
+
let input;
|
|
4346
|
+
if (typeof val === 'boolean') {
|
|
4347
|
+
input = document.createElement('input');
|
|
4348
|
+
input.type = 'checkbox';
|
|
4349
|
+
input.checked = val;
|
|
4350
|
+
input.className = 'config-tree-checkbox';
|
|
4351
|
+
} else if (typeof val === 'number') {
|
|
4352
|
+
input = document.createElement('input');
|
|
4353
|
+
input.type = 'number';
|
|
4354
|
+
input.value = val;
|
|
4355
|
+
input.step = 'any';
|
|
4356
|
+
input.className = 'config-tree-input';
|
|
4357
|
+
} else if (Array.isArray(val)) {
|
|
4358
|
+
input = document.createElement('input');
|
|
4359
|
+
input.type = 'text';
|
|
4360
|
+
input.value = val.join(', ');
|
|
4361
|
+
input.className = 'config-tree-input';
|
|
4362
|
+
input.placeholder = 'comma separated';
|
|
4363
|
+
} else {
|
|
4364
|
+
input = document.createElement('input');
|
|
4365
|
+
input.type = secretKeys.test(key) ? 'password' : 'text';
|
|
4366
|
+
input.value = val != null ? String(val) : '';
|
|
4367
|
+
input.className = 'config-tree-input';
|
|
4368
|
+
}
|
|
4369
|
+
|
|
4370
|
+
input.dataset.path = fullPath;
|
|
4371
|
+
input.dataset.origType = Array.isArray(val) ? 'array' : typeof val;
|
|
4372
|
+
|
|
4373
|
+
// Debounced save on input/change
|
|
4374
|
+
const saveEvent = (input.type === 'checkbox' || input.tagName === 'SELECT') ? 'change' : 'input';
|
|
4375
|
+
input.addEventListener(saveEvent, _debouncedSaveModule);
|
|
4376
|
+
|
|
4377
|
+
row.appendChild(label);
|
|
4378
|
+
row.appendChild(input);
|
|
4379
|
+
container.appendChild(row);
|
|
4380
|
+
}
|
|
4381
|
+
});
|
|
4382
|
+
}
|
|
4383
|
+
|
|
4384
|
+
function _buildModuleConfigObject() {
|
|
4385
|
+
const tree = document.getElementById('module-config-tree');
|
|
4386
|
+
if (!tree) return null;
|
|
4387
|
+
|
|
4388
|
+
const obj = {};
|
|
4389
|
+
tree.querySelectorAll('[data-path]').forEach(input => {
|
|
4390
|
+
const path = input.dataset.path;
|
|
4391
|
+
const origType = input.dataset.origType;
|
|
4392
|
+
let val;
|
|
4393
|
+
|
|
4394
|
+
if (input.type === 'checkbox') {
|
|
4395
|
+
val = input.checked;
|
|
4396
|
+
} else if (origType === 'number') {
|
|
4397
|
+
val = input.value === '' ? 0 : Number(input.value);
|
|
4398
|
+
} else if (origType === 'array') {
|
|
4399
|
+
val = input.value.split(',').map(s => s.trim()).filter(Boolean);
|
|
4400
|
+
} else {
|
|
4401
|
+
val = input.value;
|
|
4402
|
+
}
|
|
4403
|
+
|
|
4404
|
+
// Build nested object from dot path
|
|
4405
|
+
const parts = path.split('.');
|
|
4406
|
+
let target = obj;
|
|
4407
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
4408
|
+
if (!(parts[i] in target)) target[parts[i]] = {};
|
|
4409
|
+
target = target[parts[i]];
|
|
4410
|
+
}
|
|
4411
|
+
target[parts[parts.length - 1]] = val;
|
|
4412
|
+
});
|
|
4413
|
+
|
|
4414
|
+
return obj;
|
|
4415
|
+
}
|
|
4416
|
+
|
|
4417
|
+
function _debouncedSaveModule() {
|
|
4418
|
+
clearTimeout(_moduleSaveTimer);
|
|
4419
|
+
_moduleSaveTimer = setTimeout(async () => {
|
|
4420
|
+
_showModuleSaveStatus('saving');
|
|
4421
|
+
try {
|
|
4422
|
+
// Collect metadata
|
|
4423
|
+
const meta = {};
|
|
4424
|
+
document.querySelectorAll('#module-meta-form [data-field]').forEach(el => {
|
|
4425
|
+
const field = el.dataset.field;
|
|
4426
|
+
let val;
|
|
4427
|
+
if (field === 'preferred_port') {
|
|
4428
|
+
val = el.value === '' ? null : parseInt(el.value);
|
|
4429
|
+
} else if (field === 'monitor') {
|
|
4430
|
+
val = el.value === '' ? null : el.value === 'true';
|
|
4431
|
+
} else {
|
|
4432
|
+
val = el.value || null;
|
|
4433
|
+
}
|
|
4434
|
+
if (val !== null) meta[field] = val;
|
|
4435
|
+
});
|
|
4436
|
+
|
|
4437
|
+
// Save metadata
|
|
4438
|
+
const metaPromise = Object.keys(meta).length > 0
|
|
4439
|
+
? API.put(`/api/modules/${encodeURIComponent(_currentModuleName)}/metadata`, meta)
|
|
4440
|
+
: Promise.resolve();
|
|
4441
|
+
|
|
4442
|
+
// Save config if visible
|
|
4443
|
+
const configSection = document.getElementById('module-config-section');
|
|
4444
|
+
const configObj = _buildModuleConfigObject();
|
|
4445
|
+
const configPromise = configSection && !configSection.classList.contains('hidden') && configObj
|
|
4446
|
+
? API.put(`/api/modules/${encodeURIComponent(_currentModuleName)}/config`, configObj)
|
|
4447
|
+
: Promise.resolve();
|
|
4448
|
+
|
|
4449
|
+
await Promise.all([metaPromise, configPromise]);
|
|
4450
|
+
_showModuleSaveStatus('saved');
|
|
4451
|
+
} catch (err) {
|
|
4452
|
+
_showModuleSaveStatus('error');
|
|
4453
|
+
showToast('保存失败: ' + err.message, 'error');
|
|
4454
|
+
}
|
|
4455
|
+
}, 800);
|
|
4456
|
+
}
|
|
4457
|
+
|
|
4458
|
+
function _showModuleSaveStatus(status) {
|
|
4459
|
+
const el = document.getElementById('module-save-status');
|
|
4460
|
+
if (!el) return;
|
|
4461
|
+
el.classList.remove('saving', 'saved', 'error', 'fade-out');
|
|
4462
|
+
if (status === 'saving') {
|
|
4463
|
+
el.textContent = '保存中...';
|
|
4464
|
+
el.classList.add('saving');
|
|
4465
|
+
} else if (status === 'saved') {
|
|
4466
|
+
el.textContent = '已保存';
|
|
4467
|
+
el.classList.add('saved');
|
|
4468
|
+
setTimeout(() => {
|
|
4469
|
+
el.classList.add('fade-out');
|
|
4470
|
+
setTimeout(() => { el.textContent = ''; el.classList.remove('saved', 'fade-out'); }, 300);
|
|
4471
|
+
}, 2000);
|
|
4472
|
+
} else if (status === 'error') {
|
|
4473
|
+
el.textContent = '保存失败';
|
|
4474
|
+
el.classList.add('error');
|
|
4475
|
+
setTimeout(() => {
|
|
4476
|
+
el.classList.add('fade-out');
|
|
4477
|
+
setTimeout(() => { el.textContent = ''; el.classList.remove('error', 'fade-out'); }, 300);
|
|
4478
|
+
}, 3000);
|
|
4479
|
+
}
|
|
4480
|
+
}
|
|
4481
|
+
|
|
4205
4482
|
// ============================================================
|
|
4206
4483
|
// Initialization
|
|
4207
4484
|
// ============================================================
|
|
@@ -4663,6 +4940,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
|
4663
4940
|
if (dateEl) loadDevLogArchiveDetail(dateEl.dataset.date);
|
|
4664
4941
|
});
|
|
4665
4942
|
|
|
4943
|
+
// Modules page
|
|
4944
|
+
const btnModuleBack = document.getElementById('btn-module-back');
|
|
4945
|
+
if (btnModuleBack) btnModuleBack.addEventListener('click', loadModules);
|
|
4946
|
+
|
|
4947
|
+
// Module metadata form auto-save
|
|
4948
|
+
document.querySelectorAll('#module-meta-form [data-field]').forEach(el => {
|
|
4949
|
+
const event = (el.tagName === 'SELECT') ? 'change' : 'input';
|
|
4950
|
+
el.addEventListener(event, _debouncedSaveModule);
|
|
4951
|
+
});
|
|
4952
|
+
|
|
4666
4953
|
// Navigate to last active page (or dashboard)
|
|
4667
4954
|
navigate(localStorage.getItem('activePage') || 'dashboard');
|
|
4668
4955
|
|
package/kernel/event_hub.py
CHANGED
|
@@ -40,6 +40,9 @@ def _loads(raw: str):
|
|
|
40
40
|
|
|
41
41
|
QUEUE_MAXSIZE = 10000
|
|
42
42
|
|
|
43
|
+
# System events that are auto-broadcast to ALL connected modules (no subscription needed)
|
|
44
|
+
SYSTEM_EVENTS = {"module.offline", "module.ready", "module.shutdown"}
|
|
45
|
+
|
|
43
46
|
|
|
44
47
|
class EventHub:
|
|
45
48
|
|
|
@@ -193,10 +196,27 @@ class EventHub:
|
|
|
193
196
|
# ── Routing ──
|
|
194
197
|
|
|
195
198
|
def _route_event(self, sender_id: str, msg: dict, event_type: str, echo: bool):
|
|
196
|
-
"""Enqueue event to all matching subscribers' delivery queues.
|
|
199
|
+
"""Enqueue event to all matching subscribers' delivery queues.
|
|
200
|
+
System events (module.offline, module.ready, module.shutdown) are auto-broadcast
|
|
201
|
+
to ALL connected modules, regardless of subscription."""
|
|
197
202
|
e_parts = tuple(event_type.split("."))
|
|
198
203
|
raw = None # lazy serialization
|
|
199
204
|
|
|
205
|
+
# System events → broadcast to all connected modules
|
|
206
|
+
if event_type in SYSTEM_EVENTS:
|
|
207
|
+
for mid, queue in self._queues.items():
|
|
208
|
+
if mid == sender_id and not echo:
|
|
209
|
+
continue
|
|
210
|
+
if raw is None:
|
|
211
|
+
raw = _dumps(msg)
|
|
212
|
+
try:
|
|
213
|
+
queue.put_nowait(raw)
|
|
214
|
+
except asyncio.QueueFull:
|
|
215
|
+
pass
|
|
216
|
+
self._cnt_queued += 1
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
# Normal events → subscription-based routing
|
|
200
220
|
for mid, patterns in self.subscriptions.items():
|
|
201
221
|
if mid == sender_id and not echo:
|
|
202
222
|
continue
|
|
@@ -209,8 +229,6 @@ class EventHub:
|
|
|
209
229
|
try:
|
|
210
230
|
queue.put_nowait(raw)
|
|
211
231
|
except asyncio.QueueFull:
|
|
212
|
-
# Best-effort: drop if queue is full and we can't await
|
|
213
|
-
# (we're not in an async context in _route_event)
|
|
214
232
|
pass
|
|
215
233
|
self._cnt_queued += 1
|
|
216
234
|
break
|
package/kernel/registry_store.py
CHANGED
|
@@ -77,7 +77,7 @@ class RegistryStore:
|
|
|
77
77
|
|
|
78
78
|
# Strip action field — it's a request verb, not part of the registration payload
|
|
79
79
|
record = {k: v for k, v in data.items() if k != "action"}
|
|
80
|
-
record["status"] = "
|
|
80
|
+
record["status"] = "registered" # State machine: connected → registered (via register RPC)
|
|
81
81
|
record["registered_at"] = time.time()
|
|
82
82
|
self.modules[mid] = record
|
|
83
83
|
self.heartbeats[mid] = time.time()
|
|
@@ -94,21 +94,38 @@ class RegistryStore:
|
|
|
94
94
|
if module_id not in self.modules:
|
|
95
95
|
return {"ok": False, "error": "module not registered"}
|
|
96
96
|
self.heartbeats[module_id] = time.time()
|
|
97
|
-
|
|
97
|
+
# Don't change status — heartbeat just keeps alive, doesn't upgrade state
|
|
98
98
|
return {"ok": True}
|
|
99
99
|
|
|
100
|
+
def set_connected(self, module_id: str):
|
|
101
|
+
"""Mark a module as connected (WS established, not yet registered).
|
|
102
|
+
Only upgrades from offline; doesn't downgrade from registered/ready."""
|
|
103
|
+
if module_id in self.modules:
|
|
104
|
+
if self.modules[module_id].get("status") == "offline":
|
|
105
|
+
self.modules[module_id]["status"] = "connected"
|
|
106
|
+
|
|
107
|
+
def set_ready(self, module_id: str):
|
|
108
|
+
"""Mark a module as ready (received module.ready event)."""
|
|
109
|
+
if module_id in self.modules:
|
|
110
|
+
self.modules[module_id]["status"] = "ready"
|
|
111
|
+
|
|
100
112
|
def set_offline(self, module_id: str):
|
|
101
|
-
"""Mark a module as offline (called on WebSocket disconnect)."""
|
|
113
|
+
"""Mark a module as offline (called after debounce on WebSocket disconnect)."""
|
|
102
114
|
if module_id in self.modules:
|
|
103
115
|
self.modules[module_id]["status"] = "offline"
|
|
104
116
|
|
|
117
|
+
def is_ready(self, module_id: str) -> bool:
|
|
118
|
+
"""Check if a module is in ready state."""
|
|
119
|
+
mod = self.modules.get(module_id)
|
|
120
|
+
return mod is not None and mod.get("status") == "ready"
|
|
121
|
+
|
|
105
122
|
def check_ttl(self) -> list[str]:
|
|
106
123
|
"""Mark modules as offline if heartbeat expired. Returns list of newly-offline module_ids."""
|
|
107
124
|
now = time.time()
|
|
108
125
|
expired = []
|
|
109
126
|
for mid, last in list(self.heartbeats.items()):
|
|
110
127
|
if mid in self.modules and now - last > self.ttl:
|
|
111
|
-
if self.modules[mid].get("status")
|
|
128
|
+
if self.modules[mid].get("status") not in ("offline",):
|
|
112
129
|
self.modules[mid]["status"] = "offline"
|
|
113
130
|
expired.append(mid)
|
|
114
131
|
return expired
|
|
@@ -164,7 +181,7 @@ class RegistryStore:
|
|
|
164
181
|
"""
|
|
165
182
|
results = []
|
|
166
183
|
for mid, data in self.modules.items():
|
|
167
|
-
if data.get("status")
|
|
184
|
+
if data.get("status") not in ("registered", "ready"):
|
|
168
185
|
continue
|
|
169
186
|
if module and not fnmatch.fnmatch(mid, module):
|
|
170
187
|
continue
|
package/kernel/rpc_router.py
CHANGED
|
@@ -124,6 +124,12 @@ class RpcRouter:
|
|
|
124
124
|
if dot_idx > 0:
|
|
125
125
|
target = method[:dot_idx]
|
|
126
126
|
if target in self.connections:
|
|
127
|
+
# Check if target module is ready (state machine protection)
|
|
128
|
+
if not self.registry.is_ready(target):
|
|
129
|
+
await ws.send_text(_error_msg(
|
|
130
|
+
msg_id, MODULE_OFFLINE,
|
|
131
|
+
f"Module not ready: {target} (status: {self.registry.modules.get(target, {}).get('status', 'unknown')})"))
|
|
132
|
+
return
|
|
127
133
|
await self._forward(caller_id, ws, msg_id, target, method, params)
|
|
128
134
|
return
|
|
129
135
|
# Target not connected — check if registered but offline
|
|
@@ -247,10 +253,8 @@ class RpcRouter:
|
|
|
247
253
|
|
|
248
254
|
# When Launcher registers, Kernel publishes its own module.ready
|
|
249
255
|
if mid == "launcher" and self.kernel_server:
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
self.kernel_server._ready_published = True
|
|
253
|
-
print(f"[kernel] launcher registered → kernel module.ready published")
|
|
256
|
+
self.kernel_server.publish_ready()
|
|
257
|
+
print(f"[kernel] launcher registered → kernel module.ready published")
|
|
254
258
|
return result
|
|
255
259
|
|
|
256
260
|
async def _registry_deregister(self, caller_id: str, params: dict) -> dict:
|
|
@@ -303,6 +307,12 @@ class RpcRouter:
|
|
|
303
307
|
event_type = params.get("event", "")
|
|
304
308
|
data = params.get("data")
|
|
305
309
|
echo = params.get("echo", False)
|
|
310
|
+
|
|
311
|
+
# When a module publishes module.ready, update its status in registry
|
|
312
|
+
if event_type == "module.ready":
|
|
313
|
+
mid = (data or {}).get("module_id", caller_id)
|
|
314
|
+
self.registry.set_ready(mid)
|
|
315
|
+
|
|
306
316
|
return self.event_hub.publish_event(caller_id, event_id, event_type, data, echo)
|
|
307
317
|
|
|
308
318
|
async def _event_subscribe(self, caller_id: str, params: dict) -> dict:
|
|
@@ -334,7 +344,7 @@ class RpcRouter:
|
|
|
334
344
|
"module_count": len(self.registry.modules),
|
|
335
345
|
"online_count": sum(
|
|
336
346
|
1 for m in self.registry.modules.values()
|
|
337
|
-
if m.get("status")
|
|
347
|
+
if m.get("status") in ("registered", "ready")
|
|
338
348
|
),
|
|
339
349
|
"event_stats": eh_health.get("details", {}),
|
|
340
350
|
}
|