@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/pc.html CHANGED
@@ -18,8 +18,8 @@
18
18
  body {
19
19
  font-family: -apple-system, "PingFang SC", "Helvetica Neue", sans-serif;
20
20
  background: var(--bg); color: var(--text);
21
- padding: 20px 24px 40px;
22
- max-width: 1280px; margin: 0 auto;
21
+ padding: 20px 32px 40px;
22
+ max-width: 1600px; margin: 0 auto;
23
23
  }
24
24
  header { display: flex; align-items: center; justify-content: space-between; padding: 4px 2px 16px; gap: 8px; }
25
25
  h1 { font-size: 20px; font-weight: 600; }
@@ -37,10 +37,14 @@
37
37
  border-radius: 10px; padding: 10px 14px; font-size: 13px; line-height: 1.6; margin-bottom: 16px;
38
38
  }
39
39
 
40
- /* 双栏桌面布局:左栏提交任务,右栏任务流 */
41
- .layout { display: grid; grid-template-columns: 400px minmax(0, 1fr); gap: 18px; align-items: start; }
40
+ /* 双栏桌面布局:左栏提交任务,右栏任务流;
41
+ 一级视图(.one-col)退化为全宽单栏,Worker 卡片横向铺开,不挤在窄左栏 */
42
+ .layout { display: grid; grid-template-columns: 420px minmax(0, 1fr); gap: 18px; align-items: start; }
43
+ .layout.one-col { grid-template-columns: minmax(0, 1fr); }
42
44
  @media (max-width: 900px) { .layout { grid-template-columns: 1fr; } }
43
45
  .col-left { position: sticky; top: 16px; display: flex; flex-direction: column; gap: 16px; }
46
+ /* 全宽单栏时密钥面板拉通一行太长,限个舒适宽度 */
47
+ .layout.one-col #auth-panel { max-width: 640px; }
44
48
 
45
49
  .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; }
46
50
  label { display: block; font-size: 12px; color: var(--dim); margin: 12px 0 5px; }
@@ -52,13 +56,6 @@
52
56
  textarea { min-height: 84px; resize: vertical; }
53
57
  input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); }
54
58
 
55
- .types { display: flex; gap: 8px; margin-top: 4px; }
56
- .types button {
57
- flex: 1; padding: 8px 0; border-radius: 8px; border: 1px solid var(--line);
58
- background: var(--bg); color: var(--dim); font-size: 13px; cursor: pointer;
59
- }
60
- .types button.active { border-color: var(--accent); color: var(--accent); background: rgba(59,130,246,.12); }
61
-
62
59
  .submit {
63
60
  width: 100%; margin-top: 16px; padding: 11px;
64
61
  background: var(--accent); color: #fff; border: none; border-radius: 10px;
@@ -80,19 +77,10 @@
80
77
  .logs {
81
78
  margin-top: 10px; background: #0b1220; border-radius: 8px; padding: 10px;
82
79
  font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.6;
83
- max-height: 480px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;
80
+ max-height: min(60vh, 640px); overflow-y: auto; white-space: pre-wrap; word-break: break-all;
84
81
  color: #a5f3fc; display: none;
85
82
  }
86
83
  .task.open .logs { display: block; }
87
- /* shell 视图:默认只露出命令回显 + 末尾 5 行 stdout,接近终端体验;
88
- 元信息/结果/更早输出要点「展开」才可见 */
89
- .term {
90
- margin-top: 10px; background: #0b1220; border-radius: 8px; padding: 10px;
91
- font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.6;
92
- white-space: pre-wrap; word-break: break-all; color: #a5f3fc; cursor: pointer;
93
- }
94
- .term .prompt { color: var(--ok); }
95
- .term .dimline { color: var(--dim); }
96
84
  .task .actions { margin-top: 10px; display: flex; gap: 8px; }
97
85
  .task .actions button {
98
86
  font-size: 13px; padding: 5px 16px; border-radius: 6px;
@@ -107,11 +95,46 @@
107
95
  display: flex; flex-direction: column;
108
96
  }
109
97
  #term-overlay.hidden { display: none; }
110
- #term-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; }
111
- #term-title { font-size: 14px; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
112
- #term-status { flex: 1; font-size: 12px; color: var(--dim); text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
98
+ #term-head { display: flex; align-items: center; gap: 10px; padding: 8px 16px; }
99
+ /* tab 条:每个打开的终端一个 tab,非当前终端有新输出时亮活动圆点 */
100
+ #term-tabs { flex: 1; display: flex; gap: 6px; overflow-x: auto; min-width: 0; }
101
+ .term-tab {
102
+ display: flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 8px;
103
+ border: 1px solid var(--line); background: var(--panel); color: var(--dim);
104
+ font-size: 13px; cursor: pointer; white-space: nowrap; max-width: 220px;
105
+ }
106
+ .term-tab .tt-title { overflow: hidden; text-overflow: ellipsis; }
107
+ .term-tab.active { border-color: var(--accent); color: var(--text); background: rgba(59,130,246,.12); }
108
+ .term-tab .tt-dot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--warn); display: none; }
109
+ .term-tab.dirty .tt-dot { display: block; }
110
+ #term-layout-btns { display: flex; gap: 6px; }
111
+ #term-layout-btns button {
112
+ font-size: 12px; padding: 4px 12px; border-radius: 6px; cursor: pointer;
113
+ background: var(--panel); color: var(--dim); border: 1px solid var(--line);
114
+ }
115
+ #term-layout-btns button.active { border-color: var(--accent); color: var(--accent); background: rgba(59,130,246,.12); }
113
116
  #term-close { font-size: 13px; padding: 5px 16px; border-radius: 6px; background: var(--panel); color: var(--text); border: 1px solid var(--line); cursor: pointer; }
114
- #term-body { flex: 1; margin: 0 16px 16px; min-height: 0; }
117
+ /* 单屏模式:pane 绝对定位叠层,只显示 active 的一个;
118
+ 并排(grid)模式:所有终端按 CSS grid 平铺,同时盯多个 pty */
119
+ #term-body { flex: 1; margin: 0 16px 16px; min-height: 0; position: relative; }
120
+ #term-body.grid {
121
+ display: grid; gap: 10px;
122
+ grid-template-columns: repeat(var(--cols, 2), minmax(0, 1fr));
123
+ grid-auto-rows: minmax(0, 1fr);
124
+ }
125
+ .term-pane { position: absolute; inset: 0; display: none; flex-direction: column; }
126
+ .term-pane.active { display: flex; }
127
+ #term-body.grid .term-pane {
128
+ position: relative; inset: auto; display: flex; min-height: 0;
129
+ border: 1px solid var(--line); border-radius: 8px; overflow: hidden;
130
+ }
131
+ #term-body.grid .term-pane.active { border-color: var(--accent); }
132
+ .tp-head { display: flex; align-items: center; gap: 10px; padding: 6px 10px; background: var(--panel); flex: none; }
133
+ .tp-title { font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 30%; }
134
+ .tp-status { flex: 1; font-size: 11px; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
135
+ .tp-close { flex: none; border: none; background: none; color: var(--dim); font-size: 14px; cursor: pointer; padding: 0 4px; }
136
+ .tp-close:hover { color: var(--err); }
137
+ .tp-term { flex: 1; min-height: 0; }
115
138
  #auth-bar { display: flex; justify-content: space-between; align-items: center; cursor: pointer; }
116
139
  #auth-bar .title { font-size: 13px; color: var(--ok); }
117
140
  #auth-bar .edit { font-size: 12px; color: var(--dim); }
@@ -130,6 +153,27 @@
130
153
  background: #000c; color: #fff; padding: 9px 18px; border-radius: 8px;
131
154
  font-size: 13px; opacity: 0; transition: opacity .2s; pointer-events: none;
132
155
  }
156
+ /* 两级视图:一级 Worker 列表 / 二级所选 Worker 详情 */
157
+ #back-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
158
+ #btn-back {
159
+ padding: 6px 14px; border: 1px solid var(--line); border-radius: 8px;
160
+ background: var(--panel); color: var(--text); font-size: 13px; cursor: pointer;
161
+ }
162
+ #btn-back:hover { border-color: var(--accent); color: var(--accent); }
163
+ #worker-title { font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
164
+ #worker-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
165
+ .worker-card {
166
+ background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
167
+ padding: 14px; cursor: pointer; transition: border-color .15s;
168
+ }
169
+ .worker-card:hover { border-color: var(--accent); }
170
+ .worker-card .wc-row1 { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
171
+ .worker-card .wc-name { font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
172
+ .worker-card .wc-meta { font-size: 12px; color: var(--dim); margin-top: 6px; line-height: 1.5; word-break: break-all; }
173
+ .w-online { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: rgba(34,197,94,.15); color: var(--ok); white-space: nowrap; }
174
+ .w-offline { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: rgba(239,68,68,.15); color: var(--err); white-space: nowrap; }
175
+ .empty-tip { color: var(--dim); font-size: 13px; text-align: center; padding: 40px 0; }
176
+
133
177
  .hidden { display: none !important; }
134
178
  </style>
135
179
  </head>
@@ -148,7 +192,7 @@
148
192
  请勿在不信任的网络(如公共 WiFi)使用,并尽快为服务端配置 HTTPS(如 Caddy 自动证书)。
149
193
  </div>
150
194
 
151
- <div class="layout">
195
+ <div class="layout one-col" id="layout">
152
196
  <div class="col-left">
153
197
 
154
198
  <!-- 鉴权设置:密钥已存 localStorage 时折叠成一行,点「修改」才展开 -->
@@ -167,62 +211,48 @@
167
211
  </div>
168
212
  </div>
169
213
 
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>
214
+ <!-- 一级视图:Worker 列表,选中某个 Worker 进入二级详情 -->
215
+ <div id="view-workers">
216
+ <div id="worker-cards"></div>
217
+ <div id="workers-empty" class="empty-tip hidden">暂无已注册 Worker</div>
218
+ </div>
219
+
220
+ <!-- 二级视图:所选 Worker 的新建任务 -->
221
+ <div id="view-worker" class="hidden">
222
+ <div id="back-row">
223
+ <button id="btn-back" type="button">‹ Worker 列表</button>
224
+ <span id="worker-title"></span>
178
225
  </div>
179
226
 
227
+ <!-- 新建任务(仅 pty 交互终端) -->
228
+ <div class="panel">
180
229
  <label>标题</label>
181
230
  <input id="f-title" placeholder="一句话描述这个任务">
182
231
 
183
- <div id="fields-agent">
184
- <label>Agent(Worker 上报的可用 CLI)</label>
185
- <select id="f-agent"><option value="claude">claude</option></select>
186
- <label>Prompt(交给编码 Agent 的指令)</label>
187
- <textarea id="f-prompt" placeholder="例:把 README 里的安装步骤补全,然后跑一遍测试"></textarea>
188
- <label>项目目录名(相对 workspace)</label>
189
- <input id="f-project" value="default">
190
- </div>
191
- <div id="fields-shell" class="hidden">
192
- <label>命令(在 workspace 目录执行)</label>
193
- <textarea id="f-command" placeholder="例:git -C myrepo pull && npm test"></textarea>
194
- </div>
195
- <div id="fields-pty" class="hidden">
196
- <label>项目目录名(相对 workspace,终端的起始目录)</label>
197
- <input id="f-pty-project" value="default">
198
- <label style="color:var(--dim)">提交后点任务卡上的「进入终端」开始交互</label>
199
- </div>
200
- <div id="fields-script" class="hidden">
201
- <label>脚本名(worker/scripts 目录下的白名单脚本)</label>
202
- <input id="f-script" placeholder="例:backup.sh">
203
- <label>参数(空格分隔)</label>
204
- <input id="f-args" placeholder="例:--full /tmp/out">
205
- </div>
206
- <div id="fields-chat" class="hidden">
207
- <label>留言内容</label>
208
- <textarea id="f-message" placeholder="记一条备忘,或触发 CHAT_HOOK"></textarea>
209
- </div>
232
+ <label>项目目录(终端的起始目录,Worker workspace 下已有目录)</label>
233
+ <select id="f-pty-project"><option value="default">default</option></select>
234
+ <label style="color:var(--dim)">提交后点任务卡上的「进入终端」开始交互</label>
210
235
 
211
236
  <button class="submit" id="btn-submit">提交任务</button>
212
- </div>
237
+ </div>
238
+ </div><!-- /view-worker -->
213
239
 
214
240
  </div><!-- /.col-left -->
215
241
 
216
- <!-- 任务列表 -->
217
- <div id="task-list"></div>
242
+ <!-- 任务列表(仅所选 Worker,进入二级视图后显示在右侧) -->
243
+ <div id="task-list" class="hidden"></div>
218
244
 
219
245
  </div><!-- /.layout -->
220
246
 
221
- <!-- 交互终端覆盖层(pty 任务):xterm.js 渲染,输入经 /ws 下行到 Worker 的 PTY -->
247
+ <!-- 交互终端覆盖层(pty 任务):xterm.js 渲染,输入经 /ws 下行到 Worker 的 PTY
248
+ tab 条 + 单屏/并排布局切换(并排时多个 pty 同屏平铺),活动圆点标记非当前终端的新输出 -->
222
249
  <div id="term-overlay" class="hidden">
223
250
  <div id="term-head">
224
- <span id="term-title"></span>
225
- <span id="term-status"></span>
251
+ <div id="term-tabs"></div>
252
+ <div id="term-layout-btns">
253
+ <button type="button" data-layout="single">单屏</button>
254
+ <button type="button" data-layout="grid">并排</button>
255
+ </div>
226
256
  <button id="term-close" type="button">收起</button>
227
257
  </div>
228
258
  <div id="term-body"></div>
@@ -334,7 +364,17 @@ function signHeaders(method, path, bodyStr) {
334
364
  return { 'X-RW-Ts': String(ts), 'X-RW-Nonce': nonce, 'X-RW-Sig': sig };
335
365
  }
336
366
 
367
+ // 未配置密钥时禁止发起任何需要签名的请求:空密钥签名必 401,
368
+ // 还会白刷服务端验签失败限流计数,统一在本地拦截
369
+ function hasSecret() {
370
+ return secretInput.value.trim() !== '';
371
+ }
372
+ function requireSecret() {
373
+ if (!hasSecret()) throw new Error('请先配置 API Secret');
374
+ }
375
+
337
376
  async function api(path, body) {
377
+ requireSecret();
338
378
  const method = body === undefined ? 'GET' : 'POST';
339
379
  const bodyStr = body === undefined ? '' : JSON.stringify(body);
340
380
  const res = await fetch(path, {
@@ -361,7 +401,10 @@ let wsAuthRejected = false; // 验签被拒后停止重连,直到密钥变
361
401
  secretInput.addEventListener('change', () => {
362
402
  localStorage.setItem('rw_secret', secretInput.value.trim());
363
403
  wsAuthRejected = false; // 密钥换了,允许重新尝试实时连接
364
- if (!wsReady) connectWS();
404
+ if (hasSecret()) {
405
+ if (!wsReady) connectWS();
406
+ refresh(); // 之前无密钥没拉过数据,配好后补一次
407
+ }
365
408
  });
366
409
 
367
410
  // 密钥已配置时收起输入区,只留一行状态条;点状态条可展开修改
@@ -404,7 +447,7 @@ function setConnMode(realtime) {
404
447
  }
405
448
 
406
449
  function connectWS() {
407
- if (wsAuthRejected) return;
450
+ if (wsAuthRejected || !hasSecret()) return; // 无密钥时握手必被 4003 拒绝,不发起
408
451
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
409
452
  const sock = new WebSocket(`${proto}://${location.host}/ws`);
410
453
  curWs = sock;
@@ -441,7 +484,6 @@ function connectWS() {
441
484
  renderTasks();
442
485
  break;
443
486
  case 'task.logs':
444
- mergePreview(msg.id, msg.lines); // 未展开时也要刷新 shell 视图
445
487
  appendLogs(msg.id, msg.lines);
446
488
  break;
447
489
  case 'workers':
@@ -450,7 +492,13 @@ function connectWS() {
450
492
  // --- pty 交互终端流 ---
451
493
  case 'pty.data': {
452
494
  const ent = termEntries.get(msg.id);
453
- if (ent) { ent.xt.write(msg.data); ent.live = true; termStatus(ent); }
495
+ if (ent) {
496
+ ent.xt.write(msg.data);
497
+ ent.live = true;
498
+ // 非当前终端有新输出:tab 亮活动圆点,切过去后熄灭
499
+ if (msg.id !== activeTermId) { ent.dirty = true; ent.tab.classList.add('dirty'); }
500
+ termStatus(ent);
501
+ }
454
502
  break;
455
503
  }
456
504
  case 'pty.started': {
@@ -477,7 +525,8 @@ function connectWS() {
477
525
  ent.pendingAttach = false;
478
526
  if (msg.tail) ent.xt.write(msg.tail); // 回看缓冲:切后台回来不白屏
479
527
  ent.live = msg.live;
480
- if (!msg.live) ent.xt.write('\r\n\x1b[33m[会话未就绪:任务尚未被领取或 Worker 离线,请稍候…]\x1b[0m\r\n');
528
+ ent.liveHint = msg.live ? '' : (ATTACH_HINTS[msg.reason] || ATTACH_HINTS.unknown);
529
+ if (!msg.live) ent.xt.write(`\r\n\x1b[33m[会话未就绪:${ent.liveHint}]\x1b[0m\r\n`);
481
530
  termStatus(ent);
482
531
  }
483
532
  break;
@@ -487,7 +536,7 @@ function connectWS() {
487
536
  sock.addEventListener('close', (ev) => {
488
537
  wsReady = false;
489
538
  setConnMode(false);
490
- if (termEntries.size) termStatus(termEntries.get(activeTermId) || termEntries.values().next().value);
539
+ for (const ent of termEntries.values()) termStatus(ent); // 各打开终端的状态条都显示重连中
491
540
  if (ev.code === 4003) {
492
541
  // 验签被拒:无意义重试只会刷服务端日志,等用户改密钥
493
542
  wsAuthRejected = true;
@@ -510,31 +559,194 @@ function fmtTime(ts) {
510
559
  `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
511
560
  }
512
561
 
562
+ let workerList = []; // 心跳下发的 Worker 快照;目标 Worker 与项目目录下拉都基于它
563
+
564
+ function relTime(ts) {
565
+ const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
566
+ if (s < 60) return `${s} 秒前`;
567
+ if (s < 3600) return `${Math.round(s / 60)} 分钟前`;
568
+ if (s < 86400) return `${Math.round(s / 3600)} 小时前`;
569
+ return `${Math.round(s / 86400)} 天前`;
570
+ }
571
+
513
572
  function renderWorkers(workers) {
573
+ workerList = workers;
514
574
  const ws = $('worker-status');
515
- const online = workers.find((w) => w.online);
516
- const text = online ? `Mac Mini 在线(${online.workerId})` : 'Mac Mini 离线';
575
+ const onlineCount = workers.filter((w) => w.online).length;
576
+ const text = workers.length ? `${onlineCount}/${workers.length} 台 Worker 在线` : '无已注册 Worker';
517
577
  if (ws.textContent !== text) ws.textContent = text; // 没变就不写,避免无谓重绘
518
- ws.className = online ? 'online' : 'offline';
519
- renderAgentOptions(workers);
578
+ ws.className = onlineCount ? 'online' : 'offline';
579
+ // 当前所在二级视图的 Worker 已注销(如配置移除):退回一级列表
580
+ if (currentWorkerId && !workers.some((w) => w.workerId === currentWorkerId)) navBack();
581
+ renderWorkerCards();
582
+ // 首份快照到达后按地址栏 hash 恢复视图(刷新进入二级页/展开的长任务)
583
+ if (!routeInited) { routeInited = true; applyRoute(); }
584
+ }
585
+
586
+ // 一级视图 Worker 卡片:sig 去重,心跳快照没变就不重建(避免打断点击)
587
+ let cardsSig = '';
588
+ function renderWorkerCards() {
589
+ $('workers-empty').classList.toggle('hidden', workerList.length > 0);
590
+ const sig = JSON.stringify(workerList.map((w) => [
591
+ w.workerId, w.hostname, w.online, w.lastSeenAt, w.projects || [],
592
+ ]));
593
+ if (sig === cardsSig) return;
594
+ cardsSig = sig;
595
+ const box = $('worker-cards');
596
+ box.textContent = '';
597
+ for (const w of workerList) {
598
+ const card = document.createElement('div');
599
+ card.className = 'worker-card';
600
+ const name = w.hostname && w.hostname !== w.workerId ? `${w.workerId}(${w.hostname})` : w.workerId;
601
+ const status = w.online ? '在线' : `离线(最近心跳 ${relTime(w.lastSeenAt)})`;
602
+ card.innerHTML = `
603
+ <div class="wc-row1">
604
+ <span class="wc-name"></span>
605
+ <span class="${w.online ? 'w-online' : 'w-offline'}"></span>
606
+ </div>
607
+ <div class="wc-meta"></div>`;
608
+ card.querySelector('.wc-name').textContent = name; // textContent 天然防注入
609
+ card.querySelector('.wc-row1 span:last-child').textContent = status;
610
+ const projects = [...new Set(w.projects || [])];
611
+ card.querySelector('.wc-meta').textContent = projects.length ? `项目目录:${projects.join(' / ')}` : '未上报项目目录';
612
+ card.addEventListener('click', () => navToWorker(w.workerId));
613
+ box.appendChild(card);
614
+ }
615
+ }
616
+
617
+ // ---------- 两级视图导航:一级 Worker 列表 / 二级所选 Worker 详情 ----------
618
+ let currentWorkerId = null;
619
+ let routeInited = false; // 首份 Worker 快照到达前 hash 恢复无法执行(卡片还没渲染)
620
+ // pushRoute=false 时只切视图不动地址栏(来自 hashchange 的同步,避免二次入栈)
621
+ function navToWorker(workerId, pushRoute = true) {
622
+ const w = workerList.find((x) => x.workerId === workerId);
623
+ if (!w) return;
624
+ currentWorkerId = workerId;
625
+ $('worker-title').textContent = w.hostname && w.hostname !== workerId ? `${workerId}(${w.hostname})` : workerId;
626
+ $('view-workers').classList.add('hidden');
627
+ $('view-worker').classList.remove('hidden');
628
+ $('task-list').classList.remove('hidden'); // 二级视图才展示右侧任务列表
629
+ $('layout').classList.remove('one-col'); // 进入二级视图恢复双栏
630
+ if (pushRoute) pushRoute_();
631
+ updateSubmitState();
632
+ renderProjectOptions();
633
+ renderTasks(); // 切换到该 Worker 的任务过滤
634
+ }
635
+ function navBack(pushRoute = true) {
636
+ currentWorkerId = null;
637
+ $('view-worker').classList.add('hidden');
638
+ $('view-workers').classList.remove('hidden');
639
+ $('task-list').classList.add('hidden');
640
+ $('layout').classList.add('one-col'); // 回到一级视图:全宽铺开 Worker 卡片
641
+ if (pushRoute) pushRoute_();
642
+ renderTasks(); // 离开二级视图时清掉过滤后的任务节点
643
+ }
644
+ $('btn-back').addEventListener('click', () => navBack());
645
+
646
+ // ---------- hash 路由:#/w/<workerId>(附 /t/<展开的任务 id>)----------
647
+ // 进入二级视图同步地址栏:刷新留在当前页面;长任务的展开状态也随 URL 保留
648
+ function route() {
649
+ const parts = location.hash.slice(1).split('/').filter(Boolean);
650
+ return {
651
+ worker: parts[0] === 'w' ? decodeURIComponent(parts[1] || '') : null,
652
+ tasks: parts[2] === 't' ? (parts[3] || '').split('.').filter(Boolean) : [],
653
+ };
654
+ }
655
+ function buildHash() {
656
+ if (!currentWorkerId) return '#/';
657
+ // 只把当前视图可见(属于该 Worker)的展开任务写进 URL
658
+ const open = [...openTasks].filter((id) => {
659
+ const t = taskCache.get(id);
660
+ return t && (t.workerId === currentWorkerId || t.targetWorker === currentWorkerId);
661
+ });
662
+ const base = `#/w/${encodeURIComponent(currentWorkerId)}`;
663
+ return open.length ? `${base}/t/${open.join('.')}` : base;
664
+ }
665
+ function pushRoute_() {
666
+ const h = buildHash();
667
+ if (location.hash === h) return;
668
+ history.pushState(null, '', h);
669
+ }
670
+ // 展开/收起只替换当前历史条目:不给返回键堆一堆中间状态
671
+ function syncTaskRoute() {
672
+ history.replaceState(null, '', buildHash());
673
+ }
674
+ function applyRoute() {
675
+ const r = route();
676
+ if (r.worker && r.worker !== currentWorkerId) navToWorker(r.worker, false);
677
+ else if (!r.worker && currentWorkerId) navBack(false);
678
+ for (const id of r.tasks) { // 刷新后恢复长任务的展开(日志区)
679
+ if (openTasks.has(id) || !taskEls.has(id)) continue;
680
+ openTasks.add(id);
681
+ const entry = taskEls.get(id);
682
+ entry.el.classList.add('open');
683
+ entry.toggle.textContent = '收起';
684
+ loadLogs(id);
685
+ }
686
+ }
687
+ window.addEventListener('hashchange', applyRoute);
688
+
689
+ function updateSubmitState() {
690
+ $('btn-submit').disabled = !currentWorkerId;
691
+ }
692
+
693
+ // 项目目录下拉数据源:只有所选 Worker 的上报(显式指派,不做跨机并集)
694
+ function selectedWorker() {
695
+ return workerList.find((w) => w.workerId === currentWorkerId) || null;
696
+ }
697
+
698
+ // 项目目录下拉:列 Worker 心跳上报的 workspace 已有目录;末尾常驻
699
+ // 「+ 新建目录…」,输入的新目录在任务执行时由 Worker 自动创建
700
+ // (pty 执行前会 mkdir)
701
+ const NEW_PROJECT = '__new__';
702
+ const projectSels = ['f-pty-project'];
703
+
704
+ function renderProjectOptions() {
705
+ const w = selectedWorker();
706
+ const projects = [...new Set(w ? (w.projects || []) : [])].sort();
707
+ if (!projects.length) return; // Worker 尚未上报,保留默认 default 选项
708
+ for (const id of projectSels) {
709
+ const sel = $(id);
710
+ const prev = sel.value === NEW_PROJECT ? 'default' : sel.value;
711
+ sel.textContent = '';
712
+ for (const p of projects) {
713
+ const opt = document.createElement('option');
714
+ opt.value = p;
715
+ opt.textContent = p;
716
+ sel.appendChild(opt);
717
+ }
718
+ const newOpt = document.createElement('option');
719
+ newOpt.value = NEW_PROJECT;
720
+ newOpt.textContent = '+ 新建目录…';
721
+ sel.appendChild(newOpt);
722
+ if ([...sel.options].some((o) => o.value === prev)) sel.value = prev; // 保持用户已选项
723
+ }
520
724
  }
521
725
 
522
- // Agent 下拉:优先在线 Worker 的列表;都离线时用最近一次上报兜底
523
- function renderAgentOptions(workers) {
524
- const src = workers.filter((w) => w.online);
525
- const agents = [...new Set((src.length ? src : workers).flatMap((w) => w.agents || []))];
526
- if (!agents.length) return; // Worker 尚未上报,保留默认 claude 选项
527
- const sel = $('f-agent');
528
- const prev = sel.value;
529
- sel.textContent = '';
530
- for (const a of agents) {
531
- const opt = document.createElement('option');
532
- opt.value = a;
533
- opt.textContent = a;
534
- sel.appendChild(opt);
726
+ // 选「+ 新建目录…」:弹输入框收目录名,合法则加入两个下拉并选中;
727
+ // 取消或非法则退回原选项,NEW_PROJECT 值不会进入提交 payload
728
+ function handleNewProject(ev) {
729
+ const sel = ev.target;
730
+ if (sel.value !== NEW_PROJECT) return;
731
+ const fallback = [...sel.options].find((o) => o.value !== NEW_PROJECT)?.value || '';
732
+ const name = (window.prompt('新项目目录名(任务执行时在 Worker workspace 下自动创建)') || '').trim();
733
+ if (!name || !/^[\w.-]+$/.test(name) || name.includes('..')) {
734
+ if (name) toast('目录名只能含字母/数字/._-,且不含 ..');
735
+ sel.value = fallback;
736
+ return;
737
+ }
738
+ for (const id of projectSels) {
739
+ const s = $(id);
740
+ if (![...s.options].some((o) => o.value === name)) {
741
+ const opt = document.createElement('option');
742
+ opt.value = name;
743
+ opt.textContent = name;
744
+ s.insertBefore(opt, s.querySelector(`option[value="${NEW_PROJECT}"]`));
745
+ }
746
+ s.value = name;
535
747
  }
536
- if ([...sel.options].some((o) => o.value === prev)) sel.value = prev; // 保持用户已选项
537
748
  }
749
+ for (const id of projectSels) $(id).addEventListener('change', handleNewProject);
538
750
 
539
751
  function createTaskEl(t) {
540
752
  const div = document.createElement('div');
@@ -545,7 +757,6 @@ function createTaskEl(t) {
545
757
  <span class="badge"></span>
546
758
  </div>
547
759
  <div class="meta"></div>
548
- <div class="term hidden"></div>
549
760
  <div class="logs"></div>
550
761
  <div class="actions">
551
762
  <button data-act="toggle">展开</button>
@@ -557,18 +768,13 @@ function createTaskEl(t) {
557
768
  el: div,
558
769
  badge: div.querySelector('.badge'),
559
770
  meta: div.querySelector('.meta'),
560
- termEl: div.querySelector('.term'),
561
771
  logsEl: div.querySelector('.logs'),
562
772
  toggle: div.querySelector('[data-act=toggle]'),
563
773
  term: div.querySelector('[data-act=term]'),
564
774
  cancel: div.querySelector('[data-act=cancel]'),
565
775
  status: null,
566
776
  metaText: null,
567
- termSig: null,
568
777
  };
569
- entry.termEl.addEventListener('click', () => {
570
- if (!openTasks.has(t.id)) entry.toggle.click();
571
- });
572
778
  entry.term.addEventListener('click', () => openTerm(t));
573
779
  entry.toggle.addEventListener('click', async () => {
574
780
  if (openTasks.has(t.id)) {
@@ -580,7 +786,7 @@ function createTaskEl(t) {
580
786
  await loadLogs(t.id);
581
787
  }
582
788
  entry.toggle.textContent = openTasks.has(t.id) ? '收起' : '展开';
583
- patchTerm(t, entry); // 展开时隐藏 shell 预览,收起时恢复
789
+ syncTaskRoute(); // 展开状态入 URL:长任务执行中刷新仍留在任务页面
584
790
  });
585
791
  entry.cancel.addEventListener('click', async () => {
586
792
  try {
@@ -602,66 +808,20 @@ function patchTask(t, entry) {
602
808
  entry.toggle.textContent = openTasks.has(t.id) ? '收起' : '展开';
603
809
  }
604
810
  entry.term.classList.toggle('hidden', t.type !== 'pty'); // pty 任务入口
605
- const meta = `${t.type} · ${fmtTime(t.createdAt)}${t.finishedAt ? ' → ' + fmtTime(t.finishedAt) : ''}`;
811
+ // Worker 标注:已领取显示执行者,pending 显示指派目标
812
+ const workerPart = t.workerId ? `@${t.workerId}` : (t.targetWorker ? `→ ${t.targetWorker}` : '');
813
+ const meta = `${t.type}${workerPart ? ' · ' + workerPart : ''} · ${fmtTime(t.createdAt)}${t.finishedAt ? ' → ' + fmtTime(t.finishedAt) : ''}`;
606
814
  if (entry.metaText !== meta) { entry.metaText = meta; entry.meta.textContent = meta; }
607
815
  entry.el.classList.toggle('open', openTasks.has(t.id));
608
- patchTerm(t, entry);
609
816
  // 展开且有缓存:内容没变就不动 DOM,保留滚动位置
610
817
  if (openTasks.has(t.id) && logCache.has(t.id)) writeLogs(t.id, logCache.get(t.id));
611
818
  }
612
819
 
613
- // shell 视图:$ 命令回显 + 末尾最多 5 行 stdout;展开后让位给全量日志。
614
- // 用 sig 跳过无变化的重绘,避免日志流式到达时整卡闪烁。
615
- function patchTerm(t, entry) {
616
- const open = openTasks.has(t.id);
617
- const tail = Array.isArray(t.tail) ? t.tail : [];
618
- const sig = `${open}|${t.cmd || ''}|${t.outLines || 0}|${tail.join('\n')}`;
619
- if (entry.termSig === sig) return;
620
- entry.termSig = sig;
621
- entry.termEl.textContent = '';
622
- if (open || (!t.cmd && !tail.length)) { entry.termEl.classList.add('hidden'); return; }
623
- entry.termEl.classList.remove('hidden');
624
- const omitted = (t.outLines || 0) - tail.length;
625
- if (omitted > 0) {
626
- const d = document.createElement('div');
627
- d.className = 'dimline';
628
- d.textContent = `… 更早 ${omitted} 行输出已折叠,点击展开`;
629
- entry.termEl.appendChild(d);
630
- }
631
- if (t.cmd) {
632
- const line = document.createElement('div');
633
- const p = document.createElement('span');
634
- p.className = 'prompt';
635
- p.textContent = '$ ';
636
- line.appendChild(p);
637
- line.appendChild(document.createTextNode(t.cmd.startsWith('$ ') ? t.cmd.slice(2) : t.cmd));
638
- entry.termEl.appendChild(line);
639
- }
640
- for (const l of tail) {
641
- const line = document.createElement('div');
642
- line.textContent = l;
643
- entry.termEl.appendChild(line);
644
- }
645
- }
646
-
647
- // 任务运行中:WS 日志流增量维护预览(规则与 server 端 shellPreview 一致)
648
- function mergePreview(id, lines) {
649
- const t = taskCache.get(id);
650
- if (!t) return;
651
- let changed = false;
652
- for (const line of lines) {
653
- if (line.startsWith('$ ')) { if (!t.cmd) { t.cmd = line; changed = true; } continue; }
654
- if (line.startsWith('[worker]') || line.startsWith('[server]') || line === '') continue;
655
- t.outLines = (t.outLines || 0) + 1;
656
- t.tail = [...(t.tail || []), line].slice(-5);
657
- changed = true;
658
- }
659
- if (changed) renderTasks();
660
- }
661
-
662
820
  function renderTasks() {
663
821
  const list = $('task-list');
664
- const tasks = [...taskCache.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, 50);
822
+ const tasks = [...taskCache.values()]
823
+ .filter((t) => !currentWorkerId || t.workerId === currentWorkerId || t.targetWorker === currentWorkerId)
824
+ .sort((a, b) => b.createdAt - a.createdAt).slice(0, 50);
665
825
  // 清理已不存在的任务节点
666
826
  const ids = new Set(tasks.map((t) => t.id));
667
827
  for (const [id, entry] of taskEls) {
@@ -715,15 +875,26 @@ function appendLogs(id, lines) {
715
875
 
716
876
  // ---------- 交互终端(pty 任务:xterm.js 渲染,IO 走 /ws 中继到 Worker 的 PTY)----------
717
877
  // PC 有物理键盘:xterm 聚焦后直接承接按键,无需手机端的隐藏输入代理与按键工具条
718
- const termEntries = new Map(); // taskId -> { xt, fit, live, pendingAttach }
878
+ // 布局借鉴 mwt LayoutManager:单屏(叠层只显示一个)/ 并排(grid 平铺同盯多个 pty),
879
+ // tab 活动圆点标记非当前终端的新输出;偏好存 localStorage
880
+ const termEntries = new Map(); // taskId -> { xt, fit, pane, tab, statusEl, live, pendingAttach, liveHint, dirty, attached }
719
881
  let activeTermId = null;
882
+ let termLayout = localStorage.getItem('rw_termLayout') === 'grid' ? 'grid' : 'single';
883
+ // 会话未就绪时的原因文案(与服务端 pty.attach 应答的 reason 对应)
884
+ const ATTACH_HINTS = {
885
+ not_claimed: '任务尚未被领取,等待 Worker 上线领取…',
886
+ worker_offline: 'Worker 离线(通道断开),重连后可自动恢复…',
887
+ starting: '任务已被领取,会话启动中,请稍候…',
888
+ ended: '会话已结束或已被回收',
889
+ unknown: '任务尚未被领取或 Worker 离线,请稍候…',
890
+ };
720
891
 
892
+ // 状态条挂在各终端自己的 pane 头部(并排时各有各的状态)
721
893
  function termStatus(ent) {
722
- const el = $('term-status');
723
- if (!ent) { el.textContent = ''; return; }
724
- if (!wsReady) el.textContent = '实时通道断开,重连中…';
725
- else if (ent.live) el.textContent = '已连接';
726
- else el.textContent = '会话未就绪(任务未领取或 Worker 离线)';
894
+ if (!ent || !ent.statusEl) return;
895
+ if (!wsReady) ent.statusEl.textContent = '实时通道断开,重连中…';
896
+ else if (ent.live) ent.statusEl.textContent = '已连接';
897
+ else ent.statusEl.textContent = `会话未就绪(${ent.liveHint || ATTACH_HINTS.unknown})`;
727
898
  }
728
899
 
729
900
  function ensureTerm(t) {
@@ -739,37 +910,108 @@ function ensureTerm(t) {
739
910
  });
740
911
  const fit = new FitAddon.FitAddon();
741
912
  xt.loadAddon(fit);
742
- xt.open($('term-body'));
743
- ent = { xt, fit, live: false, pendingAttach: false };
913
+ // 每个终端独占一个 pane(标题栏 + 渲染区):挂在同一容器里会让多实例叠在一起串台
914
+ const pane = document.createElement('div');
915
+ pane.className = 'term-pane';
916
+ pane.innerHTML = `
917
+ <div class="tp-head">
918
+ <span class="tp-title"></span>
919
+ <span class="tp-status"></span>
920
+ <button class="tp-close" type="button" title="关闭该终端">✕</button>
921
+ </div>
922
+ <div class="tp-term"></div>`;
923
+ pane.querySelector('.tp-title').textContent = t.title || '交互终端'; // textContent 天然防注入
924
+ pane.querySelector('.tp-close').addEventListener('click', (ev) => {
925
+ ev.stopPropagation();
926
+ disposeTerm(t.id); // 只释放本地实例,任务卡上「进入终端」可随时重进(pty.attach 回补缓冲)
927
+ });
928
+ pane.addEventListener('mousedown', () => { // 并排时点非当前 pane 即切焦点过去
929
+ if (activeTermId !== t.id) activateTerm(t.id);
930
+ });
931
+ $('term-body').appendChild(pane);
932
+ xt.open(pane.querySelector('.tp-term'));
933
+ // 顶部同步一个 tab:点击切换,活动圆点标记后台新输出
934
+ const tab = document.createElement('div');
935
+ tab.className = 'term-tab';
936
+ tab.innerHTML = '<span class="tt-dot"></span><span class="tt-title"></span>';
937
+ tab.querySelector('.tt-title').textContent = t.title || '交互终端';
938
+ tab.addEventListener('click', () => activateTerm(t.id));
939
+ $('term-tabs').appendChild(tab);
940
+ ent = {
941
+ xt, fit, pane, tab, statusEl: pane.querySelector('.tp-status'),
942
+ live: false, pendingAttach: false, liveHint: '', dirty: false, attached: false,
943
+ };
744
944
  termEntries.set(t.id, ent);
745
945
  xt.onData((data) => sendWs({ type: 'session.input', taskId: t.id, data }));
746
946
  return ent;
747
947
  }
748
948
 
749
- function fitActiveTerm() {
750
- const ent = termEntries.get(activeTermId);
949
+ // 切换当前终端:点亮对应 tab 与 pane,熄灭活动圆点,聚焦承接输入
950
+ function activateTerm(id) {
951
+ const ent = termEntries.get(id);
751
952
  if (!ent) return;
953
+ activeTermId = id;
954
+ ent.dirty = false;
955
+ for (const [tid, e] of termEntries) {
956
+ const on = tid === id;
957
+ e.tab.classList.toggle('active', on);
958
+ e.tab.classList.toggle('dirty', e.dirty); // active 的 dirty 已清,顺带同步
959
+ // 单屏模式 pane 叠层只显示当前;并排模式 CSS 让全部可见,active 只高亮边框
960
+ e.pane.classList.toggle('active', on);
961
+ }
962
+ fitTerm(id, ent); // pane 可见后再 fit(隐藏状态下拿不到尺寸)
963
+ termStatus(ent);
964
+ ent.xt.focus();
965
+ }
966
+
967
+ // 按容器尺寸重算 cols/rows,并通知 Worker 端 PTY 同步窗口大小
968
+ function fitTerm(id, ent) {
752
969
  try { ent.fit.fit(); } catch { return; }
753
- sendWs({ type: 'session.resize', taskId: activeTermId, cols: ent.xt.cols, rows: ent.xt.rows });
970
+ if (wsReady) sendWs({ type: 'session.resize', taskId: id, cols: ent.xt.cols, rows: ent.xt.rows });
971
+ }
972
+
973
+ // fit 所有当前可见终端(并排:全部;单屏:仅 active)
974
+ function fitVisibleTerms() {
975
+ if ($('term-overlay').classList.contains('hidden')) return;
976
+ for (const [id, ent] of termEntries) {
977
+ if (termLayout === 'grid' || id === activeTermId) fitTerm(id, ent);
978
+ }
979
+ }
980
+
981
+ // 应用布局模式:并排按终端数重算 grid 列数(至多 3 列),并重新 fit 全部可见终端
982
+ function applyTermLayout() {
983
+ const body = $('term-body');
984
+ body.classList.toggle('grid', termLayout === 'grid');
985
+ document.querySelectorAll('#term-layout-btns button').forEach((b) => {
986
+ b.classList.toggle('active', b.dataset.layout === termLayout);
987
+ });
988
+ if (termLayout === 'grid') {
989
+ const n = termEntries.size;
990
+ body.style.setProperty('--cols', String(n <= 1 ? 1 : (n <= 4 ? 2 : 3)));
991
+ } else {
992
+ body.style.removeProperty('--cols');
993
+ }
994
+ setTimeout(fitVisibleTerms, 60); // 等布局切换稳定再重 fit
754
995
  }
755
996
 
756
997
  function openTerm(t) {
757
998
  const ent = ensureTerm(t);
758
999
  if (!ent) return;
759
- activeTermId = t.id;
760
- $('term-title').textContent = t.title || '交互终端';
761
1000
  $('term-overlay').classList.remove('hidden');
762
- fitActiveTerm();
763
- ent.pendingAttach = true;
764
- sendWs({ type: 'pty.attach', taskId: t.id }); // 取回看缓冲 + 会话存活状态
765
- ent.xt.focus();
1001
+ applyTermLayout(); // 距上次收起终端数可能有变,确保布局类与列数正确
1002
+ activateTerm(t.id);
1003
+ // 重复打开不重复 attach:回看缓冲只回补一次,避免终端内容翻倍
1004
+ if (!ent.attached) {
1005
+ ent.attached = true;
1006
+ ent.pendingAttach = true;
1007
+ sendWs({ type: 'pty.attach', taskId: t.id }); // 取回看缓冲 + 会话存活状态
1008
+ }
766
1009
  termStatus(ent);
767
1010
  }
768
1011
 
769
1012
  function closeTermOverlay() {
770
1013
  $('term-overlay').classList.add('hidden');
771
1014
  activeTermId = null;
772
- termStatus(null);
773
1015
  if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
774
1016
  }
775
1017
 
@@ -778,8 +1020,15 @@ function disposeTerm(id) {
778
1020
  const ent = termEntries.get(id);
779
1021
  if (!ent) return;
780
1022
  termEntries.delete(id);
781
- if (activeTermId === id) closeTermOverlay();
1023
+ ent.tab.remove();
782
1024
  try { ent.xt.dispose(); } catch { /* 已释放 */ }
1025
+ ent.pane.remove();
1026
+ if (activeTermId === id) {
1027
+ const next = termEntries.keys().next().value;
1028
+ if (next && !$('term-overlay').classList.contains('hidden')) activateTerm(next);
1029
+ else closeTermOverlay();
1030
+ }
1031
+ applyTermLayout(); // 终端数有变:重算并排列数(覆盖层收起时 fit 有兜底不执行)
783
1032
  }
784
1033
 
785
1034
  $('term-close').addEventListener('click', closeTermOverlay);
@@ -787,6 +1036,14 @@ $('term-body').addEventListener('click', () => {
787
1036
  const ent = termEntries.get(activeTermId);
788
1037
  if (ent) ent.xt.focus();
789
1038
  });
1039
+ // 布局切换:单屏 / 并排,偏好持久化
1040
+ document.querySelectorAll('#term-layout-btns button').forEach((btn) => {
1041
+ btn.addEventListener('click', () => {
1042
+ termLayout = btn.dataset.layout;
1043
+ localStorage.setItem('rw_termLayout', termLayout);
1044
+ applyTermLayout();
1045
+ });
1046
+ });
790
1047
  // Esc 收起终端覆盖层(终端聚焦时 Esc 会先被 xterm 吃掉,此处兜底非聚焦场景)
791
1048
  document.addEventListener('keydown', (ev) => {
792
1049
  if (ev.key === 'Escape' && activeTermId && !$('term-overlay').classList.contains('hidden')) {
@@ -797,8 +1054,8 @@ document.addEventListener('keydown', (ev) => {
797
1054
  closeTermOverlay();
798
1055
  }
799
1056
  });
800
- // 窗口尺寸变化后重新 fit
801
- window.addEventListener('resize', () => { if (activeTermId) setTimeout(fitActiveTerm, 120); });
1057
+ // 窗口尺寸变化后重新 fit 所有可见终端
1058
+ window.addEventListener('resize', () => { if (termEntries.size) setTimeout(fitVisibleTerms, 120); });
802
1059
 
803
1060
  // ---------- 轮询回退(仅 WS 不可用时生效)----------
804
1061
  async function refresh() {
@@ -815,64 +1072,33 @@ async function refresh() {
815
1072
  toast(err.message);
816
1073
  }
817
1074
  }
818
- setInterval(() => { if (!wsReady && !wsAuthRejected) refresh(); }, 4000);
819
-
820
- // ---------- 类型切换 ----------
821
- let currentType = 'agent';
822
- document.querySelectorAll('#type-bar button').forEach((btn) => {
823
- btn.addEventListener('click', () => {
824
- currentType = btn.dataset.type;
825
- document.querySelectorAll('#type-bar button').forEach((b) => b.classList.toggle('active', b === btn));
826
- for (const t of ['agent', 'shell', 'pty', 'script', 'chat']) {
827
- $('fields-' + t).classList.toggle('hidden', t !== currentType);
828
- }
829
- });
830
- });
1075
+ setInterval(() => { if (!wsReady && !wsAuthRejected && hasSecret()) refresh(); }, 4000);
831
1076
 
832
1077
  // ---------- 提交任务(REST,WS 负责推送变更)----------
833
1078
  $('btn-submit').addEventListener('click', async () => {
1079
+ // 显式指派:二级视图下任务固定提交给当前 Worker
1080
+ const targetWorker = currentWorkerId;
1081
+ if (!targetWorker) return toast('请先在 Worker 列表中选择 Worker');
834
1082
  const title = $('f-title').value.trim();
835
- let payload = {};
836
- if (currentType === 'agent') {
837
- payload = {
838
- prompt: $('f-prompt').value.trim(),
839
- project: $('f-project').value.trim() || 'default',
840
- agent: $('f-agent').value,
841
- };
842
- if (!payload.prompt) return toast('请填写 Prompt');
843
- } else if (currentType === 'shell') {
844
- payload = { command: $('f-command').value.trim() };
845
- if (!payload.command) return toast('请填写命令');
846
- } else if (currentType === 'pty') {
847
- payload = { project: $('f-pty-project').value.trim() || 'default' };
848
- if (!/^[\w.-]+$/.test(payload.project)) return toast('项目目录名只能含字母/数字/._-');
849
- } else if (currentType === 'script') {
850
- payload = {
851
- script: $('f-script').value.trim(),
852
- args: $('f-args').value.trim() ? $('f-args').value.trim().split(/\s+/) : [],
853
- };
854
- if (!payload.script) return toast('请填写脚本名');
855
- } else {
856
- payload = { message: $('f-message').value.trim() };
857
- if (!payload.message) return toast('请填写留言');
858
- }
1083
+ const payload = { project: $('f-pty-project').value.trim() || 'default' };
1084
+ if (!/^[\w.-]+$/.test(payload.project)) return toast('项目目录名只能含字母/数字/._-');
859
1085
  try {
860
1086
  $('btn-submit').disabled = true;
861
- const autoTitle = payload.prompt || payload.command || payload.script ||
862
- (currentType === 'pty' ? '交互终端' : payload.message || '留言');
863
- await api('/api/tasks', { type: currentType, title: title || autoTitle, payload });
864
- toast(currentType === 'pty' ? '已提交,任务运行后点「进入终端」' : '任务已提交');
1087
+ await api('/api/tasks', { type: 'pty', title: title || '交互终端', payload, targetWorker });
1088
+ toast('已提交,任务运行后点「进入终端」');
865
1089
  $('f-title').value = '';
866
1090
  if (!wsReady) await refresh();
867
1091
  } catch (err) {
868
1092
  toast(err.message);
869
1093
  } finally {
870
- $('btn-submit').disabled = false;
1094
+ updateSubmitState();
871
1095
  }
872
1096
  });
873
1097
 
874
1098
  connectWS();
875
- refresh(); // 首次加载兜底:WS 握手前的空档先用 REST 铺一次数据
1099
+ // 首次加载兜底:WS 握手前的空档先用 REST 铺一次数据(无密钥时不发请求)
1100
+ if (hasSecret()) refresh();
1101
+ else setConnMode(false); // 无密钥不发起任何连接,状态条直接落到轮询态
876
1102
  </script>
877
1103
  </body>
878
1104
  </html>