@jeik/dingtalk-connector 0.8.27 → 0.8.28
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/CHANGELOG.md +49 -0
- package/README.en.md +28 -35
- package/README.md +28 -43
- package/dist/background-work-Cxb4b-91.mjs +42 -0
- package/dist/card-content-cache-c4Ls96eJ.mjs +2 -0
- package/dist/chunk-upload-PwhogwfE.mjs +2 -0
- package/dist/{common-DE0gd0Jg.mjs → common-BZiVgd3A.mjs} +2 -2
- package/dist/{common-B_A78n8u.mjs → common-DgL28gCZ.mjs} +1 -1
- package/dist/{connection-fc6B4z4G.mjs → connection-CZ7ZoN5_.mjs} +313 -86
- package/dist/entry-bundled.mjs +1 -1
- package/dist/gateway-methods-BkI1gKaC.mjs +2 -0
- package/dist/{gateway-methods--aKsyMs-.mjs → gateway-methods-kNX3qvqw.mjs} +1 -1
- package/dist/index.mjs +2 -2
- package/dist/{message-handler-DQZiKx3i.mjs → message-handler-B1usz1wb.mjs} +238 -122
- package/dist/{messaging-WleKf54h.mjs → messaging-DVS3UKOp.mjs} +6 -2
- package/dist/{runtime-DzZo6Gvk.mjs → runtime-A6G7WAWn.mjs} +3 -3
- package/docs/RELEASE_NOTES_V0.8.28.md +56 -0
- package/package.json +2 -2
- package/src/core/connection.ts +465 -158
- package/src/core/message-handler.ts +173 -86
- package/src/reply-dispatcher.ts +234 -77
- package/src/services/messaging/card.ts +2 -0
- package/src/utils/background-work.ts +63 -0
- package/dist/card-content-cache-Dwd8HICP.mjs +0 -2
- package/dist/chunk-upload-DaLXXZH3.mjs +0 -2
- package/dist/gateway-methods-BQd4E0Sk.mjs +0 -2
- /package/dist/{card-content-cache-CYUzCaQJ.mjs → card-content-cache-bDQFz2u8.mjs} +0 -0
- /package/dist/{chunk-upload-BBQgGtcZ.mjs → chunk-upload-BE71drPg.mjs} +0 -0
- /package/dist/{game-xiyou-CAT0ilSj.mjs → game-xiyou-uwsv23AC.mjs} +0 -0
package/src/reply-dispatcher.ts
CHANGED
|
@@ -302,11 +302,71 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
302
302
|
const log = createLoggerFromConfig(account.config, `DingTalk:${accountId}`);
|
|
303
303
|
|
|
304
304
|
// AI Card 状态管理
|
|
305
|
-
|
|
305
|
+
// 即时建卡(earlyCard/preCreatedCard)在 message-handler 已创建并显示「正在召唤大模型」。
|
|
306
|
+
// 必须在 dispatcher 启动时就挂到 currentCardTarget,否则模型在 startStreaming 之前失败时
|
|
307
|
+
// onIdle/closeStreaming 会因「无 AI Card」直接跳过,群聊卡永久停在 INPUTING。
|
|
308
|
+
let currentCardTarget: AICardInstance | null = preCreatedCard ?? null;
|
|
306
309
|
let accumulatedText = "";
|
|
307
310
|
const deliveredFinalTexts = new Set<string>();
|
|
308
311
|
// 防止 startStreaming 在 closeStreaming 之后重新创建新卡片(会导致多余 AI Card)
|
|
309
312
|
let sessionClosed = false;
|
|
313
|
+
/**
|
|
314
|
+
* 群聊 fail-before-reply 时 OpenClaw 会把用户可见文案收成 NO_REPLY(静默),
|
|
315
|
+
* 但 payload 仍带 rawError/isError。normalize 会在 deliver 前丢掉整包,
|
|
316
|
+
* 导致 closeStreaming 空文本 → answerCard 误定格「思考完成」。
|
|
317
|
+
* 用 onSkip 捕获后写入此处,供 closeStreaming 定稿错误卡。
|
|
318
|
+
*/
|
|
319
|
+
let pendingErrorText: string | null = null;
|
|
320
|
+
if (preCreatedCard && !isDirect) {
|
|
321
|
+
registerActiveCard(conversationId, preCreatedCard);
|
|
322
|
+
}
|
|
323
|
+
if (preCreatedCard) {
|
|
324
|
+
log.info(
|
|
325
|
+
`[DingTalk] 启动即挂接即时建卡 currentCardTarget=${preCreatedCard.cardInstanceId} isDirect=${isDirect}`,
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 从 isError/rawError payload 提取中文错误文案(含被静默丢弃的群聊失败) */
|
|
330
|
+
const captureErrorFromPayload = (
|
|
331
|
+
payload: unknown,
|
|
332
|
+
source: string,
|
|
333
|
+
): string | null => {
|
|
334
|
+
const p = payload as {
|
|
335
|
+
text?: unknown;
|
|
336
|
+
isError?: unknown;
|
|
337
|
+
rawError?: unknown;
|
|
338
|
+
} | null;
|
|
339
|
+
if (!p) return null;
|
|
340
|
+
const raw = readPayloadRawError(p);
|
|
341
|
+
const text = typeof p.text === "string" ? p.text.trim() : "";
|
|
342
|
+
const looksSilent =
|
|
343
|
+
!text ||
|
|
344
|
+
/^NO_REPLY$/i.test(text) ||
|
|
345
|
+
text.split(/\s+/).every((t) => /^NO_REPLY$/i.test(t));
|
|
346
|
+
|
|
347
|
+
let errorText: string | null = null;
|
|
348
|
+
if (raw) {
|
|
349
|
+
errorText = matchModelErrorText(raw, { includeCatchAll: true });
|
|
350
|
+
}
|
|
351
|
+
if (!errorText && p.isError && text && !looksSilent) {
|
|
352
|
+
errorText =
|
|
353
|
+
matchModelErrorText(text, { includeCatchAll: true }) ?? text;
|
|
354
|
+
}
|
|
355
|
+
if (!errorText && (raw || p.isError)) {
|
|
356
|
+
errorText = MODEL_ERROR_GENERIC;
|
|
357
|
+
}
|
|
358
|
+
if (!errorText) return null;
|
|
359
|
+
|
|
360
|
+
pendingErrorText = errorText;
|
|
361
|
+
// 错误文案优先覆盖空/占位累积,供 closeStreaming 使用
|
|
362
|
+
if (!accumulatedText.trim() || looksSilent) {
|
|
363
|
+
accumulatedText = errorText;
|
|
364
|
+
}
|
|
365
|
+
log.info(
|
|
366
|
+
`[DingTalk][errorCapture] source=${source} len=${errorText.length} preview=${errorText.slice(0, 60)}`,
|
|
367
|
+
);
|
|
368
|
+
return errorText;
|
|
369
|
+
};
|
|
310
370
|
|
|
311
371
|
// 异步模式:累积完整响应
|
|
312
372
|
let asyncModeFullResponse = "";
|
|
@@ -545,12 +605,13 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
545
605
|
}
|
|
546
606
|
};
|
|
547
607
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
});
|
|
553
|
-
|
|
608
|
+
// 串行链:失败只记日志不阻断后续写;但本次 await 的 Promise 仍要 reject,
|
|
609
|
+
// 以便 onPartialReply 等调用方能走非 QPS 错误的兜底提示。
|
|
610
|
+
const writePromise = streamWriteChain.then(() => runWrite(force));
|
|
611
|
+
streamWriteChain = writePromise.catch((e) => {
|
|
612
|
+
log.warn(`[DingTalk][stream] 队列任务失败(不中断后续):${e?.message || e}`);
|
|
613
|
+
});
|
|
614
|
+
return writePromise;
|
|
554
615
|
};
|
|
555
616
|
|
|
556
617
|
/** 定稿前:取消尾随定时器,强制推送全文并等待队列排空 */
|
|
@@ -784,12 +845,22 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
784
845
|
await streamWriteChain;
|
|
785
846
|
|
|
786
847
|
// 选定最终答案:marker 优先 → lastAnswerText/accumulatedText 取更长 → 剥离尾标记
|
|
848
|
+
// 群聊模型失败被 OpenClaw 静默(NO_REPLY)时,用 onSkip 捕获的 pendingErrorText
|
|
787
849
|
let finalText = finalClean(pickFinalText());
|
|
850
|
+
if (!finalText.trim() && pendingErrorText) {
|
|
851
|
+
finalText = pendingErrorText;
|
|
852
|
+
log.info(
|
|
853
|
+
`[DingTalk][closeStreaming] 使用 pendingErrorText 定稿(群聊静默失败恢复),len=${finalText.length}`,
|
|
854
|
+
);
|
|
855
|
+
}
|
|
788
856
|
// 是否有真实对话答案(在套兜底文案之前判断)。纯工具进度/无回复时为 false,
|
|
789
857
|
// 用于 answerCard 模式下决定"不另建无文本输出答案卡"。
|
|
858
|
+
// 错误文案也算「真实内容」——绝不能走「思考完成」空答案分支。
|
|
790
859
|
const hadRealAnswer = finalText.trim().length > 0;
|
|
860
|
+
// pendingErrorText 非空 ⇒ 本轮是模型/上游异常定稿,禁止走「思考完成」
|
|
861
|
+
const isErrorFinal = Boolean(pendingErrorText);
|
|
791
862
|
log.info(
|
|
792
|
-
`[DingTalk][closeStreaming] 最终答案来源=${finalMarkedText !== null ? "marker[-final-]" : (lastAnswerText ? "非reasoning/累积取长" : "accumulatedText兜底")},长度=${finalText.length},lastAnswerLen=${lastAnswerText.length},accLen=${accumulatedText.length}`,
|
|
863
|
+
`[DingTalk][closeStreaming] 最终答案来源=${finalMarkedText !== null ? "marker[-final-]" : isErrorFinal ? "pendingErrorText" : (lastAnswerText ? "非reasoning/累积取长" : "accumulatedText兜底")},长度=${finalText.length},lastAnswerLen=${lastAnswerText.length},accLen=${accumulatedText.length},isErrorFinal=${isErrorFinal}`,
|
|
793
864
|
);
|
|
794
865
|
|
|
795
866
|
// ✅ 如果累积的文本为空,使用默认提示文案
|
|
@@ -956,11 +1027,35 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
956
1027
|
const answerActToken = Number((account.config as any)?.answerActToken) || 500;
|
|
957
1028
|
const answerTokens = estimateTokens(finalText);
|
|
958
1029
|
|
|
959
|
-
|
|
1030
|
+
// ⚠️ 分支顺序有意为之,勿合并:
|
|
1031
|
+
// 1) 错误 final → 单卡定错误文案(绝不能「思考完成」/双卡)
|
|
1032
|
+
// 2) 无真实答案 → 原卡「思考完成」(纯工具轮等)
|
|
1033
|
+
// 3) token ≤ answerActToken → 原卡直接定稿(日常短答)
|
|
1034
|
+
// 4) token > answerActToken → 原卡思考完成 + 新建答案卡(大答案机制,不可破坏)
|
|
1035
|
+
if (isErrorFinal) {
|
|
1036
|
+
log.info(
|
|
1037
|
+
`[DingTalk][closeStreaming] answerCard 模式:错误定稿原卡(len=${finalText.length}),不建答案卡、不定格思考完成`,
|
|
1038
|
+
);
|
|
1039
|
+
try {
|
|
1040
|
+
await flushCardStream(finalText, cardSnapshot as any);
|
|
1041
|
+
} catch (e: any) {
|
|
1042
|
+
log.warn(`[DingTalk][closeStreaming] 错误终稿 flush 失败(继续 FINISH):${e?.message || e}`);
|
|
1043
|
+
}
|
|
1044
|
+
await finishAICard(
|
|
1045
|
+
cardSnapshot as any,
|
|
1046
|
+
finalText,
|
|
1047
|
+
account.config as DingtalkConfig,
|
|
1048
|
+
log,
|
|
1049
|
+
undefined,
|
|
1050
|
+
undefined,
|
|
1051
|
+
conversationId,
|
|
1052
|
+
);
|
|
1053
|
+
} else if (!hadRealAnswer) {
|
|
1054
|
+
// 无真实答案:原卡定格「思考完成」(纯工具轮 / 被静默的 NO_REPLY 等)
|
|
960
1055
|
log.info(`[DingTalk][closeStreaming] answerCard 模式:无真实答案,仅定格原卡思考完成(不建答案卡)`);
|
|
961
1056
|
await finalizeOriginalToDone();
|
|
962
1057
|
} else if (answerTokens <= answerActToken) {
|
|
963
|
-
//
|
|
1058
|
+
// 小答案:单卡定稿(不新建答案卡)—— 与错误路径分离,保证阈值逻辑独立
|
|
964
1059
|
log.info(`[DingTalk][closeStreaming] answerCard 模式:答案约 ${answerTokens} token ≤ ${answerActToken},原卡直接定稿(不建答案卡)`);
|
|
965
1060
|
try {
|
|
966
1061
|
await flushCardStream(finalText, cardSnapshot as any);
|
|
@@ -977,7 +1072,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
977
1072
|
conversationId,
|
|
978
1073
|
);
|
|
979
1074
|
} else {
|
|
980
|
-
// 大答案:原卡思考完成 +
|
|
1075
|
+
// 大答案:原卡思考完成 + 新建答案卡投全文(双卡机制,保留,勿改阈值语义)
|
|
981
1076
|
log.info(`[DingTalk][closeStreaming] answerCard 模式:答案约 ${answerTokens} token > ${answerActToken},原卡思考完成 + 新建答案卡(模板=${answerTplId})`);
|
|
982
1077
|
// 注意:此处故意不把 finalText flush 到原卡,避免长文在原卡慢速流式,再被思考完成盖掉
|
|
983
1078
|
await finalizeOriginalToDone();
|
|
@@ -1064,16 +1159,110 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
1064
1159
|
}
|
|
1065
1160
|
};
|
|
1066
1161
|
|
|
1162
|
+
/**
|
|
1163
|
+
* 统一错误定稿:模型/dispatch 异常时把中文提示写到 AI Card 并 FINISHED。
|
|
1164
|
+
* 供 dispatcher onError 与 message-handler catch(同步 throw)共用。
|
|
1165
|
+
* 不依赖 OpenClaw 返回的 replyOptions.onError(SDK 根本不导出该字段)。
|
|
1166
|
+
*/
|
|
1167
|
+
const finalizeDispatchError = async (
|
|
1168
|
+
error: unknown,
|
|
1169
|
+
kind: string = "invoke",
|
|
1170
|
+
): Promise<void> => {
|
|
1171
|
+
const errorMsg = String(error);
|
|
1172
|
+
log.error(`[DingTalk][finalizeDispatchError] kind=${kind} failed: ${errorMsg}`);
|
|
1173
|
+
|
|
1174
|
+
// 确保卡片已就绪(错误可能在 startStreaming 之前发生)
|
|
1175
|
+
await startStreaming();
|
|
1176
|
+
|
|
1177
|
+
const errorText =
|
|
1178
|
+
matchModelErrorText(errorMsg, { includeCatchAll: true }) ??
|
|
1179
|
+
"⚠️ 模型请求异常,请稍后重试";
|
|
1180
|
+
log.warn(`[DingTalk][finalizeDispatchError] 错误提示: ${errorText.slice(0, 80)}`);
|
|
1181
|
+
|
|
1182
|
+
// 错误文案覆盖既有内容,保证 closeStreaming 定稿的是异常提示而非空/占位
|
|
1183
|
+
pendingErrorText = errorText;
|
|
1184
|
+
accumulatedText = errorText;
|
|
1185
|
+
|
|
1186
|
+
// 卡片还活着 → closeStreaming 会用 accumulatedText 定稿。
|
|
1187
|
+
// 卡片已关 → 跳过,避免对同一张卡重复 finish。
|
|
1188
|
+
// 仅当会话未关闭且卡片引用丢失时恢复 preCreatedCard。
|
|
1189
|
+
if (!currentCardTarget && !sessionClosed) {
|
|
1190
|
+
if (preCreatedCard) {
|
|
1191
|
+
try {
|
|
1192
|
+
currentCardTarget = preCreatedCard;
|
|
1193
|
+
if (!isDirect) {
|
|
1194
|
+
registerActiveCard(conversationId, preCreatedCard);
|
|
1195
|
+
}
|
|
1196
|
+
log.info(
|
|
1197
|
+
`[DingTalk][finalizeDispatchError] 复用即时建卡显示错误(cardInstanceId=${preCreatedCard.cardInstanceId})`,
|
|
1198
|
+
);
|
|
1199
|
+
} catch (cardErr: any) {
|
|
1200
|
+
log.warn(
|
|
1201
|
+
`[DingTalk][finalizeDispatchError] 复用即时建卡失败:${cardErr.message},降级发送普通消息`,
|
|
1202
|
+
);
|
|
1203
|
+
try {
|
|
1204
|
+
await sendMessage(
|
|
1205
|
+
account.config as DingtalkConfig,
|
|
1206
|
+
sessionWebhook,
|
|
1207
|
+
accumulatedText,
|
|
1208
|
+
{ useMarkdown: false, log: params.runtime.log },
|
|
1209
|
+
);
|
|
1210
|
+
} catch (sendErr: any) {
|
|
1211
|
+
log.error(
|
|
1212
|
+
`[DingTalk][finalizeDispatchError] 降级发送普通消息失败:${sendErr.message}`,
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
} else {
|
|
1217
|
+
log.warn(`[DingTalk][finalizeDispatchError] 无可用 AI Card,降级发送普通消息`);
|
|
1218
|
+
try {
|
|
1219
|
+
await sendMessage(
|
|
1220
|
+
account.config as DingtalkConfig,
|
|
1221
|
+
sessionWebhook,
|
|
1222
|
+
accumulatedText,
|
|
1223
|
+
{ useMarkdown: false, log: params.runtime.log },
|
|
1224
|
+
);
|
|
1225
|
+
} catch (sendErr: any) {
|
|
1226
|
+
log.error(
|
|
1227
|
+
`[DingTalk][finalizeDispatchError] 降级发送普通消息失败:${sendErr.message}`,
|
|
1228
|
+
);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
await closeStreaming();
|
|
1234
|
+
};
|
|
1235
|
+
|
|
1067
1236
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
1068
1237
|
core.channel.reply.createReplyDispatcherWithTyping({
|
|
1069
1238
|
...prefixOptions,
|
|
1070
1239
|
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, agentId),
|
|
1240
|
+
// OpenClaw 官方钩子:静默/空包在 deliver 前被丢掉时回调(含群聊 fail-before-reply → NO_REPLY)。
|
|
1241
|
+
// 此时 payload 仍带 rawError/isError,必须在此捕获,否则 closeStreaming 会空定稿成「思考完成」。
|
|
1242
|
+
onSkip: (payload: unknown, info: { kind?: string; reason?: string }) => {
|
|
1243
|
+
const raw = readPayloadRawError(payload);
|
|
1244
|
+
const isErr = Boolean((payload as { isError?: unknown } | null)?.isError);
|
|
1245
|
+
// 仅拦截「失败类」静默:isError / rawError。
|
|
1246
|
+
// 模型主动 NO_REPLY(无错误)保持静默——不在此伪造错误文案,避免群聊刷屏。
|
|
1247
|
+
// 此时 earlyCard 仍会由 onIdle→closeStreaming 收尾(思考完成 / 无文本),不会永久转圈。
|
|
1248
|
+
if (!raw && !isErr) {
|
|
1249
|
+
log.info(
|
|
1250
|
+
`[DingTalk][onSkip] kind=${info?.kind} reason=${info?.reason} 非错误静默(NO_REPLY/empty),跳过错误捕获`,
|
|
1251
|
+
);
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
log.warn(
|
|
1255
|
+
`[DingTalk][onSkip] kind=${info?.kind} reason=${info?.reason} isError=${isErr} rawLen=${raw.length}(群聊静默失败常见)`,
|
|
1256
|
+
);
|
|
1257
|
+
captureErrorFromPayload(payload, `onSkip:${info?.kind ?? "?"}:${info?.reason ?? "?"}`);
|
|
1258
|
+
},
|
|
1071
1259
|
onReplyStart: () => {
|
|
1072
1260
|
log.info(`[DingTalk][onReplyStart] 开始回复,流式 enabled=${streamingEnabled}`);
|
|
1073
1261
|
// 每次 onReplyStart 都是全新的回复周期,清空去重集合 + 标记认定状态
|
|
1074
1262
|
deliveredFinalTexts.clear();
|
|
1075
1263
|
finalMarkedText = null;
|
|
1076
1264
|
lastAnswerText = "";
|
|
1265
|
+
pendingErrorText = null;
|
|
1077
1266
|
processMarkerLogged = false;
|
|
1078
1267
|
finalMarkerLogged = false;
|
|
1079
1268
|
markerSystemActive = false;
|
|
@@ -1096,6 +1285,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
1096
1285
|
// rawError 为空 = 正常回复 / 无原始错误,绝不匹配,避免误伤。
|
|
1097
1286
|
const rawError = readPayloadRawError(payload);
|
|
1098
1287
|
if (rawError) {
|
|
1288
|
+
captureErrorFromPayload(payload, `deliver:${info?.kind ?? "?"}`);
|
|
1099
1289
|
const matchedErrorText = matchModelErrorText(rawError, {
|
|
1100
1290
|
includeCatchAll: true,
|
|
1101
1291
|
});
|
|
@@ -1120,19 +1310,40 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
1120
1310
|
// 工具失败/状态通知 payload:流式时可短暂展示,但绝不计入最终答案(不设 accumulatedText / 不当 final)。
|
|
1121
1311
|
// 修复:dws 等工具调用失败的结果偶发被当成最终答案、提前停渲染。
|
|
1122
1312
|
if (isNonAnswerPayload(payload)) {
|
|
1313
|
+
const isModelErrorFinal =
|
|
1314
|
+
Boolean((payload as any).isError) && info?.kind === "final" && Boolean(text.trim());
|
|
1315
|
+
|
|
1316
|
+
// ✅ 上游模型异常 final:必须写入 accumulatedText,供 closeStreaming 定稿。
|
|
1317
|
+
// 工具失败 (kind=tool) 仍不计入最终答案。
|
|
1318
|
+
if (isModelErrorFinal) {
|
|
1319
|
+
accumulatedText = text;
|
|
1320
|
+
log.info(
|
|
1321
|
+
`[DingTalk][deliver] 将上游异常 final 文本存入 accumulatedText(len=${text.length}),防止 onIdle 覆盖`,
|
|
1322
|
+
);
|
|
1323
|
+
} else if ((payload as any).isError && !accumulatedText.trim() && text.trim()) {
|
|
1324
|
+
accumulatedText = text;
|
|
1325
|
+
log.info(
|
|
1326
|
+
`[DingTalk][deliver] 将上游异常 payload 文本存入 accumulatedText(len=${text.length}),防止 onIdle 覆盖`,
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1123
1330
|
if (streamingEnabled && currentCardTarget && !asyncMode && finalMarkedText === null) {
|
|
1124
1331
|
try {
|
|
1125
|
-
|
|
1332
|
+
// 模型错误 final 强制刷卡,避免节流丢最后一帧,群聊卡仍停在「正在召唤大模型」
|
|
1333
|
+
await enqueueCardStream(displayClean(text), {
|
|
1334
|
+
force: isModelErrorFinal,
|
|
1335
|
+
});
|
|
1126
1336
|
} catch (e: any) {
|
|
1127
1337
|
if (!isQpsLimitError(e)) log.warn(`[DingTalk][deliver] 状态/错误 payload 写卡失败:${e?.message || e}`);
|
|
1128
1338
|
}
|
|
1129
1339
|
}
|
|
1130
|
-
|
|
1131
|
-
//
|
|
1132
|
-
if (
|
|
1133
|
-
|
|
1134
|
-
|
|
1340
|
+
|
|
1341
|
+
// 模型错误 final:主动定稿,不单靠 onIdle(群聊 throw/无 partial 时 onIdle 可能找不到卡)
|
|
1342
|
+
if (isModelErrorFinal) {
|
|
1343
|
+
log.info(`[DingTalk][deliver] 上游 isError final,主动 closeStreaming 定稿错误卡`);
|
|
1344
|
+
await closeStreaming();
|
|
1135
1345
|
}
|
|
1346
|
+
|
|
1136
1347
|
log.info(`[DingTalk][deliver] 非答案 payload(isError=${(payload as any).isError},isStatusNotice=${(payload as any).isStatusNotice}),仅展示不计入最终答案`);
|
|
1137
1348
|
return;
|
|
1138
1349
|
}
|
|
@@ -1320,68 +1531,11 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
1320
1531
|
}
|
|
1321
1532
|
},
|
|
1322
1533
|
onError: async (error, info) => {
|
|
1323
|
-
|
|
1324
|
-
log.error(`[DingTalk][onError] ${info.kind} reply failed: ${errorMsg}`);
|
|
1534
|
+
log.error(`[DingTalk][onError] ${info.kind} reply failed: ${String(error)}`);
|
|
1325
1535
|
params.runtime.error?.(
|
|
1326
|
-
`dingtalk[${account.accountId}] ${info.kind} reply failed: ${
|
|
1536
|
+
`dingtalk[${account.accountId}] ${info.kind} reply failed: ${String(error)}`
|
|
1327
1537
|
);
|
|
1328
|
-
|
|
1329
|
-
// 确保卡片已就绪(错误可能在 startStreaming 之前发生)
|
|
1330
|
-
await startStreaming();
|
|
1331
|
-
|
|
1332
|
-
// 上游模型异常:按错误类型匹配中文提示(与 deliver 共用完整规则表)
|
|
1333
|
-
const errorText =
|
|
1334
|
-
matchModelErrorText(errorMsg, { includeCatchAll: true }) ??
|
|
1335
|
-
"⚠️ 模型请求异常,请稍后重试";
|
|
1336
|
-
log.warn(`[DingTalk][onError] 错误提示: ${errorText.slice(0, 80)}`);
|
|
1337
|
-
|
|
1338
|
-
// 始终写入 accumulatedText(即使 currentCardTarget 已被 onIdle 清空)
|
|
1339
|
-
if (!accumulatedText.trim()) {
|
|
1340
|
-
accumulatedText = errorText;
|
|
1341
|
-
}
|
|
1342
|
-
|
|
1343
|
-
// 卡片还活着 → closeStreaming 会用 accumulatedText 定稿到卡片。
|
|
1344
|
-
// 卡片已关(onIdle 先触发且已复用 preCreatedCard 关闭了会话)→ 跳过,
|
|
1345
|
-
// 避免对同一张卡片重复调用 closeStreaming 导致钉钉 API 报错。
|
|
1346
|
-
// 仅当会话尚未被关闭且卡片引用已丢失时才尝试恢复卡片引用。
|
|
1347
|
-
if (!currentCardTarget && !sessionClosed) {
|
|
1348
|
-
if (preCreatedCard) {
|
|
1349
|
-
// 即时建卡已创建但 startStreaming 未调用(或失败),直接复用
|
|
1350
|
-
try {
|
|
1351
|
-
currentCardTarget = preCreatedCard as any;
|
|
1352
|
-
if (!isDirect) {
|
|
1353
|
-
registerActiveCard(conversationId, preCreatedCard);
|
|
1354
|
-
}
|
|
1355
|
-
log.info(`[DingTalk][onError] 复用即时建卡显示错误(cardInstanceId=${preCreatedCard.cardInstanceId})`);
|
|
1356
|
-
} catch (cardErr: any) {
|
|
1357
|
-
log.warn(`[DingTalk][onError] 复用即时建卡失败:${cardErr.message},降级发送普通消息`);
|
|
1358
|
-
try {
|
|
1359
|
-
await sendMessage(
|
|
1360
|
-
account.config as DingtalkConfig,
|
|
1361
|
-
sessionWebhook,
|
|
1362
|
-
accumulatedText,
|
|
1363
|
-
{ useMarkdown: false, log: params.runtime.log }
|
|
1364
|
-
);
|
|
1365
|
-
} catch (sendErr: any) {
|
|
1366
|
-
log.error(`[DingTalk][onError] 降级发送普通消息失败:${sendErr.message}`);
|
|
1367
|
-
}
|
|
1368
|
-
}
|
|
1369
|
-
} else {
|
|
1370
|
-
log.warn(`[DingTalk][onError] 卡片已关闭且无即时建卡,降级发送普通消息`);
|
|
1371
|
-
try {
|
|
1372
|
-
await sendMessage(
|
|
1373
|
-
account.config as DingtalkConfig,
|
|
1374
|
-
sessionWebhook,
|
|
1375
|
-
accumulatedText,
|
|
1376
|
-
{ useMarkdown: false, log: params.runtime.log }
|
|
1377
|
-
);
|
|
1378
|
-
} catch (sendErr: any) {
|
|
1379
|
-
log.error(`[DingTalk][onError] 降级发送普通消息失败:${sendErr.message}`);
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
await closeStreaming();
|
|
1538
|
+
await finalizeDispatchError(error, info.kind);
|
|
1385
1539
|
typingCallbacks.onIdle?.();
|
|
1386
1540
|
},
|
|
1387
1541
|
onIdle: async () => {
|
|
@@ -1397,6 +1551,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
1397
1551
|
|
|
1398
1552
|
// 构建完整的 replyOptions:replyOptions 只包含 onReplyStart、onTypingController、onTypingCleanup
|
|
1399
1553
|
// deliver、onError、onIdle、onCleanup 等回调已经在 createReplyDispatcherWithTyping 的参数中定义
|
|
1554
|
+
// finalizeDispatchError 单独导出:OpenClaw 的 replyOptions 不含 onError,message-handler catch 必须直接调用
|
|
1400
1555
|
return {
|
|
1401
1556
|
dispatcher,
|
|
1402
1557
|
replyOptions: {
|
|
@@ -1532,5 +1687,7 @@ export function createDingtalkReplyDispatcher(params: CreateDingtalkReplyDispatc
|
|
|
1532
1687
|
},
|
|
1533
1688
|
markDispatchIdle,
|
|
1534
1689
|
getAsyncModeResponse: () => asyncModeFullResponse,
|
|
1690
|
+
/** 同步 dispatch 异常时由 message-handler catch 调用,统一 finish 错误卡 */
|
|
1691
|
+
finalizeDispatchError,
|
|
1535
1692
|
};
|
|
1536
1693
|
}
|
|
@@ -674,9 +674,11 @@ export async function finishAICard(
|
|
|
674
674
|
log?.error?.(
|
|
675
675
|
`[DingTalk][AICard] FINISHED 重试失败:${retryErr.message}`,
|
|
676
676
|
);
|
|
677
|
+
throw retryErr;
|
|
677
678
|
}
|
|
678
679
|
} else {
|
|
679
680
|
log?.error?.(`[DingTalk][AICard] FINISHED 更新失败:${err.message}`);
|
|
681
|
+
throw err;
|
|
680
682
|
}
|
|
681
683
|
}
|
|
682
684
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 后台任务引用计数。
|
|
3
|
+
*
|
|
4
|
+
* 钉钉 WS 回调只「入队」后就返回,真正的 AI 跑在 sessionQueues 后台 Promise 里。
|
|
5
|
+
* 若只在 WS 回调里 markMessageProcessingStart/End,会在入队后立刻 End,
|
|
6
|
+
* 长任务(尤其 503 重试)期间心跳超时会触发幽灵重连 → 后续消息「网关没反应」。
|
|
7
|
+
*
|
|
8
|
+
* 用法:session 队列任务创建后 trackBackgroundWork(taskPromise)。
|
|
9
|
+
* connection 层订阅计数变化,在 count>0 时保持处理中 keepAlive。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
type CountListener = (count: number) => void;
|
|
13
|
+
|
|
14
|
+
let activeCount = 0;
|
|
15
|
+
const listeners = new Set<CountListener>();
|
|
16
|
+
|
|
17
|
+
export function getActiveBackgroundWorkCount(): number {
|
|
18
|
+
return activeCount;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 订阅计数变化;返回取消订阅函数 */
|
|
22
|
+
export function onBackgroundWorkCountChange(listener: CountListener): () => void {
|
|
23
|
+
listeners.add(listener);
|
|
24
|
+
return () => {
|
|
25
|
+
listeners.delete(listener);
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function notify(): void {
|
|
30
|
+
for (const listener of listeners) {
|
|
31
|
+
try {
|
|
32
|
+
listener(activeCount);
|
|
33
|
+
} catch {
|
|
34
|
+
// ignore listener errors
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 跟踪一个后台 Promise:开始 +1,settle -1。
|
|
41
|
+
* 重复 settle 安全(只减一次)。
|
|
42
|
+
*/
|
|
43
|
+
export function trackBackgroundWork(
|
|
44
|
+
work: Promise<unknown>,
|
|
45
|
+
label?: string,
|
|
46
|
+
): void {
|
|
47
|
+
activeCount += 1;
|
|
48
|
+
notify();
|
|
49
|
+
let settled = false;
|
|
50
|
+
const done = () => {
|
|
51
|
+
if (settled) return;
|
|
52
|
+
settled = true;
|
|
53
|
+
activeCount = Math.max(0, activeCount - 1);
|
|
54
|
+
notify();
|
|
55
|
+
};
|
|
56
|
+
void work.then(done, done);
|
|
57
|
+
if (label && typeof process !== "undefined" && process.env?.DINGTALK_DEBUG_QUEUE === "1") {
|
|
58
|
+
console.log(`[DingTalk][bgWork] +1 label=${label} count=${activeCount}`);
|
|
59
|
+
void work.finally(() => {
|
|
60
|
+
console.log(`[DingTalk][bgWork] -1 label=${label} count=${activeCount}`);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|