agent-comm-hub 0.7.0 → 0.8.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/README.md +1 -1
- package/agents/SKILL.md +5 -2
- package/assets/admin.html +526 -16
- package/lib/cli.js +207 -24
- package/lib/index.js +203 -22
- package/package.json +2 -2
package/assets/admin.html
CHANGED
|
@@ -135,11 +135,82 @@
|
|
|
135
135
|
.status[data-state="err"] { color: var(--danger); }
|
|
136
136
|
.status[data-state="err"] .dot { background: var(--danger); }
|
|
137
137
|
@keyframes breathe { 50% { opacity: .45; } }
|
|
138
|
+
|
|
139
|
+
/* mode chip: local / lan / remote — tells the operator which trust model is live */
|
|
140
|
+
.mode-chip {
|
|
141
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
142
|
+
font-size: 11px; font-weight: 600; letter-spacing: .02em;
|
|
143
|
+
border: 1px solid var(--line-strong); border-radius: 999px;
|
|
144
|
+
padding: 2px 10px; color: var(--text-dim); background: var(--surface-2);
|
|
145
|
+
white-space: nowrap;
|
|
146
|
+
}
|
|
147
|
+
.mode-chip[data-mode="local"] { color: var(--ok); border-color: var(--ok-line); background: var(--ok-wash); }
|
|
148
|
+
.mode-chip[data-mode="remote"] { color: var(--accent); border-color: var(--accent-line); background: var(--accent-wash); }
|
|
149
|
+
.mode-chip[data-mode="limited"] { color: var(--warn); border-color: var(--warn-line); background: var(--warn-wash); }
|
|
150
|
+
.mode-chip[data-mode="offline"] { color: var(--danger); border-color: var(--danger-line); background: var(--danger-wash); }
|
|
151
|
+
|
|
152
|
+
/* identity / multi-user onboarding panel */
|
|
153
|
+
.id-map {
|
|
154
|
+
display: grid; gap: 8px;
|
|
155
|
+
padding: 12px 16px; border-bottom: 1px solid var(--line);
|
|
156
|
+
background: var(--surface-2);
|
|
157
|
+
}
|
|
158
|
+
.id-map .flow {
|
|
159
|
+
display: grid; grid-template-columns: 1fr auto 1fr auto 1fr; gap: 8px; align-items: center;
|
|
160
|
+
font-size: 11.5px; color: var(--text-dim);
|
|
161
|
+
}
|
|
162
|
+
.id-map .node {
|
|
163
|
+
border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
|
|
164
|
+
background: var(--bg); padding: 8px 10px; text-align: center; min-width: 0;
|
|
165
|
+
}
|
|
166
|
+
.id-map .node b { display: block; color: var(--text); font-size: 12px; font-weight: 600; }
|
|
167
|
+
.id-map .node span { color: var(--text-faint); font-size: 10.5px; }
|
|
168
|
+
.id-map .arrow { color: var(--text-faint); font-size: 14px; text-align: center; }
|
|
169
|
+
.id-map .note { font-size: 11px; color: var(--text-faint); line-height: 1.5; }
|
|
170
|
+
.id-map .note b { color: var(--text-dim); font-weight: 600; }
|
|
171
|
+
.id-map .note code {
|
|
172
|
+
font-family: var(--font-mono); font-size: 10.5px;
|
|
173
|
+
background: var(--surface-3); border-radius: 3px; padding: 0 4px;
|
|
174
|
+
color: var(--text-dim);
|
|
175
|
+
}
|
|
176
|
+
/* tighten identity flow on mid widths */
|
|
177
|
+
@media (max-width: 1100px) {
|
|
178
|
+
.id-map .flow { grid-template-columns: 1fr; gap: 6px; }
|
|
179
|
+
.id-map .arrow { transform: rotate(90deg); }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
.invite-grid {
|
|
183
|
+
display: grid; grid-template-columns: minmax(120px, 1fr) minmax(160px, 1.4fr) minmax(90px, 120px) auto; gap: 8px;
|
|
184
|
+
padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--surface-2);
|
|
185
|
+
}
|
|
186
|
+
@media (max-width: 980px) { .invite-grid { grid-template-columns: 1fr 1fr; } }
|
|
187
|
+
.invite-grid input, .invite-grid select { width: 100%; }
|
|
188
|
+
|
|
189
|
+
.snippet {
|
|
190
|
+
margin: 0; padding: 12px 16px;
|
|
191
|
+
background: var(--bg); border-top: 1px solid var(--line);
|
|
192
|
+
font: 11px/1.55 var(--font-mono); color: var(--text-dim);
|
|
193
|
+
white-space: pre-wrap; word-break: break-all; max-height: 220px; overflow: auto;
|
|
194
|
+
}
|
|
195
|
+
.snippet.hidden { display: none; }
|
|
196
|
+
.snippet-bar {
|
|
197
|
+
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
|
198
|
+
padding: 8px 16px; border-top: 1px solid var(--line); background: var(--surface-2);
|
|
199
|
+
}
|
|
200
|
+
.snippet-bar.hidden { display: none; }
|
|
201
|
+
.snippet-bar .hint { font-size: 11px; color: var(--text-faint); flex: 1; min-width: 0; }
|
|
202
|
+
|
|
203
|
+
.ops-strip {
|
|
204
|
+
display: flex; gap: 10px; flex-wrap: wrap; align-items: baseline;
|
|
205
|
+
padding: 8px 16px; border-bottom: 1px solid var(--line);
|
|
206
|
+
font-size: 11px; color: var(--text-faint);
|
|
207
|
+
}
|
|
208
|
+
.ops-strip b { color: var(--text-dim); font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
138
209
|
.topbar .field { display: flex; align-items: center; gap: 6px; }
|
|
139
210
|
.topbar .field label { font-size: 11px; color: var(--text-faint); }
|
|
140
211
|
.topbar .field input { min-height: 28px; padding: 4px 8px; font-size: 12px; }
|
|
141
|
-
#token { width:
|
|
142
|
-
#mpath { width:
|
|
212
|
+
#token { width: 220px; }
|
|
213
|
+
#mpath { width: min(320px, 42vw); }
|
|
143
214
|
.spacer { flex: 1; }
|
|
144
215
|
|
|
145
216
|
/* ── stats strip: plain numbers + labels, quiet hover (anchors to panels) ── */
|
|
@@ -326,6 +397,56 @@
|
|
|
326
397
|
.toast.warn { border-left-color: var(--warn); }
|
|
327
398
|
@keyframes slide-in { from { opacity: 0; transform: translateX(14px); } }
|
|
328
399
|
|
|
400
|
+
/* ── inline send box (replaces window.prompt) ── */
|
|
401
|
+
.send-box {
|
|
402
|
+
flex: 1 1 100%;
|
|
403
|
+
display: flex; gap: 8px; align-items: flex-end;
|
|
404
|
+
padding: 10px 12px; margin-top: 6px;
|
|
405
|
+
background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius-sm);
|
|
406
|
+
animation: row-in .16s ease-out;
|
|
407
|
+
}
|
|
408
|
+
.send-box textarea {
|
|
409
|
+
flex: 1; min-width: 0; min-height: 64px; resize: vertical;
|
|
410
|
+
background: var(--bg); color: var(--text);
|
|
411
|
+
border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
|
|
412
|
+
padding: 8px 10px; font: 12px/1.5 var(--font-ui);
|
|
413
|
+
}
|
|
414
|
+
.send-box textarea:focus {
|
|
415
|
+
outline: none; border-color: var(--accent);
|
|
416
|
+
box-shadow: 0 0 0 2px var(--accent-wash);
|
|
417
|
+
}
|
|
418
|
+
.send-box .col { display: flex; flex-direction: column; gap: 6px; }
|
|
419
|
+
|
|
420
|
+
/* ── confirm dialog (replaces window.prompt for destructive / input) ── */
|
|
421
|
+
.dialog-backdrop {
|
|
422
|
+
position: fixed; inset: 0; z-index: 40;
|
|
423
|
+
background: rgba(0, 0, 0, .55);
|
|
424
|
+
display: grid; place-items: center;
|
|
425
|
+
animation: fade-in .12s ease-out;
|
|
426
|
+
}
|
|
427
|
+
.dialog-backdrop.hidden { display: none; }
|
|
428
|
+
@keyframes fade-in { from { opacity: 0; } }
|
|
429
|
+
.dialog {
|
|
430
|
+
width: min(420px, calc(100vw - 32px));
|
|
431
|
+
background: var(--surface); border: 1px solid var(--line-strong);
|
|
432
|
+
border-radius: var(--radius); box-shadow: var(--shadow-toast);
|
|
433
|
+
padding: 16px; display: grid; gap: 12px;
|
|
434
|
+
animation: slide-in .16s ease-out;
|
|
435
|
+
}
|
|
436
|
+
.dialog h3 { margin: 0; font-size: 13px; font-weight: 600; }
|
|
437
|
+
.dialog p { margin: 0; font-size: 12px; color: var(--text-dim); line-height: 1.5; }
|
|
438
|
+
.dialog input, .dialog textarea {
|
|
439
|
+
width: 100%; background: var(--bg); color: var(--text);
|
|
440
|
+
border: 1px solid var(--line-strong); border-radius: var(--radius-sm);
|
|
441
|
+
padding: 8px 10px; font: 12px/1.5 var(--font-ui);
|
|
442
|
+
}
|
|
443
|
+
.dialog textarea { min-height: 96px; resize: vertical; }
|
|
444
|
+
.dialog input:focus, .dialog textarea:focus {
|
|
445
|
+
outline: none; border-color: var(--accent);
|
|
446
|
+
box-shadow: 0 0 0 2px var(--accent-wash);
|
|
447
|
+
}
|
|
448
|
+
.dialog .row { display: flex; gap: 8px; justify-content: flex-end; }
|
|
449
|
+
|
|
329
450
|
/* ── responsive / motion ────────────────────────────────────────────── */
|
|
330
451
|
@media (max-width: 980px) {
|
|
331
452
|
.workspace { grid-template-columns: 1fr; }
|
|
@@ -354,14 +475,15 @@
|
|
|
354
475
|
</span>
|
|
355
476
|
</div>
|
|
356
477
|
<span class="status" id="conn" data-state="off" aria-live="polite"><span class="dot"></span><span id="conn-text" data-i18n="statusOff">未连接</span></span>
|
|
478
|
+
<span class="mode-chip" id="mode-chip" data-mode="unknown" title="运行形态:本机 / 远程鉴权 / 无管理权限">—</span>
|
|
357
479
|
<span class="spacer"></span>
|
|
358
480
|
<div class="field">
|
|
359
481
|
<label for="token" data-i18n="fieldToken">token</label>
|
|
360
482
|
<input id="token" type="password" placeholder="manager token" autocomplete="off">
|
|
361
483
|
</div>
|
|
362
484
|
<div class="field">
|
|
363
|
-
<label for="mpath" data-i18n="fieldMcp">
|
|
364
|
-
<input id="mpath" value="/mcp" spellcheck="false" aria-label="MCP
|
|
485
|
+
<label for="mpath" data-i18n="fieldMcp">endpoint</label>
|
|
486
|
+
<input id="mpath" value="/mcp" spellcheck="false" aria-label="MCP endpoint" data-i18n-ph="fieldMcpPh" placeholder="/mcp 或 http://127.0.0.1:18764/mcp">
|
|
365
487
|
</div>
|
|
366
488
|
<button id="lang" class="ghost" data-i18n-title="langTitle" title="切换语言">中 / EN</button>
|
|
367
489
|
<button id="reload" class="primary" data-i18n="btnReload">重新连接</button>
|
|
@@ -383,6 +505,44 @@
|
|
|
383
505
|
</nav>
|
|
384
506
|
|
|
385
507
|
<main class="workspace">
|
|
508
|
+
<!-- identity: multi-user same-agent model + invite → MCP config snippet -->
|
|
509
|
+
<section class="panel" id="panel-identity">
|
|
510
|
+
<header>
|
|
511
|
+
<h2 data-i18n="panelIdentity">身份与接入</h2>
|
|
512
|
+
<span class="hint" id="id-hint" data-i18n="identityHint">一人一 token = 一个 peer,同名 agent 也互不串台</span>
|
|
513
|
+
<span class="spacer"></span>
|
|
514
|
+
<span class="hint" id="ops-health"></span>
|
|
515
|
+
</header>
|
|
516
|
+
<div class="id-map" aria-label="identity mapping">
|
|
517
|
+
<div class="flow">
|
|
518
|
+
<div class="node"><b data-i18n="idPerson">人 / 机器</b><span>A · B · C</span></div>
|
|
519
|
+
<div class="arrow" aria-hidden="true">→</div>
|
|
520
|
+
<div class="node"><b data-i18n="idToken">专属 token</b><span>zhangsan / lisi / wangwu</span></div>
|
|
521
|
+
<div class="arrow" aria-hidden="true">→</div>
|
|
522
|
+
<div class="node"><b data-i18n="idPeer">独立 peer</b><span>kimi-code × 3 · 信箱隔离</span></div>
|
|
523
|
+
</div>
|
|
524
|
+
<div class="note" data-i18n-html="identityNote">
|
|
525
|
+
<b>规则:</b>token 决定 peer,不是 clientInfo 名。A 发给 B 时 C 收不到;全员用 <code>to:"all"</code>,小圈子用群组。
|
|
526
|
+
同一人多 agent 建议 <code>人.agent</code>(如 <code>zhangsan.kimi</code>)。
|
|
527
|
+
</div>
|
|
528
|
+
</div>
|
|
529
|
+
<form class="invite-grid" id="invite-form">
|
|
530
|
+
<input name="owner" data-i18n-ph="inviteOwner" placeholder="归属人(张三)" required spellcheck="false" aria-label="owner">
|
|
531
|
+
<input name="peer" data-i18n-ph="invitePeer" placeholder="peer id(zhangsan 或 zhangsan.kimi)" required spellcheck="false" aria-label="peer id">
|
|
532
|
+
<select name="role" aria-label="role">
|
|
533
|
+
<option value="agent">agent</option>
|
|
534
|
+
<option value="manager">manager</option>
|
|
535
|
+
</select>
|
|
536
|
+
<button class="primary" type="submit" data-i18n="inviteIssue">签发并生成配置</button>
|
|
537
|
+
</form>
|
|
538
|
+
<div class="snippet-bar hidden" id="snippet-bar">
|
|
539
|
+
<span class="hint" id="snippet-hint"></span>
|
|
540
|
+
<button class="ghost" id="snippet-copy" data-i18n="snippetCopy">复制 MCP 配置</button>
|
|
541
|
+
<button class="ghost" id="snippet-hide" data-i18n="snippetHide">收起</button>
|
|
542
|
+
</div>
|
|
543
|
+
<pre class="snippet hidden" id="snippet" tabindex="0"></pre>
|
|
544
|
+
</section>
|
|
545
|
+
|
|
386
546
|
<!-- main: members & tokens -->
|
|
387
547
|
<section class="panel" id="panel-tokens">
|
|
388
548
|
<header>
|
|
@@ -473,6 +633,19 @@
|
|
|
473
633
|
|
|
474
634
|
<div class="toasts" id="toasts" aria-live="polite"></div>
|
|
475
635
|
|
|
636
|
+
<!-- in-page dialog (replaces native prompt/confirm) -->
|
|
637
|
+
<div class="dialog-backdrop hidden" id="dialog-backdrop" role="presentation">
|
|
638
|
+
<div class="dialog" role="dialog" aria-modal="true" aria-labelledby="dialog-title">
|
|
639
|
+
<h3 id="dialog-title"></h3>
|
|
640
|
+
<p id="dialog-body" hidden></p>
|
|
641
|
+
<textarea id="dialog-input" hidden rows="4"></textarea>
|
|
642
|
+
<div class="row">
|
|
643
|
+
<button type="button" id="dialog-cancel"></button>
|
|
644
|
+
<button type="button" class="primary" id="dialog-ok"></button>
|
|
645
|
+
</div>
|
|
646
|
+
</div>
|
|
647
|
+
</div>
|
|
648
|
+
|
|
476
649
|
<script>
|
|
477
650
|
'use strict'
|
|
478
651
|
/* ── i18n: tiny dictionary, zh/en; language persisted in localStorage ── */
|
|
@@ -485,7 +658,8 @@ const I18N = {
|
|
|
485
658
|
statusOn: '已连接',
|
|
486
659
|
statusErr: '连接异常',
|
|
487
660
|
fieldToken: 'token',
|
|
488
|
-
fieldMcp: '
|
|
661
|
+
fieldMcp: 'endpoint',
|
|
662
|
+
fieldMcpPh: '/mcp 或 http://127.0.0.1:18764/mcp',
|
|
489
663
|
btnReload: '重新连接',
|
|
490
664
|
btnReveal: '明/打码',
|
|
491
665
|
btnRevealTitle: '切换 明文/打码 显示',
|
|
@@ -498,8 +672,29 @@ const I18N = {
|
|
|
498
672
|
statTokens: '已签发令牌',
|
|
499
673
|
statGroups: '群组',
|
|
500
674
|
statLog: '消息尾迹(30 条)',
|
|
675
|
+
panelIdentity: '身份与接入',
|
|
501
676
|
panelTokens: '成员与令牌',
|
|
502
677
|
panelRoster: '花名册',
|
|
678
|
+
identityHint: '一人一 token = 一个 peer,同名 agent 也互不串台',
|
|
679
|
+
identityNote: '<b>规则:</b>token 决定 peer,不是 clientInfo 名。A 发给 B 时 C 收不到;全员用 <code>to:"all"</code>,小圈子用群组。同一人多 agent 建议 <code>人.agent</code>(如 <code>zhangsan.kimi</code>)。',
|
|
680
|
+
idPerson: '人 / 机器',
|
|
681
|
+
idToken: '专属 token',
|
|
682
|
+
idPeer: '独立 peer',
|
|
683
|
+
inviteOwner: '归属人(张三)',
|
|
684
|
+
invitePeer: 'peer id(zhangsan 或 zhangsan.kimi)',
|
|
685
|
+
inviteIssue: '签发并生成配置',
|
|
686
|
+
snippetCopy: '复制 MCP 配置',
|
|
687
|
+
snippetHide: '收起',
|
|
688
|
+
snippetHint: '把这段配置发给 {peer};token 只显示这一次',
|
|
689
|
+
snippetCopied: '已复制 {peer} 的 MCP 配置',
|
|
690
|
+
modeLocal: '本机',
|
|
691
|
+
modeRemote: '远程鉴权',
|
|
692
|
+
modeLimited: '只读(无 manager)',
|
|
693
|
+
modeUnknown: '检测中…',
|
|
694
|
+
modeOffline: '未连接',
|
|
695
|
+
errEndpoint: '无法访问 MCP 端点 {url} · 若本页是静态打开,请填 hub 完整地址(默认 http://127.0.0.1:18764/mcp)',
|
|
696
|
+
errHttp: 'HTTP {status} · {url}',
|
|
697
|
+
opsHealth: '队列上限 {queue} · 历史 {hist} · peer {n}',
|
|
503
698
|
panelGroups: '群组',
|
|
504
699
|
panelLog: '消息尾迹',
|
|
505
700
|
groupsHint: '频道内互发 · 离线成员从历史补读',
|
|
@@ -558,8 +753,11 @@ const I18N = {
|
|
|
558
753
|
groupDeleted: '已删除 #{name}',
|
|
559
754
|
groupDelNote: '删除群 <b>{name}</b>?(消息历史保留)',
|
|
560
755
|
groupDelYes: '确认删除',
|
|
561
|
-
groupSend: '发到 #{name}
|
|
756
|
+
groupSend: '发到 #{name}',
|
|
562
757
|
groupSent: '已发送到 #{name}',
|
|
758
|
+
groupSendPh: '输入要发到该群组的消息…',
|
|
759
|
+
dialogOk: '确定',
|
|
760
|
+
dialogCancel: '取消',
|
|
563
761
|
groupRemoved: '已移除 {peer}',
|
|
564
762
|
groupRemoveTitle: '移除 {peer}',
|
|
565
763
|
groupAdded: '已添加 {peer} → #{group}',
|
|
@@ -582,7 +780,8 @@ const I18N = {
|
|
|
582
780
|
statusOn: 'connected',
|
|
583
781
|
statusErr: 'connection error',
|
|
584
782
|
fieldToken: 'token',
|
|
585
|
-
fieldMcp: '
|
|
783
|
+
fieldMcp: 'endpoint',
|
|
784
|
+
fieldMcpPh: '/mcp or http://127.0.0.1:18764/mcp',
|
|
586
785
|
btnReload: 'Reconnect',
|
|
587
786
|
btnReveal: 'show/hide',
|
|
588
787
|
btnRevealTitle: 'Toggle plain/masked tokens',
|
|
@@ -595,8 +794,29 @@ const I18N = {
|
|
|
595
794
|
statTokens: 'Tokens issued',
|
|
596
795
|
statGroups: 'Groups',
|
|
597
796
|
statLog: 'Message tail (30)',
|
|
797
|
+
panelIdentity: 'Identity & Onboarding',
|
|
598
798
|
panelTokens: 'Members & Tokens',
|
|
599
799
|
panelRoster: 'Roster',
|
|
800
|
+
identityHint: 'One person = one token = one peer · same client name stays isolated',
|
|
801
|
+
identityNote: '<b>Rule:</b> the token decides the peer, not clientInfo. A→B never lands in C\'s mailbox; use <code>to:"all"</code> for everyone, groups for a subset. Multi-agent per person: <code>person.agent</code> (e.g. <code>zhangsan.kimi</code>).',
|
|
802
|
+
idPerson: 'Person / machine',
|
|
803
|
+
idToken: 'Dedicated token',
|
|
804
|
+
idPeer: 'Isolated peer',
|
|
805
|
+
inviteOwner: 'Owner (Zhang San)',
|
|
806
|
+
invitePeer: 'peer id (zhangsan or zhangsan.kimi)',
|
|
807
|
+
inviteIssue: 'Issue + config',
|
|
808
|
+
snippetCopy: 'Copy MCP config',
|
|
809
|
+
snippetHide: 'Hide',
|
|
810
|
+
snippetHint: 'Send this config to {peer}; the token is shown only once',
|
|
811
|
+
snippetCopied: 'copied MCP config for {peer}',
|
|
812
|
+
modeLocal: 'Local',
|
|
813
|
+
modeRemote: 'Remote auth',
|
|
814
|
+
modeLimited: 'Read-only (no manager)',
|
|
815
|
+
modeUnknown: 'detecting…',
|
|
816
|
+
modeOffline: 'Offline',
|
|
817
|
+
errEndpoint: 'Cannot reach MCP endpoint {url} · if this page was opened as a static file, set the full hub URL (default http://127.0.0.1:18764/mcp)',
|
|
818
|
+
errHttp: 'HTTP {status} · {url}',
|
|
819
|
+
opsHealth: 'queue {queue} · history {hist} · peers {n}',
|
|
600
820
|
panelGroups: 'Groups',
|
|
601
821
|
panelLog: 'Message Tail',
|
|
602
822
|
groupsHint: 'Channel broadcast · offline members read back from history',
|
|
@@ -655,8 +875,11 @@ const I18N = {
|
|
|
655
875
|
groupDeleted: 'deleted #{name}',
|
|
656
876
|
groupDelNote: 'Delete group <b>{name}</b>? (message history is kept)',
|
|
657
877
|
groupDelYes: 'Delete',
|
|
658
|
-
groupSend: 'Send to #{name}
|
|
878
|
+
groupSend: 'Send to #{name}',
|
|
659
879
|
groupSent: 'sent to #{name}',
|
|
880
|
+
groupSendPh: 'Message to this group…',
|
|
881
|
+
dialogOk: 'OK',
|
|
882
|
+
dialogCancel: 'Cancel',
|
|
660
883
|
groupRemoved: 'removed {peer}',
|
|
661
884
|
groupRemoveTitle: 'Remove {peer}',
|
|
662
885
|
groupAdded: 'added {peer} → #{group}',
|
|
@@ -683,10 +906,12 @@ const t = (key, vars) => {
|
|
|
683
906
|
if (vars) for (const [k, v] of Object.entries(vars)) s = s.split('{' + k + '}').join(String(v))
|
|
684
907
|
return s
|
|
685
908
|
}
|
|
686
|
-
/* Apply keys to static DOM: [data-i18n] text, [data-i18n-
|
|
909
|
+
/* Apply keys to static DOM: [data-i18n] text, [data-i18n-html] rich text,
|
|
910
|
+
[data-i18n-ph] placeholder. Rich keys are trusted UI copy (no user input). */
|
|
687
911
|
function applyI18n() {
|
|
688
912
|
document.title = t('title')
|
|
689
913
|
document.querySelectorAll('[data-i18n]').forEach((el) => { el.textContent = t(el.dataset.i18n) })
|
|
914
|
+
document.querySelectorAll('[data-i18n-html]').forEach((el) => { el.innerHTML = t(el.dataset.i18nHtml) })
|
|
690
915
|
document.querySelectorAll('[data-i18n-ph]').forEach((el) => { el.placeholder = t(el.dataset.i18nPh) })
|
|
691
916
|
document.querySelectorAll('[data-i18n-title]').forEach((el) => { el.title = t(el.dataset.i18nTitle) })
|
|
692
917
|
}
|
|
@@ -694,7 +919,35 @@ document.querySelector('#lang').textContent = t('langToggle')
|
|
|
694
919
|
|
|
695
920
|
const $ = (id) => document.getElementById(id)
|
|
696
921
|
const token = () => $('token').value.trim()
|
|
697
|
-
|
|
922
|
+
|
|
923
|
+
/** True when this page was opened as a static file (assets/admin.html on
|
|
924
|
+
* Live Server / file://) rather than served by the hub at /admin. In that
|
|
925
|
+
* case location.origin is NOT the hub — default to loopback:18764. */
|
|
926
|
+
function pageLooksStatic() {
|
|
927
|
+
const p = location.pathname
|
|
928
|
+
return /assets\//i.test(p) || /\.html?$/i.test(p)
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const DEFAULT_HUB = 'http://127.0.0.1:18764'
|
|
932
|
+
|
|
933
|
+
function endpoint() {
|
|
934
|
+
const raw = ($('mpath').value.trim() || '/mcp')
|
|
935
|
+
// Absolute URL wins.
|
|
936
|
+
if (/^https?:\/\//i.test(raw)) return raw.replace(/\/+$/, '')
|
|
937
|
+
// host[:port][/path] without a leading slash → treat as host root + path.
|
|
938
|
+
if (/^[a-z0-9._-]+(:\d+)?(\/|$)/i.test(raw) && !raw.startsWith('/')) {
|
|
939
|
+
const scheme = location.protocol === 'https:' && !pageLooksStatic() ? 'https://' : 'http://'
|
|
940
|
+
return (scheme + raw).replace(/\/+$/, '')
|
|
941
|
+
}
|
|
942
|
+
const path = raw.startsWith('/') ? raw : '/' + raw
|
|
943
|
+
if (pageLooksStatic()) return DEFAULT_HUB + path
|
|
944
|
+
return location.origin + path
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/** Base for /admin/api/* (strip the /mcp suffix from the MCP endpoint). */
|
|
948
|
+
function apiBase() {
|
|
949
|
+
return endpoint().replace(/\/mcp\/?$/i, '')
|
|
950
|
+
}
|
|
698
951
|
|
|
699
952
|
let sid = null
|
|
700
953
|
async function rpc(method, params) {
|
|
@@ -706,13 +959,21 @@ async function rpc(method, params) {
|
|
|
706
959
|
const isNotification = method.startsWith('notifications/')
|
|
707
960
|
const body = { jsonrpc: '2.0', method, params }
|
|
708
961
|
if (!isNotification) body.id = Date.now() % 1e6
|
|
709
|
-
|
|
962
|
+
let res
|
|
963
|
+
try {
|
|
964
|
+
res = await fetch(endpoint(), { method: 'POST', headers, body: JSON.stringify(body) })
|
|
965
|
+
} catch {
|
|
966
|
+
throw new Error(t('errEndpoint', { url: endpoint() }))
|
|
967
|
+
}
|
|
710
968
|
if (res.status === 401) throw new Error(t('errUnauth'))
|
|
969
|
+
// Static file servers (Live Server :5500, python -m http.server) answer
|
|
970
|
+
// POST /mcp with 405 — surface the real hub URL instead of a bare code.
|
|
971
|
+
if (res.status === 405) throw new Error(t('errHttp', { status: 405, url: endpoint() }))
|
|
711
972
|
const json = isNotification ? null : await res.json().catch(() => null)
|
|
712
973
|
const newSid = res.headers.get('mcp-session-id')
|
|
713
974
|
if (newSid) sid = newSid
|
|
714
975
|
if (isNotification) return {}
|
|
715
|
-
if (!json || json.error) throw new Error(json?.error?.message ?? ('
|
|
976
|
+
if (!json || json.error) throw new Error(json?.error?.message ?? t('errHttp', { status: res.status, url: endpoint() }))
|
|
716
977
|
const parsed = JSON.parse(json.result?.content?.[0]?.text ?? '{}')
|
|
717
978
|
if (json.result?.isError) throw new Error(parsed.error ?? 'tool error')
|
|
718
979
|
return parsed
|
|
@@ -727,6 +988,46 @@ async function ensureSession() {
|
|
|
727
988
|
}
|
|
728
989
|
async function call(tool, args) { await ensureSession(); return rpc('tools/call', { name: tool, arguments: args ?? {} }) }
|
|
729
990
|
|
|
991
|
+
/* ── in-page prompt/confirm (never window.prompt — native dialogs break
|
|
992
|
+
the console's layout and block the event loop) ── */
|
|
993
|
+
function promptDialog({ title, body, placeholder, multiline = true, okLabel, cancelLabel }) {
|
|
994
|
+
return new Promise(resolve => {
|
|
995
|
+
const backdrop = $('dialog-backdrop')
|
|
996
|
+
const titleEl = $('dialog-title')
|
|
997
|
+
const bodyEl = $('dialog-body')
|
|
998
|
+
const input = $('dialog-input')
|
|
999
|
+
const ok = $('dialog-ok')
|
|
1000
|
+
const cancel = $('dialog-cancel')
|
|
1001
|
+
titleEl.textContent = title ?? ''
|
|
1002
|
+
if (body) { bodyEl.hidden = false; bodyEl.textContent = body } else { bodyEl.hidden = true; bodyEl.textContent = '' }
|
|
1003
|
+
input.hidden = false
|
|
1004
|
+
input.value = ''
|
|
1005
|
+
input.placeholder = placeholder ?? ''
|
|
1006
|
+
ok.textContent = okLabel ?? t('dialogOk')
|
|
1007
|
+
cancel.textContent = cancelLabel ?? t('dialogCancel')
|
|
1008
|
+
backdrop.classList.remove('hidden')
|
|
1009
|
+
const close = (value) => {
|
|
1010
|
+
backdrop.classList.add('hidden')
|
|
1011
|
+
ok.onclick = null
|
|
1012
|
+
cancel.onclick = null
|
|
1013
|
+
input.onkeydown = null
|
|
1014
|
+
backdrop.onclick = null
|
|
1015
|
+
resolve(value)
|
|
1016
|
+
}
|
|
1017
|
+
ok.onclick = () => close(input.value.trim())
|
|
1018
|
+
cancel.onclick = () => close(null)
|
|
1019
|
+
backdrop.onclick = (e) => { if (e.target === backdrop) close(null) }
|
|
1020
|
+
input.onkeydown = (e) => {
|
|
1021
|
+
if (e.key === 'Enter' && (multiline === false || e.metaKey || e.ctrlKey)) {
|
|
1022
|
+
e.preventDefault()
|
|
1023
|
+
close(input.value.trim())
|
|
1024
|
+
}
|
|
1025
|
+
if (e.key === 'Escape') { e.preventDefault(); close(null) }
|
|
1026
|
+
}
|
|
1027
|
+
queueMicrotask(() => input.focus())
|
|
1028
|
+
})
|
|
1029
|
+
}
|
|
1030
|
+
|
|
730
1031
|
/* ── toast feedback: four states (info/ok/warn/err), aria-live broadcast ── */
|
|
731
1032
|
function toast(msg, kind = '') {
|
|
732
1033
|
const el = document.createElement('div')
|
|
@@ -749,7 +1050,7 @@ async function adminApi(method, path, body) {
|
|
|
749
1050
|
const headers = {}
|
|
750
1051
|
if (!(body instanceof FormData) && body !== undefined) headers['Content-Type'] = 'application/json'
|
|
751
1052
|
if (token() !== '') headers.Authorization = 'Bearer ' + token()
|
|
752
|
-
const res = await fetch(
|
|
1053
|
+
const res = await fetch(apiBase() + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) })
|
|
753
1054
|
if (res.status === 401 || res.status === 403) throw new Error(t('errNeedManager'))
|
|
754
1055
|
const json = await res.json().catch(() => null)
|
|
755
1056
|
if (!json || json.error) throw new Error(json?.error ?? ('HTTP ' + res.status))
|
|
@@ -838,9 +1139,184 @@ $('tok-form').onsubmit = async (ev) => {
|
|
|
838
1139
|
revealTokens = true // reveal after issuing so the new token can be copied
|
|
839
1140
|
loadTokens()
|
|
840
1141
|
toast(t('tokenIssued', { peer: res.created.peer, role: res.created.role, token: res.created.token }), 'ok')
|
|
1142
|
+
showSnippet(res.created)
|
|
841
1143
|
ev.target.reset()
|
|
842
1144
|
} catch (e) { toast(String(e.message ?? e), 'err') }
|
|
843
1145
|
}
|
|
1146
|
+
|
|
1147
|
+
/* ── mode + identity onboarding ────────────────────────────────────────
|
|
1148
|
+
Local hub (no --auth-tokens): tokens API is absent / 403 → local mode,
|
|
1149
|
+
invite form stays visible but issuing fails with a clear toast.
|
|
1150
|
+
Remote hub with manager token: full invite → one-shot MCP config.
|
|
1151
|
+
Remote hub with agent token: limited (read-only console). */
|
|
1152
|
+
let hubMode = 'unknown' // local | remote | limited | offline | unknown
|
|
1153
|
+
function setMode(mode) {
|
|
1154
|
+
hubMode = mode
|
|
1155
|
+
const chip = $('mode-chip')
|
|
1156
|
+
chip.dataset.mode = mode
|
|
1157
|
+
chip.textContent = mode === 'local' ? t('modeLocal')
|
|
1158
|
+
: mode === 'remote' ? t('modeRemote')
|
|
1159
|
+
: mode === 'limited' ? t('modeLimited')
|
|
1160
|
+
: mode === 'offline' ? t('modeOffline')
|
|
1161
|
+
: t('modeUnknown')
|
|
1162
|
+
chip.title = mode === 'local'
|
|
1163
|
+
? 'Loopback trust model — no token table. Multi-user needs --auth-tokens on a server/LAN hub.'
|
|
1164
|
+
: mode === 'remote'
|
|
1165
|
+
? 'Remote auth active — each person/agent holds a dedicated token → dedicated peer mailbox.'
|
|
1166
|
+
: mode === 'limited'
|
|
1167
|
+
? 'Connected, but this token is not a manager — token issue/revoke is unavailable.'
|
|
1168
|
+
: mode === 'offline'
|
|
1169
|
+
? 'Hub unreachable — check the endpoint field (full URL if this page is static-hosted).'
|
|
1170
|
+
: 'Detecting…'
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
function mcpBaseUrl() {
|
|
1174
|
+
// Same origin as the admin page; path from the mcp field.
|
|
1175
|
+
return location.origin + ($('mpath').value.trim() || '/mcp')
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function buildMcpConfig(identity) {
|
|
1179
|
+
const url = mcpBaseUrl()
|
|
1180
|
+
const headers = { Authorization: 'Bearer ' + identity.token }
|
|
1181
|
+
// Multi-shape snippet: covers the majority of MCP clients in one paste.
|
|
1182
|
+
return [
|
|
1183
|
+
'# agent-comm-hub · peer: ' + identity.peer + (identity.owner ? ' (' + identity.owner + ')' : ''),
|
|
1184
|
+
'# 一人一枚 token;不要多人共用(共用=共享信箱)',
|
|
1185
|
+
'',
|
|
1186
|
+
'## Claude Code / generic HTTP MCP (.mcp.json)',
|
|
1187
|
+
JSON.stringify({
|
|
1188
|
+
mcpServers: {
|
|
1189
|
+
'agent-hub': {
|
|
1190
|
+
type: 'http',
|
|
1191
|
+
url,
|
|
1192
|
+
headers,
|
|
1193
|
+
},
|
|
1194
|
+
},
|
|
1195
|
+
}, null, 2),
|
|
1196
|
+
'',
|
|
1197
|
+
'## opencode (opencode.json → mcp)',
|
|
1198
|
+
JSON.stringify({
|
|
1199
|
+
mcp: {
|
|
1200
|
+
'agent-hub': {
|
|
1201
|
+
type: 'remote',
|
|
1202
|
+
url,
|
|
1203
|
+
headers,
|
|
1204
|
+
},
|
|
1205
|
+
},
|
|
1206
|
+
}, null, 2),
|
|
1207
|
+
'',
|
|
1208
|
+
'## curl 探测',
|
|
1209
|
+
'curl -sS -X POST ' + url + ' \\',
|
|
1210
|
+
" -H 'Content-Type: application/json' \\",
|
|
1211
|
+
" -H 'Accept: application/json, text/event-stream' \\",
|
|
1212
|
+
" -H 'Authorization: Bearer " + identity.token + "' \\",
|
|
1213
|
+
" -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},\"clientInfo\":{\"name\":\"probe\",\"version\":\"1\"}}}'",
|
|
1214
|
+
].join('\n')
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
let lastSnippetIdentity = null
|
|
1218
|
+
function showSnippet(identity) {
|
|
1219
|
+
lastSnippetIdentity = identity
|
|
1220
|
+
const bar = $('snippet-bar')
|
|
1221
|
+
const pre = $('snippet')
|
|
1222
|
+
bar.classList.remove('hidden')
|
|
1223
|
+
pre.classList.remove('hidden')
|
|
1224
|
+
$('snippet-hint').textContent = t('snippetHint', { peer: identity.peer })
|
|
1225
|
+
pre.textContent = buildMcpConfig(identity)
|
|
1226
|
+
pre.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
$('snippet-copy').onclick = async () => {
|
|
1230
|
+
if (!lastSnippetIdentity) return
|
|
1231
|
+
try {
|
|
1232
|
+
await navigator.clipboard.writeText(buildMcpConfig(lastSnippetIdentity))
|
|
1233
|
+
toast(t('snippetCopied', { peer: lastSnippetIdentity.peer }), 'ok')
|
|
1234
|
+
} catch (e) { toast(t('copyFail', { msg: String(e.message ?? e) }), 'err') }
|
|
1235
|
+
}
|
|
1236
|
+
$('snippet-hide').onclick = () => {
|
|
1237
|
+
$('snippet-bar').classList.add('hidden')
|
|
1238
|
+
$('snippet').classList.add('hidden')
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
/* Owner types a human name → suggest a peer slug (editable). */
|
|
1242
|
+
const inviteOwner = document.querySelector('#invite-form [name=owner]')
|
|
1243
|
+
const invitePeer = document.querySelector('#invite-form [name=peer]')
|
|
1244
|
+
let peerTouched = false
|
|
1245
|
+
invitePeer?.addEventListener('input', () => { peerTouched = true })
|
|
1246
|
+
inviteOwner?.addEventListener('input', () => {
|
|
1247
|
+
if (peerTouched) return
|
|
1248
|
+
const slug = String(inviteOwner.value || '')
|
|
1249
|
+
.trim()
|
|
1250
|
+
.toLowerCase()
|
|
1251
|
+
.replace(/\s+/g, '-')
|
|
1252
|
+
.replace(/[^a-z0-9._:-]+/g, '')
|
|
1253
|
+
invitePeer.value = slug
|
|
1254
|
+
})
|
|
1255
|
+
|
|
1256
|
+
$('invite-form').onsubmit = async (ev) => {
|
|
1257
|
+
ev.preventDefault()
|
|
1258
|
+
const f = new FormData(ev.target)
|
|
1259
|
+
const peer = String(f.get('peer') || '').trim()
|
|
1260
|
+
const owner = String(f.get('owner') || '').trim()
|
|
1261
|
+
const role = String(f.get('role') || 'agent')
|
|
1262
|
+
if (!peer) return
|
|
1263
|
+
try {
|
|
1264
|
+
const res = await adminApi('POST', '/admin/api/tokens/add', {
|
|
1265
|
+
peer,
|
|
1266
|
+
role,
|
|
1267
|
+
...(owner !== '' ? { owner } : {}),
|
|
1268
|
+
})
|
|
1269
|
+
revealTokens = true
|
|
1270
|
+
peerTouched = false
|
|
1271
|
+
loadTokens()
|
|
1272
|
+
toast(t('tokenIssued', { peer: res.created.peer, role: res.created.role, token: res.created.token }), 'ok')
|
|
1273
|
+
showSnippet(res.created)
|
|
1274
|
+
ev.target.reset()
|
|
1275
|
+
peerTouched = false
|
|
1276
|
+
} catch (e) {
|
|
1277
|
+
// Local mode / non-manager: surface a precise next step instead of a raw 403.
|
|
1278
|
+
const msg = String(e.message ?? e)
|
|
1279
|
+
if (hubMode === 'local' || /manager token required|401|403/i.test(msg)) {
|
|
1280
|
+
toast(t('emptyTokensRemote'), 'warn')
|
|
1281
|
+
} else {
|
|
1282
|
+
toast(msg, 'err')
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
async function detectMode() {
|
|
1288
|
+
try {
|
|
1289
|
+
await adminApi('GET', '/admin/api/tokens?reveal=0', undefined)
|
|
1290
|
+
setMode('remote')
|
|
1291
|
+
return
|
|
1292
|
+
} catch (e) {
|
|
1293
|
+
const msg = String(e?.message ?? e)
|
|
1294
|
+
// Network / wrong origin: never claim "limited" when the hub is unreachable.
|
|
1295
|
+
if (/Cannot reach|HTTP 405|Failed to fetch|NetworkError|ECONNREFUSED/i.test(msg)) {
|
|
1296
|
+
setMode('offline')
|
|
1297
|
+
return
|
|
1298
|
+
}
|
|
1299
|
+
if (/manager token required|unauthorized|401|403/i.test(msg)) {
|
|
1300
|
+
// Token present but not a manager, or remote with empty token.
|
|
1301
|
+
try {
|
|
1302
|
+
await call('bridge_status')
|
|
1303
|
+
setMode('limited')
|
|
1304
|
+
} catch (e2) {
|
|
1305
|
+
const m2 = String(e2?.message ?? e2)
|
|
1306
|
+
setMode(/Cannot reach|HTTP 405|Failed to fetch/i.test(m2) ? 'offline' : 'limited')
|
|
1307
|
+
}
|
|
1308
|
+
return
|
|
1309
|
+
}
|
|
1310
|
+
// API not present (local hub without --auth-tokens): status still works.
|
|
1311
|
+
try {
|
|
1312
|
+
await call('bridge_status')
|
|
1313
|
+
setMode('local')
|
|
1314
|
+
} catch (e2) {
|
|
1315
|
+
const m2 = String(e2?.message ?? e2)
|
|
1316
|
+
setMode(/Cannot reach|HTTP 405|Failed to fetch/i.test(m2) ? 'offline' : 'limited')
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
844
1320
|
$('tok-reveal').onclick = () => { revealTokens = !revealTokens; loadTokens() }
|
|
845
1321
|
$('tok-refresh').onclick = () => { pendingRevoke = null; loadTokens() }
|
|
846
1322
|
|
|
@@ -883,7 +1359,9 @@ function rosterRow(p) {
|
|
|
883
1359
|
const actions = document.createElement('div')
|
|
884
1360
|
actions.className = 'actions'
|
|
885
1361
|
actions.append(mkBtn(t('actionRename'), 'ghost', () => { editingId = p.id; confirmingId = null; renderRoster(lastPeers) }))
|
|
886
|
-
|
|
1362
|
+
// Offline peers stay registered until kicked or idle-GC'd — always offer
|
|
1363
|
+
// remove so a stuck offline identity can be cleared from the roster.
|
|
1364
|
+
actions.append(mkBtn(t('actionKick'), 'ghost danger', () => { confirmingId = p.id; editingId = null; renderRoster(lastPeers) }))
|
|
887
1365
|
|
|
888
1366
|
row.append(presence, who, chip, state, badge, actions)
|
|
889
1367
|
return row
|
|
@@ -1031,7 +1509,11 @@ function groupRow(g) {
|
|
|
1031
1509
|
renderGroups(lastGroups)
|
|
1032
1510
|
}))
|
|
1033
1511
|
actions.append(mkBtn(t('actionSend'), 'ghost', async () => {
|
|
1034
|
-
const text =
|
|
1512
|
+
const text = await promptDialog({
|
|
1513
|
+
title: t('groupSend', { name: g.name ?? g.id }),
|
|
1514
|
+
placeholder: t('groupSendPh'),
|
|
1515
|
+
multiline: true,
|
|
1516
|
+
})
|
|
1035
1517
|
if (!text) return
|
|
1036
1518
|
try { await call('bridge_group_send', { group: g.id, message: text }); toast(t('groupSent', { name: g.name ?? g.id }), 'ok') }
|
|
1037
1519
|
catch (e) { toast(String(e.message ?? e), 'err') }
|
|
@@ -1166,10 +1648,19 @@ async function refresh() {
|
|
|
1166
1648
|
const status = await call('bridge_status')
|
|
1167
1649
|
lastStatus = status
|
|
1168
1650
|
setConn('ok', t('statusOn'))
|
|
1651
|
+
// Recovered from a failed tick: re-probe tokens API for the real mode.
|
|
1652
|
+
if (hubMode === 'offline' || hubMode === 'unknown') void detectMode()
|
|
1169
1653
|
const bar = $('bar')
|
|
1170
1654
|
bar.className = ''
|
|
1171
1655
|
bar.textContent = t('stateRefreshEvery', { time: new Date().toLocaleTimeString(LANG === 'zh' ? 'zh-CN' : 'en-GB', { hour12: false }) })
|
|
1172
1656
|
if (!editingId && !confirmingId) renderRoster(status.peers ?? [])
|
|
1657
|
+
// Ops strip: capacity knobs the server operator actually needs at a glance.
|
|
1658
|
+
const n = (status.peers ?? []).length
|
|
1659
|
+
$('ops-health').textContent = t('opsHealth', {
|
|
1660
|
+
queue: status.maxQueue ?? '—',
|
|
1661
|
+
hist: status.historyLimit ?? '—',
|
|
1662
|
+
n,
|
|
1663
|
+
})
|
|
1173
1664
|
const groups = await call('bridge_group_list')
|
|
1174
1665
|
if (!pendingGroupDelete && expandedGroup === null) renderGroups(groups.groups ?? [])
|
|
1175
1666
|
const tail = await call('bridge_history', { peer: 'all', limit: 30 })
|
|
@@ -1177,11 +1668,16 @@ async function refresh() {
|
|
|
1177
1668
|
loadTokens()
|
|
1178
1669
|
} catch (e) {
|
|
1179
1670
|
setConn('err', t('statusErr'))
|
|
1671
|
+
setMode('offline')
|
|
1180
1672
|
const bar = $('bar')
|
|
1181
1673
|
bar.className = 'err'
|
|
1182
1674
|
// A hub restart invalidates the old session: next tick re-runs initialize.
|
|
1183
1675
|
sid = null
|
|
1184
1676
|
bar.textContent = String(e.message ?? e)
|
|
1677
|
+
// Don't leave the roster stuck on "connecting…" when the hub is down.
|
|
1678
|
+
if ($('roster').querySelector('.loading')) {
|
|
1679
|
+
$('roster').innerHTML = `<div class="empty"><b>${escapeHtml(t('statusErr'))}</b>${escapeHtml(String(e.message ?? e))}</div>`
|
|
1680
|
+
}
|
|
1185
1681
|
}
|
|
1186
1682
|
}
|
|
1187
1683
|
|
|
@@ -1197,10 +1693,22 @@ $('g-create').onsubmit = async (ev) => {
|
|
|
1197
1693
|
refresh()
|
|
1198
1694
|
}
|
|
1199
1695
|
$('refresh').onclick = () => { editingId = null; confirmingId = null; refresh() }
|
|
1200
|
-
$('reload').onclick = () => { sid = null; localStorage.setItem('hub-admin-token', token()); refresh() }
|
|
1696
|
+
$('reload').onclick = () => { sid = null; localStorage.setItem('hub-admin-token', token()); void detectMode(); refresh() }
|
|
1201
1697
|
$('lang').onclick = () => { localStorage.setItem('hub-admin-lang', LANG === 'zh' ? 'en' : 'zh'); location.reload() }
|
|
1202
1698
|
$('token').addEventListener('change', () => localStorage.setItem('hub-admin-token', token()))
|
|
1203
1699
|
$('token').value = localStorage.getItem('hub-admin-token') ?? ''
|
|
1700
|
+
// Static-hosted pages: seed the endpoint with the default hub URL once.
|
|
1701
|
+
if (pageLooksStatic() && !localStorage.getItem('hub-admin-endpoint')) {
|
|
1702
|
+
$('mpath').value = DEFAULT_HUB + '/mcp'
|
|
1703
|
+
localStorage.setItem('hub-admin-endpoint', $('mpath').value)
|
|
1704
|
+
} else {
|
|
1705
|
+
$('mpath').value = localStorage.getItem('hub-admin-endpoint') || $('mpath').value
|
|
1706
|
+
}
|
|
1707
|
+
$('mpath').addEventListener('change', () => {
|
|
1708
|
+
localStorage.setItem('hub-admin-endpoint', $('mpath').value.trim())
|
|
1709
|
+
sid = null
|
|
1710
|
+
void detectMode()
|
|
1711
|
+
})
|
|
1204
1712
|
|
|
1205
1713
|
/* ── material feedback: topbar gets heavier past the content edge
|
|
1206
1714
|
(rAF-throttled, interruptible transition) ── */
|
|
@@ -1227,7 +1735,9 @@ document.addEventListener('keydown', (e) => {
|
|
|
1227
1735
|
})
|
|
1228
1736
|
|
|
1229
1737
|
applyI18n()
|
|
1738
|
+
setMode('unknown')
|
|
1230
1739
|
setInterval(refresh, 3000)
|
|
1740
|
+
void detectMode()
|
|
1231
1741
|
refresh()
|
|
1232
1742
|
</script>
|
|
1233
1743
|
</body>
|