@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/README.en.md +7 -4
- package/README.md +7 -4
- package/lib/client.js +578 -141
- package/lib/index.js +298 -298
- package/package.json +6 -2
- package/plugin-src/client/channels/feishu/api.js +5 -2
- package/plugin-src/client/channels/feishu/group-settings.js +14 -14
- package/plugin-src/client/channels/feishu/index.js +2 -1
- package/plugin-src/client/channels/feishu/slash-panel-settings.js +322 -0
- package/plugin-src/client/context-enhancement.js +7 -0
- package/plugin-src/client/delivery-settings.js +16 -0
- package/plugin-src/client/i18n.js +39 -6
- package/plugin-src/client/styles.js +9 -0
- package/plugin-src/host/channels/feishu/production.mjs +2 -1
- package/plugin-src/host/channels/feishu/rpc.mjs +28 -8
- package/scripts/verify-package.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +183 -44
- package/src/channels/feishu/feishu-cards.mjs +7 -1
- package/src/channels/feishu/feishu-channel.mjs +9 -4
- package/src/channels/feishu/feishu-runtime.mjs +86 -15
- package/src/channels/feishu/message-utils.mjs +48 -12
- package/src/channels/feishu/multi-bot-controller.mjs +29 -7
- package/src/channels/feishu/plugin-config-store.mjs +3 -1
- package/src/channels/feishu/slash-command-panel.mjs +155 -0
- package/src/channels/feishu/slash-command-registry.mjs +257 -41
- package/src/channels/shared/context-enhancement.mjs +148 -7
- package/src/channels/shared/i18n-en/feishu.mjs +5 -0
- package/src/channels/weixin/weixin-bridge.mjs +28 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmanrui/dsh-im",
|
|
3
|
-
"version": "4.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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-
|
|
185
|
+
'aria-labelledby': 'dim-feishu-mention-topic-title',
|
|
186
186
|
},
|
|
187
187
|
h('div', { className: 'dim-feishuGroupControlHeader' },
|
|
188
|
-
h('h3', { id: 'dim-feishu-
|
|
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
|
-
'
|
|
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.
|
|
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(
|
|
562
|
-
value: settings.
|
|
561
|
+
h(MentionTopicReplyEditor, {
|
|
562
|
+
value: settings.mentionTopicReply,
|
|
563
563
|
disabled,
|
|
564
|
-
onSave: (
|
|
565
|
-
FEISHU_ENDPOINTS.
|
|
566
|
-
{
|
|
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
|
-
|
|
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
|
-
'
|
|
267
|
-
'
|
|
268
|
-
'
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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; }
|