@myassis/gateway 1.0.90 → 1.0.92

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.
@@ -121,8 +121,23 @@ exports.appConfig = {
121
121
  enablePrecompression: process.env.ENABLE_PRECOMPRESSION !== 'false',
122
122
  /** 回复结束后延迟多久启动预压缩(毫秒),留出时间给用户连续追问 */
123
123
  precompressionDelay: parseInt(process.env.PRECOMPRESSION_DELAY || '3000', 10),
124
- /** 触发上下文压缩的字符阈值 */
125
- summaryTriggerChars: parseInt(process.env.SUMMARY_TRIGGER_CHARS || '60000', 10),
124
+ /**
125
+ * 触发上下文压缩的字符阈值。
126
+ *
127
+ * 口径是「实际入参字符数」(历史工具输出已按 oldToolContentLimit 折算),
128
+ * 因此可以贴着 contextMaxChars 设,而不必为截断留出巨大余量。
129
+ * 旧默认值 60000 是按原始体积设的,换算到实际入参口径后过早触发,
130
+ * 表现为「没聊几句就开始压缩」。
131
+ */
132
+ summaryTriggerChars: parseInt(process.env.SUMMARY_TRIGGER_CHARS || '120000', 10),
133
+ /**
134
+ * 触发上下文压缩的消息条数阈值。
135
+ *
136
+ * 一轮「用户提问 + 助手带工具回复」就占 2 条,旧值 10 意味着第 5 轮对话
137
+ * 必定压缩,与内容多少无关 —— 这是「没聊几句就压缩」最直接的原因。
138
+ * 条数只作为体积估算失真时的兜底,阈值应远高于正常对话轮数。
139
+ */
140
+ summaryThreshold: parseInt(process.env.SUMMARY_THRESHOLD || '60', 10),
126
141
  appName: '我的助手'
127
142
  };
128
143
  /**
@@ -393,6 +393,39 @@ router.get('/sessions/:sessionId/messages/sync', ensureAgentManager, async (req,
393
393
  res.status(500).json({ success: false, error: 'Failed to sync messages' });
394
394
  }
395
395
  });
396
+ /**
397
+ * GET /api/agent/sessions/:sessionId/state
398
+ *
399
+ * 会话的后台运行态。终端在断线期间收不到任何流事件,重连后必须问一次
400
+ * 「这一轮到底还在跑吗」,否则只能二选一地猜:要么把仍在生成的会话当成
401
+ * 已结束(加载圆点与计划进度圈凭空消失),要么永远停在加载态。
402
+ *
403
+ * 注意要注册在 /messages/:messageId 之前,避免被当成 messageId 匹配。
404
+ */
405
+ router.get('/sessions/:sessionId/state', ensureAgentManager, async (req, res) => {
406
+ try {
407
+ const userId = req.userId;
408
+ const { sessionId } = req.params;
409
+ const session = (0, index_js_2.getSessionManager)(userId).getSession(sessionId);
410
+ if (!session) {
411
+ return res.status(404).json({ success: false, error: 'Session not found' });
412
+ }
413
+ return res.json({
414
+ success: true,
415
+ data: {
416
+ isGenerating: session.isGenerating,
417
+ // 只在生成中才回消息 id:结束后回它会让终端以为还有流在推进
418
+ currentMessageId: session.isGenerating ? session.currentMessageId : null,
419
+ plan: session.plan,
420
+ planUpdatedAt: session.planUpdatedAt,
421
+ },
422
+ });
423
+ }
424
+ catch (error) {
425
+ logger.error(`Get session state error: ${error}`);
426
+ res.status(500).json({ success: false, error: 'Failed to get session state' });
427
+ }
428
+ });
396
429
  /**
397
430
  * DELETE /api/agent/sessions/:sessionId/messages/:messageId
398
431
  * Delete a specific message
@@ -255,7 +255,9 @@ class AgentManager {
255
255
  updatedAt: s.updatedAt,
256
256
  messageQueue: s.messageQueue,
257
257
  messageQueueAutoExecute: s.messageQueueAutoExecute,
258
- plan: s.plan
258
+ plan: s.plan,
259
+ isGenerating: s.isGenerating,
260
+ currentMessageId: s.isGenerating ? s.currentMessageId : null
259
261
  }));
260
262
  }
261
263
  /**
@@ -31,10 +31,9 @@ const dataService_js_1 = require("../dataService.js");
31
31
  const index_js_1 = require("../../stores/index.js");
32
32
  const LLMClient_js_1 = require("../llm/LLMClient.js");
33
33
  const index_js_2 = require("../../config/index.js");
34
- const ContextBuilder_js_1 = require("./ContextBuilder.js");
35
34
  const ToolLedger_js_1 = require("./ToolLedger.js");
36
35
  const DEFAULT_CONFIG = {
37
- summaryThreshold: 10,
36
+ summaryThreshold: index_js_2.appConfig.summaryThreshold,
38
37
  summaryTriggerChars: index_js_2.appConfig.summaryTriggerChars,
39
38
  enabled: true,
40
39
  };
@@ -74,6 +73,43 @@ const sendSSE = (res, data) => {
74
73
  res.write(`data: ${JSON.stringify(data)}\n\n`);
75
74
  res.flush?.();
76
75
  };
76
+ /**
77
+ * 估算「会话消息实际进入上下文后」的字符数。
78
+ *
79
+ * 不能直接用 estimateChars:它算的是原始体积,而历史消息里的工具输出
80
+ * 在 LLMClient.mapMessages 中每条只保留 oldToolContentLimit(默认 500)字符。
81
+ * 用原始体积判断压缩,会把一条 8000 字符的工具输出按 8000 计,
82
+ * 而它实际只占 500 —— 于是「没聊几句就开始压缩」:一轮 11 次工具调用的
83
+ * 会话,原始体积轻松破 6 万,实际入参却不到 1 万。
84
+ *
85
+ * 这里按真实入参口径折算,让压缩阈值和它想控制的对象是同一个东西。
86
+ */
87
+ function estimateContextChars(messages) {
88
+ const limit = index_js_2.appConfig.oldToolContentLimit;
89
+ let total = 0;
90
+ for (const message of messages || []) {
91
+ if (typeof message.content === 'string')
92
+ total += message.content.length;
93
+ else if (message.content)
94
+ total += JSON.stringify(message.content).length;
95
+ if (message.reasoning_content)
96
+ total += String(message.reasoning_content).length;
97
+ for (const group of (message.toolCalls || [])) {
98
+ total += (group?.content?.length || 0) + (group?.reasoningContent?.length || 0);
99
+ for (const item of (group?.toolCalls || [])) {
100
+ total += (item?.toolName?.length || 0);
101
+ // 参数会被 clipJsonArguments 截断,但下限就是 limit 量级,按 limit 计
102
+ total += Math.min(String(item?.input ?? '').length, limit);
103
+ // 输出在 mapMessages 里被硬截断到 limit
104
+ total += Math.min(String(item?.output ?? '').length, limit);
105
+ }
106
+ }
107
+ for (const att of (message.attachments || [])) {
108
+ total += (att?.url?.length || 0) + (att?.name?.length || 0);
109
+ }
110
+ }
111
+ return total;
112
+ }
77
113
  /**
78
114
  * 记忆管理器
79
115
  * 实现总结式记忆:当对话超过一定长度时,自动生成对话摘要
@@ -134,8 +170,9 @@ class MemoryManager {
134
170
  return false;
135
171
  if (messages.length >= this.config.summaryThreshold)
136
172
  return true;
137
- // 单条工具结果可能极大,条数少也可能远超上下文预算
138
- return (0, ContextBuilder_js_1.estimateChars)(messages) >= this.config.summaryTriggerChars;
173
+ // 单条工具结果可能极大,条数少也可能远超上下文预算。
174
+ // 按「实际入参口径」估算:历史工具输出会被截断,用原始体积会严重高估。
175
+ return estimateContextChars(messages) >= this.config.summaryTriggerChars;
139
176
  }
140
177
  /**
141
178
  * 取得“最后一条被摘要消息”的时间戳作为摘要边界。
@@ -714,7 +714,27 @@ class Session {
714
714
  // 这里预先取到 WebSocketService,把每个 SSE 事件同步给该用户的其他连接,
715
715
  // 从而让所有终端看到同一份会话流。子 Agent 的内部流不需要同步。
716
716
  const wsService = childAgent ? null : await this.getWebSocketService();
717
- /** SSE 事件镜像给当前用户的其他终端(排除发起端,避免重复渲染) */
717
+ // SSE 通道是否还活着。
718
+ //
719
+ // 中继模式下隧道一断,这条回环响应会被直接销毁,而本轮生成仍在后台跑。
720
+ // 此时发起端拿不到任何后续事件:SSE 已断,而它又因 excludeClientId 被
721
+ // 排除在 WebSocket 镜像之外,界面上的加载圆点与计划进度圈会凭空消失。
722
+ // 所以 SSE 一断就取消排除,让发起端也从镜像里接着收。
723
+ let sseAlive = !!res;
724
+ if (res && typeof res.on === 'function') {
725
+ res.on('close', () => {
726
+ if (!sseAlive)
727
+ return;
728
+ sseAlive = false;
729
+ logger.warn(`会话 ${this.id} 的 SSE 已断开,本轮生成继续,后续事件改由 WebSocket 镜像下发`);
730
+ });
731
+ }
732
+ /**
733
+ * 把 SSE 事件镜像给当前用户的其他终端。
734
+ *
735
+ * SSE 还活着时排除发起端(它已从 SSE 收到同样的事件,不排除会渲染两遍);
736
+ * SSE 已断开时不再排除,发起端重连 WebSocket 后就能接着看完本轮。
737
+ */
718
738
  const mirrorToOtherClients = (data) => {
719
739
  if (!wsService)
720
740
  return;
@@ -730,7 +750,7 @@ class Session {
730
750
  assistantMessageId,
731
751
  event: data,
732
752
  },
733
- }, { excludeClientId: clientId });
753
+ }, { excludeClientId: sseAlive ? clientId : undefined });
734
754
  }
735
755
  catch (error) {
736
756
  logger.error('SSE mirror error:', error);
@@ -758,7 +778,8 @@ class Session {
758
778
  // SSE 辅助方法:res 为 null 时跳过写入(本地执行模式),但仍同步给其他终端
759
779
  const sendSSE = (res, data) => {
760
780
  mirrorToOtherClients(data);
761
- if (!res)
781
+ // 连接已断开时不再写入:写只会抛错刷日志,内容已改由镜像下发
782
+ if (!res || !sseAlive)
762
783
  return;
763
784
  try {
764
785
  res.write(`data: ${JSON.stringify(data)}\n\n`);
@@ -770,7 +791,7 @@ class Session {
770
791
  };
771
792
  // 心跳机制:每 15 秒发送一次心跳,防止连接超时
772
793
  const heartbeatInterval = setInterval(() => {
773
- if (res && !res.destroyed) {
794
+ if (res && sseAlive && !res.destroyed) {
774
795
  sendSSE(res, { type: 'heartbeat' });
775
796
  }
776
797
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myassis/gateway",
3
- "version": "1.0.90",
3
+ "version": "1.0.92",
4
4
  "description": "我的助手 Gateway Service - 本地 AI 网关服务,支持认证、WebSocket 实时通信和任务调度",
5
5
  "main": "dist/index.js",
6
6
  "bin": {