@xmanrui/dsh-im 4.16.0 โ†’ 4.17.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.
@@ -903,6 +903,136 @@ export function approvalCard({ toolName, operation, reason, approvalId }) {
903
903
  return cardWith(t('๐Ÿ” ๅทฅๅ…ทๅฎกๆ‰น'), elements);
904
904
  }
905
905
 
906
+ // โ”€โ”€ Streaming step card (ๆตๅผ่ฟ‡็จ‹ๅก็‰‡) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
907
+
908
+ /** One streaming step card carries at most this many JSON bytes after the
909
+ * card has been serialized (Feishu caps card content near 30KB; stay lower
910
+ * so headers and JSON escaping always fit). */
911
+ export const STEP_STREAM_CARD_MAX_BYTES = 24_000;
912
+
913
+ /**
914
+ * Build one streaming step card from the accumulated process blocks:
915
+ * { kind: 'message', text } โ€” interim note / warning / context line
916
+ * { kind: 'tools', lines: string[] } โ€” tool-call summary panel
917
+ * `status`: 'running' keeps panels expanded and ends with an italic status
918
+ * line; 'completed' / 'stopped' collapse the panels and swap the status text;
919
+ * 'sealed' is an overflow spill chunk with no status line at all. Finished
920
+ * turns (and sealed spill chunks) merge every tool/thinking panel into one
921
+ * collapsed "process details" panel so the sealed card stays compact.
922
+ */
923
+
924
+ export function stepStreamCard(rawBlocks, { status = 'running' } = {}) {
925
+ const running = status === 'running';
926
+ const elements = [];
927
+ const panelElements = [];
928
+ for (const block of Array.isArray(rawBlocks) ? rawBlocks : []) {
929
+ if (block?.kind === 'tools' || block?.kind === 'notes') {
930
+ const lines = (Array.isArray(block.lines) ? block.lines : [])
931
+ .filter((line) => typeof line === 'string' && line.trim());
932
+ if (lines.length === 0) continue;
933
+ const count = lines.length + (Number(block.omitted) || 0);
934
+ const panel = stepPanel(lines, {
935
+ title: block.kind === 'tools'
936
+ ? t('๐Ÿ› ๏ธ ๅทฅๅ…ทๆ‘˜่ฆ๏ผˆ{count}๏ผ‰', { count })
937
+ : t('๐Ÿ’ญ ๆ€่€ƒ่ฟ‡็จ‹๏ผˆ{count}๏ผ‰', { count }),
938
+ // Tool summaries stay visible while the turn runs; thinking notes
939
+ // remain folded at all times. Finished turns keep both panels but
940
+ // tuck them inside one collapsed "process details" wrapper.
941
+ expanded: block.kind === 'tools' && running,
942
+ });
943
+ if (running) elements.push(panel);
944
+ else panelElements.push(panel);
945
+ continue;
946
+ }
947
+ const text = typeof block?.text === 'string' ? block.text.trim() : '';
948
+ if (text) elements.push({ tag: 'markdown', content: text });
949
+ }
950
+ if (!running && panelElements.length > 0) {
951
+ // Finished turns: the tool/thinking panels nest inside one collapsed
952
+ // "process details" wrapper, so the sealed card shows a single line.
953
+ elements.push(processDetailsPanel(panelElements));
954
+ }
955
+ if (elements.length === 0) elements.push({ tag: 'markdown', content: ' ' });
956
+ if (status !== 'sealed') {
957
+ elements.push({ tag: 'markdown', content: `_${stepStatusText(status)}_` });
958
+ }
959
+ return JSON.stringify({
960
+ schema: '2.0',
961
+ header: { title: plainText(t('โš™๏ธ ไปปๅŠก่ฟ‡็จ‹')), template: 'blue' },
962
+ body: { elements },
963
+ });
964
+ }
965
+
966
+ /** The collapsed wrapper that holds the per-kind panels on finished turns. */
967
+ function processDetailsPanel(children) {
968
+ return {
969
+ tag: 'collapsible_panel',
970
+ expanded: false,
971
+ background_color: 'grey-50',
972
+ border: { color: 'grey', corner_radius: '8px' },
973
+ padding: '8px 8px 8px 8px',
974
+ header: {
975
+ title: { tag: 'plain_text', content: t('๐Ÿ“‹ ่ฟ‡็จ‹่ฏฆๆƒ…') },
976
+ vertical_align: 'center',
977
+ padding: '8px 8px 8px 8px',
978
+ icon: { tag: 'standard_icon', token: 'down-small-ccm_outlined', color: 'grey', size: '16px 16px' },
979
+ icon_position: 'right',
980
+ icon_expanded_angle: -180,
981
+ },
982
+ elements: children,
983
+ };
984
+ }
985
+
986
+ /** The collapsible grey panel used for tool summaries and thinking notes. */
987
+ function stepPanel(lines, { title, expanded }) {
988
+ return {
989
+ tag: 'collapsible_panel',
990
+ expanded: expanded === true,
991
+ background_color: 'grey-50',
992
+ border: { color: 'grey', corner_radius: '8px' },
993
+ padding: '8px 8px 8px 8px',
994
+ header: {
995
+ title: { tag: 'plain_text', content: title },
996
+ vertical_align: 'center',
997
+ padding: '8px 8px 8px 8px',
998
+ icon: { tag: 'standard_icon', token: 'down-small-ccm_outlined', color: 'grey', size: '16px 16px' },
999
+ icon_position: 'right',
1000
+ icon_expanded_angle: -180,
1001
+ },
1002
+ elements: [{ tag: 'markdown', content: lines.join('\n') }],
1003
+ };
1004
+ }
1005
+
1006
+ function stepStatusText(status) {
1007
+ if (status === 'completed') return t('ๅทฒๅฎŒๆˆ');
1008
+ if (status === 'stopped') return t('ๅทฒๅœๆญข');
1009
+ return t('่ฟ่กŒไธญ');
1010
+ }
1011
+
1012
+ /**
1013
+ * Split accumulated blocks into card-sized chunks at block boundaries,
1014
+ * budgeted by the encoded running-status card (the largest render). Every
1015
+ * chunk keeps at least one block so progress is never dropped. The caller
1016
+ * renders all but the last chunk as `sealed` and the last one live.
1017
+ */
1018
+ export function splitStepStreamCardBlocks(blocks, limit = STEP_STREAM_CARD_MAX_BYTES) {
1019
+ const list = (Array.isArray(blocks) ? blocks : []).filter(Boolean);
1020
+ if (list.length === 0) return [];
1021
+ const chunks = [];
1022
+ let current = [];
1023
+ for (const block of list) {
1024
+ if (current.length > 0
1025
+ && Buffer.byteLength(stepStreamCard([...current, block]), 'utf8') > limit) {
1026
+ chunks.push(current);
1027
+ current = [block];
1028
+ } else {
1029
+ current.push(block);
1030
+ }
1031
+ }
1032
+ if (current.length > 0) chunks.push(current);
1033
+ return chunks;
1034
+ }
1035
+
906
1036
  /**
907
1037
  * Interactive question card. When the question carries options, each option is
908
1038
  * rendered as its own button; the selected option label is submitted via a
@@ -3,6 +3,7 @@ import { FeishuHarnessBridge } from './bridge.mjs';
3
3
  import { cardActionProbeCard } from './feishu-cards.mjs';
4
4
  import { VerifiedFeishuChannel } from './feishu-channel.mjs';
5
5
  import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
6
+ import { normalizeFeishuStepPushMode } from './step-push-mode.mjs';
6
7
  import {
7
8
  registerSlashCommands,
8
9
  SLASH_COMMAND_MANIFEST,
@@ -112,6 +113,7 @@ export class FeishuRuntime {
112
113
  #groupResponseMode;
113
114
  #groupTopicReply;
114
115
  #stepPush;
116
+ #stepPushMode;
115
117
  #ownerOpenIds;
116
118
  #harness;
117
119
  #state;
@@ -143,6 +145,7 @@ export class FeishuRuntime {
143
145
  groupResponseMode,
144
146
  groupTopicReply = false,
145
147
  stepPush = false,
148
+ stepPushMode = 'post',
146
149
  ownerOpenId,
147
150
  ownerOpenIds,
148
151
  harness,
@@ -180,6 +183,7 @@ export class FeishuRuntime {
180
183
  this.#groupResponseMode = normalizeFeishuGroupResponseMode(groupResponseMode);
181
184
  this.#groupTopicReply = groupTopicReply === true;
182
185
  this.#stepPush = stepPush === true;
186
+ this.#stepPushMode = normalizeFeishuStepPushMode(stepPushMode);
183
187
  this.#ownerOpenIds = normalizedOwners;
184
188
  this.#harness = harness;
185
189
  this.#state = state;
@@ -214,6 +218,11 @@ export class FeishuRuntime {
214
218
  this.#bridge?.setStepPush(this.#stepPush);
215
219
  }
216
220
 
221
+ setStepPushMode(value) {
222
+ this.#stepPushMode = normalizeFeishuStepPushMode(value);
223
+ this.#bridge?.setStepPushMode(this.#stepPushMode);
224
+ }
225
+
217
226
  async start() {
218
227
  while (true) {
219
228
  while (this.#stopping) await this.#stopping;
@@ -304,6 +313,7 @@ export class FeishuRuntime {
304
313
  groupResponseMode: this.#groupResponseMode,
305
314
  groupTopicReply: this.#groupTopicReply,
306
315
  stepPush: this.#stepPush,
316
+ stepPushMode: this.#stepPushMode,
307
317
  repair: this.#repair,
308
318
  replyTimeoutMs: this.#replyTimeoutMs,
309
319
  // Interaction cards (approval/question buttons) are on by default.
@@ -15,6 +15,11 @@ import {
15
15
  isFeishuGroupResponseMode,
16
16
  normalizeFeishuGroupResponseMode,
17
17
  } from './group-response-mode.mjs';
18
+ import {
19
+ DEFAULT_FEISHU_STEP_PUSH_MODE,
20
+ isFeishuStepPushMode,
21
+ normalizeFeishuStepPushMode,
22
+ } from './step-push-mode.mjs';
18
23
 
19
24
  const ACTIVE_REGISTRATION_STATES = new Set([
20
25
  'starting', 'qr_ready', 'polling', 'slow_down', 'domain_switched',
@@ -92,6 +97,7 @@ function configuredBotFingerprint(config) {
92
97
  groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
93
98
  groupTopicReply: config.groupTopicReply === true,
94
99
  stepPush: config.stepPush === true,
100
+ stepPushMode: normalizeFeishuStepPushMode(config.stepPushMode),
95
101
  groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
96
102
  deletionPending: config.deletionPending === true,
97
103
  connectedAt: config.connectedAt ?? null,
@@ -447,6 +453,8 @@ export class MultiBotDshFeishuController {
447
453
  botName: bot.name,
448
454
  botOpenId: bot.openId,
449
455
  activated: bot.activated,
456
+ stepPush: existing?.stepPush ?? true,
457
+ stepPushMode: existing?.stepPushMode ?? DEFAULT_FEISHU_STEP_PUSH_MODE,
450
458
  deletionPending: false,
451
459
  connectedAt: new Date().toISOString(),
452
460
  createdAt: existing?.createdAt ?? new Date().toISOString(),
@@ -614,6 +622,20 @@ export class MultiBotDshFeishuController {
614
622
  }));
615
623
  }
616
624
 
625
+ async updateStepPushMode(botId, stepPushMode) {
626
+ this.#assertOpen();
627
+ if (!isFeishuStepPushMode(stepPushMode)) {
628
+ throw new TypeError('Invalid Feishu step push mode');
629
+ }
630
+ return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
631
+ const config = this.#requireBot(botId);
632
+ const saved = await this.#configStore.saveBot({ ...config, stepPushMode });
633
+ this.#runtimes.get(botId)?.setStepPushMode?.(saved.stepPushMode);
634
+ this.#touch();
635
+ return this.status(botId);
636
+ }));
637
+ }
638
+
617
639
  async deleteBot(botId) {
618
640
  this.#assertOpen();
619
641
  return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
@@ -678,6 +700,7 @@ export class MultiBotDshFeishuController {
678
700
  groupResponseMode: normalizeFeishuGroupResponseMode(config.groupResponseMode),
679
701
  groupTopicReply: config.groupTopicReply === true,
680
702
  stepPush: config.stepPush === true,
703
+ stepPushMode: normalizeFeishuStepPushMode(config.stepPushMode),
681
704
  groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
682
705
  bot: publicBot(config),
683
706
  connection,
@@ -1111,6 +1134,11 @@ export class MultiBotDshFeishuController {
1111
1134
  botName: bot.name,
1112
1135
  botOpenId: bot.openId,
1113
1136
  activated: bot.activated,
1137
+ // New connections start with the process-card presentation; existing
1138
+ // bots keep whatever they saved before (the spread above re-applies
1139
+ // their stored values, undefined falls through to the defaults here).
1140
+ stepPush: existing?.stepPush ?? true,
1141
+ stepPushMode: existing?.stepPushMode ?? DEFAULT_FEISHU_STEP_PUSH_MODE,
1114
1142
  deletionPending: false,
1115
1143
  connectedAt: new Date().toISOString(),
1116
1144
  createdAt: existing?.createdAt ?? new Date().toISOString(),
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
3
  import { dirname } from 'node:path';
4
4
  import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
5
+ import { normalizeFeishuStepPushMode } from './step-push-mode.mjs';
5
6
 
6
7
  export const LEGACY_FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET';
7
8
 
@@ -47,6 +48,7 @@ function normalizeBot(value, { legacy = false } = {}) {
47
48
  groupResponseMode: normalizeFeishuGroupResponseMode(value.groupResponseMode),
48
49
  groupTopicReply: value.groupTopicReply === true,
49
50
  stepPush: value.stepPush === true,
51
+ stepPushMode: normalizeFeishuStepPushMode(value.stepPushMode),
50
52
  groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
51
53
  deletionPending: value.deletionPending === true,
52
54
  connectedAt: cleanString(value.connectedAt),
@@ -0,0 +1,19 @@
1
+ export const FEISHU_STEP_PUSH_MODES = Object.freeze({
2
+ POST: 'post',
3
+ STREAMING_CARD: 'streaming_card',
4
+ });
5
+
6
+ /** New connections explicitly opt into the process-card presentation. */
7
+ export const DEFAULT_FEISHU_STEP_PUSH_MODE = FEISHU_STEP_PUSH_MODES.STREAMING_CARD;
8
+
9
+ export function normalizeFeishuStepPushMode(value) {
10
+ // Bots created before modes existed used posts when step push was enabled.
11
+ return value === FEISHU_STEP_PUSH_MODES.STREAMING_CARD
12
+ ? FEISHU_STEP_PUSH_MODES.STREAMING_CARD
13
+ : FEISHU_STEP_PUSH_MODES.POST;
14
+ }
15
+
16
+ export function isFeishuStepPushMode(value) {
17
+ return value === FEISHU_STEP_PUSH_MODES.POST
18
+ || value === FEISHU_STEP_PUSH_MODES.STREAMING_CARD;
19
+ }
@@ -323,6 +323,14 @@ export default {
323
323
  '๐Ÿ”™ ่ฟ”ๅ›ž่œๅ•': '๐Ÿ”™ Back to menu',
324
324
  'ๅทฒๅฎŒๆˆ': 'Completed',
325
325
  'ๅทฒๅœๆญข': 'Stopped',
326
+ '่ฟ่กŒไธญ': 'Running',
327
+ 'โš™๏ธ ไปปๅŠก่ฟ‡็จ‹': 'โš™๏ธ Task progress',
328
+ '๐Ÿ“‹ ่ฟ‡็จ‹่ฏฆๆƒ…': '๐Ÿ“‹ Process details',
329
+ '๐Ÿ› ๏ธ ๅทฅๅ…ทๆ‘˜่ฆ๏ผˆ{count}๏ผ‰': '๐Ÿ› ๏ธ Tool summary ({count})',
330
+ '๐Ÿ’ญ ๆ€่€ƒ่ฟ‡็จ‹๏ผˆ{count}๏ผ‰': '๐Ÿ’ญ Thinking ({count})',
331
+ '๐Ÿ› ๏ธ ๅทฅๅ…ท': '๐Ÿ› ๏ธ Tools',
332
+ '๐Ÿ’ญ ๆ€่€ƒ': '๐Ÿ’ญ Thinking',
333
+ '๐Ÿ“‹ ่ฟ‡็จ‹่ฏฆๆƒ…๏ผˆๅทฅๅ…ท {tools} ยท ๆ€่€ƒ {notes}๏ผ‰': '๐Ÿ“‹ Process details (tools {tools} ยท thinking {notes})',
326
334
  'ๅทฒไธญๆญข': 'Aborted',
327
335
  'ๅทฒๅ–ๆถˆ': 'Cancelled',
328
336
  'ๅทฒ็ป“ๆŸ': 'Ended',