@xmanrui/dsh-im 0.3.0 → 0.5.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 (68) hide show
  1. package/README.md +64 -0
  2. package/assets/logo-icon.png +0 -0
  3. package/assets/logo.png +0 -0
  4. package/lib/client.js +765 -295
  5. package/lib/index.js +112 -110
  6. package/package.json +2 -1
  7. package/plugin-src/client/build.mjs +6 -1
  8. package/plugin-src/client/channels/dingtalk/api.js +2 -0
  9. package/plugin-src/client/channels/dingtalk/index.js +67 -14
  10. package/plugin-src/client/channels/feishu/api.js +2 -0
  11. package/plugin-src/client/channels/feishu/index.js +70 -22
  12. package/plugin-src/client/channels/qq/api.js +2 -0
  13. package/plugin-src/client/channels/qq/index.js +45 -8
  14. package/plugin-src/client/channels/shared/token-api.js +2 -0
  15. package/plugin-src/client/channels/shared/token-channel.js +34 -8
  16. package/plugin-src/client/channels/wecom/api.js +2 -0
  17. package/plugin-src/client/channels/wecom/index.js +45 -8
  18. package/plugin-src/client/channels/weixin/api.js +2 -0
  19. package/plugin-src/client/channels/weixin/index.js +68 -8
  20. package/plugin-src/client/channels/whatsapp/api.js +2 -0
  21. package/plugin-src/client/channels/whatsapp/index.js +27 -5
  22. package/plugin-src/client/i18n.js +15 -1
  23. package/plugin-src/client/index.js +28 -1
  24. package/plugin-src/client/styles.js +32 -9
  25. package/plugin-src/client/workspace-editor.js +96 -0
  26. package/plugin-src/client/workspace-snapshot-fence.js +28 -0
  27. package/plugin-src/host/channels/dingtalk/production.mjs +34 -11
  28. package/plugin-src/host/channels/dingtalk/rpc.mjs +17 -2
  29. package/plugin-src/host/channels/feishu/production.mjs +42 -5
  30. package/plugin-src/host/channels/feishu/rpc.mjs +17 -2
  31. package/plugin-src/host/channels/qq/production.mjs +48 -22
  32. package/plugin-src/host/channels/qq/rpc.mjs +16 -2
  33. package/plugin-src/host/channels/shared/production.mjs +48 -22
  34. package/plugin-src/host/channels/shared/rpc.mjs +15 -0
  35. package/plugin-src/host/channels/shared/workspace-rpc.mjs +25 -0
  36. package/plugin-src/host/channels/slack/production.mjs +48 -23
  37. package/plugin-src/host/channels/slack/rpc.mjs +16 -0
  38. package/plugin-src/host/channels/wecom/production.mjs +49 -23
  39. package/plugin-src/host/channels/wecom/rpc.mjs +16 -2
  40. package/plugin-src/host/channels/weixin/production.mjs +31 -11
  41. package/plugin-src/host/channels/weixin/rpc.mjs +21 -2
  42. package/plugin-src/host/channels/whatsapp/production.mjs +49 -23
  43. package/plugin-src/host/channels/whatsapp/rpc.mjs +16 -2
  44. package/src/channels/dingtalk/dingtalk-bridge.mjs +22 -11
  45. package/src/channels/dingtalk/dingtalk-controller.mjs +6 -1
  46. package/src/channels/dingtalk/harness-client.mjs +4 -3
  47. package/src/channels/dingtalk/state-store.mjs +5 -0
  48. package/src/channels/discord/discord-api.mjs +1 -1
  49. package/src/channels/feishu/bridge.mjs +36 -16
  50. package/src/channels/feishu/harness-client.mjs +6 -5
  51. package/src/channels/feishu/state-store.mjs +5 -0
  52. package/src/channels/qq/qq-bridge.mjs +25 -15
  53. package/src/channels/qq/qq-controller.mjs +8 -1
  54. package/src/channels/qq/state-store.mjs +5 -0
  55. package/src/channels/shared/bot-workspace-store.mjs +584 -0
  56. package/src/channels/shared/conversation-state-store.mjs +5 -0
  57. package/src/channels/shared/text-harness-bridge.mjs +23 -13
  58. package/src/channels/shared/workspace-command.mjs +26 -0
  59. package/src/channels/shared/workspace-session.mjs +44 -0
  60. package/src/channels/wecom/state-store.mjs +5 -0
  61. package/src/channels/wecom/wecom-bridge.mjs +22 -13
  62. package/src/channels/wecom/wecom-controller.mjs +8 -1
  63. package/src/channels/weixin/harness-client.mjs +6 -5
  64. package/src/channels/weixin/state-store.mjs +5 -0
  65. package/src/channels/weixin/weixin-api.mjs +1 -1
  66. package/src/channels/weixin/weixin-bridge.mjs +16 -6
  67. package/src/channels/weixin/weixin-controller.mjs +6 -1
  68. package/src/channels/whatsapp/whatsapp-controller.mjs +8 -1
@@ -3,6 +3,8 @@ import * as React from 'react';
3
3
  import { CredentialActionIcon, CredentialBindingPanel } from '../../credential-binding.js';
4
4
  import { h } from '../../i18n.js';
5
5
  import { installDingtalkStyles } from '../dingtalk/styles.js';
6
+ import { WorkspaceEditor } from '../../workspace-editor.js';
7
+ import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
6
8
 
7
9
  const Button = React.forwardRef(function Button(
8
10
  { children, kind = 'secondary', className = '', ...props },
@@ -51,7 +53,7 @@ export function createTokenChannelSettings(definition) {
51
53
  emptyActionLabel = '填写 Bot Token',
52
54
  } = definition;
53
55
 
54
- function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
56
+ function AccountCard({ account, busy, removing, onReconnect, onWorkspaceSave, onRequestRemove, onConfirmRemove, onCancelRemove }) {
55
57
  const state = busy === 'reconnect' ? 'connecting' : account.state;
56
58
  const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning';
57
59
  const stateLabel = account.connected ? '运行正常' : state === 'connecting' ? '正在连接' : '连接未就绪';
@@ -73,6 +75,11 @@ export function createTokenChannelSettings(definition) {
73
75
  h('dt', null, '消息通道'), h('dd', null, account.connected ? connectionLabel : '离线')),
74
76
  h('div', { className: 'ddt-metric dim-botMetric' },
75
77
  h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))),
78
+ h(WorkspaceEditor, {
79
+ workspace: account.workspace,
80
+ disabled: Boolean(busy),
81
+ onSave: onWorkspaceSave,
82
+ }),
76
83
  h('div', { className: 'ddt-accountFooter dim-cardFooter' },
77
84
  summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
78
85
  h('div', { className: 'ddt-actions dim-cardActions' },
@@ -106,6 +113,7 @@ export function createTokenChannelSettings(definition) {
106
113
  const [busyByBot, setBusyByBot] = React.useState({});
107
114
  const [removeTarget, setRemoveTarget] = React.useState(null);
108
115
  const mounted = React.useRef(true);
116
+ const workspaceFence = useWorkspaceSnapshotFence();
109
117
 
110
118
  React.useEffect(() => {
111
119
  const disposeDingtalk = installDingtalkStyles();
@@ -124,13 +132,17 @@ export function createTokenChannelSettings(definition) {
124
132
  }, [rpcCall]);
125
133
 
126
134
  const loadStatus = React.useCallback(async ({ signal, silent = false } = {}) => {
135
+ const workspaceVersion = workspaceFence.beginStatus();
136
+ if (workspaceVersion === null) return;
127
137
  if (!silent && mounted.current) setModel((current) => ({ ...current, phase: 'loading', error: null }));
128
138
  try {
129
139
  const snapshot = api.normalizeSnapshot(await invoke(endpoints.status, {}, signal));
130
- if (!mounted.current || signal?.aborted) return;
140
+ if (!mounted.current || signal?.aborted
141
+ || !workspaceFence.canCommitStatus(workspaceVersion)) return;
131
142
  setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
132
143
  } catch (error) {
133
- if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted) {
144
+ if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted
145
+ && workspaceFence.canCommitStatus(workspaceVersion)) {
134
146
  setModel((current) => ({
135
147
  ...current,
136
148
  phase: silent ? current.phase : 'error',
@@ -138,7 +150,7 @@ export function createTokenChannelSettings(definition) {
138
150
  }));
139
151
  }
140
152
  }
141
- }, [invoke]);
153
+ }, [invoke, workspaceFence]);
142
154
 
143
155
  React.useEffect(() => {
144
156
  const controller = new AbortController();
@@ -160,6 +172,7 @@ export function createTokenChannelSettings(definition) {
160
172
  }, [loadStatus, model.phase]);
161
173
 
162
174
  const bindCredentials = React.useCallback(async (values) => {
175
+ const snapshotVersion = workspaceFence.beginMutation();
163
176
  setBusy(true);
164
177
  setCredentialError(null);
165
178
  try {
@@ -168,30 +181,37 @@ export function createTokenChannelSettings(definition) {
168
181
  credentialPayload(values),
169
182
  ));
170
183
  if (!mounted.current) return;
171
- setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
184
+ if (workspaceFence.canCommitMutation(snapshotVersion)) {
185
+ setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
186
+ }
172
187
  setCredentialOpen(false);
173
188
  } catch (error) {
174
189
  if (mounted.current) setCredentialError(api.presentError(error));
175
190
  } finally {
191
+ const shouldRefresh = workspaceFence.endMutation();
192
+ if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
176
193
  if (mounted.current) setBusy(false);
177
194
  }
178
- }, [invoke]);
195
+ }, [invoke, loadStatus, workspaceFence]);
179
196
 
180
197
  const botAction = React.useCallback(async (account, operation, endpoint, payload) => {
198
+ const snapshotVersion = workspaceFence.beginMutation();
181
199
  setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
182
200
  try {
183
201
  const snapshot = api.normalizeSnapshot(await invoke(endpoint, payload));
184
- if (mounted.current) {
202
+ if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
185
203
  setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
186
204
  }
187
205
  } finally {
206
+ const shouldRefresh = workspaceFence.endMutation();
207
+ if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
188
208
  if (mounted.current) setBusyByBot((current) => {
189
209
  const next = { ...current };
190
210
  delete next[account.botId];
191
211
  return next;
192
212
  });
193
213
  }
194
- }, [invoke]);
214
+ }, [invoke, loadStatus, workspaceFence]);
195
215
 
196
216
  const botList = model.bots.length > 0
197
217
  ? h('section', { className: 'dim-listSection' },
@@ -208,6 +228,12 @@ export function createTokenChannelSettings(definition) {
208
228
  endpoints.reconnectBot,
209
229
  { botId: account.botId },
210
230
  ),
231
+ onWorkspaceSave: (workspace) => botAction(
232
+ account,
233
+ 'workspace',
234
+ endpoints.setWorkspace,
235
+ { botId: account.botId, workspace },
236
+ ),
211
237
  onRequestRemove: () => setRemoveTarget(account.botId),
212
238
  onCancelRemove: () => setRemoveTarget(null),
213
239
  onConfirmRemove: async () => {
@@ -8,6 +8,7 @@ export const WECOM_ENDPOINTS = Object.freeze({
8
8
  bindCredentials: 'bot.bind-credentials',
9
9
  reconnectBot: 'bot.reconnect',
10
10
  deleteBot: 'bot.delete',
11
+ setWorkspace: 'bot.workspace.set',
11
12
  });
12
13
 
13
14
  const PROVISION_STATES = new Set(['starting', 'pending', 'refreshing', 'connecting', 'connected', 'failed', 'cancelled']);
@@ -79,6 +80,7 @@ function normalizeBot(value) {
79
80
  botId: id(value.botId),
80
81
  connected,
81
82
  state: connected ? 'connected' : state,
83
+ workspace: text(value.workspace, '', 4_096),
82
84
  bot: {
83
85
  name: text(value.bot?.name, '企业微信机器人', 100),
84
86
  appIdMasked: text(value.bot?.appIdMasked, '应用标识已安全保存', 140),
@@ -3,6 +3,8 @@ import * as React from 'react';
3
3
  import { WecomLogoGlyph } from '../../channel-logos.js';
4
4
  import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
5
5
  import { h } from '../../i18n.js';
6
+ import { WorkspaceEditor } from '../../workspace-editor.js';
7
+ import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
6
8
  import { installDingtalkStyles } from '../dingtalk/styles.js';
7
9
  import {
8
10
  WECOM_ENDPOINTS,
@@ -146,7 +148,16 @@ function RemoveConfirmation({ account, busy, onConfirm, onCancel }) {
146
148
  h(Button, { kind: 'danger', onClick: onConfirm, disabled: busy }, busy ? '正在移除…' : '确认移除接入')));
147
149
  }
148
150
 
149
- export function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
151
+ export function AccountCard({
152
+ account,
153
+ busy,
154
+ removing,
155
+ onReconnect,
156
+ onWorkspaceSave,
157
+ onRequestRemove,
158
+ onConfirmRemove,
159
+ onCancelRemove,
160
+ }) {
150
161
  const tone = account.connected ? 'success' : account.state === 'error' ? 'error' : 'warning';
151
162
  const stateLabel = account.connected ? '运行正常' : account.state === 'connecting' ? '正在连接' : '连接未就绪';
152
163
  const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
@@ -162,6 +173,11 @@ export function AccountCard({ account, busy, removing, onReconnect, onRequestRem
162
173
  h('dl', { className: 'ddt-metrics dim-botMetrics' },
163
174
  h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '消息通道'), h('dd', null, account.connected ? 'WebSocket 长连接' : '离线')),
164
175
  h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))),
176
+ h(WorkspaceEditor, {
177
+ workspace: account.workspace,
178
+ disabled: Boolean(busy),
179
+ onSave: onWorkspaceSave,
180
+ }),
165
181
  h('div', { className: 'ddt-accountFooter dim-cardFooter' },
166
182
  summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
167
183
  h('div', { className: 'ddt-actions dim-cardActions' },
@@ -182,6 +198,7 @@ export function WecomSettingsTab({ rpcCall }) {
182
198
  const [credentialError, setCredentialError] = React.useState(null);
183
199
  const [now, setNow] = React.useState(Date.now());
184
200
  const mounted = React.useRef(true);
201
+ const workspaceFence = useWorkspaceSnapshotFence();
185
202
  const addButtonRef = React.useRef(null);
186
203
 
187
204
  React.useEffect(() => {
@@ -201,10 +218,13 @@ export function WecomSettingsTab({ rpcCall }) {
201
218
  }, [rpcCall]);
202
219
 
203
220
  const loadStatus = React.useCallback(async ({ signal, silent = false, restore = false } = {}) => {
221
+ const workspaceVersion = workspaceFence.beginStatus();
222
+ if (workspaceVersion === null) return undefined;
204
223
  if (!silent && mounted.current) setModel((current) => ({ ...current, phase: 'loading', error: null }));
205
224
  try {
206
225
  const snapshot = normalizeSnapshot(await invoke(WECOM_ENDPOINTS.status, {}, signal));
207
- if (!mounted.current || signal?.aborted) return undefined;
226
+ if (!mounted.current || signal?.aborted
227
+ || !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
208
228
  setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
209
229
  if (restore && snapshot.provisioning) setProvision({
210
230
  ...snapshot.provisioning,
@@ -212,12 +232,13 @@ export function WecomSettingsTab({ rpcCall }) {
212
232
  });
213
233
  return snapshot;
214
234
  } catch (error) {
215
- if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted) {
235
+ if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted
236
+ && workspaceFence.canCommitStatus(workspaceVersion)) {
216
237
  setModel((current) => ({ ...current, phase: silent ? current.phase : 'error', error: presentError(error) }));
217
238
  }
218
239
  return undefined;
219
240
  }
220
- }, [invoke]);
241
+ }, [invoke, workspaceFence]);
221
242
 
222
243
  React.useEffect(() => {
223
244
  const controller = new AbortController();
@@ -258,6 +279,7 @@ export function WecomSettingsTab({ rpcCall }) {
258
279
  }, [invoke, provision?.attemptId]);
259
280
 
260
281
  const bindCredentials = React.useCallback(async ({ identity, secret }) => {
282
+ const snapshotVersion = workspaceFence.beginMutation();
261
283
  setBusy(true);
262
284
  setCredentialError(null);
263
285
  try {
@@ -266,14 +288,18 @@ export function WecomSettingsTab({ rpcCall }) {
266
288
  { botId: identity, secret },
267
289
  ));
268
290
  if (!mounted.current) return;
269
- setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
291
+ if (workspaceFence.canCommitMutation(snapshotVersion)) {
292
+ setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
293
+ }
270
294
  setCredentialOpen(false);
271
295
  } catch (error) {
272
296
  if (mounted.current) setCredentialError(presentError(error));
273
297
  } finally {
298
+ const shouldRefresh = workspaceFence.endMutation();
299
+ if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
274
300
  if (mounted.current) setBusy(false);
275
301
  }
276
- }, [invoke]);
302
+ }, [invoke, loadStatus, workspaceFence]);
277
303
 
278
304
  const closeProvision = React.useCallback(async () => {
279
305
  setBusy(true);
@@ -318,17 +344,22 @@ export function WecomSettingsTab({ rpcCall }) {
318
344
  }, [invoke, loadStatus, provision?.attemptId, provision?.pollIntervalMs, provision?.status]);
319
345
 
320
346
  const botAction = React.useCallback(async (account, operation, endpoint, payload) => {
347
+ const snapshotVersion = workspaceFence.beginMutation();
321
348
  setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
322
349
  try {
323
350
  const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
324
- if (mounted.current) setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
351
+ if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
352
+ setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
353
+ }
325
354
  return snapshot;
326
355
  } finally {
356
+ const shouldRefresh = workspaceFence.endMutation();
357
+ if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
327
358
  if (mounted.current) setBusyByBot((current) => {
328
359
  const next = { ...current }; delete next[account.botId]; return next;
329
360
  });
330
361
  }
331
- }, [invoke]);
362
+ }, [invoke, loadStatus, workspaceFence]);
332
363
 
333
364
  let provisionView = null;
334
365
  if (provision?.status === 'starting') provisionView = h('div', { className: 'ddt-card ddt-loading dim-surfaceCard' }, h('div', { className: 'ddt-spinner' }), '正在申请企业微信二维码…');
@@ -349,6 +380,12 @@ export function WecomSettingsTab({ rpcCall }) {
349
380
  busy: busyByBot[account.botId],
350
381
  removing: removeTarget === account.botId,
351
382
  onReconnect: () => void botAction(account, 'reconnect', WECOM_ENDPOINTS.reconnectBot, { botId: account.botId }),
383
+ onWorkspaceSave: (workspace) => botAction(
384
+ account,
385
+ 'workspace',
386
+ WECOM_ENDPOINTS.setWorkspace,
387
+ { botId: account.botId, workspace },
388
+ ),
352
389
  onRequestRemove: () => setRemoveTarget(account.botId),
353
390
  onCancelRemove: () => setRemoveTarget(null),
354
391
  onConfirmRemove: async () => {
@@ -7,6 +7,7 @@ export const WEIXIN_ENDPOINTS = Object.freeze({
7
7
  cancelProvisioning: 'provision.cancel',
8
8
  reconnectBot: 'bot.reconnect',
9
9
  deleteBot: 'bot.delete',
10
+ setWorkspace: 'bot.workspace.set',
10
11
  });
11
12
 
12
13
  const ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
@@ -103,6 +104,7 @@ function normalizeBot(value) {
103
104
  state: connected ? 'connected' : state,
104
105
  connected,
105
106
  configured: value.configured === true,
107
+ workspace: string(value.workspace).slice(0, 4_096),
106
108
  bot: {
107
109
  name: string(value.bot.name, '微信机器人'),
108
110
  accountIdMasked: string(value.bot.accountIdMasked, '已安全保存'),
@@ -15,6 +15,8 @@ import {
15
15
  unwrapRpcResult,
16
16
  } from './api.js';
17
17
  import { createPollScheduler, useAnimationFrameScheduler } from '../../lifecycle.js';
18
+ import { WorkspaceEditor } from '../../workspace-editor.js';
19
+ import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
18
20
  import { installWeixinStyles } from './styles.js';
19
21
 
20
22
  export const name = 'weixin-settings';
@@ -193,7 +195,16 @@ function checkedTime(timestamp) {
193
195
  }
194
196
  }
195
197
 
196
- export function AccountCard({ account, busy, removing, onReconnect, onRequestRemove, onConfirmRemove, onCancelRemove }) {
198
+ export function AccountCard({
199
+ account,
200
+ busy,
201
+ removing,
202
+ onReconnect,
203
+ onWorkspaceSave,
204
+ onRequestRemove,
205
+ onConfirmRemove,
206
+ onCancelRemove,
207
+ }) {
197
208
  const state = busy === 'reconnect' ? 'connecting' : account.state;
198
209
  const tone = account.connected ? 'success' : state === 'error' ? 'error' : 'warning';
199
210
  const summary = account.error?.message ?? (account.connected ? null : account.health.summary);
@@ -211,6 +222,11 @@ export function AccountCard({ account, busy, removing, onReconnect, onRequestRem
211
222
  h('dd', null, account.connected ? 'iLink 长轮询' : '离线')),
212
223
  h('div', { className: 'dxw-metric dim-botMetric' }, h('dt', null, '最近检查'),
213
224
  h('dd', null, checkedTime(account.health.lastCheckedAt)))),
225
+ h(WorkspaceEditor, {
226
+ workspace: account.workspace,
227
+ disabled: Boolean(busy),
228
+ onSave: onWorkspaceSave,
229
+ }),
214
230
  h('div', { className: 'dxw-accountFooter dim-cardFooter' },
215
231
  summary ? h('div', { className: 'dxw-summary dim-cardSummary' }, summary) : null,
216
232
  h('div', { className: 'dxw-actions dim-cardActions' },
@@ -236,6 +252,7 @@ function AccountList(props) {
236
252
  busy: props.busyByBot[account.botId],
237
253
  removing: props.removeTarget === account.botId,
238
254
  onReconnect: () => props.onReconnect(account),
255
+ onWorkspaceSave: (workspace) => props.onWorkspaceSave(account, workspace),
239
256
  onRequestRemove: () => props.onRequestRemove(account),
240
257
  onConfirmRemove: () => props.onConfirmRemove(account),
241
258
  onCancelRemove: props.onCancelRemove,
@@ -269,8 +286,15 @@ export function WeixinSettingsTab({ rpcCall }) {
269
286
  const [notice, setNotice] = React.useState('');
270
287
  const [now, setNow] = React.useState(() => Date.now());
271
288
  const addButtonRef = React.useRef(null);
289
+ const mountedRef = React.useRef(true);
290
+ const workspaceFence = useWorkspaceSnapshotFence();
272
291
  const scheduleAnimationFrame = useAnimationFrameScheduler();
273
292
 
293
+ React.useEffect(() => {
294
+ mountedRef.current = true;
295
+ return () => { mountedRef.current = false; };
296
+ }, []);
297
+
274
298
  const announce = React.useCallback((value) => {
275
299
  setNotice('');
276
300
  scheduleAnimationFrame(() => {
@@ -285,10 +309,13 @@ export function WeixinSettingsTab({ rpcCall }) {
285
309
  silent = false,
286
310
  restoreProvisioning = false,
287
311
  } = {}) => {
312
+ const workspaceVersion = workspaceFence.beginStatus();
313
+ if (workspaceVersion === null || !mountedRef.current) return undefined;
288
314
  if (!silent) setModel((current) => ({ ...current, phase: 'loading', error: null }));
289
315
  try {
290
316
  const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.status, {}, signal));
291
- if (signal?.aborted) return undefined;
317
+ if (signal?.aborted || !mountedRef.current
318
+ || !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
292
319
  setModel({
293
320
  phase: 'ready', bots: snapshot.bots, totals: snapshot.totals,
294
321
  revision: snapshot.revision, error: null,
@@ -302,7 +329,8 @@ export function WeixinSettingsTab({ rpcCall }) {
302
329
  }
303
330
  return snapshot;
304
331
  } catch (error) {
305
- if (signal?.aborted || error?.name === 'AbortError') return undefined;
332
+ if (signal?.aborted || error?.name === 'AbortError' || !mountedRef.current
333
+ || !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
306
334
  setModel((current) => ({
307
335
  ...current,
308
336
  phase: silent && current.phase === 'ready' ? 'ready' : 'error',
@@ -310,7 +338,7 @@ export function WeixinSettingsTab({ rpcCall }) {
310
338
  }));
311
339
  return undefined;
312
340
  }
313
- }, [invoke]);
341
+ }, [invoke, workspaceFence]);
314
342
 
315
343
  React.useEffect(() => {
316
344
  const controller = new AbortController();
@@ -466,35 +494,66 @@ export function WeixinSettingsTab({ rpcCall }) {
466
494
  }, []);
467
495
 
468
496
  const reconnect = React.useCallback(async (account) => {
497
+ const snapshotVersion = workspaceFence.beginMutation();
469
498
  setBotBusy(account.botId, 'reconnect');
470
499
  try {
471
500
  const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.reconnectBot, { botId: account.botId }));
472
- setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision }));
501
+ if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
502
+ setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision }));
503
+ }
473
504
  const refreshed = snapshot.bots.find((bot) => bot.botId === account.botId);
474
505
  announce(refreshed?.connected ? '微信连接检查完成。' : '微信仍未连接,插件会继续自动重试。');
475
506
  } catch (error) {
476
507
  announce(`连接检查失败:${presentError(error).message}`);
477
508
  } finally {
509
+ const shouldRefresh = workspaceFence.endMutation();
510
+ if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
478
511
  setBotBusy(account.botId, null);
479
512
  }
480
- }, [announce, invoke, setBotBusy]);
513
+ }, [announce, invoke, loadStatus, setBotBusy, workspaceFence]);
514
+
515
+ const saveWorkspace = React.useCallback(async (account, workspace) => {
516
+ const workspaceVersion = workspaceFence.beginMutation();
517
+ setBotBusy(account.botId, 'workspace');
518
+ try {
519
+ const snapshot = normalizeSnapshot(await invoke(
520
+ WEIXIN_ENDPOINTS.setWorkspace,
521
+ { botId: account.botId, workspace },
522
+ ));
523
+ if (mountedRef.current && workspaceFence.canCommitMutation(workspaceVersion)) {
524
+ setModel({
525
+ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals,
526
+ revision: snapshot.revision, error: null,
527
+ });
528
+ }
529
+ } finally {
530
+ const shouldRefresh = workspaceFence.endMutation();
531
+ if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
532
+ if (mountedRef.current) setBotBusy(account.botId, null);
533
+ }
534
+ }, [invoke, loadStatus, setBotBusy, workspaceFence]);
481
535
 
482
536
  const remove = React.useCallback(async (account) => {
537
+ const snapshotVersion = workspaceFence.beginMutation();
483
538
  setBotBusy(account.botId, 'delete');
484
539
  try {
485
540
  const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.deleteBot, {
486
541
  botId: account.botId,
487
542
  confirm: true,
488
543
  }));
489
- setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision }));
544
+ if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
545
+ setModel((current) => ({ ...current, bots: snapshot.bots, totals: snapshot.totals, revision: snapshot.revision }));
546
+ }
490
547
  setRemoveTarget(null);
491
548
  announce('微信账号及本机凭据已移除。');
492
549
  } catch (error) {
493
550
  announce(`移除失败:${presentError(error).message}`);
494
551
  } finally {
552
+ const shouldRefresh = workspaceFence.endMutation();
553
+ if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
495
554
  setBotBusy(account.botId, null);
496
555
  }
497
- }, [announce, invoke, setBotBusy]);
556
+ }, [announce, invoke, loadStatus, setBotBusy, workspaceFence]);
498
557
 
499
558
  let provisionView = null;
500
559
  if (provision?.status === 'starting') {
@@ -554,6 +613,7 @@ export function WeixinSettingsTab({ rpcCall }) {
554
613
  busyByBot,
555
614
  removeTarget,
556
615
  onReconnect: (account) => void reconnect(account),
616
+ onWorkspaceSave: saveWorkspace,
557
617
  onRequestRemove: (account) => setRemoveTarget(account.botId),
558
618
  onConfirmRemove: (account) => void remove(account),
559
619
  onCancelRemove: () => setRemoveTarget(null),
@@ -7,6 +7,7 @@ export const WHATSAPP_ENDPOINTS = Object.freeze({
7
7
  cancelProvisioning: 'provision.cancel',
8
8
  reconnectBot: 'bot.reconnect',
9
9
  deleteBot: 'bot.delete',
10
+ setWorkspace: 'bot.workspace.set',
10
11
  });
11
12
 
12
13
  const PROVISION_STATES = new Set(['starting', 'pending', 'connecting', 'connected', 'failed', 'cancelled']);
@@ -80,6 +81,7 @@ function normalizeBot(value) {
80
81
  botId: id(value.botId),
81
82
  connected,
82
83
  state: connected ? 'connected' : state,
84
+ workspace: text(value.workspace, '', 4_096),
83
85
  bot: {
84
86
  name: text(value.bot?.name, 'WhatsApp机器人', 100),
85
87
  idMasked: text(value.bot?.idMasked, 'WhatsApp账号', 140),
@@ -3,6 +3,8 @@ import * as React from 'react';
3
3
  import { WhatsappLogoGlyph } from '../../channel-logos.js';
4
4
  import { QrActionIcon } from '../../credential-binding.js';
5
5
  import { h } from '../../i18n.js';
6
+ import { WorkspaceEditor } from '../../workspace-editor.js';
7
+ import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
6
8
  import { installDingtalkStyles } from '../dingtalk/styles.js';
7
9
  import {
8
10
  WHATSAPP_ENDPOINTS,
@@ -159,6 +161,7 @@ export function WhatsappAccountCard({
159
161
  busy,
160
162
  removing,
161
163
  onReconnect,
164
+ onWorkspaceSave,
162
165
  onRequestRemove,
163
166
  onConfirmRemove,
164
167
  onCancelRemove,
@@ -186,6 +189,11 @@ export function WhatsappAccountCard({
186
189
  h('dd', null, account.connected ? 'WhatsApp Web' : '离线')),
187
190
  h('div', { className: 'ddt-metric dim-botMetric' },
188
191
  h('dt', null, '最近检查'), h('dd', null, checkedTime(account.health.lastCheckedAt)))),
192
+ h(WorkspaceEditor, {
193
+ workspace: account.workspace,
194
+ disabled: Boolean(busy),
195
+ onSave: onWorkspaceSave,
196
+ }),
189
197
  h('div', { className: 'ddt-accountFooter dim-cardFooter' },
190
198
  summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
191
199
  h('div', { className: 'ddt-actions dim-cardActions' },
@@ -213,6 +221,7 @@ export function WhatsappSettingsTab({ rpcCall }) {
213
221
  const [removeTarget, setRemoveTarget] = React.useState(null);
214
222
  const [now, setNow] = React.useState(Date.now());
215
223
  const mounted = React.useRef(true);
224
+ const workspaceFence = useWorkspaceSnapshotFence();
216
225
  const addButtonRef = React.useRef(null);
217
226
 
218
227
  React.useEffect(() => {
@@ -232,10 +241,13 @@ export function WhatsappSettingsTab({ rpcCall }) {
232
241
  }, [rpcCall]);
233
242
 
234
243
  const loadStatus = React.useCallback(async ({ signal, silent = false, restore = false } = {}) => {
244
+ const workspaceVersion = workspaceFence.beginStatus();
245
+ if (workspaceVersion === null) return undefined;
235
246
  if (!silent && mounted.current) setModel((current) => ({ ...current, phase: 'loading', error: null }));
236
247
  try {
237
248
  const snapshot = normalizeSnapshot(await invoke(WHATSAPP_ENDPOINTS.status, {}, signal));
238
- if (!mounted.current || signal?.aborted) return undefined;
249
+ if (!mounted.current || signal?.aborted
250
+ || !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
239
251
  setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
240
252
  if (restore && snapshot.provisioning) setProvision({
241
253
  ...snapshot.provisioning,
@@ -243,7 +255,8 @@ export function WhatsappSettingsTab({ rpcCall }) {
243
255
  });
244
256
  return snapshot;
245
257
  } catch (error) {
246
- if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted) {
258
+ if (error?.name !== 'AbortError' && mounted.current && !signal?.aborted
259
+ && workspaceFence.canCommitStatus(workspaceVersion)) {
247
260
  setModel((current) => ({
248
261
  ...current,
249
262
  phase: silent ? current.phase : 'error',
@@ -252,7 +265,7 @@ export function WhatsappSettingsTab({ rpcCall }) {
252
265
  }
253
266
  return undefined;
254
267
  }
255
- }, [invoke]);
268
+ }, [invoke, workspaceFence]);
256
269
 
257
270
  React.useEffect(() => {
258
271
  const controller = new AbortController();
@@ -350,20 +363,23 @@ export function WhatsappSettingsTab({ rpcCall }) {
350
363
  }, [invoke, loadStatus, provision?.attemptId, provision?.status]);
351
364
 
352
365
  const botAction = React.useCallback(async (account, operation, endpoint, payload) => {
366
+ const snapshotVersion = workspaceFence.beginMutation();
353
367
  setBusyByBot((current) => ({ ...current, [account.botId]: operation }));
354
368
  try {
355
369
  const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
356
- if (mounted.current) {
370
+ if (mounted.current && workspaceFence.canCommitMutation(snapshotVersion)) {
357
371
  setModel({ phase: 'ready', bots: snapshot.bots, totals: snapshot.totals, error: null });
358
372
  }
359
373
  } finally {
374
+ const shouldRefresh = workspaceFence.endMutation();
375
+ if (shouldRefresh && mounted.current) void loadStatus({ silent: true });
360
376
  if (mounted.current) setBusyByBot((current) => {
361
377
  const next = { ...current };
362
378
  delete next[account.botId];
363
379
  return next;
364
380
  });
365
381
  }
366
- }, [invoke]);
382
+ }, [invoke, loadStatus, workspaceFence]);
367
383
 
368
384
  const botList = model.bots.length > 0
369
385
  ? h('section', { className: 'dim-listSection' },
@@ -380,6 +396,12 @@ export function WhatsappSettingsTab({ rpcCall }) {
380
396
  WHATSAPP_ENDPOINTS.reconnectBot,
381
397
  { botId: account.botId },
382
398
  ),
399
+ onWorkspaceSave: (workspace) => botAction(
400
+ account,
401
+ 'workspace',
402
+ WHATSAPP_ENDPOINTS.setWorkspace,
403
+ { botId: account.botId, workspace },
404
+ ),
383
405
  onRequestRemove: () => setRemoveTarget(account.botId),
384
406
  onCancelRemove: () => setRemoveTarget(null),
385
407
  onConfirmRemove: async () => {
@@ -7,7 +7,8 @@ const EN = Object.freeze({
7
7
  'IM机器人': 'IM bots',
8
8
  'IM机器人设置': 'IM bot settings',
9
9
  'IM 渠道': 'IM channels',
10
- '把机器人接入 DeepSeek Harness': 'Connect bots to DeepSeek Harness',
10
+ '让聊天机器人轻松接入 DeepSeek Harness': 'Connect IM bots to DeepSeek Harness with ease',
11
+ '帮助与反馈 · 前往 GitHub': 'Help & feedback · Open GitHub',
11
12
  '微信': 'WeChat',
12
13
  '飞书': 'Feishu',
13
14
  '钉钉': 'DingTalk',
@@ -43,6 +44,19 @@ const EN = Object.freeze({
43
44
  '已断开': 'Disconnected',
44
45
  '消息通道': 'Message channel',
45
46
  '最近检查': 'Last checked',
47
+ '当前工作区': 'Current workspace',
48
+ '工作区绝对路径': 'Absolute workspace path',
49
+ '/绝对路径/到/工作区': '/absolute/path/to/workspace',
50
+ '修改': 'Change',
51
+ '保存': 'Save',
52
+ '保存中…': 'Saving…',
53
+ '未设置': 'Not set',
54
+ '工作区修改失败,请重试。': 'Could not update the workspace. Try again.',
55
+ '请输入工作区绝对路径。': 'Enter an absolute workspace path.',
56
+ '工作区必须是绝对路径。': 'The workspace must be an absolute path.',
57
+ '工作区路径不存在。': 'The workspace path does not exist.',
58
+ '工作区路径必须指向一个目录。': 'The workspace path must point to a directory.',
59
+ '找不到要修改的机器人。': 'The bot could not be found.',
46
60
  '尚未检查': 'Not checked yet',
47
61
  '刚刚': 'Just now',
48
62
  '检查连接': 'Check connection',