@soimy/dingtalk 3.5.1 → 3.5.3
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 +13 -24
- package/openclaw.plugin.json +695 -0
- package/package.json +12 -7
- package/src/ack-reaction-service.ts +1 -1
- package/src/auth.ts +1 -1
- package/src/card/card-action-handler.ts +1 -1
- package/src/card/card-stop-handler.ts +1 -1
- package/src/card/card-streaming-mode.ts +30 -0
- package/src/card/reasoning-answer-split.ts +162 -0
- package/src/card/reasoning-block-assembler.ts +157 -0
- package/src/card-callback-service.ts +1 -1
- package/src/card-draft-controller.ts +117 -6
- package/src/card-service.ts +112 -1
- package/src/channel.ts +131 -96
- package/src/command/card-stop-command.ts +4 -22
- package/src/command/inbound-command-dispatch-service.ts +464 -0
- package/src/config-schema.ts +62 -38
- package/src/config.ts +25 -3
- package/src/docs-service.ts +5 -5
- package/src/http-client.ts +20 -0
- package/src/inbound-handler.ts +475 -501
- package/src/logger-context.ts +16 -2
- package/src/media-utils.ts +166 -10
- package/src/message-utils.ts +33 -5
- package/src/{attachment-text-extractor.ts → messaging/attachment-text-extractor.ts} +1 -1
- package/src/{quoted-file-service.ts → messaging/quoted-file-service.ts} +14 -9
- package/src/onboarding.ts +29 -0
- package/src/plugin-sdk-channel-actions-augment.ts +11 -0
- package/src/reply-strategy-card.ts +294 -28
- package/src/reply-strategy-markdown.ts +124 -19
- package/src/reply-strategy.ts +22 -2
- package/src/send-service.ts +178 -7
- package/src/targeting/agent-routing.ts +55 -32
- package/src/{group-members-store.ts → targeting/group-members-store.ts} +1 -1
- package/src/types.ts +60 -4
- package/src/utils.ts +190 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@soimy/dingtalk",
|
|
3
|
-
"version": "3.5.
|
|
3
|
+
"version": "3.5.3",
|
|
4
4
|
"description": "DingTalk (钉钉) channel plugin for OpenClaw",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bot",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"type-check": "tsc -p tsconfig.json"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"axios": "
|
|
49
|
+
"axios": "1.13.6",
|
|
50
50
|
"dingtalk-stream": "^2.1.4",
|
|
51
51
|
"form-data": "^4.0.0",
|
|
52
52
|
"mammoth": "^1.12.0",
|
|
@@ -58,20 +58,25 @@
|
|
|
58
58
|
"@vitest/coverage-v8": "^3.2.4",
|
|
59
59
|
"oxfmt": "0.34.0",
|
|
60
60
|
"oxlint": "^1.49.0",
|
|
61
|
-
"oxlint-tsgolint": "^0.
|
|
61
|
+
"oxlint-tsgolint": "^0.18.0",
|
|
62
62
|
"typescript": "^5.3.0",
|
|
63
63
|
"vitepress": "1.6.4",
|
|
64
64
|
"vitest": "^3.2.4"
|
|
65
65
|
},
|
|
66
66
|
"peerDependencies": {
|
|
67
|
-
"openclaw": ">=2026.3.
|
|
67
|
+
"openclaw": ">=2026.3.28"
|
|
68
|
+
},
|
|
69
|
+
"peerDependenciesMeta": {
|
|
70
|
+
"openclaw": {
|
|
71
|
+
"optional": true
|
|
72
|
+
}
|
|
68
73
|
},
|
|
69
74
|
"openclaw": {
|
|
70
75
|
"compat": {
|
|
71
|
-
"pluginApi": ">=2026.3.
|
|
76
|
+
"pluginApi": ">=2026.3.28"
|
|
72
77
|
},
|
|
73
78
|
"build": {
|
|
74
|
-
"openclawVersion": "2026.3.
|
|
79
|
+
"openclawVersion": "2026.3.28"
|
|
75
80
|
},
|
|
76
81
|
"extensions": [
|
|
77
82
|
"./index.ts"
|
|
@@ -94,7 +99,7 @@
|
|
|
94
99
|
]
|
|
95
100
|
},
|
|
96
101
|
"install": {
|
|
97
|
-
"minHostVersion": ">=2026.3.
|
|
102
|
+
"minHostVersion": ">=2026.3.28",
|
|
98
103
|
"npmSpec": "@soimy/dingtalk",
|
|
99
104
|
"localPath": ".",
|
|
100
105
|
"defaultChoice": "npm"
|
package/src/auth.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
2
2
|
import type { CardCallbackAnalysis } from "../card-callback-service";
|
|
3
3
|
import type { DingTalkConfig, Logger } from "../types";
|
|
4
4
|
import { resolveCardRun } from "./card-run-registry";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
1
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
|
|
2
2
|
import { getAccessToken } from "../auth";
|
|
3
3
|
import { finishStoppedAICard, hideCardStopButton } from "../card-service";
|
|
4
4
|
import { dispatchDingTalkCardStopCommand } from "../command/card-stop-command";
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DingTalkConfig } from "../types";
|
|
2
|
+
|
|
3
|
+
export type CardStreamingMode = "off" | "answer" | "all";
|
|
4
|
+
|
|
5
|
+
// Process-lifetime one-shot warnings are intentional here: a given config key
|
|
6
|
+
// should only emit the deprecation notice once per runtime.
|
|
7
|
+
const warnedLegacyConfigs = new Set<string>();
|
|
8
|
+
|
|
9
|
+
export function resolveCardStreamingMode(
|
|
10
|
+
config: Pick<DingTalkConfig, "cardStreamingMode" | "cardRealTimeStream">,
|
|
11
|
+
): {
|
|
12
|
+
mode: CardStreamingMode;
|
|
13
|
+
usedDeprecatedCardRealTimeStream: boolean;
|
|
14
|
+
} {
|
|
15
|
+
if (config.cardStreamingMode) {
|
|
16
|
+
return { mode: config.cardStreamingMode, usedDeprecatedCardRealTimeStream: false };
|
|
17
|
+
}
|
|
18
|
+
if (config.cardRealTimeStream === true) {
|
|
19
|
+
return { mode: "all", usedDeprecatedCardRealTimeStream: true };
|
|
20
|
+
}
|
|
21
|
+
return { mode: "off", usedDeprecatedCardRealTimeStream: false };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function shouldWarnDeprecatedCardRealTimeStreamOnce(configKey: string): boolean {
|
|
25
|
+
if (warnedLegacyConfigs.has(configKey)) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
warnedLegacyConfigs.add(configKey);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
export interface CardReasoningAnswerSplit {
|
|
2
|
+
reasoningText?: string;
|
|
3
|
+
answerText?: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const THINKING_TAG_RE = /<\s*(\/?)\s*(?:think(?:ing)?|thought|antthinking)\b[^<>]*>/gi;
|
|
7
|
+
|
|
8
|
+
function isWrappedReasoningLine(line: string): boolean {
|
|
9
|
+
const trimmed = line.trim();
|
|
10
|
+
return trimmed.startsWith("_") && trimmed.endsWith("_") && trimmed.length >= 3;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function cleanReasoningLine(line: string): string {
|
|
14
|
+
const trimmed = line.trim();
|
|
15
|
+
if (!trimmed) {
|
|
16
|
+
return "";
|
|
17
|
+
}
|
|
18
|
+
return trimmed.replace(/^_/, "").replace(/_$/, "").trim();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function joinAnswerParts(parts: string[]): string | undefined {
|
|
22
|
+
const joined = parts.map((part) => part.trim()).filter(Boolean).join("\n\n").trim();
|
|
23
|
+
return joined || undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hasStructuredMarkdown(text: string): boolean {
|
|
27
|
+
const trimmed = text.trim();
|
|
28
|
+
if (!trimmed) {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
return (
|
|
32
|
+
trimmed.includes("**")
|
|
33
|
+
|| trimmed.includes("```")
|
|
34
|
+
|| /(^|\n)\s*(?:[-*+]\s|#{1,6}\s|>\s|\d+\.\s)/.test(trimmed)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function splitTopLevelReasoningPrefix(text: string): CardReasoningAnswerSplit | null {
|
|
39
|
+
const markerIndex = text.indexOf("Reasoning:");
|
|
40
|
+
if (markerIndex < 0) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const before = text.slice(0, markerIndex).trim();
|
|
45
|
+
if (before && hasStructuredMarkdown(before)) {
|
|
46
|
+
return {
|
|
47
|
+
answerText: text,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const trailing = text.slice(markerIndex + "Reasoning:".length);
|
|
52
|
+
const lines = trailing.split("\n");
|
|
53
|
+
const reasoningLines: string[] = [];
|
|
54
|
+
let started = false;
|
|
55
|
+
let remainderIndex = -1;
|
|
56
|
+
|
|
57
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
58
|
+
const line = lines[index];
|
|
59
|
+
const trimmed = line.trim();
|
|
60
|
+
|
|
61
|
+
if (!started) {
|
|
62
|
+
if (!trimmed) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!isWrappedReasoningLine(trimmed)) {
|
|
66
|
+
return {
|
|
67
|
+
answerText: text,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
started = true;
|
|
71
|
+
reasoningLines.push(cleanReasoningLine(trimmed));
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!trimmed) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (isWrappedReasoningLine(trimmed)) {
|
|
80
|
+
reasoningLines.push(cleanReasoningLine(trimmed));
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
remainderIndex = index;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (reasoningLines.length === 0) {
|
|
89
|
+
return {
|
|
90
|
+
answerText: text,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const after = remainderIndex >= 0 ? lines.slice(remainderIndex).join("\n").trim() : "";
|
|
95
|
+
return {
|
|
96
|
+
reasoningText: reasoningLines.join("\n").trim() || undefined,
|
|
97
|
+
answerText: joinAnswerParts([before, after]),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function splitTopLevelThinkingTags(text: string): CardReasoningAnswerSplit | null {
|
|
102
|
+
if (!THINKING_TAG_RE.test(text)) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
THINKING_TAG_RE.lastIndex = 0;
|
|
106
|
+
|
|
107
|
+
let reasoning = "";
|
|
108
|
+
let answer = "";
|
|
109
|
+
let lastIndex = 0;
|
|
110
|
+
let inThinking = false;
|
|
111
|
+
|
|
112
|
+
for (const match of text.matchAll(THINKING_TAG_RE)) {
|
|
113
|
+
const matchIndex = match.index ?? 0;
|
|
114
|
+
const segment = text.slice(lastIndex, matchIndex);
|
|
115
|
+
if (inThinking) {
|
|
116
|
+
reasoning += segment;
|
|
117
|
+
} else {
|
|
118
|
+
answer += segment;
|
|
119
|
+
}
|
|
120
|
+
inThinking = match[1] !== "/";
|
|
121
|
+
lastIndex = matchIndex + match[0].length;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const tail = text.slice(lastIndex);
|
|
125
|
+
if (inThinking) {
|
|
126
|
+
reasoning += tail;
|
|
127
|
+
} else {
|
|
128
|
+
answer += tail;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const cleanedReasoning = reasoning.trim();
|
|
132
|
+
if (!cleanedReasoning) {
|
|
133
|
+
return {
|
|
134
|
+
answerText: text,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
reasoningText: cleanedReasoning,
|
|
140
|
+
answerText: answer.trim() || undefined,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function splitCardReasoningAnswerText(text?: string): CardReasoningAnswerSplit {
|
|
145
|
+
if (typeof text !== "string") {
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const prefixed = splitTopLevelReasoningPrefix(text);
|
|
150
|
+
if (prefixed) {
|
|
151
|
+
return prefixed;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const tagged = splitTopLevelThinkingTags(text);
|
|
155
|
+
if (tagged) {
|
|
156
|
+
return tagged;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
answerText: text,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
export interface ReasoningBlockAssembler {
|
|
2
|
+
ingestSnapshot: (text: string | undefined) => string[];
|
|
3
|
+
flushPendingAtBoundary: () => string[];
|
|
4
|
+
reset: () => void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function stripReasoningPrefix(text: string): string {
|
|
8
|
+
const trimmed = text.trim();
|
|
9
|
+
if (trimmed.startsWith("Reasoning:")) {
|
|
10
|
+
return trimmed.slice("Reasoning:".length).trimStart();
|
|
11
|
+
}
|
|
12
|
+
return trimmed;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function cleanReasoningLine(line: string): string {
|
|
16
|
+
return line.trim().replace(/^_/, "").replace(/_$/, "").trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isClosedReasoningLine(line: string): boolean {
|
|
20
|
+
const trimmed = line.trim();
|
|
21
|
+
return trimmed.startsWith("_") && trimmed.endsWith("_") && trimmed.length >= 2;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function startsReasonBlock(line: string): boolean {
|
|
25
|
+
return cleanReasoningLine(line).startsWith("Reason:");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function blocksStartWithPrefix(blocks: string[], prefix: string[]): boolean {
|
|
29
|
+
if (prefix.length > blocks.length) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
return prefix.every((entry, index) => blocks[index] === entry);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseReasoningSnapshot(text: string | undefined): {
|
|
36
|
+
completeBlocks: string[];
|
|
37
|
+
pendingBlock: string;
|
|
38
|
+
} {
|
|
39
|
+
const normalized = typeof text === "string" ? stripReasoningPrefix(text) : "";
|
|
40
|
+
if (!normalized.trim()) {
|
|
41
|
+
return {
|
|
42
|
+
completeBlocks: [],
|
|
43
|
+
pendingBlock: "",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const lines = normalized
|
|
48
|
+
.split("\n")
|
|
49
|
+
.map((line) => line.trim())
|
|
50
|
+
.filter((line) => line.length > 0);
|
|
51
|
+
|
|
52
|
+
const completeBlocks: string[] = [];
|
|
53
|
+
let currentLines: string[] = [];
|
|
54
|
+
let currentMode: "explicit" | "implicit" | null = null;
|
|
55
|
+
let currentComplete = true;
|
|
56
|
+
|
|
57
|
+
const finalizeCurrent = () => {
|
|
58
|
+
if (currentLines.length === 0) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (currentMode === "explicit" && currentComplete) {
|
|
62
|
+
completeBlocks.push(currentLines.join("\n"));
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
for (const line of lines) {
|
|
67
|
+
if (startsReasonBlock(line)) {
|
|
68
|
+
finalizeCurrent();
|
|
69
|
+
currentLines = [cleanReasoningLine(line)];
|
|
70
|
+
currentMode = "explicit";
|
|
71
|
+
currentComplete = isClosedReasoningLine(line);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (currentLines.length === 0) {
|
|
76
|
+
const trimmedLine = line.trim();
|
|
77
|
+
const cleanedLine = cleanReasoningLine(line);
|
|
78
|
+
if (!cleanedLine) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!trimmedLine.startsWith("_")) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
currentLines = [cleanedLine];
|
|
85
|
+
currentMode = "implicit";
|
|
86
|
+
currentComplete = false;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
currentLines.push(cleanReasoningLine(line));
|
|
91
|
+
currentComplete = currentMode === "explicit"
|
|
92
|
+
? currentComplete && isClosedReasoningLine(line)
|
|
93
|
+
: false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (currentLines.length === 0) {
|
|
97
|
+
return {
|
|
98
|
+
completeBlocks,
|
|
99
|
+
pendingBlock: "",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (currentMode === "explicit" && currentComplete) {
|
|
104
|
+
completeBlocks.push(currentLines.join("\n"));
|
|
105
|
+
return {
|
|
106
|
+
completeBlocks,
|
|
107
|
+
pendingBlock: "",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
completeBlocks,
|
|
113
|
+
pendingBlock: currentLines.join("\n").trim(),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function createReasoningBlockAssembler(): ReasoningBlockAssembler {
|
|
118
|
+
let emittedBlocks: string[] = [];
|
|
119
|
+
let pendingBlock = "";
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
ingestSnapshot(text: string | undefined): string[] {
|
|
123
|
+
const parsed = parseReasoningSnapshot(text);
|
|
124
|
+
pendingBlock = parsed.pendingBlock;
|
|
125
|
+
|
|
126
|
+
if (parsed.completeBlocks.length === 0) {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (blocksStartWithPrefix(parsed.completeBlocks, emittedBlocks)) {
|
|
131
|
+
const nextBlocks = parsed.completeBlocks.slice(emittedBlocks.length);
|
|
132
|
+
emittedBlocks = [...parsed.completeBlocks];
|
|
133
|
+
return nextBlocks;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (blocksStartWithPrefix(emittedBlocks, parsed.completeBlocks)) {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return [];
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
flushPendingAtBoundary(): string[] {
|
|
144
|
+
if (!pendingBlock.trim()) {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
const flushed = pendingBlock;
|
|
148
|
+
pendingBlock = "";
|
|
149
|
+
return [flushed];
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
reset(): void {
|
|
153
|
+
emittedBlocks = [];
|
|
154
|
+
pendingBlock = "";
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -22,14 +22,18 @@ type TimelineEntry = {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
export interface CardDraftController {
|
|
25
|
-
updateAnswer: (text: string) => Promise<void>;
|
|
25
|
+
updateAnswer: (text: string, options?: { stream?: boolean }) => Promise<void>;
|
|
26
26
|
updateReasoning: (text: string) => Promise<void>;
|
|
27
27
|
updateThinking: (text: string) => Promise<void>;
|
|
28
|
+
appendThinkingBlock: (text: string) => Promise<void>;
|
|
28
29
|
updateTool: (text: string) => Promise<void>;
|
|
29
30
|
appendTool: (text: string) => Promise<void>;
|
|
31
|
+
appendToolBeforeCurrentAnswer: (text: string) => Promise<void>;
|
|
30
32
|
/** Signal that a new assistant turn has started (e.g. after a tool call). */
|
|
31
33
|
notifyNewAssistantTurn: () => Promise<void>;
|
|
32
34
|
startAssistantTurn: () => Promise<void>;
|
|
35
|
+
/** Seal the active thinking entry (keep it in timeline) without removing it. */
|
|
36
|
+
sealActiveThinking: () => Promise<void>;
|
|
33
37
|
flush: () => Promise<void>;
|
|
34
38
|
waitForInFlight: () => Promise<void>;
|
|
35
39
|
stop: () => void;
|
|
@@ -77,6 +81,8 @@ export function createCardDraftController(params: {
|
|
|
77
81
|
let failed = false;
|
|
78
82
|
let stopped = false;
|
|
79
83
|
let lastSentContent = "";
|
|
84
|
+
let lastQueuedContent = "";
|
|
85
|
+
let inFlightContent = "";
|
|
80
86
|
let lastAnswerContent = "";
|
|
81
87
|
|
|
82
88
|
let timelineEntries: TimelineEntry[] = [];
|
|
@@ -116,6 +122,19 @@ export function createCardDraftController(params: {
|
|
|
116
122
|
return timelineEntries.length - 1;
|
|
117
123
|
};
|
|
118
124
|
|
|
125
|
+
const findCurrentSegmentAnswerIndex = (): number | null => {
|
|
126
|
+
return activeAnswerIndex;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const findLastAnswerEntryIndex = (): number | null => {
|
|
130
|
+
for (let index = timelineEntries.length - 1; index >= 0; index -= 1) {
|
|
131
|
+
if (timelineEntries[index]?.kind === "answer") {
|
|
132
|
+
return index;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
};
|
|
137
|
+
|
|
119
138
|
const renderTimeline = (options: {
|
|
120
139
|
fallbackAnswer?: string;
|
|
121
140
|
overrideAnswer?: string;
|
|
@@ -174,13 +193,29 @@ export function createCardDraftController(params: {
|
|
|
174
193
|
activeAnswerIndex = null;
|
|
175
194
|
};
|
|
176
195
|
|
|
196
|
+
const clearPendingRender = () => {
|
|
197
|
+
loop.resetPending();
|
|
198
|
+
lastQueuedContent = "";
|
|
199
|
+
};
|
|
200
|
+
|
|
177
201
|
const queueRender = () => {
|
|
178
202
|
const rendered = renderTimeline({ compactProcessAnswerSpacing: true });
|
|
179
|
-
if (rendered) {
|
|
180
|
-
|
|
203
|
+
if (!rendered) {
|
|
204
|
+
clearPendingRender();
|
|
181
205
|
return;
|
|
182
206
|
}
|
|
183
|
-
|
|
207
|
+
if (rendered === lastSentContent) {
|
|
208
|
+
const hasNewerInFlight = !!inFlightContent && inFlightContent !== rendered;
|
|
209
|
+
if (!hasNewerInFlight) {
|
|
210
|
+
clearPendingRender();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (rendered === lastQueuedContent) {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
lastQueuedContent = rendered;
|
|
218
|
+
loop.update(rendered);
|
|
184
219
|
};
|
|
185
220
|
|
|
186
221
|
const flushBoundaryFrame = async () => {
|
|
@@ -215,14 +250,20 @@ export function createCardDraftController(params: {
|
|
|
215
250
|
throttleMs: effectiveThrottleMs,
|
|
216
251
|
isStopped: () => stopped || failed,
|
|
217
252
|
sendOrEditStreamMessage: async (content: string) => {
|
|
253
|
+
inFlightContent = content;
|
|
218
254
|
try {
|
|
219
255
|
await streamAICard(params.card, content, false, params.log);
|
|
220
256
|
lastSentContent = content;
|
|
257
|
+
lastQueuedContent = "";
|
|
221
258
|
lastAnswerContent = getFinalAnswerContent();
|
|
222
259
|
} catch (err: unknown) {
|
|
223
260
|
failed = true;
|
|
224
261
|
const message = err instanceof Error ? err.message : String(err);
|
|
225
262
|
params.log?.warn?.(`[DingTalk][AICard] Stream failed: ${message}`);
|
|
263
|
+
} finally {
|
|
264
|
+
if (inFlightContent === content) {
|
|
265
|
+
inFlightContent = "";
|
|
266
|
+
}
|
|
226
267
|
}
|
|
227
268
|
},
|
|
228
269
|
});
|
|
@@ -250,7 +291,7 @@ export function createCardDraftController(params: {
|
|
|
250
291
|
queueRender();
|
|
251
292
|
};
|
|
252
293
|
|
|
253
|
-
const updateAnswer = async (text: string) => {
|
|
294
|
+
const updateAnswer = async (text: string, options: { stream?: boolean } = {}) => {
|
|
254
295
|
await waitForPendingBoundary();
|
|
255
296
|
if (stopped || failed) {
|
|
256
297
|
return;
|
|
@@ -271,6 +312,10 @@ export function createCardDraftController(params: {
|
|
|
271
312
|
} else {
|
|
272
313
|
activeAnswerIndex = appendTimelineEntry("answer", normalized);
|
|
273
314
|
}
|
|
315
|
+
if (options.stream === false) {
|
|
316
|
+
clearPendingRender();
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
274
319
|
queueRender();
|
|
275
320
|
};
|
|
276
321
|
|
|
@@ -292,6 +337,61 @@ export function createCardDraftController(params: {
|
|
|
292
337
|
queueRender();
|
|
293
338
|
};
|
|
294
339
|
|
|
340
|
+
const appendThinkingBlock = async (text: string) => {
|
|
341
|
+
await waitForPendingBoundary();
|
|
342
|
+
if (stopped || failed) {
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const normalized = normalizeProcessText(text);
|
|
346
|
+
if (!normalized) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (timelineEntries.length > 0) {
|
|
350
|
+
await flushBoundaryFrame();
|
|
351
|
+
}
|
|
352
|
+
sealLiveThinking();
|
|
353
|
+
const currentSegmentAnswerIndex = findCurrentSegmentAnswerIndex();
|
|
354
|
+
if (currentSegmentAnswerIndex !== null) {
|
|
355
|
+
timelineEntries.splice(currentSegmentAnswerIndex, 0, { kind: "thinking", text: normalized });
|
|
356
|
+
if (activeAnswerIndex !== null && activeAnswerIndex >= currentSegmentAnswerIndex) {
|
|
357
|
+
activeAnswerIndex += 1;
|
|
358
|
+
}
|
|
359
|
+
} else {
|
|
360
|
+
sealCurrentAnswer();
|
|
361
|
+
appendTimelineEntry("thinking", normalized);
|
|
362
|
+
}
|
|
363
|
+
queueRender();
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const appendToolBeforeCurrentAnswer = async (text: string) => {
|
|
367
|
+
await waitForPendingBoundary();
|
|
368
|
+
if (stopped || failed) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
const normalized = normalizeProcessText(text);
|
|
372
|
+
if (!normalized) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (timelineEntries.length > 0) {
|
|
376
|
+
await flushBoundaryFrame();
|
|
377
|
+
}
|
|
378
|
+
sealLiveThinking();
|
|
379
|
+
const insertionIndex = findCurrentSegmentAnswerIndex() ?? findLastAnswerEntryIndex();
|
|
380
|
+
if (insertionIndex !== null) {
|
|
381
|
+
timelineEntries.splice(insertionIndex, 0, { kind: "tool", text: normalized });
|
|
382
|
+
if (activeAnswerIndex !== null && activeAnswerIndex >= insertionIndex) {
|
|
383
|
+
activeAnswerIndex += 1;
|
|
384
|
+
}
|
|
385
|
+
if (activeThinkingIndex !== null && activeThinkingIndex >= insertionIndex) {
|
|
386
|
+
activeThinkingIndex += 1;
|
|
387
|
+
}
|
|
388
|
+
} else {
|
|
389
|
+
sealCurrentAnswer();
|
|
390
|
+
appendTimelineEntry("tool", normalized);
|
|
391
|
+
}
|
|
392
|
+
queueRender();
|
|
393
|
+
};
|
|
394
|
+
|
|
295
395
|
const notifyNewAssistantTurn = async () => {
|
|
296
396
|
if (stopped || failed) {
|
|
297
397
|
return;
|
|
@@ -303,7 +403,7 @@ export function createCardDraftController(params: {
|
|
|
303
403
|
}
|
|
304
404
|
if (activeThinkingIndex !== null) {
|
|
305
405
|
removeTimelineEntry(activeThinkingIndex);
|
|
306
|
-
|
|
406
|
+
clearPendingRender();
|
|
307
407
|
}
|
|
308
408
|
};
|
|
309
409
|
|
|
@@ -311,10 +411,21 @@ export function createCardDraftController(params: {
|
|
|
311
411
|
updateAnswer,
|
|
312
412
|
updateReasoning,
|
|
313
413
|
updateThinking: updateReasoning,
|
|
414
|
+
appendThinkingBlock,
|
|
314
415
|
updateTool,
|
|
315
416
|
appendTool: updateTool,
|
|
417
|
+
appendToolBeforeCurrentAnswer,
|
|
316
418
|
notifyNewAssistantTurn,
|
|
317
419
|
startAssistantTurn: notifyNewAssistantTurn,
|
|
420
|
+
sealActiveThinking: async () => {
|
|
421
|
+
if (stopped || failed) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
if (activeThinkingIndex !== null) {
|
|
425
|
+
sealLiveThinking();
|
|
426
|
+
await beginBoundaryFlush();
|
|
427
|
+
}
|
|
428
|
+
},
|
|
318
429
|
flush: () => loop.flush(),
|
|
319
430
|
waitForInFlight: () => loop.waitForInFlight(),
|
|
320
431
|
|