@sunnoy/wecom 2.1.0 → 2.2.0

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.
@@ -1,9 +1,12 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { readFile, writeFile } from "node:fs/promises";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
5
+ import { fileURLToPath, URL } from "node:url";
4
6
  import { WSClient, generateReqId } from "@wecom/aibot-node-sdk";
7
+ import { uploadAndSendMedia, buildMediaErrorSummary } from "./media-uploader.js";
8
+ import { createPersistentReqIdStore } from "./reqid-store.js";
5
9
  import { agentSendMedia, agentSendText, agentUploadMedia } from "./agent-api.js";
6
- import { prepareImageBufferForMsgItem } from "../image-processor.js";
7
10
  import { logger } from "../logger.js";
8
11
  import { normalizeThinkingTags } from "../think-parser.js";
9
12
  import { MessageDeduplicator } from "../utils.js";
@@ -20,6 +23,7 @@ import {
20
23
  CHANNEL_ID,
21
24
  DEFAULT_MEDIA_MAX_MB,
22
25
  DEFAULT_WELCOME_MESSAGE,
26
+ DEFAULT_WELCOME_MESSAGES,
23
27
  FILE_DOWNLOAD_TIMEOUT_MS,
24
28
  IMAGE_DOWNLOAD_TIMEOUT_MS,
25
29
  MEDIA_DOCUMENT_PLACEHOLDER,
@@ -34,6 +38,7 @@ import {
34
38
  import { setConfigProxyUrl } from "./http.js";
35
39
  import { checkDmPolicy } from "./dm-policy.js";
36
40
  import { checkGroupPolicy } from "./group-policy.js";
41
+ import { fetchAndSaveMcpConfig } from "./mcp-config.js";
37
42
  import {
38
43
  clearAccountDisplaced,
39
44
  forecastActiveSendQuota,
@@ -61,17 +66,20 @@ import { ensureDynamicAgentListed } from "./workspace-template.js";
61
66
  const DEFAULT_AGENT_ID = "main";
62
67
  const DEFAULT_STATE_DIRNAME = ".openclaw";
63
68
  const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moldbot", ".moltbot"];
64
- const MAX_REPLY_MSG_ITEMS = 10;
65
- const MAX_REPLY_IMAGE_BYTES = 10 * 1024 * 1024;
69
+ const WAITING_MODEL_TICK_MS = 1_000;
66
70
  const REASONING_STREAM_THROTTLE_MS = 800;
67
71
  const VISIBLE_STREAM_THROTTLE_MS = 800;
68
72
  // Reserve headroom below the SDK's per-reqId queue limit (100) so the final
69
73
  // reply always has room.
70
74
  const MAX_INTERMEDIATE_STREAM_MESSAGES = 85;
75
+ // WeCom stream messages expire if not updated within 6 minutes. Send a
76
+ // keepalive update every 4 minutes to keep the stream alive during long runs.
77
+ const STREAM_KEEPALIVE_INTERVAL_MS = 4 * 60 * 1000;
71
78
  // Match MEDIA:/FILE: directives at line start, optionally preceded by markdown list markers.
72
79
  const REPLY_MEDIA_DIRECTIVE_PATTERN = /^\s*(?:[-*•]\s+|\d+\.\s+)?(?:MEDIA|FILE)\s*:/im;
73
80
  const WECOM_REPLY_MEDIA_GUIDANCE_HEADER = "[WeCom reply media rule]";
74
81
  const inboundMessageDeduplicator = new MessageDeduplicator();
82
+ const sessionReasoningInitLocks = new Map();
75
83
 
76
84
  function withTimeout(promise, timeoutMs, message) {
77
85
  if (!timeoutMs || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
@@ -119,6 +127,15 @@ function normalizeReasoningStreamText(text) {
119
127
  return lines.join("\n").trim();
120
128
  }
121
129
 
130
+ function buildWaitingModelContent(seconds) {
131
+ const normalizedSeconds = Math.max(1, Number.parseInt(String(seconds ?? 1), 10) || 1);
132
+ const lines = [];
133
+ for (let current = 1; current <= normalizedSeconds; current += 1) {
134
+ lines.push(`等待模型响应 ${current}s`);
135
+ }
136
+ return `<think>${lines.join("\n")}`;
137
+ }
138
+
122
139
  function buildWsStreamContent({ reasoningText = "", visibleText = "", finish = false }) {
123
140
  const normalizedReasoning = String(reasoningText ?? "").trim();
124
141
  const normalizedVisible = String(visibleText ?? "").trim();
@@ -135,6 +152,112 @@ function buildWsStreamContent({ reasoningText = "", visibleText = "", finish = f
135
152
  return normalizedVisible ? `${thinkBlock}\n${normalizedVisible}` : thinkBlock;
136
153
  }
137
154
 
155
+ function normalizeWecomCreateTimeMs(value) {
156
+ const seconds = Number(value);
157
+ if (!Number.isFinite(seconds) || seconds <= 0) {
158
+ return 0;
159
+ }
160
+ return Math.trunc(seconds * 1000);
161
+ }
162
+
163
+ function getWecomSourceTiming(createTime, now = Date.now()) {
164
+ const sourceCreateTimeMs = normalizeWecomCreateTimeMs(createTime);
165
+ if (!sourceCreateTimeMs) {
166
+ return {
167
+ sourceCreateTime: undefined,
168
+ sourceCreateTimeIso: undefined,
169
+ sourceToIngressMs: undefined,
170
+ };
171
+ }
172
+
173
+ return {
174
+ sourceCreateTime: createTime,
175
+ sourceCreateTimeIso: new Date(sourceCreateTimeMs).toISOString(),
176
+ sourceToIngressMs: Math.max(0, now - sourceCreateTimeMs),
177
+ };
178
+ }
179
+
180
+ function resolveWsKeepaliveContent({ reasoningText = "", visibleText = "", lastStreamText = "" }) {
181
+ const currentContent = buildWsStreamContent({
182
+ reasoningText,
183
+ visibleText,
184
+ finish: false,
185
+ });
186
+ return currentContent || String(lastStreamText ?? "").trim() || THINKING_MESSAGE;
187
+ }
188
+
189
+ function normalizeSessionStoreKey(sessionKey) {
190
+ return String(sessionKey ?? "").trim().toLowerCase();
191
+ }
192
+
193
+ async function withSessionReasoningInitLock(storePath, task) {
194
+ const lockKey = path.resolve(String(storePath ?? ""));
195
+ const previous = sessionReasoningInitLocks.get(lockKey) ?? Promise.resolve();
196
+ const current = previous.then(task, task);
197
+ sessionReasoningInitLocks.set(lockKey, current);
198
+ return await current.finally(() => {
199
+ if (sessionReasoningInitLocks.get(lockKey) === current) {
200
+ sessionReasoningInitLocks.delete(lockKey);
201
+ }
202
+ });
203
+ }
204
+
205
+ async function ensureDefaultSessionReasoningLevel({
206
+ core,
207
+ storePath,
208
+ sessionKey,
209
+ ctx,
210
+ reasoningLevel = "stream",
211
+ channelTag = "WS",
212
+ }) {
213
+ const normalizedSessionKey = normalizeSessionStoreKey(sessionKey);
214
+ if (!storePath || !normalizedSessionKey || !ctx || !core?.session?.recordSessionMetaFromInbound) {
215
+ return null;
216
+ }
217
+
218
+ try {
219
+ const recorded = await core.session.recordSessionMetaFromInbound({
220
+ storePath,
221
+ sessionKey: normalizedSessionKey,
222
+ ctx,
223
+ });
224
+ if (!recorded || recorded.reasoningLevel != null) {
225
+ return recorded;
226
+ }
227
+
228
+ return await withSessionReasoningInitLock(storePath, async () => {
229
+ let store;
230
+ try {
231
+ store = JSON.parse(await readFile(storePath, "utf8"));
232
+ } catch (error) {
233
+ logger.warn(`[${channelTag}] Failed to read session store for reasoning default: ${error.message}`);
234
+ return recorded;
235
+ }
236
+
237
+ const resolvedKey = Object.keys(store).find((key) => normalizeSessionStoreKey(key) === normalizedSessionKey);
238
+ if (!resolvedKey) {
239
+ return recorded;
240
+ }
241
+
242
+ const existing = store[resolvedKey];
243
+ if (!existing || typeof existing !== "object" || existing.reasoningLevel != null) {
244
+ return existing ?? recorded;
245
+ }
246
+
247
+ store[resolvedKey] = { ...existing, reasoningLevel };
248
+ await writeFile(storePath, `${JSON.stringify(store, null, 2)}\n`);
249
+ logger.info(`[${channelTag}] Initialized session reasoningLevel default`, {
250
+ sessionKey: resolvedKey,
251
+ reasoningLevel,
252
+ });
253
+ return store[resolvedKey];
254
+ });
255
+ } catch (error) {
256
+ logger.warn(`[${channelTag}] Failed to initialize session reasoning default: ${error.message}`);
257
+ return null;
258
+ }
259
+ }
260
+
138
261
  function createSdkLogger(accountId) {
139
262
  return {
140
263
  debug: (message, ...args) => logger.debug(`[WS:${accountId}] ${message}`, ...args),
@@ -165,19 +288,6 @@ function resolveChannelCore(runtime) {
165
288
  throw new Error("OpenClaw channel runtime is unavailable");
166
289
  }
167
290
 
168
- function resolveMediaRuntime(runtime) {
169
- const registeredRuntime = getRegisteredRuntimeOrNull();
170
- const candidates = [runtime, registeredRuntime];
171
-
172
- for (const candidate of candidates) {
173
- if (typeof candidate?.media?.loadWebMedia === "function") {
174
- return candidate;
175
- }
176
- }
177
-
178
- throw new Error("OpenClaw media runtime is unavailable");
179
- }
180
-
181
291
  function resolveUserPath(value) {
182
292
  const trimmed = String(value ?? "").trim();
183
293
  if (!trimmed) {
@@ -279,17 +389,61 @@ function buildReplyMediaGuidance(config, agentId) {
279
389
  const browserMediaDir = path.join(resolveStateDir(), "media", "browser");
280
390
  return [
281
391
  WECOM_REPLY_MEDIA_GUIDANCE_HEADER,
282
- `Only use FILE:/abs/path or MEDIA:/abs/path for files inside the current workspace: ${workspaceDir}`,
392
+ `Local reply files are allowed only under the current workspace: ${workspaceDir}`,
393
+ "Inside the agent sandbox, that same workspace is visible as /workspace.",
283
394
  `Browser-generated files are also allowed only under: ${browserMediaDir}`,
284
395
  "Never reference any other host path.",
285
- "Do NOT call message.send with local file paths. The sandbox will block it.",
286
- "Instead, in your final reply put each image on its own line as MEDIA:/abs/path",
287
- "and each non-image file on its own line as FILE:/abs/path.",
396
+ "Do NOT call message.send or message.sendAttachment to deliver files back to the current WeCom chat/user; use MEDIA: or FILE: directives instead.",
397
+ "For images: put each image path on its own line as MEDIA:/abs/path.",
398
+ "If a local file is in the current sandbox workspace, use its /workspace/... path directly.",
399
+ "For every non-image file (PDF, MD, DOC, DOCX, XLS, XLSX, CSV, ZIP, MP4, TXT, etc.): put it on its own line as FILE:/abs/path.",
400
+ "Example: FILE:/workspace/skills/deep-research/SKILL.md",
401
+ "CRITICAL: Never use MEDIA: for non-image files. PDF must always use FILE:, never MEDIA:.",
402
+ "CRITICAL: If a tool already returned a path prefixed with FILE: (e.g. FILE:/abs/path.pdf), keep the FILE: prefix exactly as-is. Do NOT change it to MEDIA:.",
288
403
  "Each directive MUST be on its own line with no other text on that line.",
289
404
  "The plugin will automatically send the media to the user.",
290
405
  ].join("\n");
291
406
  }
292
407
 
408
+ function normalizeReplyMediaUrlForLoad(mediaUrl, config, agentId) {
409
+ let normalized = String(mediaUrl ?? "").trim();
410
+ if (!normalized) {
411
+ return "";
412
+ }
413
+
414
+ if (/^file:\/\//i.test(normalized)) {
415
+ try {
416
+ const parsed = new URL(normalized);
417
+ if (parsed.protocol === "file:") {
418
+ normalized = fileURLToPath(parsed);
419
+ }
420
+ } catch {
421
+ return normalized;
422
+ }
423
+ }
424
+
425
+ if (/^sandbox:\/{0,2}/i.test(normalized)) {
426
+ normalized = normalized.replace(/^sandbox:\/{0,2}/i, "/");
427
+ }
428
+
429
+ if (normalized === "/workspace" || normalized.startsWith("/workspace/")) {
430
+ const workspaceDir = resolveAgentWorkspaceDir(config, agentId || resolveDefaultAgentId(config));
431
+ const rel = normalized === "/workspace" ? "" : normalized.slice("/workspace/".length);
432
+ const resolved = rel
433
+ ? path.resolve(workspaceDir, ...rel.split("/").filter(Boolean))
434
+ : path.resolve(workspaceDir);
435
+ // Prevent path traversal outside workspace directory
436
+ const normalizedWorkspace = path.resolve(workspaceDir) + path.sep;
437
+ if (resolved !== path.resolve(workspaceDir) && !resolved.startsWith(normalizedWorkspace)) {
438
+ logger.warn(`[WS] Blocked path traversal attempt: ${mediaUrl} resolved to ${resolved}`);
439
+ return "";
440
+ }
441
+ return resolved;
442
+ }
443
+
444
+ return normalized;
445
+ }
446
+
293
447
  function buildBodyForAgent(body, config, agentId) {
294
448
  // Guidance is now injected via before_prompt_build hook into system prompt.
295
449
  // Keep buildBodyForAgent as a plain passthrough for the user message body.
@@ -356,205 +510,97 @@ function applyAccountNetworkConfig(account) {
356
510
  setApiBaseUrl(network.apiBaseUrl ?? "");
357
511
  }
358
512
 
359
- function resolveReplyMediaFilename(mediaUrl, loaded) {
360
- const candidateNames = [
361
- loaded?.fileName,
362
- loaded?.filename,
363
- loaded?.name,
364
- typeof loaded?.path === "string" ? path.basename(loaded.path) : "",
365
- ];
366
-
367
- for (const candidate of candidateNames) {
368
- const normalized = String(candidate ?? "").trim();
369
- if (normalized) {
370
- return normalized;
371
- }
372
- }
373
-
374
- const normalizedUrl = String(mediaUrl ?? "").trim();
375
- if (!normalizedUrl) {
376
- return "attachment";
377
- }
378
-
379
- if (normalizedUrl.startsWith("/")) {
380
- return path.basename(normalizedUrl) || "attachment";
381
- }
382
-
383
- try {
384
- return path.basename(new URL(normalizedUrl).pathname) || "attachment";
385
- } catch {
386
- return path.basename(normalizedUrl) || "attachment";
387
- }
388
- }
389
-
390
- function resolveReplyMediaAgentType(mediaUrl, loaded) {
391
- const contentType = String(loaded?.contentType ?? "").toLowerCase();
392
- if (loaded?.kind === "image" || contentType.startsWith("image/")) {
393
- return "image";
394
- }
395
- const filename = resolveReplyMediaFilename(mediaUrl, loaded).toLowerCase();
396
- if (/\.(jpg|jpeg|png|gif|bmp|webp)$/.test(filename)) {
397
- return "image";
398
- }
399
- return "file";
513
+ function stripThinkTags(text) {
514
+ return String(text ?? "").replace(/<think>[\s\S]*?<\/think>/g, "").trim();
400
515
  }
401
516
 
402
- async function prepareReplyMediaOutputs({ payload, runtime, config, agentId, mirrorImagesToAgent = false }) {
403
- const mediaUrls = resolveReplyMediaUrls(payload);
404
- if (mediaUrls.length === 0) {
405
- return { msgItems: [], agentMedia: [], mirroredAgentMedia: [] };
406
- }
407
-
408
- let mediaRuntime;
409
- try {
410
- mediaRuntime = resolveMediaRuntime(runtime);
411
- } catch (error) {
412
- logger.error(`[WS] Reply media runtime is unavailable: ${error.message}`);
413
- return { msgItems: [], agentMedia: [], mirroredAgentMedia: [] };
414
- }
415
-
416
- const localRoots = resolveReplyMediaLocalRoots(config, agentId);
417
- const msgItems = [];
418
- const agentMedia = [];
419
- const mirroredAgentMedia = [];
420
-
421
- for (const mediaUrl of mediaUrls) {
422
- try {
423
- const loaded = await mediaRuntime.media.loadWebMedia(mediaUrl, {
424
- maxBytes: MAX_REPLY_IMAGE_BYTES,
425
- localRoots,
517
+ async function sendMediaBatch({ wsClient, frame, state, account, runtime, config, agentId }) {
518
+ const body = frame?.body ?? {};
519
+ const chatId = body.chatid || body.from?.userid;
520
+ const mediaLocalRoots = resolveReplyMediaLocalRoots(config, agentId);
521
+
522
+ for (const mediaUrl of state.pendingMediaUrls) {
523
+ const normalizedUrl = normalizeReplyMediaUrlForLoad(mediaUrl, config, agentId);
524
+ if (!normalizedUrl) {
525
+ state.hasMediaFailed = true;
526
+ logger.error(`[WS] Media send failed: url=${mediaUrl}, reason=invalid_local_path`);
527
+ const summary = buildMediaErrorSummary(mediaUrl, {
528
+ ok: false,
529
+ rejectReason: "invalid_local_path",
530
+ error: "reply media path resolved outside allowed roots",
426
531
  });
427
- const mediaType = resolveReplyMediaAgentType(mediaUrl, loaded);
532
+ state.mediaErrorSummary = state.mediaErrorSummary
533
+ ? `${state.mediaErrorSummary}\n\n${summary}`
534
+ : summary;
535
+ continue;
536
+ }
537
+ const result = await uploadAndSendMedia({
538
+ wsClient,
539
+ mediaUrl: normalizedUrl,
540
+ chatId,
541
+ mediaLocalRoots,
542
+ includeDefaultMediaLocalRoots: false,
543
+ log: (...args) => logger.info(...args),
544
+ errorLog: (...args) => logger.error(...args),
545
+ });
428
546
 
429
- if (mediaType === "image") {
430
- if (msgItems.length >= MAX_REPLY_MSG_ITEMS) {
431
- logger.warn(`[WS] Reply contains more than ${MAX_REPLY_MSG_ITEMS} images; extra images were skipped`);
432
- continue;
433
- }
434
- const image = prepareImageBufferForMsgItem(loaded.buffer);
435
- msgItems.push({
436
- msgtype: "image",
437
- image: {
438
- base64: image.base64,
439
- md5: image.md5,
440
- },
441
- });
442
- if (mirrorImagesToAgent) {
443
- mirroredAgentMedia.push({
444
- mediaUrl,
445
- mediaType,
446
- buffer: loaded.buffer,
447
- filename: resolveReplyMediaFilename(mediaUrl, loaded),
448
- });
449
- }
450
- continue;
547
+ if (result.ok) {
548
+ state.hasMedia = true;
549
+ if (result.downgraded) {
550
+ logger.info(`[WS] Media downgraded: ${result.downgradeNote}`);
451
551
  }
452
-
453
- agentMedia.push({
454
- mediaUrl,
455
- mediaType,
456
- buffer: loaded.buffer,
457
- filename: resolveReplyMediaFilename(mediaUrl, loaded),
458
- });
459
- } catch (error) {
460
- logger.error(`[WS] Failed to prepare reply media ${mediaUrl}: ${error.message}`);
552
+ } else {
553
+ state.hasMediaFailed = true;
554
+ logger.error(`[WS] Media send failed: url=${mediaUrl}, reason=${result.rejectReason || result.error}`);
555
+ const summary = buildMediaErrorSummary(mediaUrl, result);
556
+ state.mediaErrorSummary = state.mediaErrorSummary
557
+ ? `${state.mediaErrorSummary}\n\n${summary}`
558
+ : summary;
461
559
  }
462
560
  }
463
-
464
- return { msgItems, agentMedia, mirroredAgentMedia };
465
- }
466
-
467
- function buildPassiveMediaAgentTarget({ senderId, chatId, isGroupChat }) {
468
- return isGroupChat ? { chatId } : { toUser: senderId };
469
- }
470
-
471
- function buildPassiveMediaNotice(mediaType, { deliveredViaAgent = true } = {}) {
472
- if (mediaType === "file") {
473
- return deliveredViaAgent
474
- ? "由于当前企业微信bot不支持给用户发送文件,文件通过自建应用发送。"
475
- : "由于当前企业微信bot不支持给用户发送文件,且当前未配置自建应用发送渠道。";
476
- }
477
- if (mediaType === "image") {
478
- return deliveredViaAgent
479
- ? "由于当前企业微信bot不支持直接发送图片,图片通过自建应用发送。"
480
- : "由于当前企业微信bot不支持直接发送图片,且当前未配置自建应用发送渠道。";
481
- }
482
- return deliveredViaAgent
483
- ? "由于当前企业微信bot不支持直接发送媒体,媒体通过自建应用发送。"
484
- : "由于当前企业微信bot不支持直接发送媒体,且当前未配置自建应用发送渠道。";
561
+ state.pendingMediaUrls = [];
485
562
  }
486
563
 
487
- function buildPassiveMediaNoticeBlock(mediaEntries, { deliveredViaAgent = true } = {}) {
488
- if (!Array.isArray(mediaEntries) || mediaEntries.length === 0) {
489
- return "";
490
- }
564
+ async function finishThinkingStream({ wsClient, frame, state, accountId }) {
565
+ const visibleText = stripThinkTags(state.accumulatedText);
566
+ let finishText;
491
567
 
492
- const mediaTypes = [...new Set(mediaEntries.map((entry) => entry.mediaType).filter(Boolean))];
493
- return mediaTypes
494
- .map((mediaType) => buildPassiveMediaNotice(mediaType, { deliveredViaAgent }))
495
- .filter(Boolean)
496
- .join("\n\n");
497
- }
498
-
499
- async function deliverPassiveAgentMedia({
500
- account,
501
- senderId,
502
- chatId,
503
- isGroupChat,
504
- text,
505
- includeText = false,
506
- includeNotice = true,
507
- mediaEntries,
508
- }) {
509
- if (!Array.isArray(mediaEntries) || mediaEntries.length === 0) {
510
- return;
511
- }
512
-
513
- if (!account?.agentCredentials) {
514
- logger.warn("[WS] Agent API is not configured; skipped passive non-image media delivery");
515
- return;
516
- }
517
-
518
- applyAccountNetworkConfig(account);
519
-
520
- const target = buildPassiveMediaAgentTarget({ senderId, chatId, isGroupChat });
521
- const noticeParts = [];
522
- if (includeText && text) {
523
- noticeParts.push(text);
524
- }
525
- if (includeNotice) {
526
- const noticeText = buildPassiveMediaNoticeBlock(mediaEntries);
527
- if (noticeText) {
528
- noticeParts.push(noticeText);
568
+ if (visibleText) {
569
+ let finalVisibleText = state.accumulatedText;
570
+ if (state.hasMediaFailed && state.mediaErrorSummary) {
571
+ finalVisibleText += `\n\n${state.mediaErrorSummary}`;
529
572
  }
530
- }
531
- if (noticeParts.length > 0) {
532
- await agentSendText({
533
- agent: account.agentCredentials,
534
- ...target,
535
- text: noticeParts.join("\n\n"),
573
+ finishText = buildWsStreamContent({
574
+ reasoningText: state.reasoningText,
575
+ visibleText: finalVisibleText,
576
+ finish: true,
536
577
  });
578
+ } else if (state.hasMedia) {
579
+ finishText = "文件已发送,请查收。";
580
+ } else if (state.hasMediaFailed && state.mediaErrorSummary) {
581
+ finishText = state.mediaErrorSummary;
582
+ } else {
583
+ finishText = "处理完成。";
537
584
  }
538
585
 
539
- for (const entry of mediaEntries) {
540
- const mediaId = await agentUploadMedia({
541
- agent: account.agentCredentials,
542
- type: entry.mediaType,
543
- buffer: entry.buffer,
544
- filename: entry.filename,
545
- });
546
- await agentSendMedia({
547
- agent: account.agentCredentials,
548
- ...target,
549
- mediaId,
550
- mediaType: entry.mediaType,
551
- });
552
- }
586
+ await sendWsReply({
587
+ wsClient,
588
+ frame,
589
+ streamId: state.streamId,
590
+ text: finishText,
591
+ finish: true,
592
+ accountId,
593
+ });
553
594
  }
554
595
 
555
596
  function resolveWelcomeMessage(account) {
556
597
  const configured = String(account?.config?.welcomeMessage ?? "").trim();
557
- return configured || DEFAULT_WELCOME_MESSAGE;
598
+ if (configured) {
599
+ return configured;
600
+ }
601
+
602
+ const index = Math.floor(Math.random() * DEFAULT_WELCOME_MESSAGES.length);
603
+ return DEFAULT_WELCOME_MESSAGES[index] || DEFAULT_WELCOME_MESSAGE;
558
604
  }
559
605
 
560
606
  function collectMixedMessageItems({ mixed, textParts, imageUrls, imageAesKeys }) {
@@ -808,13 +854,13 @@ async function flushPendingRepliesViaAgentApi(account) {
808
854
  }
809
855
  }
810
856
 
811
- async function sendThinkingReply({ wsClient, frame, streamId }) {
857
+ async function sendThinkingReply({ wsClient, frame, streamId, text = THINKING_MESSAGE }) {
812
858
  try {
813
859
  await sendWsReply({
814
860
  wsClient,
815
861
  frame,
816
862
  streamId,
817
- text: THINKING_MESSAGE,
863
+ text,
818
864
  finish: false,
819
865
  });
820
866
  } catch (error) {
@@ -872,6 +918,7 @@ function buildInboundContext({
872
918
  SenderName: senderId,
873
919
  GroupId: isGroupChat ? chatId : undefined,
874
920
  Timestamp: Date.now(),
921
+ SourceTimestamp: normalizeWecomCreateTimeMs(body?.create_time) || undefined,
875
922
  Provider: CHANNEL_ID,
876
923
  Surface: CHANNEL_ID,
877
924
  OriginatingChannel: CHANNEL_ID,
@@ -898,7 +945,7 @@ function buildInboundContext({
898
945
  return { ctxPayload: core.reply.finalizeInboundContext(context), storePath };
899
946
  }
900
947
 
901
- async function processWsMessage({ frame, account, config, runtime, wsClient }) {
948
+ async function processWsMessage({ frame, account, config, runtime, wsClient, reqIdStore }) {
902
949
  const core = resolveChannelCore(runtime);
903
950
  const body = frame?.body ?? {};
904
951
  const senderId = body?.from?.userid;
@@ -927,6 +974,27 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
927
974
  return;
928
975
  }
929
976
 
977
+ const perfStartedAt = Date.now();
978
+ const sourceTiming = getWecomSourceTiming(body?.create_time, perfStartedAt);
979
+ const perfState = {
980
+ firstReasoningReceivedAt: 0,
981
+ firstReasoningForwardedAt: 0,
982
+ firstVisibleReceivedAt: 0,
983
+ firstVisibleForwardedAt: 0,
984
+ thinkingSentAt: 0,
985
+ finalReplySentAt: 0,
986
+ };
987
+ const logPerf = (stage, extra = {}) => {
988
+ logger.info(`[WSPERF:${account.accountId}] ${stage}`, {
989
+ messageId,
990
+ senderId,
991
+ chatId,
992
+ isGroupChat,
993
+ elapsedMs: Date.now() - perfStartedAt,
994
+ ...extra,
995
+ });
996
+ };
997
+
930
998
  recordInboundMessage({ accountId: account.accountId, chatId });
931
999
 
932
1000
  const { textParts, imageUrls, imageAesKeys, fileUrls, fileAesKeys, quoteContent } = parseMessageContent(body);
@@ -938,11 +1006,13 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
938
1006
  chatId,
939
1007
  isGroupChat,
940
1008
  messageId,
1009
+ ...sourceTiming,
941
1010
  textLength: originalText.length,
942
1011
  imageCount: imageUrls.length,
943
1012
  fileCount: fileUrls.length,
944
1013
  preview: originalText.slice(0, 80) || (imageUrls.length ? "[image]" : fileUrls.length ? "[file]" : ""),
945
1014
  });
1015
+ logPerf("inbound", sourceTiming);
946
1016
 
947
1017
  if (!text && quoteContent) {
948
1018
  text = quoteContent;
@@ -1042,9 +1112,24 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
1042
1112
  }),
1043
1113
  ]);
1044
1114
  const mediaList = [...imageMediaList, ...fileMediaList];
1115
+ logPerf("media_ready", {
1116
+ imageCount: imageMediaList.length,
1117
+ fileCount: fileMediaList.length,
1118
+ });
1045
1119
 
1046
- const streamId = generateReqId("stream");
1047
- const state = { accumulatedText: "", reasoningText: "", streamId, replyMediaUrls: [] };
1120
+ const streamId = reqIdStore?.getSync(chatId) ?? generateReqId("stream");
1121
+ if (reqIdStore) reqIdStore.set(chatId, streamId);
1122
+ const state = {
1123
+ accumulatedText: "",
1124
+ reasoningText: "",
1125
+ streamId,
1126
+ replyMediaUrls: [],
1127
+ pendingMediaUrls: [],
1128
+ hasMedia: false,
1129
+ hasMediaFailed: false,
1130
+ mediaErrorSummary: "",
1131
+ deliverCalled: false,
1132
+ };
1048
1133
  setMessageState(messageId, state);
1049
1134
 
1050
1135
  // Throttle reasoning and visible text stream updates to avoid exceeding
@@ -1054,26 +1139,95 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
1054
1139
  let pendingReasoningTimer = null;
1055
1140
  let lastVisibleSendAt = 0;
1056
1141
  let pendingVisibleTimer = null;
1142
+ let lastStreamSentAt = 0;
1143
+ let lastNonEmptyStreamText = "";
1144
+ let lastForwardedVisibleText = "";
1145
+ let keepaliveTimer = null;
1146
+ let waitingModelTimer = null;
1147
+ let waitingModelSeconds = 0;
1148
+ let waitingModelActive = false;
1057
1149
 
1058
1150
  const canSendIntermediate = () => streamMessagesSent < MAX_INTERMEDIATE_STREAM_MESSAGES;
1059
1151
 
1152
+ const stopWaitingModelUpdates = () => {
1153
+ waitingModelActive = false;
1154
+ if (waitingModelTimer) {
1155
+ clearTimeout(waitingModelTimer);
1156
+ waitingModelTimer = null;
1157
+ }
1158
+ };
1159
+
1160
+ const sendWaitingModelUpdate = async (seconds) => {
1161
+ const waitingText = buildWaitingModelContent(seconds);
1162
+ lastStreamSentAt = Date.now();
1163
+ lastNonEmptyStreamText = waitingText;
1164
+ try {
1165
+ streamMessagesSent++;
1166
+ await sendWsReply({
1167
+ wsClient,
1168
+ frame,
1169
+ streamId: state.streamId,
1170
+ text: waitingText,
1171
+ finish: false,
1172
+ accountId: account.accountId,
1173
+ });
1174
+ logPerf("waiting_model_forwarded", {
1175
+ seconds,
1176
+ streamMessagesSent,
1177
+ chars: waitingText.length,
1178
+ });
1179
+ } catch (error) {
1180
+ logger.warn(`[WS] Waiting-model stream send failed (non-fatal): ${error.message}`);
1181
+ }
1182
+ };
1183
+
1184
+ const scheduleWaitingModelUpdate = () => {
1185
+ if (!waitingModelActive) {
1186
+ return;
1187
+ }
1188
+ if (waitingModelTimer) {
1189
+ clearTimeout(waitingModelTimer);
1190
+ }
1191
+ waitingModelTimer = setTimeout(async () => {
1192
+ waitingModelTimer = null;
1193
+ if (!waitingModelActive || !canSendIntermediate()) {
1194
+ return;
1195
+ }
1196
+ waitingModelSeconds += 1;
1197
+ await sendWaitingModelUpdate(waitingModelSeconds);
1198
+ scheduleWaitingModelUpdate();
1199
+ }, WAITING_MODEL_TICK_MS);
1200
+ };
1201
+
1060
1202
  const sendReasoningUpdate = async () => {
1061
1203
  if (!canSendIntermediate()) return;
1062
1204
  lastReasoningSendAt = Date.now();
1205
+ lastStreamSentAt = lastReasoningSendAt;
1206
+ const streamText = buildWsStreamContent({
1207
+ reasoningText: state.reasoningText,
1208
+ visibleText: state.accumulatedText,
1209
+ finish: false,
1210
+ });
1211
+ if (streamText) {
1212
+ lastNonEmptyStreamText = streamText;
1213
+ }
1063
1214
  try {
1064
1215
  streamMessagesSent++;
1065
1216
  await sendWsReply({
1066
1217
  wsClient,
1067
1218
  frame,
1068
1219
  streamId: state.streamId,
1069
- text: buildWsStreamContent({
1070
- reasoningText: state.reasoningText,
1071
- visibleText: state.accumulatedText,
1072
- finish: false,
1073
- }),
1220
+ text: streamText,
1074
1221
  finish: false,
1075
1222
  accountId: account.accountId,
1076
1223
  });
1224
+ if (!perfState.firstReasoningForwardedAt) {
1225
+ perfState.firstReasoningForwardedAt = Date.now();
1226
+ logPerf("first_reasoning_forwarded", {
1227
+ streamMessagesSent,
1228
+ chars: streamText.length,
1229
+ });
1230
+ }
1077
1231
  } catch (error) {
1078
1232
  logger.warn(`[WS] Reasoning stream send failed (non-fatal): ${error.message}`);
1079
1233
  }
@@ -1082,25 +1236,161 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
1082
1236
  const sendVisibleUpdate = async () => {
1083
1237
  if (!canSendIntermediate()) return;
1084
1238
  lastVisibleSendAt = Date.now();
1239
+ lastStreamSentAt = lastVisibleSendAt;
1240
+ const visibleText = state.accumulatedText;
1241
+ const streamText = buildWsStreamContent({
1242
+ reasoningText: state.reasoningText,
1243
+ visibleText,
1244
+ finish: false,
1245
+ });
1246
+ if (streamText) {
1247
+ lastNonEmptyStreamText = streamText;
1248
+ }
1249
+ lastForwardedVisibleText = visibleText;
1085
1250
  try {
1086
1251
  streamMessagesSent++;
1087
1252
  await sendWsReply({
1088
1253
  wsClient,
1089
1254
  frame,
1090
1255
  streamId: state.streamId,
1091
- text: buildWsStreamContent({
1092
- reasoningText: state.reasoningText,
1093
- visibleText: state.accumulatedText,
1094
- finish: false,
1095
- }),
1256
+ text: streamText,
1096
1257
  finish: false,
1097
1258
  accountId: account.accountId,
1098
1259
  });
1260
+ if (!perfState.firstVisibleForwardedAt) {
1261
+ perfState.firstVisibleForwardedAt = Date.now();
1262
+ logPerf("first_visible_forwarded", {
1263
+ streamMessagesSent,
1264
+ chars: streamText.length,
1265
+ });
1266
+ }
1099
1267
  } catch (error) {
1100
1268
  logger.warn(`[WS] Visible stream send failed (non-fatal): ${error.message}`);
1101
1269
  }
1102
1270
  };
1103
1271
 
1272
+ const flushPendingStreamUpdates = async () => {
1273
+ const hadPendingReasoning = Boolean(pendingReasoningTimer);
1274
+ const hadPendingVisible = Boolean(pendingVisibleTimer);
1275
+
1276
+ if (pendingReasoningTimer) {
1277
+ clearTimeout(pendingReasoningTimer);
1278
+ pendingReasoningTimer = null;
1279
+ }
1280
+ if (pendingVisibleTimer) {
1281
+ clearTimeout(pendingVisibleTimer);
1282
+ pendingVisibleTimer = null;
1283
+ }
1284
+
1285
+ if (hadPendingReasoning) {
1286
+ const visibleText = hadPendingVisible ? state.accumulatedText : lastForwardedVisibleText;
1287
+ const streamText = buildWsStreamContent({
1288
+ reasoningText: state.reasoningText,
1289
+ visibleText,
1290
+ finish: false,
1291
+ });
1292
+ if (!streamText || streamText === lastNonEmptyStreamText) {
1293
+ return;
1294
+ }
1295
+ lastReasoningSendAt = Date.now();
1296
+ lastStreamSentAt = lastReasoningSendAt;
1297
+ lastNonEmptyStreamText = streamText;
1298
+ if (hadPendingVisible) {
1299
+ lastForwardedVisibleText = visibleText;
1300
+ }
1301
+ try {
1302
+ streamMessagesSent++;
1303
+ await sendWsReply({
1304
+ wsClient,
1305
+ frame,
1306
+ streamId: state.streamId,
1307
+ text: streamText,
1308
+ finish: false,
1309
+ accountId: account.accountId,
1310
+ });
1311
+ if (!perfState.firstReasoningForwardedAt) {
1312
+ perfState.firstReasoningForwardedAt = Date.now();
1313
+ logPerf("first_reasoning_forwarded", {
1314
+ streamMessagesSent,
1315
+ chars: streamText.length,
1316
+ });
1317
+ }
1318
+ } catch (error) {
1319
+ logger.warn(`[WS] Reasoning stream send failed (non-fatal): ${error.message}`);
1320
+ }
1321
+ return;
1322
+ }
1323
+ if (hadPendingVisible) {
1324
+ await sendVisibleUpdate();
1325
+ }
1326
+ };
1327
+
1328
+ const scheduleKeepalive = () => {
1329
+ if (keepaliveTimer) clearTimeout(keepaliveTimer);
1330
+ keepaliveTimer = setTimeout(async () => {
1331
+ keepaliveTimer = null;
1332
+ if (!canSendIntermediate()) return;
1333
+ const idle = Date.now() - lastStreamSentAt;
1334
+ if (idle < STREAM_KEEPALIVE_INTERVAL_MS) {
1335
+ // A real update was sent recently; wait remaining time then send immediately.
1336
+ const remaining = STREAM_KEEPALIVE_INTERVAL_MS - idle;
1337
+ keepaliveTimer = setTimeout(async () => {
1338
+ keepaliveTimer = null;
1339
+ if (!canSendIntermediate()) return;
1340
+ logger.debug(`[WS] Sending stream keepalive after deferred wait (idle ${Math.round((Date.now() - lastStreamSentAt) / 1000)}s)`);
1341
+ lastStreamSentAt = Date.now();
1342
+ const keepaliveText = resolveWsKeepaliveContent({
1343
+ reasoningText: state.reasoningText,
1344
+ visibleText: state.accumulatedText,
1345
+ lastStreamText: lastNonEmptyStreamText,
1346
+ });
1347
+ if (keepaliveText) {
1348
+ lastNonEmptyStreamText = keepaliveText;
1349
+ }
1350
+ try {
1351
+ streamMessagesSent++;
1352
+ await sendWsReply({
1353
+ wsClient,
1354
+ frame,
1355
+ streamId: state.streamId,
1356
+ text: keepaliveText,
1357
+ finish: false,
1358
+ accountId: account.accountId,
1359
+ });
1360
+ } catch (err) {
1361
+ logger.warn(`[WS] Keepalive send failed (non-fatal): ${err.message}`);
1362
+ }
1363
+ scheduleKeepalive();
1364
+ }, remaining);
1365
+ return;
1366
+ }
1367
+ logger.debug(`[WS] Sending stream keepalive (idle ${Math.round(idle / 1000)}s)`);
1368
+ lastStreamSentAt = Date.now();
1369
+ const keepaliveText = resolveWsKeepaliveContent({
1370
+ reasoningText: state.reasoningText,
1371
+ visibleText: state.accumulatedText,
1372
+ lastStreamText: lastNonEmptyStreamText,
1373
+ });
1374
+ if (keepaliveText) {
1375
+ lastNonEmptyStreamText = keepaliveText;
1376
+ }
1377
+ try {
1378
+ streamMessagesSent++;
1379
+ await sendWsReply({
1380
+ wsClient,
1381
+ frame,
1382
+ streamId: state.streamId,
1383
+ text: keepaliveText,
1384
+ finish: false,
1385
+ accountId: account.accountId,
1386
+ });
1387
+ } catch (error) {
1388
+ logger.warn(`[WS] Stream keepalive send failed (non-fatal): ${error.message}`);
1389
+ }
1390
+ scheduleKeepalive();
1391
+ }, STREAM_KEEPALIVE_INTERVAL_MS);
1392
+ };
1393
+
1104
1394
  const cancelPendingTimers = () => {
1105
1395
  if (pendingReasoningTimer) {
1106
1396
  clearTimeout(pendingReasoningTimer);
@@ -1110,6 +1400,11 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
1110
1400
  clearTimeout(pendingVisibleTimer);
1111
1401
  pendingVisibleTimer = null;
1112
1402
  }
1403
+ if (keepaliveTimer) {
1404
+ clearTimeout(keepaliveTimer);
1405
+ keepaliveTimer = null;
1406
+ }
1407
+ stopWaitingModelUpdates();
1113
1408
  };
1114
1409
 
1115
1410
  const cleanupState = () => {
@@ -1118,8 +1413,21 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
1118
1413
  };
1119
1414
 
1120
1415
  if (account.sendThinkingMessage !== false) {
1121
- await sendThinkingReply({ wsClient, frame, streamId });
1416
+ waitingModelActive = true;
1417
+ waitingModelSeconds = 1;
1418
+ await sendThinkingReply({
1419
+ wsClient,
1420
+ frame,
1421
+ streamId,
1422
+ text: buildWaitingModelContent(waitingModelSeconds),
1423
+ });
1424
+ lastNonEmptyStreamText = buildWaitingModelContent(waitingModelSeconds);
1425
+ perfState.thinkingSentAt = Date.now();
1426
+ logPerf("thinking_sent", { streamId });
1427
+ scheduleWaitingModelUpdate();
1122
1428
  }
1429
+ lastStreamSentAt = Date.now();
1430
+ scheduleKeepalive();
1123
1431
 
1124
1432
  const peerKind = isGroupChat ? "group" : "dm";
1125
1433
  const peerId = isGroupChat ? chatId : senderId;
@@ -1167,13 +1475,13 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
1167
1475
  });
1168
1476
  ctxPayload.CommandAuthorized = commandAuthorized;
1169
1477
 
1170
- void core.session
1171
- .recordSessionMetaFromInbound({
1172
- storePath,
1173
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
1174
- ctx: ctxPayload,
1175
- })
1176
- .catch((error) => logger.error(`[WS] Failed to record session metadata: ${error.message}`));
1478
+ await ensureDefaultSessionReasoningLevel({
1479
+ core,
1480
+ storePath,
1481
+ sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
1482
+ ctx: ctxPayload,
1483
+ channelTag: "WS",
1484
+ });
1177
1485
 
1178
1486
  const runDispatch = async () => {
1179
1487
  let cleanedUp = false;
@@ -1186,6 +1494,12 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
1186
1494
  };
1187
1495
 
1188
1496
  try {
1497
+ logPerf("dispatch_start", {
1498
+ routeAgentId: route.agentId,
1499
+ sessionKey: route.sessionKey,
1500
+ mediaCount: mediaList.length,
1501
+ ...sourceTiming,
1502
+ });
1189
1503
  await streamContext.run(
1190
1504
  { streamId, streamKey: peerId, agentId: route.agentId, accountId: account.accountId },
1191
1505
  async () => {
@@ -1199,7 +1513,14 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
1199
1513
  if (!nextReasoning) {
1200
1514
  return;
1201
1515
  }
1516
+ stopWaitingModelUpdates();
1202
1517
  state.reasoningText = nextReasoning;
1518
+ if (!perfState.firstReasoningReceivedAt) {
1519
+ perfState.firstReasoningReceivedAt = Date.now();
1520
+ logPerf("first_reasoning_received", {
1521
+ chars: nextReasoning.length,
1522
+ });
1523
+ }
1203
1524
 
1204
1525
  // Throttle: skip if sent recently, schedule a trailing update instead.
1205
1526
  const elapsed = Date.now() - lastReasoningSendAt;
@@ -1217,30 +1538,62 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
1217
1538
  },
1218
1539
  dispatcherOptions: {
1219
1540
  deliver: async (payload, info) => {
1541
+ state.deliverCalled = true;
1220
1542
  const normalized = normalizeReplyPayload(payload);
1221
1543
  const chunk = normalized.text;
1222
1544
  const mediaUrls = normalized.mediaUrls;
1545
+
1546
+ if (chunk) {
1547
+ stopWaitingModelUpdates();
1548
+ state.accumulatedText += chunk;
1549
+ }
1550
+
1223
1551
  for (const mediaUrl of mediaUrls) {
1224
1552
  if (!state.replyMediaUrls.includes(mediaUrl)) {
1225
1553
  state.replyMediaUrls.push(mediaUrl);
1554
+ state.pendingMediaUrls.push(mediaUrl);
1555
+ }
1556
+ }
1557
+
1558
+ if (state.pendingMediaUrls.length > 0) {
1559
+ try {
1560
+ await sendMediaBatch({
1561
+ wsClient, frame, state, account, runtime, config,
1562
+ agentId: route.agentId,
1563
+ });
1564
+ } catch (mediaErr) {
1565
+ state.hasMediaFailed = true;
1566
+ const errMsg = String(mediaErr);
1567
+ const summary = `文件发送失败:内部处理异常,请升级 openclaw 到最新版本后重试。\n错误详情:${errMsg}`;
1568
+ state.mediaErrorSummary = state.mediaErrorSummary
1569
+ ? `${state.mediaErrorSummary}\n\n${summary}`
1570
+ : summary;
1571
+ logger.error(`[WS] sendMediaBatch threw: ${errMsg}`);
1226
1572
  }
1227
1573
  }
1228
1574
 
1229
- state.accumulatedText += chunk;
1575
+ if (!perfState.firstVisibleReceivedAt && chunk?.trim()) {
1576
+ perfState.firstVisibleReceivedAt = Date.now();
1577
+ logPerf("first_visible_received", {
1578
+ chars: chunk.length,
1579
+ });
1580
+ }
1581
+
1230
1582
  if (info.kind !== "final") {
1231
- // Throttle visible text stream updates to avoid exceeding
1232
- // the SDK queue limit together with reasoning updates.
1233
- const elapsed = Date.now() - lastVisibleSendAt;
1234
- if (elapsed < VISIBLE_STREAM_THROTTLE_MS) {
1235
- if (!pendingVisibleTimer) {
1236
- pendingVisibleTimer = setTimeout(async () => {
1237
- pendingVisibleTimer = null;
1238
- await sendVisibleUpdate();
1239
- }, VISIBLE_STREAM_THROTTLE_MS - elapsed);
1583
+ const hasText = stripThinkTags(state.accumulatedText);
1584
+ if (hasText) {
1585
+ const elapsed = Date.now() - lastVisibleSendAt;
1586
+ if (elapsed < VISIBLE_STREAM_THROTTLE_MS) {
1587
+ if (!pendingVisibleTimer) {
1588
+ pendingVisibleTimer = setTimeout(async () => {
1589
+ pendingVisibleTimer = null;
1590
+ await sendVisibleUpdate();
1591
+ }, VISIBLE_STREAM_THROTTLE_MS - elapsed);
1592
+ }
1593
+ return;
1240
1594
  }
1241
- return;
1595
+ await sendVisibleUpdate();
1242
1596
  }
1243
- await sendVisibleUpdate();
1244
1597
  }
1245
1598
  },
1246
1599
  onError: (error, info) => {
@@ -1251,112 +1604,67 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
1251
1604
  },
1252
1605
  );
1253
1606
 
1607
+ // Flush the latest throttled snapshot before finish=true so reasoning
1608
+ // and visible deltas are not collapsed away by the final frame.
1609
+ await flushPendingStreamUpdates();
1610
+
1254
1611
  // Cancel pending throttled timers before the final reply to prevent
1255
1612
  // non-final updates from being sent after finish=true.
1256
1613
  cancelPendingTimers();
1257
1614
 
1258
- const preparedReplyMedia = await prepareReplyMediaOutputs({
1259
- payload: { mediaUrls: state.replyMediaUrls },
1260
- runtime,
1261
- config,
1262
- agentId: route.agentId,
1263
- mirrorImagesToAgent: Boolean(account?.agentCredentials),
1264
- });
1265
- const msgItem = preparedReplyMedia.msgItems;
1266
- const deferredMediaDeliveredViaAgent = Boolean(account?.agentCredentials);
1267
- const finalReplyText = buildWsStreamContent({
1268
- reasoningText: state.reasoningText,
1269
- visibleText: state.accumulatedText,
1270
- finish: true,
1271
- });
1272
- const passiveMediaNotice = buildPassiveMediaNoticeBlock(preparedReplyMedia.agentMedia, {
1273
- deliveredViaAgent: deferredMediaDeliveredViaAgent,
1274
- });
1275
- const finalWsText = [finalReplyText, passiveMediaNotice].filter(Boolean).join("\n\n");
1276
-
1277
- if (preparedReplyMedia.agentMedia.length > 0 && !account?.agentCredentials) {
1278
- logger.warn("[WS] Agent API is not configured; passive non-image media delivery was skipped");
1279
- }
1280
-
1281
- // If dispatch returned no content at all (e.g. upstream empty_stream),
1282
- // send a fallback so the user isn't left waiting in silence.
1283
- const effectiveFinalText = finalWsText || (msgItem.length === 0 ? "模型暂时无法响应,请稍后重试。" : "");
1284
- if (effectiveFinalText || msgItem.length > 0) {
1285
- logger.info("[WS] Sending passive final reply", {
1615
+ try {
1616
+ await finishThinkingStream({
1617
+ wsClient,
1618
+ frame,
1619
+ state,
1286
1620
  accountId: account.accountId,
1287
- agentId: route.agentId,
1288
- streamId: state.streamId,
1289
- textLength: effectiveFinalText.length,
1290
- imageItemCount: msgItem.length,
1291
- deferredAgentMediaCount: preparedReplyMedia.agentMedia.length,
1292
- mirroredAgentImageCount: preparedReplyMedia.mirroredAgentMedia.length,
1293
- emptyStreamFallback: !finalWsText,
1294
1621
  });
1295
- try {
1296
- await sendWsReply({
1297
- wsClient,
1298
- frame,
1299
- streamId: state.streamId,
1300
- text: effectiveFinalText,
1301
- finish: true,
1302
- msgItem,
1303
- accountId: account.accountId,
1304
- });
1305
- } catch (sendError) {
1306
- // WS disconnected or timed out — enqueue for retry via Agent API on reconnect.
1307
- logger.warn(`[WS] Final reply send failed, enqueuing for retry: ${sendError.message}`, {
1308
- accountId: account.accountId,
1309
- chatId,
1310
- senderId,
1311
- });
1622
+ perfState.finalReplySentAt = Date.now();
1623
+ logPerf("final_reply_sent", {
1624
+ textLength: state.accumulatedText.length,
1625
+ hasMedia: state.hasMedia,
1626
+ hasMediaFailed: state.hasMediaFailed,
1627
+ });
1628
+ } catch (sendError) {
1629
+ logger.warn(`[WS] Final reply send failed, enqueuing for retry: ${sendError.message}`, {
1630
+ accountId: account.accountId,
1631
+ chatId,
1632
+ senderId,
1633
+ });
1634
+ if (state.accumulatedText) {
1312
1635
  enqueuePendingReply(account.accountId, {
1313
- text: effectiveFinalText,
1636
+ text: state.accumulatedText,
1314
1637
  senderId,
1315
1638
  chatId,
1316
1639
  isGroupChat,
1317
1640
  });
1318
1641
  }
1319
1642
  }
1320
-
1321
- if (account?.agentCredentials && preparedReplyMedia.mirroredAgentMedia.length > 0) {
1322
- await deliverPassiveAgentMedia({
1323
- account,
1324
- senderId,
1325
- chatId,
1326
- isGroupChat,
1327
- text: state.accumulatedText,
1328
- includeText: false,
1329
- includeNotice: false,
1330
- mediaEntries: preparedReplyMedia.mirroredAgentMedia,
1331
- });
1332
- }
1333
-
1334
- if (account?.agentCredentials && preparedReplyMedia.agentMedia.length > 0) {
1335
- await deliverPassiveAgentMedia({
1336
- account,
1337
- senderId,
1338
- chatId,
1339
- isGroupChat,
1340
- text: finalWsText,
1341
- includeText: false,
1342
- includeNotice: false,
1343
- mediaEntries: preparedReplyMedia.agentMedia,
1344
- });
1345
- }
1346
1643
  safeCleanup();
1644
+ logPerf("dispatch_complete", {
1645
+ hadReasoning: Boolean(perfState.firstReasoningReceivedAt),
1646
+ hadVisibleText: Boolean(perfState.firstVisibleReceivedAt),
1647
+ totalOutputChars: state.accumulatedText.length,
1648
+ replyMediaCount: state.replyMediaUrls.length,
1649
+ });
1347
1650
  } catch (error) {
1348
1651
  logger.error(`[WS] Failed to dispatch reply: ${error.message}`);
1652
+ logPerf("dispatch_failed", {
1653
+ error: error.message,
1654
+ });
1349
1655
  try {
1350
- await sendWsReply({
1656
+ // Ensure the user sees an error message, not "处理完成。"
1657
+ if (!stripThinkTags(state.accumulatedText) && !state.hasMedia) {
1658
+ state.accumulatedText = `⚠️ 处理出错:${error.message}`;
1659
+ }
1660
+ await finishThinkingStream({
1351
1661
  wsClient,
1352
1662
  frame,
1353
- streamId: state.streamId,
1354
- text: "处理消息时出错,请稍后再试。",
1355
- finish: true,
1663
+ state,
1356
1664
  accountId: account.accountId,
1357
1665
  });
1358
- } catch (retryError) {
1359
- // If the error reply also fails (WS disconnected), enqueue accumulated text if any.
1666
+ } catch (finishErr) {
1667
+ logger.error(`[WS] Failed to finish thinking stream after dispatch error: ${finishErr.message}`);
1360
1668
  if (state.accumulatedText) {
1361
1669
  enqueuePendingReply(account.accountId, {
1362
1670
  text: state.accumulatedText,
@@ -1371,8 +1679,24 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
1371
1679
  };
1372
1680
 
1373
1681
  const lockKey = `${account.accountId}:${peerId}`;
1682
+ const queuedAt = Date.now();
1374
1683
  const previous = dispatchLocks.get(lockKey) ?? Promise.resolve();
1375
- const current = previous.then(runDispatch, runDispatch);
1684
+ const current = previous.then(
1685
+ async () => {
1686
+ const queueWaitMs = Date.now() - queuedAt;
1687
+ if (queueWaitMs >= 50) {
1688
+ logPerf("dispatch_lock_acquired", { queueWaitMs });
1689
+ }
1690
+ return await runDispatch();
1691
+ },
1692
+ async () => {
1693
+ const queueWaitMs = Date.now() - queuedAt;
1694
+ if (queueWaitMs >= 50) {
1695
+ logPerf("dispatch_lock_acquired", { queueWaitMs, previousFailed: true });
1696
+ }
1697
+ return await runDispatch();
1698
+ },
1699
+ );
1376
1700
  dispatchLocks.set(lockKey, current);
1377
1701
  current.finally(() => {
1378
1702
  if (dispatchLocks.get(lockKey) === current) {
@@ -1406,10 +1730,19 @@ export async function startWsMonitor({ account, config, runtime, abortSignal, ws
1406
1730
  maxReconnectAttempts: WS_MAX_RECONNECT_ATTEMPTS,
1407
1731
  });
1408
1732
 
1733
+ const reqIdStore = createPersistentReqIdStore(account.accountId);
1734
+ await reqIdStore.warmup();
1735
+
1409
1736
  return new Promise((resolve, reject) => {
1410
1737
  let settled = false;
1411
1738
 
1412
1739
  const cleanup = async () => {
1740
+ try {
1741
+ await reqIdStore.flush();
1742
+ } catch (flushErr) {
1743
+ logger.warn(`[WS:${account.accountId}] Failed to flush reqId store on cleanup: ${flushErr.message}`);
1744
+ }
1745
+ reqIdStore.destroy();
1413
1746
  await cleanupWsAccount(account.accountId);
1414
1747
  };
1415
1748
 
@@ -1451,6 +1784,8 @@ export async function startWsMonitor({ account, config, runtime, abortSignal, ws
1451
1784
  clearAccountDisplaced(account.accountId);
1452
1785
  setWsClient(account.accountId, wsClient);
1453
1786
 
1787
+ void fetchAndSaveMcpConfig(wsClient, account.accountId, runtime);
1788
+
1454
1789
  // Drain pending replies that failed due to prior WS disconnection.
1455
1790
  if (account?.agentCredentials && hasPendingReplies(account.accountId)) {
1456
1791
  void flushPendingRepliesViaAgentApi(account).catch((flushError) => {
@@ -1477,7 +1812,7 @@ export async function startWsMonitor({ account, config, runtime, abortSignal, ws
1477
1812
  wsClient.on("message", async (frame) => {
1478
1813
  try {
1479
1814
  await withTimeout(
1480
- processWsMessage({ frame, account, config, runtime, wsClient }),
1815
+ processWsMessage({ frame, account, config, runtime, wsClient, reqIdStore }),
1481
1816
  MESSAGE_PROCESS_TIMEOUT_MS,
1482
1817
  `Message processing timed out (msgId=${frame?.body?.msgid ?? "unknown"})`,
1483
1818
  );
@@ -1546,14 +1881,20 @@ export async function startWsMonitor({ account, config, runtime, abortSignal, ws
1546
1881
  }
1547
1882
 
1548
1883
  export const wsMonitorTesting = {
1884
+ buildWsStreamContent,
1885
+ ensureDefaultSessionReasoningLevel,
1886
+ resolveWsKeepaliveContent,
1549
1887
  processWsMessage,
1550
1888
  parseMessageContent,
1551
1889
  splitReplyMediaFromText,
1552
1890
  buildBodyForAgent,
1891
+ normalizeReplyMediaUrlForLoad,
1553
1892
  flushPendingRepliesViaAgentApi,
1893
+ stripThinkTags,
1894
+ finishThinkingStream,
1554
1895
  };
1555
1896
 
1556
- export { buildReplyMediaGuidance };
1897
+ export { buildReplyMediaGuidance, ensureDefaultSessionReasoningLevel, normalizeReplyMediaUrlForLoad };
1557
1898
 
1558
1899
  // Shared internals used by callback-inbound.js
1559
1900
  export {