@soimy/dingtalk 3.5.2 → 3.6.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.
- package/README.md +6 -23
- package/index.ts +7 -0
- package/openclaw.plugin.json +799 -0
- package/package.json +5 -5
- package/src/card/card-markdown-image-reroute.ts +106 -0
- package/src/card/card-run-registry.ts +54 -1
- package/src/card/card-stop-handler.ts +10 -20
- package/src/card/card-streaming-mode.ts +30 -0
- package/src/card/card-template.ts +14 -3
- package/src/card/reasoning-answer-split.ts +162 -0
- package/src/card/statusline-renderer.ts +94 -0
- package/src/card-draft-controller.ts +326 -54
- package/src/card-service.ts +479 -8
- package/src/channel.ts +19 -1062
- package/src/config-schema.ts +81 -38
- package/src/config.ts +142 -4
- package/src/device-registration.ts +245 -0
- package/src/gateway/channel-gateway.ts +636 -0
- package/src/inbound-handler.ts +489 -49
- package/src/media-utils.ts +169 -7
- package/src/message-utils.ts +153 -17
- package/src/messaging/btw-deliver.ts +85 -0
- package/src/messaging/channel-actions.ts +173 -0
- package/src/messaging/channel-outbound.ts +158 -0
- package/src/messaging/quoted-file-service.ts +9 -4
- package/src/onboarding.ts +323 -205
- package/src/platform/channel-status.ts +81 -0
- package/src/plugin-sdk-channel-actions-augment.ts +11 -0
- package/src/reply-strategy-card.ts +568 -44
- package/src/reply-strategy-markdown.ts +2 -2
- package/src/reply-strategy-types.ts +93 -0
- package/src/reply-strategy-with-reaction.ts +1 -1
- package/src/reply-strategy.ts +14 -56
- package/src/run-usage-store.ts +59 -0
- package/src/send-service.ts +225 -7
- package/src/session-state.ts +62 -0
- package/src/targeting/agent-name-matcher.ts +28 -0
- package/src/targeting/agent-routing.ts +44 -28
- package/src/types.ts +49 -117
- package/src/utils.ts +25 -0
|
@@ -7,32 +7,133 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
|
-
|
|
10
|
+
commitAICardBlocks,
|
|
11
11
|
isCardInTerminalState,
|
|
12
|
+
updateAICardStatusLine,
|
|
12
13
|
} from "./card-service";
|
|
14
|
+
import { splitCardReasoningAnswerText } from "./card/reasoning-answer-split";
|
|
13
15
|
import { createReasoningBlockAssembler } from "./card/reasoning-block-assembler";
|
|
16
|
+
import {
|
|
17
|
+
resolveCardStreamingMode,
|
|
18
|
+
shouldWarnDeprecatedCardRealTimeStreamOnce,
|
|
19
|
+
} from "./card/card-streaming-mode";
|
|
20
|
+
import {
|
|
21
|
+
buildImagePlaceholderText,
|
|
22
|
+
extractMarkdownImageCandidates,
|
|
23
|
+
} from "./card/card-markdown-image-reroute";
|
|
14
24
|
import { createCardDraftController } from "./card-draft-controller";
|
|
15
25
|
import { attachCardRunController } from "./card/card-run-registry";
|
|
16
|
-
import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy";
|
|
17
|
-
import {
|
|
26
|
+
import type { DeliverPayload, ReplyOptions, ReplyStrategy, ReplyStrategyContext } from "./reply-strategy-types";
|
|
27
|
+
import { resolveRelativePath } from "./config";
|
|
28
|
+
import { prepareMediaInput, resolveOutboundMediaType } from "./media-utils";
|
|
29
|
+
import { getTaskTimeSeconds, updateSessionState } from "./session-state";
|
|
30
|
+
import { renderStatusLine } from "./card/statusline-renderer";
|
|
31
|
+
import type { StatusLineData } from "./card/statusline-renderer";
|
|
32
|
+
import { recordRunStart, getAggregatedUsage, clearRuns } from "./run-usage-store";
|
|
33
|
+
import { sendBySession, sendMessage, sendProactiveMedia, uploadMedia } from "./send-service";
|
|
18
34
|
import type { AICardInstance } from "./types";
|
|
19
35
|
import { AICardStatus } from "./types";
|
|
20
36
|
import { formatDingTalkErrorPayloadLog } from "./utils";
|
|
21
37
|
|
|
22
38
|
const EMPTY_FINAL_REPLY = "✅ Done";
|
|
39
|
+
const DEFAULT_CARD_FAILED_MESSAGE = "回复生成失败,请重试";
|
|
40
|
+
type CardReplyLifecycleState = "open" | "final_seen" | "sealed";
|
|
41
|
+
|
|
42
|
+
/** Deferred media attachment for out-of-card delivery */
|
|
43
|
+
interface DeferredMedia {
|
|
44
|
+
url: string;
|
|
45
|
+
type: "voice" | "video" | "file";
|
|
46
|
+
}
|
|
23
47
|
|
|
24
48
|
export function createCardReplyStrategy(
|
|
25
49
|
ctx: ReplyStrategyContext & { card: AICardInstance; isStopRequested?: () => boolean },
|
|
26
50
|
): ReplyStrategy {
|
|
27
51
|
const { card, config, log, isStopRequested } = ctx;
|
|
28
52
|
|
|
29
|
-
const
|
|
53
|
+
const buildStatusLine = (): string | undefined => {
|
|
54
|
+
if (!ctx.taskMeta) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
const resolvedUsage =
|
|
58
|
+
typeof card.dapiUsage === "number"
|
|
59
|
+
? card.dapiUsage
|
|
60
|
+
: typeof ctx.taskMeta.usage === "number"
|
|
61
|
+
? ctx.taskMeta.usage
|
|
62
|
+
: undefined;
|
|
63
|
+
|
|
64
|
+
const sessionTaskTimeSeconds = card.accountId && card.conversationId
|
|
65
|
+
? getTaskTimeSeconds(card.accountId, card.contextConversationId || card.conversationId)
|
|
66
|
+
: undefined;
|
|
67
|
+
const cardElapsedMs = Math.max(0, Date.now() - card.createdAt);
|
|
68
|
+
const sessionElapsedMs = typeof sessionTaskTimeSeconds === "number"
|
|
69
|
+
? sessionTaskTimeSeconds * 1000
|
|
70
|
+
: undefined;
|
|
71
|
+
const metaElapsedMs = typeof ctx.taskMeta.elapsedMs === "number" && ctx.taskMeta.elapsedMs > 0
|
|
72
|
+
? ctx.taskMeta.elapsedMs
|
|
73
|
+
: undefined;
|
|
74
|
+
ctx.taskMeta.elapsedMs = Math.max(
|
|
75
|
+
cardElapsedMs,
|
|
76
|
+
sessionElapsedMs ?? 0,
|
|
77
|
+
metaElapsedMs ?? 0,
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
let inputTokens: number | undefined;
|
|
81
|
+
let outputTokens: number | undefined;
|
|
82
|
+
let cacheRead: number | undefined;
|
|
83
|
+
if (ctx.taskMeta.runIds && ctx.taskMeta.runIds.size > 0) {
|
|
84
|
+
const tokenUsage = getAggregatedUsage(ctx.taskMeta.runIds);
|
|
85
|
+
if (typeof tokenUsage.input === "number") { inputTokens = tokenUsage.input; }
|
|
86
|
+
if (typeof tokenUsage.output === "number") { outputTokens = tokenUsage.output; }
|
|
87
|
+
if (typeof tokenUsage.cacheRead === "number") { cacheRead = tokenUsage.cacheRead; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const statusLineData: StatusLineData = {
|
|
91
|
+
model: ctx.taskMeta.model,
|
|
92
|
+
effort: ctx.taskMeta.effort,
|
|
93
|
+
agent: ctx.taskMeta.agent,
|
|
94
|
+
taskTime: typeof ctx.taskMeta.elapsedMs === "number" ? Math.round(ctx.taskMeta.elapsedMs / 1000) : undefined,
|
|
95
|
+
inputTokens,
|
|
96
|
+
outputTokens,
|
|
97
|
+
cacheRead,
|
|
98
|
+
dapi_usage: resolvedUsage,
|
|
99
|
+
};
|
|
100
|
+
const statusLine = renderStatusLine(statusLineData, config);
|
|
101
|
+
return statusLine || undefined;
|
|
102
|
+
};
|
|
103
|
+
const { mode, usedDeprecatedCardRealTimeStream } = resolveCardStreamingMode(config);
|
|
104
|
+
const streamAnswerLive = mode === "answer" || mode === "all";
|
|
105
|
+
const renderAnswerBlocksLive = mode === "all";
|
|
106
|
+
const streamThinkingLive = mode === "all";
|
|
107
|
+
let lifecycleState: CardReplyLifecycleState = "open";
|
|
108
|
+
const shouldAcceptAnswerSnapshot = () => lifecycleState === "open";
|
|
109
|
+
const isLifecycleSealed = () => lifecycleState === "sealed";
|
|
110
|
+
|
|
111
|
+
if (usedDeprecatedCardRealTimeStream) {
|
|
112
|
+
const warningKey = `dingtalk-card-streaming:${ctx.accountId || config.clientId || "default"}`;
|
|
113
|
+
if (shouldWarnDeprecatedCardRealTimeStreamOnce(warningKey)) {
|
|
114
|
+
log?.warn?.(
|
|
115
|
+
"[DingTalk][Config] `cardRealTimeStream` is deprecated. Use `cardStreamingMode` with `off` | `answer` | `all`.",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const controller = createCardDraftController({
|
|
121
|
+
card,
|
|
122
|
+
log,
|
|
123
|
+
realTimeStreamEnabled: streamAnswerLive,
|
|
124
|
+
throttleMs: config.cardStreamInterval ?? 1000,
|
|
125
|
+
getStatusLine: buildStatusLine,
|
|
126
|
+
});
|
|
30
127
|
const reasoningAssembler = createReasoningBlockAssembler();
|
|
31
128
|
if (card.outTrackId) {
|
|
32
129
|
attachCardRunController(card.outTrackId, controller);
|
|
33
130
|
}
|
|
34
131
|
let finalTextForFallback: string | undefined;
|
|
35
132
|
let sawFinalDelivery = false;
|
|
133
|
+
/** Tracks the latest reasoning snapshot text for non-streaming boundary flush. */
|
|
134
|
+
let latestReasoningSnapshot = "";
|
|
135
|
+
/** Non-image media attachments deferred for out-of-card delivery. */
|
|
136
|
+
let pendingNonImageMedia: DeferredMedia[] = [];
|
|
36
137
|
|
|
37
138
|
const getRenderedTimeline = (options: { preferFinalAnswer?: boolean } = {}): string => {
|
|
38
139
|
const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? EMPTY_FINAL_REPLY : undefined);
|
|
@@ -51,25 +152,236 @@ export function createCardReplyStrategy(
|
|
|
51
152
|
}
|
|
52
153
|
};
|
|
53
154
|
|
|
54
|
-
const
|
|
155
|
+
const applyModeAwareDeliveredReasoning = async (text: string | undefined): Promise<void> => {
|
|
156
|
+
if (typeof text !== "string" || !text.trim() || isStopRequested?.()) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (streamThinkingLive) {
|
|
160
|
+
await controller.appendThinkingBlock(text);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
await applyModeAwareReasoningSnapshot(text);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const applyModeAwareReasoningSnapshot = async (text: string | undefined): Promise<void> => {
|
|
167
|
+
if (typeof text !== "string" || !text.trim() || isStopRequested?.()) {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (streamThinkingLive) {
|
|
171
|
+
latestReasoningSnapshot = text;
|
|
172
|
+
await controller.updateReasoning(text);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
55
175
|
const blocks = reasoningAssembler.ingestSnapshot(text);
|
|
176
|
+
const trimmed = text.trimStart();
|
|
56
177
|
if (
|
|
57
178
|
blocks.length === 0
|
|
58
|
-
&&
|
|
59
|
-
&& text.trim()
|
|
60
|
-
&& !text.trimStart().startsWith("Reasoning:")
|
|
179
|
+
&& !trimmed.startsWith("Reasoning:")
|
|
61
180
|
) {
|
|
62
|
-
|
|
181
|
+
if (trimmed.startsWith("Reason:")) {
|
|
182
|
+
latestReasoningSnapshot = "";
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
latestReasoningSnapshot = text.trim();
|
|
63
186
|
return;
|
|
64
187
|
}
|
|
188
|
+
latestReasoningSnapshot = "";
|
|
65
189
|
await appendAssembledThinkingBlocks(blocks);
|
|
66
190
|
};
|
|
67
191
|
|
|
68
192
|
const flushPendingReasoning = async (): Promise<void> => {
|
|
193
|
+
if (streamThinkingLive) {
|
|
194
|
+
await controller.sealActiveThinking();
|
|
195
|
+
latestReasoningSnapshot = "";
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
69
198
|
const blocks = reasoningAssembler.flushPendingAtBoundary();
|
|
199
|
+
if (latestReasoningSnapshot) {
|
|
200
|
+
blocks.push(latestReasoningSnapshot);
|
|
201
|
+
latestReasoningSnapshot = "";
|
|
202
|
+
}
|
|
70
203
|
await appendAssembledThinkingBlocks(blocks);
|
|
71
204
|
};
|
|
72
205
|
|
|
206
|
+
const handleAssistantBoundary = async (): Promise<void> => {
|
|
207
|
+
if (streamThinkingLive) {
|
|
208
|
+
await controller.sealActiveThinking();
|
|
209
|
+
latestReasoningSnapshot = "";
|
|
210
|
+
reasoningAssembler.reset();
|
|
211
|
+
await controller.notifyNewAssistantTurn();
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const pendingReasoningBlocks = reasoningAssembler.flushPendingAtBoundary();
|
|
215
|
+
if (latestReasoningSnapshot) {
|
|
216
|
+
pendingReasoningBlocks.push(latestReasoningSnapshot);
|
|
217
|
+
latestReasoningSnapshot = "";
|
|
218
|
+
}
|
|
219
|
+
reasoningAssembler.reset();
|
|
220
|
+
const turnBoundary = controller.notifyNewAssistantTurn();
|
|
221
|
+
if (pendingReasoningBlocks.length > 0) {
|
|
222
|
+
await turnBoundary;
|
|
223
|
+
await appendAssembledThinkingBlocks(pendingReasoningBlocks);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
await turnBoundary;
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const normalizeDeliveredText = (
|
|
230
|
+
text: string,
|
|
231
|
+
options: { isReasoning: boolean },
|
|
232
|
+
): { reasoningText?: string; answerText?: string } => {
|
|
233
|
+
if (options.isReasoning) {
|
|
234
|
+
const split = splitCardReasoningAnswerText(text);
|
|
235
|
+
return { reasoningText: split.reasoningText || text };
|
|
236
|
+
}
|
|
237
|
+
const split = splitCardReasoningAnswerText(text);
|
|
238
|
+
return {
|
|
239
|
+
reasoningText: split.reasoningText,
|
|
240
|
+
answerText: split.answerText,
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const applyDeliveredContent = async (
|
|
245
|
+
normalized: { reasoningText?: string; answerText?: string },
|
|
246
|
+
options: {
|
|
247
|
+
routeReasoningThroughModePolicy: boolean;
|
|
248
|
+
answerHandling?: "update" | "capture" | "ignore";
|
|
249
|
+
},
|
|
250
|
+
): Promise<void> => {
|
|
251
|
+
if (normalized.reasoningText) {
|
|
252
|
+
if (options.routeReasoningThroughModePolicy) {
|
|
253
|
+
await applyModeAwareDeliveredReasoning(normalized.reasoningText);
|
|
254
|
+
} else {
|
|
255
|
+
// Conservative local split fallback: keep existing behavior for mixed payloads.
|
|
256
|
+
await controller.appendThinkingBlock(normalized.reasoningText);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (normalized.answerText && options.answerHandling !== "ignore") {
|
|
260
|
+
if (options.answerHandling === "capture") {
|
|
261
|
+
finalTextForFallback = normalized.answerText;
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
await controller.updateAnswer(normalized.answerText);
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
const rewriteLocalMarkdownImagesToPlaceholders = (text: string): string => {
|
|
269
|
+
const candidates = extractMarkdownImageCandidates(text);
|
|
270
|
+
if (candidates.length === 0) {
|
|
271
|
+
return text;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
let nextText = text;
|
|
275
|
+
for (const candidate of candidates.toReversed()) {
|
|
276
|
+
if (candidate.classification !== "local") {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const placeholder = buildImagePlaceholderText({ alt: candidate.alt, url: candidate.url });
|
|
280
|
+
nextText = `${nextText.slice(0, candidate.start)}${placeholder}${nextText.slice(candidate.end)}`;
|
|
281
|
+
}
|
|
282
|
+
return nextText;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const handleAnswerSnapshot = async (text: string | undefined): Promise<void> => {
|
|
286
|
+
if (!shouldAcceptAnswerSnapshot() || isStopRequested?.()) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (!text) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const rewrittenSnapshot = rewriteLocalMarkdownImagesToPlaceholders(text);
|
|
293
|
+
const normalizedSnapshot = normalizeDeliveredText(rewrittenSnapshot, { isReasoning: false });
|
|
294
|
+
|
|
295
|
+
if (normalizedSnapshot.reasoningText) {
|
|
296
|
+
await applyModeAwareReasoningSnapshot(normalizedSnapshot.reasoningText);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const answerSnapshot = normalizedSnapshot.answerText
|
|
300
|
+
?? (!normalizedSnapshot.reasoningText ? rewrittenSnapshot : undefined);
|
|
301
|
+
if (!answerSnapshot) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
await controller.updateAnswer(answerSnapshot, {
|
|
306
|
+
stream: streamAnswerLive,
|
|
307
|
+
renderBlocks: renderAnswerBlocksLive,
|
|
308
|
+
});
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const applySplitTextToTimeline = async (
|
|
312
|
+
text: string,
|
|
313
|
+
options: { answerHandling?: "update" | "capture" | "ignore" } = {},
|
|
314
|
+
) => {
|
|
315
|
+
const normalized = normalizeDeliveredText(text, { isReasoning: false });
|
|
316
|
+
await applyDeliveredContent(normalized, {
|
|
317
|
+
routeReasoningThroughModePolicy: true,
|
|
318
|
+
answerHandling: options.answerHandling ?? "update",
|
|
319
|
+
});
|
|
320
|
+
return normalized;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const rerouteMarkdownImagesFromAnswer = async (text: string): Promise<string> => {
|
|
324
|
+
const candidates = extractMarkdownImageCandidates(text);
|
|
325
|
+
if (candidates.length === 0) {
|
|
326
|
+
return text;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
type SuccessfulReroute = {
|
|
330
|
+
start: number;
|
|
331
|
+
end: number;
|
|
332
|
+
placeholder: string;
|
|
333
|
+
mediaId: string;
|
|
334
|
+
blockText: string;
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
let nextText = text;
|
|
338
|
+
const successfulReroutes: SuccessfulReroute[] = [];
|
|
339
|
+
|
|
340
|
+
for (const candidate of candidates.toReversed()) {
|
|
341
|
+
if (candidate.classification !== "local") {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
let prepared: Awaited<ReturnType<typeof prepareMediaInput>> | undefined;
|
|
346
|
+
try {
|
|
347
|
+
prepared = await prepareMediaInput(candidate.url, log, config.mediaUrlAllowlist);
|
|
348
|
+
const mediaPath = prepared.cleanup
|
|
349
|
+
? prepared.path
|
|
350
|
+
: resolveRelativePath(prepared.path);
|
|
351
|
+
const mediaType = resolveOutboundMediaType({ mediaPath, asVoice: false });
|
|
352
|
+
if (mediaType !== "image") {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const result = await uploadMedia(config, mediaPath, "image", log);
|
|
357
|
+
if (!result?.mediaId) {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const placeholder = buildImagePlaceholderText({ alt: candidate.alt, url: candidate.url });
|
|
362
|
+
const blockText = candidate.alt.trim() || placeholder.replace(/^见下图/, "").trim() || "图片";
|
|
363
|
+
successfulReroutes.push({
|
|
364
|
+
start: candidate.start,
|
|
365
|
+
end: candidate.end,
|
|
366
|
+
placeholder,
|
|
367
|
+
mediaId: result.mediaId,
|
|
368
|
+
blockText,
|
|
369
|
+
});
|
|
370
|
+
nextText = `${nextText.slice(0, candidate.start)}${placeholder}${nextText.slice(candidate.end)}`;
|
|
371
|
+
} catch {
|
|
372
|
+
// Failure fallback: keep the original markdown unchanged.
|
|
373
|
+
} finally {
|
|
374
|
+
await prepared?.cleanup?.();
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
for (const reroute of successfulReroutes.toSorted((left, right) => left.start - right.start)) {
|
|
379
|
+
await controller.appendImageBlock(reroute.mediaId, reroute.blockText);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return nextText;
|
|
383
|
+
};
|
|
384
|
+
|
|
73
385
|
return {
|
|
74
386
|
getReplyOptions(): ReplyOptions {
|
|
75
387
|
return {
|
|
@@ -78,37 +390,58 @@ export function createCardReplyStrategy(
|
|
|
78
390
|
disableBlockStreaming: ctx.disableBlockStreaming ?? true,
|
|
79
391
|
|
|
80
392
|
onAssistantMessageStart: async () => {
|
|
81
|
-
if (isStopRequested?.()) {
|
|
393
|
+
if (isLifecycleSealed() || isStopRequested?.()) {
|
|
82
394
|
return;
|
|
83
395
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
await appendAssembledThinkingBlocks(pendingReasoningBlocks);
|
|
396
|
+
await handleAssistantBoundary();
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
onAgentRunStart: (runId: string) => {
|
|
400
|
+
if (isLifecycleSealed()) {
|
|
90
401
|
return;
|
|
91
402
|
}
|
|
92
|
-
|
|
403
|
+
recordRunStart(runId);
|
|
404
|
+
if (ctx.taskMeta) {
|
|
405
|
+
if (!ctx.taskMeta.runIds) { ctx.taskMeta.runIds = new Set(); }
|
|
406
|
+
ctx.taskMeta.runIds.add(runId);
|
|
407
|
+
}
|
|
93
408
|
},
|
|
94
409
|
|
|
95
|
-
onPartialReply:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
await controller.updateAnswer(payload.text);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
: undefined,
|
|
410
|
+
onPartialReply: async (payload) => {
|
|
411
|
+
await handleAnswerSnapshot(payload.text);
|
|
412
|
+
},
|
|
102
413
|
|
|
103
414
|
onReasoningStream: async (payload) => {
|
|
104
|
-
if (
|
|
105
|
-
|
|
415
|
+
if (isLifecycleSealed() || isStopRequested?.()) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
await applyModeAwareReasoningSnapshot(payload.text);
|
|
419
|
+
},
|
|
420
|
+
|
|
421
|
+
onModelSelected: (selected) => {
|
|
422
|
+
if (!card.accountId || !card.conversationId) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
updateSessionState(card.accountId, card.contextConversationId || card.conversationId, {
|
|
426
|
+
model: selected.model,
|
|
427
|
+
effort: selected.thinkLevel,
|
|
428
|
+
});
|
|
429
|
+
if (ctx.taskMeta) {
|
|
430
|
+
ctx.taskMeta.model = selected.model;
|
|
431
|
+
ctx.taskMeta.effort = selected.thinkLevel;
|
|
432
|
+
}
|
|
433
|
+
const statusLine = buildStatusLine();
|
|
434
|
+
if (statusLine) {
|
|
435
|
+
void updateAICardStatusLine(card, statusLine, log);
|
|
106
436
|
}
|
|
107
437
|
},
|
|
108
438
|
};
|
|
109
439
|
},
|
|
110
440
|
|
|
111
441
|
async deliver(payload: DeliverPayload): Promise<void> {
|
|
442
|
+
if (isLifecycleSealed()) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
112
445
|
const textToSend = payload.text;
|
|
113
446
|
|
|
114
447
|
// Empty-payload guard — card final is an exception (e.g. file-only response).
|
|
@@ -120,8 +453,12 @@ export function createCardReplyStrategy(
|
|
|
120
453
|
|
|
121
454
|
// ---- final: defer to finalize, just save text ----
|
|
122
455
|
if (payload.kind === "final") {
|
|
456
|
+
const isFirstFinalDelivery = !sawFinalDelivery;
|
|
457
|
+
lifecycleState = "final_seen";
|
|
123
458
|
await flushPendingReasoning();
|
|
124
|
-
|
|
459
|
+
if (isFirstFinalDelivery) {
|
|
460
|
+
sawFinalDelivery = true;
|
|
461
|
+
}
|
|
125
462
|
log?.info?.(
|
|
126
463
|
`[DingTalk][Finalize] deliver(final) received — cardState=${card.state} ` +
|
|
127
464
|
`textLen=${typeof textToSend === "string" ? textToSend.length : "null"} ` +
|
|
@@ -129,12 +466,47 @@ export function createCardReplyStrategy(
|
|
|
129
466
|
`lastAnswer="${(controller.getLastAnswerContent() ?? "").slice(0, 80)}" ` +
|
|
130
467
|
`lastContent="${(controller.getLastContent() ?? "").slice(0, 80)}"`,
|
|
131
468
|
);
|
|
469
|
+
// Inline media upload → image blocks in card; defer non-image attachments
|
|
132
470
|
if (payload.mediaUrls.length > 0) {
|
|
133
|
-
|
|
471
|
+
for (const url of payload.mediaUrls) {
|
|
472
|
+
try {
|
|
473
|
+
const prepared = await prepareMediaInput(url, log, config.mediaUrlAllowlist);
|
|
474
|
+
const mediaType = resolveOutboundMediaType({ mediaPath: prepared.path, asVoice: false });
|
|
475
|
+
if (mediaType !== "image") {
|
|
476
|
+
log?.debug?.(`[DingTalk][Card] Deferring non-image media (${mediaType}) for out-of-card delivery: ${url}`);
|
|
477
|
+
// Collect non-image attachments for later delivery
|
|
478
|
+
if (mediaType === "voice" || mediaType === "video" || mediaType === "file") {
|
|
479
|
+
pendingNonImageMedia.push({ url, type: mediaType });
|
|
480
|
+
}
|
|
481
|
+
await prepared.cleanup?.();
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
const result = await uploadMedia(config, prepared.path, "image", log);
|
|
485
|
+
await prepared.cleanup?.();
|
|
486
|
+
if (result?.mediaId) {
|
|
487
|
+
await controller.appendImageBlock(result.mediaId);
|
|
488
|
+
}
|
|
489
|
+
} catch (err: unknown) {
|
|
490
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
491
|
+
log?.debug?.(`[DingTalk][Card] Failed to upload media as image block: ${msg}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
134
494
|
}
|
|
135
495
|
const rawFinalText = typeof textToSend === "string" ? textToSend : "";
|
|
136
496
|
if (rawFinalText) {
|
|
137
|
-
|
|
497
|
+
if (payload.isReasoning === true) {
|
|
498
|
+
await applyModeAwareReasoningSnapshot(rawFinalText);
|
|
499
|
+
await flushPendingReasoning();
|
|
500
|
+
} else {
|
|
501
|
+
const rewrittenFinalText = await rerouteMarkdownImagesFromAnswer(rawFinalText);
|
|
502
|
+
const normalizedFinal = await applySplitTextToTimeline(rewrittenFinalText, {
|
|
503
|
+
answerHandling: "capture",
|
|
504
|
+
});
|
|
505
|
+
if (isFirstFinalDelivery && !normalizedFinal.answerText && !normalizedFinal.reasoningText) {
|
|
506
|
+
finalTextForFallback = rewrittenFinalText;
|
|
507
|
+
}
|
|
508
|
+
await flushPendingReasoning();
|
|
509
|
+
}
|
|
138
510
|
}
|
|
139
511
|
return;
|
|
140
512
|
}
|
|
@@ -149,22 +521,51 @@ export function createCardReplyStrategy(
|
|
|
149
521
|
log?.info?.(
|
|
150
522
|
`[DingTalk] Tool result received, streaming to AI Card: ${(textToSend ?? "").slice(0, 100)}`,
|
|
151
523
|
);
|
|
152
|
-
|
|
524
|
+
if (lifecycleState === "final_seen") {
|
|
525
|
+
await controller.appendToolBeforeCurrentAnswer(textToSend ?? "");
|
|
526
|
+
} else {
|
|
527
|
+
await controller.appendTool(textToSend ?? "");
|
|
528
|
+
}
|
|
153
529
|
return;
|
|
154
530
|
}
|
|
155
531
|
|
|
156
532
|
const isReasoningBlock = payload.isReasoning === true;
|
|
157
533
|
if (typeof textToSend === "string" && textToSend.trim()) {
|
|
158
534
|
if (isReasoningBlock) {
|
|
159
|
-
|
|
535
|
+
const normalized = normalizeDeliveredText(textToSend, { isReasoning: true });
|
|
536
|
+
await applyDeliveredContent(normalized, {
|
|
537
|
+
routeReasoningThroughModePolicy: false,
|
|
538
|
+
answerHandling: "ignore",
|
|
539
|
+
});
|
|
160
540
|
} else {
|
|
161
|
-
await
|
|
541
|
+
await applySplitTextToTimeline(rewriteLocalMarkdownImagesToPlaceholders(textToSend), {
|
|
542
|
+
answerHandling: lifecycleState === "open" ? "update" : "capture",
|
|
543
|
+
});
|
|
162
544
|
}
|
|
163
545
|
}
|
|
164
546
|
|
|
165
547
|
// ---- block: only handle reasoning/media (other text blocks are unused) ----
|
|
166
548
|
if (payload.mediaUrls.length > 0) {
|
|
167
|
-
|
|
549
|
+
for (const url of payload.mediaUrls) {
|
|
550
|
+
try {
|
|
551
|
+
const prepared = await prepareMediaInput(url, log, config.mediaUrlAllowlist);
|
|
552
|
+
const mediaType = resolveOutboundMediaType({ mediaPath: prepared.path, asVoice: false });
|
|
553
|
+
if (mediaType !== "image") {
|
|
554
|
+
log?.debug?.(`[DingTalk][Card] Deferring non-image media (${mediaType}) for out-of-card delivery: ${url}`);
|
|
555
|
+
pendingNonImageMedia.push({ url, type: mediaType });
|
|
556
|
+
await prepared.cleanup?.();
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const result = await uploadMedia(config, prepared.path, "image", log);
|
|
560
|
+
await prepared.cleanup?.();
|
|
561
|
+
if (result?.mediaId) {
|
|
562
|
+
await controller.appendImageBlock(result.mediaId);
|
|
563
|
+
}
|
|
564
|
+
} catch (err: unknown) {
|
|
565
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
566
|
+
log?.debug?.(`[DingTalk][Card] Failed to upload media as image block: ${msg}`);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
168
569
|
}
|
|
169
570
|
},
|
|
170
571
|
|
|
@@ -180,16 +581,59 @@ export function createCardReplyStrategy(
|
|
|
180
581
|
|
|
181
582
|
if (isStopRequested?.()) {
|
|
182
583
|
log?.info?.("[DingTalk][Finalize] Skipping — card stop was requested");
|
|
584
|
+
lifecycleState = "sealed";
|
|
585
|
+
if (card.accountId && card.conversationId) {
|
|
586
|
+
clearRuns(ctx.taskMeta?.runIds);
|
|
587
|
+
}
|
|
183
588
|
return;
|
|
184
589
|
}
|
|
185
590
|
|
|
186
591
|
if (card.state === AICardStatus.FINISHED) {
|
|
187
|
-
|
|
592
|
+
// Card was already finalized (e.g. first embedded run timed out).
|
|
593
|
+
// If session-recovery triggered a second run that produced new content,
|
|
594
|
+
// deliver it as a markdown fallback so the user sees the final result.
|
|
595
|
+
// The user may see partial overlap with the frozen card's content, but
|
|
596
|
+
// delivering the full answer is preferred over silence.
|
|
597
|
+
const recoveryText = getRenderedTimeline({ preferFinalAnswer: true })
|
|
598
|
+
|| finalTextForFallback
|
|
599
|
+
|| controller.getLastAnswerContent()
|
|
600
|
+
|| controller.getLastContent();
|
|
601
|
+
if (recoveryText) {
|
|
602
|
+
log?.info?.(
|
|
603
|
+
`[DingTalk][Finalize] Card already FINISHED — sending markdown fallback for session-recovery content ` +
|
|
604
|
+
`len=${recoveryText.length} preview="${recoveryText.slice(0, 80)}"`,
|
|
605
|
+
);
|
|
606
|
+
const sendResult = await sendMessage(ctx.config, ctx.to, recoveryText, {
|
|
607
|
+
sessionWebhook: ctx.sessionWebhook,
|
|
608
|
+
atUserId: !ctx.isDirect ? ctx.senderId : null,
|
|
609
|
+
log,
|
|
610
|
+
accountId: ctx.accountId,
|
|
611
|
+
storePath: ctx.storePath,
|
|
612
|
+
conversationId: ctx.groupId,
|
|
613
|
+
quotedRef: ctx.replyQuotedRef,
|
|
614
|
+
forceMarkdown: true,
|
|
615
|
+
});
|
|
616
|
+
if (!sendResult.ok) {
|
|
617
|
+
log?.warn?.(
|
|
618
|
+
`[DingTalk][Finalize] Markdown fallback after FINISHED card failed: ${sendResult.error}`,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
} else {
|
|
622
|
+
log?.info?.("[DingTalk][Finalize] Skipping — card already FINISHED and no new content");
|
|
623
|
+
}
|
|
624
|
+
lifecycleState = "sealed";
|
|
625
|
+
if (card.accountId && card.conversationId) {
|
|
626
|
+
clearRuns(ctx.taskMeta?.runIds);
|
|
627
|
+
}
|
|
188
628
|
return;
|
|
189
629
|
}
|
|
190
630
|
|
|
191
631
|
if (card.state === AICardStatus.STOPPED) {
|
|
192
632
|
log?.info?.("[DingTalk][Finalize] Skipping — card already STOPPED");
|
|
633
|
+
lifecycleState = "sealed";
|
|
634
|
+
if (card.accountId && card.conversationId) {
|
|
635
|
+
clearRuns(ctx.taskMeta?.runIds);
|
|
636
|
+
}
|
|
193
637
|
return;
|
|
194
638
|
}
|
|
195
639
|
|
|
@@ -197,8 +641,7 @@ export function createCardReplyStrategy(
|
|
|
197
641
|
if (card.state === AICardStatus.FAILED || controller.isFailed()) {
|
|
198
642
|
const fallbackText = getRenderedTimeline({ preferFinalAnswer: true })
|
|
199
643
|
|| controller.getLastAnswerContent()
|
|
200
|
-
||
|
|
201
|
-
|| card.lastStreamedContent;
|
|
644
|
+
|| DEFAULT_CARD_FAILED_MESSAGE;
|
|
202
645
|
if (fallbackText) {
|
|
203
646
|
log?.debug?.("[DingTalk] Card failed during streaming, sending markdown fallback");
|
|
204
647
|
const sendResult = await sendMessage(ctx.config, ctx.to, fallbackText, {
|
|
@@ -217,25 +660,92 @@ export function createCardReplyStrategy(
|
|
|
217
660
|
} else {
|
|
218
661
|
log?.debug?.("[DingTalk] Card failed but no content to fallback with");
|
|
219
662
|
}
|
|
663
|
+
lifecycleState = "sealed";
|
|
664
|
+
if (card.accountId && card.conversationId) {
|
|
665
|
+
clearRuns(ctx.taskMeta?.runIds);
|
|
666
|
+
}
|
|
220
667
|
return;
|
|
221
668
|
}
|
|
222
669
|
|
|
223
|
-
// Normal finalize.
|
|
670
|
+
// Normal finalize (V2 template path: single instances API call).
|
|
224
671
|
try {
|
|
225
672
|
await flushPendingReasoning();
|
|
673
|
+
|
|
226
674
|
await controller.flush();
|
|
227
675
|
await controller.waitForInFlight();
|
|
228
|
-
|
|
229
|
-
|
|
676
|
+
|
|
677
|
+
// Prepare finalize options for single instances API call
|
|
678
|
+
const fallbackAnswer = finalTextForFallback || (sawFinalDelivery ? EMPTY_FINAL_REPLY : undefined);
|
|
679
|
+
const blockListJson = controller.getRenderedBlocks({
|
|
680
|
+
fallbackAnswer,
|
|
681
|
+
overrideAnswer: finalTextForFallback,
|
|
682
|
+
});
|
|
683
|
+
const content = controller.getRenderedContent({
|
|
684
|
+
fallbackAnswer,
|
|
685
|
+
overrideAnswer: finalTextForFallback,
|
|
686
|
+
}) || fallbackAnswer || EMPTY_FINAL_REPLY;
|
|
687
|
+
|
|
230
688
|
controller.stop();
|
|
231
689
|
log?.info?.(
|
|
232
|
-
`[DingTalk][Finalize] Calling
|
|
690
|
+
`[DingTalk][Finalize] Calling commitAICardBlocks — ` +
|
|
691
|
+
`blockListLen=${blockListJson.length} contentLen=${content.length} ` +
|
|
233
692
|
`source=${finalTextForFallback ? "final.payload" : controller.getFinalAnswerContent() ? "timeline.answer" : sawFinalDelivery ? "timeline.fileOnly" : "fallbackDone"} ` +
|
|
234
|
-
`preview="${
|
|
693
|
+
`preview="${content.slice(0, 120)}"`,
|
|
235
694
|
);
|
|
236
|
-
|
|
695
|
+
|
|
696
|
+
// Build statusLine for card template
|
|
697
|
+
const statusLine = buildStatusLine();
|
|
698
|
+
|
|
699
|
+
await commitAICardBlocks(card, {
|
|
700
|
+
blockListJson,
|
|
701
|
+
content,
|
|
702
|
+
statusLine,
|
|
237
703
|
quotedRef: ctx.replyQuotedRef,
|
|
238
|
-
});
|
|
704
|
+
}, log);
|
|
705
|
+
|
|
706
|
+
// Send deferred non-image attachments after card finalize
|
|
707
|
+
// Use sessionWebhook for reply-session semantics; fallback to proactive if unavailable.
|
|
708
|
+
if (pendingNonImageMedia.length > 0) {
|
|
709
|
+
log?.debug?.(`[DingTalk][Card] Sending ${pendingNonImageMedia.length} deferred non-image attachments`);
|
|
710
|
+
for (const { url, type } of pendingNonImageMedia) {
|
|
711
|
+
try {
|
|
712
|
+
const prepared = await prepareMediaInput(url, log, config.mediaUrlAllowlist);
|
|
713
|
+
const actualMediaPath = prepared.path;
|
|
714
|
+
|
|
715
|
+
// Prefer sessionWebhook for reply-session permission semantics
|
|
716
|
+
if (ctx.sessionWebhook) {
|
|
717
|
+
const sendResult = await sendMessage(config, ctx.to, "", {
|
|
718
|
+
sessionWebhook: ctx.sessionWebhook,
|
|
719
|
+
mediaPath: actualMediaPath,
|
|
720
|
+
mediaType: type,
|
|
721
|
+
log,
|
|
722
|
+
accountId: ctx.accountId,
|
|
723
|
+
storePath: ctx.storePath,
|
|
724
|
+
});
|
|
725
|
+
if (!sendResult.ok) {
|
|
726
|
+
log?.warn?.(`[DingTalk][Card] Deferred media session send failed: ${sendResult.error || "unknown"}`);
|
|
727
|
+
}
|
|
728
|
+
} else {
|
|
729
|
+
// Fallback: proactive send when no reply session available
|
|
730
|
+
const result = await sendProactiveMedia(config, ctx.to, actualMediaPath, type, {
|
|
731
|
+
log,
|
|
732
|
+
accountId: ctx.accountId,
|
|
733
|
+
});
|
|
734
|
+
if (!result.ok) {
|
|
735
|
+
log?.warn?.(`[DingTalk][Card] Deferred media proactive send failed: ${result.error || "unknown"}`);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
await prepared.cleanup?.();
|
|
740
|
+
} catch (err: unknown) {
|
|
741
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
742
|
+
log?.warn?.(`[DingTalk][Card] Failed to send deferred media: ${msg}`);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
pendingNonImageMedia = []; // Clear after sending
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
lifecycleState = "sealed";
|
|
239
749
|
|
|
240
750
|
// In group chats, send a lightweight @mention via session webhook
|
|
241
751
|
// so the sender gets a notification — card API doesn't support @mention.
|
|
@@ -261,15 +771,29 @@ export function createCardReplyStrategy(
|
|
|
261
771
|
card.state = AICardStatus.FAILED;
|
|
262
772
|
card.lastUpdated = Date.now();
|
|
263
773
|
}
|
|
774
|
+
} finally {
|
|
775
|
+
lifecycleState = "sealed";
|
|
776
|
+
if (card.accountId && card.conversationId) {
|
|
777
|
+
clearRuns(ctx.taskMeta?.runIds);
|
|
778
|
+
}
|
|
264
779
|
}
|
|
265
780
|
},
|
|
266
781
|
|
|
267
782
|
async abort(_error: Error): Promise<void> {
|
|
783
|
+
lifecycleState = "sealed";
|
|
784
|
+
if (card.accountId && card.conversationId) {
|
|
785
|
+
clearRuns(ctx.taskMeta?.runIds);
|
|
786
|
+
}
|
|
268
787
|
if (!isCardInTerminalState(card.state)) {
|
|
269
788
|
controller.stop();
|
|
270
789
|
await controller.waitForInFlight();
|
|
271
790
|
try {
|
|
272
|
-
|
|
791
|
+
// For V2 template, finalize via instances API
|
|
792
|
+
const errorBlockListJson = JSON.stringify([{ type: 0, markdown: "❌ 处理失败" }]);
|
|
793
|
+
await commitAICardBlocks(card, {
|
|
794
|
+
blockListJson: errorBlockListJson,
|
|
795
|
+
content: "❌ 处理失败",
|
|
796
|
+
}, log);
|
|
273
797
|
} catch (cardCloseErr: unknown) {
|
|
274
798
|
log?.debug?.(`[DingTalk] Failed to finalize card after dispatch error: ${(cardCloseErr as Error).message}`);
|
|
275
799
|
card.state = AICardStatus.FAILED;
|