@xmanrui/dsh-im 4.9.1 → 4.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/PROACTIVE_DELIVERY.en.md +8 -0
  2. package/PROACTIVE_DELIVERY.md +8 -0
  3. package/lib/client.js +610 -56
  4. package/lib/index.js +273 -272
  5. package/package.json +12 -1
  6. package/plugin-src/client/channels/feishu/api.js +2 -0
  7. package/plugin-src/client/channels/feishu/index.js +54 -0
  8. package/plugin-src/client/global-settings.js +341 -0
  9. package/plugin-src/client/i18n.js +29 -0
  10. package/plugin-src/client/index.js +47 -6
  11. package/plugin-src/client/styles.js +49 -0
  12. package/plugin-src/host/channels/dingtalk/production.mjs +12 -0
  13. package/plugin-src/host/channels/feishu/production.mjs +14 -0
  14. package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
  15. package/plugin-src/host/channels/qq/production.mjs +12 -0
  16. package/plugin-src/host/channels/shared/production.mjs +12 -0
  17. package/plugin-src/host/channels/slack/production.mjs +12 -0
  18. package/plugin-src/host/channels/wecom/production.mjs +12 -0
  19. package/plugin-src/host/channels/weixin/production.mjs +12 -0
  20. package/plugin-src/host/channels/whatsapp/production.mjs +12 -0
  21. package/plugin-src/host/delivery-adapter.mjs +1 -0
  22. package/plugin-src/host/delivery-service.mjs +15 -0
  23. package/plugin-src/host/harness-session-coordinator.mjs +26 -5
  24. package/plugin-src/host/inbound-ttl-rpc.mjs +83 -0
  25. package/plugin-src/host/inbound-ttl-runtime.mjs +85 -0
  26. package/plugin-src/host/inbound-ttl-service.mjs +179 -0
  27. package/plugin-src/host/index.mjs +8 -0
  28. package/plugin-src/host/modern-harness-api.mjs +58 -2
  29. package/src/channels/feishu/bridge.mjs +164 -4
  30. package/src/channels/feishu/feishu-channel.mjs +45 -10
  31. package/src/channels/feishu/feishu-runtime.mjs +9 -0
  32. package/src/channels/feishu/message-utils.mjs +13 -0
  33. package/src/channels/feishu/multi-bot-controller.mjs +16 -0
  34. package/src/channels/feishu/plugin-config-store.mjs +1 -0
  35. package/src/channels/feishu/state-store.mjs +25 -0
  36. package/src/channels/shared/bot-workspace-store.mjs +6 -0
  37. package/src/channels/shared/inbound-file.mjs +117 -2
  38. package/src/channels/shared/inbound-ttl-store.mjs +110 -0
  39. package/src/channels/shared/inbound-ttl.mjs +22 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.9.1",
3
+ "version": "4.10.0",
4
4
  "description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -87,6 +87,17 @@
87
87
  "@deepseek-ai/dsh-client-locale"
88
88
  ],
89
89
  "platform": "web"
90
+ },
91
+ "compatibility": {
92
+ "dsh": "0.1.2-alpha.4 || 0.1.2-alpha.5 || 0.1.2-rc.1",
93
+ "dshReleases": {
94
+ "0.1.2-alpha.4": "compatible",
95
+ "0.1.2-alpha.5": "compatible",
96
+ "0.1.2-rc.1": "compatible"
97
+ },
98
+ "profiles": [
99
+ "web"
100
+ ]
90
101
  }
91
102
  },
92
103
  "scripts": {
@@ -29,6 +29,7 @@ export const FEISHU_ENDPOINTS = Object.freeze({
29
29
  setContextEnhancement: "bot.context-enhancement.set",
30
30
  setAccessPolicy: "bot.access-policy.set",
31
31
  setGroupResponseMode: "bot.group-response-mode.set",
32
+ setGroupTopicReply: "bot.group-topic-reply.set",
32
33
  // Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
33
34
  testConnection: "connection.test",
34
35
  disconnect: "connection.disconnect",
@@ -212,6 +213,7 @@ export function normalizeBotConnection(value, fallbackBotId) {
212
213
  ? { accessPolicy: normalizeAccessPolicy(value.accessPolicy) }
213
214
  : {}),
214
215
  groupResponseMode: normalizeGroupResponseMode(value.groupResponseMode),
216
+ groupTopicReply: value.groupTopicReply === true,
215
217
  groupMessagePermissionGranted: value.groupMessagePermissionGranted === true,
216
218
  bot: normalizeBot(value.bot),
217
219
  health: normalizeHealth(value.health, connected),
@@ -542,6 +542,50 @@ function GroupResponseModeEditor({
542
542
  );
543
543
  }
544
544
 
545
+ function GroupTopicReplyEditor({ value = false, disabled = false, onSave }) {
546
+ const current = value === true ? "on" : "off";
547
+ const [saving, setSaving] = React.useState(false);
548
+ const [error, setError] = React.useState(null);
549
+
550
+ const change = async (event) => {
551
+ const next = event.target.value === "on";
552
+ if ((next ? "on" : "off") === current || saving || disabled) return;
553
+ setSaving(true);
554
+ setError(null);
555
+ try {
556
+ await onSave?.(next);
557
+ } catch (cause) {
558
+ setError(cause?.message ?? "群聊以话题方式回复设置保存失败,请重试。");
559
+ } finally {
560
+ setSaving(false);
561
+ }
562
+ };
563
+
564
+ return h("div", { className: "bxf-responseMode dim-responseMode" },
565
+ h("div", { className: "bxf-responseModeHeader dim-responseModeHeader" },
566
+ h("span", null, "群聊以话题方式回复"),
567
+ saving
568
+ ? h("span", { className: "bxf-responseModeStatus dim-responseModeStatus" }, "保存中…")
569
+ : null),
570
+ h("select", {
571
+ className: "bxf-responseModeSelect dim-responseModeSelect",
572
+ value: current,
573
+ disabled: disabled || saving,
574
+ "aria-label": "群聊以话题方式回复",
575
+ onChange: (event) => { void change(event); },
576
+ },
577
+ h("option", { value: "off" }, "关闭(群内直接回复)"),
578
+ h("option", { value: "on" }, "开启(自动开启独立飞书话题)"),
579
+ ),
580
+ h("small", { className: "bxf-responseModeHelp dim-responseModeHelp" },
581
+ "开启后,群聊中向机器人提问会自动开启独立飞书话题,回复落在话题内;每个话题是 dsh 会话列表里一条独立会话,上下文互不串。私聊不受影响。"),
582
+ error ? h("p", {
583
+ className: "bxf-responseModeError dim-responseModeError",
584
+ role: "alert",
585
+ }, error) : null,
586
+ );
587
+ }
588
+
545
589
  export function BotCard({
546
590
  connection,
547
591
  busy,
@@ -557,6 +601,7 @@ export function BotCard({
557
601
  onAgentPresetSave,
558
602
  onContextEnhancementSave,
559
603
  onGroupResponseModeSave,
604
+ onGroupTopicReplySave,
560
605
  onGroupMessagePermissionAuthorize,
561
606
  onRequestRemove,
562
607
  onConfirmRemove,
@@ -635,6 +680,11 @@ export function BotCard({
635
680
  onSave: onGroupResponseModeSave,
636
681
  onAuthorize: onGroupMessagePermissionAuthorize,
637
682
  }),
683
+ h(GroupTopicReplyEditor, {
684
+ value: connection.groupTopicReply,
685
+ disabled: Boolean(busy),
686
+ onSave: onGroupTopicReplySave,
687
+ }),
638
688
  provisionContent
639
689
  ? h("section", {
640
690
  className: "bxf-botProvision dim-botProvision",
@@ -726,6 +776,7 @@ function BotList(props) {
726
776
  onAgentPresetSave: (agentPreset) => props.onAgentPresetSave(bot, agentPreset),
727
777
  onContextEnhancementSave: (config) => props.onContextEnhancementSave(bot, config),
728
778
  onGroupResponseModeSave: (groupResponseMode) => props.onGroupResponseModeSave(bot, groupResponseMode),
779
+ onGroupTopicReplySave: (groupTopicReply) => props.onGroupTopicReplySave(bot, groupTopicReply),
729
780
  onGroupMessagePermissionAuthorize: () => props.onGroupMessagePermissionAuthorize(bot),
730
781
  onRequestRemove: () => props.onRequestRemove(bot),
731
782
  onConfirmRemove: () => props.onConfirmRemove(bot),
@@ -1545,6 +1596,9 @@ export function FeishuSettingsTab({ rpcCall }) {
1545
1596
  connection, "context-enhancement", FEISHU_ENDPOINTS.setContextEnhancement, { config },
1546
1597
  ),
1547
1598
  onGroupResponseModeSave: saveGroupResponseMode,
1599
+ onGroupTopicReplySave: (connection, groupTopicReply) => saveBotSetting(
1600
+ connection, "group-topic-reply", FEISHU_ENDPOINTS.setGroupTopicReply, { groupTopicReply },
1601
+ ),
1548
1602
  onGroupMessagePermissionAuthorize: authorizeGroupMessages,
1549
1603
  onRequestRemove: requestRemove,
1550
1604
  onConfirmRemove: (bot) => void confirmRemove(bot),
@@ -0,0 +1,341 @@
1
+ import * as React from 'react';
2
+
3
+ import {
4
+ DEFAULT_INBOUND_TTL_HOURS,
5
+ INBOUND_TTL_MAX_HOURS,
6
+ normalizeInboundTtlHours,
7
+ } from '../../src/channels/shared/inbound-ttl.mjs';
8
+ import { h } from './i18n.js';
9
+
10
+ export const GLOBAL_SETTINGS_RPC_CHANNEL = '/dsh-im-settings';
11
+
12
+ export const GLOBAL_SETTINGS_TAB_ID = 'global-settings';
13
+
14
+ const ATTACHMENTS_TAB_ID = 'dim-general-settings-tab-attachments';
15
+ const ATTACHMENTS_PANEL_ID = 'dim-general-settings-panel-attachments';
16
+
17
+ export const GLOBAL_SETTINGS_ENDPOINTS = Object.freeze({
18
+ getTtl: 'settings.inbound-ttl.get',
19
+ setTtl: 'settings.inbound-ttl.set',
20
+ sweep: 'settings.inbound-ttl.sweep',
21
+ });
22
+
23
+ function presentError(error, fallback) {
24
+ return error?.message || fallback;
25
+ }
26
+
27
+ function unwrapRpcResult(result) {
28
+ if (result?.ok === true) return result.value;
29
+ if (result?.ok === false) {
30
+ const error = new Error(result.error?.message || '请求失败,请稍后重试。');
31
+ error.code = result.error?.code;
32
+ throw error;
33
+ }
34
+ return result;
35
+ }
36
+
37
+ export function GlobalSettingsLogoGlyph({ size } = {}) {
38
+ return h('svg', {
39
+ ...(size === undefined ? {} : { width: size, height: size }),
40
+ viewBox: '0 0 24 24',
41
+ focusable: 'false',
42
+ 'aria-hidden': 'true',
43
+ 'data-im-icon': 'global-settings',
44
+ }, h('path', {
45
+ fill: 'currentColor',
46
+ d: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.488.488 0 0 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58ZM12 15.6A3.61 3.61 0 0 1 8.4 12c0-1.98 1.62-3.6 3.6-3.6s3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6Z',
47
+ }));
48
+ }
49
+
50
+ function GlobalButton({ children, kind = 'secondary', className = '', ...props }) {
51
+ return h('button', {
52
+ ...props,
53
+ type: props.type ?? 'button',
54
+ className: `dim-deliveryButton ${className}`.trim(),
55
+ 'data-kind': kind,
56
+ }, children);
57
+ }
58
+
59
+ export function GlobalSettingsPanel({ rpcCall }) {
60
+ const [phase, setPhase] = React.useState('loading');
61
+ const [loadError, setLoadError] = React.useState(null);
62
+ const [ttlInput, setTtlInput] = React.useState('');
63
+ const [savedTtl, setSavedTtl] = React.useState(null);
64
+ const [ttlError, setTtlError] = React.useState(false);
65
+ const [saveError, setSaveError] = React.useState(null);
66
+ const [saveSucceeded, setSaveSucceeded] = React.useState(false);
67
+ const [isSaving, setIsSaving] = React.useState(false);
68
+ const [sweepConfirming, setSweepConfirming] = React.useState(false);
69
+ const [sweeping, setSweeping] = React.useState(false);
70
+ const ttlErrorId = React.useId();
71
+ const ttlHintsId = React.useId();
72
+ const sweepTriggerId = React.useId();
73
+ const sweepConfirmId = React.useId();
74
+ const sweepConfirmTextId = React.useId();
75
+ const sweepConfirmButtonId = React.useId();
76
+ const mounted = React.useRef(true);
77
+ const saving = React.useRef(false);
78
+
79
+ const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
80
+ if (typeof rpcCall !== 'function') throw new Error('通用设置暂不可用。');
81
+ return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
82
+ }, [rpcCall]);
83
+
84
+ const loadSettings = React.useCallback(async ({ signal } = {}) => {
85
+ setPhase('loading');
86
+ setLoadError(null);
87
+ try {
88
+ const value = await invoke(GLOBAL_SETTINGS_ENDPOINTS.getTtl, {}, signal);
89
+ if (signal?.aborted || !mounted.current) return;
90
+ const ttlHours = normalizeInboundTtlHours(value?.ttlHours);
91
+ if (ttlHours === null) {
92
+ setLoadError('通用设置返回了无法识别的响应。');
93
+ setPhase('error');
94
+ return;
95
+ }
96
+ setSavedTtl(ttlHours);
97
+ setTtlInput(String(ttlHours));
98
+ setTtlError(false);
99
+ setSaveError(null);
100
+ setSaveSucceeded(false);
101
+ setPhase('ready');
102
+ } catch (caught) {
103
+ if (signal?.aborted || caught?.name === 'AbortError' || !mounted.current) return;
104
+ setLoadError(presentError(caught, '无法读取通用设置,请稍后重试。'));
105
+ setPhase('error');
106
+ }
107
+ }, [invoke]);
108
+
109
+ React.useEffect(() => {
110
+ mounted.current = true;
111
+ const controller = new AbortController();
112
+ void loadSettings({ signal: controller.signal });
113
+ return () => {
114
+ mounted.current = false;
115
+ controller.abort();
116
+ };
117
+ }, [loadSettings]);
118
+
119
+ React.useEffect(() => {
120
+ if (!sweepConfirming) return;
121
+ globalThis.document?.getElementById(sweepConfirmButtonId)?.focus();
122
+ }, [sweepConfirmButtonId, sweepConfirming]);
123
+
124
+ const commitTtl = async () => {
125
+ if (phase === 'loading' || saving.current) return;
126
+ const proposed = normalizeInboundTtlHours(ttlInput.trim());
127
+ if (proposed === null) {
128
+ setSaveError(null);
129
+ setSaveSucceeded(false);
130
+ setTtlError(true);
131
+ return;
132
+ }
133
+ setTtlError(false);
134
+ if (proposed === savedTtl) return;
135
+ saving.current = true;
136
+ setIsSaving(true);
137
+ setSaveError(null);
138
+ setSaveSucceeded(false);
139
+ try {
140
+ const value = await invoke(GLOBAL_SETTINGS_ENDPOINTS.setTtl, { ttlHours: proposed });
141
+ if (!mounted.current) return;
142
+ const confirmed = normalizeInboundTtlHours(value?.ttlHours);
143
+ const finalTtl = confirmed === null ? proposed : confirmed;
144
+ setSavedTtl(finalTtl);
145
+ // Never clobber the field if the user kept typing while the save was in flight.
146
+ setTtlInput((current) => current.trim() === String(proposed) ? String(finalTtl) : current);
147
+ setLoadError(null);
148
+ setSaveSucceeded(true);
149
+ setPhase('ready');
150
+ } catch (caught) {
151
+ if (mounted.current) {
152
+ setSaveError(presentError(caught, '设置保存失败,请稍后重试。'));
153
+ }
154
+ } finally {
155
+ saving.current = false;
156
+ if (mounted.current) setIsSaving(false);
157
+ }
158
+ };
159
+
160
+ const requestSweep = () => {
161
+ if (sweeping) return;
162
+ setSweepConfirming(true);
163
+ };
164
+
165
+ const cancelSweep = ({ restoreFocus = false } = {}) => {
166
+ setSweepConfirming(false);
167
+ if (restoreFocus) globalThis.document?.getElementById(sweepTriggerId)?.focus();
168
+ };
169
+
170
+ const runSweep = async () => {
171
+ setSweepConfirming(false);
172
+ setSweeping(true);
173
+ try {
174
+ // Manual sweeps are silent by design: the busy label on the button is
175
+ // the only feedback, and RPC results or failures are not announced.
176
+ await invoke(GLOBAL_SETTINGS_ENDPOINTS.sweep, {});
177
+ } catch {
178
+ // Intentionally swallowed — no result feedback for manual sweeps.
179
+ } finally {
180
+ if (mounted.current) setSweeping(false);
181
+ }
182
+ };
183
+
184
+ const inlineStatus = phase === 'loading'
185
+ ? { role: 'status', message: '正在读取通用设置…' }
186
+ : ttlError
187
+ ? { role: 'alert', message: `请输入 -1、0 或 1~${INBOUND_TTL_MAX_HOURS} 之间的整数。` }
188
+ : saveError
189
+ ? { role: 'alert', message: saveError }
190
+ : phase === 'error' && loadError
191
+ ? { role: 'alert', message: loadError }
192
+ : saveSucceeded
193
+ ? { role: 'status', message: '已保存' }
194
+ : null;
195
+
196
+ const proposedTtl = normalizeInboundTtlHours(ttlInput.trim());
197
+ const canSave = phase !== 'loading'
198
+ && !isSaving
199
+ && proposedTtl !== null
200
+ && proposedTtl !== savedTtl;
201
+
202
+ return h('section', { className: 'dim-generalSettingsPage', 'aria-label': '通用设置' },
203
+ h('header', { className: 'dim-generalSettingsHeader' },
204
+ h('h2', null, '通用设置')),
205
+ h('div', { className: 'dim-generalSettingsTabsBar' },
206
+ h('div', {
207
+ className: 'dim-generalSettingsTabs',
208
+ role: 'tablist',
209
+ 'aria-label': '通用设置分类',
210
+ },
211
+ h('button', {
212
+ type: 'button',
213
+ id: ATTACHMENTS_TAB_ID,
214
+ className: 'dim-generalSettingsTab',
215
+ role: 'tab',
216
+ 'aria-selected': true,
217
+ 'aria-controls': ATTACHMENTS_PANEL_ID,
218
+ }, '附件'))),
219
+ h('div', {
220
+ id: ATTACHMENTS_PANEL_ID,
221
+ className: 'dim-generalSettingsTabPanel',
222
+ role: 'tabpanel',
223
+ 'aria-labelledby': ATTACHMENTS_TAB_ID,
224
+ },
225
+ h('section', {
226
+ className: 'dim-globalSection',
227
+ 'aria-label': '附件',
228
+ 'aria-busy': phase === 'loading',
229
+ },
230
+ h('div', { className: 'dim-globalHead' },
231
+ h('div', { className: 'dim-globalHeadTitle' },
232
+ h('h3', { id: 'dim-globalTtlTitle' }, '附件保留时长 (小时)'),
233
+ h('div', { className: 'dim-globalTtlHelp' },
234
+ h('button', {
235
+ type: 'button',
236
+ className: 'dim-channelHelpButton dim-globalTtlHelpButton',
237
+ 'aria-label': '查看附件保留时长说明',
238
+ 'aria-describedby': ttlHintsId,
239
+ }, h('span', { 'aria-hidden': 'true' }, '?')),
240
+ h('div', {
241
+ id: ttlHintsId,
242
+ className: 'dim-globalTtlTooltip',
243
+ role: 'tooltip',
244
+ },
245
+ h('ul', { className: 'dim-globalTtlHints' },
246
+ h('li', null,
247
+ h('code', null, '-1'),
248
+ h('span', null, '永久保留,不会自动清理')),
249
+ h('li', null,
250
+ h('code', null, '0'),
251
+ h('span', null, '每 Turn 结束后立即清理')),
252
+ h('li', null,
253
+ h('code', null, `1~${INBOUND_TTL_MAX_HOURS}`),
254
+ h('span', null, '小时后自动清理'))))))),
255
+ h('form', {
256
+ className: 'dim-globalTtlRow',
257
+ onSubmit: (event) => {
258
+ event.preventDefault();
259
+ void commitTtl();
260
+ },
261
+ },
262
+ h('input', {
263
+ id: 'dim-globalTtlInput',
264
+ className: 'dim-globalTtlInput',
265
+ type: 'text',
266
+ value: ttlInput,
267
+ placeholder: String(DEFAULT_INBOUND_TTL_HOURS),
268
+ autoComplete: 'off',
269
+ disabled: phase === 'loading' || isSaving,
270
+ 'aria-labelledby': 'dim-globalTtlTitle',
271
+ 'aria-invalid': ttlError ? 'true' : undefined,
272
+ 'aria-describedby': ttlError ? ttlErrorId : ttlHintsId,
273
+ onBlur: () => {
274
+ setTtlError(normalizeInboundTtlHours(ttlInput.trim()) === null);
275
+ },
276
+ onKeyDown: (event) => {
277
+ if (event.key !== 'Enter') return;
278
+ event.preventDefault();
279
+ void commitTtl();
280
+ },
281
+ onChange: (event) => {
282
+ setTtlInput(event.target.value);
283
+ setTtlError(false);
284
+ setSaveError(null);
285
+ setSaveSucceeded(false);
286
+ },
287
+ }),
288
+ h(GlobalButton, {
289
+ type: 'submit',
290
+ kind: 'primary',
291
+ className: 'dim-globalSaveButton',
292
+ disabled: !canSave,
293
+ }, isSaving ? '保存中…' : '保存'),
294
+ h('div', {
295
+ className: 'dim-globalSweepAction',
296
+ onBlur: (event) => {
297
+ if (sweepConfirming && !event.currentTarget.contains(event.relatedTarget)) cancelSweep();
298
+ },
299
+ },
300
+ h(GlobalButton, {
301
+ id: sweepTriggerId,
302
+ className: 'dim-globalSweepButton',
303
+ onClick: () => (sweepConfirming ? cancelSweep() : requestSweep()),
304
+ disabled: sweeping,
305
+ 'aria-haspopup': 'dialog',
306
+ 'aria-expanded': sweepConfirming,
307
+ 'aria-controls': sweepConfirmId,
308
+ }, sweeping ? '正在清理…' : '清理过期附件'),
309
+ sweepConfirming
310
+ ? h('div', {
311
+ id: sweepConfirmId,
312
+ className: 'dim-globalSweepConfirm',
313
+ role: 'alertdialog',
314
+ 'aria-label': '确认清理过期附件',
315
+ 'aria-describedby': sweepConfirmTextId,
316
+ onKeyDown: (event) => {
317
+ if (event.key !== 'Escape') return;
318
+ event.preventDefault();
319
+ cancelSweep({ restoreFocus: true });
320
+ },
321
+ },
322
+ h('p', { id: sweepConfirmTextId }, '确认清理当前已过期的附件?'),
323
+ h('div', { className: 'dim-globalSweepConfirmActions' },
324
+ h(GlobalButton, { onClick: () => cancelSweep({ restoreFocus: true }) }, '取消'),
325
+ h(GlobalButton, {
326
+ id: sweepConfirmButtonId,
327
+ kind: 'danger',
328
+ className: 'dim-globalSweepConfirmButton',
329
+ onClick: () => void runSweep(),
330
+ }, '确认清理')))
331
+ : null),
332
+ inlineStatus
333
+ ? h('p', {
334
+ id: ttlErrorId,
335
+ className: 'dim-globalInline',
336
+ 'data-tone': inlineStatus.role === 'alert' ? 'error' : undefined,
337
+ role: inlineStatus.role,
338
+ 'aria-live': inlineStatus.role === 'status' ? 'polite' : undefined,
339
+ }, inlineStatus.message)
340
+ : null))));
341
+ }
@@ -215,6 +215,11 @@ const EN = Object.freeze({
215
215
  '需在飞书为该机器人开通“获取群组中所有消息”权限(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.',
216
216
  '群聊响应方式修改失败,请重试。': 'Could not update the group response mode. Try again.',
217
217
  '群消息权限授权失败,请重试。': 'Could not authorize group-message permission. Try again.',
218
+ '群聊以话题方式回复': 'Reply to group chats as Feishu topics',
219
+ '群聊以话题方式回复设置保存失败,请重试。': 'Could not save the topic reply setting. Try again.',
220
+ '关闭(群内直接回复)': 'Off (reply in the group feed)',
221
+ '开启(自动开启独立飞书话题)': 'On (auto-open a dedicated Feishu topic)',
222
+ '开启后,群聊中向机器人提问会自动开启独立飞书话题,回复落在话题内;每个话题是 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.',
218
223
  '钉钉设置': 'DingTalk settings',
219
224
  '企业微信设置': 'WeCom settings',
220
225
  '扫码接入机器人': 'Scan QR code',
@@ -837,6 +842,28 @@ const EN = Object.freeze({
837
842
  '正在读取投递目标…': 'Loading delivery targets…',
838
843
  '尚未配置投递目标': 'No delivery targets yet',
839
844
  '点击“新建目标”可从已聊过的会话选择,也可手动填写。': 'Select New target to choose a conversation or enter the details manually.',
845
+ '全局设置': 'Global settings',
846
+ '通用设置': 'General settings',
847
+ '通用设置分类': 'General settings categories',
848
+ '附件': 'Attachments',
849
+ 'IM 设置导航': 'IM settings navigation',
850
+ '附件保留时长 (小时)': 'Attachment retention (hours)',
851
+ '查看附件保留时长说明': 'View attachment retention details',
852
+ '永久保留,不会自动清理': 'Keep forever; nothing is removed automatically',
853
+ '每 Turn 结束后立即清理': 'Cleaned up as soon as each turn ends',
854
+ '小时后自动清理': 'hours until automatic cleanup',
855
+ '请输入 -1、0 或 1~${INBOUND_TTL_MAX_HOURS} 之间的整数。': 'Enter -1, 0, or an integer from 1 to ${INBOUND_TTL_MAX_HOURS}.',
856
+ '设置保存失败,请稍后重试。': 'Could not save the setting. Try again later.',
857
+ '已保存': 'Saved',
858
+ '通用设置暂不可用。': 'General settings are unavailable.',
859
+ '正在读取通用设置…': 'Loading general settings…',
860
+ '无法读取通用设置,请稍后重试。': 'Could not load general settings. Try again later.',
861
+ '通用设置返回了无法识别的响应。': 'General settings returned an unrecognized response.',
862
+ '清理过期附件': 'Clean up expired attachments',
863
+ '确认清理过期附件': 'Confirm expired attachment cleanup',
864
+ '确认清理当前已过期的附件?': 'Clean up the attachments that are currently expired?',
865
+ '确认清理': 'Confirm cleanup',
866
+ '正在清理…': 'Cleaning up…',
840
867
  });
841
868
 
842
869
  export const en = EN;
@@ -865,6 +892,8 @@ function translateDynamic(text) {
865
892
  if (match) return `${match[1]}/${match[2]} online`;
866
893
  match = /^已接入 (\d+) 个机器人,其中 (\d+) 个在线$/.exec(text);
867
894
  if (match) return `${match[2]} of ${match[1]} bots online`;
895
+ match = /^请输入 -1、0 或 1~(\d+) 之间的整数。$/.exec(text);
896
+ if (match) return `Enter -1, 0, or an integer from 1 to ${match[1]}.`;
868
897
  match = /^正在读取\s*(.+?)\s*机器人状态…$/.exec(text);
869
898
  if (match) return `Loading ${channelName(match[1])} bot status…`;
870
899
  match = /^无法读取\s*(.+?)\s*机器人状态$/.exec(text);
@@ -48,6 +48,12 @@ import {
48
48
  DELIVERY_RPC_CHANNEL,
49
49
  DeliveryTargetSettingsPage,
50
50
  } from './delivery-settings.js';
51
+ import {
52
+ GLOBAL_SETTINGS_RPC_CHANNEL,
53
+ GLOBAL_SETTINGS_TAB_ID,
54
+ GlobalSettingsLogoGlyph,
55
+ GlobalSettingsPanel,
56
+ } from './global-settings.js';
51
57
  import {
52
58
  createLoopbackAwareRpcCalls,
53
59
  replacePageLocation,
@@ -173,6 +179,7 @@ export function IMSettingsTab({
173
179
  officeRpcCall,
174
180
  updateRpcCall,
175
181
  deliveryRpcCall,
182
+ globalSettingsRpcCall,
176
183
  workspaceDirectoryPicker,
177
184
  browserLocation = globalThis.location,
178
185
  navigateToRecoveryUrl = replacePageLocation,
@@ -182,7 +189,15 @@ export function IMSettingsTab({
182
189
  const [runningVersion, setRunningVersion] = React.useState(IM_PLUGIN_VERSION);
183
190
  const [deliverySettings, setDeliverySettings] = React.useState(null);
184
191
  const githubTooltipId = React.useId();
192
+ const generalSettingsTooltipId = React.useId();
193
+ const globalSettingsSelected = selected === GLOBAL_SETTINGS_TAB_ID;
185
194
  const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0];
195
+ const activeTabId = globalSettingsSelected
196
+ ? 'dim-general-settings-trigger'
197
+ : `dim-tab-${active.id}`;
198
+ const activePanelId = globalSettingsSelected
199
+ ? `dim-panel-${GLOBAL_SETTINGS_TAB_ID}`
200
+ : `dim-panel-${active.id}`;
186
201
  const reportLoopbackRecovery = React.useCallback((recovery) => {
187
202
  setLoopbackRecovery((current) => current?.url === recovery.url ? current : recovery);
188
203
  }, []);
@@ -202,6 +217,7 @@ export function IMSettingsTab({
202
217
  officeRpcCall,
203
218
  updateRpcCall,
204
219
  deliveryRpcCall,
220
+ globalSettingsRpcCall,
205
221
  }, {
206
222
  location: browserLocation,
207
223
  onRecovery: reportLoopbackRecovery,
@@ -211,6 +227,7 @@ export function IMSettingsTab({
211
227
  discordRpcCall,
212
228
  deliveryRpcCall,
213
229
  feishuRpcCall,
230
+ globalSettingsRpcCall,
214
231
  officeRpcCall,
215
232
  qqRpcCall,
216
233
  reportLoopbackRecovery,
@@ -253,17 +270,36 @@ export function IMSettingsTab({
253
270
  id: githubTooltipId,
254
271
  className: 'dim-githubTooltip',
255
272
  role: 'tooltip',
256
- }, '帮助与反馈 · 前往 GitHub'))),
273
+ }, '帮助与反馈 · 前往 GitHub')),
274
+ h('span', { className: 'dim-generalSettingsAction' },
275
+ h('button', {
276
+ type: 'button',
277
+ id: 'dim-general-settings-trigger',
278
+ className: 'dim-generalSettingsButton',
279
+ 'aria-label': '通用设置',
280
+ 'aria-describedby': generalSettingsTooltipId,
281
+ 'aria-controls': `dim-panel-${GLOBAL_SETTINGS_TAB_ID}`,
282
+ 'aria-current': globalSettingsSelected ? 'page' : undefined,
283
+ onClick: () => {
284
+ setSelected(GLOBAL_SETTINGS_TAB_ID);
285
+ setDeliverySettings(null);
286
+ },
287
+ }, h(GlobalSettingsLogoGlyph, { size: 17 })),
288
+ h('span', {
289
+ id: generalSettingsTooltipId,
290
+ className: 'dim-generalSettingsTooltip',
291
+ role: 'tooltip',
292
+ }, '通用设置'))),
257
293
  ),
258
294
  h('div', { className: 'dim-layout' },
259
- h('nav', { className: 'dim-rail', role: 'tablist', 'aria-label': 'IM 渠道' },
295
+ h('nav', { className: 'dim-rail', role: 'tablist', 'aria-label': 'IM 设置导航' },
260
296
  CHANNELS.map((channel) => h('button', {
261
297
  key: channel.id,
262
298
  type: 'button',
263
299
  role: 'tab',
264
300
  id: `dim-tab-${channel.id}`,
265
301
  className: 'dim-channel',
266
- 'aria-selected': channel.id === active.id,
302
+ 'aria-selected': !globalSettingsSelected && channel.id === active.id,
267
303
  'aria-controls': `dim-panel-${channel.id}`,
268
304
  onClick: () => {
269
305
  setSelected(channel.id);
@@ -279,8 +315,8 @@ export function IMSettingsTab({
279
315
  h('main', {
280
316
  className: 'dim-panel',
281
317
  role: 'tabpanel',
282
- id: `dim-panel-${active.id}`,
283
- 'aria-labelledby': `dim-tab-${active.id}`,
318
+ id: activePanelId,
319
+ 'aria-labelledby': activeTabId,
284
320
  },
285
321
  loopbackRecovery
286
322
  ? h(LoopbackRecoveryNotice, {
@@ -289,7 +325,9 @@ export function IMSettingsTab({
289
325
  })
290
326
  : null,
291
327
  h(BotSettingsContext.Provider, { value: botSettingsContext },
292
- deliverySettings?.channel === active.id
328
+ globalSettingsSelected
329
+ ? h(GlobalSettingsPanel, { rpcCall: rpcCalls.globalSettingsRpcCall })
330
+ : deliverySettings?.channel === active.id
293
331
  ? h(DeliveryTargetSettingsPage, {
294
332
  channel: active.id,
295
333
  account: deliverySettings,
@@ -370,6 +408,8 @@ export function apply(ctx) {
370
408
  ctx.connection.rpc.call(UPDATE_RPC_CHANNEL, endpoint, payload, signal);
371
409
  const deliveryRpcCall = (endpoint, payload, signal) =>
372
410
  ctx.connection.rpc.call(DELIVERY_RPC_CHANNEL, endpoint, payload, signal);
411
+ const globalSettingsRpcCall = (endpoint, payload, signal) =>
412
+ ctx.connection.rpc.call(GLOBAL_SETTINGS_RPC_CHANNEL, endpoint, payload, signal);
373
413
  const workspaceDirectoryPicker = Object.freeze({
374
414
  listDirectory: (path, signal) =>
375
415
  callWorkspaceDirectoryApi(ctx, 'listDirectory', path, signal),
@@ -395,6 +435,7 @@ export function apply(ctx) {
395
435
  officeRpcCall,
396
436
  updateRpcCall,
397
437
  deliveryRpcCall,
438
+ globalSettingsRpcCall,
398
439
  workspaceDirectoryPicker,
399
440
  }),
400
441
  }, IMSettingsTab));