@xcanwin/manyoyo 7.0.17 → 7.0.20

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/server.js CHANGED
@@ -138,6 +138,11 @@ const WEB_SESSION_KEY_SEPARATOR = '~';
138
138
  const WEB_DEFAULT_AGENT_ID = 'default';
139
139
  const WEB_DEFAULT_AGENT_NAME = 'AGENT 1';
140
140
  const WEB_CONFIG_KEEP_SECRET_PLACEHOLDER = '***HIDDEN_SECRET***';
141
+ // 取 15s 是为了在 nginx proxy_read_timeout 默认 60s 下留足余量(即便某次心跳
142
+ // 因事件循环繁忙被推迟,也还有三次机会),同时不至于把 NDJSON 流刷得太碎
143
+ const DEFAULT_AGENT_STREAM_HEARTBEAT_MS = 15 * 1000;
144
+ // token 级增量的历史落盘节流:写盘只为断线/刷新后能对账恢复,不需要逐 token 持久化
145
+ const AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS = 1000;
141
146
  const FRONTEND_DIR = path.join(__dirname, 'frontend');
142
147
  const SAFE_CONTAINER_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
143
148
  const IMAGE_VERSION_TAG_PATTERN = /^(\d+\.\d+\.\d+)-([A-Za-z0-9][A-Za-z0-9_.-]*)$/;
@@ -930,7 +935,12 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
930
935
  const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
931
936
  const flagSpecs = [
932
937
  { flag: '--verbose', pattern: /(?:^|\s)--verbose(?:\s|$)/ },
933
- { flag: '--output-format stream-json', pattern: /(?:^|\s)--output-format(?:\s|$)/ }
938
+ { flag: '--output-format stream-json', pattern: /(?:^|\s)--output-format(?:\s|$)/ },
939
+ // 没有这个 flag 时 claude 只在整条 assistant 消息生成完之后才吐一行 JSON:
940
+ // 一段几千字的回答会静默几十秒再一次性刷出来,用户看到的"执行过程"是分批跳变的。
941
+ // 带上之后会额外输出 stream_event/content_block_delta 的 token 级增量
942
+ //(解析见 extractContentDeltaFromPayload)
943
+ { flag: '--include-partial-messages', pattern: /(?:^|\s)--include-partial-messages(?:\s|$)/ }
934
944
  ];
935
945
  const sessionId = options && typeof options.sessionId === 'string' ? options.sessionId.trim() : '';
936
946
  if (sessionId) {
@@ -1792,12 +1802,39 @@ function prepareStructuredTraceEvents(agentProgram, payload, state) {
1792
1802
  return [];
1793
1803
  }
1794
1804
 
1795
- function extractContentDeltaFromPayload(agentProgram, payload) {
1805
+ function extractContentDeltaFromPayload(agentProgram, payload, state = {}) {
1796
1806
  if (!payload || typeof payload !== 'object') {
1797
1807
  return null;
1798
1808
  }
1799
1809
  if (agentProgram === 'claude') {
1800
- if (pickFirstString(payload.type) !== 'assistant') {
1810
+ const payloadType = pickFirstString(payload.type);
1811
+ // --include-partial-messages 产生的 token 级增量。text 是累计全文(服务端
1812
+ // 落盘用),chunk 是本次新增的片段(下发给前端用)——一轮回答可能有几千个
1813
+ // 增量,每次都把累计全文发一遍是 O(n²) 流量,长回答能到 MB 级。
1814
+ // 一轮里可能有多条 assistant 消息(正文→工具→正文),message_start 清空累计,
1815
+ // 语义与下面整条 assistant 消息的 reset 一致
1816
+ if (payloadType === 'stream_event') {
1817
+ const streamEvent = toPlainObject(payload.event);
1818
+ const streamEventType = pickFirstString(streamEvent.type);
1819
+ if (streamEventType === 'message_start') {
1820
+ state.claudePartialText = '';
1821
+ return { text: '', reset: true, partial: true, chunk: '', chunkReset: true };
1822
+ }
1823
+ if (streamEventType !== 'content_block_delta') {
1824
+ return null;
1825
+ }
1826
+ const delta = toPlainObject(streamEvent.delta);
1827
+ if (pickFirstString(delta.type) !== 'text_delta') {
1828
+ return null;
1829
+ }
1830
+ const deltaText = typeof delta.text === 'string' ? delta.text : '';
1831
+ if (!deltaText) {
1832
+ return null;
1833
+ }
1834
+ state.claudePartialText = `${state.claudePartialText || ''}${deltaText}`;
1835
+ return { text: state.claudePartialText, reset: true, partial: true, chunk: deltaText };
1836
+ }
1837
+ if (payloadType !== 'assistant') {
1801
1838
  return null;
1802
1839
  }
1803
1840
  const message = toPlainObject(payload.message);
@@ -3752,7 +3789,13 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
3752
3789
  }
3753
3790
  onEvent({
3754
3791
  type: 'content_delta',
3755
- content: contentDeltaAccumulator
3792
+ content: contentDeltaAccumulator,
3793
+ // token 级增量标记为 partial:调用方据此跳过事件日志、
3794
+ // 并对历史落盘做节流(否则每个 token 都要重写一次历史 JSON);
3795
+ // chunk/chunkReset 只在 partial 时有意义,见 extractContentDeltaFromPayload
3796
+ partial: deltaContent.partial === true,
3797
+ chunk: typeof deltaContent.chunk === 'string' ? deltaContent.chunk : '',
3798
+ chunkReset: deltaContent.chunkReset === true
3756
3799
  });
3757
3800
  }
3758
3801
  return;
@@ -3970,10 +4013,15 @@ function getValidSessionRef(ctx, res, encodedName) {
3970
4013
  return parsed;
3971
4014
  }
3972
4015
 
3973
- function buildSessionSummary(ctx, state, containerMap, sessionRef) {
4016
+ // preloadedHistory:调用方已经读过同一个容器的历史时直接复用。历史 JSON 是整份
4017
+ // 读文件 + JSON.parse,容器多、agent 多时按 agent 数重复读会把 GET /api/sessions
4018
+ // 拖到秒级,而它是同步 IO——事件循环被占住期间 agent/stream 的输出只能攒着分批推
4019
+ function buildSessionSummary(ctx, state, containerMap, sessionRef, preloadedHistory = null) {
3974
4020
  const containerName = sessionRef && sessionRef.containerName ? sessionRef.containerName : '';
3975
4021
  const agentId = sessionRef && sessionRef.agentId ? sessionRef.agentId : WEB_DEFAULT_AGENT_ID;
3976
- const history = loadWebSessionHistory(state.webHistoryDir, containerName);
4022
+ const history = preloadedHistory && typeof preloadedHistory === 'object'
4023
+ ? preloadedHistory
4024
+ : loadWebSessionHistory(state.webHistoryDir, containerName);
3977
4025
  const realAgentSession = getWebAgentSession(history, agentId, { includeArchived: true });
3978
4026
  const agentSession = realAgentSession
3979
4027
  || (agentId === WEB_DEFAULT_AGENT_ID ? createEmptyWebAgentSession(WEB_DEFAULT_AGENT_ID) : null);
@@ -4070,7 +4118,7 @@ function buildSessionDetail(ctx, state, containerMap, name) {
4070
4118
  sessionRef.agentId,
4071
4119
  containerInfo.defaultCommand
4072
4120
  );
4073
- const summary = buildSessionSummary(ctx, state, containerMap, sessionRef);
4121
+ const summary = buildSessionSummary(ctx, state, containerMap, sessionRef, history);
4074
4122
  const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true })
4075
4123
  || (sessionRef.agentId === WEB_DEFAULT_AGENT_ID ? createEmptyWebAgentSession(WEB_DEFAULT_AGENT_ID) : null);
4076
4124
  const latestMessage = agentSession && agentSession.messages.length
@@ -4871,7 +4919,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
4871
4919
  .map(agentSession => buildSessionSummary(ctx, state, containerMap, {
4872
4920
  containerName: name,
4873
4921
  agentId: agentSession.agentId
4874
- }))
4922
+ }, history))
4875
4923
  .filter(Boolean);
4876
4924
  })
4877
4925
  .sort(compareWebSessionCreatedDesc);
@@ -5516,7 +5564,30 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5516
5564
  'Cache-Control': 'no-store',
5517
5565
  'X-Accel-Buffering': 'no'
5518
5566
  });
5519
- const emitStreamEvent = createWebStreamEmitter(res, state.webHistoryDir, sessionRef);
5567
+ // agent 可能几十秒不产生任何输出(长 WebSearch、长 Bash、长思考),
5568
+ // 而反向代理按"上游多久没发字节"计空闲超时(nginx proxy_read_timeout
5569
+ // 默认 60s),超时会 RST 掉这条流,浏览器侧抛 network error。这里定期
5570
+ // 发一行 ping 保活;前端忽略该事件,也不写入会话事件日志
5571
+ const heartbeatMs = ctx.agentStreamHeartbeatMs || DEFAULT_AGENT_STREAM_HEARTBEAT_MS;
5572
+ let lastStreamWriteAt = Date.now();
5573
+ const heartbeatTimer = setInterval(() => {
5574
+ if (res.writableEnded || res.destroyed) {
5575
+ return;
5576
+ }
5577
+ if (Date.now() - lastStreamWriteAt < heartbeatMs) {
5578
+ return;
5579
+ }
5580
+ sendNdjson(res, { type: 'ping' });
5581
+ lastStreamWriteAt = Date.now();
5582
+ }, Math.max(50, Math.floor(heartbeatMs / 2)));
5583
+ if (typeof heartbeatTimer.unref === 'function') {
5584
+ heartbeatTimer.unref();
5585
+ }
5586
+ const rawEmitStreamEvent = createWebStreamEmitter(res, state.webHistoryDir, sessionRef);
5587
+ const emitStreamEvent = (...args) => {
5588
+ lastStreamWriteAt = Date.now();
5589
+ return rawEmitStreamEvent(...args);
5590
+ };
5520
5591
  emitStreamEvent({
5521
5592
  type: 'meta',
5522
5593
  containerName: sessionRef.containerName,
@@ -5545,6 +5616,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5545
5616
  pending: true
5546
5617
  });
5547
5618
 
5619
+ let lastPartialPersistAt = 0;
5548
5620
  const diagStreamStartedAt = Date.now(); // DIAG_LOG
5549
5621
  let diagEventSeq = 0; // DIAG_LOG
5550
5622
  diagLog(ctx, 'stream_exec_start', { // DIAG_LOG
@@ -5556,15 +5628,20 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5556
5628
  const result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
5557
5629
  agentProgram: agentMeta.agentProgram,
5558
5630
  onEvent: event => {
5559
- diagEventSeq += 1; // DIAG_LOG
5560
- diagLog(ctx, 'stream_event', { // DIAG_LOG
5561
- session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
5562
- seq: diagEventSeq,
5563
- type: event && event.type,
5564
- sinceStartMs: Date.now() - diagStreamStartedAt,
5565
- textLength: event && typeof event.text === 'string' ? event.text.length : undefined,
5566
- contentLength: event && typeof event.content === 'string' ? event.content.length : undefined
5567
- });
5631
+ // token 级增量:只走网线,不进事件日志、不逐条打诊断日志,
5632
+ // 历史落盘按 AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS 节流
5633
+ const isPartialDelta = event && event.type === 'content_delta' && event.partial === true;
5634
+ if (!isPartialDelta) {
5635
+ diagEventSeq += 1; // DIAG_LOG
5636
+ diagLog(ctx, 'stream_event', { // DIAG_LOG
5637
+ session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
5638
+ seq: diagEventSeq,
5639
+ type: event && event.type,
5640
+ sinceStartMs: Date.now() - diagStreamStartedAt,
5641
+ textLength: event && typeof event.text === 'string' ? event.text.length : undefined,
5642
+ contentLength: event && typeof event.content === 'string' ? event.content.length : undefined
5643
+ });
5644
+ }
5568
5645
  if (event && event.type === 'trace' && event.text) {
5569
5646
  traceLines.push(String(event.text));
5570
5647
  if (event.traceEvent && typeof event.traceEvent === 'object') {
@@ -5577,6 +5654,9 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5577
5654
  });
5578
5655
  }
5579
5656
  if (event && event.type === 'content_delta' && typeof event.content === 'string') {
5657
+ const now = Date.now();
5658
+ const shouldPersist = !isPartialDelta
5659
+ || now - lastPartialPersistAt >= AGENT_STREAM_PARTIAL_PERSIST_INTERVAL_MS;
5580
5660
  if (!streamingReplyMessageId) {
5581
5661
  const streamingReplyMessage = appendWebSessionMessage(
5582
5662
  state.webHistoryDir,
@@ -5592,13 +5672,24 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5592
5672
  streamingReplyMessageId = streamingReplyMessage && streamingReplyMessage.id
5593
5673
  ? streamingReplyMessage.id
5594
5674
  : '';
5595
- } else {
5675
+ lastPartialPersistAt = now;
5676
+ } else if (shouldPersist) {
5596
5677
  patchWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId, {
5597
5678
  content: event.content,
5598
5679
  pending: true
5599
5680
  });
5681
+ lastPartialPersistAt = now;
5600
5682
  }
5601
5683
  }
5684
+ if (isPartialDelta) {
5685
+ lastStreamWriteAt = Date.now();
5686
+ // 只发增量片段:前端在 content_chunk 上做追加,
5687
+ // reset 表示换了一条 assistant 消息、从空白重新开始
5688
+ sendNdjson(res, event.chunkReset === true
5689
+ ? { type: 'content_chunk', text: '', reset: true }
5690
+ : { type: 'content_chunk', text: event.chunk });
5691
+ return;
5692
+ }
5602
5693
  emitStreamEvent(event, resolveWebStreamControlEventType(event), {
5603
5694
  transportType: event && event.type ? event.type : '',
5604
5695
  text: event && (event.text || event.content) ? String(event.text || event.content) : ''
@@ -5675,6 +5766,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5675
5766
  error: e && e.message ? e.message : 'Agent 执行失败'
5676
5767
  }, 'agent.turn.failed', { error: e && e.message ? e.message : 'Agent 执行失败' });
5677
5768
  } finally {
5769
+ clearInterval(heartbeatTimer);
5678
5770
  res.end();
5679
5771
  }
5680
5772
  }
@@ -5813,6 +5905,10 @@ async function startWebServer(options) {
5813
5905
  showImagePullHint: options.showImagePullHint,
5814
5906
  removeContainer: options.removeContainer,
5815
5907
  logger: options.logger && typeof options.logger.info === 'function' ? options.logger : fallbackLogger,
5908
+ // agent/stream 的保活心跳间隔,需小于反向代理的空闲读超时(nginx 默认 60s)
5909
+ agentStreamHeartbeatMs: Number.isFinite(options.agentStreamHeartbeatMs) && options.agentStreamHeartbeatMs > 0
5910
+ ? options.agentStreamHeartbeatMs
5911
+ : DEFAULT_AGENT_STREAM_HEARTBEAT_MS,
5816
5912
  colors: options.colors || {
5817
5913
  GREEN: '',
5818
5914
  CYAN: '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcanwin/manyoyo",
3
- "version": "7.0.17",
3
+ "version": "7.0.20",
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",