@xmanrui/dsh-im 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -4
- package/lib/client.js +649 -357
- package/lib/index.js +108 -96
- package/package.json +1 -1
- package/plugin-src/client/build.mjs +1 -1
- package/plugin-src/client/i18n.js +14 -0
- package/plugin-src/client/index.js +11 -3
- package/plugin-src/client/styles.js +49 -8
- package/plugin-src/client/workspace-directory-picker.js +230 -0
- package/plugin-src/client/workspace-editor.js +38 -65
- package/scripts/verify-package.mjs +5 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +3 -1
- package/src/channels/dingtalk/harness-client.mjs +56 -0
- package/src/channels/discord/discord-api.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +3 -1
- package/src/channels/feishu/harness-client.mjs +56 -0
- package/src/channels/qq/qq-bridge.mjs +3 -1
- package/src/channels/shared/bot-workspace-store.mjs +185 -4
- package/src/channels/shared/harness-session-binding.mjs +110 -0
- package/src/channels/shared/text-harness-bridge.mjs +4 -2
- package/src/channels/shared/workspace-command.mjs +212 -16
- package/src/channels/shared/workspace-session.mjs +22 -9
- package/src/channels/wecom/wecom-bridge.mjs +3 -1
- package/src/channels/weixin/harness-client.mjs +56 -0
- package/src/channels/weixin/weixin-api.mjs +1 -1
- package/src/channels/weixin/weixin-bridge.mjs +5 -3
package/package.json
CHANGED
|
@@ -45,6 +45,20 @@ const EN = Object.freeze({
|
|
|
45
45
|
'消息通道': 'Message channel',
|
|
46
46
|
'最近检查': 'Last checked',
|
|
47
47
|
'当前工作区': 'Current workspace',
|
|
48
|
+
'选择目录': 'Choose folder',
|
|
49
|
+
'选择机器人工作区目录': 'Select bot workspace folder',
|
|
50
|
+
'当前目录': 'Current folder',
|
|
51
|
+
'主目录': 'Home',
|
|
52
|
+
'正在准备目录选择器…': 'Preparing folder picker…',
|
|
53
|
+
'正在读取目录…': 'Loading folders…',
|
|
54
|
+
'这个目录中没有子文件夹。': 'This folder has no subfolders.',
|
|
55
|
+
'此目录的子文件夹过多,仅显示前一部分。': 'This folder has too many subfolders; only the first group is shown.',
|
|
56
|
+
'无法读取目录,请重试。': 'Could not load the folder. Try again.',
|
|
57
|
+
'重试': 'Retry',
|
|
58
|
+
'显示隐藏文件夹': 'Show hidden folders',
|
|
59
|
+
'切换后会清除这个机器人的旧会话映射。': 'Switching clears this bot’s previous session mappings.',
|
|
60
|
+
'切换中…': 'Switching…',
|
|
61
|
+
'选择此目录': 'Select this folder',
|
|
48
62
|
'工作区绝对路径': 'Absolute workspace path',
|
|
49
63
|
'/绝对路径/到/工作区': '/absolute/path/to/workspace',
|
|
50
64
|
'修改': 'Change',
|
|
@@ -39,9 +39,10 @@ import { WhatsappSettingsTab } from './channels/whatsapp/index.js';
|
|
|
39
39
|
import { installWhatsappStyles } from './channels/whatsapp/styles.js';
|
|
40
40
|
import { en, h, IM_LOCALE_NAMESPACE, setImTranslator, zh } from './i18n.js';
|
|
41
41
|
import { installImStyles } from './styles.js';
|
|
42
|
+
import { WorkspaceDirectoryPickerContext } from './workspace-editor.js';
|
|
42
43
|
|
|
43
44
|
export const name = 'im-settings';
|
|
44
|
-
export const inject = ['slots', 'connection', 'locale'];
|
|
45
|
+
export const inject = ['slots', 'connection', 'locale', 'workspaces'];
|
|
45
46
|
|
|
46
47
|
const IM_PLUGIN_LOGO_URL = globalThis.__DSH_IM_LOGO_DATA_URL__ ?? 'assets/logo-icon.png';
|
|
47
48
|
|
|
@@ -122,11 +123,13 @@ export function IMSettingsTab({
|
|
|
122
123
|
wecomRpcCall,
|
|
123
124
|
weixinRpcCall,
|
|
124
125
|
whatsappRpcCall,
|
|
126
|
+
workspaceDirectoryPicker,
|
|
125
127
|
}) {
|
|
126
128
|
const [selected, setSelected] = React.useState('weixin');
|
|
127
129
|
const githubTooltipId = React.useId();
|
|
128
130
|
const active = CHANNELS.find((channel) => channel.id === selected) ?? CHANNELS[0];
|
|
129
|
-
return h(
|
|
131
|
+
return h(WorkspaceDirectoryPickerContext.Provider, { value: workspaceDirectoryPicker },
|
|
132
|
+
h('section', { className: 'dim-page', 'aria-label': 'IM机器人设置' },
|
|
130
133
|
h('header', { className: 'dim-title' },
|
|
131
134
|
h('div', { className: 'dim-brand' },
|
|
132
135
|
h('img', {
|
|
@@ -194,7 +197,7 @@ export function IMSettingsTab({
|
|
|
194
197
|
? h(DiscordSettingsTab, { rpcCall: discordRpcCall })
|
|
195
198
|
: h(WhatsappSettingsTab, { rpcCall: whatsappRpcCall })),
|
|
196
199
|
),
|
|
197
|
-
);
|
|
200
|
+
));
|
|
198
201
|
}
|
|
199
202
|
|
|
200
203
|
export function apply(ctx) {
|
|
@@ -240,6 +243,10 @@ export function apply(ctx) {
|
|
|
240
243
|
ctx.connection.rpc.call(WHATSAPP_RPC_CHANNEL, endpoint, payload, signal);
|
|
241
244
|
const slackRpcCall = (endpoint, payload, signal) =>
|
|
242
245
|
ctx.connection.rpc.call(SLACK_RPC_CHANNEL, endpoint, payload, signal);
|
|
246
|
+
const workspaceDirectoryPicker = Object.freeze({
|
|
247
|
+
listDirectory: (path, signal) => ctx.workspaces.listDirectory(path, signal),
|
|
248
|
+
pickDirectory: () => ctx.workspaces.pickDirectory(),
|
|
249
|
+
});
|
|
243
250
|
|
|
244
251
|
ctx.slots.inject('settings.plugins.tab', () => ctx.slots.register({
|
|
245
252
|
name: 'settings.plugins.tab',
|
|
@@ -257,6 +264,7 @@ export function apply(ctx) {
|
|
|
257
264
|
wecomRpcCall,
|
|
258
265
|
weixinRpcCall,
|
|
259
266
|
whatsappRpcCall,
|
|
267
|
+
workspaceDirectoryPicker,
|
|
260
268
|
}),
|
|
261
269
|
}, IMSettingsTab));
|
|
262
270
|
}
|
|
@@ -138,14 +138,47 @@ const CSS = String.raw`
|
|
|
138
138
|
.dim-panel .dim-workspaceEdit { padding: 0; border: 0; color: #1677ff; background: transparent; font: inherit; font-weight: 560; cursor: pointer; }
|
|
139
139
|
.dim-panel .dim-workspaceEdit:disabled { cursor: not-allowed; opacity: .55; }
|
|
140
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-
|
|
142
|
-
.dim-
|
|
143
|
-
.dim-
|
|
144
|
-
.dim-
|
|
145
|
-
.dim-
|
|
146
|
-
.dim-
|
|
147
|
-
.dim-
|
|
148
|
-
.dim-
|
|
141
|
+
.dim-directoryPickerBackdrop { --dim-blue: var(--dsw-alias-state-business-primary, #3370ff); --dim-blue-soft: color-mix(in srgb, var(--dim-blue) 9%, transparent); position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 24px; background: rgb(15 17 21 / 42%); backdrop-filter: blur(3px); }
|
|
142
|
+
.dim-directoryPickerBackdrop, .dim-directoryPickerBackdrop *, .dim-directoryPickerBackdrop *::before, .dim-directoryPickerBackdrop *::after { box-sizing: border-box; }
|
|
143
|
+
.dim-directoryPicker { width: min(720px, 100%); height: min(620px, calc(100vh - 48px)); min-height: 420px; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; border: 1px solid var(--dsw-alias-border-l2, #dfe1e5); border-radius: 18px; outline: none; color: var(--dsw-alias-label-primary, #1f2329); background: var(--dsw-alias-bg-layer-1, #fff); box-shadow: 0 24px 72px rgb(15 17 21 / 24%); }
|
|
144
|
+
.dim-directoryPickerHeader { min-width: 0; padding: 22px 24px 17px; border-bottom: 1px solid var(--dsw-alias-border-l1, #eef0f3); }
|
|
145
|
+
.dim-directoryPickerHeader h3 { margin: 0 0 14px; color: var(--dsw-alias-label-primary, #1f2329); font-size: 20px; line-height: 1.35; font-weight: 680; }
|
|
146
|
+
.dim-directoryPickerHeader > p { margin: 0; color: var(--dsw-alias-label-secondary, #646a73); font-size: 13px; }
|
|
147
|
+
.dim-directoryCrumbs { min-width: 0; display: flex; align-items: center; flex-wrap: wrap; gap: 4px; color: var(--dsw-alias-label-tertiary, #8f959e); }
|
|
148
|
+
.dim-directoryCrumbs button { max-width: 210px; overflow: hidden; padding: 3px 5px; border: 0; border-radius: 6px; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 12px; line-height: 18px; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
|
|
149
|
+
.dim-directoryCrumbs button:hover:not(:disabled) { color: var(--dim-blue); background: var(--dim-blue-soft); }
|
|
150
|
+
.dim-directoryCrumbs button[aria-current="page"] { color: var(--dsw-alias-label-primary, #1f2329); font-weight: 650; }
|
|
151
|
+
.dim-directoryCrumbs button:focus-visible, .dim-directoryList button:focus-visible, .dim-directoryPickerActions button:focus-visible { outline: 2px solid color-mix(in srgb, var(--dim-blue) 65%, white); outline-offset: 1px; }
|
|
152
|
+
.dim-directoryCrumbSeparator { flex: none; font-size: 12px; }
|
|
153
|
+
.dim-directoryPickerBody { min-height: 0; overflow-y: auto; padding: 14px 16px; scrollbar-width: thin; scrollbar-color: var(--dsw-alias-border-l2, #dfe1e5) transparent; }
|
|
154
|
+
.dim-directoryList { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; }
|
|
155
|
+
.dim-directoryList button { width: 100%; min-height: 46px; display: grid; grid-template-columns: 24px minmax(0, 1fr) 18px; align-items: center; gap: 10px; padding: 7px 11px; border: 0; border-radius: 9px; color: var(--dsw-alias-label-primary, #1f2329); background: transparent; font: inherit; text-align: left; cursor: pointer; }
|
|
156
|
+
.dim-directoryList button:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover, #f7f8fa); }
|
|
157
|
+
.dim-directoryList button:disabled, .dim-directoryCrumbs button:disabled { cursor: wait; opacity: .55; }
|
|
158
|
+
.dim-directoryFolder { width: 24px; height: 24px; display: grid; place-items: center; color: var(--dsw-alias-label-secondary, #646a73); }
|
|
159
|
+
.dim-directoryFolder svg { width: 22px; height: 22px; }
|
|
160
|
+
.dim-directoryName { min-width: 0; overflow: hidden; font-size: 14px; line-height: 20px; text-overflow: ellipsis; white-space: nowrap; }
|
|
161
|
+
.dim-directoryChevron { width: 18px; height: 18px; display: grid; place-items: center; color: var(--dsw-alias-label-tertiary, #8f959e); }
|
|
162
|
+
.dim-directoryChevron svg { width: 17px; height: 17px; }
|
|
163
|
+
.dim-directoryPickerState { min-height: 210px; display: grid; place-content: center; justify-items: center; gap: 10px; color: var(--dsw-alias-label-secondary, #646a73); text-align: center; }
|
|
164
|
+
.dim-directoryPickerState p { margin: 0; font-size: 13px; line-height: 1.6; }
|
|
165
|
+
.dim-directoryPickerSpinner { width: 24px; height: 24px; border: 3px solid var(--dsw-alias-border-l2, #e6e8eb); border-top-color: var(--dim-blue); border-radius: 50%; animation: dim-spin .8s linear infinite; }
|
|
166
|
+
.dim-directoryPickerError { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 8px 0 0; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary, #d54941) 22%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 8px; color: var(--dsw-alias-state-error-primary, #d54941); background: color-mix(in srgb, var(--dsw-alias-state-error-primary, #d54941) 7%, var(--dsw-alias-bg-layer-1, #fff)); font-size: 12px; line-height: 1.5; }
|
|
167
|
+
.dim-directoryPickerError button { flex: none; padding: 4px 8px; border: 0; border-radius: 6px; color: inherit; background: transparent; font: inherit; font-weight: 650; cursor: pointer; }
|
|
168
|
+
.dim-directoryPickerTruncated { margin: 10px 4px 0; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 12px; line-height: 1.5; }
|
|
169
|
+
.dim-directoryPickerFooter { display: grid; grid-template-columns: max-content minmax(0, 1fr) max-content; align-items: center; gap: 14px; padding: 16px 20px; border-top: 1px solid var(--dsw-alias-border-l1, #eef0f3); background: var(--dsw-alias-bg-layer-1, #fff); }
|
|
170
|
+
.dim-directoryHidden { display: inline-flex; align-items: center; gap: 7px; padding: 2px 0; border: 0; color: var(--dsw-alias-label-secondary, #646a73); background: transparent; font: inherit; font-size: 12px; white-space: nowrap; cursor: pointer; }
|
|
171
|
+
.dim-directoryHidden:focus-visible { outline: 2px solid color-mix(in srgb, var(--dim-blue) 65%, white); outline-offset: 2px; }
|
|
172
|
+
.dim-directoryHidden:disabled { cursor: not-allowed; opacity: .52; }
|
|
173
|
+
.dim-directoryHiddenBox { position: relative; width: 15px; height: 15px; flex: 0 0 15px; border: 1px solid var(--dsw-alias-border-l2, #c9cdd4); border-radius: 4px; background: var(--dsw-alias-bg-layer-1, #fff); }
|
|
174
|
+
.dim-directoryHidden[aria-pressed="true"] .dim-directoryHiddenBox { border-color: var(--dim-blue); background: var(--dim-blue); }
|
|
175
|
+
.dim-directoryHidden[aria-pressed="true"] .dim-directoryHiddenBox::after { content: ""; position: absolute; left: 4px; top: 1px; width: 4px; height: 8px; border: solid white; border-width: 0 2px 2px 0; transform: rotate(45deg); }
|
|
176
|
+
.dim-directoryPickerNotice { min-width: 0; margin: 0; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; line-height: 1.45; text-align: right; }
|
|
177
|
+
.dim-directoryPickerActions { display: flex; gap: 8px; }
|
|
178
|
+
.dim-directoryPickerActions button { min-height: 36px; padding: 0 14px; 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; white-space: nowrap; cursor: pointer; }
|
|
179
|
+
.dim-directoryPickerActions .dim-directoryPickerPrimary { border-color: var(--dim-blue); color: #fff; background: var(--dim-blue); }
|
|
180
|
+
.dim-directoryPickerActions button:hover:not(:disabled) { filter: brightness(.97); }
|
|
181
|
+
.dim-directoryPickerActions button:disabled { cursor: not-allowed; opacity: .52; }
|
|
149
182
|
.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; }
|
|
150
183
|
.dim-panel .dim-cardActions { flex: none; display: flex; align-items: center; flex-wrap: nowrap; gap: 8px; margin: 0 0 0 auto; }
|
|
151
184
|
.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; }
|
|
@@ -206,9 +239,17 @@ const CSS = String.raw`
|
|
|
206
239
|
.dim-title p { white-space: normal; }
|
|
207
240
|
.dim-githubTooltip { right: auto; left: 0; }
|
|
208
241
|
.dim-rail { grid-template-columns: minmax(0, 1fr); }
|
|
242
|
+
.dim-directoryPickerBackdrop { padding: 10px; }
|
|
243
|
+
.dim-directoryPicker { height: calc(100vh - 20px); min-height: 0; border-radius: 14px; }
|
|
244
|
+
.dim-directoryPickerHeader { padding: 18px 17px 14px; }
|
|
245
|
+
.dim-directoryPickerHeader h3 { font-size: 18px; }
|
|
246
|
+
.dim-directoryPickerBody { padding: 10px; }
|
|
247
|
+
.dim-directoryPickerFooter { grid-template-columns: minmax(0, 1fr) max-content; gap: 10px; padding: 13px 14px; }
|
|
248
|
+
.dim-directoryPickerNotice { grid-column: 1 / -1; grid-row: 1; text-align: left; }
|
|
209
249
|
}
|
|
210
250
|
@media (prefers-reduced-motion: reduce) {
|
|
211
251
|
.dim-page * { transition-duration: .01ms !important; }
|
|
252
|
+
.dim-directoryPickerSpinner { animation-duration: 1.8s; }
|
|
212
253
|
}
|
|
213
254
|
`;
|
|
214
255
|
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
|
|
4
|
+
import { h } from './i18n.js';
|
|
5
|
+
|
|
6
|
+
function pickerErrorCode(error) {
|
|
7
|
+
return error?.rpcError?.code ?? error?.code;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function pickerErrorDetails(error) {
|
|
11
|
+
return error?.rpcError?.details ?? error?.details;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function pickerErrorMessage(error) {
|
|
15
|
+
return error?.rpcError?.message ?? error?.message ?? '无法读取目录,请重试。';
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function FolderIcon() {
|
|
19
|
+
return React.createElement('svg', {
|
|
20
|
+
viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.8,
|
|
21
|
+
strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true',
|
|
22
|
+
},
|
|
23
|
+
React.createElement('path', { d: 'M3.5 7.25A2.25 2.25 0 0 1 5.75 5h4.1l1.8 2h6.6a2.25 2.25 0 0 1 2.25 2.25v7A2.75 2.75 0 0 1 17.75 19h-12A2.25 2.25 0 0 1 3.5 16.75v-9.5Z' }));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ChevronIcon() {
|
|
27
|
+
return React.createElement('svg', {
|
|
28
|
+
viewBox: '0 0 20 20', fill: 'none', stroke: 'currentColor', strokeWidth: 1.7,
|
|
29
|
+
strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': 'true',
|
|
30
|
+
}, React.createElement('path', { d: 'm7.5 4.5 5 5.5-5 5.5' }));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function displayCrumbs(listing) {
|
|
34
|
+
const homeIndex = listing.crumbs.findIndex((crumb) => crumb.path === listing.home);
|
|
35
|
+
if (homeIndex < 0) return listing.crumbs;
|
|
36
|
+
return listing.crumbs.slice(homeIndex);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function WorkspaceDirectoryPicker({
|
|
40
|
+
open,
|
|
41
|
+
startPath,
|
|
42
|
+
picker,
|
|
43
|
+
busy = false,
|
|
44
|
+
saveError = null,
|
|
45
|
+
onPicked,
|
|
46
|
+
onCancel,
|
|
47
|
+
}) {
|
|
48
|
+
const [listing, setListing] = React.useState(null);
|
|
49
|
+
const [loading, setLoading] = React.useState(false);
|
|
50
|
+
const [error, setError] = React.useState(null);
|
|
51
|
+
const [showHidden, setShowHidden] = React.useState(false);
|
|
52
|
+
const [retryKey, setRetryKey] = React.useState(0);
|
|
53
|
+
const requestRef = React.useRef(0);
|
|
54
|
+
const controllerRef = React.useRef(null);
|
|
55
|
+
const dialogRef = React.useRef(null);
|
|
56
|
+
const bodyRef = React.useRef(null);
|
|
57
|
+
const titleId = React.useId();
|
|
58
|
+
const noticeId = React.useId();
|
|
59
|
+
const initialPathRef = React.useRef(startPath);
|
|
60
|
+
const onPickedRef = React.useRef(onPicked);
|
|
61
|
+
const onCancelRef = React.useRef(onCancel);
|
|
62
|
+
const busyRef = React.useRef(busy);
|
|
63
|
+
|
|
64
|
+
onPickedRef.current = onPicked;
|
|
65
|
+
onCancelRef.current = onCancel;
|
|
66
|
+
busyRef.current = busy;
|
|
67
|
+
|
|
68
|
+
const loadDirectory = React.useCallback(async (path, { reportError = true } = {}) => {
|
|
69
|
+
const request = requestRef.current + 1;
|
|
70
|
+
requestRef.current = request;
|
|
71
|
+
controllerRef.current?.abort();
|
|
72
|
+
const controller = new AbortController();
|
|
73
|
+
controllerRef.current = controller;
|
|
74
|
+
setLoading(true);
|
|
75
|
+
if (reportError) setError(null);
|
|
76
|
+
try {
|
|
77
|
+
const next = await picker.listDirectory(path, controller.signal);
|
|
78
|
+
if (request !== requestRef.current || controller.signal.aborted) return { aborted: true };
|
|
79
|
+
if (bodyRef.current) bodyRef.current.scrollTop = 0;
|
|
80
|
+
setListing(next);
|
|
81
|
+
setError(null);
|
|
82
|
+
return { value: next };
|
|
83
|
+
} catch (cause) {
|
|
84
|
+
if (request !== requestRef.current || controller.signal.aborted) return { aborted: true };
|
|
85
|
+
if (reportError) setError(pickerErrorMessage(cause));
|
|
86
|
+
return { error: cause };
|
|
87
|
+
} finally {
|
|
88
|
+
if (request === requestRef.current) setLoading(false);
|
|
89
|
+
}
|
|
90
|
+
}, [picker]);
|
|
91
|
+
|
|
92
|
+
React.useEffect(() => {
|
|
93
|
+
if (!open) return undefined;
|
|
94
|
+
let active = true;
|
|
95
|
+
setListing(null);
|
|
96
|
+
setError(null);
|
|
97
|
+
setShowHidden(false);
|
|
98
|
+
dialogRef.current?.focus?.();
|
|
99
|
+
const handleKeyDown = (event) => {
|
|
100
|
+
if (event.key === 'Escape' && !busyRef.current) onCancelRef.current?.();
|
|
101
|
+
};
|
|
102
|
+
if (typeof document !== 'undefined') document.addEventListener('keydown', handleKeyDown);
|
|
103
|
+
|
|
104
|
+
const start = async () => {
|
|
105
|
+
const initialPath = initialPathRef.current;
|
|
106
|
+
const initial = await loadDirectory(initialPath || undefined, { reportError: false });
|
|
107
|
+
if (!active || initial.aborted || initial.value) return;
|
|
108
|
+
const code = pickerErrorCode(initial.error);
|
|
109
|
+
const details = pickerErrorDetails(initial.error);
|
|
110
|
+
if (code === 'directory-picker-unavailable'
|
|
111
|
+
&& details?.capability === 'native'
|
|
112
|
+
&& typeof picker.pickDirectory === 'function') {
|
|
113
|
+
setLoading(true);
|
|
114
|
+
try {
|
|
115
|
+
const selected = await picker.pickDirectory();
|
|
116
|
+
if (!active) return;
|
|
117
|
+
if (selected !== null) await onPickedRef.current?.(selected);
|
|
118
|
+
else onCancelRef.current?.();
|
|
119
|
+
} catch (cause) {
|
|
120
|
+
if (active) setError(pickerErrorMessage(cause));
|
|
121
|
+
} finally {
|
|
122
|
+
if (active) setLoading(false);
|
|
123
|
+
}
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (initialPath && code === 'directory-unreadable') {
|
|
127
|
+
const home = await loadDirectory(undefined, { reportError: false });
|
|
128
|
+
if (!active || home.aborted || home.value) return;
|
|
129
|
+
setError(pickerErrorMessage(home.error));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
setError(pickerErrorMessage(initial.error));
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
void start();
|
|
136
|
+
return () => {
|
|
137
|
+
active = false;
|
|
138
|
+
if (typeof document !== 'undefined') document.removeEventListener('keydown', handleKeyDown);
|
|
139
|
+
requestRef.current += 1;
|
|
140
|
+
controllerRef.current?.abort();
|
|
141
|
+
};
|
|
142
|
+
}, [loadDirectory, open, picker, retryKey]);
|
|
143
|
+
|
|
144
|
+
if (!open) return null;
|
|
145
|
+
const entries = (listing?.entries ?? []).filter((entry) => showHidden || !entry.hidden);
|
|
146
|
+
const crumbs = listing ? displayCrumbs(listing) : [];
|
|
147
|
+
const presentedError = saveError ?? error;
|
|
148
|
+
|
|
149
|
+
const content = h('div', {
|
|
150
|
+
className: 'dim-directoryPickerBackdrop',
|
|
151
|
+
onMouseDown: (event) => {
|
|
152
|
+
if (event.target === event.currentTarget && !busy) onCancel();
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
h('section', {
|
|
156
|
+
ref: dialogRef,
|
|
157
|
+
className: 'dim-directoryPicker',
|
|
158
|
+
role: 'dialog',
|
|
159
|
+
'aria-modal': 'true',
|
|
160
|
+
'aria-labelledby': titleId,
|
|
161
|
+
'aria-describedby': noticeId,
|
|
162
|
+
tabIndex: -1,
|
|
163
|
+
},
|
|
164
|
+
h('header', { className: 'dim-directoryPickerHeader' },
|
|
165
|
+
h('h3', { id: titleId }, '选择机器人工作区目录'),
|
|
166
|
+
listing
|
|
167
|
+
? h('nav', { className: 'dim-directoryCrumbs', 'aria-label': '当前目录' },
|
|
168
|
+
crumbs.map((crumb, index) => h(React.Fragment, { key: crumb.path },
|
|
169
|
+
index > 0 ? h('span', { className: 'dim-directoryCrumbSeparator', 'aria-hidden': 'true' }, '›') : null,
|
|
170
|
+
React.createElement('button', {
|
|
171
|
+
type: 'button',
|
|
172
|
+
title: crumb.path,
|
|
173
|
+
disabled: loading || busy,
|
|
174
|
+
'aria-current': index === crumbs.length - 1 ? 'page' : undefined,
|
|
175
|
+
onClick: () => void loadDirectory(crumb.path),
|
|
176
|
+
}, crumb.path === listing.home
|
|
177
|
+
? h('span', null, '主目录')
|
|
178
|
+
: (crumb.name || crumb.path)))),
|
|
179
|
+
)
|
|
180
|
+
: h('p', null, '正在准备目录选择器…')),
|
|
181
|
+
h('div', { ref: bodyRef, className: 'dim-directoryPickerBody', 'aria-busy': loading },
|
|
182
|
+
loading && !listing
|
|
183
|
+
? h('div', { className: 'dim-directoryPickerState' },
|
|
184
|
+
h('span', { className: 'dim-directoryPickerSpinner', 'aria-hidden': 'true' }),
|
|
185
|
+
h('p', null, '正在读取目录…'))
|
|
186
|
+
: listing
|
|
187
|
+
? entries.length > 0
|
|
188
|
+
? h('ul', { className: 'dim-directoryList' }, entries.map((entry) => h('li', { key: entry.path },
|
|
189
|
+
React.createElement('button', {
|
|
190
|
+
type: 'button',
|
|
191
|
+
title: entry.path,
|
|
192
|
+
disabled: loading || busy,
|
|
193
|
+
onClick: () => void loadDirectory(entry.path),
|
|
194
|
+
},
|
|
195
|
+
h('span', { className: 'dim-directoryFolder' }, h(FolderIcon)),
|
|
196
|
+
React.createElement('span', { className: 'dim-directoryName' }, entry.name),
|
|
197
|
+
h('span', { className: 'dim-directoryChevron' }, h(ChevronIcon))))))
|
|
198
|
+
: h('div', { className: 'dim-directoryPickerState' },
|
|
199
|
+
h('p', null, '这个目录中没有子文件夹。'))
|
|
200
|
+
: null,
|
|
201
|
+
listing?.truncated
|
|
202
|
+
? h('p', { className: 'dim-directoryPickerTruncated' }, '此目录的子文件夹过多,仅显示前一部分。')
|
|
203
|
+
: null,
|
|
204
|
+
presentedError ? h('div', { className: 'dim-directoryPickerError', role: 'alert' },
|
|
205
|
+
h('span', null, presentedError),
|
|
206
|
+
!listing && !busy ? h('button', {
|
|
207
|
+
type: 'button', onClick: () => setRetryKey((value) => value + 1),
|
|
208
|
+
}, '重试') : null) : null),
|
|
209
|
+
h('footer', { className: 'dim-directoryPickerFooter' },
|
|
210
|
+
h('button', {
|
|
211
|
+
type: 'button',
|
|
212
|
+
className: 'dim-directoryHidden',
|
|
213
|
+
'aria-pressed': showHidden,
|
|
214
|
+
onClick: () => setShowHidden((value) => !value),
|
|
215
|
+
disabled: busy || !listing,
|
|
216
|
+
},
|
|
217
|
+
h('span', { className: 'dim-directoryHiddenBox', 'aria-hidden': 'true' }),
|
|
218
|
+
h('span', null, '显示隐藏文件夹')),
|
|
219
|
+
h('p', { id: noticeId, className: 'dim-directoryPickerNotice' }, '切换后会清除这个机器人的旧会话映射。'),
|
|
220
|
+
h('div', { className: 'dim-directoryPickerActions' },
|
|
221
|
+
h('button', { type: 'button', onClick: onCancel, disabled: busy }, '取消'),
|
|
222
|
+
h('button', {
|
|
223
|
+
type: 'button',
|
|
224
|
+
className: 'dim-directoryPickerPrimary',
|
|
225
|
+
disabled: busy || loading || !listing,
|
|
226
|
+
onClick: () => listing && void onPicked(listing.path),
|
|
227
|
+
}, busy ? '切换中…' : '选择此目录')))));
|
|
228
|
+
|
|
229
|
+
return typeof document === 'undefined' ? content : createPortal(content, document.body);
|
|
230
|
+
}
|
|
@@ -1,96 +1,69 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
|
|
3
3
|
import { h } from './i18n.js';
|
|
4
|
+
import { WorkspaceDirectoryPicker } from './workspace-directory-picker.js';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
return value.startsWith('/') || /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value);
|
|
7
|
-
}
|
|
6
|
+
export const WorkspaceDirectoryPickerContext = React.createContext(null);
|
|
8
7
|
|
|
9
|
-
export function WorkspaceEditor({ workspace, disabled = false, onSave }) {
|
|
10
|
-
const
|
|
11
|
-
const
|
|
8
|
+
export function WorkspaceEditor({ workspace, directoryPicker, disabled = false, onSave }) {
|
|
9
|
+
const sharedDirectoryPicker = React.useContext(WorkspaceDirectoryPickerContext);
|
|
10
|
+
const activeDirectoryPicker = directoryPicker ?? sharedDirectoryPicker;
|
|
11
|
+
const [open, setOpen] = React.useState(false);
|
|
12
12
|
const [saving, setSaving] = React.useState(false);
|
|
13
13
|
const [error, setError] = React.useState(null);
|
|
14
|
-
const inputRef = React.useRef(null);
|
|
15
14
|
const editButtonRef = React.useRef(null);
|
|
16
|
-
const
|
|
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]);
|
|
15
|
+
const savingRef = React.useRef(false);
|
|
29
16
|
|
|
30
|
-
const
|
|
31
|
-
|
|
17
|
+
const close = React.useCallback(() => {
|
|
18
|
+
setOpen(false);
|
|
32
19
|
setError(null);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
};
|
|
20
|
+
queueMicrotask(() => editButtonRef.current?.focus?.());
|
|
21
|
+
}, []);
|
|
36
22
|
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (!looksLikeAbsolutePath(value)) {
|
|
42
|
-
setError('工作区必须是绝对路径。');
|
|
23
|
+
const pick = React.useCallback(async (value) => {
|
|
24
|
+
if (!value || savingRef.current || disabled) return;
|
|
25
|
+
if (value === workspace) {
|
|
26
|
+
close();
|
|
43
27
|
return;
|
|
44
28
|
}
|
|
29
|
+
savingRef.current = true;
|
|
45
30
|
setSaving(true);
|
|
46
31
|
setError(null);
|
|
47
32
|
try {
|
|
48
33
|
await onSave?.(value);
|
|
49
|
-
|
|
50
|
-
setEditing(false);
|
|
34
|
+
close();
|
|
51
35
|
} catch (cause) {
|
|
52
36
|
setError(cause?.message ?? '工作区修改失败,请重试。');
|
|
53
37
|
} finally {
|
|
38
|
+
savingRef.current = false;
|
|
54
39
|
setSaving(false);
|
|
55
40
|
}
|
|
56
|
-
};
|
|
41
|
+
}, [close, disabled, onSave, workspace]);
|
|
57
42
|
|
|
58
43
|
return h('div', { className: 'dim-workspace' },
|
|
59
44
|
h('div', { className: 'dim-workspaceHeader' },
|
|
60
45
|
h('span', null, '当前工作区'),
|
|
61
|
-
|
|
46
|
+
h('button', {
|
|
62
47
|
type: 'button',
|
|
63
48
|
ref: editButtonRef,
|
|
64
49
|
className: 'dim-workspaceEdit',
|
|
65
|
-
onClick: () => {
|
|
66
|
-
disabled,
|
|
67
|
-
}, '
|
|
68
|
-
|
|
69
|
-
?
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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' }, '未设置'),
|
|
50
|
+
onClick: () => { setOpen(true); setError(null); },
|
|
51
|
+
disabled: disabled || !activeDirectoryPicker,
|
|
52
|
+
}, '选择目录')),
|
|
53
|
+
workspace
|
|
54
|
+
? React.createElement('code', {
|
|
55
|
+
className: 'dim-workspacePath',
|
|
56
|
+
title: workspace,
|
|
57
|
+
}, workspace)
|
|
58
|
+
: h('code', { className: 'dim-workspacePath' }, '未设置'),
|
|
59
|
+
open ? h(WorkspaceDirectoryPicker, {
|
|
60
|
+
open,
|
|
61
|
+
startPath: workspace,
|
|
62
|
+
picker: activeDirectoryPicker,
|
|
63
|
+
busy: saving || disabled,
|
|
64
|
+
saveError: error,
|
|
65
|
+
onPicked: pick,
|
|
66
|
+
onCancel: close,
|
|
67
|
+
}) : null,
|
|
95
68
|
);
|
|
96
69
|
}
|
|
@@ -72,6 +72,11 @@ for (const marker of ['/feishu', '/weixin', '/dingtalk', '/wecom', '/qq', '/slac
|
|
|
72
72
|
throw new Error(`host bundle does not contain the internal ${marker} RPC provider`);
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
|
+
for (const marker of ['/session Session ID', 'bindWorkspaceSession', 'session-subagent-unsupported']) {
|
|
76
|
+
if (!host.includes(marker)) {
|
|
77
|
+
throw new Error(`host bundle does not contain the Session binding marker: ${marker}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
75
80
|
if (/@xmanrui\/dsh-(?:feishu|weixin|dingtalk)/.test(host)) {
|
|
76
81
|
throw new Error('host bundle still imports an external channel plugin');
|
|
77
82
|
}
|
|
@@ -16,6 +16,8 @@ const HELP_TEXT = [
|
|
|
16
16
|
'/new 开启一个全新会话',
|
|
17
17
|
'/workspace 工作区绝对路径 切换工作区',
|
|
18
18
|
'/workspacelist 列出工作区绝对路径',
|
|
19
|
+
'/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
|
|
20
|
+
'/session Session ID 将当前聊天绑定到指定会话',
|
|
19
21
|
'/status 检查连接状态',
|
|
20
22
|
'/help 显示本帮助',
|
|
21
23
|
].join('\n');
|
|
@@ -217,7 +219,7 @@ export class DingtalkHarnessBridge {
|
|
|
217
219
|
await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
|
|
218
220
|
return;
|
|
219
221
|
}
|
|
220
|
-
const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
|
|
222
|
+
const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
|
|
221
223
|
if (workspaceCommand) {
|
|
222
224
|
for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
|
|
223
225
|
await this.#send(sessionWebhook, reply);
|
|
@@ -2,6 +2,8 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { isAbsolute } from 'node:path';
|
|
4
4
|
|
|
5
|
+
import { adoptRegisteredWorkspaceSession } from '../shared/harness-session-binding.mjs';
|
|
6
|
+
|
|
5
7
|
function workspacePaths(value) {
|
|
6
8
|
if (!Array.isArray(value?.items)) return [];
|
|
7
9
|
return value.items.flatMap((item) => (
|
|
@@ -9,6 +11,47 @@ function workspacePaths(value) {
|
|
|
9
11
|
));
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
function workspaceFromList(workspacePath, workspaceList) {
|
|
15
|
+
if (!Array.isArray(workspaceList?.items)
|
|
16
|
+
|| !Array.isArray(workspaceList?.archivedSessionIds)) {
|
|
17
|
+
throw new Error('Harness returned an invalid response for workspace.list');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const workspace = workspaceList.items.find((item) => item?.path === workspacePath);
|
|
21
|
+
if (!workspace) return null;
|
|
22
|
+
if (!Array.isArray(workspace.sessionIds)
|
|
23
|
+
|| workspace.sessionIds.some((sessionId) => typeof sessionId !== 'string')) {
|
|
24
|
+
throw new Error('Harness returned invalid session IDs for workspace.list');
|
|
25
|
+
}
|
|
26
|
+
return workspace;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function workspaceSessions(workspace, archivedSessionIds, sessionList) {
|
|
30
|
+
if (!Array.isArray(sessionList?.items)) {
|
|
31
|
+
throw new Error('Harness returned an invalid response for session.list');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const archived = new Set(archivedSessionIds);
|
|
35
|
+
const summaries = new Map(sessionList.items.flatMap((item) => (
|
|
36
|
+
typeof item?.sessionId === 'string' ? [[item.sessionId, item]] : []
|
|
37
|
+
)));
|
|
38
|
+
return {
|
|
39
|
+
workspace: workspace.path,
|
|
40
|
+
sessions: workspace.sessionIds.map((sessionId) => {
|
|
41
|
+
const summary = summaries.get(sessionId);
|
|
42
|
+
const title = summary?.projections?.values?.title;
|
|
43
|
+
return {
|
|
44
|
+
sessionId,
|
|
45
|
+
title: typeof title === 'string' ? title : null,
|
|
46
|
+
archived: archived.has(sessionId),
|
|
47
|
+
blank: summary?.blank === true,
|
|
48
|
+
origin: summary?.origin === 'subagent' ? 'subagent' : null,
|
|
49
|
+
summaryAvailable: summary !== undefined,
|
|
50
|
+
};
|
|
51
|
+
}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
12
55
|
function sleep(ms, signal) {
|
|
13
56
|
return new Promise((resolve, reject) => {
|
|
14
57
|
if (signal?.aborted) {
|
|
@@ -229,6 +272,19 @@ export class HarnessClient {
|
|
|
229
272
|
return workspacePaths(await this.rpc('workspace.list', {}, 30_000, options));
|
|
230
273
|
}
|
|
231
274
|
|
|
275
|
+
async listWorkspaceSessions(workspacePath, options = {}) {
|
|
276
|
+
await this.ensureRunning(options);
|
|
277
|
+
const workspaceList = await this.rpc('workspace.list', {}, 30_000, options);
|
|
278
|
+
const workspace = workspaceFromList(workspacePath, workspaceList);
|
|
279
|
+
if (!workspace) return { workspace: workspacePath, sessions: [] };
|
|
280
|
+
const sessionList = await this.rpc('session.list', {}, 30_000, options);
|
|
281
|
+
return workspaceSessions(workspace, workspaceList.archivedSessionIds, sessionList);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async adoptWorkspaceSession(value, options = {}) {
|
|
285
|
+
return adoptRegisteredWorkspaceSession(this, value, options);
|
|
286
|
+
}
|
|
287
|
+
|
|
232
288
|
async workspaceId(options = {}) {
|
|
233
289
|
const { workspace = this.#workspace, ...rpcOptions } = options;
|
|
234
290
|
const { items } = await this.rpc('workspace.list', {}, 30_000, rpcOptions);
|