@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.
- package/PROACTIVE_DELIVERY.en.md +8 -0
- package/PROACTIVE_DELIVERY.md +8 -0
- package/lib/client.js +679 -73
- package/lib/index.js +276 -275
- package/package.json +21 -6
- package/plugin-src/client/channels/dingtalk/api.js +23 -6
- package/plugin-src/client/channels/dingtalk/index.js +33 -6
- package/plugin-src/client/channels/dingtalk/styles.js +2 -0
- package/plugin-src/client/channels/feishu/api.js +2 -0
- package/plugin-src/client/channels/feishu/index.js +54 -0
- package/plugin-src/client/global-settings.js +341 -0
- package/plugin-src/client/i18n.js +33 -0
- package/plugin-src/client/index.js +47 -6
- package/plugin-src/client/styles.js +49 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +12 -0
- package/plugin-src/host/channels/dingtalk/rpc.mjs +24 -0
- package/plugin-src/host/channels/feishu/production.mjs +14 -0
- package/plugin-src/host/channels/feishu/rpc.mjs +16 -0
- package/plugin-src/host/channels/qq/production.mjs +12 -0
- package/plugin-src/host/channels/shared/production.mjs +12 -0
- package/plugin-src/host/channels/slack/production.mjs +12 -0
- package/plugin-src/host/channels/wecom/production.mjs +12 -0
- package/plugin-src/host/channels/weixin/production.mjs +12 -0
- package/plugin-src/host/channels/whatsapp/production.mjs +12 -0
- package/plugin-src/host/delivery-adapter.mjs +1 -0
- package/plugin-src/host/delivery-service.mjs +15 -0
- package/plugin-src/host/harness-session-coordinator.mjs +26 -5
- package/plugin-src/host/inbound-ttl-rpc.mjs +83 -0
- package/plugin-src/host/inbound-ttl-runtime.mjs +85 -0
- package/plugin-src/host/inbound-ttl-service.mjs +179 -0
- package/plugin-src/host/index.mjs +8 -0
- package/plugin-src/host/modern-harness-api.mjs +75 -10
- package/src/channels/dingtalk/connection-error.mjs +299 -0
- package/src/channels/dingtalk/dingtalk-controller.mjs +79 -34
- package/src/channels/dingtalk/dingtalk-runtime.mjs +9 -2
- package/src/channels/feishu/bridge.mjs +164 -4
- package/src/channels/feishu/feishu-channel.mjs +45 -10
- package/src/channels/feishu/feishu-runtime.mjs +9 -0
- package/src/channels/feishu/message-utils.mjs +13 -0
- package/src/channels/feishu/multi-bot-controller.mjs +16 -0
- package/src/channels/feishu/plugin-config-store.mjs +1 -0
- package/src/channels/feishu/state-store.mjs +25 -0
- package/src/channels/shared/bot-workspace-store.mjs +6 -0
- package/src/channels/shared/i18n-en/dingtalk.mjs +23 -0
- package/src/channels/shared/inbound-file.mjs +117 -2
- package/src/channels/shared/inbound-ttl-store.mjs +110 -0
- 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.
|
|
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":
|
|
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
|
|
112
|
-
|
|
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
|
-
|
|
232
|
-
|
|
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(
|
|
195
|
+
h(ConnectionErrorDiagnostic, { error }),
|
|
182
196
|
h('div', { className: 'ddt-actions dim-viewActions' },
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
+
}
|