@jacksontian/kite-server 0.3.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
@@ -47,13 +47,6 @@
47
47
  textarea { min-height: 72px; resize: vertical; }
48
48
  input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); }
49
49
 
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
50
  .submit {
58
51
  width: 100%; margin-top: 14px; padding: 12px;
59
52
  background: var(--accent); color: #fff; border: none; border-radius: 10px;
@@ -79,15 +72,6 @@
79
72
  color: #a5f3fc; display: none;
80
73
  }
81
74
  .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
75
  .task .actions { margin-top: 8px; display: flex; gap: 8px; }
92
76
  .task .actions button {
93
77
  font-size: 12px; padding: 4px 12px; border-radius: 6px;
@@ -106,7 +90,11 @@
106
90
  #term-head { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; }
107
91
  #term-title { font-size: 13px; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
108
92
  #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; }
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; }
110
98
  #term-bar { display: flex; align-items: center; gap: 6px; padding: 8px; }
111
99
  #term-bar button {
112
100
  min-width: 44px; padding: 9px 0; font-size: 13px; border-radius: 8px;
@@ -114,7 +102,6 @@
114
102
  }
115
103
  #term-status { flex: 1; font-size: 11px; color: var(--dim); text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
116
104
  #term-input { position: fixed; left: -9999px; top: 0; width: 1px; height: 1px; opacity: .01; border: none; }
117
- #type-bar button { font-size: 12px; }
118
105
  #auth-bar { display: flex; justify-content: space-between; align-items: center; cursor: pointer; }
119
106
  #auth-bar .title { font-size: 13px; color: var(--ok); }
120
107
  #auth-bar .edit { font-size: 12px; color: var(--dim); }
@@ -126,6 +113,24 @@
126
113
  }
127
114
  #btn-eye.on { color: var(--accent); border-color: var(--accent); }
128
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
+
129
134
  @keyframes task-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
130
135
  .task.new { animation: task-in .25s ease; }
131
136
  #toast {
@@ -167,55 +172,34 @@
167
172
  </div>
168
173
  </div>
169
174
 
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>
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>
179
180
 
180
- <label>目标 Worker(任务在哪台机器执行)</label>
181
- <select id="f-worker"><option value="">任意在线 Worker</option></select>
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>
186
+ </div>
182
187
 
188
+ <!-- 新建任务(仅 pty 交互终端) -->
189
+ <div class="panel">
183
190
  <label>标题</label>
184
191
  <input id="f-title" placeholder="一句话描述这个任务">
185
192
 
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>
193
+ <label>项目目录(终端的起始目录,Worker workspace 下已有目录)</label>
194
+ <select id="f-pty-project"><option value="default">default</option></select>
195
+ <label style="color:var(--dim)">提交后点任务卡上的「进入终端」开始交互</label>
213
196
 
214
197
  <button class="submit" id="btn-submit">提交任务</button>
215
- </div>
198
+ </div>
216
199
 
217
- <!-- 任务列表 -->
218
- <div id="task-list"></div>
200
+ <!-- 任务列表(仅所选 Worker) -->
201
+ <div id="task-list"></div>
202
+ </div><!-- /view-worker -->
219
203
 
220
204
  <!-- 交互终端覆盖层(pty 任务):xterm.js 渲染,输入经 /ws 下行到 Worker 的 PTY -->
221
205
  <div id="term-overlay" class="hidden">
@@ -344,7 +328,17 @@ function signHeaders(method, path, bodyStr) {
344
328
  return { 'X-RW-Ts': String(ts), 'X-RW-Nonce': nonce, 'X-RW-Sig': sig };
345
329
  }
346
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
+
347
340
  async function api(path, body) {
341
+ requireSecret();
348
342
  const method = body === undefined ? 'GET' : 'POST';
349
343
  const bodyStr = body === undefined ? '' : JSON.stringify(body);
350
344
  const res = await fetch(path, {
@@ -371,7 +365,10 @@ let wsAuthRejected = false; // 验签被拒后停止重连,直到密钥变
371
365
  secretInput.addEventListener('change', () => {
372
366
  localStorage.setItem('rw_secret', secretInput.value.trim());
373
367
  wsAuthRejected = false; // 密钥换了,允许重新尝试实时连接
374
- if (!wsReady) connectWS();
368
+ if (hasSecret()) {
369
+ if (!wsReady) connectWS();
370
+ refresh(); // 之前无密钥没拉过数据,配好后补一次
371
+ }
375
372
  });
376
373
 
377
374
  // 密钥已配置时收起输入区,只留一行状态条;点状态条可展开修改
@@ -414,7 +411,7 @@ function setConnMode(realtime) {
414
411
  }
415
412
 
416
413
  function connectWS() {
417
- if (wsAuthRejected) return;
414
+ if (wsAuthRejected || !hasSecret()) return; // 无密钥时握手必被 4003 拒绝,不发起
418
415
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
419
416
  const sock = new WebSocket(`${proto}://${location.host}/ws`);
420
417
  curWs = sock;
@@ -451,7 +448,6 @@ function connectWS() {
451
448
  renderTasks();
452
449
  break;
453
450
  case 'task.logs':
454
- mergePreview(msg.id, msg.lines); // 未展开时也要刷新 shell 视图
455
451
  appendLogs(msg.id, msg.lines);
456
452
  break;
457
453
  case 'workers':
@@ -487,7 +483,8 @@ function connectWS() {
487
483
  ent.pendingAttach = false;
488
484
  if (msg.tail) ent.xt.write(msg.tail); // 回看缓冲:切后台回来不白屏
489
485
  ent.live = msg.live;
490
- 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`);
491
488
  termStatus(ent);
492
489
  }
493
490
  break;
@@ -520,7 +517,7 @@ function fmtTime(ts) {
520
517
  `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
521
518
  }
522
519
 
523
- let workerList = []; // 最近一次 Worker 快照:渲染目标下拉与 Agent 联动
520
+ let workerList = []; // 最近一次 Worker 快照:渲染目标下拉与项目目录联动
524
521
 
525
522
  /** 相对时间:离线 Worker 的最近心跳展示成「X 分钟前」,超过一天回退具体时间 */
526
523
  function relTime(ts) {
@@ -548,60 +545,168 @@ function renderWorkers(workers) {
548
545
  }
549
546
  if (ws.textContent !== text) ws.textContent = text; // 没变就不写,避免无谓重绘
550
547
  ws.className = online ? 'online' : 'offline';
551
- renderWorkerOptions(workers);
548
+ // 当前所在二级视图的 Worker 已注销(如配置移除):退回一级列表
549
+ if (currentWorkerId && !workers.some((w) => w.workerId === currentWorkerId)) navBack();
550
+ renderWorkerCards(workers);
551
+ // 首份快照到达后按地址栏 hash 恢复视图(刷新进入二级页/展开的长任务)
552
+ if (!routeInited) { routeInited = true; applyRoute(); }
552
553
  }
553
554
  // 「X 分钟前」随时间变化:存在离线 Worker 时定期用最近快照重渲染,
554
- // 保持离线时长读数新鲜(全在线时不动下拉框,避免打扰交互)
555
+ // 保持离线时长读数新鲜(全在线时不动卡片列表,避免打扰交互)
555
556
  setInterval(() => {
556
557
  if (workerList.length && !workerList.some((w) => w.online)) renderWorkers(workerList);
557
558
  }, 30_000);
558
559
 
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);
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 = '';
568
571
  for (const w of workers) {
569
- const opt = document.createElement('option');
570
- opt.value = w.workerId;
572
+ const card = document.createElement('div');
573
+ card.className = 'worker-card';
571
574
  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);
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
+ }
589
+ }
590
+
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; // 保持用户已选项
683
+ }
684
+ }
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);
605
710
 
606
711
  function createTaskEl(t) {
607
712
  const div = document.createElement('div');
@@ -612,7 +717,6 @@ function createTaskEl(t) {
612
717
  <span class="badge"></span>
613
718
  </div>
614
719
  <div class="meta"></div>
615
- <div class="term hidden"></div>
616
720
  <div class="logs"></div>
617
721
  <div class="actions">
618
722
  <button data-act="toggle">展开</button>
@@ -624,19 +728,13 @@ function createTaskEl(t) {
624
728
  el: div,
625
729
  badge: div.querySelector('.badge'),
626
730
  meta: div.querySelector('.meta'),
627
- termEl: div.querySelector('.term'),
628
731
  logsEl: div.querySelector('.logs'),
629
732
  toggle: div.querySelector('[data-act=toggle]'),
630
733
  term: div.querySelector('[data-act=term]'),
631
734
  cancel: div.querySelector('[data-act=cancel]'),
632
735
  status: null,
633
736
  metaText: null,
634
- termSig: null,
635
737
  };
636
- // 点 shell 视图本身也展开(手机上比点小按钮顺手)
637
- entry.termEl.addEventListener('click', () => {
638
- if (!openTasks.has(t.id)) entry.toggle.click();
639
- });
640
738
  entry.term.addEventListener('click', () => openTerm(t));
641
739
  entry.toggle.addEventListener('click', async () => {
642
740
  if (openTasks.has(t.id)) {
@@ -648,7 +746,7 @@ function createTaskEl(t) {
648
746
  await loadLogs(t.id);
649
747
  }
650
748
  entry.toggle.textContent = openTasks.has(t.id) ? '收起' : '展开';
651
- patchTerm(t, entry); // 展开时隐藏 shell 预览,收起时恢复
749
+ syncTaskRoute(); // 展开状态入 URL:长任务执行中刷新仍留在任务页面
652
750
  });
653
751
  entry.cancel.addEventListener('click', async () => {
654
752
  try {
@@ -675,63 +773,15 @@ function patchTask(t, entry) {
675
773
  const meta = `${t.type}${workerPart ? ' · ' + workerPart : ''} · ${fmtTime(t.createdAt)}${t.finishedAt ? ' → ' + fmtTime(t.finishedAt) : ''}`;
676
774
  if (entry.metaText !== meta) { entry.metaText = meta; entry.meta.textContent = meta; }
677
775
  entry.el.classList.toggle('open', openTasks.has(t.id));
678
- patchTerm(t, entry);
679
776
  // 展开且有缓存:内容没变就不动 DOM,保留滚动位置
680
777
  if (openTasks.has(t.id) && logCache.has(t.id)) writeLogs(t.id, logCache.get(t.id));
681
778
  }
682
779
 
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
780
  function renderTasks() {
733
781
  const list = $('task-list');
734
- 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);
735
785
  // 清理已不存在的任务节点
736
786
  const ids = new Set(tasks.map((t) => t.id));
737
787
  for (const [id, entry] of taskEls) {
@@ -785,8 +835,16 @@ function appendLogs(id, lines) {
785
835
 
786
836
  // ---------- 交互终端(pty 任务:xterm.js 渲染,IO 走 /ws 中继到 Worker 的 PTY)----------
787
837
  // 手机端无物理键盘:隐藏 textarea 承接击键,工具条补 Tab/方向键/Ctrl 组合键
788
- const termEntries = new Map(); // taskId -> { xt, fit, live, pendingAttach }
838
+ const termEntries = new Map(); // taskId -> { xt, fit, live, pendingAttach, liveHint }
789
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
+ };
790
848
  const KEY_SEQ = { BS: '\x7f', Tab: '\t', Esc: '\x1b', Up: '\x1b[A', Down: '\x1b[B', CtrlC: '\x03', CtrlD: '\x04' };
791
849
 
792
850
  function termStatus(ent) {
@@ -794,7 +852,7 @@ function termStatus(ent) {
794
852
  if (!ent) { el.textContent = ''; return; }
795
853
  if (!wsReady) el.textContent = '实时通道断开,重连中…';
796
854
  else if (ent.live) el.textContent = '已连接';
797
- else el.textContent = '会话未就绪(任务未领取或 Worker 离线)';
855
+ else el.textContent = `会话未就绪(${ent.liveHint || ATTACH_HINTS.unknown})`;
798
856
  }
799
857
 
800
858
  function ensureTerm(t) {
@@ -810,13 +868,22 @@ function ensureTerm(t) {
810
868
  });
811
869
  const fit = new FitAddon.FitAddon();
812
870
  xt.loadAddon(fit);
813
- xt.open($('term-body'));
814
- 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: '' };
815
877
  termEntries.set(t.id, ent);
816
878
  xt.onData((data) => sendWs({ type: 'session.input', taskId: t.id, data }));
817
879
  return ent;
818
880
  }
819
881
 
882
+ // 只显示当前终端的 pane,其余隐藏(实例不销毁,后台继续收输出)
883
+ function showTermPane(id) {
884
+ for (const [tid, e] of termEntries) e.pane.classList.toggle('active', tid === id);
885
+ }
886
+
820
887
  function fitActiveTerm() {
821
888
  const ent = termEntries.get(activeTermId);
822
889
  if (!ent) return;
@@ -830,6 +897,7 @@ function openTerm(t) {
830
897
  activeTermId = t.id;
831
898
  $('term-title').textContent = t.title || '交互终端';
832
899
  $('term-overlay').classList.remove('hidden');
900
+ showTermPane(t.id); // 先亮出本终端的 pane 再 fit(隐藏状态下 fit 拿不到尺寸)
833
901
  fitActiveTerm();
834
902
  ent.pendingAttach = true;
835
903
  sendWs({ type: 'pty.attach', taskId: t.id }); // 取回看缓冲 + 会话存活状态
@@ -851,6 +919,7 @@ function disposeTerm(id) {
851
919
  termEntries.delete(id);
852
920
  if (activeTermId === id) closeTermOverlay();
853
921
  try { ent.xt.dispose(); } catch { /* 已释放 */ }
922
+ ent.pane.remove();
854
923
  }
855
924
 
856
925
  // 输入代理里常驻一个哨兵字符:手机键盘在空 textarea 上按退格不产生任何事件,
@@ -917,55 +986,20 @@ async function refresh() {
917
986
  toast(err.message);
918
987
  }
919
988
  }
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
- });
989
+ setInterval(() => { if (!wsReady && !wsAuthRejected && hasSecret()) refresh(); }, 4000);
933
990
 
934
991
  // ---------- 提交任务(REST,WS 负责推送变更)----------
935
992
  $('btn-submit').addEventListener('click', async () => {
993
+ // 显式指派:二级视图下任务固定提交给当前 Worker
994
+ const targetWorker = currentWorkerId;
995
+ if (!targetWorker) return toast('请先在 Worker 列表中选择 Worker');
936
996
  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
- }
997
+ const payload = { project: $('f-pty-project').value.trim() || 'default' };
998
+ if (!/^[\w.-]+$/.test(payload.project)) return toast('项目目录名只能含字母/数字/._-');
961
999
  try {
962
1000
  $('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' ? '已提交,任务运行后点「进入终端」' : '任务已提交');
1001
+ await api('/api/tasks', { type: 'pty', title: title || '交互终端', payload, targetWorker });
1002
+ toast('已提交,任务运行后点「进入终端」');
969
1003
  $('f-title').value = '';
970
1004
  if (!wsReady) await refresh();
971
1005
  } catch (err) {
@@ -976,7 +1010,9 @@ $('btn-submit').addEventListener('click', async () => {
976
1010
  });
977
1011
 
978
1012
  connectWS();
979
- refresh(); // 首次加载兜底:WS 握手前的空档先用 REST 铺一次数据
1013
+ // 首次加载兜底:WS 握手前的空档先用 REST 铺一次数据(无密钥时不发请求)
1014
+ if (hasSecret()) refresh();
1015
+ else setConnMode(false); // 无密钥不发起任何连接,状态条直接落到轮询态
980
1016
  </script>
981
1017
  </body>
982
1018
  </html>