@xmanrui/dsh-im 4.14.0 → 4.16.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 (69) hide show
  1. package/README.en.md +10 -2
  2. package/README.md +17 -6
  3. package/lib/client.js +1980 -616
  4. package/lib/index.js +303 -293
  5. package/package.json +6 -2
  6. package/plugin-src/client/access-policy-settings.js +6 -0
  7. package/plugin-src/client/channels/feishu/index.js +16 -4
  8. package/plugin-src/client/channels/wecom-app/api.js +124 -0
  9. package/plugin-src/client/channels/wecom-app/index.js +612 -0
  10. package/plugin-src/client/channels/wecom-app/styles.js +25 -0
  11. package/plugin-src/client/delivery-settings.js +7 -0
  12. package/plugin-src/client/i18n.js +64 -1
  13. package/plugin-src/client/index.js +21 -0
  14. package/plugin-src/client/model-setting.js +135 -69
  15. package/plugin-src/client/session-channel-logos.js +241 -0
  16. package/plugin-src/client/styles.js +22 -7
  17. package/plugin-src/host/channels/dingtalk/index.mjs +8 -15
  18. package/plugin-src/host/channels/discord/index.mjs +8 -10
  19. package/plugin-src/host/channels/feishu/index.mjs +8 -15
  20. package/plugin-src/host/channels/office/index.mjs +9 -5
  21. package/plugin-src/host/channels/qq/index.mjs +8 -15
  22. package/plugin-src/host/channels/shared/access-policy-production.mjs +1 -1
  23. package/plugin-src/host/channels/shared/model-setting-rpc.mjs +1 -2
  24. package/plugin-src/host/channels/shared/startup-error.mjs +53 -0
  25. package/plugin-src/host/channels/shared/startup.mjs +47 -0
  26. package/plugin-src/host/channels/shared/workspace-rpc.mjs +1 -0
  27. package/plugin-src/host/channels/slack/index.mjs +8 -10
  28. package/plugin-src/host/channels/telegram/index.mjs +8 -10
  29. package/plugin-src/host/channels/wecom/index.mjs +8 -15
  30. package/plugin-src/host/channels/wecom/rpc.mjs +6 -1
  31. package/plugin-src/host/channels/wecom-app/index.mjs +33 -0
  32. package/plugin-src/host/channels/wecom-app/production.mjs +217 -0
  33. package/plugin-src/host/channels/wecom-app/rpc.mjs +225 -0
  34. package/plugin-src/host/channels/weixin/index.mjs +8 -15
  35. package/plugin-src/host/channels/whatsapp/index.mjs +8 -15
  36. package/plugin-src/host/delivery-adapter.mjs +4 -0
  37. package/plugin-src/host/delivery-suggestions.mjs +4 -0
  38. package/plugin-src/host/index.mjs +21 -2
  39. package/plugin-src/host/session-title-prefix.mjs +122 -0
  40. package/scripts/verify-model-setting.mjs +54 -0
  41. package/scripts/verify-session-channel-logos.mjs +59 -0
  42. package/scripts/verify-session-title-prefix.mjs +149 -0
  43. package/src/channels/feishu/bridge.mjs +305 -12
  44. package/src/channels/feishu/feishu-cards.mjs +38 -0
  45. package/src/channels/feishu/feishu-channel.mjs +88 -20
  46. package/src/channels/feishu/plugin-controller.mjs +1 -0
  47. package/src/channels/feishu/repair-manager.mjs +3 -2
  48. package/src/channels/shared/bot-workspace-store.mjs +11 -3
  49. package/src/channels/shared/command-catalog.mjs +102 -0
  50. package/src/channels/shared/i18n-en/feishu.mjs +14 -2
  51. package/src/channels/shared/i18n-en/shared-a.mjs +3 -0
  52. package/src/channels/shared/i18n-en/shared-c.mjs +9 -0
  53. package/src/channels/shared/i18n-en/wecom-app.mjs +33 -0
  54. package/src/channels/shared/i18n-en.mjs +2 -0
  55. package/src/channels/shared/model-setting.mjs +43 -8
  56. package/src/channels/shared/session-channel-labels.mjs +26 -0
  57. package/src/channels/shared/text-harness-bridge.mjs +2 -26
  58. package/src/channels/telegram/telegram-api.mjs +18 -6
  59. package/src/channels/telegram/telegram-runtime.mjs +34 -32
  60. package/src/channels/wecom/send-error.mjs +31 -0
  61. package/src/channels/wecom/wecom-bridge.mjs +59 -17
  62. package/src/channels/wecom-app/callback-server.mjs +471 -0
  63. package/src/channels/wecom-app/config-store.mjs +240 -0
  64. package/src/channels/wecom-app/harness-client.mjs +11 -0
  65. package/src/channels/wecom-app/state-store.mjs +107 -0
  66. package/src/channels/wecom-app/wecom-app-api.mjs +432 -0
  67. package/src/channels/wecom-app/wecom-app-bridge.mjs +1059 -0
  68. package/src/channels/wecom-app/wecom-app-controller.mjs +440 -0
  69. package/src/channels/wecom-app/wecom-app-runtime.mjs +221 -0
@@ -0,0 +1,612 @@
1
+ import * as React from 'react';
2
+
3
+ import { WecomLogoGlyph } from '../../channel-logos.js';
4
+ import { CollapsibleAccountSection } from '../shared/collapsible-account.js';
5
+ import { h } from '../../i18n.js';
6
+ import { WorkspaceEditor } from '../../workspace-editor.js';
7
+ import { ContextEnhancementEditor } from '../../context-enhancement.js';
8
+ import {
9
+ AgentPresetCatalogContext,
10
+ AgentPresetEditor,
11
+ EMPTY_AGENT_PRESET_CATALOG,
12
+ } from '../../agent-preset.js';
13
+ import {
14
+ EMPTY_MODEL_CATALOG,
15
+ ModelCatalogContext,
16
+ ModelEditor,
17
+ } from '../../model-setting.js';
18
+ import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
19
+ import {
20
+ BotSettingsButton,
21
+ BotStatusMeta,
22
+ ChannelListHeading,
23
+ LastMessageErrorSummary,
24
+ } from '../../channel-card-meta.js';
25
+ import { installDingtalkStyles } from '../dingtalk/styles.js';
26
+ import {
27
+ WECOM_APP_ENDPOINTS,
28
+ normalizeSnapshot,
29
+ presentError,
30
+ unwrapRpcResult,
31
+ } from './api.js';
32
+ import { installWecomAppStyles } from './styles.js';
33
+
34
+ const Button = React.forwardRef(function Button({ children, kind = 'secondary', className = '', ...props }, ref) {
35
+ return h('button', {
36
+ ...props,
37
+ ref,
38
+ type: 'button',
39
+ className: `ddt-button ${className}`.trim(),
40
+ 'data-kind': kind,
41
+ }, children);
42
+ });
43
+
44
+ function checkedTime(value) {
45
+ if (!value) return '尚未检查';
46
+ try {
47
+ return new Intl.DateTimeFormat('zh-CN', {
48
+ hour: '2-digit', minute: '2-digit', second: '2-digit',
49
+ }).format(new Date(value));
50
+ } catch {
51
+ return '刚刚';
52
+ }
53
+ }
54
+
55
+ function Heading({ totals, adding, busy, onAdd, addButtonRef }) {
56
+ return h('div', { className: 'ddt-heading' },
57
+ h('div', { className: 'ddt-tools' },
58
+ h('div', { className: 'dim-bindActions' },
59
+ h(Button, {
60
+ kind: 'primary',
61
+ className: 'dim-scanButton',
62
+ onClick: onAdd,
63
+ disabled: adding || busy,
64
+ ref: addButtonRef,
65
+ 'aria-label': '添加企业微信自建应用',
66
+ }, adding ? '收起表单' : '添加企业微信应用')),
67
+ totals.configured > 0
68
+ ? h('div', { className: 'ddt-badge dim-onlineBadge' },
69
+ h('span', null, `${totals.connected} / ${totals.configured} 在线`))
70
+ : null));
71
+ }
72
+
73
+ function LoadingView() {
74
+ return h('div', { className: 'ddt-card ddt-loading dim-surfaceCard dim-loadingView', 'aria-busy': 'true' },
75
+ h('div', { className: 'ddt-spinner dim-spinner' }),
76
+ h('span', null, '正在读取企业微信应用状态…'));
77
+ }
78
+
79
+ function EmptyView({ busy, onStart }) {
80
+ return h('div', { className: 'ddt-card dim-surfaceCard' },
81
+ h('div', { className: 'ddt-cardBody ddt-empty dim-surfaceBody dim-emptyView' },
82
+ h('div', { className: 'dim-emptyCopy' },
83
+ h('div', { className: 'ddt-stateLabel dim-stateLabel' },
84
+ h('span', { className: 'ddt-dot dim-stateDot' }), h('span', null, '尚未绑定企业微信应用')),
85
+ h('h3', null, '在企业微信中创建自建应用,即可在微信里使用'),
86
+ h('p', null, '在应用管理后台创建自建应用,开启「接收消息」并填入下面的参数。成员的微信关注该企业的微信插件后,就能在微信中与本应用双向对话。'),
87
+ h('div', { className: 'ddt-actions dim-viewActions' },
88
+ h(Button, { kind: 'primary', onClick: onStart, disabled: busy }, '填写应用参数'))),
89
+ h('div', { className: 'ddt-brandMark dim-emptyBrand dwecomapp-brand', 'aria-hidden': 'true' },
90
+ h(WecomLogoGlyph, { size: 64 }))));
91
+ }
92
+
93
+ function Field({ label, value, onChange, placeholder, busy, required = false, type = 'text' }) {
94
+ return h('label', { className: 'dim-credentialField' },
95
+ h('span', null, label),
96
+ h('input', {
97
+ value,
98
+ onChange: (event) => onChange(event.target.value),
99
+ placeholder,
100
+ type,
101
+ autoCapitalize: 'none',
102
+ autoCorrect: 'off',
103
+ spellCheck: false,
104
+ autoComplete: 'off',
105
+ disabled: busy,
106
+ required,
107
+ }));
108
+ }
109
+
110
+ function BindForm({ busy, error, onSubmit, onCancel }) {
111
+ const [corpId, setCorpId] = React.useState('');
112
+ const [agentId, setAgentId] = React.useState('');
113
+ const [secret, setSecret] = React.useState('');
114
+ const [token, setToken] = React.useState('');
115
+ const [aesKey, setAesKey] = React.useState('');
116
+ const [apiBaseUrl, setApiBaseUrl] = React.useState('');
117
+ const [callbackBaseUrl, setCallbackBaseUrl] = React.useState('');
118
+ const [streamEnabled, setStreamEnabled] = React.useState(true);
119
+ const headingId = React.useId();
120
+ const formRef = React.useRef(null);
121
+
122
+ const submit = (event) => {
123
+ event.preventDefault();
124
+ if (busy) return;
125
+ void onSubmit?.({
126
+ corpId: corpId.trim(),
127
+ agentId: agentId.trim(),
128
+ secret: secret.trim(),
129
+ token: token.trim(),
130
+ encodingAESKey: aesKey.trim(),
131
+ apiBaseUrl: apiBaseUrl.trim() || undefined,
132
+ callbackBaseUrl: callbackBaseUrl.trim() || undefined,
133
+ streamEnabled,
134
+ });
135
+ };
136
+
137
+ return h('section', {
138
+ className: 'ddt-card dim-surfaceCard dim-credentialPanel',
139
+ 'aria-labelledby': headingId,
140
+ },
141
+ h('h3', { id: headingId, className: 'dim-credentialTitle' }, '绑定企业微信自建应用'),
142
+ h('form', { ref: formRef, className: 'dim-credentialForm', onSubmit: submit },
143
+ h('div', { className: 'dim-appFieldGrid' },
144
+ h(Field, { label: '企业 ID(CorpID)', value: corpId, onChange: setCorpId, placeholder: '例如 ww1234567890abcdef', busy, required: true }),
145
+ h(Field, { label: '应用 AgentId', value: agentId, onChange: setAgentId, placeholder: '例如 1000002', busy, required: true }),
146
+ h(Field, { label: '应用 Secret', value: secret, onChange: setSecret, placeholder: '应用详情页的 Secret', busy, required: true, type: 'password' }),
147
+ h(Field, { label: '回调 Token', value: token, onChange: setToken, placeholder: '接收消息 → API 接收中的 Token', busy, required: true, type: 'password' }),
148
+ h(Field, { label: 'EncodingAESKey', value: aesKey, onChange: setAesKey, placeholder: '43 位字母或数字', busy, required: true, type: 'password' }),
149
+ h(Field, { label: '代理地址(可选)', value: apiBaseUrl, onChange: setApiBaseUrl, placeholder: '留空直连 qyapi.weixin.qq.com', busy })),
150
+ h('div', { className: 'dim-appFieldGrid' },
151
+ h(Field, { label: '公网回调基址(可选)', value: callbackBaseUrl, onChange: setCallbackBaseUrl, placeholder: '例如 https://im.example.com', busy }),
152
+ h('div', { className: 'dim-appSwitchRow' },
153
+ h('button', {
154
+ type: 'button',
155
+ className: 'ddt-button dim-streamToggle',
156
+ 'aria-pressed': streamEnabled ? 'true' : 'false',
157
+ onClick: () => setStreamEnabled((value) => !value),
158
+ disabled: busy,
159
+ }, streamEnabled ? '流式回复:开' : '流式回复:关'),
160
+ h('span', { className: 'dim-switchHint' }, '企业微信客户端实时出字;微信端不支持时自动改为整段发送'))),
161
+ error ? h('div', { className: 'ddt-inlineError dim-inlineError', role: 'alert' }, h('p', null, error.message)) : null,
162
+ h('div', { className: 'ddt-actions dim-viewActions' },
163
+ h(Button, { kind: 'primary', onClick: () => formRef.current?.requestSubmit(), disabled: busy }, busy ? '正在绑定…' : '保存并连接'),
164
+ h(Button, { kind: 'quiet', onClick: onCancel, disabled: busy }, '取消'))));
165
+ }
166
+
167
+ function CallbackUrlBox({ url, busy, resetBusy, onCopy, onReset }) {
168
+ const [copied, setCopied] = React.useState(false);
169
+ const copy = async () => {
170
+ try {
171
+ await navigator.clipboard.writeText(url);
172
+ setCopied(true);
173
+ window.setTimeout(() => setCopied(false), 2_000);
174
+ } catch {
175
+ setCopied(false);
176
+ }
177
+ onCopy?.();
178
+ };
179
+ return h('div', { className: 'dim-callbackBox' },
180
+ h('strong', null, '回调 URL(填入企业微信后台「接收消息 → 设置API接收」)'),
181
+ h('div', { className: 'dim-callbackRow' },
182
+ h('input', {
183
+ value: url || '未配置公网回调基址:请在本页「设置」中填写回调基址,或手动拼接 回调基址 + 路径',
184
+ readOnly: true,
185
+ onFocus: (event) => event.target.select?.(),
186
+ }),
187
+ url ? h(Button, { onClick: copy, disabled: busy, small: true }, copied ? '已复制' : '复制') : null,
188
+ h(Button, { kind: 'danger', onClick: onReset, disabled: busy || resetBusy }, resetBusy ? '重置中…' : '重置密钥')),
189
+ h('span', { className: 'dim-callbackHint' },
190
+ '重置密钥后回调 URL 会变化,需要同步更新企业微信后台。若公网反代未就绪,可临时使用 http://服务器IP:端口 形式的回调地址。'));
191
+ }
192
+
193
+ function AppSettingsEditor({ bot, busy, onSave }) {
194
+ const [apiBaseUrl, setApiBaseUrl] = React.useState(bot.apiBaseUrl ?? '');
195
+ const [callbackBaseUrl, setCallbackBaseUrl] = React.useState(bot.callbackBaseUrl ?? '');
196
+ const [streamEnabled, setStreamEnabled] = React.useState(bot.streamEnabled !== false);
197
+ const [dirty, setDirty] = React.useState(false);
198
+ const formRef = React.useRef(null);
199
+ React.useEffect(() => {
200
+ setApiBaseUrl(bot.apiBaseUrl ?? '');
201
+ setCallbackBaseUrl(bot.callbackBaseUrl ?? '');
202
+ setStreamEnabled(bot.streamEnabled !== false);
203
+ setDirty(false);
204
+ }, [bot.apiBaseUrl, bot.callbackBaseUrl, bot.streamEnabled]);
205
+ const touch = () => setDirty(true);
206
+ const save = (event) => {
207
+ event.preventDefault();
208
+ if (!dirty || busy) return;
209
+ void onSave({
210
+ apiBaseUrl: apiBaseUrl.trim() || null,
211
+ callbackBaseUrl: callbackBaseUrl.trim() || null,
212
+ streamEnabled,
213
+ });
214
+ setDirty(false);
215
+ };
216
+ return h('form', { ref: formRef, className: 'dim-appFieldGrid', onSubmit: save },
217
+ h(Field, { label: '代理地址(可选)', value: apiBaseUrl, onChange: (value) => { setApiBaseUrl(value); touch(); }, placeholder: '留空直连 qyapi.weixin.qq.com', busy }),
218
+ h(Field, { label: '公网回调基址(可选)', value: callbackBaseUrl, onChange: (value) => { setCallbackBaseUrl(value); touch(); }, placeholder: '例如 https://im.example.com', busy }),
219
+ h('div', { className: 'dim-appSwitchRow' },
220
+ h('button', {
221
+ type: 'button',
222
+ className: 'ddt-button dim-streamToggle',
223
+ 'aria-pressed': streamEnabled ? 'true' : 'false',
224
+ onClick: () => { setStreamEnabled((value) => !value); touch(); },
225
+ disabled: busy,
226
+ }, streamEnabled ? '流式回复:开' : '流式回复:关')),
227
+ h('div', { className: 'ddt-actions dim-viewActions' },
228
+ h(Button, { onClick: () => formRef.current?.requestSubmit(), disabled: busy || !dirty }, busy ? '保存中…' : '保存设置')));
229
+ }
230
+
231
+ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
232
+ return h('div', { className: 'ddt-confirm dim-confirm', role: 'alertdialog' },
233
+ h('strong', null, `从 DeepSeek Harness 移除“${account.bot.name}”?`),
234
+ h('p', null, '这会删除本机保存的应用凭据、回调配置及会话映射。企业微信后台的应用不会被删除。'),
235
+ h('div', { className: 'ddt-actions dim-viewActions' },
236
+ h(Button, { onClick: onCancel, disabled: busy }, '保留应用'),
237
+ h(Button, { kind: 'danger', onClick: onConfirm, disabled: busy }, busy ? '正在移除…' : '确认移除接入')));
238
+ }
239
+
240
+ export function AccountCard({
241
+ account,
242
+ busy,
243
+ feedback,
244
+ removing,
245
+ onReconnect,
246
+ onSettingsSave,
247
+ onSecretReset,
248
+ onWorkspaceSave,
249
+ onModelSave,
250
+ onAgentPresetSave,
251
+ onContextEnhancementSave,
252
+ onRequestRemove,
253
+ onConfirmRemove,
254
+ onCancelRemove,
255
+ }) {
256
+ const tone = account.connected ? 'success' : account.state === 'error' ? 'error' : 'warning';
257
+ const stateLabel = account.connected ? '运行正常' : account.state === 'connecting' ? '正在连接' : '连接未就绪';
258
+ const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
259
+ const elementId = `wecomapp-settings-${account.botId.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
260
+ return h('article', { className: 'ddt-card dim-botCard', 'data-bot-id': account.botId },
261
+ h('div', { className: 'ddt-cardBody dim-botCardBody' },
262
+ h(CollapsibleAccountSection, {
263
+ id: elementId,
264
+ header: h('div', { className: 'ddt-accountTop dim-botCardTop' },
265
+ h('div', { className: 'ddt-accountIdentity dim-botIdentity' },
266
+ h('div', { className: 'ddt-avatar dim-botAvatar dwecomapp-avatar', 'aria-hidden': 'true' }, h(WecomLogoGlyph, { size: 29 })),
267
+ h('div', { className: 'dim-botName' },
268
+ h('h3', null, account.bot.name),
269
+ h('p', null, `${account.bot.corpIdMasked} · AgentId ${account.bot.agentId}`))),
270
+ h('div', {
271
+ className: 'dim-botCardTools',
272
+ onClick: (event) => { event.stopPropagation(); },
273
+ onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') event.stopPropagation(); },
274
+ },
275
+ h(BotStatusMeta, {
276
+ className: 'ddt-health',
277
+ dotClassName: 'ddt-dot',
278
+ tone,
279
+ stateLabel,
280
+ lastCheckedAt: account.health.lastCheckedAt,
281
+ formatCheckedTime: checkedTime,
282
+ }),
283
+ h(BotSettingsButton, {
284
+ channel: 'wecomApp',
285
+ botId: account.botId,
286
+ botName: account.bot.name,
287
+ connected: account.connected,
288
+ accessPolicy: account.accessPolicy,
289
+ })))
290
+ },
291
+ h(CallbackUrlBox, {
292
+ url: account.bot.callbackUrl,
293
+ busy: Boolean(busy),
294
+ resetBusy: busy === 'reset',
295
+ onReset: onSecretReset,
296
+ }),
297
+ h(AppSettingsEditor, {
298
+ bot: account.bot,
299
+ busy: Boolean(busy),
300
+ onSave: onSettingsSave,
301
+ }),
302
+ h(WorkspaceEditor, {
303
+ workspace: account.workspace,
304
+ disabled: Boolean(busy),
305
+ onSave: onWorkspaceSave,
306
+ }),
307
+ h(ModelEditor, {
308
+ model: account.model,
309
+ disabled: Boolean(busy),
310
+ onSave: onModelSave,
311
+ }),
312
+ h(AgentPresetEditor, {
313
+ agentPreset: account.agentPreset,
314
+ disabled: Boolean(busy),
315
+ onSave: onAgentPresetSave,
316
+ }),
317
+ h(ContextEnhancementEditor, {
318
+ config: account.contextEnhancement,
319
+ disabled: Boolean(busy),
320
+ onSave: onContextEnhancementSave,
321
+ }),
322
+ h('div', { className: 'ddt-accountFooter dim-cardFooter' },
323
+ h('div', { className: 'dim-cardFooterLayout' },
324
+ h('div', { className: 'ddt-actions dim-cardActions' },
325
+ h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) }, busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
326
+ h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) }, '移除接入')),
327
+ summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
328
+ account.lastMessageError ? h(LastMessageErrorSummary, {
329
+ className: 'ddt-summary',
330
+ error: account.lastMessageError,
331
+ }) : null,
332
+ feedback ? h('div', {
333
+ className: 'ddt-summary dim-cardFeedback',
334
+ role: 'status',
335
+ 'aria-live': 'polite',
336
+ }, feedback) : null)),
337
+ ),
338
+ ),
339
+ removing ? h(RemoveConfirmation, {
340
+ account, busy: busy === 'delete', onConfirm: onConfirmRemove, onCancel: onCancelRemove,
341
+ }) : null);
342
+ }
343
+
344
+ export function WecomAppSettingsTab({ rpcCall }) {
345
+ const [model, setModel] = React.useState({
346
+ phase: 'loading', bots: [], totals: { configured: 0, connected: 0 }, error: null,
347
+ agentPresetCatalog: EMPTY_AGENT_PRESET_CATALOG,
348
+ modelCatalog: EMPTY_MODEL_CATALOG,
349
+ });
350
+ const [bindOpen, setBindOpen] = React.useState(false);
351
+ const [bindError, setBindError] = React.useState(null);
352
+ const [busy, setBusy] = React.useState(false);
353
+ const [busyByBot, setBusyByBot] = React.useState({});
354
+ const [feedbackByBot, setFeedbackByBot] = React.useState({});
355
+ const [removeTarget, setRemoveTarget] = React.useState(null);
356
+ const [notice, setNotice] = React.useState('');
357
+ const mounted = React.useRef(true);
358
+ const workspaceFence = useWorkspaceSnapshotFence();
359
+ const addButtonRef = React.useRef(null);
360
+ const noticeFrameRef = React.useRef(null);
361
+
362
+ const announce = React.useCallback((message) => {
363
+ if (!mounted.current) return;
364
+ if (noticeFrameRef.current !== null) {
365
+ window.cancelAnimationFrame(noticeFrameRef.current);
366
+ noticeFrameRef.current = null;
367
+ }
368
+ setNotice('');
369
+ if (message) {
370
+ noticeFrameRef.current = window.requestAnimationFrame(() => {
371
+ noticeFrameRef.current = null;
372
+ if (mounted.current) setNotice(message);
373
+ });
374
+ }
375
+ }, []);
376
+
377
+ React.useEffect(() => {
378
+ const disposeDingtalk = installDingtalkStyles();
379
+ const disposeWecomApp = installWecomAppStyles();
380
+ mounted.current = true;
381
+ return () => {
382
+ mounted.current = false;
383
+ if (noticeFrameRef.current !== null) {
384
+ window.cancelAnimationFrame(noticeFrameRef.current);
385
+ noticeFrameRef.current = null;
386
+ }
387
+ disposeWecomApp();
388
+ disposeDingtalk();
389
+ };
390
+ }, []);
391
+
392
+ const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
393
+ if (typeof rpcCall !== 'function') throw new TypeError('企业微信应用设置页缺少 RPC 连接');
394
+ return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
395
+ }, [rpcCall]);
396
+
397
+ const loadStatus = React.useCallback(async ({ signal, silent = false, restore = false } = {}) => {
398
+ const workspaceVersion = workspaceFence.beginStatus();
399
+ if (workspaceVersion === null) return undefined;
400
+ if (!silent && mounted.current) setModel((current) => ({ ...current, phase: 'loading', error: null }));
401
+ try {
402
+ const snapshot = normalizeSnapshot(await invoke(WECOM_APP_ENDPOINTS.status, {}, signal));
403
+ if (!mounted.current || signal?.aborted
404
+ || !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
405
+ setModel({
406
+ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
407
+ agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
408
+ modelCatalog: snapshot.modelCatalog ?? EMPTY_MODEL_CATALOG,
409
+ });
410
+ if (restore && snapshot.bots.length > 0) setBindOpen(false);
411
+ return snapshot;
412
+ } catch (error) {
413
+ if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted
414
+ && workspaceFence.canCommitStatus(workspaceVersion)) {
415
+ setModel((current) => ({ ...current, phase: silent ? current.phase : 'error', error: presentError(error) }));
416
+ }
417
+ return undefined;
418
+ }
419
+ }, [invoke, workspaceFence]);
420
+
421
+ React.useEffect(() => {
422
+ const controller = new AbortController();
423
+ void loadStatus({ signal: controller.signal, restore: true });
424
+ return () => controller.abort();
425
+ }, [loadStatus]);
426
+
427
+ React.useEffect(() => {
428
+ if (model.phase !== 'ready') return undefined;
429
+ const controller = new AbortController();
430
+ const timer = window.setInterval(() => void loadStatus({ signal: controller.signal, silent: true }), 15_000);
431
+ return () => { controller.abort(); window.clearInterval(timer); };
432
+ }, [loadStatus, model.phase]);
433
+
434
+ const bindApp = React.useCallback(async (payload) => {
435
+ const snapshotVersion = workspaceFence.beginMutation();
436
+ setBusy(true);
437
+ setBindError(null);
438
+ try {
439
+ const snapshot = normalizeSnapshot(await invoke(WECOM_APP_ENDPOINTS.bindApp, payload));
440
+ if (!mounted.current) return;
441
+ if (workspaceFence.canCommitMutation(snapshotVersion)) {
442
+ setModel({
443
+ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
444
+ agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
445
+ modelCatalog: snapshot.modelCatalog ?? EMPTY_MODEL_CATALOG,
446
+ });
447
+ }
448
+ setBindOpen(false);
449
+ announce('企业微信应用已绑定。请把回调 URL 填入企业微信后台并保存。');
450
+ } catch (error) {
451
+ if (mounted.current) setBindError(presentError(error));
452
+ } finally {
453
+ const shouldRefresh = workspaceFence.endMutation();
454
+ if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
455
+ if (mounted.current) setBusy(false);
456
+ }
457
+ }, [announce, invoke, loadStatus, workspaceFence]);
458
+
459
+ const botAction = React.useCallback(async (account, operation, endpoint, payload) => {
460
+ const snapshotVersion = workspaceFence.beginMutation();
461
+ setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
462
+ try {
463
+ const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
464
+ if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
465
+ setModel({
466
+ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null,
467
+ agentPresetCatalog: snapshot.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
468
+ modelCatalog: snapshot.modelCatalog ?? EMPTY_MODEL_CATALOG,
469
+ });
470
+ }
471
+ return snapshot;
472
+ } finally {
473
+ const shouldRefresh = workspaceFence.endMutation();
474
+ if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
475
+ if (mounted.current) setBusyByBot((current) => {
476
+ const next = { ...current }; delete next[account.botId]; return next;
477
+ });
478
+ }
479
+ }, [invoke, loadStatus, workspaceFence]);
480
+
481
+ const reconnect = React.useCallback(async (account) => {
482
+ setFeedbackByBot((current) => {
483
+ const next = { ...current };
484
+ delete next[account.botId];
485
+ return next;
486
+ });
487
+ try {
488
+ const snapshot = await botAction(
489
+ account,
490
+ 'reconnect',
491
+ WECOM_APP_ENDPOINTS.reconnectBot,
492
+ { botId: account.botId, sendTest: true },
493
+ );
494
+ if (!snapshot) return;
495
+ const refreshed = snapshot.bots.find((bot) => bot.botId === account.botId);
496
+ let feedback;
497
+ if (!refreshed?.connected) {
498
+ feedback = '企业微信应用仍未就绪,插件会继续自动重试。';
499
+ } else if (snapshot.testMessage?.sent) {
500
+ feedback = '连接检查完成,测试消息已发送。';
501
+ } else if (snapshot.testMessage?.code === 'test-target-unavailable') {
502
+ feedback = '连接检查完成。应用尚未收到可用于测试的私聊消息。';
503
+ } else if (snapshot.testMessage) {
504
+ feedback = '连接检查完成,但测试消息发送失败。';
505
+ } else {
506
+ feedback = '连接检查完成。';
507
+ }
508
+ if (mounted.current) {
509
+ setFeedbackByBot((current) => ({ ...current, [account.botId]: feedback }));
510
+ }
511
+ announce(feedback);
512
+ } catch {
513
+ const feedback = '连接检查失败,请稍后重试。';
514
+ if (mounted.current) {
515
+ setFeedbackByBot((current) => ({ ...current, [account.botId]: feedback }));
516
+ }
517
+ announce(feedback);
518
+ }
519
+ }, [announce, botAction]);
520
+
521
+ const botList = model.bots.length > 0
522
+ ? h('section', { className: 'dim-listSection' },
523
+ h(ChannelListHeading, {
524
+ className: 'ddt-listHeading',
525
+ title: '已绑定的企业微信应用',
526
+ connectionLabel: 'HTTP 回调通道',
527
+ }),
528
+ h('ul', { className: 'ddt-list dim-botList' }, model.bots.map((account) =>
529
+ h('li', { key: account.botId }, h(AccountCard, {
530
+ account,
531
+ busy: busyByBot[account.botId],
532
+ feedback: feedbackByBot[account.botId],
533
+ removing: removeTarget === account.botId,
534
+ onReconnect: () => void reconnect(account),
535
+ onSettingsSave: (settings) => botAction(
536
+ account,
537
+ 'settings',
538
+ WECOM_APP_ENDPOINTS.updateSettings,
539
+ { botId: account.botId, ...settings },
540
+ ),
541
+ onSecretReset: async () => {
542
+ try {
543
+ await botAction(account, 'reset', WECOM_APP_ENDPOINTS.resetCallbackSecret, { botId: account.botId });
544
+ announce('回调密钥已重置,请把新的回调 URL 更新到企业微信后台。');
545
+ } catch {
546
+ announce('回调密钥重置失败,请稍后重试。');
547
+ }
548
+ },
549
+ onWorkspaceSave: (workspace) => botAction(
550
+ account,
551
+ 'workspace',
552
+ WECOM_APP_ENDPOINTS.setWorkspace,
553
+ { botId: account.botId, workspace },
554
+ ),
555
+ onModelSave: (selectedModel) => botAction(
556
+ account,
557
+ 'model',
558
+ WECOM_APP_ENDPOINTS.setModel,
559
+ { botId: account.botId, model: selectedModel },
560
+ ),
561
+ onAgentPresetSave: (agentPreset) => botAction(
562
+ account,
563
+ 'preset',
564
+ WECOM_APP_ENDPOINTS.setAgentPreset,
565
+ { botId: account.botId, agentPreset },
566
+ ),
567
+ onContextEnhancementSave: (config) => botAction(
568
+ account,
569
+ 'context-enhancement',
570
+ WECOM_APP_ENDPOINTS.setContextEnhancement,
571
+ { botId: account.botId, config },
572
+ ),
573
+ onRequestRemove: () => setRemoveTarget(account.botId),
574
+ onCancelRemove: () => setRemoveTarget(null),
575
+ onConfirmRemove: async () => {
576
+ await botAction(account, 'delete', WECOM_APP_ENDPOINTS.deleteBot, { botId: account.botId, confirm: true });
577
+ if (mounted.current) setRemoveTarget(null);
578
+ },
579
+ })))))
580
+ : null;
581
+
582
+ const bindView = bindOpen
583
+ ? h(BindForm, {
584
+ busy,
585
+ error: bindError,
586
+ onSubmit: bindApp,
587
+ onCancel: () => { setBindOpen(false); setBindError(null); },
588
+ })
589
+ : null;
590
+
591
+ return h(ModelCatalogContext.Provider, {
592
+ value: model.modelCatalog ?? EMPTY_MODEL_CATALOG,
593
+ }, h(AgentPresetCatalogContext.Provider, {
594
+ value: model.agentPresetCatalog ?? EMPTY_AGENT_PRESET_CATALOG,
595
+ }, h('section', { className: 'ddt-page dwecomapp-page dim-channelPage', 'aria-label': '企业微信应用设置' },
596
+ h(Heading, {
597
+ totals: model.totals,
598
+ adding: bindOpen,
599
+ busy,
600
+ onAdd: () => { setBindOpen((value) => !value); setBindError(null); },
601
+ addButtonRef,
602
+ }),
603
+ h('div', { className: 'ddt-visuallyHidden', role: 'status', 'aria-live': 'polite' }, notice),
604
+ model.phase === 'loading' ? h(LoadingView)
605
+ : model.phase === 'error'
606
+ ? h('div', { className: 'ddt-card dim-surfaceCard' }, h('div', { className: 'ddt-inlineError dim-inlineError' }, h('h3', null, '无法读取企业微信应用状态'), h('p', null, model.error?.message), h(Button, { onClick: () => void loadStatus() }, '重新读取')))
607
+ : h(React.Fragment, null,
608
+ bindView,
609
+ model.bots.length === 0 && !bindOpen
610
+ ? h(EmptyView, { busy, onStart: () => setBindOpen(true) }) : null,
611
+ botList))));
612
+ }
@@ -0,0 +1,25 @@
1
+ export const WECOM_APP_STYLE_ID = 'xmanrui-dsh-im-wecom-app-settings';
2
+
3
+ const CSS = String.raw`
4
+ .dwecomapp-page { --ddt-accent: #07c160; --ddt-accent-deep: #059a4c; --ddt-accent-wash: #eefaf3; }
5
+ .dwecomapp-avatar, .dwecomapp-brand { color: #07c160; background: #fff; border: 1px solid var(--dsw-alias-border-l2, #e5e6eb); }
6
+ .dwecomapp-avatar svg, .dwecomapp-brand svg { display: block; }
7
+ .dim-appFieldGrid { display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); }
8
+ .dim-callbackBox { display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border: 1px dashed var(--dsw-alias-border-l2, #e5e6eb); border-radius: 8px; }
9
+ .dim-callbackBox strong { font-size: 12px; color: var(--dsw-alias-text-secondary, #646a73); }
10
+ .dim-callbackRow { display: flex; gap: 8px; align-items: center; }
11
+ .dim-callbackRow input { flex: 1 1 auto; min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
12
+ .dim-appSwitchRow { display: flex; align-items: center; gap: 8px; font-size: 13px; }
13
+ `;
14
+
15
+ export function installWecomAppStyles() {
16
+ if (typeof document === 'undefined') return () => {};
17
+ const existing = document.querySelector(`style[data-plugin-css="${WECOM_APP_STYLE_ID}"]`);
18
+ if (existing) return () => {};
19
+ const style = document.createElement('style');
20
+ style.dataset.plugin = '@xmanrui/dsh-im';
21
+ style.dataset.pluginCss = WECOM_APP_STYLE_ID;
22
+ style.textContent = CSS;
23
+ document.head.appendChild(style);
24
+ return () => style.remove();
25
+ }
@@ -65,6 +65,13 @@ const CHANNEL_DEFINITIONS = Object.freeze({
65
65
  group: [{ key: 'chatId', label: '群 Chat ID', placeholder: '填写群 chatid' }],
66
66
  },
67
67
  },
68
+ wecomApp: {
69
+ label: '企业微信应用',
70
+ kinds: [{ value: 'user', label: '私聊' }],
71
+ fields: {
72
+ user: [{ key: 'chatId', label: '用户 ID', placeholder: '填写企业微信用户 ID' }],
73
+ },
74
+ },
68
75
  qq: {
69
76
  label: 'QQ',
70
77
  kinds: [{ value: 'user', label: '单聊' }, { value: 'group', label: '群聊' }],