@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "通过扫码、App Manifest或机器人凭据把IM机器人接入DeepSeek Harness(支持飞书、微信、钉钉、企业微信、QQ、Slack、Telegram、Discord和WhatsApp)。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,6 +23,7 @@
23
23
  "./package.json": "./package.json"
24
24
  },
25
25
  "files": [
26
+ "assets",
26
27
  "bin",
27
28
  "cordis.patch.yml",
28
29
  "lib",
@@ -1,4 +1,4 @@
1
- import { mkdir, writeFile } from 'node:fs/promises';
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { dirname, resolve } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
 
@@ -8,6 +8,8 @@ const sourceDirectory = dirname(fileURLToPath(import.meta.url));
8
8
  const packageRoot = resolve(sourceDirectory, '../..');
9
9
  const outputPath = resolve(packageRoot, 'lib/client.js');
10
10
  const loaderId = process.env.DSH_IM_CLIENT_ID ?? '@xmanrui/dsh-im';
11
+ const logoPath = resolve(packageRoot, 'assets/logo-icon.png');
12
+ const logoDataUrl = `data:image/png;base64,${(await readFile(logoPath)).toString('base64')}`;
11
13
 
12
14
  const result = await build({
13
15
  entryPoints: [resolve(sourceDirectory, 'index.js')],
@@ -16,6 +18,9 @@ const result = await build({
16
18
  platform: 'browser',
17
19
  target: ['chrome100'],
18
20
  external: ['react'],
21
+ define: {
22
+ 'globalThis.__DSH_IM_LOGO_DATA_URL__': JSON.stringify(logoDataUrl),
23
+ },
19
24
  write: false,
20
25
  minify: process.env.NODE_ENV === 'production',
21
26
  legalComments: 'none',
@@ -8,6 +8,7 @@ export const DINGTALK_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 ACCOUNT_STATES = new Set(['connected', 'connecting', 'offline', 'error']);
@@ -145,6 +146,7 @@ function normalizeBot(value) {
145
146
  state,
146
147
  connected,
147
148
  configured: value.configured !== false,
149
+ workspace: optionalString(value.workspace, 4_096) ?? '',
148
150
  bot: {
149
151
  name: optionalString(bot.name, 100) ?? '钉钉机器人',
150
152
  clientIdMasked: optionalString(bot.clientIdMasked, 140) ?? '已安全保存',
@@ -2,6 +2,8 @@ import * as React from 'react';
2
2
 
3
3
  import { CredentialActionIcon, CredentialBindingPanel, QrActionIcon } from '../../credential-binding.js';
4
4
  import { h } from '../../i18n.js';
5
+ import { WorkspaceEditor } from '../../workspace-editor.js';
6
+ import { useWorkspaceSnapshotFence } from '../../workspace-snapshot-fence.js';
5
7
  import {
6
8
  DINGTALK_ENDPOINTS,
7
9
  DINGTALK_RPC_CHANNEL,
@@ -208,6 +210,7 @@ export function AccountCard({
208
210
  busy,
209
211
  removing,
210
212
  onReconnect,
213
+ onWorkspaceSave,
211
214
  onRequestRemove,
212
215
  onConfirmRemove,
213
216
  onCancelRemove,
@@ -231,6 +234,11 @@ export function AccountCard({
231
234
  h('dd', null, account.connected ? 'Stream 长连接' : '离线')),
232
235
  h('div', { className: 'ddt-metric dim-botMetric' }, h('dt', null, '最近检查'),
233
236
  h('dd', null, checkedTime(account.health.lastCheckedAt)))),
237
+ h(WorkspaceEditor, {
238
+ workspace: account.workspace,
239
+ disabled: Boolean(busy),
240
+ onSave: onWorkspaceSave,
241
+ }),
234
242
  h('div', { className: 'ddt-accountFooter dim-cardFooter' },
235
243
  summary ? h('div', { className: 'ddt-summary dim-cardSummary' }, summary) : null,
236
244
  h('div', { className: 'ddt-actions dim-cardActions' },
@@ -256,6 +264,7 @@ function AccountList(props) {
256
264
  busy: props.busyByBot[account.botId],
257
265
  removing: props.removeTarget === account.botId,
258
266
  onReconnect: () => props.onReconnect(account),
267
+ onWorkspaceSave: (workspace) => props.onWorkspaceSave(account, workspace),
259
268
  onRequestRemove: () => props.onRequestRemove(account),
260
269
  onConfirmRemove: () => props.onConfirmRemove(account),
261
270
  onCancelRemove: props.onCancelRemove,
@@ -279,6 +288,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
279
288
  const addButtonRef = React.useRef(null);
280
289
  const mountedRef = React.useRef(true);
281
290
  const statusRequestRef = React.useRef(0);
291
+ const workspaceFence = useWorkspaceSnapshotFence();
282
292
  const noticeFrameRef = React.useRef(null);
283
293
  const focusFrameRef = React.useRef(null);
284
294
 
@@ -335,11 +345,14 @@ export function DingtalkSettingsTab({ rpcCall }) {
335
345
  restoreProvisioning = false,
336
346
  } = {}) => {
337
347
  if (!mountedRef.current || signal?.aborted) return undefined;
348
+ const workspaceVersion = workspaceFence.beginStatus();
349
+ if (workspaceVersion === null) return undefined;
338
350
  const requestId = statusRequestRef.current + 1;
339
351
  statusRequestRef.current = requestId;
340
352
  const canCommit = () => mountedRef.current
341
353
  && !signal?.aborted
342
- && statusRequestRef.current === requestId;
354
+ && statusRequestRef.current === requestId
355
+ && workspaceFence.canCommitStatus(workspaceVersion);
343
356
  if (!silent && canCommit()) {
344
357
  setModel((current) => ({ ...current, phase: 'loading', error: null }));
345
358
  }
@@ -373,7 +386,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
373
386
  }));
374
387
  return undefined;
375
388
  }
376
- }, [invoke]);
389
+ }, [invoke, workspaceFence]);
377
390
 
378
391
  React.useEffect(() => {
379
392
  const controller = new AbortController();
@@ -450,6 +463,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
450
463
 
451
464
  const bindCredentials = React.useCallback(async ({ identity, secret }) => {
452
465
  if (!mountedRef.current) return;
466
+ const snapshotVersion = workspaceFence.beginMutation();
453
467
  setBusy(true);
454
468
  setCredentialError(null);
455
469
  try {
@@ -458,21 +472,25 @@ export function DingtalkSettingsTab({ rpcCall }) {
458
472
  { clientId: identity, clientSecret: secret },
459
473
  ));
460
474
  if (!mountedRef.current) return;
461
- setModel({
462
- phase: 'ready',
463
- bots: snapshot.bots,
464
- totals: snapshot.totals,
465
- revision: snapshot.revision,
466
- error: null,
467
- });
475
+ if (workspaceFence.canCommitMutation(snapshotVersion)) {
476
+ setModel({
477
+ phase: 'ready',
478
+ bots: snapshot.bots,
479
+ totals: snapshot.totals,
480
+ revision: snapshot.revision,
481
+ error: null,
482
+ });
483
+ }
468
484
  setCredentialOpen(false);
469
485
  announce('钉钉机器人凭据已绑定。');
470
486
  } catch (error) {
471
487
  if (mountedRef.current) setCredentialError(presentError(error));
472
488
  } finally {
489
+ const shouldRefresh = workspaceFence.endMutation();
490
+ if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
473
491
  if (mountedRef.current) setBusy(false);
474
492
  }
475
- }, [announce, invoke]);
493
+ }, [announce, invoke, loadStatus, workspaceFence]);
476
494
 
477
495
  const cancelProvisioning = React.useCallback(async () => {
478
496
  if (!mountedRef.current) return;
@@ -575,12 +593,20 @@ export function DingtalkSettingsTab({ rpcCall }) {
575
593
 
576
594
  const runBotAction = React.useCallback(async ({ account, operation, endpoint, payload, success }) => {
577
595
  if (!mountedRef.current) return undefined;
596
+ const snapshotVersion = workspaceFence.beginMutation();
578
597
  setBotBusy(account.botId, operation);
579
598
  try {
580
- await invoke(endpoint, payload);
581
- if (!mountedRef.current) return undefined;
582
- const snapshot = await loadStatus({ silent: true, restoreProvisioning: false });
599
+ const snapshot = normalizeSnapshot(await invoke(endpoint, payload));
583
600
  if (!mountedRef.current) return undefined;
601
+ if (workspaceFence.canCommitMutation(snapshotVersion)) {
602
+ setModel({
603
+ phase: 'ready',
604
+ bots: snapshot.bots,
605
+ totals: snapshot.totals,
606
+ revision: snapshot.revision,
607
+ error: null,
608
+ });
609
+ }
584
610
  announce(typeof success === 'function' ? success(snapshot) : success);
585
611
  return snapshot;
586
612
  } catch (error) {
@@ -588,9 +614,11 @@ export function DingtalkSettingsTab({ rpcCall }) {
588
614
  announce(`操作失败:${presentError(error).message}`);
589
615
  return undefined;
590
616
  } finally {
617
+ const shouldRefresh = workspaceFence.endMutation();
618
+ if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true, restoreProvisioning: false });
591
619
  if (mountedRef.current) setBotBusy(account.botId, null);
592
620
  }
593
- }, [announce, invoke, loadStatus, setBotBusy]);
621
+ }, [announce, invoke, loadStatus, setBotBusy, workspaceFence]);
594
622
 
595
623
  const reconnect = React.useCallback((account) => runBotAction({
596
624
  account,
@@ -602,6 +630,30 @@ export function DingtalkSettingsTab({ rpcCall }) {
602
630
  : '钉钉仍未连接,插件会继续自动重试。',
603
631
  }), [runBotAction]);
604
632
 
633
+ const saveWorkspace = React.useCallback(async (account, workspace) => {
634
+ const workspaceVersion = workspaceFence.beginMutation();
635
+ setBotBusy(account.botId, 'workspace');
636
+ try {
637
+ const snapshot = normalizeSnapshot(await invoke(
638
+ DINGTALK_ENDPOINTS.setWorkspace,
639
+ { botId: account.botId, workspace },
640
+ ));
641
+ if (mountedRef.current && workspaceFence.canCommitMutation(workspaceVersion)) {
642
+ setModel({
643
+ phase: 'ready',
644
+ bots: snapshot.bots,
645
+ totals: snapshot.totals,
646
+ revision: snapshot.revision,
647
+ error: null,
648
+ });
649
+ }
650
+ } finally {
651
+ const shouldRefresh = workspaceFence.endMutation();
652
+ if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
653
+ if (mountedRef.current) setBotBusy(account.botId, null);
654
+ }
655
+ }, [invoke, loadStatus, setBotBusy, workspaceFence]);
656
+
605
657
  const remove = React.useCallback(async (account) => {
606
658
  const snapshot = await runBotAction({
607
659
  account,
@@ -688,6 +740,7 @@ export function DingtalkSettingsTab({ rpcCall }) {
688
740
  busyByBot,
689
741
  removeTarget,
690
742
  onReconnect: (account) => void reconnect(account),
743
+ onWorkspaceSave: saveWorkspace,
691
744
  onRequestRemove: (account) => setRemoveTarget(account.botId),
692
745
  onConfirmRemove: (account) => void remove(account),
693
746
  onCancelRemove: () => setRemoveTarget(null),
@@ -17,6 +17,7 @@ export const FEISHU_ENDPOINTS = Object.freeze({
17
17
  reconnectBot: "bot.reconnect",
18
18
  disconnectBot: "bot.disconnect",
19
19
  deleteBot: "bot.delete",
20
+ setWorkspace: "bot.workspace.set",
20
21
  // Kept for rolling upgrades. The multi-bot UI never calls these endpoints.
21
22
  testConnection: "connection.test",
22
23
  disconnect: "connection.disconnect",
@@ -159,6 +160,7 @@ export function normalizeBotConnection(value, fallbackBotId) {
159
160
  state: authoritativeState(value.state, connected),
160
161
  connected,
161
162
  configured: value.configured !== false,
163
+ workspace: optionalString(value.workspace)?.slice(0, 4_096) ?? "",
162
164
  bot: normalizeBot(value.bot),
163
165
  health: normalizeHealth(value.health, connected),
164
166
  error: normalizeError(value.error),
@@ -14,6 +14,8 @@ import {
14
14
  unwrapRpcResult,
15
15
  } from "./api.js";
16
16
  import { useAnimationFrameScheduler } from "../../lifecycle.js";
17
+ import { WorkspaceEditor } from "../../workspace-editor.js";
18
+ import { useWorkspaceSnapshotFence } from "../../workspace-snapshot-fence.js";
17
19
  import { installFeishuStyles } from "./styles.js";
18
20
 
19
21
  export const name = "feishu-settings";
@@ -364,6 +366,7 @@ export function BotCard({
364
366
  actionError,
365
367
  removing,
366
368
  onReconnect,
369
+ onWorkspaceSave,
367
370
  onRequestRemove,
368
371
  onConfirmRemove,
369
372
  onCancelRemove,
@@ -409,6 +412,11 @@ export function BotCard({
409
412
  h("div", { className: "bxf-metric dim-botMetric" }, h("dt", null, "最近检查"),
410
413
  h("dd", null, formatCheckedTime(health.lastCheckedAt))),
411
414
  ),
415
+ h(WorkspaceEditor, {
416
+ workspace: connection.workspace,
417
+ disabled: Boolean(busy),
418
+ onSave: onWorkspaceSave,
419
+ }),
412
420
  h("div", { className: "bxf-connectedFooter dim-cardFooter" },
413
421
  summary ? h("div", { className: "bxf-healthSummary dim-cardSummary", "data-error": actionError || connection.error ? "true" : undefined },
414
422
  summary) : null,
@@ -448,6 +456,7 @@ function BotList(props) {
448
456
  actionError: props.errorsByBot[bot.botId],
449
457
  removing: props.removeTargetId === bot.botId,
450
458
  onReconnect: () => props.onReconnect(bot),
459
+ onWorkspaceSave: (workspace) => props.onWorkspaceSave(bot, workspace),
451
460
  onRequestRemove: () => props.onRequestRemove(bot),
452
461
  onConfirmRemove: () => props.onConfirmRemove(bot),
453
462
  onCancelRemove: props.onCancelRemove,
@@ -525,8 +534,15 @@ export function FeishuSettingsTab({ rpcCall }) {
525
534
  const cardRefs = React.useRef(new Map());
526
535
  const removeButtonRefs = React.useRef(new Map());
527
536
  const addButtonRef = React.useRef(null);
537
+ const mountedRef = React.useRef(true);
538
+ const workspaceFence = useWorkspaceSnapshotFence();
528
539
  const scheduleAnimationFrame = useAnimationFrameScheduler();
529
540
 
541
+ React.useEffect(() => {
542
+ mountedRef.current = true;
543
+ return () => { mountedRef.current = false; };
544
+ }, []);
545
+
530
546
  const announce = React.useCallback((message) => {
531
547
  setAnnouncement("");
532
548
  scheduleAnimationFrame(() => {
@@ -548,23 +564,27 @@ export function FeishuSettingsTab({ rpcCall }) {
548
564
  }, []);
549
565
 
550
566
  const loadStatus = React.useCallback(async ({ signal, silent = false, restoreProvisioning = false } = {}) => {
567
+ const workspaceVersion = workspaceFence.beginStatus();
568
+ if (workspaceVersion === null || !mountedRef.current) return undefined;
551
569
  if (!silent) setPageBusy(true);
552
570
  try {
553
571
  const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.status, {}, signal));
554
- if (signal?.aborted) return undefined;
572
+ if (signal?.aborted || !mountedRef.current
573
+ || !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
555
574
  mergeSnapshot(snapshot, { restoreProvisioning });
556
575
  return snapshot;
557
576
  } catch (error) {
558
- if (signal?.aborted || error?.name === "AbortError") return undefined;
577
+ if (signal?.aborted || error?.name === "AbortError" || !mountedRef.current
578
+ || !workspaceFence.canCommitStatus(workspaceVersion)) return undefined;
559
579
  const presented = presentError(error);
560
580
  setModel((current) => current.phase === "loading" || !silent
561
581
  ? { ...current, phase: "error", pageError: presented }
562
582
  : { ...current, statusError: presented });
563
583
  return undefined;
564
584
  } finally {
565
- if (!silent && !signal?.aborted) setPageBusy(false);
585
+ if (!silent && !signal?.aborted && mountedRef.current) setPageBusy(false);
566
586
  }
567
- }, [invoke, mergeSnapshot]);
587
+ }, [invoke, mergeSnapshot, workspaceFence]);
568
588
 
569
589
  React.useEffect(() => {
570
590
  const controller = new AbortController();
@@ -645,6 +665,7 @@ export function FeishuSettingsTab({ rpcCall }) {
645
665
  }, [announce, invoke, model.provisioning?.attemptId]);
646
666
 
647
667
  const bindCredentials = React.useCallback(async ({ identity, secret }) => {
668
+ const snapshotVersion = workspaceFence.beginMutation();
648
669
  setCredentialBusy(true);
649
670
  setCredentialError(null);
650
671
  try {
@@ -652,15 +673,19 @@ export function FeishuSettingsTab({ rpcCall }) {
652
673
  FEISHU_ENDPOINTS.bindCredentials,
653
674
  { appId: identity, appSecret: secret },
654
675
  ));
655
- mergeSnapshot(snapshot);
676
+ if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
677
+ mergeSnapshot(snapshot);
678
+ }
656
679
  setCredentialOpen(false);
657
680
  announce("飞书机器人凭据已绑定。");
658
681
  } catch (error) {
659
682
  setCredentialError(presentError(error));
660
683
  } finally {
684
+ const shouldRefresh = workspaceFence.endMutation();
685
+ if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
661
686
  setCredentialBusy(false);
662
687
  }
663
- }, [announce, invoke, mergeSnapshot]);
688
+ }, [announce, invoke, loadStatus, mergeSnapshot, workspaceFence]);
664
689
 
665
690
  const cancelProvisioning = React.useCallback(async () => {
666
691
  const attemptId = model.provisioning?.attemptId;
@@ -798,11 +823,14 @@ export function FeishuSettingsTab({ rpcCall }) {
798
823
 
799
824
  const reconnectOneBot = React.useCallback(async (connection) => {
800
825
  const { botId, bot } = connection;
826
+ const snapshotVersion = workspaceFence.beginMutation();
801
827
  setBotBusy(botId, "reconnect");
802
828
  setBotError(botId, null);
803
829
  try {
804
830
  const snapshot = normalizeBotsSnapshot(await invoke(FEISHU_ENDPOINTS.reconnectBot, { botId }));
805
- mergeSnapshot(snapshot);
831
+ if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
832
+ mergeSnapshot(snapshot);
833
+ }
806
834
  const refreshed = snapshot.bots.find((item) => item.botId === botId);
807
835
  if (!refreshed?.connected) {
808
836
  const error = new Error(
@@ -818,9 +846,31 @@ export function FeishuSettingsTab({ rpcCall }) {
818
846
  setBotError(botId, error);
819
847
  announce(`${bot.name}操作失败,请查看机器人状态。`);
820
848
  } finally {
849
+ const shouldRefresh = workspaceFence.endMutation();
850
+ if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
821
851
  setBotBusy(botId, null);
822
852
  }
823
- }, [announce, invoke, mergeSnapshot, setBotBusy, setBotError]);
853
+ }, [announce, invoke, loadStatus, mergeSnapshot, setBotBusy, setBotError, workspaceFence]);
854
+
855
+ const saveWorkspace = React.useCallback(async (connection, workspace) => {
856
+ const { botId } = connection;
857
+ const workspaceVersion = workspaceFence.beginMutation();
858
+ setBotBusy(botId, "workspace");
859
+ setBotError(botId, null);
860
+ try {
861
+ const snapshot = normalizeBotsSnapshot(await invoke(
862
+ FEISHU_ENDPOINTS.setWorkspace,
863
+ { botId, workspace },
864
+ ));
865
+ if (mountedRef.current && workspaceFence.canCommitMutation(workspaceVersion)) {
866
+ mergeSnapshot(snapshot);
867
+ }
868
+ } finally {
869
+ const shouldRefresh = workspaceFence.endMutation();
870
+ if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
871
+ if (mountedRef.current) setBotBusy(botId, null);
872
+ }
873
+ }, [invoke, loadStatus, mergeSnapshot, setBotBusy, setBotError, workspaceFence]);
824
874
 
825
875
  const requestRemove = React.useCallback((connection) => {
826
876
  setRemoveTargetId(connection.botId);
@@ -834,32 +884,29 @@ export function FeishuSettingsTab({ rpcCall }) {
834
884
 
835
885
  const confirmRemove = React.useCallback(async (connection) => {
836
886
  const { botId, bot } = connection;
887
+ const snapshotVersion = workspaceFence.beginMutation();
837
888
  setBotBusy(botId, "delete");
838
889
  setBotError(botId, null);
839
890
  try {
840
- await invoke(FEISHU_ENDPOINTS.deleteBot, { botId, confirm: true });
891
+ const snapshot = normalizeBotsSnapshot(await invoke(
892
+ FEISHU_ENDPOINTS.deleteBot,
893
+ { botId, confirm: true },
894
+ ));
841
895
  setRemoveTargetId(null);
842
- setModel((current) => {
843
- const bots = current.bots.filter((item) => item.botId !== botId);
844
- return {
845
- ...current,
846
- bots,
847
- totals: {
848
- configured: bots.length,
849
- connected: bots.filter((item) => item.connected).length,
850
- },
851
- };
852
- });
896
+ if (mountedRef.current && workspaceFence.canCommitMutation(snapshotVersion)) {
897
+ mergeSnapshot(snapshot);
898
+ }
853
899
  announce(`${bot.name}已从此 DeepSeek Harness 移除;飞书开放平台中的应用未被删除。`);
854
- await loadStatus({ silent: true });
855
900
  scheduleAnimationFrame(() => addButtonRef.current?.focus(), "focus");
856
901
  } catch (error) {
857
902
  setBotError(botId, error);
858
903
  announce(`${bot.name}移除失败,请重试。`);
859
904
  } finally {
905
+ const shouldRefresh = workspaceFence.endMutation();
906
+ if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
860
907
  setBotBusy(botId, null);
861
908
  }
862
- }, [announce, invoke, loadStatus, scheduleAnimationFrame, setBotBusy, setBotError]);
909
+ }, [announce, invoke, loadStatus, mergeSnapshot, scheduleAnimationFrame, setBotBusy, setBotError, workspaceFence]);
863
910
 
864
911
  const provision = model.provisioning;
865
912
  let provisionContent = null;
@@ -950,6 +997,7 @@ export function FeishuSettingsTab({ rpcCall }) {
950
997
  errorsByBot,
951
998
  removeTargetId,
952
999
  onReconnect: (bot) => void reconnectOneBot(bot),
1000
+ onWorkspaceSave: saveWorkspace,
953
1001
  onRequestRemove: requestRemove,
954
1002
  onConfirmRemove: (bot) => void confirmRemove(bot),
955
1003
  onCancelRemove: cancelRemove,
@@ -8,6 +8,7 @@ export const QQ_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, 'QQ机器人', 100),
84
86
  appIdMasked: text(value.bot?.appIdMasked, '应用标识已安全保存', 140),
@@ -3,6 +3,8 @@ import * as React from 'react';
3
3
  import { QqLogoGlyph } 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
  QQ_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 QqSettingsTab({ 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 QqSettingsTab({ 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(QQ_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 QqSettingsTab({ 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 QqSettingsTab({ 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 QqSettingsTab({ rpcCall }) {
266
288
  { appId: identity, appSecret: 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 QqSettingsTab({ 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' }), '正在申请 QQ 二维码…');
@@ -349,6 +380,12 @@ export function QqSettingsTab({ rpcCall }) {
349
380
  busy: busyByBot[account.botId],
350
381
  removing: removeTarget === account.botId,
351
382
  onReconnect: () => void botAction(account, 'reconnect', QQ_ENDPOINTS.reconnectBot, { botId: account.botId }),
383
+ onWorkspaceSave: (workspace) => botAction(
384
+ account,
385
+ 'workspace',
386
+ QQ_ENDPOINTS.setWorkspace,
387
+ { botId: account.botId, workspace },
388
+ ),
352
389
  onRequestRemove: () => setRemoveTarget(account.botId),
353
390
  onCancelRemove: () => setRemoveTarget(null),
354
391
  onConfirmRemove: async () => {
@@ -24,6 +24,7 @@ export const TOKEN_BOT_ENDPOINTS = Object.freeze({
24
24
  bindCredentials: 'bot.bind-credentials',
25
25
  reconnectBot: 'bot.reconnect',
26
26
  deleteBot: 'bot.delete',
27
+ setWorkspace: 'bot.workspace.set',
27
28
  });
28
29
 
29
30
  export function createTokenChannelApi(channel, connectionSummary) {
@@ -47,6 +48,7 @@ export function createTokenChannelApi(channel, connectionSummary) {
47
48
  botId: id(value.botId),
48
49
  connected,
49
50
  state: connected ? 'connected' : state,
51
+ workspace: text(value.workspace, '', 4_096),
50
52
  bot: {
51
53
  name: text(value.bot?.name, `${channel}机器人`, 100),
52
54
  username: text(value.bot?.username, '', 100),