@sunnoy/wecom 2.1.0 → 2.2.1
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/README.md +6 -2
- package/index.js +2 -0
- package/openclaw.plugin.json +3 -0
- package/package.json +5 -3
- package/skills/wecom-contact-lookup/SKILL.md +167 -0
- package/skills/wecom-doc-manager/SKILL.md +106 -0
- package/skills/wecom-doc-manager/references/api-create-doc.md +56 -0
- package/skills/wecom-doc-manager/references/api-edit-doc-content.md +68 -0
- package/skills/wecom-doc-manager/references/api-export-document.md +88 -0
- package/skills/wecom-edit-todo/SKILL.md +254 -0
- package/skills/wecom-get-todo-detail/SKILL.md +148 -0
- package/skills/wecom-get-todo-list/SKILL.md +132 -0
- package/skills/wecom-meeting-create/SKILL.md +163 -0
- package/skills/wecom-meeting-create/references/example-full.md +30 -0
- package/skills/wecom-meeting-create/references/example-reminder.md +46 -0
- package/skills/wecom-meeting-create/references/example-security.md +22 -0
- package/skills/wecom-meeting-manage/SKILL.md +141 -0
- package/skills/wecom-meeting-query/SKILL.md +335 -0
- package/skills/wecom-preflight/SKILL.md +103 -0
- package/skills/wecom-schedule/SKILL.md +164 -0
- package/skills/wecom-schedule/references/api-check-availability.md +56 -0
- package/skills/wecom-schedule/references/api-create-schedule.md +38 -0
- package/skills/wecom-schedule/references/api-get-schedule-detail.md +81 -0
- package/skills/wecom-schedule/references/api-update-schedule.md +30 -0
- package/skills/wecom-schedule/references/ref-reminders.md +24 -0
- package/skills/wecom-smartsheet-data/SKILL.md +76 -0
- package/skills/wecom-smartsheet-data/references/api-get-records.md +61 -0
- package/skills/wecom-smartsheet-data/references/cell-value-formats.md +120 -0
- package/skills/wecom-smartsheet-schema/SKILL.md +96 -0
- package/skills/wecom-smartsheet-schema/references/field-types.md +43 -0
- package/wecom/accounts.js +1 -0
- package/wecom/callback-inbound.js +133 -33
- package/wecom/channel-plugin.js +107 -125
- package/wecom/constants.js +83 -3
- package/wecom/mcp-config.js +146 -0
- package/wecom/mcp-tool.js +660 -0
- package/wecom/media-uploader.js +208 -0
- package/wecom/openclaw-compat.js +302 -0
- package/wecom/reqid-store.js +146 -0
- package/wecom/target.js +3 -2
- package/wecom/workspace-template.js +107 -21
- package/wecom/ws-monitor.js +778 -328
- package/image-processor.js +0 -175
package/wecom/ws-monitor.js
CHANGED
|
@@ -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,12 +23,14 @@ 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,
|
|
26
30
|
MEDIA_IMAGE_PLACEHOLDER,
|
|
27
31
|
MESSAGE_PROCESS_TIMEOUT_MS,
|
|
28
32
|
REPLY_SEND_TIMEOUT_MS,
|
|
33
|
+
STREAM_MAX_LIFETIME_MS,
|
|
29
34
|
THINKING_MESSAGE,
|
|
30
35
|
WS_HEARTBEAT_INTERVAL_MS,
|
|
31
36
|
WS_MAX_RECONNECT_ATTEMPTS,
|
|
@@ -34,6 +39,7 @@ import {
|
|
|
34
39
|
import { setConfigProxyUrl } from "./http.js";
|
|
35
40
|
import { checkDmPolicy } from "./dm-policy.js";
|
|
36
41
|
import { checkGroupPolicy } from "./group-policy.js";
|
|
42
|
+
import { fetchAndSaveMcpConfig } from "./mcp-config.js";
|
|
37
43
|
import {
|
|
38
44
|
clearAccountDisplaced,
|
|
39
45
|
forecastActiveSendQuota,
|
|
@@ -61,17 +67,21 @@ import { ensureDynamicAgentListed } from "./workspace-template.js";
|
|
|
61
67
|
const DEFAULT_AGENT_ID = "main";
|
|
62
68
|
const DEFAULT_STATE_DIRNAME = ".openclaw";
|
|
63
69
|
const LEGACY_STATE_DIRNAMES = [".clawdbot", ".moldbot", ".moltbot"];
|
|
64
|
-
const
|
|
65
|
-
const MAX_REPLY_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
70
|
+
const WAITING_MODEL_TICK_MS = 1_000;
|
|
66
71
|
const REASONING_STREAM_THROTTLE_MS = 800;
|
|
67
72
|
const VISIBLE_STREAM_THROTTLE_MS = 800;
|
|
68
73
|
// Reserve headroom below the SDK's per-reqId queue limit (100) so the final
|
|
69
74
|
// reply always has room.
|
|
70
75
|
const MAX_INTERMEDIATE_STREAM_MESSAGES = 85;
|
|
76
|
+
// WeCom stream messages have a hard 6-minute absolute lifetime from creation.
|
|
77
|
+
// Keepalive updates every 4 minutes maintain visible progress but do NOT extend
|
|
78
|
+
// the lifetime. Stream rotation (see rotateStream) is the actual fix.
|
|
79
|
+
const STREAM_KEEPALIVE_INTERVAL_MS = 4 * 60 * 1000;
|
|
71
80
|
// Match MEDIA:/FILE: directives at line start, optionally preceded by markdown list markers.
|
|
72
81
|
const REPLY_MEDIA_DIRECTIVE_PATTERN = /^\s*(?:[-*•]\s+|\d+\.\s+)?(?:MEDIA|FILE)\s*:/im;
|
|
73
82
|
const WECOM_REPLY_MEDIA_GUIDANCE_HEADER = "[WeCom reply media rule]";
|
|
74
83
|
const inboundMessageDeduplicator = new MessageDeduplicator();
|
|
84
|
+
const sessionReasoningInitLocks = new Map();
|
|
75
85
|
|
|
76
86
|
function withTimeout(promise, timeoutMs, message) {
|
|
77
87
|
if (!timeoutMs || !Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
@@ -119,6 +129,15 @@ function normalizeReasoningStreamText(text) {
|
|
|
119
129
|
return lines.join("\n").trim();
|
|
120
130
|
}
|
|
121
131
|
|
|
132
|
+
function buildWaitingModelContent(seconds) {
|
|
133
|
+
const normalizedSeconds = Math.max(1, Number.parseInt(String(seconds ?? 1), 10) || 1);
|
|
134
|
+
const lines = [];
|
|
135
|
+
for (let current = 1; current <= normalizedSeconds; current += 1) {
|
|
136
|
+
lines.push(`等待模型响应 ${current}s`);
|
|
137
|
+
}
|
|
138
|
+
return `<think>${lines.join("\n")}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
122
141
|
function buildWsStreamContent({ reasoningText = "", visibleText = "", finish = false }) {
|
|
123
142
|
const normalizedReasoning = String(reasoningText ?? "").trim();
|
|
124
143
|
const normalizedVisible = String(visibleText ?? "").trim();
|
|
@@ -135,6 +154,112 @@ function buildWsStreamContent({ reasoningText = "", visibleText = "", finish = f
|
|
|
135
154
|
return normalizedVisible ? `${thinkBlock}\n${normalizedVisible}` : thinkBlock;
|
|
136
155
|
}
|
|
137
156
|
|
|
157
|
+
function normalizeWecomCreateTimeMs(value) {
|
|
158
|
+
const seconds = Number(value);
|
|
159
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
160
|
+
return 0;
|
|
161
|
+
}
|
|
162
|
+
return Math.trunc(seconds * 1000);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function getWecomSourceTiming(createTime, now = Date.now()) {
|
|
166
|
+
const sourceCreateTimeMs = normalizeWecomCreateTimeMs(createTime);
|
|
167
|
+
if (!sourceCreateTimeMs) {
|
|
168
|
+
return {
|
|
169
|
+
sourceCreateTime: undefined,
|
|
170
|
+
sourceCreateTimeIso: undefined,
|
|
171
|
+
sourceToIngressMs: undefined,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
sourceCreateTime: createTime,
|
|
177
|
+
sourceCreateTimeIso: new Date(sourceCreateTimeMs).toISOString(),
|
|
178
|
+
sourceToIngressMs: Math.max(0, now - sourceCreateTimeMs),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function resolveWsKeepaliveContent({ reasoningText = "", visibleText = "", lastStreamText = "" }) {
|
|
183
|
+
const currentContent = buildWsStreamContent({
|
|
184
|
+
reasoningText,
|
|
185
|
+
visibleText,
|
|
186
|
+
finish: false,
|
|
187
|
+
});
|
|
188
|
+
return currentContent || String(lastStreamText ?? "").trim() || THINKING_MESSAGE;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function normalizeSessionStoreKey(sessionKey) {
|
|
192
|
+
return String(sessionKey ?? "").trim().toLowerCase();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function withSessionReasoningInitLock(storePath, task) {
|
|
196
|
+
const lockKey = path.resolve(String(storePath ?? ""));
|
|
197
|
+
const previous = sessionReasoningInitLocks.get(lockKey) ?? Promise.resolve();
|
|
198
|
+
const current = previous.then(task, task);
|
|
199
|
+
sessionReasoningInitLocks.set(lockKey, current);
|
|
200
|
+
return await current.finally(() => {
|
|
201
|
+
if (sessionReasoningInitLocks.get(lockKey) === current) {
|
|
202
|
+
sessionReasoningInitLocks.delete(lockKey);
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function ensureDefaultSessionReasoningLevel({
|
|
208
|
+
core,
|
|
209
|
+
storePath,
|
|
210
|
+
sessionKey,
|
|
211
|
+
ctx,
|
|
212
|
+
reasoningLevel = "stream",
|
|
213
|
+
channelTag = "WS",
|
|
214
|
+
}) {
|
|
215
|
+
const normalizedSessionKey = normalizeSessionStoreKey(sessionKey);
|
|
216
|
+
if (!storePath || !normalizedSessionKey || !ctx || !core?.session?.recordSessionMetaFromInbound) {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
const recorded = await core.session.recordSessionMetaFromInbound({
|
|
222
|
+
storePath,
|
|
223
|
+
sessionKey: normalizedSessionKey,
|
|
224
|
+
ctx,
|
|
225
|
+
});
|
|
226
|
+
if (!recorded || recorded.reasoningLevel != null) {
|
|
227
|
+
return recorded;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return await withSessionReasoningInitLock(storePath, async () => {
|
|
231
|
+
let store;
|
|
232
|
+
try {
|
|
233
|
+
store = JSON.parse(await readFile(storePath, "utf8"));
|
|
234
|
+
} catch (error) {
|
|
235
|
+
logger.warn(`[${channelTag}] Failed to read session store for reasoning default: ${error.message}`);
|
|
236
|
+
return recorded;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const resolvedKey = Object.keys(store).find((key) => normalizeSessionStoreKey(key) === normalizedSessionKey);
|
|
240
|
+
if (!resolvedKey) {
|
|
241
|
+
return recorded;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const existing = store[resolvedKey];
|
|
245
|
+
if (!existing || typeof existing !== "object" || existing.reasoningLevel != null) {
|
|
246
|
+
return existing ?? recorded;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
store[resolvedKey] = { ...existing, reasoningLevel };
|
|
250
|
+
await writeFile(storePath, `${JSON.stringify(store, null, 2)}\n`);
|
|
251
|
+
logger.info(`[${channelTag}] Initialized session reasoningLevel default`, {
|
|
252
|
+
sessionKey: resolvedKey,
|
|
253
|
+
reasoningLevel,
|
|
254
|
+
});
|
|
255
|
+
return store[resolvedKey];
|
|
256
|
+
});
|
|
257
|
+
} catch (error) {
|
|
258
|
+
logger.warn(`[${channelTag}] Failed to initialize session reasoning default: ${error.message}`);
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
138
263
|
function createSdkLogger(accountId) {
|
|
139
264
|
return {
|
|
140
265
|
debug: (message, ...args) => logger.debug(`[WS:${accountId}] ${message}`, ...args),
|
|
@@ -165,19 +290,6 @@ function resolveChannelCore(runtime) {
|
|
|
165
290
|
throw new Error("OpenClaw channel runtime is unavailable");
|
|
166
291
|
}
|
|
167
292
|
|
|
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
293
|
function resolveUserPath(value) {
|
|
182
294
|
const trimmed = String(value ?? "").trim();
|
|
183
295
|
if (!trimmed) {
|
|
@@ -247,10 +359,18 @@ function resolveAgentWorkspaceDir(config, agentId) {
|
|
|
247
359
|
return path.join(stateDir, `workspace-${normalizedAgentId}`);
|
|
248
360
|
}
|
|
249
361
|
|
|
362
|
+
function resolveConfiguredReplyMediaLocalRoots(config) {
|
|
363
|
+
const roots = Array.isArray(config?.channels?.[CHANNEL_ID]?.mediaLocalRoots)
|
|
364
|
+
? config.channels[CHANNEL_ID].mediaLocalRoots
|
|
365
|
+
: [];
|
|
366
|
+
return roots.map((entry) => resolveUserPath(entry)).filter(Boolean);
|
|
367
|
+
}
|
|
368
|
+
|
|
250
369
|
function resolveReplyMediaLocalRoots(config, agentId) {
|
|
251
370
|
const workspaceDir = resolveAgentWorkspaceDir(config, agentId || resolveDefaultAgentId(config));
|
|
252
371
|
const browserMediaDir = path.join(resolveStateDir(), "media", "browser");
|
|
253
|
-
|
|
372
|
+
const configuredRoots = resolveConfiguredReplyMediaLocalRoots(config);
|
|
373
|
+
return [...new Set([workspaceDir, browserMediaDir, ...configuredRoots].map((entry) => path.resolve(entry)))];
|
|
254
374
|
}
|
|
255
375
|
|
|
256
376
|
function mergeReplyMediaUrls(...lists) {
|
|
@@ -277,17 +397,68 @@ function mergeReplyMediaUrls(...lists) {
|
|
|
277
397
|
function buildReplyMediaGuidance(config, agentId) {
|
|
278
398
|
const workspaceDir = resolveAgentWorkspaceDir(config, agentId || resolveDefaultAgentId(config));
|
|
279
399
|
const browserMediaDir = path.join(resolveStateDir(), "media", "browser");
|
|
280
|
-
|
|
400
|
+
const configuredRoots = resolveConfiguredReplyMediaLocalRoots(config);
|
|
401
|
+
const guidance = [
|
|
281
402
|
WECOM_REPLY_MEDIA_GUIDANCE_HEADER,
|
|
282
|
-
`
|
|
403
|
+
`Local reply files are allowed only under the current workspace: ${workspaceDir}`,
|
|
404
|
+
"Inside the agent sandbox, that same workspace is visible as /workspace.",
|
|
283
405
|
`Browser-generated files are also allowed only under: ${browserMediaDir}`,
|
|
284
|
-
"
|
|
285
|
-
"
|
|
286
|
-
"
|
|
287
|
-
"
|
|
406
|
+
"Do NOT call message.send or message.sendAttachment to deliver files back to the current WeCom chat/user; use MEDIA: or FILE: directives instead.",
|
|
407
|
+
"For images: put each image path on its own line as MEDIA:/abs/path.",
|
|
408
|
+
"If a local file is in the current sandbox workspace, use its /workspace/... path directly.",
|
|
409
|
+
"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.",
|
|
410
|
+
"Example: FILE:/workspace/skills/deep-research/SKILL.md",
|
|
411
|
+
"CRITICAL: Never use MEDIA: for non-image files. PDF must always use FILE:, never MEDIA:.",
|
|
412
|
+
"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
413
|
"Each directive MUST be on its own line with no other text on that line.",
|
|
289
414
|
"The plugin will automatically send the media to the user.",
|
|
290
|
-
]
|
|
415
|
+
];
|
|
416
|
+
|
|
417
|
+
if (configuredRoots.length > 0) {
|
|
418
|
+
guidance.push(`Additional configured host roots are also allowed: ${configuredRoots.join(", ")}`);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
guidance.push("Never reference any other host path.");
|
|
422
|
+
return guidance.join("\n");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function normalizeReplyMediaUrlForLoad(mediaUrl, config, agentId) {
|
|
426
|
+
let normalized = String(mediaUrl ?? "").trim();
|
|
427
|
+
if (!normalized) {
|
|
428
|
+
return "";
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
if (/^file:\/\//i.test(normalized)) {
|
|
432
|
+
try {
|
|
433
|
+
const parsed = new URL(normalized);
|
|
434
|
+
if (parsed.protocol === "file:") {
|
|
435
|
+
normalized = fileURLToPath(parsed);
|
|
436
|
+
}
|
|
437
|
+
} catch {
|
|
438
|
+
return normalized;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (/^sandbox:\/{0,2}/i.test(normalized)) {
|
|
443
|
+
normalized = normalized.replace(/^sandbox:\/{0,2}/i, "/");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (normalized === "/workspace" || normalized.startsWith("/workspace/")) {
|
|
447
|
+
const workspaceDir = resolveAgentWorkspaceDir(config, agentId || resolveDefaultAgentId(config));
|
|
448
|
+
const rel = normalized === "/workspace" ? "" : normalized.slice("/workspace/".length);
|
|
449
|
+
const resolved = rel
|
|
450
|
+
? path.resolve(workspaceDir, ...rel.split("/").filter(Boolean))
|
|
451
|
+
: path.resolve(workspaceDir);
|
|
452
|
+
// Prevent path traversal outside workspace directory
|
|
453
|
+
const normalizedWorkspace = path.resolve(workspaceDir) + path.sep;
|
|
454
|
+
if (resolved !== path.resolve(workspaceDir) && !resolved.startsWith(normalizedWorkspace)) {
|
|
455
|
+
logger.warn(`[WS] Blocked path traversal attempt: ${mediaUrl} resolved to ${resolved}`);
|
|
456
|
+
return "";
|
|
457
|
+
}
|
|
458
|
+
return resolved;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return normalized;
|
|
291
462
|
}
|
|
292
463
|
|
|
293
464
|
function buildBodyForAgent(body, config, agentId) {
|
|
@@ -356,205 +527,105 @@ function applyAccountNetworkConfig(account) {
|
|
|
356
527
|
setApiBaseUrl(network.apiBaseUrl ?? "");
|
|
357
528
|
}
|
|
358
529
|
|
|
359
|
-
function
|
|
360
|
-
|
|
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";
|
|
530
|
+
function stripThinkTags(text) {
|
|
531
|
+
return String(text ?? "").replace(/<think>[\s\S]*?<\/think>/g, "").trim();
|
|
400
532
|
}
|
|
401
533
|
|
|
402
|
-
async function
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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,
|
|
534
|
+
async function sendMediaBatch({ wsClient, frame, state, account, runtime, config, agentId }) {
|
|
535
|
+
const body = frame?.body ?? {};
|
|
536
|
+
const chatId = body.chatid || body.from?.userid;
|
|
537
|
+
const mediaLocalRoots = resolveReplyMediaLocalRoots(config, agentId);
|
|
538
|
+
|
|
539
|
+
for (const mediaUrl of state.pendingMediaUrls) {
|
|
540
|
+
const normalizedUrl = normalizeReplyMediaUrlForLoad(mediaUrl, config, agentId);
|
|
541
|
+
if (!normalizedUrl) {
|
|
542
|
+
state.hasMediaFailed = true;
|
|
543
|
+
logger.error(`[WS] Media send failed: url=${mediaUrl}, reason=invalid_local_path`);
|
|
544
|
+
const summary = buildMediaErrorSummary(mediaUrl, {
|
|
545
|
+
ok: false,
|
|
546
|
+
rejectReason: "invalid_local_path",
|
|
547
|
+
error: "reply media path resolved outside allowed roots",
|
|
426
548
|
});
|
|
427
|
-
|
|
549
|
+
state.mediaErrorSummary = state.mediaErrorSummary
|
|
550
|
+
? `${state.mediaErrorSummary}\n\n${summary}`
|
|
551
|
+
: summary;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
const result = await uploadAndSendMedia({
|
|
555
|
+
wsClient,
|
|
556
|
+
mediaUrl: normalizedUrl,
|
|
557
|
+
chatId,
|
|
558
|
+
mediaLocalRoots,
|
|
559
|
+
includeDefaultMediaLocalRoots: false,
|
|
560
|
+
log: (...args) => logger.info(...args),
|
|
561
|
+
errorLog: (...args) => logger.error(...args),
|
|
562
|
+
});
|
|
428
563
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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;
|
|
564
|
+
if (result.ok) {
|
|
565
|
+
state.hasMedia = true;
|
|
566
|
+
if (result.downgraded) {
|
|
567
|
+
logger.info(`[WS] Media downgraded: ${result.downgradeNote}`);
|
|
451
568
|
}
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
} catch (error) {
|
|
460
|
-
logger.error(`[WS] Failed to prepare reply media ${mediaUrl}: ${error.message}`);
|
|
569
|
+
} else {
|
|
570
|
+
state.hasMediaFailed = true;
|
|
571
|
+
logger.error(`[WS] Media send failed: url=${mediaUrl}, reason=${result.rejectReason || result.error}`);
|
|
572
|
+
const summary = buildMediaErrorSummary(mediaUrl, result);
|
|
573
|
+
state.mediaErrorSummary = state.mediaErrorSummary
|
|
574
|
+
? `${state.mediaErrorSummary}\n\n${summary}`
|
|
575
|
+
: summary;
|
|
461
576
|
}
|
|
462
577
|
}
|
|
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不支持直接发送媒体,且当前未配置自建应用发送渠道。";
|
|
578
|
+
state.pendingMediaUrls = [];
|
|
485
579
|
}
|
|
486
580
|
|
|
487
|
-
function
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
}
|
|
491
|
-
|
|
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);
|
|
581
|
+
async function finishThinkingStream({ wsClient, frame, state, accountId }) {
|
|
582
|
+
const visibleText = stripThinkTags(state.accumulatedText);
|
|
583
|
+
let finishText;
|
|
519
584
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
}
|
|
525
|
-
if (includeNotice) {
|
|
526
|
-
const noticeText = buildPassiveMediaNoticeBlock(mediaEntries);
|
|
527
|
-
if (noticeText) {
|
|
528
|
-
noticeParts.push(noticeText);
|
|
585
|
+
if (visibleText) {
|
|
586
|
+
let finalVisibleText = state.accumulatedText;
|
|
587
|
+
if (state.hasMediaFailed && state.mediaErrorSummary) {
|
|
588
|
+
finalVisibleText += `\n\n${state.mediaErrorSummary}`;
|
|
529
589
|
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
...target,
|
|
535
|
-
text: noticeParts.join("\n\n"),
|
|
536
|
-
});
|
|
537
|
-
}
|
|
538
|
-
|
|
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,
|
|
590
|
+
finishText = buildWsStreamContent({
|
|
591
|
+
reasoningText: state.reasoningText,
|
|
592
|
+
visibleText: finalVisibleText,
|
|
593
|
+
finish: true,
|
|
545
594
|
});
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
595
|
+
} else if (state.reasoningText) {
|
|
596
|
+
// If the model only emitted reasoning tokens, close the thinking stream
|
|
597
|
+
// instead of replacing it with a generic completion stub.
|
|
598
|
+
finishText = buildWsStreamContent({
|
|
599
|
+
reasoningText: state.reasoningText,
|
|
600
|
+
visibleText: "",
|
|
601
|
+
finish: true,
|
|
551
602
|
});
|
|
603
|
+
} else if (state.hasMedia) {
|
|
604
|
+
finishText = "文件已发送,请查收。";
|
|
605
|
+
} else if (state.hasMediaFailed && state.mediaErrorSummary) {
|
|
606
|
+
finishText = state.mediaErrorSummary;
|
|
607
|
+
} else {
|
|
608
|
+
finishText = "处理完成。";
|
|
552
609
|
}
|
|
610
|
+
|
|
611
|
+
await sendWsReply({
|
|
612
|
+
wsClient,
|
|
613
|
+
frame,
|
|
614
|
+
streamId: state.streamId,
|
|
615
|
+
text: finishText,
|
|
616
|
+
finish: true,
|
|
617
|
+
accountId,
|
|
618
|
+
});
|
|
553
619
|
}
|
|
554
620
|
|
|
555
621
|
function resolveWelcomeMessage(account) {
|
|
556
622
|
const configured = String(account?.config?.welcomeMessage ?? "").trim();
|
|
557
|
-
|
|
623
|
+
if (configured) {
|
|
624
|
+
return configured;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const index = Math.floor(Math.random() * DEFAULT_WELCOME_MESSAGES.length);
|
|
628
|
+
return DEFAULT_WELCOME_MESSAGES[index] || DEFAULT_WELCOME_MESSAGE;
|
|
558
629
|
}
|
|
559
630
|
|
|
560
631
|
function collectMixedMessageItems({ mixed, textParts, imageUrls, imageAesKeys }) {
|
|
@@ -808,13 +879,13 @@ async function flushPendingRepliesViaAgentApi(account) {
|
|
|
808
879
|
}
|
|
809
880
|
}
|
|
810
881
|
|
|
811
|
-
async function sendThinkingReply({ wsClient, frame, streamId }) {
|
|
882
|
+
async function sendThinkingReply({ wsClient, frame, streamId, text = THINKING_MESSAGE }) {
|
|
812
883
|
try {
|
|
813
884
|
await sendWsReply({
|
|
814
885
|
wsClient,
|
|
815
886
|
frame,
|
|
816
887
|
streamId,
|
|
817
|
-
text
|
|
888
|
+
text,
|
|
818
889
|
finish: false,
|
|
819
890
|
});
|
|
820
891
|
} catch (error) {
|
|
@@ -872,6 +943,7 @@ function buildInboundContext({
|
|
|
872
943
|
SenderName: senderId,
|
|
873
944
|
GroupId: isGroupChat ? chatId : undefined,
|
|
874
945
|
Timestamp: Date.now(),
|
|
946
|
+
SourceTimestamp: normalizeWecomCreateTimeMs(body?.create_time) || undefined,
|
|
875
947
|
Provider: CHANNEL_ID,
|
|
876
948
|
Surface: CHANNEL_ID,
|
|
877
949
|
OriginatingChannel: CHANNEL_ID,
|
|
@@ -898,7 +970,7 @@ function buildInboundContext({
|
|
|
898
970
|
return { ctxPayload: core.reply.finalizeInboundContext(context), storePath };
|
|
899
971
|
}
|
|
900
972
|
|
|
901
|
-
async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
973
|
+
async function processWsMessage({ frame, account, config, runtime, wsClient, reqIdStore }) {
|
|
902
974
|
const core = resolveChannelCore(runtime);
|
|
903
975
|
const body = frame?.body ?? {};
|
|
904
976
|
const senderId = body?.from?.userid;
|
|
@@ -927,6 +999,27 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
927
999
|
return;
|
|
928
1000
|
}
|
|
929
1001
|
|
|
1002
|
+
const perfStartedAt = Date.now();
|
|
1003
|
+
const sourceTiming = getWecomSourceTiming(body?.create_time, perfStartedAt);
|
|
1004
|
+
const perfState = {
|
|
1005
|
+
firstReasoningReceivedAt: 0,
|
|
1006
|
+
firstReasoningForwardedAt: 0,
|
|
1007
|
+
firstVisibleReceivedAt: 0,
|
|
1008
|
+
firstVisibleForwardedAt: 0,
|
|
1009
|
+
thinkingSentAt: 0,
|
|
1010
|
+
finalReplySentAt: 0,
|
|
1011
|
+
};
|
|
1012
|
+
const logPerf = (stage, extra = {}) => {
|
|
1013
|
+
logger.info(`[WSPERF:${account.accountId}] ${stage}`, {
|
|
1014
|
+
messageId,
|
|
1015
|
+
senderId,
|
|
1016
|
+
chatId,
|
|
1017
|
+
isGroupChat,
|
|
1018
|
+
elapsedMs: Date.now() - perfStartedAt,
|
|
1019
|
+
...extra,
|
|
1020
|
+
});
|
|
1021
|
+
};
|
|
1022
|
+
|
|
930
1023
|
recordInboundMessage({ accountId: account.accountId, chatId });
|
|
931
1024
|
|
|
932
1025
|
const { textParts, imageUrls, imageAesKeys, fileUrls, fileAesKeys, quoteContent } = parseMessageContent(body);
|
|
@@ -938,11 +1031,13 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
938
1031
|
chatId,
|
|
939
1032
|
isGroupChat,
|
|
940
1033
|
messageId,
|
|
1034
|
+
...sourceTiming,
|
|
941
1035
|
textLength: originalText.length,
|
|
942
1036
|
imageCount: imageUrls.length,
|
|
943
1037
|
fileCount: fileUrls.length,
|
|
944
1038
|
preview: originalText.slice(0, 80) || (imageUrls.length ? "[image]" : fileUrls.length ? "[file]" : ""),
|
|
945
1039
|
});
|
|
1040
|
+
logPerf("inbound", sourceTiming);
|
|
946
1041
|
|
|
947
1042
|
if (!text && quoteContent) {
|
|
948
1043
|
text = quoteContent;
|
|
@@ -1002,6 +1097,14 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1002
1097
|
return;
|
|
1003
1098
|
}
|
|
1004
1099
|
text = extractGroupMessageContent(originalText, account.config);
|
|
1100
|
+
if (!text.trim() && imageUrls.length === 0 && fileUrls.length === 0) {
|
|
1101
|
+
logger.debug("[WS] Group message mention stripped to empty content; skipping reply", {
|
|
1102
|
+
accountId: account.accountId,
|
|
1103
|
+
chatId,
|
|
1104
|
+
senderId,
|
|
1105
|
+
});
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1005
1108
|
}
|
|
1006
1109
|
|
|
1007
1110
|
const senderIsAdmin = isWecomAdmin(senderId, account.config);
|
|
@@ -1042,9 +1145,25 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1042
1145
|
}),
|
|
1043
1146
|
]);
|
|
1044
1147
|
const mediaList = [...imageMediaList, ...fileMediaList];
|
|
1148
|
+
logPerf("media_ready", {
|
|
1149
|
+
imageCount: imageMediaList.length,
|
|
1150
|
+
fileCount: fileMediaList.length,
|
|
1151
|
+
});
|
|
1045
1152
|
|
|
1046
|
-
const streamId = generateReqId("stream");
|
|
1047
|
-
|
|
1153
|
+
const streamId = reqIdStore?.getSync(chatId) ?? generateReqId("stream");
|
|
1154
|
+
if (reqIdStore) reqIdStore.set(chatId, streamId);
|
|
1155
|
+
const state = {
|
|
1156
|
+
accumulatedText: "",
|
|
1157
|
+
reasoningText: "",
|
|
1158
|
+
streamId,
|
|
1159
|
+
streamCreatedAt: Date.now(),
|
|
1160
|
+
replyMediaUrls: [],
|
|
1161
|
+
pendingMediaUrls: [],
|
|
1162
|
+
hasMedia: false,
|
|
1163
|
+
hasMediaFailed: false,
|
|
1164
|
+
mediaErrorSummary: "",
|
|
1165
|
+
deliverCalled: false,
|
|
1166
|
+
};
|
|
1048
1167
|
setMessageState(messageId, state);
|
|
1049
1168
|
|
|
1050
1169
|
// Throttle reasoning and visible text stream updates to avoid exceeding
|
|
@@ -1054,26 +1173,96 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1054
1173
|
let pendingReasoningTimer = null;
|
|
1055
1174
|
let lastVisibleSendAt = 0;
|
|
1056
1175
|
let pendingVisibleTimer = null;
|
|
1176
|
+
let lastStreamSentAt = 0;
|
|
1177
|
+
let lastNonEmptyStreamText = "";
|
|
1178
|
+
let lastForwardedVisibleText = "";
|
|
1179
|
+
let keepaliveTimer = null;
|
|
1180
|
+
let rotationTimer = null;
|
|
1181
|
+
let waitingModelTimer = null;
|
|
1182
|
+
let waitingModelSeconds = 0;
|
|
1183
|
+
let waitingModelActive = false;
|
|
1057
1184
|
|
|
1058
1185
|
const canSendIntermediate = () => streamMessagesSent < MAX_INTERMEDIATE_STREAM_MESSAGES;
|
|
1059
1186
|
|
|
1187
|
+
const stopWaitingModelUpdates = () => {
|
|
1188
|
+
waitingModelActive = false;
|
|
1189
|
+
if (waitingModelTimer) {
|
|
1190
|
+
clearTimeout(waitingModelTimer);
|
|
1191
|
+
waitingModelTimer = null;
|
|
1192
|
+
}
|
|
1193
|
+
};
|
|
1194
|
+
|
|
1195
|
+
const sendWaitingModelUpdate = async (seconds) => {
|
|
1196
|
+
const waitingText = buildWaitingModelContent(seconds);
|
|
1197
|
+
lastStreamSentAt = Date.now();
|
|
1198
|
+
lastNonEmptyStreamText = waitingText;
|
|
1199
|
+
try {
|
|
1200
|
+
streamMessagesSent++;
|
|
1201
|
+
await sendWsReply({
|
|
1202
|
+
wsClient,
|
|
1203
|
+
frame,
|
|
1204
|
+
streamId: state.streamId,
|
|
1205
|
+
text: waitingText,
|
|
1206
|
+
finish: false,
|
|
1207
|
+
accountId: account.accountId,
|
|
1208
|
+
});
|
|
1209
|
+
logPerf("waiting_model_forwarded", {
|
|
1210
|
+
seconds,
|
|
1211
|
+
streamMessagesSent,
|
|
1212
|
+
chars: waitingText.length,
|
|
1213
|
+
});
|
|
1214
|
+
} catch (error) {
|
|
1215
|
+
logger.warn(`[WS] Waiting-model stream send failed (non-fatal): ${error.message}`);
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1218
|
+
|
|
1219
|
+
const scheduleWaitingModelUpdate = () => {
|
|
1220
|
+
if (!waitingModelActive) {
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
if (waitingModelTimer) {
|
|
1224
|
+
clearTimeout(waitingModelTimer);
|
|
1225
|
+
}
|
|
1226
|
+
waitingModelTimer = setTimeout(async () => {
|
|
1227
|
+
waitingModelTimer = null;
|
|
1228
|
+
if (!waitingModelActive || !canSendIntermediate()) {
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
waitingModelSeconds += 1;
|
|
1232
|
+
await sendWaitingModelUpdate(waitingModelSeconds);
|
|
1233
|
+
scheduleWaitingModelUpdate();
|
|
1234
|
+
}, WAITING_MODEL_TICK_MS);
|
|
1235
|
+
};
|
|
1236
|
+
|
|
1060
1237
|
const sendReasoningUpdate = async () => {
|
|
1061
1238
|
if (!canSendIntermediate()) return;
|
|
1062
1239
|
lastReasoningSendAt = Date.now();
|
|
1240
|
+
lastStreamSentAt = lastReasoningSendAt;
|
|
1241
|
+
const streamText = buildWsStreamContent({
|
|
1242
|
+
reasoningText: state.reasoningText,
|
|
1243
|
+
visibleText: state.accumulatedText,
|
|
1244
|
+
finish: false,
|
|
1245
|
+
});
|
|
1246
|
+
if (streamText) {
|
|
1247
|
+
lastNonEmptyStreamText = streamText;
|
|
1248
|
+
}
|
|
1063
1249
|
try {
|
|
1064
1250
|
streamMessagesSent++;
|
|
1065
1251
|
await sendWsReply({
|
|
1066
1252
|
wsClient,
|
|
1067
1253
|
frame,
|
|
1068
1254
|
streamId: state.streamId,
|
|
1069
|
-
text:
|
|
1070
|
-
reasoningText: state.reasoningText,
|
|
1071
|
-
visibleText: state.accumulatedText,
|
|
1072
|
-
finish: false,
|
|
1073
|
-
}),
|
|
1255
|
+
text: streamText,
|
|
1074
1256
|
finish: false,
|
|
1075
1257
|
accountId: account.accountId,
|
|
1076
1258
|
});
|
|
1259
|
+
if (!perfState.firstReasoningForwardedAt) {
|
|
1260
|
+
perfState.firstReasoningForwardedAt = Date.now();
|
|
1261
|
+
logPerf("first_reasoning_forwarded", {
|
|
1262
|
+
streamMessagesSent,
|
|
1263
|
+
chars: streamText.length,
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1077
1266
|
} catch (error) {
|
|
1078
1267
|
logger.warn(`[WS] Reasoning stream send failed (non-fatal): ${error.message}`);
|
|
1079
1268
|
}
|
|
@@ -1082,25 +1271,230 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1082
1271
|
const sendVisibleUpdate = async () => {
|
|
1083
1272
|
if (!canSendIntermediate()) return;
|
|
1084
1273
|
lastVisibleSendAt = Date.now();
|
|
1274
|
+
lastStreamSentAt = lastVisibleSendAt;
|
|
1275
|
+
const visibleText = state.accumulatedText;
|
|
1276
|
+
const streamText = buildWsStreamContent({
|
|
1277
|
+
reasoningText: state.reasoningText,
|
|
1278
|
+
visibleText,
|
|
1279
|
+
finish: false,
|
|
1280
|
+
});
|
|
1281
|
+
if (streamText) {
|
|
1282
|
+
lastNonEmptyStreamText = streamText;
|
|
1283
|
+
}
|
|
1284
|
+
lastForwardedVisibleText = visibleText;
|
|
1085
1285
|
try {
|
|
1086
1286
|
streamMessagesSent++;
|
|
1087
1287
|
await sendWsReply({
|
|
1088
1288
|
wsClient,
|
|
1089
1289
|
frame,
|
|
1090
1290
|
streamId: state.streamId,
|
|
1091
|
-
text:
|
|
1092
|
-
reasoningText: state.reasoningText,
|
|
1093
|
-
visibleText: state.accumulatedText,
|
|
1094
|
-
finish: false,
|
|
1095
|
-
}),
|
|
1291
|
+
text: streamText,
|
|
1096
1292
|
finish: false,
|
|
1097
1293
|
accountId: account.accountId,
|
|
1098
1294
|
});
|
|
1295
|
+
if (!perfState.firstVisibleForwardedAt) {
|
|
1296
|
+
perfState.firstVisibleForwardedAt = Date.now();
|
|
1297
|
+
logPerf("first_visible_forwarded", {
|
|
1298
|
+
streamMessagesSent,
|
|
1299
|
+
chars: streamText.length,
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1099
1302
|
} catch (error) {
|
|
1100
1303
|
logger.warn(`[WS] Visible stream send failed (non-fatal): ${error.message}`);
|
|
1101
1304
|
}
|
|
1102
1305
|
};
|
|
1103
1306
|
|
|
1307
|
+
const flushPendingStreamUpdates = async () => {
|
|
1308
|
+
const hadPendingReasoning = Boolean(pendingReasoningTimer);
|
|
1309
|
+
const hadPendingVisible = Boolean(pendingVisibleTimer);
|
|
1310
|
+
|
|
1311
|
+
if (pendingReasoningTimer) {
|
|
1312
|
+
clearTimeout(pendingReasoningTimer);
|
|
1313
|
+
pendingReasoningTimer = null;
|
|
1314
|
+
}
|
|
1315
|
+
if (pendingVisibleTimer) {
|
|
1316
|
+
clearTimeout(pendingVisibleTimer);
|
|
1317
|
+
pendingVisibleTimer = null;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
if (hadPendingReasoning) {
|
|
1321
|
+
const visibleText = hadPendingVisible ? state.accumulatedText : lastForwardedVisibleText;
|
|
1322
|
+
const streamText = buildWsStreamContent({
|
|
1323
|
+
reasoningText: state.reasoningText,
|
|
1324
|
+
visibleText,
|
|
1325
|
+
finish: false,
|
|
1326
|
+
});
|
|
1327
|
+
if (!streamText || streamText === lastNonEmptyStreamText) {
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
lastReasoningSendAt = Date.now();
|
|
1331
|
+
lastStreamSentAt = lastReasoningSendAt;
|
|
1332
|
+
lastNonEmptyStreamText = streamText;
|
|
1333
|
+
if (hadPendingVisible) {
|
|
1334
|
+
lastForwardedVisibleText = visibleText;
|
|
1335
|
+
}
|
|
1336
|
+
try {
|
|
1337
|
+
streamMessagesSent++;
|
|
1338
|
+
await sendWsReply({
|
|
1339
|
+
wsClient,
|
|
1340
|
+
frame,
|
|
1341
|
+
streamId: state.streamId,
|
|
1342
|
+
text: streamText,
|
|
1343
|
+
finish: false,
|
|
1344
|
+
accountId: account.accountId,
|
|
1345
|
+
});
|
|
1346
|
+
if (!perfState.firstReasoningForwardedAt) {
|
|
1347
|
+
perfState.firstReasoningForwardedAt = Date.now();
|
|
1348
|
+
logPerf("first_reasoning_forwarded", {
|
|
1349
|
+
streamMessagesSent,
|
|
1350
|
+
chars: streamText.length,
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
} catch (error) {
|
|
1354
|
+
logger.warn(`[WS] Reasoning stream send failed (non-fatal): ${error.message}`);
|
|
1355
|
+
}
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
if (hadPendingVisible) {
|
|
1359
|
+
await sendVisibleUpdate();
|
|
1360
|
+
}
|
|
1361
|
+
};
|
|
1362
|
+
|
|
1363
|
+
const scheduleKeepalive = () => {
|
|
1364
|
+
if (keepaliveTimer) clearTimeout(keepaliveTimer);
|
|
1365
|
+
keepaliveTimer = setTimeout(async () => {
|
|
1366
|
+
keepaliveTimer = null;
|
|
1367
|
+
if (!canSendIntermediate()) return;
|
|
1368
|
+
const idle = Date.now() - lastStreamSentAt;
|
|
1369
|
+
if (idle < STREAM_KEEPALIVE_INTERVAL_MS) {
|
|
1370
|
+
// A real update was sent recently; wait remaining time then send immediately.
|
|
1371
|
+
const remaining = STREAM_KEEPALIVE_INTERVAL_MS - idle;
|
|
1372
|
+
keepaliveTimer = setTimeout(async () => {
|
|
1373
|
+
keepaliveTimer = null;
|
|
1374
|
+
if (!canSendIntermediate()) return;
|
|
1375
|
+
logger.debug(`[WS] Sending stream keepalive after deferred wait (idle ${Math.round((Date.now() - lastStreamSentAt) / 1000)}s)`);
|
|
1376
|
+
lastStreamSentAt = Date.now();
|
|
1377
|
+
const keepaliveText = resolveWsKeepaliveContent({
|
|
1378
|
+
reasoningText: state.reasoningText,
|
|
1379
|
+
visibleText: state.accumulatedText,
|
|
1380
|
+
lastStreamText: lastNonEmptyStreamText,
|
|
1381
|
+
});
|
|
1382
|
+
if (keepaliveText) {
|
|
1383
|
+
lastNonEmptyStreamText = keepaliveText;
|
|
1384
|
+
}
|
|
1385
|
+
try {
|
|
1386
|
+
streamMessagesSent++;
|
|
1387
|
+
await sendWsReply({
|
|
1388
|
+
wsClient,
|
|
1389
|
+
frame,
|
|
1390
|
+
streamId: state.streamId,
|
|
1391
|
+
text: keepaliveText,
|
|
1392
|
+
finish: false,
|
|
1393
|
+
accountId: account.accountId,
|
|
1394
|
+
});
|
|
1395
|
+
} catch (err) {
|
|
1396
|
+
logger.warn(`[WS] Keepalive send failed (non-fatal): ${err.message}`);
|
|
1397
|
+
}
|
|
1398
|
+
scheduleKeepalive();
|
|
1399
|
+
}, remaining);
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
logger.debug(`[WS] Sending stream keepalive (idle ${Math.round(idle / 1000)}s)`);
|
|
1403
|
+
lastStreamSentAt = Date.now();
|
|
1404
|
+
const keepaliveText = resolveWsKeepaliveContent({
|
|
1405
|
+
reasoningText: state.reasoningText,
|
|
1406
|
+
visibleText: state.accumulatedText,
|
|
1407
|
+
lastStreamText: lastNonEmptyStreamText,
|
|
1408
|
+
});
|
|
1409
|
+
if (keepaliveText) {
|
|
1410
|
+
lastNonEmptyStreamText = keepaliveText;
|
|
1411
|
+
}
|
|
1412
|
+
try {
|
|
1413
|
+
streamMessagesSent++;
|
|
1414
|
+
await sendWsReply({
|
|
1415
|
+
wsClient,
|
|
1416
|
+
frame,
|
|
1417
|
+
streamId: state.streamId,
|
|
1418
|
+
text: keepaliveText,
|
|
1419
|
+
finish: false,
|
|
1420
|
+
accountId: account.accountId,
|
|
1421
|
+
});
|
|
1422
|
+
} catch (error) {
|
|
1423
|
+
logger.warn(`[WS] Stream keepalive send failed (non-fatal): ${error.message}`);
|
|
1424
|
+
}
|
|
1425
|
+
scheduleKeepalive();
|
|
1426
|
+
}, STREAM_KEEPALIVE_INTERVAL_MS);
|
|
1427
|
+
};
|
|
1428
|
+
|
|
1429
|
+
// --- Stream rotation: finish the current stream before the 6-minute hard
|
|
1430
|
+
// limit and seamlessly continue on a new streamId. ----
|
|
1431
|
+
|
|
1432
|
+
const rotateStream = async () => {
|
|
1433
|
+
// Flush any pending throttled updates to the current stream first.
|
|
1434
|
+
await flushPendingStreamUpdates();
|
|
1435
|
+
|
|
1436
|
+
const oldStreamId = state.streamId;
|
|
1437
|
+
const hasVisibleText = Boolean(stripThinkTags(state.accumulatedText));
|
|
1438
|
+
|
|
1439
|
+
// Build finish content. When still in the thinking phase (no visible
|
|
1440
|
+
// text yet), append a small marker so the finished message is not empty.
|
|
1441
|
+
const finishText = buildWsStreamContent({
|
|
1442
|
+
reasoningText: state.reasoningText,
|
|
1443
|
+
visibleText: hasVisibleText ? state.accumulatedText : "⏳ 处理中…",
|
|
1444
|
+
finish: true,
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
try {
|
|
1448
|
+
streamMessagesSent++;
|
|
1449
|
+
await sendWsReply({
|
|
1450
|
+
wsClient,
|
|
1451
|
+
frame,
|
|
1452
|
+
streamId: oldStreamId,
|
|
1453
|
+
text: finishText,
|
|
1454
|
+
finish: true,
|
|
1455
|
+
accountId: account.accountId,
|
|
1456
|
+
});
|
|
1457
|
+
} catch (err) {
|
|
1458
|
+
logger.warn(`[WS] Stream rotation: failed to finish old stream: ${err.message}`);
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// Switch to new stream.
|
|
1462
|
+
const newStreamId = generateReqId("stream");
|
|
1463
|
+
state.streamId = newStreamId;
|
|
1464
|
+
state.streamCreatedAt = Date.now();
|
|
1465
|
+
state.accumulatedText = "";
|
|
1466
|
+
state.reasoningText = "";
|
|
1467
|
+
|
|
1468
|
+
// Reset per-stream counters.
|
|
1469
|
+
streamMessagesSent = 0;
|
|
1470
|
+
lastStreamSentAt = Date.now();
|
|
1471
|
+
lastReasoningSendAt = 0;
|
|
1472
|
+
lastVisibleSendAt = 0;
|
|
1473
|
+
lastNonEmptyStreamText = "";
|
|
1474
|
+
lastForwardedVisibleText = "";
|
|
1475
|
+
|
|
1476
|
+
if (reqIdStore) reqIdStore.set(chatId, newStreamId);
|
|
1477
|
+
|
|
1478
|
+
logPerf("stream_rotated", { oldStreamId, newStreamId });
|
|
1479
|
+
|
|
1480
|
+
// Re-arm timers for the new stream.
|
|
1481
|
+
scheduleRotation();
|
|
1482
|
+
scheduleKeepalive();
|
|
1483
|
+
};
|
|
1484
|
+
|
|
1485
|
+
const scheduleRotation = () => {
|
|
1486
|
+
if (rotationTimer) clearTimeout(rotationTimer);
|
|
1487
|
+
const remaining = STREAM_MAX_LIFETIME_MS - (Date.now() - state.streamCreatedAt);
|
|
1488
|
+
if (remaining <= 0) {
|
|
1489
|
+
void rotateStream();
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
rotationTimer = setTimeout(() => {
|
|
1493
|
+
rotationTimer = null;
|
|
1494
|
+
void rotateStream();
|
|
1495
|
+
}, remaining);
|
|
1496
|
+
};
|
|
1497
|
+
|
|
1104
1498
|
const cancelPendingTimers = () => {
|
|
1105
1499
|
if (pendingReasoningTimer) {
|
|
1106
1500
|
clearTimeout(pendingReasoningTimer);
|
|
@@ -1110,6 +1504,15 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1110
1504
|
clearTimeout(pendingVisibleTimer);
|
|
1111
1505
|
pendingVisibleTimer = null;
|
|
1112
1506
|
}
|
|
1507
|
+
if (keepaliveTimer) {
|
|
1508
|
+
clearTimeout(keepaliveTimer);
|
|
1509
|
+
keepaliveTimer = null;
|
|
1510
|
+
}
|
|
1511
|
+
if (rotationTimer) {
|
|
1512
|
+
clearTimeout(rotationTimer);
|
|
1513
|
+
rotationTimer = null;
|
|
1514
|
+
}
|
|
1515
|
+
stopWaitingModelUpdates();
|
|
1113
1516
|
};
|
|
1114
1517
|
|
|
1115
1518
|
const cleanupState = () => {
|
|
@@ -1118,8 +1521,22 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1118
1521
|
};
|
|
1119
1522
|
|
|
1120
1523
|
if (account.sendThinkingMessage !== false) {
|
|
1121
|
-
|
|
1524
|
+
waitingModelActive = true;
|
|
1525
|
+
waitingModelSeconds = 1;
|
|
1526
|
+
await sendThinkingReply({
|
|
1527
|
+
wsClient,
|
|
1528
|
+
frame,
|
|
1529
|
+
streamId,
|
|
1530
|
+
text: buildWaitingModelContent(waitingModelSeconds),
|
|
1531
|
+
});
|
|
1532
|
+
lastNonEmptyStreamText = buildWaitingModelContent(waitingModelSeconds);
|
|
1533
|
+
perfState.thinkingSentAt = Date.now();
|
|
1534
|
+
logPerf("thinking_sent", { streamId });
|
|
1535
|
+
scheduleWaitingModelUpdate();
|
|
1122
1536
|
}
|
|
1537
|
+
lastStreamSentAt = Date.now();
|
|
1538
|
+
scheduleKeepalive();
|
|
1539
|
+
scheduleRotation();
|
|
1123
1540
|
|
|
1124
1541
|
const peerKind = isGroupChat ? "group" : "dm";
|
|
1125
1542
|
const peerId = isGroupChat ? chatId : senderId;
|
|
@@ -1167,13 +1584,13 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1167
1584
|
});
|
|
1168
1585
|
ctxPayload.CommandAuthorized = commandAuthorized;
|
|
1169
1586
|
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1587
|
+
await ensureDefaultSessionReasoningLevel({
|
|
1588
|
+
core,
|
|
1589
|
+
storePath,
|
|
1590
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
1591
|
+
ctx: ctxPayload,
|
|
1592
|
+
channelTag: "WS",
|
|
1593
|
+
});
|
|
1177
1594
|
|
|
1178
1595
|
const runDispatch = async () => {
|
|
1179
1596
|
let cleanedUp = false;
|
|
@@ -1186,6 +1603,12 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1186
1603
|
};
|
|
1187
1604
|
|
|
1188
1605
|
try {
|
|
1606
|
+
logPerf("dispatch_start", {
|
|
1607
|
+
routeAgentId: route.agentId,
|
|
1608
|
+
sessionKey: route.sessionKey,
|
|
1609
|
+
mediaCount: mediaList.length,
|
|
1610
|
+
...sourceTiming,
|
|
1611
|
+
});
|
|
1189
1612
|
await streamContext.run(
|
|
1190
1613
|
{ streamId, streamKey: peerId, agentId: route.agentId, accountId: account.accountId },
|
|
1191
1614
|
async () => {
|
|
@@ -1199,7 +1622,14 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1199
1622
|
if (!nextReasoning) {
|
|
1200
1623
|
return;
|
|
1201
1624
|
}
|
|
1625
|
+
stopWaitingModelUpdates();
|
|
1202
1626
|
state.reasoningText = nextReasoning;
|
|
1627
|
+
if (!perfState.firstReasoningReceivedAt) {
|
|
1628
|
+
perfState.firstReasoningReceivedAt = Date.now();
|
|
1629
|
+
logPerf("first_reasoning_received", {
|
|
1630
|
+
chars: nextReasoning.length,
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1203
1633
|
|
|
1204
1634
|
// Throttle: skip if sent recently, schedule a trailing update instead.
|
|
1205
1635
|
const elapsed = Date.now() - lastReasoningSendAt;
|
|
@@ -1217,30 +1647,62 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1217
1647
|
},
|
|
1218
1648
|
dispatcherOptions: {
|
|
1219
1649
|
deliver: async (payload, info) => {
|
|
1650
|
+
state.deliverCalled = true;
|
|
1220
1651
|
const normalized = normalizeReplyPayload(payload);
|
|
1221
1652
|
const chunk = normalized.text;
|
|
1222
1653
|
const mediaUrls = normalized.mediaUrls;
|
|
1654
|
+
|
|
1655
|
+
if (chunk) {
|
|
1656
|
+
stopWaitingModelUpdates();
|
|
1657
|
+
state.accumulatedText += chunk;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1223
1660
|
for (const mediaUrl of mediaUrls) {
|
|
1224
1661
|
if (!state.replyMediaUrls.includes(mediaUrl)) {
|
|
1225
1662
|
state.replyMediaUrls.push(mediaUrl);
|
|
1663
|
+
state.pendingMediaUrls.push(mediaUrl);
|
|
1226
1664
|
}
|
|
1227
1665
|
}
|
|
1228
1666
|
|
|
1229
|
-
state.
|
|
1667
|
+
if (state.pendingMediaUrls.length > 0) {
|
|
1668
|
+
try {
|
|
1669
|
+
await sendMediaBatch({
|
|
1670
|
+
wsClient, frame, state, account, runtime, config,
|
|
1671
|
+
agentId: route.agentId,
|
|
1672
|
+
});
|
|
1673
|
+
} catch (mediaErr) {
|
|
1674
|
+
state.hasMediaFailed = true;
|
|
1675
|
+
const errMsg = String(mediaErr);
|
|
1676
|
+
const summary = `文件发送失败:内部处理异常,请升级 openclaw 到最新版本后重试。\n错误详情:${errMsg}`;
|
|
1677
|
+
state.mediaErrorSummary = state.mediaErrorSummary
|
|
1678
|
+
? `${state.mediaErrorSummary}\n\n${summary}`
|
|
1679
|
+
: summary;
|
|
1680
|
+
logger.error(`[WS] sendMediaBatch threw: ${errMsg}`);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
if (!perfState.firstVisibleReceivedAt && chunk?.trim()) {
|
|
1685
|
+
perfState.firstVisibleReceivedAt = Date.now();
|
|
1686
|
+
logPerf("first_visible_received", {
|
|
1687
|
+
chars: chunk.length,
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1230
1691
|
if (info.kind !== "final") {
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1692
|
+
const hasText = stripThinkTags(state.accumulatedText);
|
|
1693
|
+
if (hasText) {
|
|
1694
|
+
const elapsed = Date.now() - lastVisibleSendAt;
|
|
1695
|
+
if (elapsed < VISIBLE_STREAM_THROTTLE_MS) {
|
|
1696
|
+
if (!pendingVisibleTimer) {
|
|
1697
|
+
pendingVisibleTimer = setTimeout(async () => {
|
|
1698
|
+
pendingVisibleTimer = null;
|
|
1699
|
+
await sendVisibleUpdate();
|
|
1700
|
+
}, VISIBLE_STREAM_THROTTLE_MS - elapsed);
|
|
1701
|
+
}
|
|
1702
|
+
return;
|
|
1240
1703
|
}
|
|
1241
|
-
|
|
1704
|
+
await sendVisibleUpdate();
|
|
1242
1705
|
}
|
|
1243
|
-
await sendVisibleUpdate();
|
|
1244
1706
|
}
|
|
1245
1707
|
},
|
|
1246
1708
|
onError: (error, info) => {
|
|
@@ -1251,112 +1713,67 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1251
1713
|
},
|
|
1252
1714
|
);
|
|
1253
1715
|
|
|
1716
|
+
// Flush the latest throttled snapshot before finish=true so reasoning
|
|
1717
|
+
// and visible deltas are not collapsed away by the final frame.
|
|
1718
|
+
await flushPendingStreamUpdates();
|
|
1719
|
+
|
|
1254
1720
|
// Cancel pending throttled timers before the final reply to prevent
|
|
1255
1721
|
// non-final updates from being sent after finish=true.
|
|
1256
1722
|
cancelPendingTimers();
|
|
1257
1723
|
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
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", {
|
|
1724
|
+
try {
|
|
1725
|
+
await finishThinkingStream({
|
|
1726
|
+
wsClient,
|
|
1727
|
+
frame,
|
|
1728
|
+
state,
|
|
1286
1729
|
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
1730
|
});
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
accountId: account.accountId,
|
|
1309
|
-
chatId,
|
|
1310
|
-
senderId,
|
|
1311
|
-
});
|
|
1731
|
+
perfState.finalReplySentAt = Date.now();
|
|
1732
|
+
logPerf("final_reply_sent", {
|
|
1733
|
+
textLength: state.accumulatedText.length,
|
|
1734
|
+
hasMedia: state.hasMedia,
|
|
1735
|
+
hasMediaFailed: state.hasMediaFailed,
|
|
1736
|
+
});
|
|
1737
|
+
} catch (sendError) {
|
|
1738
|
+
logger.warn(`[WS] Final reply send failed, enqueuing for retry: ${sendError.message}`, {
|
|
1739
|
+
accountId: account.accountId,
|
|
1740
|
+
chatId,
|
|
1741
|
+
senderId,
|
|
1742
|
+
});
|
|
1743
|
+
if (state.accumulatedText) {
|
|
1312
1744
|
enqueuePendingReply(account.accountId, {
|
|
1313
|
-
text:
|
|
1745
|
+
text: state.accumulatedText,
|
|
1314
1746
|
senderId,
|
|
1315
1747
|
chatId,
|
|
1316
1748
|
isGroupChat,
|
|
1317
1749
|
});
|
|
1318
1750
|
}
|
|
1319
1751
|
}
|
|
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
1752
|
safeCleanup();
|
|
1753
|
+
logPerf("dispatch_complete", {
|
|
1754
|
+
hadReasoning: Boolean(perfState.firstReasoningReceivedAt),
|
|
1755
|
+
hadVisibleText: Boolean(perfState.firstVisibleReceivedAt),
|
|
1756
|
+
totalOutputChars: state.accumulatedText.length,
|
|
1757
|
+
replyMediaCount: state.replyMediaUrls.length,
|
|
1758
|
+
});
|
|
1347
1759
|
} catch (error) {
|
|
1348
1760
|
logger.error(`[WS] Failed to dispatch reply: ${error.message}`);
|
|
1761
|
+
logPerf("dispatch_failed", {
|
|
1762
|
+
error: error.message,
|
|
1763
|
+
});
|
|
1349
1764
|
try {
|
|
1350
|
-
|
|
1765
|
+
// Ensure the user sees an error message, not "处理完成。"
|
|
1766
|
+
if (!stripThinkTags(state.accumulatedText) && !state.hasMedia) {
|
|
1767
|
+
state.accumulatedText = `⚠️ 处理出错:${error.message}`;
|
|
1768
|
+
}
|
|
1769
|
+
await finishThinkingStream({
|
|
1351
1770
|
wsClient,
|
|
1352
1771
|
frame,
|
|
1353
|
-
|
|
1354
|
-
text: "处理消息时出错,请稍后再试。",
|
|
1355
|
-
finish: true,
|
|
1772
|
+
state,
|
|
1356
1773
|
accountId: account.accountId,
|
|
1357
1774
|
});
|
|
1358
|
-
} catch (
|
|
1359
|
-
|
|
1775
|
+
} catch (finishErr) {
|
|
1776
|
+
logger.error(`[WS] Failed to finish thinking stream after dispatch error: ${finishErr.message}`);
|
|
1360
1777
|
if (state.accumulatedText) {
|
|
1361
1778
|
enqueuePendingReply(account.accountId, {
|
|
1362
1779
|
text: state.accumulatedText,
|
|
@@ -1371,8 +1788,24 @@ async function processWsMessage({ frame, account, config, runtime, wsClient }) {
|
|
|
1371
1788
|
};
|
|
1372
1789
|
|
|
1373
1790
|
const lockKey = `${account.accountId}:${peerId}`;
|
|
1791
|
+
const queuedAt = Date.now();
|
|
1374
1792
|
const previous = dispatchLocks.get(lockKey) ?? Promise.resolve();
|
|
1375
|
-
const current = previous.then(
|
|
1793
|
+
const current = previous.then(
|
|
1794
|
+
async () => {
|
|
1795
|
+
const queueWaitMs = Date.now() - queuedAt;
|
|
1796
|
+
if (queueWaitMs >= 50) {
|
|
1797
|
+
logPerf("dispatch_lock_acquired", { queueWaitMs });
|
|
1798
|
+
}
|
|
1799
|
+
return await runDispatch();
|
|
1800
|
+
},
|
|
1801
|
+
async () => {
|
|
1802
|
+
const queueWaitMs = Date.now() - queuedAt;
|
|
1803
|
+
if (queueWaitMs >= 50) {
|
|
1804
|
+
logPerf("dispatch_lock_acquired", { queueWaitMs, previousFailed: true });
|
|
1805
|
+
}
|
|
1806
|
+
return await runDispatch();
|
|
1807
|
+
},
|
|
1808
|
+
);
|
|
1376
1809
|
dispatchLocks.set(lockKey, current);
|
|
1377
1810
|
current.finally(() => {
|
|
1378
1811
|
if (dispatchLocks.get(lockKey) === current) {
|
|
@@ -1406,10 +1839,19 @@ export async function startWsMonitor({ account, config, runtime, abortSignal, ws
|
|
|
1406
1839
|
maxReconnectAttempts: WS_MAX_RECONNECT_ATTEMPTS,
|
|
1407
1840
|
});
|
|
1408
1841
|
|
|
1842
|
+
const reqIdStore = createPersistentReqIdStore(account.accountId);
|
|
1843
|
+
await reqIdStore.warmup();
|
|
1844
|
+
|
|
1409
1845
|
return new Promise((resolve, reject) => {
|
|
1410
1846
|
let settled = false;
|
|
1411
1847
|
|
|
1412
1848
|
const cleanup = async () => {
|
|
1849
|
+
try {
|
|
1850
|
+
await reqIdStore.flush();
|
|
1851
|
+
} catch (flushErr) {
|
|
1852
|
+
logger.warn(`[WS:${account.accountId}] Failed to flush reqId store on cleanup: ${flushErr.message}`);
|
|
1853
|
+
}
|
|
1854
|
+
reqIdStore.destroy();
|
|
1413
1855
|
await cleanupWsAccount(account.accountId);
|
|
1414
1856
|
};
|
|
1415
1857
|
|
|
@@ -1451,6 +1893,8 @@ export async function startWsMonitor({ account, config, runtime, abortSignal, ws
|
|
|
1451
1893
|
clearAccountDisplaced(account.accountId);
|
|
1452
1894
|
setWsClient(account.accountId, wsClient);
|
|
1453
1895
|
|
|
1896
|
+
void fetchAndSaveMcpConfig(wsClient, account.accountId, runtime);
|
|
1897
|
+
|
|
1454
1898
|
// Drain pending replies that failed due to prior WS disconnection.
|
|
1455
1899
|
if (account?.agentCredentials && hasPendingReplies(account.accountId)) {
|
|
1456
1900
|
void flushPendingRepliesViaAgentApi(account).catch((flushError) => {
|
|
@@ -1477,7 +1921,7 @@ export async function startWsMonitor({ account, config, runtime, abortSignal, ws
|
|
|
1477
1921
|
wsClient.on("message", async (frame) => {
|
|
1478
1922
|
try {
|
|
1479
1923
|
await withTimeout(
|
|
1480
|
-
processWsMessage({ frame, account, config, runtime, wsClient }),
|
|
1924
|
+
processWsMessage({ frame, account, config, runtime, wsClient, reqIdStore }),
|
|
1481
1925
|
MESSAGE_PROCESS_TIMEOUT_MS,
|
|
1482
1926
|
`Message processing timed out (msgId=${frame?.body?.msgid ?? "unknown"})`,
|
|
1483
1927
|
);
|
|
@@ -1546,14 +1990,20 @@ export async function startWsMonitor({ account, config, runtime, abortSignal, ws
|
|
|
1546
1990
|
}
|
|
1547
1991
|
|
|
1548
1992
|
export const wsMonitorTesting = {
|
|
1993
|
+
buildWsStreamContent,
|
|
1994
|
+
ensureDefaultSessionReasoningLevel,
|
|
1995
|
+
resolveWsKeepaliveContent,
|
|
1549
1996
|
processWsMessage,
|
|
1550
1997
|
parseMessageContent,
|
|
1551
1998
|
splitReplyMediaFromText,
|
|
1552
1999
|
buildBodyForAgent,
|
|
2000
|
+
normalizeReplyMediaUrlForLoad,
|
|
1553
2001
|
flushPendingRepliesViaAgentApi,
|
|
2002
|
+
stripThinkTags,
|
|
2003
|
+
finishThinkingStream,
|
|
1554
2004
|
};
|
|
1555
2005
|
|
|
1556
|
-
export { buildReplyMediaGuidance };
|
|
2006
|
+
export { buildReplyMediaGuidance, ensureDefaultSessionReasoningLevel, normalizeReplyMediaUrlForLoad };
|
|
1557
2007
|
|
|
1558
2008
|
// Shared internals used by callback-inbound.js
|
|
1559
2009
|
export {
|