@xmanrui/dsh-im 4.29.1 → 4.30.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.29.1",
3
+ "version": "4.30.0",
4
4
  "icon": "./assets/logo-plugin-message-link-gradient.webp",
5
5
  "description": "把十二种 IM 渠道和公网 AI Office 接入本机 DeepSeek Harness。 Connect twelve IM channels and a public AI Office to a local DeepSeek Harness.",
6
6
  "keywords": [
@@ -15,6 +15,7 @@ import { normalizeAccessPolicy } from "../../../../src/channels/shared/access-po
15
15
  import { normalizeContextEnhancementConfig } from "../../../../src/channels/shared/context-enhancement.mjs";
16
16
  import { normalizeFeishuStepPushMode } from "../../../../src/channels/feishu/step-push-mode.mjs";
17
17
  import { normalizeFeishuVoiceConfig } from "../../../../src/channels/feishu/voice-config.mjs";
18
+ import { normalizeSlashPanelConfig } from "../../../../src/channels/feishu/slash-command-panel.mjs";
18
19
 
19
20
  export const FEISHU_RPC_CHANNEL = "/feishu";
20
21
 
@@ -36,10 +37,11 @@ export const FEISHU_ENDPOINTS = Object.freeze({
36
37
  setAccessPolicy: "bot.access-policy.set",
37
38
  setAlias: 'bot.alias.set',
38
39
  setGroupResponseMode: "bot.group-response-mode.set",
39
- setGroupTopicReply: "bot.group-topic-reply.set",
40
+ setMentionTopicReply: "bot.mention-topic-reply.set",
40
41
  setStepPush: "bot.step-push.set",
41
42
  setStepPushMode: "bot.step-push-mode.set",
42
43
  setVoice: "bot.voice.set",
44
+ setSlashPanel: "bot.slash-panel.set",
43
45
  // Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
44
46
  testConnection: "connection.test",
45
47
  disconnect: "connection.disconnect",
@@ -226,10 +228,11 @@ export function normalizeBotConnection(value, fallbackBotId) {
226
228
  ? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
227
229
  : {}),
228
230
  groupResponseMode: normalizeGroupResponseMode(value.groupResponseMode),
229
- groupTopicReply: value.groupTopicReply === true,
231
+ mentionTopicReply: value.mentionTopicReply !== false,
230
232
  stepPush: value.stepPush === true,
231
233
  stepPushMode: normalizeFeishuStepPushMode(value.stepPushMode),
232
234
  voice: normalizeFeishuVoiceConfig(value.voice),
235
+ slashPanel: normalizeSlashPanelConfig(value.slashPanel),
233
236
  groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
234
237
  bot: normalizeBot(value.bot),
235
238
  health: normalizeHealth(value.health, connected),
@@ -27,7 +27,7 @@ function SettingsButton({ children, kind = 'secondary', className = '', ...props
27
27
  function groupSettingsFrom(value) {
28
28
  return {
29
29
  groupResponseMode: normalizeGroupResponseMode(value?.groupResponseMode),
30
- groupTopicReply: value?.groupTopicReply === true,
30
+ mentionTopicReply: value?.mentionTopicReply !== false,
31
31
  groupMessagePermissionGranted: value?.groupMessagePermissionGranted === true,
32
32
  };
33
33
  }
@@ -161,7 +161,7 @@ export function GroupResponseModeEditor({
161
161
  }, error) : null);
162
162
  }
163
163
 
164
- export function GroupTopicReplyEditor({ value = false, disabled = false, onSave }) {
164
+ export function MentionTopicReplyEditor({ value = true, disabled = false, onSave }) {
165
165
  const current = value === true ? 'on' : 'off';
166
166
  const [saving, setSaving] = React.useState(false);
167
167
  const [error, setError] = React.useState(null);
@@ -174,7 +174,7 @@ export function GroupTopicReplyEditor({ value = false, disabled = false, onSave
174
174
  try {
175
175
  await onSave?.(next);
176
176
  } catch (cause) {
177
- setError(cause?.message ?? '群聊以话题方式回复设置保存失败,请重试。');
177
+ setError(cause?.message ?? '以话题方式回复设置保存失败,请重试。');
178
178
  } finally {
179
179
  setSaving(false);
180
180
  }
@@ -182,10 +182,10 @@ export function GroupTopicReplyEditor({ value = false, disabled = false, onSave
182
182
 
183
183
  return h('section', {
184
184
  className: 'dim-feishuGroupControl',
185
- 'aria-labelledby': 'dim-feishu-group-topic-title',
185
+ 'aria-labelledby': 'dim-feishu-mention-topic-title',
186
186
  },
187
187
  h('div', { className: 'dim-feishuGroupControlHeader' },
188
- h('h3', { id: 'dim-feishu-group-topic-title' }, '群聊以话题方式回复'),
188
+ h('h3', { id: 'dim-feishu-mention-topic-title' }, '被 @ 时以话题方式回复'),
189
189
  saving
190
190
  ? h('span', { className: 'dim-feishuGroupControlStatus', role: 'status' }, '保存中…')
191
191
  : null),
@@ -193,13 +193,13 @@ export function GroupTopicReplyEditor({ value = false, disabled = false, onSave
193
193
  className: 'dim-feishuGroupSelect',
194
194
  value: current,
195
195
  disabled: disabled || saving,
196
- 'aria-label': '群聊以话题方式回复',
196
+ 'aria-label': '被 @ 时以话题方式回复',
197
197
  onChange: (event) => { void change(event); },
198
198
  },
199
- h('option', { value: 'off' }, '关闭(群内直接回复)'),
199
+ h('option', { value: 'off' }, '关闭(在会话主窗口回复)'),
200
200
  h('option', { value: 'on' }, '开启(自动开启独立飞书话题)')),
201
201
  h('p', { className: 'dim-feishuGroupHelp' },
202
- '开启后,群聊中向机器人提问会自动开启独立飞书话题,回复落在话题内;每个话题是 dsh 会话列表里一条独立会话,上下文互不串。私聊不受影响。'),
202
+ '开启后,在群聊或私聊里 @机器人 提问,机器人会在那条消息下新建一个飞书话题并在话题内回答;该话题是一条独立会话,话题里的后续消息都属于同一个会话。只发一个 @机器人(打开菜单)不会新建话题,没有 @机器人 的消息也留在原来的会话里。'),
203
203
  error ? h('p', {
204
204
  className: 'dim-feishuGroupError',
205
205
  role: 'alert',
@@ -315,7 +315,7 @@ export function FeishuGroupSettingsPage({ account, rpcCall }) {
315
315
  }, [
316
316
  account.botId,
317
317
  account.groupResponseMode,
318
- account.groupTopicReply,
318
+ account.mentionTopicReply,
319
319
  account.groupMessagePermissionGranted,
320
320
  ]);
321
321
 
@@ -558,12 +558,12 @@ export function FeishuGroupSettingsPage({ account, rpcCall }) {
558
558
  },
559
559
  onAuthorize: () => startAuthorization(),
560
560
  }),
561
- h(GroupTopicReplyEditor, {
562
- value: settings.groupTopicReply,
561
+ h(MentionTopicReplyEditor, {
562
+ value: settings.mentionTopicReply,
563
563
  disabled,
564
- onSave: (groupTopicReply) => saveSetting(
565
- FEISHU_ENDPOINTS.setGroupTopicReply,
566
- { groupTopicReply },
564
+ onSave: (mentionTopicReply) => saveSetting(
565
+ FEISHU_ENDPOINTS.setMentionTopicReply,
566
+ { mentionTopicReply },
567
567
  ),
568
568
  })),
569
569
  h(PermissionFlow, {
@@ -608,7 +608,8 @@ export function BotCard({
608
608
  accessPolicy: connection.accessPolicy,
609
609
  channelSettings: {
610
610
  groupResponseMode: connection.groupResponseMode,
611
- groupTopicReply: connection.groupTopicReply,
611
+ mentionTopicReply: connection.mentionTopicReply,
612
+ slashPanel: connection.slashPanel,
612
613
  groupMessagePermissionGranted: connection.groupMessagePermissionGranted,
613
614
  },
614
615
  }),
@@ -0,0 +1,322 @@
1
+ import * as React from 'react';
2
+
3
+ import { h, localizeText } from '../../i18n.js';
4
+ import {
5
+ FEISHU_ENDPOINTS,
6
+ normalizeBotsSnapshot,
7
+ presentError,
8
+ unwrapRpcResult,
9
+ } from './api.js';
10
+ import {
11
+ SLASH_COMMAND_MANIFEST,
12
+ SLASH_PANEL_MODES,
13
+ normalizeSlashPanelConfig,
14
+ } from '../../../../src/channels/feishu/slash-command-panel.mjs';
15
+
16
+ const MANIFEST_BY_NAME = new Map(SLASH_COMMAND_MANIFEST.map((entry) => [entry.command, entry]));
17
+ const ALL_COMMANDS = Object.freeze(SLASH_COMMAND_MANIFEST.map((entry) => entry.command));
18
+
19
+ function PanelButton({ children, disabled = false, onClick, ...props }) {
20
+ return h('button', {
21
+ ...props,
22
+ type: 'button',
23
+ className: 'dim-deliveryButton',
24
+ disabled,
25
+ onClick,
26
+ }, children);
27
+ }
28
+
29
+ function panelSignature(panel) {
30
+ const normalized = normalizeSlashPanelConfig(panel);
31
+ return `${normalized.mode}:${normalized.order.join(',')}`;
32
+ }
33
+
34
+ function commandText(name) {
35
+ const entry = MANIFEST_BY_NAME.get(name);
36
+ return `/${name} — ${entry?.default ?? name}`;
37
+ }
38
+
39
+ /**
40
+ * "指令面板" settings page: which commands the Feishu "/" panel offers, and in
41
+ * which order.
42
+ *
43
+ * The panel lives on Feishu's side per app, so saving only records the choice;
44
+ * the Host re-syncs the app in the background (creating and removing commands,
45
+ * one per second, because Feishu returns the panel in creation order).
46
+ */
47
+ export function FeishuSlashPanelSettingsPage({ account, rpcCall, onSaved }) {
48
+ const incoming = React.useMemo(() => normalizeSlashPanelConfig(account.slashPanel), [account.botId, panelSignature(account.slashPanel)]);
49
+ const [mode, setMode] = React.useState(incoming.mode);
50
+ const [order, setOrder] = React.useState(() => [...incoming.order]);
51
+ const [pending, setPending] = React.useState('');
52
+ const [dirty, setDirty] = React.useState(false);
53
+ const [saving, setSaving] = React.useState(false);
54
+ const [saved, setSaved] = React.useState(false);
55
+ const [syncing, setSyncing] = React.useState(false);
56
+ /** Bot ids waiting for the reader to confirm a copy, or null when idle. */
57
+ const [syncTargets, setSyncTargets] = React.useState(null);
58
+ const [syncNotice, setSyncNotice] = React.useState(null);
59
+ const [error, setError] = React.useState(null);
60
+ const mounted = React.useRef(true);
61
+
62
+ React.useEffect(() => {
63
+ mounted.current = true;
64
+ return () => { mounted.current = false; };
65
+ }, []);
66
+
67
+ // A status refresh must not discard what the reader is editing; once saved,
68
+ // the next snapshot becomes the new baseline.
69
+ React.useEffect(() => {
70
+ if (dirty) return;
71
+ setMode(incoming.mode);
72
+ setOrder([...incoming.order]);
73
+ }, [incoming.mode, incoming.order, dirty]);
74
+
75
+ const edit = React.useCallback((update) => {
76
+ setSaved(false);
77
+ setDirty(true);
78
+ update();
79
+ }, []);
80
+
81
+ const move = (index, offset) => edit(() => {
82
+ const next = [...order];
83
+ const target = index + offset;
84
+ if (target < 0 || target >= next.length) return;
85
+ [next[index], next[target]] = [next[target], next[index]];
86
+ setOrder(next);
87
+ });
88
+
89
+ const remove = (index) => edit(() => setOrder(order.filter((_, at) => at !== index)));
90
+
91
+ const add = () => {
92
+ if (!pending || order.includes(pending)) return;
93
+ edit(() => setOrder([...order, pending]));
94
+ setPending('');
95
+ };
96
+
97
+ /** The panel this page currently shows, in the shape the Host stores. */
98
+ const draftPanel = () => (mode === SLASH_PANEL_MODES.CUSTOM
99
+ ? { mode: SLASH_PANEL_MODES.CUSTOM, order: [...order] }
100
+ : { mode: SLASH_PANEL_MODES.DEFAULT, order: [] });
101
+
102
+ /** Adopt a Host snapshot as the new baseline for this page. */
103
+ const applySnapshot = (value) => {
104
+ const snapshot = normalizeBotsSnapshot(value);
105
+ const bot = snapshot.bots.find((entry) => entry.botId === account.botId);
106
+ const stored = normalizeSlashPanelConfig(bot?.slashPanel);
107
+ setMode(stored.mode);
108
+ setOrder([...stored.order]);
109
+ // Publish the stored panel upwards *before* clearing `dirty`: that re-runs
110
+ // the snapshot effect above, and a still-stale account would put the list
111
+ // the reader just replaced back on screen (and into the next copy payload).
112
+ onSaved?.(stored);
113
+ setDirty(false);
114
+ setSaved(true);
115
+ return snapshot;
116
+ };
117
+
118
+ const save = async () => {
119
+ if (saving) return;
120
+ setSaving(true);
121
+ setError(null);
122
+ try {
123
+ if (typeof rpcCall !== 'function') throw new Error('飞书指令面板设置暂不可用。');
124
+ const value = unwrapRpcResult(await rpcCall(
125
+ FEISHU_ENDPOINTS.setSlashPanel,
126
+ { botId: account.botId, slashPanel: draftPanel() },
127
+ ));
128
+ if (!mounted.current) return;
129
+ applySnapshot(value);
130
+ } catch (cause) {
131
+ if (!mounted.current) return;
132
+ setError(presentError(cause));
133
+ } finally {
134
+ if (mounted.current) setSaving(false);
135
+ }
136
+ };
137
+
138
+ /**
139
+ * Copy this panel to every other Feishu bot. Each bot keeps its own stored
140
+ * panel, so the copy is one save per bot (the same endpoint the Save button
141
+ * uses) — offline bots pick the panel up when they start.
142
+ */
143
+ const startSyncAll = async () => {
144
+ if (saving || syncing || typeof rpcCall !== 'function') return;
145
+ setSyncing(true);
146
+ setError(null);
147
+ setSyncNotice(null);
148
+ try {
149
+ const snapshot = normalizeBotsSnapshot(unwrapRpcResult(
150
+ await rpcCall(FEISHU_ENDPOINTS.status, {}),
151
+ ));
152
+ const others = snapshot.bots
153
+ .map((entry) => entry.botId)
154
+ .filter((botId) => botId && botId !== account.botId);
155
+ if (!mounted.current) return;
156
+ if (others.length === 0) {
157
+ setSyncNotice({ scope: 'none' });
158
+ return;
159
+ }
160
+ // Ask first: the other bots lose whatever panel they had.
161
+ setSyncTargets(others);
162
+ } catch (cause) {
163
+ if (mounted.current) setError(presentError(cause));
164
+ } finally {
165
+ if (mounted.current) setSyncing(false);
166
+ }
167
+ };
168
+
169
+ const confirmSyncAll = async () => {
170
+ const targets = syncTargets;
171
+ if (!targets || syncing || typeof rpcCall !== 'function') return;
172
+ setSyncing(true);
173
+ setError(null);
174
+ setSyncTargets(null);
175
+ const payload = draftPanel();
176
+ let done = 0;
177
+ let failed = 0;
178
+ // The bot on screen goes first so its own state is authoritative even when
179
+ // a later bot fails.
180
+ for (const botId of [account.botId, ...targets]) {
181
+ try {
182
+ const value = unwrapRpcResult(await rpcCall(
183
+ FEISHU_ENDPOINTS.setSlashPanel,
184
+ { botId, slashPanel: payload },
185
+ ));
186
+ if (botId === account.botId && mounted.current) applySnapshot(value);
187
+ done += 1;
188
+ } catch {
189
+ failed += 1;
190
+ }
191
+ }
192
+ if (!mounted.current) return;
193
+ setSyncing(false);
194
+ setSyncNotice({ scope: 'done', done, failed });
195
+ };
196
+
197
+ const available = ALL_COMMANDS.filter((name) => !order.includes(name));
198
+
199
+ return h('section', {
200
+ className: 'dim-feishuGroupSettings',
201
+ 'aria-label': '指令面板设置',
202
+ },
203
+ h('section', {
204
+ className: 'dim-feishuGroupControl',
205
+ 'aria-labelledby': 'dim-feishu-slash-panel-title',
206
+ },
207
+ h('div', { className: 'dim-feishuGroupControlHeader' },
208
+ h('h3', { id: 'dim-feishu-slash-panel-title' }, '指令面板'),
209
+ saving
210
+ ? h('span', { className: 'dim-feishuGroupControlStatus', role: 'status' }, '保存中…')
211
+ : saved
212
+ ? h('span', { className: 'dim-feishuGroupControlStatus', role: 'status' }, '已保存,面板稍后同步')
213
+ : null),
214
+ h('p', { className: 'dim-feishuGroupHelp' },
215
+ '面板只决定聊天输入框里打「/」时列出的指令和顺序;被移除的指令仍可手动输入,功能不受影响。同步由机器人后台完成,指令较多时需要一点时间。'),
216
+ h('select', {
217
+ className: 'dim-feishuGroupSelect',
218
+ value: mode,
219
+ 'aria-label': '指令面板模式',
220
+ disabled: saving || syncing,
221
+ onChange: (event) => edit(() => {
222
+ const next = event.target.value;
223
+ setMode(next);
224
+ // Switching to a custom panel starts from everything, in the shipped
225
+ // order, so the reader only has to remove what they do not want.
226
+ if (next === SLASH_PANEL_MODES.CUSTOM && order.length === 0) setOrder([...ALL_COMMANDS]);
227
+ }),
228
+ },
229
+ h('option', { value: SLASH_PANEL_MODES.DEFAULT }, '跟随默认(随插件版本更新)'),
230
+ h('option', { value: SLASH_PANEL_MODES.CUSTOM }, '自定义(自己挑指令和顺序)')),
231
+ mode === SLASH_PANEL_MODES.CUSTOM
232
+ ? h(React.Fragment, null,
233
+ order.length === 0
234
+ ? h('p', { className: 'dim-feishuGroupHelp' }, '当前没有选择任何指令,保存后「/」面板会变成空的。')
235
+ : h('ul', { className: 'dim-feishuPanelList' },
236
+ order.map((name, index) => h('li', {
237
+ key: name,
238
+ className: 'dim-feishuPanelRow',
239
+ },
240
+ h('span', { className: 'dim-feishuPanelIndex' }, String(index + 1)),
241
+ h('span', { className: 'dim-feishuPanelCommand' }, commandText(name)),
242
+ h('span', { className: 'dim-feishuPanelRowActions' },
243
+ h(PanelButton, {
244
+ 'aria-label': [localizeText('上移'), `/${name}`].join(' '),
245
+ disabled: saving || syncing || index === 0,
246
+ onClick: () => move(index, -1),
247
+ }, '↑'),
248
+ h(PanelButton, {
249
+ 'aria-label': [localizeText('下移'), `/${name}`].join(' '),
250
+ disabled: saving || syncing || index === order.length - 1,
251
+ onClick: () => move(index, 1),
252
+ }, '↓'),
253
+ h(PanelButton, {
254
+ 'aria-label': [localizeText('移除'), `/${name}`].join(' '),
255
+ disabled: saving || syncing,
256
+ onClick: () => remove(index),
257
+ }, '移除'))))),
258
+ h('div', { className: 'dim-feishuPanelAdd' },
259
+ h('select', {
260
+ className: 'dim-feishuGroupSelect',
261
+ value: pending,
262
+ 'aria-label': '添加指令',
263
+ disabled: saving || syncing || available.length === 0,
264
+ onChange: (event) => setPending(event.target.value),
265
+ },
266
+ h('option', { value: '' }, available.length === 0 ? '指令都已加入' : '选择要加入的指令'),
267
+ available.map((name) => h('option', { key: name, value: name }, commandText(name)))),
268
+ h(PanelButton, {
269
+ disabled: saving || syncing || !pending,
270
+ onClick: add,
271
+ }, '加入'),
272
+ h(PanelButton, {
273
+ disabled: saving || syncing || available.length === 0,
274
+ onClick: () => edit(() => setOrder([...ALL_COMMANDS])),
275
+ }, '恢复全部'),
276
+ h(PanelButton, {
277
+ disabled: saving || syncing || order.length === 0,
278
+ onClick: () => edit(() => setOrder([])),
279
+ }, '全部移除')))
280
+ : null,
281
+ h('div', { className: 'dim-feishuPanelFooter' },
282
+ h(PanelButton, {
283
+ 'data-kind': 'primary',
284
+ disabled: saving || syncing || !dirty,
285
+ onClick: () => { void save(); },
286
+ }, '保存'),
287
+ h(PanelButton, {
288
+ disabled: saving || syncing,
289
+ onClick: () => { void startSyncAll(); },
290
+ }, '保存并同步到其他机器人')),
291
+ syncTargets
292
+ ? h('div', { className: 'dim-feishuPanelFooter' },
293
+ h('span', { className: 'dim-feishuGroupHelp' },
294
+ [localizeText('将把这套面板设置写入另外'),
295
+ ` ${syncTargets.length} `,
296
+ localizeText('个飞书机器人,它们各自的面板设置会被覆盖。')].join('')),
297
+ h(PanelButton, {
298
+ disabled: syncing,
299
+ onClick: () => { void confirmSyncAll(); },
300
+ }, '确认同步'),
301
+ h(PanelButton, {
302
+ disabled: syncing,
303
+ onClick: () => setSyncTargets(null),
304
+ }, '取消'))
305
+ : null,
306
+ syncing
307
+ ? h('p', { className: 'dim-feishuGroupHelp', role: 'status' }, '正在同步…')
308
+ : null,
309
+ syncNotice?.scope === 'none'
310
+ ? h('p', { className: 'dim-feishuGroupHelp', role: 'status' }, '这个渠道没有其他飞书机器人,不用同步。')
311
+ : null,
312
+ syncNotice?.scope === 'done'
313
+ ? h('p', { className: 'dim-feishuGroupHelp', role: 'status' },
314
+ syncNotice.failed > 0
315
+ ? [localizeText('已同步到'), ` ${syncNotice.done} `, localizeText('个机器人,'),
316
+ ` ${syncNotice.failed} `, localizeText('个失败。')].join('')
317
+ : [localizeText('已同步到'), ` ${syncNotice.done} `, localizeText('个机器人。')].join(''))
318
+ : null,
319
+ error
320
+ ? h('p', { className: 'dim-feishuGroupError', role: 'alert' }, error)
321
+ : null));
322
+ }
@@ -3,6 +3,7 @@ import * as React from 'react';
3
3
  import { AccessPolicySettingsPage } from './access-policy-settings.js';
4
4
  import { FeishuGroupSettingsPage } from './channels/feishu/group-settings.js';
5
5
  import { FeishuVoiceSettingsPage } from './channels/feishu/voice-settings.js';
6
+ import { FeishuSlashPanelSettingsPage } from './channels/feishu/slash-panel-settings.js';
6
7
  import { h, isEnglish, localizeText } from './i18n.js';
7
8
 
8
9
  export const DELIVERY_RPC_CHANNEL = '/dsh-im-delivery';
@@ -30,6 +31,7 @@ export const BOT_SETTINGS_TABS = Object.freeze([
30
31
  export const FEISHU_BOT_SETTINGS_TABS = Object.freeze([
31
32
  ...BOT_SETTINGS_TABS,
32
33
  Object.freeze({ id: 'group', label: '群聊' }),
34
+ Object.freeze({ id: 'slash', label: '指令面板' }),
33
35
  Object.freeze({ id: 'voice', label: '语音交互' }),
34
36
  ]);
35
37
 
@@ -633,12 +635,20 @@ export function DeliveryTargetSettingsPage({
633
635
  const [saving, setSaving] = React.useState(false);
634
636
  const [botCopyState, setBotCopyState] = React.useState(null);
635
637
  const [accessPolicy, setAccessPolicy] = React.useState(account.accessPolicy);
638
+ // The slash panel page re-reads its baseline from this snapshot after a save,
639
+ // so the saved panel has to land here — a stale account would put the
640
+ // pre-save list back on screen and into the next "copy to other bots" payload.
641
+ const [slashPanel, setSlashPanel] = React.useState(account.slashPanel);
636
642
  const mounted = React.useRef(true);
637
643
 
638
644
  React.useEffect(() => {
639
645
  setAccessPolicy(account.accessPolicy);
640
646
  }, [account.botId, account.accessPolicy]);
641
647
 
648
+ React.useEffect(() => {
649
+ setSlashPanel(account.slashPanel);
650
+ }, [account.botId, account.slashPanel]);
651
+
642
652
  const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
643
653
  if (typeof rpcCall !== 'function') throw new Error('投递目标设置暂不可用。');
644
654
  return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
@@ -794,6 +804,12 @@ export function DeliveryTargetSettingsPage({
794
804
  account,
795
805
  rpcCall: accessRpcCall,
796
806
  })
807
+ : activeTab.id === 'slash' && channel === 'feishu'
808
+ ? h(FeishuSlashPanelSettingsPage, {
809
+ account: { ...account, slashPanel },
810
+ rpcCall: accessRpcCall,
811
+ onSaved: setSlashPanel,
812
+ })
797
813
  : activeTab.id === 'voice' && channel === 'feishu'
798
814
  ? h(FeishuVoiceSettingsPage, {
799
815
  key: account.botId,
@@ -263,12 +263,42 @@ const EN = Object.freeze({
263
263
  '需在飞书为该机器人开通“获取群组中所有消息”权限(im:message.group_msg);开通后,机器人会处理群聊中的所有可见消息。': 'Grant this bot the “Read all messages in associated group chat” Feishu scope (im:message.group_msg); once granted, it will process every visible group message.',
264
264
  '群聊响应方式修改失败,请重试。': 'Could not update the group response mode. Try again.',
265
265
  '群消息权限授权失败,请重试。': 'Could not authorize group-message permission. Try again.',
266
- '群聊以话题方式回复': 'Reply to group chats as Feishu topics',
267
- '群聊以话题方式回复设置保存失败,请重试。': 'Could not save the topic reply setting. Try again.',
268
- '关闭(群内直接回复)': 'Off (reply in the group feed)',
266
+ '被 @ 时以话题方式回复': 'Reply as a Feishu topic when mentioned',
267
+ '以话题方式回复设置保存失败,请重试。': 'Could not save the topic reply setting. Try again.',
268
+ '关闭(在会话主窗口回复)': 'Off (reply in the chat feed)',
269
269
  '开启(自动开启独立飞书话题)': 'On (auto-open a dedicated Feishu topic)',
270
- '开启后,群聊中向机器人提问会自动开启独立飞书话题,回复落在话题内;每个话题是 dsh 会话列表里一条独立会话,上下文互不串。私聊不受影响。': 'When enabled, questions to the bot in a group auto-open a dedicated Feishu topic and replies stay inside it. Each topic is an independent conversation in dsh with its own context. Private chats are unaffected.',
270
+ '开启后,在群聊或私聊里 @机器人 提问,机器人会在那条消息下新建一个飞书话题并在话题内回答;该话题是一条独立会话,话题里的后续消息都属于同一个会话。只发一个 @机器人(打开菜单)不会新建话题,没有 @机器人 的消息也留在原来的会话里。': 'When enabled, mentioning the bot in a group or a private chat opens a dedicated Feishu topic under that message and the answer stays inside it. That topic is an independent conversation in dsh, and later messages in the topic belong to the same conversation. A bare "@bot" (which opens the menu) opens no topic, and messages without a mention stay in the chat\'s own conversation.',
271
271
  '分步直推': 'Step push',
272
+ '指令面板': 'Command panel',
273
+ '个失败。': 'failed.',
274
+ '个机器人,': 'bots,',
275
+ '个机器人。': 'bots.',
276
+ '已同步到': 'Copied to',
277
+ '这个渠道没有其他飞书机器人,不用同步。': 'This channel has no other Feishu bots to copy to.',
278
+ '正在同步…': 'Copying…',
279
+ '确认同步': 'Copy to all',
280
+ '个飞书机器人,它们各自的面板设置会被覆盖。': 'other Feishu bots, overwriting their own panel settings.',
281
+ '将把这套面板设置写入另外': 'This copies the panel to',
282
+ '保存并同步到其他机器人': 'Save and copy to the other bots',
283
+ '指令面板设置': 'Command panel settings',
284
+ '指令面板模式': 'Command panel mode',
285
+ '面板只决定聊天输入框里打「/」时列出的指令和顺序;被移除的指令仍可手动输入,功能不受影响。同步由机器人后台完成,指令较多时需要一点时间。': 'The panel only decides which commands the "/" list in the chat input offers, and in which order. A removed command can still be typed by hand and keeps working. The bot syncs the panel in the background; many commands take a moment.',
286
+ '跟随默认(随插件版本更新)': 'Follow the default (updated with the plugin)',
287
+ '自定义(自己挑指令和顺序)': 'Custom (choose the commands and their order)',
288
+ '当前没有选择任何指令,保存后「/」面板会变成空的。': 'No commands are selected; saving leaves the "/" panel empty.',
289
+ '添加指令': 'Add command',
290
+ '选择要加入的指令': 'Choose a command to add',
291
+ '指令都已加入': 'Every command is already listed',
292
+ '加入': 'Add',
293
+ '恢复全部': 'Restore all',
294
+ '全部移除': 'Remove all',
295
+ '移除': 'Remove',
296
+ '上移': 'Move up',
297
+ '下移': 'Move down',
298
+ '保存': 'Save',
299
+ '已保存,面板稍后同步': 'Saved; the panel syncs shortly',
300
+ '请选择面板要显示的指令与顺序。': 'Choose which commands the panel shows, and in what order.',
301
+ '飞书指令面板设置暂不可用。': 'The Feishu command panel settings are unavailable right now.',
272
302
  '查看分步直推说明': 'View step push help',
273
303
  '开启后逐步推送工具调用与过程说明': 'Push tool calls and process notes step by step',
274
304
  '任务过程展示': 'Task progress display',
@@ -354,7 +384,6 @@ const EN = Object.freeze({
354
384
  '工作区绝对路径': 'Absolute workspace path',
355
385
  '/绝对路径/到/工作区': '/absolute/path/to/workspace',
356
386
  '修改': 'Change',
357
- '保存': 'Save',
358
387
  '上下文增强': 'Context enhancement',
359
388
  '未开启': 'Not enabled',
360
389
  '仅群聊': 'Groups only',
@@ -494,6 +494,15 @@ const CSS = String.raw`
494
494
  .dim-feishuGroupPermissionAction .dim-deliveryButton { color: var(--dsw-alias-state-business-primary, #3370ff); border-color: color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 30%, var(--dsw-alias-border-l2, #dfe1e5)); }
495
495
  .dim-feishuGroupError, .dim-feishuGroupRefreshError { margin: 0; padding: 9px 11px; border-radius: 8px; font-size: 12px; line-height: 18px; }
496
496
  .dim-feishuGroupError, .dim-feishuGroupRefreshError { color: var(--dsw-alias-state-error-primary, #d54941); background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #d54941) 7%, var(--dsw-alias-bg-layer-1, #fff)); }
497
+ .dim-feishuPanelList { display: grid; gap: 7px; margin: 0; padding: 0; list-style: none; }
498
+ .dim-feishuPanelRow { min-width: 0; display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; align-items: center; gap: 10px; padding: 8px 10px; border: 1px solid var(--dsw-alias-border-l1, #eef0f3); border-radius: 9px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
499
+ .dim-feishuPanelIndex { min-width: 18px; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 17px; text-align: right; }
500
+ .dim-feishuPanelCommand { min-width: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 12px; line-height: 18px; overflow-wrap: anywhere; }
501
+ .dim-feishuPanelRowActions, .dim-feishuPanelAdd { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
502
+ .dim-feishuPanelRow .dim-deliveryButton { min-height: 28px; padding: 0 9px; font-size: 12px; }
503
+ .dim-feishuPanelAdd { margin-top: 2px; }
504
+ .dim-feishuPanelAdd .dim-feishuGroupSelect { flex: 1 1 220px; width: auto; }
505
+ .dim-feishuPanelFooter { display: flex; justify-content: flex-start; }
497
506
  .dim-feishuGroupAuthorization { min-width: 0; display: grid; grid-template-columns: 184px minmax(0, 1fr); align-items: start; gap: 24px; padding: 18px; border: 1px solid color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 30%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 12px; background: color-mix(in srgb, var(--dsw-alias-state-business-primary, #3370ff) 2.5%, var(--dsw-alias-bg-layer-3, #fff)); }
498
507
  .dim-feishuGroupAuthorizationState { min-height: 126px; grid-template-columns: 32px minmax(0, 1fr); align-items: center; }
499
508
  .dim-feishuGroupAuthorizationState h3, .dim-feishuGroupAuthorizationError h3, .dim-feishuGroupAuthorizationCopy h3 { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 16px; line-height: 24px; font-weight: 650; }
@@ -253,7 +253,7 @@ export async function createProductionController(ctx, config = {}, internals = {
253
253
  domain: botConfig.domain,
254
254
  botOpenId: botConfig.botOpenId,
255
255
  groupResponseMode: botConfig.groupResponseMode,
256
- groupTopicReply: botConfig.groupTopicReply,
256
+ mentionTopicReply: botConfig.mentionTopicReply,
257
257
  stepPush: botConfig.stepPush,
258
258
  stepPushMode: botConfig.stepPushMode,
259
259
  voice: voiceSource,
@@ -267,6 +267,7 @@ export async function createProductionController(ctx, config = {}, internals = {
267
267
  }),
268
268
  replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
269
269
  slashCommands: config.slashCommands !== false,
270
+ slashPanel: botConfig.slashPanel,
270
271
  ...(wsAgent ? { wsAgent } : {}),
271
272
  logger: {
272
273
  error: (...args) => logger.error?.(`[${botId ?? botConfig.id}]`, ...args),