@xmanrui/dsh-im 4.37.0 → 4.38.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.37.0",
3
+ "version": "4.38.0",
4
4
  "icon": "./assets/logo-plugin-message-link-gradient.webp",
5
5
  "description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。 Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH.",
6
6
  "keywords": [
@@ -1,32 +1,45 @@
1
1
  import * as React from 'react';
2
2
  import { h } from './i18n.js';
3
3
  import { SET_MESSAGE_MODE_ENDPOINT, normalizeBusyMessageMode } from '../../src/channels/shared/message-mode.mjs';
4
+ import { normalizeFeishuStepCardPanels } from '../../src/channels/feishu/step-push-mode.mjs';
5
+ import { FEISHU_ENDPOINTS } from './channels/feishu/api.js';
4
6
 
5
- function modeFromResult(result, botId) {
6
- if (result?.ok !== true) throw new Error(result?.error?.message || '无法读取消息处理设置,请稍后重试。');
7
+ function settingsFromResult(result, botId) {
8
+ if (result?.ok !== true) throw new Error(result?.error?.message || '无法读取通用设置,请稍后重试。');
7
9
  const bot = result.value?.bots?.find((entry) => entry.botId === botId);
8
10
  if (!bot) throw new Error('找不到要修改的机器人。');
9
- return normalizeBusyMessageMode(bot.busyMessageMode);
11
+ return {
12
+ busyMessageMode: normalizeBusyMessageMode(bot.busyMessageMode),
13
+ stepCardPanels: normalizeFeishuStepCardPanels(bot.stepCardPanels),
14
+ };
10
15
  }
11
16
 
12
- export function BotGeneralSettingsPage({ account, rpcCall }) {
13
- const [mode, setMode] = React.useState('queue');
14
- const [savedMode, setSavedMode] = React.useState(null);
17
+ export function BotGeneralSettingsPage({ channel, account, rpcCall }) {
18
+ const modeId = React.useId();
19
+ const [settings, setSettings] = React.useState(() => ({
20
+ busyMessageMode: 'queue', stepCardPanels: normalizeFeishuStepCardPanels(),
21
+ }));
22
+ const [savedSettings, setSavedSettings] = React.useState(null);
15
23
  const [busy, setBusy] = React.useState(false);
16
24
  const [feedback, setFeedback] = React.useState(null);
17
25
  const mounted = React.useRef(false);
26
+ const inFlight = React.useRef(false);
27
+ const mode = settings.busyMessageMode;
18
28
 
19
29
  const load = React.useCallback(async () => {
30
+ if (inFlight.current) return;
31
+ inFlight.current = true;
20
32
  setBusy(true);
21
33
  setFeedback(null);
22
34
  try {
23
- const value = modeFromResult(await rpcCall('connection.status', {}), account.botId);
35
+ const value = settingsFromResult(await rpcCall('connection.status', {}), account.botId);
24
36
  if (!mounted.current) return;
25
- setMode(value);
26
- setSavedMode(value);
37
+ setSettings(value);
38
+ setSavedSettings(value);
27
39
  } catch (error) {
28
- if (mounted.current) setFeedback({ error: true, text: error.message });
40
+ if (mounted.current) setFeedback(error.message);
29
41
  } finally {
42
+ inFlight.current = false;
30
43
  if (mounted.current) setBusy(false);
31
44
  }
32
45
  }, [account.botId, rpcCall]);
@@ -37,47 +50,71 @@ export function BotGeneralSettingsPage({ account, rpcCall }) {
37
50
  return () => { mounted.current = false; };
38
51
  }, [load]);
39
52
 
40
- const save = async () => {
53
+ const save = async (field, value) => {
54
+ if (inFlight.current || savedSettings === null || value === savedSettings[field]) return;
55
+ inFlight.current = true;
56
+ setSettings({ ...settings, [field]: value });
41
57
  setBusy(true);
42
58
  setFeedback(null);
43
59
  try {
44
- const value = modeFromResult(await rpcCall(SET_MESSAGE_MODE_ENDPOINT, {
45
- botId: account.botId, busyMessageMode: mode,
60
+ const endpoint = field === 'stepCardPanels' ? FEISHU_ENDPOINTS.setStepCardPanels : SET_MESSAGE_MODE_ENDPOINT;
61
+ const saved = settingsFromResult(await rpcCall(endpoint, {
62
+ botId: account.botId, [field]: value,
46
63
  }), account.botId);
47
64
  if (!mounted.current) return;
48
- setMode(value);
49
- setSavedMode(value);
50
- setFeedback({ text: '已保存,对新收到的消息生效。' });
65
+ setSettings(saved);
66
+ setSavedSettings(saved);
51
67
  } catch (error) {
52
- if (mounted.current) setFeedback({ error: true, text: error.message });
68
+ if (mounted.current) {
69
+ setSettings(savedSettings);
70
+ setFeedback(error.message);
71
+ }
53
72
  } finally {
73
+ inFlight.current = false;
54
74
  if (mounted.current) setBusy(false);
55
75
  }
56
76
  };
57
77
 
58
- return h('section', { className: 'dim-accessPage', 'aria-label': '机器人通用设置' },
59
- h('h2', null, '通用'),
60
- h('p', null, '以下设置仅影响当前机器人。'),
61
- h('label', { className: 'dim-accessField' },
62
- h('span', null, '任务运行时的新消息处理方式'),
63
- h('select', {
64
- value: mode, disabled: busy || savedMode === null,
65
- 'aria-label': '任务运行时的新消息处理方式',
66
- onChange: (event) => { setMode(event.target.value); setFeedback(null); },
67
- },
68
- h('option', { value: 'queue' }, '排队(默认)'),
69
- h('option', { value: 'steer' }, '插话'))),
70
- h('p', null, mode === 'queue'
71
- ? '当前任务结束后,再处理新消息。可使用 /steer 手动插话。'
72
- : '将新的纯文字消息作为补充指令加入当前任务,Agent 在下一步读取。'),
73
- h('p', null, '没有正在运行的任务时,正常开始新任务。图片、文件、语音和带引用的消息按原有方式处理。'),
74
- h('button', {
75
- type: 'button', className: 'dim-deliveryButton', 'data-kind': 'primary',
76
- disabled: busy || savedMode === null || mode === savedMode,
77
- onClick: () => void save(),
78
- }, busy ? '处理中…' : '保存'),
79
- savedMode === null && !busy ? h('button', {
80
- type: 'button', className: 'dim-deliveryButton', onClick: () => void load(),
81
- }, '重新读取') : null,
82
- feedback ? h('p', { className: 'dim-targetFeedback', role: feedback.error ? 'alert' : 'status' }, feedback.text) : null);
78
+ return h('section', { className: 'dim-botGeneralSettings', 'aria-label': '机器人通用设置', 'aria-busy': busy },
79
+ h('div', { className: 'dim-botGeneralList' },
80
+ h('div', { className: 'dim-botGeneralRow' },
81
+ h('div', { className: 'dim-botGeneralText' },
82
+ h('label', { className: 'dim-botGeneralTitle', htmlFor: modeId }, '消息处理方式'),
83
+ h('p', { className: 'dim-botGeneralHelp', id: `${modeId}-description` }, mode === 'queue'
84
+ ? '当前任务结束后处理新消息'
85
+ : '将新消息补充到当前任务')),
86
+ h('div', { className: 'dim-botGeneralControl' },
87
+ h('select', {
88
+ id: modeId, value: mode, disabled: busy || savedSettings === null,
89
+ 'aria-label': '消息处理方式',
90
+ 'aria-describedby': `${modeId}-description`,
91
+ onChange: (event) => void save('busyMessageMode', event.target.value),
92
+ },
93
+ h('option', { value: 'queue' }, '排队'),
94
+ h('option', { value: 'steer' }, '插话')),
95
+ h('svg', { width: 14, height: 14, viewBox: '0 0 16 16', fill: 'none', 'aria-hidden': true },
96
+ h('path', { d: 'm4 6 4 4 4-4', stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round', strokeLinejoin: 'round' })))),
97
+ channel === 'feishu' ? [
98
+ ['thinkingExpanded', '展开思考过程'],
99
+ ['toolsExpanded', '展开工具摘要'],
100
+ ].map(([field, label]) => h('div', { key: field, className: 'dim-botGeneralRow dim-botGeneralPanelRow' },
101
+ h('label', { className: 'dim-botGeneralTitle', htmlFor: `${modeId}-${field}` }, label),
102
+ h('div', { className: 'dim-botGeneralControl' },
103
+ h('input', {
104
+ id: `${modeId}-${field}`, type: 'checkbox', role: 'switch', className: 'dim-contextSwitch',
105
+ checked: settings.stepCardPanels[field], disabled: busy || savedSettings === null,
106
+ 'aria-label': label, 'aria-describedby': `${modeId}-panels-description`,
107
+ onChange: (event) => void save('stepCardPanels', {
108
+ ...settings.stepCardPanels, [field]: event.target.checked,
109
+ }),
110
+ })))) : null),
111
+ channel === 'feishu' ? h('p', { className: 'dim-botGeneralHelp dim-botGeneralPanelHelp', id: `${modeId}-panels-description` },
112
+ '仅对实时过程卡生效,任务结束后自动收起。') : null,
113
+ feedback ? h('p', {
114
+ className: 'dim-accessFeedback', 'data-tone': 'error', role: 'alert',
115
+ }, feedback) : null,
116
+ savedSettings === null && !busy ? h('div', { className: 'dim-botGeneralActions' },
117
+ h('button', {
118
+ type: 'button', className: 'dim-deliveryButton', onClick: () => void load(),
119
+ }, '重新读取')) : null);
83
120
  }
@@ -14,7 +14,7 @@ import { normalizeModelCatalog, normalizeModelSelection, SET_MODEL_ENDPOINT } fr
14
14
  import { normalizeLastMessageError } from "../../last-message-error.js";
15
15
  import { normalizeAccessPolicy } from "../../../../src/channels/shared/access-policy.mjs";
16
16
  import { normalizeContextEnhancementConfig } from "../../../../src/channels/shared/context-enhancement.mjs";
17
- import { normalizeFeishuStepPushMode } from "../../../../src/channels/feishu/step-push-mode.mjs";
17
+ import { normalizeFeishuStepPushMode, normalizeFeishuStepCardPanels } from "../../../../src/channels/feishu/step-push-mode.mjs";
18
18
  import { normalizeFeishuVoiceConfig } from "../../../../src/channels/feishu/voice-config.mjs";
19
19
  import { normalizeSlashPanelConfig } from "../../../../src/channels/feishu/slash-command-panel.mjs";
20
20
 
@@ -42,6 +42,7 @@ export const FEISHU_ENDPOINTS = Object.freeze({
42
42
  setMentionTopicReply: "bot.mention-topic-reply.set",
43
43
  setStepPush: "bot.step-push.set",
44
44
  setStepPushMode: "bot.step-push-mode.set",
45
+ setStepCardPanels: "bot.step-card-panels.set",
45
46
  setVoice: "bot.voice.set",
46
47
  setSlashPanel: "bot.slash-panel.set",
47
48
  // Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
@@ -233,6 +234,7 @@ export function normalizeBotConnection(value, fallbackBotId) {
233
234
  mentionTopicReply: value.mentionTopicReply !== false,
234
235
  stepPush: value.stepPush === true,
235
236
  stepPushMode: normalizeFeishuStepPushMode(value.stepPushMode),
237
+ stepCardPanels: normalizeFeishuStepCardPanels(value.stepCardPanels),
236
238
  voice: normalizeFeishuVoiceConfig(value.voice),
237
239
  slashPanel: normalizeSlashPanelConfig(value.slashPanel),
238
240
  groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
@@ -26,9 +26,9 @@ export const DELIVERY_ENDPOINTS = Object.freeze({
26
26
  });
27
27
 
28
28
  export const BOT_SETTINGS_TABS = Object.freeze([
29
+ Object.freeze({ id: 'general', label: '通用' }),
29
30
  Object.freeze({ id: 'delivery', label: '投递设置' }),
30
31
  Object.freeze({ id: 'access', label: '访问设置' }),
31
- Object.freeze({ id: 'general', label: '通用' }),
32
32
  ]);
33
33
 
34
34
  export const FEISHU_BOT_SETTINGS_TABS = Object.freeze([
@@ -781,7 +781,7 @@ export function DeliveryTargetSettingsPage({
781
781
  'aria-labelledby': activeTabDomId,
782
782
  },
783
783
  activeTab.id === 'general'
784
- ? h(BotGeneralSettingsPage, { key: account.botId, account, rpcCall: accessRpcCall })
784
+ ? h(BotGeneralSettingsPage, { key: `${channel}:${account.botId}`, channel, account, rpcCall: accessRpcCall })
785
785
  : activeTab.id === 'access'
786
786
  ? h(AccessPolicySettingsPage, {
787
787
  channel,
@@ -1028,14 +1028,15 @@ const EN = Object.freeze({
1028
1028
  "通用": "General",
1029
1029
  "处理中…": "Processing…",
1030
1030
  "机器人通用设置": "General bot settings",
1031
- "以下设置仅影响当前机器人。": "These settings apply only to this bot.",
1032
- "任务运行时的新消息处理方式": "New messages while a task is running",
1033
- "排队(默认)": "Queue (default)",
1031
+ '展开思考过程': 'Expand thinking',
1032
+ '展开工具摘要': 'Expand tool summaries',
1033
+ '仅对实时过程卡生效,任务结束后自动收起。': 'Applies to streaming process cards only. Panels collapse when the task ends.',
1034
+ '请选择实时过程卡面板的展开状态。': 'Choose whether to expand the streaming process card panels.',
1035
+ "消息处理方式": "Message handling",
1036
+ "排队": "Queue",
1034
1037
  "插话": "Steer",
1035
- "当前任务结束后,再处理新消息。可使用 /steer 手动插话。": "Process new messages after the current task finishes. Use /steer to send an explicit correction.",
1036
- "将新的纯文字消息作为补充指令加入当前任务,Agent 在下一步读取。": "Add new plain-text messages to the running task. The Agent reads them at its next step.",
1037
- "没有正在运行的任务时,正常开始新任务。图片、文件、语音和带引用的消息按原有方式处理。": "When idle, start a new task normally. Images, files, voice and quoted messages keep their existing handling.",
1038
- "已保存,对新收到的消息生效。": "Saved. Applies to newly received messages.",
1038
+ "当前任务结束后处理新消息": "Process new messages after the current task",
1039
+ "将新消息补充到当前任务": "Add new messages to the current task",
1039
1040
  "无法读取消息处理设置,请稍后重试。": "Could not read message handling settings. Try again later.",
1040
1041
  "请提交有效的消息处理方式。": "Choose a valid message handling mode.",
1041
1042
  '通用设置': 'General settings',
@@ -633,6 +633,25 @@ const CSS = String.raw`
633
633
  .dim-accessFeedback[data-tone="success"] { color: var(--dsw-alias-state-success-primary, #20a162); }
634
634
  .dim-accessFeedback[data-tone="error"] { color: var(--dsw-alias-state-error-primary, #d54941); }
635
635
  .dim-accessActions { display: flex; justify-content: flex-end; }
636
+ .dim-botGeneralSettings { min-width: 0; }
637
+ .dim-botGeneralList { min-width: 0; display: grid; }
638
+ .dim-botGeneralRow { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; align-items: center; gap: 24px; padding: 16px 0; border-bottom: 1px solid var(--dsw-alias-border-l2, #dfe1e5); }
639
+ .dim-botGeneralRow:last-child { border-bottom: 0; }
640
+ .dim-botGeneralPanelRow { border-bottom: 0; }
641
+ .dim-botGeneralText { min-width: 0; display: grid; gap: 4px; }
642
+ .dim-botGeneralTitle { color: var(--dsw-alias-label-primary, #1f2329); font-size: 14px; line-height: 22px; font-weight: 400; }
643
+ .dim-botGeneralHelp { margin: 0; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; line-height: 18px; overflow-wrap: anywhere; }
644
+ .dim-botGeneralPanelHelp { padding-bottom: 16px; border-bottom: 1px solid var(--dsw-alias-border-l2, #dfe1e5); }
645
+ .dim-botGeneralControl { position: relative; min-width: 0; max-width: 100%; justify-self: end; color: var(--dsw-alias-label-primary, #1f2329); }
646
+ .dim-botGeneralControl select { display: block; min-width: 104px; max-width: 100%; height: 36px; padding: 0 36px 0 14px; border: 0; border-radius: 10px; color: inherit; background: var(--dsw-alias-bg-module-platform, #f5f6f7); font: inherit; font-size: 14px; line-height: 22px; appearance: none; cursor: pointer; }
647
+ .dim-botGeneralControl select:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, #eef0f3); }
648
+ .dim-botGeneralControl select:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 2px; }
649
+ .dim-botGeneralControl select:disabled { opacity: .5; cursor: not-allowed; }
650
+ .dim-botGeneralControl svg { position: absolute; top: 50%; right: 12px; transform: translateY(-50%); pointer-events: none; }
651
+ .dim-botGeneralControl .dim-contextSwitch { display: block; }
652
+ .dim-botGeneralControl .dim-contextSwitch:disabled { opacity: .5; cursor: not-allowed; }
653
+ .dim-botGeneralControl .dim-contextSwitch:focus-visible { outline: 2px solid var(--dsw-alias-state-business-primary, #3370ff); outline-offset: 3px; }
654
+ .dim-botGeneralActions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 8px; padding-top: 16px; }
636
655
  .dim-generalSettingsPage { min-width: 0; display: grid; }
637
656
  .dim-generalSettingsHeader { min-width: 0; margin: 0 0 8px; }
638
657
  .dim-generalSettingsHeader h2 { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 18px; line-height: 25px; font-weight: 680; }
@@ -260,6 +260,7 @@ export async function createProductionController(ctx, config = {}, internals = {
260
260
  mentionTopicReply: botConfig.mentionTopicReply,
261
261
  stepPush: botConfig.stepPush,
262
262
  stepPushMode: botConfig.stepPushMode,
263
+ stepCardPanels: botConfig.stepCardPanels,
263
264
  voice: voiceSource,
264
265
  sessionSyncTargetsFor: sessionSyncTargetsFor,
265
266
  ownerOpenIds: botConfig.ownerOpenIds ?? [botConfig.ownerOpenId],
@@ -30,6 +30,8 @@ import {
30
30
  import {
31
31
  isFeishuStepPushMode,
32
32
  normalizeFeishuStepPushMode,
33
+ isFeishuStepCardPanels,
34
+ normalizeFeishuStepCardPanels,
33
35
  } from '../../../../src/channels/feishu/step-push-mode.mjs';
34
36
  import { normalizeFeishuVoiceConfig } from '../../../../src/channels/feishu/voice-config.mjs';
35
37
  import {
@@ -315,6 +317,7 @@ function publicBotEntry(entry) {
315
317
  mentionTopicReply: source.mentionTopicReply !== false,
316
318
  stepPush: source.stepPush === true,
317
319
  stepPushMode: normalizeFeishuStepPushMode(source.stepPushMode),
320
+ stepCardPanels: normalizeFeishuStepCardPanels(source.stepCardPanels),
318
321
  voice: normalizeFeishuVoiceConfig(source.voice),
319
322
  slashPanel: normalizeSlashPanelConfig(source.slashPanel),
320
323
  groupMessagePermissionGranted: source.groupMessagePermissionGranted === true,
@@ -504,6 +507,13 @@ function validPayload(endpoint, payload) {
504
507
  ? null
505
508
  : '请选择分步直推的呈现方式。';
506
509
  }
510
+ if (endpoint === FEISHU_ENDPOINTS.setStepCardPanels) {
511
+ return hasOnlyKeys(payload, new Set(['botId', 'stepCardPanels']))
512
+ && safeOpaqueId(payload.botId)
513
+ && isFeishuStepCardPanels(payload.stepCardPanels)
514
+ ? null
515
+ : '请选择实时过程卡面板的展开状态。';
516
+ }
507
517
  if (endpoint === FEISHU_ENDPOINTS.setVoice) {
508
518
  const voice = payload?.voice;
509
519
  const validVoice = voice === null
@@ -835,6 +845,14 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
835
845
  await controller.updateStepPushMode(payload.botId, payload.stepPushMode),
836
846
  { encodeQr: cachedEncodeQr },
837
847
  );
848
+ } else if (endpoint === FEISHU_ENDPOINTS.setStepCardPanels) {
849
+ if (typeof controller.updateStepCardPanels !== 'function') {
850
+ throw new Error('Step card panel update is unavailable');
851
+ }
852
+ value = await toPublicFeishuStatus(
853
+ await controller.updateStepCardPanels(payload.botId, payload.stepCardPanels),
854
+ { encodeQr: cachedEncodeQr },
855
+ );
838
856
  } else if (endpoint === FEISHU_ENDPOINTS.setVoice) {
839
857
  if (typeof controller.updateVoice !== 'function') {
840
858
  throw new Error('Voice update is unavailable');
@@ -148,27 +148,32 @@ if (client.includes('settings.plugins.tab') || clientSources.includes('settings.
148
148
  }
149
149
  // Connections still have no channel-enable toggle. Checkable inputs are owned
150
150
  // only by the shared context editor, the saved-target Session sync row, the
151
- // email settings, and the Telegram thinking-traces block.
151
+ // email settings, the Telegram thinking-traces block, and Feishu panel settings.
152
152
  // The context editor contains one switch template and one mapped field-input
153
153
  // template; the delivery target adds one ordinary checkbox template; the
154
- // Telegram thinking-traces block adds one ordinary checkbox template.
154
+ // Telegram thinking-traces block adds one ordinary checkbox template. General
155
+ // settings adds one mapped switch template for the two Feishu panel preferences.
155
156
  const contextEditorSource = await readFile(resolve(root, 'plugin-src/client/context-enhancement.js'), 'utf8');
156
157
  const deliverySettingsSource = await readFile(resolve(root, 'plugin-src/client/delivery-settings.js'), 'utf8');
157
158
  const emailSettingsSource = await readFile(resolve(root, 'plugin-src/client/channels/email/index.js'), 'utf8');
158
159
  const thinkingTracesSource = await readFile(resolve(root, 'plugin-src/client/channels/telegram/thinking-traces.js'), 'utf8');
160
+ const botGeneralSettingsSource = await readFile(resolve(root, 'plugin-src/client/bot-general-settings.js'), 'utf8');
159
161
  const otherClientSources = clientSources
160
162
  .replace(contextEditorSource, '')
161
163
  .replace(deliverySettingsSource, '')
162
164
  .replace(emailSettingsSource, '')
163
- .replace(thinkingTracesSource, '');
165
+ .replace(thinkingTracesSource, '')
166
+ .replace(botGeneralSettingsSource, '');
164
167
  if (/role:\s*["']switch|type:\s*["']checkbox/.test(otherClientSources)
165
168
  || (deliverySettingsSource.match(/type:\s*["']checkbox["']/g) ?? []).length !== 1
166
169
  || /role:\s*["']switch["']/u.test(deliverySettingsSource)
167
170
  || (thinkingTracesSource.match(/type:\s*["']checkbox["']/g) ?? []).length !== 1
168
171
  || /role:\s*["']switch["']/u.test(thinkingTracesSource)
169
- || (client.match(/role:\s*["']switch["']/g) ?? []).length !== 1
170
- || (client.match(/type:\s*["']checkbox["']/g) ?? []).length !== 4) {
171
- throw new Error('checkable inputs must be limited to context enhancement, Session sync, email settings, and the Telegram thinking-traces toggle');
172
+ || (botGeneralSettingsSource.match(/type:\s*["']checkbox["']/g) ?? []).length !== 1
173
+ || (botGeneralSettingsSource.match(/role:\s*["']switch["']/g) ?? []).length !== 1
174
+ || (client.match(/role:\s*["']switch["']/g) ?? []).length !== 2
175
+ || (client.match(/type:\s*["']checkbox["']/g) ?? []).length !== 5) {
176
+ throw new Error('checkable inputs must be limited to context enhancement, Session sync, email settings, Telegram thinking traces, and Feishu panel settings');
172
177
  }
173
178
  for (const marker of ['bot.context-enhancement.set', '<dsh_im_source>', '<dsh_im_source_guidance>']) {
174
179
  if (!host.includes(marker) || !client.includes(marker)) {
@@ -122,6 +122,7 @@ import {
122
122
  import {
123
123
  FEISHU_STEP_PUSH_MODES,
124
124
  normalizeFeishuStepPushMode,
125
+ normalizeFeishuStepCardPanels,
125
126
  } from './step-push-mode.mjs';
126
127
  import { FeishuLiveCot } from './live-cot.mjs';
127
128
 
@@ -680,6 +681,7 @@ export class FeishuHarnessBridge {
680
681
  #stepPush = false;
681
682
  /** Step push presentation: discrete posts, a CardKit card, or native live CoT. */
682
683
  #stepPushMode = FEISHU_STEP_PUSH_MODES.POST;
684
+ #stepCardPanels;
683
685
  /** Per-conversation step push state: key → { lastSentAt, count, breakerLogged }. */
684
686
  #stepPushSendState = new Map();
685
687
  /** Live streaming step cards: key → per-turn card state (streaming_card mode). */
@@ -745,6 +747,7 @@ export class FeishuHarnessBridge {
745
747
  mentionTopicReply = true,
746
748
  stepPush = false,
747
749
  stepPushMode = FEISHU_STEP_PUSH_MODES.POST,
750
+ stepCardPanels,
748
751
  stepPushClock = null,
749
752
  repair,
750
753
  repairPollIntervalMs = REPAIR_POLL_INTERVAL_MS,
@@ -805,6 +808,7 @@ export class FeishuHarnessBridge {
805
808
  this.#mentionTopicReply = mentionTopicReply !== false;
806
809
  this.#stepPush = stepPush === true;
807
810
  this.#stepPushMode = normalizeFeishuStepPushMode(stepPushMode);
811
+ this.#stepCardPanels = normalizeFeishuStepCardPanels(stepCardPanels);
808
812
  this.#stepPushClock = stepPushClock ?? DEFAULT_STEP_PUSH_CLOCK;
809
813
  this.#voice = voice ?? null;
810
814
  this.#repair = repair ?? null;
@@ -901,6 +905,10 @@ export class FeishuHarnessBridge {
901
905
  this.#stepPushMode = normalizeFeishuStepPushMode(value);
902
906
  }
903
907
 
908
+ setStepCardPanels(value) {
909
+ this.#stepCardPanels = normalizeFeishuStepCardPanels(value);
910
+ }
911
+
904
912
  setVoice(voice) {
905
913
  this.#voice = voice ?? null;
906
914
  }
@@ -4845,7 +4853,7 @@ export class FeishuHarnessBridge {
4845
4853
  * their lifecycle is owned by the ask path, not the mirror recovery.
4846
4854
  * lastContent keeps stepStreamCard's raw JSON string (single-encoded).
4847
4855
  */
4848
- async #persistMirrorState(card, liveBlocks, status) {
4856
+ async #persistMirrorState(card, liveBlocks, status, stepCardPanels) {
4849
4857
  const sessionId = card.sessionSyncSessionId;
4850
4858
  if (!sessionId || typeof this.#state.setMirror !== 'function') return;
4851
4859
  await this.#state.setMirror(card.sessionSyncKey, {
@@ -4853,7 +4861,7 @@ export class FeishuHarnessBridge {
4853
4861
  chatId: card.chatId,
4854
4862
  cardIds: [...card.cardIds],
4855
4863
  claimedAt: Date.now(),
4856
- lastContent: stepStreamCard(liveBlocks, { status }),
4864
+ lastContent: stepStreamCard(liveBlocks, { status, stepCardPanels }),
4857
4865
  blocks: structuredClone(card.blocks), answerStart: card.answerStart, answerEnd: card.answerEnd,
4858
4866
  lastSeq: this.#sessionSyncTurns.get(card.sessionSyncKey)?.lastSeq ?? -1,
4859
4867
  pendingStep: this.#sessionSyncTurns.get(card.sessionSyncKey)?.pendingStep ?? null,
@@ -4861,7 +4869,7 @@ export class FeishuHarnessBridge {
4861
4869
  }
4862
4870
 
4863
4871
  /** Sync only this interaction's cards; earlier interactions remain sealed. */
4864
- async #syncStepCardChunks(card, chunks, status) {
4872
+ async #syncStepCardChunks(card, chunks, status, stepCardPanels) {
4865
4873
  const answerVersion = card.answerVersion ?? 0;
4866
4874
  const activeIds = card.cardIds.slice(card.activeCardStart);
4867
4875
  const groups = chunks.length ? [...chunks] : [[]];
@@ -4874,6 +4882,7 @@ export class FeishuHarnessBridge {
4874
4882
  // payload halfway through this render or be marked as already delivered.
4875
4883
  const contents = groups.map((blocks, index) => stepStreamCard(blocks, {
4876
4884
  status: index === groups.length - 1 ? status : 'sealed',
4885
+ stepCardPanels,
4877
4886
  }));
4878
4887
  for (let index = 0; index < contents.length; index += 1) {
4879
4888
  let id = activeIds[index];
@@ -4898,12 +4907,13 @@ export class FeishuHarnessBridge {
4898
4907
 
4899
4908
  async #renderStepCardNow(chatId, card) {
4900
4909
  if (card.broken) return;
4901
- const chunks = splitStepStreamCardBlocks(card.blocks);
4910
+ const stepCardPanels = this.#stepCardPanels;
4911
+ const chunks = splitStepStreamCardBlocks(card.blocks, undefined, undefined, stepCardPanels);
4902
4912
  if (!chunks.length && card.messageId === null) return;
4903
4913
  const live = chunks[chunks.length - 1] ?? [];
4904
4914
  try {
4905
- await this.#syncStepCardChunks(card, chunks, 'running');
4906
- await this.#persistMirrorState(card, live, 'running');
4915
+ await this.#syncStepCardChunks(card, chunks, 'running', stepCardPanels);
4916
+ await this.#persistMirrorState(card, live, 'running', stepCardPanels);
4907
4917
  card.lastRenderAt = this.#stepPushClock.now();
4908
4918
  } catch (error) {
4909
4919
  card.broken = true;
@@ -4943,9 +4953,10 @@ export class FeishuHarnessBridge {
4943
4953
  if (body.trim()) {
4944
4954
  this.#writeStepCardAnswer(card, body);
4945
4955
  }
4946
- const chunks = splitStepStreamCardBlocks(card.blocks);
4956
+ const stepCardPanels = this.#stepCardPanels;
4957
+ const chunks = splitStepStreamCardBlocks(card.blocks, undefined, undefined, stepCardPanels);
4947
4958
  try {
4948
- await this.#syncStepCardChunks(card, chunks, status);
4959
+ await this.#syncStepCardChunks(card, chunks, status, stepCardPanels);
4949
4960
  return { ok: true, cardIds: card.cardIds };
4950
4961
  } catch (error) {
4951
4962
  // Only trust the streamed draft when the LAST SUCCESSFUL render actually
@@ -4996,8 +5007,9 @@ export class FeishuHarnessBridge {
4996
5007
  if (card.broken) return;
4997
5008
  if (card.messageId === null) return;
4998
5009
  try {
4999
- const chunks = splitStepStreamCardBlocks(card.blocks);
5000
- await this.#syncStepCardChunks(card, chunks, 'sealed');
5010
+ const stepCardPanels = this.#stepCardPanels;
5011
+ const chunks = splitStepStreamCardBlocks(card.blocks, undefined, undefined, stepCardPanels);
5012
+ await this.#syncStepCardChunks(card, chunks, 'sealed', stepCardPanels);
5001
5013
  } catch (error) {
5002
5014
  this.#logger.warn?.(
5003
5015
  '[dsh-feishu] step streaming card rotate seal failed:',
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { posix, win32 } from 'node:path';
18
18
  import { t } from '../shared/i18n.mjs';
19
+ import { normalizeFeishuStepCardPanels } from './step-push-mode.mjs';
19
20
 
20
21
  export const MENU_PAGE_SIZE = 10;
21
22
 
@@ -1000,15 +1001,16 @@ export const STEP_STREAM_CARD_MAX_BYTES = 24_000;
1000
1001
  * Build one streaming step card from the accumulated process blocks:
1001
1002
  * { kind: 'message', text } — interim note / warning / context line
1002
1003
  * { kind: 'tools', lines: string[] } — tool-call summary panel
1003
- * `status`: 'running' keeps panels expanded and ends with an italic status
1004
+ * `status`: 'running' uses the configured panel expansion and ends with an italic status
1004
1005
  * line; 'completed' / 'stopped' collapse the panels and swap the status text;
1005
1006
  * 'sealed' is an overflow spill chunk with no status line at all. Finished
1006
1007
  * turns (and sealed spill chunks) merge every tool/thinking panel into one
1007
1008
  * collapsed "process details" panel so the sealed card stays compact.
1008
1009
  */
1009
1010
 
1010
- export function stepStreamCard(rawBlocks, { status = 'running' } = {}) {
1011
+ export function stepStreamCard(rawBlocks, { status = 'running', stepCardPanels } = {}) {
1011
1012
  const running = status === 'running';
1013
+ const panels = normalizeFeishuStepCardPanels(stepCardPanels);
1012
1014
  const elements = [];
1013
1015
  const panelElements = [];
1014
1016
  for (const block of Array.isArray(rawBlocks) ? rawBlocks : []) {
@@ -1021,10 +1023,8 @@ export function stepStreamCard(rawBlocks, { status = 'running' } = {}) {
1021
1023
  title: block.kind === 'tools'
1022
1024
  ? t('🛠️ 工具摘要({count})', { count })
1023
1025
  : t('💭 思考过程({count})', { count }),
1024
- // Tool summaries stay visible while the turn runs; thinking notes
1025
- // remain folded at all times. Finished turns keep both panels but
1026
- // tuck them inside one collapsed "process details" wrapper.
1027
- expanded: block.kind === 'tools' && running,
1026
+ // Finished turns keep both panels inside the collapsed details wrapper.
1027
+ expanded: running && (block.kind === 'tools' ? panels.toolsExpanded : panels.thinkingExpanded),
1028
1028
  });
1029
1029
  if (running) elements.push(panel);
1030
1030
  else panelElements.push(panel);
@@ -1265,6 +1265,7 @@ export function splitStepStreamCardBlocks(
1265
1265
  blocks,
1266
1266
  limit = STEP_STREAM_CARD_MAX_BYTES,
1267
1267
  maxTables = STEP_STREAM_CARD_MAX_TABLES,
1268
+ stepCardPanels,
1268
1269
  ) {
1269
1270
  // A single block can carry more tables than a card accepts — one short answer
1270
1271
  // with six small tables — and splitting only between blocks cannot help. Each
@@ -1277,7 +1278,7 @@ export function splitStepStreamCardBlocks(
1277
1278
  const chunks = [];
1278
1279
  let current = [];
1279
1280
  for (const block of list) {
1280
- const tooLarge = Buffer.byteLength(stepStreamCard([...current, block]), 'utf8') > limit;
1281
+ const tooLarge = Buffer.byteLength(stepStreamCard([...current, block], { stepCardPanels }), 'utf8') > limit;
1281
1282
  const tooManyTables = stepStreamCardTableCount([...current, block]) > maxTables;
1282
1283
  if (current.length > 0 && (tooLarge || tooManyTables)) {
1283
1284
  chunks.push(current);
@@ -5,7 +5,7 @@ import { FeishuHarnessBridge } from './bridge.mjs';
5
5
  import { cardActionProbeCard } from './feishu-cards.mjs';
6
6
  import { VerifiedFeishuChannel, waitForFeishuOperation } from './feishu-channel.mjs';
7
7
  import { normalizeFeishuGroupResponseMode } from './group-response-mode.mjs';
8
- import { normalizeFeishuStepPushMode } from './step-push-mode.mjs';
8
+ import { normalizeFeishuStepPushMode, normalizeFeishuStepCardPanels } from './step-push-mode.mjs';
9
9
  import { createVoice } from './voice.mjs';
10
10
  import {
11
11
  syncSlashCommands,
@@ -119,6 +119,7 @@ export class FeishuRuntime {
119
119
  #mentionTopicReply;
120
120
  #stepPush;
121
121
  #stepPushMode;
122
+ #stepCardPanels;
122
123
  #voice = null;
123
124
  #sessionSyncTargetsFor;
124
125
  #ownerOpenIds;
@@ -171,6 +172,7 @@ export class FeishuRuntime {
171
172
  mentionTopicReply = true,
172
173
  stepPush = false,
173
174
  stepPushMode = 'post',
175
+ stepCardPanels,
174
176
  voice = null,
175
177
  sessionSyncTargetsFor = null,
176
178
  ownerOpenId,
@@ -214,6 +216,7 @@ export class FeishuRuntime {
214
216
  this.#mentionTopicReply = mentionTopicReply !== false;
215
217
  this.#stepPush = stepPush === true;
216
218
  this.#stepPushMode = normalizeFeishuStepPushMode(stepPushMode);
219
+ this.#stepCardPanels = normalizeFeishuStepCardPanels(stepCardPanels);
217
220
  // voice 为 { config, secret } 来源;凭据缺失时 createVoice 返回禁用对象,
218
221
  // 桥接层完全跳过语音路径,不影响连接。
219
222
  this.#voice = voice == null
@@ -262,6 +265,11 @@ export class FeishuRuntime {
262
265
  this.#bridge?.setStepPushMode(this.#stepPushMode);
263
266
  }
264
267
 
268
+ setStepCardPanels(value) {
269
+ this.#stepCardPanels = normalizeFeishuStepCardPanels(value);
270
+ this.#bridge?.setStepCardPanels(this.#stepCardPanels);
271
+ }
272
+
265
273
  setVoice(source) {
266
274
  this.#voice = source == null
267
275
  ? null
@@ -371,6 +379,7 @@ export class FeishuRuntime {
371
379
  mentionTopicReply: this.#mentionTopicReply,
372
380
  stepPush: this.#stepPush,
373
381
  stepPushMode: this.#stepPushMode,
382
+ stepCardPanels: this.#stepCardPanels,
374
383
  voice: this.#voice,
375
384
  sessionSyncTargetsFor: this.#sessionSyncTargetsFor,
376
385
  repair: this.#repair,
@@ -21,6 +21,8 @@ import {
21
21
  DEFAULT_FEISHU_STEP_PUSH_MODE,
22
22
  isFeishuStepPushMode,
23
23
  normalizeFeishuStepPushMode,
24
+ isFeishuStepCardPanels,
25
+ normalizeFeishuStepCardPanels,
24
26
  } from './step-push-mode.mjs';
25
27
  import { normalizeFeishuVoiceConfig } from './voice-config.mjs';
26
28
  import {
@@ -106,6 +108,7 @@ function configuredBotFingerprint(config) {
106
108
  mentionTopicReply: config.mentionTopicReply !== false,
107
109
  stepPush: config.stepPush === true,
108
110
  stepPushMode: normalizeFeishuStepPushMode(config.stepPushMode),
111
+ stepCardPanels: normalizeFeishuStepCardPanels(config.stepCardPanels),
109
112
  voice: config.voice,
110
113
  slashPanel: normalizeSlashPanelConfig(config.slashPanel),
111
114
  groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,
@@ -816,6 +819,20 @@ export class MultiBotDshFeishuController {
816
819
  }));
817
820
  }
818
821
 
822
+ async updateStepCardPanels(botId, stepCardPanels) {
823
+ this.#assertOpen();
824
+ if (!isFeishuStepCardPanels(stepCardPanels)) {
825
+ throw new TypeError('Invalid Feishu step card panels');
826
+ }
827
+ return this.#serializeConfig(() => this.#withBotTransition(botId, async () => {
828
+ const config = this.#requireBot(botId);
829
+ const saved = await this.#configStore.saveBot({ ...config, stepCardPanels });
830
+ this.#runtimes.get(botId)?.setStepCardPanels?.(saved.stepCardPanels);
831
+ this.#touch();
832
+ return this.status(botId);
833
+ }));
834
+ }
835
+
819
836
  async updateVoice(botId, voice) {
820
837
  this.#assertOpen();
821
838
  if (voice !== null && (typeof voice !== 'object' || Array.isArray(voice))) {
@@ -901,6 +918,7 @@ export class MultiBotDshFeishuController {
901
918
  mentionTopicReply: config.mentionTopicReply !== false,
902
919
  stepPush: config.stepPush === true,
903
920
  stepPushMode: normalizeFeishuStepPushMode(config.stepPushMode),
921
+ stepCardPanels: normalizeFeishuStepCardPanels(config.stepCardPanels),
904
922
  voice: normalizeFeishuVoiceConfig(config.voice),
905
923
  slashPanel: normalizeSlashPanelConfig(config.slashPanel),
906
924
  groupMessagePermissionGranted: config.groupMessagePermissionGranted === true,