@soimy/dingtalk 3.4.2 → 3.5.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.
@@ -1,61 +1,224 @@
1
1
  /**
2
2
  * Card draft controller for throttled AI Card streaming updates.
3
3
  *
4
- * Wraps {@link createDraftStreamLoop} with a phase-based state machine
5
- * (idle reasoning answer) that manages what content is sent to the
6
- * DingTalk AI Card during the reply lifecycle.
4
+ * The controller keeps a single rendered card timeline made of:
5
+ * - sealed process blocks (`thinking` / `tool`)
6
+ * - an optional live thinking block
7
+ * - accumulated answer turns rendered as plain markdown
7
8
  *
8
- * Responsibilities (and non-responsibilities):
9
- * - DOES manage throttled card preview updates via streamAICard
10
- * - DOES enforce single-flight, latest-wins, phase-gated semantics
11
- * - Does NOT handle tool append, finalize, or markdown fallback —
12
- * those stay in inbound-handler's deliver callback.
9
+ * It delegates throttling and single-flight transport guarantees to
10
+ * {@link createDraftStreamLoop}.
13
11
  */
14
12
 
15
- import { formatContentForCard, streamAICard } from "./card-service";
13
+ import { streamAICard } from "./card-service";
16
14
  import { createDraftStreamLoop } from "./draft-stream-loop";
17
15
  import type { AICardInstance, Logger } from "./types";
18
16
 
19
- export type CardDraftPhase = "idle" | "reasoning" | "answer";
17
+ type TimelineEntryKind = "thinking" | "tool" | "answer";
18
+
19
+ type TimelineEntry = {
20
+ kind: TimelineEntryKind;
21
+ text: string;
22
+ };
20
23
 
21
24
  export interface CardDraftController {
22
- updateAnswer: (text: string) => void;
23
- updateReasoning: (text: string) => void;
25
+ updateAnswer: (text: string) => Promise<void>;
26
+ updateReasoning: (text: string) => Promise<void>;
27
+ updateThinking: (text: string) => Promise<void>;
28
+ updateTool: (text: string) => Promise<void>;
29
+ appendTool: (text: string) => Promise<void>;
24
30
  /** Signal that a new assistant turn has started (e.g. after a tool call). */
25
- notifyNewAssistantTurn: () => void;
31
+ notifyNewAssistantTurn: () => Promise<void>;
32
+ startAssistantTurn: () => Promise<void>;
26
33
  flush: () => Promise<void>;
27
34
  waitForInFlight: () => Promise<void>;
28
35
  stop: () => void;
29
36
  isFailed: () => boolean;
30
- /** Last content sent to card (reasoning or answer). */
37
+ /** Last content successfully sent to card. */
31
38
  getLastContent: () => string;
32
- /** Last content sent to card during answer phase only. */
39
+ /** Last answer-only content successfully sent to card. */
33
40
  getLastAnswerContent: () => string;
41
+ /** Current answer-only content composed from all completed answer turns. */
42
+ getFinalAnswerContent: () => string;
43
+ /** Current rendered timeline, including process blocks and answer text. */
44
+ getRenderedContent: (options?: {
45
+ fallbackAnswer?: string;
46
+ overrideAnswer?: string;
47
+ compactProcessAnswerSpacing?: boolean;
48
+ }) => string;
49
+ }
50
+
51
+ function normalizeProcessText(text: string | undefined): string {
52
+ return typeof text === "string" ? text.trim() : "";
53
+ }
54
+
55
+ function normalizeAnswerText(text: string | undefined): string {
56
+ return typeof text === "string" ? text.trimStart() : "";
57
+ }
58
+
59
+ function quoteMarkdown(text: string): string {
60
+ return text
61
+ .split("\n")
62
+ .map((line) => line.trim() ? `> ${line.trim()}` : ">")
63
+ .join("\n");
64
+ }
65
+
66
+ function renderProcessBlock(_kind: "thinking" | "tool", text: string): string {
67
+ return quoteMarkdown(text);
34
68
  }
35
69
 
36
70
  export function createCardDraftController(params: {
37
71
  card: AICardInstance;
38
72
  throttleMs?: number;
73
+ /** Legacy compatibility: verbose mode previously lowered the throttle. */
74
+ verboseMode?: boolean;
39
75
  log?: Logger;
40
76
  }): CardDraftController {
41
- let phase: CardDraftPhase = "idle";
42
77
  let failed = false;
43
78
  let stopped = false;
44
79
  let lastSentContent = "";
45
80
  let lastAnswerContent = "";
46
- let answerPrefix = "";
47
- let turnBoundaryPending = false;
81
+
82
+ let timelineEntries: TimelineEntry[] = [];
83
+ let activeThinkingIndex: number | null = null;
84
+ let activeAnswerIndex: number | null = null;
85
+ let pendingBoundaryPromise: Promise<void> | null = null;
86
+
87
+ const effectiveThrottleMs = params.throttleMs ?? (params.verboseMode ? 50 : 300);
88
+
89
+ const getFinalAnswerContent = (): string => {
90
+ return timelineEntries
91
+ .filter((entry) => entry.kind === "answer" && entry.text)
92
+ .map((entry) => entry.text)
93
+ .join("\n\n");
94
+ };
95
+
96
+ const removeTimelineEntry = (index: number) => {
97
+ timelineEntries.splice(index, 1);
98
+ if (activeThinkingIndex !== null) {
99
+ if (activeThinkingIndex === index) {
100
+ activeThinkingIndex = null;
101
+ } else if (activeThinkingIndex > index) {
102
+ activeThinkingIndex -= 1;
103
+ }
104
+ }
105
+ if (activeAnswerIndex !== null) {
106
+ if (activeAnswerIndex === index) {
107
+ activeAnswerIndex = null;
108
+ } else if (activeAnswerIndex > index) {
109
+ activeAnswerIndex -= 1;
110
+ }
111
+ }
112
+ };
113
+
114
+ const appendTimelineEntry = (kind: TimelineEntryKind, text: string): number => {
115
+ timelineEntries.push({ kind, text });
116
+ return timelineEntries.length - 1;
117
+ };
118
+
119
+ const renderTimeline = (options: {
120
+ fallbackAnswer?: string;
121
+ overrideAnswer?: string;
122
+ compactProcessAnswerSpacing?: boolean;
123
+ } = {}): string => {
124
+ const entries = timelineEntries.map((entry) => ({ ...entry }));
125
+
126
+ const overrideAnswer = normalizeAnswerText(options.overrideAnswer);
127
+ if (overrideAnswer) {
128
+ const lastAnswerIndex = [...entries]
129
+ .map((entry, index) => ({ entry, index }))
130
+ .toReversed()
131
+ .find(({ entry }) => entry.kind === "answer")?.index;
132
+ if (lastAnswerIndex !== undefined) {
133
+ entries[lastAnswerIndex] = { kind: "answer", text: overrideAnswer };
134
+ } else {
135
+ entries.push({ kind: "answer", text: overrideAnswer });
136
+ }
137
+ } else if (!entries.some((entry) => entry.kind === "answer" && entry.text)) {
138
+ const fallbackAnswer = normalizeAnswerText(options.fallbackAnswer);
139
+ if (fallbackAnswer) {
140
+ entries.push({ kind: "answer", text: fallbackAnswer });
141
+ }
142
+ }
143
+
144
+ let rendered = "";
145
+ const compactProcessAnswerSpacing = options.compactProcessAnswerSpacing === true;
146
+ for (let index = 0; index < entries.length; index += 1) {
147
+ const entry = entries[index];
148
+ if (!entry?.text) {
149
+ continue;
150
+ }
151
+ const part = entry.kind === "answer"
152
+ ? entry.text
153
+ : renderProcessBlock(entry.kind, entry.text);
154
+ if (!rendered) {
155
+ rendered = part;
156
+ continue;
157
+ }
158
+ const previousKind = entries[index - 1]?.kind;
159
+ const separator =
160
+ compactProcessAnswerSpacing && previousKind
161
+ ? "\n"
162
+ : "\n\n";
163
+ rendered += `${separator}${part}`;
164
+ }
165
+
166
+ return rendered;
167
+ };
168
+
169
+ const sealLiveThinking = () => {
170
+ activeThinkingIndex = null;
171
+ };
172
+
173
+ const sealCurrentAnswer = () => {
174
+ activeAnswerIndex = null;
175
+ };
176
+
177
+ const queueRender = () => {
178
+ const rendered = renderTimeline({ compactProcessAnswerSpacing: true });
179
+ if (rendered) {
180
+ loop.update(rendered);
181
+ return;
182
+ }
183
+ loop.resetPending();
184
+ };
185
+
186
+ const flushBoundaryFrame = async () => {
187
+ if (stopped || failed) {
188
+ return;
189
+ }
190
+ await loop.flush();
191
+ await loop.waitForInFlight();
192
+ loop.resetThrottleWindow();
193
+ };
194
+
195
+ const beginBoundaryFlush = () => {
196
+ if (pendingBoundaryPromise) {
197
+ return pendingBoundaryPromise;
198
+ }
199
+ const current = flushBoundaryFrame().finally(() => {
200
+ if (pendingBoundaryPromise === current) {
201
+ pendingBoundaryPromise = null;
202
+ }
203
+ });
204
+ pendingBoundaryPromise = current;
205
+ return current;
206
+ };
207
+
208
+ const waitForPendingBoundary = async () => {
209
+ if (pendingBoundaryPromise) {
210
+ await pendingBoundaryPromise;
211
+ }
212
+ };
48
213
 
49
214
  const loop = createDraftStreamLoop({
50
- throttleMs: params.throttleMs ?? 300,
215
+ throttleMs: effectiveThrottleMs,
51
216
  isStopped: () => stopped || failed,
52
217
  sendOrEditStreamMessage: async (content: string) => {
53
218
  try {
54
219
  await streamAICard(params.card, content, false, params.log);
55
220
  lastSentContent = content;
56
- if (phase === "answer") {
57
- lastAnswerContent = content;
58
- }
221
+ lastAnswerContent = getFinalAnswerContent();
59
222
  } catch (err: unknown) {
60
223
  failed = true;
61
224
  const message = err instanceof Error ? err.message : String(err);
@@ -64,41 +227,94 @@ export function createCardDraftController(params: {
64
227
  },
65
228
  });
66
229
 
67
- return {
68
- updateReasoning: (text: string) => {
69
- if (stopped || failed || phase === "answer") { return; }
70
- phase = "reasoning";
71
- const formatted = formatContentForCard(text, "thinking");
72
- if (formatted) {
73
- loop.update(formatted);
230
+ const updateReasoning = async (text: string) => {
231
+ await waitForPendingBoundary();
232
+ if (stopped || failed || activeAnswerIndex !== null) {
233
+ return;
234
+ }
235
+ const normalized = normalizeProcessText(text);
236
+ if (!normalized) {
237
+ return;
238
+ }
239
+ if (activeThinkingIndex === null && timelineEntries.length > 0) {
240
+ const lastKind = timelineEntries.at(-1)?.kind;
241
+ if (lastKind && lastKind !== "thinking") {
242
+ await flushBoundaryFrame();
74
243
  }
75
- },
244
+ }
245
+ if (activeThinkingIndex !== null) {
246
+ timelineEntries[activeThinkingIndex] = { kind: "thinking", text: normalized };
247
+ } else {
248
+ activeThinkingIndex = appendTimelineEntry("thinking", normalized);
249
+ }
250
+ queueRender();
251
+ };
76
252
 
77
- updateAnswer: (text: string) => {
78
- if (stopped || failed) { return; }
79
- if (phase !== "answer") {
80
- params.log?.debug?.(`[DingTalk][Draft] phase ${phase} → answer`);
81
- phase = "answer";
82
- if (turnBoundaryPending && lastAnswerContent) {
83
- answerPrefix = lastAnswerContent + "\n\n";
84
- }
85
- turnBoundaryPending = false;
253
+ const updateAnswer = async (text: string) => {
254
+ await waitForPendingBoundary();
255
+ if (stopped || failed) {
256
+ return;
257
+ }
258
+ const normalized = normalizeAnswerText(text);
259
+ if (!normalized.trim()) {
260
+ return;
261
+ }
262
+ if (activeAnswerIndex === null && timelineEntries.length > 0) {
263
+ const lastKind = timelineEntries.at(-1)?.kind;
264
+ if (lastKind && lastKind !== "answer") {
265
+ await flushBoundaryFrame();
86
266
  }
87
- const trimmed = text?.trimStart();
88
- if (trimmed) {
89
- loop.update(answerPrefix + trimmed);
90
- }
91
- },
267
+ }
268
+ sealLiveThinking();
269
+ if (activeAnswerIndex !== null) {
270
+ timelineEntries[activeAnswerIndex] = { kind: "answer", text: normalized };
271
+ } else {
272
+ activeAnswerIndex = appendTimelineEntry("answer", normalized);
273
+ }
274
+ queueRender();
275
+ };
92
276
 
93
- notifyNewAssistantTurn: () => {
94
- if (stopped || failed) { return; }
95
- turnBoundaryPending = true;
96
- if (phase === "reasoning") {
97
- loop.resetPending();
98
- }
99
- phase = "idle";
100
- },
277
+ const updateTool = async (text: string) => {
278
+ await waitForPendingBoundary();
279
+ if (stopped || failed) {
280
+ return;
281
+ }
282
+ const normalized = normalizeProcessText(text);
283
+ if (!normalized) {
284
+ return;
285
+ }
286
+ if (timelineEntries.length > 0) {
287
+ await flushBoundaryFrame();
288
+ }
289
+ sealLiveThinking();
290
+ sealCurrentAnswer();
291
+ appendTimelineEntry("tool", normalized);
292
+ queueRender();
293
+ };
101
294
 
295
+ const notifyNewAssistantTurn = async () => {
296
+ if (stopped || failed) {
297
+ return;
298
+ }
299
+ if (activeAnswerIndex !== null) {
300
+ sealCurrentAnswer();
301
+ await beginBoundaryFlush();
302
+ return;
303
+ }
304
+ if (activeThinkingIndex !== null) {
305
+ removeTimelineEntry(activeThinkingIndex);
306
+ loop.resetPending();
307
+ }
308
+ };
309
+
310
+ return {
311
+ updateAnswer,
312
+ updateReasoning,
313
+ updateThinking: updateReasoning,
314
+ updateTool,
315
+ appendTool: updateTool,
316
+ notifyNewAssistantTurn,
317
+ startAssistantTurn: notifyNewAssistantTurn,
102
318
  flush: () => loop.flush(),
103
319
  waitForInFlight: () => loop.waitForInFlight(),
104
320
 
@@ -110,5 +326,7 @@ export function createCardDraftController(params: {
110
326
  isFailed: () => failed,
111
327
  getLastContent: () => lastSentContent,
112
328
  getLastAnswerContent: () => lastAnswerContent,
329
+ getFinalAnswerContent,
330
+ getRenderedContent: renderTimeline,
113
331
  };
114
332
  }