@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.
@@ -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>
@@ -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
- commandInput.value = '';
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 < 5; 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
- for (let i = 0; i < 3; i++) {
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 renderMessages(messages) {
334
- messagesNode.innerHTML = '';
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
- if (state.loadingMessages) {
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.forEach(function (msg, index) {
350
- const row = document.createElement('article');
351
- row.className = 'msg ' + (msg.role || 'system') + (msg.pending ? ' pending' : '');
352
- row.style.setProperty('--msg-index', String(index));
353
-
354
- const meta = document.createElement('div');
355
- meta.className = 'msg-meta';
356
- meta.textContent = buildMessageMeta(msg);
357
-
358
- const bubble = document.createElement('div');
359
- bubble.className = 'bubble';
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
- const pre = document.createElement('pre');
362
- pre.textContent = msg.content || '';
363
- bubble.appendChild(pre);
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
- row.appendChild(meta);
366
- row.appendChild(bubble);
367
- messagesNode.appendChild(row);
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
- messagesNode.scrollTop = messagesNode.scrollHeight;
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
- renderMessages(state.messages);
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 sendStaticAsset(res, assetName) {
355
- const filePath = resolveStaticAsset(assetName);
356
- if (!filePath) {
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 容器会话列表,右侧可发送命令并查看输出。${NC}`);
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.2",
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",