@xcanwin/manyoyo 7.0.18 → 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_.-]*)$/;
@@ -462,9 +467,10 @@ function removeContainerIdempotent(ctx, containerName) {
462
467
  }
463
468
  }
464
469
 
465
- function removeAllAgentHistoryArtifacts(state, containerName, agentIds) {
470
+ function removeAllAgentHistoryArtifacts(webHistoryDir, containerName, agentIds) {
471
+ const eventStore = new FileEventStore(webHistoryDir);
466
472
  (Array.isArray(agentIds) ? agentIds : []).forEach(agentId => {
467
- state.eventStore.remove(buildWebSessionKey(containerName, agentId));
473
+ eventStore.remove(buildWebSessionKey(containerName, agentId));
468
474
  });
469
475
  }
470
476
 
@@ -622,57 +628,29 @@ function appendWebSessionMessage(webHistoryDir, sessionRefOrContainerName, role,
622
628
  return message;
623
629
  }
624
630
 
625
- // 流式过程中高频出现的"透传"控制事件类型(见 resolveWebStreamControlEventType):
626
- // 每个 content_delta/trace 事件都会各自触发一次,量随回合长度线性增长。会话
627
- // 生命周期事件(session.ready/stopping/process.exited 等)不在其中,必须每次
628
- // 都立即落盘。
629
- const NOISY_CONTROL_EVENT_TYPES = new Set(['process.stdout', 'agent.turn.delta', 'agent.tool.observed']);
630
- const LIVE_HISTORY_FLUSH_INTERVAL_MS = 400;
631
-
632
- // 中间态历史落盘节流:同一 aggregateId 在节流窗口内只允许成功落盘一次,供
633
- // appendWebSessionControlEvent(events 镜像)与 /agent/stream 路由里的
634
- // patchWebSessionMessage(trace/content_delta 消息内容)共用同一个"落盘配额",
635
- // 避免同一个事件被两条独立写路径各自整份重写一次 demo.json。节流只影响"实时
636
- // 进度对轮询标签页可见的时机",不影响最终数据——两条写路径在回合结束/出错时
637
- // 都还有一次不受节流影响的完整落盘。
638
- function shouldFlushLiveHistory(state, aggregateId) {
639
- const now = Date.now();
640
- const lastFlushAt = state._liveHistoryFlushThrottle.get(aggregateId) || 0;
641
- if (now - lastFlushAt < LIVE_HISTORY_FLUSH_INTERVAL_MS) {
642
- return false;
643
- }
644
- state._liveHistoryFlushThrottle.set(aggregateId, now);
645
- return true;
646
- }
647
-
648
- function appendWebSessionControlEvent(state, sessionRef, type, data = {}) {
649
- const aggregateId = buildWebSessionKey(sessionRef.containerName, sessionRef.agentId);
650
- // seq 必须以 state.eventStore 的内存缓存为准(而不是下面 agentSession.events
651
- // 这份可能因落盘节流而滞后的历史 JSON 副本),否则节流生效时算出来的 seq
652
- // 会与 eventStore 实际的 lastSeq 对不上,append() 会因"seq 必须连续递增"报错。
631
+ function appendWebSessionControlEvent(webHistoryDir, sessionRef, type, data = {}) {
632
+ const history = loadWebSessionHistory(webHistoryDir, sessionRef.containerName);
633
+ const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
634
+ const events = Array.isArray(agentSession.events) ? agentSession.events : [];
635
+ const lastEvent = events.length ? events[events.length - 1] : null;
653
636
  const event = createControlEvent({
654
637
  type,
655
- aggregateId,
656
- seq: state.eventStore.getNextSeq(aggregateId),
638
+ aggregateId: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
639
+ seq: lastEvent ? lastEvent.seq + 1 : 1,
657
640
  data
658
641
  });
659
- state.eventStore.append(event);
660
-
661
- const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
662
- const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
663
- const events = Array.isArray(agentSession.events) ? agentSession.events : [];
642
+ const eventStore = new FileEventStore(webHistoryDir);
643
+ eventStore.append(event);
664
644
  agentSession.events = [...events, event].slice(-WEB_HISTORY_MAX_MESSAGES);
665
645
  agentSession.updatedAt = event.timestamp;
666
646
  history.updatedAt = event.timestamp;
667
- if (!NOISY_CONTROL_EVENT_TYPES.has(type) || shouldFlushLiveHistory(state, aggregateId)) {
668
- saveWebSessionHistory(state.webHistoryDir, sessionRef.containerName, history);
669
- }
647
+ saveWebSessionHistory(webHistoryDir, sessionRef.containerName, history);
670
648
  return event;
671
649
  }
672
650
 
673
- function loadWebSessionControlEvents(state, sessionRef, fallbackEvents = []) {
651
+ function loadWebSessionControlEvents(webHistoryDir, sessionRef, fallbackEvents = []) {
674
652
  try {
675
- const events = state.eventStore.read(
653
+ const events = new FileEventStore(webHistoryDir).read(
676
654
  buildWebSessionKey(sessionRef.containerName, sessionRef.agentId)
677
655
  );
678
656
  if (events.length) {
@@ -957,7 +935,12 @@ function buildClaudeAgentExecCommand(template, prompt, options = {}) {
957
935
  const templateText = normalizeAgentPromptCommandTemplate(template, 'agentPromptCommand');
958
936
  const flagSpecs = [
959
937
  { flag: '--verbose', pattern: /(?:^|\s)--verbose(?:\s|$)/ },
960
- { 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|$)/ }
961
944
  ];
962
945
  const sessionId = options && typeof options.sessionId === 'string' ? options.sessionId.trim() : '';
963
946
  if (sessionId) {
@@ -1819,12 +1802,39 @@ function prepareStructuredTraceEvents(agentProgram, payload, state) {
1819
1802
  return [];
1820
1803
  }
1821
1804
 
1822
- function extractContentDeltaFromPayload(agentProgram, payload) {
1805
+ function extractContentDeltaFromPayload(agentProgram, payload, state = {}) {
1823
1806
  if (!payload || typeof payload !== 'object') {
1824
1807
  return null;
1825
1808
  }
1826
1809
  if (agentProgram === 'claude') {
1827
- 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') {
1828
1838
  return null;
1829
1839
  }
1830
1840
  const message = toPlainObject(payload.message);
@@ -2096,7 +2106,7 @@ function prepareCodexTraceEvent(payload) {
2096
2106
  async function prepareWebAgentExecution(ctx, state, sessionRef, prompt) {
2097
2107
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
2098
2108
  const agentSession = getWebAgentSession(history, sessionRef.agentId, { create: true, includeArchived: true });
2099
- const containerMap = listWebManyoyoContainers(ctx, state);
2109
+ const containerMap = listWebManyoyoContainers(ctx);
2100
2110
  const containerInfo = containerMap[sessionRef.containerName] || {};
2101
2111
  const normalizedContainerTemplate = normalizeAgentPromptCommandTemplate(history.agentPromptCommand, 'agentPromptCommand');
2102
2112
  if (normalizedContainerTemplate !== history.agentPromptCommand) {
@@ -3036,7 +3046,7 @@ function buildCreateRuntime(ctx, state, payload) {
3036
3046
  }
3037
3047
 
3038
3048
  function resolveUniqueContainerName(ctx, state, baseName) {
3039
- const containerMap = listWebManyoyoContainers(ctx, state);
3049
+ const containerMap = listWebManyoyoContainers(ctx);
3040
3050
  const historyNames = listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName);
3041
3051
  const taken = new Set([...Object.keys(containerMap), ...historyNames]);
3042
3052
  // 基于历史最大编号 +1 命名,即使较小编号的副本被删除也不回收其编号,
@@ -3117,7 +3127,7 @@ async function createClonedContainer(ctx, state, sourceContainerName, requestedN
3117
3127
  let finalName = '';
3118
3128
  if (requestedName) {
3119
3129
  validateContainerNameStrict(requestedName);
3120
- const containerMap = listWebManyoyoContainers(ctx, state);
3130
+ const containerMap = listWebManyoyoContainers(ctx);
3121
3131
  const historyNames = listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName);
3122
3132
  const taken = new Set([...Object.keys(containerMap), ...historyNames]);
3123
3133
  if (taken.has(requestedName)) {
@@ -3181,20 +3191,7 @@ function estimateStartTimeFromStatus(status) {
3181
3191
  return null;
3182
3192
  }
3183
3193
 
3184
- // 同一进程内短 TTL 缓存:GET /api/sessions、/detail 等接口各自独立调用这个函数,
3185
- // 容器一多时 docker ps + 批量 inspect 本身就不便宜,多标签页轮询会让它们在
3186
- // 短时间内反复触发。2 秒内的重复调用直接复用上一次结果,不再次触碰
3187
- // ctx.dockerExecArgs(同步 spawnSync,会独占事件循环)。
3188
- const CONTAINER_LIST_CACHE_TTL_MS = 2000;
3189
-
3190
- function listWebManyoyoContainers(ctx, state) {
3191
- if (state && state._containerListCache) {
3192
- const { timestamp, data } = state._containerListCache;
3193
- if (Date.now() - timestamp < CONTAINER_LIST_CACHE_TTL_MS) {
3194
- return data;
3195
- }
3196
- }
3197
-
3194
+ function listWebManyoyoContainers(ctx) {
3198
3195
  const output = ctx.dockerExecArgs(
3199
3196
  ['ps', '-a', '--format', '{{.Names}}\t{{.Status}}\t{{.Image}}'],
3200
3197
  { ignoreError: true }
@@ -3202,9 +3199,6 @@ function listWebManyoyoContainers(ctx, state) {
3202
3199
 
3203
3200
  const map = {};
3204
3201
  if (!output.trim()) {
3205
- if (state) {
3206
- state._containerListCache = { timestamp: Date.now(), data: map };
3207
- }
3208
3202
  return map;
3209
3203
  }
3210
3204
 
@@ -3261,9 +3255,6 @@ function listWebManyoyoContainers(ctx, state) {
3261
3255
  };
3262
3256
  });
3263
3257
 
3264
- if (state) {
3265
- state._containerListCache = { timestamp: Date.now(), data: map };
3266
- }
3267
3258
  return map;
3268
3259
  }
3269
3260
 
@@ -3798,7 +3789,13 @@ async function execAgentInWebContainerStream(ctx, state, sessionRefOrContainerNa
3798
3789
  }
3799
3790
  onEvent({
3800
3791
  type: 'content_delta',
3801
- 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
3802
3799
  });
3803
3800
  }
3804
3801
  return;
@@ -3920,9 +3917,9 @@ function sendNdjson(res, payload) {
3920
3917
  res.write(`${JSON.stringify(payload)}\n`);
3921
3918
  }
3922
3919
 
3923
- function createWebStreamEmitter(res, state, sessionRef) {
3920
+ function createWebStreamEmitter(res, webHistoryDir, sessionRef) {
3924
3921
  return (payload, type, data = {}) => {
3925
- const controlEvent = appendWebSessionControlEvent(state, sessionRef, type, data);
3922
+ const controlEvent = appendWebSessionControlEvent(webHistoryDir, sessionRef, type, data);
3926
3923
  sendNdjson(res, { ...payload, controlEvent });
3927
3924
  };
3928
3925
  }
@@ -4016,10 +4013,15 @@ function getValidSessionRef(ctx, res, encodedName) {
4016
4013
  return parsed;
4017
4014
  }
4018
4015
 
4019
- 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) {
4020
4020
  const containerName = sessionRef && sessionRef.containerName ? sessionRef.containerName : '';
4021
4021
  const agentId = sessionRef && sessionRef.agentId ? sessionRef.agentId : WEB_DEFAULT_AGENT_ID;
4022
- const history = loadWebSessionHistory(state.webHistoryDir, containerName);
4022
+ const history = preloadedHistory && typeof preloadedHistory === 'object'
4023
+ ? preloadedHistory
4024
+ : loadWebSessionHistory(state.webHistoryDir, containerName);
4023
4025
  const realAgentSession = getWebAgentSession(history, agentId, { includeArchived: true });
4024
4026
  const agentSession = realAgentSession
4025
4027
  || (agentId === WEB_DEFAULT_AGENT_ID ? createEmptyWebAgentSession(WEB_DEFAULT_AGENT_ID) : null);
@@ -4116,7 +4118,7 @@ function buildSessionDetail(ctx, state, containerMap, name) {
4116
4118
  sessionRef.agentId,
4117
4119
  containerInfo.defaultCommand
4118
4120
  );
4119
- const summary = buildSessionSummary(ctx, state, containerMap, sessionRef);
4121
+ const summary = buildSessionSummary(ctx, state, containerMap, sessionRef, history);
4120
4122
  const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true })
4121
4123
  || (sessionRef.agentId === WEB_DEFAULT_AGENT_ID ? createEmptyWebAgentSession(WEB_DEFAULT_AGENT_ID) : null);
4122
4124
  const latestMessage = agentSession && agentSession.messages.length
@@ -4159,7 +4161,7 @@ function buildSessionAudit(ctx, state, sessionRef) {
4159
4161
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
4160
4162
  const agentSession = getWebAgentSession(history, sessionRef.agentId, { includeArchived: true })
4161
4163
  || createEmptyWebAgentSession(sessionRef.agentId);
4162
- const events = loadWebSessionControlEvents(state, sessionRef, agentSession.events);
4164
+ const events = loadWebSessionControlEvents(state.webHistoryDir, sessionRef, agentSession.events);
4163
4165
  const applied = history.applied && typeof history.applied === 'object' && !Array.isArray(history.applied)
4164
4166
  ? history.applied
4165
4167
  : {};
@@ -4827,7 +4829,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
4827
4829
  method: 'GET',
4828
4830
  match: currentPath => currentPath === '/api/system/capacity' ? [] : null,
4829
4831
  handler: async () => {
4830
- const containerMap = listWebManyoyoContainers(ctx, state);
4832
+ const containerMap = listWebManyoyoContainers(ctx);
4831
4833
  const diskPath = path.dirname(path.resolve(state.webConfigPath));
4832
4834
  const report = estimateContainerCapacity({
4833
4835
  runtimeCommand: ctx.dockerCmd || 'docker',
@@ -4904,7 +4906,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
4904
4906
  method: 'GET',
4905
4907
  match: currentPath => currentPath === '/api/sessions' ? [] : null,
4906
4908
  handler: async () => {
4907
- const containerMap = listWebManyoyoContainers(ctx, state);
4909
+ const containerMap = listWebManyoyoContainers(ctx);
4908
4910
  const names = new Set([
4909
4911
  ...Object.keys(containerMap),
4910
4912
  ...listWebHistorySessionNames(state.webHistoryDir, ctx.isValidContainerName)
@@ -4917,7 +4919,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
4917
4919
  .map(agentSession => buildSessionSummary(ctx, state, containerMap, {
4918
4920
  containerName: name,
4919
4921
  agentId: agentSession.agentId
4920
- }))
4922
+ }, history))
4921
4923
  .filter(Boolean);
4922
4924
  })
4923
4925
  .sort(compareWebSessionCreatedDesc);
@@ -5302,7 +5304,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5302
5304
  return;
5303
5305
  }
5304
5306
 
5305
- const containerMap = listWebManyoyoContainers(ctx, state);
5307
+ const containerMap = listWebManyoyoContainers(ctx);
5306
5308
  const detail = buildSessionDetail(ctx, state, containerMap, sessionRef);
5307
5309
  sendJson(res, 200, { name: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId), detail });
5308
5310
  }
@@ -5315,7 +5317,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5315
5317
  if (!sessionRef) {
5316
5318
  return;
5317
5319
  }
5318
- const containerMap = listWebManyoyoContainers(ctx, state);
5320
+ const containerMap = listWebManyoyoContainers(ctx);
5319
5321
  const containerInfo = containerMap[sessionRef.containerName] || {};
5320
5322
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
5321
5323
  const effectiveTemplate = resolveEffectiveAgentPromptCommandForSession(
@@ -5413,7 +5415,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5413
5415
  return;
5414
5416
  }
5415
5417
 
5416
- const containerMap = listWebManyoyoContainers(ctx, state);
5418
+ const containerMap = listWebManyoyoContainers(ctx);
5417
5419
  const detail = buildSessionDetail(ctx, state, containerMap, sessionRef);
5418
5420
  sendJson(res, 200, {
5419
5421
  saved: true,
@@ -5562,7 +5564,30 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5562
5564
  'Cache-Control': 'no-store',
5563
5565
  'X-Accel-Buffering': 'no'
5564
5566
  });
5565
- const emitStreamEvent = createWebStreamEmitter(res, state, 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
+ };
5566
5591
  emitStreamEvent({
5567
5592
  type: 'meta',
5568
5593
  containerName: sessionRef.containerName,
@@ -5591,6 +5616,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5591
5616
  pending: true
5592
5617
  });
5593
5618
 
5619
+ let lastPartialPersistAt = 0;
5594
5620
  const diagStreamStartedAt = Date.now(); // DIAG_LOG
5595
5621
  let diagEventSeq = 0; // DIAG_LOG
5596
5622
  diagLog(ctx, 'stream_exec_start', { // DIAG_LOG
@@ -5602,29 +5628,35 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5602
5628
  const result = await execAgentInWebContainerStream(ctx, state, sessionRef, command, {
5603
5629
  agentProgram: agentMeta.agentProgram,
5604
5630
  onEvent: event => {
5605
- diagEventSeq += 1; // DIAG_LOG
5606
- diagLog(ctx, 'stream_event', { // DIAG_LOG
5607
- session: buildWebSessionKey(sessionRef.containerName, sessionRef.agentId),
5608
- seq: diagEventSeq,
5609
- type: event && event.type,
5610
- sinceStartMs: Date.now() - diagStreamStartedAt,
5611
- textLength: event && typeof event.text === 'string' ? event.text.length : undefined,
5612
- contentLength: event && typeof event.content === 'string' ? event.content.length : undefined
5613
- });
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
+ }
5614
5645
  if (event && event.type === 'trace' && event.text) {
5615
5646
  traceLines.push(String(event.text));
5616
5647
  if (event.traceEvent && typeof event.traceEvent === 'object') {
5617
5648
  traceEvents.push(event.traceEvent);
5618
5649
  }
5619
- if (shouldFlushLiveHistory(state, buildWebSessionKey(sessionRef.containerName, sessionRef.agentId))) {
5620
- patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
5621
- content: traceLines.join('\n'),
5622
- traceEvents: traceEvents.slice(),
5623
- pending: true
5624
- });
5625
- }
5650
+ patchWebSessionMessage(state.webHistoryDir, sessionRef, traceMessage && traceMessage.id, {
5651
+ content: traceLines.join('\n'),
5652
+ traceEvents: traceEvents.slice(),
5653
+ pending: true
5654
+ });
5626
5655
  }
5627
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;
5628
5660
  if (!streamingReplyMessageId) {
5629
5661
  const streamingReplyMessage = appendWebSessionMessage(
5630
5662
  state.webHistoryDir,
@@ -5640,13 +5672,24 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5640
5672
  streamingReplyMessageId = streamingReplyMessage && streamingReplyMessage.id
5641
5673
  ? streamingReplyMessage.id
5642
5674
  : '';
5643
- } else if (shouldFlushLiveHistory(state, buildWebSessionKey(sessionRef.containerName, sessionRef.agentId))) {
5675
+ lastPartialPersistAt = now;
5676
+ } else if (shouldPersist) {
5644
5677
  patchWebSessionMessage(state.webHistoryDir, sessionRef, streamingReplyMessageId, {
5645
5678
  content: event.content,
5646
5679
  pending: true
5647
5680
  });
5681
+ lastPartialPersistAt = now;
5648
5682
  }
5649
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
+ }
5650
5693
  emitStreamEvent(event, resolveWebStreamControlEventType(event), {
5651
5694
  transportType: event && event.type ? event.type : '',
5652
5695
  text: event && (event.text || event.content) ? String(event.text || event.content) : ''
@@ -5723,6 +5766,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5723
5766
  error: e && e.message ? e.message : 'Agent 执行失败'
5724
5767
  }, 'agent.turn.failed', { error: e && e.message ? e.message : 'Agent 执行失败' });
5725
5768
  } finally {
5769
+ clearInterval(heartbeatTimer);
5726
5770
  res.end();
5727
5771
  }
5728
5772
  }
@@ -5743,7 +5787,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5743
5787
  sendJson(res, 404, { error: '当前会话没有运行中的 agent 任务' });
5744
5788
  return;
5745
5789
  }
5746
- appendWebSessionControlEvent(state, sessionRef, 'session.stopping');
5790
+ appendWebSessionControlEvent(state.webHistoryDir, sessionRef, 'session.stopping');
5747
5791
  sendJson(res, 200, { ok: true, stopping: true });
5748
5792
  }
5749
5793
  },
@@ -5762,7 +5806,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5762
5806
 
5763
5807
  if (removeHistory) {
5764
5808
  const history = loadWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
5765
- removeAllAgentHistoryArtifacts(state, sessionRef.containerName, Object.keys(history.agents || {}));
5809
+ removeAllAgentHistoryArtifacts(state.webHistoryDir, sessionRef.containerName, Object.keys(history.agents || {}));
5766
5810
  removeWebSessionHistory(state.webHistoryDir, sessionRef.containerName);
5767
5811
  } else if (removedContainer) {
5768
5812
  appendWebSessionMessage(state.webHistoryDir, sessionRef, 'system', `容器 ${sessionRef.containerName} 已删除。`);
@@ -5791,7 +5835,7 @@ async function handleWebApi(req, res, pathname, ctx, state) {
5791
5835
  if (history.agents && typeof history.agents === 'object') {
5792
5836
  if (removeHistory) {
5793
5837
  delete history.agents[sessionRef.agentId];
5794
- removeAllAgentHistoryArtifacts(state, sessionRef.containerName, [sessionRef.agentId]);
5838
+ removeAllAgentHistoryArtifacts(state.webHistoryDir, sessionRef.containerName, [sessionRef.agentId]);
5795
5839
  } else if (history.agents[sessionRef.agentId]) {
5796
5840
  history.agents[sessionRef.agentId].archived = true;
5797
5841
  history.agents[sessionRef.agentId].updatedAt = new Date().toISOString();
@@ -5861,6 +5905,10 @@ async function startWebServer(options) {
5861
5905
  showImagePullHint: options.showImagePullHint,
5862
5906
  removeContainer: options.removeContainer,
5863
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,
5864
5912
  colors: options.colors || {
5865
5913
  GREEN: '',
5866
5914
  CYAN: '',
@@ -5903,11 +5951,6 @@ async function startWebServer(options) {
5903
5951
  terminalSessions: new Map(),
5904
5952
  agentRuns: new Map()
5905
5953
  };
5906
- // 跨请求复用同一个 FileEventStore 实例,append() 的增量缓存才能生效
5907
- // (否则每次都 new 一个新实例,缓存永远命中不到)
5908
- state.eventStore = new FileEventStore(state.webHistoryDir);
5909
- // shouldFlushLiveHistory() 的节流状态:aggregateId -> 上次成功落盘的时间戳
5910
- state._liveHistoryFlushThrottle = new Map();
5911
5954
 
5912
5955
  ensureWebHistoryDir(state.webHistoryDir);
5913
5956
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xcanwin/manyoyo",
3
- "version": "7.0.18",
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",