@xmanrui/dsh-im 4.29.1 → 4.31.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.31.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": [
@@ -90,6 +90,10 @@
90
90
  {
91
91
  "name": "Biertolovisk",
92
92
  "url": "https://github.com/rosalynthompson32-a11y"
93
+ },
94
+ {
95
+ "name": "Tianjun Cai",
96
+ "url": "https://github.com/Pegasus02"
93
97
  }
94
98
  ],
95
99
  "license": "MIT",
@@ -178,7 +182,7 @@
178
182
  "mailparser": "^3.9.28",
179
183
  "nodemailer": "^10.0.10",
180
184
  "qrcode": "1.5.4",
181
- "undici": "7.29.0"
185
+ "undici": "7.29.1"
182
186
  },
183
187
  "devDependencies": {
184
188
  "@deepseek-ai/cordis": "4.0.1",
@@ -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
+ }
@@ -20,6 +20,7 @@ const FIELD_LABELS = Object.freeze({
20
20
  chatId: '会话标识',
21
21
  threadId: '话题标识',
22
22
  botId: '机器人标识',
23
+ sentAt: '发送时间',
23
24
  });
24
25
 
25
26
  const FIELD_HELP = Object.freeze({
@@ -39,6 +40,10 @@ const FIELD_HELP = Object.freeze({
39
40
  labelKey: 'threadIdHelpLabel',
40
41
  text: '该字段不是每个渠道都能提供。飞书话题群的消息会带上话题 ID,用于区分同一群组内的不同话题;当前消息不在话题中时,即使已选择该字段,<dsh_im_source> 中也会省略 threadId。',
41
42
  }),
43
+ sentAt: Object.freeze({
44
+ labelKey: 'sentAtHelpLabel',
45
+ text: '当前消息的发送时间,格式为 YYYY-MM-DD HH:mm:ss(本机时区)。优先使用平台给出的发送时间;平台未提供时,从微信消息 ID 中解析出发送时间;两者都不可用时,即使已选择该字段,<dsh_im_source> 中也会省略 sentAt。目前仅微信渠道提供;把本机时区写进增强提示词,模型判断先后顺序时更准确。',
46
+ }),
42
47
  });
43
48
 
44
49
  const SCOPE_COPY = Object.freeze({
@@ -50,6 +55,7 @@ const SCOPE_COPY = Object.freeze({
50
55
  conversationTitleHelpLabel: '查看群聊会话标题字段说明',
51
56
  chatIdHelpLabel: '查看群聊会话标识字段说明',
52
57
  threadIdHelpLabel: '查看群聊话题标识字段说明',
58
+ sentAtHelpLabel: '查看群聊发送时间字段说明',
53
59
  guidanceLabel: '增强提示词',
54
60
  guidanceHelpLabel: '查看群聊增强提示词使用说明',
55
61
  guidanceUsage: '用于告诉模型如何使用当前群聊消息的 <dsh_im_source> 来源字段。只填写正文,插件会自动添加 <dsh_im_source_guidance> 成对标签。',
@@ -63,6 +69,7 @@ const SCOPE_COPY = Object.freeze({
63
69
  conversationTitleHelpLabel: '查看私聊会话标题字段说明',
64
70
  chatIdHelpLabel: '查看私聊会话标识字段说明',
65
71
  threadIdHelpLabel: '查看私聊话题标识字段说明',
72
+ sentAtHelpLabel: '查看私聊发送时间字段说明',
66
73
  guidanceLabel: '增强提示词',
67
74
  guidanceHelpLabel: '查看私聊增强提示词使用说明',
68
75
  guidanceUsage: '用于告诉模型如何使用当前私聊消息的 <dsh_im_source> 来源字段。只填写正文,插件会自动添加 <dsh_im_source_guidance> 成对标签。',
@@ -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',
@@ -391,6 +420,10 @@ const EN = Object.freeze({
391
420
  '查看私聊话题标识字段说明': 'View direct topic ID field details',
392
421
  '该字段不是每个渠道都能提供。飞书话题群的消息会带上话题 ID,用于区分同一群组内的不同话题;当前消息不在话题中时,即使已选择该字段,<dsh_im_source> 中也会省略 threadId。': 'This field is not available on every channel. Messages in a Feishu topic chat carry the topic ID, which distinguishes different topics inside the same group. If the current message is not inside a topic, <dsh_im_source> omits threadId even when the field is selected.',
393
422
  '机器人标识': 'Bot ID',
423
+ '发送时间': 'Sent at',
424
+ '查看群聊发送时间字段说明': 'View group sent-at field details',
425
+ '查看私聊发送时间字段说明': 'View direct sent-at field details',
426
+ '当前消息的发送时间,格式为 YYYY-MM-DD HH:mm:ss(本机时区)。优先使用平台给出的发送时间;平台未提供时,从微信消息 ID 中解析出发送时间;两者都不可用时,即使已选择该字段,<dsh_im_source> 中也会省略 sentAt。目前仅微信渠道提供;把本机时区写进增强提示词,模型判断先后顺序时更准确。': 'When the current message was sent, as YYYY-MM-DD HH:mm:ss in the local time zone. The platform send time is used when available; otherwise the send time is decoded from the WeChat message ID. If neither is available, <dsh_im_source> omits sentAt even when the field is selected. WeChat is the only channel that provides it today. Naming your time zone in the guidance makes ordering judgements more accurate.',
394
427
  '增强提示词': 'Guidance',
395
428
  '查看增强提示词使用说明': 'View guidance instructions',
396
429
  '查看群聊增强提示词使用说明': 'View group guidance instructions',
@@ -417,7 +450,7 @@ const EN = Object.freeze({
417
450
  '上下文增强保存失败,请重试。': 'Could not save context enhancement. Try again.',
418
451
  '请提交完整的上下文增强设置。': 'Submit the complete context enhancement settings.',
419
452
  '群聊和私聊开关必须是布尔值。': 'Group and direct switches must be booleans.',
420
- '来源字段只能选择已定义的八个字段。': 'Source fields must be chosen from the eight defined fields.',
453
+ '来源字段只能选择已定义的字段。': 'Source fields must be chosen from the defined fields.',
421
454
  '保存中…': 'Saving…',
422
455
  '未设置': 'Not set',
423
456
  '工作区修改失败,请重试。': 'Could not update the workspace. Try again.',
@@ -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; }