@xcanwin/manyoyo 6.2.6 → 6.2.8

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.
@@ -1224,6 +1224,23 @@ body.workspace-switcher-open .workspace-switcher-panel {
1224
1224
  line-height: 1.6;
1225
1225
  }
1226
1226
 
1227
+ .files-markdown-preview {
1228
+ padding: 4px 6px;
1229
+ }
1230
+
1231
+ .files-image-preview {
1232
+ display: flex;
1233
+ justify-content: center;
1234
+ padding: 12px;
1235
+ }
1236
+
1237
+ .files-image-preview img {
1238
+ max-width: 100%;
1239
+ height: auto;
1240
+ border-radius: 8px;
1241
+ border: 1px solid rgba(181, 146, 99, 0.2);
1242
+ }
1243
+
1227
1244
  .files-editor-host {
1228
1245
  min-height: 100%;
1229
1246
  }
@@ -387,35 +387,6 @@
387
387
  return status;
388
388
  }
389
389
 
390
- function buildStructuredTraceResidualLines(message) {
391
- const lines = String(message && message.content ? message.content : '')
392
- .split('\n')
393
- .map(function (line) {
394
- return String(line || '').trim();
395
- })
396
- .filter(Boolean);
397
- const traceEvents = Array.isArray(message && message.traceEvents) ? message.traceEvents : [];
398
- const consumed = new Map();
399
- traceEvents.forEach(function (traceEvent) {
400
- const key = traceEvent && traceEvent.text ? String(traceEvent.text).trim() : '';
401
- if (!key) {
402
- return;
403
- }
404
- consumed.set(key, (consumed.get(key) || 0) + 1);
405
- });
406
- return lines.filter(function (line) {
407
- if (!line || line === '[执行过程]') {
408
- return false;
409
- }
410
- const remaining = consumed.get(line) || 0;
411
- if (remaining > 0) {
412
- consumed.set(line, remaining - 1);
413
- return false;
414
- }
415
- return true;
416
- });
417
- }
418
-
419
390
  function resolveTraceTone(traceEvent) {
420
391
  const kind = traceEvent && traceEvent.kind ? String(traceEvent.kind) : '';
421
392
  if (kind === 'command') return 'command';
@@ -586,20 +557,24 @@
586
557
  const container = document.createElement('div');
587
558
  container.className = 'trace-structured';
588
559
 
560
+ const messageId = message && message.id ? message.id : '';
561
+ const pending = Boolean(state.agentRun.active && state.agentRun.traceMessageId === messageId);
562
+
589
563
  const flow = document.createElement('div');
590
564
  flow.className = 'trace-flow';
591
- buildStructuredTraceResidualLines(message).forEach(function (line) {
592
- flow.appendChild(createResidualTraceCard(line));
593
- });
565
+ if (pending) {
566
+ window.ManyoyoChatBehavior.buildStructuredTraceResidualLines(message).forEach(function (line) {
567
+ flow.appendChild(createResidualTraceCard(line));
568
+ });
569
+ }
594
570
  const traceEvents = Array.isArray(message && message.traceEvents) ? message.traceEvents : [];
595
- traceEvents.forEach(function (traceEvent) {
571
+ const mergedTraceEvents = window.ManyoyoChatBehavior.mergeToolTraceEvents(traceEvents);
572
+ mergedTraceEvents.forEach(function (traceEvent) {
596
573
  flow.appendChild(createTraceEventCard(traceEvent));
597
574
  });
598
575
 
599
- const messageId = message && message.id ? message.id : '';
600
- const pending = Boolean(state.agentRun.active && state.agentRun.traceMessageId === messageId);
601
- const summaryInfo = window.ManyoyoChatBehavior.summarizeTraceFlow(traceEvents, { pending });
602
- const hasError = traceEvents.some(function (event) {
576
+ const summaryInfo = window.ManyoyoChatBehavior.summarizeTraceFlow(mergedTraceEvents, { pending });
577
+ const hasError = mergedTraceEvents.some(function (event) {
603
578
  return event && event.kind === 'error';
604
579
  });
605
580
  const defaultOpen = hasError;
@@ -22,6 +22,62 @@
22
22
  return trimmed ? `${trimmed} · MANYOYO Web` : 'MANYOYO Web';
23
23
  }
24
24
 
25
+ function buildStructuredTraceResidualLines(message) {
26
+ const lines = String(message && message.content ? message.content : '')
27
+ .split('\n')
28
+ .map(line => String(line || '').trim())
29
+ .filter(Boolean);
30
+ const traceEvents = Array.isArray(message && message.traceEvents) ? message.traceEvents : [];
31
+ const consumed = new Map();
32
+ traceEvents.forEach(traceEvent => {
33
+ const text = traceEvent && traceEvent.text ? String(traceEvent.text) : '';
34
+ if (!text) {
35
+ return;
36
+ }
37
+ text.split('\n').forEach(subLine => {
38
+ const key = String(subLine || '').trim();
39
+ if (!key) {
40
+ return;
41
+ }
42
+ consumed.set(key, (consumed.get(key) || 0) + 1);
43
+ });
44
+ });
45
+ return lines.filter(line => {
46
+ if (!line || line === '[执行过程]') {
47
+ return false;
48
+ }
49
+ const remaining = consumed.get(line) || 0;
50
+ if (remaining > 0) {
51
+ consumed.set(line, remaining - 1);
52
+ return false;
53
+ }
54
+ return true;
55
+ });
56
+ }
57
+
58
+ const MERGEABLE_TRACE_KINDS = new Set(['tool', 'command', 'mcp']);
59
+
60
+ function mergeToolTraceEvents(traceEvents) {
61
+ const events = Array.isArray(traceEvents) ? traceEvents : [];
62
+ const result = [];
63
+ const indexByKey = new Map();
64
+ events.forEach(event => {
65
+ const kind = event && event.kind ? String(event.kind) : '';
66
+ const toolId = event && event.toolId ? String(event.toolId) : '';
67
+ if (MERGEABLE_TRACE_KINDS.has(kind) && toolId) {
68
+ const key = `${kind}:${toolId}`;
69
+ if (indexByKey.has(key)) {
70
+ const index = indexByKey.get(key);
71
+ result[index] = Object.assign({}, result[index], event);
72
+ return;
73
+ }
74
+ indexByKey.set(key, result.length);
75
+ }
76
+ result.push(event);
77
+ });
78
+ return result;
79
+ }
80
+
25
81
  function mergeTraceIntoReply(messages) {
26
82
  const list = Array.isArray(messages) ? messages : [];
27
83
  const result = [];
@@ -44,6 +100,8 @@
44
100
  isNearBottom,
45
101
  summarizeTraceFlow,
46
102
  buildDocumentTitle,
47
- mergeTraceIntoReply
103
+ mergeTraceIntoReply,
104
+ mergeToolTraceEvents,
105
+ buildStructuredTraceResidualLines
48
106
  };
49
107
  }());
@@ -104,6 +104,7 @@
104
104
  <div class="files-preview-meta" data-role="preview-meta">请选择左侧文件或目录</div>
105
105
  </div>
106
106
  <div class="files-preview-actions">
107
+ <button type="button" class="secondary" data-action="toggle-markdown-view" hidden>查看源码</button>
107
108
  <button type="button" class="secondary" data-action="save" disabled>保存</button>
108
109
  </div>
109
110
  </header>
@@ -122,6 +123,7 @@
122
123
  const visitBtn = root.querySelector('[data-action="visit"]');
123
124
  const mkdirBtn = root.querySelector('[data-action="mkdir"]');
124
125
  const saveBtn = root.querySelector('[data-action="save"]');
126
+ const toggleMarkdownViewBtn = root.querySelector('[data-action="toggle-markdown-view"]');
125
127
 
126
128
  const state = {
127
129
  visible: false,
@@ -144,7 +146,8 @@
144
146
  editor: null,
145
147
  editorHost: null,
146
148
  previewReadOnly: true,
147
- previewDirty: false
149
+ previewDirty: false,
150
+ markdownViewMode: 'rendered'
148
151
  };
149
152
 
150
153
  function setStatus(text) {
@@ -161,6 +164,12 @@
161
164
  state.editorHost = null;
162
165
  }
163
166
 
167
+ function getPreviewLanguage() {
168
+ return state.selectedFile
169
+ ? (state.selectedFile.language || inferLanguageFromPath(state.selectedFile.path))
170
+ : '';
171
+ }
172
+
164
173
  function isEditablePreview() {
165
174
  return Boolean(
166
175
  state.selectedFile
@@ -168,6 +177,7 @@
168
177
  && state.previewReadOnly === false
169
178
  && state.historyOnly !== true
170
179
  && state.savingFile !== true
180
+ && (getPreviewLanguage() !== 'markdown' || state.markdownViewMode === 'source')
171
181
  );
172
182
  }
173
183
 
@@ -179,6 +189,28 @@
179
189
  saveBtn.textContent = state.savingFile ? '保存中...' : '保存';
180
190
  }
181
191
 
192
+ function syncMarkdownToggleButton() {
193
+ if (!toggleMarkdownViewBtn) {
194
+ return;
195
+ }
196
+ const isMarkdown = Boolean(state.selectedFile)
197
+ && state.selectedFile.kind === 'text'
198
+ && getPreviewLanguage() === 'markdown';
199
+ toggleMarkdownViewBtn.hidden = !isMarkdown;
200
+ toggleMarkdownViewBtn.textContent = state.markdownViewMode === 'source' ? '查看渲染' : '查看源码';
201
+ }
202
+
203
+ function resolveMarkdownImageUrl(basePath, relativeHref) {
204
+ try {
205
+ const lastSlash = String(basePath || '').lastIndexOf('/');
206
+ const baseDir = lastSlash >= 0 ? basePath.slice(0, lastSlash + 1) : '/';
207
+ const resolvedPath = new URL(relativeHref, 'http://manyoyo-internal' + baseDir).pathname;
208
+ return '/api/sessions/' + encodeURIComponent(state.sessionName) + '/fs/raw?path=' + encodeURIComponent(resolvedPath);
209
+ } catch (e) {
210
+ return '';
211
+ }
212
+ }
213
+
182
214
  function renderPreviewEmpty(title, description) {
183
215
  state.selectedFile = null;
184
216
  state.previewReadOnly = true;
@@ -193,6 +225,7 @@
193
225
  previewBodyNode.innerHTML = `<div class="files-empty">${escapeHtml(description)}</div>`;
194
226
  }
195
227
  destroyEditor();
228
+ syncMarkdownToggleButton();
196
229
  syncSaveButton();
197
230
  }
198
231
 
@@ -226,12 +259,34 @@
226
259
  previewMetaNode.textContent = `${payload.kind === 'text' ? '文本文件' : '文件'} · ${formatBytes(payload.size)} · ${modeLabel}${payload.truncated ? ' · 已截断预览' : ''}`;
227
260
  }
228
261
  if (!previewBodyNode) {
262
+ syncMarkdownToggleButton();
229
263
  syncSaveButton();
230
264
  return;
231
265
  }
232
266
 
233
267
  if (payload.kind === 'text') {
234
268
  const language = payload.language || inferLanguageFromPath(payload.path);
269
+ const showMarkdownRendered = language === 'markdown'
270
+ && state.markdownViewMode !== 'source'
271
+ && window.ManyoyoMarkdown
272
+ && typeof window.ManyoyoMarkdown.render === 'function';
273
+
274
+ if (showMarkdownRendered) {
275
+ destroyEditor();
276
+ const markdownNode = document.createElement('div');
277
+ markdownNode.className = 'md-content files-markdown-preview';
278
+ markdownNode.innerHTML = window.ManyoyoMarkdown.render(String(payload.content || ''), {
279
+ imageUrlResolver: function (relativeHref) {
280
+ return resolveMarkdownImageUrl(payload.path, relativeHref);
281
+ }
282
+ });
283
+ previewBodyNode.innerHTML = '';
284
+ previewBodyNode.appendChild(markdownNode);
285
+ syncMarkdownToggleButton();
286
+ syncSaveButton();
287
+ return;
288
+ }
289
+
235
290
  if (window.ManyoyoCodeEditor && typeof window.ManyoyoCodeEditor.create === 'function') {
236
291
  if (!state.editor || !state.editorHost || !previewBodyNode.contains(state.editorHost)) {
237
292
  destroyEditor();
@@ -252,18 +307,31 @@
252
307
  state.editor.setLanguage(language);
253
308
  state.editor.setReadOnly(state.previewReadOnly);
254
309
  }
310
+ syncMarkdownToggleButton();
255
311
  syncSaveButton();
256
312
  return;
257
313
  }
258
314
 
259
315
  destroyEditor();
260
316
  previewBodyNode.innerHTML = `<pre class="files-pre">${escapeHtml(String(payload.content || ''))}</pre>`;
317
+ syncMarkdownToggleButton();
318
+ syncSaveButton();
319
+ return;
320
+ }
321
+
322
+ if (payload.kind === 'image') {
323
+ destroyEditor();
324
+ const rawUrl = '/api/sessions/' + encodeURIComponent(state.sessionName) + '/fs/raw?path='
325
+ + encodeURIComponent(payload.path);
326
+ previewBodyNode.innerHTML = `<div class="files-image-preview"><img src="${escapeHtml(rawUrl)}" alt="${escapeHtml(payload.path || '')}" /></div>`;
327
+ syncMarkdownToggleButton();
261
328
  syncSaveButton();
262
329
  return;
263
330
  }
264
331
 
265
332
  destroyEditor();
266
333
  previewBodyNode.innerHTML = `<div class="files-note">当前文件暂不支持在线预览。文件类型:${escapeHtml(payload.kind || 'unknown')}</div>`;
334
+ syncMarkdownToggleButton();
267
335
  syncSaveButton();
268
336
  }
269
337
 
@@ -397,6 +465,7 @@
397
465
  state.loadingFile = true;
398
466
  state.selectedPath = pathText;
399
467
  state.selectedEntry = selectedEntry;
468
+ state.markdownViewMode = 'rendered';
400
469
  renderList();
401
470
  renderPreviewEmpty(pathText, '正在读取文件内容...');
402
471
  try {
@@ -584,6 +653,13 @@
584
653
  });
585
654
  }
586
655
 
656
+ if (toggleMarkdownViewBtn) {
657
+ toggleMarkdownViewBtn.addEventListener('click', function () {
658
+ state.markdownViewMode = state.markdownViewMode === 'source' ? 'rendered' : 'source';
659
+ renderPreviewPayload(state.selectedFile);
660
+ });
661
+ }
662
+
587
663
  renderList();
588
664
 
589
665
  return {
@@ -5,7 +5,8 @@
5
5
  configured: false,
6
6
  available: false,
7
7
  linkGuardBound: false,
8
- linkOpenHandler: null
8
+ linkOpenHandler: null,
9
+ currentImageResolver: null
9
10
  };
10
11
 
11
12
  function escapeHtml(value) {
@@ -247,8 +248,14 @@
247
248
  '[\uD83D\uDDBC\uFE0F点击查看图片:' + (safeText || escapeHtml(safeHref)) + ']'
248
249
  );
249
250
  }
250
- // 相对路径:正常渲染为图片
251
- let output = '<img src="' + escapeHtml(safeHref) + '" alt="' + escapeHtml(text || '') + '"';
251
+ // 相对路径:交给调用方按当前渲染上下文解析为可访问的 URL(未提供 resolver 时原样使用)
252
+ const resolvedHref = typeof runtime.currentImageResolver === 'function'
253
+ ? runtime.currentImageResolver(safeHref)
254
+ : safeHref;
255
+ if (!resolvedHref) {
256
+ return safeText || escapeHtml(token ? token.text : text || '');
257
+ }
258
+ let output = '<img src="' + escapeHtml(resolvedHref) + '" alt="' + escapeHtml(text || '') + '"';
252
259
  if (rawTitle) {
253
260
  output += ' title="' + escapeHtml(rawTitle) + '"';
254
261
  }
@@ -272,7 +279,7 @@
272
279
  return Boolean(msg && msg.mode === 'agent' && msg.role === 'assistant');
273
280
  }
274
281
 
275
- function render(content) {
282
+ function render(content, options) {
276
283
  const source = String(content == null ? '' : content);
277
284
  if (!source) {
278
285
  return '';
@@ -284,10 +291,15 @@
284
291
  if (!markedApi) {
285
292
  return '';
286
293
  }
294
+ runtime.currentImageResolver = options && typeof options.imageUrlResolver === 'function'
295
+ ? options.imageUrlResolver
296
+ : null;
287
297
  try {
288
298
  return String(markedApi.parse(source) || '');
289
299
  } catch (e) {
290
300
  return '';
301
+ } finally {
302
+ runtime.currentImageResolver = null;
291
303
  }
292
304
  }
293
305
 
@@ -1,33 +1,33 @@
1
- .bubble .md-content {
1
+ .md-content {
2
2
  color: var(--text);
3
3
  font-size: 13px;
4
4
  line-height: 1.6;
5
5
  word-break: break-word;
6
6
  }
7
7
 
8
- .bubble .md-content > :first-child {
8
+ .md-content > :first-child {
9
9
  margin-top: 0;
10
10
  }
11
11
 
12
- .bubble .md-content > :last-child {
12
+ .md-content > :last-child {
13
13
  margin-bottom: 0;
14
14
  }
15
15
 
16
- .bubble .md-content p,
17
- .bubble .md-content ul,
18
- .bubble .md-content ol,
19
- .bubble .md-content blockquote,
20
- .bubble .md-content pre,
21
- .bubble .md-content table {
16
+ .md-content p,
17
+ .md-content ul,
18
+ .md-content ol,
19
+ .md-content blockquote,
20
+ .md-content pre,
21
+ .md-content table {
22
22
  margin: 0.6em 0;
23
23
  }
24
24
 
25
- .bubble .md-content ul,
26
- .bubble .md-content ol {
25
+ .md-content ul,
26
+ .md-content ol {
27
27
  padding-left: 1.4em;
28
28
  }
29
29
 
30
- .bubble .md-content code {
30
+ .md-content code {
31
31
  font-family: var(--font-mono);
32
32
  font-size: 12px;
33
33
  padding: 1px 5px;
@@ -35,7 +35,7 @@
35
35
  background: rgba(194, 149, 79, 0.16);
36
36
  }
37
37
 
38
- .bubble .md-content pre {
38
+ .md-content pre {
39
39
  border-radius: 8px;
40
40
  padding: 9px 10px;
41
41
  border: 1px solid #e6d2b7;
@@ -43,7 +43,7 @@
43
43
  overflow-x: auto;
44
44
  }
45
45
 
46
- .bubble .md-content pre code {
46
+ .md-content pre code {
47
47
  background: transparent;
48
48
  border-radius: 0;
49
49
  padding: 0;
@@ -51,26 +51,26 @@
51
51
  line-height: 1.5;
52
52
  }
53
53
 
54
- .bubble .md-content blockquote {
54
+ .md-content blockquote {
55
55
  margin-left: 0;
56
56
  padding-left: 10px;
57
57
  border-left: 3px solid #dcb788;
58
58
  color: #66492d;
59
59
  }
60
60
 
61
- .bubble .md-content table {
61
+ .md-content table {
62
62
  border-collapse: collapse;
63
63
  width: 100%;
64
64
  }
65
65
 
66
- .bubble .md-content th,
67
- .bubble .md-content td {
66
+ .md-content th,
67
+ .md-content td {
68
68
  border: 1px solid #e6d2b7;
69
69
  padding: 4px 6px;
70
70
  text-align: left;
71
71
  }
72
72
 
73
- .bubble .md-content a {
73
+ .md-content a {
74
74
  color: #8a4f17;
75
75
  text-decoration-line: underline;
76
76
  text-decoration-thickness: 2px;
@@ -78,7 +78,7 @@
78
78
  text-decoration-color: rgba(138, 79, 23, 0.8);
79
79
  }
80
80
 
81
- .bubble .md-content a:hover,
82
- .bubble .md-content a:focus-visible {
81
+ .md-content a:hover,
82
+ .md-content a:focus-visible {
83
83
  text-decoration-color: #8a4f17;
84
84
  }
package/lib/web/server.js CHANGED
@@ -45,6 +45,16 @@ const WEB_AGENT_CONTEXT_PER_MESSAGE_MAX_CHARS = 600;
45
45
  const NATIVE_SESSION_RESUME_PROGRAMS = new Set(['claude', 'codex', 'opencode']);
46
46
  const WEB_FILE_PREVIEW_MAX_BYTES = 512 * 1024;
47
47
  const WEB_FILE_EDIT_MAX_BYTES = 2 * 1024 * 1024;
48
+ const WEB_FILE_RAW_MAX_CONCURRENT_PER_CONTAINER = 4;
49
+ const IMAGE_EXTENSION_CONTENT_TYPES = {
50
+ '.png': 'image/png',
51
+ '.jpg': 'image/jpeg',
52
+ '.jpeg': 'image/jpeg',
53
+ '.gif': 'image/gif',
54
+ '.webp': 'image/webp',
55
+ '.bmp': 'image/bmp',
56
+ '.ico': 'image/x-icon'
57
+ };
48
58
  const WEB_AUTH_COOKIE_NAME = 'manyoyo_web_auth';
49
59
  const WEB_AUTH_TTL_SECONDS = 12 * 60 * 60;
50
60
  const WEB_SESSION_KEY_SEPARATOR = '~';
@@ -1473,6 +1483,7 @@ function prepareCodexTraceEvent(payload) {
1473
1483
  const mcpServer = pickFirstString(item.server);
1474
1484
  const mcpTool = pickFirstString(item.tool);
1475
1485
  const itemStatus = pickFirstString(item.status);
1486
+ const toolId = pickFirstString(item.id);
1476
1487
 
1477
1488
  function shortenText(value, maxChars = 140) {
1478
1489
  const raw = clipText(stripAnsi(String(value || '')).replace(/\s+/g, ' ').trim(), maxChars);
@@ -1548,14 +1559,16 @@ function prepareCodexTraceEvent(payload) {
1548
1559
  return createTraceEvent('tool', `[工具开始] ${toolName || 'tool_call'}`, {
1549
1560
  phase: 'started',
1550
1561
  status: pickDisplayStatus('in_progress'),
1551
- toolName: toolName || 'tool_call'
1562
+ toolName: toolName || 'tool_call',
1563
+ toolId
1552
1564
  });
1553
1565
  }
1554
1566
  if (itemType === 'command_execution') {
1555
1567
  return createTraceEvent('command', `[命令开始] ${commandText || 'command_execution'}`, {
1556
1568
  phase: 'started',
1557
1569
  status: pickDisplayStatus('in_progress'),
1558
- command: commandText || 'command_execution'
1570
+ command: commandText || 'command_execution',
1571
+ toolId
1559
1572
  });
1560
1573
  }
1561
1574
  if (itemType === 'mcp_tool_call') {
@@ -1573,7 +1586,8 @@ function prepareCodexTraceEvent(payload) {
1573
1586
  arguments: item.arguments && typeof item.arguments === 'object' && !Array.isArray(item.arguments)
1574
1587
  ? item.arguments
1575
1588
  : null,
1576
- argumentSummary: summary
1589
+ argumentSummary: summary,
1590
+ toolId
1577
1591
  }
1578
1592
  );
1579
1593
  }
@@ -1602,7 +1616,8 @@ function prepareCodexTraceEvent(payload) {
1602
1616
  return createTraceEvent('tool', `[工具完成] ${toolName || 'tool_call'}`, {
1603
1617
  phase: 'completed',
1604
1618
  status: pickDisplayStatus('completed'),
1605
- toolName: toolName || 'tool_call'
1619
+ toolName: toolName || 'tool_call',
1620
+ toolId
1606
1621
  });
1607
1622
  }
1608
1623
  if (itemType === 'command_execution') {
@@ -1612,7 +1627,8 @@ function prepareCodexTraceEvent(payload) {
1612
1627
  status: pickDisplayStatus(suffix),
1613
1628
  command: commandText || 'command_execution',
1614
1629
  exitCode: typeof item.exit_code === 'number' ? item.exit_code : null,
1615
- result: item.aggregated_output !== undefined ? item.aggregated_output : null
1630
+ result: item.aggregated_output !== undefined ? item.aggregated_output : null,
1631
+ toolId
1616
1632
  });
1617
1633
  }
1618
1634
  if (itemType === 'mcp_tool_call') {
@@ -1632,7 +1648,8 @@ function prepareCodexTraceEvent(payload) {
1632
1648
  : null,
1633
1649
  argumentSummary: summary,
1634
1650
  result: item.result !== undefined ? item.result : null,
1635
- error: item.error !== undefined ? item.error : null
1651
+ error: item.error !== undefined ? item.error : null,
1652
+ toolId
1636
1653
  }
1637
1654
  );
1638
1655
  }
@@ -2930,6 +2947,34 @@ ${scriptSource}
2930
2947
  __MANYOYO_NODE__`;
2931
2948
  }
2932
2949
 
2950
+ function spawnContainerRawFileStream(ctx, containerName, filePath) {
2951
+ return spawn(
2952
+ ctx.dockerCmd,
2953
+ ['exec', containerName, 'cat', '--', filePath],
2954
+ { stdio: ['ignore', 'pipe', 'pipe'] }
2955
+ );
2956
+ }
2957
+
2958
+ const webRawFileStreamCounts = new Map();
2959
+
2960
+ function acquireWebRawFileStreamSlot(containerName) {
2961
+ const current = webRawFileStreamCounts.get(containerName) || 0;
2962
+ if (current >= WEB_FILE_RAW_MAX_CONCURRENT_PER_CONTAINER) {
2963
+ return false;
2964
+ }
2965
+ webRawFileStreamCounts.set(containerName, current + 1);
2966
+ return true;
2967
+ }
2968
+
2969
+ function releaseWebRawFileStreamSlot(containerName) {
2970
+ const current = webRawFileStreamCounts.get(containerName) || 0;
2971
+ if (current <= 1) {
2972
+ webRawFileStreamCounts.delete(containerName);
2973
+ return;
2974
+ }
2975
+ webRawFileStreamCounts.set(containerName, current - 1);
2976
+ }
2977
+
2933
2978
  function inferFileLanguage(filePath) {
2934
2979
  const ext = path.extname(String(filePath || '')).toLowerCase();
2935
2980
  return FILE_LANGUAGE_MAP[ext] || 'text';
@@ -3018,9 +3063,11 @@ function buildContainerFileReadCommand(requestedPath, options = {}) {
3018
3063
  return buildWebContainerNodeCommand(`
3019
3064
  // __MANYOYO_FS_READ__
3020
3065
  const fs = require('fs');
3066
+ const path = require('path');
3021
3067
 
3022
3068
  const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
3023
3069
  const maxBytes = ${String(maxBytes)};
3070
+ const imageExtensions = new Set(${JSON.stringify(Object.keys(IMAGE_EXTENSION_CONTENT_TYPES))});
3024
3071
 
3025
3072
  function looksBinary(buffer) {
3026
3073
  const length = Math.min(buffer.length, 4096);
@@ -3044,32 +3091,66 @@ try {
3044
3091
  throw new Error('目标不是文件: ' + realPath);
3045
3092
  }
3046
3093
 
3047
- const size = stat.size;
3048
- const readBytes = maxBytes > 0 ? Math.min(size, maxBytes) : size;
3049
- const buffer = Buffer.alloc(readBytes);
3050
- const fd = fs.openSync(realPath, 'r');
3051
- try {
3052
- fs.readSync(fd, buffer, 0, readBytes, 0);
3053
- } finally {
3054
- fs.closeSync(fd);
3055
- }
3056
-
3057
- if (looksBinary(buffer)) {
3094
+ // 图片走 fs/raw 流式传输,这里只确认元信息,不把整份二进制读进内存/JSON
3095
+ if (imageExtensions.has(path.extname(realPath).toLowerCase())) {
3058
3096
  process.stdout.write(JSON.stringify({
3059
3097
  path: realPath,
3060
- kind: 'binary',
3061
- size,
3062
- truncated: maxBytes > 0 && size > maxBytes
3098
+ kind: 'image',
3099
+ size: stat.size
3063
3100
  }));
3064
3101
  } else {
3065
- process.stdout.write(JSON.stringify({
3066
- path: realPath,
3067
- kind: 'text',
3068
- size,
3069
- truncated: maxBytes > 0 && size > maxBytes,
3070
- content: buffer.toString('utf8')
3071
- }));
3102
+ const size = stat.size;
3103
+ const readBytes = maxBytes > 0 ? Math.min(size, maxBytes) : size;
3104
+ const buffer = Buffer.alloc(readBytes);
3105
+ const fd = fs.openSync(realPath, 'r');
3106
+ try {
3107
+ fs.readSync(fd, buffer, 0, readBytes, 0);
3108
+ } finally {
3109
+ fs.closeSync(fd);
3110
+ }
3111
+
3112
+ if (looksBinary(buffer)) {
3113
+ process.stdout.write(JSON.stringify({
3114
+ path: realPath,
3115
+ kind: 'binary',
3116
+ size,
3117
+ truncated: maxBytes > 0 && size > maxBytes
3118
+ }));
3119
+ } else {
3120
+ process.stdout.write(JSON.stringify({
3121
+ path: realPath,
3122
+ kind: 'text',
3123
+ size,
3124
+ truncated: maxBytes > 0 && size > maxBytes,
3125
+ content: buffer.toString('utf8')
3126
+ }));
3127
+ }
3128
+ }
3129
+ } catch (e) {
3130
+ process.stdout.write(JSON.stringify({
3131
+ error: e && e.message ? e.message : '读取文件失败'
3132
+ }));
3133
+ }
3134
+ `);
3135
+ }
3136
+
3137
+ function buildContainerFileStatCommand(requestedPath) {
3138
+ return buildWebContainerNodeCommand(`
3139
+ // __MANYOYO_FS_STAT__
3140
+ const fs = require('fs');
3141
+
3142
+ const requestedPath = ${JSON.stringify(String(requestedPath || ''))};
3143
+
3144
+ try {
3145
+ const realPath = fs.realpathSync(requestedPath);
3146
+ const stat = fs.statSync(realPath);
3147
+ if (!stat.isFile()) {
3148
+ throw new Error('目标不是文件: ' + realPath);
3072
3149
  }
3150
+ process.stdout.write(JSON.stringify({
3151
+ path: realPath,
3152
+ size: stat.size
3153
+ }));
3073
3154
  } catch (e) {
3074
3155
  process.stdout.write(JSON.stringify({
3075
3156
  error: e && e.message ? e.message : '读取文件失败'
@@ -4316,6 +4397,73 @@ async function handleWebApi(req, res, pathname, ctx, state) {
4316
4397
  sendJson(res, 200, payload);
4317
4398
  }
4318
4399
  },
4400
+ {
4401
+ method: 'GET',
4402
+ match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/fs\/raw$/),
4403
+ handler: async match => {
4404
+ const sessionRef = getValidSessionRef(ctx, res, match[1]);
4405
+ if (!sessionRef) {
4406
+ return;
4407
+ }
4408
+ const requestUrl = new URL(req.url || '/api/sessions/x/fs/raw', 'http://localhost');
4409
+ const targetPath = String(requestUrl.searchParams.get('path') || '').trim();
4410
+ if (!targetPath) {
4411
+ sendJson(res, 400, { error: 'path 不能为空' });
4412
+ return;
4413
+ }
4414
+ const ext = path.extname(targetPath).toLowerCase();
4415
+ const contentType = IMAGE_EXTENSION_CONTENT_TYPES[ext];
4416
+ if (!contentType) {
4417
+ sendJson(res, 400, { error: '不支持预览该文件类型' });
4418
+ return;
4419
+ }
4420
+
4421
+ await ensureWebContainer(ctx, state, sessionRef.containerName, sessionRef);
4422
+ const statPayload = await execJsonCommandInWebContainer(
4423
+ ctx,
4424
+ sessionRef.containerName,
4425
+ buildContainerFileStatCommand(targetPath)
4426
+ );
4427
+ if (statPayload && statPayload.error) {
4428
+ sendJson(res, 404, { error: statPayload.error });
4429
+ return;
4430
+ }
4431
+
4432
+ if (!acquireWebRawFileStreamSlot(sessionRef.containerName)) {
4433
+ sendJson(res, 429, { error: '预览并发已达上限,请稍后重试' });
4434
+ return;
4435
+ }
4436
+
4437
+ let released = false;
4438
+ const releaseOnce = () => {
4439
+ if (released) {
4440
+ return;
4441
+ }
4442
+ released = true;
4443
+ releaseWebRawFileStreamSlot(sessionRef.containerName);
4444
+ };
4445
+
4446
+ const child = spawnContainerRawFileStream(ctx, sessionRef.containerName, statPayload.path);
4447
+ // stderr 单独消费,绝不混入响应体,避免管道缓冲区打满导致 cat 进程卡死
4448
+ child.stderr.on('data', () => {});
4449
+ child.on('error', () => releaseOnce());
4450
+ child.on('close', () => releaseOnce());
4451
+ res.on('close', () => {
4452
+ releaseOnce();
4453
+ if (!child.killed) {
4454
+ child.kill();
4455
+ }
4456
+ });
4457
+
4458
+ res.writeHead(200, {
4459
+ 'Content-Type': contentType,
4460
+ 'Content-Length': String(statPayload.size),
4461
+ 'X-Content-Type-Options': 'nosniff',
4462
+ 'Cache-Control': 'no-store'
4463
+ });
4464
+ child.stdout.pipe(res);
4465
+ }
4466
+ },
4319
4467
  {
4320
4468
  method: 'PUT',
4321
4469
  match: currentPath => currentPath.match(/^\/api\/sessions\/([^/]+)\/fs\/write$/),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcanwin/manyoyo",
3
- "version": "6.2.6",
3
+ "version": "6.2.8",
4
4
  "imageVersion": "1.9.1-common",
5
5
  "playwrightCliVersion": "0.1.18",
6
6
  "description": "AI Agent CLI Security Sandbox for Docker and Podman",