@xcanwin/manyoyo 4.0.2 → 4.0.4
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/lib/web/frontend/app.css +144 -1
- package/lib/web/frontend/app.html +18 -0
- package/lib/web/frontend/app.js +499 -31
- package/lib/web/server.js +324 -6
- package/package.json +5 -2
package/lib/web/frontend/app.css
CHANGED
|
@@ -362,7 +362,7 @@ button.danger:hover {
|
|
|
362
362
|
min-height: 0;
|
|
363
363
|
padding: 12px 14px 10px;
|
|
364
364
|
display: grid;
|
|
365
|
-
grid-template-rows: auto minmax(0, 1fr) auto;
|
|
365
|
+
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
|
366
366
|
gap: 0;
|
|
367
367
|
animation: panelIn 380ms ease 80ms both;
|
|
368
368
|
}
|
|
@@ -416,6 +416,53 @@ button.danger:hover {
|
|
|
416
416
|
flex-wrap: wrap;
|
|
417
417
|
}
|
|
418
418
|
|
|
419
|
+
.mode-switch {
|
|
420
|
+
display: flex;
|
|
421
|
+
justify-content: space-between;
|
|
422
|
+
align-items: center;
|
|
423
|
+
gap: 10px;
|
|
424
|
+
margin: 8px 8px 2px;
|
|
425
|
+
min-height: 34px;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
.mode-switch-left {
|
|
429
|
+
display: inline-flex;
|
|
430
|
+
align-items: center;
|
|
431
|
+
gap: 8px;
|
|
432
|
+
min-width: 0;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.mode-switch button {
|
|
436
|
+
min-width: 98px;
|
|
437
|
+
color: var(--text);
|
|
438
|
+
background: #eef4f0;
|
|
439
|
+
border-color: var(--line);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.mode-switch button.is-active {
|
|
443
|
+
color: #ffffff;
|
|
444
|
+
background: var(--accent);
|
|
445
|
+
border-color: var(--accent-strong);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
body.command-mode #modeCommandBtn,
|
|
449
|
+
body.terminal-mode #modeTerminalBtn {
|
|
450
|
+
color: #ffffff;
|
|
451
|
+
background: var(--accent);
|
|
452
|
+
border-color: var(--accent-strong);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.mode-terminal-controls {
|
|
456
|
+
display: none;
|
|
457
|
+
align-items: center;
|
|
458
|
+
gap: 8px;
|
|
459
|
+
min-width: 0;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
body.terminal-mode .mode-terminal-controls {
|
|
463
|
+
display: inline-flex;
|
|
464
|
+
}
|
|
465
|
+
|
|
419
466
|
#messages {
|
|
420
467
|
min-height: 0;
|
|
421
468
|
overflow-y: auto;
|
|
@@ -426,6 +473,71 @@ button.danger:hover {
|
|
|
426
473
|
scroll-behavior: smooth;
|
|
427
474
|
}
|
|
428
475
|
|
|
476
|
+
#terminalPanel {
|
|
477
|
+
min-height: 0;
|
|
478
|
+
display: none;
|
|
479
|
+
flex-direction: column;
|
|
480
|
+
gap: 8px;
|
|
481
|
+
padding: 6px 8px 8px;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.terminal-status {
|
|
485
|
+
display: inline-block;
|
|
486
|
+
color: var(--muted);
|
|
487
|
+
font-size: 12px;
|
|
488
|
+
white-space: nowrap;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
#terminalScreen {
|
|
492
|
+
flex: 1;
|
|
493
|
+
min-height: 0;
|
|
494
|
+
height: 100%;
|
|
495
|
+
border-radius: 10px;
|
|
496
|
+
border: 1px solid #2a3d34;
|
|
497
|
+
box-shadow: inset 0 0 0 1px rgba(123, 161, 146, 0.12);
|
|
498
|
+
overflow: hidden;
|
|
499
|
+
background: radial-gradient(circle at 10% 8%, #1a2832 0%, #0c131a 64%);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
#terminalScreen .xterm {
|
|
503
|
+
width: 100%;
|
|
504
|
+
height: 100%;
|
|
505
|
+
padding: 8px 6px;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
#terminalScreen .xterm-screen {
|
|
509
|
+
width: 100%;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
.terminal-foot {
|
|
513
|
+
color: #556961;
|
|
514
|
+
font-size: 12px;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
body.command-mode #messages {
|
|
518
|
+
display: flex;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
body.command-mode #terminalPanel {
|
|
522
|
+
display: none;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
body.command-mode .composer {
|
|
526
|
+
display: block;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
body.terminal-mode #messages {
|
|
530
|
+
display: none;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
body.terminal-mode #terminalPanel {
|
|
534
|
+
display: flex;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
body.terminal-mode .composer {
|
|
538
|
+
display: none;
|
|
539
|
+
}
|
|
540
|
+
|
|
429
541
|
.msg {
|
|
430
542
|
max-width: min(900px, 92%);
|
|
431
543
|
width: fit-content;
|
|
@@ -652,6 +764,10 @@ button.danger:hover {
|
|
|
652
764
|
max-height: none;
|
|
653
765
|
}
|
|
654
766
|
|
|
767
|
+
#terminalPanel {
|
|
768
|
+
min-height: 0;
|
|
769
|
+
}
|
|
770
|
+
|
|
655
771
|
.composer {
|
|
656
772
|
position: sticky;
|
|
657
773
|
bottom: 0;
|
|
@@ -740,6 +856,33 @@ button.danger:hover {
|
|
|
740
856
|
grid-template-columns: 1fr auto;
|
|
741
857
|
}
|
|
742
858
|
|
|
859
|
+
.mode-switch {
|
|
860
|
+
margin: 8px 10px 2px;
|
|
861
|
+
overflow-x: auto;
|
|
862
|
+
padding-bottom: 2px;
|
|
863
|
+
gap: 8px;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
.mode-switch-left {
|
|
867
|
+
flex: 0 0 auto;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
.mode-switch button {
|
|
871
|
+
min-width: 90px;
|
|
872
|
+
flex: 0 0 auto;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
.mode-terminal-controls {
|
|
876
|
+
flex: 0 0 auto;
|
|
877
|
+
gap: 6px;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
.terminal-status {
|
|
881
|
+
max-width: 5.2em;
|
|
882
|
+
overflow: hidden;
|
|
883
|
+
text-overflow: ellipsis;
|
|
884
|
+
}
|
|
885
|
+
|
|
743
886
|
#commandInput {
|
|
744
887
|
min-height: 68px;
|
|
745
888
|
max-height: 160px;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
/>
|
|
9
9
|
<title>MANYOYO Web</title>
|
|
10
10
|
<link rel="stylesheet" href="/app/frontend/app.css" />
|
|
11
|
+
<link rel="stylesheet" href="/app/vendor/xterm.css" />
|
|
11
12
|
</head>
|
|
12
13
|
<body>
|
|
13
14
|
<div class="app">
|
|
@@ -62,7 +63,22 @@
|
|
|
62
63
|
<button type="button" id="removeAllBtn" class="danger">删除对话</button>
|
|
63
64
|
</div>
|
|
64
65
|
</header>
|
|
66
|
+
<section class="mode-switch" id="modeSwitch">
|
|
67
|
+
<div class="mode-switch-left">
|
|
68
|
+
<button type="button" id="modeCommandBtn" class="secondary is-active">命令模式</button>
|
|
69
|
+
<button type="button" id="modeTerminalBtn" class="secondary">交互终端</button>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="mode-terminal-controls">
|
|
72
|
+
<button type="button" id="terminalConnectBtn">连接终端</button>
|
|
73
|
+
<button type="button" id="terminalDisconnectBtn" class="secondary">断开终端</button>
|
|
74
|
+
<span id="terminalStatus" class="terminal-status">未连接</span>
|
|
75
|
+
</div>
|
|
76
|
+
</section>
|
|
65
77
|
<section id="messages"></section>
|
|
78
|
+
<section id="terminalPanel" hidden>
|
|
79
|
+
<div id="terminalScreen" aria-label="终端输出区域"></div>
|
|
80
|
+
<div class="terminal-foot">点击终端后可直接输入;适用于 codex / claude 等交互式 agent。</div>
|
|
81
|
+
</section>
|
|
66
82
|
<form class="composer" id="composer">
|
|
67
83
|
<div class="composer-inner">
|
|
68
84
|
<textarea id="commandInput" placeholder="输入容器命令,例如: ls -la"></textarea>
|
|
@@ -77,6 +93,8 @@
|
|
|
77
93
|
<div id="sidebarBackdrop" class="sidebar-backdrop" hidden></div>
|
|
78
94
|
</div>
|
|
79
95
|
|
|
96
|
+
<script src="/app/vendor/xterm.js"></script>
|
|
97
|
+
<script src="/app/vendor/xterm-addon-fit.js"></script>
|
|
80
98
|
<script src="/app/frontend/app.js"></script>
|
|
81
99
|
</body>
|
|
82
100
|
</html>
|
package/lib/web/frontend/app.js
CHANGED
|
@@ -39,11 +39,26 @@
|
|
|
39
39
|
sessions: [],
|
|
40
40
|
active: '',
|
|
41
41
|
messages: [],
|
|
42
|
+
messageRenderKeys: [],
|
|
43
|
+
mode: 'command',
|
|
42
44
|
sending: false,
|
|
43
45
|
loadingSessions: false,
|
|
44
46
|
loadingMessages: false,
|
|
45
47
|
mobileSidebarOpen: false,
|
|
46
|
-
mobileActionsOpen: false
|
|
48
|
+
mobileActionsOpen: false,
|
|
49
|
+
terminal: {
|
|
50
|
+
term: null,
|
|
51
|
+
fitAddon: null,
|
|
52
|
+
socket: null,
|
|
53
|
+
connected: false,
|
|
54
|
+
connecting: false,
|
|
55
|
+
status: '未连接',
|
|
56
|
+
sessionName: '',
|
|
57
|
+
terminalReady: false,
|
|
58
|
+
fitTimer: null,
|
|
59
|
+
lastSentCols: 0,
|
|
60
|
+
lastSentRows: 0
|
|
61
|
+
}
|
|
47
62
|
};
|
|
48
63
|
|
|
49
64
|
const sidebarNode = document.querySelector('.sidebar');
|
|
@@ -56,7 +71,14 @@
|
|
|
56
71
|
const sidebarBackdrop = document.getElementById('sidebarBackdrop');
|
|
57
72
|
const activeTitle = document.getElementById('activeTitle');
|
|
58
73
|
const activeMeta = document.getElementById('activeMeta');
|
|
74
|
+
const modeCommandBtn = document.getElementById('modeCommandBtn');
|
|
75
|
+
const modeTerminalBtn = document.getElementById('modeTerminalBtn');
|
|
59
76
|
const messagesNode = document.getElementById('messages');
|
|
77
|
+
const terminalPanel = document.getElementById('terminalPanel');
|
|
78
|
+
const terminalConnectBtn = document.getElementById('terminalConnectBtn');
|
|
79
|
+
const terminalDisconnectBtn = document.getElementById('terminalDisconnectBtn');
|
|
80
|
+
const terminalStatus = document.getElementById('terminalStatus');
|
|
81
|
+
const terminalScreen = document.getElementById('terminalScreen');
|
|
60
82
|
const newSessionForm = document.getElementById('newSessionForm');
|
|
61
83
|
const newSessionName = document.getElementById('newSessionName');
|
|
62
84
|
const createSessionBtn = newSessionForm.querySelector('button[type="submit"]');
|
|
@@ -69,6 +91,11 @@
|
|
|
69
91
|
const removeAllBtn = document.getElementById('removeAllBtn');
|
|
70
92
|
const MOBILE_LAYOUT_MEDIA = window.matchMedia('(max-width: 980px)');
|
|
71
93
|
const MOBILE_COMPACT_MEDIA = window.matchMedia('(max-width: 640px)');
|
|
94
|
+
const TERMINAL_FIT_DEBOUNCE_MS = 60;
|
|
95
|
+
const TERMINAL_MIN_COLS = 40;
|
|
96
|
+
const TERMINAL_MIN_ROWS = 12;
|
|
97
|
+
const TERMINAL_DEFAULT_COLS = 120;
|
|
98
|
+
const TERMINAL_DEFAULT_ROWS = 36;
|
|
72
99
|
|
|
73
100
|
function roleName(role) {
|
|
74
101
|
if (role === 'user') return '你';
|
|
@@ -144,6 +171,278 @@
|
|
|
144
171
|
return parts.join(' · ');
|
|
145
172
|
}
|
|
146
173
|
|
|
174
|
+
function writeTerminalLine(text) {
|
|
175
|
+
if (!state.terminal.term) return;
|
|
176
|
+
state.terminal.term.writeln(text);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function renderTerminalIntro() {
|
|
180
|
+
if (!state.terminal.term) return;
|
|
181
|
+
state.terminal.term.reset();
|
|
182
|
+
writeTerminalLine('MANYOYO Interactive Terminal');
|
|
183
|
+
writeTerminalLine(state.active ? ('当前会话: ' + state.active) : '当前会话: 未选择');
|
|
184
|
+
writeTerminalLine('点击“连接终端”后可运行 codex / claude 等交互式 agent。');
|
|
185
|
+
writeTerminalLine('');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function resolveFitAddonCtor() {
|
|
189
|
+
if (window.FitAddon && typeof window.FitAddon.FitAddon === 'function') {
|
|
190
|
+
return window.FitAddon.FitAddon;
|
|
191
|
+
}
|
|
192
|
+
if (typeof window.FitAddon === 'function') {
|
|
193
|
+
return window.FitAddon;
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function normalizeTerminalSize(cols, rows) {
|
|
199
|
+
const parsedCols = Number.parseInt(cols, 10);
|
|
200
|
+
const parsedRows = Number.parseInt(rows, 10);
|
|
201
|
+
const safeCols = Number.isFinite(parsedCols) && parsedCols > 0 ? parsedCols : TERMINAL_DEFAULT_COLS;
|
|
202
|
+
const safeRows = Number.isFinite(parsedRows) && parsedRows > 0 ? parsedRows : TERMINAL_DEFAULT_ROWS;
|
|
203
|
+
return {
|
|
204
|
+
cols: Math.max(TERMINAL_MIN_COLS, safeCols),
|
|
205
|
+
rows: Math.max(TERMINAL_MIN_ROWS, safeRows)
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function notifyTerminalResize(force) {
|
|
210
|
+
if (!state.terminal.term) return;
|
|
211
|
+
if (!state.terminal.socket || state.terminal.socket.readyState !== window.WebSocket.OPEN) return;
|
|
212
|
+
const size = normalizeTerminalSize(
|
|
213
|
+
state.terminal.term.cols,
|
|
214
|
+
state.terminal.term.rows
|
|
215
|
+
);
|
|
216
|
+
const cols = size.cols;
|
|
217
|
+
const rows = size.rows;
|
|
218
|
+
if (!force && cols === state.terminal.lastSentCols && rows === state.terminal.lastSentRows) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
state.terminal.lastSentCols = cols;
|
|
222
|
+
state.terminal.lastSentRows = rows;
|
|
223
|
+
state.terminal.socket.send(JSON.stringify({
|
|
224
|
+
type: 'resize',
|
|
225
|
+
cols: cols,
|
|
226
|
+
rows: rows
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function fitTerminalNow(forceNotify) {
|
|
231
|
+
if (!state.terminal.term || !state.terminal.fitAddon) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (!terminalScreen || terminalScreen.clientWidth <= 0 || terminalScreen.clientHeight <= 0) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
state.terminal.fitAddon.fit();
|
|
239
|
+
} catch (e) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
notifyTerminalResize(Boolean(forceNotify));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function scheduleTerminalFit(forceNotify) {
|
|
246
|
+
if (!state.terminal.term || !state.terminal.fitAddon) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (state.terminal.fitTimer) {
|
|
250
|
+
window.clearTimeout(state.terminal.fitTimer);
|
|
251
|
+
state.terminal.fitTimer = null;
|
|
252
|
+
}
|
|
253
|
+
state.terminal.fitTimer = window.setTimeout(function () {
|
|
254
|
+
state.terminal.fitTimer = null;
|
|
255
|
+
fitTerminalNow(forceNotify);
|
|
256
|
+
}, TERMINAL_FIT_DEBOUNCE_MS);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function ensureTerminalReady() {
|
|
260
|
+
if (state.terminal.terminalReady) {
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
if (!window.Terminal) {
|
|
264
|
+
state.terminal.status = '终端组件加载失败';
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
const FitAddonCtor = resolveFitAddonCtor();
|
|
268
|
+
if (!FitAddonCtor) {
|
|
269
|
+
state.terminal.status = '终端组件加载失败';
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
state.terminal.term = new window.Terminal({
|
|
273
|
+
cursorBlink: true,
|
|
274
|
+
convertEol: false,
|
|
275
|
+
fontFamily: '"IBM Plex Mono", "SFMono-Regular", Consolas, Menlo, monospace',
|
|
276
|
+
fontSize: 13,
|
|
277
|
+
scrollback: 5000,
|
|
278
|
+
theme: {
|
|
279
|
+
background: '#0c131a',
|
|
280
|
+
foreground: '#dde8f3',
|
|
281
|
+
cursor: '#6fe7b5'
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
state.terminal.fitAddon = new FitAddonCtor();
|
|
285
|
+
state.terminal.term.loadAddon(state.terminal.fitAddon);
|
|
286
|
+
state.terminal.term.open(terminalScreen);
|
|
287
|
+
scheduleTerminalFit(false);
|
|
288
|
+
state.terminal.term.onData(function (data) {
|
|
289
|
+
if (!data || !state.terminal.socket || state.terminal.socket.readyState !== window.WebSocket.OPEN) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
state.terminal.socket.send(JSON.stringify({
|
|
293
|
+
type: 'input',
|
|
294
|
+
data: data
|
|
295
|
+
}));
|
|
296
|
+
});
|
|
297
|
+
state.terminal.term.onResize(function (size) {
|
|
298
|
+
if (!size || !size.cols || !size.rows) {
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
notifyTerminalResize(false);
|
|
302
|
+
});
|
|
303
|
+
state.terminal.terminalReady = true;
|
|
304
|
+
renderTerminalIntro();
|
|
305
|
+
return true;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function buildTerminalWsUrl(sessionName) {
|
|
309
|
+
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
|
310
|
+
const url = new URL(
|
|
311
|
+
'/api/sessions/' + encodeURIComponent(sessionName) + '/terminal/ws',
|
|
312
|
+
protocol + '://' + window.location.host
|
|
313
|
+
);
|
|
314
|
+
const size = normalizeTerminalSize(
|
|
315
|
+
state.terminal.term ? state.terminal.term.cols : TERMINAL_DEFAULT_COLS,
|
|
316
|
+
state.terminal.term ? state.terminal.term.rows : TERMINAL_DEFAULT_ROWS
|
|
317
|
+
);
|
|
318
|
+
url.searchParams.set('cols', String(size.cols));
|
|
319
|
+
url.searchParams.set('rows', String(size.rows));
|
|
320
|
+
return url.toString();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function disconnectTerminal(reason, silent) {
|
|
324
|
+
const socket = state.terminal.socket;
|
|
325
|
+
state.terminal.socket = null;
|
|
326
|
+
state.terminal.connected = false;
|
|
327
|
+
state.terminal.connecting = false;
|
|
328
|
+
state.terminal.sessionName = '';
|
|
329
|
+
state.terminal.lastSentCols = 0;
|
|
330
|
+
state.terminal.lastSentRows = 0;
|
|
331
|
+
if (state.terminal.fitTimer) {
|
|
332
|
+
window.clearTimeout(state.terminal.fitTimer);
|
|
333
|
+
state.terminal.fitTimer = null;
|
|
334
|
+
}
|
|
335
|
+
if (socket && (socket.readyState === window.WebSocket.OPEN || socket.readyState === window.WebSocket.CONNECTING)) {
|
|
336
|
+
try {
|
|
337
|
+
socket.close();
|
|
338
|
+
} catch (e) {
|
|
339
|
+
// noop
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (typeof reason === 'string' && reason) {
|
|
343
|
+
state.terminal.status = reason;
|
|
344
|
+
if (!silent && state.terminal.term) {
|
|
345
|
+
writeTerminalLine('[system] ' + reason);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function connectTerminal() {
|
|
351
|
+
if (!state.active) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (!ensureTerminalReady()) {
|
|
355
|
+
syncUi();
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (state.terminal.connected && state.terminal.sessionName === state.active) {
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (state.terminal.connected || state.terminal.connecting) {
|
|
362
|
+
disconnectTerminal('终端会话已重置', true);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const sessionName = state.active;
|
|
366
|
+
fitTerminalNow(false);
|
|
367
|
+
const socket = new window.WebSocket(buildTerminalWsUrl(sessionName));
|
|
368
|
+
state.terminal.socket = socket;
|
|
369
|
+
state.terminal.connecting = true;
|
|
370
|
+
state.terminal.connected = false;
|
|
371
|
+
state.terminal.status = '连接中...';
|
|
372
|
+
state.terminal.sessionName = sessionName;
|
|
373
|
+
state.terminal.lastSentCols = 0;
|
|
374
|
+
state.terminal.lastSentRows = 0;
|
|
375
|
+
writeTerminalLine('[system] 正在连接终端...');
|
|
376
|
+
syncUi();
|
|
377
|
+
|
|
378
|
+
socket.addEventListener('open', function () {
|
|
379
|
+
if (state.terminal.socket !== socket) {
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
state.terminal.connecting = false;
|
|
383
|
+
state.terminal.connected = true;
|
|
384
|
+
state.terminal.status = '已连接';
|
|
385
|
+
if (state.terminal.term) {
|
|
386
|
+
state.terminal.term.focus();
|
|
387
|
+
scheduleTerminalFit(true);
|
|
388
|
+
}
|
|
389
|
+
syncUi();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
socket.addEventListener('message', function (event) {
|
|
393
|
+
if (!state.terminal.term) return;
|
|
394
|
+
let payload = null;
|
|
395
|
+
try {
|
|
396
|
+
payload = JSON.parse(event.data);
|
|
397
|
+
} catch (e) {
|
|
398
|
+
payload = { type: 'output', data: String(event.data || '') };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (!payload || typeof payload !== 'object') return;
|
|
402
|
+
if (payload.type === 'output' && typeof payload.data === 'string') {
|
|
403
|
+
state.terminal.term.write(payload.data);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (payload.type === 'status') {
|
|
407
|
+
if (payload.phase === 'ready') {
|
|
408
|
+
state.terminal.status = '已连接';
|
|
409
|
+
} else if (payload.phase === 'closed') {
|
|
410
|
+
state.terminal.status = '终端已关闭';
|
|
411
|
+
writeTerminalLine('[system] 终端会话已结束');
|
|
412
|
+
}
|
|
413
|
+
syncUi();
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
if (payload.type === 'error' && typeof payload.error === 'string') {
|
|
417
|
+
state.terminal.status = '终端异常';
|
|
418
|
+
writeTerminalLine('[error] ' + payload.error);
|
|
419
|
+
syncUi();
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
socket.addEventListener('error', function () {
|
|
424
|
+
if (state.terminal.socket !== socket) {
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
state.terminal.status = '终端连接异常';
|
|
428
|
+
syncUi();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
socket.addEventListener('close', function () {
|
|
432
|
+
if (state.terminal.socket !== socket) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
state.terminal.socket = null;
|
|
436
|
+
state.terminal.connecting = false;
|
|
437
|
+
state.terminal.connected = false;
|
|
438
|
+
state.terminal.sessionName = '';
|
|
439
|
+
if (state.terminal.status === '连接中...' || state.terminal.status === '已连接') {
|
|
440
|
+
state.terminal.status = '终端已断开';
|
|
441
|
+
}
|
|
442
|
+
syncUi();
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
147
446
|
function isMobileLayout() {
|
|
148
447
|
return MOBILE_LAYOUT_MEDIA.matches;
|
|
149
448
|
}
|
|
@@ -196,19 +495,48 @@
|
|
|
196
495
|
if (!state.active) {
|
|
197
496
|
activeTitle.textContent = '未选择会话';
|
|
198
497
|
activeMeta.textContent = '请选择左侧会话';
|
|
199
|
-
|
|
498
|
+
if (state.mode === 'command') {
|
|
499
|
+
commandInput.value = '';
|
|
500
|
+
}
|
|
200
501
|
} else {
|
|
201
502
|
activeTitle.textContent = state.active;
|
|
202
503
|
activeMeta.textContent = buildActiveMeta(getActiveSession());
|
|
203
504
|
}
|
|
204
505
|
|
|
506
|
+
const commandMode = state.mode !== 'terminal';
|
|
507
|
+
document.body.classList.toggle('command-mode', commandMode);
|
|
508
|
+
document.body.classList.toggle('terminal-mode', !commandMode);
|
|
509
|
+
if (modeCommandBtn) {
|
|
510
|
+
modeCommandBtn.classList.toggle('is-active', commandMode);
|
|
511
|
+
modeCommandBtn.setAttribute('aria-pressed', commandMode ? 'true' : 'false');
|
|
512
|
+
}
|
|
513
|
+
if (modeTerminalBtn) {
|
|
514
|
+
modeTerminalBtn.classList.toggle('is-active', !commandMode);
|
|
515
|
+
modeTerminalBtn.setAttribute('aria-pressed', !commandMode ? 'true' : 'false');
|
|
516
|
+
}
|
|
517
|
+
if (terminalPanel) {
|
|
518
|
+
terminalPanel.hidden = commandMode;
|
|
519
|
+
}
|
|
520
|
+
if (!commandMode && state.terminal.terminalReady) {
|
|
521
|
+
scheduleTerminalFit(false);
|
|
522
|
+
}
|
|
523
|
+
|
|
205
524
|
const busy = state.loadingSessions || state.loadingMessages || state.sending;
|
|
206
525
|
refreshBtn.disabled = busy;
|
|
207
526
|
removeBtn.disabled = !state.active || busy;
|
|
208
527
|
removeAllBtn.disabled = !state.active || busy;
|
|
209
|
-
sendBtn.disabled = !state.active || busy;
|
|
210
|
-
commandInput.disabled = !state.active || state.sending;
|
|
528
|
+
sendBtn.disabled = !commandMode || !state.active || busy;
|
|
529
|
+
commandInput.disabled = !commandMode || !state.active || state.sending;
|
|
211
530
|
createSessionBtn.disabled = state.loadingSessions || state.sending;
|
|
531
|
+
if (terminalConnectBtn) {
|
|
532
|
+
terminalConnectBtn.disabled = !state.active || busy || state.terminal.connecting || state.terminal.connected;
|
|
533
|
+
}
|
|
534
|
+
if (terminalDisconnectBtn) {
|
|
535
|
+
terminalDisconnectBtn.disabled = !(state.terminal.connecting || state.terminal.connected);
|
|
536
|
+
}
|
|
537
|
+
if (terminalStatus) {
|
|
538
|
+
terminalStatus.textContent = state.terminal.status;
|
|
539
|
+
}
|
|
212
540
|
|
|
213
541
|
if (!state.active) {
|
|
214
542
|
sendState.textContent = '未选择会话';
|
|
@@ -248,7 +576,7 @@
|
|
|
248
576
|
|
|
249
577
|
function renderSessionsLoading() {
|
|
250
578
|
sessionList.innerHTML = '';
|
|
251
|
-
for (let i = 0; i <
|
|
579
|
+
for (let i = 0; i < 3; i++) {
|
|
252
580
|
const skeleton = document.createElement('div');
|
|
253
581
|
skeleton.className = 'skeleton session';
|
|
254
582
|
sessionList.appendChild(skeleton);
|
|
@@ -307,10 +635,17 @@
|
|
|
307
635
|
|
|
308
636
|
btn.addEventListener('click', function () {
|
|
309
637
|
if (state.loadingMessages || state.sending) return;
|
|
638
|
+
if ((state.terminal.connected || state.terminal.connecting) && state.terminal.sessionName && state.terminal.sessionName !== session.name) {
|
|
639
|
+
disconnectTerminal('会话切换,终端已断开', true);
|
|
640
|
+
}
|
|
310
641
|
state.active = session.name;
|
|
311
642
|
if (isMobileLayout()) {
|
|
312
643
|
closeMobileSessionPanel();
|
|
313
644
|
}
|
|
645
|
+
if (state.mode === 'terminal' && ensureTerminalReady()) {
|
|
646
|
+
renderTerminalIntro();
|
|
647
|
+
scheduleTerminalFit(false);
|
|
648
|
+
}
|
|
314
649
|
syncUi();
|
|
315
650
|
renderSessions();
|
|
316
651
|
loadMessages().catch(function (e) {
|
|
@@ -323,51 +658,121 @@
|
|
|
323
658
|
|
|
324
659
|
function renderMessagesLoading() {
|
|
325
660
|
messagesNode.innerHTML = '';
|
|
326
|
-
|
|
661
|
+
state.messageRenderKeys = [];
|
|
662
|
+
for (let i = 0; i < 2; i++) {
|
|
327
663
|
const skeleton = document.createElement('div');
|
|
328
664
|
skeleton.className = 'skeleton message';
|
|
329
665
|
messagesNode.appendChild(skeleton);
|
|
330
666
|
}
|
|
331
667
|
}
|
|
332
668
|
|
|
333
|
-
function
|
|
334
|
-
|
|
669
|
+
function isMessagesNearBottom(thresholdPx) {
|
|
670
|
+
const threshold = Number.isFinite(thresholdPx) ? thresholdPx : 40;
|
|
671
|
+
if (!messagesNode) return true;
|
|
672
|
+
return (messagesNode.scrollHeight - (messagesNode.scrollTop + messagesNode.clientHeight)) <= threshold;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function scrollMessagesToBottomImmediate() {
|
|
676
|
+
if (!messagesNode) return;
|
|
677
|
+
const previousBehavior = messagesNode.style.scrollBehavior;
|
|
678
|
+
messagesNode.style.scrollBehavior = 'auto';
|
|
679
|
+
messagesNode.scrollTop = messagesNode.scrollHeight;
|
|
680
|
+
messagesNode.style.scrollBehavior = previousBehavior;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function getMessageRenderKey(msg, index) {
|
|
684
|
+
if (msg && msg.id) {
|
|
685
|
+
return `id:${msg.id}`;
|
|
686
|
+
}
|
|
687
|
+
const role = msg && msg.role ? String(msg.role) : '';
|
|
688
|
+
const timestamp = msg && msg.timestamp ? String(msg.timestamp) : '';
|
|
689
|
+
const exitCode = msg && typeof msg.exitCode === 'number' ? String(msg.exitCode) : '';
|
|
690
|
+
const pending = msg && msg.pending ? '1' : '0';
|
|
691
|
+
const content = msg && msg.content ? String(msg.content) : '';
|
|
692
|
+
return `idx:${index}|${role}|${timestamp}|${exitCode}|${pending}|${content}`;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
function createMessageRow(msg, index) {
|
|
696
|
+
const row = document.createElement('article');
|
|
697
|
+
row.className = 'msg ' + (msg.role || 'system') + (msg.pending ? ' pending' : '');
|
|
698
|
+
row.style.setProperty('--msg-index', String(index));
|
|
335
699
|
|
|
336
|
-
|
|
700
|
+
const meta = document.createElement('div');
|
|
701
|
+
meta.className = 'msg-meta';
|
|
702
|
+
meta.textContent = buildMessageMeta(msg);
|
|
703
|
+
|
|
704
|
+
const bubble = document.createElement('div');
|
|
705
|
+
bubble.className = 'bubble';
|
|
706
|
+
|
|
707
|
+
const pre = document.createElement('pre');
|
|
708
|
+
pre.textContent = msg.content || '';
|
|
709
|
+
bubble.appendChild(pre);
|
|
710
|
+
|
|
711
|
+
row.appendChild(meta);
|
|
712
|
+
row.appendChild(bubble);
|
|
713
|
+
return row;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function renderMessages(messages, options) {
|
|
717
|
+
const renderOptions = options && typeof options === 'object' ? options : {};
|
|
718
|
+
const stickToBottom = renderOptions.stickToBottom === true || isMessagesNearBottom(40);
|
|
719
|
+
|
|
720
|
+
if (state.loadingMessages && !messages.length) {
|
|
337
721
|
renderMessagesLoading();
|
|
338
722
|
return;
|
|
339
723
|
}
|
|
340
724
|
|
|
341
725
|
if (!messages.length) {
|
|
726
|
+
messagesNode.innerHTML = '';
|
|
342
727
|
const empty = document.createElement('div');
|
|
343
728
|
empty.className = 'empty';
|
|
344
729
|
empty.textContent = '输入命令后,容器输出会显示在这里。';
|
|
345
730
|
messagesNode.appendChild(empty);
|
|
731
|
+
state.messageRenderKeys = [];
|
|
346
732
|
return;
|
|
347
733
|
}
|
|
348
734
|
|
|
349
|
-
messages.
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
735
|
+
const nextKeys = messages.map(function (msg, index) {
|
|
736
|
+
return getMessageRenderKey(msg, index);
|
|
737
|
+
});
|
|
738
|
+
const prevKeys = Array.isArray(state.messageRenderKeys) ? state.messageRenderKeys : [];
|
|
739
|
+
const hasRenderedMessages = messagesNode.children.length === prevKeys.length && prevKeys.length > 0;
|
|
740
|
+
let updated = false;
|
|
741
|
+
|
|
742
|
+
if (!renderOptions.forceFullRender && hasRenderedMessages) {
|
|
743
|
+
let prefix = 0;
|
|
744
|
+
while (
|
|
745
|
+
prefix < prevKeys.length &&
|
|
746
|
+
prefix < nextKeys.length &&
|
|
747
|
+
prevKeys[prefix] === nextKeys[prefix]
|
|
748
|
+
) {
|
|
749
|
+
prefix += 1;
|
|
750
|
+
}
|
|
360
751
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
752
|
+
if (prefix === prevKeys.length && prefix === nextKeys.length) {
|
|
753
|
+
updated = true;
|
|
754
|
+
} else if (prefix > 0) {
|
|
755
|
+
while (messagesNode.children.length > prefix) {
|
|
756
|
+
messagesNode.removeChild(messagesNode.lastChild);
|
|
757
|
+
}
|
|
758
|
+
for (let i = prefix; i < messages.length; i++) {
|
|
759
|
+
messagesNode.appendChild(createMessageRow(messages[i], i));
|
|
760
|
+
}
|
|
761
|
+
updated = true;
|
|
762
|
+
}
|
|
763
|
+
}
|
|
364
764
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
765
|
+
if (!updated) {
|
|
766
|
+
messagesNode.innerHTML = '';
|
|
767
|
+
messages.forEach(function (msg, index) {
|
|
768
|
+
messagesNode.appendChild(createMessageRow(msg, index));
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
state.messageRenderKeys = nextKeys;
|
|
369
772
|
|
|
370
|
-
|
|
773
|
+
if (stickToBottom) {
|
|
774
|
+
scrollMessagesToBottomImmediate();
|
|
775
|
+
}
|
|
371
776
|
}
|
|
372
777
|
|
|
373
778
|
async function loadSessions(preferredName) {
|
|
@@ -391,6 +796,10 @@
|
|
|
391
796
|
if (!state.active && state.sessions.length) {
|
|
392
797
|
state.active = state.sessions[0].name;
|
|
393
798
|
}
|
|
799
|
+
|
|
800
|
+
if (state.terminal.sessionName && state.terminal.sessionName !== state.active) {
|
|
801
|
+
disconnectTerminal('会话已变化,终端已断开', true);
|
|
802
|
+
}
|
|
394
803
|
} catch (e) {
|
|
395
804
|
requestError = e;
|
|
396
805
|
} finally {
|
|
@@ -403,6 +812,11 @@
|
|
|
403
812
|
throw requestError;
|
|
404
813
|
}
|
|
405
814
|
|
|
815
|
+
if (state.mode === 'terminal' && ensureTerminalReady() && !state.terminal.connected && !state.terminal.connecting) {
|
|
816
|
+
renderTerminalIntro();
|
|
817
|
+
scheduleTerminalFit(false);
|
|
818
|
+
}
|
|
819
|
+
|
|
406
820
|
await loadMessages();
|
|
407
821
|
}
|
|
408
822
|
|
|
@@ -415,7 +829,9 @@
|
|
|
415
829
|
}
|
|
416
830
|
|
|
417
831
|
state.loadingMessages = true;
|
|
418
|
-
|
|
832
|
+
if (!state.messages.length) {
|
|
833
|
+
renderMessages(state.messages);
|
|
834
|
+
}
|
|
419
835
|
syncUi();
|
|
420
836
|
|
|
421
837
|
let requestError = null;
|
|
@@ -476,7 +892,7 @@
|
|
|
476
892
|
timestamp: new Date().toISOString(),
|
|
477
893
|
pending: true
|
|
478
894
|
}]);
|
|
479
|
-
renderMessages(state.messages);
|
|
895
|
+
renderMessages(state.messages, { stickToBottom: true });
|
|
480
896
|
|
|
481
897
|
state.sending = true;
|
|
482
898
|
syncUi();
|
|
@@ -491,7 +907,7 @@
|
|
|
491
907
|
} catch (e) {
|
|
492
908
|
if (state.active === submitSession) {
|
|
493
909
|
state.messages = previousMessages;
|
|
494
|
-
renderMessages(state.messages);
|
|
910
|
+
renderMessages(state.messages, { stickToBottom: true });
|
|
495
911
|
}
|
|
496
912
|
alert(e.message);
|
|
497
913
|
} finally {
|
|
@@ -519,6 +935,41 @@
|
|
|
519
935
|
composer.requestSubmit();
|
|
520
936
|
});
|
|
521
937
|
|
|
938
|
+
if (modeCommandBtn) {
|
|
939
|
+
modeCommandBtn.addEventListener('click', function () {
|
|
940
|
+
state.mode = 'command';
|
|
941
|
+
syncUi();
|
|
942
|
+
commandInput.focus();
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
if (modeTerminalBtn) {
|
|
947
|
+
modeTerminalBtn.addEventListener('click', function () {
|
|
948
|
+
state.mode = 'terminal';
|
|
949
|
+
syncUi();
|
|
950
|
+
if (ensureTerminalReady()) {
|
|
951
|
+
if (!state.terminal.connected && !state.terminal.connecting) {
|
|
952
|
+
renderTerminalIntro();
|
|
953
|
+
}
|
|
954
|
+
scheduleTerminalFit(false);
|
|
955
|
+
state.terminal.term.focus();
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
if (terminalConnectBtn) {
|
|
961
|
+
terminalConnectBtn.addEventListener('click', function () {
|
|
962
|
+
connectTerminal();
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
if (terminalDisconnectBtn) {
|
|
967
|
+
terminalDisconnectBtn.addEventListener('click', function () {
|
|
968
|
+
disconnectTerminal('终端已手动断开');
|
|
969
|
+
syncUi();
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
|
|
522
973
|
refreshBtn.addEventListener('click', function () {
|
|
523
974
|
closeMobileActionsMenu();
|
|
524
975
|
loadSessions(state.active).catch(function (e) { alert(e.message); });
|
|
@@ -560,8 +1011,17 @@
|
|
|
560
1011
|
function onLayoutMediaChange() {
|
|
561
1012
|
setMobileSessionPanel(state.mobileSidebarOpen);
|
|
562
1013
|
setMobileActionsMenu(state.mobileActionsOpen);
|
|
1014
|
+
if (state.mode === 'terminal' && state.terminal.terminalReady) {
|
|
1015
|
+
scheduleTerminalFit(false);
|
|
1016
|
+
}
|
|
563
1017
|
}
|
|
564
1018
|
|
|
1019
|
+
window.addEventListener('resize', function () {
|
|
1020
|
+
if (state.mode === 'terminal' && state.terminal.terminalReady) {
|
|
1021
|
+
scheduleTerminalFit(false);
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
|
|
565
1025
|
if (typeof MOBILE_LAYOUT_MEDIA.addEventListener === 'function') {
|
|
566
1026
|
MOBILE_LAYOUT_MEDIA.addEventListener('change', onLayoutMediaChange);
|
|
567
1027
|
} else if (typeof MOBILE_LAYOUT_MEDIA.addListener === 'function') {
|
|
@@ -590,6 +1050,9 @@
|
|
|
590
1050
|
if (!yes) return;
|
|
591
1051
|
try {
|
|
592
1052
|
const current = state.active;
|
|
1053
|
+
if (state.terminal.sessionName === current && (state.terminal.connected || state.terminal.connecting)) {
|
|
1054
|
+
disconnectTerminal('容器删除,终端已断开', true);
|
|
1055
|
+
}
|
|
593
1056
|
await api('/api/sessions/' + encodeURIComponent(current) + '/remove', {
|
|
594
1057
|
method: 'POST'
|
|
595
1058
|
});
|
|
@@ -615,9 +1078,14 @@
|
|
|
615
1078
|
}
|
|
616
1079
|
});
|
|
617
1080
|
|
|
1081
|
+
window.addEventListener('beforeunload', function () {
|
|
1082
|
+
disconnectTerminal('', true);
|
|
1083
|
+
});
|
|
1084
|
+
|
|
618
1085
|
renderSessions();
|
|
619
1086
|
renderMessages(state.messages);
|
|
620
1087
|
setMobileSessionPanel(false);
|
|
1088
|
+
document.body.classList.add('command-mode');
|
|
621
1089
|
syncUi();
|
|
622
1090
|
loadSessions().catch(function (e) {
|
|
623
1091
|
alert(e.message);
|
package/lib/web/server.js
CHANGED
|
@@ -1,18 +1,43 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { spawnSync } = require('child_process');
|
|
3
|
+
const { spawnSync, spawn } = require('child_process');
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const os = require('os');
|
|
7
7
|
const crypto = require('crypto');
|
|
8
8
|
const http = require('http');
|
|
9
|
+
const WebSocket = require('ws');
|
|
9
10
|
|
|
10
11
|
const WEB_HISTORY_MAX_MESSAGES = 500;
|
|
11
12
|
const WEB_OUTPUT_MAX_CHARS = 16000;
|
|
13
|
+
const WEB_TERMINAL_MAX_SESSIONS = 20;
|
|
14
|
+
const WEB_TERMINAL_FORCE_KILL_MS = 2000;
|
|
15
|
+
const WEB_TERMINAL_DEFAULT_COLS = 120;
|
|
16
|
+
const WEB_TERMINAL_DEFAULT_ROWS = 36;
|
|
17
|
+
const WEB_TERMINAL_MIN_COLS = 40;
|
|
18
|
+
const WEB_TERMINAL_MIN_ROWS = 12;
|
|
12
19
|
const WEB_AUTH_COOKIE_NAME = 'manyoyo_web_auth';
|
|
13
20
|
const WEB_AUTH_TTL_SECONDS = 12 * 60 * 60;
|
|
14
21
|
const FRONTEND_DIR = path.join(__dirname, 'frontend');
|
|
15
22
|
|
|
23
|
+
let XTERM_JS_FILE = null;
|
|
24
|
+
let XTERM_CSS_FILE = null;
|
|
25
|
+
let XTERM_ADDON_FIT_JS_FILE = null;
|
|
26
|
+
try {
|
|
27
|
+
const xtermPackageDir = path.dirname(require.resolve('@xterm/xterm/package.json'));
|
|
28
|
+
XTERM_JS_FILE = path.join(xtermPackageDir, 'lib', 'xterm.js');
|
|
29
|
+
XTERM_CSS_FILE = path.join(xtermPackageDir, 'css', 'xterm.css');
|
|
30
|
+
} catch (e) {
|
|
31
|
+
XTERM_JS_FILE = null;
|
|
32
|
+
XTERM_CSS_FILE = null;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const xtermAddonFitPackageDir = path.dirname(require.resolve('@xterm/addon-fit/package.json'));
|
|
36
|
+
XTERM_ADDON_FIT_JS_FILE = path.join(xtermAddonFitPackageDir, 'lib', 'addon-fit.js');
|
|
37
|
+
} catch (e) {
|
|
38
|
+
XTERM_ADDON_FIT_JS_FILE = null;
|
|
39
|
+
}
|
|
40
|
+
|
|
16
41
|
const MIME_TYPES = {
|
|
17
42
|
'.css': 'text/css; charset=utf-8',
|
|
18
43
|
'.js': 'application/javascript; charset=utf-8',
|
|
@@ -351,9 +376,24 @@ function resolveStaticAsset(name) {
|
|
|
351
376
|
return fs.existsSync(fullPath) ? fullPath : null;
|
|
352
377
|
}
|
|
353
378
|
|
|
354
|
-
function
|
|
355
|
-
|
|
356
|
-
|
|
379
|
+
function resolveVendorAsset(name) {
|
|
380
|
+
if (!isSafeStaticAssetName(name)) {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
if (name === 'xterm.js') {
|
|
384
|
+
return XTERM_JS_FILE && fs.existsSync(XTERM_JS_FILE) ? XTERM_JS_FILE : null;
|
|
385
|
+
}
|
|
386
|
+
if (name === 'xterm.css') {
|
|
387
|
+
return XTERM_CSS_FILE && fs.existsSync(XTERM_CSS_FILE) ? XTERM_CSS_FILE : null;
|
|
388
|
+
}
|
|
389
|
+
if (name === 'xterm-addon-fit.js') {
|
|
390
|
+
return XTERM_ADDON_FIT_JS_FILE && fs.existsSync(XTERM_ADDON_FIT_JS_FILE) ? XTERM_ADDON_FIT_JS_FILE : null;
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function sendFileAsset(res, filePath) {
|
|
396
|
+
if (!filePath || !fs.existsSync(filePath)) {
|
|
357
397
|
sendHtml(res, 404, '<h1>404 Not Found</h1>');
|
|
358
398
|
return;
|
|
359
399
|
}
|
|
@@ -368,6 +408,14 @@ function sendStaticAsset(res, assetName) {
|
|
|
368
408
|
res.end(content);
|
|
369
409
|
}
|
|
370
410
|
|
|
411
|
+
function sendStaticAsset(res, assetName) {
|
|
412
|
+
sendFileAsset(res, resolveStaticAsset(assetName));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function sendVendorAsset(res, assetName) {
|
|
416
|
+
sendFileAsset(res, resolveVendorAsset(assetName));
|
|
417
|
+
}
|
|
418
|
+
|
|
371
419
|
function loadTemplate(name) {
|
|
372
420
|
const filePath = resolveStaticAsset(name);
|
|
373
421
|
if (!filePath) {
|
|
@@ -376,6 +424,196 @@ function loadTemplate(name) {
|
|
|
376
424
|
return fs.readFileSync(filePath, 'utf-8');
|
|
377
425
|
}
|
|
378
426
|
|
|
427
|
+
function toPositiveInt(value, fallback) {
|
|
428
|
+
const parsed = Number.parseInt(value, 10);
|
|
429
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
430
|
+
return fallback;
|
|
431
|
+
}
|
|
432
|
+
return parsed;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function normalizeTerminalSize(cols, rows) {
|
|
436
|
+
return {
|
|
437
|
+
cols: Math.max(WEB_TERMINAL_MIN_COLS, toPositiveInt(cols, WEB_TERMINAL_DEFAULT_COLS)),
|
|
438
|
+
rows: Math.max(WEB_TERMINAL_MIN_ROWS, toPositiveInt(rows, WEB_TERMINAL_DEFAULT_ROWS))
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function getUpgradeStatusText(statusCode) {
|
|
443
|
+
if (statusCode === 400) return 'Bad Request';
|
|
444
|
+
if (statusCode === 401) return 'Unauthorized';
|
|
445
|
+
if (statusCode === 404) return 'Not Found';
|
|
446
|
+
if (statusCode === 429) return 'Too Many Requests';
|
|
447
|
+
if (statusCode === 500) return 'Internal Server Error';
|
|
448
|
+
return 'Error';
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function sendWebSocketUpgradeError(socket, statusCode, message) {
|
|
452
|
+
const body = String(message || getUpgradeStatusText(statusCode));
|
|
453
|
+
const reason = getUpgradeStatusText(statusCode);
|
|
454
|
+
if (!socket.destroyed) {
|
|
455
|
+
socket.write(
|
|
456
|
+
`HTTP/1.1 ${statusCode} ${reason}\r\n` +
|
|
457
|
+
'Content-Type: text/plain; charset=utf-8\r\n' +
|
|
458
|
+
'Connection: close\r\n' +
|
|
459
|
+
`Content-Length: ${Buffer.byteLength(body, 'utf-8')}\r\n` +
|
|
460
|
+
'\r\n' +
|
|
461
|
+
body
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
socket.destroy();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function sendTerminalEvent(ws, type, payload = {}) {
|
|
468
|
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
ws.send(JSON.stringify({ type, ...payload }));
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function spawnWebTerminalProcess(ctx, containerName, cols, rows) {
|
|
475
|
+
const terminalBootstrap = [
|
|
476
|
+
'MANYOYO_WEB_BASHRC="$(mktemp /tmp/manyoyo-web-bashrc.XXXXXX 2>/dev/null || mktemp)"',
|
|
477
|
+
'cat > "$MANYOYO_WEB_BASHRC" <<\'EOF_MANYOYO_RC\'',
|
|
478
|
+
'if [ -f /etc/bash.bashrc ]; then',
|
|
479
|
+
' . /etc/bash.bashrc',
|
|
480
|
+
'fi',
|
|
481
|
+
'if [ -f ~/.bashrc ]; then',
|
|
482
|
+
' . ~/.bashrc',
|
|
483
|
+
'fi',
|
|
484
|
+
'if [ -n "${MANYOYO_TERM_COLS:-}" ] && [ -n "${MANYOYO_TERM_ROWS:-}" ]; then',
|
|
485
|
+
' COLUMNS="$MANYOYO_TERM_COLS"',
|
|
486
|
+
' LINES="$MANYOYO_TERM_ROWS"',
|
|
487
|
+
' export COLUMNS LINES',
|
|
488
|
+
' stty cols "$MANYOYO_TERM_COLS" rows "$MANYOYO_TERM_ROWS" >/dev/null 2>&1 || true',
|
|
489
|
+
'fi',
|
|
490
|
+
'EOF_MANYOYO_RC',
|
|
491
|
+
'chmod 600 "$MANYOYO_WEB_BASHRC" >/dev/null 2>&1 || true',
|
|
492
|
+
'if command -v script >/dev/null 2>&1; then',
|
|
493
|
+
' exec script -qefc "/bin/bash --rcfile $MANYOYO_WEB_BASHRC -i" /dev/null;',
|
|
494
|
+
'fi;',
|
|
495
|
+
'if command -v python3 >/dev/null 2>&1; then',
|
|
496
|
+
' exec python3 -c \'import os, pty; pty.spawn(["/bin/bash","--rcfile",os.environ.get("MANYOYO_WEB_BASHRC","/dev/null"),"-i"])\';',
|
|
497
|
+
'fi;',
|
|
498
|
+
'if command -v python >/dev/null 2>&1; then',
|
|
499
|
+
' exec python -c \'import os, pty; pty.spawn(["/bin/bash","--rcfile",os.environ.get("MANYOYO_WEB_BASHRC","/dev/null"),"-i"])\';',
|
|
500
|
+
'fi;',
|
|
501
|
+
'echo "[manyoyo] 容器内未找到 script/python,终端将降级为非 TTY 模式" >&2;',
|
|
502
|
+
'exec /bin/bash --rcfile "$MANYOYO_WEB_BASHRC" -i'
|
|
503
|
+
].join('\n');
|
|
504
|
+
|
|
505
|
+
const termValue = process.env.TERM && process.env.TERM !== 'dumb' ? process.env.TERM : 'xterm-256color';
|
|
506
|
+
const colorTermValue = process.env.COLORTERM || 'truecolor';
|
|
507
|
+
const dockerExecArgs = [
|
|
508
|
+
'exec',
|
|
509
|
+
'-i',
|
|
510
|
+
'-e', `TERM=${termValue}`,
|
|
511
|
+
'-e', `COLORTERM=${colorTermValue}`,
|
|
512
|
+
'-e', `MANYOYO_TERM_COLS=${String(cols)}`,
|
|
513
|
+
'-e', `MANYOYO_TERM_ROWS=${String(rows)}`,
|
|
514
|
+
containerName,
|
|
515
|
+
'/bin/bash',
|
|
516
|
+
'-lc',
|
|
517
|
+
terminalBootstrap
|
|
518
|
+
];
|
|
519
|
+
|
|
520
|
+
return spawn(ctx.dockerCmd, dockerExecArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows) {
|
|
524
|
+
const sessionId = `${containerName}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
|
525
|
+
const ptyProcess = spawnWebTerminalProcess(ctx, containerName, cols, rows);
|
|
526
|
+
const session = {
|
|
527
|
+
id: sessionId,
|
|
528
|
+
containerName,
|
|
529
|
+
ptyProcess,
|
|
530
|
+
closing: false
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
state.terminalSessions.set(sessionId, session);
|
|
534
|
+
sendTerminalEvent(ws, 'status', {
|
|
535
|
+
phase: 'ready',
|
|
536
|
+
sessionId,
|
|
537
|
+
containerName,
|
|
538
|
+
cols,
|
|
539
|
+
rows
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const cleanup = () => {
|
|
543
|
+
if (session.closing) {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
session.closing = true;
|
|
547
|
+
state.terminalSessions.delete(sessionId);
|
|
548
|
+
if (ptyProcess && !ptyProcess.killed) {
|
|
549
|
+
ptyProcess.kill('SIGTERM');
|
|
550
|
+
setTimeout(() => {
|
|
551
|
+
if (!ptyProcess.killed) {
|
|
552
|
+
ptyProcess.kill('SIGKILL');
|
|
553
|
+
}
|
|
554
|
+
}, WEB_TERMINAL_FORCE_KILL_MS);
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
ptyProcess.stdout.on('data', chunk => {
|
|
559
|
+
sendTerminalEvent(ws, 'output', { data: chunk.toString('utf-8') });
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
ptyProcess.stderr.on('data', chunk => {
|
|
563
|
+
sendTerminalEvent(ws, 'output', { data: chunk.toString('utf-8') });
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
ptyProcess.on('error', err => {
|
|
567
|
+
sendTerminalEvent(ws, 'error', {
|
|
568
|
+
error: err && err.message ? err.message : '终端进程启动失败'
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
ptyProcess.on('close', (code, signal) => {
|
|
573
|
+
sendTerminalEvent(ws, 'status', {
|
|
574
|
+
phase: 'closed',
|
|
575
|
+
code: typeof code === 'number' ? code : null,
|
|
576
|
+
signal: signal || null
|
|
577
|
+
});
|
|
578
|
+
cleanup();
|
|
579
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
580
|
+
ws.close();
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
ws.on('message', raw => {
|
|
585
|
+
let payload = null;
|
|
586
|
+
try {
|
|
587
|
+
payload = JSON.parse(raw.toString('utf-8'));
|
|
588
|
+
} catch (e) {
|
|
589
|
+
payload = {
|
|
590
|
+
type: 'input',
|
|
591
|
+
data: raw.toString('utf-8')
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
if (!payload || typeof payload !== 'object') {
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (payload.type === 'input' && typeof payload.data === 'string' && payload.data.length) {
|
|
599
|
+
ptyProcess.stdin.write(payload.data);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (payload.type === 'resize') {
|
|
604
|
+
// 当前后端不直接驱动 docker exec 的 TTY 动态 resize,保留事件以便后续扩展。
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (payload.type === 'close') {
|
|
609
|
+
ws.close();
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
ws.on('close', cleanup);
|
|
614
|
+
ws.on('error', cleanup);
|
|
615
|
+
}
|
|
616
|
+
|
|
379
617
|
async function handleWebAuthRoutes(req, res, pathname, ctx, state) {
|
|
380
618
|
if (req.method === 'GET' && pathname === '/auth/login') {
|
|
381
619
|
sendHtml(res, 200, loadTemplate('login.html'));
|
|
@@ -600,12 +838,28 @@ async function startWebServer(options) {
|
|
|
600
838
|
|
|
601
839
|
const state = {
|
|
602
840
|
webHistoryDir: options.webHistoryDir || path.join(os.homedir(), '.manyoyo', 'web-history'),
|
|
603
|
-
authSessions: new Map()
|
|
841
|
+
authSessions: new Map(),
|
|
842
|
+
terminalSessions: new Map()
|
|
604
843
|
};
|
|
605
844
|
|
|
606
845
|
ctx.validateHostPath();
|
|
607
846
|
ensureWebHistoryDir(state.webHistoryDir);
|
|
608
847
|
|
|
848
|
+
const wsServer = new WebSocket.Server({
|
|
849
|
+
noServer: true,
|
|
850
|
+
maxPayload: 1024 * 1024
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
wsServer.on('connection', (ws, req, meta = {}) => {
|
|
854
|
+
const containerName = meta.containerName;
|
|
855
|
+
if (!containerName || !ctx.isValidContainerName(containerName)) {
|
|
856
|
+
ws.close();
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
const { cols, rows } = normalizeTerminalSize(meta.cols, meta.rows);
|
|
860
|
+
bindTerminalWebSocket(ctx, state, ws, containerName, cols, rows);
|
|
861
|
+
});
|
|
862
|
+
|
|
609
863
|
const server = http.createServer(async (req, res) => {
|
|
610
864
|
try {
|
|
611
865
|
const fallbackHost = `${formatUrlHost(ctx.serverHost)}:${ctx.serverPort}`;
|
|
@@ -639,6 +893,17 @@ async function startWebServer(options) {
|
|
|
639
893
|
return;
|
|
640
894
|
}
|
|
641
895
|
|
|
896
|
+
const appVendorMatch = pathname.match(/^\/app\/vendor\/([A-Za-z0-9._-]+)$/);
|
|
897
|
+
if (req.method === 'GET' && appVendorMatch) {
|
|
898
|
+
const assetName = appVendorMatch[1];
|
|
899
|
+
if (!(assetName === 'xterm.css' || assetName === 'xterm.js' || assetName === 'xterm-addon-fit.js')) {
|
|
900
|
+
sendHtml(res, 404, '<h1>404 Not Found</h1>');
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
sendVendorAsset(res, assetName);
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
|
|
642
907
|
if (pathname === '/healthz') {
|
|
643
908
|
sendJson(res, 200, { ok: true });
|
|
644
909
|
return;
|
|
@@ -662,13 +927,66 @@ async function startWebServer(options) {
|
|
|
662
927
|
}
|
|
663
928
|
});
|
|
664
929
|
|
|
930
|
+
server.on('upgrade', (req, socket, head) => {
|
|
931
|
+
const fallbackHost = `${formatUrlHost(ctx.serverHost)}:${ctx.serverPort}`;
|
|
932
|
+
let url;
|
|
933
|
+
try {
|
|
934
|
+
url = new URL(req.url || '/', `http://${req.headers.host || fallbackHost}`);
|
|
935
|
+
} catch (e) {
|
|
936
|
+
sendWebSocketUpgradeError(socket, 400, 'Invalid URL');
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const terminalMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/terminal\/ws$/);
|
|
941
|
+
if (!terminalMatch) {
|
|
942
|
+
socket.destroy();
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const authSession = getWebAuthSession(state, req);
|
|
947
|
+
if (!authSession) {
|
|
948
|
+
sendWebSocketUpgradeError(socket, 401, 'UNAUTHORIZED');
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const containerName = decodeSessionName(terminalMatch[1]);
|
|
953
|
+
if (!ctx.isValidContainerName(containerName)) {
|
|
954
|
+
sendWebSocketUpgradeError(socket, 400, `containerName 非法: ${containerName}`);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
if (state.terminalSessions.size >= WEB_TERMINAL_MAX_SESSIONS) {
|
|
959
|
+
sendWebSocketUpgradeError(socket, 429, 'TERMINAL_LIMIT_REACHED');
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
const { cols, rows } = normalizeTerminalSize(
|
|
964
|
+
url.searchParams.get('cols'),
|
|
965
|
+
url.searchParams.get('rows')
|
|
966
|
+
);
|
|
967
|
+
|
|
968
|
+
ensureWebContainer(ctx, state, containerName)
|
|
969
|
+
.then(() => {
|
|
970
|
+
wsServer.handleUpgrade(req, socket, head, ws => {
|
|
971
|
+
wsServer.emit('connection', ws, req, {
|
|
972
|
+
containerName,
|
|
973
|
+
cols,
|
|
974
|
+
rows
|
|
975
|
+
});
|
|
976
|
+
});
|
|
977
|
+
})
|
|
978
|
+
.catch(e => {
|
|
979
|
+
sendWebSocketUpgradeError(socket, 500, e && e.message ? e.message : '终端创建失败');
|
|
980
|
+
});
|
|
981
|
+
});
|
|
982
|
+
|
|
665
983
|
await new Promise((resolve, reject) => {
|
|
666
984
|
server.once('error', reject);
|
|
667
985
|
server.listen(ctx.serverPort, ctx.serverHost, () => {
|
|
668
986
|
const { GREEN, CYAN, YELLOW, NC } = ctx.colors;
|
|
669
987
|
const listenHost = formatUrlHost(ctx.serverHost);
|
|
670
988
|
console.log(`${GREEN}✅ MANYOYO Web 服务已启动: http://${listenHost}:${ctx.serverPort}${NC}`);
|
|
671
|
-
console.log(`${CYAN}提示: 左侧是 manyoyo
|
|
989
|
+
console.log(`${CYAN}提示: 左侧是 manyoyo 容器会话列表,右侧支持命令模式与交互式终端模式。${NC}`);
|
|
672
990
|
if (ctx.serverHost === '0.0.0.0') {
|
|
673
991
|
console.log(`${CYAN}提示: 当前监听全部网卡,请用本机局域网 IP 访问。${NC}`);
|
|
674
992
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xcanwin/manyoyo",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.4",
|
|
4
4
|
"imageVersion": "1.7.4",
|
|
5
5
|
"description": "AI Agent CLI Security Sandbox for Docker and Podman",
|
|
6
6
|
"keywords": [
|
|
@@ -53,8 +53,11 @@
|
|
|
53
53
|
"config.example.json"
|
|
54
54
|
],
|
|
55
55
|
"dependencies": {
|
|
56
|
+
"@xterm/addon-fit": "^0.11.0",
|
|
57
|
+
"@xterm/xterm": "^6.0.0",
|
|
56
58
|
"commander": "^12.0.0",
|
|
57
|
-
"json5": "^2.2.3"
|
|
59
|
+
"json5": "^2.2.3",
|
|
60
|
+
"ws": "^8.19.0"
|
|
58
61
|
},
|
|
59
62
|
"devDependencies": {
|
|
60
63
|
"jest": "^29.7.0",
|