@jacksontian/kite-server 0.1.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/config.example.yaml +37 -0
- package/config.js +205 -0
- package/package.json +37 -0
- package/server.js +921 -0
- package/web/index.html +900 -0
package/web/index.html
ADDED
|
@@ -0,0 +1,900 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<meta name="theme-color" content="#111827">
|
|
7
|
+
<title>Kite 控制台</title>
|
|
8
|
+
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
|
|
9
|
+
<style>
|
|
10
|
+
:root {
|
|
11
|
+
--bg: #111827; --panel: #1f2937; --line: #374151;
|
|
12
|
+
--text: #e5e7eb; --dim: #9ca3af; --accent: #3b82f6;
|
|
13
|
+
--ok: #22c55e; --err: #ef4444; --warn: #f59e0b;
|
|
14
|
+
}
|
|
15
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
16
|
+
body {
|
|
17
|
+
font-family: -apple-system, "PingFang SC", "Helvetica Neue", sans-serif;
|
|
18
|
+
background: var(--bg); color: var(--text);
|
|
19
|
+
padding: 12px 12px calc(24px + env(safe-area-inset-bottom));
|
|
20
|
+
max-width: 640px; margin: 0 auto;
|
|
21
|
+
}
|
|
22
|
+
header { display: flex; align-items: center; justify-content: space-between; padding: 8px 4px 12px; gap: 8px; }
|
|
23
|
+
h1 { font-size: 18px; font-weight: 600; }
|
|
24
|
+
.header-right { display: flex; align-items: center; gap: 8px; }
|
|
25
|
+
#conn-mode { font-size: 11px; padding: 3px 8px; border-radius: 999px; background: var(--panel); color: var(--dim); white-space: nowrap; }
|
|
26
|
+
#conn-mode.realtime { color: var(--ok); }
|
|
27
|
+
#conn-mode.polling { color: var(--warn); }
|
|
28
|
+
#worker-status { font-size: 12px; padding: 4px 10px; border-radius: 999px; background: var(--panel); color: var(--dim); white-space: nowrap; }
|
|
29
|
+
#worker-status.online { color: var(--ok); }
|
|
30
|
+
#worker-status.offline { color: var(--err); }
|
|
31
|
+
|
|
32
|
+
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px; margin-bottom: 14px; }
|
|
33
|
+
label { display: block; font-size: 12px; color: var(--dim); margin: 10px 0 4px; }
|
|
34
|
+
input, textarea, select {
|
|
35
|
+
width: 100%; background: var(--bg); color: var(--text);
|
|
36
|
+
border: 1px solid var(--line); border-radius: 8px;
|
|
37
|
+
padding: 10px; font-size: 15px; font-family: inherit;
|
|
38
|
+
}
|
|
39
|
+
textarea { min-height: 72px; resize: vertical; }
|
|
40
|
+
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); }
|
|
41
|
+
|
|
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
|
+
.submit {
|
|
50
|
+
width: 100%; margin-top: 14px; padding: 12px;
|
|
51
|
+
background: var(--accent); color: #fff; border: none; border-radius: 10px;
|
|
52
|
+
font-size: 16px; font-weight: 600;
|
|
53
|
+
}
|
|
54
|
+
.submit:disabled { opacity: .5; }
|
|
55
|
+
|
|
56
|
+
.task { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; margin-bottom: 8px; }
|
|
57
|
+
.task .row1 { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
|
58
|
+
.task .title { font-size: 14px; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
59
|
+
.task .meta { font-size: 11px; color: var(--dim); margin-top: 4px; }
|
|
60
|
+
.badge { font-size: 11px; padding: 2px 8px; border-radius: 999px; white-space: nowrap; }
|
|
61
|
+
.b-pending { background: rgba(245,158,11,.15); color: var(--warn); }
|
|
62
|
+
.b-running { background: rgba(59,130,246,.15); color: var(--accent); }
|
|
63
|
+
.b-done { background: rgba(34,197,94,.15); color: var(--ok); }
|
|
64
|
+
.b-failed { background: rgba(239,68,68,.15); color: var(--err); }
|
|
65
|
+
.b-canceled { background: rgba(156,163,175,.15); color: var(--dim); }
|
|
66
|
+
|
|
67
|
+
.logs {
|
|
68
|
+
margin-top: 8px; background: #0b1220; border-radius: 8px; padding: 8px;
|
|
69
|
+
font-family: ui-monospace, Menlo, monospace; font-size: 11px; line-height: 1.6;
|
|
70
|
+
max-height: 300px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;
|
|
71
|
+
color: #a5f3fc; display: none;
|
|
72
|
+
}
|
|
73
|
+
.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
|
+
.task .actions { margin-top: 8px; display: flex; gap: 8px; }
|
|
84
|
+
.task .actions button {
|
|
85
|
+
font-size: 12px; padding: 4px 12px; border-radius: 6px;
|
|
86
|
+
background: var(--bg); color: var(--dim); border: 1px solid var(--line);
|
|
87
|
+
}
|
|
88
|
+
.task .actions button:disabled { opacity: .5; }
|
|
89
|
+
.task .actions button.term-btn { color: var(--accent); border-color: var(--accent); }
|
|
90
|
+
|
|
91
|
+
/* 交互终端全屏覆盖层(pty 任务) */
|
|
92
|
+
#term-overlay {
|
|
93
|
+
position: fixed; inset: 0; z-index: 50; background: #0b1220;
|
|
94
|
+
display: flex; flex-direction: column;
|
|
95
|
+
padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom);
|
|
96
|
+
}
|
|
97
|
+
#term-overlay.hidden { display: none; }
|
|
98
|
+
#term-head { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; }
|
|
99
|
+
#term-title { font-size: 13px; color: var(--dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
100
|
+
#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; }
|
|
102
|
+
#term-bar { display: flex; align-items: center; gap: 6px; padding: 8px; }
|
|
103
|
+
#term-bar button {
|
|
104
|
+
min-width: 44px; padding: 9px 0; font-size: 13px; border-radius: 8px;
|
|
105
|
+
background: var(--panel); color: var(--text); border: 1px solid var(--line);
|
|
106
|
+
}
|
|
107
|
+
#term-status { flex: 1; font-size: 11px; color: var(--dim); text-align: right; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
108
|
+
#term-input { position: fixed; left: -9999px; top: 0; width: 1px; height: 1px; opacity: .01; border: none; }
|
|
109
|
+
#type-bar button { font-size: 12px; }
|
|
110
|
+
#auth-bar { display: flex; justify-content: space-between; align-items: center; cursor: pointer; }
|
|
111
|
+
#auth-bar .title { font-size: 13px; color: var(--ok); }
|
|
112
|
+
#auth-bar .edit { font-size: 12px; color: var(--dim); }
|
|
113
|
+
.secret-row { display: flex; gap: 8px; }
|
|
114
|
+
.secret-row input { flex: 1; }
|
|
115
|
+
#btn-eye, #btn-done {
|
|
116
|
+
flex: none; padding: 0 12px; border: 1px solid var(--line); border-radius: 8px;
|
|
117
|
+
background: var(--bg); color: var(--dim); font-size: 13px; cursor: pointer;
|
|
118
|
+
}
|
|
119
|
+
#btn-eye.on { color: var(--accent); border-color: var(--accent); }
|
|
120
|
+
#btn-done { color: var(--ok); }
|
|
121
|
+
@keyframes task-in { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
|
|
122
|
+
.task.new { animation: task-in .25s ease; }
|
|
123
|
+
#toast {
|
|
124
|
+
position: fixed; left: 50%; bottom: 32px; transform: translateX(-50%);
|
|
125
|
+
background: #000c; color: #fff; padding: 8px 16px; border-radius: 8px;
|
|
126
|
+
font-size: 13px; opacity: 0; transition: opacity .2s; pointer-events: none;
|
|
127
|
+
}
|
|
128
|
+
.hidden { display: none !important; }
|
|
129
|
+
</style>
|
|
130
|
+
</head>
|
|
131
|
+
<body>
|
|
132
|
+
<header>
|
|
133
|
+
<h1>Kite</h1>
|
|
134
|
+
<div class="header-right">
|
|
135
|
+
<span id="conn-mode">连接中</span>
|
|
136
|
+
<span id="worker-status">检测中…</span>
|
|
137
|
+
</div>
|
|
138
|
+
</header>
|
|
139
|
+
|
|
140
|
+
<!-- 鉴权设置:密钥已存 localStorage 时折叠成一行,点「修改」才展开 -->
|
|
141
|
+
<div class="panel" id="auth-panel">
|
|
142
|
+
<div id="auth-bar" class="hidden">
|
|
143
|
+
<span class="title">✓ 密钥已配置(存本机浏览器,不上网传输)</span>
|
|
144
|
+
<span class="edit">修改</span>
|
|
145
|
+
</div>
|
|
146
|
+
<div id="auth-form">
|
|
147
|
+
<label>API Secret(签名密钥:只存本机浏览器,永不在网络上传输)</label>
|
|
148
|
+
<div class="secret-row">
|
|
149
|
+
<input id="secret" type="password" placeholder="粘贴 API_SECRET" autocomplete="off">
|
|
150
|
+
<button id="btn-eye" type="button">显示</button>
|
|
151
|
+
<button id="btn-done" type="button">完成</button>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
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>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<label>标题</label>
|
|
167
|
+
<input id="f-title" placeholder="一句话描述这个任务">
|
|
168
|
+
|
|
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>
|
|
196
|
+
|
|
197
|
+
<button class="submit" id="btn-submit">提交任务</button>
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
<!-- 任务列表 -->
|
|
201
|
+
<div id="task-list"></div>
|
|
202
|
+
|
|
203
|
+
<!-- 交互终端覆盖层(pty 任务):xterm.js 渲染,输入经 /ws 下行到 Worker 的 PTY -->
|
|
204
|
+
<div id="term-overlay" class="hidden">
|
|
205
|
+
<div id="term-head">
|
|
206
|
+
<span id="term-title"></span>
|
|
207
|
+
<button id="term-close" type="button">收起</button>
|
|
208
|
+
</div>
|
|
209
|
+
<div id="term-body"></div>
|
|
210
|
+
<div id="term-bar">
|
|
211
|
+
<button type="button" data-key="BS">⌫</button>
|
|
212
|
+
<button type="button" data-key="Tab">Tab</button>
|
|
213
|
+
<button type="button" data-key="Esc">Esc</button>
|
|
214
|
+
<button type="button" data-key="Up">↑</button>
|
|
215
|
+
<button type="button" data-key="Down">↓</button>
|
|
216
|
+
<button type="button" data-key="CtrlC">^C</button>
|
|
217
|
+
<button type="button" data-key="CtrlD">^D</button>
|
|
218
|
+
<span id="term-status"></span>
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
<!-- 手机键盘代理:藏在屏幕外承接击键;xterm 的按键绑定在触屏上靠它转交 -->
|
|
222
|
+
<textarea id="term-input" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
|
|
223
|
+
|
|
224
|
+
<div id="toast"></div>
|
|
225
|
+
|
|
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>
|
|
228
|
+
<script>
|
|
229
|
+
'use strict';
|
|
230
|
+
const $ = (id) => document.getElementById(id);
|
|
231
|
+
const secretInput = $('secret');
|
|
232
|
+
secretInput.value = localStorage.getItem('rw_secret') || '';
|
|
233
|
+
|
|
234
|
+
function toast(msg) {
|
|
235
|
+
const el = $('toast');
|
|
236
|
+
el.textContent = msg; el.style.opacity = '1';
|
|
237
|
+
setTimeout(() => { el.style.opacity = '0'; }, 2200);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ---------- 纯 JS SHA-256 / HMAC-SHA256 ----------
|
|
241
|
+
// 非 HTTPS 环境(http://VPS-IP)下浏览器的 crypto.subtle 不可用,故内联实现。
|
|
242
|
+
// 密钥只参与本地 HMAC 运算,从不随请求发送。
|
|
243
|
+
const SHA256_K = new Uint32Array([
|
|
244
|
+
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
|
|
245
|
+
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
|
|
246
|
+
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
|
|
247
|
+
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
|
|
248
|
+
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
|
|
249
|
+
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
|
|
250
|
+
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
|
|
251
|
+
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
|
|
252
|
+
]);
|
|
253
|
+
const rotr = (x, n) => (x >>> n) | (x << (32 - n));
|
|
254
|
+
|
|
255
|
+
function sha256Bytes(data) {
|
|
256
|
+
const H = new Uint32Array([
|
|
257
|
+
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a,
|
|
258
|
+
0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19,
|
|
259
|
+
]);
|
|
260
|
+
const len = data.length;
|
|
261
|
+
const paddedLen = (((len + 8) >> 6) + 1) << 6;
|
|
262
|
+
const padded = new Uint8Array(paddedLen);
|
|
263
|
+
padded.set(data);
|
|
264
|
+
padded[len] = 0x80;
|
|
265
|
+
const dv = new DataView(padded.buffer);
|
|
266
|
+
dv.setUint32(paddedLen - 8, Math.floor(len / 0x20000000)); // 比特长度高 32 位
|
|
267
|
+
dv.setUint32(paddedLen - 4, (len << 3) >>> 0); // 比特长度低 32 位
|
|
268
|
+
const w = new Uint32Array(64);
|
|
269
|
+
for (let off = 0; off < paddedLen; off += 64) {
|
|
270
|
+
for (let i = 0; i < 16; i++) w[i] = dv.getUint32(off + i * 4);
|
|
271
|
+
for (let i = 16; i < 64; i++) {
|
|
272
|
+
const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
|
|
273
|
+
const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
|
|
274
|
+
w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
|
|
275
|
+
}
|
|
276
|
+
let a = H[0], b = H[1], c = H[2], d = H[3], e = H[4], f = H[5], g = H[6], h = H[7];
|
|
277
|
+
for (let i = 0; i < 64; i++) {
|
|
278
|
+
const t1 = (h + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + SHA256_K[i] + w[i]) >>> 0;
|
|
279
|
+
const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
|
|
280
|
+
h = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
|
|
281
|
+
}
|
|
282
|
+
H[0] = (H[0] + a) >>> 0; H[1] = (H[1] + b) >>> 0; H[2] = (H[2] + c) >>> 0; H[3] = (H[3] + d) >>> 0;
|
|
283
|
+
H[4] = (H[4] + e) >>> 0; H[5] = (H[5] + f) >>> 0; H[6] = (H[6] + g) >>> 0; H[7] = (H[7] + h) >>> 0;
|
|
284
|
+
}
|
|
285
|
+
const out = new Uint8Array(32);
|
|
286
|
+
const odv = new DataView(out.buffer);
|
|
287
|
+
for (let i = 0; i < 8; i++) odv.setUint32(i * 4, H[i]);
|
|
288
|
+
return out;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const bytesToHex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
292
|
+
const utf8 = (s) => new TextEncoder().encode(s);
|
|
293
|
+
const sha256Hex = (s) => bytesToHex(sha256Bytes(utf8(s)));
|
|
294
|
+
|
|
295
|
+
function hmacSha256Hex(keyStr, msgStr) {
|
|
296
|
+
let key = utf8(keyStr);
|
|
297
|
+
if (key.length > 64) key = sha256Bytes(key);
|
|
298
|
+
const msg = utf8(msgStr);
|
|
299
|
+
const inner = new Uint8Array(64 + msg.length);
|
|
300
|
+
const outer = new Uint8Array(64 + 32);
|
|
301
|
+
for (let i = 0; i < 64; i++) {
|
|
302
|
+
const k = i < key.length ? key[i] : 0;
|
|
303
|
+
inner[i] = k ^ 0x36;
|
|
304
|
+
outer[i] = k ^ 0x5c;
|
|
305
|
+
}
|
|
306
|
+
inner.set(msg, 64);
|
|
307
|
+
outer.set(sha256Bytes(inner), 64);
|
|
308
|
+
return bytesToHex(sha256Bytes(outer));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------- 请求签名(与 server/worker 的签名串格式严格一致)----------
|
|
312
|
+
function makeNonce() {
|
|
313
|
+
const bytes = new Uint8Array(16);
|
|
314
|
+
if (window.crypto && crypto.getRandomValues) crypto.getRandomValues(bytes);
|
|
315
|
+
else for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
|
|
316
|
+
return bytesToHex(bytes);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function signHeaders(method, path, bodyStr) {
|
|
320
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
321
|
+
const nonce = makeNonce();
|
|
322
|
+
const sig = hmacSha256Hex(secretInput.value.trim(), [method, path, ts, nonce, sha256Hex(bodyStr)].join('\n'));
|
|
323
|
+
return { 'X-RW-Ts': String(ts), 'X-RW-Nonce': nonce, 'X-RW-Sig': sig };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function api(path, body) {
|
|
327
|
+
const method = body === undefined ? 'GET' : 'POST';
|
|
328
|
+
const bodyStr = body === undefined ? '' : JSON.stringify(body);
|
|
329
|
+
const res = await fetch(path, {
|
|
330
|
+
method,
|
|
331
|
+
headers: {
|
|
332
|
+
'Content-Type': 'application/json',
|
|
333
|
+
...signHeaders(method, path, bodyStr),
|
|
334
|
+
},
|
|
335
|
+
body: body === undefined ? undefined : bodyStr,
|
|
336
|
+
});
|
|
337
|
+
const data = await res.json().catch(() => ({}));
|
|
338
|
+
if (res.status === 401) throw new Error('签名验证失败(检查密钥与本机时钟)');
|
|
339
|
+
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
|
|
340
|
+
return data;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---------- 本地状态 ----------
|
|
344
|
+
const taskCache = new Map(); // id -> 任务摘要
|
|
345
|
+
const logCache = new Map(); // id -> 日志全文字符串(展开过的任务才有)
|
|
346
|
+
const openTasks = new Set(); // 展开查看日志的任务 id
|
|
347
|
+
let wsReady = false;
|
|
348
|
+
let wsAuthRejected = false; // 验签被拒后停止重连,直到密钥变更
|
|
349
|
+
|
|
350
|
+
secretInput.addEventListener('change', () => {
|
|
351
|
+
localStorage.setItem('rw_secret', secretInput.value.trim());
|
|
352
|
+
wsAuthRejected = false; // 密钥换了,允许重新尝试实时连接
|
|
353
|
+
if (!wsReady) connectWS();
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// 密钥已配置时收起输入区,只留一行状态条;点状态条可展开修改
|
|
357
|
+
function toggleAuth(collapsed) {
|
|
358
|
+
$('auth-bar').classList.toggle('hidden', !collapsed);
|
|
359
|
+
$('auth-form').classList.toggle('hidden', collapsed);
|
|
360
|
+
if (collapsed) setSecretVisible(false); // 收起后恢复掩码,避免下次展开直接露出明文
|
|
361
|
+
}
|
|
362
|
+
$('auth-bar').addEventListener('click', () => {
|
|
363
|
+
toggleAuth(false);
|
|
364
|
+
secretInput.focus();
|
|
365
|
+
});
|
|
366
|
+
// 退出编辑:无论是否改动都可收起;内容被清空则保持展开(没有可用密钥)
|
|
367
|
+
$('btn-done').addEventListener('click', () => {
|
|
368
|
+
toggleAuth(secretInput.value.trim() !== '');
|
|
369
|
+
});
|
|
370
|
+
toggleAuth(secretInput.value.trim() !== '');
|
|
371
|
+
|
|
372
|
+
// 明文/掩码切换,仅改变本地展示方式,不影响签名
|
|
373
|
+
function setSecretVisible(visible) {
|
|
374
|
+
secretInput.type = visible ? 'text' : 'password';
|
|
375
|
+
const btn = $('btn-eye');
|
|
376
|
+
btn.textContent = visible ? '隐藏' : '显示';
|
|
377
|
+
btn.classList.toggle('on', visible);
|
|
378
|
+
}
|
|
379
|
+
$('btn-eye').addEventListener('click', () => setSecretVisible(secretInput.type === 'password'));
|
|
380
|
+
|
|
381
|
+
// ---------- WebSocket 实时通道(断线自动回退轮询)----------
|
|
382
|
+
let curWs = null; // 当前实时连接,pty 终端输入经它上行
|
|
383
|
+
|
|
384
|
+
function sendWs(msg) {
|
|
385
|
+
if (curWs && curWs.readyState === WebSocket.OPEN) curWs.send(JSON.stringify(msg));
|
|
386
|
+
else toast('实时通道未连接,输入无法送达');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function setConnMode(realtime) {
|
|
390
|
+
const el = $('conn-mode');
|
|
391
|
+
el.textContent = realtime ? '实时' : '轮询';
|
|
392
|
+
el.className = realtime ? 'realtime' : 'polling';
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function connectWS() {
|
|
396
|
+
if (wsAuthRejected) return;
|
|
397
|
+
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
|
398
|
+
const sock = new WebSocket(`${proto}://${location.host}/ws`);
|
|
399
|
+
curWs = sock;
|
|
400
|
+
|
|
401
|
+
sock.addEventListener('open', () => {
|
|
402
|
+
// 签名鉴权:sig = HMAC(密钥, "WS\nTS\nNONCE"),密钥本身不进网络
|
|
403
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
404
|
+
const nonce = makeNonce();
|
|
405
|
+
const sig = hmacSha256Hex(secretInput.value.trim(), ['WS', ts, nonce].join('\n'));
|
|
406
|
+
sock.send(JSON.stringify({ type: 'auth', ts, nonce, sig }));
|
|
407
|
+
});
|
|
408
|
+
sock.addEventListener('message', (ev) => {
|
|
409
|
+
let msg;
|
|
410
|
+
try { msg = JSON.parse(ev.data); } catch { return; }
|
|
411
|
+
switch (msg.type) {
|
|
412
|
+
case 'hello':
|
|
413
|
+
wsReady = true;
|
|
414
|
+
setConnMode(true);
|
|
415
|
+
taskCache.clear();
|
|
416
|
+
for (const t of msg.tasks) taskCache.set(t.id, t);
|
|
417
|
+
renderTasks();
|
|
418
|
+
renderWorkers(msg.workers);
|
|
419
|
+
// 重连后给打开着的终端补回看缓冲与会话存活状态
|
|
420
|
+
for (const id of termEntries.keys()) {
|
|
421
|
+
const ent = termEntries.get(id);
|
|
422
|
+
ent.pendingAttach = true;
|
|
423
|
+
sock.send(JSON.stringify({ type: 'pty.attach', taskId: id }));
|
|
424
|
+
}
|
|
425
|
+
break;
|
|
426
|
+
case 'task.created':
|
|
427
|
+
case 'task.updated':
|
|
428
|
+
taskCache.set(msg.task.id, msg.task);
|
|
429
|
+
if (msg.result !== undefined) logCache.delete(msg.task.id); // 结果变了,展开时重新拉全量
|
|
430
|
+
renderTasks();
|
|
431
|
+
break;
|
|
432
|
+
case 'task.logs':
|
|
433
|
+
mergePreview(msg.id, msg.lines); // 未展开时也要刷新 shell 视图
|
|
434
|
+
appendLogs(msg.id, msg.lines);
|
|
435
|
+
break;
|
|
436
|
+
case 'workers':
|
|
437
|
+
renderWorkers(msg.workers);
|
|
438
|
+
break;
|
|
439
|
+
// --- pty 交互终端流 ---
|
|
440
|
+
case 'pty.data': {
|
|
441
|
+
const ent = termEntries.get(msg.id);
|
|
442
|
+
if (ent) { ent.xt.write(msg.data); ent.live = true; termStatus(ent); }
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
case 'pty.started': {
|
|
446
|
+
const ent = termEntries.get(msg.id);
|
|
447
|
+
if (ent && !ent.live) {
|
|
448
|
+
ent.live = true;
|
|
449
|
+
ent.xt.write('\r\n\x1b[32m[终端会话已就绪]\x1b[0m\r\n');
|
|
450
|
+
termStatus(ent);
|
|
451
|
+
}
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
case 'pty.exited': {
|
|
455
|
+
const ent = termEntries.get(msg.id);
|
|
456
|
+
if (ent) {
|
|
457
|
+
ent.live = false;
|
|
458
|
+
ent.xt.write(`\r\n\x1b[31m[会话结束:${msg.reason || ''}]\x1b[0m\r\n`);
|
|
459
|
+
termStatus(ent);
|
|
460
|
+
}
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
case 'pty.attach': {
|
|
464
|
+
const ent = termEntries.get(msg.id);
|
|
465
|
+
if (ent && ent.pendingAttach) {
|
|
466
|
+
ent.pendingAttach = false;
|
|
467
|
+
if (msg.tail) ent.xt.write(msg.tail); // 回看缓冲:切后台回来不白屏
|
|
468
|
+
ent.live = msg.live;
|
|
469
|
+
if (!msg.live) ent.xt.write('\r\n\x1b[33m[会话未就绪:任务尚未被领取或 Worker 离线,请稍候…]\x1b[0m\r\n');
|
|
470
|
+
termStatus(ent);
|
|
471
|
+
}
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
sock.addEventListener('close', (ev) => {
|
|
477
|
+
wsReady = false;
|
|
478
|
+
setConnMode(false);
|
|
479
|
+
if (termEntries.size) termStatus(termEntries.get(activeTermId) || termEntries.values().next().value);
|
|
480
|
+
if (ev.code === 4003) {
|
|
481
|
+
// 验签被拒:无意义重试只会刷服务端日志,等用户改密钥
|
|
482
|
+
wsAuthRejected = true;
|
|
483
|
+
toggleAuth(false); // 展开密钥输入区,方便直接修改
|
|
484
|
+
toast('密钥无效或时钟偏差过大,实时连接已停止');
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
setTimeout(connectWS, 3000); // 锁屏/切网导致的断线,3 秒后重连
|
|
488
|
+
});
|
|
489
|
+
sock.addEventListener('error', () => sock.close());
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ---------- 渲染(增量 diff:节点按 id 复用,只更新变化的部分) ----------
|
|
493
|
+
const STATUS_TEXT = { pending: '等待中', running: '执行中', done: '完成', failed: '失败', canceled: '已取消' };
|
|
494
|
+
const taskEls = new Map(); // taskId -> { el, badge, meta, logsEl, toggle, cancel, status, metaText }
|
|
495
|
+
function fmtTime(ts) {
|
|
496
|
+
if (!ts) return '-';
|
|
497
|
+
const d = new Date(ts);
|
|
498
|
+
return `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ` +
|
|
499
|
+
`${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function renderWorkers(workers) {
|
|
503
|
+
const ws = $('worker-status');
|
|
504
|
+
const online = workers.find((w) => w.online);
|
|
505
|
+
const text = online ? `Mac Mini 在线(${online.workerId})` : 'Mac Mini 离线';
|
|
506
|
+
if (ws.textContent !== text) ws.textContent = text; // 没变就不写,避免无谓重绘
|
|
507
|
+
ws.className = online ? 'online' : 'offline';
|
|
508
|
+
renderAgentOptions(workers);
|
|
509
|
+
}
|
|
510
|
+
|
|
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);
|
|
524
|
+
}
|
|
525
|
+
if ([...sel.options].some((o) => o.value === prev)) sel.value = prev; // 保持用户已选项
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function createTaskEl(t) {
|
|
529
|
+
const div = document.createElement('div');
|
|
530
|
+
div.className = 'task new' + (openTasks.has(t.id) ? ' open' : '');
|
|
531
|
+
div.innerHTML = `
|
|
532
|
+
<div class="row1">
|
|
533
|
+
<span class="title"></span>
|
|
534
|
+
<span class="badge"></span>
|
|
535
|
+
</div>
|
|
536
|
+
<div class="meta"></div>
|
|
537
|
+
<div class="term hidden"></div>
|
|
538
|
+
<div class="logs"></div>
|
|
539
|
+
<div class="actions">
|
|
540
|
+
<button data-act="toggle">展开</button>
|
|
541
|
+
<button data-act="term" class="hidden term-btn">进入终端</button>
|
|
542
|
+
<button data-act="cancel" class="hidden">取消</button>
|
|
543
|
+
</div>`;
|
|
544
|
+
div.querySelector('.title').textContent = t.title || t.id; // textContent 天然防注入
|
|
545
|
+
const entry = {
|
|
546
|
+
el: div,
|
|
547
|
+
badge: div.querySelector('.badge'),
|
|
548
|
+
meta: div.querySelector('.meta'),
|
|
549
|
+
termEl: div.querySelector('.term'),
|
|
550
|
+
logsEl: div.querySelector('.logs'),
|
|
551
|
+
toggle: div.querySelector('[data-act=toggle]'),
|
|
552
|
+
term: div.querySelector('[data-act=term]'),
|
|
553
|
+
cancel: div.querySelector('[data-act=cancel]'),
|
|
554
|
+
status: null,
|
|
555
|
+
metaText: null,
|
|
556
|
+
termSig: null,
|
|
557
|
+
};
|
|
558
|
+
// 点 shell 视图本身也展开(手机上比点小按钮顺手)
|
|
559
|
+
entry.termEl.addEventListener('click', () => {
|
|
560
|
+
if (!openTasks.has(t.id)) entry.toggle.click();
|
|
561
|
+
});
|
|
562
|
+
entry.term.addEventListener('click', () => openTerm(t));
|
|
563
|
+
entry.toggle.addEventListener('click', async () => {
|
|
564
|
+
if (openTasks.has(t.id)) {
|
|
565
|
+
openTasks.delete(t.id);
|
|
566
|
+
div.classList.remove('open');
|
|
567
|
+
} else {
|
|
568
|
+
openTasks.add(t.id);
|
|
569
|
+
div.classList.add('open');
|
|
570
|
+
await loadLogs(t.id);
|
|
571
|
+
}
|
|
572
|
+
entry.toggle.textContent = openTasks.has(t.id) ? '收起' : '展开';
|
|
573
|
+
patchTerm(t, entry); // 展开时隐藏 shell 预览,收起时恢复
|
|
574
|
+
});
|
|
575
|
+
entry.cancel.addEventListener('click', async () => {
|
|
576
|
+
try {
|
|
577
|
+
entry.cancel.disabled = true;
|
|
578
|
+
await api(`/api/tasks/${t.id}/cancel`, {});
|
|
579
|
+
if (!wsReady) await refresh();
|
|
580
|
+
} catch (e) { toast(e.message); } finally { entry.cancel.disabled = false; }
|
|
581
|
+
});
|
|
582
|
+
setTimeout(() => div.classList.remove('new'), 300);
|
|
583
|
+
return entry;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function patchTask(t, entry) {
|
|
587
|
+
if (entry.status !== t.status) {
|
|
588
|
+
entry.status = t.status;
|
|
589
|
+
entry.badge.textContent = STATUS_TEXT[t.status] || t.status;
|
|
590
|
+
entry.badge.className = `badge b-${t.status}`;
|
|
591
|
+
entry.cancel.classList.toggle('hidden', t.status !== 'pending');
|
|
592
|
+
entry.toggle.textContent = openTasks.has(t.id) ? '收起' : '展开';
|
|
593
|
+
}
|
|
594
|
+
entry.term.classList.toggle('hidden', t.type !== 'pty'); // pty 任务入口
|
|
595
|
+
const meta = `${t.type} · ${fmtTime(t.createdAt)}${t.finishedAt ? ' → ' + fmtTime(t.finishedAt) : ''}`;
|
|
596
|
+
if (entry.metaText !== meta) { entry.metaText = meta; entry.meta.textContent = meta; }
|
|
597
|
+
entry.el.classList.toggle('open', openTasks.has(t.id));
|
|
598
|
+
patchTerm(t, entry);
|
|
599
|
+
// 展开且有缓存:内容没变就不动 DOM,保留滚动位置
|
|
600
|
+
if (openTasks.has(t.id) && logCache.has(t.id)) writeLogs(t.id, logCache.get(t.id));
|
|
601
|
+
}
|
|
602
|
+
|
|
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
|
+
function renderTasks() {
|
|
653
|
+
const list = $('task-list');
|
|
654
|
+
const tasks = [...taskCache.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, 50);
|
|
655
|
+
// 清理已不存在的任务节点
|
|
656
|
+
const ids = new Set(tasks.map((t) => t.id));
|
|
657
|
+
for (const [id, entry] of taskEls) {
|
|
658
|
+
if (!ids.has(id)) { entry.el.remove(); taskEls.delete(id); disposeTerm(id); }
|
|
659
|
+
}
|
|
660
|
+
// 增量更新/插入,并保证顺序(新任务插到正确位置,而不是闪到末尾)
|
|
661
|
+
let prev = null;
|
|
662
|
+
for (const t of tasks) {
|
|
663
|
+
let entry = taskEls.get(t.id);
|
|
664
|
+
if (!entry) { entry = createTaskEl(t); taskEls.set(t.id, entry); }
|
|
665
|
+
patchTask(t, entry);
|
|
666
|
+
const expected = prev ? prev.nextSibling : list.firstChild;
|
|
667
|
+
if (entry.el !== expected) list.insertBefore(entry.el, expected);
|
|
668
|
+
prev = entry.el;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// 写日志到指定任务的日志区;仅在内容变化时写入,贴底时才自动滚动
|
|
673
|
+
function writeLogs(id, text) {
|
|
674
|
+
const entry = taskEls.get(id);
|
|
675
|
+
if (!entry || entry.logsEl.textContent === text) return;
|
|
676
|
+
const el = entry.logsEl;
|
|
677
|
+
const stickBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 20;
|
|
678
|
+
el.textContent = text;
|
|
679
|
+
if (stickBottom) el.scrollTop = el.scrollHeight;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// 展开任务时经 REST 拉全量日志,之后增量靠 WS 的 task.logs 事件追加
|
|
683
|
+
async function loadLogs(id) {
|
|
684
|
+
try {
|
|
685
|
+
const task = await api(`/api/tasks/${id}`);
|
|
686
|
+
let text = task.logs.join('\n');
|
|
687
|
+
if (task.result) text += `\n\n--- 结果 ---\n${JSON.stringify(task.result, null, 2)}`;
|
|
688
|
+
logCache.set(id, text);
|
|
689
|
+
writeLogs(id, text);
|
|
690
|
+
} catch { /* 任务可能已被清理 */ }
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function appendLogs(id, lines) {
|
|
694
|
+
if (!openTasks.has(id)) return;
|
|
695
|
+
const task = taskCache.get(id);
|
|
696
|
+
if (task && ['done', 'failed'].includes(task.status)) {
|
|
697
|
+
loadLogs(id); // 终态时重拉一次,确保结果块出现
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
if (!logCache.has(id)) { loadLogs(id); return; } // 尚未拉过全量,直接重拉
|
|
701
|
+
const text = logCache.get(id) + '\n' + lines.join('\n');
|
|
702
|
+
logCache.set(id, text);
|
|
703
|
+
writeLogs(id, text);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// ---------- 交互终端(pty 任务:xterm.js 渲染,IO 走 /ws 中继到 Worker 的 PTY)----------
|
|
707
|
+
// 手机端无物理键盘:隐藏 textarea 承接击键,工具条补 Tab/方向键/Ctrl 组合键
|
|
708
|
+
const termEntries = new Map(); // taskId -> { xt, fit, live, pendingAttach }
|
|
709
|
+
let activeTermId = null;
|
|
710
|
+
const KEY_SEQ = { BS: '\x7f', Tab: '\t', Esc: '\x1b', Up: '\x1b[A', Down: '\x1b[B', CtrlC: '\x03', CtrlD: '\x04' };
|
|
711
|
+
|
|
712
|
+
function termStatus(ent) {
|
|
713
|
+
const el = $('term-status');
|
|
714
|
+
if (!ent) { el.textContent = ''; return; }
|
|
715
|
+
if (!wsReady) el.textContent = '实时通道断开,重连中…';
|
|
716
|
+
else if (ent.live) el.textContent = '已连接';
|
|
717
|
+
else el.textContent = '会话未就绪(任务未领取或 Worker 离线)';
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function ensureTerm(t) {
|
|
721
|
+
let ent = termEntries.get(t.id);
|
|
722
|
+
if (ent) return ent;
|
|
723
|
+
if (!window.Terminal || !window.FitAddon) {
|
|
724
|
+
toast('xterm.js 加载失败(依赖 CDN,请检查网络后刷新)');
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
const xt = new Terminal({
|
|
728
|
+
fontSize: 13, scrollback: 3000, cursorBlink: true,
|
|
729
|
+
theme: { background: '#0b1220', foreground: '#e5e7eb' },
|
|
730
|
+
});
|
|
731
|
+
const fit = new FitAddon.FitAddon();
|
|
732
|
+
xt.loadAddon(fit);
|
|
733
|
+
xt.open($('term-body'));
|
|
734
|
+
ent = { xt, fit, live: false, pendingAttach: false };
|
|
735
|
+
termEntries.set(t.id, ent);
|
|
736
|
+
xt.onData((data) => sendWs({ type: 'session.input', taskId: t.id, data }));
|
|
737
|
+
return ent;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function fitActiveTerm() {
|
|
741
|
+
const ent = termEntries.get(activeTermId);
|
|
742
|
+
if (!ent) return;
|
|
743
|
+
try { ent.fit.fit(); } catch { return; }
|
|
744
|
+
sendWs({ type: 'session.resize', taskId: activeTermId, cols: ent.xt.cols, rows: ent.xt.rows });
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function openTerm(t) {
|
|
748
|
+
const ent = ensureTerm(t);
|
|
749
|
+
if (!ent) return;
|
|
750
|
+
activeTermId = t.id;
|
|
751
|
+
$('term-title').textContent = t.title || '交互终端';
|
|
752
|
+
$('term-overlay').classList.remove('hidden');
|
|
753
|
+
fitActiveTerm();
|
|
754
|
+
ent.pendingAttach = true;
|
|
755
|
+
sendWs({ type: 'pty.attach', taskId: t.id }); // 取回看缓冲 + 会话存活状态
|
|
756
|
+
focusTermInput();
|
|
757
|
+
termStatus(ent);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function closeTermOverlay() {
|
|
761
|
+
$('term-overlay').classList.add('hidden');
|
|
762
|
+
activeTermId = null;
|
|
763
|
+
termStatus(null);
|
|
764
|
+
if (document.activeElement && document.activeElement.blur) document.activeElement.blur();
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// 终端实例随会话常驻:收起不销毁,后台继续接收输出;任务卡被清理时才释放
|
|
768
|
+
function disposeTerm(id) {
|
|
769
|
+
const ent = termEntries.get(id);
|
|
770
|
+
if (!ent) return;
|
|
771
|
+
termEntries.delete(id);
|
|
772
|
+
if (activeTermId === id) closeTermOverlay();
|
|
773
|
+
try { ent.xt.dispose(); } catch { /* 已释放 */ }
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// 输入代理里常驻一个哨兵字符:手机键盘在空 textarea 上按退格不产生任何事件,
|
|
777
|
+
// 哨兵被删掉即代表一次 Backspace(发 \x7f),随后立即补回
|
|
778
|
+
const TERM_SENTINEL = '\u00a0';
|
|
779
|
+
|
|
780
|
+
function resetTermInput() {
|
|
781
|
+
const el = $('term-input');
|
|
782
|
+
el.value = TERM_SENTINEL;
|
|
783
|
+
try { el.setSelectionRange(1, 1); } catch { /* 个别浏览器不支持,忽略 */ }
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
function focusTermInput() {
|
|
787
|
+
if (!$('term-input').value) resetTermInput();
|
|
788
|
+
$('term-input').focus({ preventScroll: true });
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
$('term-close').addEventListener('click', closeTermOverlay);
|
|
792
|
+
$('term-body').addEventListener('click', focusTermInput);
|
|
793
|
+
// 隐藏 textarea 承接手机键盘击键;换行统一归一成 \r 交给 PTY
|
|
794
|
+
$('term-input').addEventListener('input', (ev) => {
|
|
795
|
+
const el = ev.target;
|
|
796
|
+
let val = el.value;
|
|
797
|
+
if (!val) {
|
|
798
|
+
// 哨兵被删 = 用户按了退格
|
|
799
|
+
if (activeTermId) sendWs({ type: 'session.input', taskId: activeTermId, data: '\x7f' });
|
|
800
|
+
resetTermInput();
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (val.startsWith(TERM_SENTINEL)) val = val.slice(TERM_SENTINEL.length);
|
|
804
|
+
resetTermInput();
|
|
805
|
+
if (activeTermId && val) {
|
|
806
|
+
sendWs({ type: 'session.input', taskId: activeTermId, data: val.replace(/\n/g, '\r') });
|
|
807
|
+
}
|
|
808
|
+
});
|
|
809
|
+
$('term-input').addEventListener('keydown', (ev) => {
|
|
810
|
+
if (ev.key === 'Enter') {
|
|
811
|
+
ev.preventDefault(); // 拦住换行,直接发回车,避免 input 事件补发 \n
|
|
812
|
+
if (activeTermId) sendWs({ type: 'session.input', taskId: activeTermId, data: '\r' });
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
document.querySelectorAll('#term-bar button').forEach((btn) => {
|
|
816
|
+
btn.addEventListener('click', () => {
|
|
817
|
+
if (!activeTermId) return;
|
|
818
|
+
sendWs({ type: 'session.input', taskId: activeTermId, data: KEY_SEQ[btn.dataset.key] });
|
|
819
|
+
focusTermInput();
|
|
820
|
+
});
|
|
821
|
+
});
|
|
822
|
+
// 屏幕旋转/键盘弹起都会改变可用高度,延迟一点等布局稳定再 fit
|
|
823
|
+
window.addEventListener('resize', () => { if (activeTermId) setTimeout(fitActiveTerm, 120); });
|
|
824
|
+
|
|
825
|
+
// ---------- 轮询回退(仅 WS 不可用时生效)----------
|
|
826
|
+
async function refresh() {
|
|
827
|
+
try {
|
|
828
|
+
const [{ tasks }, { workers }] = await Promise.all([
|
|
829
|
+
api('/api/tasks?limit=50'),
|
|
830
|
+
api('/api/worker/status'),
|
|
831
|
+
]);
|
|
832
|
+
taskCache.clear();
|
|
833
|
+
for (const t of tasks) taskCache.set(t.id, t);
|
|
834
|
+
renderTasks();
|
|
835
|
+
renderWorkers(workers);
|
|
836
|
+
} catch (err) {
|
|
837
|
+
toast(err.message);
|
|
838
|
+
}
|
|
839
|
+
}
|
|
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
|
+
});
|
|
853
|
+
|
|
854
|
+
// ---------- 提交任务(REST,WS 负责推送变更)----------
|
|
855
|
+
$('btn-submit').addEventListener('click', async () => {
|
|
856
|
+
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
|
+
}
|
|
881
|
+
try {
|
|
882
|
+
$('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' ? '已提交,任务运行后点「进入终端」' : '任务已提交');
|
|
887
|
+
$('f-title').value = '';
|
|
888
|
+
if (!wsReady) await refresh();
|
|
889
|
+
} catch (err) {
|
|
890
|
+
toast(err.message);
|
|
891
|
+
} finally {
|
|
892
|
+
$('btn-submit').disabled = false;
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
connectWS();
|
|
897
|
+
refresh(); // 首次加载兜底:WS 握手前的空档先用 REST 铺一次数据
|
|
898
|
+
</script>
|
|
899
|
+
</body>
|
|
900
|
+
</html>
|