@xmanrui/dsh-im 4.9.0 → 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 (47) hide show
  1. package/PROACTIVE_DELIVERY.en.md +8 -0
  2. package/PROACTIVE_DELIVERY.md +8 -0
  3. package/lib/client.js +679 -73
  4. package/lib/index.js +276 -275
  5. package/package.json +21 -6
  6. package/plugin-src/client/channels/dingtalk/api.js +23 -6
  7. package/plugin-src/client/channels/dingtalk/index.js +33 -6
  8. package/plugin-src/client/channels/dingtalk/styles.js +2 -0
  9. package/plugin-src/client/channels/feishu/api.js +2 -0
  10. package/plugin-src/client/channels/feishu/index.js +54 -0
  11. package/plugin-src/client/global-settings.js +341 -0
  12. package/plugin-src/client/i18n.js +33 -0
  13. package/plugin-src/client/index.js +47 -6
  14. package/plugin-src/client/styles.js +49 -0
  15. package/plugin-src/host/channels/dingtalk/production.mjs +12 -0
  16. package/plugin-src/host/channels/dingtalk/rpc.mjs +24 -0
  17. package/plugin-src/host/channels/feishu/production.mjs +14 -0
  18. package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
  19. package/plugin-src/host/channels/qq/production.mjs +12 -0
  20. package/plugin-src/host/channels/shared/production.mjs +12 -0
  21. package/plugin-src/host/channels/slack/production.mjs +12 -0
  22. package/plugin-src/host/channels/wecom/production.mjs +12 -0
  23. package/plugin-src/host/channels/weixin/production.mjs +12 -0
  24. package/plugin-src/host/channels/whatsapp/production.mjs +12 -0
  25. package/plugin-src/host/delivery-adapter.mjs +1 -0
  26. package/plugin-src/host/delivery-service.mjs +15 -0
  27. package/plugin-src/host/harness-session-coordinator.mjs +26 -5
  28. package/plugin-src/host/inbound-ttl-rpc.mjs +83 -0
  29. package/plugin-src/host/inbound-ttl-runtime.mjs +85 -0
  30. package/plugin-src/host/inbound-ttl-service.mjs +179 -0
  31. package/plugin-src/host/index.mjs +8 -0
  32. package/plugin-src/host/modern-harness-api.mjs +75 -10
  33. package/src/channels/dingtalk/connection-error.mjs +299 -0
  34. package/src/channels/dingtalk/dingtalk-controller.mjs +79 -34
  35. package/src/channels/dingtalk/dingtalk-runtime.mjs +9 -2
  36. package/src/channels/feishu/bridge.mjs +164 -4
  37. package/src/channels/feishu/feishu-channel.mjs +45 -10
  38. package/src/channels/feishu/feishu-runtime.mjs +9 -0
  39. package/src/channels/feishu/message-utils.mjs +13 -0
  40. package/src/channels/feishu/multi-bot-controller.mjs +16 -0
  41. package/src/channels/feishu/plugin-config-store.mjs +1 -0
  42. package/src/channels/feishu/state-store.mjs +25 -0
  43. package/src/channels/shared/bot-workspace-store.mjs +6 -0
  44. package/src/channels/shared/i18n-en/dingtalk.mjs +23 -0
  45. package/src/channels/shared/inbound-file.mjs +117 -2
  46. package/src/channels/shared/inbound-ttl-store.mjs +110 -0
  47. 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.0",
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",
@@ -22,10 +22,7 @@
22
22
  "whatsapp",
23
23
  "ai-office"
24
24
  ],
25
- "author": {
26
- "name": "xmanrui",
27
- "url": "https://github.com/xmanrui"
28
- },
25
+ "author": "xmanrui (https://github.com/xmanrui)",
29
26
  "contributors": [
30
27
  {
31
28
  "name": "C3H3-AI",
@@ -47,7 +44,9 @@
47
44
  "url": "git+https://github.com/xmanrui/dsh-im.git"
48
45
  },
49
46
  "homepage": "https://github.com/xmanrui/dsh-im#readme",
50
- "bugs": "https://github.com/xmanrui/dsh-im/issues",
47
+ "bugs": {
48
+ "url": "https://github.com/xmanrui/dsh-im/issues"
49
+ },
51
50
  "publishConfig": {
52
51
  "access": "public"
53
52
  },
@@ -88,6 +87,17 @@
88
87
  "@deepseek-ai/dsh-client-locale"
89
88
  ],
90
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
+ ]
91
101
  }
92
102
  },
93
103
  "scripts": {
@@ -116,5 +126,10 @@
116
126
  "react-dom": "18.3.1",
117
127
  "react-test-renderer": "18.3.1",
118
128
  "semver": "7.8.5"
129
+ },
130
+ "directories": {
131
+ "doc": "docs",
132
+ "lib": "lib",
133
+ "test": "test"
119
134
  }
120
135
  }
@@ -85,11 +85,20 @@ function sanitizeMessage(value, fallback) {
85
85
  return message.replace(/([=:]\s*)[^\s,;,。]+/g, '$1••••••').slice(0, 240);
86
86
  }
87
87
 
88
+ function safeReferenceId(value) {
89
+ const referenceId = optionalString(value, 40);
90
+ return referenceId && /^DT-CONN-[A-F0-9]{8}$/.test(referenceId) ? referenceId : undefined;
91
+ }
92
+
88
93
  function normalizeError(value, fallbackCode, fallbackMessage) {
89
94
  if (!isRecord(value)) return undefined;
95
+ const hint = sanitizeMessage(value.hint, '');
96
+ const referenceId = safeReferenceId(value.referenceId);
90
97
  return {
91
98
  code: safeErrorCode(value.code, fallbackCode),
92
99
  message: sanitizeMessage(value.message, fallbackMessage),
100
+ ...(hint ? { hint } : {}),
101
+ ...(referenceId ? { referenceId } : {}),
93
102
  };
94
103
  }
95
104
 
@@ -108,8 +117,15 @@ export function unwrapRpcResult(result) {
108
117
  throw new Error('钉钉服务返回了无法识别的响应');
109
118
  }
110
119
  if (!result.ok) {
111
- const error = new Error(sanitizeMessage(result.error?.message, '钉钉操作失败'));
112
- error.code = safeErrorCode(result.error?.code, 'DINGTALK_RPC_ERROR');
120
+ const visible = normalizeError(
121
+ result.error,
122
+ 'DINGTALK_RPC_ERROR',
123
+ '钉钉操作失败',
124
+ ) ?? { code: 'DINGTALK_RPC_ERROR', message: '钉钉操作失败' };
125
+ const error = new Error(visible.message);
126
+ error.code = visible.code;
127
+ if (visible.hint) error.hint = visible.hint;
128
+ if (visible.referenceId) error.referenceId = visible.referenceId;
113
129
  throw error;
114
130
  }
115
131
  return result.value;
@@ -227,10 +243,11 @@ export function connectionTestFeedback(result) {
227
243
  }
228
244
 
229
245
  export function presentError(error) {
230
- return {
231
- code: safeErrorCode(error?.code, 'DINGTALK_ERROR'),
232
- message: sanitizeMessage(error?.message, '钉钉操作失败,请稍后重试'),
233
- };
246
+ return normalizeError(
247
+ error,
248
+ 'DINGTALK_ERROR',
249
+ '钉钉操作失败,请稍后重试',
250
+ ) ?? { code: 'DINGTALK_ERROR', message: '钉钉操作失败,请稍后重试' };
234
251
  }
235
252
 
236
253
  export function formatRemaining(milliseconds) {
@@ -169,19 +169,36 @@ function ProgressPanel({ status, busy, onCancel }) {
169
169
  h(Button, { onClick: onCancel, disabled: busy }, '取消接入')));
170
170
  }
171
171
 
172
+ function ConnectionErrorDiagnostic({ error }) {
173
+ if (!error) return null;
174
+ return h('div', { className: 'ddt-errorDiagnostic' },
175
+ error.hint ? h('p', { className: 'ddt-errorHint' }, error.hint) : null,
176
+ h('span', { className: 'ddt-errorCode' },
177
+ h('span', null, '错误码'), `: ${error.code}`,
178
+ error.referenceId
179
+ ? h(React.Fragment, null, ' · ', h('span', null, '参考号'), `: ${error.referenceId}`)
180
+ : null));
181
+ }
182
+
172
183
  function ProvisionError({ provision, busy, onRetry, onClose }) {
173
184
  const error = provision.error ?? {
174
185
  code: 'DINGTALK_PROVISION_FAILED',
175
186
  message: '钉钉机器人没有接入完成',
176
187
  };
188
+ const connectionFailed = Boolean(error.referenceId);
177
189
  return h('div', { className: 'ddt-card dim-surfaceCard' },
178
190
  h('div', { className: 'ddt-inlineError dim-inlineError', role: 'alert' },
179
- h('h3', null, provision.status === 'expired' ? '二维码已过期' : '钉钉机器人没有接入完成'),
191
+ h('h3', null, provision.status === 'expired'
192
+ ? '二维码已过期'
193
+ : connectionFailed ? '机器人已保存,但连接未就绪' : '钉钉机器人没有接入完成'),
180
194
  h('p', null, error.message),
181
- h('span', { className: 'ddt-errorCode' }, error.code),
195
+ h(ConnectionErrorDiagnostic, { error }),
182
196
  h('div', { className: 'ddt-actions dim-viewActions' },
183
- h(Button, { kind: 'primary', onClick: onRetry, disabled: busy }, '重新生成二维码'),
184
- h(Button, { onClick: onClose, disabled: busy }, '关闭'))));
197
+ connectionFailed
198
+ ? h(Button, { kind: 'primary', onClick: onClose, disabled: busy }, '查看已保存的机器人')
199
+ : h(React.Fragment, null,
200
+ h(Button, { kind: 'primary', onClick: onRetry, disabled: busy }, '重新生成二维码'),
201
+ h(Button, { onClick: onClose, disabled: busy }, '关闭')))));
185
202
  }
186
203
 
187
204
  function checkedTime(value) {
@@ -278,6 +295,7 @@ export function AccountCard({
278
295
  h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) },
279
296
  '移除接入')),
280
297
  summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
298
+ account.error ? h(ConnectionErrorDiagnostic, { error: account.error }) : null,
281
299
  account.lastMessageError ? h(LastMessageErrorSummary, {
282
300
  className: 'ddt-summary',
283
301
  error: account.lastMessageError,
@@ -614,6 +632,12 @@ export function DingtalkSettingsTab({ rpcCall }) {
614
632
  ? snapshot?.bots.find((bot) => bot.botId === result.botId)
615
633
  : snapshot?.bots.find((bot) => bot.connected);
616
634
  if (!account?.connected) {
635
+ if (account?.error) {
636
+ setProvision((current) => current?.attemptId === attemptId
637
+ ? { ...current, ...result, status: 'failed', error: account.error }
638
+ : current);
639
+ return;
640
+ }
617
641
  setProvision((current) => current?.attemptId === attemptId
618
642
  ? { ...current, ...result, status: 'connecting' }
619
643
  : current);
@@ -698,9 +722,12 @@ export function DingtalkSettingsTab({ rpcCall }) {
698
722
  return snapshot;
699
723
  } catch (error) {
700
724
  if (!mountedRef.current) return undefined;
725
+ const visibleError = presentError(error);
701
726
  const failureMessage = operation === 'reconnect'
702
- ? '连接检查失败,请稍后重试。'
703
- : `操作失败:${presentError(error).message}`;
727
+ ? visibleError.referenceId
728
+ ? `连接检查失败:${visibleError.message}(参考号:${visibleError.referenceId})`
729
+ : '连接检查失败,请稍后重试。'
730
+ : `操作失败:${visibleError.message}`;
704
731
  if (operation === 'reconnect') {
705
732
  setFeedbackByBot((current) => ({
706
733
  ...current,
@@ -77,6 +77,8 @@ const CSS = String.raw`
77
77
  .ddt-inlineError h3 { font-size: 17px; }
78
78
  .ddt-inlineError p { line-height: 1.55; }
79
79
  .ddt-errorCode { font: 11px ui-monospace, SFMono-Regular, monospace; opacity: .8; }
80
+ .ddt-errorDiagnostic { display: grid; gap: 5px; color: var(--ddt-error); }
81
+ .ddt-errorHint { margin: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: 1.55; }
80
82
  .ddt-listHeading { display: flex; align-items: center; justify-content: space-between; margin: 2px 0 9px; }
81
83
  .ddt-listHeading h3 { margin: 0; font-size: 14px; }
82
84
  .ddt-list { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
@@ -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
+ }