@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.
Files changed (40) hide show
  1. package/README.md +6 -23
  2. package/index.ts +7 -0
  3. package/openclaw.plugin.json +799 -0
  4. package/package.json +5 -5
  5. package/src/card/card-markdown-image-reroute.ts +106 -0
  6. package/src/card/card-run-registry.ts +54 -1
  7. package/src/card/card-stop-handler.ts +10 -20
  8. package/src/card/card-streaming-mode.ts +30 -0
  9. package/src/card/card-template.ts +14 -3
  10. package/src/card/reasoning-answer-split.ts +162 -0
  11. package/src/card/statusline-renderer.ts +94 -0
  12. package/src/card-draft-controller.ts +326 -54
  13. package/src/card-service.ts +479 -8
  14. package/src/channel.ts +19 -1062
  15. package/src/config-schema.ts +81 -38
  16. package/src/config.ts +142 -4
  17. package/src/device-registration.ts +245 -0
  18. package/src/gateway/channel-gateway.ts +636 -0
  19. package/src/inbound-handler.ts +489 -49
  20. package/src/media-utils.ts +169 -7
  21. package/src/message-utils.ts +153 -17
  22. package/src/messaging/btw-deliver.ts +85 -0
  23. package/src/messaging/channel-actions.ts +173 -0
  24. package/src/messaging/channel-outbound.ts +158 -0
  25. package/src/messaging/quoted-file-service.ts +9 -4
  26. package/src/onboarding.ts +323 -205
  27. package/src/platform/channel-status.ts +81 -0
  28. package/src/plugin-sdk-channel-actions-augment.ts +11 -0
  29. package/src/reply-strategy-card.ts +568 -44
  30. package/src/reply-strategy-markdown.ts +2 -2
  31. package/src/reply-strategy-types.ts +93 -0
  32. package/src/reply-strategy-with-reaction.ts +1 -1
  33. package/src/reply-strategy.ts +14 -56
  34. package/src/run-usage-store.ts +59 -0
  35. package/src/send-service.ts +225 -7
  36. package/src/session-state.ts +62 -0
  37. package/src/targeting/agent-name-matcher.ts +28 -0
  38. package/src/targeting/agent-routing.ts +44 -28
  39. package/src/types.ts +49 -117
  40. package/src/utils.ts +25 -0
@@ -4,33 +4,54 @@
4
4
  * The controller keeps a single rendered card timeline made of:
5
5
  * - sealed process blocks (`thinking` / `tool`)
6
6
  * - an optional live thinking block
7
- * - accumulated answer turns rendered as plain markdown
7
+ * - accumulated answer turns rendered as JSON CardBlock[]
8
8
  *
9
9
  * It delegates throttling and single-flight transport guarantees to
10
10
  * {@link createDraftStreamLoop}.
11
11
  */
12
12
 
13
- import { streamAICard } from "./card-service";
13
+ import {
14
+ clearAICardStreamingContent,
15
+ streamAICardContent,
16
+ updateAICardBlockList,
17
+ } from "./card-service";
14
18
  import { createDraftStreamLoop } from "./draft-stream-loop";
15
- import type { AICardInstance, Logger } from "./types";
19
+ import type { AICardInstance, CardBlock, Logger } from "./types";
16
20
 
17
- type TimelineEntryKind = "thinking" | "tool" | "answer";
21
+ type TimelineEntryKind = "thinking" | "tool" | "answer" | "image";
18
22
 
19
23
  type TimelineEntry = {
20
24
  kind: TimelineEntryKind;
21
25
  text: string;
26
+ mediaId?: string;
22
27
  };
23
28
 
29
+ // DingTalk markdown variable token definitions:
30
+ // https://open.dingtalk.com/document/development/markdown-variable-new
31
+ const PROCESS_BLOCK_FONT_SIZE_TOKEN = "common_footnote_text_style__font_size";
32
+ // DingTalk markdown variable token definitions:
33
+ // https://open.dingtalk.com/document/development/markdown-variable-new
34
+ const PROCESS_BLOCK_FONT_COLOR_TOKEN_V2 = "common_level2_base_color";
35
+
24
36
  export interface CardDraftController {
25
- updateAnswer: (text: string) => Promise<void>;
37
+ updateAnswer: (text: string, options?: { stream?: boolean; renderBlocks?: boolean }) => Promise<void>;
26
38
  updateReasoning: (text: string) => Promise<void>;
27
39
  updateThinking: (text: string) => Promise<void>;
28
40
  appendThinkingBlock: (text: string) => Promise<void>;
29
41
  updateTool: (text: string) => Promise<void>;
30
42
  appendTool: (text: string) => Promise<void>;
43
+ /** Append an image block (type=3) with an uploaded mediaId. */
44
+ appendImageBlock: (mediaId: string, text?: string) => Promise<void>;
45
+ appendToolBeforeCurrentAnswer: (text: string) => Promise<void>;
46
+ /** Drop the current answer draft while keeping sealed earlier turns intact. */
47
+ discardCurrentAnswer: () => void;
31
48
  /** Signal that a new assistant turn has started (e.g. after a tool call). */
32
- notifyNewAssistantTurn: () => Promise<void>;
49
+ notifyNewAssistantTurn: (options?: {
50
+ discardActiveAnswer?: boolean;
51
+ }) => Promise<void>;
33
52
  startAssistantTurn: () => Promise<void>;
53
+ /** Seal the active thinking entry (keep it in timeline) without removing it. */
54
+ sealActiveThinking: () => Promise<void>;
34
55
  flush: () => Promise<void>;
35
56
  waitForInFlight: () => Promise<void>;
36
57
  stop: () => void;
@@ -41,12 +62,24 @@ export interface CardDraftController {
41
62
  getLastAnswerContent: () => string;
42
63
  /** Current answer-only content composed from all completed answer turns. */
43
64
  getFinalAnswerContent: () => string;
44
- /** Current rendered timeline, including process blocks and answer text. */
65
+ /** Current rendered timeline as CardBlock[] JSON string for blockList parameter. */
66
+ getRenderedBlocks: (options?: {
67
+ fallbackAnswer?: string;
68
+ overrideAnswer?: string;
69
+ compactProcessAnswerSpacing?: boolean;
70
+ }) => string;
71
+ /** Current rendered timeline as pure markdown text for content parameter and fallback. */
45
72
  getRenderedContent: (options?: {
46
73
  fallbackAnswer?: string;
47
74
  overrideAnswer?: string;
48
75
  compactProcessAnswerSpacing?: boolean;
49
76
  }) => string;
77
+ /** Stream answer text to content key for real-time display. Only available when realTimeStreamEnabled=true. */
78
+ streamContent?: (text: string) => Promise<void>;
79
+ /** Clear the streaming content key. Only available when realTimeStreamEnabled=true. */
80
+ clearStreamingContent?: () => Promise<void>;
81
+ /** Whether real-time streaming is enabled. */
82
+ isRealTimeStreamEnabled: () => boolean;
50
83
  }
51
84
 
52
85
  function normalizeProcessText(text: string | undefined): string {
@@ -57,28 +90,33 @@ function normalizeAnswerText(text: string | undefined): string {
57
90
  return typeof text === "string" ? text.trimStart() : "";
58
91
  }
59
92
 
60
- function quoteMarkdown(text: string): string {
61
- return text
62
- .split("\n")
63
- .map((line) => line.trim() ? `> ${line.trim()}` : ">")
93
+ function wrapProcessBlockMarkdown(text: string): string {
94
+ const lines = text.split("\n").filter((line) => line.trim());
95
+ return lines
96
+ .map((line) => `> <font sizeToken=${PROCESS_BLOCK_FONT_SIZE_TOKEN} colorTokenV2=${PROCESS_BLOCK_FONT_COLOR_TOKEN_V2}>${line}</font>`)
64
97
  .join("\n");
65
98
  }
66
99
 
67
- function renderProcessBlock(_kind: "thinking" | "tool", text: string): string {
68
- return quoteMarkdown(text);
69
- }
70
-
71
100
  export function createCardDraftController(params: {
72
101
  card: AICardInstance;
73
102
  throttleMs?: number;
74
103
  /** Legacy compatibility: verbose mode previously lowered the throttle. */
75
104
  verboseMode?: boolean;
105
+ /** Enable real-time streaming to content key for answer display. */
106
+ realTimeStreamEnabled?: boolean;
107
+ /** Optional callback to get the current statusLine for piggy-backing on blockList updates. */
108
+ getStatusLine?: () => string | undefined;
76
109
  log?: Logger;
77
110
  }): CardDraftController {
78
111
  let failed = false;
79
112
  let stopped = false;
80
113
  let lastSentContent = "";
114
+ let lastQueuedContent = "";
115
+ let inFlightContent = "";
81
116
  let lastAnswerContent = "";
117
+ let lastSentStreamingContent = "";
118
+ let lastQueuedStreamingContent = "";
119
+ let inFlightStreamingContent = "";
82
120
 
83
121
  let timelineEntries: TimelineEntry[] = [];
84
122
  let activeThinkingIndex: number | null = null;
@@ -86,6 +124,52 @@ export function createCardDraftController(params: {
86
124
  let pendingBoundaryPromise: Promise<void> | null = null;
87
125
 
88
126
  const effectiveThrottleMs = params.throttleMs ?? (params.verboseMode ? 50 : 300);
127
+ const realTimeStreamEnabled = params.realTimeStreamEnabled ?? false;
128
+ let hasStreamingContent = false;
129
+
130
+ const clearPendingStreamingContent = () => {
131
+ contentLoop.resetPending();
132
+ lastQueuedStreamingContent = "";
133
+ };
134
+
135
+ const streamContentToCard = async (text: string) => {
136
+ if (!realTimeStreamEnabled) {
137
+ return;
138
+ }
139
+ const normalized = normalizeAnswerText(text);
140
+ if (!normalized.trim()) {
141
+ clearPendingStreamingContent();
142
+ return;
143
+ }
144
+ if (normalized === lastSentStreamingContent) {
145
+ const hasNewerInFlight = !!inFlightStreamingContent && inFlightStreamingContent !== normalized;
146
+ if (!hasNewerInFlight) {
147
+ clearPendingStreamingContent();
148
+ return;
149
+ }
150
+ }
151
+ if (normalized === lastQueuedStreamingContent) {
152
+ return;
153
+ }
154
+ lastQueuedStreamingContent = normalized;
155
+ contentLoop.update(normalized);
156
+ };
157
+
158
+ const clearStreamingContentFromCard = async () => {
159
+ clearPendingStreamingContent();
160
+ await contentLoop.waitForInFlight();
161
+ if (!realTimeStreamEnabled || !hasStreamingContent) {
162
+ return;
163
+ }
164
+ try {
165
+ await clearAICardStreamingContent(params.card, params.log);
166
+ hasStreamingContent = false;
167
+ lastSentStreamingContent = "";
168
+ } catch (err: unknown) {
169
+ const message = err instanceof Error ? err.message : String(err);
170
+ params.log?.debug?.(`[DingTalk][AICard] Failed to clear streaming content: ${message}`);
171
+ }
172
+ };
89
173
 
90
174
  const getFinalAnswerContent = (): string => {
91
175
  return timelineEntries
@@ -121,13 +205,30 @@ export function createCardDraftController(params: {
121
205
  return activeAnswerIndex;
122
206
  };
123
207
 
124
- const renderTimeline = (options: {
208
+ const findLastAnswerEntryIndex = (): number | null => {
209
+ for (let index = timelineEntries.length - 1; index >= 0; index -= 1) {
210
+ if (timelineEntries[index]?.kind === "answer") {
211
+ return index;
212
+ }
213
+ }
214
+ return null;
215
+ };
216
+
217
+ const renderTimelineAsBlocks = (options: {
125
218
  fallbackAnswer?: string;
126
219
  overrideAnswer?: string;
127
- compactProcessAnswerSpacing?: boolean;
128
- } = {}): string => {
220
+ } = {}): CardBlock[] => {
129
221
  const entries = timelineEntries.map((entry) => ({ ...entry }));
130
222
 
223
+ const insertAnswerEntry = (text: string) => {
224
+ const firstImageIndex = entries.findIndex((entry) => entry.kind === "image");
225
+ if (firstImageIndex >= 0) {
226
+ entries.splice(firstImageIndex, 0, { kind: "answer", text });
227
+ return;
228
+ }
229
+ entries.push({ kind: "answer", text });
230
+ };
231
+
131
232
  const overrideAnswer = normalizeAnswerText(options.overrideAnswer);
132
233
  if (overrideAnswer) {
133
234
  const lastAnswerIndex = [...entries]
@@ -137,38 +238,46 @@ export function createCardDraftController(params: {
137
238
  if (lastAnswerIndex !== undefined) {
138
239
  entries[lastAnswerIndex] = { kind: "answer", text: overrideAnswer };
139
240
  } else {
140
- entries.push({ kind: "answer", text: overrideAnswer });
241
+ insertAnswerEntry(overrideAnswer);
141
242
  }
142
243
  } else if (!entries.some((entry) => entry.kind === "answer" && entry.text)) {
143
244
  const fallbackAnswer = normalizeAnswerText(options.fallbackAnswer);
144
245
  if (fallbackAnswer) {
145
- entries.push({ kind: "answer", text: fallbackAnswer });
246
+ insertAnswerEntry(fallbackAnswer);
146
247
  }
147
248
  }
148
249
 
149
- let rendered = "";
150
- const compactProcessAnswerSpacing = options.compactProcessAnswerSpacing === true;
151
- for (let index = 0; index < entries.length; index += 1) {
152
- const entry = entries[index];
153
- if (!entry?.text) {
154
- continue;
155
- }
156
- const part = entry.kind === "answer"
157
- ? entry.text
158
- : renderProcessBlock(entry.kind, entry.text);
159
- if (!rendered) {
160
- rendered = part;
161
- continue;
250
+ const blocks: CardBlock[] = [];
251
+ for (const entry of entries) {
252
+ if (!entry) { continue; }
253
+ switch (entry.kind) {
254
+ case "answer":
255
+ if (entry.text?.trim()) {
256
+ blocks.push({ type: 0, markdown: entry.text });
257
+ }
258
+ break;
259
+ case "thinking":
260
+ if (entry.text?.trim()) {
261
+ blocks.push({ type: 1, markdown: wrapProcessBlockMarkdown(entry.text) });
262
+ }
263
+ break;
264
+ case "tool":
265
+ if (entry.text?.trim()) {
266
+ blocks.push({ type: 2, markdown: wrapProcessBlockMarkdown(entry.text) });
267
+ }
268
+ break;
269
+ case "image":
270
+ if (entry.mediaId?.trim()) {
271
+ blocks.push({
272
+ type: 3,
273
+ mediaId: entry.mediaId,
274
+ ...(entry.text?.trim() ? { text: entry.text } : {}),
275
+ });
276
+ }
277
+ break;
162
278
  }
163
- const previousKind = entries[index - 1]?.kind;
164
- const separator =
165
- compactProcessAnswerSpacing && previousKind
166
- ? "\n"
167
- : "\n\n";
168
- rendered += `${separator}${part}`;
169
279
  }
170
-
171
- return rendered;
280
+ return blocks;
172
281
  };
173
282
 
174
283
  const sealLiveThinking = () => {
@@ -179,21 +288,55 @@ export function createCardDraftController(params: {
179
288
  activeAnswerIndex = null;
180
289
  };
181
290
 
182
- const queueRender = () => {
183
- const rendered = renderTimeline({ compactProcessAnswerSpacing: true });
184
- if (rendered) {
185
- loop.update(rendered);
291
+ const discardCurrentAnswer = () => {
292
+ if (activeAnswerIndex === null) {
186
293
  return;
187
294
  }
295
+ removeTimelineEntry(activeAnswerIndex);
296
+ queueRender();
297
+ };
298
+
299
+ const clearPendingRender = () => {
188
300
  loop.resetPending();
301
+ lastQueuedContent = "";
302
+ };
303
+
304
+ const queueRender = () => {
305
+ const blocks = renderTimelineAsBlocks();
306
+ const rendered = JSON.stringify(blocks);
307
+
308
+ // Always update blockList via instances API (throttled)
309
+ if (blocks.length === 0) {
310
+ clearPendingRender();
311
+ return;
312
+ }
313
+ if (rendered === lastSentContent) {
314
+ const hasNewerInFlight = !!inFlightContent && inFlightContent !== rendered;
315
+ if (!hasNewerInFlight) {
316
+ clearPendingRender();
317
+ return;
318
+ }
319
+ }
320
+ if (rendered === lastQueuedContent) {
321
+ return;
322
+ }
323
+ lastQueuedContent = rendered;
324
+ loop.update(rendered);
189
325
  };
190
326
 
191
327
  const flushBoundaryFrame = async () => {
192
328
  if (stopped || failed) {
193
329
  return;
194
330
  }
331
+ await contentLoop.flush();
332
+ await contentLoop.waitForInFlight();
333
+ // Clear streaming content before committing blockList at boundary
334
+ if (hasStreamingContent) {
335
+ await clearStreamingContentFromCard();
336
+ }
195
337
  await loop.flush();
196
338
  await loop.waitForInFlight();
339
+ contentLoop.resetThrottleWindow();
197
340
  loop.resetThrottleWindow();
198
341
  };
199
342
 
@@ -220,14 +363,44 @@ export function createCardDraftController(params: {
220
363
  throttleMs: effectiveThrottleMs,
221
364
  isStopped: () => stopped || failed,
222
365
  sendOrEditStreamMessage: async (content: string) => {
366
+ inFlightContent = content;
223
367
  try {
224
- await streamAICard(params.card, content, false, params.log);
368
+ // Use instances API for blockList (not streaming API)
369
+ const statusLine = params.getStatusLine?.();
370
+ await updateAICardBlockList(params.card, content, params.log, statusLine ? { statusLine } : undefined);
225
371
  lastSentContent = content;
372
+ lastQueuedContent = "";
226
373
  lastAnswerContent = getFinalAnswerContent();
227
374
  } catch (err: unknown) {
228
375
  failed = true;
229
376
  const message = err instanceof Error ? err.message : String(err);
230
- params.log?.warn?.(`[DingTalk][AICard] Stream failed: ${message}`);
377
+ params.log?.warn?.(`[DingTalk][AICard] BlockList update failed: ${message}`);
378
+ } finally {
379
+ if (inFlightContent === content) {
380
+ inFlightContent = "";
381
+ }
382
+ }
383
+ },
384
+ });
385
+
386
+ const contentLoop = createDraftStreamLoop({
387
+ throttleMs: effectiveThrottleMs,
388
+ isStopped: () => stopped || failed,
389
+ sendOrEditStreamMessage: async (content: string) => {
390
+ inFlightStreamingContent = content;
391
+ try {
392
+ await streamAICardContent(params.card, content, params.log);
393
+ hasStreamingContent = true;
394
+ lastSentStreamingContent = content;
395
+ lastQueuedStreamingContent = "";
396
+ lastAnswerContent = content;
397
+ } catch (err: unknown) {
398
+ const message = err instanceof Error ? err.message : String(err);
399
+ params.log?.debug?.(`[DingTalk][AICard] Failed to stream content: ${message}`);
400
+ } finally {
401
+ if (inFlightStreamingContent === content) {
402
+ inFlightStreamingContent = "";
403
+ }
231
404
  }
232
405
  },
233
406
  });
@@ -255,7 +428,7 @@ export function createCardDraftController(params: {
255
428
  queueRender();
256
429
  };
257
430
 
258
- const updateAnswer = async (text: string) => {
431
+ const updateAnswer = async (text: string, options: { stream?: boolean; renderBlocks?: boolean } = {}) => {
259
432
  await waitForPendingBoundary();
260
433
  if (stopped || failed) {
261
434
  return;
@@ -276,6 +449,16 @@ export function createCardDraftController(params: {
276
449
  } else {
277
450
  activeAnswerIndex = appendTimelineEntry("answer", normalized);
278
451
  }
452
+ if (options.stream !== false) {
453
+ await streamContentToCard(normalized);
454
+ } else {
455
+ clearPendingStreamingContent();
456
+ }
457
+ const shouldRenderBlocks = options.renderBlocks ?? (options.stream !== false);
458
+ if (!shouldRenderBlocks) {
459
+ clearPendingRender();
460
+ return;
461
+ }
279
462
  queueRender();
280
463
  };
281
464
 
@@ -323,21 +506,73 @@ export function createCardDraftController(params: {
323
506
  queueRender();
324
507
  };
325
508
 
326
- const notifyNewAssistantTurn = async () => {
509
+ const appendToolBeforeCurrentAnswer = async (text: string) => {
510
+ await waitForPendingBoundary();
327
511
  if (stopped || failed) {
328
512
  return;
329
513
  }
330
- if (activeAnswerIndex !== null) {
514
+ const normalized = normalizeProcessText(text);
515
+ if (!normalized) {
516
+ return;
517
+ }
518
+ if (timelineEntries.length > 0) {
519
+ await flushBoundaryFrame();
520
+ }
521
+ sealLiveThinking();
522
+ const insertionIndex = findCurrentSegmentAnswerIndex() ?? findLastAnswerEntryIndex();
523
+ if (insertionIndex !== null) {
524
+ timelineEntries.splice(insertionIndex, 0, { kind: "tool", text: normalized });
525
+ if (activeAnswerIndex !== null && activeAnswerIndex >= insertionIndex) {
526
+ activeAnswerIndex += 1;
527
+ }
528
+ if (activeThinkingIndex !== null && activeThinkingIndex >= insertionIndex) {
529
+ activeThinkingIndex += 1;
530
+ }
531
+ } else {
331
532
  sealCurrentAnswer();
533
+ appendTimelineEntry("tool", normalized);
534
+ }
535
+ queueRender();
536
+ };
537
+
538
+ const notifyNewAssistantTurn = async (options: {
539
+ discardActiveAnswer?: boolean;
540
+ } = {}) => {
541
+ if (stopped || failed) {
542
+ return;
543
+ }
544
+ if (activeAnswerIndex !== null) {
545
+ if (options.discardActiveAnswer) {
546
+ discardCurrentAnswer();
547
+ } else {
548
+ sealCurrentAnswer();
549
+ }
332
550
  await beginBoundaryFlush();
333
551
  return;
334
552
  }
335
553
  if (activeThinkingIndex !== null) {
336
554
  removeTimelineEntry(activeThinkingIndex);
337
- loop.resetPending();
555
+ clearPendingRender();
338
556
  }
339
557
  };
340
558
 
559
+ const appendImageBlock = async (mediaId: string, text = "") => {
560
+ await waitForPendingBoundary();
561
+ if (stopped || failed) {
562
+ return;
563
+ }
564
+ if (!mediaId.trim()) {
565
+ return;
566
+ }
567
+ if (timelineEntries.length > 0) {
568
+ await flushBoundaryFrame();
569
+ }
570
+ sealLiveThinking();
571
+ sealCurrentAnswer();
572
+ timelineEntries.push({ kind: "image", text, mediaId });
573
+ queueRender();
574
+ };
575
+
341
576
  return {
342
577
  updateAnswer,
343
578
  updateReasoning,
@@ -345,13 +580,32 @@ export function createCardDraftController(params: {
345
580
  appendThinkingBlock,
346
581
  updateTool,
347
582
  appendTool: updateTool,
583
+ appendImageBlock,
584
+ appendToolBeforeCurrentAnswer,
585
+ discardCurrentAnswer,
348
586
  notifyNewAssistantTurn,
349
587
  startAssistantTurn: notifyNewAssistantTurn,
350
- flush: () => loop.flush(),
351
- waitForInFlight: () => loop.waitForInFlight(),
588
+ sealActiveThinking: async () => {
589
+ if (stopped || failed) {
590
+ return;
591
+ }
592
+ if (activeThinkingIndex !== null) {
593
+ sealLiveThinking();
594
+ await beginBoundaryFlush();
595
+ }
596
+ },
597
+ flush: async () => {
598
+ await contentLoop.flush();
599
+ await loop.flush();
600
+ },
601
+ waitForInFlight: async () => {
602
+ await contentLoop.waitForInFlight();
603
+ await loop.waitForInFlight();
604
+ },
352
605
 
353
606
  stop: () => {
354
607
  stopped = true;
608
+ contentLoop.stop();
355
609
  loop.stop();
356
610
  },
357
611
 
@@ -359,6 +613,24 @@ export function createCardDraftController(params: {
359
613
  getLastContent: () => lastSentContent,
360
614
  getLastAnswerContent: () => lastAnswerContent,
361
615
  getFinalAnswerContent,
362
- getRenderedContent: renderTimeline,
616
+ getRenderedBlocks: (options?: { fallbackAnswer?: string; overrideAnswer?: string }) => {
617
+ const blocks = renderTimelineAsBlocks(options);
618
+ if (blocks.length === 0) {
619
+ return "";
620
+ }
621
+ return JSON.stringify(blocks);
622
+ },
623
+ getRenderedContent: (options?: { fallbackAnswer?: string; overrideAnswer?: string; compactProcessAnswerSpacing?: boolean }) => {
624
+ const blocks = renderTimelineAsBlocks(options);
625
+ // Extract markdown from answer blocks (type: 0) and join with double newlines
626
+ const answerTexts = blocks
627
+ .filter((block) => block.type === 0 && "markdown" in block && block.markdown)
628
+ .map((block) => ("markdown" in block ? block.markdown : ""));
629
+ return answerTexts.join("\n\n");
630
+ },
631
+
632
+ streamContent: realTimeStreamEnabled ? streamContentToCard : undefined,
633
+ clearStreamingContent: realTimeStreamEnabled ? clearStreamingContentFromCard : undefined,
634
+ isRealTimeStreamEnabled: () => realTimeStreamEnabled,
363
635
  };
364
636
  }