@jacksontian/kite-server 0.1.0 → 0.4.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/web/index.html CHANGED
@@ -5,7 +5,9 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
6
6
  <meta name="theme-color" content="#111827">
7
7
  <title>Kite 控制台</title>
8
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
8
+ <!-- xterm 随包自托管(vendor/ 为锁版本的官方产物):不依赖 CDN,
9
+ 杜绝 CDN 投毒/HTTP 下被篡改注入窃取密钥的供应链风险 -->
10
+ <link rel="stylesheet" href="vendor/xterm.min.css">
9
11
  <style>
10
12
  :root {
11
13
  --bg: #111827; --panel: #1f2937; --line: #374151;
@@ -29,6 +31,12 @@
29
31
  #worker-status.online { color: var(--ok); }
30
32
  #worker-status.offline { color: var(--err); }
31
33
 
34
+ /* 明文 HTTP 部署的风险提示(仅非 localhost 的 http: 环境显示) */
35
+ #http-warn {
36
+ background: rgba(239,68,68,.12); border: 1px solid var(--err); color: var(--err);
37
+ border-radius: 10px; padding: 8px 12px; font-size: 12px; line-height: 1.6; margin-bottom: 14px;
38
+ }
39
+
32
40
  .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px; margin-bottom: 14px; }
33
41
  label { display: block; font-size: 12px; color: var(--dim); margin: 10px 0 4px; }
34
42
  input, textarea, select {
@@ -39,13 +47,6 @@
39
47
  textarea { min-height: 72px; resize: vertical; }
40
48
  input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); }
41
49
 
42
- .types { display: flex; gap: 8px; margin-top: 4px; }
43
- .types button {
44
- flex: 1; padding: 10px 0; border-radius: 8px; border: 1px solid var(--line);
45
- background: var(--bg); color: var(--dim); font-size: 13px;
46
- }
47
- .types button.active { border-color: var(--accent); color: var(--accent); background: rgba(59,130,246,.12); }
48
-
49
50
  .submit {
50
51
  width: 100%; margin-top: 14px; padding: 12px;
51
52
  background: var(--accent); color: #fff; border: none; border-radius: 10px;
@@ -71,15 +72,6 @@
71
72
  color: #a5f3fc; display: none;
72
73
  }
73
74
  .task.open .logs { display: block; }
74
- /* shell 视图:默认只露出命令回显 + 末尾 5 行 stdout,接近终端体验;
75
- 元信息/结果/更早输出要点「展开」才可见 */
76
- .term {
77
- margin-top: 8px; background: #0b1220; border-radius: 8px; padding: 8px;
78
- font-family: ui-monospace, Menlo, monospace; font-size: 11px; line-height: 1.6;
79
- white-space: pre-wrap; word-break: break-all; color: #a5f3fc; cursor: pointer;
80
- }
81
- .term .prompt { color: var(--ok); }
82
- .term .dimline { color: var(--dim); }
83
75
  .task .actions { margin-top: 8px; display: flex; gap: 8px; }
84
76
  .task .actions button {
85
77
  font-size: 12px; padding: 4px 12px; border-radius: 6px;
@@ -98,7 +90,11 @@
98
90
  #term-head { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; }
99
91
  #term-title { font-size: 13px; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
100
92
  #term-close { font-size: 13px; padding: 4px 12px; border-radius: 6px; background: var(--panel); color: var(--text); border: 1px solid var(--line); }
101
- #term-body { flex: 1; margin: 0 8px; min-height: 0; }
93
+ /* position:relative + 绝对定位的 .term-pane:多个 pty 终端各占一层,
94
+ 同时只显示 active 的一个,避免多实例挂在同一容器里叠出来串台 */
95
+ #term-body { flex: 1; margin: 0 8px; min-height: 0; position: relative; }
96
+ .term-pane { position: absolute; inset: 0; display: none; }
97
+ .term-pane.active { display: block; }
102
98
  #term-bar { display: flex; align-items: center; gap: 6px; padding: 8px; }
103
99
  #term-bar button {
104
100
  min-width: 44px; padding: 9px 0; font-size: 13px; border-radius: 8px;
@@ -106,7 +102,6 @@
106
102
  }
107
103
  #term-status { flex: 1; font-size: 11px; color: var(--dim); text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
108
104
  #term-input { position: fixed; left: -9999px; top: 0; width: 1px; height: 1px; opacity: .01; border: none; }
109
- #type-bar button { font-size: 12px; }
110
105
  #auth-bar { display: flex; justify-content: space-between; align-items: center; cursor: pointer; }
111
106
  #auth-bar .title { font-size: 13px; color: var(--ok); }
112
107
  #auth-bar .edit { font-size: 12px; color: var(--dim); }
@@ -118,6 +113,24 @@
118
113
  }
119
114
  #btn-eye.on { color: var(--accent); border-color: var(--accent); }
120
115
  #btn-done { color: var(--ok); }
116
+ /* 两级视图:一级 Worker 列表 / 二级所选 Worker 详情 */
117
+ #back-row { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
118
+ #btn-back {
119
+ flex: none; padding: 8px 14px; border: 1px solid var(--line); border-radius: 8px;
120
+ background: var(--panel); color: var(--text); font-size: 14px;
121
+ }
122
+ #worker-title { font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
123
+ .worker-card {
124
+ background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
125
+ padding: 12px; margin-bottom: 10px;
126
+ }
127
+ .worker-card .wc-row1 { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
128
+ .worker-card .wc-name { font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
129
+ .worker-card .wc-meta { font-size: 11px; color: var(--dim); margin-top: 4px; line-height: 1.5; word-break: break-all; }
130
+ .w-online { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: rgba(34,197,94,.15); color: var(--ok); white-space: nowrap; }
131
+ .w-offline { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: rgba(239,68,68,.15); color: var(--err); white-space: nowrap; }
132
+ .empty-tip { color: var(--dim); font-size: 13px; text-align: center; padding: 28px 0; }
133
+
121
134
  @keyframes task-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
122
135
  .task.new { animation: task-in .25s ease; }
123
136
  #toast {
@@ -137,6 +150,12 @@
137
150
  </div>
138
151
  </header>
139
152
 
153
+ <!-- 明文 HTTP 部署警告:任务内容与签名流量可被窃听,建议尽快上 HTTPS -->
154
+ <div id="http-warn" class="hidden">
155
+ ⚠️ 当前为明文 HTTP 访问:任务、日志与签名在网络链路上明文可见,且本机无法使用安全加密 API。
156
+ 请勿在不信任的网络(如公共 WiFi)使用,并尽快为服务端配置 HTTPS(如 Caddy 自动证书)。
157
+ </div>
158
+
140
159
  <!-- 鉴权设置:密钥已存 localStorage 时折叠成一行,点「修改」才展开 -->
141
160
  <div class="panel" id="auth-panel">
142
161
  <div id="auth-bar" class="hidden">
@@ -153,52 +172,34 @@
153
172
  </div>
154
173
  </div>
155
174
 
156
- <!-- 新建任务 -->
157
- <div class="panel">
158
- <div class="types" id="type-bar">
159
- <button data-type="agent" class="active">Agent</button>
160
- <button data-type="shell">Shell</button>
161
- <button data-type="pty">终端</button>
162
- <button data-type="script">脚本</button>
163
- <button data-type="chat">留言</button>
175
+ <!-- 一级视图:Worker 列表,选中某个 Worker 进入二级详情 -->
176
+ <div id="view-workers">
177
+ <div id="worker-cards"></div>
178
+ <div id="workers-empty" class="empty-tip hidden">暂无已注册 Worker</div>
179
+ </div>
180
+
181
+ <!-- 二级视图:所选 Worker 的新建任务与任务列表 -->
182
+ <div id="view-worker" class="hidden">
183
+ <div id="back-row">
184
+ <button id="btn-back" type="button">‹ Worker 列表</button>
185
+ <span id="worker-title"></span>
164
186
  </div>
165
187
 
188
+ <!-- 新建任务(仅 pty 交互终端) -->
189
+ <div class="panel">
166
190
  <label>标题</label>
167
191
  <input id="f-title" placeholder="一句话描述这个任务">
168
192
 
169
- <div id="fields-agent">
170
- <label>Agent(Worker 上报的可用 CLI)</label>
171
- <select id="f-agent"><option value="claude">claude</option></select>
172
- <label>Prompt(交给编码 Agent 的指令)</label>
173
- <textarea id="f-prompt" placeholder="例:把 README 里的安装步骤补全,然后跑一遍测试"></textarea>
174
- <label>项目目录名(相对 workspace)</label>
175
- <input id="f-project" value="default">
176
- </div>
177
- <div id="fields-shell" class="hidden">
178
- <label>命令(在 workspace 目录执行)</label>
179
- <textarea id="f-command" placeholder="例:git -C myrepo pull && npm test"></textarea>
180
- </div>
181
- <div id="fields-pty" class="hidden">
182
- <label>项目目录名(相对 workspace,终端的起始目录)</label>
183
- <input id="f-pty-project" value="default">
184
- <label style="color:var(--dim)">提交后点任务卡上的「进入终端」开始交互</label>
185
- </div>
186
- <div id="fields-script" class="hidden">
187
- <label>脚本名(worker/scripts 目录下的白名单脚本)</label>
188
- <input id="f-script" placeholder="例:backup.sh">
189
- <label>参数(空格分隔)</label>
190
- <input id="f-args" placeholder="例:--full /tmp/out">
191
- </div>
192
- <div id="fields-chat" class="hidden">
193
- <label>留言内容</label>
194
- <textarea id="f-message" placeholder="记一条备忘,或触发 CHAT_HOOK"></textarea>
195
- </div>
193
+ <label>项目目录(终端的起始目录,Worker workspace 下已有目录)</label>
194
+ <select id="f-pty-project"><option value="default">default</option></select>
195
+ <label style="color:var(--dim)">提交后点任务卡上的「进入终端」开始交互</label>
196
196
 
197
197
  <button class="submit" id="btn-submit">提交任务</button>
198
- </div>
198
+ </div>
199
199
 
200
- <!-- 任务列表 -->
201
- <div id="task-list"></div>
200
+ <!-- 任务列表(仅所选 Worker) -->
201
+ <div id="task-list"></div>
202
+ </div><!-- /view-worker -->
202
203
 
203
204
  <!-- 交互终端覆盖层(pty 任务):xterm.js 渲染,输入经 /ws 下行到 Worker 的 PTY -->
204
205
  <div id="term-overlay" class="hidden">
@@ -223,11 +224,15 @@
223
224
 
224
225
  <div id="toast"></div>
225
226
 
226
- <script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
227
- <script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
227
+ <script src="vendor/xterm.min.js"></script>
228
+ <script src="vendor/addon-fit.min.js"></script>
228
229
  <script>
229
230
  'use strict';
230
231
  const $ = (id) => document.getElementById(id);
232
+ // 非 localhost 的明文 HTTP 环境:常驻风险提示(localhost 本地联调不打扰)
233
+ if (location.protocol === 'http:' && !['localhost', '127.0.0.1'].includes(location.hostname)) {
234
+ $('http-warn').classList.remove('hidden');
235
+ }
231
236
  const secretInput = $('secret');
232
237
  secretInput.value = localStorage.getItem('rw_secret') || '';
233
238
 
@@ -323,7 +328,17 @@ function signHeaders(method, path, bodyStr) {
323
328
  return { 'X-RW-Ts': String(ts), 'X-RW-Nonce': nonce, 'X-RW-Sig': sig };
324
329
  }
325
330
 
331
+ // 未配置密钥时禁止发起任何需要签名的请求:空密钥签名必 401,
332
+ // 还会白刷服务端验签失败限流计数,统一在本地拦截
333
+ function hasSecret() {
334
+ return secretInput.value.trim() !== '';
335
+ }
336
+ function requireSecret() {
337
+ if (!hasSecret()) throw new Error('请先配置 API Secret');
338
+ }
339
+
326
340
  async function api(path, body) {
341
+ requireSecret();
327
342
  const method = body === undefined ? 'GET' : 'POST';
328
343
  const bodyStr = body === undefined ? '' : JSON.stringify(body);
329
344
  const res = await fetch(path, {
@@ -350,7 +365,10 @@ let wsAuthRejected = false; // 验签被拒后停止重连,直到密钥变
350
365
  secretInput.addEventListener('change', () => {
351
366
  localStorage.setItem('rw_secret', secretInput.value.trim());
352
367
  wsAuthRejected = false; // 密钥换了,允许重新尝试实时连接
353
- if (!wsReady) connectWS();
368
+ if (hasSecret()) {
369
+ if (!wsReady) connectWS();
370
+ refresh(); // 之前无密钥没拉过数据,配好后补一次
371
+ }
354
372
  });
355
373
 
356
374
  // 密钥已配置时收起输入区,只留一行状态条;点状态条可展开修改
@@ -393,7 +411,7 @@ function setConnMode(realtime) {
393
411
  }
394
412
 
395
413
  function connectWS() {
396
- if (wsAuthRejected) return;
414
+ if (wsAuthRejected || !hasSecret()) return; // 无密钥时握手必被 4003 拒绝,不发起
397
415
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
398
416
  const sock = new WebSocket(`${proto}://${location.host}/ws`);
399
417
  curWs = sock;
@@ -430,7 +448,6 @@ function connectWS() {
430
448
  renderTasks();
431
449
  break;
432
450
  case 'task.logs':
433
- mergePreview(msg.id, msg.lines); // 未展开时也要刷新 shell 视图
434
451
  appendLogs(msg.id, msg.lines);
435
452
  break;
436
453
  case 'workers':
@@ -466,7 +483,8 @@ function connectWS() {
466
483
  ent.pendingAttach = false;
467
484
  if (msg.tail) ent.xt.write(msg.tail); // 回看缓冲:切后台回来不白屏
468
485
  ent.live = msg.live;
469
- if (!msg.live) ent.xt.write('\r\n\x1b[33m[会话未就绪:任务尚未被领取或 Worker 离线,请稍候…]\x1b[0m\r\n');
486
+ ent.liveHint = msg.live ? '' : (ATTACH_HINTS[msg.reason] || ATTACH_HINTS.unknown);
487
+ if (!msg.live) ent.xt.write(`\r\n\x1b[33m[会话未就绪:${ent.liveHint}]\x1b[0m\r\n`);
470
488
  termStatus(ent);
471
489
  }
472
490
  break;
@@ -499,32 +517,197 @@ function fmtTime(ts) {
499
517
  `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
500
518
  }
501
519
 
520
+ let workerList = []; // 最近一次 Worker 快照:渲染目标下拉与项目目录联动
521
+
522
+ /** 相对时间:离线 Worker 的最近心跳展示成「X 分钟前」,超过一天回退具体时间 */
523
+ function relTime(ts) {
524
+ if (!ts) return '从未';
525
+ const diff = Date.now() - ts;
526
+ if (diff < 60_000) return '1 分钟内';
527
+ if (diff < 3600_000) return `${Math.floor(diff / 60_000)} 分钟前`;
528
+ if (diff < 86400_000) return `${Math.floor(diff / 3600_000)} 小时前`;
529
+ return fmtTime(ts);
530
+ }
531
+
502
532
  function renderWorkers(workers) {
533
+ workerList = workers;
503
534
  const ws = $('worker-status');
504
- const online = workers.find((w) => w.online);
505
- const text = online ? `Mac Mini 在线(${online.workerId})` : 'Mac Mini 离线';
535
+ const online = workers.filter((w) => w.online).length;
536
+ let text;
537
+ if (!workers.length) {
538
+ text = '无 Worker';
539
+ } else if (online) {
540
+ text = `${online}/${workers.length} Worker 在线`;
541
+ } else {
542
+ // 全离线时给出最近一次心跳时间,便于判断是「刚掉线」还是「长期没上线」
543
+ const lastSeen = Math.max(...workers.map((w) => w.lastSeenAt || 0));
544
+ text = `Worker 全部离线(最近心跳 ${relTime(lastSeen)})`;
545
+ }
506
546
  if (ws.textContent !== text) ws.textContent = text; // 没变就不写,避免无谓重绘
507
547
  ws.className = online ? 'online' : 'offline';
508
- renderAgentOptions(workers);
548
+ // 当前所在二级视图的 Worker 已注销(如配置移除):退回一级列表
549
+ if (currentWorkerId && !workers.some((w) => w.workerId === currentWorkerId)) navBack();
550
+ renderWorkerCards(workers);
551
+ // 首份快照到达后按地址栏 hash 恢复视图(刷新进入二级页/展开的长任务)
552
+ if (!routeInited) { routeInited = true; applyRoute(); }
553
+ }
554
+ // 「X 分钟前」随时间变化:存在离线 Worker 时定期用最近快照重渲染,
555
+ // 保持离线时长读数新鲜(全在线时不动卡片列表,避免打扰交互)
556
+ setInterval(() => {
557
+ if (workerList.length && !workerList.some((w) => w.online)) renderWorkers(workerList);
558
+ }, 30_000);
559
+
560
+ // 一级视图 Worker 卡片:sig 去重,心跳快照没变就不重建(避免打断点击)
561
+ let cardsSig = '';
562
+ function renderWorkerCards(workers) {
563
+ $('workers-empty').classList.toggle('hidden', workers.length > 0);
564
+ const sig = JSON.stringify(workers.map((w) => [
565
+ w.workerId, w.hostname, w.online, w.lastSeenAt, w.projects || [],
566
+ ]));
567
+ if (sig === cardsSig) return;
568
+ cardsSig = sig;
569
+ const box = $('worker-cards');
570
+ box.textContent = '';
571
+ for (const w of workers) {
572
+ const card = document.createElement('div');
573
+ card.className = 'worker-card';
574
+ const name = w.hostname && w.hostname !== w.workerId ? `${w.workerId}(${w.hostname})` : w.workerId;
575
+ const status = w.online ? '在线' : `离线(最近心跳 ${relTime(w.lastSeenAt)})`;
576
+ card.innerHTML = `
577
+ <div class="wc-row1">
578
+ <span class="wc-name"></span>
579
+ <span class="${w.online ? 'w-online' : 'w-offline'}"></span>
580
+ </div>
581
+ <div class="wc-meta"></div>`;
582
+ card.querySelector('.wc-name').textContent = name; // textContent 天然防注入
583
+ card.querySelector('.wc-row1 span:last-child').textContent = status;
584
+ const projects = [...new Set(w.projects || [])];
585
+ card.querySelector('.wc-meta').textContent = projects.length ? `项目目录:${projects.join(' / ')}` : '未上报项目目录';
586
+ card.addEventListener('click', () => navToWorker(w.workerId));
587
+ box.appendChild(card);
588
+ }
509
589
  }
510
590
 
511
- // Agent 下拉:优先在线 Worker 的列表;都离线时用最近一次上报兜底
512
- function renderAgentOptions(workers) {
513
- const src = workers.filter((w) => w.online);
514
- const agents = [...new Set((src.length ? src : workers).flatMap((w) => w.agents || []))];
515
- if (!agents.length) return; // Worker 尚未上报,保留默认 claude 选项
516
- const sel = $('f-agent');
517
- const prev = sel.value;
518
- sel.textContent = '';
519
- for (const a of agents) {
520
- const opt = document.createElement('option');
521
- opt.value = a;
522
- opt.textContent = a;
523
- sel.appendChild(opt);
591
+ // ---------- 两级视图导航:一级 Worker 列表 / 二级所选 Worker 详情 ----------
592
+ let currentWorkerId = null;
593
+ let routeInited = false; // 首份 Worker 快照到达前 hash 恢复无法执行(卡片还没渲染)
594
+ // pushRoute=false 时只切视图不动地址栏(来自 hashchange 的同步,避免二次入栈)
595
+ function navToWorker(workerId, pushRoute = true) {
596
+ const w = workerList.find((x) => x.workerId === workerId);
597
+ if (!w) return;
598
+ currentWorkerId = workerId;
599
+ $('worker-title').textContent = w.hostname && w.hostname !== workerId ? `${workerId}(${w.hostname})` : workerId;
600
+ $('view-workers').classList.add('hidden');
601
+ $('view-worker').classList.remove('hidden');
602
+ if (pushRoute) pushRoute_();
603
+ renderProjectOptions();
604
+ renderTasks(); // 切换到该 Worker 的任务过滤
605
+ }
606
+ function navBack(pushRoute = true) {
607
+ currentWorkerId = null;
608
+ $('view-worker').classList.add('hidden');
609
+ $('view-workers').classList.remove('hidden');
610
+ if (pushRoute) pushRoute_();
611
+ renderTasks(); // 离开二级视图时清掉过滤后的任务节点
612
+ }
613
+ $('btn-back').addEventListener('click', () => navBack());
614
+
615
+ // ---------- hash 路由:#/w/<workerId>(附 /t/<展开的任务 id>)----------
616
+ // 进入二级视图同步地址栏:刷新留在当前页面;长任务的展开状态也随 URL 保留
617
+ function route() {
618
+ const parts = location.hash.slice(1).split('/').filter(Boolean);
619
+ return {
620
+ worker: parts[0] === 'w' ? decodeURIComponent(parts[1] || '') : null,
621
+ tasks: parts[2] === 't' ? (parts[3] || '').split('.').filter(Boolean) : [],
622
+ };
623
+ }
624
+ function buildHash() {
625
+ if (!currentWorkerId) return '#/';
626
+ // 只把当前视图可见(属于该 Worker)的展开任务写进 URL
627
+ const open = [...openTasks].filter((id) => {
628
+ const t = taskCache.get(id);
629
+ return t && (t.workerId === currentWorkerId || t.targetWorker === currentWorkerId);
630
+ });
631
+ const base = `#/w/${encodeURIComponent(currentWorkerId)}`;
632
+ return open.length ? `${base}/t/${open.join('.')}` : base;
633
+ }
634
+ function pushRoute_() {
635
+ const h = buildHash();
636
+ if (location.hash === h) return;
637
+ history.pushState(null, '', h);
638
+ }
639
+ // 展开/收起只替换当前历史条目:不给返回键堆一堆中间状态
640
+ function syncTaskRoute() {
641
+ history.replaceState(null, '', buildHash());
642
+ }
643
+ function applyRoute() {
644
+ const r = route();
645
+ if (r.worker && r.worker !== currentWorkerId) navToWorker(r.worker, false);
646
+ else if (!r.worker && currentWorkerId) navBack(false);
647
+ for (const id of r.tasks) { // 刷新后恢复长任务的展开(日志区)
648
+ if (openTasks.has(id) || !taskEls.has(id)) continue;
649
+ openTasks.add(id);
650
+ const entry = taskEls.get(id);
651
+ entry.el.classList.add('open');
652
+ entry.toggle.textContent = '收起';
653
+ loadLogs(id);
654
+ }
655
+ }
656
+ window.addEventListener('hashchange', applyRoute);
657
+
658
+ // 项目目录下拉:只列所选 Worker 上报的项目目录(显式指派,不做跨机并集);
659
+ // 末尾常驻「+ 新建目录…」,输入的新目录在任务执行时由 Worker 自动创建
660
+ // (pty 执行前会 mkdir)
661
+ const NEW_PROJECT = '__new__';
662
+ const projectSels = ['f-pty-project'];
663
+
664
+ function renderProjectOptions() {
665
+ const selected = workerList.find((w) => w.workerId === currentWorkerId);
666
+ const projects = [...new Set(selected ? (selected.projects || []) : [])].sort();
667
+ if (!projects.length) return; // Worker 尚未上报,保留默认 default 选项
668
+ for (const id of projectSels) {
669
+ const sel = $(id);
670
+ const prev = sel.value === NEW_PROJECT ? 'default' : sel.value;
671
+ sel.textContent = '';
672
+ for (const p of projects) {
673
+ const opt = document.createElement('option');
674
+ opt.value = p;
675
+ opt.textContent = p;
676
+ sel.appendChild(opt);
677
+ }
678
+ const newOpt = document.createElement('option');
679
+ newOpt.value = NEW_PROJECT;
680
+ newOpt.textContent = '+ 新建目录…';
681
+ sel.appendChild(newOpt);
682
+ if ([...sel.options].some((o) => o.value === prev)) sel.value = prev; // 保持用户已选项
524
683
  }
525
- if ([...sel.options].some((o) => o.value === prev)) sel.value = prev; // 保持用户已选项
526
684
  }
527
685
 
686
+ // 选「+ 新建目录…」:弹输入框收目录名,合法则加入两个下拉并选中;
687
+ // 取消或非法则退回原选项,NEW_PROJECT 值不会进入提交 payload
688
+ function handleNewProject(ev) {
689
+ const sel = ev.target;
690
+ if (sel.value !== NEW_PROJECT) return;
691
+ const fallback = [...sel.options].find((o) => o.value !== NEW_PROJECT)?.value || '';
692
+ const name = (window.prompt('新项目目录名(任务执行时在 Worker workspace 下自动创建)') || '').trim();
693
+ if (!name || !/^[\w.-]+$/.test(name) || name.includes('..')) {
694
+ if (name) toast('目录名只能含字母/数字/._-,且不含 ..');
695
+ sel.value = fallback;
696
+ return;
697
+ }
698
+ for (const id of projectSels) {
699
+ const s = $(id);
700
+ if (![...s.options].some((o) => o.value === name)) {
701
+ const opt = document.createElement('option');
702
+ opt.value = name;
703
+ opt.textContent = name;
704
+ s.insertBefore(opt, s.querySelector(`option[value="${NEW_PROJECT}"]`));
705
+ }
706
+ s.value = name;
707
+ }
708
+ }
709
+ for (const id of projectSels) $(id).addEventListener('change', handleNewProject);
710
+
528
711
  function createTaskEl(t) {
529
712
  const div = document.createElement('div');
530
713
  div.className = 'task new' + (openTasks.has(t.id) ? ' open' : '');
@@ -534,7 +717,6 @@ function createTaskEl(t) {
534
717
  <span class="badge"></span>
535
718
  </div>
536
719
  <div class="meta"></div>
537
- <div class="term hidden"></div>
538
720
  <div class="logs"></div>
539
721
  <div class="actions">
540
722
  <button data-act="toggle">展开</button>
@@ -546,19 +728,13 @@ function createTaskEl(t) {
546
728
  el: div,
547
729
  badge: div.querySelector('.badge'),
548
730
  meta: div.querySelector('.meta'),
549
- termEl: div.querySelector('.term'),
550
731
  logsEl: div.querySelector('.logs'),
551
732
  toggle: div.querySelector('[data-act=toggle]'),
552
733
  term: div.querySelector('[data-act=term]'),
553
734
  cancel: div.querySelector('[data-act=cancel]'),
554
735
  status: null,
555
736
  metaText: null,
556
- termSig: null,
557
737
  };
558
- // 点 shell 视图本身也展开(手机上比点小按钮顺手)
559
- entry.termEl.addEventListener('click', () => {
560
- if (!openTasks.has(t.id)) entry.toggle.click();
561
- });
562
738
  entry.term.addEventListener('click', () => openTerm(t));
563
739
  entry.toggle.addEventListener('click', async () => {
564
740
  if (openTasks.has(t.id)) {
@@ -570,7 +746,7 @@ function createTaskEl(t) {
570
746
  await loadLogs(t.id);
571
747
  }
572
748
  entry.toggle.textContent = openTasks.has(t.id) ? '收起' : '展开';
573
- patchTerm(t, entry); // 展开时隐藏 shell 预览,收起时恢复
749
+ syncTaskRoute(); // 展开状态入 URL:长任务执行中刷新仍留在任务页面
574
750
  });
575
751
  entry.cancel.addEventListener('click', async () => {
576
752
  try {
@@ -592,66 +768,20 @@ function patchTask(t, entry) {
592
768
  entry.toggle.textContent = openTasks.has(t.id) ? '收起' : '展开';
593
769
  }
594
770
  entry.term.classList.toggle('hidden', t.type !== 'pty'); // pty 任务入口
595
- const meta = `${t.type} · ${fmtTime(t.createdAt)}${t.finishedAt ? ' → ' + fmtTime(t.finishedAt) : ''}`;
771
+ // Worker 标注:已领取显示执行者,pending 定向任务显示目标
772
+ const workerPart = t.workerId ? `@${t.workerId}` : (t.targetWorker ? `→ ${t.targetWorker}` : '');
773
+ const meta = `${t.type}${workerPart ? ' · ' + workerPart : ''} · ${fmtTime(t.createdAt)}${t.finishedAt ? ' → ' + fmtTime(t.finishedAt) : ''}`;
596
774
  if (entry.metaText !== meta) { entry.metaText = meta; entry.meta.textContent = meta; }
597
775
  entry.el.classList.toggle('open', openTasks.has(t.id));
598
- patchTerm(t, entry);
599
776
  // 展开且有缓存:内容没变就不动 DOM,保留滚动位置
600
777
  if (openTasks.has(t.id) && logCache.has(t.id)) writeLogs(t.id, logCache.get(t.id));
601
778
  }
602
779
 
603
- // shell 视图:$ 命令回显 + 末尾最多 5 行 stdout;展开后让位给全量日志。
604
- // 用 sig 跳过无变化的重绘,避免日志流式到达时整卡闪烁。
605
- function patchTerm(t, entry) {
606
- const open = openTasks.has(t.id);
607
- const tail = Array.isArray(t.tail) ? t.tail : [];
608
- const sig = `${open}|${t.cmd || ''}|${t.outLines || 0}|${tail.join('\n')}`;
609
- if (entry.termSig === sig) return;
610
- entry.termSig = sig;
611
- entry.termEl.textContent = '';
612
- if (open || (!t.cmd && !tail.length)) { entry.termEl.classList.add('hidden'); return; }
613
- entry.termEl.classList.remove('hidden');
614
- const omitted = (t.outLines || 0) - tail.length;
615
- if (omitted > 0) {
616
- const d = document.createElement('div');
617
- d.className = 'dimline';
618
- d.textContent = `… 更早 ${omitted} 行输出已折叠,点击展开`;
619
- entry.termEl.appendChild(d);
620
- }
621
- if (t.cmd) {
622
- const line = document.createElement('div');
623
- const p = document.createElement('span');
624
- p.className = 'prompt';
625
- p.textContent = '$ ';
626
- line.appendChild(p);
627
- line.appendChild(document.createTextNode(t.cmd.startsWith('$ ') ? t.cmd.slice(2) : t.cmd));
628
- entry.termEl.appendChild(line);
629
- }
630
- for (const l of tail) {
631
- const line = document.createElement('div');
632
- line.textContent = l;
633
- entry.termEl.appendChild(line);
634
- }
635
- }
636
-
637
- // 任务运行中:WS 日志流增量维护预览(规则与 server 端 shellPreview 一致)
638
- function mergePreview(id, lines) {
639
- const t = taskCache.get(id);
640
- if (!t) return;
641
- let changed = false;
642
- for (const line of lines) {
643
- if (line.startsWith('$ ')) { if (!t.cmd) { t.cmd = line; changed = true; } continue; }
644
- if (line.startsWith('[worker]') || line.startsWith('[server]') || line === '') continue;
645
- t.outLines = (t.outLines || 0) + 1;
646
- t.tail = [...(t.tail || []), line].slice(-5);
647
- changed = true;
648
- }
649
- if (changed) renderTasks();
650
- }
651
-
652
780
  function renderTasks() {
653
781
  const list = $('task-list');
654
- const tasks = [...taskCache.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, 50);
782
+ const tasks = [...taskCache.values()]
783
+ .filter((t) => !currentWorkerId || t.workerId === currentWorkerId || t.targetWorker === currentWorkerId)
784
+ .sort((a, b) => b.createdAt - a.createdAt).slice(0, 50);
655
785
  // 清理已不存在的任务节点
656
786
  const ids = new Set(tasks.map((t) => t.id));
657
787
  for (const [id, entry] of taskEls) {
@@ -705,8 +835,16 @@ function appendLogs(id, lines) {
705
835
 
706
836
  // ---------- 交互终端(pty 任务:xterm.js 渲染,IO 走 /ws 中继到 Worker 的 PTY)----------
707
837
  // 手机端无物理键盘:隐藏 textarea 承接击键,工具条补 Tab/方向键/Ctrl 组合键
708
- const termEntries = new Map(); // taskId -> { xt, fit, live, pendingAttach }
838
+ const termEntries = new Map(); // taskId -> { xt, fit, live, pendingAttach, liveHint }
709
839
  let activeTermId = null;
840
+ // 会话未就绪时的原因文案(与服务端 pty.attach 应答的 reason 对应)
841
+ const ATTACH_HINTS = {
842
+ not_claimed: '任务尚未被领取,等待 Worker 上线领取…',
843
+ worker_offline: 'Worker 离线(通道断开),重连后可自动恢复…',
844
+ starting: '任务已被领取,会话启动中,请稍候…',
845
+ ended: '会话已结束或已被回收',
846
+ unknown: '任务尚未被领取或 Worker 离线,请稍候…',
847
+ };
710
848
  const KEY_SEQ = { BS: '\x7f', Tab: '\t', Esc: '\x1b', Up: '\x1b[A', Down: '\x1b[B', CtrlC: '\x03', CtrlD: '\x04' };
711
849
 
712
850
  function termStatus(ent) {
@@ -714,14 +852,14 @@ function termStatus(ent) {
714
852
  if (!ent) { el.textContent = ''; return; }
715
853
  if (!wsReady) el.textContent = '实时通道断开,重连中…';
716
854
  else if (ent.live) el.textContent = '已连接';
717
- else el.textContent = '会话未就绪(任务未领取或 Worker 离线)';
855
+ else el.textContent = `会话未就绪(${ent.liveHint || ATTACH_HINTS.unknown})`;
718
856
  }
719
857
 
720
858
  function ensureTerm(t) {
721
859
  let ent = termEntries.get(t.id);
722
860
  if (ent) return ent;
723
861
  if (!window.Terminal || !window.FitAddon) {
724
- toast('xterm.js 加载失败(依赖 CDN,请检查网络后刷新)');
862
+ toast('xterm.js 加载失败(vendor/ 资源缺失,请刷新重试)');
725
863
  return null;
726
864
  }
727
865
  const xt = new Terminal({
@@ -730,13 +868,22 @@ function ensureTerm(t) {
730
868
  });
731
869
  const fit = new FitAddon.FitAddon();
732
870
  xt.loadAddon(fit);
733
- xt.open($('term-body'));
734
- ent = { xt, fit, live: false, pendingAttach: false };
871
+ // 每个终端独占一个 pane 容器:直接挂在 #term-body 上会让多个实例叠在一起串台
872
+ const pane = document.createElement('div');
873
+ pane.className = 'term-pane';
874
+ $('term-body').appendChild(pane);
875
+ xt.open(pane);
876
+ ent = { xt, fit, pane, live: false, pendingAttach: false, liveHint: '' };
735
877
  termEntries.set(t.id, ent);
736
878
  xt.onData((data) => sendWs({ type: 'session.input', taskId: t.id, data }));
737
879
  return ent;
738
880
  }
739
881
 
882
+ // 只显示当前终端的 pane,其余隐藏(实例不销毁,后台继续收输出)
883
+ function showTermPane(id) {
884
+ for (const [tid, e] of termEntries) e.pane.classList.toggle('active', tid === id);
885
+ }
886
+
740
887
  function fitActiveTerm() {
741
888
  const ent = termEntries.get(activeTermId);
742
889
  if (!ent) return;
@@ -750,6 +897,7 @@ function openTerm(t) {
750
897
  activeTermId = t.id;
751
898
  $('term-title').textContent = t.title || '交互终端';
752
899
  $('term-overlay').classList.remove('hidden');
900
+ showTermPane(t.id); // 先亮出本终端的 pane 再 fit(隐藏状态下 fit 拿不到尺寸)
753
901
  fitActiveTerm();
754
902
  ent.pendingAttach = true;
755
903
  sendWs({ type: 'pty.attach', taskId: t.id }); // 取回看缓冲 + 会话存活状态
@@ -771,6 +919,7 @@ function disposeTerm(id) {
771
919
  termEntries.delete(id);
772
920
  if (activeTermId === id) closeTermOverlay();
773
921
  try { ent.xt.dispose(); } catch { /* 已释放 */ }
922
+ ent.pane.remove();
774
923
  }
775
924
 
776
925
  // 输入代理里常驻一个哨兵字符:手机键盘在空 textarea 上按退格不产生任何事件,
@@ -837,53 +986,20 @@ async function refresh() {
837
986
  toast(err.message);
838
987
  }
839
988
  }
840
- setInterval(() => { if (!wsReady && !wsAuthRejected) refresh(); }, 4000);
841
-
842
- // ---------- 类型切换 ----------
843
- let currentType = 'agent';
844
- document.querySelectorAll('#type-bar button').forEach((btn) => {
845
- btn.addEventListener('click', () => {
846
- currentType = btn.dataset.type;
847
- document.querySelectorAll('#type-bar button').forEach((b) => b.classList.toggle('active', b === btn));
848
- for (const t of ['agent', 'shell', 'pty', 'script', 'chat']) {
849
- $('fields-' + t).classList.toggle('hidden', t !== currentType);
850
- }
851
- });
852
- });
989
+ setInterval(() => { if (!wsReady && !wsAuthRejected && hasSecret()) refresh(); }, 4000);
853
990
 
854
991
  // ---------- 提交任务(REST,WS 负责推送变更)----------
855
992
  $('btn-submit').addEventListener('click', async () => {
993
+ // 显式指派:二级视图下任务固定提交给当前 Worker
994
+ const targetWorker = currentWorkerId;
995
+ if (!targetWorker) return toast('请先在 Worker 列表中选择 Worker');
856
996
  const title = $('f-title').value.trim();
857
- let payload = {};
858
- if (currentType === 'agent') {
859
- payload = {
860
- prompt: $('f-prompt').value.trim(),
861
- project: $('f-project').value.trim() || 'default',
862
- agent: $('f-agent').value,
863
- };
864
- if (!payload.prompt) return toast('请填写 Prompt');
865
- } else if (currentType === 'shell') {
866
- payload = { command: $('f-command').value.trim() };
867
- if (!payload.command) return toast('请填写命令');
868
- } else if (currentType === 'pty') {
869
- payload = { project: $('f-pty-project').value.trim() || 'default' };
870
- if (!/^[\w.-]+$/.test(payload.project)) return toast('项目目录名只能含字母/数字/._-');
871
- } else if (currentType === 'script') {
872
- payload = {
873
- script: $('f-script').value.trim(),
874
- args: $('f-args').value.trim() ? $('f-args').value.trim().split(/\s+/) : [],
875
- };
876
- if (!payload.script) return toast('请填写脚本名');
877
- } else {
878
- payload = { message: $('f-message').value.trim() };
879
- if (!payload.message) return toast('请填写留言');
880
- }
997
+ const payload = { project: $('f-pty-project').value.trim() || 'default' };
998
+ if (!/^[\w.-]+$/.test(payload.project)) return toast('项目目录名只能含字母/数字/._-');
881
999
  try {
882
1000
  $('btn-submit').disabled = true;
883
- const autoTitle = payload.prompt || payload.command || payload.script ||
884
- (currentType === 'pty' ? '交互终端' : payload.message || '留言');
885
- await api('/api/tasks', { type: currentType, title: title || autoTitle, payload });
886
- toast(currentType === 'pty' ? '已提交,任务运行后点「进入终端」' : '任务已提交');
1001
+ await api('/api/tasks', { type: 'pty', title: title || '交互终端', payload, targetWorker });
1002
+ toast('已提交,任务运行后点「进入终端」');
887
1003
  $('f-title').value = '';
888
1004
  if (!wsReady) await refresh();
889
1005
  } catch (err) {
@@ -894,7 +1010,9 @@ $('btn-submit').addEventListener('click', async () => {
894
1010
  });
895
1011
 
896
1012
  connectWS();
897
- refresh(); // 首次加载兜底:WS 握手前的空档先用 REST 铺一次数据
1013
+ // 首次加载兜底:WS 握手前的空档先用 REST 铺一次数据(无密钥时不发请求)
1014
+ if (hasSecret()) refresh();
1015
+ else setConnMode(false); // 无密钥不发起任何连接,状态条直接落到轮询态
898
1016
  </script>
899
1017
  </body>
900
1018
  </html>