@xmanrui/dsh-im 3.0.5 → 3.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "3.0.5",
3
+ "version": "3.0.7",
4
4
  "description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -122,6 +122,11 @@ const EN = Object.freeze({
122
122
  '选择机器人工作区目录': 'Select bot workspace folder',
123
123
  '当前目录': 'Current folder',
124
124
  '主目录': 'Home',
125
+ '直接输入路径': 'Enter a path directly',
126
+ '支持 Windows 盘符、UNC 与 POSIX 绝对路径。': 'Supports Windows drives, UNC paths, and POSIX absolute paths.',
127
+ '输入 Host 上的完整绝对路径': 'Enter a full absolute path on the Host',
128
+ '前往': 'Go',
129
+ '读取中…': 'Loading…',
125
130
  '正在准备目录选择器…': 'Preparing folder picker…',
126
131
  '正在读取目录…': 'Loading folders…',
127
132
  '这个目录中没有子文件夹。': 'This folder has no subfolders.',
@@ -184,8 +184,21 @@ const CSS = String.raw`
184
184
  .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; }
185
185
  .dim-directoryCrumbs button:hover:not(:disabled) { color: var(--dim-blue); background: var(--dim-blue-soft); }
186
186
  .dim-directoryCrumbs button[aria-current="page"] { color: var(--dsw-alias-label-primary, #1f2329); font-weight: 650; }
187
- .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; }
187
+ .dim-directoryCrumbs button:focus-visible, .dim-directoryPathInput:focus-visible, .dim-directoryPathControl 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; }
188
188
  .dim-directoryCrumbSeparator { flex: none; font-size: 12px; }
189
+ .dim-directoryPathForm { display: grid; gap: 7px; margin-top: 14px; }
190
+ .dim-directoryPathMeta { min-width: 0; display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
191
+ .dim-directoryPathMeta label { flex: none; color: var(--dsw-alias-label-secondary, #646a73); font-size: 12px; font-weight: 650; }
192
+ .dim-directoryPathMeta span { min-width: 0; overflow: hidden; color: var(--dsw-alias-label-tertiary, #8f959e); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
193
+ .dim-directoryPathControl { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) max-content; gap: 8px; }
194
+ .dim-directoryPathInput { min-width: 0; height: 38px; padding: 0 11px; 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: 12px ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; line-height: 38px; }
195
+ .dim-directoryPathInput::placeholder { color: var(--dsw-alias-label-tertiary, #8f959e); }
196
+ .dim-directoryPathInput:hover:not(:disabled) { border-color: var(--dsw-alias-border-l3, #bbbfc4); }
197
+ .dim-directoryPathInput:focus { border-color: var(--dim-blue); }
198
+ .dim-directoryPathInput[aria-invalid="true"] { border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary, #d54941) 62%, var(--dsw-alias-border-l2, #dfe1e5)); }
199
+ .dim-directoryPathControl button { min-height: 38px; padding: 0 14px; border: 1px solid color-mix(in srgb, var(--dim-blue) 30%, var(--dsw-alias-border-l2, #dfe1e5)); border-radius: 8px; color: var(--dim-blue); background: var(--dim-blue-soft); font: inherit; font-size: 13px; font-weight: 650; cursor: pointer; }
200
+ .dim-directoryPathControl button:hover:not(:disabled) { border-color: color-mix(in srgb, var(--dim-blue) 48%, var(--dsw-alias-border-l2, #dfe1e5)); background: color-mix(in srgb, var(--dim-blue) 13%, transparent); }
201
+ .dim-directoryPathInput:disabled, .dim-directoryPathControl button:disabled { cursor: not-allowed; opacity: .55; }
189
202
  .dim-directoryPickerBody { min-height: 0; overflow-y: auto; padding: 14px 16px; scrollbar-width: thin; scrollbar-color: var(--dsw-alias-border-l2, #dfe1e5) transparent; }
190
203
  .dim-directoryList { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; }
191
204
  .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; }
@@ -279,6 +292,7 @@ const CSS = String.raw`
279
292
  .dim-directoryPicker { height: calc(100vh - 20px); min-height: 0; border-radius: 14px; }
280
293
  .dim-directoryPickerHeader { padding: 18px 17px 14px; }
281
294
  .dim-directoryPickerHeader h3 { font-size: 18px; }
295
+ .dim-directoryPathMeta span { display: none; }
282
296
  .dim-directoryPickerBody { padding: 10px; }
283
297
  .dim-directoryPickerFooter { grid-template-columns: minmax(0, 1fr) max-content; gap: 10px; padding: 13px 14px; }
284
298
  .dim-directoryPickerNotice { grid-column: 1 / -1; grid-row: 1; text-align: left; }
@@ -48,6 +48,7 @@ export function WorkspaceDirectoryPicker({
48
48
  const [listing, setListing] = React.useState(null);
49
49
  const [loading, setLoading] = React.useState(false);
50
50
  const [error, setError] = React.useState(null);
51
+ const [pathDraft, setPathDraft] = React.useState(startPath ?? '');
51
52
  const [showHidden, setShowHidden] = React.useState(false);
52
53
  const [retryKey, setRetryKey] = React.useState(0);
53
54
  const requestRef = React.useRef(0);
@@ -56,6 +57,8 @@ export function WorkspaceDirectoryPicker({
56
57
  const bodyRef = React.useRef(null);
57
58
  const titleId = React.useId();
58
59
  const noticeId = React.useId();
60
+ const pathInputId = React.useId();
61
+ const errorId = React.useId();
59
62
  const initialPathRef = React.useRef(startPath);
60
63
  const onPickedRef = React.useRef(onPicked);
61
64
  const onCancelRef = React.useRef(onCancel);
@@ -78,6 +81,7 @@ export function WorkspaceDirectoryPicker({
78
81
  if (request !== requestRef.current || controller.signal.aborted) return { aborted: true };
79
82
  if (bodyRef.current) bodyRef.current.scrollTop = 0;
80
83
  setListing(next);
84
+ if (typeof next?.path === 'string') setPathDraft(next.path);
81
85
  setError(null);
82
86
  return { value: next };
83
87
  } catch (cause) {
@@ -94,6 +98,7 @@ export function WorkspaceDirectoryPicker({
94
98
  let active = true;
95
99
  setListing(null);
96
100
  setError(null);
101
+ setPathDraft(initialPathRef.current ?? '');
97
102
  setShowHidden(false);
98
103
  dialogRef.current?.focus?.();
99
104
  const handleKeyDown = (event) => {
@@ -145,6 +150,7 @@ export function WorkspaceDirectoryPicker({
145
150
  const entries = (listing?.entries ?? []).filter((entry) => showHidden || !entry.hidden);
146
151
  const crumbs = listing ? displayCrumbs(listing) : [];
147
152
  const presentedError = saveError ?? error;
153
+ const pathReady = listing !== null && pathDraft === listing.path;
148
154
 
149
155
  const content = h('div', {
150
156
  className: 'dim-directoryPickerBackdrop',
@@ -177,7 +183,40 @@ export function WorkspaceDirectoryPicker({
177
183
  ? h('span', null, '主目录')
178
184
  : (crumb.name || crumb.path)))),
179
185
  )
180
- : h('p', null, '正在准备目录选择器…')),
186
+ : h('p', null, '正在准备目录选择器…'),
187
+ h('form', {
188
+ className: 'dim-directoryPathForm',
189
+ onSubmit: (event) => {
190
+ event.preventDefault();
191
+ if (!busy && !loading && pathDraft.trim()) void loadDirectory(pathDraft);
192
+ },
193
+ },
194
+ h('div', { className: 'dim-directoryPathMeta' },
195
+ h('label', { htmlFor: pathInputId }, '直接输入路径'),
196
+ h('span', null, '支持 Windows 盘符、UNC 与 POSIX 绝对路径。')),
197
+ h('div', { className: 'dim-directoryPathControl' },
198
+ h('input', {
199
+ id: pathInputId,
200
+ className: 'dim-directoryPathInput',
201
+ value: pathDraft,
202
+ placeholder: '输入 Host 上的完整绝对路径',
203
+ 'aria-label': '工作区绝对路径',
204
+ 'aria-describedby': presentedError ? errorId : undefined,
205
+ 'aria-invalid': presentedError ? 'true' : undefined,
206
+ autoCapitalize: 'none',
207
+ autoCorrect: 'off',
208
+ spellCheck: false,
209
+ maxLength: 4_096,
210
+ disabled: busy || loading,
211
+ onChange: (event) => {
212
+ setPathDraft(event.target.value);
213
+ setError(null);
214
+ },
215
+ }),
216
+ h('button', {
217
+ type: 'submit',
218
+ disabled: busy || loading || !pathDraft.trim(),
219
+ }, loading ? '读取中…' : '前往')))),
181
220
  h('div', { ref: bodyRef, className: 'dim-directoryPickerBody', 'aria-busy': loading },
182
221
  loading && !listing
183
222
  ? h('div', { className: 'dim-directoryPickerState' },
@@ -201,7 +240,7 @@ export function WorkspaceDirectoryPicker({
201
240
  listing?.truncated
202
241
  ? h('p', { className: 'dim-directoryPickerTruncated' }, '此目录的子文件夹过多,仅显示前一部分。')
203
242
  : null,
204
- presentedError ? h('div', { className: 'dim-directoryPickerError', role: 'alert' },
243
+ presentedError ? h('div', { id: errorId, className: 'dim-directoryPickerError', role: 'alert' },
205
244
  h('span', null, presentedError),
206
245
  !listing && !busy ? h('button', {
207
246
  type: 'button', onClick: () => setRetryKey((value) => value + 1),
@@ -222,7 +261,7 @@ export function WorkspaceDirectoryPicker({
222
261
  h('button', {
223
262
  type: 'button',
224
263
  className: 'dim-directoryPickerPrimary',
225
- disabled: busy || loading || !listing,
264
+ disabled: busy || loading || !pathReady,
226
265
  onClick: () => listing && void onPicked(listing.path),
227
266
  }, busy ? '切换中…' : '选择此目录')))));
228
267
 
@@ -12,10 +12,30 @@ export function createHarnessCommandExecutor(ctx, provided) {
12
12
  if (typeof gateway.invoke !== 'function') {
13
13
  throw new TypeError('dsh-im requires a callable ctx.typertGateway');
14
14
  }
15
- return (sessionId, line, options = {}) => gateway.invoke({
16
- namespace: 'commands',
17
- method: 'execute',
18
- args: { agentId: sessionId, line },
19
- signal: options.signal,
20
- });
15
+ return async (sessionId, line, options = {}) => {
16
+ const request = {
17
+ namespace: 'commands',
18
+ method: 'execute',
19
+ args: { agentId: sessionId, line, images: [] },
20
+ signal: options.signal,
21
+ };
22
+ try {
23
+ return await gateway.invoke(request);
24
+ } catch (error) {
25
+ // Newer Hosts require images; older Hosts reject that field before
26
+ // invoking the command. Retry only that exact pre-dispatch failure so
27
+ // a business failure can never cause compaction to run twice.
28
+ if (error?.name !== 'TypertGatewayError'
29
+ || error.code !== 'arguments-invalid'
30
+ || error.endpoint !== 'commands/execute'
31
+ || error.message !== 'typert gateway: commands/execute: args fields do not match the descriptor: unexpected "images"') {
32
+ throw error;
33
+ }
34
+ options.signal?.throwIfAborted();
35
+ return gateway.invoke({
36
+ ...request,
37
+ args: { agentId: sessionId, line },
38
+ });
39
+ }
40
+ };
21
41
  }
@@ -10,6 +10,8 @@ export default {
10
10
  '目前支持文字、图片,以及微信已转成文字的语音消息。': 'Currently supports text, images, and voice messages that WeChat has converted to text.',
11
11
  '微信与 DeepSeek Harness 连接正常。': 'WeChat is connected to DeepSeek Harness normally.',
12
12
  '微信交互问题发送失败。': 'Failed to send the WeChat interaction question.',
13
+ '回复已经生成,但微信发送失败,可能只收到部分内容。请将下面的诊断信息完整反馈给管理员。': 'The reply was generated, but WeChat delivery failed and only part of it may have arrived. Please send the complete diagnostic below to the administrator.',
14
+ '微信发送诊断:{diagnostic}': 'WeChat delivery diagnostic: {diagnostic}',
13
15
 
14
16
  // weixin-controller.mjs
15
17
  '微信已授权,但无法读取现有登录凭据。请检查 DSH 凭据存储。': 'WeChat was authorized, but the existing login credential could not be read. Please check the DSH credential store.',
@@ -709,8 +709,13 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
709
709
  base_info: baseInfo(),
710
710
  },
711
711
  });
712
- if (response?.ret !== undefined && response.ret !== 0) {
713
- throw new WeixinApiError('send-rejected', '微信服务拒绝了回复消息。');
712
+ const sendRejection = rejectedProviderResponse(response);
713
+ if (sendRejection) {
714
+ throw new WeixinApiError(
715
+ 'send-rejected',
716
+ '微信服务拒绝了回复消息。',
717
+ { providerCode: sendRejection },
718
+ );
714
719
  }
715
720
  return true;
716
721
  },
@@ -60,6 +60,7 @@ import { t } from '../shared/i18n.mjs';
60
60
  const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处理,无需再次回答。');
61
61
  const DEFAULT_TYPING_KEEPALIVE_MS = 5_000;
62
62
  const TYPING_RETRY_DELAY_MS = 60_000;
63
+ const WEIXIN_SEND_DIAGNOSTIC = Symbol('weixin-send-diagnostic');
63
64
 
64
65
  const HELP_TEXT = () => [
65
66
  t('微信已连接 DeepSeek Harness。'),
@@ -98,6 +99,82 @@ function nonEmptyString(value) {
98
99
  return typeof value === 'string' && value.trim() ? value.trim() : null;
99
100
  }
100
101
 
102
+ function safeDiagnosticToken(value) {
103
+ const token = value === undefined || value === null ? '' : String(value).trim();
104
+ return /^-?[A-Za-z0-9_.:-]{1,160}$/u.test(token) ? token : '-';
105
+ }
106
+
107
+ function weixinApiHost(baseUrl) {
108
+ try {
109
+ return safeDiagnosticToken(new URL(baseUrl).hostname.toLowerCase());
110
+ } catch {
111
+ return '-';
112
+ }
113
+ }
114
+
115
+ function createWeixinSendDiagnostic({
116
+ baseUrl,
117
+ text,
118
+ chunk,
119
+ chunkIndex,
120
+ chunkCount,
121
+ maxMessageChars,
122
+ contextToken,
123
+ runId,
124
+ error,
125
+ }) {
126
+ const status = Number(error?.status ?? error?.httpStatus);
127
+ const http = Number.isInteger(status)
128
+ ? String(status)
129
+ : error?.code === 'send-rejected' ? '2xx' : '-';
130
+ return [
131
+ 'endpoint=sendmessage',
132
+ `host=${weixinApiHost(baseUrl)}`,
133
+ `chunk=${chunkIndex + 1}/${chunkCount}`,
134
+ `chunkChars=${chunk.length}`,
135
+ `chunkUtf8Bytes=${Buffer.byteLength(chunk, 'utf8')}`,
136
+ `totalChars=${text.length}`,
137
+ `totalUtf8Bytes=${Buffer.byteLength(text, 'utf8')}`,
138
+ `limitChars=${maxMessageChars}`,
139
+ `contextToken=${contextToken ? 'yes' : 'no'}`,
140
+ `runId=${runId ? 'yes' : 'no'}`,
141
+ `http=${http}`,
142
+ `provider=${safeDiagnosticToken(error?.providerCode)}`,
143
+ `cause=${safeDiagnosticToken(error?.code ?? error?.name)}`,
144
+ ].join(' ');
145
+ }
146
+
147
+ function weixinSendError(error, details) {
148
+ const diagnostic = createWeixinSendDiagnostic({ ...details, error });
149
+ const wrapped = new Error(`Weixin text delivery failed (${diagnostic})`, { cause: error });
150
+ const code = safeDiagnosticToken(error?.code);
151
+ wrapped.code = code === '-' ? 'weixin-send-failed' : code;
152
+ const status = Number(error?.status ?? error?.httpStatus);
153
+ if (Number.isInteger(status)) wrapped.status = status;
154
+ const providerCode = safeDiagnosticToken(error?.providerCode);
155
+ if (providerCode !== '-') wrapped.providerCode = providerCode;
156
+ wrapped[WEIXIN_SEND_DIAGNOSTIC] = diagnostic;
157
+ return wrapped;
158
+ }
159
+
160
+ function weixinSendFailureOptions(error) {
161
+ let current = error;
162
+ for (let depth = 0; current && depth < 5; depth += 1) {
163
+ const diagnostic = current[WEIXIN_SEND_DIAGNOSTIC];
164
+ if (typeof diagnostic === 'string' && diagnostic) {
165
+ return {
166
+ reason: 'weixin-send-failed',
167
+ userMessage: [
168
+ t('回复已经生成,但微信发送失败,可能只收到部分内容。请将下面的诊断信息完整反馈给管理员。'),
169
+ t('微信发送诊断:{diagnostic}', { diagnostic }),
170
+ ].join('\n'),
171
+ };
172
+ }
173
+ current = current.cause;
174
+ }
175
+ return undefined;
176
+ }
177
+
101
178
  export function weixinInboundMessage(message, api) {
102
179
  return {
103
180
  content: extractWeixinText(message) ?? '',
@@ -634,7 +711,11 @@ export class WeixinHarnessBridge {
634
711
  );
635
712
  if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
636
713
  if (textDeliveryError && delivery.artifactSendErrors === 0) {
637
- setLastMessageFailure(this.#status, textDeliveryError);
714
+ setLastMessageFailure(
715
+ this.#status,
716
+ textDeliveryError,
717
+ weixinSendFailureOptions(textDeliveryError),
718
+ );
638
719
  }
639
720
  if (!promptRecorded) await this.#state.markSeen(messageId);
640
721
  this.#status.messagesReplied += 1;
@@ -663,9 +744,10 @@ export class WeixinHarnessBridge {
663
744
  const userMessage = inboundFileUserMessage(error)
664
745
  ?? imagePromptUserMessage(error);
665
746
  const imageDiagnostic = imagePromptDiagnostic(error);
747
+ const sendFailureOptions = weixinSendFailureOptions(error);
666
748
  const failure = setLastMessageFailure(this.#status, error, {
667
- userMessage,
668
- reason: imageDiagnostic?.reason,
749
+ userMessage: userMessage ?? sendFailureOptions?.userMessage,
750
+ reason: imageDiagnostic?.reason ?? sendFailureOptions?.reason,
669
751
  });
670
752
  this.#logger.error?.(
671
753
  `[dsh-weixin] failed to process an inbound message [${failure.referenceId}]:`,
@@ -1002,17 +1084,32 @@ export class WeixinHarnessBridge {
1002
1084
  async #send(toUserId, text, contextToken, runId) {
1003
1085
  await this.#stopTyping();
1004
1086
  const providerMessageIds = [];
1005
- for (const chunk of splitWeixinText(text, this.#maxMessageChars)) {
1006
- const result = await this.#api.sendText({
1007
- baseUrl: this.#baseUrl,
1008
- token: this.#token,
1009
- toUserId,
1010
- text: chunk,
1011
- contextToken,
1012
- runId,
1013
- signal: this.#signal,
1014
- });
1015
- providerMessageIds.push(...providerMessageIdsFor(result));
1087
+ const chunks = splitWeixinText(text, this.#maxMessageChars);
1088
+ for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex += 1) {
1089
+ const chunk = chunks[chunkIndex];
1090
+ try {
1091
+ const result = await this.#api.sendText({
1092
+ baseUrl: this.#baseUrl,
1093
+ token: this.#token,
1094
+ toUserId,
1095
+ text: chunk,
1096
+ contextToken,
1097
+ runId,
1098
+ signal: this.#signal,
1099
+ });
1100
+ providerMessageIds.push(...providerMessageIdsFor(result));
1101
+ } catch (error) {
1102
+ throw weixinSendError(error, {
1103
+ baseUrl: this.#baseUrl,
1104
+ text,
1105
+ chunk,
1106
+ chunkIndex,
1107
+ chunkCount: chunks.length,
1108
+ maxMessageChars: this.#maxMessageChars,
1109
+ contextToken,
1110
+ runId,
1111
+ });
1112
+ }
1016
1113
  }
1017
1114
  return providerMessageIds;
1018
1115
  }