@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
@@ -43,6 +43,8 @@ import { installImStyles } from './styles.js';
43
43
  export const name = 'im-settings';
44
44
  export const inject = ['slots', 'connection', 'locale'];
45
45
 
46
+ const IM_PLUGIN_LOGO_URL = globalThis.__DSH_IM_LOGO_DATA_URL__ ?? 'assets/logo-icon.png';
47
+
46
48
  const CHANNELS = Object.freeze([
47
49
  { id: 'weixin', label: '微信' },
48
50
  { id: 'feishu', label: '飞书' },
@@ -122,10 +124,35 @@ export function IMSettingsTab({
122
124
  whatsappRpcCall,
123
125
  }) {
124
126
  const [selected, setSelected] = React.useState('weixin');
127
+ const githubTooltipId = React.useId();
125
128
  const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0];
126
129
  return h('section', { className: 'dim-page', 'aria-label': 'IM机器人设置' },
127
130
  h('header', { className: 'dim-title' },
128
- h('p', null, '把机器人接入 DeepSeek Harness'),
131
+ h('div', { className: 'dim-brand' },
132
+ h('img', {
133
+ className: 'dim-brandLogo',
134
+ src: IM_PLUGIN_LOGO_URL,
135
+ alt: 'dsh-im',
136
+ width: 48,
137
+ height: 48,
138
+ }),
139
+ h('p', null, '让聊天机器人轻松接入 DeepSeek Harness')),
140
+ h('span', { className: 'dim-githubAction' },
141
+ h('a', {
142
+ className: 'dim-githubLink',
143
+ href: 'https://github.com/xmanrui/dsh-im',
144
+ target: '_blank',
145
+ rel: 'noopener noreferrer',
146
+ 'aria-label': 'dsh-im GitHub',
147
+ 'aria-describedby': githubTooltipId,
148
+ },
149
+ h('span', null, 'GitHub'),
150
+ h('span', { className: 'dim-githubArrow', 'aria-hidden': 'true' }, '↗')),
151
+ h('span', {
152
+ id: githubTooltipId,
153
+ className: 'dim-githubTooltip',
154
+ role: 'tooltip',
155
+ }, '帮助与反馈 · 前往 GitHub')),
129
156
  ),
130
157
  h('div', { className: 'dim-layout' },
131
158
  h('nav', { className: 'dim-rail', role: 'tablist', 'aria-label': 'IM 渠道' },
@@ -11,8 +11,17 @@ const CSS = String.raw`
11
11
  box-sizing: border-box;
12
12
  }
13
13
  .dim-page *, .dim-page *::before, .dim-page *::after { box-sizing: border-box; }
14
- .dim-title { margin: 0 0 26px; }
15
- .dim-title p { margin: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; line-height: 20px; white-space: nowrap; }
14
+ .dim-title { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 0 0 18px; }
15
+ .dim-brand { min-width: 0; display: flex; align-items: center; gap: 12px; }
16
+ .dim-brandLogo { width: 48px; height: 48px; flex: 0 0 48px; display: block; object-fit: contain; filter: grayscale(1) contrast(1.08); }
17
+ .dim-title p { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 14px; line-height: 20px; font-weight: 600; white-space: nowrap; }
18
+ .dim-githubAction { position: relative; display: inline-flex; flex: none; }
19
+ .dim-githubLink { min-height: 30px; display: inline-flex; align-items: center; gap: 5px; flex: none; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-layer-1, #fff); font-size: 12px; line-height: normal; font-weight: 560; text-decoration: none; transition: border-color .15s ease, color .15s ease, background .15s ease; }
20
+ .dim-githubLink:hover { border-color: #aeb3bb; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
21
+ .dim-githubLink:focus-visible { outline: 2px solid color-mix(in srgb, var(--dim-blue) 70%, white); outline-offset: 2px; }
22
+ .dim-githubArrow { font-size: 13px; line-height: 1; }
23
+ .dim-githubTooltip { position: absolute; right: 0; bottom: calc(100% + 8px); z-index: 20; width: max-content; max-width: min(220px, 80vw); padding: 6px 9px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-3, #fff); box-shadow: 0 8px 24px rgb(31 35 41 / 14%); font-size: 11px; line-height: 16px; font-weight: 500; white-space: nowrap; opacity: 0; visibility: hidden; transform: translateY(3px); pointer-events: none; transition: opacity .15s ease, transform .15s ease, visibility .15s ease; }
24
+ .dim-githubAction:hover .dim-githubTooltip, .dim-githubAction:focus-within .dim-githubTooltip { opacity: 1; visibility: visible; transform: translateY(0); }
16
25
  .dim-layout { display: grid; grid-template-columns: 174px 1px minmax(0, 1fr); gap: 24px; align-items: start; }
17
26
  .dim-rail { max-height: 520px; display: grid; align-content: start; gap: 8px; overflow-y: auto; padding: 1px 4px 1px 1px; scrollbar-width: thin; scrollbar-color: var(--dsw-alias-border-l2, #dfe1e5) transparent; }
18
27
  .dim-rail::-webkit-scrollbar { width: 4px; }
@@ -57,7 +66,7 @@ const CSS = String.raw`
57
66
  .dim-panel .dim-credentialButton:hover:not(:disabled) { border-color: #4e5969; background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
58
67
  .dim-panel .dim-credentialButton[aria-pressed="true"] { border-color: #4e5969; background: var(--dsw-alias-bg-module-platform, #f2f3f5); box-shadow: inset 0 0 0 1px rgb(78 89 105 / 8%); }
59
68
  .dim-panel .bxf-headingTools .dim-onlineBadge, .dim-panel .dxw-tools .dim-onlineBadge, .dim-panel .ddt-tools .dim-onlineBadge { min-height: 30px; display: inline-flex; align-items: center; justify-self: end; gap: 0; padding: 0 11px; border: 0; border-radius: 999px; color: var(--dsw-alias-label-secondary, #646a73); background: var(--dsw-alias-bg-module-platform, #f2f3f5); font: inherit; font-size: 12px; font-weight: 400; line-height: normal; white-space: nowrap; }
60
- .dim-panel .dim-channelPage { width: 100%; max-width: none; display: flex; flex-direction: column; gap: 18px; padding: 0 0 24px; color: var(--dsw-alias-label-primary, #1f2329); box-sizing: border-box; }
69
+ .dim-panel .dim-channelPage { width: 100%; max-width: none; display: flex; flex-direction: column; gap: 12px; padding: 0 0 24px; color: var(--dsw-alias-label-primary, #1f2329); box-sizing: border-box; }
61
70
  .dim-panel .dim-surfaceCard { position: relative; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2, #e5e6eb); border-radius: 14px; background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 1px 2px rgb(31 35 41 / 3%); }
62
71
  .dim-panel .dim-surfaceCard::before { display: none; }
63
72
  .dim-panel .dim-surfaceBody { padding: 24px; }
@@ -73,9 +82,9 @@ const CSS = String.raw`
73
82
  .dim-panel .dim-credentialError { margin: 0; color: var(--dsw-alias-state-error-primary, #d54941); font-size: 12px; line-height: 1.5; }
74
83
  .dim-panel .dim-credentialActions { margin-top: 0; }
75
84
  .dim-panel .dim-listSection { display: flex; flex-direction: column; gap: 0; }
76
- .dim-panel .dim-listHeading { min-height: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 2px 0 9px; padding: 0; }
85
+ .dim-panel .dim-listHeading { min-height: 0; display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 0 0 6px; padding: 0; }
77
86
  .dim-panel .dim-listHeading h3 { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 14px; line-height: normal; font-weight: 650; }
78
- .dim-panel .dim-botList { display: grid; gap: 12px; margin: 0; padding: 0; list-style: none; }
87
+ .dim-panel .dim-botList { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; }
79
88
  .dim-panel .dim-loadingView { padding: 38px; color: var(--dsw-alias-label-secondary, #646a73); text-align: center; }
80
89
  .dim-panel .dim-loadingView h3 { margin: 0 0 7px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 17px; line-height: normal; font-weight: 650; }
81
90
  .dim-panel .dim-loadingView p { margin: 0; line-height: 1.6; }
@@ -123,7 +132,20 @@ const CSS = String.raw`
123
132
  .dim-panel .dim-confirm { padding: 18px 24px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
124
133
  .dim-panel .dim-confirm strong, .dim-panel .dim-confirm h4 { margin: 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 14px; line-height: 1.4; font-weight: 650; }
125
134
  .dim-panel .dim-confirm p { margin: 7px 0 0; color: var(--dsw-alias-label-secondary, #646a73); line-height: 1.6; }
126
- .dim-panel .dim-cardFooter { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding-top: 12px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
135
+ .dim-panel .dim-cardFooter { display: flex; align-items: center; justify-content: space-between; gap: 15px; padding-top: 8px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
136
+ .dim-panel .dim-workspace { min-width: 0; display: grid; gap: 7px; margin-top: 14px; padding: 11px 12px; border: 1px solid var(--dsw-alias-border-l1, #eef0f3); border-radius: 9px; background: var(--dsw-alias-bg-module-platform, #f7f8fa); }
137
+ .dim-panel .dim-workspaceHeader { display: flex; align-items: center; justify-content: space-between; gap: 12px; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; line-height: normal; }
138
+ .dim-panel .dim-workspaceEdit { padding: 0; border: 0; color: #1677ff; background: transparent; font: inherit; font-weight: 560; cursor: pointer; }
139
+ .dim-panel .dim-workspaceEdit:disabled { cursor: not-allowed; opacity: .55; }
140
+ .dim-panel .dim-workspacePath { min-width: 0; display: block; overflow: hidden; color: var(--dsw-alias-label-primary, #1f2329); font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; }
141
+ .dim-panel .dim-workspaceForm { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; gap: 8px; }
142
+ .dim-panel .dim-workspaceForm input { width: 100%; min-width: 0; height: 34px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; outline: none; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
143
+ .dim-panel .dim-workspaceForm input:focus { border-color: #1677ff; box-shadow: 0 0 0 2px rgb(22 119 255 / 10%); }
144
+ .dim-panel .dim-workspaceActions { display: flex; gap: 6px; }
145
+ .dim-panel .dim-workspaceActions button { min-height: 34px; padding: 0 10px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 7px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 12px; cursor: pointer; }
146
+ .dim-panel .dim-workspaceActions button[type="submit"] { border-color: #1677ff; color: white; background: #1677ff; }
147
+ .dim-panel .dim-workspaceActions button:disabled { cursor: not-allowed; opacity: .55; }
148
+ .dim-panel .dim-workspaceError { grid-column: 1 / -1; margin: 0; color: var(--dsw-alias-state-error-primary, #d54941); font-size: 12px; line-height: 1.5; }
127
149
  .dim-panel .dim-cardSummary { min-width: 0; color: var(--dsw-alias-label-secondary, #646a73); font: inherit; font-size: 12px; font-weight: 400; line-height: normal; }
128
150
  .dim-panel .dim-cardActions { flex: none; display: flex; align-items: center; flex-wrap: nowrap; gap: 8px; margin: 0 0 0 auto; }
129
151
  .dim-panel .dim-cardActions .dim-cardAction { flex: none; min-height: 34px; padding: 0 13px; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 8px; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); font: inherit; font-size: 13px; font-weight: 560; line-height: normal; white-space: nowrap; }
@@ -131,7 +153,7 @@ const CSS = String.raw`
131
153
  .dim-panel .dim-cardActions .dim-cardAction[data-kind="danger"] { color: var(--dsw-alias-state-error-primary, #d54941); }
132
154
  .dim-panel .dim-botCard { position: relative; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2, #e5e6eb); border-radius: 14px; background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 1px 2px rgb(31 35 41 / 3%); }
133
155
  .dim-panel .dim-botCard::before { display: none; }
134
- .dim-panel .dim-botCardBody { position: relative; padding: 16px; }
156
+ .dim-panel .dim-botCardBody { position: relative; padding: 14px; }
135
157
  .dim-panel .dim-botCardTop { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
136
158
  .dim-panel .dim-botIdentity { min-width: 0; display: flex; align-items: center; gap: 12px; }
137
159
  .dim-panel .dim-botAvatar { flex: none; width: 42px; height: 42px; display: grid; place-items: center; overflow: hidden; border-radius: 12px; box-shadow: none; }
@@ -144,8 +166,8 @@ const CSS = String.raw`
144
166
  .dim-panel .dim-botCard .dim-healthDot[data-tone="success"] { background: var(--dsw-alias-state-success-primary, #20a162); box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-alias-state-success-primary, #20a162) 14%, transparent); }
145
167
  .dim-panel .dim-botCard .dim-healthDot[data-tone="warning"] { background: var(--dsw-alias-state-warn-primary, #d97706); }
146
168
  .dim-panel .dim-botCard .dim-healthDot[data-tone="error"] { background: var(--dsw-alias-state-error-primary, #d54941); }
147
- .dim-panel .dim-botMetrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 12px 0; }
148
- .dim-panel .dim-botMetric { min-width: 0; padding: 10px; border: 0; border-radius: 9px; background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
169
+ .dim-panel .dim-botMetrics { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 8px 0; }
170
+ .dim-panel .dim-botMetric { min-width: 0; padding: 8px; border: 0; border-radius: 9px; background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
149
171
  .dim-panel .dim-botMetric dt { margin: 0; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; font-weight: 400; line-height: normal; }
150
172
  .dim-panel .dim-botMetric dd { overflow: hidden; margin: 5px 0 0; color: var(--dsw-alias-label-primary, #1f2329); font-size: 13px; font-weight: 400; line-height: normal; text-overflow: ellipsis; white-space: nowrap; }
151
173
  .dim-panel .dim-botCard .dim-cardFooter { margin-top: 0; }
@@ -182,6 +204,7 @@ const CSS = String.raw`
182
204
  @media (max-width: 560px) {
183
205
  .dim-title { flex-direction: column; gap: 10px; }
184
206
  .dim-title p { white-space: normal; }
207
+ .dim-githubTooltip { right: auto; left: 0; }
185
208
  .dim-rail { grid-template-columns: minmax(0, 1fr); }
186
209
  }
187
210
  @media (prefers-reduced-motion: reduce) {
@@ -0,0 +1,96 @@
1
+ import * as React from 'react';
2
+
3
+ import { h } from './i18n.js';
4
+
5
+ function looksLikeAbsolutePath(value) {
6
+ return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value);
7
+ }
8
+
9
+ export function WorkspaceEditor({ workspace, disabled = false, onSave }) {
10
+ const [editing, setEditing] = React.useState(false);
11
+ const [draft, setDraft] = React.useState(workspace ?? '');
12
+ const [saving, setSaving] = React.useState(false);
13
+ const [error, setError] = React.useState(null);
14
+ const inputRef = React.useRef(null);
15
+ const editButtonRef = React.useRef(null);
16
+ const restoreFocus = React.useRef(false);
17
+
18
+ React.useEffect(() => {
19
+ if (!editing) setDraft(workspace ?? '');
20
+ }, [workspace, editing]);
21
+
22
+ React.useEffect(() => {
23
+ if (editing) inputRef.current?.focus();
24
+ else if (restoreFocus.current) {
25
+ restoreFocus.current = false;
26
+ editButtonRef.current?.focus();
27
+ }
28
+ }, [editing]);
29
+
30
+ const cancel = () => {
31
+ setDraft(workspace ?? '');
32
+ setError(null);
33
+ restoreFocus.current = true;
34
+ setEditing(false);
35
+ };
36
+
37
+ const submit = async (event) => {
38
+ event.preventDefault();
39
+ const value = draft.trim();
40
+ if (!value || saving || disabled) return;
41
+ if (!looksLikeAbsolutePath(value)) {
42
+ setError('工作区必须是绝对路径。');
43
+ return;
44
+ }
45
+ setSaving(true);
46
+ setError(null);
47
+ try {
48
+ await onSave?.(value);
49
+ restoreFocus.current = true;
50
+ setEditing(false);
51
+ } catch (cause) {
52
+ setError(cause?.message ?? '工作区修改失败,请重试。');
53
+ } finally {
54
+ setSaving(false);
55
+ }
56
+ };
57
+
58
+ return h('div', { className: 'dim-workspace' },
59
+ h('div', { className: 'dim-workspaceHeader' },
60
+ h('span', null, '当前工作区'),
61
+ editing ? null : h('button', {
62
+ type: 'button',
63
+ ref: editButtonRef,
64
+ className: 'dim-workspaceEdit',
65
+ onClick: () => { setEditing(true); setError(null); },
66
+ disabled,
67
+ }, '修改')),
68
+ editing
69
+ ? h('form', { className: 'dim-workspaceForm', onSubmit: submit },
70
+ h('input', {
71
+ ref: inputRef,
72
+ value: draft,
73
+ onChange: (event) => setDraft(event.target.value),
74
+ placeholder: '/绝对路径/到/工作区',
75
+ 'aria-label': '工作区绝对路径',
76
+ autoCapitalize: 'none',
77
+ autoCorrect: 'off',
78
+ spellCheck: false,
79
+ maxLength: 4_096,
80
+ required: true,
81
+ disabled: saving || disabled,
82
+ }),
83
+ h('div', { className: 'dim-workspaceActions' },
84
+ h('button', {
85
+ type: 'submit', disabled: saving || disabled || !draft.trim(),
86
+ }, saving ? '保存中…' : '保存'),
87
+ h('button', { type: 'button', onClick: cancel, disabled: saving || disabled }, '取消')),
88
+ error ? h('p', { className: 'dim-workspaceError', role: 'alert' }, error) : null)
89
+ : workspace
90
+ ? React.createElement('code', {
91
+ className: 'dim-workspacePath',
92
+ title: workspace,
93
+ }, workspace)
94
+ : h('code', { className: 'dim-workspacePath' }, '未设置'),
95
+ );
96
+ }
@@ -0,0 +1,28 @@
1
+ import * as React from 'react';
2
+
3
+ /** Keep every full-snapshot response ordered behind the latest client mutation. */
4
+ export function useWorkspaceSnapshotFence() {
5
+ const state = React.useRef({ version: 0, pendingMutations: 0 });
6
+ return React.useMemo(() => Object.freeze({
7
+ beginStatus() {
8
+ return state.current.pendingMutations === 0 ? state.current.version : null;
9
+ },
10
+ canCommitStatus(version) {
11
+ return version !== null
12
+ && state.current.pendingMutations === 0
13
+ && state.current.version === version;
14
+ },
15
+ beginMutation() {
16
+ state.current.pendingMutations += 1;
17
+ state.current.version += 1;
18
+ return state.current.version;
19
+ },
20
+ canCommitMutation(version) {
21
+ return state.current.version === version;
22
+ },
23
+ endMutation() {
24
+ state.current.pendingMutations = Math.max(0, state.current.pendingMutations - 1);
25
+ return state.current.pendingMutations === 0;
26
+ },
27
+ }), []);
28
+ }
@@ -8,6 +8,12 @@ import { DingtalkController } from '../../../../src/channels/dingtalk/dingtalk-c
8
8
  import { DingtalkRuntime } from '../../../../src/channels/dingtalk/dingtalk-runtime.mjs';
9
9
  import { HarnessClient } from '../../../../src/channels/dingtalk/harness-client.mjs';
10
10
  import { DingtalkStateStore } from '../../../../src/channels/dingtalk/state-store.mjs';
11
+ import {
12
+ BotWorkspaceStore,
13
+ createBotWorkspaceScope,
14
+ createWorkspaceAwareController,
15
+ observeBotWorkspaceRemovals,
16
+ } from '../../../../src/channels/shared/bot-workspace-store.mjs';
11
17
  import { createConnectionSupervisor } from './connection-supervisor.mjs';
12
18
 
13
19
  function harnessOrigin(webServer, configured) {
@@ -26,6 +32,7 @@ function pluginPaths(config) {
26
32
  root,
27
33
  config: resolve(config.configPath ?? join(root, 'config.json')),
28
34
  bots: resolve(config.botsDir ?? join(root, 'bots')),
35
+ workspaces: resolve(config.workspacesPath ?? join(root, 'workspaces.json')),
29
36
  };
30
37
  }
31
38
 
@@ -45,6 +52,19 @@ export async function createProductionController(ctx, config = {}, internals = {
45
52
  : (ctx.logger ?? console);
46
53
  const paths = pluginPaths(config);
47
54
  const configStore = await new ConfigStore(paths.config).load();
55
+ const defaultWorkspace = resolve(config.workspace ?? process.cwd());
56
+ const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
57
+ const workspaces = internals.workspaces
58
+ ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
59
+ const canListConfiguredBots = typeof configStore.list === 'function';
60
+ const configuredBots = canListConfiguredBots ? configStore.list() : [];
61
+ if (canListConfiguredBots) {
62
+ await workspaces.reconcile(configuredBots.map((bot) => bot.botId));
63
+ }
64
+ await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.botId)));
65
+ const observedConfigStore = typeof configStore.remove === 'function'
66
+ ? observeBotWorkspaceRemovals(configStore, { workspaces })
67
+ : configStore;
48
68
  const deviceAuth = internals.deviceAuth ?? new DeviceAuth({
49
69
  baseUrl: config.registrationBaseUrl,
50
70
  });
@@ -61,23 +81,25 @@ export async function createProductionController(ctx, config = {}, internals = {
61
81
  };
62
82
  const harness = new Harness({
63
83
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
64
- workspace: resolve(config.workspace ?? process.cwd()),
84
+ workspace: defaultWorkspace,
65
85
  agentPreset: config.agentPreset ?? 'standard',
66
86
  autostart: false,
67
87
  dshBin: config.dshBin ?? 'dsh',
68
88
  });
69
- const controller = new Controller({
89
+ const coreController = new Controller({
70
90
  deviceAuth,
71
91
  credentials: ctx.credentials,
72
- configStore,
92
+ configStore: observedConfigStore,
73
93
  logger,
74
94
  createRuntime: async ({ botId, config: botConfig, clientSecret }) => {
75
95
  const state = await stateFor(botId);
96
+ await workspaces.ensure(botId);
97
+ const workspaceScope = createBotWorkspaceScope(harness, { botId, workspaces, state });
76
98
  return new Runtime({
77
99
  config: botConfig,
78
100
  clientSecret,
79
- harness,
80
- state,
101
+ harness: workspaceScope.harness,
102
+ state: workspaceScope.state,
81
103
  replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
82
104
  maxMessageChars: config.maxMessageChars ?? 4_000,
83
105
  connectTimeoutMs: config.connectTimeoutMs ?? 15_000,
@@ -94,15 +116,16 @@ export async function createProductionController(ctx, config = {}, internals = {
94
116
  stateStores.delete(botId);
95
117
  if (state && typeof state.remove === 'function') {
96
118
  await state.remove();
97
- return;
98
- }
99
- try {
100
- await unlink(statePath(botId));
101
- } catch (error) {
102
- if (error?.code !== 'ENOENT') throw error;
119
+ } else {
120
+ try {
121
+ await unlink(statePath(botId));
122
+ } catch (error) {
123
+ if (error?.code !== 'ENOENT') throw error;
124
+ }
103
125
  }
104
126
  },
105
127
  });
128
+ const controller = createWorkspaceAwareController(coreController, { workspaces, stateFor });
106
129
  const supervisor = createSupervisor({
107
130
  controller,
108
131
  harness,
@@ -1,5 +1,6 @@
1
1
  import QRCode from 'qrcode';
2
2
  import { resolveRpcAuthority } from '../../rpc-authority.mjs';
3
+ import { publicWorkspaceError, SET_WORKSPACE_ENDPOINT, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
3
4
 
4
5
  export const DINGTALK_RPC_CHANNEL = '/dingtalk';
5
6
  export const DINGTALK_ENDPOINTS = Object.freeze({
@@ -10,6 +11,7 @@ export const DINGTALK_ENDPOINTS = Object.freeze({
10
11
  bindCredentials: 'bot.bind-credentials',
11
12
  reconnectBot: 'bot.reconnect',
12
13
  deleteBot: 'bot.delete',
14
+ setWorkspace: SET_WORKSPACE_ENDPOINT,
13
15
  approveSender: 'bot.sender.approve',
14
16
  revokeSender: 'bot.sender.revoke',
15
17
  });
@@ -76,6 +78,10 @@ function payloadFailure(endpoint, payload) {
76
78
  ? null
77
79
  : 'bot.delete requires a botId and confirm=true.';
78
80
  }
81
+ if (endpoint === DINGTALK_ENDPOINTS.setWorkspace) {
82
+ return validWorkspacePayload(payload)
83
+ ? null : '请输入工作区绝对路径。';
84
+ }
79
85
  if (endpoint === DINGTALK_ENDPOINTS.approveSender) {
80
86
  return exactKeys(payload, ['botId', 'requestId', 'confirm'])
81
87
  && validId(payload.botId)
@@ -207,6 +213,12 @@ export function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } =
207
213
  value = await publicStatus(await controller.reconnectBot(payload.botId), cachedEncode);
208
214
  } else if (endpoint === DINGTALK_ENDPOINTS.deleteBot) {
209
215
  value = await publicStatus(await controller.deleteBot(payload.botId), cachedEncode);
216
+ } else if (endpoint === DINGTALK_ENDPOINTS.setWorkspace) {
217
+ if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
218
+ value = await publicStatus(
219
+ await controller.updateWorkspace(payload.botId, payload.workspace),
220
+ cachedEncode,
221
+ );
210
222
  } else if (endpoint === DINGTALK_ENDPOINTS.approveSender) {
211
223
  value = await publicStatus(
212
224
  await controller.approveSender(payload.botId, payload.requestId),
@@ -219,8 +231,11 @@ export function createDingtalkRpcHandler(controller, { encodeQr = qrDataUrl } =
219
231
  );
220
232
  }
221
233
  return signal?.aborted ? cancelled() : { ok: true, value };
222
- } catch {
223
- return signal?.aborted ? cancelled() : internalFailure();
234
+ } catch (error) {
235
+ const workspaceError = publicWorkspaceError(error);
236
+ return signal?.aborted ? cancelled() : workspaceError
237
+ ? { ok: false, error: workspaceError }
238
+ : internalFailure();
224
239
  }
225
240
  };
226
241
  }
@@ -12,6 +12,12 @@ import {
12
12
  } from '../../../../src/channels/feishu/plugin-config-store.mjs';
13
13
  import { MultiBotDshFeishuController } from '../../../../src/channels/feishu/multi-bot-controller.mjs';
14
14
  import { StateStore } from '../../../../src/channels/feishu/state-store.mjs';
15
+ import {
16
+ BotWorkspaceStore,
17
+ createBotWorkspaceScope,
18
+ createWorkspaceAwareController,
19
+ observeBotWorkspaceRemovals,
20
+ } from '../../../../src/channels/shared/bot-workspace-store.mjs';
15
21
 
16
22
  function harnessOrigin(webServer, configured) {
17
23
  if (configured !== undefined) return new URL(configured);
@@ -34,6 +40,7 @@ function pluginPaths(config) {
34
40
  config: resolve(config.configPath ?? join(root, 'config.json')),
35
41
  legacyState: resolve(config.statePath ?? join(root, 'state.json')),
36
42
  bots: resolve(config.botsDir ?? join(root, 'bots')),
43
+ workspaces: resolve(config.workspacesPath ?? join(root, 'workspaces.json')),
37
44
  };
38
45
  }
39
46
 
@@ -58,6 +65,24 @@ export async function createProductionController(ctx, config = {}, internals = {
58
65
  : (ctx.logger ?? console);
59
66
  const paths = pluginPaths(config);
60
67
  const configStore = await new ConfigStore(paths.config).load();
68
+ const defaultWorkspace = resolve(config.workspace ?? process.cwd());
69
+ const WorkspaceStore = internals.WorkspaceStore ?? BotWorkspaceStore;
70
+ const workspaces = internals.workspaces
71
+ ?? await new WorkspaceStore(paths.workspaces, { defaultWorkspace }).load();
72
+ const canListConfiguredBots = typeof configStore.list === 'function';
73
+ const listConfiguredBots = () => canListConfiguredBots ? configStore.list() : [];
74
+ const configuredBots = listConfiguredBots();
75
+ if (canListConfiguredBots) {
76
+ await workspaces.reconcile(configuredBots.map((bot) => bot.id));
77
+ }
78
+ await Promise.all(configuredBots.map((bot) => workspaces.ensure(bot.id)));
79
+ const observedConfigStore = typeof configStore.removeBot === 'function'
80
+ ? observeBotWorkspaceRemovals(configStore, {
81
+ workspaces,
82
+ method: 'removeBot',
83
+ botIdFromRemoved: (removed) => removed.id,
84
+ })
85
+ : configStore;
61
86
  // State is lazy per bot. A corrupt legacy file can therefore fail only the
62
87
  // migrated bot and cannot prevent healthy v2 bots from starting.
63
88
  const stateStores = new Map();
@@ -75,9 +100,14 @@ export async function createProductionController(ctx, config = {}, internals = {
75
100
  }
76
101
  return state;
77
102
  };
103
+ const stateForBotId = async (botId) => {
104
+ const botConfig = listConfiguredBots().find((bot) => bot.id === botId);
105
+ if (!botConfig) throw new Error('Unknown Feishu bot');
106
+ return stateFor(botConfig);
107
+ };
78
108
  const harness = new Harness({
79
109
  baseUrl: harnessOrigin(ctx.webServer, config.harnessBaseUrl),
80
- workspace: resolve(config.workspace ?? process.cwd()),
110
+ workspace: defaultWorkspace,
81
111
  agentPreset: config.agentPreset ?? 'standard',
82
112
  // This plugin is already hosted by a running DSH process. Starting a
83
113
  // second DSH would create a competing server and lifecycle.
@@ -85,21 +115,24 @@ export async function createProductionController(ctx, config = {}, internals = {
85
115
  dshBin: config.dshBin ?? 'dsh',
86
116
  });
87
117
 
88
- const controller = new Controller({
118
+ const coreController = new Controller({
89
119
  registerApp: (options) => lark.registerApp(options),
90
120
  verifyApp,
91
121
  credentials: ctx.credentials,
92
- configStore,
122
+ configStore: observedConfigStore,
93
123
  createRuntime: async ({ botId, config: botConfig, appSecret }) => {
94
124
  const state = await stateFor(botConfig);
125
+ const id = botId ?? botConfig.id ?? botConfig.appId;
126
+ await workspaces.ensure(id);
127
+ const workspaceScope = createBotWorkspaceScope(harness, { botId: id, workspaces, state });
95
128
  return new Runtime({
96
129
  lark,
97
130
  appId: botConfig.appId,
98
131
  appSecret,
99
132
  domain: botConfig.domain,
100
133
  ownerOpenIds: botConfig.ownerOpenIds ?? [botConfig.ownerOpenId],
101
- harness,
102
- state,
134
+ harness: workspaceScope.harness,
135
+ state: workspaceScope.state,
103
136
  replyTimeoutMs: config.replyTimeoutMs ?? 600_000,
104
137
  logger: {
105
138
  error: (...args) => logger.error?.(`[${botId ?? botConfig.id}]`, ...args),
@@ -118,6 +151,10 @@ export async function createProductionController(ctx, config = {}, internals = {
118
151
  }
119
152
  },
120
153
  });
154
+ const controller = createWorkspaceAwareController(coreController, {
155
+ workspaces,
156
+ stateFor: stateForBotId,
157
+ });
121
158
 
122
159
  const supervisor = createSupervisor({
123
160
  controller,
@@ -1,5 +1,6 @@
1
1
  import QRCode from 'qrcode';
2
2
  import { resolveRpcAuthority } from '../../rpc-authority.mjs';
3
+ import { publicWorkspaceError, validWorkspacePayload } from '../shared/workspace-rpc.mjs';
3
4
  import {
4
5
  FEISHU_ENDPOINTS,
5
6
  FEISHU_RPC_CHANNEL,
@@ -174,6 +175,7 @@ function publicBotEntry(entry) {
174
175
  bot: publicBot(source.bot),
175
176
  health: publicHealth(source, connected),
176
177
  };
178
+ if (typeof source.workspace === 'string' && source.workspace) result.workspace = source.workspace;
177
179
  const error = publicError(source.error);
178
180
  if (error) result.error = error;
179
181
  return result;
@@ -268,6 +270,10 @@ function validPayload(endpoint, payload) {
268
270
  ? null
269
271
  : 'Deleting a bot requires a valid botId and confirm=true.';
270
272
  }
273
+ if (endpoint === FEISHU_ENDPOINTS.setWorkspace) {
274
+ return validWorkspacePayload(payload)
275
+ ? null : '请输入工作区绝对路径。';
276
+ }
271
277
  return 'Unknown Feishu endpoint.';
272
278
  }
273
279
 
@@ -429,14 +435,23 @@ export function createFeishuRpcHandler(controller, { encodeQr = qrCodeDataUrl }
429
435
  } else if (endpoint === FEISHU_MULTI_ENDPOINTS.disconnectBot) {
430
436
  if (typeof controller.disconnectBot !== 'function') throw new Error('Multi-bot disconnect is unavailable');
431
437
  value = await toPublicFeishuStatus(await controller.disconnectBot(payload.botId), { encodeQr: cachedEncodeQr });
438
+ } else if (endpoint === FEISHU_ENDPOINTS.setWorkspace) {
439
+ if (typeof controller.updateWorkspace !== 'function') throw new Error('Workspace update is unavailable');
440
+ value = await toPublicFeishuStatus(
441
+ await controller.updateWorkspace(payload.botId, payload.workspace),
442
+ { encodeQr: cachedEncodeQr },
443
+ );
432
444
  } else {
433
445
  if (typeof controller.deleteBot !== 'function') throw new Error('Multi-bot delete is unavailable');
434
446
  value = await toPublicFeishuStatus(await controller.deleteBot(payload.botId), { encodeQr: cachedEncodeQr });
435
447
  }
436
448
  if (signal?.aborted) return cancelled();
437
449
  return { ok: true, value };
438
- } catch {
439
- return signal?.aborted ? cancelled() : internalFailure();
450
+ } catch (error) {
451
+ const workspaceError = publicWorkspaceError(error);
452
+ return signal?.aborted ? cancelled() : workspaceError
453
+ ? { ok: false, error: { ...workspaceError, details: {} } }
454
+ : internalFailure();
440
455
  }
441
456
  };
442
457
  }