@jacksontian/kite-server 0.3.0 → 0.4.1

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/web/index.html CHANGED
@@ -4,6 +4,11 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
6
  <meta name="theme-color" content="#111827">
7
+ <script>
8
+ // 主题初始化(先于样式解析,避免先闪一下另一套配色):本地偏好 > 系统外观
9
+ document.documentElement.dataset.theme = localStorage.getItem('rw_theme')
10
+ || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
11
+ </script>
7
12
  <title>Kite 控制台</title>
8
13
  <!-- xterm 随包自托管(vendor/ 为锁版本的官方产物):不依赖 CDN,
9
14
  杜绝 CDN 投毒/HTTP 下被篡改注入窃取密钥的供应链风险 -->
@@ -13,6 +18,14 @@
13
18
  --bg: #111827; --panel: #1f2937; --line: #374151;
14
19
  --text: #e5e7eb; --dim: #9ca3af; --accent: #3b82f6;
15
20
  --ok: #22c55e; --err: #ef4444; --warn: #f59e0b;
21
+ --term-bg: #0b1220; --log-text: #a5f3fc;
22
+ }
23
+ /* light 主题:xterm 是 canvas 绘制读不到 CSS 变量,终端色由 JS 同步切 */
24
+ :root[data-theme='light'] {
25
+ --bg: #f4f6f9; --panel: #fff; --line: #d9dee7;
26
+ --text: #1f2937; --dim: #64748b; --accent: #2563eb;
27
+ --ok: #16a34a; --err: #dc2626; --warn: #d97706;
28
+ --term-bg: #fff; --log-text: #0e7490;
16
29
  }
17
30
  * { box-sizing: border-box; margin: 0; padding: 0; }
18
31
  body {
@@ -30,6 +43,10 @@
30
43
  #worker-status { font-size: 12px; padding: 4px 10px; border-radius: 999px; background: var(--panel); color: var(--dim); white-space: nowrap; }
31
44
  #worker-status.online { color: var(--ok); }
32
45
  #worker-status.offline { color: var(--err); }
46
+ #btn-theme {
47
+ flex: none; font-size: 12px; padding: 4px 10px; border-radius: 8px;
48
+ background: var(--panel); color: var(--dim); border: 1px solid var(--line); white-space: nowrap;
49
+ }
33
50
 
34
51
  /* 明文 HTTP 部署的风险提示(仅非 localhost 的 http: 环境显示) */
35
52
  #http-warn {
@@ -47,13 +64,6 @@
47
64
  textarea { min-height: 72px; resize: vertical; }
48
65
  input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); }
49
66
 
50
- .types { display: flex; gap: 8px; margin-top: 4px; }
51
- .types button {
52
- flex: 1; padding: 10px 0; border-radius: 8px; border: 1px solid var(--line);
53
- background: var(--bg); color: var(--dim); font-size: 13px;
54
- }
55
- .types button.active { border-color: var(--accent); color: var(--accent); background: rgba(59,130,246,.12); }
56
-
57
67
  .submit {
58
68
  width: 100%; margin-top: 14px; padding: 12px;
59
69
  background: var(--accent); color: #fff; border: none; border-radius: 10px;
@@ -73,21 +83,12 @@
73
83
  .b-canceled { background: rgba(156,163,175,.15); color: var(--dim); }
74
84
 
75
85
  .logs {
76
- margin-top: 8px; background: #0b1220; border-radius: 8px; padding: 8px;
86
+ margin-top: 8px; background: var(--term-bg); border-radius: 8px; padding: 8px;
77
87
  font-family: ui-monospace, Menlo, monospace; font-size: 11px; line-height: 1.6;
78
88
  max-height: 300px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;
79
- color: #a5f3fc; display: none;
89
+ color: var(--log-text); display: none;
80
90
  }
81
91
  .task.open .logs { display: block; }
82
- /* shell 视图:默认只露出命令回显 + 末尾 5 行 stdout,接近终端体验;
83
- 元信息/结果/更早输出要点「展开」才可见 */
84
- .term {
85
- margin-top: 8px; background: #0b1220; border-radius: 8px; padding: 8px;
86
- font-family: ui-monospace, Menlo, monospace; font-size: 11px; line-height: 1.6;
87
- white-space: pre-wrap; word-break: break-all; color: #a5f3fc; cursor: pointer;
88
- }
89
- .term .prompt { color: var(--ok); }
90
- .term .dimline { color: var(--dim); }
91
92
  .task .actions { margin-top: 8px; display: flex; gap: 8px; }
92
93
  .task .actions button {
93
94
  font-size: 12px; padding: 4px 12px; border-radius: 6px;
@@ -98,7 +99,7 @@
98
99
 
99
100
  /* 交互终端全屏覆盖层(pty 任务) */
100
101
  #term-overlay {
101
- position: fixed; inset: 0; z-index: 50; background: #0b1220;
102
+ position: fixed; inset: 0; z-index: 50; background: var(--term-bg);
102
103
  display: flex; flex-direction: column;
103
104
  padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom);
104
105
  }
@@ -106,7 +107,11 @@
106
107
  #term-head { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; }
107
108
  #term-title { font-size: 13px; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
108
109
  #term-close { font-size: 13px; padding: 4px 12px; border-radius: 6px; background: var(--panel); color: var(--text); border: 1px solid var(--line); }
109
- #term-body { flex: 1; margin: 0 8px; min-height: 0; }
110
+ /* position:relative + 绝对定位的 .term-pane:多个 pty 终端各占一层,
111
+ 同时只显示 active 的一个,避免多实例挂在同一容器里叠出来串台 */
112
+ #term-body { flex: 1; margin: 0 8px; min-height: 0; position: relative; }
113
+ .term-pane { position: absolute; inset: 0; display: none; }
114
+ .term-pane.active { display: block; }
110
115
  #term-bar { display: flex; align-items: center; gap: 6px; padding: 8px; }
111
116
  #term-bar button {
112
117
  min-width: 44px; padding: 9px 0; font-size: 13px; border-radius: 8px;
@@ -114,7 +119,6 @@
114
119
  }
115
120
  #term-status { flex: 1; font-size: 11px; color: var(--dim); text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
116
121
  #term-input { position: fixed; left: -9999px; top: 0; width: 1px; height: 1px; opacity: .01; border: none; }
117
- #type-bar button { font-size: 12px; }
118
122
  #auth-bar { display: flex; justify-content: space-between; align-items: center; cursor: pointer; }
119
123
  #auth-bar .title { font-size: 13px; color: var(--ok); }
120
124
  #auth-bar .edit { font-size: 12px; color: var(--dim); }
@@ -126,6 +130,24 @@
126
130
  }
127
131
  #btn-eye.on { color: var(--accent); border-color: var(--accent); }
128
132
  #btn-done { color: var(--ok); }
133
+ /* 两级视图:一级 Worker 列表 / 二级所选 Worker 详情 */
134
+ #back-row { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
135
+ #btn-back {
136
+ flex: none; padding: 8px 14px; border: 1px solid var(--line); border-radius: 8px;
137
+ background: var(--panel); color: var(--text); font-size: 14px;
138
+ }
139
+ #worker-title { font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
140
+ .worker-card {
141
+ background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
142
+ padding: 12px; margin-bottom: 10px;
143
+ }
144
+ .worker-card .wc-row1 { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
145
+ .worker-card .wc-name { font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
146
+ .worker-card .wc-meta { font-size: 11px; color: var(--dim); margin-top: 4px; line-height: 1.5; word-break: break-all; }
147
+ .w-online { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: rgba(34,197,94,.15); color: var(--ok); white-space: nowrap; }
148
+ .w-offline { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: rgba(239,68,68,.15); color: var(--err); white-space: nowrap; }
149
+ .empty-tip { color: var(--dim); font-size: 13px; text-align: center; padding: 28px 0; }
150
+
129
151
  @keyframes task-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
130
152
  .task.new { animation: task-in .25s ease; }
131
153
  #toast {
@@ -142,6 +164,7 @@
142
164
  <div class="header-right">
143
165
  <span id="conn-mode">连接中</span>
144
166
  <span id="worker-status">检测中…</span>
167
+ <button id="btn-theme" type="button" title="切换 浅色/深色 主题">浅色</button>
145
168
  </div>
146
169
  </header>
147
170
 
@@ -167,55 +190,34 @@
167
190
  </div>
168
191
  </div>
169
192
 
170
- <!-- 新建任务 -->
171
- <div class="panel">
172
- <div class="types" id="type-bar">
173
- <button data-type="agent" class="active">Agent</button>
174
- <button data-type="shell">Shell</button>
175
- <button data-type="pty">终端</button>
176
- <button data-type="script">脚本</button>
177
- <button data-type="chat">留言</button>
178
- </div>
193
+ <!-- 一级视图:Worker 列表,选中某个 Worker 进入二级详情 -->
194
+ <div id="view-workers">
195
+ <div id="worker-cards"></div>
196
+ <div id="workers-empty" class="empty-tip hidden">暂无已注册 Worker</div>
197
+ </div>
179
198
 
180
- <label>目标 Worker(任务在哪台机器执行)</label>
181
- <select id="f-worker"><option value="">任意在线 Worker</option></select>
199
+ <!-- 二级视图:所选 Worker 的新建任务与任务列表 -->
200
+ <div id="view-worker" class="hidden">
201
+ <div id="back-row">
202
+ <button id="btn-back" type="button">‹ Worker 列表</button>
203
+ <span id="worker-title"></span>
204
+ </div>
182
205
 
206
+ <!-- 新建任务(仅 pty 交互终端) -->
207
+ <div class="panel">
183
208
  <label>标题</label>
184
209
  <input id="f-title" placeholder="一句话描述这个任务">
185
210
 
186
- <div id="fields-agent">
187
- <label>Agent(Worker 上报的可用 CLI)</label>
188
- <select id="f-agent"><option value="claude">claude</option></select>
189
- <label>Prompt(交给编码 Agent 的指令)</label>
190
- <textarea id="f-prompt" placeholder="例:把 README 里的安装步骤补全,然后跑一遍测试"></textarea>
191
- <label>项目目录名(相对 workspace)</label>
192
- <input id="f-project" value="default">
193
- </div>
194
- <div id="fields-shell" class="hidden">
195
- <label>命令(在 workspace 目录执行)</label>
196
- <textarea id="f-command" placeholder="例:git -C myrepo pull && npm test"></textarea>
197
- </div>
198
- <div id="fields-pty" class="hidden">
199
- <label>项目目录名(相对 workspace,终端的起始目录)</label>
200
- <input id="f-pty-project" value="default">
201
- <label style="color:var(--dim)">提交后点任务卡上的「进入终端」开始交互</label>
202
- </div>
203
- <div id="fields-script" class="hidden">
204
- <label>脚本名(worker/scripts 目录下的白名单脚本)</label>
205
- <input id="f-script" placeholder="例:backup.sh">
206
- <label>参数(空格分隔)</label>
207
- <input id="f-args" placeholder="例:--full /tmp/out">
208
- </div>
209
- <div id="fields-chat" class="hidden">
210
- <label>留言内容</label>
211
- <textarea id="f-message" placeholder="记一条备忘,或触发 CHAT_HOOK"></textarea>
212
- </div>
211
+ <label>项目目录(终端的起始目录,Worker workspace 下已有目录)</label>
212
+ <select id="f-pty-project"><option value="default">default</option></select>
213
+ <label style="color:var(--dim)">提交后点任务卡上的「进入终端」开始交互</label>
213
214
 
214
215
  <button class="submit" id="btn-submit">提交任务</button>
215
- </div>
216
+ </div>
216
217
 
217
- <!-- 任务列表 -->
218
- <div id="task-list"></div>
218
+ <!-- 任务列表(仅所选 Worker) -->
219
+ <div id="task-list"></div>
220
+ </div><!-- /view-worker -->
219
221
 
220
222
  <!-- 交互终端覆盖层(pty 任务):xterm.js 渲染,输入经 /ws 下行到 Worker 的 PTY -->
221
223
  <div id="term-overlay" class="hidden">
@@ -258,6 +260,32 @@ function toast(msg) {
258
260
  setTimeout(() => { el.style.opacity = '0'; }, 2200);
259
261
  }
260
262
 
263
+ // ---------- 主题:light/dark 切换,偏好存 localStorage,首访跟随系统外观 ----------
264
+ // xterm 是 canvas 绘制读不到 CSS 变量,终端配色在这里维护并同步给各实例;
265
+ // 浅色下重排 16 色调色板(默认 ANSI 的黄/亮黄在白底上不可见)
266
+ const TERM_THEME = {
267
+ dark: { background: '#0b1220', foreground: '#e5e7eb' },
268
+ light: {
269
+ background: '#ffffff', foreground: '#111827', cursor: '#111827',
270
+ black: '#111827', red: '#dc2626', green: '#16a34a', yellow: '#ca8a04',
271
+ blue: '#2563eb', magenta: '#9333ea', cyan: '#0891b2', white: '#f3f4f6',
272
+ brightBlack: '#6b7280', brightRed: '#ef4444', brightGreen: '#22c55e',
273
+ brightYellow: '#eab308', brightBlue: '#3b82f6', brightMagenta: '#a855f7',
274
+ brightCyan: '#22d3ee', brightWhite: '#ffffff',
275
+ },
276
+ };
277
+ function currentTheme() {
278
+ return document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
279
+ }
280
+ function applyTheme(theme) {
281
+ document.documentElement.dataset.theme = theme;
282
+ localStorage.setItem('rw_theme', theme);
283
+ $('btn-theme').textContent = theme === 'light' ? '深色' : '浅色';
284
+ document.querySelector('meta[name="theme-color"]').content = theme === 'light' ? '#f4f6f9' : '#111827';
285
+ for (const ent of termEntries.values()) ent.xt.options.theme = { ...TERM_THEME[theme] };
286
+ }
287
+ $('btn-theme').addEventListener('click', () => applyTheme(currentTheme() === 'light' ? 'dark' : 'light'));
288
+
261
289
  // ---------- 纯 JS SHA-256 / HMAC-SHA256 ----------
262
290
  // 非 HTTPS 环境(http://VPS-IP)下浏览器的 crypto.subtle 不可用,故内联实现。
263
291
  // 密钥只参与本地 HMAC 运算,从不随请求发送。
@@ -344,7 +372,17 @@ function signHeaders(method, path, bodyStr) {
344
372
  return { 'X-RW-Ts': String(ts), 'X-RW-Nonce': nonce, 'X-RW-Sig': sig };
345
373
  }
346
374
 
375
+ // 未配置密钥时禁止发起任何需要签名的请求:空密钥签名必 401,
376
+ // 还会白刷服务端验签失败限流计数,统一在本地拦截
377
+ function hasSecret() {
378
+ return secretInput.value.trim() !== '';
379
+ }
380
+ function requireSecret() {
381
+ if (!hasSecret()) throw new Error('请先配置 API Secret');
382
+ }
383
+
347
384
  async function api(path, body) {
385
+ requireSecret();
348
386
  const method = body === undefined ? 'GET' : 'POST';
349
387
  const bodyStr = body === undefined ? '' : JSON.stringify(body);
350
388
  const res = await fetch(path, {
@@ -371,7 +409,10 @@ let wsAuthRejected = false; // 验签被拒后停止重连,直到密钥变
371
409
  secretInput.addEventListener('change', () => {
372
410
  localStorage.setItem('rw_secret', secretInput.value.trim());
373
411
  wsAuthRejected = false; // 密钥换了,允许重新尝试实时连接
374
- if (!wsReady) connectWS();
412
+ if (hasSecret()) {
413
+ if (!wsReady) connectWS();
414
+ refresh(); // 之前无密钥没拉过数据,配好后补一次
415
+ }
375
416
  });
376
417
 
377
418
  // 密钥已配置时收起输入区,只留一行状态条;点状态条可展开修改
@@ -414,7 +455,7 @@ function setConnMode(realtime) {
414
455
  }
415
456
 
416
457
  function connectWS() {
417
- if (wsAuthRejected) return;
458
+ if (wsAuthRejected || !hasSecret()) return; // 无密钥时握手必被 4003 拒绝,不发起
418
459
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
419
460
  const sock = new WebSocket(`${proto}://${location.host}/ws`);
420
461
  curWs = sock;
@@ -451,7 +492,6 @@ function connectWS() {
451
492
  renderTasks();
452
493
  break;
453
494
  case 'task.logs':
454
- mergePreview(msg.id, msg.lines); // 未展开时也要刷新 shell 视图
455
495
  appendLogs(msg.id, msg.lines);
456
496
  break;
457
497
  case 'workers':
@@ -487,7 +527,8 @@ function connectWS() {
487
527
  ent.pendingAttach = false;
488
528
  if (msg.tail) ent.xt.write(msg.tail); // 回看缓冲:切后台回来不白屏
489
529
  ent.live = msg.live;
490
- if (!msg.live) ent.xt.write('\r\n\x1b[33m[会话未就绪:任务尚未被领取或 Worker 离线,请稍候…]\x1b[0m\r\n');
530
+ ent.liveHint = msg.live ? '' : (ATTACH_HINTS[msg.reason] || ATTACH_HINTS.unknown);
531
+ if (!msg.live) ent.xt.write(`\r\n\x1b[33m[会话未就绪:${ent.liveHint}]\x1b[0m\r\n`);
491
532
  termStatus(ent);
492
533
  }
493
534
  break;
@@ -520,7 +561,7 @@ function fmtTime(ts) {
520
561
  `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
521
562
  }
522
563
 
523
- let workerList = []; // 最近一次 Worker 快照:渲染目标下拉与 Agent 联动
564
+ let workerList = []; // 最近一次 Worker 快照:渲染目标下拉与项目目录联动
524
565
 
525
566
  /** 相对时间:离线 Worker 的最近心跳展示成「X 分钟前」,超过一天回退具体时间 */
526
567
  function relTime(ts) {
@@ -548,60 +589,168 @@ function renderWorkers(workers) {
548
589
  }
549
590
  if (ws.textContent !== text) ws.textContent = text; // 没变就不写,避免无谓重绘
550
591
  ws.className = online ? 'online' : 'offline';
551
- renderWorkerOptions(workers);
592
+ // 当前所在二级视图的 Worker 已注销(如配置移除):退回一级列表
593
+ if (currentWorkerId && !workers.some((w) => w.workerId === currentWorkerId)) navBack();
594
+ renderWorkerCards(workers);
595
+ // 首份快照到达后按地址栏 hash 恢复视图(刷新进入二级页/展开的长任务)
596
+ if (!routeInited) { routeInited = true; applyRoute(); }
552
597
  }
553
598
  // 「X 分钟前」随时间变化:存在离线 Worker 时定期用最近快照重渲染,
554
- // 保持离线时长读数新鲜(全在线时不动下拉框,避免打扰交互)
599
+ // 保持离线时长读数新鲜(全在线时不动卡片列表,避免打扰交互)
555
600
  setInterval(() => {
556
601
  if (workerList.length && !workerList.some((w) => w.online)) renderWorkers(workerList);
557
602
  }, 30_000);
558
603
 
559
- // 目标 Worker 下拉:保持用户已选项;无有效选择时默认第一个在线 Worker
560
- function renderWorkerOptions(workers) {
561
- const sel = $('f-worker');
562
- const prev = sel.value;
563
- sel.textContent = '';
564
- const anyOpt = document.createElement('option');
565
- anyOpt.value = '';
566
- anyOpt.textContent = '任意在线 Worker';
567
- sel.appendChild(anyOpt);
604
+ // 一级视图 Worker 卡片:sig 去重,心跳快照没变就不重建(避免打断点击)
605
+ let cardsSig = '';
606
+ function renderWorkerCards(workers) {
607
+ $('workers-empty').classList.toggle('hidden', workers.length > 0);
608
+ const sig = JSON.stringify(workers.map((w) => [
609
+ w.workerId, w.hostname, w.online, w.lastSeenAt, w.projects || [],
610
+ ]));
611
+ if (sig === cardsSig) return;
612
+ cardsSig = sig;
613
+ const box = $('worker-cards');
614
+ box.textContent = '';
568
615
  for (const w of workers) {
569
- const opt = document.createElement('option');
570
- opt.value = w.workerId;
616
+ const card = document.createElement('div');
617
+ card.className = 'worker-card';
571
618
  const name = w.hostname && w.hostname !== w.workerId ? `${w.workerId}(${w.hostname})` : w.workerId;
572
- // 离线时附上最近心跳时间,选 Worker 时一眼看出掉线多久
573
- opt.textContent = name + (w.online ? '' : `(离线,最近心跳 ${relTime(w.lastSeenAt)})`);
574
- sel.appendChild(opt);
575
- }
576
- if ([...sel.options].some((o) => o.value === prev)) sel.value = prev; // 保持用户已选项
577
- else {
578
- const firstOnline = workers.find((w) => w.online);
579
- sel.value = firstOnline ? firstOnline.workerId : '';
580
- }
581
- renderAgentOptions(); // 选择可能变了,同步 Agent 列表
582
- }
583
-
584
- // Agent 下拉:选了具体 Worker 只列它上报的 agents;「任意」模式优先在线
585
- // Worker 的并集,都离线时用最近一次上报兜底
586
- function renderAgentOptions() {
587
- const selected = workerList.find((w) => w.workerId === $('f-worker').value);
588
- const src = selected
589
- ? [selected]
590
- : (() => { const on = workerList.filter((w) => w.online); return on.length ? on : workerList; })();
591
- const agents = [...new Set(src.flatMap((w) => w.agents || []))];
592
- if (!agents.length) return; // Worker 尚未上报,保留默认 claude 选项
593
- const sel = $('f-agent');
594
- const prev = sel.value;
595
- sel.textContent = '';
596
- for (const a of agents) {
597
- const opt = document.createElement('option');
598
- opt.value = a;
599
- opt.textContent = a;
600
- sel.appendChild(opt);
601
- }
602
- if ([...sel.options].some((o) => o.value === prev)) sel.value = prev; // 保持用户已选项
603
- }
604
- $('f-worker').addEventListener('change', renderAgentOptions);
619
+ const status = w.online ? '在线' : `离线(最近心跳 ${relTime(w.lastSeenAt)})`;
620
+ card.innerHTML = `
621
+ <div class="wc-row1">
622
+ <span class="wc-name"></span>
623
+ <span class="${w.online ? 'w-online' : 'w-offline'}"></span>
624
+ </div>
625
+ <div class="wc-meta"></div>`;
626
+ card.querySelector('.wc-name').textContent = name; // textContent 天然防注入
627
+ card.querySelector('.wc-row1 span:last-child').textContent = status;
628
+ const projects = [...new Set(w.projects || [])];
629
+ card.querySelector('.wc-meta').textContent = projects.length ? `项目目录:${projects.join(' / ')}` : '未上报项目目录';
630
+ card.addEventListener('click', () => navToWorker(w.workerId));
631
+ box.appendChild(card);
632
+ }
633
+ }
634
+
635
+ // ---------- 两级视图导航:一级 Worker 列表 / 二级所选 Worker 详情 ----------
636
+ let currentWorkerId = null;
637
+ let routeInited = false; // 首份 Worker 快照到达前 hash 恢复无法执行(卡片还没渲染)
638
+ // pushRoute=false 时只切视图不动地址栏(来自 hashchange 的同步,避免二次入栈)
639
+ function navToWorker(workerId, pushRoute = true) {
640
+ const w = workerList.find((x) => x.workerId === workerId);
641
+ if (!w) return;
642
+ currentWorkerId = workerId;
643
+ $('worker-title').textContent = w.hostname && w.hostname !== workerId ? `${workerId}(${w.hostname})` : workerId;
644
+ $('view-workers').classList.add('hidden');
645
+ $('view-worker').classList.remove('hidden');
646
+ if (pushRoute) pushRoute_();
647
+ renderProjectOptions();
648
+ renderTasks(); // 切换到该 Worker 的任务过滤
649
+ }
650
+ function navBack(pushRoute = true) {
651
+ currentWorkerId = null;
652
+ $('view-worker').classList.add('hidden');
653
+ $('view-workers').classList.remove('hidden');
654
+ if (pushRoute) pushRoute_();
655
+ renderTasks(); // 离开二级视图时清掉过滤后的任务节点
656
+ }
657
+ $('btn-back').addEventListener('click', () => navBack());
658
+
659
+ // ---------- hash 路由:#/w/<workerId>(附 /t/<展开的任务 id>)----------
660
+ // 进入二级视图同步地址栏:刷新留在当前页面;长任务的展开状态也随 URL 保留
661
+ function route() {
662
+ const parts = location.hash.slice(1).split('/').filter(Boolean);
663
+ return {
664
+ worker: parts[0] === 'w' ? decodeURIComponent(parts[1] || '') : null,
665
+ tasks: parts[2] === 't' ? (parts[3] || '').split('.').filter(Boolean) : [],
666
+ };
667
+ }
668
+ function buildHash() {
669
+ if (!currentWorkerId) return '#/';
670
+ // 只把当前视图可见(属于该 Worker)的展开任务写进 URL
671
+ const open = [...openTasks].filter((id) => {
672
+ const t = taskCache.get(id);
673
+ return t && (t.workerId === currentWorkerId || t.targetWorker === currentWorkerId);
674
+ });
675
+ const base = `#/w/${encodeURIComponent(currentWorkerId)}`;
676
+ return open.length ? `${base}/t/${open.join('.')}` : base;
677
+ }
678
+ function pushRoute_() {
679
+ const h = buildHash();
680
+ if (location.hash === h) return;
681
+ history.pushState(null, '', h);
682
+ }
683
+ // 展开/收起只替换当前历史条目:不给返回键堆一堆中间状态
684
+ function syncTaskRoute() {
685
+ history.replaceState(null, '', buildHash());
686
+ }
687
+ function applyRoute() {
688
+ const r = route();
689
+ if (r.worker && r.worker !== currentWorkerId) navToWorker(r.worker, false);
690
+ else if (!r.worker && currentWorkerId) navBack(false);
691
+ for (const id of r.tasks) { // 刷新后恢复长任务的展开(日志区)
692
+ if (openTasks.has(id) || !taskEls.has(id)) continue;
693
+ openTasks.add(id);
694
+ const entry = taskEls.get(id);
695
+ entry.el.classList.add('open');
696
+ entry.toggle.textContent = '收起';
697
+ loadLogs(id);
698
+ }
699
+ }
700
+ window.addEventListener('hashchange', applyRoute);
701
+
702
+ // 项目目录下拉:只列所选 Worker 上报的项目目录(显式指派,不做跨机并集);
703
+ // 末尾常驻「+ 新建目录…」,输入的新目录在任务执行时由 Worker 自动创建
704
+ // (pty 执行前会 mkdir)
705
+ const NEW_PROJECT = '__new__';
706
+ const projectSels = ['f-pty-project'];
707
+
708
+ function renderProjectOptions() {
709
+ const selected = workerList.find((w) => w.workerId === currentWorkerId);
710
+ const projects = [...new Set(selected ? (selected.projects || []) : [])].sort();
711
+ if (!projects.length) return; // Worker 尚未上报,保留默认 default 选项
712
+ for (const id of projectSels) {
713
+ const sel = $(id);
714
+ const prev = sel.value === NEW_PROJECT ? 'default' : sel.value;
715
+ sel.textContent = '';
716
+ for (const p of projects) {
717
+ const opt = document.createElement('option');
718
+ opt.value = p;
719
+ opt.textContent = p;
720
+ sel.appendChild(opt);
721
+ }
722
+ const newOpt = document.createElement('option');
723
+ newOpt.value = NEW_PROJECT;
724
+ newOpt.textContent = '+ 新建目录…';
725
+ sel.appendChild(newOpt);
726
+ if ([...sel.options].some((o) => o.value === prev)) sel.value = prev; // 保持用户已选项
727
+ }
728
+ }
729
+
730
+ // 选「+ 新建目录…」:弹输入框收目录名,合法则加入两个下拉并选中;
731
+ // 取消或非法则退回原选项,NEW_PROJECT 值不会进入提交 payload
732
+ function handleNewProject(ev) {
733
+ const sel = ev.target;
734
+ if (sel.value !== NEW_PROJECT) return;
735
+ const fallback = [...sel.options].find((o) => o.value !== NEW_PROJECT)?.value || '';
736
+ const name = (window.prompt('新项目目录名(任务执行时在 Worker workspace 下自动创建)') || '').trim();
737
+ if (!name || !/^[\w.-]+$/.test(name) || name.includes('..')) {
738
+ if (name) toast('目录名只能含字母/数字/._-,且不含 ..');
739
+ sel.value = fallback;
740
+ return;
741
+ }
742
+ for (const id of projectSels) {
743
+ const s = $(id);
744
+ if (![...s.options].some((o) => o.value === name)) {
745
+ const opt = document.createElement('option');
746
+ opt.value = name;
747
+ opt.textContent = name;
748
+ s.insertBefore(opt, s.querySelector(`option[value="${NEW_PROJECT}"]`));
749
+ }
750
+ s.value = name;
751
+ }
752
+ }
753
+ for (const id of projectSels) $(id).addEventListener('change', handleNewProject);
605
754
 
606
755
  function createTaskEl(t) {
607
756
  const div = document.createElement('div');
@@ -612,7 +761,6 @@ function createTaskEl(t) {
612
761
  <span class="badge"></span>
613
762
  </div>
614
763
  <div class="meta"></div>
615
- <div class="term hidden"></div>
616
764
  <div class="logs"></div>
617
765
  <div class="actions">
618
766
  <button data-act="toggle">展开</button>
@@ -624,19 +772,13 @@ function createTaskEl(t) {
624
772
  el: div,
625
773
  badge: div.querySelector('.badge'),
626
774
  meta: div.querySelector('.meta'),
627
- termEl: div.querySelector('.term'),
628
775
  logsEl: div.querySelector('.logs'),
629
776
  toggle: div.querySelector('[data-act=toggle]'),
630
777
  term: div.querySelector('[data-act=term]'),
631
778
  cancel: div.querySelector('[data-act=cancel]'),
632
779
  status: null,
633
780
  metaText: null,
634
- termSig: null,
635
781
  };
636
- // 点 shell 视图本身也展开(手机上比点小按钮顺手)
637
- entry.termEl.addEventListener('click', () => {
638
- if (!openTasks.has(t.id)) entry.toggle.click();
639
- });
640
782
  entry.term.addEventListener('click', () => openTerm(t));
641
783
  entry.toggle.addEventListener('click', async () => {
642
784
  if (openTasks.has(t.id)) {
@@ -648,7 +790,7 @@ function createTaskEl(t) {
648
790
  await loadLogs(t.id);
649
791
  }
650
792
  entry.toggle.textContent = openTasks.has(t.id) ? '收起' : '展开';
651
- patchTerm(t, entry); // 展开时隐藏 shell 预览,收起时恢复
793
+ syncTaskRoute(); // 展开状态入 URL:长任务执行中刷新仍留在任务页面
652
794
  });
653
795
  entry.cancel.addEventListener('click', async () => {
654
796
  try {
@@ -675,63 +817,15 @@ function patchTask(t, entry) {
675
817
  const meta = `${t.type}${workerPart ? ' · ' + workerPart : ''} · ${fmtTime(t.createdAt)}${t.finishedAt ? ' → ' + fmtTime(t.finishedAt) : ''}`;
676
818
  if (entry.metaText !== meta) { entry.metaText = meta; entry.meta.textContent = meta; }
677
819
  entry.el.classList.toggle('open', openTasks.has(t.id));
678
- patchTerm(t, entry);
679
820
  // 展开且有缓存:内容没变就不动 DOM,保留滚动位置
680
821
  if (openTasks.has(t.id) && logCache.has(t.id)) writeLogs(t.id, logCache.get(t.id));
681
822
  }
682
823
 
683
- // shell 视图:$ 命令回显 + 末尾最多 5 行 stdout;展开后让位给全量日志。
684
- // 用 sig 跳过无变化的重绘,避免日志流式到达时整卡闪烁。
685
- function patchTerm(t, entry) {
686
- const open = openTasks.has(t.id);
687
- const tail = Array.isArray(t.tail) ? t.tail : [];
688
- const sig = `${open}|${t.cmd || ''}|${t.outLines || 0}|${tail.join('\n')}`;
689
- if (entry.termSig === sig) return;
690
- entry.termSig = sig;
691
- entry.termEl.textContent = '';
692
- if (open || (!t.cmd && !tail.length)) { entry.termEl.classList.add('hidden'); return; }
693
- entry.termEl.classList.remove('hidden');
694
- const omitted = (t.outLines || 0) - tail.length;
695
- if (omitted > 0) {
696
- const d = document.createElement('div');
697
- d.className = 'dimline';
698
- d.textContent = `… 更早 ${omitted} 行输出已折叠,点击展开`;
699
- entry.termEl.appendChild(d);
700
- }
701
- if (t.cmd) {
702
- const line = document.createElement('div');
703
- const p = document.createElement('span');
704
- p.className = 'prompt';
705
- p.textContent = '$ ';
706
- line.appendChild(p);
707
- line.appendChild(document.createTextNode(t.cmd.startsWith('$ ') ? t.cmd.slice(2) : t.cmd));
708
- entry.termEl.appendChild(line);
709
- }
710
- for (const l of tail) {
711
- const line = document.createElement('div');
712
- line.textContent = l;
713
- entry.termEl.appendChild(line);
714
- }
715
- }
716
-
717
- // 任务运行中:WS 日志流增量维护预览(规则与 server 端 shellPreview 一致)
718
- function mergePreview(id, lines) {
719
- const t = taskCache.get(id);
720
- if (!t) return;
721
- let changed = false;
722
- for (const line of lines) {
723
- if (line.startsWith('$ ')) { if (!t.cmd) { t.cmd = line; changed = true; } continue; }
724
- if (line.startsWith('[worker]') || line.startsWith('[server]') || line === '') continue;
725
- t.outLines = (t.outLines || 0) + 1;
726
- t.tail = [...(t.tail || []), line].slice(-5);
727
- changed = true;
728
- }
729
- if (changed) renderTasks();
730
- }
731
-
732
824
  function renderTasks() {
733
825
  const list = $('task-list');
734
- const tasks = [...taskCache.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, 50);
826
+ const tasks = [...taskCache.values()]
827
+ .filter((t) => !currentWorkerId || t.workerId === currentWorkerId || t.targetWorker === currentWorkerId)
828
+ .sort((a, b) => b.createdAt - a.createdAt).slice(0, 50);
735
829
  // 清理已不存在的任务节点
736
830
  const ids = new Set(tasks.map((t) => t.id));
737
831
  for (const [id, entry] of taskEls) {
@@ -785,16 +879,33 @@ function appendLogs(id, lines) {
785
879
 
786
880
  // ---------- 交互终端(pty 任务:xterm.js 渲染,IO 走 /ws 中继到 Worker 的 PTY)----------
787
881
  // 手机端无物理键盘:隐藏 textarea 承接击键,工具条补 Tab/方向键/Ctrl 组合键
788
- const termEntries = new Map(); // taskId -> { xt, fit, live, pendingAttach }
882
+ const termEntries = new Map(); // taskId -> { xt, fit, live, pendingAttach, liveHint }
789
883
  let activeTermId = null;
884
+ // 会话未就绪时的原因文案(与服务端 pty.attach 应答的 reason 对应)
885
+ const ATTACH_HINTS = {
886
+ not_claimed: '任务尚未被领取,等待 Worker 上线领取…',
887
+ worker_offline: 'Worker 离线(通道断开),重连后可自动恢复…',
888
+ starting: '任务已被领取,会话启动中,请稍候…',
889
+ ended: '会话已结束或已被回收',
890
+ unknown: '任务尚未被领取或 Worker 离线,请稍候…',
891
+ };
790
892
  const KEY_SEQ = { BS: '\x7f', Tab: '\t', Esc: '\x1b', Up: '\x1b[A', Down: '\x1b[B', CtrlC: '\x03', CtrlD: '\x04' };
791
893
 
894
+ // 终端标题要有区分度:自定义标题照用;默认标题(空或「交互终端…」)
895
+ // 退化为「项目目录 · #任务短号」,同项目开多个终端也不会重名
896
+ function termLabel(t) {
897
+ if (t.title && !/^交互终端/.test(t.title)) return t.title;
898
+ const short = String(t.id).slice(-4);
899
+ const proj = t.project || (t.payload && t.payload.project);
900
+ return proj ? `${proj} · #${short}` : `终端 · #${short}`;
901
+ }
902
+
792
903
  function termStatus(ent) {
793
904
  const el = $('term-status');
794
905
  if (!ent) { el.textContent = ''; return; }
795
906
  if (!wsReady) el.textContent = '实时通道断开,重连中…';
796
907
  else if (ent.live) el.textContent = '已连接';
797
- else el.textContent = '会话未就绪(任务未领取或 Worker 离线)';
908
+ else el.textContent = `会话未就绪(${ent.liveHint || ATTACH_HINTS.unknown})`;
798
909
  }
799
910
 
800
911
  function ensureTerm(t) {
@@ -806,17 +917,26 @@ function ensureTerm(t) {
806
917
  }
807
918
  const xt = new Terminal({
808
919
  fontSize: 13, scrollback: 3000, cursorBlink: true,
809
- theme: { background: '#0b1220', foreground: '#e5e7eb' },
920
+ theme: { ...TERM_THEME[currentTheme()] },
810
921
  });
811
922
  const fit = new FitAddon.FitAddon();
812
923
  xt.loadAddon(fit);
813
- xt.open($('term-body'));
814
- ent = { xt, fit, live: false, pendingAttach: false };
924
+ // 每个终端独占一个 pane 容器:直接挂在 #term-body 上会让多个实例叠在一起串台
925
+ const pane = document.createElement('div');
926
+ pane.className = 'term-pane';
927
+ $('term-body').appendChild(pane);
928
+ xt.open(pane);
929
+ ent = { xt, fit, pane, live: false, pendingAttach: false, liveHint: '' };
815
930
  termEntries.set(t.id, ent);
816
931
  xt.onData((data) => sendWs({ type: 'session.input', taskId: t.id, data }));
817
932
  return ent;
818
933
  }
819
934
 
935
+ // 只显示当前终端的 pane,其余隐藏(实例不销毁,后台继续收输出)
936
+ function showTermPane(id) {
937
+ for (const [tid, e] of termEntries) e.pane.classList.toggle('active', tid === id);
938
+ }
939
+
820
940
  function fitActiveTerm() {
821
941
  const ent = termEntries.get(activeTermId);
822
942
  if (!ent) return;
@@ -828,8 +948,9 @@ function openTerm(t) {
828
948
  const ent = ensureTerm(t);
829
949
  if (!ent) return;
830
950
  activeTermId = t.id;
831
- $('term-title').textContent = t.title || '交互终端';
951
+ $('term-title').textContent = termLabel(t);
832
952
  $('term-overlay').classList.remove('hidden');
953
+ showTermPane(t.id); // 先亮出本终端的 pane 再 fit(隐藏状态下 fit 拿不到尺寸)
833
954
  fitActiveTerm();
834
955
  ent.pendingAttach = true;
835
956
  sendWs({ type: 'pty.attach', taskId: t.id }); // 取回看缓冲 + 会话存活状态
@@ -851,6 +972,7 @@ function disposeTerm(id) {
851
972
  termEntries.delete(id);
852
973
  if (activeTermId === id) closeTermOverlay();
853
974
  try { ent.xt.dispose(); } catch { /* 已释放 */ }
975
+ ent.pane.remove();
854
976
  }
855
977
 
856
978
  // 输入代理里常驻一个哨兵字符:手机键盘在空 textarea 上按退格不产生任何事件,
@@ -917,55 +1039,20 @@ async function refresh() {
917
1039
  toast(err.message);
918
1040
  }
919
1041
  }
920
- setInterval(() => { if (!wsReady && !wsAuthRejected) refresh(); }, 4000);
921
-
922
- // ---------- 类型切换 ----------
923
- let currentType = 'agent';
924
- document.querySelectorAll('#type-bar button').forEach((btn) => {
925
- btn.addEventListener('click', () => {
926
- currentType = btn.dataset.type;
927
- document.querySelectorAll('#type-bar button').forEach((b) => b.classList.toggle('active', b === btn));
928
- for (const t of ['agent', 'shell', 'pty', 'script', 'chat']) {
929
- $('fields-' + t).classList.toggle('hidden', t !== currentType);
930
- }
931
- });
932
- });
1042
+ setInterval(() => { if (!wsReady && !wsAuthRejected && hasSecret()) refresh(); }, 4000);
933
1043
 
934
1044
  // ---------- 提交任务(REST,WS 负责推送变更)----------
935
1045
  $('btn-submit').addEventListener('click', async () => {
1046
+ // 显式指派:二级视图下任务固定提交给当前 Worker
1047
+ const targetWorker = currentWorkerId;
1048
+ if (!targetWorker) return toast('请先在 Worker 列表中选择 Worker');
936
1049
  const title = $('f-title').value.trim();
937
- let payload = {};
938
- if (currentType === 'agent') {
939
- payload = {
940
- prompt: $('f-prompt').value.trim(),
941
- project: $('f-project').value.trim() || 'default',
942
- agent: $('f-agent').value,
943
- };
944
- if (!payload.prompt) return toast('请填写 Prompt');
945
- } else if (currentType === 'shell') {
946
- payload = { command: $('f-command').value.trim() };
947
- if (!payload.command) return toast('请填写命令');
948
- } else if (currentType === 'pty') {
949
- payload = { project: $('f-pty-project').value.trim() || 'default' };
950
- if (!/^[\w.-]+$/.test(payload.project)) return toast('项目目录名只能含字母/数字/._-');
951
- } else if (currentType === 'script') {
952
- payload = {
953
- script: $('f-script').value.trim(),
954
- args: $('f-args').value.trim() ? $('f-args').value.trim().split(/\s+/) : [],
955
- };
956
- if (!payload.script) return toast('请填写脚本名');
957
- } else {
958
- payload = { message: $('f-message').value.trim() };
959
- if (!payload.message) return toast('请填写留言');
960
- }
1050
+ const payload = { project: $('f-pty-project').value.trim() || 'default' };
1051
+ if (!/^[\w.-]+$/.test(payload.project)) return toast('项目目录名只能含字母/数字/._-');
961
1052
  try {
962
1053
  $('btn-submit').disabled = true;
963
- const autoTitle = payload.prompt || payload.command || payload.script ||
964
- (currentType === 'pty' ? '交互终端' : payload.message || '留言');
965
- // 目标 Worker:空值 = 任意 Worker 均可领取(undefined 不会被序列化进 body)
966
- const targetWorker = $('f-worker').value || undefined;
967
- await api('/api/tasks', { type: currentType, title: title || autoTitle, payload, targetWorker });
968
- toast(currentType === 'pty' ? '已提交,任务运行后点「进入终端」' : '任务已提交');
1054
+ await api('/api/tasks', { type: 'pty', title: title || `交互终端(${payload.project})`, payload, targetWorker });
1055
+ toast('已提交,任务运行后点「进入终端」');
969
1056
  $('f-title').value = '';
970
1057
  if (!wsReady) await refresh();
971
1058
  } catch (err) {
@@ -975,8 +1062,11 @@ $('btn-submit').addEventListener('click', async () => {
975
1062
  }
976
1063
  });
977
1064
 
1065
+ applyTheme(currentTheme()); // 主题初始化:同步按钮文案与 theme-color(head 已设好 data-theme)
978
1066
  connectWS();
979
- refresh(); // 首次加载兜底:WS 握手前的空档先用 REST 铺一次数据
1067
+ // 首次加载兜底:WS 握手前的空档先用 REST 铺一次数据(无密钥时不发请求)
1068
+ if (hasSecret()) refresh();
1069
+ else setConnMode(false); // 无密钥不发起任何连接,状态条直接落到轮询态
980
1070
  </script>
981
1071
  </body>
982
1072
  </html>